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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user