diff --git a/.changeset/ev-cut-pool-change.md b/.changeset/ev-cut-pool-change.md new file mode 100644 index 000000000..749cfaeec --- /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 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 ef2730047..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} @@ -553,3 +553,112 @@ 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.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)) + } + // 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) + } +} + +// 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 20cfdf39a..ff0755b1d 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -86,15 +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 - 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 + offers map[string]float64 // per charger driver } // EVFuseBudget returns the managed EVs' common hard cap, which the loadpoint @@ -159,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) @@ -315,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 @@ -356,6 +372,7 @@ func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Confi if !plugged { return pool, false } + 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 }