1047 lines
33 KiB
Go
1047 lines
33 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
|
||
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
|
||
}
|