From b5bbbdec93a69b8594e5930cbe264ab3f4d0cb4f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 10:41:41 +0200 Subject: [PATCH 1/3] fix(control): a cut on its way covers the overage it was sent for Easee Cloud applies a cut after 30-120 s, but the EV fuse cap took each cut from the highest offer of the last 20 s. Once that window passed, the meter still showed the old draw and Core cut again: in the field matrix 10,400 -> 6,900 W, then 0 W after a 6 A kettle step. The same window missed a raise still on its way, so a cut could leave the phase over the limit once the raise landed. The cut now counts our own orders that cannot have shown yet, both ways, from the per-charger, per-phase log of #1776/#1793. A cut on its way covers the overage it was sent for until its charger can have applied it (max(20 s, declared power_max_age_s), 180 s undeclared); Core cuts only what it does not cover. A raise on its way adds to the cut. A cut and every order after it count as not shown; of the orders before it, a raise counts from the lowest. An offer found in force at a restart, which Core did not send in this run, counts as shown. Once a phase's filtered current passes 1.25 x In, nothing on its way holds back a cut. Over the fuse the cap never rises above the latest offers. Rises, starts and the hour's import ceiling are unchanged. Field matrix: 93 of 99 netting rows pass. The other six are cut by the split, which floors 7,600 W to 6,900 W (7600/690*690 = 7599.999...); Co-Authored-By: Claude Opus 5.5 --- .changeset/ev-cut-counts-cut-on-its-way.md | 5 + go/internal/control/ev_fuse_budget.go | 135 ++++++++-- .../control/ev_fuse_cut_netting_test.go | 232 ++++++++++++++++++ 3 files changed, 350 insertions(+), 22 deletions(-) create mode 100644 .changeset/ev-cut-counts-cut-on-its-way.md create mode 100644 go/internal/control/ev_fuse_cut_netting_test.go diff --git a/.changeset/ev-cut-counts-cut-on-its-way.md b/.changeset/ev-cut-counts-cut-on-its-way.md new file mode 100644 index 00000000..57714b95 --- /dev/null +++ b/.changeset/ev-cut-counts-cut-on-its-way.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Charging is no longer cut twice when a cloud charger is slow to apply a cut. Easee Cloud takes 30–120 s to apply a new current, but FTW cut again after 20 s, before the first cut could show on the site meter, so a kettle on top could take the car from 10,400 W to 0 W. FTW now lets a cut it has sent cover the overage until the charger can have applied it: the time the driver declares, or 3 minutes. Only what a new load adds is cut. A raise still on its way now counts in a cut, so the phase is not left over the limit when the raise lands. Once a phase's averaged current passes 1.25 times the fuse rating, which a gG fuse carries for an hour, FTW cuts at once whatever is on its way. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index f0d7f96b..8234d378 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -37,6 +37,12 @@ const evRestartDwell = 35 * time.Second // and rise thresholds. const evPhaseMarginA = 1.0 +// fuseHourFactor is the phase current, as a multiple of the fuse rating, +// that a gG fuse of 16-63 A carries for an hour. A cut on its way covers the +// overage it was sent for only while every phase's filtered current stays +// at or below it. +const fuseHourFactor = 1.25 + // sentCommand is one of our own commands, never a device reading. type sentCommand struct { at time.Time @@ -119,11 +125,50 @@ func (l commandLog) unshown(at time.Time, settle time.Duration) (w, a float64) { return math.Max(0, highW-lowW), math.Max(0, highA-lowA) } +// toCome is what a charger's latest offer will still change in a reading +// taken at `at`, as a cut counts it, in watts and in current per phase: +// positive is a raise on its way, negative a cut on its way. The reading may +// show the offer in force settle before it or one sent since, up to `at`, +// but a cut and every offer after it count as not shown yet: a cut covers +// the overage it was sent for until its charger can have applied it. Of the +// offers before the first cut, a raise counts from the lowest. Before the +// first logged offer the charger counts as before. +func (l commandLog) toCome(at time.Time, settle time.Duration, before sentCommand) (w, a float64) { + from := at.Add(-settle) + settled, latest := before, before + for _, c := range l { + latest = c + if !c.at.After(from) { + settled = c + } + } + low, prev, cutW, cutA := settled, settled, false, false + for _, c := range l { + if !c.at.After(from) || c.at.After(at) { + continue + } + cutW, cutA = cutW || c.w < prev.w, cutA || c.a < prev.a + if !cutW { + low.w = math.Min(low.w, c.w) + } + if !cutA { + low.a = math.Min(low.a, c.a) + } + prev = c + } + return latest.w - low.w, latest.a - low.a +} + // chargerOffers is one charger's offers, kept for the time its driver // declares its power takes to show. type chargerOffers struct { log commandLog settle time.Duration + // found is the offer in force before the limit's first site reading, + // after a restart: Core did not send it in this run. A rise counts the + // charger as 0 before it; a cut takes it as shown, since the reading is + // all Core knows of it. + found sentCommand } // pendingLoad is load we ordered that a site reading may not show yet: @@ -147,6 +192,25 @@ func (s *State) pendingLoad(at time.Time) pendingLoad { return p } +// unshownOrders is what a cut counts beside a reading taken at `at`: rise, +// the load our latest offers add that the reading may not show yet, and +// cut, the load the reading may still show that they remove (toCome). +// latestW is the latest offers. +func (s *State) unshownOrders(at time.Time) (rise, cut pendingLoad, latestW float64) { + for _, c := range s.evLimit.offers { + w, a := c.log.toCome(at, c.settle, c.found) + rise.w, cut.w = rise.w+math.Max(0, w), cut.w+math.Max(0, -w) + for i := range rise.phaseA { + rise.phaseA[i] += math.Max(0, a) + cut.phaseA[i] += math.Max(0, -a) + } + if n := len(c.log); n > 0 { + latestW += c.log[n-1].w + } + } + return rise, cut, latestW +} + // recordEVOffers keeps each plugged charger's offers, with the current each // puts on the phases its command names, for the longer of evSettle and the // power_max_age_s its driver declares, or evUnknownSettle when it declares @@ -172,6 +236,9 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) w := math.Max(0, point.CommandedW) sent := sentCommand{at: now, w: w, a: s.offerPhaseA(w, point.CommandedPhases)} if n := len(c.log); n == 0 || c.log[n-1].w != sent.w || c.log[n-1].a != sent.a { + if n == 0 && !s.evLimit.set { + c.found = sent + } c.log = c.log.keep(sent, read.Add(-c.settle)) } offers[point.ID] = c @@ -236,8 +303,12 @@ type evPool struct { // rises one step on a fresh reading with room for two, at least evSettle // after the last cut. Room counts every // offer sent above what the reading may show as flowing until its charger -// can have applied it; cuts read the meter alone. EVFusePhaseRoomA gives -// the same room per phase to the split between cars. An idle pool starts only after the meter has kept showing +// can have applied it; a cut never frees room before the meter shows it. +// A cut counts our orders both ways: a raise on its way adds to the overage, +// and a cut on its way covers the overage it was sent for until its charger +// can have applied it, while the phases stay at or below fuseHourFactor. +// EVFusePhaseRoomA gives the same room per phase to the split between cars. +// An idle pool starts only after the meter has kept showing // room for its minimum plus a step. Each reading acts once, so a silent // meter can never raise or restart a charge. func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State, configs []loadpoint.Config, fuseMaxW float64) (float64, bool) { @@ -283,7 +354,8 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo if _, high, _, ok := s.evOffers.span(now); ok { peakW = math.Max(peakW, high) } - r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW, s.pendingLoad(at)) + rise, cut, latestW := s.unshownOrders(at) + r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW, s.pendingLoad(at), rise, cut) _, fresh := qualifiedFuseObservation(meter, now) fresh = fresh && r.measured lim.phaseRoomA, lim.phaseRoomKnown = 0, r.phaseRoomKnown @@ -293,11 +365,16 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases // The label follows whichever limit leaves less room. lim.byImport = math.Min(r.limitW, r.riseW) < r.fuseW - // The fuse cuts on every reading, by its heat budget where the meter - // reports phase currents. The hour's budget absorbs a short overage of - // less than a step: it cuts once the overage has lasted evSettle, so one - // reading over the ceiling changes nothing. - cutW := r.fuseCutW + // The fuse cuts on every reading over it, by its heat budget where the + // meter reports phase currents. It takes off the latest offers only what + // the cuts the reading may still show do not cover, plus the rises it may + // not show yet. Over the fuse, the cap never rises. The hour's budget + // absorbs a short overage of less than a step: it cuts once the overage + // has lasted evSettle, so one reading over the ceiling changes nothing. + over, capW, byImport := r.fuseOverW > 0, math.Inf(1), false + if over { + capW = latestW - math.Max(0, r.fuseCutW) + } if r.limitW >= 0 { lim.overSince = time.Time{} } else { @@ -305,19 +382,21 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo 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) + // The meter may still show any offer of the last evSettle. + // Taking the overage from the highest cuts each measured watt once. + hourW := peakW + r.limitW + byImport, capW, over = !over || hourW < capW, math.Min(capW, hourW), true } } - if cutW < 0 { - lim.byImport = cutW < r.fuseCutW - // The meter may still show any offer of the last evSettle. Taking - // the overage from the highest cuts each measured watt once. - lim.capW, lim.roomSince = math.Min(prev, math.Max(0, peakW+cutW)), time.Time{} + if over { + lim.byImport = byImport + lim.capW, lim.roomSince = math.Min(prev, math.Max(0, capW)), time.Time{} if lim.capW < prev { lim.cutAt = now slog.Info("EV hard limit: site meter over limit", "grid_w", gridW, "worst_phase_a", r.worstA, "fuse_heat_a", s.fuseHeat.worstA(), "import_limit_w", s.effectiveImportCeilingW(fuseMaxW), - "overage_w", -cutW, "offer_w", peakW, "cap_w", lim.capW) + "overage_w", r.fuseOverW, "import_overage_w", math.Max(0, -r.limitW), + "unshown_rise_w", rise.w, "unshown_cut_w", cut.w, "offer_w", latestW, "cap_w", lim.capW) } return } @@ -406,7 +485,8 @@ func EVLimitReason(state *State) string { // every reading. The import ceiling's parts are +Inf while it is off. type evMeterRoom struct { fuseW float64 // below the fuse less its margin and the worst phase, read or heat, and what we sent; rises and starts - fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat + fuseOverW float64 // the fuse's measured overage, 0 under it; see fuseHeat + fuseCutW float64 // what the fuse needs cut from the latest offers: the overage and unshown rises, less unshown cuts limitW float64 // below the hour's import limit; negative is an overage riseW float64 // below the level an increase may reach, less what we sent startLevelW float64 // import an idle start may reach: the rise level, at most the ceiling @@ -417,11 +497,12 @@ type evMeterRoom struct { phaseRoomKnown bool // the meter reports phase currents } -func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64, sent pendingLoad) evMeterRoom { +func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64, pending, rise, cut pendingLoad) evMeterRoom { inf, totalW := math.Inf(1), math.Max(0, fuseMaxW-s.fuseSafetyMarginW())-gridW - r := evMeterRoom{fuseW: totalW - sent.w, fuseCutW: totalW, limitW: inf, riseW: inf, startLevelW: inf} + r := evMeterRoom{fuseW: totalW - pending.w, fuseOverW: math.Max(0, -totalW), limitW: inf, riseW: inf, startLevelW: inf} + r.fuseCutW = r.fuseOverW + rise.w - cut.w if limitW, riseW, ok := s.importHourLimitsW(); ok { - r.limitW, r.riseW = limitW-gridW, riseW-gridW-sent.w + r.limitW, r.riseW = limitW-gridW, riseW-gridW-pending.w // A car that could run only on one hour's slack would stop when // the next hour starts at the ceiling. r.startLevelW = math.Min(riseW, s.PeakImportCeilingW) @@ -438,10 +519,20 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW for _, a := range amps { r.worstA = math.Max(r.worstA, a) } - r.fuseW = math.Min(r.fuseW, (limitA-s.fuseHeat.levelA(amps, sent.phaseA))*s.SiteFuseVoltage*phases) + r.fuseW = math.Min(r.fuseW, (limitA-s.fuseHeat.levelA(amps, pending.phaseA))*s.SiteFuseVoltage*phases) r.phaseRoomA, r.phaseRoomKnown = limitA-s.fuseHeat.levelA(amps, [3]float64{}), true - // The phases measure the fuse itself; total import stands in only without them. - r.fuseCutW = -s.fuseHeat.overA(amps, limitA, fuseInstantFactor*s.SiteFuseAmps) * s.SiteFuseVoltage * phases + // The phases measure the fuse itself; total import stands in only + // without them. A single-phase order may land on any phase, so the + // cut takes the most a rise adds to a phase and the least a cut on + // its way takes off one. Past the current a gG fuse carries for an + // hour, nothing on its way holds back a cut. + overA := s.fuseHeat.overA(amps, limitA, fuseInstantFactor*s.SiteFuseAmps) + coverA := min(cut.phaseA[0], cut.phaseA[1], cut.phaseA[2]) + if s.fuseHeat.worstA() > fuseHourFactor*s.SiteFuseAmps { + coverA = 0 + } + r.fuseOverW = overA * s.SiteFuseVoltage * phases + r.fuseCutW = (overA + max(rise.phaseA[0], rise.phaseA[1], rise.phaseA[2]) - coverA) * s.SiteFuseVoltage * phases } return r } diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go new file mode 100644 index 00000000..b84c56a9 --- /dev/null +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -0,0 +1,232 @@ +package control + +import ( + "encoding/json" + "fmt" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// cutsSince returns the offers below the one before each, from `at` on. +func (s *evMeterSite) cutsSince(at time.Time) []evTimed { + var out []evTimed + prev := math.Inf(1) + for _, o := range s.offers { + if !o.at.Before(at) && o.w < prev { + out = append(out, o) + } + prev = o.w + } + return out +} + +// Field matrix, netting rows: Easee Cloud applies a cut after 30-120 s. A +// house step on L1 takes the car from 15 A to 13 A; while that cut travels, +// a kettle adds 3 A on L1. Core cuts once for the step and once for the +// kettle's part. On master the second cut came 20 s after the first, before +// either could show, and the kettle's took the car lower still. +func TestEVFuseSlowChargerCutCoversItsOverage(t *testing.T) { + for _, tc := range []struct { + delay time.Duration + declared float64 + }{ + {40 * time.Second, 180}, {2 * time.Minute, 180}, {time.Minute, 0}, + } { + t.Run(fmt.Sprintf("delay_%v/declared_%.0fs", tc.delay, tc.declared), func(t *testing.T) { + s := fuseHeatSite(t) + s.chargerDelay, s.chargerMaxAgeS = tc.delay, tc.declared + s.run(time.Minute) + stepAt := s.now + s.addHouseA(6, 0, 0) // L1 26 A + took, offer := s.firstCut(2 * time.Minute) + if took < 0 || offer != 8970 { + t.Fatalf("step cut after %v to %.0f W, want 8,970 W", took, offer) + } + s.run(15 * time.Second) + s.addHouseA(3, 0, 0) // L1 29 A while the cut travels + s.run(tc.delay + time.Minute) + cuts := offerWatts(s.cutsSince(stepAt)) + if len(cuts) != 2 || cuts[0] != 8970 || cuts[1] != 6900 { + t.Fatalf("cuts %v, want 8,970 W for the step and 6,900 W for the kettle", cuts) + } + if a := s.phaseA(); s.evW != 6900 || a[0] > 24 { + t.Fatalf("car %.0f W, L1 %.1f A after both cuts landed", s.evW, a[0]) + } + }) + } +} + +// cutRig feeds EVFuseBudget site readings one second apart and applies its +// cap to the charger's offer, in 1 A steps, as the controller does. +type cutRig struct { + t *testing.T + store *telemetry.Store + state *State + now time.Time + offerW float64 +} + +func newCutRig(t *testing.T, offerW, declaredS float64) *cutRig { + t.Helper() + store, state, _, _ := evMeterBudgetFixture(t, 0, nil, offerW, "3p") + r := &cutRig{t: t, store: store, state: state, now: time.Unix(1_792_246_834, 0), offerW: offerW} + state.clock = func() time.Time { return r.now } + data, _ := json.Marshal(map[string]any{"power_max_age_s": declaredS}) + store.Update("charger", telemetry.DerEV, 0, nil, data) + return r +} + +// read delivers one reading and returns the cap. +func (r *cutRig) read(phaseA [3]float64) float64 { + r.t.Helper() + r.now = r.now.Add(time.Second) + data, _ := json.Marshal(map[string]any{"l1_a": phaseA[0], "l2_a": phaseA[1], "l3_a": phaseA[2]}) + r.store.Update("meter", telemetry.DerMeter, (phaseA[0]+phaseA[1]+phaseA[2])*230, nil, data) + r.store.Get("meter", telemetry.DerMeter).UpdatedAt = r.now + _, _, points, configs := evMeterBudgetFixture(r.t, 0, nil, r.offerW, "3p") + points[0].CommandedPhases = 3 + capW, _ := EVFuseBudget(r.store, r.state, points, configs, 17250) + if capW < r.offerW { + r.offerW = math.Floor(capW/690) * 690 + } + return capW +} + +// A raise on its way adds to the cut. The car draws 10 A while its 14 A +// raise travels, and the house adds 8 A on L1: 26 A on the 24 A working +// limit. Once the heat budget is spent, the cut takes the 2 A the meter +// shows and the 4 A it does not show yet, so L1 ends at 24 A when the raise +// lands. Taking the overage from the raise alone left L1 at 28 A. +func TestEVFuseRaiseOnItsWayAddsToCut(t *testing.T) { + r := newCutRig(t, 6900, 180) + for range 300 { + r.read([3]float64{18, 15, 15}) // 8 A of house on L1, 5 A on the others + } + r.offerW = 9660 // the raise to 14 A, sent with room for it + r.read([3]float64{18, 15, 15}) + var cutAt int + for i := 1; i <= 150 && r.offerW == 9660; i++ { + r.read([3]float64{26, 15, 15}) + cutAt = i + } + if r.offerW != 5520 { + t.Fatalf("offer %.0f W after %d s over, want 9,660 W less 2 A measured and 4 A on its way = 5,520 W", r.offerW, cutAt) + } + for range 60 { + if r.read([3]float64{26, 15, 15}); r.offerW != 5520 { + t.Fatalf("cut again to %.0f W while the first cut travels", r.offerW) + } + } +} + +// A cut that has not shown once its charger can have applied it covers +// nothing more, and the next reading over the limit cuts again: after the +// declared response time, or after evUnknownSettle when nothing is declared. +func TestEVFuseCutRetriedAfterResponseTime(t *testing.T) { + for _, tc := range []struct { + declared float64 + want time.Duration + }{ + {30, 30 * time.Second}, {180, 3 * time.Minute}, {0, evUnknownSettle}, + } { + t.Run(fmt.Sprintf("declared_%.0fs", tc.declared), func(t *testing.T) { + s := fuseHeatSite(t) + s.chargerMaxAgeS, s.chargerIgnores = tc.declared, true + s.run(time.Minute) + stepAt := s.now + s.addHouseA(6, 0, 0) // L1 26 A, under 1.25 x 25 A + if took, offer := s.firstCut(2 * time.Minute); took < 0 || offer != 8970 { + t.Fatalf("step cut after %v to %.0f W", took, offer) + } + s.run(tc.want + 10*time.Second) + cuts := s.cutsSince(stepAt) + if len(cuts) != 2 { + t.Fatalf("cuts %v, want a second one after %v", offerWatts(cuts), tc.want) + } + if gap := cuts[1].at.Sub(cuts[0].at); gap < tc.want || gap > tc.want+5*time.Second { + t.Fatalf("second cut %v after the first, want %v", gap, tc.want) + } + }) + } +} + +// Severe overage cuts whatever is on its way. Above the instant factor the +// first cut goes at once. While that cut travels, L1 stays at 33 A; once its +// filtered current passes 1.25 x 25 A, the current a gG fuse carries for an +// hour, Core cuts again without waiting the charger's 180 s. +func TestEVFuseSevereOverageCutsWhateverIsOnItsWay(t *testing.T) { + s := fuseHeatSite(t) + s.chargerMaxAgeS, s.chargerIgnores = 180, true + s.run(time.Minute) + stepAt := s.now + s.addHouseA(13, 0, 0) // L1 33 A + if took, offer := s.firstCut(5 * time.Second); took != 0 || offer != 4140 { + t.Fatalf("instant cut after %v to %.0f W, want at once to 4,140 W", took, offer) + } + s.run(150 * time.Second) + cuts := s.cutsSince(stepAt) + if len(cuts) != 2 || cuts[1].w != 0 { + t.Fatalf("cuts %v, want a second to 0 W", offerWatts(cuts)) + } + // From 20 A the filtered current passes 31.25 A after 109 s at 33 A. + if gap := cuts[1].at.Sub(cuts[0].at); gap < 100*time.Second || gap > 115*time.Second { + t.Fatalf("second cut %v after the first, want once the heat passes 1.25 x In", gap) + } +} + +// The cost of trusting a cut on its way. A fast charger that declares +// nothing applies a cut in 5 s, but Core counts it as travelling for 180 s. +// A second step on L1 inside that time is covered by the cut that already +// landed, so L1 sits over the working limit until 180 s after the cut. It +// stays under 1.25 x In, and the heat rule bounds anything above. +func TestEVFuseLandedCutCoversALaterStepForAtMostItsResponseTime(t *testing.T) { + s := fuseHeatSite(t) + s.run(time.Minute) + stepAt := s.now + s.addHouseA(11, 0, 0) // L1 31 A: instant cut of 7 A, lands in 5 s + if took, offer := s.firstCut(5 * time.Second); took != 0 || offer != 5520 { + t.Fatalf("instant cut after %v to %.0f W, want 5,520 W", took, offer) + } + s.run(30 * time.Second) + s.addHouseA(5, 0, 0) // L1 29 A with the car's cut landed + var over time.Duration + var peak float64 + for range 4 * 60 { + s.step() + if a := s.phaseA()[0]; a > 24 { + over += time.Second + peak = math.Max(peak, a) + } + } + cuts := s.cutsSince(stepAt) + if len(cuts) != 2 || cuts[1].at.Sub(cuts[0].at) > evUnknownSettle+5*time.Second { + t.Fatalf("cuts %v, want a second once the first stops covering", offerWatts(cuts)) + } + if over > evUnknownSettle || peak > fuseHourFactor*s.state.SiteFuseAmps { + t.Fatalf("L1 over the working limit for %v, up to %.1f A", over, peak) + } + t.Logf("L1 at %.1f A over the working limit for %v", peak, over) +} + +// After a restart, the offer Core finds in force is one it did not send in +// this run. A cut takes it as shown: the first reading over the limit trims +// the overage from it, and while that cut travels to a slow charger no +// further reading counts the found offer as a raise on its way. +func TestEVFuseRestartTakesFoundOfferAsShown(t *testing.T) { + s := fuseHeatSite(t) + s.chargerDelay, s.chargerMaxAgeS = time.Minute, 180 + s.addHouseA(6, 0, 0) + s.run(30 * time.Second) + s.boot() + bootAt := s.now + if took, offer := s.firstCut(5 * time.Second); took != 0 || offer != 8970 { + t.Fatalf("restart at 26 A cut after %v to %.0f W, want the first reading to 8,970 W", took, offer) + } + s.run(50 * time.Second) + if cuts := offerWatts(s.cutsSince(bootAt)); len(cuts) != 1 { + t.Fatalf("cuts %v while the first travels", cuts) + } +} From 072d04dd69582fb08425e0f72a4d414ca074b6e1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 11:05:19 +0200 Subject: [PATCH 2/3] fix(control): a cut covers only its own run, on known phases Review of the first commit found three ways a cut on its way could cover more than it removes: - A cut that landed covered a later step on the same phase for the charger's whole response time: 180 s for a fast charger that declares nothing, such as OCPP. A cut now covers only within the run over the limit it was sent in. The run ends once every phase has read under the working limit for evSettle; a short dip, as battery relief gives, does not end it. Probe: 2 min 35 s over the limit becomes 22 s, as master. - A car that draws less than its order retried each cut after 180 s (10 min 36 s over the limit in the review's case). Once the run's first cut has outlasted its charger's response time, cuts cover for evSettle, as before: 5 min 16 s. - A change of phases read as a cut in amps per phase: 15 A on one phase to 6 A on three covered 9 A on every phase. Orders now carry their phase count; across a change the latest order counts in full as a raise. A single-phase cut covers no phase when other chargers share the pool. Without phase currents Core cannot see the hottest phase, so there a cut covers for evSettle, as before. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/ev_fuse_budget.go | 155 ++++++++++++------ .../control/ev_fuse_cut_netting_test.go | 143 ++++++++++++---- 2 files changed, 219 insertions(+), 79 deletions(-) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 8234d378..97945793 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -48,6 +48,7 @@ type sentCommand struct { at time.Time w float64 a float64 // EV offers: current on each phase it may land on + n int // EV offers: the phases it lands on, 1 or the site's } // commandLog keeps a device's commands for evSettle, plus the one in force @@ -128,13 +129,13 @@ func (l commandLog) unshown(at time.Time, settle time.Duration) (w, a float64) { // toCome is what a charger's latest offer will still change in a reading // taken at `at`, as a cut counts it, in watts and in current per phase: // positive is a raise on its way, negative a cut on its way. The reading may -// show the offer in force settle before it or one sent since, up to `at`, -// but a cut and every offer after it count as not shown yet: a cut covers -// the overage it was sent for until its charger can have applied it. Of the -// offers before the first cut, a raise counts from the lowest. Before the -// first logged offer the charger counts as before. -func (l commandLog) toCome(at time.Time, settle time.Duration, before sentCommand) (w, a float64) { - from := at.Add(-settle) +// show the offer in force at from or one sent since, up to `at`, but a cut +// and every offer after it count as not shown yet. Of the offers before the +// first cut, a raise counts from the lowest. An offer on other phases than +// the latest says nothing of the latest's phases, so the latest then counts +// in full as a raise on each. Before the first logged offer the charger +// counts as before. +func (l commandLog) toCome(from, at time.Time, before sentCommand) (w, a float64) { settled, latest := before, before for _, c := range l { latest = c @@ -143,10 +144,12 @@ func (l commandLog) toCome(at time.Time, settle time.Duration, before sentComman } } low, prev, cutW, cutA := settled, settled, false, false + otherPhases := settled.a > 0 && settled.n != latest.n for _, c := range l { if !c.at.After(from) || c.at.After(at) { continue } + otherPhases = otherPhases || c.a > 0 && c.n != latest.n cutW, cutA = cutW || c.w < prev.w, cutA || c.a < prev.a if !cutW { low.w = math.Min(low.w, c.w) @@ -156,6 +159,9 @@ func (l commandLog) toCome(at time.Time, settle time.Duration, before sentComman } prev = c } + if otherPhases { + low.a = 0 + } return latest.w - low.w, latest.a - low.a } @@ -194,21 +200,45 @@ func (s *State) pendingLoad(at time.Time) pendingLoad { // unshownOrders is what a cut counts beside a reading taken at `at`: rise, // the load our latest offers add that the reading may not show yet, and -// cut, the load the reading may still show that they remove (toCome). -// latestW is the latest offers. -func (s *State) unshownOrders(at time.Time) (rise, cut pendingLoad, latestW float64) { - for _, c := range s.evLimit.offers { - w, a := c.log.toCome(at, c.settle, c.found) - rise.w, cut.w = rise.w+math.Max(0, w), cut.w+math.Max(0, -w) - for i := range rise.phaseA { - rise.phaseA[i] += math.Max(0, a) - cut.phaseA[i] += math.Max(0, -a) +// cover, the load it may still show that our cuts on their way take off +// (toCome). latestW is the latest offers. A cut covers only within the run +// over the limit it was sent in, for its charger's response time: once the +// meter has shown every phase under the limit for evSettle, a cut sent +// before counts as shown. Once the run's first cut has outlasted that time, a cut covers for +// evSettle, as before; so it does without phase currents, where Core +// cannot see the hottest phase. A single-phase cut covers on the phase +// it lands on, which Core does not know, so with other chargers in the pool +// it covers no phase. +func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingLoad, latestW float64) { + lim := &s.evLimit + for _, c := range lim.offers { + w, a := c.log.toCome(at.Add(-c.settle), at, c.found) + coverFor := c.settle + if !perPhase || !lim.fuseCutAt.IsZero() && at.Sub(lim.fuseCutAt) > c.settle { + coverFor = evSettle + } + from := at.Add(-coverFor) + if lim.fuseOverAt.After(from) { + from = lim.fuseOverAt } + runW, runA := c.log.toCome(from, at, c.found) + coverA := math.Max(0, math.Min(-a, -runA)) + var latest sentCommand if n := len(c.log); n > 0 { - latestW += c.log[n-1].w + latest = c.log[n-1] } + if latest.n == 1 && len(lim.offers) > 1 { + coverA = 0 + } + rise.w += math.Max(0, w) + cover.w += math.Max(0, math.Min(-w, -runW)) + for i := range rise.phaseA { + rise.phaseA[i] += math.Max(0, a) + cover.phaseA[i] += coverA + } + latestW += latest.w } - return rise, cut, latestW + return rise, cover, latestW } // recordEVOffers keeps each plugged charger's offers, with the current each @@ -234,8 +264,9 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) } } w := math.Max(0, point.CommandedW) - sent := sentCommand{at: now, w: w, a: s.offerPhaseA(w, point.CommandedPhases)} - if n := len(c.log); n == 0 || c.log[n-1].w != sent.w || c.log[n-1].a != sent.a { + a, phases := s.offerPhaseA(w, point.CommandedPhases) + sent := sentCommand{at: now, w: w, a: a, n: phases} + if n := len(c.log); n == 0 || c.log[n-1].w != sent.w || c.log[n-1].a != sent.a || c.log[n-1].n != sent.n { if n == 0 && !s.evLimit.set { c.found = sent } @@ -247,16 +278,16 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) } // offerPhaseA is the current an offer of w puts on each phase it may land -// on. Unknown phases count as one. -func (s *State) offerPhaseA(w float64, phases int) float64 { - if s.SiteFuseVoltage <= 0 { - return 0 - } +// on, and how many phases it lands on. Unknown phases count as one. +func (s *State) offerPhaseA(w float64, phases int) (float64, int) { n := 1 if phases == 3 && s.SiteFusePhases > 1 { n = s.SiteFusePhases } - return w / (s.SiteFuseVoltage * float64(n)) + if s.SiteFuseVoltage <= 0 { + return 0, n + } + return w / (s.SiteFuseVoltage * float64(n)), n } // EVFusePhaseRoomA is the current the chargers may still add on the @@ -284,6 +315,9 @@ type evMeterLimit struct { phaseRoomA float64 // below the working limit on the busiest phase at readingAt phaseRoomKnown bool // the meter reports phase currents overSince time.Time // first reading of a run over the hour's import limit + fuseOverAt time.Time // first reading of a run with a phase, or the total, over the fuse's working limit + fuseCutAt time.Time // the first cut of that run + fuseUnderAt time.Time // first reading of a stretch under that limit overOffers map[string]float64 // each charger's offer when that run began idleWh float64 // import since roomSince, for the ceiling's start test byImport bool // the hour's import limit, not the fuse, left less room at readingAt @@ -306,7 +340,8 @@ type evPool struct { // can have applied it; a cut never frees room before the meter shows it. // A cut counts our orders both ways: a raise on its way adds to the overage, // and a cut on its way covers the overage it was sent for until its charger -// can have applied it, while the phases stay at or below fuseHourFactor. +// can have applied it, within its run over the limit and while the phases +// stay at or below fuseHourFactor (unshownOrders). // EVFusePhaseRoomA gives the same room per phase to the split between cars. // An idle pool starts only after the meter has kept showing // room for its minimum plus a step. Each reading acts once, so a silent @@ -354,8 +389,7 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo if _, high, _, ok := s.evOffers.span(now); ok { peakW = math.Max(peakW, high) } - rise, cut, latestW := s.unshownOrders(at) - r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW, s.pendingLoad(at), rise, cut) + r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW, s.pendingLoad(at)) _, fresh := qualifiedFuseObservation(meter, now) fresh = fresh && r.measured lim.phaseRoomA, lim.phaseRoomKnown = 0, r.phaseRoomKnown @@ -371,9 +405,35 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo // not show yet. Over the fuse, the cap never rises. The hour's budget // absorbs a short overage of less than a step: it cuts once the overage // has lasted evSettle, so one reading over the ceiling changes nothing. + // A run over the fuse's working limit ends once every phase has read + // under it for evSettle; a short dip, as battery relief gives, does not. + switch { + case r.overLimit: + lim.fuseUnderAt = time.Time{} + if lim.fuseOverAt.IsZero() || gap { + lim.fuseOverAt, lim.fuseCutAt = at, time.Time{} + } + case lim.fuseUnderAt.IsZero(): + lim.fuseUnderAt = at + case at.Sub(lim.fuseUnderAt) >= evSettle: + lim.fuseOverAt, lim.fuseCutAt = time.Time{}, time.Time{} + } + rise, cover, latestW := s.unshownOrders(at, r.perPhase) + fuseCutW := r.fuseOverW + rise.w - cover.w + if r.perPhase { + // A single-phase order may land on any phase, so the cut takes the + // most a rise adds to a phase and the least a cut takes off one. + // Past the current a gG fuse carries for an hour, nothing on its + // way holds back a cut. + coverA := min(cover.phaseA[0], cover.phaseA[1], cover.phaseA[2]) + if s.fuseHeat.worstA() > fuseHourFactor*s.SiteFuseAmps { + coverA = 0 + } + fuseCutW = (r.fuseOverA + max(rise.phaseA[0], rise.phaseA[1], rise.phaseA[2]) - coverA) * s.SiteFuseVoltage * pool.phases + } over, capW, byImport := r.fuseOverW > 0, math.Inf(1), false if over { - capW = latestW - math.Max(0, r.fuseCutW) + capW = latestW - math.Max(0, fuseCutW) } if r.limitW >= 0 { lim.overSince = time.Time{} @@ -393,10 +453,13 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo lim.capW, lim.roomSince = math.Min(prev, math.Max(0, capW)), time.Time{} if lim.capW < prev { lim.cutAt = now + if r.fuseOverW > 0 && lim.fuseCutAt.IsZero() { + lim.fuseCutAt = now + } slog.Info("EV hard limit: site meter over limit", "grid_w", gridW, "worst_phase_a", r.worstA, "fuse_heat_a", s.fuseHeat.worstA(), "import_limit_w", s.effectiveImportCeilingW(fuseMaxW), "overage_w", r.fuseOverW, "import_overage_w", math.Max(0, -r.limitW), - "unshown_rise_w", rise.w, "unshown_cut_w", cut.w, "offer_w", latestW, "cap_w", lim.capW) + "unshown_rise_w", rise.w, "unshown_cut_w", cover.w, "offer_w", latestW, "cap_w", lim.capW) } return } @@ -485,8 +548,10 @@ func EVLimitReason(state *State) string { // every reading. The import ceiling's parts are +Inf while it is off. type evMeterRoom struct { fuseW float64 // below the fuse less its margin and the worst phase, read or heat, and what we sent; rises and starts - fuseOverW float64 // the fuse's measured overage, 0 under it; see fuseHeat - fuseCutW float64 // what the fuse needs cut from the latest offers: the overage and unshown rises, less unshown cuts + fuseOverW float64 // the fuse's measured overage that cuts now, 0 under it; see fuseHeat + fuseOverA float64 // the same on the worst phase, with perPhase + perPhase bool // the phase currents measure the fuse + overLimit bool // a phase, or the total without phases, reads over the fuse's working limit limitW float64 // below the hour's import limit; negative is an overage riseW float64 // below the level an increase may reach, less what we sent startLevelW float64 // import an idle start may reach: the rise level, at most the ceiling @@ -497,12 +562,11 @@ type evMeterRoom struct { phaseRoomKnown bool // the meter reports phase currents } -func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64, pending, rise, cut pendingLoad) evMeterRoom { +func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64, sent pendingLoad) evMeterRoom { inf, totalW := math.Inf(1), math.Max(0, fuseMaxW-s.fuseSafetyMarginW())-gridW - r := evMeterRoom{fuseW: totalW - pending.w, fuseOverW: math.Max(0, -totalW), limitW: inf, riseW: inf, startLevelW: inf} - r.fuseCutW = r.fuseOverW + rise.w - cut.w + r := evMeterRoom{fuseW: totalW - sent.w, fuseOverW: math.Max(0, -totalW), overLimit: totalW < 0, limitW: inf, riseW: inf, startLevelW: inf} if limitW, riseW, ok := s.importHourLimitsW(); ok { - r.limitW, r.riseW = limitW-gridW, riseW-gridW-pending.w + r.limitW, r.riseW = limitW-gridW, riseW-gridW-sent.w // A car that could run only on one hour's slack would stop when // the next hour starts at the ceiling. r.startLevelW = math.Min(riseW, s.PeakImportCeilingW) @@ -519,20 +583,11 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW for _, a := range amps { r.worstA = math.Max(r.worstA, a) } - r.fuseW = math.Min(r.fuseW, (limitA-s.fuseHeat.levelA(amps, pending.phaseA))*s.SiteFuseVoltage*phases) + r.fuseW = math.Min(r.fuseW, (limitA-s.fuseHeat.levelA(amps, sent.phaseA))*s.SiteFuseVoltage*phases) r.phaseRoomA, r.phaseRoomKnown = limitA-s.fuseHeat.levelA(amps, [3]float64{}), true - // The phases measure the fuse itself; total import stands in only - // without them. A single-phase order may land on any phase, so the - // cut takes the most a rise adds to a phase and the least a cut on - // its way takes off one. Past the current a gG fuse carries for an - // hour, nothing on its way holds back a cut. - overA := s.fuseHeat.overA(amps, limitA, fuseInstantFactor*s.SiteFuseAmps) - coverA := min(cut.phaseA[0], cut.phaseA[1], cut.phaseA[2]) - if s.fuseHeat.worstA() > fuseHourFactor*s.SiteFuseAmps { - coverA = 0 - } - r.fuseOverW = overA * s.SiteFuseVoltage * phases - r.fuseCutW = (overA + max(rise.phaseA[0], rise.phaseA[1], rise.phaseA[2]) - coverA) * s.SiteFuseVoltage * phases + // The phases measure the fuse itself; total import stands in only without them. + r.fuseOverA = s.fuseHeat.overA(amps, limitA, fuseInstantFactor*s.SiteFuseAmps) + r.fuseOverW, r.perPhase, r.overLimit = r.fuseOverA*s.SiteFuseVoltage*phases, true, r.worstA > limitA } return r } diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go index b84c56a9..575c8ad5 100644 --- a/go/internal/control/ev_fuse_cut_netting_test.go +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -67,27 +67,39 @@ type cutRig struct { state *State now time.Time offerW float64 + phases int // the phases the offer lands on } func newCutRig(t *testing.T, offerW, declaredS float64) *cutRig { t.Helper() store, state, _, _ := evMeterBudgetFixture(t, 0, nil, offerW, "3p") - r := &cutRig{t: t, store: store, state: state, now: time.Unix(1_792_246_834, 0), offerW: offerW} + r := &cutRig{t: t, store: store, state: state, now: time.Unix(1_792_246_834, 0), offerW: offerW, phases: 3} state.clock = func() time.Time { return r.now } data, _ := json.Marshal(map[string]any{"power_max_age_s": declaredS}) store.Update("charger", telemetry.DerEV, 0, nil, data) return r } -// read delivers one reading and returns the cap. +// read delivers one reading with phase currents and returns the cap. func (r *cutRig) read(phaseA [3]float64) float64 { r.t.Helper() - r.now = r.now.Add(time.Second) data, _ := json.Marshal(map[string]any{"l1_a": phaseA[0], "l2_a": phaseA[1], "l3_a": phaseA[2]}) - r.store.Update("meter", telemetry.DerMeter, (phaseA[0]+phaseA[1]+phaseA[2])*230, nil, data) + return r.deliver((phaseA[0]+phaseA[1]+phaseA[2])*230, data) +} + +// readGrid delivers one reading of total import only. +func (r *cutRig) readGrid(gridW float64) float64 { + r.t.Helper() + return r.deliver(gridW, []byte(`{}`)) +} + +func (r *cutRig) deliver(gridW float64, data []byte) float64 { + r.t.Helper() + r.now = r.now.Add(time.Second) + r.store.Update("meter", telemetry.DerMeter, gridW, nil, data) r.store.Get("meter", telemetry.DerMeter).UpdatedAt = r.now _, _, points, configs := evMeterBudgetFixture(r.t, 0, nil, r.offerW, "3p") - points[0].CommandedPhases = 3 + points[0].CommandedPhases = r.phases capW, _ := EVFuseBudget(r.store, r.state, points, configs, 17250) if capW < r.offerW { r.offerW = math.Floor(capW/690) * 690 @@ -177,38 +189,111 @@ func TestEVFuseSevereOverageCutsWhateverIsOnItsWay(t *testing.T) { } } -// The cost of trusting a cut on its way. A fast charger that declares -// nothing applies a cut in 5 s, but Core counts it as travelling for 180 s. -// A second step on L1 inside that time is covered by the cut that already -// landed, so L1 sits over the working limit until 180 s after the cut. It -// stays under 1.25 x In, and the heat rule bounds anything above. -func TestEVFuseLandedCutCoversALaterStepForAtMostItsResponseTime(t *testing.T) { +// A cut covers only within the run over the limit it was sent in. A fast +// charger that declares nothing applies a cut in 5 s, but counts as slow +// for 180 s. Once the meter has shown L1 under the limit for evSettle, a +// second step on L1 is a new run, and the landed cut covers none of it. +// Within evSettle of the landing it is the same run, and the landed cut +// covers the step until the run's first cut has had 180 s: that is the +// cost of trusting a cut on its way, at under 1.25 x In. +func TestEVFuseLandedCutCoversOnlyItsRun(t *testing.T) { + for _, tc := range []struct { + after time.Duration // the second step after the first cut + maxOver time.Duration + }{ + {30 * time.Second, 30 * time.Second}, {10 * time.Second, evUnknownSettle}, + } { + t.Run(fmt.Sprintf("step_after_%v", tc.after), func(t *testing.T) { + s := fuseHeatSite(t) + s.run(time.Minute) + s.addHouseA(11, 0, 0) // L1 31 A: instant cut of 7 A, lands in 5 s + if took, offer := s.firstCut(5 * time.Second); took != 0 || offer != 5520 { + t.Fatalf("instant cut after %v to %.0f W, want 5,520 W", took, offer) + } + s.run(tc.after) + s.addHouseA(5, 0, 0) // L1 29 A with the car's cut landed + var over time.Duration + var peak float64 + for range 4 * 60 { + s.step() + if a := s.phaseA()[0]; a > 24 { + over += time.Second + peak = math.Max(peak, a) + } + } + if over > tc.maxOver || peak > fuseHourFactor*s.state.SiteFuseAmps { + t.Fatalf("L1 over the working limit for %v, up to %.1f A", over, peak) + } + t.Logf("L1 at %.1f A over the working limit for %v", peak, over) + }) + } +} + +// A car that draws less than its offer hides a cut: the cut comes off the +// offer, not the draw, and changes nothing the meter can show. The first cut +// covers for the charger's 180 s; once it has outlasted that, cuts cover for +// evSettle only, so the next ones follow as on master. With the car at 10 A +// of 15 A and L1 2 A over, L1 stays over for the heat budget (61 s), 180 s +// and three cuts 21 s apart; master took 2 min 36 s. +func TestEVFuseCarUnderItsOfferCutsAgainAfterResponseTime(t *testing.T) { s := fuseHeatSite(t) - s.run(time.Minute) + s.carMaxW = 6900 // 10 A of its 15 A offer + s.run(2 * time.Minute) stepAt := s.now - s.addHouseA(11, 0, 0) // L1 31 A: instant cut of 7 A, lands in 5 s - if took, offer := s.firstCut(5 * time.Second); took != 0 || offer != 5520 { - t.Fatalf("instant cut after %v to %.0f W, want 5,520 W", took, offer) - } - s.run(30 * time.Second) - s.addHouseA(5, 0, 0) // L1 29 A with the car's cut landed - var over time.Duration - var peak float64 - for range 4 * 60 { + s.addHouseA(11, 0, 0) // L1 26 A + var over, overFor time.Duration + for range 10 * 60 { s.step() - if a := s.phaseA()[0]; a > 24 { + if s.phaseA()[0] > 24 { over += time.Second - peak = math.Max(peak, a) + overFor = over + } else { + over = 0 } } - cuts := s.cutsSince(stepAt) - if len(cuts) != 2 || cuts[1].at.Sub(cuts[0].at) > evUnknownSettle+5*time.Second { - t.Fatalf("cuts %v, want a second once the first stops covering", offerWatts(cuts)) + if s.phaseA()[0] > 24 || overFor > evUnknownSettle+150*time.Second { + t.Fatalf("L1 %.1f A, over the working limit for %v; cuts %v", s.phaseA()[0], overFor, offerWatts(s.cutsSince(stepAt))) + } + t.Logf("L1 over the working limit for %v", overFor) +} + +// Without phase currents Core cannot see the hottest phase, so a cut on its +// way covers for evSettle only, as before: the total import cuts again 20 s +// after the first cut while the charger has not applied it. +func TestEVFuseWithoutPhaseCurrentsCoversForSettle(t *testing.T) { + r := newCutRig(t, 10350, 180) + for range 30 { + r.readGrid(15000) + } + r.readGrid(20000) // 2,750 W over 17,250 W + if r.offerW != 7590 { + t.Fatalf("first cut to %.0f W, want 7,590 W", r.offerW) + } + var again int + for i := 1; i <= 60 && r.offerW == 7590; i++ { + r.readGrid(20000) + again = i + } + if r.offerW >= 7590 || again < int(evSettle/time.Second) { + t.Fatalf("second cut after %d s to %.0f W, want one after evSettle", again, r.offerW) + } +} + +// A change of phases is no cut. A 15 A single-phase order replaced by a 6 A +// three-phase one lowers the current on the order's phase but adds 6 A to +// the other two, so it covers nothing: with L2 at 32 A on the 25 A fuse the +// cut takes the whole offer. +func TestEVFusePhaseChangeCoversNothing(t *testing.T) { + r := newCutRig(t, 3450, 180) + r.phases = 1 + for range 30 { + r.read([3]float64{20, 5, 5}) } - if over > evUnknownSettle || peak > fuseHourFactor*s.state.SiteFuseAmps { - t.Fatalf("L1 over the working limit for %v, up to %.1f A", over, peak) + r.offerW, r.phases = 4140, 3 + r.read([3]float64{20, 5, 5}) + if r.read([3]float64{20, 32, 20}); r.offerW != 0 { + t.Fatalf("offer %.0f W with L2 at 32 A and a 6 A three-phase order on its way, want 0 W", r.offerW) } - t.Logf("L1 at %.1f A over the working limit for %v", peak, over) } // After a restart, the offer Core finds in force is one it did not send in From 6bdad956e94dfb80130e546b2be7f1e4feeba976 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 22 Oct 2026 11:43:15 +0200 Subject: [PATCH 3/3] test(control): field rows after the cut netting on master 5ae590ad FTW_FIELD_KNOWN=write removes 99 over-cut netting, 1 limit netting and 4 over-cut D rows and adds none. 71 of 338 cases still fail, all listed on master: energy 40 (39 D, 1 A), idle 25 (export, readback, hold3p), own-raise 6 (relief). Co-Authored-By: Claude Opus 5.5 --- .../control/testdata/sitesim_field_known.txt | 108 ------------------ 1 file changed, 108 deletions(-) diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index 409d68ae..006f47c3 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -9,10 +9,6 @@ # phases; the planner gets steps a three-phase order can deliver. # #1795 srcfl/ftw#1795: a charger drawing against its order reads not # following. -# netting no fix yet: a cut counts every order older than evSettle (20 s) -# as shown, but a cloud charger applies it after 30-120 s, so Core -# cuts twice, or too little while a raise is on its way -# (ev_fuse_budget.go stepEVMeterLimit). # D after beta.2: plan a one-phase car at the rate it takes # (srcfl/ftw#1796, srcfl/ftw#1792). # export srcfl/ftw#1782, draft: an exporting phase counts as import, so @@ -24,144 +20,55 @@ # hold3p no fix yet: the app sends Charge now as 3p for an auto # loadpoint, and on a charger whose table stops below 4,140 W # Core floors the order to 0 W (fuse_limit). -help1022/20A/auto/own/cloud60s/car1p/2A/chargenow over-cut netting help1022/20A/auto/own/cloud60s/car1p/2A/planned energy D -help1022/20A/auto/own/cloud60s/car3p/2A/chargenow over-cut netting -help1022/20A/auto/own/cloud60s/car3p/2A/pause over-cut netting -help1022/20A/auto/own/easee120s/car1p/2A/chargenow over-cut netting help1022/20A/auto/own/easee120s/car1p/2A/planned energy A -help1022/20A/auto/own/easee120s/car3p/2A/chargenow over-cut netting -help1022/20A/auto/own/easee120s/car3p/2A/pause over-cut netting -help1022/20A/auto/own/easee40s/car1p/2A/chargenow over-cut netting help1022/20A/auto/own/easee40s/car1p/2A/planned energy D -help1022/20A/auto/own/easee40s/car3p/2A/chargenow over-cut netting -help1022/20A/auto/own/easee40s/car3p/2A/pause over-cut netting help1022/20A/auto/own/fast2s/car1p/2A/planned energy D -homebox/16A/empty/none/cloud60s/car3p/balanced/chargenow over-cut netting -homebox/16A/empty/none/cloud60s/car3p/balanced/pause limit netting -homebox/16A/empty/none/easee120s/car3p/balanced/chargenow over-cut netting -homebox/16A/empty/none/easee40s/car3p/balanced/chargenow over-cut netting -pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery over-cut netting pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/chargenow+battery own-raise relief pr1763/25A/3p/3p-15A/cloud60s/car3p/6-2-2/pause+battery own-raise relief -pr1763/25A/3p/3p-15A/easee120s/car3p/6-2-2/chargenow+battery over-cut netting pr1763/25A/3p/3p-15A/easee120s/car3p/6-2-2/chargenow+battery own-raise relief -pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery over-cut netting pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery own-raise relief pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/pause+battery own-raise relief pr1763/25A/3p/3p-15A/fast2s/car3p/6-2-2/pause+battery own-raise relief probe/25A/auto/1p-3/easee40s/car3p/balanced/chargenow+charger1p32A idle hold3p -probe/25A/auto/1p-3/easee40s/car3p/balanced/planned+charger1p32A over-cut netting probe/25A/auto/1p-3/fast2s/car3p/balanced/chargenow+charger1p32A idle hold3p probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback -sweep/16A/1p/1p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/1p/1p/easee40s/car3p/balanced/planned over-cut netting -sweep/16A/1p/none/easee40s/car3p/balanced/chargenow over-cut netting -sweep/16A/1p/none/easee40s/car3p/balanced/planned over-cut netting sweep/16A/3p/3p/easee40s/car1p/L1/planned energy D -sweep/16A/3p/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/3p/3p/easee40s/car1p/balanced/planned energy D -sweep/16A/3p/3p/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/3p/3p/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/3p/mixed/easee40s/car1p/L1/planned energy D -sweep/16A/3p/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/3p/mixed/easee40s/car1p/balanced/planned energy D -sweep/16A/3p/mixed/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/3p/mixed/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/3p/none/easee40s/car1p/L1/planned energy D -sweep/16A/3p/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/3p/none/easee40s/car1p/balanced/planned energy D -sweep/16A/3p/none/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/3p/none/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/auto/3p/easee40s/car1p/L1/planned energy D -sweep/16A/auto/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/auto/3p/easee40s/car1p/balanced/planned energy D -sweep/16A/auto/3p/easee40s/car1p/balanced/planned over-cut D -sweep/16A/auto/3p/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/auto/mixed/easee40s/car1p/L1/planned energy D -sweep/16A/auto/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/auto/mixed/easee40s/car1p/balanced/planned energy D -sweep/16A/auto/mixed/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/auto/mixed/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/auto/none/easee40s/car1p/L1/planned energy D -sweep/16A/auto/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/auto/none/easee40s/car1p/balanced/planned energy D -sweep/16A/auto/none/easee40s/car1p/balanced/planned over-cut D -sweep/16A/auto/none/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/empty/3p/easee40s/car1p/L1/planned energy D -sweep/16A/empty/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/empty/3p/easee40s/car1p/balanced/planned energy D -sweep/16A/empty/3p/easee40s/car1p/balanced/planned over-cut D -sweep/16A/empty/3p/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/empty/mixed/easee40s/car1p/L1/planned energy D -sweep/16A/empty/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/empty/mixed/easee40s/car1p/balanced/planned energy D -sweep/16A/empty/mixed/easee40s/car1p/balanced/planned over-cut netting -sweep/16A/empty/mixed/easee40s/car3p/balanced/chargenow over-cut netting sweep/16A/empty/none/easee40s/car1p/L1/planned energy D -sweep/16A/empty/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/16A/empty/none/easee40s/car1p/balanced/planned energy D -sweep/16A/empty/none/easee40s/car1p/balanced/planned over-cut D -sweep/25A/1p/1p/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/1p/1p/easee40s/car3p/L1/planned over-cut netting -sweep/25A/1p/1p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/1p/none/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/1p/none/easee40s/car3p/L1/planned over-cut netting -sweep/25A/1p/none/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/3p/3p/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/3p/3p/easee40s/car1p/L1/planned energy D -sweep/25A/3p/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/3p/3p/easee40s/car1p/balanced/planned energy D -sweep/25A/3p/3p/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/3p/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/3p/mixed/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/3p/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/3p/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/3p/mixed/easee40s/car1p/balanced/planned energy D -sweep/25A/3p/mixed/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/3p/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/3p/none/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/3p/none/easee40s/car1p/L1/planned energy D -sweep/25A/3p/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/3p/none/easee40s/car1p/balanced/planned energy D -sweep/25A/3p/none/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/3p/none/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/auto/3p/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/auto/3p/easee40s/car1p/L1/planned energy D -sweep/25A/auto/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/auto/3p/easee40s/car1p/balanced/planned energy D -sweep/25A/auto/3p/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/auto/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/auto/mixed/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/auto/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/auto/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/auto/mixed/easee40s/car1p/balanced/planned energy D -sweep/25A/auto/mixed/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/auto/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/auto/none/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/auto/none/easee40s/car1p/L1/planned energy D -sweep/25A/auto/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/auto/none/easee40s/car1p/balanced/planned energy D -sweep/25A/auto/none/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/auto/none/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/empty/3p/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/empty/3p/easee40s/car1p/L1/planned energy D -sweep/25A/empty/3p/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/empty/3p/easee40s/car1p/balanced/planned energy D -sweep/25A/empty/3p/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/empty/3p/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/empty/mixed/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/empty/mixed/easee40s/car1p/L1/planned energy D -sweep/25A/empty/mixed/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/empty/mixed/easee40s/car1p/balanced/planned energy D -sweep/25A/empty/mixed/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/empty/mixed/easee40s/car3p/balanced/chargenow over-cut netting -sweep/25A/empty/none/easee40s/car1p/L1/chargenow over-cut netting sweep/25A/empty/none/easee40s/car1p/L1/planned energy D -sweep/25A/empty/none/easee40s/car1p/balanced/chargenow over-cut netting sweep/25A/empty/none/easee40s/car1p/balanced/planned energy D -sweep/25A/empty/none/easee40s/car3p/L1/chargenow over-cut netting -sweep/25A/empty/none/easee40s/car3p/balanced/chargenow over-cut netting timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle export timing/16A/3p/3p/easee120s/car3p/L1/surplus idle export timing/16A/3p/3p/easee40s/car3p/L1/surplus idle export @@ -174,28 +81,13 @@ timing/16A/empty/none/cloud60s/car3p/L1/surplus idle export timing/16A/empty/none/easee120s/car3p/L1/surplus idle export timing/16A/empty/none/easee40s/car3p/L1/surplus idle export timing/16A/empty/none/fast2s/car3p/L1/surplus idle export -timing/25A/3p/3p/cloud60s/car3p/L1/chargenow over-cut netting -timing/25A/3p/3p/cloud60s/car3p/L1/pause over-cut netting timing/25A/3p/3p/cloud60s/car3p/L1/surplus idle readback -timing/25A/3p/3p/easee120s/car3p/L1/chargenow over-cut netting -timing/25A/3p/3p/easee120s/car3p/L1/pause over-cut netting timing/25A/3p/3p/easee120s/car3p/L1/surplus idle readback -timing/25A/3p/3p/easee40s/car3p/L1/pause over-cut netting timing/25A/3p/3p/easee40s/car3p/L1/surplus idle readback -timing/25A/auto/mixed/cloud60s/car3p/L1/chargenow over-cut netting -timing/25A/auto/mixed/cloud60s/car3p/L1/pause over-cut netting timing/25A/auto/mixed/cloud60s/car3p/L1/surplus idle readback -timing/25A/auto/mixed/easee120s/car3p/L1/chargenow over-cut netting -timing/25A/auto/mixed/easee120s/car3p/L1/pause over-cut netting timing/25A/auto/mixed/easee120s/car3p/L1/surplus idle readback -timing/25A/auto/mixed/easee40s/car3p/L1/pause over-cut netting timing/25A/auto/mixed/easee40s/car3p/L1/surplus idle readback -timing/25A/empty/none/cloud60s/car3p/L1/chargenow over-cut netting -timing/25A/empty/none/cloud60s/car3p/L1/pause over-cut netting timing/25A/empty/none/cloud60s/car3p/L1/surplus idle readback -timing/25A/empty/none/easee120s/car3p/L1/chargenow over-cut netting -timing/25A/empty/none/easee120s/car3p/L1/pause over-cut netting timing/25A/empty/none/easee120s/car3p/L1/surplus idle readback -timing/25A/empty/none/easee40s/car3p/L1/pause over-cut netting timing/25A/empty/none/easee40s/car3p/L1/surplus idle readback timing/25A/empty/none/fast2s/car3p/L1/surplus idle export