Files
EMS/internal/engine/engine.go
Lutz Finsterle 0b51ce5240 Add ambient temperature gate to heating period detection
Adds heating_min_ambient_c (default 15°C) to season config. When outdoor
temperature is at or above this threshold, SG-Ready is suppressed even if
the calendar month is within the heating season. Prevents unnecessary heat
pump boost activation on warm spring/autumn days.

Logic: heating active = in_heating_month AND ambient < threshold
Zero value (unset) disables the temperature gate (calendar-only, old behaviour).

New test: TestHeatingPeriodAmbientSuppression covers warm-day suppression,
cold-day pass-through, and summer month independence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 11:13:05 +02:00

712 lines
20 KiB
Go

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, state.AmbientTempC)
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)...)
// --- Override hard stop: cancel override on excessive import ---
actions = append(actions, e.overrideHardStop(gridW)...)
// --- 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 and manual overrides.
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,
"was_override", cs.ManualOverride,
)
cs.Active = false
cs.ManualOverride = false // EMS takes back full control after emergency
cs.OverrideUntil = time.Time{}
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
}
// overrideHardStop cancels active overrides when grid import exceeds the
// configured hard-stop threshold. Called before normal shutdown logic so
// there is no hysteresis delay — protection is immediate.
func (e *Engine) overrideHardStop(gridW float64) []Action {
limit := e.cfg.EMS.OverrideMaxImportW
if limit <= 0 || gridW <= limit {
return nil
}
var actions []Action
for c, cs := range e.consumers {
if !cs.Active || !cs.ManualOverride {
continue
}
e.logger.Warn("override hard stop: import exceeds limit",
"consumer", c,
"grid_w", gridW,
"limit_w", limit,
)
cs.Active = false
cs.ManualOverride = false
cs.OverrideUntil = time.Time{}
a := Action{
Consumer: c,
TurnOn: false,
Reason: fmt.Sprintf("override cancelled: import %.0fW > limit %.0fW", gridW, limit),
}
if c == ConsumerWW {
a.TargetTempC = e.cfg.Strategic.WWBaseC
}
actions = append(actions, a)
}
return actions
}
// ApplyOverride directly sets a consumer's state and override lockout.
// Called from the web UI override handler so the engine state is consistent
// immediately, without waiting for the next SyncHardwareState cycle.
func (e *Engine) ApplyOverride(consumer Consumer, on bool, duration time.Duration) {
cs, ok := e.consumers[consumer]
if !ok {
return
}
now := time.Now()
cs.Active = on
cs.ManualOverride = true
cs.OverrideUntil = now.Add(duration)
cs.LowPowerCycles = 0
if on {
cs.ActivatedAt = now
} else {
cs.ActivatedAt = time.Time{}
}
e.logger.Info("manual override applied",
"consumer", consumer,
"on", on,
"duration", duration,
"until", cs.OverrideUntil.Format("15:04"),
)
}
// isHeatingPeriod returns true if heating is appropriate given the current month
// and outdoor temperature. If HeatingMinAmbientC is configured (> 0), ambient
// temperatures above that threshold suppress SG-Ready even within the heating months.
func (e *Engine) isHeatingPeriod(now time.Time, ambientC float64) 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)
var inMonth bool
if start > end {
inMonth = month >= start || month <= end
} else {
inMonth = month >= start && month <= end
}
if !inMonth {
return false
}
// Temperature override: warm day within heating months → not a heating day
threshold := e.cfg.Season.HeatingMinAmbientC
if threshold > 0 && ambientC >= threshold {
e.logger.Debug("heating period suppressed by ambient temperature",
"ambient_c", ambientC,
"threshold_c", threshold,
)
return false
}
return true
}
// 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
}