From 990fc39e6bf614ebc93b425e70f6d43ad057539a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 18:38:30 +0200 Subject: [PATCH 1/2] fix(control): an unplugged car is no EV cut the battery waits for (#1674) Codex on #1706: with several managed chargers, unplugging one car during an overage of the hour's import limit dropped its offer from the pool. pendingEVCutW counted that drop as a cut Core sent, although the meter may already show the unplug, and held back battery relief for up to 20 s. The EV guard now notes which loadpoints are plugged. When a car leaves or joins, the run's baseline restarts at the pool's offer then, so only cuts sent to cars still plugged count. Our own commands only. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-cut-pool-change.md | 5 ++ .../control/ev_battery_sequence_test.go | 50 +++++++++++++++++++ go/internal/control/ev_fuse_budget.go | 13 +++++ 3 files changed, 68 insertions(+) create mode 100644 .changeset/ev-cut-pool-change.md diff --git a/.changeset/ev-cut-pool-change.md b/.changeset/ev-cut-pool-change.md new file mode 100644 index 000000000..1884ade31 --- /dev/null +++ b/.changeset/ev-cut-pool-change.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +With two or more managed chargers, unplugging one car during an overage of the import ceiling no longer holds back battery relief. Its offer left the pool, but Core did not cut it, so the battery no longer waits for it. A cut Core sends to a car that stays plugged still counts. diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index ef2730047..388bb16c1 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -553,3 +553,53 @@ func TestFuseReliefLeavesOnlyCeilingWattsToCommandedEVCut(t *testing.T) { } } } + +// Two managed cars charge under the hour's ceiling, and one is unplugged +// during an overage. Its offer leaves the pool, but no cut of ours removed +// it, and the meter may already show it. The battery must not wait for it, +// though it still waits for a cut sent to the car that stays. +func TestUnpluggedCarIsNoCutTheBatteryWaitsFor(t *testing.T) { + now := time.Date(2026, 10, 17, 16, 30, 0, 0, time.Local) + st := NewState(0, 0, "meter") + st.clock = func() time.Time { return now } + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = 25, 230, 3, 1 + st.PeakImportCeilingW = 7500 + store := telemetry.NewStore() + configs := []loadpoint.Config{ + {ID: "a", DriverName: "easee-a", PhaseMode: "3p", MinChargeW: 4140}, + {ID: "b", DriverName: "easee-b", PhaseMode: "3p", MinChargeW: 4140}, + } + // One control tick: the meter reads gridW, the guard steps on the cars' + // offers, the controller leaves car a at aAfterW, then the battery asks + // for relief. + tick := func(gridW float64, bPlugged bool, aAfterW float64) float64 { + now = now.Add(time.Second) + a := gridW / 690 + data, _ := json.Marshal(map[string]any{"l1_a": a, "l2_a": a, "l3_a": a}) + store.Update("meter", telemetry.DerMeter, gridW, nil, data) + store.Get("meter", telemetry.DerMeter).UpdatedAt = now + store.RecordDriverSuccess("meter") + points := []loadpoint.State{ + {ID: "a", DriverName: "easee-a", PluggedIn: true, CommandedKnown: true, CommandedW: 5520}, + {ID: "b", DriverName: "easee-b", PluggedIn: bPlugged, CommandedKnown: true, CommandedW: 5520}, + } + EVFuseBudget(store, st, points, configs, 17250) + points[0].CommandedW = aAfterW + st.EVCommandedW = loadpoint.CommandedEVPowerW(points) + targets := []DispatchTarget{{Driver: "pixii"}} + batteries := map[string]*telemetry.DerReading{"pixii": {}} + return math.Max(0, fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250, false)) + } + // 400 W over the ceiling, less than a step: the guard waits. + if got := tick(7900, true, 5520); math.Abs(got-400) > 1e-6 { + t.Fatalf("first overage: relief %.0f W, want 400 W", got) + } + // Car b is unplugged and the house adds 6 kW: 880 W over, no cut sent. + if got := tick(7900-5520+6000, false, 5520); math.Abs(got-880) > 1e-6 { + t.Fatalf("after car b left: relief %.0f W, want 880 W; pending cut %.0f W", got, st.pendingEVCutW()) + } + // 2,000 W over, and car a is stopped: that cut may still wait. + if got := tick(9500, false, 0); got != 0 { + t.Fatalf("after car a's stop: relief %.0f W, want 0 W", got) + } +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 20cfdf39a..e24a7ebb7 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -3,6 +3,8 @@ package control import ( "log/slog" "math" + "slices" + "strings" "time" "github.com/srcfl/ftw/go/internal/loadpoint" @@ -89,12 +91,14 @@ type evMeterLimit struct { raisedGridW float64 // site import when the last rise was sent overSince time.Time // first reading of a run over the hour's import limit overOfferW float64 // the pool's offer when that run began + members string // the plugged loadpoints overOfferW counts idleWh float64 // import since roomSince, for the ceiling's start test } type evPool struct { offerW, wantW, minW, phases float64 offerKnown bool + members string // plugged loadpoint IDs, sorted } // EVFuseBudget returns the managed EVs' common hard cap, which the loadpoint @@ -128,6 +132,11 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State if pool.offerKnown { state.evOffers = state.evOffers.add(state.now(), pool.offerW) } + if pool.members != lim.members { + // A car that leaves or joins moves the pool's offer without a cut of + // ours, and the meter may already show it. Count from here. + lim.members, lim.overOfferW = pool.members, pool.offerW + } if !lim.set || meter.UpdatedAt.After(lim.readingAt) { state.stepEVMeterLimit(meter, gridW, pool, fuseMaxW) } @@ -337,11 +346,13 @@ func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Confi } pool := evPool{phases: 3, wantW: math.Inf(1), offerKnown: true} plugged := false + var members []string for _, point := range points { if !point.PluggedIn { continue } plugged = true + members = append(members, point.ID) cfg := byID[point.ID] // Empty is the declared three-phase default. The controller's own // gate covers runtime auto/manual phase changes. @@ -356,6 +367,8 @@ func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Confi if !plugged { return pool, false } + slices.Sort(members) + pool.members = strings.Join(members, "\x00") if pool.offerKnown { pool.offerW = loadpoint.CommandedEVPowerW(points) } From af689212a987752d8d799dd0351a0acb1d0cae47 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 19:49:27 +0200 Subject: [PATCH 2/2] fix(control): keep a pending EV cut per charger when another car leaves (#1674) Codex on #1713: restarting the whole pool's baseline when a car left also dropped a cut already sent to a car that stays. The battery then covered that cut too, and the site could export when it landed. The EV guard now notes each charger's offer when a run over the hour's limit begins. pendingEVCutW sums, over chargers still plugged, the start offer less this tick's offer. A car that leaves drops out; a car that joins has no start offer. main.go hands Core this tick's offers per driver through SetEVCommanded, from loadpoint.CommandedEVPowerByDriver, which keeps a stopped car at 0 W. Our own commands only; the 20 s cap stays. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-cut-pool-change.md | 2 +- go/cmd/ftw/main.go | 2 +- go/internal/control/dispatch.go | 3 + .../control/ev_battery_sequence_test.go | 69 +++++++++++++++++-- go/internal/control/ev_fuse_budget.go | 52 +++++++------- go/internal/loadpoint/deadline_need.go | 22 ++++-- 6 files changed, 113 insertions(+), 37 deletions(-) diff --git a/.changeset/ev-cut-pool-change.md b/.changeset/ev-cut-pool-change.md index 1884ade31..749cfaeec 100644 --- a/.changeset/ev-cut-pool-change.md +++ b/.changeset/ev-cut-pool-change.md @@ -2,4 +2,4 @@ "ftw": patch --- -With two or more managed chargers, unplugging one car during an overage of the import ceiling no longer holds back battery relief. Its offer left the pool, but Core did not cut it, so the battery no longer waits for it. A cut Core sends to a car that stays plugged still counts. +With two or more managed chargers, unplugging one car during an overage of the import ceiling no longer holds back battery relief. Its offer left the pool, but Core did not cut it, so the battery no longer waits for it. A cut Core has sent to a car that stays plugged still counts, so its landing does not swing the site into export. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index fb9efc2c6..5613b6603 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -3037,7 +3037,7 @@ func main() { ctrl.EVCurtailHeadroomW = evCurtailHeadroomW ctrl.BatteryBoostEVChargingW = boostEVW ctrl.BatteryBoostReserveSoC = boostReserveSoC - ctrl.EVCommandedW = loadpoint.CommandedEVPowerW(lpStatesSnapshot) + ctrl.SetEVCommanded(lpStatesSnapshot) ctrl.SetEVFuseDemands(evFuseDemands) fuseMaxW := ctrl.SiteFuseAmps * ctrl.SiteFuseVoltage * float64(ctrl.SiteFusePhases) targets := control.ComputeDispatch(tel, ctrl, capsSnap, fuseMaxW) diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index c5156008d..28b5ca5a8 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -370,6 +370,9 @@ type State struct { // It reserves site capacity before optional battery charging, even before // an EV ramps up and in modes without an accepted optimizer plan. EVCommandedW float64 + // evCommanded is the same offers per charger driver; SetEVCommanded + // keeps both. + evCommanded map[string]float64 // evFuseDemands keeps this tick's intent before the fuse clamp so optional // battery charging yields even when the EV cannot start yet. evFuseDemands []evFuseDemand diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index 388bb16c1..01eed478c 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -77,7 +77,7 @@ func TestJoinedEVWaitsForMeasuredBatteryBackoffThenResumes(t *testing.T) { emit(config.DriverName, telemetry.DerEV, actualEV) emit("meter", telemetry.DerMeter, houseW+actualBattery+actualEV) controller.TickWithDispatch(context.Background(), now, true) - st.EVCommandedW = loadpoint.CommandedEVPowerW(mgr.States()) + st.SetEVCommanded(mgr.States()) st.SetEVFuseDemands(controller.FuseDemands()) targets := ComputeDispatch(store, st, caps(map[string]float64{"pixii": 15000}), 17250) if len(targets) != 1 || targets[0].TargetW > 1210.001 { @@ -257,7 +257,7 @@ func (s *evBatterySite) step() { s.emit("meter", telemetry.DerMeter, s.gridW()) s.state.ObserveSiteImport(s.store) s.ctrl.TickWithDispatch(context.Background(), s.now, true) - s.state.EVCommandedW = loadpoint.CommandedEVPowerW(s.mgr.States()) + s.state.SetEVCommanded(s.mgr.States()) s.state.SetEVFuseDemands(s.ctrl.FuseDemands()) targets := ComputeDispatch(s.store, s.state, caps(map[string]float64{"pixii": 15000}), 17250) if len(targets) != 1 { @@ -541,9 +541,9 @@ func TestFuseReliefLeavesOnlyCeilingWattsToCommandedEVCut(t *testing.T) { st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = 25, 230, 3, 1 st.PeakImportCeilingW = 7500 if tc.overFor > 0 { - st.evLimit.overSince, st.evLimit.overOfferW = now.Add(-tc.overFor), 9660 + st.evLimit.overSince, st.evLimit.overOffers = now.Add(-tc.overFor), map[string]float64{"easee": 9660} } - st.EVCommandedW = tc.commandedW + st.SetEVCommanded([]loadpoint.State{{ID: "car", DriverName: "easee", PluggedIn: true, CommandedKnown: true, CommandedW: tc.commandedW}}) targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {}} meter := &telemetry.DerReading{SmoothedW: tc.gridW, Data: tc.data} @@ -585,7 +585,7 @@ func TestUnpluggedCarIsNoCutTheBatteryWaitsFor(t *testing.T) { } EVFuseBudget(store, st, points, configs, 17250) points[0].CommandedW = aAfterW - st.EVCommandedW = loadpoint.CommandedEVPowerW(points) + st.SetEVCommanded(points) targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {}} return math.Max(0, fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250, false)) @@ -603,3 +603,62 @@ func TestUnpluggedCarIsNoCutTheBatteryWaitsFor(t *testing.T) { t.Fatalf("after car a's stop: relief %.0f W, want 0 W", got) } } + +// Core stops car a during an overage of the hour's ceiling, and car b is +// unplugged 5 s later, before a's stop reaches the meter. b leaving is no +// cut of ours, but a's stop still is: the battery keeps waiting for it, and +// the site does not export when it lands. +func TestCutOnStayingCarStillWaitsWhenAnotherLeaves(t *testing.T) { + start := time.Date(2026, 10, 17, 16, 30, 0, 0, time.Local) + now := start + st := NewState(0, 0, "meter") + st.clock = func() time.Time { return now } + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = 25, 230, 3, 1 + st.PeakImportCeilingW = 7500 + store := telemetry.NewStore() + configs := []loadpoint.Config{ + {ID: "a", DriverName: "easee-a", PhaseMode: "3p", MinChargeW: 4140}, + {ID: "b", DriverName: "easee-b", PhaseMode: "3p", MinChargeW: 4140}, + } + const houseW = 2000 + batteryW, lowestMeter := 0.0, math.Inf(1) + for tick := 0; tick < 15; tick++ { + now = start.Add(time.Duration(tick) * time.Second) + bPlugged := tick < 5 + aDrawW, bDrawW := 10350.0, 4140.0 + if tick >= 10 { + aDrawW = 0 // a's stop, sent on the first tick, lands 10 s later. + } + if !bPlugged { + bDrawW = 0 + } + gridW := houseW + aDrawW + bDrawW + batteryW + lowestMeter = math.Min(lowestMeter, gridW) + a := gridW / 690 + data, _ := json.Marshal(map[string]any{"l1_a": a, "l2_a": a, "l3_a": a}) + store.Update("meter", telemetry.DerMeter, gridW, nil, data) + store.Get("meter", telemetry.DerMeter).UpdatedAt = now + store.RecordDriverSuccess("meter") + aOfferW := 10350.0 // a's offer before this tick + if tick > 0 { + aOfferW = 0 + } + points := []loadpoint.State{ + {ID: "a", DriverName: "easee-a", PluggedIn: true, CommandedKnown: true, CommandedW: aOfferW}, + {ID: "b", DriverName: "easee-b", PluggedIn: bPlugged, CommandedKnown: true, CommandedW: 4140}, + } + EVFuseBudget(store, st, points, configs, 17250) + points[0].CommandedW = 0 // Core stops car a on the first tick. + st.SetEVCommanded(points) + targets := []DispatchTarget{{Driver: "pixii"}} + batteries := map[string]*telemetry.DerReading{"pixii": {SmoothedW: batteryW}} + relief := fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250, false) + batteryW = -math.Max(0, relief) // The battery follows within a tick. + if batteryW < 0 && tick < 10 { + t.Fatalf("%ds: battery %.0f W for watts a's stop removes; pending cut %.0f W", tick, batteryW, st.pendingEVCutW()) + } + } + if lowestMeter < 0 { + t.Fatalf("the site exported %.0f W when a's stop landed", -lowestMeter) + } +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index e24a7ebb7..ff0755b1d 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -3,8 +3,6 @@ package control import ( "log/slog" "math" - "slices" - "strings" "time" "github.com/srcfl/ftw/go/internal/loadpoint" @@ -88,17 +86,16 @@ type evMeterLimit struct { readingAt time.Time // newest site reading seen roomSince time.Time // first reading of the current run with room to start raised bool - raisedGridW float64 // site import when the last rise was sent - overSince time.Time // first reading of a run over the hour's import limit - overOfferW float64 // the pool's offer when that run began - members string // the plugged loadpoints overOfferW counts - idleWh float64 // import since roomSince, for the ceiling's start test + raisedGridW float64 // site import when the last rise was sent + overSince time.Time // first reading of a run over the hour's import limit + overOffers map[string]float64 // each charger's offer when that run began + idleWh float64 // import since roomSince, for the ceiling's start test } type evPool struct { offerW, wantW, minW, phases float64 offerKnown bool - members string // plugged loadpoint IDs, sorted + offers map[string]float64 // per charger driver } // EVFuseBudget returns the managed EVs' common hard cap, which the loadpoint @@ -132,11 +129,6 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State if pool.offerKnown { state.evOffers = state.evOffers.add(state.now(), pool.offerW) } - if pool.members != lim.members { - // A car that leaves or joins moves the pool's offer without a cut of - // ours, and the meter may already show it. Count from here. - lim.members, lim.overOfferW = pool.members, pool.offerW - } if !lim.set || meter.UpdatedAt.After(lim.readingAt) { state.stepEVMeterLimit(meter, gridW, pool, fuseMaxW) } @@ -168,7 +160,7 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo lim.overSince = time.Time{} } else { if lim.overSince.IsZero() || gap { - lim.overSince, lim.overOfferW = at, pool.offerW + lim.overSince, lim.overOffers = at, pool.offers } if !r.hourly || gap || -r.limitW >= step || at.Sub(lim.overSince) >= evSettle { cutW = math.Min(cutW, r.limitW) @@ -324,17 +316,32 @@ func (s *State) pendingBatteryCommandsW(now time.Time) float64 { return pending } +// SetEVCommanded stores this tick's final charger offers, after every +// charger clamp, in total and per driver. +func (s *State) SetEVCommanded(points []loadpoint.State) { + s.EVCommandedW = loadpoint.CommandedEVPowerW(points) + s.evCommanded = loadpoint.CommandedEVPowerByDriver(points) +} + // pendingEVCutW is the EV reduction we sent during the current run over the -// hour's import limit: the offer when the run began less this tick's offers. -// Our own commands only. A cut that has reached the meter ends the run, and -// no run waits longer than evSettle, so a car that draws less than its offer -// cannot hold an overage through a second cut. +// hour's import limit: for each charger still plugged, its offer when the +// run began less its offer this tick. A car that leaves drops out, since no +// cut of ours removed it; a car that joins has no start offer. Our own +// commands only. A cut that has reached the meter ends the run, and no run +// waits longer than evSettle, so a car that draws less than its offer cannot +// hold an overage through a second cut. func (s *State) pendingEVCutW() float64 { lim := &s.evLimit - if lim.overSince.IsZero() || s.now().Sub(lim.overSince) >= evSettle || !finiteFuseValue(s.EVCommandedW) { + if lim.overSince.IsZero() || s.now().Sub(lim.overSince) >= evSettle { return 0 } - return math.Max(0, lim.overOfferW-math.Max(0, s.EVCommandedW)) + var cutW float64 + for driver, startW := range lim.overOffers { + if nowW, plugged := s.evCommanded[driver]; plugged { + cutW += math.Max(0, startW-math.Max(0, nowW)) + } + } + return cutW } // evPoolFor sums the plugged managed chargers. Before the first command after @@ -346,13 +353,11 @@ func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Confi } pool := evPool{phases: 3, wantW: math.Inf(1), offerKnown: true} plugged := false - var members []string for _, point := range points { if !point.PluggedIn { continue } plugged = true - members = append(members, point.ID) cfg := byID[point.ID] // Empty is the declared three-phase default. The controller's own // gate covers runtime auto/manual phase changes. @@ -367,8 +372,7 @@ func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Confi if !plugged { return pool, false } - slices.Sort(members) - pool.members = strings.Join(members, "\x00") + pool.offers = loadpoint.CommandedEVPowerByDriver(points) if pool.offerKnown { pool.offerW = loadpoint.CommandedEVPowerW(points) } diff --git a/go/internal/loadpoint/deadline_need.go b/go/internal/loadpoint/deadline_need.go index 2438c4d7f..73f88e0eb 100644 --- a/go/internal/loadpoint/deadline_need.go +++ b/go/internal/loadpoint/deadline_need.go @@ -73,15 +73,25 @@ func LimitPower(st State, steps []float64) (minW, maxW float64, out []float64) { // CommandedEVPowerW uses the actual offers after every charger clamp. A // charger need not have started drawing for its offer to occupy the fuse. func CommandedEVPowerW(states []State) float64 { + var total float64 + for _, w := range CommandedEVPowerByDriver(states) { + total += math.Max(0, w) + } + return total +} + +// CommandedEVPowerByDriver is each plugged charger's known offer, 0 W +// included, so a stopped car is still present. A driver with several +// loadpoints counts its highest offer. +func CommandedEVPowerByDriver(states []State) map[string]float64 { byDriver := make(map[string]float64, len(states)) for _, st := range states { - if st.PluggedIn && st.CommandedKnown && finite(st.CommandedW) && st.CommandedW > byDriver[st.DriverName] { + if !st.PluggedIn || !st.CommandedKnown || !finite(st.CommandedW) { + continue + } + if w, seen := byDriver[st.DriverName]; !seen || st.CommandedW > w { byDriver[st.DriverName] = st.CommandedW } } - var total float64 - for _, w := range byDriver { - total += w - } - return total + return byDriver }