From f20094d57a25e95cd11c2576a6953d5d03deb43e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:13:04 +0200 Subject: [PATCH 1/4] fix(control): the EV guard gives the main fuse a heat budget (#1674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #1695 the EV hard limit cut on every one-second reading over the fuse less 1 A on a phase, and on any reading where total import passed the fuse less its margin. A heat pump start or an oven switching on cut the car, which then climbed back 1 A per confirmed step. Each phase of the main fuse is now a first-order low-pass on I² from the site meter, tau 60 s. A phase cuts once its filtered current passes the fuse less 1 A, or at once above 1.2 x the fuse. The cut takes off what the meter shows over the limit, so one cut stops the heat rising. Rises and restarts still read the instantaneous worst phase. Where the meter reports phase currents, they replace total import as the fuse check; a tariff ceiling still cuts. The heat holds through a meter gap and steps over the real time when the meter returns. After a Core restart it starts at the first reading. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-fuse-heat-budget.md | 5 + docs/safety.md | 7 + go/internal/control/dispatch.go | 2 + .../control/ev_battery_sequence_test.go | 16 +- go/internal/control/ev_fuse_budget.go | 67 ++--- go/internal/control/ev_fuse_budget_test.go | 3 +- go/internal/control/fuse_heat.go | 108 +++++++ go/internal/control/fuse_heat_test.go | 270 ++++++++++++++++++ 8 files changed, 435 insertions(+), 43 deletions(-) create mode 100644 .changeset/ev-fuse-heat-budget.md create mode 100644 go/internal/control/fuse_heat.go create mode 100644 go/internal/control/fuse_heat_test.go diff --git a/.changeset/ev-fuse-heat-budget.md b/.changeset/ev-fuse-heat-budget.md new file mode 100644 index 000000000..a75663bdf --- /dev/null +++ b/.changeset/ev-fuse-heat-budget.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The EV's hard limit now treats the main fuse as heat, as a fuse or breaker does. A short house spike, such as a heat pump or an oven starting, no longer cuts the charge. A phase above the fuse less 1 A cuts it only once that phase's current, weighted over about the last minute, passes the same limit, and at once above 1.2 times the fuse. Where the meter reports phase currents, they replace total import as the fuse check, so a short spike on all three phases no longer cuts either. A tariff import ceiling still applies. diff --git a/docs/safety.md b/docs/safety.md index 895c7c3ca..60a41c7df 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -49,6 +49,13 @@ The reactive fuse saver may bypass normal slew limiting because preventing a fuse trip is the higher-priority quantified risk. It must still respect available battery energy and hardware power capability. +The EV hard limit treats the main fuse as heat, not as one reading. It +low-passes each phase's I² from the site meter with a 60 s time constant. +A phase over the fuse less 1 A cuts the EVs once that filtered current +passes the same limit, and at once above 1.2 × the fuse. Where the meter +reports phase currents, they replace total import as the EVs' fuse check; a +tariff import ceiling still applies. + The implementation and tests in [`go/internal/control`](../go/internal/control) are authoritative. Planner-side limits in [`go/internal/mpc`](../go/internal/mpc) reduce infeasible plans but do not diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 3f97ce1e4..a896b5285 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -376,6 +376,8 @@ type State struct { evFuseDemandsSet bool // evLimit is the EVs' meter-driven hard cap, kept between site readings. evLimit evMeterLimit + // fuseHeat is the main fuse's heat per phase, from site readings only. + fuseHeat fuseHeat // evOffers and batteryCommands are our own recent commands, the only // device input the EV hard limit and the EV/battery split use. evOffers commandLog diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index 77de95aca..401cc846f 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -261,10 +261,10 @@ func (s *evBatterySite) offer() float64 { return s.offers[len(s.offers)-1].w } -// A 2 kW house step lands while the battery and the car both charge. The -// battery dispatch and the EV guard answer the same overage, so the car is -// cut too. EV first: the car must not stop, the battery must yield, and the -// car must get back to its offer within a minute. +// A 2 kW house step lands while the battery and the car both charge. EV +// first: the car must not stop and the battery must yield. The battery +// answers within a tick, inside the fuse's heat budget, so the car keeps its +// offer; a car that is cut must get back to it within a minute. func TestEVFirstHouseStepWhileBatteryAndEVCharge(t *testing.T) { s := newEVBatterySite(t, 3000) s.run(3*time.Minute + 30*time.Second) @@ -291,14 +291,18 @@ func TestEVFirstHouseStepWhileBatteryAndEVCharge(t *testing.T) { backAt = s.now } } - t.Logf("EV %.0f -> lowest %.0f W, back after %v; battery %.0f -> %.0f W", evBefore, lowest, backAt.Sub(stepAt), batteryBefore, s.batteryW) + back := "never cut" + if !backAt.IsZero() { + back = "back after " + backAt.Sub(stepAt).String() + } + t.Logf("EV %.0f -> lowest %.0f W, %s; battery %.0f -> %.0f W", evBefore, lowest, back, batteryBefore, s.batteryW) if lowest <= 0 { t.Fatal("the house step stopped the car") } if s.batteryW > batteryBefore-2000+.01 { t.Fatalf("battery %.0f -> %.0f W did not yield the step", batteryBefore, s.batteryW) } - if backAt.IsZero() || backAt.Sub(stepAt) > time.Minute { + if lowest < evBefore && (backAt.IsZero() || backAt.Sub(stepAt) > time.Minute) { t.Fatalf("EV not back at %.0f W within a minute (now %.0f W)", evBefore, s.offer()) } if s.gridW() > 16560.01 { diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 0d0e5e127..230454094 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -1,7 +1,6 @@ package control import ( - "encoding/json" "log/slog" "math" "time" @@ -99,12 +98,13 @@ type evPool struct { // EVFuseBudget returns the managed EVs' common hard cap, which the loadpoint // controller divides between cars. Callers hold the control lock, and the -// shared freshness gate must already allow dispatch. An overage on the latest -// site reading cuts at once. Inside the limits the cap holds; it rises one -// step only on a fresh reading with room for two, once the meter has shown -// the last change. 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. +// shared freshness gate must already allow dispatch. A fuse overage in total +// import cuts at once; a phase overage cuts once that phase's heat budget is +// spent, or at once above fuseInstantFactor. Otherwise the cap holds; it +// rises one step only on a fresh reading with room for two, once the meter +// has shown the last change. 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) { if store == nil || state == nil || !finiteFuseValue(fuseMaxW) || fuseMaxW <= 0 { return 0, true @@ -117,6 +117,7 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State if !finiteFuseValue(gridW) { return 0, true } + state.stepFuseHeat(meter) lim := &state.evLimit pool, plugged := evPoolFor(state, points, configs) if !plugged { @@ -148,10 +149,11 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo } r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW) step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases - // The fuse cuts on every reading. 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.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 if r.limitW >= 0 { lim.overSince = time.Time{} } else { @@ -169,7 +171,8 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo if lim.capW < prev { lim.changedAt, lim.raised = now, false slog.Info("EV hard limit: site meter over limit", "grid_w", gridW, "worst_phase_a", r.worstA, - "import_limit_w", s.effectiveImportCeilingW(fuseMaxW), "overage_w", -cutW, "offer_w", peakW, "cap_w", lim.capW) + "fuse_heat_a", s.fuseHeat.worstA(), "import_limit_w", s.effectiveImportCeilingW(fuseMaxW), + "overage_w", -cutW, "offer_w", peakW, "cap_w", lim.capW) } return } @@ -252,7 +255,8 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first // evMeterRoom is the measured room in EV watts. The fuse's part holds on // 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 + fuseW float64 // below the fuse less its margin and the worst phase; rises and starts + fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat limitW float64 // below the hour's import limit; negative is an overage riseW float64 // below the level an increase may reach startLevelW float64 // import an idle start may reach: the rise level, at most the ceiling @@ -262,8 +266,8 @@ type evMeterRoom struct { } func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64) evMeterRoom { - inf := math.Inf(1) - r := evMeterRoom{fuseW: math.Max(0, fuseMaxW-s.fuseSafetyMarginW()) - gridW, limitW: inf, riseW: inf, startLevelW: inf} + inf, totalW := math.Inf(1), math.Max(0, fuseMaxW-s.fuseSafetyMarginW())-gridW + r := evMeterRoom{fuseW: totalW, fuseCutW: totalW, limitW: inf, riseW: inf, startLevelW: inf} if limitW, riseW, ok := s.importHourLimitsW(); ok { r.limitW, r.riseW = limitW-gridW, riseW-gridW // A car that could run only on one hour's slack would stop when @@ -271,14 +275,19 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW r.startLevelW = math.Min(riseW, s.PeakImportCeilingW) r.hourly = s.importHourMeasured() } - worst, present, valid := worstPhaseA(meter, s) + amps, present, valid := phaseCurrentsA(meter, s) if present && !valid { return r } r.measured = true if present { - r.fuseW = math.Min(r.fuseW, (s.SiteFuseAmps-evPhaseMarginA-worst)*s.SiteFuseVoltage*phases) - r.worstA = worst + limitA := s.SiteFuseAmps - evPhaseMarginA + for _, a := range amps { + r.worstA = math.Max(r.worstA, a) + } + r.fuseW = math.Min(r.fuseW, (limitA-r.worstA)*s.SiteFuseVoltage*phases) + // 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 } return r } @@ -286,25 +295,11 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW // worstPhaseA reads the site's configured phases. present is false when the // meter reports no phase currents; valid is false when they cannot be used. func worstPhaseA(meter *telemetry.DerReading, state *State) (worstA float64, present, valid bool) { - // DER data uses snake case; separate fields retain absent-vs-zero. - var data struct { - L1 *float64 `json:"l1_a"` - L2 *float64 `json:"l2_a"` - L3 *float64 `json:"l3_a"` - } - if json.Unmarshal(meter.Data, &data) != nil || (data.L1 == nil && data.L2 == nil && data.L3 == nil) { - return 0, false, false - } - if !finiteFuseValue(state.SiteFuseAmps) || !finiteFuseValue(state.SiteFuseVoltage) || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases < 1 || state.SiteFusePhases > 3 { - return 0, true, false - } - for _, amps := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] { - if amps == nil || !finiteFuseValue(*amps) { - return 0, true, false - } - worstA = math.Max(worstA, math.Abs(*amps)) + amps, present, valid := phaseCurrentsA(meter, state) + for _, a := range amps { + worstA = math.Max(worstA, a) } - return worstA, true, true + return worstA, present, valid } // pendingBatteryCommandsW is battery increase we ordered that the meter may diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 3941c095c..0a0364619 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -608,7 +608,8 @@ func evMeterBudgetFixture(t *testing.T, gridW float64, phaseA []float64, offerW } // A 3p car offered 15 A beside 11 A of house load on L1: 26 A on a 24 A -// working limit. Two amps on three phases come off the offer at once. +// working limit. With no earlier reading the fuse heat starts at 26 A, so +// two amps on three phases come off the offer at once. func TestEVHardLimitTrimsPhaseOverageFromOffer(t *testing.T) { store, state, points, configs := evMeterBudgetFixture(t, 8280, []float64{26, 20, 20}, 10350, "3p") got, known := EVFuseBudget(store, state, points, configs, 17250) diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go new file mode 100644 index 000000000..fa3b4f1ac --- /dev/null +++ b/go/internal/control/fuse_heat.go @@ -0,0 +1,108 @@ +package control + +import ( + "encoding/json" + "math" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// fuseHeatTau is the time constant of the main fuse's heat model. +const fuseHeatTau = 60 * time.Second + +// fuseInstantFactor is the phase current, as a multiple of the fuse rating, +// above which the EV is cut at once with no heat budget. It stays below the +// 1.25 × In that a gG fuse of 16–63 A carries for an hour. +const fuseInstantFactor = 1.2 + +// fuseHeat models each phase of the main fuse as a first-order low-pass on +// I², fed only by the site meter's phase currents. A breaker and a fuse both +// trip on heat, not on one reading. FTW does not know which one a site has, +// so the EV hard limit holds the filtered current under the working limit, +// below both types' one-hour ratings (a miniature circuit breaker does not +// trip at 1.13 × In, a gG fuse does not blow at 1.25 × In), and gives no +// budget above fuseInstantFactor. +// +// A Core restart starts the model at the first reading's current, with no +// memory of the minutes before. +type fuseHeat struct { + set bool + at time.Time // the reading the model last stepped on + sq [3]float64 // filtered I² per phase, A² +} + +// step folds one reading into the model over the real time since the last. +// Between readings the heat holds. +func (h *fuseHeat) step(at time.Time, amps []float64) { + if !h.set { + for i, a := range amps { + h.sq[i] = a * a + } + h.set, h.at = true, at + return + } + dt := at.Sub(h.at) + if dt <= 0 { + return + } + k := -math.Expm1(-dt.Seconds() / fuseHeatTau.Seconds()) + for i, a := range amps { + h.sq[i] += (a*a - h.sq[i]) * k + } + h.at = at +} + +// overA is how far the phases the budget no longer covers sit above limitA +// on this reading: a phase whose filtered current is over limitA, or one +// above instantA. Zero while the budget covers every phase. +func (h *fuseHeat) overA(amps []float64, limitA, instantA float64) float64 { + over := 0.0 + for i, a := range amps { + if h.sq[i] > limitA*limitA || a > instantA { + over = math.Max(over, a-limitA) + } + } + return over +} + +// worstA is the highest filtered phase current. +func (h *fuseHeat) worstA() float64 { + return math.Sqrt(max(h.sq[0], h.sq[1], h.sq[2])) +} + +// stepFuseHeat folds each new site reading into the model, whether or not a +// car is plugged in. +func (s *State) stepFuseHeat(meter *telemetry.DerReading) { + if s.fuseHeat.set && !meter.UpdatedAt.After(s.fuseHeat.at) { + return + } + if amps, _, valid := phaseCurrentsA(meter, s); valid { + s.fuseHeat.step(meter.UpdatedAt, amps) + } +} + +// phaseCurrentsA reads the site's configured phases as magnitudes. present +// is false when the meter reports no phase currents; valid is false when +// they cannot be used. +func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, present, valid bool) { + // DER data uses snake case; separate fields retain absent-vs-zero. + var data struct { + L1 *float64 `json:"l1_a"` + L2 *float64 `json:"l2_a"` + L3 *float64 `json:"l3_a"` + } + if json.Unmarshal(meter.Data, &data) != nil || (data.L1 == nil && data.L2 == nil && data.L3 == nil) { + return nil, false, false + } + if !finiteFuseValue(state.SiteFuseAmps) || !finiteFuseValue(state.SiteFuseVoltage) || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases < 1 || state.SiteFusePhases > 3 { + return nil, true, false + } + for _, a := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] { + if a == nil || !finiteFuseValue(*a) { + return nil, true, false + } + amps = append(amps, math.Abs(*a)) + } + return amps, true, true +} diff --git a/go/internal/control/fuse_heat_test.go b/go/internal/control/fuse_heat_test.go new file mode 100644 index 000000000..34d7cc890 --- /dev/null +++ b/go/internal/control/fuse_heat_test.go @@ -0,0 +1,270 @@ +package control + +import ( + "encoding/json" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// secondsToSpend runs the model at 1 s readings from fromA to atA and +// returns the first second the filtered current is over limitA. +func secondsToSpend(fromA, atA, limitA float64) int { + var h fuseHeat + t := time.Unix(0, 0) + h.step(t, []float64{fromA}) + for s := 1; s <= 3600; s++ { + h.step(t.Add(time.Duration(s)*time.Second), []float64{atA}) + if h.sq[0] > limitA*limitA { + return s + } + } + return -1 +} + +// From 20 A on a 25 A fuse the budget runs out at the working limit of +// 24 A after about a minute at 26 A, 37 s at 28 A and 26 s at 30 A. A 10 s +// spike to 32 A leaves 22.3 A of heat. +func TestFuseHeatSpendsBudgetFrom20A(t *testing.T) { + for _, tc := range []struct { + atA float64 + want int + }{{26, 61}, {28, 37}, {30, 26}} { + if got := secondsToSpend(20, tc.atA, 24); math.Abs(float64(got-tc.want)) > 1 { + t.Errorf("%.0f A spent the budget after %d s, want ~%d s", tc.atA, got, tc.want) + } + } + var h fuseHeat + t0 := time.Unix(0, 0) + h.step(t0, []float64{20}) + for s := 1; s <= 10; s++ { + h.step(t0.Add(time.Duration(s)*time.Second), []float64{32}) + } + if got := h.worstA(); math.Abs(got-22.3) > .05 { + t.Fatalf("10 s at 32 A left %.2f A of heat, want 22.3 A", got) + } +} + +// fuseHeatState is a 25 A three-phase site with phase currents on the meter. +func fuseHeatState() (*telemetry.Store, *State) { + state := NewState(0, 0, "meter") + state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases = 25, 230, 3 + return telemetry.NewStore(), state +} + +func meterAt(store *telemetry.Store, at time.Time, phaseA [3]float64) *telemetry.DerReading { + raw, _ := json.Marshal(map[string]float64{"l1_a": phaseA[0], "l2_a": phaseA[1], "l3_a": phaseA[2]}) + store.Update("meter", telemetry.DerMeter, (phaseA[0]+phaseA[1]+phaseA[2])*230, nil, raw) + r := store.Get("meter", telemetry.DerMeter) + r.UpdatedAt = at + return r +} + +// While the meter is silent the heat holds. The first reading after a gap +// longer than evSettle steps it over the whole gap. +func TestFuseHeatHoldsThroughGapAndStepsRealTime(t *testing.T) { + store, state := fuseHeatState() + t0 := time.Unix(1_800_000_000, 0) + state.stepFuseHeat(meterAt(store, t0, [3]float64{20, 20, 20})) + silent := store.Get("meter", telemetry.DerMeter) + for range 30 { + state.stepFuseHeat(silent) // ticks with no new reading + } + if got := state.fuseHeat.worstA(); got != 20 { + t.Fatalf("heat moved to %.3f A without a reading", got) + } + gap := 30 * time.Second + if gap <= evSettle { + t.Fatalf("test gap %v must exceed evSettle %v", gap, evSettle) + } + state.stepFuseHeat(meterAt(store, t0.Add(gap), [3]float64{26, 20, 20})) + want := 676 - 276*math.Exp(-gap.Seconds()/60) + if got := state.fuseHeat.sq[0]; math.Abs(got-want) > 1e-9 { + t.Fatalf("heat after a %v gap %.3f A², want %.3f A²", gap, got, want) + } + if state.fuseHeat.sq[1] != 400 { + t.Fatalf("a steady phase moved to %.3f A²", state.fuseHeat.sq[1]) + } +} + +// A Core restart has no memory: the first reading sets the heat. +func TestFuseHeatStartsAtFirstReadingAfterRestart(t *testing.T) { + store, state := fuseHeatState() + state.stepFuseHeat(meterAt(store, time.Unix(1_800_000_000, 0), [3]float64{26, 3, 0})) + if state.fuseHeat.sq != [3]float64{676, 9, 0} { + t.Fatalf("first reading set %v", state.fuseHeat.sq) + } +} + +// fuseHeatSite charges at 15 A beside 5 A of house load on each phase of a +// 25 A fuse with no import ceiling: 20 A per phase, long enough for the heat +// to settle there. +func fuseHeatSite(t *testing.T) *evMeterSite { + t.Helper() + s := newEVMeterSite(t, 0, 3450) + s.run(10 * time.Minute) + if s.offer() != 10350 || s.evW != 10350 || math.Abs(s.state.fuseHeat.worstA()-20) > .01 { + t.Fatalf("setup: offer %.0f draw %.0f heat %.2f A", s.offer(), s.evW, s.state.fuseHeat.worstA()) + } + return s +} + +// addHouseA adds current to the house on each phase. +func (s *evMeterSite) addHouseA(l1, l2, l3 float64) { + s.housePhaseA[0] += l1 + s.housePhaseA[1] += l2 + s.housePhaseA[2] += l3 + s.houseW += (l1 + l2 + l3) * 230 +} + +// firstCut runs until the offer drops below 10,350 W and returns when. +func (s *evMeterSite) firstCut(limit time.Duration) (time.Duration, float64) { + s.t.Helper() + from := s.now + for s.now.Sub(from) < limit { + s.step() + if s.offer() < 10350 { + return s.now.Sub(from), s.offer() + } + } + return -1, s.offer() +} + +// A house step on L1 from 20 A cuts once the heat budget is spent, not on +// the first reading over the working limit, and the cut brings L1 back +// under it. +func TestEVHardLimitPhaseCutWaitsForHeatBudget(t *testing.T) { + for _, tc := range []struct { + atA float64 + after time.Duration + offerW float64 + }{ + {26, 61 * time.Second, 8970}, // 2 A over + {28, 37 * time.Second, 7590}, // 4 A over + {30, 26 * time.Second, 6210}, // 6 A over, at 1.2 × In exactly + } { + s := fuseHeatSite(t) + s.addHouseA(tc.atA-20, 0, 0) + took, offer := s.firstCut(3 * time.Minute) + if took < tc.after-2*time.Second || took > tc.after+2*time.Second { + t.Errorf("%.0f A on L1 cut after %v, want ~%v", tc.atA, took, tc.after) + } + if offer != tc.offerW { + t.Errorf("%.0f A on L1 cut to %.0f W, want %.0f W", tc.atA, offer, tc.offerW) + } + s.run(time.Minute) + if a := s.phaseA(); a[0] > 24+1e-9 { + t.Errorf("%.0f A on L1: L1 at %.2f A after the cut", tc.atA, a[0]) + } + } +} + +// Short house spikes, even past the fuse on all three phases at once, fit +// in the heat budget and never move the offer. +func TestEVHardLimitHeatBudgetCarriesHouseSpikes(t *testing.T) { + for _, tc := range []struct { + name string + l1, l2, l3 float64 + lasts time.Duration + }{ + // A three-phase heat pump compressor start: 28 A on every phase, + // 19.3 kW in total against 16.9 kW of fuse, for 5 s. + {"heat_pump_start", 8, 8, 8, 5 * time.Second}, + // 29.5 A on L1, just under 1.2 × In, for 10 s. + {"l1_10s_under_instant", 9.5, 0, 0, 10 * time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + s := fuseHeatSite(t) + from := s.now + s.addHouseA(tc.l1, tc.l2, tc.l3) + s.run(tc.lasts) + if s.maxGridW <= 16905 && tc.l2 > 0 { + t.Fatalf("setup: the spike never passed the total fuse ceiling (%.0f W)", s.maxGridW) + } + s.addHouseA(-tc.l1, -tc.l2, -tc.l3) + s.run(2 * time.Minute) + for _, w := range offerWatts(s.offersSince(from)) { + if w != 10350 { + t.Fatalf("spike moved the offer: %v", offerWatts(s.offersSince(from))) + } + } + }) + } +} + +// A three-phase oven that stays on spends the budget once and cuts once, +// by what the meter shows over the working limit, then holds. +func TestEVHardLimitSustainedOvenCutsOnceAfterBudget(t *testing.T) { + s := fuseHeatSite(t) + from := s.now + s.addHouseA(5.5, 5.5, 5.5) // 25.5 A per phase, 17.6 kW against 16.9 kW + took, offer := s.firstCut(3 * time.Minute) + // θ goes from 400 toward 650.25 A² and passes 576 after + // 60 × ln(250.25/74.25) = 72.9 s. + if took < 71*time.Second || took > 75*time.Second || offer != 8970 { + t.Fatalf("oven cut after %v to %.0f W, want ~73 s to 8,970 W", took, offer) + } + s.run(5 * time.Minute) + got := offerWatts(s.offersSince(from)) + for i, w := range got { + if w != 10350 && w != 8970 || i > 0 && w > got[i-1] { + t.Fatalf("oven gave offers %v, want one cut to 8,970 W", got) + } + } + if a := s.phaseA(); a[0] > 24 || s.state.fuseHeat.worstA() > 24 { + t.Fatalf("after the cut L1 %.2f A, heat %.2f A", a[0], s.state.fuseHeat.worstA()) + } +} + +// Above 1.2 × In a phase gets no budget: the first reading cuts it back to +// the working limit, even for a spike that would fit in the heat budget. +func TestEVHardLimitCutsAtOnceAboveInstantLimit(t *testing.T) { + for _, tc := range []struct { + atA float64 + offerW float64 + }{{31, 5520}, {32, 4830}} { + s := fuseHeatSite(t) + s.addHouseA(tc.atA-20, 0, 0) + took, offer := s.firstCut(10 * time.Second) + if took != time.Second || offer != tc.offerW { + t.Fatalf("%.0f A cut after %v to %.0f W, want the first reading to %.0f W", tc.atA, took, offer, tc.offerW) + } + } +} + +// A silent meter holds the heat, and its first reading back steps it over +// the gap: the cut lands when it would have with readings all along. +func TestEVHardLimitHeatBudgetAcrossMeterGap(t *testing.T) { + s := fuseHeatSite(t) + s.addHouseA(6, 0, 0) // 26 A on L1 + s.meterSilent = true + held := s.state.fuseHeat.sq + s.run(30 * time.Second) + if s.offer() != 10350 || s.state.fuseHeat.sq != held { + t.Fatalf("during the gap: offer %.0f W heat %v, want %v", s.offer(), s.state.fuseHeat.sq, held) + } + s.meterSilent = false + took, offer := s.firstCut(2 * time.Minute) + if took += 30 * time.Second; took < 59*time.Second || took > 63*time.Second || offer != 8970 { + t.Fatalf("26 A with a 30 s gap cut after %v to %.0f W, want ~61 s to 8,970 W", took, offer) + } +} + +// After a Core restart the heat starts at the first reading. Restarting +// 30 s into 26 A on L1 counts the fuse as already at 26 A and cuts at once, +// which is conservative only because that reading is high. +func TestEVHardLimitRestartStartsHeatAtFirstReading(t *testing.T) { + s := fuseHeatSite(t) + s.addHouseA(6, 0, 0) + s.run(30 * time.Second) + if s.offer() != 10350 { + t.Fatalf("setup: cut before the budget: %.0f W", s.offer()) + } + s.boot() + took, offer := s.firstCut(5 * time.Second) + if took != time.Second || offer != 8970 { + t.Fatalf("restart at 26 A cut after %v to %.0f W, want the first reading to 8,970 W", took, offer) + } +} From 7ab19386b75cd9a16ae0332be5c66dde415126c6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:18:39 +0200 Subject: [PATCH 2/4] fix(control): an EV rise waits for the fuse heat as well (#1674) Repeated house peaks on a 16 A fuse (17 A for 10 s, 14 A between) cut the car, which climbed back in the quiet between peaks and was cut again: 4, 7 and 10 cuts in 30 peaks. Rises and starts now read each phase at the higher of the reading and its heat, so each pattern cuts once and settles where the peaks fit. Adds Fredrik's 16 A case as tests. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/ev-fuse-heat-budget.md | 2 +- docs/safety.md | 3 +- go/internal/control/ev_fuse_budget.go | 4 +- go/internal/control/fuse_heat.go | 11 +++ go/internal/control/fuse_heat_test.go | 132 ++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) diff --git a/.changeset/ev-fuse-heat-budget.md b/.changeset/ev-fuse-heat-budget.md index a75663bdf..0b4721dc8 100644 --- a/.changeset/ev-fuse-heat-budget.md +++ b/.changeset/ev-fuse-heat-budget.md @@ -2,4 +2,4 @@ "ftw": patch --- -The EV's hard limit now treats the main fuse as heat, as a fuse or breaker does. A short house spike, such as a heat pump or an oven starting, no longer cuts the charge. A phase above the fuse less 1 A cuts it only once that phase's current, weighted over about the last minute, passes the same limit, and at once above 1.2 times the fuse. Where the meter reports phase currents, they replace total import as the fuse check, so a short spike on all three phases no longer cuts either. A tariff import ceiling still applies. +The EV's hard limit now treats the main fuse as heat, as a fuse or breaker does. A short house spike, such as a heat pump or an oven starting, no longer cuts the charge. A phase above the fuse less 1 A cuts it only once that phase's current, weighted over about the last minute, passes the same limit, and at once above 1.2 times the fuse. The charge rises again only once that weighted current has room too, so repeated peaks cut it once instead of every minute. Where the meter reports phase currents, they replace total import as the fuse check, so a short spike on all three phases no longer cuts either. A tariff import ceiling still applies. diff --git a/docs/safety.md b/docs/safety.md index 60a41c7df..c030b6b8f 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -52,7 +52,8 @@ available battery energy and hardware power capability. The EV hard limit treats the main fuse as heat, not as one reading. It low-passes each phase's I² from the site meter with a 60 s time constant. A phase over the fuse less 1 A cuts the EVs once that filtered current -passes the same limit, and at once above 1.2 × the fuse. Where the meter +passes the same limit, and at once above 1.2 × the fuse. A rise or start +reads the higher of the reading and that filtered current. Where the meter reports phase currents, they replace total import as the EVs' fuse check; a tariff import ceiling still applies. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 230454094..a480e37e0 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -255,7 +255,7 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first // evMeterRoom is the measured room in EV watts. The fuse's part holds on // 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; rises and starts + fuseW float64 // below the fuse less its margin and the worst phase, read or heat; rises and starts fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat limitW float64 // below the hour's import limit; negative is an overage riseW float64 // below the level an increase may reach @@ -285,7 +285,7 @@ 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-r.worstA)*s.SiteFuseVoltage*phases) + r.fuseW = math.Min(r.fuseW, (limitA-s.fuseHeat.levelA(amps))*s.SiteFuseVoltage*phases) // 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 } diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index fa3b4f1ac..e71645d21 100644 --- a/go/internal/control/fuse_heat.go +++ b/go/internal/control/fuse_heat.go @@ -66,6 +66,17 @@ func (h *fuseHeat) overA(amps []float64, limitA, instantA float64) float64 { return over } +// levelA is the highest phase current, taking each phase at the higher of +// the reading and its heat. Rises and starts read it, so a rise never spends +// heat the last minute has already used. +func (h *fuseHeat) levelA(amps []float64) float64 { + level := 0.0 + for i, a := range amps { + level = math.Max(level, math.Max(a, math.Sqrt(h.sq[i]))) + } + return level +} + // worstA is the highest filtered phase current. func (h *fuseHeat) worstA() float64 { return math.Sqrt(max(h.sq[0], h.sq[1], h.sq[2])) diff --git a/go/internal/control/fuse_heat_test.go b/go/internal/control/fuse_heat_test.go index 34d7cc890..f14e9ecb2 100644 --- a/go/internal/control/fuse_heat_test.go +++ b/go/internal/control/fuse_heat_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -268,3 +269,134 @@ func TestEVHardLimitRestartStartsHeatAtFirstReading(t *testing.T) { t.Fatalf("restart at 26 A cut after %v to %.0f W, want the first reading to 8,970 W", took, offer) } } + +// peaks runs the model on 17 A for 10 s and 14 A for gap, 30 times from +// 14 A, at 1 s readings, and returns the highest filtered current and the +// first second it was over limitA. +func peaks(gap int, limitA float64) (maxA float64, firstOver int) { + var h fuseHeat + t := time.Unix(0, 0) + h.step(t, []float64{14}) + for s := 1; s <= 30*(10+gap); s++ { + a := 14.0 + if (s-1)%(10+gap) < 10 { + a = 17 + } + h.step(t.Add(time.Duration(s)*time.Second), []float64{a}) + maxA = math.Max(maxA, h.worstA()) + if firstOver == 0 && h.sq[0] > limitA*limitA { + firstOver = s + } + } + return maxA, firstOver +} + +// Fredrik, 17 Oct 2026: on a 16 A fuse a steady 17 A must come down, but +// 10 s peaks to 17 A with 14 A between them may be fine in total, since a +// fuse trips on heat. The working limit is 15 A. A phase cuts when its +// filtered current is strictly over the limit; exactly on it is inside, as +// for a reading. The 30 s pattern goes 0.011 A over in its seventh peak, so +// either rule would cut it. +func TestFuseHeatSixteenAmpPeaks(t *testing.T) { + for _, tc := range []struct { + gap int + maxA float64 + firstOver int // 0: never + }{ + {50, 14.78, 0}, + {30, 15.01, 250}, + {20, 15.24, 98}, + {10, 15.70, 48}, + } { + maxA, firstOver := peaks(tc.gap, 15) + if math.Abs(maxA-tc.maxA) > .005 || firstOver != tc.firstOver { + t.Errorf("17 A 10 s / 14 A %d s: max %.3f A, over at %d s; want %.2f A, %d s", tc.gap, maxA, firstOver, tc.maxA, tc.firstOver) + } + } + if got := secondsToSpend(14, 17, 15); got != 23 { + t.Errorf("a steady 17 A spent the budget after %d s, want 23 s", got) + } +} + +// fuse16Site is Fredrik's case end to end: a 16 A fuse, 4 A of house load +// and the car at 10 A on each phase, 14 A in all, settled. +func fuse16Site(t *testing.T) *evMeterSite { + t.Helper() + s := newEVMeterSite(t, 0, 2760) + s.state.SiteFuseAmps = 16 + s.ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + s.ctrl.SetFuseEVMax(func() (float64, bool) { + return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), 16*690) + }) + s.run(10 * time.Minute) + if s.offer() != 6900 || s.phaseA()[0] != 14 || math.Abs(s.state.fuseHeat.worstA()-14) > .01 { + t.Fatalf("setup: offer %.0f W L1 %.2f A heat %.2f A", s.offer(), s.phaseA()[0], s.state.fuseHeat.worstA()) + } + return s +} + +// The same peaks on L1 with the car charging: patterns whose heat stays +// under 15 A never move the offer; the others cut once when the budget is +// spent and settle where the peaks fit. A rise reads the heat as well as +// the reading, so the car does not climb back between peaks and cut again. +func TestEVHardLimitSixteenAmpPeaksCutOnceAndSettle(t *testing.T) { + for _, tc := range []struct { + name string + gap time.Duration // 0: the 17 A stays + cutAfter time.Duration // 0: never + settleW float64 + }{ + {"14A_50s", 50 * time.Second, 0, 6900}, + {"14A_30s", 30 * time.Second, 250 * time.Second, 6210}, + {"14A_20s", 20 * time.Second, 98 * time.Second, 6210}, + {"14A_10s", 10 * time.Second, 48 * time.Second, 5520}, + {"steady_17A", 0, 23 * time.Second, 5520}, + } { + t.Run(tc.name, func(t *testing.T) { + s := fuse16Site(t) + from, maxHeat := s.now, 0.0 + run := func(d time.Duration) { + for end := s.now.Add(d); s.now.Before(end); { + s.step() + maxHeat = math.Max(maxHeat, s.state.fuseHeat.worstA()) + } + } + if tc.gap == 0 { + s.addHouseA(3, 0, 0) + run(10 * time.Minute) + } else { + for range 30 { + s.addHouseA(3, 0, 0) + run(10 * time.Second) + s.addHouseA(-3, 0, 0) + run(tc.gap) + } + } + var cuts []evTimed + offers := s.offersSince(from) + for i := 1; i < len(offers); i++ { + if offers[i].w < offers[i-1].w { + cuts = append(cuts, offers[i]) + } + } + want := 0 + if tc.cutAfter > 0 { + want = 1 + } + if len(cuts) != want { + t.Fatalf("cuts %v, want %d", offerWatts(cuts), want) + } + if tc.cutAfter > 0 { + if took := cuts[0].at.Sub(from); took < tc.cutAfter-3*time.Second || took > tc.cutAfter+3*time.Second { + t.Errorf("cut after %v, want ~%v", took, tc.cutAfter) + } + } + if s.offer() != tc.settleW { + t.Errorf("settled at %.0f W, want %.0f W", s.offer(), tc.settleW) + } + if maxHeat > 15.2 { + t.Errorf("heat reached %.3f A on a 15 A working limit", maxHeat) + } + }) + } +} From db9717cf2ecf9488038da101c149f109ba500c48 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 16:45:06 +0200 Subject: [PATCH 3/4] test(control): a total import the phases contradict does not cut the EV (#1674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Göran's site, 14:20:34Z on v0.147.2-beta.1: one Pixii reading gave 19,174 W while its worst phase was 5.7 A, and the total against the fuse cut the car from 10,350 W to 8,081 W. With phase currents the phases judge the fuse and the offer holds; without them the same total still cuts to 8,081 W. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- go/internal/control/fuse_heat_test.go | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/go/internal/control/fuse_heat_test.go b/go/internal/control/fuse_heat_test.go index f14e9ecb2..82faa7c45 100644 --- a/go/internal/control/fuse_heat_test.go +++ b/go/internal/control/fuse_heat_test.go @@ -400,3 +400,50 @@ func TestEVHardLimitSixteenAmpPeaksCutOnceAndSettle(t *testing.T) { }) } } + +// Göran, 14:20:34Z on v0.147.2-beta.1: one Pixii reading gave 19,174 W of +// total import while its worst phase was 5.7 A on a 25 A fuse. The total +// against 16,905 W cut the car from 10,350 W to 8,081 W, and it took about a +// minute to climb back. With phase currents the phases judge the fuse, so +// the offer holds; without them the same total still cuts. +func TestEVHardLimitHoldsWhenTotalImportDisagreesWithPhases(t *testing.T) { + store, state, points, configs := evMeterBudgetFixture(t, 0, nil, 10350, "3p") + state.SiteFuseSafetyA = 0.5 // 17,250 - 345 = 16,905 W, as on the site + t0 := time.Unix(1_792_246_834, 0) + now := t0 + state.clock = func() time.Time { return now } + read := func(at time.Time, gridW float64, phaseA []float64) float64 { + t.Helper() + now = at + data := map[string]any{"control_power_available": true, "control_power_observed_at": at.Format(time.RFC3339Nano)} + if phaseA != nil { + data["l1_a"], data["l2_a"], data["l3_a"] = phaseA[0], phaseA[1], phaseA[2] + } + raw, _ := json.Marshal(data) + store.Update("meter", telemetry.DerMeter, gridW, nil, raw) + store.Get("meter", telemetry.DerMeter).UpdatedAt = at + capW, _ := EVFuseBudget(store, state, points, configs, 17250) + return capW + } + for i, r := range []struct { + gridW float64 + phaseA []float64 + }{ + {15334, []float64{12.3, 9.5, 7.9}}, + {19174, []float64{5.7, 4.9, 4.1}}, // the reading that cut + {15334, []float64{12.3, 9.5, 7.9}}, + } { + if capW := read(t0.Add(time.Duration(i)*time.Second), r.gridW, r.phaseA); capW < 10350 { + t.Fatalf("reading %d (%.0f W, worst phase %.1f A) cut the cap to %.0f W", i, r.gridW, r.phaseA[0], capW) + } + } + + store, state, points, configs = evMeterBudgetFixture(t, 0, nil, 10350, "3p") + state.SiteFuseSafetyA = 0.5 + now = t0 + state.clock = func() time.Time { return now } + read(t0, 15334, nil) + if capW := read(t0.Add(time.Second), 19174, nil); math.Abs(capW-8081) > .01 { + t.Fatalf("without phase currents 19,174 W gave %.1f W, want 10,350 - 2,269 = 8,081 W", capW) + } +} From f16b34b7661de4cb0fc8f8fedb324d36edc23173 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sat, 17 Oct 2026 17:30:50 +0200 Subject: [PATCH 4/4] fix(control): the fuse heat counts a meter gap at the last current seen (#1674) The heat model applied each new reading's current to the whole time since the last one. After a meter gap, one high reading counted as the whole gap at that current and could cut the car at once, and a low one erased an overload from before the gap. A reading now stands for the time until the next: the model integrates the last current seen over that time, then holds the new one. Cut times, counted from the first reading at the new current, are unchanged. The 30 s pattern on a 16 A fuse peaks just over 15 A only as a peak ends, when the meter shows 14 A, so it is not cut. Raised by the Codex review on #1701. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- go/internal/control/fuse_heat.go | 40 +++++----- go/internal/control/fuse_heat_test.go | 109 ++++++++++++++++---------- 2 files changed, 88 insertions(+), 61 deletions(-) diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index e71645d21..f6fcffbd9 100644 --- a/go/internal/control/fuse_heat.go +++ b/go/internal/control/fuse_heat.go @@ -24,33 +24,37 @@ const fuseInstantFactor = 1.2 // trip at 1.13 × In, a gG fuse does not blow at 1.25 × In), and gives no // budget above fuseInstantFactor. // -// A Core restart starts the model at the first reading's current, with no -// memory of the minutes before. +// A reading stands for the time until the next one. A meter gap counts at +// the last current seen: the reading after it neither fills the gap with its +// own current nor erases an overload from before it. A Core restart starts +// the model at the first reading's current, with no memory of the minutes +// before. type fuseHeat struct { - set bool - at time.Time // the reading the model last stepped on - sq [3]float64 // filtered I² per phase, A² + set bool + at time.Time // the last reading + amps [3]float64 // its phase currents, held until the next + sq [3]float64 // filtered I² per phase up to at, A² } -// step folds one reading into the model over the real time since the last. -// Between readings the heat holds. +// step folds the time since the last reading into the model at that +// reading's currents, then holds the new ones. func (h *fuseHeat) step(at time.Time, amps []float64) { if !h.set { for i, a := range amps { h.sq[i] = a * a } - h.set, h.at = true, at - return - } - dt := at.Sub(h.at) - if dt <= 0 { - return - } - k := -math.Expm1(-dt.Seconds() / fuseHeatTau.Seconds()) - for i, a := range amps { - h.sq[i] += (a*a - h.sq[i]) * k + } else { + dt := at.Sub(h.at) + if dt <= 0 { + return + } + k := -math.Expm1(-dt.Seconds() / fuseHeatTau.Seconds()) + for i, a := range h.amps { + h.sq[i] += (a*a - h.sq[i]) * k + } } - h.at = at + h.set, h.at, h.amps = true, at, [3]float64{} + copy(h.amps[:], amps) } // overA is how far the phases the budget no longer covers sit above limitA diff --git a/go/internal/control/fuse_heat_test.go b/go/internal/control/fuse_heat_test.go index 82faa7c45..df4af1ab8 100644 --- a/go/internal/control/fuse_heat_test.go +++ b/go/internal/control/fuse_heat_test.go @@ -11,7 +11,8 @@ import ( ) // secondsToSpend runs the model at 1 s readings from fromA to atA and -// returns the first second the filtered current is over limitA. +// returns how long after the first reading at atA the filtered current is +// over limitA. func secondsToSpend(fromA, atA, limitA float64) int { var h fuseHeat t := time.Unix(0, 0) @@ -19,7 +20,7 @@ func secondsToSpend(fromA, atA, limitA float64) int { for s := 1; s <= 3600; s++ { h.step(t.Add(time.Duration(s)*time.Second), []float64{atA}) if h.sq[0] > limitA*limitA { - return s + return s - 1 } } return -1 @@ -40,8 +41,12 @@ func TestFuseHeatSpendsBudgetFrom20A(t *testing.T) { var h fuseHeat t0 := time.Unix(0, 0) h.step(t0, []float64{20}) - for s := 1; s <= 10; s++ { - h.step(t0.Add(time.Duration(s)*time.Second), []float64{32}) + for s := 1; s <= 11; s++ { + a := 32.0 + if s == 11 { + a = 20 // the reading that ends the spike + } + h.step(t0.Add(time.Duration(s)*time.Second), []float64{a}) } if got := h.worstA(); math.Abs(got-22.3) > .05 { t.Fatalf("10 s at 32 A left %.2f A of heat, want 22.3 A", got) @@ -63,30 +68,42 @@ func meterAt(store *telemetry.Store, at time.Time, phaseA [3]float64) *telemetry return r } -// While the meter is silent the heat holds. The first reading after a gap -// longer than evSettle steps it over the whole gap. -func TestFuseHeatHoldsThroughGapAndStepsRealTime(t *testing.T) { - store, state := fuseHeatState() - t0 := time.Unix(1_800_000_000, 0) - state.stepFuseHeat(meterAt(store, t0, [3]float64{20, 20, 20})) - silent := store.Get("meter", telemetry.DerMeter) - for range 30 { - state.stepFuseHeat(silent) // ticks with no new reading - } - if got := state.fuseHeat.worstA(); got != 20 { - t.Fatalf("heat moved to %.3f A without a reading", got) - } +// While the meter is silent the heat holds. A gap counts at the last +// current seen: the reading after it neither fills the gap with its own +// current nor erases an overload from before it. +func TestFuseHeatCountsGapAtLastCurrentSeen(t *testing.T) { gap := 30 * time.Second if gap <= evSettle { t.Fatalf("test gap %v must exceed evSettle %v", gap, evSettle) } - state.stepFuseHeat(meterAt(store, t0.Add(gap), [3]float64{26, 20, 20})) - want := 676 - 276*math.Exp(-gap.Seconds()/60) - if got := state.fuseHeat.sq[0]; math.Abs(got-want) > 1e-9 { - t.Fatalf("heat after a %v gap %.3f A², want %.3f A²", gap, got, want) - } - if state.fuseHeat.sq[1] != 400 { - t.Fatalf("a steady phase moved to %.3f A²", state.fuseHeat.sq[1]) + for _, tc := range []struct { + name string + before, after float64 + }{ + {"spike_after_gap", 20, 28}, + {"overload_before_gap", 28, 20}, + } { + store, state := fuseHeatState() + t0 := time.Unix(1_800_000_000, 0) + state.stepFuseHeat(meterAt(store, t0.Add(-time.Hour), [3]float64{20, 20, 20})) + state.stepFuseHeat(meterAt(store, t0, [3]float64{tc.before, 20, 20})) + held := state.fuseHeat.sq + silent := store.Get("meter", telemetry.DerMeter) + for range 30 { + state.stepFuseHeat(silent) // ticks with no new reading + } + if state.fuseHeat.sq != held { + t.Fatalf("%s: heat moved to %v without a reading", tc.name, state.fuseHeat.sq) + } + state.stepFuseHeat(meterAt(store, t0.Add(gap), [3]float64{tc.after, 20, 20})) + a := tc.before + want := a*a - (a*a-held[0])*math.Exp(-gap.Seconds()/60) + if got := state.fuseHeat.sq[0]; math.Abs(got-want) > 1e-9 { + t.Fatalf("%s: heat after a %v gap %.3f A², want %.3f A²", tc.name, gap, got, want) + } + if state.fuseHeat.sq[1] != 400 { + t.Fatalf("%s: a steady phase moved to %.3f A²", tc.name, state.fuseHeat.sq[1]) + } } } @@ -120,14 +137,15 @@ func (s *evMeterSite) addHouseA(l1, l2, l3 float64) { s.houseW += (l1 + l2 + l3) * 230 } -// firstCut runs until the offer drops below 10,350 W and returns when. +// firstCut runs until the offer drops below 10,350 W and returns how long +// after the first reading of the run that was. func (s *evMeterSite) firstCut(limit time.Duration) (time.Duration, float64) { s.t.Helper() - from := s.now - for s.now.Sub(from) < limit { + first := s.now.Add(time.Second) + for s.now.Sub(first) < limit { s.step() if s.offer() < 10350 { - return s.now.Sub(from), s.offer() + return s.now.Sub(first), s.offer() } } return -1, s.offer() @@ -229,14 +247,16 @@ func TestEVHardLimitCutsAtOnceAboveInstantLimit(t *testing.T) { s := fuseHeatSite(t) s.addHouseA(tc.atA-20, 0, 0) took, offer := s.firstCut(10 * time.Second) - if took != time.Second || offer != tc.offerW { + if took != 0 || offer != tc.offerW { t.Fatalf("%.0f A cut after %v to %.0f W, want the first reading to %.0f W", tc.atA, took, offer, tc.offerW) } } } -// A silent meter holds the heat, and its first reading back steps it over -// the gap: the cut lands when it would have with readings all along. +// A silent meter holds the heat and counts the gap at the last current it +// showed. A house step during the gap starts to count from the first reading +// that shows it, so the cut lands a budget after the meter returns. An +// overload the meter never showed is not counted. func TestEVHardLimitHeatBudgetAcrossMeterGap(t *testing.T) { s := fuseHeatSite(t) s.addHouseA(6, 0, 0) // 26 A on L1 @@ -248,8 +268,8 @@ func TestEVHardLimitHeatBudgetAcrossMeterGap(t *testing.T) { } s.meterSilent = false took, offer := s.firstCut(2 * time.Minute) - if took += 30 * time.Second; took < 59*time.Second || took > 63*time.Second || offer != 8970 { - t.Fatalf("26 A with a 30 s gap cut after %v to %.0f W, want ~61 s to 8,970 W", took, offer) + if took < 59*time.Second || took > 63*time.Second || offer != 8970 { + t.Fatalf("26 A after a 30 s gap cut %v after the meter returned, to %.0f W; want ~61 s to 8,970 W", took, offer) } } @@ -265,14 +285,14 @@ func TestEVHardLimitRestartStartsHeatAtFirstReading(t *testing.T) { } s.boot() took, offer := s.firstCut(5 * time.Second) - if took != time.Second || offer != 8970 { + if 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) } } // peaks runs the model on 17 A for 10 s and 14 A for gap, 30 times from -// 14 A, at 1 s readings, and returns the highest filtered current and the -// first second it was over limitA. +// 14 A, at 1 s readings, and returns the highest filtered current and how +// long after the first 17 A reading it was first over limitA. func peaks(gap int, limitA float64) (maxA float64, firstOver int) { var h fuseHeat t := time.Unix(0, 0) @@ -285,7 +305,7 @@ func peaks(gap int, limitA float64) (maxA float64, firstOver int) { h.step(t.Add(time.Duration(s)*time.Second), []float64{a}) maxA = math.Max(maxA, h.worstA()) if firstOver == 0 && h.sq[0] > limitA*limitA { - firstOver = s + firstOver = s - 1 } } return maxA, firstOver @@ -295,8 +315,8 @@ func peaks(gap int, limitA float64) (maxA float64, firstOver int) { // 10 s peaks to 17 A with 14 A between them may be fine in total, since a // fuse trips on heat. The working limit is 15 A. A phase cuts when its // filtered current is strictly over the limit; exactly on it is inside, as -// for a reading. The 30 s pattern goes 0.011 A over in its seventh peak, so -// either rule would cut it. +// for a reading. The 30 s pattern goes 0.011 A over at the end of its +// seventh peak, so either rule counts it as over. func TestFuseHeatSixteenAmpPeaks(t *testing.T) { for _, tc := range []struct { gap int @@ -336,8 +356,8 @@ func fuse16Site(t *testing.T) *evMeterSite { } // The same peaks on L1 with the car charging: patterns whose heat stays -// under 15 A never move the offer; the others cut once when the budget is -// spent and settle where the peaks fit. A rise reads the heat as well as +// under 15 A while the meter shows a peak never move the offer; the others +// cut once when the budget is spent and settle where the peaks fit. A rise reads the heat as well as // the reading, so the car does not climb back between peaks and cut again. func TestEVHardLimitSixteenAmpPeaksCutOnceAndSettle(t *testing.T) { for _, tc := range []struct { @@ -347,7 +367,9 @@ func TestEVHardLimitSixteenAmpPeaksCutOnceAndSettle(t *testing.T) { settleW float64 }{ {"14A_50s", 50 * time.Second, 0, 6900}, - {"14A_30s", 30 * time.Second, 250 * time.Second, 6210}, + // The heat passes 15 A only as the peak ends, when the meter + // already shows 14 A: nothing is left to cut. + {"14A_30s", 30 * time.Second, 0, 6900}, {"14A_20s", 20 * time.Second, 98 * time.Second, 6210}, {"14A_10s", 10 * time.Second, 48 * time.Second, 5520}, {"steady_17A", 0, 23 * time.Second, 5520}, @@ -387,7 +409,8 @@ func TestEVHardLimitSixteenAmpPeaksCutOnceAndSettle(t *testing.T) { t.Fatalf("cuts %v, want %d", offerWatts(cuts), want) } if tc.cutAfter > 0 { - if took := cuts[0].at.Sub(from); took < tc.cutAfter-3*time.Second || took > tc.cutAfter+3*time.Second { + // From the first reading of the first peak. + if took := cuts[0].at.Sub(from) - time.Second; took < tc.cutAfter-2*time.Second || took > tc.cutAfter+2*time.Second { t.Errorf("cut after %v, want ~%v", took, tc.cutAfter) } }