Initial commit: EMS — Energie Management System
Complete self-consumption optimisation system for 7 kWp PV installation: - Prometheus collector (grid power, SOC, PV, per-phase, compressor) - Pure decision engine with SOC gates, hysteresis, priority ordering - Shelly Gen1/Gen2 actuator (SHA-256 Digest auth, PM power readback) - Viessmann OAuth2 client for DHW temperature control - PV forecast integration (forecast.solar) - Wallbox mutual exclusion (VX3 4.6 kW AC output constraint) - Car-not-charging detection via Shelly PM - Compressor idle → early SG-Ready release - Per-phase grid power for single-phase wallbox decisions - Manual override detection and web UI with override buttons - Full unit test coverage for decision engine - systemd service, Makefile, complete documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
386
internal/engine/engine_test.go
Normal file
386
internal/engine/engine_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user