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)...) // --- 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 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 }