From c1067111e4870212e47db772360378987d015dde Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Sun, 23 Aug 2026 13:10:24 +0200 Subject: [PATCH] Loadpoint: don't reserve surplus when paused by feed-in priority (#33072) --- core/loadpoint.go | 4 +- core/site_prioritize_test.go | 81 ++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/core/loadpoint.go b/core/loadpoint.go index 6b6b23f3b..db2a1daed 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -1047,7 +1047,9 @@ func (lp *Loadpoint) charging() bool { func (lp *Loadpoint) PvChargeStarting() bool { lp.RLock() enabled := lp.enabled - pvTimerRunning := !lp.pvTimer.IsZero() + // an elapsed timer means a delay was skipped, e.g. by a feed-in pause, not an + // enable pending, hence such a loadpoint claims no surplus + pvTimerRunning := !lp.pvTimer.IsZero() && !lp.pvTimer.Equal(elapsed) lp.RUnlock() if lp.GetMode() != api.ModePV || !lp.connected() || lp.chargeGoalReached(enabled) { diff --git a/core/site_prioritize_test.go b/core/site_prioritize_test.go index aeaae88b8..f3fa115cd 100644 --- a/core/site_prioritize_test.go +++ b/core/site_prioritize_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + evbus "github.com/asaskevich/EventBus" "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" @@ -44,6 +45,8 @@ func TestPvChargeStarting(t *testing.T) { {"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}, + // elapsed means a delay was skipped, e.g. by a feed-in pause, not an enable pending + {"timer elapsed", newPVLoadpoint(0, api.ModePV, api.StatusB, false, elapsed), false}, } for _, tc := range tc { @@ -91,4 +94,82 @@ func TestReservedPVPower(t *testing.T) { } } +// feedInCharger is a stateful charger, unlike the mocks used elsewhere, since the +// feed-in pause is asserted across multiple update cycles +type feedInCharger struct { + status api.ChargeStatus + enabled bool +} + +func (c *feedInCharger) Status() (api.ChargeStatus, error) { return c.status, nil } +func (c *feedInCharger) Enabled() (bool, error) { return c.enabled, nil } +func (c *feedInCharger) MaxCurrent(int64) error { return nil } + +func (c *feedInCharger) Enable(v bool) error { + c.enabled = v + // a disabled charger stops drawing, i.e. reports connected instead of charging + if !v && c.status == api.StatusC { + c.status = api.StatusB + } + return nil +} + +// TestReservedPVPowerSmartFeedInPause asserts that a loadpoint paused by its smart feed-in +// priority limit does not reserve surplus: it is meant to export instead of charge +func TestReservedPVPowerSmartFeedInPause(t *testing.T) { + // the feed-in rate rises above the limit while the loadpoint is idle or charging + for _, tc := range []struct { + name string + status api.ChargeStatus + enabled bool + }{ + {"idle", api.StatusB, false}, + {"charging", api.StatusC, true}, + } { + t.Run(tc.name, func(t *testing.T) { + clck := clock.NewMock() + limit := 0.05 + + car := newPVLoadpoint(1, api.ModePV, tc.status, tc.enabled, time.Time{}) + car.bus = evbus.New() + car.clock = clck + car.charger = &feedInCharger{status: tc.status, enabled: tc.enabled} + car.chargeMeter = &Null{} // silence nil panics + car.chargeRater = &Null{} // silence nil panics + car.chargeTimer = &Null{} // silence nil panics + car.wakeUpTimer = NewTimer() + car.smartFeedInPriorityLimit = &limit + car.vehicleSoc = 20 + car.limitSoc = 80 + attachListeners(t, car) + + low := newPVLoadpoint(0, api.ModePV, api.StatusB, false, time.Time{}) + + site := &Site{ + log: util.NewLogger("site"), + loadpoints: []*Loadpoint{car, low}, + } + + // checkSmartLimit looks up rates by wall clock time, not lp.clock + now := time.Now() + feedin := api.Rates{{Start: now.Add(-time.Hour), End: now.Add(time.Hour), Value: limit + 0.01}} + + // the pause holds across cycles, so must the absence of a reservation + for i := range 2 { + car.Update(-3500, 0, nil, feedin, false, false, 0, nil, nil, nil) + + if car.enabled { + t.Fatalf("cycle %d: car must be paused by the feed-in limit", i) + } + + if got := site.reservedPVPower(low); got != 0 { + t.Errorf("cycle %d: paused car reserves %.0fW, want 0W (pvTimer %v)", i, got, car.pvTimer) + } + + clck.Add(time.Minute) + } + }) + } +} + var _ loadpoint.API = (*Loadpoint)(nil)