diff --git a/core/loadpoint.go b/core/loadpoint.go index cdee2532b..deaddf4cf 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -63,6 +63,7 @@ const ( boostDisabled = 0 boostStart = 1 boostContinue = 2 + boostHold = 3 // soc limit reached: stop draining but keep vehicle priority over recharging ) // elapsed is the time an expired timer will be set to @@ -1523,7 +1524,7 @@ func (lp *Loadpoint) publishTimer(name string, delay time.Duration, action strin // boostPower returns the additional power that the loadpoint should draw from the battery func (lp *Loadpoint) boostPower(batteryBoostPower float64) float64 { boost := lp.GetBatteryBoost() - if boost == boostDisabled { + if boost == boostDisabled || boost == boostHold { return 0 } @@ -2042,15 +2043,15 @@ func (lp *Loadpoint) phaseSwitchCompleted() bool { // Update is the main control function. It reevaluates meters and charger state func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64, dim *bool) { - // auto-disable battery boost when SOC drops below limit - if lp.GetBatteryBoost() != boostDisabled { + // hold battery boost when SOC drops below the limit: stop draining the battery, but + // keep the vehicle prioritised over recharging it (via sitePower priorityAdjustment) + // until the vehicle disconnects. This holds the battery at the configured level + // instead of the naive on/off which lets the battery recharge and oscillate (#30558). + if boost := lp.GetBatteryBoost(); boost != boostDisabled && boost != boostHold { if limit := lp.GetBatteryBoostLimit(); limit < 100 { if batterySoc := lp.site.GetBatterySoc(); batterySoc < float64(limit) { - lp.log.DEBUG.Printf("battery boost disabled: soc below limit (%.0f%% < %d%%)", batterySoc, limit) - - if err := lp.SetBatteryBoost(false); err != nil { - lp.log.ERROR.Printf("set battery boost: %v", err) - } + lp.log.DEBUG.Printf("battery boost hold: soc below limit (%.0f%% < %d%%)", batterySoc, limit) + lp.setBatteryBoost(boostHold) } } } diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 642a52421..22d82b4a9 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -863,3 +863,22 @@ func TestWelcomeChargeAppliedOnlyOnce(t *testing.T) { welcomeCharge, _ = lp.updateChargerStatus() assert.False(t, welcomeCharge) } + +// TestBatteryBoostHold verifies that in the hold state (soc limit reached) battery +// boost no longer draws power from the battery, while still counting as active so +// the site keeps prioritising the vehicle over recharging the battery (#30558). +func TestBatteryBoostHold(t *testing.T) { + lp := &Loadpoint{log: util.NewLogger("foo")} + + // disabled draws nothing + lp.batteryBoost = boostDisabled + assert.Equal(t, 0.0, lp.boostPower(2000), "disabled") + + // hold draws nothing (stops draining the battery)... + lp.batteryBoost = boostHold + assert.Equal(t, 0.0, lp.boostPower(2000), "hold") + + // ...but is still an active boost state (GetBatteryBoost != boostDisabled), + // which is what keeps the sitePower priority adjustment applied to the loadpoint + assert.NotEqual(t, boostDisabled, lp.GetBatteryBoost(), "hold is active") +}