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..97945793 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -37,11 +37,18 @@ 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 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 @@ -119,11 +126,55 @@ 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 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 + if !c.at.After(from) { + settled = c + } + } + 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) + } + if !cutA { + low.a = math.Min(low.a, c.a) + } + prev = c + } + if otherPhases { + low.a = 0 + } + 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 +198,49 @@ 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 +// 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 { + 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, cover, 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 @@ -170,8 +264,12 @@ 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 + } c.log = c.log.keep(sent, read.Add(-c.settle)) } offers[point.ID] = c @@ -180,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 @@ -217,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 @@ -236,8 +337,13 @@ 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, 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 // 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) { @@ -293,11 +399,42 @@ 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. + // 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, fuseCutW) + } if r.limitW >= 0 { lim.overSince = time.Time{} } else { @@ -305,19 +442,24 @@ 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 + 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", -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", cover.w, "offer_w", latestW, "cap_w", lim.capW) } return } @@ -406,7 +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 - fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat + 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 @@ -419,7 +564,7 @@ type evMeterRoom struct { 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 - sent.w, fuseCutW: totalW, limitW: inf, riseW: inf, startLevelW: inf} + 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-sent.w // A car that could run only on one hour's slack would stop when @@ -441,7 +586,8 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW 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. - r.fuseCutW = -s.fuseHeat.overA(amps, limitA, fuseInstantFactor*s.SiteFuseAmps) * s.SiteFuseVoltage * phases + 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 new file mode 100644 index 00000000..575c8ad5 --- /dev/null +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -0,0 +1,317 @@ +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 + 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, 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 with phase currents and returns the cap. +func (r *cutRig) read(phaseA [3]float64) float64 { + r.t.Helper() + data, _ := json.Marshal(map[string]any{"l1_a": phaseA[0], "l2_a": phaseA[1], "l3_a": phaseA[2]}) + 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 = r.phases + 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) + } +} + +// 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.carMaxW = 6900 // 10 A of its 15 A offer + s.run(2 * time.Minute) + stepAt := s.now + s.addHouseA(11, 0, 0) // L1 26 A + var over, overFor time.Duration + for range 10 * 60 { + s.step() + if s.phaseA()[0] > 24 { + over += time.Second + overFor = over + } else { + over = 0 + } + } + 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}) + } + 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) + } +} + +// 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) + } +} 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