Files
EMS/internal/engine/engine_test.go

453 lines
13 KiB
Go

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,
false,
)
}
// 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")
}
}
}