package engine import ( "log/slog" "os" "testing" "time" "github.com/tb/ems/internal/collector" "github.com/tb/ems/internal/config" ) func testConfig() *config.Config { return &config.Config{ SOC: config.SOCThresholds{ BlockAll: 50, SGReadyOnly: 70, PlusWallboxA: 90, AllConsumers: 90, }, Hysteresis: config.HysteresisConfig{ ExportOnDuration: "4m", ImportOffDuration: "6m", MinRuntimeWallbox: "15m", MinRuntimeSGReady: "30m", }, Thresholds: config.PowerThresholds{ SGReadyExportW: -500, WWExportW: -500, WallboxAExportW: -1800, WallboxBExportW: -3800, ImportOffW: 200, }, Consumers: config.ConsumersConfig{ CompressorIdleW: 50, WallboxMinChargeW: 50, IdleCycles: 3, }, Season: config.SeasonConfig{ HeatingStartMonth: 10, HeatingEndMonth: 4, }, Strategic: config.StrategicConfig{ WWBaseC: 50, WWWindowStart: "12:30", WWWindowEnd: "18:00", }, } } func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) } func TestSOCBlocksAll(t *testing.T) { eng := NewEngine(testConfig(), testLogger()) now := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) // January = heating period state := collector.SystemState{ GridPowerW: -3000, // 3kW export BatterySOC: 40, // below 50% → all blocked } 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)) } } func TestSOCAllowsSGReady(t *testing.T) { eng := NewEngine(testConfig(), testLogger()) // Simulate export for >4 minutes to pass hysteresis base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) // January = heating state := collector.SystemState{ GridPowerW: -600, // 600W export, above SG-Ready threshold BatterySOC: 60, // 50-70% → SG-Ready only } // First call — starts hysteresis timer 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, 0, time.Time{}) if len(actions) != 1 { t.Fatalf("expected 1 action after hysteresis, got %d", len(actions)) } if actions[0].Consumer != ConsumerSGReady { t.Errorf("expected SG-Ready, got %v", actions[0].Consumer) } if !actions[0].TurnOn { t.Error("expected TurnOn=true") } } func TestSOCBlocksWallboxAt60(t *testing.T) { eng := NewEngine(testConfig(), testLogger()) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) state := collector.SystemState{ GridPowerW: -5000, // massive export BatterySOC: 60, // only SG-Ready allowed } // Pass hysteresis 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 { if a.Consumer == ConsumerWallboxA || a.Consumer == ConsumerWallboxB { t.Errorf("wallbox should not be activated at SOC 60%%, got %v", a.Consumer) } } } func TestSGReadyOnlyInHeatingPeriod(t *testing.T) { eng := NewEngine(testConfig(), testLogger()) // July = NOT heating period base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) state := collector.SystemState{ GridPowerW: -600, BatterySOC: 95, // all consumers allowed } // Pass hysteresis 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 { t.Error("SG-Ready should not activate outside heating period") } } } func TestSOCEmergencyBrake(t *testing.T) { eng := NewEngine(testConfig(), testLogger()) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) // First, activate SG-Ready with high SOC state := collector.SystemState{ GridPowerW: -600, BatterySOC: 95, } 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, 0, time.Time{}) foundBrake := false for _, a := range actions { if a.Consumer == ConsumerSGReady && !a.TurnOn { foundBrake = true } } if !foundBrake { t.Error("expected SOC emergency brake to shut off SG-Ready") } } func TestShutdownReverseOrder(t *testing.T) { cfg := testConfig() cfg.Hysteresis.MinRuntimeWallbox = "0s" cfg.Hysteresis.MinRuntimeSGReady = "0s" cfg.Hysteresis.ImportOffDuration = "0s" eng := NewEngine(cfg, testLogger()) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) // Inject WallboxB + SG-Ready as active (simulating recovery from a previous run // where WallboxB was switched on manually, bypassing the mutex). eng.RecoverState(map[Consumer]DeviceStatus{ ConsumerWallboxB: {On: true}, ConsumerSGReady: {On: true}, }) // Import detected — WallboxB should be shut down first (reverse priority order) state := collector.SystemState{ GridPowerW: 500, // importing BatterySOC: 95, } actions := eng.Decide(state, base, 0, 0, time.Time{}) if len(actions) == 0 { t.Fatal("expected shutdown action") } found := false for _, a := range actions { if !a.TurnOn && a.Consumer == ConsumerWallboxB { found = true } } if !found { t.Error("expected WallboxB to be shut down first") } } func TestHeatingPeriodDetection(t *testing.T) { eng := NewEngine(testConfig(), testLogger()) cold := 5.0 // well below any threshold — pure calendar test tests := []struct { month time.Month expected bool }{ {time.January, true}, {time.February, true}, {time.March, true}, {time.April, true}, {time.May, false}, {time.June, false}, {time.July, false}, {time.August, false}, {time.September, false}, {time.October, true}, {time.November, true}, {time.December, true}, } for _, tt := range tests { t.Run(tt.month.String(), func(t *testing.T) { date := time.Date(2025, tt.month, 15, 12, 0, 0, 0, time.UTC) if got := eng.isHeatingPeriod(date, cold); got != tt.expected { t.Errorf("month %s: got %v, want %v", tt.month, got, tt.expected) } }) } } func TestHeatingPeriodAmbientSuppression(t *testing.T) { cfg := testConfig() cfg.Season.HeatingMinAmbientC = 15.0 eng := NewEngine(cfg, testLogger()) // Winter month, but warm day — should be suppressed warmWinterDay := time.Date(2025, time.January, 15, 12, 0, 0, 0, time.UTC) if eng.isHeatingPeriod(warmWinterDay, 18.0) { t.Error("expected heating period suppressed when ambient (18°C) >= threshold (15°C)") } // Winter month, cold day — should be active if !eng.isHeatingPeriod(warmWinterDay, 8.0) { t.Error("expected heating period active when ambient (8°C) < threshold (15°C)") } // Summer month, cold day — still not heating (month gate takes precedence) summerDay := time.Date(2025, time.July, 15, 12, 0, 0, 0, time.UTC) if eng.isHeatingPeriod(summerDay, 5.0) { t.Error("expected heating period inactive in summer regardless of temperature") } } 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) // Good solar day — proactive charging should activate WallboxA state := collector.SystemState{ PVProductionW: 3000, // ≥ PVThresholdA (1000W) and ≥ PVThresholdB (2000W) GridPowerW: -5000, BatterySOC: 95, } forecastKWh := 20.0 // above ForecastMidKWh // 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 { if a.Consumer == ConsumerWallboxA && a.TurnOn { wbAOn = true } if a.Consumer == ConsumerWallboxB && a.TurnOn { wbBOn = true } } if !wbAOn { t.Error("expected WallboxA to activate") } if wbBOn { t.Error("WallboxB must not activate while WallboxA is active (mutex)") } // Second Decide with WallboxA still active: WallboxB must still be blocked 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)") } } } func TestCarNotChargingReleasesWallbox(t *testing.T) { cfg := testConfig() 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 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) } // Simulate 3 cycles with Shelly PM reading near zero (car not charging / unplugged) lowPower := DeviceStatus{On: true, PowerW: 10} // 10W < 50W threshold for i := 0; i < 3; i++ { eng.SyncHardwareState( map[Consumer]DeviceStatus{ConsumerWallboxA: lowPower}, base.Add(time.Duration(i+1)*2*time.Minute), time.Hour, ) } // Decide should now release WallboxA 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 { found = true } } if !found { t.Error("expected WallboxA to be turned off after 3 low-power cycles") } } func TestCompressorIdleReleasesSGReady(t *testing.T) { cfg := testConfig() cfg.Hysteresis.ExportOnDuration = "0s" cfg.Hysteresis.MinRuntimeSGReady = "30m" // long min-runtime cfg.Consumers.IdleCycles = 3 cfg.Consumers.CompressorIdleW = 50 eng := NewEngine(cfg, testLogger()) base := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) // January = heating period // Activate SG-Ready state := collector.SystemState{ GridPowerW: -600, BatterySOC: 95, CompressorPowerW: 1500, // compressor running } 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) } // 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, 0, time.Time{}) } // After 3 idle cycles, SG-Ready should be released despite min-runtime not reached found := false for _, a := range actions { if a.Consumer == ConsumerSGReady && !a.TurnOn { found = true } } if !found { t.Error("expected SG-Ready to be released early when compressor is idle for 3 cycles") } } func TestMinRuntimeRespected(t *testing.T) { cfg := testConfig() 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 WallboxA via proactive charging state := collector.SystemState{ PVProductionW: 2000, GridPowerW: -2000, BatterySOC: 95, } eng.Decide(state, base, 0, 20.0, time.Time{}) // WallboxA activates // 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 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 { t.Error("Wallbox A should not be shut down before 15 min runtime") } } }