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

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