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) + } +}