diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index 7d985c1..c64d82f 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -127,8 +127,9 @@ battery: # Proactive car charging strategy (forecast-driven, no export threshold required) car_charging: - min_soc: 35 # start car charging only if battery SOC ≥ this (%) + min_soc: 25 # start car charging only if battery SOC ≥ this (%) soc_floor: 5 # emergency brake floor — stop if SOC drops below this (%) + soc_high_bypass_pct: 90 # activate without forecast check when SOC ≥ this % (battery full → always charge) eod_soc_target: 90 # desired battery SOC at end of day / sunset (%) eod_time: "16:00" # start EOD soft-stop checks after this time no_car_retry_min: 30 # minutes before retrying after no-car detection diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 0c0db95..930ee1f 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -119,6 +119,7 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) { "soc", state.BatterySOC, "pv_w", state.PVProductionW, "compressor_w", state.CompressorPowerW, + "ambient_c", state.AmbientTempC, "l1_w", state.PhaseL1PowerW, "l2_w", state.PhaseL2PowerW, "l3_w", state.PhaseL3PowerW, diff --git a/internal/config/config.go b/internal/config/config.go index 74df165..1d8bc96 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,14 +33,15 @@ type BatteryConfig struct { // CarChargingConfig holds parameters for the proactive car charging strategy. type CarChargingConfig struct { - MinSOC int `yaml:"min_soc"` // minimum SOC% to start car charging (e.g. 35) - SOCFloor int `yaml:"soc_floor"` // never drain battery below this % (e.g. 5) - EODSOCTarget int `yaml:"eod_soc_target"` // target SOC% to reach by sunset (e.g. 90) - EODTime string `yaml:"eod_time"` // soft-stop check starts at this time (e.g. "16:00") - NoCarRetryMin int `yaml:"no_car_retry_min"` // minutes before retrying after no-car detection - PVThresholdAW float64 `yaml:"pv_threshold_a_w"` // min PV production to start WallboxA (W) - PVThresholdBW float64 `yaml:"pv_threshold_b_w"` // min PV production to start WallboxB (W) - GridDeltaThreshW float64 `yaml:"grid_delta_thresh_w"` // min grid power shift after WallboxB activation = car detected (W) + MinSOC int `yaml:"min_soc"` // minimum SOC% to start car charging (e.g. 25) + SOCFloor int `yaml:"soc_floor"` // never drain battery below this % (e.g. 5) + SOCHighBypassPct int `yaml:"soc_high_bypass_pct"` // activate without forecast check when SOC ≥ this % (0 → default 90%) + EODSOCTarget int `yaml:"eod_soc_target"` // target SOC% to reach by sunset (e.g. 90) + EODTime string `yaml:"eod_time"` // soft-stop check starts at this time (e.g. "16:00") + NoCarRetryMin int `yaml:"no_car_retry_min"` // minutes before retrying after no-car detection + PVThresholdAW float64 `yaml:"pv_threshold_a_w"` // min PV production to start WallboxA (W) + PVThresholdBW float64 `yaml:"pv_threshold_b_w"` // min PV production to start WallboxB (W) + GridDeltaThreshW float64 `yaml:"grid_delta_thresh_w"` // min grid power shift after WallboxB activation = car detected (W) } func (c *CarChargingConfig) EODTimeParsed(ref time.Time) time.Time { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 084a944..5091208 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -260,8 +260,22 @@ func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh fl return nil } - // Forecast must be available and meet the minimum threshold (mid = worthwhile day) - if forecastKWh == 0 || forecastKWh < float64(e.cfg.Strategic.ForecastMidKWh) { + // Determine activation gate: either forecast is good, or SOC is already high + // (battery full → activate regardless of forecast; any PV surplus should charge the car). + socHighBypass := float64(cc.SOCHighBypassPct) + if socHighBypass == 0 { + socHighBypass = 90 // default: bypass forecast check when battery ≥ 90% + } + forecastOK := forecastKWh > 0 && forecastKWh >= float64(e.cfg.Strategic.ForecastMidKWh) + socHigh := state.BatterySOC >= socHighBypass + + if !forecastOK && !socHigh { + e.logger.Debug("proactive car charging: skipped", + "forecast_kwh", forecastKWh, + "forecast_threshold", e.cfg.Strategic.ForecastMidKWh, + "soc", state.BatterySOC, + "soc_bypass_pct", socHighBypass, + ) return nil } @@ -297,10 +311,14 @@ func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh fl csA.ProactiveCharging = true csA.LowPowerCycles = 0 delete(e.hyst.ExportSinceAbove, ConsumerWallboxA) - return []Action{{ + trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh) + if socHigh && !forecastOK { + trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass) + } + return []Action{{ Consumer: ConsumerWallboxA, TurnOn: true, - Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, forecast %.1fkWh, SOC %.0f%%", state.PVProductionW, cc.PVThresholdAW, forecastKWh, state.BatterySOC), + Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdAW, trigger), }} } } @@ -330,10 +348,14 @@ func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh fl csB.ProbeStartGridW = state.GridPowerW csB.LowPowerCycles = 0 delete(e.hyst.ExportSinceAbove, ConsumerWallboxB) + trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh) + if socHigh && !forecastOK { + trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass) + } return []Action{{ Consumer: ConsumerWallboxB, TurnOn: true, - Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, forecast %.1fkWh, SOC %.0f%%", state.PVProductionW, cc.PVThresholdBW, forecastKWh, state.BatterySOC), + Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdBW, trigger), }} } } @@ -545,7 +567,7 @@ func (e *Engine) evaluateTurnOn( // All conditions met — turn on e.logger.Info("turning on consumer", - "consumer", consumer, + "consumer", consumer.String(), "grid_w", gridW, "threshold", threshold, "export_duration", exportDuration, @@ -581,7 +603,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action { // Manually overridden to ON — don't shut down until override expires if cs.ManualOverride && now.Before(cs.OverrideUntil) { e.logger.Debug("skipping shutdown, consumer is manually overridden", - "consumer", c, + "consumer", c.String(), "override_until", cs.OverrideUntil.Format("15:04"), ) continue @@ -590,7 +612,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action { // Proactive wallboxes are not shut down by import hysteresis. // Their stops are handled by EOD soft stop, no-car probe, and idle cycles. if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) { - e.logger.Debug("skipping shutdown, proactive car charging active", "consumer", c) + e.logger.Debug("skipping shutdown, proactive car charging active", "consumer", c.String()) continue } @@ -605,7 +627,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action { } if acceptedImportW > 0 && gridW <= acceptedImportW { e.logger.Debug("skipping shutdown, import within accepted tolerance", - "consumer", c, + "consumer", c.String(), "grid_w", gridW, "accepted_import_w", acceptedImportW, ) @@ -616,7 +638,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action { runtime := now.Sub(cs.ActivatedAt) if runtime < minRuntime { e.logger.Debug("skipping shutdown, min runtime not reached", - "consumer", c, + "consumer", c.String(), "runtime", runtime, "min_runtime", minRuntime, ) @@ -624,7 +646,7 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action { } e.logger.Info("shutting down consumer", - "consumer", c, + "consumer", c.String(), "runtime", runtime, ) @@ -669,7 +691,7 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action { } e.logger.Warn("SOC emergency brake", - "consumer", c, + "consumer", c.String(), "soc", soc, "was_override", cs.ManualOverride, ) @@ -708,7 +730,7 @@ func (e *Engine) overrideHardStop(gridW float64) []Action { } e.logger.Warn("override hard stop: import exceeds limit", - "consumer", c, + "consumer", c.String(), "grid_w", gridW, "limit_w", limit, ) @@ -748,7 +770,7 @@ func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duratio cs.ActivatedAt = time.Time{} } e.logger.Info("manual override applied", - "consumer", consumer, + "consumer", consumer.String(), "on", on, "duration", duration, "until", cs.OverrideUntil.Format("15:04"), @@ -943,7 +965,7 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti if cs.Active != status.On { // External change detected e.logger.Info("manual override detected — external state change", - "consumer", c, + "consumer", c.String(), "engine_state", cs.Active, "hardware_state", status.On, "override_until", now.Add(overrideTimeout).Format("15:04"), @@ -961,7 +983,7 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti // Override expired and state matches — resume EMS control cs.ManualOverride = false cs.OverrideUntil = time.Time{} - e.logger.Info("manual override expired, resuming EMS control", "consumer", c) + e.logger.Info("manual override expired, resuming EMS control", "consumer", c.String()) } // Track low-power cycles for car-not-charging detection (PM devices only). @@ -970,7 +992,7 @@ func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Ti if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) { cs.LowPowerCycles++ e.logger.Debug("wallbox low power cycle", - "consumer", c, + "consumer", c.String(), "power_w", status.PowerW, "low_power_cycles", cs.LowPowerCycles, ) diff --git a/internal/status/status.go b/internal/status/status.go index 17866da..51b4219 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -23,12 +23,12 @@ var consumerMeta = map[engine.Consumer]struct{ Label, Icon string }{ engine.ConsumerWallboxB: {"Wallbox B (4 kW)", "🔌"}, } -// consumerOrder defines the display order of consumers. +// consumerOrder defines the display order of consumers — highest EMS priority first. var consumerOrder = []engine.Consumer{ - engine.ConsumerSGReady, - engine.ConsumerWW, engine.ConsumerWallboxA, engine.ConsumerWallboxB, + engine.ConsumerWW, + engine.ConsumerSGReady, } // consumerRecord tracks the state of a single consumer across cycles.