From 9ad5120c988d5c5745d6cde05862aef7ada612af Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 13 Jul 2026 16:11:49 +0200 Subject: [PATCH] Loadpoint: release priority PV reservation when car reached charge goal (#31759) --- core/loadpoint.go | 27 +++++++++++++++++++-------- core/loadpoint/api.go | 2 ++ core/loadpoint/mock.go | 14 ++++++++++++++ core/site.go | 4 ++-- core/site_prioritize_test.go | 15 +++++++++++++-- 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index d874701e6..678401b2a 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1031,19 +1031,30 @@ func (lp *Loadpoint) charging() bool { return lp.GetStatus() == api.StatusC } -// pvChargeStarting reports a PV loadpoint claiming surplus but not yet drawing it -// (enable timer running, or enabled but not yet charging). See #31194. -func (lp *Loadpoint) pvChargeStarting() bool { - if lp.GetMode() != api.ModePV || !lp.connected() { +// PvChargeStarting reports a PV loadpoint that claimed surplus via a running +// enable timer but is not yet drawing it and has not reached its goal. See #31194, #31684. +func (lp *Loadpoint) PvChargeStarting() bool { + if lp.GetMode() != api.ModePV || !lp.connected() || lp.chargeGoalReached() { return false } lp.RLock() - enabled, timer := lp.enabled, lp.pvTimer - lp.RUnlock() + defer lp.RUnlock() - // enable timer running (not yet enabled), or enabled but vehicle not yet charging - return (!enabled && !timer.IsZero()) || (enabled && !lp.charging()) + // enable timer running (not yet enabled) + return !lp.enabled && !lp.pvTimer.IsZero() +} + +// chargeGoalReached reports whether the loadpoint will not draw more: enabled +// but not charging, an energy limit reached, or soc at/above the limit (#31684). +func (lp *Loadpoint) chargeGoalReached() bool { + // enabled but drawing nothing: it won't ramp up + if lp.IsEnabled() && !lp.charging() { + return true + } + + soc := lp.GetSoc() + return lp.LimitEnergyReached() || (soc > 0 && soc >= float64(lp.EffectiveLimitSoc())) } // setStatus updates the internal charging state according to EV diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 6eea8c6a9..ef4b5b05a 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -112,6 +112,8 @@ type API interface { EffectiveMinPower() float64 // EffectiveMaxPower returns the max charging power taking active phases into account EffectiveMaxPower() float64 + // PvChargeStarting reports a PV loadpoint claiming surplus but not yet drawing it + PvChargeStarting() bool // EffectivePlanStrategy returns the effective plan strategy EffectivePlanStrategy() api.PlanStrategy // PublishEffectiveValues publishes effective values for currently attached vehicle diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index c15927f74..c156ad5a2 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -767,6 +767,20 @@ func (mr *MockAPIMockRecorder) PublishEffectiveValues() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishEffectiveValues", reflect.TypeOf((*MockAPI)(nil).PublishEffectiveValues)) } +// PvChargeStarting mocks base method. +func (m *MockAPI) PvChargeStarting() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PvChargeStarting") + ret0, _ := ret[0].(bool) + return ret0 +} + +// PvChargeStarting indicates an expected call of PvChargeStarting. +func (mr *MockAPIMockRecorder) PvChargeStarting() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PvChargeStarting", reflect.TypeOf((*MockAPI)(nil).PvChargeStarting)) +} + // SetBatteryBoost mocks base method. func (m *MockAPI) SetBatteryBoost(enable bool) error { m.ctrl.T.Helper() diff --git a/core/site.go b/core/site.go index 415f97404..9b58ca0b2 100644 --- a/core/site.go +++ b/core/site.go @@ -1022,10 +1022,10 @@ func (site *Site) reservedPVPower(lp updater) float64 { var reserved float64 for _, other := range site.loadpoints { - if any(other) == any(lp) { + if other == lp { continue } - if other.EffectivePriority() > prio && other.pvChargeStarting() { + if other.EffectivePriority() > prio && other.PvChargeStarting() { reserved += other.EffectiveMaxPower() } } diff --git a/core/site_prioritize_test.go b/core/site_prioritize_test.go index f4946aa46..aeaae88b8 100644 --- a/core/site_prioritize_test.go +++ b/core/site_prioritize_test.go @@ -28,21 +28,26 @@ func newPVLoadpoint(prio int, mode api.ChargeMode, status api.ChargeStatus, enab func TestPvChargeStarting(t *testing.T) { now := clock.NewMock().Now() + // enable timer running but car already full (soc at default 100% limit): not starting up + enablePendingFull := newPVLoadpoint(0, api.ModePV, api.StatusB, false, now) + enablePendingFull.vehicleSoc = 100 + tc := []struct { name string lp *Loadpoint starting bool }{ {"enable timer running", newPVLoadpoint(0, api.ModePV, api.StatusB, false, now), true}, - {"enabled not charging", newPVLoadpoint(0, api.ModePV, api.StatusB, true, time.Time{}), true}, + {"enabled not charging", newPVLoadpoint(0, api.ModePV, api.StatusB, true, time.Time{}), false}, {"enabled and charging", newPVLoadpoint(0, api.ModePV, api.StatusC, true, time.Time{}), false}, {"disabled idle", newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}), false}, {"disconnected", newPVLoadpoint(0, api.ModePV, api.StatusA, false, now), false}, {"not pv mode", newPVLoadpoint(0, api.ModeNow, api.StatusB, false, now), false}, + {"enable pending but car full", enablePendingFull, false}, } for _, tc := range tc { - if got := tc.lp.pvChargeStarting(); got != tc.starting { + if got := tc.lp.PvChargeStarting(); got != tc.starting { t.Errorf("%s: want %v, got %v", tc.name, tc.starting, got) } } @@ -78,6 +83,12 @@ func TestReservedPVPower(t *testing.T) { if got := site.reservedPVPower(low); got != 0 { t.Errorf("low after high charging: want 0, got %.0f", got) } + + // high stays enabled and connected but no longer draws (car full): no reservation (#31684) + high.status = api.StatusB + if got := site.reservedPVPower(low); got != 0 { + t.Errorf("low after high stopped drawing: want 0, got %.0f", got) + } } var _ loadpoint.API = (*Loadpoint)(nil)