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 35ce0f966..6167d172d 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -646,7 +646,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 f304490ad..b382762d3 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -3,28 +3,4 @@ # and when a row fails that is not listed. FTW_FIELD_KNOWN=write rewrites # this file from a run, keeps each label and marks unlisted failures "new". # -# B srcfl/ftw#1799: planned charging and Charge now run on three -# phases; the planner gets steps a three-phase order can deliver. -# #1795 srcfl/ftw#1795: a charger drawing against its order reads not -# following. -# readback no fix yet: surplus raises again before the charger applied the -# last raise, then pauses when both land (surplus_reserve.go:128). -probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback -timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle readback -timing/16A/3p/3p/easee120s/car3p/L1/surplus idle readback -timing/16A/3p/3p/easee40s/car3p/L1/surplus idle readback -timing/16A/auto/mixed/cloud60s/car3p/L1/surplus idle readback -timing/16A/auto/mixed/easee120s/car3p/L1/surplus idle readback -timing/16A/auto/mixed/easee40s/car3p/L1/surplus idle readback -timing/16A/empty/none/cloud60s/car3p/L1/surplus idle readback -timing/16A/empty/none/easee120s/car3p/L1/surplus idle readback -timing/16A/empty/none/easee40s/car3p/L1/surplus idle readback -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 +# No row fails: every case keeps the invariants. diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index f5fb3b740..3e9fa1dfd 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. @@ -1661,6 +1663,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)) @@ -1962,8 +1965,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 } @@ -2036,10 +2041,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 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 + 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 < (math.Max(0, o.prevW)+order)/2 { + 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: three phases from 6 A // each. Below that minimum surplus may use all allowed steps, so one 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..3db32b688 --- /dev/null +++ b/go/internal/loadpoint/controller_surplus_readback_test.go @@ -0,0 +1,49 @@ +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}, + {"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}, + } { + 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) + } + }) + } +}