From 2878b1b716526d0fc653a97069667a27f608d707 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 15:00:02 +0100 Subject: [PATCH 1/2] fix(loadpoint): surplus counts our orders, not a charger's late reading Easee Cloud reports a charger's draw about 10 s late. Surplus added that reading back as the car's draw, so after a pause it still counted the stopped car's old draw as spare solar, and after a raise it raised again before the first had landed. Both landed in import and the car paused about every 80 s. Surplus now counts each charger at what the meter may show of our orders. A raise on its way already claims its room: it counts at its order and no further raise goes out until a reading shows it, so only import lowers it meanwhile. A charger ordered off counts at 0. A smaller cut keeps the reading, which shows the draw the meter still has until the cut lands. Seven field-matrix readback rows and the sitesim row "9 kW surplus, readback 10 s late" pass. The three 25 A empty/none rows never order anything, also with a fast charger; they are relabelled nostep. Co-Authored-By: Claude Opus 5.5 --- .changeset/surplus-late-readback.md | 5 ++ go/internal/control/sitesim_test.go | 2 +- .../control/testdata/sitesim_field_known.txt | 17 ++--- go/internal/loadpoint/controller.go | 71 ++++++++++++++++++- .../controller_surplus_readback_test.go | 46 ++++++++++++ 5 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 .changeset/surplus-late-readback.md create mode 100644 go/internal/loadpoint/controller_surplus_readback_test.go diff --git a/.changeset/surplus-late-readback.md b/.changeset/surplus-late-readback.md new file mode 100644 index 000000000..36db7efbe --- /dev/null +++ b/.changeset/surplus-late-readback.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Surplus charging now holds a steady level when the charger reports its draw late, as Easee Cloud does. A raise on its way already uses the room it was sent for, so Core waits for a reading to show it before raising again. A charger told to stop no longer counts its old draw as spare solar. Before, the car stepped up past the surplus, landed in import and paused about every minute and a half. diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 8e4bea715..c833f30c6 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -645,7 +645,7 @@ func simScenarios() []simScenario { plant: surplus, lp: surplusPhase("")}, {name: "phase mode auto, 9 kW surplus", run: 5 * time.Minute, surplus: true, plant: surplus, lp: surplusPhase("auto")}, - {name: "9 kW surplus, readback 10 s late", known: "surplus adds back the charger's late readback, surplus_reserve.go:128", run: 5 * time.Minute, surplus: true, + {name: "9 kW surplus, readback 10 s late", run: 5 * time.Minute, surplus: true, plant: func(p *sitesim.Plant) { surplus(p); p.EVSE.ReadbackLag = 10 * time.Second }, lp: surplusPhase("3p")}, {name: "surplus_unlock_bat_soc saved, Charge now", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index eacc80a97..144ccbd25 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -9,8 +9,8 @@ # following. # export srcfl/ftw#1782, draft: an exporting phase counts as import, so # surplus never finds room to start. -# readback no fix yet: surplus raises again before the charger applied the -# last raise, then pauses when both land (surplus_reserve.go:128). +# nostep no fix yet: with phase_mode unset and no step table, surplus +# orders nothing, even with a fast charger. # relief srcfl/ftw#1789, draft: the battery ends its relief before the # car's cut shows. pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery own-raise relief @@ -20,7 +20,6 @@ pr1763/25A/3p/3p-15A/easee120s/car3p/6-2-2/pause+battery own-raise relief pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery own-raise relief pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/pause+battery own-raise relief pr1763/25A/3p/3p-15A/fast2s/car3p/6-2-2/pause+battery own-raise relief -probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle export timing/16A/3p/3p/easee120s/car3p/L1/surplus idle export timing/16A/3p/3p/easee40s/car3p/L1/surplus idle export @@ -33,13 +32,7 @@ timing/16A/empty/none/cloud60s/car3p/L1/surplus idle export timing/16A/empty/none/easee120s/car3p/L1/surplus idle export timing/16A/empty/none/easee40s/car3p/L1/surplus idle export timing/16A/empty/none/fast2s/car3p/L1/surplus idle export -timing/25A/3p/3p/cloud60s/car3p/L1/surplus idle readback -timing/25A/3p/3p/easee120s/car3p/L1/surplus idle readback -timing/25A/3p/3p/easee40s/car3p/L1/surplus idle readback -timing/25A/auto/mixed/cloud60s/car3p/L1/surplus idle readback -timing/25A/auto/mixed/easee120s/car3p/L1/surplus idle readback -timing/25A/auto/mixed/easee40s/car3p/L1/surplus idle readback -timing/25A/empty/none/cloud60s/car3p/L1/surplus idle readback -timing/25A/empty/none/easee120s/car3p/L1/surplus idle readback -timing/25A/empty/none/easee40s/car3p/L1/surplus idle readback +timing/25A/empty/none/cloud60s/car3p/L1/surplus idle nostep +timing/25A/empty/none/easee120s/car3p/L1/surplus idle nostep +timing/25A/empty/none/easee40s/car3p/L1/surplus idle nostep timing/25A/empty/none/fast2s/car3p/L1/surplus idle export diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 6cb11aee1..2a4050c34 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -118,6 +118,8 @@ type Controller struct { // transient surplus spike can't ratchet the EV up a step it can't hold // (which would whipsaw the home battery's PI). See computeSurplusCmd. surplusStepW map[string]float64 + // surplusOrders is each loadpoint's latest order as surplus reads it. + surplusOrders map[string]surplusOrder // vehicleStatus is the bound vehicle driver and its charging state. // nil disables auto-wake. @@ -1657,6 +1659,7 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, s if w, ok := cmd["power_w"].(float64); ok { offerW, haveOffer = w, true c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt) + c.noteSurplusOrder(lpCfg.ID, now, w) mode, _ := cmd["phase_mode"].(string) split, _ := cmd["phase_split_w"].(float64) c.manager.setCommandedPhases(lpCfg.ID, c.commandPhases(mode, w, split)) @@ -1958,8 +1961,10 @@ func (c *Controller) computeSurplusCmd(now time.Time, lpCfg Config, wantW float6 // `−pvW − loadW`, i.e. PV-magnitude minus house load — invariant // under whatever the home battery is currently doing with the // surplus. The EV's own draw is part of that closure already, so - // we don't add it here. - instant := surplusW + // we don't add it here. The chargers' readings lag their draw, so the + // closure's EV draw is replaced by what our orders let the meter show. + adjW, raisingW := c.surplusOrderAdjustW(now, lpCfg.ID) + instant := surplusW + adjW if instant < 0 { instant = 0 } @@ -2032,10 +2037,72 @@ func (c *Controller) computeSurplusCmd(now time.Time, lpCfg Config, wantW float6 } } } + // A raise on its way already claims its room: no further raise until a + // reading shows it. + if raisingW > 0 && snapped > raisingW { + snapped = math.Max(prev, raisingW) + } c.setSurplusStepW(lpCfg.ID, snapped) return snapped } +// surplusRaiseSettle bounds how long a raise counts as on its way when no +// reading shows it: the slowest charger we know, Easee Cloud's 180 s. +const surplusRaiseSettle = 3 * time.Minute + +// surplusOrder is a loadpoint's latest order and what its readings have +// shown of it since. A reading lags the draw, so one that reaches the order +// shows it only after one under it. +type surplusOrder struct { + w, prevW float64 + at time.Time + below, shown bool +} + +func (c *Controller) noteSurplusOrder(id string, now time.Time, w float64) { + if c.surplusOrders == nil { + c.surplusOrders = map[string]surplusOrder{} + } + if o, ok := c.surplusOrders[id]; !ok || o.w != w { + c.surplusOrders[id] = surplusOrder{w: w, prevW: o.w, at: now} + } +} + +// surplusOrderAdjustW is what surplus adds to the closure's EV readings so +// it counts each charger at what the meter may show of our orders, and the +// raise this loadpoint has on its way (0 for none). A raise counts at its +// order until a reading shows it: the meter shows its room as export until +// it lands, then the draw while the reading still lags, so only import +// lowers it. A charger ordered off counts at 0: its reading still shows the +// draw after the stop has landed. A smaller cut keeps the reading, which +// shows the draw the meter still has until the cut lands. +func (c *Controller) surplusOrderAdjustW(now time.Time, id string) (adjW, raisingW float64) { + for _, st := range c.manager.States() { + o, ok := c.surplusOrders[st.ID] + if !st.PluggedIn || !ok || !finite(st.CurrentPowerW) { + continue + } + reading, order := math.Max(0, st.CurrentPowerW), math.Max(0, o.w) + if reading < 0.9*order { + o.below = true + } else if o.below { + o.shown = true + } + c.surplusOrders[st.ID] = o + if order > o.prevW && !o.shown && now.Sub(o.at) < surplusRaiseSettle { + adjW += order - reading + if st.ID == id { + raisingW = order + } + continue + } + if order == 0 { + adjW -= reading // A charger ordered off is leaving. + } + } + return adjW, raisingW +} + // pickSurplusSteps returns the step set surplus_only should snap to // for this loadpoint. Default is 3Φ-eligible only (the no-flap rule); // when the day's peak forecast surplus can't sustain a 3Φ minimum, diff --git a/go/internal/loadpoint/controller_surplus_readback_test.go b/go/internal/loadpoint/controller_surplus_readback_test.go new file mode 100644 index 000000000..1ed8328c7 --- /dev/null +++ b/go/internal/loadpoint/controller_surplus_readback_test.go @@ -0,0 +1,46 @@ +package loadpoint + +import ( + "testing" + "time" +) + +// A charger reports its draw late: Easee Cloud about 10 s after the draw. +// Surplus counts each charger at what the meter may show of our orders, not +// at its late reading. A raise on its way already claims its room; a +// charger ordered off is leaving; a smaller cut keeps the reading until it +// lands. +func TestSurplusCountsOurOrdersNotALateReading(t *testing.T) { + start := time.Date(2026, 10, 21, 22, 15, 0, 0, time.UTC) + for _, tc := range []struct { + name string + orders []float64 // each sent 2 s apart; the last is the latest + readings []float64 // each read 1 s after the latest order + wantAdj, wantRaise float64 + }{ + {"ordered off, the reading still shows the draw", []float64{6210, 0}, []float64{6210}, -6210, 0}, + {"a raise on its way", []float64{4830, 6900}, []float64{4830}, 2070, 6900}, + {"a raise the reading has shown", []float64{4830, 6900}, []float64{4830, 6900}, 0, 0}, + {"a restart under the draw read before the stop", []float64{6210, 0, 4830}, []float64{6210}, -1380, 4830}, + {"a cut on its way keeps the reading", []float64{8970, 3450}, []float64{8970}, 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + c := newTestController(t, []Config{{ID: "garage", DriverName: "easee", SurplusOnly: true, MinChargeW: 4140, MaxChargeW: 11040}}, nil, nil, &fakeSender{}) + now := start + for _, w := range tc.orders { + now = now.Add(2 * time.Second) + c.manager.SetCommanded("garage", w, "pv_surplus") + c.noteSurplusOrder("garage", now, w) + } + var adj, raise float64 + for _, r := range tc.readings { + now = now.Add(time.Second) + c.manager.Observe("garage", true, r, 0, true) + adj, raise = c.surplusOrderAdjustW(now, "garage") + } + if adj != tc.wantAdj || raise != tc.wantRaise { + t.Fatalf("adjust %.0f W, raise on its way %.0f W; want %.0f and %.0f", adj, raise, tc.wantAdj, tc.wantRaise) + } + }) + } +} From 04a61e8ae9e90c8ada5647ba59a87472d64400b6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 15:25:05 +0100 Subject: [PATCH 2/2] fix(loadpoint): a raise shows once a reading passes halfway to it Review: a raise counted as shown only after a reading under 0.9 x the order, so a one-step raise from 9 A up (6210 -> 6900 W) never showed and held the next raise for the full 180 s, even on a fast charger. A reading now shows a raise once it passes halfway from the order before, after one short of halfway; a high reading from before a cut still does not count. Co-Authored-By: Claude Opus 5.5 --- go/internal/loadpoint/controller.go | 6 +++--- go/internal/loadpoint/controller_surplus_readback_test.go | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 2a4050c34..26c7db6fc 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -2051,8 +2051,8 @@ func (c *Controller) computeSurplusCmd(now time.Time, lpCfg Config, wantW float6 const surplusRaiseSettle = 3 * time.Minute // surplusOrder is a loadpoint's latest order and what its readings have -// shown of it since. A reading lags the draw, so one that reaches the order -// shows it only after one under it. +// shown of it since. A reading lags the draw, so one past halfway from the +// order before to this one shows it only after one short of halfway. type surplusOrder struct { w, prevW float64 at time.Time @@ -2083,7 +2083,7 @@ func (c *Controller) surplusOrderAdjustW(now time.Time, id string) (adjW, raisin continue } reading, order := math.Max(0, st.CurrentPowerW), math.Max(0, o.w) - if reading < 0.9*order { + if reading < (math.Max(0, o.prevW)+order)/2 { o.below = true } else if o.below { o.shown = true diff --git a/go/internal/loadpoint/controller_surplus_readback_test.go b/go/internal/loadpoint/controller_surplus_readback_test.go index 1ed8328c7..3db32b688 100644 --- a/go/internal/loadpoint/controller_surplus_readback_test.go +++ b/go/internal/loadpoint/controller_surplus_readback_test.go @@ -21,6 +21,9 @@ func TestSurplusCountsOurOrdersNotALateReading(t *testing.T) { {"ordered off, the reading still shows the draw", []float64{6210, 0}, []float64{6210}, -6210, 0}, {"a raise on its way", []float64{4830, 6900}, []float64{4830}, 2070, 6900}, {"a raise the reading has shown", []float64{4830, 6900}, []float64{4830, 6900}, 0, 0}, + {"one step up from 9 A, on its way", []float64{6210, 6900}, []float64{6210}, 690, 6900}, + {"one step up from 9 A, shown", []float64{6210, 6900}, []float64{6210, 6900}, 0, 0}, + {"a raise after a cut that has not landed", []float64{8970, 4830, 6210}, []float64{8970}, -2760, 6210}, {"a restart under the draw read before the stop", []float64{6210, 0, 4830}, []float64{6210}, -1380, 4830}, {"a cut on its way keeps the reading", []float64{8970, 3450}, []float64{8970}, 0, 0}, } {