Implement proactive forecast-driven car charging strategy

- Replace reactive export-threshold wallbox activation with proactive
  logic: WallboxA/B activate when forecast ≥ mid AND PV ≥ threshold
  AND SOC ≥ 35%, without requiring grid export surplus
- Add WallboxB no-car detection via grid-delta probe (no PM available):
  after probe window, if grid shift < GridDeltaThreshW → no car, retry
  after configured timeout
- Add EOD soft stop: after 16:00, stop proactive car charging if
  remaining PV estimate can't cover battery deficit to 90% by sunset
- WW boost no longer requires export threshold; dynamic setpoint uses
  tank top temp + hysteresis + boost delta, capped at 60°C
- Proactive wallboxes bypass import-hysteresis shutdown; SOC emergency
  brake uses SOCFloor (5%) instead of standard AllConsumers gate
- Add WWTopTempC to SystemState (ww_top_temp metric from DHW cylinder)
- Add BatteryConfig (capacity_kwh), CarChargingConfig to config
- Add WWMaxSetpointC, WWHysteresisC to StrategicConfig
- Update Decide() signature: forecastKWh + sunsetTime parameters
- Update all tests; add proactive charging test cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 21:23:04 +02:00
parent fc535d395c
commit 971e22c4be
6 changed files with 447 additions and 114 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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),
}}
}

View File

@@ -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 {

11
main.go
View File

@@ -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