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) 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) 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) 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) actions := eng.Decide(state, base.Add(5*time.Minute), 0) // 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) actions := eng.Decide(state, base.Add(5*time.Minute), 0) 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) eng.Decide(state, base.Add(5*time.Minute), 0) // 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) 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) 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()) 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); got != tt.expected { t.Errorf("month %s: got %v, want %v", tt.month, got, tt.expected) } }) } } func TestWallboxMutualExclusion(t *testing.T) { cfg := testConfig() cfg.Hysteresis.ExportOnDuration = "0s" cfg.Hysteresis.ImportOffDuration = "0s" 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 state := collector.SystemState{ GridPowerW: -5000, BatterySOC: 95, } // First Decide: WallboxA should activate (P3), WallboxB must be blocked (mutex) actions := eng.Decide(state, base, 0) 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) 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.Hysteresis.ExportOnDuration = "0s" cfg.Consumers.IdleCycles = 3 cfg.Consumers.WallboxMinChargeW = 50 eng := NewEngine(cfg, testLogger()) base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // Activate WallboxA state := collector.SystemState{GridPowerW: -2000, BatterySOC: 95} actions := eng.Decide(state, base, 0) 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) 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) 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) } // 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.ExportOnDuration = "0s" cfg.Hysteresis.MinRuntimeWallbox = "15m" eng := NewEngine(cfg, testLogger()) base := time.Date(2025, 7, 15, 12, 0, 0, 0, time.UTC) // summer // Activate Wallbox A state := collector.SystemState{ GridPowerW: -2000, BatterySOC: 95, } eng.Decide(state, base, 0) // Try to shutdown after 5 minutes (< 15min minimum) state.GridPowerW = 500 eng.Decide(state, base.Add(1*time.Minute), 0) // start import timer actions := eng.Decide(state, base.Add(8*time.Minute), 0) // import for >6min for _, a := range actions { if a.Consumer == ConsumerWallboxA && !a.TurnOn { t.Error("Wallbox A should not be shut down before 15 min runtime") } } }