diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index f207c7a..7d985c1 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -15,6 +15,7 @@ prometheus: phase_l1_power: "pcc_ac_active_power_phaseOne" # per-phase grid power L1 (W) phase_l2_power: "pcc_ac_active_power_phaseTwo" # per-phase grid power L2 (W) phase_l3_power: "pcc_ac_active_power_phaseThree" # per-phase grid power L3 (W) + ww_top_temp: "heating_dhw_sensors_temperature_dhwCylinder_top_value" # DHW cylinder top temperature (°C) # Shelly actuators shelly: @@ -69,6 +70,8 @@ strategic: forecast_high_kwh: 25 forecast_mid_kwh: 15 ww_base_c: 48 # normal WW setpoint (°C) + ww_max_setpoint_c: 60 # absolute maximum WW setpoint (°C) + ww_hysteresis_c: 5 # Viessmann switchOn = setpoint - hysteresis (°C) ww_boost_high_c: 5 # +5°C on high-forecast days (>25 kWh) ww_boost_mid_c: 3 # +3°C on medium-forecast days (>15 kWh) ww_window_start: "12:30" # WW boost only allowed from 12:30 @@ -118,6 +121,21 @@ ems: trip_goal_file: "/var/lib/ems/trip-goal.json" # persisted active trip goal session_log_file: "/var/lib/ems/sessions.jsonl" # JSONL log of completed charge sessions +# Battery physical properties (for EOD soft-stop calculation) +battery: + capacity_kwh: 8.0 # usable battery capacity (kWh) + +# Proactive car charging strategy (forecast-driven, no export threshold required) +car_charging: + min_soc: 35 # start car charging only if battery SOC ≥ this (%) + soc_floor: 5 # emergency brake floor — stop if SOC drops below this (%) + 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 + pv_threshold_a_w: 1000 # min PV production to start WallboxA (W) — 50% of 2kW rated + pv_threshold_b_w: 2000 # min PV production to start WallboxB (W) — 50% of 4kW rated + grid_delta_thresh_w: 2000 # min grid power shift after WallboxB activation = car detected (W) + # Known car profiles for trip mode cars: mini: diff --git a/internal/collector/collector.go b/internal/collector/collector.go index fd850cd..0c0db95 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -26,6 +26,7 @@ type SystemState struct { PhaseL1PowerW float64 // per-phase grid power L1 (positive=import, negative=export) PhaseL2PowerW float64 // per-phase grid power L2 PhaseL3PowerW float64 // per-phase grid power L3 + WWTopTempC float64 // DHW cylinder top temperature (°C) } // IsExporting returns true if the system is exporting to grid. @@ -91,6 +92,7 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) { {"phase_l1_power", &state.PhaseL1PowerW, 1}, {"phase_l2_power", &state.PhaseL2PowerW, 1}, {"phase_l3_power", &state.PhaseL3PowerW, 1}, + {"ww_top_temp", &state.WWTopTempC, 1}, } for _, t := range targets { @@ -120,6 +122,7 @@ func (c *Collector) Collect(ctx context.Context) (SystemState, error) { "l1_w", state.PhaseL1PowerW, "l2_w", state.PhaseL2PowerW, "l3_w", state.PhaseL3PowerW, + "ww_top_c", state.WWTopTempC, ) return state, nil diff --git a/internal/config/config.go b/internal/config/config.go index ac1e462..74df165 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,18 +10,43 @@ import ( // Config is the top-level EMS configuration. type Config struct { - Prometheus PrometheusConfig `yaml:"prometheus"` - Shelly ShellyConfig `yaml:"shelly"` - Viessmann ViessmannConfig `yaml:"viessmann"` - SOC SOCThresholds `yaml:"soc_thresholds"` - Hysteresis HysteresisConfig `yaml:"hysteresis"` - Thresholds PowerThresholds `yaml:"thresholds"` - Consumers ConsumersConfig `yaml:"consumers"` - Strategic StrategicConfig `yaml:"strategic"` - Season SeasonConfig `yaml:"season"` - Forecast ForecastConfig `yaml:"forecast"` - Cars map[string]CarProfile `yaml:"cars"` - EMS EMSConfig `yaml:"ems"` + Prometheus PrometheusConfig `yaml:"prometheus"` + Shelly ShellyConfig `yaml:"shelly"` + Viessmann ViessmannConfig `yaml:"viessmann"` + SOC SOCThresholds `yaml:"soc_thresholds"` + Hysteresis HysteresisConfig `yaml:"hysteresis"` + Thresholds PowerThresholds `yaml:"thresholds"` + Consumers ConsumersConfig `yaml:"consumers"` + Strategic StrategicConfig `yaml:"strategic"` + Season SeasonConfig `yaml:"season"` + Forecast ForecastConfig `yaml:"forecast"` + Battery BatteryConfig `yaml:"battery"` + CarCharging CarChargingConfig `yaml:"car_charging"` + Cars map[string]CarProfile `yaml:"cars"` + EMS EMSConfig `yaml:"ems"` +} + +// BatteryConfig holds physical battery properties. +type BatteryConfig struct { + CapacityKWh float64 `yaml:"capacity_kwh"` // usable battery capacity in kWh +} + +// 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) +} + +func (c *CarChargingConfig) EODTimeParsed(ref time.Time) time.Time { + var h, m int + fmt.Sscanf(c.EODTime, "%d:%d", &h, &m) + return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location()) } // CarProfile holds the display name and battery capacity of a known vehicle. @@ -122,9 +147,11 @@ type StrategicConfig struct { ForecastMidKWh float64 `yaml:"forecast_mid_kwh"` WWBoostHighC float64 `yaml:"ww_boost_high_c"` WWBoostMidC float64 `yaml:"ww_boost_mid_c"` - WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C) - WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30" - WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00" + WWBaseC float64 `yaml:"ww_base_c"` // normal WW setpoint (°C) + WWMaxSetpointC float64 `yaml:"ww_max_setpoint_c"` // absolute maximum WW setpoint (°C), e.g. 60 + WWHysteresisC float64 `yaml:"ww_hysteresis_c"` // Viessmann switchOn = setpoint - hysteresis (°C), e.g. 5 + WWWindowStart string `yaml:"ww_window_start"` // e.g. "12:30" + WWWindowEnd string `yaml:"ww_window_end"` // e.g. "18:00" ScheduleOn string `yaml:"schedule_on"` ScheduleOff string `yaml:"schedule_off"` } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 9dd6f98..5472dfe 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -51,11 +51,14 @@ type DeviceStatus struct { // ConsumerState tracks the runtime state of a single consumer. type ConsumerState struct { - Active bool - ActivatedAt time.Time // when it was last turned on - ManualOverride bool - OverrideUntil time.Time - LowPowerCycles int // consecutive cycles with power below minimum threshold + Active bool + ActivatedAt time.Time // when it was last turned on + ManualOverride bool + OverrideUntil time.Time + LowPowerCycles int // consecutive cycles with power below minimum threshold + ProactiveCharging bool // true if activated by proactive car-charging logic (bypasses import shutdown) + ProbeStartGridW float64 // grid power snapshot at WallboxB activation (for no-car detection) + NoCarRetryUntil time.Time // don't retry WallboxB proactive charging until this time } // OverrideInfo is returned to callers that need to display or record override state. @@ -100,9 +103,11 @@ func NewEngine(cfg *config.Config, logger *slog.Logger) *Engine { } // Decide evaluates the current system state and returns a list of actions. -// wwBoostC is the WW temperature boost in °C derived from the PV forecast -// (0 = no forecast / forecast too low to warrant boosting). -func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC float64) []Action { +// +// - wwBoostC: WW temperature boost in °C from PV forecast (0 = no boost warranted) +// - forecastKWh: today's forecast total in kWh (0 if forecasting disabled) +// - sunsetTime: estimated time of sunset (used for EOD soft stop); zero = disabled +func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, forecastKWh float64, sunsetTime time.Time) []Action { var actions []Action soc := state.BatterySOC @@ -114,10 +119,11 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo e.logger.Debug("decision input", "grid_w", gridW, "soc", soc, + "pv_w", state.PVProductionW, + "forecast_kwh", forecastKWh, "heating_period", heatingPeriod, "ww_window", wwWindow, "ww_boost_c", wwBoostC, - "allowed", allowed, ) // --- SOC emergency brake --- @@ -139,7 +145,14 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo }) } - // --- Shutdown logic (reverse priority order) --- + // --- EOD soft stop: after configured time, stop car charging if remaining PV insufficient --- + actions = append(actions, e.checkEODSoftStop(state, now, sunsetTime)...) + + // --- WallboxB no-car probe: check grid delta after activation --- + actions = append(actions, e.checkWallboxBProbe(state, now)...) + + // --- Shutdown logic (reverse priority order, import hysteresis) --- + // Proactive wallboxes are skipped — EOD/probe/idle-cycles handle their stops. if gridW > e.cfg.Thresholds.ImportOffW { if e.hyst.ImportSinceAbove.IsZero() { e.hyst.ImportSinceAbove = now @@ -191,9 +204,17 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo e.logger.Info("wallbox released: car not charging", "consumer", wb, "low_power_cycles", cs.LowPowerCycles, + "proactive", cs.ProactiveCharging, ) cs.Active = false + cs.ProactiveCharging = false cs.LowPowerCycles = 0 + // For WallboxA: set retry timeout so proactive logic doesn't immediately re-activate + if wb == ConsumerWallboxA && e.cfg.CarCharging.NoCarRetryMin > 0 { + e.consumers[ConsumerWallboxA].NoCarRetryUntil = now.Add( + time.Duration(e.cfg.CarCharging.NoCarRetryMin) * time.Minute, + ) + } actions = append(actions, Action{ Consumer: wb, TurnOn: false, @@ -203,51 +224,245 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo } // --- Turn-on logic (priority order) --- - if gridW <= 0 { - // P1: SG-Ready (heating period only) - if heatingPeriod { - actions = append(actions, e.evaluateTurnOn( - ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW, - allowed, now, - )...) - } - // P2: WW boost (time window + forecast required) - if wwWindow && wwBoostC > 0 { - actions = append(actions, e.evaluateWWTurnOn(gridW, wwBoostC, allowed, now)...) - } + // P1: Proactive car charging — forecast-driven, no export threshold required. + // Mutual exclusion (A vs B) is enforced inside evaluateCarCharging. + actions = append(actions, e.evaluateCarCharging(state, forecastKWh, now)...) - // P3: Wallbox A (2kW, single-phase) — only if Wallbox B is not active. - // Uses per-phase export check if available, otherwise falls back to total. - // Re-reads Active state directly so a same-cycle activation of WallboxB blocks WallboxA. - if !e.consumers[ConsumerWallboxB].Active { - phaseGridW := gridW // fallback: total grid power - if state.PhaseL1PowerW != 0 || state.PhaseL2PowerW != 0 || state.PhaseL3PowerW != 0 { - phaseGridW = min3(state.PhaseL1PowerW, state.PhaseL2PowerW, state.PhaseL3PowerW) - } - threshold := e.cfg.Thresholds.WallboxAExportW - if e.cfg.Thresholds.WallboxAPhaseExportW != 0 { - threshold = e.cfg.Thresholds.WallboxAPhaseExportW - } - actions = append(actions, e.evaluateTurnOn( - ConsumerWallboxA, phaseGridW, threshold, - allowed, now, - )...) - } + // P2: WW boost — time window + forecast, no export threshold, no car charging. + // Only runs if no wallbox is active (car charging takes priority). + if wwWindow && wwBoostC > 0 && + !e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active { + actions = append(actions, e.evaluateWWTurnOn(state, wwBoostC, allowed, now)...) + } - // P4: Wallbox B (4kW, 3-phase) — only if Wallbox A is not active. - // Re-reads Active state so a same-cycle activation of WallboxA blocks WallboxB. - if !e.consumers[ConsumerWallboxA].Active { - actions = append(actions, e.evaluateTurnOn( - ConsumerWallboxB, gridW, e.cfg.Thresholds.WallboxBExportW, - allowed, now, - )...) - } + // P3: SG-Ready — reactive, export-threshold based, heating period only. + // Only runs if no wallbox is active (car charging takes priority). + if heatingPeriod && + !e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active { + actions = append(actions, e.evaluateTurnOn( + ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW, + allowed, now, + )...) } return actions } +// evaluateCarCharging implements proactive forecast-driven car charging. +// Tries WallboxA first (has PM for car detection), then WallboxB (grid-delta probe). +// Does not require export surplus — just sufficient PV production and a good forecast. +func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh float64, now time.Time) []Action { + cc := e.cfg.CarCharging + + // Skip if proactive charging is not configured (thresholds must be set) + if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 { + return nil + } + + // Forecast must be available and meet the minimum threshold (mid = worthwhile day) + if forecastKWh == 0 || forecastKWh < float64(e.cfg.Strategic.ForecastMidKWh) { + return nil + } + + // SOC must be above the minimum for proactive charging + if state.BatterySOC < float64(cc.MinSOC) { + return nil + } + + csA := e.consumers[ConsumerWallboxA] + csB := e.consumers[ConsumerWallboxB] + + // --- Try WallboxA (has PM, preferred) --- + if !csA.Active && !csB.Active { + // Respect no-car retry timeout (set after idle-cycles detection) + if !csA.NoCarRetryUntil.IsZero() && now.Before(csA.NoCarRetryUntil) { + e.logger.Debug("WallboxA proactive: skipping, in no-car retry window", + "retry_until", csA.NoCarRetryUntil.Format("15:04"), + ) + // Fall through to WallboxB below + } else if state.PVProductionW >= cc.PVThresholdAW { + // Respect manual override + if csA.ManualOverride && now.Before(csA.OverrideUntil) { + return nil + } + e.logger.Info("proactive: activating WallboxA", + "pv_w", state.PVProductionW, + "threshold_w", cc.PVThresholdAW, + "soc", state.BatterySOC, + "forecast_kwh", forecastKWh, + ) + csA.Active = true + csA.ActivatedAt = now + csA.ProactiveCharging = true + csA.LowPowerCycles = 0 + delete(e.hyst.ExportSinceAbove, ConsumerWallboxA) + return []Action{{ + Consumer: ConsumerWallboxA, + TurnOn: true, + Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, forecast %.1fkWh, SOC %.0f%%", state.PVProductionW, cc.PVThresholdAW, forecastKWh, state.BatterySOC), + }} + } + } + + // --- Try WallboxB (no PM, uses grid-delta probe) --- + // Only if WallboxA is not active and B is not already running. + if !csA.Active && !csB.Active { + if csB.ManualOverride && now.Before(csB.OverrideUntil) { + return nil + } + if !csB.NoCarRetryUntil.IsZero() && now.Before(csB.NoCarRetryUntil) { + e.logger.Debug("WallboxB proactive: skipping, in no-car retry window", + "retry_until", csB.NoCarRetryUntil.Format("15:04"), + ) + return nil + } + if state.PVProductionW >= cc.PVThresholdBW { + e.logger.Info("proactive: activating WallboxB (grid-delta probe)", + "pv_w", state.PVProductionW, + "threshold_w", cc.PVThresholdBW, + "soc", state.BatterySOC, + "forecast_kwh", forecastKWh, + ) + csB.Active = true + csB.ActivatedAt = now + csB.ProactiveCharging = true + csB.ProbeStartGridW = state.GridPowerW + csB.LowPowerCycles = 0 + delete(e.hyst.ExportSinceAbove, ConsumerWallboxB) + return []Action{{ + Consumer: ConsumerWallboxB, + TurnOn: true, + Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, forecast %.1fkWh, SOC %.0f%%", state.PVProductionW, cc.PVThresholdBW, forecastKWh, state.BatterySOC), + }} + } + } + + return nil +} + +// checkWallboxBProbe checks whether a car is actually charging on WallboxB after activation. +// WallboxB has no PM, so we use grid-delta: if the grid power hasn't shifted by at least +// GridDeltaThreshW within the probe window, no car is connected → deactivate and set retry. +func (e *Engine) checkWallboxBProbe(state collector.SystemState, now time.Time) []Action { + cs := e.consumers[ConsumerWallboxB] + if !cs.Active || !cs.ProactiveCharging || cs.ActivatedAt.IsZero() { + return nil + } + + cc := e.cfg.CarCharging + probeDuration := e.cfg.Hysteresis.MinRuntimeWallboxParsed() / 2 // half the min runtime + if now.Sub(cs.ActivatedAt) < probeDuration { + return nil // probe window not reached yet + } + + // Already probed if ProbeStartGridW is zero after first probe (reset after detection) + if cs.ProbeStartGridW == 0 { + return nil // already concluded + } + + gridDelta := cs.ProbeStartGridW - state.GridPowerW // negative = more import = car charging + // A 4kW wallbox causes a grid shift of ~4000W (or large export reduction). + // Positive gridDelta means we're importing more than at probe start (expected with car charging). + // We check: grid increased by at least threshold (car drawing power). + if gridDelta >= cc.GridDeltaThreshW || -gridDelta >= cc.GridDeltaThreshW { + // Either significantly more import or less export = car detected + e.logger.Info("WallboxB probe: car detected via grid delta", + "probe_start_w", cs.ProbeStartGridW, + "current_w", state.GridPowerW, + "delta_w", gridDelta, + ) + cs.ProbeStartGridW = 0 // mark probe as concluded + return nil + } + + // No meaningful grid shift → no car connected + e.logger.Info("WallboxB probe: no car detected, deactivating", + "probe_start_w", cs.ProbeStartGridW, + "current_w", state.GridPowerW, + "delta_w", gridDelta, + "threshold_w", cc.GridDeltaThreshW, + ) + cs.Active = false + cs.ProactiveCharging = false + cs.ProbeStartGridW = 0 + if cc.NoCarRetryMin > 0 { + cs.NoCarRetryUntil = now.Add(time.Duration(cc.NoCarRetryMin) * time.Minute) + } + return []Action{{ + Consumer: ConsumerWallboxB, + TurnOn: false, + Reason: fmt.Sprintf("no-car probe: grid delta %.0fW < %.0fW", gridDelta, cc.GridDeltaThreshW), + }} +} + +// checkEODSoftStop implements the end-of-day battery protection. +// After CarCharging.EODTime, if the remaining estimated PV production is insufficient +// to fill the battery to the EOD target by sunset, proactive car charging is stopped. +func (e *Engine) checkEODSoftStop(state collector.SystemState, now time.Time, sunsetTime time.Time) []Action { + cc := e.cfg.CarCharging + if cc.EODTime == "" || cc.EODSOCTarget == 0 || e.cfg.Battery.CapacityKWh == 0 { + return nil + } + + eodTime := cc.EODTimeParsed(now) + if now.Before(eodTime) { + return nil // too early for EOD check + } + + // Determine sunset reference + if sunsetTime.IsZero() || sunsetTime.Before(now) { + return nil // no valid sunset time, skip + } + + hoursToSunset := sunsetTime.Sub(now).Hours() + if hoursToSunset <= 0 { + hoursToSunset = 0 + } + + // Estimate remaining PV production (current watt × hours to sunset) + remainingPVkWh := (state.PVProductionW / 1000.0) * hoursToSunset + + // Battery energy needed to reach target SOC + socDeficitKWh := (float64(cc.EODSOCTarget)/100.0 - state.BatterySOC/100.0) * e.cfg.Battery.CapacityKWh + if socDeficitKWh <= 0 { + return nil // already at or above target SOC + } + + // House base load consumption during remaining time + houseKWh := (e.cfg.Forecast.BaseLoadW / 1000.0) * hoursToSunset + + // If remaining PV can't cover the battery deficit plus house load, stop charging + if remainingPVkWh >= socDeficitKWh+houseKWh { + return nil // enough PV remaining + } + + e.logger.Info("EOD soft stop: remaining PV insufficient to reach target SOC", + "remaining_pv_kwh", fmt.Sprintf("%.2f", remainingPVkWh), + "soc_deficit_kwh", fmt.Sprintf("%.2f", socDeficitKWh), + "house_kwh", fmt.Sprintf("%.2f", houseKWh), + "hours_to_sunset", fmt.Sprintf("%.1f", hoursToSunset), + "current_soc", state.BatterySOC, + "target_soc", cc.EODSOCTarget, + ) + + var stopActions []Action + for _, wb := range []Consumer{ConsumerWallboxB, ConsumerWallboxA} { + cs := e.consumers[wb] + if !cs.Active || !cs.ProactiveCharging { + continue + } + cs.Active = false + cs.ProactiveCharging = false + stopActions = append(stopActions, Action{ + Consumer: wb, + TurnOn: false, + Reason: fmt.Sprintf("EOD soft stop: %.1fkWh PV remaining < %.1fkWh needed", remainingPVkWh, socDeficitKWh+houseKWh), + }) + } + return stopActions +} + // min3 returns the minimum of three float64 values. func min3(a, b, c float64) float64 { if b < a { @@ -372,6 +587,13 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action { continue } + // 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) + continue + } + // Per-consumer accepted import tolerance: if the current import is within // the configured tolerance for this wallbox, skip shutdown. var acceptedImportW float64 @@ -423,6 +645,8 @@ func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *Action { // socEmergencyBrake immediately shuts off consumers whose SOC threshold // is no longer met, ignoring minimum runtimes and manual overrides. +// Proactive car-charging wallboxes use the SOCFloor threshold instead of the +// standard SOC gates, allowing charging down to a lower limit during solar hours. func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action { var actions []Action allowed := e.allowedConsumers(soc) @@ -431,6 +655,15 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action { if !cs.Active { continue } + + // Proactive wallboxes: only emergency-brake at SOCFloor + if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) { + floor := float64(e.cfg.CarCharging.SOCFloor) + if floor > 0 && soc >= floor { + continue // still above floor, keep charging + } + } + if allowed[c] { continue } @@ -444,6 +677,7 @@ func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action { cs.Active = false cs.ManualOverride = false // EMS takes back full control after emergency cs.OverrideUntil = time.Time{} + cs.ProactiveCharging = false a := Action{ Consumer: c, TurnOn: false, @@ -567,50 +801,51 @@ func (e *Engine) minRuntime(c Consumer) time.Duration { } // evaluateWWTurnOn checks whether WW boost should be activated. -// Prerequisites (time window and forecast) are already verified by the caller. -func (e *Engine) evaluateWWTurnOn(gridW, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action { +// Prerequisites (time window, forecast > 0, no car charging) are already verified by the caller. +// No export threshold is required — the heat pump compressor load is covered by PV. +// Dynamic setpoint: current tank top + hysteresis + boost delta, capped at WWMaxSetpointC. +func (e *Engine) evaluateWWTurnOn(state collector.SystemState, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action { cs := e.consumers[ConsumerWW] if cs.Active { return nil } if cs.ManualOverride && now.Before(cs.OverrideUntil) { - delete(e.hyst.ExportSinceAbove, ConsumerWW) return nil } if !allowed[ConsumerWW] { - delete(e.hyst.ExportSinceAbove, ConsumerWW) - return nil - } - if gridW > e.cfg.Thresholds.WWExportW { - delete(e.hyst.ExportSinceAbove, ConsumerWW) return nil } - if _, ok := e.hyst.ExportSinceAbove[ConsumerWW]; !ok { - e.hyst.ExportSinceAbove[ConsumerWW] = now + // Dynamic setpoint: set high enough above current temp to trigger heating immediately. + // Viessmann switchOn threshold = setpoint - hysteresis. + // Target = current_top + hysteresis + boost_delta, capped at max. + hysteresis := e.cfg.Strategic.WWHysteresisC + if hysteresis == 0 { + hysteresis = 5 // safe default } - exportDuration := now.Sub(e.hyst.ExportSinceAbove[ConsumerWW]) - if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() { - return nil + maxSetpoint := e.cfg.Strategic.WWMaxSetpointC + if maxSetpoint == 0 { + maxSetpoint = 60 // safe default + } + targetTemp := state.WWTopTempC + hysteresis + wwBoostC + if targetTemp > maxSetpoint { + targetTemp = maxSetpoint } - targetTemp := e.cfg.Strategic.WWBaseC + wwBoostC e.logger.Info("activating WW boost", - "grid_w", gridW, + "ww_top_c", state.WWTopTempC, "ww_boost_c", wwBoostC, "target_temp_c", targetTemp, - "export_duration", exportDuration, ) cs.Active = true cs.ActivatedAt = now - delete(e.hyst.ExportSinceAbove, ConsumerWW) return []Action{{ Consumer: ConsumerWW, TurnOn: true, TargetTempC: targetTemp, - Reason: fmt.Sprintf("export %.0fW for %s, WW boost +%.0f°C", -gridW, exportDuration, wwBoostC), + Reason: fmt.Sprintf("WW boost +%.0f°C → setpoint %.0f°C (tank %.0f°C)", wwBoostC, targetTemp, state.WWTopTempC), }} } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index d1daa38..16dadb3 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -61,7 +61,7 @@ func TestSOCBlocksAll(t *testing.T) { BatterySOC: 40, // below 50% → all blocked } - actions := eng.Decide(state, now, 0) + actions := eng.Decide(state, now, 0, 0, time.Time{}) if len(actions) != 0 { t.Errorf("expected no actions with SOC 40%%, got %d actions", len(actions)) } @@ -79,13 +79,13 @@ func TestSOCAllowsSGReady(t *testing.T) { } // First call — starts hysteresis timer - actions := eng.Decide(state, base, 0) + actions := eng.Decide(state, base, 0, 0, time.Time{}) if len(actions) != 0 { t.Errorf("expected no actions on first call (hysteresis), got %d", len(actions)) } // Second call after 5 minutes — hysteresis passed - actions = eng.Decide(state, base.Add(5*time.Minute), 0) + actions = eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{}) if len(actions) != 1 { t.Fatalf("expected 1 action after hysteresis, got %d", len(actions)) } @@ -107,8 +107,8 @@ func TestSOCBlocksWallboxAt60(t *testing.T) { } // Pass hysteresis - eng.Decide(state, base, 0) - actions := eng.Decide(state, base.Add(5*time.Minute), 0) + eng.Decide(state, base, 0, 0, time.Time{}) + actions := eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{}) // Should only get SG-Ready, no wallboxes for _, a := range actions { @@ -130,8 +130,8 @@ func TestSGReadyOnlyInHeatingPeriod(t *testing.T) { } // Pass hysteresis - eng.Decide(state, base, 0) - actions := eng.Decide(state, base.Add(5*time.Minute), 0) + eng.Decide(state, base, 0, 0, time.Time{}) + actions := eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{}) for _, a := range actions { if a.Consumer == ConsumerSGReady { @@ -149,14 +149,14 @@ func TestSOCEmergencyBrake(t *testing.T) { GridPowerW: -600, BatterySOC: 95, } - eng.Decide(state, base, 0) - eng.Decide(state, base.Add(5*time.Minute), 0) + eng.Decide(state, base, 0, 0, time.Time{}) + eng.Decide(state, base.Add(5*time.Minute), 0, 0, time.Time{}) // Now SOC drops below threshold state.BatterySOC = 45 state.GridPowerW = -600 // still exporting, but SOC is too low - actions := eng.Decide(state, base.Add(10*time.Minute), 0) + actions := eng.Decide(state, base.Add(10*time.Minute), 0, 0, time.Time{}) foundBrake := false for _, a := range actions { @@ -190,7 +190,7 @@ func TestShutdownReverseOrder(t *testing.T) { GridPowerW: 500, // importing BatterySOC: 95, } - actions := eng.Decide(state, base, 0) + actions := eng.Decide(state, base, 0, 0, time.Time{}) if len(actions) == 0 { t.Fatal("expected shutdown action") @@ -265,18 +265,28 @@ func TestWallboxMutualExclusion(t *testing.T) { cfg := testConfig() cfg.Hysteresis.ExportOnDuration = "0s" cfg.Hysteresis.ImportOffDuration = "0s" + // Configure proactive car charging + cfg.Strategic.ForecastMidKWh = 15 + cfg.CarCharging = config.CarChargingConfig{ + MinSOC: 35, + SOCFloor: 5, + PVThresholdAW: 1000, + PVThresholdBW: 2000, + } eng := NewEngine(cfg, testLogger()) base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer (no SG-Ready) - // Massive export — enough to meet both wallbox thresholds + // Good solar day — proactive charging should activate WallboxA state := collector.SystemState{ - GridPowerW: -5000, - BatterySOC: 95, + PVProductionW: 3000, // ≥ PVThresholdA (1000W) and ≥ PVThresholdB (2000W) + GridPowerW: -5000, + BatterySOC: 95, } + forecastKWh := 20.0 // above ForecastMidKWh - // First Decide: WallboxA should activate (P3), WallboxB must be blocked (mutex) - actions := eng.Decide(state, base, 0) + // First Decide: WallboxA should activate (tried first), WallboxB must be blocked (mutex) + actions := eng.Decide(state, base, 0, forecastKWh, time.Time{}) var wbAOn, wbBOn bool for _, a := range actions { @@ -295,7 +305,7 @@ func TestWallboxMutualExclusion(t *testing.T) { } // Second Decide with WallboxA still active: WallboxB must still be blocked - actions = eng.Decide(state, base.Add(2*time.Minute), 0) + actions = eng.Decide(state, base.Add(2*time.Minute), 0, forecastKWh, time.Time{}) for _, a := range actions { if a.Consumer == ConsumerWallboxB && a.TurnOn { t.Error("WallboxB must not activate while WallboxA is active (second cycle)") @@ -305,16 +315,24 @@ func TestWallboxMutualExclusion(t *testing.T) { func TestCarNotChargingReleasesWallbox(t *testing.T) { cfg := testConfig() - cfg.Hysteresis.ExportOnDuration = "0s" cfg.Consumers.IdleCycles = 3 cfg.Consumers.WallboxMinChargeW = 50 + // Configure proactive car charging + cfg.Strategic.ForecastMidKWh = 15 + cfg.CarCharging = config.CarChargingConfig{ + MinSOC: 35, + SOCFloor: 5, + PVThresholdAW: 1000, + NoCarRetryMin: 30, + } eng := NewEngine(cfg, testLogger()) base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) + forecastKWh := 20.0 - // Activate WallboxA - state := collector.SystemState{GridPowerW: -2000, BatterySOC: 95} - actions := eng.Decide(state, base, 0) + // Activate WallboxA via proactive charging + state := collector.SystemState{PVProductionW: 2000, GridPowerW: -2000, BatterySOC: 95} + actions := eng.Decide(state, base, 0, forecastKWh, time.Time{}) if len(actions) != 1 || actions[0].Consumer != ConsumerWallboxA || !actions[0].TurnOn { t.Fatalf("expected WallboxA to activate, got %v", actions) } @@ -330,7 +348,7 @@ func TestCarNotChargingReleasesWallbox(t *testing.T) { } // Decide should now release WallboxA - actions = eng.Decide(state, base.Add(8*time.Minute), 0) + actions = eng.Decide(state, base.Add(8*time.Minute), 0, forecastKWh, time.Time{}) found := false for _, a := range actions { if a.Consumer == ConsumerWallboxA && !a.TurnOn { @@ -358,7 +376,7 @@ func TestCompressorIdleReleasesSGReady(t *testing.T) { BatterySOC: 95, CompressorPowerW: 1500, // compressor running } - actions := eng.Decide(state, base, 0) + actions := eng.Decide(state, base, 0, 0, time.Time{}) if len(actions) != 1 || actions[0].Consumer != ConsumerSGReady || !actions[0].TurnOn { t.Fatalf("expected SG-Ready to activate, got %v", actions) } @@ -366,7 +384,7 @@ func TestCompressorIdleReleasesSGReady(t *testing.T) { // Compressor drops to idle — 3 consecutive cycles state.CompressorPowerW = 10 // below idle threshold for i := 1; i <= 3; i++ { - actions = eng.Decide(state, base.Add(time.Duration(i)*2*time.Minute), 0) + actions = eng.Decide(state, base.Add(time.Duration(i)*2*time.Minute), 0, 0, time.Time{}) } // After 3 idle cycles, SG-Ready should be released despite min-runtime not reached @@ -383,24 +401,47 @@ func TestCompressorIdleReleasesSGReady(t *testing.T) { func TestMinRuntimeRespected(t *testing.T) { cfg := testConfig() - cfg.Hysteresis.ExportOnDuration = "0s" cfg.Hysteresis.MinRuntimeWallbox = "15m" + cfg.Hysteresis.ImportOffDuration = "0s" + cfg.Strategic.ForecastMidKWh = 15 + cfg.CarCharging = config.CarChargingConfig{ + MinSOC: 35, + SOCFloor: 5, + PVThresholdAW: 1000, + } eng := NewEngine(cfg, testLogger()) base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer - // Activate Wallbox A + // Activate WallboxA via proactive charging state := collector.SystemState{ - GridPowerW: -2000, - BatterySOC: 95, + PVProductionW: 2000, + GridPowerW: -2000, + BatterySOC: 95, } - eng.Decide(state, base, 0) + eng.Decide(state, base, 0, 20.0, time.Time{}) // WallboxA activates - // Try to shutdown after 5 minutes (< 15min minimum) - state.GridPowerW = 500 - eng.Decide(state, base.Add(1*time.Minute), 0) // start import timer + // Drop PV — now importing; proactive charging is active so import doesn't shut it down. + // But if we test a non-proactive consumer: inject WallboxA as non-proactive via RecoverState, + // and verify min-runtime is still respected for import-shutdown path. + // Simpler: use RecoverState with SG-Ready active (min 30m), try to shut down in <30m. + eng2 := NewEngine(cfg, testLogger()) + eng2.RecoverState(map[Consumer]DeviceStatus{ + ConsumerSGReady: {On: true}, + }) + // SG-Ready ActivatedAt is zero (unknown) → treated as exceeding min-runtime, so it can be shut down. + // For a real min-runtime test, inject with SyncHardwareState to set ActivatedAt. + // Instead, manually set ActivatedAt via ApplyOverride then clear override: + cfg2 := testConfig() + cfg2.Hysteresis.MinRuntimeWallbox = "15m" + cfg2.Hysteresis.ImportOffDuration = "0s" + eng3 := NewEngine(cfg2, testLogger()) + eng3.ApplyOverride(ConsumerWallboxA, true, 0) // turn on, no lock + // Reset override flag so import-shutdown applies + eng3.consumers[ConsumerWallboxA].ManualOverride = false - actions := eng.Decide(state, base.Add(8*time.Minute), 0) // import for >6min + state2 := collector.SystemState{GridPowerW: 500, BatterySOC: 95} + actions := eng3.Decide(state2, base.Add(5*time.Minute), 0, 0, time.Time{}) // 5min < 15min for _, a := range actions { if a.Consumer == ConsumerWallboxA && !a.TurnOn { diff --git a/main.go b/main.go index 718824f..dce1432 100644 --- a/main.go +++ b/main.go @@ -263,8 +263,17 @@ func runCycle( if !wwBoostDisabled.IsActive() { wwBoostC = computeWWBoost(fcResult, cfg) } + forecastKWh := 0.0 + if fcResult != nil { + forecastKWh = fcResult.TotalKWh + } + // Estimate sunset as SurplusWindowEnd + 1h; fall back to zero (disables EOD check) + var sunsetTime time.Time + if fcResult != nil && !fcResult.SurplusWindowEnd.IsZero() { + sunsetTime = fcResult.SurplusWindowEnd.Add(time.Hour) + } decisionStart := time.Now() - actions := eng.Decide(state, now, wwBoostC) + actions := eng.Decide(state, now, wwBoostC, forecastKWh, sunsetTime) m.DecisionDuration.Observe(time.Since(decisionStart).Seconds()) // Step 3b: Trip mode — session energy tracking + wallbox activation