Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ev-cut-pool-change.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions go/internal/control/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 113 additions & 4 deletions go/internal/control/ev_battery_sequence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand All @@ -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)
}
}
39 changes: 28 additions & 11 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
22 changes: 16 additions & 6 deletions go/internal/loadpoint/deadline_need.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading