From f81845f1ad86a2b7e5f0b1ce4dbf2bdf249f4814 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 17:32:49 +0200 Subject: [PATCH] fix(control): the battery leaves the hour's overage to the EV cut Core sent (#1674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Göran, 17 October 14:22:38 UTC: an overage of the import ceiling stopped the car, and in the same tick the battery's fuse guard discharged for the same watts. When the stop reached the meter the battery was still discharging, and the site exported about 4 kW for ~10 s. fuseReliefFromSnapshot now keeps the fuse apart from the hour's import limit. The fuse and the per-phase relief act on every reading, as before. Where the hour's limit is tighter, its overage less the EV cut sent during that overage asks for relief. The EV guard notes the pool's offer on the first reading of a run over the hour's limit; the cut is that offer less this tick's offers. Our own commands only, no charger or battery reading. The cut counts while the run is younger than 20 s. A cut that reaches the meter ends the run, so a later overage gets relief at once, and a car that draws less than its offer cannot hold an overage through a second cut. The charge guard is unchanged, so a charging battery still yields to the car. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/battery-counts-ev-cut.md | 5 + go/internal/control/dispatch.go | 15 +- .../control/ev_battery_sequence_test.go | 250 +++++++++++++++++- go/internal/control/ev_fuse_budget.go | 16 +- 4 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 .changeset/battery-counts-ev-cut.md diff --git a/.changeset/battery-counts-ev-cut.md b/.changeset/battery-counts-ev-cut.md new file mode 100644 index 000000000..fbdb4c1c4 --- /dev/null +++ b/.changeset/battery-counts-ev-cut.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When an overage of the import ceiling makes Core cut or stop the car, the battery no longer discharges for the same watts. Before, both answered the overage, and when the car's cut reached the meter 5–15 seconds later the battery was still discharging, so the site exported for several seconds. The hour's budget now waits for a cut Core sends during that overage, for at most 20 seconds. The battery covers any part the cut does not remove, a new overage after the cut has shown, and all of it if the car has not followed in time. The fuse still gets battery relief on every reading. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index a896b5285..c5156008d 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -4450,10 +4450,17 @@ func fuseReliefFromSnapshot( currentGrid = meter.SmoothedW } - // The effective import ceiling includes both the fuse margin and an - // optional tariff peak. Per-phase relief remains fuse-only and may act only - // while the matching aggregate meter sample is on the import side. - overage := predicted - state.effectiveImportCeilingW(fuseMaxW) + // The fuse acts on every reading. The hour's import limit is a budget: + // the part of its overage that an EV cut we sent will remove waits for + // that cut while the meter may not show it yet. Relief for the same watts + // would discharge the battery into export once the cut lands (#1674). + // Per-phase relief remains fuse-only and may act only while the matching + // aggregate meter sample is on the import side. + fuseW := math.Max(0, fuseMaxW-state.fuseSafetyMarginW()) + overage := predicted - fuseW + if ceilingW := state.effectiveImportCeilingW(fuseMaxW); ceilingW < fuseW { + overage = math.Max(overage, predicted-ceilingW-state.pendingEVCutW()) + } if currentGrid >= 0 { relief := perPhaseOverageReadingW(meter, state) * float64(state.SiteFusePhases) if relief > 0 && accountPhaseTargetDelta { diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index 401cc846f..ef2730047 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -170,11 +170,21 @@ type evBatterySite struct { evW float64 commands, offers []evTimed maxGridW float64 + chargerDelay time.Duration + carMaxW float64 // 0: the car takes its whole offer + batteryDelay time.Duration // 0: the battery follows within a tick + batteryCommands []evTimed + meterW, targetW []evTimed // every site reading and battery target } func newEVBatterySite(t *testing.T, houseW float64) *evBatterySite { t.Helper() - s := &evBatterySite{t: t, now: time.Now().Truncate(time.Second), store: telemetry.NewStore(), houseW: houseW} + return newEVBatterySiteAt(t, houseW, time.Now().Truncate(time.Second)) +} + +func newEVBatterySiteAt(t *testing.T, houseW float64, start time.Time) *evBatterySite { + t.Helper() + s := &evBatterySite{t: t, now: start, store: telemetry.NewStore(), houseW: houseW, chargerDelay: 5 * time.Second} config := loadpoint.Config{ID: "garage", DriverName: "charger", MinChargeW: 4140, MaxChargeW: 10350, PhaseMode: "3p", AllowedStepsW: []float64{0, 4140, 4830, 5520, 6210, 6900, 7590, 8280, 8970, 9660, 10350}} s.mgr = loadpoint.NewManager() @@ -200,7 +210,7 @@ func newEVBatterySite(t *testing.T, houseW float64) *evBatterySite { } if cmd.Action == "ev_set_current" { s.offers = append(s.offers, evTimed{s.now, cmd.PowerW}) - s.commands = append(s.commands, evTimed{s.now.Add(5 * time.Second), cmd.PowerW}) + s.commands = append(s.commands, evTimed{s.now.Add(s.chargerDelay), cmd.PowerW}) } return nil }) @@ -233,11 +243,19 @@ func (s *evBatterySite) step() { s.now = s.now.Add(time.Second) for len(s.commands) > 0 && !s.commands[0].at.After(s.now) { s.evW, s.commands = s.commands[0].w, s.commands[1:] + if s.carMaxW > 0 { + s.evW = math.Min(s.evW, s.carMaxW) + } + } + for len(s.batteryCommands) > 0 && !s.batteryCommands[0].at.After(s.now) { + s.batteryW, s.batteryCommands = s.batteryCommands[0].w, s.batteryCommands[1:] } s.maxGridW = math.Max(s.maxGridW, s.gridW()) + s.meterW = append(s.meterW, evTimed{s.now, s.gridW()}) s.emit("pixii", telemetry.DerBattery, s.batteryW) s.emit("charger", telemetry.DerEV, s.evW) 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.SetEVFuseDemands(s.ctrl.FuseDemands()) @@ -245,7 +263,12 @@ func (s *evBatterySite) step() { if len(targets) != 1 { s.t.Fatalf("targets %+v", targets) } - s.batteryW = targets[0].TargetW // A local battery follows within a tick. + s.targetW = append(s.targetW, evTimed{s.now, targets[0].TargetW}) + if s.batteryDelay <= 0 { + s.batteryW = targets[0].TargetW // A local battery follows within a tick. + return + } + s.batteryCommands = append(s.batteryCommands, evTimed{s.now.Add(s.batteryDelay), targets[0].TargetW}) } func (s *evBatterySite) run(d time.Duration) { @@ -261,6 +284,20 @@ func (s *evBatterySite) offer() float64 { return s.offers[len(s.offers)-1].w } +func (s *evBatterySite) hourLimitW() float64 { + limitW, _, _ := s.state.importHourLimitsW() + return limitW +} + +func since(log []evTimed, at time.Time) []evTimed { + for i, e := range log { + if !e.at.Before(at) { + return log[i:] + } + } + return nil +} + // A 2 kW house step lands while the battery and the car both charge. EV // first: the car must not stop and the battery must yield. The battery // answers within a tick, inside the fuse's heat budget, so the car keeps its @@ -309,3 +346,210 @@ func TestEVFirstHouseStepWhileBatteryAndEVCharge(t *testing.T) { t.Fatalf("grid %.0f W above 24 A after the step", s.gridW()) } } + +// newIdleBatteryEVSite starts at a clock hour with the battery planned idle, +// so only the fuse guard moves it. +func newIdleBatteryEVSite(t *testing.T, houseW, ceilingW float64) *evBatterySite { + t.Helper() + start := time.Date(2026, 10, 17, 16, 0, 0, 0, time.Local) + s := newEVBatterySiteAt(t, houseW, start) + s.state.PeakImportCeilingW = ceilingW + idle := SlotDirective{SlotStart: start, SlotEnd: start.Add(time.Hour), Strategy: "arbitrage"} + s.state.SlotDirective = func(time.Time) (SlotDirective, bool) { return idle, true } + return s +} + +// A sunny afternoon under a 7.5 kW import ceiling: PV covers the house and +// 3 kW of the car, so the car draws 9,660 W, more than the ceiling. +func newCeilingEVSite(t *testing.T) *evBatterySite { + t.Helper() + s := newIdleBatteryEVSite(t, -3000, 7500) + s.run(5 * time.Minute) + if s.offer() != 9660 || s.evW != 9660 || s.batteryW != 0 { + t.Fatalf("setup: offer %.0f W draw %.0f W battery %.0f W", s.offer(), s.evW, s.batteryW) + } + return s +} + +// #1674, Göran (segran2: Easee Cloud, Pixii, 25 A), 17 Oct 2026 14:22:38 +// UTC. An overage of the import ceiling stopped the car, and in the same +// tick the battery's fuse guard discharged for the same watts. When the stop +// reached the meter the battery was still discharging, and the site exported +// about 4 kW for ~10 s. The hour's budget waits for the stop we commanded, +// so the battery leaves those watts alone and nothing swings into export. +func TestCeilingOverageLeftToCommandedEVCutDoesNotExport(t *testing.T) { + s := newCeilingEVSite(t) + s.chargerDelay, s.batteryDelay = 10*time.Second, 3*time.Second + stepAt := s.now + s.houseW += 7000 // The sun goes and the sauna starts: 13,660 W. + s.run(40 * time.Second) + lowestMeter, lowestBattery := math.Inf(1), math.Inf(1) + for _, m := range since(s.meterW, stepAt) { + lowestMeter = math.Min(lowestMeter, m.w) + } + for _, b := range since(s.targetW, stepAt) { + lowestBattery = math.Min(lowestBattery, b.w) + } + t.Logf("offer %.0f W; lowest meter %.0f W, lowest battery target %.0f W", s.offer(), lowestMeter, lowestBattery) + if s.offer() != 0 || s.evW != 0 { + t.Fatalf("the car did not stop: offer %.0f W draw %.0f W", s.offer(), s.evW) + } + if lowestMeter < 0 { + t.Fatalf("the site exported %.0f W when the car's stop landed", -lowestMeter) + } + if lowestBattery < 0 { + t.Fatalf("the battery discharged %.0f W for watts the car's stop removes", -lowestBattery) + } + if s.maxGridW > 16560 { + t.Fatalf("grid %.0f W over the fuse while waiting for the car", s.maxGridW) + } + // The stop went out on the first reading over the ceiling. + for _, m := range since(s.meterW, stepAt.Add(time.Second+s.chargerDelay)) { + if m.w > 7500 { + t.Fatalf("%v: meter %.0f W after the car's stop landed", m.at.Sub(stepAt), m.w) + } + } +} + +// A cut may hold the hour's overage for evSettle and no longer. A charger +// that has not followed by then leaves the overage to the battery until the +// cut shows. +func TestCeilingOverageGetsBatteryReliefWhenEVCutIsLate(t *testing.T) { + s := newCeilingEVSite(t) + s.chargerDelay = time.Minute + stepAt := s.now + s.houseW += 7000 + s.run(time.Minute) // The stop lands one second later. + reliefBy := stepAt.Add(time.Second + evSettle) + t.Logf("battery %.0f W from %v; meter %.0f W", since(s.targetW, reliefBy)[0].w, reliefBy.Sub(stepAt), s.gridW()) + for _, b := range since(s.targetW, reliefBy) { + if b.w >= 0 { + t.Fatalf("%v: no battery relief %v after the stop was sent (target %.0f W)", b.at.Sub(stepAt), b.at.Sub(stepAt.Add(time.Second)), b.w) + } + } + for _, m := range since(s.meterW, reliefBy.Add(time.Second)) { + if m.w > s.hourLimitW()+50 { + t.Fatalf("%v: meter %.0f W, the battery did not hold the hour's limit of %.0f W", m.at.Sub(stepAt), m.w, s.hourLimitW()) + } + } +} + +// A cut that has reached the meter removes nothing more. A new overage +// after it gets battery relief as soon as the smoothed reading shows it. +func TestCeilingOverageAfterTheCutShowsGetsBatteryRelief(t *testing.T) { + s := newCeilingEVSite(t) + s.houseW += 7000 + s.run(15 * time.Second) // The stop landed after 5 s. + if s.evW != 0 || s.gridW() > 7500 { + t.Fatalf("setup: draw %.0f W meter %.0f W", s.evW, s.gridW()) + } + stepAt := s.now + s.houseW += 4500 // 8,500 W, over the hour's limit with the car off. + s.run(6 * time.Second) + t.Logf("battery %.0f W after 2 s; meter %.0f W, limit %.0f W", since(s.targetW, stepAt.Add(2*time.Second))[0].w, s.gridW(), s.hourLimitW()) + for _, b := range since(s.targetW, stepAt.Add(2*time.Second)) { + if b.w >= 0 { + t.Fatalf("%v: no battery relief for an overage no cut removes (target %.0f W)", b.at.Sub(stepAt), b.w) + } + } + if s.gridW() > s.hourLimitW()+50 { + t.Fatalf("meter %.0f W over the hour's limit of %.0f W", s.gridW(), s.hourLimitW()) + } +} + +// The car takes 2 kW of a 10,350 W offer, so a cut only lowers the offer. +// The overage may wait evSettle for the first cut, not again for the stop +// that follows it. +func TestCeilingOverageWaitsOnceWhenTheCarDrawsLessThanItsOffer(t *testing.T) { + s := newIdleBatteryEVSite(t, -3000, 7500) + s.carMaxW = 2000 + s.run(5 * time.Minute) + if s.offer() != 10350 || s.evW != 2000 { + t.Fatalf("setup: offer %.0f W draw %.0f W", s.offer(), s.evW) + } + stepAt := s.now + s.houseW += 12600 // 11,600 W, a cut's worth over the hour's limit. + s.run(time.Minute) + reliefBy := stepAt.Add(time.Second + evSettle) + t.Logf("offer %.0f W; battery %.0f W from %v", s.offer(), since(s.targetW, reliefBy)[0].w, reliefBy.Sub(stepAt)) + for _, b := range since(s.targetW, reliefBy) { + if b.w >= 0 { + t.Fatalf("%v: no battery relief after the overage waited %v (target %.0f W)", b.at.Sub(stepAt), evSettle, b.w) + } + } + for _, m := range since(s.meterW, reliefBy.Add(time.Second)) { + if m.w > s.hourLimitW()+50 { + t.Fatalf("%v: meter %.0f W, the battery did not hold the hour's limit of %.0f W", m.at.Sub(stepAt), m.w, s.hourLimitW()) + } + } +} + +// The fuse never waits for a car. A house step past the heat budget's +// instant limit (31 A on the 25 A fuse) cuts the car at once and gets +// battery relief on the first reading, which it keeps until the car's cut +// shows on the meter. Then the battery lets go, and nothing exports. +func TestFuseOverageGetsBatteryReliefUntilEVCutShows(t *testing.T) { + s := newIdleBatteryEVSite(t, 3000, 0) + s.run(5 * time.Minute) + if s.offer() != 10350 || s.batteryW != 0 { + t.Fatalf("setup: offer %.0f W battery %.0f W", s.offer(), s.batteryW) + } + stepAt := s.now + s.houseW += 8000 // 21,350 W, 31 A on each phase. + s.run(15 * time.Second) + cutShows := stepAt.Add(time.Second + s.chargerDelay) + t.Logf("first battery target %.0f W; offer %.0f W", since(s.targetW, stepAt.Add(time.Second))[0].w, s.offer()) + for _, b := range since(s.targetW, stepAt.Add(time.Second)) { + if b.at.Before(cutShows) && b.w >= 0 { + t.Fatalf("%v: no battery relief before the car's cut shows (target %.0f W)", b.at.Sub(stepAt), b.w) + } + } + for _, m := range since(s.meterW, stepAt.Add(2*time.Second)) { + if m.w > 17250 || m.w < 0 { + t.Fatalf("%v: meter %.0f W, want within the 25 A breaker and no export", m.at.Sub(stepAt), m.w) + } + } + if s.offer() >= 10350 || s.batteryW != 0 { + t.Fatalf("after the cut: offer %.0f W battery %.0f W, want a cut car and an idle battery", s.offer(), s.batteryW) + } +} + +// Only the hour's import limit leaves watts to an EV cut, only those the cut +// removes, and only during the first evSettle of the overage it was sent +// for. The fuse acts on every reading, on each phase too. +func TestFuseReliefLeavesOnlyCeilingWattsToCommandedEVCut(t *testing.T) { + now := time.Date(2026, 10, 17, 16, 30, 0, 0, time.Local) + hotPhase := json.RawMessage(`{"l1_a":25,"l2_a":18,"l3_a":18}`) + cases := []struct { + name string + gridW, commandedW float64 + overFor time.Duration // 0: the meter shows no run over the limit + data json.RawMessage + wantW float64 + }{ + {"ceiling overage the stop removes", 13660, 0, 5 * time.Second, nil, 0}, + {"fuse overage", 18000, 0, 5 * time.Second, nil, 18000 - 16560}, + {"phase over the fuse", 13660, 0, 5 * time.Second, hotPhase, (25 - 24) * 230 * 3}, + {"ceiling overage beyond the cut", 13660, 4140, 5 * time.Second, nil, 13660 - 7500 - (9660 - 4140)}, + {"no cut", 13660, 9660, 5 * time.Second, nil, 13660 - 7500}, + {"overage older than evSettle", 13660, 0, evSettle, nil, 13660 - 7500}, + {"cut sent before this overage", 13660, 0, 0, nil, 13660 - 7500}, + } + for _, tc := range cases { + 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 + if tc.overFor > 0 { + st.evLimit.overSince, st.evLimit.overOfferW = now.Add(-tc.overFor), 9660 + } + st.EVCommandedW = tc.commandedW + targets := []DispatchTarget{{Driver: "pixii"}} + batteries := map[string]*telemetry.DerReading{"pixii": {}} + meter := &telemetry.DerReading{SmoothedW: tc.gridW, Data: tc.data} + got := fuseReliefFromSnapshot(targets, batteries, meter, st, 17250, false) + if math.Abs(math.Max(0, got)-tc.wantW) > 1e-6 { + t.Errorf("%s: relief %.0f W, want %.0f W", tc.name, got, tc.wantW) + } + } +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index a480e37e0..20cfdf39a 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -88,6 +88,7 @@ type evMeterLimit struct { 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 } @@ -158,7 +159,7 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo lim.overSince = time.Time{} } else { if lim.overSince.IsZero() || gap { - lim.overSince = at + lim.overSince, lim.overOfferW = at, pool.offerW } if !r.hourly || gap || -r.limitW >= step || at.Sub(lim.overSince) >= evSettle { cutW = math.Min(cutW, r.limitW) @@ -314,6 +315,19 @@ func (s *State) pendingBatteryCommandsW(now time.Time) float64 { return pending } +// 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. +func (s *State) pendingEVCutW() float64 { + lim := &s.evLimit + if lim.overSince.IsZero() || s.now().Sub(lim.overSince) >= evSettle || !finiteFuseValue(s.EVCommandedW) { + return 0 + } + return math.Max(0, lim.overOfferW-math.Max(0, s.EVCommandedW)) +} + // evPoolFor sums the plugged managed chargers. Before the first command after // a restart their offer is unknown, and the pool counts as idle. func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Config) (evPool, bool) {