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:
622
internal/engine/engine.go
Normal file
622
internal/engine/engine.go
Normal file
@@ -0,0 +1,622 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/tb/ems/internal/collector"
|
||||
"github.com/tb/ems/internal/config"
|
||||
)
|
||||
|
||||
// Consumer identifies a controllable load.
|
||||
type Consumer int
|
||||
|
||||
const (
|
||||
ConsumerSGReady Consumer = iota
|
||||
ConsumerWW // domestic hot water boost via Viessmann API
|
||||
ConsumerWallboxA
|
||||
ConsumerWallboxB
|
||||
)
|
||||
|
||||
func (c Consumer) String() string {
|
||||
switch c {
|
||||
case ConsumerSGReady:
|
||||
return "sg_ready"
|
||||
case ConsumerWW:
|
||||
return "ww"
|
||||
case ConsumerWallboxA:
|
||||
return "wallbox_a"
|
||||
case ConsumerWallboxB:
|
||||
return "wallbox_b"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Action represents a switching decision.
|
||||
type Action struct {
|
||||
Consumer Consumer
|
||||
TurnOn bool
|
||||
Reason string
|
||||
TargetTempC float64 // non-zero for ConsumerWW: the absolute temperature to set
|
||||
}
|
||||
|
||||
// DeviceStatus holds the hardware-reported state of a consumer device,
|
||||
// as read back from the physical device each cycle.
|
||||
type DeviceStatus struct {
|
||||
On bool
|
||||
PowerW float64 // measured active power; 0 if device has no power meter
|
||||
}
|
||||
|
||||
// ConsumerState tracks the runtime state of a single consumer.
|
||||
type ConsumerState struct {
|
||||
Active bool
|
||||
ActivatedAt time.Time // when it was last turned on
|
||||
ManualOverride bool
|
||||
OverrideUntil time.Time
|
||||
LowPowerCycles int // consecutive cycles with power below minimum threshold
|
||||
}
|
||||
|
||||
// OverrideInfo is returned to callers that need to display or record override state.
|
||||
type OverrideInfo struct {
|
||||
Active bool
|
||||
Until time.Time
|
||||
}
|
||||
|
||||
// HysteresisState tracks the timing for hysteresis decisions.
|
||||
type HysteresisState struct {
|
||||
// How long has export been above the on-threshold continuously?
|
||||
ExportSinceAbove map[Consumer]time.Time
|
||||
// How long has import been above the off-threshold continuously?
|
||||
ImportSinceAbove time.Time
|
||||
}
|
||||
|
||||
// Engine is the EMS decision engine.
|
||||
// It is pure: given a state snapshot and timing info, it returns actions.
|
||||
// No network calls, no side effects — fully testable.
|
||||
type Engine struct {
|
||||
cfg *config.Config
|
||||
consumers map[Consumer]*ConsumerState
|
||||
hyst HysteresisState
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewEngine creates a new decision engine.
|
||||
func NewEngine(cfg *config.Config, logger *slog.Logger) *Engine {
|
||||
return &Engine{
|
||||
cfg: cfg,
|
||||
consumers: map[Consumer]*ConsumerState{
|
||||
ConsumerSGReady: {},
|
||||
ConsumerWW: {},
|
||||
ConsumerWallboxA: {},
|
||||
ConsumerWallboxB: {},
|
||||
},
|
||||
hyst: HysteresisState{
|
||||
ExportSinceAbove: make(map[Consumer]time.Time),
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Decide evaluates the current system state and returns a list of actions.
|
||||
// wwBoostC is the WW temperature boost in °C derived from the PV forecast
|
||||
// (0 = no forecast / forecast too low to warrant boosting).
|
||||
func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC float64) []Action {
|
||||
var actions []Action
|
||||
|
||||
soc := state.BatterySOC
|
||||
gridW := state.GridPowerW // positive = import, negative = export
|
||||
heatingPeriod := e.isHeatingPeriod(now)
|
||||
wwWindow := e.isWWWindow(now)
|
||||
allowed := e.allowedConsumers(soc)
|
||||
|
||||
e.logger.Debug("decision input",
|
||||
"grid_w", gridW,
|
||||
"soc", soc,
|
||||
"heating_period", heatingPeriod,
|
||||
"ww_window", wwWindow,
|
||||
"ww_boost_c", wwBoostC,
|
||||
"allowed", allowed,
|
||||
)
|
||||
|
||||
// --- SOC emergency brake ---
|
||||
actions = append(actions, e.socEmergencyBrake(soc, now)...)
|
||||
|
||||
// --- WW window shutdown ---
|
||||
// If WW is active but we're outside the allowed time window, reset immediately.
|
||||
if cs := e.consumers[ConsumerWW]; cs.Active && !wwWindow {
|
||||
e.logger.Info("WW window ended, resetting DHW temperature")
|
||||
cs.Active = false
|
||||
actions = append(actions, Action{
|
||||
Consumer: ConsumerWW,
|
||||
TurnOn: false,
|
||||
TargetTempC: e.cfg.Strategic.WWBaseC,
|
||||
Reason: "WW time window ended",
|
||||
})
|
||||
}
|
||||
|
||||
// --- Shutdown logic (reverse priority order) ---
|
||||
if gridW > e.cfg.Thresholds.ImportOffW {
|
||||
if e.hyst.ImportSinceAbove.IsZero() {
|
||||
e.hyst.ImportSinceAbove = now
|
||||
}
|
||||
importDuration := now.Sub(e.hyst.ImportSinceAbove)
|
||||
if importDuration >= e.cfg.Hysteresis.ImportOffDurationParsed() {
|
||||
if a := e.shutdownLastConsumer(now); a != nil {
|
||||
actions = append(actions, *a)
|
||||
e.hyst.ImportSinceAbove = time.Time{}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
e.hyst.ImportSinceAbove = time.Time{}
|
||||
}
|
||||
|
||||
// --- Compressor idle: release SG-Ready early if heat pump stopped ---
|
||||
if cs := e.consumers[ConsumerSGReady]; cs.Active {
|
||||
if state.CompressorPowerW < float64(e.cfg.Consumers.CompressorIdleW) {
|
||||
cs.LowPowerCycles++
|
||||
e.logger.Debug("SG-Ready: compressor idle cycle",
|
||||
"compressor_w", state.CompressorPowerW,
|
||||
"idle_cycles", cs.LowPowerCycles,
|
||||
)
|
||||
if cs.LowPowerCycles >= e.cfg.Consumers.IdleCycles {
|
||||
e.logger.Info("SG-Ready released early: compressor idle",
|
||||
"idle_cycles", cs.LowPowerCycles,
|
||||
"compressor_w", state.CompressorPowerW,
|
||||
)
|
||||
cs.Active = false
|
||||
cs.LowPowerCycles = 0
|
||||
actions = append(actions, Action{
|
||||
Consumer: ConsumerSGReady,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("compressor idle for %d cycles", e.cfg.Consumers.IdleCycles),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
cs.LowPowerCycles = 0
|
||||
}
|
||||
}
|
||||
|
||||
// --- Car not charging: release wallbox if Shelly PM shows no draw ---
|
||||
for _, wb := range []Consumer{ConsumerWallboxA, ConsumerWallboxB} {
|
||||
cs := e.consumers[wb]
|
||||
if !cs.Active {
|
||||
continue
|
||||
}
|
||||
if cs.LowPowerCycles >= e.cfg.Consumers.IdleCycles {
|
||||
e.logger.Info("wallbox released: car not charging",
|
||||
"consumer", wb,
|
||||
"low_power_cycles", cs.LowPowerCycles,
|
||||
)
|
||||
cs.Active = false
|
||||
cs.LowPowerCycles = 0
|
||||
actions = append(actions, Action{
|
||||
Consumer: wb,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("car not charging for %d cycles", e.cfg.Consumers.IdleCycles),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Turn-on logic (priority order) ---
|
||||
if gridW <= 0 {
|
||||
// P1: SG-Ready (heating period only)
|
||||
if heatingPeriod {
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
|
||||
// P2: WW boost (time window + forecast required)
|
||||
if wwWindow && wwBoostC > 0 {
|
||||
actions = append(actions, e.evaluateWWTurnOn(gridW, wwBoostC, allowed, now)...)
|
||||
}
|
||||
|
||||
// P3: Wallbox A (2kW, single-phase) — only if Wallbox B is not active.
|
||||
// Uses per-phase export check if available, otherwise falls back to total.
|
||||
// Re-reads Active state directly so a same-cycle activation of WallboxB blocks WallboxA.
|
||||
if !e.consumers[ConsumerWallboxB].Active {
|
||||
phaseGridW := gridW // fallback: total grid power
|
||||
if state.PhaseL1PowerW != 0 || state.PhaseL2PowerW != 0 || state.PhaseL3PowerW != 0 {
|
||||
phaseGridW = min3(state.PhaseL1PowerW, state.PhaseL2PowerW, state.PhaseL3PowerW)
|
||||
}
|
||||
threshold := e.cfg.Thresholds.WallboxAExportW
|
||||
if e.cfg.Thresholds.WallboxAPhaseExportW != 0 {
|
||||
threshold = e.cfg.Thresholds.WallboxAPhaseExportW
|
||||
}
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerWallboxA, phaseGridW, threshold,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
|
||||
// P4: Wallbox B (4kW, 3-phase) — only if Wallbox A is not active.
|
||||
// Re-reads Active state so a same-cycle activation of WallboxA blocks WallboxB.
|
||||
if !e.consumers[ConsumerWallboxA].Active {
|
||||
actions = append(actions, e.evaluateTurnOn(
|
||||
ConsumerWallboxB, gridW, e.cfg.Thresholds.WallboxBExportW,
|
||||
allowed, now,
|
||||
)...)
|
||||
}
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
// min3 returns the minimum of three float64 values.
|
||||
func min3(a, b, c float64) float64 {
|
||||
if b < a {
|
||||
a = b
|
||||
}
|
||||
if c < a {
|
||||
return c
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// allowedConsumers returns which consumers are allowed based on SOC.
|
||||
func (e *Engine) allowedConsumers(soc float64) map[Consumer]bool {
|
||||
allowed := make(map[Consumer]bool)
|
||||
|
||||
if soc >= e.cfg.SOC.AllConsumers {
|
||||
allowed[ConsumerSGReady] = true
|
||||
allowed[ConsumerWW] = true
|
||||
allowed[ConsumerWallboxA] = true
|
||||
allowed[ConsumerWallboxB] = true
|
||||
} else if soc >= e.cfg.SOC.SGReadyOnly {
|
||||
allowed[ConsumerSGReady] = true
|
||||
allowed[ConsumerWW] = true
|
||||
allowed[ConsumerWallboxA] = true
|
||||
} else if soc >= e.cfg.SOC.BlockAll {
|
||||
allowed[ConsumerSGReady] = true
|
||||
allowed[ConsumerWW] = true
|
||||
}
|
||||
// below BlockAll: nothing allowed
|
||||
|
||||
return allowed
|
||||
}
|
||||
|
||||
// evaluateTurnOn checks if a consumer should be turned on.
|
||||
func (e *Engine) evaluateTurnOn(
|
||||
consumer Consumer,
|
||||
gridW float64,
|
||||
threshold float64,
|
||||
allowed map[Consumer]bool,
|
||||
now time.Time,
|
||||
) []Action {
|
||||
cs := e.consumers[consumer]
|
||||
|
||||
// Already active — nothing to do
|
||||
if cs.Active {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Manually overridden to OFF — respect until timeout
|
||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||
delete(e.hyst.ExportSinceAbove, consumer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Not allowed by SOC
|
||||
if !allowed[consumer] {
|
||||
delete(e.hyst.ExportSinceAbove, consumer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if export exceeds the threshold
|
||||
// gridW is negative for export, threshold is negative (e.g. -1800)
|
||||
// export > 1800W means gridW < -1800
|
||||
if gridW > threshold {
|
||||
// Not enough export
|
||||
delete(e.hyst.ExportSinceAbove, consumer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Export is above threshold — track how long
|
||||
if _, ok := e.hyst.ExportSinceAbove[consumer]; !ok {
|
||||
e.hyst.ExportSinceAbove[consumer] = now
|
||||
}
|
||||
|
||||
exportDuration := now.Sub(e.hyst.ExportSinceAbove[consumer])
|
||||
if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() {
|
||||
// Not long enough yet
|
||||
return nil
|
||||
}
|
||||
|
||||
// All conditions met — turn on
|
||||
e.logger.Info("turning on consumer",
|
||||
"consumer", consumer,
|
||||
"grid_w", gridW,
|
||||
"threshold", threshold,
|
||||
"export_duration", exportDuration,
|
||||
)
|
||||
|
||||
cs.Active = true
|
||||
cs.ActivatedAt = now
|
||||
delete(e.hyst.ExportSinceAbove, consumer)
|
||||
|
||||
return []Action{{
|
||||
Consumer: consumer,
|
||||
TurnOn: true,
|
||||
Reason: fmt.Sprintf(
|
||||
"export %.0fW > %.0fW for %s",
|
||||
-gridW, -threshold, exportDuration,
|
||||
),
|
||||
}}
|
||||
}
|
||||
|
||||
// shutdownLastConsumer turns off the lowest-priority active consumer
|
||||
// that has exceeded its minimum runtime.
|
||||
func (e *Engine) shutdownLastConsumer(now time.Time) *Action {
|
||||
// Reverse priority: WallboxB → WallboxA → WW → SGReady
|
||||
order := []Consumer{ConsumerWallboxB, ConsumerWallboxA, ConsumerWW, ConsumerSGReady}
|
||||
|
||||
for _, c := range order {
|
||||
cs := e.consumers[c]
|
||||
if !cs.Active {
|
||||
continue
|
||||
}
|
||||
|
||||
// Manually overridden to ON — don't shut down until override expires
|
||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||
e.logger.Debug("skipping shutdown, consumer is manually overridden",
|
||||
"consumer", c,
|
||||
"override_until", cs.OverrideUntil.Format("15:04"),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
minRuntime := e.minRuntime(c)
|
||||
runtime := now.Sub(cs.ActivatedAt)
|
||||
if runtime < minRuntime {
|
||||
e.logger.Debug("skipping shutdown, min runtime not reached",
|
||||
"consumer", c,
|
||||
"runtime", runtime,
|
||||
"min_runtime", minRuntime,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
e.logger.Info("shutting down consumer",
|
||||
"consumer", c,
|
||||
"runtime", runtime,
|
||||
)
|
||||
|
||||
cs.Active = false
|
||||
a := &Action{
|
||||
Consumer: c,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("import detected, runtime %s", runtime),
|
||||
}
|
||||
if c == ConsumerWW {
|
||||
a.TargetTempC = e.cfg.Strategic.WWBaseC
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// socEmergencyBrake immediately shuts off consumers whose SOC threshold
|
||||
// is no longer met, ignoring minimum runtimes.
|
||||
func (e *Engine) socEmergencyBrake(soc float64, now time.Time) []Action {
|
||||
var actions []Action
|
||||
allowed := e.allowedConsumers(soc)
|
||||
|
||||
for c, cs := range e.consumers {
|
||||
if !cs.Active {
|
||||
continue
|
||||
}
|
||||
if allowed[c] {
|
||||
continue
|
||||
}
|
||||
|
||||
e.logger.Warn("SOC emergency brake",
|
||||
"consumer", c,
|
||||
"soc", soc,
|
||||
)
|
||||
|
||||
cs.Active = false
|
||||
a := Action{
|
||||
Consumer: c,
|
||||
TurnOn: false,
|
||||
Reason: fmt.Sprintf("SOC emergency brake: %.0f%%", soc),
|
||||
}
|
||||
if c == ConsumerWW {
|
||||
a.TargetTempC = e.cfg.Strategic.WWBaseC
|
||||
}
|
||||
actions = append(actions, a)
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
// isHeatingPeriod returns true if the current month is within the heating season.
|
||||
func (e *Engine) isHeatingPeriod(now time.Time) bool {
|
||||
month := int(now.Month())
|
||||
start := e.cfg.Season.HeatingStartMonth
|
||||
end := e.cfg.Season.HeatingEndMonth
|
||||
|
||||
// Handles wrap-around: e.g. October(10) to April(4)
|
||||
if start > end {
|
||||
return month >= start || month <= end
|
||||
}
|
||||
return month >= start && month <= end
|
||||
}
|
||||
|
||||
// minRuntime returns the minimum runtime for a consumer.
|
||||
func (e *Engine) minRuntime(c Consumer) time.Duration {
|
||||
switch c {
|
||||
case ConsumerSGReady:
|
||||
return e.cfg.Hysteresis.MinRuntimeSGReadyParsed()
|
||||
case ConsumerWallboxA, ConsumerWallboxB:
|
||||
return e.cfg.Hysteresis.MinRuntimeWallboxParsed()
|
||||
default:
|
||||
return 0 // WW has no minimum runtime
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateWWTurnOn checks whether WW boost should be activated.
|
||||
// Prerequisites (time window and forecast) are already verified by the caller.
|
||||
func (e *Engine) evaluateWWTurnOn(gridW, wwBoostC float64, allowed map[Consumer]bool, now time.Time) []Action {
|
||||
cs := e.consumers[ConsumerWW]
|
||||
if cs.Active {
|
||||
return nil
|
||||
}
|
||||
if cs.ManualOverride && now.Before(cs.OverrideUntil) {
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
return nil
|
||||
}
|
||||
if !allowed[ConsumerWW] {
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
return nil
|
||||
}
|
||||
if gridW > e.cfg.Thresholds.WWExportW {
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := e.hyst.ExportSinceAbove[ConsumerWW]; !ok {
|
||||
e.hyst.ExportSinceAbove[ConsumerWW] = now
|
||||
}
|
||||
exportDuration := now.Sub(e.hyst.ExportSinceAbove[ConsumerWW])
|
||||
if exportDuration < e.cfg.Hysteresis.ExportOnDurationParsed() {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetTemp := e.cfg.Strategic.WWBaseC + wwBoostC
|
||||
e.logger.Info("activating WW boost",
|
||||
"grid_w", gridW,
|
||||
"ww_boost_c", wwBoostC,
|
||||
"target_temp_c", targetTemp,
|
||||
"export_duration", exportDuration,
|
||||
)
|
||||
|
||||
cs.Active = true
|
||||
cs.ActivatedAt = now
|
||||
delete(e.hyst.ExportSinceAbove, ConsumerWW)
|
||||
|
||||
return []Action{{
|
||||
Consumer: ConsumerWW,
|
||||
TurnOn: true,
|
||||
TargetTempC: targetTemp,
|
||||
Reason: fmt.Sprintf("export %.0fW for %s, WW boost +%.0f°C", -gridW, exportDuration, wwBoostC),
|
||||
}}
|
||||
}
|
||||
|
||||
// isWWWindow returns true if the current time falls within the configured WW boost window.
|
||||
func (e *Engine) isWWWindow(now time.Time) bool {
|
||||
start, err1 := parseTimeOfDay(e.cfg.Strategic.WWWindowStart, now)
|
||||
end, err2 := parseTimeOfDay(e.cfg.Strategic.WWWindowEnd, now)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return now.After(start) && now.Before(end)
|
||||
}
|
||||
|
||||
// parseTimeOfDay parses "HH:MM" and returns a time.Time on the same day as ref.
|
||||
func parseTimeOfDay(s string, ref time.Time) (time.Time, error) {
|
||||
var h, m int
|
||||
if _, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid time-of-day %q: %w", s, err)
|
||||
}
|
||||
return time.Date(ref.Year(), ref.Month(), ref.Day(), h, m, 0, 0, ref.Location()), nil
|
||||
}
|
||||
|
||||
// ConsumerStates returns a snapshot of all consumer states (for metrics).
|
||||
func (e *Engine) ConsumerStates() map[Consumer]bool {
|
||||
states := make(map[Consumer]bool)
|
||||
for c, cs := range e.consumers {
|
||||
states[c] = cs.Active
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// RecoverState injects externally-read consumer states on startup.
|
||||
// Does not set override flags — startup state is treated as the EMS baseline.
|
||||
func (e *Engine) RecoverState(states map[Consumer]DeviceStatus) {
|
||||
for c, status := range states {
|
||||
if cs, ok := e.consumers[c]; ok {
|
||||
cs.Active = status.On
|
||||
cs.ManualOverride = false
|
||||
cs.OverrideUntil = time.Time{}
|
||||
cs.LowPowerCycles = 0
|
||||
// ActivatedAt left as zero: unknown start time means the consumer
|
||||
// is always considered to have exceeded its minimum runtime.
|
||||
cs.ActivatedAt = time.Time{}
|
||||
}
|
||||
}
|
||||
e.logger.Info("consumer state recovered from Shelly read-back",
|
||||
"sg_ready", states[ConsumerSGReady].On,
|
||||
"wallbox_a", states[ConsumerWallboxA].On,
|
||||
"wallbox_b", states[ConsumerWallboxB].On,
|
||||
)
|
||||
}
|
||||
|
||||
// SyncHardwareState compares live hardware states against the engine's internal state.
|
||||
// Discrepancies indicate an external change (manual override via Shelly app etc.).
|
||||
// On mismatch: engine state is updated to match hardware, and the consumer is locked
|
||||
// from EMS control for overrideTimeout.
|
||||
// On match: expired overrides are cleared, resuming normal EMS control.
|
||||
// Power readings (from PM-capable devices) update the low-power cycle counter for
|
||||
// car-not-charging detection.
|
||||
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration) {
|
||||
for c, status := range states {
|
||||
cs, ok := e.consumers[c]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if cs.Active != status.On {
|
||||
// External change detected
|
||||
e.logger.Info("manual override detected — external state change",
|
||||
"consumer", c,
|
||||
"engine_state", cs.Active,
|
||||
"hardware_state", status.On,
|
||||
"override_until", now.Add(overrideTimeout).Format("15:04"),
|
||||
)
|
||||
cs.Active = status.On
|
||||
cs.ManualOverride = true
|
||||
cs.OverrideUntil = now.Add(overrideTimeout)
|
||||
cs.LowPowerCycles = 0
|
||||
if !status.On {
|
||||
cs.ActivatedAt = time.Time{}
|
||||
} else {
|
||||
cs.ActivatedAt = now
|
||||
}
|
||||
} else if cs.ManualOverride && now.After(cs.OverrideUntil) {
|
||||
// Override expired and state matches — resume EMS control
|
||||
cs.ManualOverride = false
|
||||
cs.OverrideUntil = time.Time{}
|
||||
e.logger.Info("manual override expired, resuming EMS control", "consumer", c)
|
||||
}
|
||||
|
||||
// Track low-power cycles for car-not-charging detection (PM devices only).
|
||||
// Only meaningful when PowerW > 0 (i.e., device has a power meter and is on).
|
||||
if status.PowerW > 0 {
|
||||
if cs.Active && status.PowerW < float64(e.cfg.Consumers.WallboxMinChargeW) {
|
||||
cs.LowPowerCycles++
|
||||
e.logger.Debug("wallbox low power cycle",
|
||||
"consumer", c,
|
||||
"power_w", status.PowerW,
|
||||
"low_power_cycles", cs.LowPowerCycles,
|
||||
)
|
||||
} else {
|
||||
cs.LowPowerCycles = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides returns current override info for all consumers that are overridden.
|
||||
func (e *Engine) Overrides() map[Consumer]OverrideInfo {
|
||||
result := make(map[Consumer]OverrideInfo)
|
||||
for c, cs := range e.consumers {
|
||||
if cs.ManualOverride {
|
||||
result[c] = OverrideInfo{Active: true, Until: cs.OverrideUntil}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
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