Files
EMS/internal/engine/engine.go

1047 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
ProactiveCharging bool // true if activated by proactive car-charging logic (bypasses import shutdown)
ProbeStartGridW float64 // grid power snapshot at WallboxB activation (for no-car detection)
NoCarRetryUntil time.Time // don't retry WallboxB proactive charging until this time
}
// 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: WW temperature boost in °C from PV forecast (0 = no boost warranted)
// - forecastKWh: today's forecast total in kWh (0 if forecasting disabled)
// - sunsetTime: estimated time of sunset (used for EOD soft stop); zero = disabled
func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC, forecastKWh float64, sunsetTime time.Time) []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,
"pv_w", state.PVProductionW,
"forecast_kwh", forecastKWh,
"heating_period", heatingPeriod,
"ww_window", wwWindow,
"ww_boost_c", wwBoostC,
)
// --- 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",
})
}
// --- EOD soft stop: after configured time, stop car charging if remaining PV insufficient ---
actions = append(actions, e.checkEODSoftStop(state, now, sunsetTime)...)
// --- WallboxB no-car probe: check grid delta after activation ---
actions = append(actions, e.checkWallboxBProbe(state, now)...)
// --- Shutdown logic (reverse priority order, import hysteresis) ---
// Proactive wallboxes are skipped — EOD/probe/idle-cycles handle their stops.
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, gridW); 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 ---
// A startup grace period is applied after activation: the heat pump compressor
// takes several minutes to start after receiving the SG-Ready signal, so idle
// detection is suppressed until the grace period has elapsed.
if cs := e.consumers[ConsumerSGReady]; cs.Active {
startupMin := e.cfg.Consumers.SGReadyStartupMin
inGrace := startupMin > 0 && !cs.ActivatedAt.IsZero() && now.Sub(cs.ActivatedAt) < time.Duration(startupMin)*time.Minute
if inGrace {
e.logger.Debug("SG-Ready: startup grace period, skipping idle check",
"activated_at", cs.ActivatedAt.Format("15:04"),
"grace_min", startupMin,
"elapsed_min", int(now.Sub(cs.ActivatedAt).Minutes()),
)
} else 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,
"proactive", cs.ProactiveCharging,
)
cs.Active = false
cs.ProactiveCharging = false
cs.LowPowerCycles = 0
// For WallboxA: set retry timeout so proactive logic doesn't immediately re-activate
if wb == ConsumerWallboxA && e.cfg.CarCharging.NoCarRetryMin > 0 {
e.consumers[ConsumerWallboxA].NoCarRetryUntil = now.Add(
time.Duration(e.cfg.CarCharging.NoCarRetryMin) * time.Minute,
)
}
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) ---
// P1: Proactive car charging — forecast-driven, no export threshold required.
// Mutual exclusion (A vs B) is enforced inside evaluateCarCharging.
actions = append(actions, e.evaluateCarCharging(state, forecastKWh, now)...)
// P2: WW boost — time window + forecast, no export threshold, no car charging.
// Only runs if no wallbox is active (car charging takes priority).
if wwWindow && wwBoostC > 0 &&
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active {
actions = append(actions, e.evaluateWWTurnOn(state, wwBoostC, allowed, now)...)
}
// P3: SG-Ready — reactive, export-threshold based, heating period only.
// Only runs if no wallbox is active (car charging takes priority).
if heatingPeriod &&
!e.consumers[ConsumerWallboxA].Active && !e.consumers[ConsumerWallboxB].Active {
actions = append(actions, e.evaluateTurnOn(
ConsumerSGReady, gridW, e.cfg.Thresholds.SGReadyExportW,
allowed, now,
)...)
}
return actions
}
// evaluateCarCharging implements proactive forecast-driven car charging.
// Tries WallboxA first (has PM for car detection), then WallboxB (grid-delta probe).
// Does not require export surplus — just sufficient PV production and a good forecast.
func (e *Engine) evaluateCarCharging(state collector.SystemState, forecastKWh float64, now time.Time) []Action {
cc := e.cfg.CarCharging
// Skip if proactive charging is not configured (thresholds must be set)
if cc.PVThresholdAW == 0 && cc.PVThresholdBW == 0 {
return nil
}
// Determine activation gate: either forecast is good, or SOC is already high
// (battery full → activate regardless of forecast; any PV surplus should charge the car).
socHighBypass := float64(cc.SOCHighBypassPct)
if socHighBypass == 0 {
socHighBypass = 90 // default: bypass forecast check when battery ≥ 90%
}
forecastOK := forecastKWh > 0 && forecastKWh >= float64(e.cfg.Strategic.ForecastMidKWh)
socHigh := state.BatterySOC >= socHighBypass
if !forecastOK && !socHigh {
e.logger.Debug("proactive car charging: skipped",
"forecast_kwh", forecastKWh,
"forecast_threshold", e.cfg.Strategic.ForecastMidKWh,
"soc", state.BatterySOC,
"soc_bypass_pct", socHighBypass,
)
return nil
}
// SOC must be above the minimum for proactive charging
if state.BatterySOC < float64(cc.MinSOC) {
return nil
}
csA := e.consumers[ConsumerWallboxA]
csB := e.consumers[ConsumerWallboxB]
// --- Try WallboxA (has PM, preferred) ---
if !csA.Active && !csB.Active {
// Respect no-car retry timeout (set after idle-cycles detection)
if !csA.NoCarRetryUntil.IsZero() && now.Before(csA.NoCarRetryUntil) {
e.logger.Debug("WallboxA proactive: skipping, in no-car retry window",
"retry_until", csA.NoCarRetryUntil.Format("15:04"),
)
// Fall through to WallboxB below
} else if state.PVProductionW >= cc.PVThresholdAW {
// Respect manual override
if csA.ManualOverride && now.Before(csA.OverrideUntil) {
return nil
}
e.logger.Info("proactive: activating WallboxA",
"pv_w", state.PVProductionW,
"threshold_w", cc.PVThresholdAW,
"soc", state.BatterySOC,
"forecast_kwh", forecastKWh,
)
csA.Active = true
csA.ActivatedAt = now
csA.ProactiveCharging = true
csA.LowPowerCycles = 0
delete(e.hyst.ExportSinceAbove, ConsumerWallboxA)
trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh)
if socHigh && !forecastOK {
trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass)
}
return []Action{{
Consumer: ConsumerWallboxA,
TurnOn: true,
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdAW, trigger),
}}
}
}
// --- Try WallboxB (no PM, uses grid-delta probe) ---
// Only if WallboxA is not active and B is not already running.
if !csA.Active && !csB.Active {
if csB.ManualOverride && now.Before(csB.OverrideUntil) {
return nil
}
if !csB.NoCarRetryUntil.IsZero() && now.Before(csB.NoCarRetryUntil) {
e.logger.Debug("WallboxB proactive: skipping, in no-car retry window",
"retry_until", csB.NoCarRetryUntil.Format("15:04"),
)
return nil
}
if state.PVProductionW >= cc.PVThresholdBW {
e.logger.Info("proactive: activating WallboxB (grid-delta probe)",
"pv_w", state.PVProductionW,
"threshold_w", cc.PVThresholdBW,
"soc", state.BatterySOC,
"forecast_kwh", forecastKWh,
)
csB.Active = true
csB.ActivatedAt = now
csB.ProactiveCharging = true
csB.ProbeStartGridW = state.GridPowerW
csB.LowPowerCycles = 0
delete(e.hyst.ExportSinceAbove, ConsumerWallboxB)
trigger := fmt.Sprintf("forecast %.1fkWh", forecastKWh)
if socHigh && !forecastOK {
trigger = fmt.Sprintf("SOC %.0f%% ≥ %.0f%% (bypass)", state.BatterySOC, socHighBypass)
}
return []Action{{
Consumer: ConsumerWallboxB,
TurnOn: true,
Reason: fmt.Sprintf("proactive: PV %.0fW ≥ %.0fW, %s", state.PVProductionW, cc.PVThresholdBW, trigger),
}}
}
}
return nil
}
// checkWallboxBProbe checks whether a car is actually charging on WallboxB after activation.
// WallboxB has no PM, so we use grid-delta: if the grid power hasn't shifted by at least
// GridDeltaThreshW within the probe window, no car is connected → deactivate and set retry.
func (e *Engine) checkWallboxBProbe(state collector.SystemState, now time.Time) []Action {
cs := e.consumers[ConsumerWallboxB]
if !cs.Active || !cs.ProactiveCharging || cs.ActivatedAt.IsZero() {
return nil
}
cc := e.cfg.CarCharging
probeDuration := e.cfg.Hysteresis.MinRuntimeWallboxParsed() / 2 // half the min runtime
if now.Sub(cs.ActivatedAt) < probeDuration {
return nil // probe window not reached yet
}
// Already probed if ProbeStartGridW is zero after first probe (reset after detection)
if cs.ProbeStartGridW == 0 {
return nil // already concluded
}
gridDelta := cs.ProbeStartGridW - state.GridPowerW // negative = more import = car charging
// A 4kW wallbox causes a grid shift of ~4000W (or large export reduction).
// Positive gridDelta means we're importing more than at probe start (expected with car charging).
// We check: grid increased by at least threshold (car drawing power).
if gridDelta >= cc.GridDeltaThreshW || -gridDelta >= cc.GridDeltaThreshW {
// Either significantly more import or less export = car detected
e.logger.Info("WallboxB probe: car detected via grid delta",
"probe_start_w", cs.ProbeStartGridW,
"current_w", state.GridPowerW,
"delta_w", gridDelta,
)
cs.ProbeStartGridW = 0 // mark probe as concluded
return nil
}
// No meaningful grid shift → no car connected
e.logger.Info("WallboxB probe: no car detected, deactivating",
"probe_start_w", cs.ProbeStartGridW,
"current_w", state.GridPowerW,
"delta_w", gridDelta,
"threshold_w", cc.GridDeltaThreshW,
)
cs.Active = false
cs.ProactiveCharging = false
cs.ProbeStartGridW = 0
if cc.NoCarRetryMin > 0 {
cs.NoCarRetryUntil = now.Add(time.Duration(cc.NoCarRetryMin) * time.Minute)
}
return []Action{{
Consumer: ConsumerWallboxB,
TurnOn: false,
Reason: fmt.Sprintf("no-car probe: grid delta %.0fW < %.0fW", gridDelta, cc.GridDeltaThreshW),
}}
}
// checkEODSoftStop implements the end-of-day battery protection.
// After CarCharging.EODTime, if the remaining estimated PV production is insufficient
// to fill the battery to the EOD target by sunset, proactive car charging is stopped.
func (e *Engine) checkEODSoftStop(state collector.SystemState, now time.Time, sunsetTime time.Time) []Action {
cc := e.cfg.CarCharging
if cc.EODTime == "" || cc.EODSOCTarget == 0 || e.cfg.Battery.CapacityKWh == 0 {
return nil
}
eodTime := cc.EODTimeParsed(now)
if now.Before(eodTime) {
return nil // too early for EOD check
}
// Determine sunset reference
if sunsetTime.IsZero() || sunsetTime.Before(now) {
return nil // no valid sunset time, skip
}
hoursToSunset := sunsetTime.Sub(now).Hours()
if hoursToSunset <= 0 {
hoursToSunset = 0
}
// Estimate remaining PV production (current watt × hours to sunset)
remainingPVkWh := (state.PVProductionW / 1000.0) * hoursToSunset
// Battery energy needed to reach target SOC
socDeficitKWh := (float64(cc.EODSOCTarget)/100.0 - state.BatterySOC/100.0) * e.cfg.Battery.CapacityKWh
if socDeficitKWh <= 0 {
return nil // already at or above target SOC
}
// House base load consumption during remaining time
houseKWh := (e.cfg.Forecast.BaseLoadW / 1000.0) * hoursToSunset
// If remaining PV can't cover the battery deficit plus house load, stop charging
if remainingPVkWh >= socDeficitKWh+houseKWh {
return nil // enough PV remaining
}
e.logger.Info("EOD soft stop: remaining PV insufficient to reach target SOC",
"remaining_pv_kwh", fmt.Sprintf("%.2f", remainingPVkWh),
"soc_deficit_kwh", fmt.Sprintf("%.2f", socDeficitKWh),
"house_kwh", fmt.Sprintf("%.2f", houseKWh),
"hours_to_sunset", fmt.Sprintf("%.1f", hoursToSunset),
"current_soc", state.BatterySOC,
"target_soc", cc.EODSOCTarget,
)
var stopActions []Action
for _, wb := range []Consumer{ConsumerWallboxB, ConsumerWallboxA} {
cs := e.consumers[wb]
if !cs.Active || !cs.ProactiveCharging {
continue
}
cs.Active = false
cs.ProactiveCharging = false
stopActions = append(stopActions, Action{
Consumer: wb,
TurnOn: false,
Reason: fmt.Sprintf("EOD soft stop: %.1fkWh PV remaining < %.1fkWh needed", remainingPVkWh, socDeficitKWh+houseKWh),
})
}
return stopActions
}
// 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.String(),
"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.
// gridW is the current grid power (positive = import) used to check per-consumer import tolerance.
func (e *Engine) shutdownLastConsumer(now time.Time, gridW float64) *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.String(),
"override_until", cs.OverrideUntil.Format("15:04"),
)
continue
}
// Proactive wallboxes are not shut down by import hysteresis.
// Their stops are handled by EOD soft stop, no-car probe, and idle cycles.
if cs.ProactiveCharging && (c == ConsumerWallboxA || c == ConsumerWallboxB) {
e.logger.Debug("skipping shutdown, proactive car charging active", "consumer", c.String())
continue
}
// Per-consumer accepted import tolerance: if the current import is within
// the configured tolerance for this wallbox, skip shutdown.
var acceptedImportW float64
switch c {
case ConsumerWallboxA:
acceptedImportW = e.cfg.Consumers.WallboxAAcceptedImportW
case ConsumerWallboxB:
acceptedImportW = e.cfg.Consumers.WallboxBAcceptedImportW
}
if acceptedImportW > 0 && gridW <= acceptedImportW {
e.logger.Debug("skipping shutdown, import within accepted tolerance",
"consumer", c.String(),
"grid_w", gridW,
"accepted_import_w", acceptedImportW,
)
continue
}
minRuntime := e.minRuntime(c)
runtime := now.Sub(cs.ActivatedAt)
if runtime < minRuntime {
e.logger.Debug("skipping shutdown, min runtime not reached",
"consumer", c.String(),
"runtime", runtime,
"min_runtime", minRuntime,
)
continue
}
e.logger.Info("shutting down consumer",
"consumer", c.String(),
"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.
// Proactive car-charging wallboxes use the SOCFloor threshold instead of the
// standard SOC gates, allowing charging down to a lower limit during solar hours.
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
}
// Intentional wallbox charging (proactive or trip mode): only emergency-brake at SOCFloor.
// Trip mode uses ManualOverride=true, proactive charging uses ProactiveCharging=true —
// both deserve soc_floor protection instead of the standard block_all gate.
isIntentionalCharging := (cs.ProactiveCharging || cs.ManualOverride) &&
(c == ConsumerWallboxA || c == ConsumerWallboxB)
if isIntentionalCharging {
floor := float64(e.cfg.CarCharging.SOCFloor)
if floor > 0 && soc >= floor {
continue // still above floor, keep charging
}
}
if allowed[c] {
continue
}
e.logger.Warn("SOC emergency brake",
"consumer", c.String(),
"soc", soc,
"was_override", cs.ManualOverride,
)
cs.Active = false
cs.ManualOverride = false // EMS takes back full control after emergency
cs.OverrideUntil = time.Time{}
cs.ProactiveCharging = 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
}
// 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.String(),
"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.String(),
"on", on,
"duration", duration,
"until", cs.OverrideUntil.Format("15:04"),
)
}
// RollbackAction reverts the engine's internal state for an action that the actuator
// failed to execute. Without this, the engine believes the switch happened, diverges
// from hardware, and SyncHardwareState will misinterpret the next read-back as a
// manual override and apply a 1-hour lockout.
//
// After rollback the engine state matches hardware again, so the next cycle's
// SyncHardwareState sees no mismatch and the action is simply retried.
func (e *Engine) RollbackAction(action Action) {
cs, ok := e.consumers[action.Consumer]
if !ok {
return
}
e.logger.Warn("rolling back engine state after failed action",
"consumer", action.Consumer,
"turn_on", action.TurnOn,
)
cs.Active = !action.TurnOn
if action.TurnOn {
// Turn-on failed: undo activation side-effects
cs.ActivatedAt = time.Time{}
cs.ProactiveCharging = false
cs.ProbeStartGridW = 0
cs.LowPowerCycles = 0
}
// Turn-off failed: just restore Active=true. ActivatedAt is preserved
// (shutdown code doesn't reset it), so min-runtime stays correct.
}
// 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, forecast > 0, no car charging) are already verified by the caller.
// No export threshold is required — the heat pump compressor load is covered by PV.
// Dynamic setpoint: current tank top + hysteresis + boost delta, capped at WWMaxSetpointC.
func (e *Engine) evaluateWWTurnOn(state collector.SystemState, 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) {
return nil
}
if !allowed[ConsumerWW] {
return nil
}
// Dynamic setpoint: set high enough above current temp to trigger heating immediately.
// Viessmann switchOn threshold = setpoint - hysteresis.
// Target = current_top + hysteresis + boost_delta, capped at max.
hysteresis := e.cfg.Strategic.WWHysteresisC
if hysteresis == 0 {
hysteresis = 5 // safe default
}
maxSetpoint := e.cfg.Strategic.WWMaxSetpointC
if maxSetpoint == 0 {
maxSetpoint = 60 // safe default
}
targetTemp := state.WWTopTempC + hysteresis + wwBoostC
if targetTemp > maxSetpoint {
targetTemp = maxSetpoint
}
e.logger.Info("activating WW boost",
"ww_top_c", state.WWTopTempC,
"ww_boost_c", wwBoostC,
"target_temp_c", targetTemp,
)
cs.Active = true
cs.ActivatedAt = now
return []Action{{
Consumer: ConsumerWW,
TurnOn: true,
TargetTempC: targetTemp,
Reason: fmt.Sprintf("WW boost +%.0f°C → setpoint %.0f°C (tank %.0f°C)", wwBoostC, targetTemp, state.WWTopTempC),
}}
}
// 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.
// When monitorOnly is true, hardware mismatches silently update engine state without
// logging or applying override lockouts — the EMS cannot act anyway, so treating
// every missed switch as a "manual override" would produce spurious log spam.
func (e *Engine) SyncHardwareState(states map[Consumer]DeviceStatus, now time.Time, overrideTimeout time.Duration, monitorOnly bool) {
for c, status := range states {
cs, ok := e.consumers[c]
if !ok {
continue
}
if cs.Active != status.On {
if monitorOnly {
// In monitor-only mode the EMS cannot execute switch actions, so a
// mismatch just means a pending action couldn't be carried out.
// Silently realign engine state to hardware without locking.
cs.Active = status.On
if !status.On {
cs.ActivatedAt = time.Time{}
} else {
cs.ActivatedAt = now
}
} else {
// External change detected — log and apply override lockout
e.logger.Info("manual override detected — external state change",
"consumer", c.String(),
"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.String())
}
// 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.String(),
"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
}