From fc535d395c8df0d7ead4fb116cff59c950c9748f Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Mon, 6 Apr 2026 19:34:18 +0200 Subject: [PATCH] Add per-consumer accepted import tolerance for wallboxes With a 4.6kW inverter and 4kW WallboxB + ~450W house load, any PV dip causes brief grid import and would trigger shutdown/restart cycling. New config: wallbox_a_accepted_import_w / wallbox_b_accepted_import_w WallboxB set to 600W: engine skips shutdown if import <= 600W, keeping the car charging through short cloud shadows. WallboxA disabled (2kW leaves sufficient inverter headroom without needing tolerance). Logic: shutdownLastConsumer checks per-consumer tolerance before applying min-runtime and initiating turn-off. Co-Authored-By: Claude Sonnet 4.6 --- configs/ems-config.yaml | 2 ++ internal/config/config.go | 8 +++++--- internal/engine/engine.go | 23 +++++++++++++++++++++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/configs/ems-config.yaml b/configs/ems-config.yaml index 524e461..f207c7a 100644 --- a/configs/ems-config.yaml +++ b/configs/ems-config.yaml @@ -81,6 +81,8 @@ consumers: compressor_idle_w: 500 # heat pump compressor below this = idle (W); running starts at ~2700W, so 500W cleanly separates idle (0W) from running wallbox_min_charge_w: 50 # wallbox below this = car not charging (W) idle_cycles: 3 # consecutive idle cycles before early release (~6 min at 2-min poll) + wallbox_a_accepted_import_w: 0 # tolerate this much grid import while WallboxA is running (0 = disabled; WallboxA is 2kW, inverter headroom is sufficient) + wallbox_b_accepted_import_w: 600 # tolerate up to 600W import while WallboxB runs (4kW wallbox + 450W house > 4.6kW inverter max on PV dips) # Season detection season: diff --git a/internal/config/config.go b/internal/config/config.go index ff8f0f6..ac1e462 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -109,9 +109,11 @@ type PowerThresholds struct { // ConsumersConfig holds per-consumer behavior thresholds. type ConsumersConfig struct { - CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W) - WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W) - IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release + CompressorIdleW int `yaml:"compressor_idle_w"` // below this = heat pump compressor idle (W) + WallboxMinChargeW int `yaml:"wallbox_min_charge_w"` // below this = car not charging (W) + IdleCycles int `yaml:"idle_cycles"` // consecutive idle cycles before early release + WallboxAAcceptedImportW float64 `yaml:"wallbox_a_accepted_import_w"` // tolerate this much grid import while WallboxA is running (0 = disabled) + WallboxBAcceptedImportW float64 `yaml:"wallbox_b_accepted_import_w"` // tolerate this much grid import while WallboxB is running (0 = disabled) } // StrategicConfig holds PV forecast based strategic settings. diff --git a/internal/engine/engine.go b/internal/engine/engine.go index ff60122..9dd6f98 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -146,7 +146,7 @@ func (e *Engine) Decide(state collector.SystemState, now time.Time, wwBoostC flo } importDuration := now.Sub(e.hyst.ImportSinceAbove) if importDuration >= e.cfg.Hysteresis.ImportOffDurationParsed() { - if a := e.shutdownLastConsumer(now); a != nil { + if a := e.shutdownLastConsumer(now, gridW); a != nil { actions = append(actions, *a) e.hyst.ImportSinceAbove = time.Time{} } @@ -352,7 +352,8 @@ func (e *Engine) evaluateTurnOn( // shutdownLastConsumer turns off the lowest-priority active consumer // that has exceeded its minimum runtime. -func (e *Engine) shutdownLastConsumer(now time.Time) *Action { +// 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} @@ -371,6 +372,24 @@ func (e *Engine) shutdownLastConsumer(now time.Time) *Action { 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, + "grid_w", gridW, + "accepted_import_w", acceptedImportW, + ) + continue + } + minRuntime := e.minRuntime(c) runtime := now.Sub(cs.ActivatedAt) if runtime < minRuntime {