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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 19:34:18 +02:00
parent 009f8b05f3
commit fc535d395c
3 changed files with 28 additions and 5 deletions

View File

@@ -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:

View File

@@ -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.

View File

@@ -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 {