From 6b19eba7f384450d9b7f1027cf5fb64a0bcb4563 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 06:46:12 +0200 Subject: [PATCH] fix(control): a cut ends the reservation of the higher orders before it #1776 counts an EV order as flowing until the charger can have applied it, using the highest order sent within the charger's response time. That included orders a later cut had already replaced. With Easee Cloud's declared 180 s, a charge at 10,350 W, a 25 s pause and a 4,140 W request kept 10,350 W reserved: the restart waited 2 min 47 s with fuse_limit while the phases read about 1.3 A. Reported on a real Easee/Pixii site by segran2 in #1763, reproduced in evMeterSite. The charger follows its latest order, and the meter shows its draw until a cut lands, so a cut ends the reservation of the higher orders before it. A run of rises still counts its highest, which is its latest, so stacked rises stay covered. The restart now comes after 12 s. Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-cut-ends-reservation.md | 5 ++ go/internal/control/ev_fuse_budget.go | 14 ++++-- .../ev_fuse_restart_after_pause_test.go | 48 +++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 .changeset/ev-cut-ends-reservation.md create mode 100644 go/internal/control/ev_fuse_restart_after_pause_test.go diff --git a/.changeset/ev-cut-ends-reservation.md b/.changeset/ev-cut-ends-reservation.md new file mode 100644 index 000000000..3768eac09 --- /dev/null +++ b/.changeset/ev-cut-ends-reservation.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Charging starts again promptly after a pause. In 0.155.0, an Easee Cloud charger that was paused and then asked to start waited up to three minutes, shown as limited by the main fuse, while the phases had plenty of room: FTW still counted the higher current it had ordered before the pause as on its way. A pause now ends that reservation, because the charger follows its latest order and the site meter shows its draw until the pause lands. In the reported case the restart now comes after 12 s instead of 2 min 47 s. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 093e62aeb..f0d7f96b7 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -89,9 +89,11 @@ func (l commandLog) span(now time.Time) (low, high, latest float64, ok bool) { // unshown is how much more a charger may draw than a reading taken at `at` // shows, in watts and in current per phase: the highest offer sent since -// settle before the reading, less the lowest offer the reading may show. -// An offer set before that window is in the reading. A cut needs nothing: -// the reading shows the draw until the cut lands. Before our first offer +// settle before the reading and not replaced by a lower one, less the +// lowest offer the reading may show. An offer set before that window is in +// the reading. A cut needs nothing: the reading shows the draw until the +// cut lands, and the charger moves to its latest order, so a cut also ends +// the reservation of the higher offers before it. Before our first offer // the charger counts as 0. func (l commandLog) unshown(at time.Time, settle time.Duration) (w, a float64) { from := at.Add(-settle) @@ -102,6 +104,12 @@ func (l commandLog) unshown(at time.Time, settle time.Duration) (w, a float64) { settledW, settledA = c.w, c.a continue } + if c.w < highW { + highW = c.w + } + if c.a < highA { + highA = c.a + } highW, highA = math.Max(highW, c.w), math.Max(highA, c.a) if !c.at.After(at) { lowW, lowA = math.Min(lowW, c.w), math.Min(lowA, c.a) diff --git a/go/internal/control/ev_fuse_restart_after_pause_test.go b/go/internal/control/ev_fuse_restart_after_pause_test.go new file mode 100644 index 000000000..881a084b9 --- /dev/null +++ b/go/internal/control/ev_fuse_restart_after_pause_test.go @@ -0,0 +1,48 @@ +package control + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +// A field case on v0.155.0-beta.1 (Easee Cloud, 25 A fuse): a charge at +// 10,350 W, a 25 s pause, then a 4,140 W request. Easee declares 180 s, so +// the rise sent before the pause stayed reserved, and the restart waited +// close to three minutes with fuse_limit while the phases read about 1.3 A. +// A cut ends the reservation of the higher orders before it: the charger +// moves to its latest order, and the meter shows its draw until the cut +// lands. +func TestEVHardLimitRestartAfterPauseIgnoresReplacedRise(t *testing.T) { + s := newEVMeterSite(t, 0, 900) + s.chargerDelay, s.telemetryDelay, s.chargerMaxAgeS = 5*time.Second, 10*time.Second, 180 + s.run(25 * time.Second) + if s.offer() <= 0 { + t.Fatalf("the car did not start before the pause: offers %v", offerWatts(s.offers)) + } + s.ctrl.SetManualHold("garage", loadpoint.ManualHold{PowerW: 0, PhaseMode: "3p", Persistent: true}) + s.run(25 * time.Second) + if s.offer() != 0 { + t.Fatalf("the pause did not stop the offer: %v", offerWatts(s.offers)) + } + askedAt := s.now + s.ctrl.SetManualHold("garage", loadpoint.ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) + s.run(3 * time.Minute) + var startedAt time.Time + for _, o := range s.offersSince(askedAt) { + if o.w > 0 { + startedAt = o.at + break + } + } + if startedAt.IsZero() { + t.Fatalf("no offer within 3 min after the restart: %v", offerWatts(s.offersSince(askedAt))) + } + // The idle-start dwell (35 s) may still apply; the replaced rise must not. + if wait := startedAt.Sub(askedAt); wait > 60*time.Second { + t.Fatalf("restart waited %v for a 4,140 W offer with the phases near 1.3 A", wait) + } else { + t.Logf("restart offered after %v", wait) + } +}