From a840def5dd71e2c43681c9ece30047053947856e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 14:26:19 +0100 Subject: [PATCH 01/34] fix(control): battery fuse guard reads each phase with its direction Delete perPhaseOverageW, perPhaseOverageReadingW and perPhaseReliefW. The battery fuse guard, the fuse saver and the forced discharge read the signed phase currents (phaseCurrentsA, with #1849's sum check and dead zone), count the batteries' own move on each phase, and relieve to the car's working limit, the fuse less max(1 A, safety_margin_a). Item A5 of the stable plan. Co-Authored-By: Claude Opus 5.5 --- .changeset/a5-battery-fuse-signed-phases.md | 5 + go/internal/control/dispatch.go | 231 +++++----------- .../control/ev_battery_priority_test.go | 5 +- .../control/ev_battery_sequence_test.go | 6 +- .../fuse_battery_phase_direction_test.go | 146 ++++++++++ go/internal/control/fuse_phase.go | 2 +- go/internal/control/fuse_saver_test.go | 126 ++++----- go/internal/control/golden_dump_test.go | 7 +- go/internal/control/peak_limit_test.go | 2 +- .../golden/early_exit_protections.json | 66 ++--- .../control/testdata/golden/seeded_fuse.json | 261 +++++++++--------- .../control/testdata/golden/slew_limiter.json | 10 +- .../testdata/golden/targeted_invariants.json | 24 +- 13 files changed, 477 insertions(+), 414 deletions(-) create mode 100644 .changeset/a5-battery-fuse-signed-phases.md create mode 100644 go/internal/control/fuse_battery_phase_direction_test.go diff --git a/.changeset/a5-battery-fuse-signed-phases.md b/.changeset/a5-battery-fuse-signed-phases.md new file mode 100644 index 000000000..9d5f66132 --- /dev/null +++ b/.changeset/a5-battery-fuse-signed-phases.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The home battery now protects each fuse phase in the direction its current flows. It no longer discharges into a phase that already exports past the fuse, and no longer cuts a discharge that keeps an importing phase under it. It counts its own share of each phase, so it holds a steady relief instead of switching between full relief and none on every dispatch. It now relieves to the same limit the car uses: the fuse less 1 A, or less the safety margin when that is larger. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 3e01f1eea..ed19b5165 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -3592,7 +3592,7 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta // replacement target would double-remove their contribution and miss // fuse overages. seenBat := make(map[string]struct{}, len(targets)) - var currentBat float64 + var currentBat, rawBat float64 for _, t := range targets { if _, seen := seenBat[t.Driver]; seen { continue @@ -3600,12 +3600,14 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta seenBat[t.Driver] = struct{}{} if r := store.Get(t.Driver, telemetry.DerBattery); r != nil { currentBat += r.SmoothedW + rawBat += r.RawW } } var currentGrid float64 + var meter *telemetry.DerReading if siteMeter != "" { - if r := store.Get(siteMeter, telemetry.DerMeter); r != nil { - currentGrid = r.SmoothedW + if meter = store.Get(siteMeter, telemetry.DerMeter); meter != nil { + currentGrid = meter.SmoothedW } } var sumTarget float64 @@ -3614,22 +3616,9 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta } predicted := currentGrid - currentBat + sumTarget - // Per-phase relief: the worst single-phase amperage above the fuse - // trip threshold (less the safety margin), converted by - // perPhaseReliefW into the AGGREGATE battery action needed to bring - // it back — the site's configured phase count does that conversion. - // - // This guard uses relief in BOTH directions. `perPhaseOverageW` is - // direction-agnostic — phase amps from the meter are absolute - // magnitudes. Attribute to whichever side the AGGREGATE METER is - // currently flowing (currentGrid), not to the post-target - // `predicted`. Per-phase amps and currentGrid are read from the - // same DerMeter sample, so they're internally consistent; - // `predicted` mixes in `sumTarget` (a hypothetical future state) - // and can swing across 0 on a large planner request, attributing - // a current-export overage to the import path (or vice versa) and - // pushing the grid further into the violating direction. - perPhase := perPhaseReliefW(store, state) + // Per phase, each way, once the targets replace the batteries' power + // in the same raw reading. + phaseImportW, phaseExportW := phaseFuseReliefW(meter, state, sumTarget-rawBat) // Aggregate budget honours the safety margin too — keep dispatch // commands strictly inside the breaker envelope so the inverter's // own per-phase limiter doesn't fire first and cause a flap. @@ -3641,19 +3630,8 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta // (recurring Ferroamp 0x8030 fault). Both share one enforcement surface. effImportW := state.effectiveImportCeilingW(fuseMaxW) effExportW := state.effectiveExportCeilingW(fuseMaxW) - importOverage := predicted - effImportW - exportOverage := -effExportW - predicted - if perPhase > 0 { - if currentGrid >= 0 { - if perPhase > importOverage { - importOverage = perPhase - } - } else { - if perPhase > exportOverage { - exportOverage = perPhase - } - } - } + importOverage := math.Max(predicted-effImportW, phaseImportW) + exportOverage := math.Max(-effExportW-predicted, phaseExportW) out := make([]DispatchTarget, len(targets)) copy(out, targets) @@ -3844,106 +3822,55 @@ func (s *State) effectiveExportCeilingW(fuseMaxW float64) float64 { return eff } -// perPhaseOverageW returns the wattage by which the worst single phase -// exceeds the per-phase fuse amperage (less the safety margin). 0 when -// within limits, when per-phase data isn't available, or when the -// per-phase clamp is disabled (state.SiteFuseAmps == 0). The meter -// driver must emit l1_a / l2_a / l3_a in DerReading.Data — Pixii, -// Ferroamp, and Sungrow all do this today. -// -// Only the configured phases are read, in L1/L2/L3 order, matching -// sitePhaseCurrentsAt in cmd/ftw: a 1Φ site has no L2, so a stray -// l2_a in the meter payload is not a breaker this site owns. -// -// Same law as fuseSafetyMarginW: amps become watts only through the -// configured per-phase voltage. No hardcoded 230 V here — a site whose -// fuse description is incomplete gets no per-phase clamp rather than an -// invented one. Config fills both fields (fuse.voltage defaults to 230, -// fuse.phases to 3, and validation rejects <= 0), so this only affects -// harnesses that wire SiteFuseAmps alone. -// -// Direction-agnostic: phase amps from the meter are absolute magnitudes, -// so this function reports overage regardless of whether the breaker is -// being approached on the import or export side. The caller attributes -// the overage to whichever direction the aggregate grid is flowing. -func perPhaseOverageW(store *telemetry.Store, state *State) float64 { - if state == nil || state.SiteFuseAmps <= 0 || state.SiteMeterDriver == "" { - return 0 - } - r := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - return perPhaseOverageReadingW(r, state) -} - -// perPhaseOverageReadingW is the snapshot form used when one control decision -// must keep the aggregate reading and its phase data from the same meter sample. -func perPhaseOverageReadingW(r *telemetry.DerReading, state *State) float64 { - if state == nil || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases <= 0 { - return 0 - } - if r == nil || len(r.Data) == 0 { - return 0 - } - var d struct { - L1A *float64 `json:"l1_a"` - L2A *float64 `json:"l2_a"` - L3A *float64 `json:"l3_a"` - } - if err := json.Unmarshal(r.Data, &d); err != nil { - return 0 - } - // Use absolute magnitude — drivers like Pixii decode per-phase amps - // as signed i16, so export shows up as negative numbers and a naive - // `*p > maxA` walk starting from 0 leaves maxA = 0 (no fire) even - // when a phase is at -17 A. The fuse trips on current magnitude - // regardless of direction, and the caller attributes the overage - // to the active aggregate-grid direction. - phaseAmps := [...]*float64{d.L1A, d.L2A, d.L3A} - phases := state.SiteFusePhases - if phases > len(phaseAmps) { - phases = len(phaseAmps) - } - maxA := 0.0 - for i := 0; i < phases; i++ { - p := phaseAmps[i] - if p == nil { - continue - } - a := math.Abs(*p) - if a > maxA { - maxA = a - } - } - threshold := state.SiteFuseAmps - state.SiteFuseSafetyA - if threshold < 0 { - threshold = 0 - } - if maxA <= threshold { - return 0 - } - return (maxA - threshold) * state.SiteFuseVoltage -} - -// perPhaseReliefW converts a worst-phase overage into the AGGREGATE -// battery action needed to clear it. One conversion, one law: a battery -// spreads its action evenly across the site's phases, so each watt of -// aggregate action relieves 1/SiteFusePhases watts on the worst phase, -// and an overage of N watts needs N × SiteFusePhases watts of action. -// -// The phase count is the site's, from config — not the constant 3 the -// two call sites used to carry separately. On a 1Φ site the aggregate -// meter and the single phase are the same wire, so demanding 3 × N -// there overshoots by 2 × N and can push the grid through zero into a -// violation on the opposite side. +// phaseFuseReliefW is the aggregate battery move that brings every phase +// inside the working limit: the fuse less the car's 1 A margin, or less +// safety_margin_a when that is larger. importW asks for less charge or more +// discharge, exportW for the reverse. moveW is the batteries' change from +// the power this reading already holds; like the relief, it spreads evenly +// over the site's phases. // -// Conservative for a 1Φ battery on a 3Φ site (Pixii Home etc.): it -// over-corrects on the phases it does not sit on — less import/export -// there, still safe. -func perPhaseReliefW(store *telemetry.Store, state *State) float64 { - overage := perPhaseOverageW(store, state) - if overage <= 0 { - return 0 - } - return overage * float64(state.SiteFusePhases) +// The phases are the meter's signed currents (phaseCurrentsA). When no +// phase reads as export while the total does, the meter gives no direction +// per phase, and the phases take the total's. When no move fits every +// phase, the move leaves the worst phase least over, so a move never +// raises the current on the phase it protects. 0, 0 without usable phase +// currents. +func phaseFuseReliefW(meter *telemetry.DerReading, state *State, moveW float64) (importW, exportW float64) { + if meter == nil || state == nil { + return 0, 0 + } + amps, _, valid := phaseCurrentsA(meter, state, meter.RawW) + if !valid || len(amps) == 0 { + return 0, 0 + } + perA := state.SiteFuseVoltage * float64(state.SiteFusePhases) // aggregate W per phase ampere + limitA := math.Max(0, state.SiteFuseAmps-math.Max(evPhaseMarginA, state.SiteFuseSafetyA)) + exports := meter.RawW < 0 + for _, a := range amps { + exports = exports && a >= 0 + } + hi, lo := math.Inf(-1), math.Inf(1) + for _, a := range amps { + if exports { + a = -a + } + a += moveW / perA + hi, lo = math.Max(hi, a), math.Min(lo, a) + } + // The smallest shift that keeps every phase inside, or the middle one. + shiftA := math.Max(-limitA-lo, math.Min(0, limitA-hi)) + if -limitA-lo > limitA-hi { + shiftA = -(hi + lo) / 2 + } + importW, exportW = math.Max(0, -shiftA)*perA, math.Max(0, shiftA)*perA + // A target that fills the limit exactly can land a rounding error past it. + if importW < 1 { + importW = 0 + } + if exportW < 1 { + exportW = 0 + } + return importW, exportW } // applyPlanSignFloor enforces "executed battery total must agree in sign @@ -4431,7 +4358,7 @@ func fuseSaverFromLivePower( // site is safe. Preserve the deadband in that case: this early-return path // exists to answer a fuse condition, not to take control of an externally // moved battery merely because its reading sits outside a command cap. - requiredRelief := fuseReliefFromSnapshot(rawLive, batteryReadings, meter, state, fuseMaxW, false) + requiredRelief := fuseReliefFromSnapshot(rawLive, batteryReadings, meter, state, fuseMaxW) if requiredRelief <= 0 { return nil } @@ -4449,7 +4376,7 @@ func fuseSaverFromLivePower( if len(safeBaseline) == 0 { return nil } - out := forceFuseDischargeWithSnapshot(safeBaseline, batteryReadings, meter, state, driverCapacities, fuseMaxW, true) + out := forceFuseDischargeWithSnapshot(safeBaseline, batteryReadings, meter, state, driverCapacities, fuseMaxW) rawByDriver := make(map[string]DispatchTarget, len(rawLive)) for _, t := range rawLive { rawByDriver[t.Driver] = t @@ -4474,45 +4401,34 @@ func fuseReliefFromSnapshot( meter *telemetry.DerReading, state *State, fuseMaxW float64, - accountPhaseTargetDelta bool, ) float64 { if fuseMaxW <= 0 || len(targets) == 0 || state == nil { return 0 } - currentBat, sumTarget, predicted := predictedGridFromSnapshot(targets, batteryReadings, meter) - currentGrid := 0.0 - if meter != nil { - currentGrid = meter.SmoothedW - } + _, sumTarget, predicted := predictedGridFromSnapshot(targets, batteryReadings, meter) // The fuse acts on every reading. The hour's import limit is a budget: // the part of its overage that an EV cut we sent will remove waits for // that cut while the meter may not show it yet. Relief for the same watts // would discharge the battery into export once the cut lands (#1674). - // Per-phase relief remains fuse-only and may act only while the matching - // aggregate meter sample is on the import side. + // Per-phase relief is fuse-only. fuseW := math.Max(0, fuseMaxW-state.fuseSafetyMarginW()) overage := predicted - fuseW if ceilingW := state.effectiveImportCeilingW(fuseMaxW); ceilingW < fuseW { overage = math.Max(overage, predicted-ceilingW-state.pendingEVCutW()) } - if currentGrid >= 0 { - relief := perPhaseOverageReadingW(meter, state) * float64(state.SiteFusePhases) - if relief > 0 && accountPhaseTargetDelta { - // The early fuse baseline can only hold or reduce each included - // target. Count that import reduction against the raw phase relief so - // a command-cap clamp is not followed by the same correction twice. - relief += sumTarget - currentBat - if relief < 0 { - relief = 0 - } - } - if relief > overage { - overage = relief + // The phases are raw, so the batteries' move is from their raw power. + moveW := sumTarget + seen := make(map[string]bool, len(targets)) + for _, t := range targets { + if r := batteryReadings[t.Driver]; r != nil && !seen[t.Driver] { + moveW -= r.RawW } + seen[t.Driver] = true } - return overage + phaseW, _ := phaseFuseReliefW(meter, state, moveW) + return math.Max(overage, phaseW) } // predictedGridFromSnapshot holds non-targeted site power constant and replaces @@ -4564,7 +4480,7 @@ func forceFuseDischarge( readings[target.Driver] = store.Get(target.Driver, telemetry.DerBattery) } meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - return forceFuseDischargeWithSnapshot(targets, readings, meter, state, driverCapacities, fuseMaxW, false) + return forceFuseDischargeWithSnapshot(targets, readings, meter, state, driverCapacities, fuseMaxW) } // forceFuseDischargeWithSnapshot keeps all math for one decision on the same @@ -4577,12 +4493,11 @@ func forceFuseDischargeWithSnapshot( state *State, driverCapacities map[string]float64, fuseMaxW float64, - accountPhaseTargetDelta bool, ) []DispatchTarget { if fuseMaxW <= 0 || len(targets) == 0 || state == nil { return targets } - overage := fuseReliefFromSnapshot(targets, batteryReadings, meter, state, fuseMaxW, accountPhaseTargetDelta) + overage := fuseReliefFromSnapshot(targets, batteryReadings, meter, state, fuseMaxW) if overage <= 0 { return targets } diff --git a/go/internal/control/ev_battery_priority_test.go b/go/internal/control/ev_battery_priority_test.go index f84b0d96c..582a67eee 100644 --- a/go/internal/control/ev_battery_priority_test.go +++ b/go/internal/control/ev_battery_priority_test.go @@ -47,7 +47,10 @@ func TestIssue1868BatteryChargeYieldsBeforeTheCar(t *testing.T) { if target.TargetW > 3931.001 { t.Fatalf("%s left the car's relief in the battery: %+v", mode, target) } - if mode == ModeCharge && target.TargetW < 3800 { + // Charge mode asks for a raise. The fuse guard caps it at L1's + // working limit less half the margin (3758.5 W), and the car's + // headroom then takes the same 69 W again. + if mode == ModeCharge && target.TargetW < 3600 { t.Fatalf("small overage needlessly stopped battery charging: %+v", target) } } diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index aa46d4acc..d94da1831 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -556,7 +556,7 @@ func TestFuseReliefLeavesOnlyCeilingWattsToCommandedEVCut(t *testing.T) { targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {}} meter := &telemetry.DerReading{SmoothedW: tc.gridW, Data: tc.data} - got := fuseReliefFromSnapshot(targets, batteries, meter, st, 17250, false) + got := fuseReliefFromSnapshot(targets, batteries, meter, st, 17250) if math.Abs(math.Max(0, got)-tc.wantW) > 1e-6 { t.Errorf("%s: relief %.0f W, want %.0f W", tc.name, got, tc.wantW) } @@ -597,7 +597,7 @@ func TestUnpluggedCarIsNoCutTheBatteryWaitsFor(t *testing.T) { st.SetEVCommanded(points) targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {}} - return math.Max(0, fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250, false)) + return math.Max(0, fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250)) } // 400 W over the ceiling, less than a step: the guard waits. if got := tick(7900, true, 5520); math.Abs(got-400) > 1e-6 { @@ -661,7 +661,7 @@ func TestCutOnStayingCarStillWaitsWhenAnotherLeaves(t *testing.T) { st.SetEVCommanded(points) targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {SmoothedW: batteryW}} - relief := fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250, false) + relief := fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250) batteryW = -math.Max(0, relief) // The battery follows within a tick. if batteryW < 0 && tick < 10 { t.Fatalf("%ds: battery %.0f W for watts a's stop removes; pending cut %.0f W", tick, batteryW, st.pendingEVCutW()) diff --git a/go/internal/control/fuse_battery_phase_direction_test.go b/go/internal/control/fuse_battery_phase_direction_test.go new file mode 100644 index 000000000..111c03ee3 --- /dev/null +++ b/go/internal/control/fuse_battery_phase_direction_test.go @@ -0,0 +1,146 @@ +package control + +import ( + "encoding/json" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// The battery fuse paths read each phase with its direction and count the +// batteries' own move. 16 A fuse, 230 V, 3 phases; the working limit is +// 15 A. + +func phaseDirectionStore(t *testing.T, gridW, batW float64, data map[string]any) *telemetry.Store { + t.Helper() + s := telemetry.NewStore() + raw, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + s.Update("meter", telemetry.DerMeter, gridW, nil, raw) + s.DriverHealthMut("meter").RecordSuccess() + soc := 0.6 + s.Update("bat", telemetry.DerBattery, batW, &soc, nil) + s.DriverHealthMut("bat").RecordSuccess() + return s +} + +func phaseDirectionState(safetyA float64) *State { + st := NewState(0, 50, "meter") + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = 16, 230, 3, safetyA + st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: 10000, MaxDischargeW: 10000}} + return st +} + +// L1 exports 20 A, L2/L3 import 12 A, the total imports 920 W. No phase +// imports over the limit, and a discharge adds export on L1. +var exportingL1 = map[string]any{"l1_a": 20.0, "l2_a": 12.0, "l3_a": 12.0, + "l1_w": -4600.0, "l2_w": 2760.0, "l3_w": 2760.0} + +func TestFuseSaverDoesNotDischargeIntoExportingPhase(t *testing.T) { + store := phaseDirectionStore(t, 920, 0, exportingL1) + out := fuseSaverFromLivePower(store, phaseDirectionState(0.5), map[string]float64{"bat": 15200}, 16*230*3) + for _, o := range out { + if o.TargetW < 0 { + t.Fatalf("fuse saver ordered %.0f W discharge; L1 export 20.0 A -> %.1f A on a 16 A fuse", o.TargetW, 20+(-o.TargetW)/690) + } + } +} + +func TestIdleDispatchDoesNotDischargeIntoExportingPhase(t *testing.T) { + store := phaseDirectionStore(t, 920, 0, exportingL1) + st := phaseDirectionState(0.5) + st.Mode = ModeIdle + out := ComputeDispatch(store, st, map[string]float64{"bat": 15200}, 16*230*3) + for _, o := range out { + if o.TargetW < 0 { + t.Fatalf("Idle ComputeDispatch ordered %.0f W discharge; L1 export 20.0 A -> %.1f A", o.TargetW, 20+(-o.TargetW)/690) + } + } +} + +// L1 imports 22 A, L2/L3 export 15 A, the battery discharges 3 kW and the +// total exports. Cutting the discharge raises L1's import. +func TestFuseGuardKeepsDischargeThatRelievesImportingPhase(t *testing.T) { + store := phaseDirectionStore(t, (22-30)*230, -3000, map[string]any{ + "l1_a": 22.0, "l2_a": 15.0, "l3_a": 15.0, + "l1_w": 5060.0, "l2_w": -3450.0, "l3_w": -3450.0}) + st := phaseDirectionState(0.5) + out := applyFuseGuard([]DispatchTarget{{Driver: "bat", TargetW: -3000}}, store, st, 16*230*3) + if out[0].TargetW > -3000+1 { + t.Fatalf("fuse guard cut discharge -3000 -> %.0f W; L1 import 22.0 -> %.1f A; hold max discharge %.0f W until +%v", + out[0].TargetW, 22+(out[0].TargetW+3000)/690, st.FuseHoldMaxDischargeW, st.FuseHoldUntil.Sub(st.now())) + } +} + +// L1 exports 18 A (one-phase PV), L2/L3 import 14 A, the total imports +// 2300 W. Charging relieves L1 and loads L2/L3. +var exportingL1Charge = map[string]any{"l1_a": 18.0, "l2_a": 14.0, "l3_a": 14.0, + "l1_w": -4140.0, "l2_w": 3220.0, "l3_w": 3220.0} + +func TestFuseSaverIgnoresExportingPhaseUnderImportTotal(t *testing.T) { + store := phaseDirectionStore(t, 2300, 0, exportingL1Charge) + if out := fuseSaverFromLivePower(store, phaseDirectionState(0), map[string]float64{"bat": 15200}, 11040); out != nil { + t.Errorf("fuseSaverFromLivePower = %.0f W; discharging pushes exporting L1 further past the fuse", out[0].TargetW) + } +} + +// No charge fits both sides: 3000 W takes L2/L3 to 18.3 A, none leaves L1 +// at 18 A of export. The guard leaves the charge where the worst phase is +// least over: 1380 W, with every phase at 16 A. +func TestFuseGuardChargeBalancesExportingAndImportingPhases(t *testing.T) { + store := phaseDirectionStore(t, 2300, 0, exportingL1Charge) + got := applyFuseGuard([]DispatchTarget{{Driver: "bat", TargetW: 3000}}, store, phaseDirectionState(0), 11040) + l1, l2 := -18+got[0].TargetW/690, 14+got[0].TargetW/690 + if worst := math.Max(math.Abs(l1), math.Abs(l2)); worst > 16.01 { + t.Errorf("applyFuseGuard charge 3000 -> %.0f W leaves L1 %.2f A, L2/L3 %.2f A; want every phase at or below 16 A", got[0].TargetW, l1, l2) + } +} + +// The relief counts the battery's own share of the phase it reads. A +// battery that follows its order keeps L1 at the working limit on every +// tick instead of returning to 0 W when the overage stops showing. +func TestPerPhaseFuseReliefHoldsWhileOverloadPersists(t *testing.T) { + st := phaseDirectionState(0.5) + st.Mode = ModeIdle + now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + st.clock = func() time.Time { return now } + + store := telemetry.NewStore() + house := [3]float64{20, 5, 5} // A per phase, import + batW := 0.0 // battery follows its last order within a tick + caps := map[string]float64{"bat": 15200} + var targets, l1 []float64 + for tick := 0; tick < 10; tick++ { + a := house + for i := range a { + a[i] += batW / (230 * 3) // a three-phase battery spreads evenly + } + raw, _ := json.Marshal(map[string]any{"l1_a": a[0], "l2_a": a[1], "l3_a": a[2]}) + store.Update("meter", telemetry.DerMeter, (a[0]+a[1]+a[2])*230, nil, raw) + store.DriverHealthMut("meter").RecordSuccess() + soc := 0.6 + store.Update("bat", telemetry.DerBattery, batW, &soc, nil) + store.DriverHealthMut("bat").RecordSuccess() + + out := ComputeDispatch(store, st, caps, 16*230*3) + w := 0.0 + for _, o := range out { + if o.Driver == "bat" { + w = o.TargetW + } + } + targets = append(targets, math.Round(w)) + l1 = append(l1, math.Round(a[0]*10)/10) + batW = w + now = now.Add(5 * time.Second) + } + for i := 1; i < len(l1); i++ { + if l1[i] > 15 { + t.Fatalf("tick %d: L1 %.1f A over the 15 A working limit while the overload persists; targets %v, L1 %v", i, l1[i], targets, l1) + } + } +} diff --git a/go/internal/control/fuse_phase.go b/go/internal/control/fuse_phase.go index da041b83e..cf70ef9c3 100644 --- a/go/internal/control/fuse_phase.go +++ b/go/internal/control/fuse_phase.go @@ -17,7 +17,7 @@ import ( // Meters report phase magnitudes, so the mean uses |aggregate|. Phases // flowing in opposite directions make the gap look smaller than it is; the // per-phase fuse guards still act on a real overage. A single-phase battery -// or charger is outside this model, as it is for perPhaseReliefW. +// or charger is outside this model, as it is for phaseFuseReliefW. func phaseImbalanceA(r *telemetry.DerReading, state *State) (float64, bool) { if state == nil || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases <= 1 { return 0, false diff --git a/go/internal/control/fuse_saver_test.go b/go/internal/control/fuse_saver_test.go index 0e2f48476..205e25505 100644 --- a/go/internal/control/fuse_saver_test.go +++ b/go/internal/control/fuse_saver_test.go @@ -300,19 +300,19 @@ func setupPerPhaseSite(phases int, voltage float64, aggGridW float64, l1A, l2A, func TestPerPhaseClampScalesChargingOnImbalance(t *testing.T) { // Isolate the per-phase case: aggregate predicted (7000 + 4000 = // 11000 W) is under the 11040 W fuse, so the legacy aggregate - // branch wouldn't fire. But L1 alone is at 18 A × 230 V = 4140 W - // (over the 16 A fuse on that phase) — only the per-phase clamp - // can catch it. - store, state, _ := setupPerPhase(7000, 18, 12, 8, 0.6, 10000) + // branch wouldn't fire. But the 4000 W charge adds 4000 / 690 = + // 5.8 A to each phase, which takes L1 from 13 A to 18.8 A, over the + // 15 A working limit (16 A fuse less 1 A) — only the per-phase + // clamp can catch it. + store, state, _ := setupPerPhase(7000, 13, 12, 8, 0.6, 10000) targets := []DispatchTarget{{Driver: "bat", TargetW: 4000}} guarded := applyFuseGuard(targets, store, state, 11040) if !guarded[0].Clamped { t.Errorf("Clamped flag must mark per-phase response") } - // Per-phase overage = (18 − 16) × 230 = 460 W on the worst phase. - // Balanced-3Φ assumption ⇒ 460 × 3 = 1380 W of total battery - // reduction needed. Charge 4000 → 4000 − 1380 = 2620 W. - expected := 2620.0 + // L1 has (15 − 13) × 690 = 1380 W of room for a balanced-3Φ + // charge. Charge 4000 → 1380 W. + expected := 1380.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("charge after per-phase clamp = %.0f W, want %.0f W", guarded[0].TargetW, expected) @@ -333,9 +333,9 @@ func TestPerPhaseClampFiresFuseSaverFromIdle(t *testing.T) { if out[0].TargetW >= 0 { t.Errorf("expected discharge target, got %.0f W", out[0].TargetW) } - // Same math as the charging test: 460 W × 3 = 1380 W. From idle - // (target 0), full discharge of 1380 W. - expected := -1380.0 + // L1 is (18 − 15) A over the working limit: 3 × 690 = 2070 W of + // discharge from idle. + expected := -2070.0 if math.Abs(out[0].TargetW-expected) > 1 { t.Errorf("forced discharge = %.0f W, want %.0f W", out[0].TargetW, expected) } @@ -365,13 +365,14 @@ func TestPerPhaseClampDisabledWhenSiteFuseAmpsZero(t *testing.T) { // huge imbalance, per-phase × 3 dominates. func TestPerPhaseClampDominatesAggregateWhenLarger(t *testing.T) { // Aggregate over by 500 W (predicted = 11540 vs 11040). But L1 is - // at 22 A → over by 6 A × 230 = 1380 W per-phase × 3 = 4140 W. + // at 22 A → over the 15 A working limit by 7 A × 230 = 1610 W + // per-phase × 3 = 4830 W. store, state, caps := setupPerPhase(11540, 22, 14, 8, 0.6, 10000) out := fuseSaverFromLivePower(store, state, caps, 11040) if out == nil { t.Fatalf("expected discharge, got nil") } - expected := -4140.0 + expected := -4830.0 if math.Abs(out[0].TargetW-expected) > 1 { t.Errorf("forced discharge = %.0f W, want %.0f W (per-phase × 3)", out[0].TargetW, expected) @@ -384,11 +385,12 @@ func TestPerPhaseClampDominatesAggregateWhenLarger(t *testing.T) { // trigger forceFuseDischarge, which would push more power out and // worsen the over-current phase. Regression for PR #219 review S1. func TestPerPhaseClampFiresOnExportImbalance(t *testing.T) { - // Aggregate gridW = -7000 W (heavy export). Phases (magnitude): - // L2 = 18 A (over the 16 A fuse), L1 = 12, L3 = 8. Per-phase - // overage = (18 − 16) × 230 × 3 = 1380 W of discharge to shrink. - // Battery is currently discharging −5000 W; the target reduces - // magnitude to bring L2 back under fuse. + // Aggregate gridW = -7000 W (heavy export). Phases (magnitude, no + // phase power, so they take the total's direction): L2 = 18 A (over + // the 15 A working limit), L1 = 12, L3 = 8. Per-phase overage = + // (18 − 15) × 230 × 3 = 2070 W of discharge to shrink. Battery is + // currently discharging −5000 W; the target reduces magnitude to + // bring L2 back under the limit. store, state, _ := setupPerPhase(-7000, 12, 18, 8, 0.6, 10000) // Set the live battery reading so applyFuseGuard's "predicted" // reflects the current discharge state. @@ -401,8 +403,8 @@ func TestPerPhaseClampFiresOnExportImbalance(t *testing.T) { if !guarded[0].Clamped { t.Errorf("export-side per-phase overage must clamp the discharge target") } - // New discharge magnitude: 5000 − 1380 = 3620 W → target = −3620 W. - expected := -3620.0 + // New discharge magnitude: 5000 − 2070 = 2930 W → target = −2930 W. + expected := -2930.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("discharge after export-side per-phase clamp = %.0f W, want %.0f W", guarded[0].TargetW, expected) @@ -460,43 +462,42 @@ func TestForceFuseDischargeIgnoresExportSidePerPhase(t *testing.T) { // inverter's own per-phase limiter trips first and causes a flap). // Regression for PR #219 — the bug class motivating the safety margin. func TestPerPhaseClampHonorsSafetyMargin(t *testing.T) { - // Phases (15.5, 12, 8). MaxAmps = 16, SafetyA = 1.0 → effective - // threshold = 15 A. L1 at 15.5 A is OVER the effective threshold - // (under the raw 16 A). Per-phase overage = (15.5 − 15) × 230 × 3 - // = 345 W of charge to shrink. - store, state, _ := setupPerPhase(7000, 15.5, 12, 8, 0.6, 10000) - state.SiteFuseSafetyA = 1.0 + // Phases (10, 7, 5). MaxAmps = 16, SafetyA = 2.0 → effective + // threshold = 14 A, below the 1 A working margin's 15 A. The + // 4000 W charge adds 5.8 A per phase, so L1 reaches 15.8 A: over + // 14 A by 1.8 A, 1240 W of charge to shrink. Without the margin it + // would be 550 W. + store, state, _ := setupPerPhase(5000, 10, 7, 5, 0.6, 10000) + state.SiteFuseSafetyA = 2.0 state.SiteFusePhases = 3 targets := []DispatchTarget{{Driver: "bat", TargetW: 4000}} guarded := applyFuseGuard(targets, store, state, 11040) if !guarded[0].Clamped { - t.Errorf("safety margin should pull the threshold under L1 = 15.5 A") - } - // Aggregate effFuseW = 11040 − 1.0×230×3 = 10350. Predicted = - // 7000 + 4000 − 0 = 11000 → aggregate overage = 11000 − 10350 = 650. - // Per-phase overage × 3 = 345. Aggregate wins (650 > 345). - // Buffer = half-margin = 0.5×1×230×3 = 345 W (post-fix headroom). - // Charge 4000 − 650 − 345 = 3005. - expected := 3005.0 + t.Errorf("safety margin should pull the threshold under L1") + } + // Aggregate effFuseW = 11040 − 2×230×3 = 9660. Predicted = + // 5000 + 4000 − 0 = 9000 → no aggregate overage. Per-phase wins. + // Buffer = half-margin = 0.5×2×230×3 = 690 W (post-fix headroom). + // Charge 4000 − 1240 − 690 = 2070. + expected := 2070.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("charge after safety-margin clamp = %.0f W, want %.0f W", guarded[0].TargetW, expected) } } -// SafetyMarginA = 0 must produce identical output to the pre-PR -// behaviour (locks the back-compat contract every existing per-phase -// test relies on by leaving the field unset). +// SafetyMarginA = 0 keeps the 1 A working margin the car uses (every +// existing per-phase test relies on it by leaving the field unset). func TestPerPhaseClampSafetyMarginZeroIsBackCompat(t *testing.T) { - store, state, _ := setupPerPhase(7000, 18, 12, 8, 0.6, 10000) + store, state, _ := setupPerPhase(7000, 13, 12, 8, 0.6, 10000) state.SiteFuseSafetyA = 0 state.SiteFusePhases = 3 targets := []DispatchTarget{{Driver: "bat", TargetW: 4000}} guarded := applyFuseGuard(targets, store, state, 11040) // Same math as TestPerPhaseClampScalesChargingOnImbalance. - expected := 2620.0 + expected := 1380.0 if math.Abs(guarded[0].TargetW-expected) > 1 { - t.Errorf("safety_margin_a=0 must match pre-PR behaviour: got %.0f W, want %.0f W", + t.Errorf("safety_margin_a=0 must keep the 1 A working margin: got %.0f W, want %.0f W", guarded[0].TargetW, expected) } } @@ -508,9 +509,9 @@ func TestPerPhaseClampSafetyMarginZeroIsBackCompat(t *testing.T) { // phase count, and on a 1Φ site the aggregate meter and the single // phase are the same wire — relief there is 1 × the overage, not 3 ×. // -// A 1Φ site at 24 A on a 16 A fuse (0.5 A margin ⇒ 15.5 A threshold, -// 230 V): fuse budget 3680 W, aggregate ceiling 3565 W, meter at -// 5520 W. Worst-phase overage = (24 − 15.5) × 230 = 1955 W, which on +// A 1Φ site at 24 A on a 16 A fuse (1 A margin ⇒ 15 A threshold, +// 230 V): fuse budget 3680 W, aggregate ceiling 3450 W, meter at +// 5520 W. Worst-phase overage = (24 − 15) × 230 = 2070 W, which on // this site is also exactly the aggregate overage — the two numbers // must agree when there is only one phase. @@ -518,21 +519,21 @@ func TestPerPhaseClampSafetyMarginZeroIsBackCompat(t *testing.T) { // once, and leaves the meter on the import side of zero. func TestPerPhaseReliefMatchesSinglePhaseSiteOnImport(t *testing.T) { store, state, caps := setupPerPhaseSite(1, 230, 5520, 24, 0, 0, 0.6, 10000) - state.SiteFuseSafetyA = 0.5 + state.SiteFuseSafetyA = 1 fuseMaxW := 16 * 230.0 * 1 out := fuseSaverFromLivePower(store, state, caps, fuseMaxW) if out == nil { t.Fatalf("1Φ site at 24 A on a 16 A fuse: expected a forced discharge") } - expected := -1955.0 + expected := -2070.0 if math.Abs(out[0].TargetW-expected) > 1 { t.Fatalf("forced discharge = %.0f W, want %.0f W (overage × 1 phase, "+ "not × 3)", out[0].TargetW, expected) } // The site has one phase, so the post-dispatch meter is that - // phase. Demanding 3 × 1955 W would have driven 5520 W of import - // to 345 W of export: relief the site never needed, pushing the + // phase. Demanding 3 × 2070 W would have driven 5520 W of import + // to 690 W of export: relief the site never needed, pushing the // same breaker up the other way. if postGridW := 5520 + out[0].TargetW; postGridW < 0 { t.Errorf("post-dispatch grid = %.0f W: relief overshot through zero "+ @@ -547,7 +548,7 @@ func TestPerPhaseReliefMatchesSinglePhaseSiteOnExport(t *testing.T) { // Meter magnitudes mirror the import case: 5520 W out, 24 A on the // one phase. Pixii-style signed per-phase amps, so L1 reads −24. store, state, _ := setupPerPhaseSite(1, 230, -5520, -24, 0, 0, 0.6, 10000) - state.SiteFuseSafetyA = 0.5 + state.SiteFuseSafetyA = 1 fuseMaxW := 16 * 230.0 * 1 // The battery is already discharging what the meter shows, and the @@ -560,10 +561,10 @@ func TestPerPhaseReliefMatchesSinglePhaseSiteOnExport(t *testing.T) { if !guarded[0].Clamped { t.Fatalf("export-side per-phase overage must clamp the discharge target") } - // Discharge 5000 − overage 1955 − buffer (half the 115 W margin) - // = 2987.5 W. Demanding 1955 × 3 leaves nothing: the discharge is + // Discharge 5000 − overage 2070 − buffer (half the 230 W margin) + // = 2815 W. Demanding 2070 × 3 leaves nothing: the discharge is // zeroed and the site swings to 520 W of import. - expected := -2987.5 + expected := -2815.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("discharge after 1Φ export clamp = %.1f W, want %.1f W", guarded[0].TargetW, expected) @@ -599,15 +600,15 @@ func TestPerPhaseClampReadsConfiguredPhasesOnly(t *testing.T) { // Per-phase watts come from the configured voltage, the same source // fuseSafetyMarginW uses. A 240 V site converts its amps at 240 V. func TestPerPhaseClampUsesConfiguredVoltage(t *testing.T) { - store, state, _ := setupPerPhaseSite(3, 240, 7000, 18, 12, 8, 0.6, 10000) + store, state, _ := setupPerPhaseSite(3, 240, 7000, 13, 12, 8, 0.6, 10000) targets := []DispatchTarget{{Driver: "bat", TargetW: 4000}} guarded := applyFuseGuard(targets, store, state, 16*240.0*3) if !guarded[0].Clamped { - t.Fatalf("L1 at 18 A on a 16 A fuse must clamp") + t.Fatalf("a 4000 W charge on L1 at 13 A on a 16 A fuse must clamp") } - // (18 − 16) × 240 = 480 W per phase × 3 = 1440 W of relief. - // Charge 4000 → 2560. At the old hardcoded 230 V it would be 2620. - expected := 2560.0 + // L1 has (15 − 13) A of room below the working limit: 2 × 240 × 3 + // = 1440 W of charge. At the old hardcoded 230 V it would be 1380. + expected := 1440.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("charge after per-phase clamp on a 240 V site = %.0f W, "+ "want %.0f W", guarded[0].TargetW, expected) @@ -914,9 +915,10 @@ func TestDeadbandExitDefendsPerPhaseBreaker(t *testing.T) { t.Fatalf("L1 at 18 A on a 16 A fuse inside the aggregate deadband: " + "expected a forced discharge, got no targets") } - // Per-phase overage = (18 − 16) × 230 = 460 W on the worst phase, - // × 3 under the balanced-3Φ assumption = 1380 W from idle. - expected := -1380.0 + // Per-phase overage = (18 − 15) × 230 = 690 W over the working + // limit on the worst phase, × 3 under the balanced-3Φ assumption + // = 2070 W from idle. + expected := -2070.0 if math.Abs(out[0].TargetW-expected) > 1 { t.Errorf("forced discharge = %.0f W, want %.0f W", out[0].TargetW, expected) } @@ -932,7 +934,7 @@ func TestDeadbandExitPerPhaseReliefPreservesLiveDischarge(t *testing.T) { const ( liveBatteryW = -5000.0 liveWorstA = 18.0 - fuseA = 16.0 + fuseA = 15.0 // 16 A fuse less the 1 A working margin phases = 3.0 voltage = 230.0 ) @@ -969,7 +971,7 @@ func TestDeadbandExitPerPhaseReliefStartsFromLiveCharge(t *testing.T) { const ( liveBatteryW = 5000.0 liveWorstA = 18.0 - fuseA = 16.0 + fuseA = 15.0 // 16 A fuse less the 1 A working margin phases = 3.0 voltage = 230.0 ) @@ -1380,7 +1382,7 @@ func TestDeadbandExitUsesOnlyRemainingPerPhaseReliefAfterChargeCap(t *testing.T) liveBatteryW = 5000.0 maxChargeW = 4500.0 liveWorstA = 18.0 - fuseA = 16.0 + fuseA = 15.0 // 16 A fuse less the 1 A working margin phaseReliefW = (liveWorstA - fuseA) * 230 * 3 ) store, state, caps := setupPerPhase(0, liveWorstA, 4, 4, 0.6, 10000) diff --git a/go/internal/control/golden_dump_test.go b/go/internal/control/golden_dump_test.go index ae96c9960..32636555b 100644 --- a/go/internal/control/golden_dump_test.go +++ b/go/internal/control/golden_dump_test.go @@ -331,7 +331,12 @@ func runGoldenScenarioAt(sc goldenScenario, scenarioNow time.Time, slotDirective // Pre-dispatch attribution reads (pure). intent := planSignIntent(st) - perPhase := perPhaseOverageW(store, st) + // The worst phase's overage either way, in watts on that phase. + var perPhase float64 + importW, exportW := phaseFuseReliefW(store.Get(st.SiteMeterDriver, telemetry.DerMeter), st, 0) + if st.SiteFusePhases > 0 { + perPhase = math.Max(importW, exportW) / float64(st.SiteFusePhases) + } effImp := st.effectiveImportCeilingW(sc.Inputs.FuseMaxW) effExp := st.effectiveExportCeilingW(sc.Inputs.FuseMaxW) diff --git a/go/internal/control/peak_limit_test.go b/go/internal/control/peak_limit_test.go index 596212a4e..d8a7be220 100644 --- a/go/internal/control/peak_limit_test.go +++ b/go/internal/control/peak_limit_test.go @@ -122,7 +122,7 @@ func TestSetPeakLimitRejectsNonFinite(t *testing.T) { } // An undescribed fuse yields no ceiling rather than an invented one — -// the same back-compat rule fuseSafetyMarginW and perPhaseOverageW keep +// the same back-compat rule fuseSafetyMarginW and phaseFuseReliefW keep // for harnesses that wire only some of the fuse fields. func TestSetPeakLimitWithoutFuseChecksOnlyTheSign(t *testing.T) { st := NewState(0, 50, "ferroamp") // no SiteFuse* wired diff --git a/go/internal/control/testdata/golden/early_exit_protections.json b/go/internal/control/testdata/golden/early_exit_protections.json index e1147757e..6344bac47 100644 --- a/go/internal/control/testdata/golden/early_exit_protections.json +++ b/go/internal/control/testdata/golden/early_exit_protections.json @@ -1,7 +1,7 @@ { "family": "early_exit_protections", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "68c5b6125898bb9da70728b8921259c87e96171d", + "ftw_commit": "83c2b24ccdb730278a9c4e6ef778bab5f9499167", "record_count": 21, "records": [ { @@ -519,7 +519,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1380, + "target_w": -2070, "clamped": true } ], @@ -536,7 +536,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 460, + "per_phase_overage_w": 690, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -544,9 +544,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1380, + "battery_target_sum_w": -2070, "raw_grid_w": 0, - "projected_grid_w": -1380 + "projected_grid_w": -2070 } }, { @@ -775,7 +775,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 2300, + "per_phase_overage_w": 2530, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -841,7 +841,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1610, + "target_w": -1840, "clamped": true } ], @@ -858,17 +858,17 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1610, + "per_phase_overage_w": 1840, "effective_import_ceiling_w": 5750, "effective_export_ceiling_w": 5750, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1610, + "battery_target_sum_w": -1840, "raw_grid_w": 7360, - "projected_grid_w": 5750 + "projected_grid_w": 5520 } }, { @@ -1009,7 +1009,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2070, + "target_w": -2760, "clamped": true } ], @@ -1026,7 +1026,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 690, + "per_phase_overage_w": 920, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1034,9 +1034,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -2070, + "battery_target_sum_w": -2760, "raw_grid_w": 20, - "projected_grid_w": -2050 + "projected_grid_w": -2740 } }, { @@ -1170,7 +1170,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2760, + "target_w": -3450, "clamped": true } ], @@ -1187,7 +1187,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 920, + "per_phase_overage_w": 1150, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1195,9 +1195,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -2760, + "battery_target_sum_w": -3450, "raw_grid_w": 1000, - "projected_grid_w": -1760 + "projected_grid_w": -2450 } }, { @@ -1253,7 +1253,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1150, + "target_w": -1380, "clamped": true } ], @@ -1270,17 +1270,17 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1150, + "per_phase_overage_w": 1380, "effective_import_ceiling_w": 5750, "effective_export_ceiling_w": 5750, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1150, + "battery_target_sum_w": -1380, "raw_grid_w": 6900, - "projected_grid_w": 5750 + "projected_grid_w": 5520 } }, { @@ -1495,7 +1495,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3450, + "target_w": -4140, "clamped": true } ], @@ -1512,7 +1512,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1150, + "per_phase_overage_w": 1380, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1520,9 +1520,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -3450, + "battery_target_sum_w": -4140, "raw_grid_w": 2000, - "projected_grid_w": -1450 + "projected_grid_w": -2140 } }, { @@ -1578,7 +1578,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1610, + "target_w": -1840, "clamped": true } ], @@ -1595,17 +1595,17 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1610, + "per_phase_overage_w": 1840, "effective_import_ceiling_w": 5750, "effective_export_ceiling_w": 5750, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1610, + "battery_target_sum_w": -1840, "raw_grid_w": 7360, - "projected_grid_w": 5750 + "projected_grid_w": 5520 } }, { diff --git a/go/internal/control/testdata/golden/seeded_fuse.json b/go/internal/control/testdata/golden/seeded_fuse.json index 0100f4d67..9862640e4 100644 --- a/go/internal/control/testdata/golden/seeded_fuse.json +++ b/go/internal/control/testdata/golden/seeded_fuse.json @@ -1,7 +1,7 @@ { "family": "seeded_fuse", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "303f86818a95f056f68e0b652db131a33074a421", + "ftw_commit": "83c2b24ccdb730278a9c4e6ef778bab5f9499167", "record_count": 80, "records": [ { @@ -69,20 +69,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3245.228082551051, - "clamped": true + "target_w": -1530.5555555555554, + "clamped": false }, { "driver": "sungrow", - "target_w": -3647.771917448948, - "clamped": true + "target_w": -2326.4444444444443, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp", - "sungrow" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, @@ -100,9 +97,9 @@ }, "site_totals": { "battery_current_w": 1432, - "battery_target_sum_w": -6892.999999999999, + "battery_target_sum_w": -3857, "raw_grid_w": 8815, - "projected_grid_w": 490.0000000000009 + "projected_grid_w": 3526 } }, { @@ -213,7 +210,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1195.9999999999998, + "per_phase_overage_w": 1425.9999999999998, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -580,7 +577,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 253.00000000000034, + "per_phase_overage_w": 483.0000000000003, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -663,7 +660,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1035, + "per_phase_overage_w": 1265, "effective_import_ceiling_w": 4300, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -757,12 +754,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": -0, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": -0, "clamped": true } ], @@ -780,7 +777,7 @@ "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 184.00000000000017, + "per_phase_overage_w": 414.00000000000017, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -870,13 +867,13 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2534.523809523809, - "clamped": true + "target_w": -2017.0238095238092, + "clamped": false }, { "driver": "pixii", - "target_w": -2534.523809523809, - "clamped": true + "target_w": -2017.0238095238092, + "clamped": false }, { "driver": "sungrow", @@ -886,8 +883,6 @@ ], "clamp_attributions": { "clamped_drivers": [ - "ferroamp", - "pixii", "sungrow" ], "all_targets_zero_clamped": false, @@ -899,7 +894,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 345, + "per_phase_overage_w": 460, "effective_import_ceiling_w": 10695, "effective_export_ceiling_w": 10695, "import_ceiling_binding": false, @@ -907,9 +902,9 @@ }, "site_totals": { "battery_current_w": -3018, - "battery_target_sum_w": -10069.047619047618, + "battery_target_sum_w": -9034.047619047618, "raw_grid_w": 11913, - "projected_grid_w": 4861.952380952382 + "projected_grid_w": 5896.952380952382 } }, { @@ -1081,7 +1076,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 598.0000000000003, + "per_phase_overage_w": 713.0000000000003, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, "import_ceiling_binding": true, @@ -1688,20 +1683,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3402.6442494330904, - "clamped": true + "target_w": -1518.5290322580647, + "clamped": false }, { "driver": "sungrow", - "target_w": -3145.9557505669086, - "clamped": true + "target_w": -959.0709677419356, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp", - "sungrow" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, @@ -1711,7 +1703,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1356.9999999999998, + "per_phase_overage_w": 1586.9999999999998, "effective_import_ceiling_w": 5311, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1719,9 +1711,9 @@ }, "site_totals": { "battery_current_w": 2317, - "battery_target_sum_w": -6548.5999999999985, + "battery_target_sum_w": -2477.6000000000004, "raw_grid_w": 7991, - "projected_grid_w": -874.5999999999985 + "projected_grid_w": 3196.3999999999996 } }, { @@ -1883,17 +1875,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -644.0000000000001, + "target_w": -323.0000000000004, "clamped": true }, { "driver": "pixii", - "target_w": -644.0000000000001, + "target_w": -323.0000000000004, "clamped": true }, { "driver": "sungrow", - "target_w": -644.0000000000001, + "target_w": -323.0000000000004, "clamped": true } ], @@ -1912,7 +1904,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 644.0000000000001, + "per_phase_overage_w": 759.0000000000001, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, "import_ceiling_binding": false, @@ -1920,9 +1912,9 @@ }, "site_totals": { "battery_current_w": 1308, - "battery_target_sum_w": -1932.0000000000005, + "battery_target_sum_w": -969.0000000000011, "raw_grid_w": 13971, - "projected_grid_w": 10731 + "projected_grid_w": 11693.999999999998 } }, { @@ -2290,7 +2282,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 505.99999999999983, + "per_phase_overage_w": 735.9999999999999, "effective_import_ceiling_w": 6067, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -2480,20 +2472,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2820.0999999999995, - "clamped": true + "target_w": -1026.1, + "clamped": false }, { "driver": "sungrow", - "target_w": -2820.0999999999995, - "clamped": true + "target_w": -1026.1, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp", - "sungrow" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, @@ -2503,7 +2492,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1195.9999999999998, + "per_phase_overage_w": 1310.9999999999998, "effective_import_ceiling_w": 10695, "effective_export_ceiling_w": 10695, "import_ceiling_binding": false, @@ -2511,9 +2500,9 @@ }, "site_totals": { "battery_current_w": 4591, - "battery_target_sum_w": -5640.199999999999, + "battery_target_sum_w": -2052.2, "raw_grid_w": 11072, - "projected_grid_w": 840.8000000000011 + "projected_grid_w": 4428.8 } }, { @@ -3365,7 +3354,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 206.99999999999966, + "per_phase_overage_w": 436.9999999999997, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3564,17 +3553,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": -0, "clamped": true }, { "driver": "pixii", - "target_w": 0, + "target_w": -0, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": -0, "clamped": true } ], @@ -3593,7 +3582,7 @@ "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 275.99999999999983, + "per_phase_overage_w": 505.99999999999983, "effective_import_ceiling_w": 5718, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -3812,7 +3801,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1334.0000000000002, + "per_phase_overage_w": 1449.0000000000002, "effective_import_ceiling_w": 10695, "effective_export_ceiling_w": 10695, "import_ceiling_binding": false, @@ -4800,7 +4789,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1550, + "target_w": -0, "clamped": true } ], @@ -4808,16 +4797,16 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": false, + "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, "fuse_ev_max_w": 0, "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 1550, - "per_phase_overage_w": 1150, + "fuse_hold_max_discharge_w": 0, + "per_phase_overage_w": 1380, "effective_import_ceiling_w": 4886, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -4825,9 +4814,9 @@ }, "site_totals": { "battery_current_w": -3716, - "battery_target_sum_w": -1550, + "battery_target_sum_w": 0, "raw_grid_w": -15970, - "projected_grid_w": -13804 + "projected_grid_w": -12254 } }, { @@ -4895,20 +4884,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -5000, - "clamped": true + "target_w": -3102.7, + "clamped": false }, { "driver": "sungrow", - "target_w": -5000, - "clamped": true + "target_w": -3102.7, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp", - "sungrow" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, @@ -4918,7 +4904,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1288.0000000000002, + "per_phase_overage_w": 1518.0000000000002, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4926,9 +4912,9 @@ }, "site_totals": { "battery_current_w": 2407, - "battery_target_sum_w": -10000, + "battery_target_sum_w": -6205.4, "raw_grid_w": 14354, - "projected_grid_w": 1947 + "projected_grid_w": 5741.6 } }, { @@ -5313,20 +5299,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1645.1999999999998, - "clamped": true + "target_w": -782.6999999999998, + "clamped": false }, { "driver": "sungrow", - "target_w": -1645.1999999999998, - "clamped": true + "target_w": -782.6999999999998, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp", - "sungrow" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, @@ -5344,9 +5327,9 @@ }, "site_totals": { "battery_current_w": 3297, - "battery_target_sum_w": -3290.3999999999996, + "battery_target_sum_w": -1565.3999999999996, "raw_grid_w": 8104, - "projected_grid_w": 1516.6000000000004 + "projected_grid_w": 3241.6000000000004 } }, { @@ -6292,7 +6275,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 184.00000000000017, + "per_phase_overage_w": 414.00000000000017, "effective_import_ceiling_w": 7497, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6513,17 +6496,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2088.5, + "target_w": -1335.8333333333328, "clamped": true }, { "driver": "pixii", - "target_w": -2088.5, + "target_w": -1335.8333333333328, "clamped": true }, { "driver": "sungrow", - "target_w": -2088.5, + "target_w": -1335.8333333333328, "clamped": true } ], @@ -6541,8 +6524,8 @@ "fuse_ev_max_w": 0, "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 6265.5, - "per_phase_overage_w": 0, + "fuse_hold_max_discharge_w": 4007.499999999998, + "per_phase_overage_w": 23.000000000000327, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, "import_ceiling_binding": false, @@ -6550,9 +6533,9 @@ }, "site_totals": { "battery_current_w": -4249, - "battery_target_sum_w": -6265.5, + "battery_target_sum_w": -4007.499999999998, "raw_grid_w": -14716, - "projected_grid_w": -16732.5 + "projected_grid_w": -14474.499999999998 } }, { @@ -6625,7 +6608,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1150, + "per_phase_overage_w": 1380, "effective_import_ceiling_w": 6574, "effective_export_ceiling_w": 7458, "import_ceiling_binding": false, @@ -6896,12 +6879,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1720.5, + "target_w": -1592, "clamped": true }, { "driver": "sungrow", - "target_w": -1720.5, + "target_w": -1592, "clamped": true } ], @@ -6918,18 +6901,18 @@ "fuse_ev_max_w": 0, "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 3441, - "per_phase_overage_w": 0, + "fuse_hold_max_discharge_w": 3184, + "per_phase_overage_w": 45.99999999999984, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -3322, - "battery_target_sum_w": -3441, + "battery_target_sum_w": -3184, "raw_grid_w": -17131, - "projected_grid_w": -17250 + "projected_grid_w": -16993 } }, { @@ -7099,7 +7082,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1080.9999999999998, + "per_phase_overage_w": 1195.9999999999998, "effective_import_ceiling_w": 10695, "effective_export_ceiling_w": 10695, "import_ceiling_binding": false, @@ -7165,7 +7148,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3174.000000000001, + "target_w": -1139.0000000000014, "clamped": true } ], @@ -7182,7 +7165,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1058.0000000000002, + "per_phase_overage_w": 1288.0000000000002, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -7190,9 +7173,9 @@ }, "site_totals": { "battery_current_w": 2725, - "battery_target_sum_w": -3174.000000000001, + "battery_target_sum_w": -1139.0000000000014, "raw_grid_w": 14295, - "projected_grid_w": 8396 + "projected_grid_w": 10430.999999999998 } }, { @@ -7475,7 +7458,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 184.00000000000017, + "per_phase_overage_w": 414.00000000000017, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7683,12 +7666,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": -0, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": -0, "clamped": true } ], @@ -7706,7 +7689,7 @@ "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 345, + "per_phase_overage_w": 460, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, "import_ceiling_binding": false, @@ -7917,30 +7900,34 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 5000, - "clamped": false + "target_w": 4493.333333333333, + "clamped": true }, { "driver": "pixii", - "target_w": 5000, - "clamped": false + "target_w": 4493.333333333333, + "clamped": true }, { "driver": "sungrow", - "target_w": 5000, - "clamped": false + "target_w": 4493.333333333333, + "clamped": true } ], "clamp_attributions": { - "clamped_drivers": [], + "clamped_drivers": [ + "ferroamp", + "pixii", + "sungrow" + ], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, + "fuse_hold_latched": true, + "fuse_hold_max_charge_w": 13480, "fuse_hold_max_discharge_w": 0, "per_phase_overage_w": 0, "effective_import_ceiling_w": 10350, @@ -7950,9 +7937,9 @@ }, "site_totals": { "battery_current_w": 853, - "battery_target_sum_w": 15000, + "battery_target_sum_w": 13480, "raw_grid_w": -7899, - "projected_grid_w": 6248 + "projected_grid_w": 4728 } }, { @@ -8025,7 +8012,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "per_phase_overage_w": 206.99999999999966, "effective_import_ceiling_w": 4624, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -8131,17 +8118,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -716.6666666666667, + "target_w": -0, "clamped": true }, { "driver": "pixii", - "target_w": -716.6666666666667, + "target_w": -0, "clamped": true }, { "driver": "sungrow", - "target_w": -716.6666666666667, + "target_w": -0, "clamped": true } ], @@ -8151,26 +8138,26 @@ "pixii", "sungrow" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": false, + "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, "fuse_ev_max_w": 0, "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 2150, - "per_phase_overage_w": 1173.0000000000002, + "fuse_hold_max_discharge_w": 0, + "per_phase_overage_w": 1403.0000000000002, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 1653, - "battery_target_sum_w": -2150, + "battery_target_sum_w": 0, "raw_grid_w": -13447, - "projected_grid_w": -17250 + "projected_grid_w": -15100 } } ] diff --git a/go/internal/control/testdata/golden/slew_limiter.json b/go/internal/control/testdata/golden/slew_limiter.json index 896d7f298..0c5efa302 100644 --- a/go/internal/control/testdata/golden/slew_limiter.json +++ b/go/internal/control/testdata/golden/slew_limiter.json @@ -1,7 +1,7 @@ { "family": "slew_limiter", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "15f9edc3b973d7b9151e88d63575a9b7d615a0b3", + "ftw_commit": "83c2b24ccdb730278a9c4e6ef778bab5f9499167", "record_count": 127, "records": [ { @@ -3225,7 +3225,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2760, + "target_w": -1949.9999999999995, "clamped": true } ], @@ -3242,7 +3242,7 @@ "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 920, + "per_phase_overage_w": 1150, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3250,9 +3250,9 @@ }, "site_totals": { "battery_current_w": 1500, - "battery_target_sum_w": -2760, + "battery_target_sum_w": -1949.9999999999995, "raw_grid_w": 1000, - "projected_grid_w": -3260 + "projected_grid_w": -2449.9999999999995 } }, { diff --git a/go/internal/control/testdata/golden/targeted_invariants.json b/go/internal/control/testdata/golden/targeted_invariants.json index 467ca3418..b44f92069 100644 --- a/go/internal/control/testdata/golden/targeted_invariants.json +++ b/go/internal/control/testdata/golden/targeted_invariants.json @@ -1,7 +1,7 @@ { "family": "targeted_invariants", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "68c5b6125898bb9da70728b8921259c87e96171d", + "ftw_commit": "83c2b24ccdb730278a9c4e6ef778bab5f9499167", "record_count": 18, "records": [ { @@ -683,7 +683,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3360, + "target_w": -3450, "clamped": true } ], @@ -700,7 +700,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 920, + "per_phase_overage_w": 1150, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -708,9 +708,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -3360, + "battery_target_sum_w": -3450, "raw_grid_w": 1000, - "projected_grid_w": -2360 + "projected_grid_w": -2450 } }, { @@ -782,7 +782,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2240, + "target_w": -0, "clamped": true } ], @@ -790,16 +790,16 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": false, + "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, "fuse_ev_max_w": 0, "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 2240, - "per_phase_overage_w": 920, + "fuse_hold_max_discharge_w": 0, + "per_phase_overage_w": 1150, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -807,9 +807,9 @@ }, "site_totals": { "battery_current_w": -2000, - "battery_target_sum_w": -2240, + "battery_target_sum_w": 0, "raw_grid_w": -6000, - "projected_grid_w": -6240 + "projected_grid_w": -4000 } }, { From 2f793d8fe87535cb8d0cc0a27107c862da360571 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 15:57:50 +0100 Subject: [PATCH 02/34] fix(control): fuse guard reads phases after the hold caps the targets The guard took the phase relief from the original request, then the 30 s fuse hold capped that request and the relief came off again. A charge limited by one phase switched between 1380 W and 0 W on every dispatch. Predict the aggregate and the phases from the held targets instead. The phase reader now takes the meter's raw total from OnlineMeterW, the same reading as its currents, so an offline meter's cached phases drive no relief. The batteries' raw power is read in one helper. dispatch.go keeps its 41 cached telemetry reads. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/dispatch.go | 148 +++++++++++------- .../control/ev_battery_sequence_test.go | 6 +- .../fuse_battery_phase_direction_test.go | 75 +++++++++ go/internal/control/golden_dump_test.go | 2 +- 4 files changed, 172 insertions(+), 59 deletions(-) diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index ed19b5165..9089ffe5f 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -3591,48 +3591,26 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta // reflected in currentGrid; subtracting them here without adding a // replacement target would double-remove their contribution and miss // fuse overages. - seenBat := make(map[string]struct{}, len(targets)) - var currentBat, rawBat float64 + batteries := make(map[string]*telemetry.DerReading, len(targets)) + var currentBat float64 for _, t := range targets { - if _, seen := seenBat[t.Driver]; seen { + if _, seen := batteries[t.Driver]; seen { continue } - seenBat[t.Driver] = struct{}{} - if r := store.Get(t.Driver, telemetry.DerBattery); r != nil { + r := store.Get(t.Driver, telemetry.DerBattery) + batteries[t.Driver] = r + if r != nil { currentBat += r.SmoothedW - rawBat += r.RawW } } var currentGrid float64 - var meter *telemetry.DerReading + var phases phaseSample if siteMeter != "" { - if meter = store.Get(siteMeter, telemetry.DerMeter); meter != nil { + if meter := store.Get(siteMeter, telemetry.DerMeter); meter != nil { currentGrid = meter.SmoothedW + phases = livePhaseSample(store, siteMeter, meter) } } - var sumTarget float64 - for _, t := range targets { - sumTarget += t.TargetW - } - predicted := currentGrid - currentBat + sumTarget - - // Per phase, each way, once the targets replace the batteries' power - // in the same raw reading. - phaseImportW, phaseExportW := phaseFuseReliefW(meter, state, sumTarget-rawBat) - // Aggregate budget honours the safety margin too — keep dispatch - // commands strictly inside the breaker envelope so the inverter's - // own per-phase limiter doesn't fire first and cause a flap. - // - // Import ceiling is the tighter of (fuse − safety margin) and the - // clock hour's import limit; export ceiling is the tighter of (fuse − - // safety margin) and the operator's max_export_w protection limit — - // set when an inverter trips on sustained export below the breaker - // (recurring Ferroamp 0x8030 fault). Both share one enforcement surface. - effImportW := state.effectiveImportCeilingW(fuseMaxW) - effExportW := state.effectiveExportCeilingW(fuseMaxW) - importOverage := math.Max(predicted-effImportW, phaseImportW) - exportOverage := math.Max(-effExportW-predicted, phaseExportW) - out := make([]DispatchTarget, len(targets)) copy(out, targets) @@ -3685,6 +3663,32 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta state.FuseHoldUntil = time.Time{} } + // Predict from the held targets. Relief taken from the request would + // count the hold's cut a second time, and a charge limited by one phase + // would switch between the cap and 0 on every dispatch. + var sumTarget float64 + for _, t := range out { + sumTarget += t.TargetW + } + predicted := currentGrid - currentBat + sumTarget + + // Per phase, each way, once the targets replace the batteries' power + // in the same raw reading. + phaseImportW, phaseExportW := phaseFuseReliefW(phases, state, phaseMoveW(out, batteries)) + // Aggregate budget honours the safety margin too — keep dispatch + // commands strictly inside the breaker envelope so the inverter's + // own per-phase limiter doesn't fire first and cause a flap. + // + // Import ceiling is the tighter of (fuse − safety margin) and the + // clock hour's import limit; export ceiling is the tighter of (fuse − + // safety margin) and the operator's max_export_w protection limit — + // set when an inverter trips on sustained export below the breaker + // (recurring Ferroamp 0x8030 fault). Both share one enforcement surface. + effImportW := state.effectiveImportCeilingW(fuseMaxW) + effExportW := state.effectiveExportCeilingW(fuseMaxW) + importOverage := math.Max(predicted-effImportW, phaseImportW) + exportOverage := math.Max(-effExportW-predicted, phaseExportW) + if importOverage <= 0 && exportOverage <= 0 { return out } @@ -3822,30 +3826,70 @@ func (s *State) effectiveExportCeilingW(fuseMaxW float64) float64 { return eff } +// phaseSample is one meter reading's phase currents with the raw total of +// the same reading. The zero value has no phases. +type phaseSample struct { + meter *telemetry.DerReading + totalW float64 +} + +// livePhaseSample pairs meter with its raw total while the meter still +// reports. A watchdog-offline meter gives no phases, since its cached +// currents are no longer the site. So does a store that has moved on to a +// newer reading, since its total no longer matches meter's currents. +func livePhaseSample(store *telemetry.Store, driver string, meter *telemetry.DerReading) phaseSample { + if store == nil || meter == nil { + return phaseSample{} + } + totalW, at, ok := store.OnlineMeterW(driver) + if !ok || !at.Equal(meter.UpdatedAt) { + return phaseSample{} + } + return phaseSample{meter: meter, totalW: totalW} +} + +// phaseMoveW is the batteries' change from the raw power their readings hold +// to the targets. The phase currents are raw, so the move is too. +func phaseMoveW(targets []DispatchTarget, batteries map[string]*telemetry.DerReading) float64 { + var moveW float64 + seen := make(map[string]bool, len(targets)) + for _, t := range targets { + moveW += t.TargetW + if r := batteries[t.Driver]; r != nil && !seen[t.Driver] { + moveW -= r.RawW + } + seen[t.Driver] = true + } + return moveW +} + // phaseFuseReliefW is the aggregate battery move that brings every phase // inside the working limit: the fuse less the car's 1 A margin, or less // safety_margin_a when that is larger. importW asks for less charge or more // discharge, exportW for the reverse. moveW is the batteries' change from -// the power this reading already holds; like the relief, it spreads evenly -// over the site's phases. +// the power this reading already holds (phaseMoveW); like the relief, it +// spreads evenly over the site's phases. // // The phases are the meter's signed currents (phaseCurrentsA). When no // phase reads as export while the total does, the meter gives no direction // per phase, and the phases take the total's. When no move fits every -// phase, the move leaves the worst phase least over, so a move never -// raises the current on the phase it protects. 0, 0 without usable phase -// currents. -func phaseFuseReliefW(meter *telemetry.DerReading, state *State, moveW float64) (importW, exportW float64) { - if meter == nil || state == nil { +// phase, the move leaves the worst phase least over, so on a meter that +// gives each phase's direction a move never raises the current on the phase +// it protects. Two limits remain: a meter without phase power that mixes +// directions under an exporting total, where an importing phase is read as +// export, and a battery on one phase, which is read as balanced. 0, 0 +// without usable phase currents. +func phaseFuseReliefW(p phaseSample, state *State, moveW float64) (importW, exportW float64) { + if p.meter == nil || state == nil { return 0, 0 } - amps, _, valid := phaseCurrentsA(meter, state, meter.RawW) + amps, _, valid := phaseCurrentsA(p.meter, state, p.totalW) if !valid || len(amps) == 0 { return 0, 0 } perA := state.SiteFuseVoltage * float64(state.SiteFusePhases) // aggregate W per phase ampere limitA := math.Max(0, state.SiteFuseAmps-math.Max(evPhaseMarginA, state.SiteFuseSafetyA)) - exports := meter.RawW < 0 + exports := p.totalW < 0 for _, a := range amps { exports = exports && a >= 0 } @@ -4358,7 +4402,8 @@ func fuseSaverFromLivePower( // site is safe. Preserve the deadband in that case: this early-return path // exists to answer a fuse condition, not to take control of an externally // moved battery merely because its reading sits outside a command cap. - requiredRelief := fuseReliefFromSnapshot(rawLive, batteryReadings, meter, state, fuseMaxW) + phases := livePhaseSample(store, state.SiteMeterDriver, meter) + requiredRelief := fuseReliefFromSnapshot(rawLive, batteryReadings, meter, phases, state, fuseMaxW) if requiredRelief <= 0 { return nil } @@ -4376,7 +4421,7 @@ func fuseSaverFromLivePower( if len(safeBaseline) == 0 { return nil } - out := forceFuseDischargeWithSnapshot(safeBaseline, batteryReadings, meter, state, driverCapacities, fuseMaxW) + out := forceFuseDischargeWithSnapshot(safeBaseline, batteryReadings, meter, phases, state, driverCapacities, fuseMaxW) rawByDriver := make(map[string]DispatchTarget, len(rawLive)) for _, t := range rawLive { rawByDriver[t.Driver] = t @@ -4399,6 +4444,7 @@ func fuseReliefFromSnapshot( targets []DispatchTarget, batteryReadings map[string]*telemetry.DerReading, meter *telemetry.DerReading, + phases phaseSample, state *State, fuseMaxW float64, ) float64 { @@ -4406,7 +4452,7 @@ func fuseReliefFromSnapshot( return 0 } - _, sumTarget, predicted := predictedGridFromSnapshot(targets, batteryReadings, meter) + _, _, predicted := predictedGridFromSnapshot(targets, batteryReadings, meter) // The fuse acts on every reading. The hour's import limit is a budget: // the part of its overage that an EV cut we sent will remove waits for @@ -4418,16 +4464,7 @@ func fuseReliefFromSnapshot( if ceilingW := state.effectiveImportCeilingW(fuseMaxW); ceilingW < fuseW { overage = math.Max(overage, predicted-ceilingW-state.pendingEVCutW()) } - // The phases are raw, so the batteries' move is from their raw power. - moveW := sumTarget - seen := make(map[string]bool, len(targets)) - for _, t := range targets { - if r := batteryReadings[t.Driver]; r != nil && !seen[t.Driver] { - moveW -= r.RawW - } - seen[t.Driver] = true - } - phaseW, _ := phaseFuseReliefW(meter, state, moveW) + phaseW, _ := phaseFuseReliefW(phases, state, phaseMoveW(targets, batteryReadings)) return math.Max(overage, phaseW) } @@ -4480,7 +4517,7 @@ func forceFuseDischarge( readings[target.Driver] = store.Get(target.Driver, telemetry.DerBattery) } meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - return forceFuseDischargeWithSnapshot(targets, readings, meter, state, driverCapacities, fuseMaxW) + return forceFuseDischargeWithSnapshot(targets, readings, meter, livePhaseSample(store, state.SiteMeterDriver, meter), state, driverCapacities, fuseMaxW) } // forceFuseDischargeWithSnapshot keeps all math for one decision on the same @@ -4490,6 +4527,7 @@ func forceFuseDischargeWithSnapshot( targets []DispatchTarget, batteryReadings map[string]*telemetry.DerReading, meter *telemetry.DerReading, + phases phaseSample, state *State, driverCapacities map[string]float64, fuseMaxW float64, @@ -4497,7 +4535,7 @@ func forceFuseDischargeWithSnapshot( if fuseMaxW <= 0 || len(targets) == 0 || state == nil { return targets } - overage := fuseReliefFromSnapshot(targets, batteryReadings, meter, state, fuseMaxW) + overage := fuseReliefFromSnapshot(targets, batteryReadings, meter, phases, state, fuseMaxW) if overage <= 0 { return targets } diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index d94da1831..b631054a6 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -556,7 +556,7 @@ func TestFuseReliefLeavesOnlyCeilingWattsToCommandedEVCut(t *testing.T) { targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {}} meter := &telemetry.DerReading{SmoothedW: tc.gridW, Data: tc.data} - got := fuseReliefFromSnapshot(targets, batteries, meter, st, 17250) + got := fuseReliefFromSnapshot(targets, batteries, meter, phaseSample{meter: meter, totalW: meter.RawW}, st, 17250) if math.Abs(math.Max(0, got)-tc.wantW) > 1e-6 { t.Errorf("%s: relief %.0f W, want %.0f W", tc.name, got, tc.wantW) } @@ -597,7 +597,7 @@ func TestUnpluggedCarIsNoCutTheBatteryWaitsFor(t *testing.T) { st.SetEVCommanded(points) targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {}} - return math.Max(0, fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250)) + return math.Max(0, fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, phaseSample{}, st, 17250)) } // 400 W over the ceiling, less than a step: the guard waits. if got := tick(7900, true, 5520); math.Abs(got-400) > 1e-6 { @@ -661,7 +661,7 @@ func TestCutOnStayingCarStillWaitsWhenAnotherLeaves(t *testing.T) { st.SetEVCommanded(points) targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {SmoothedW: batteryW}} - relief := fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250) + relief := fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, phaseSample{}, st, 17250) batteryW = -math.Max(0, relief) // The battery follows within a tick. if batteryW < 0 && tick < 10 { t.Fatalf("%ds: battery %.0f W for watts a's stop removes; pending cut %.0f W", tick, batteryW, st.pendingEVCutW()) diff --git a/go/internal/control/fuse_battery_phase_direction_test.go b/go/internal/control/fuse_battery_phase_direction_test.go index 111c03ee3..2e740b1f8 100644 --- a/go/internal/control/fuse_battery_phase_direction_test.go +++ b/go/internal/control/fuse_battery_phase_direction_test.go @@ -144,3 +144,78 @@ func TestPerPhaseFuseReliefHoldsWhileOverloadPersists(t *testing.T) { } } } + +// chargeLoop repeats a 4000 W charge request against house loads of +// 13/7/5 A on a 0 A margin, with a three-phase battery that follows each +// order. dispatch returns the battery's next target. +func chargeLoop(t *testing.T, ticks int, dispatch func(*telemetry.Store, *State) float64) (targets, l1 []float64) { + t.Helper() + st := phaseDirectionState(0) + now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + st.clock = func() time.Time { return now } + store := telemetry.NewStore() + batW := 0.0 + for tick := 0; tick < ticks; tick++ { + a := [3]float64{13, 7, 5} + for i := range a { + a[i] += batW / 690 + } + raw, _ := json.Marshal(map[string]any{"l1_a": a[0], "l2_a": a[1], "l3_a": a[2], + "l1_w": a[0] * 230, "l2_w": a[1] * 230, "l3_w": a[2] * 230}) + store.Update("meter", telemetry.DerMeter, (a[0]+a[1]+a[2])*230, nil, raw) + store.DriverHealthMut("meter").RecordSuccess() + soc := 0.5 + store.Update("bat", telemetry.DerBattery, batW, &soc, nil) + store.DriverHealthMut("bat").RecordSuccess() + batW = dispatch(store, st) + targets = append(targets, math.Round(batW)) + l1 = append(l1, math.Round(a[0]*100)/100) + now = now.Add(5 * time.Second) + } + return targets, l1 +} + +// The fuse hold caps the request before the guard reads the phases, so the +// hold's cut is not taken off again. The charge settles at 1380 W with L1 +// at 15 A instead of switching between 1380 W and 0 W. +func TestFuseGuardHeldChargeHoldsSteady(t *testing.T) { + assertSteady := func(name string, targets, l1 []float64) { + t.Helper() + for i := 1; i < len(targets); i++ { + if math.Abs(targets[i]-targets[i-1]) > 50 || l1[i] > 15.01 { + t.Fatalf("%s: tick %d charge %v -> %v W; targets %v, L1 %v", name, i, targets[i-1], targets[i], targets, l1) + } + } + } + targets, l1 := chargeLoop(t, 10, func(store *telemetry.Store, st *State) float64 { + return applyFuseGuard([]DispatchTarget{{Driver: "bat", TargetW: 4000}}, store, st, 16*230*3)[0].TargetW + }) + assertSteady("applyFuseGuard", targets, l1) + + targets, l1 = chargeLoop(t, 12, func(store *telemetry.Store, st *State) float64 { + st.Mode = ModeCharge + st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: 4000, MaxDischargeW: 4000}} + for _, o := range ComputeDispatch(store, st, map[string]float64{"bat": 15200}, 16*230*3) { + if o.Driver == "bat" { + return o.TargetW + } + } + return 0 + }) + assertSteady("Charge mode", targets, l1) +} + +// A watchdog-offline meter's cached phases are not the site: they drive no +// phase relief. +func TestPhaseReliefSkipsOfflineMeter(t *testing.T) { + store := phaseDirectionStore(t, 20*230, 0, map[string]any{"l1_a": 20.0, "l2_a": 0.0, "l3_a": 0.0, + "l1_w": 4600.0, "l2_w": 0.0, "l3_w": 0.0}) + meter := store.Get("meter", telemetry.DerMeter) + if imp, _ := phaseFuseReliefW(livePhaseSample(store, "meter", meter), phaseDirectionState(0), 0); imp <= 0 { + t.Fatalf("online meter: import relief %.0f W, want > 0 for L1 at 20 A", imp) + } + store.DriverHealthMut("meter").SetOffline() + if imp, exp := phaseFuseReliefW(livePhaseSample(store, "meter", meter), phaseDirectionState(0), 0); imp != 0 || exp != 0 { + t.Fatalf("offline meter: relief %.0f/%.0f W, want none", imp, exp) + } +} diff --git a/go/internal/control/golden_dump_test.go b/go/internal/control/golden_dump_test.go index 32636555b..07a6f8f4d 100644 --- a/go/internal/control/golden_dump_test.go +++ b/go/internal/control/golden_dump_test.go @@ -333,7 +333,7 @@ func runGoldenScenarioAt(sc goldenScenario, scenarioNow time.Time, slotDirective intent := planSignIntent(st) // The worst phase's overage either way, in watts on that phase. var perPhase float64 - importW, exportW := phaseFuseReliefW(store.Get(st.SiteMeterDriver, telemetry.DerMeter), st, 0) + importW, exportW := phaseFuseReliefW(livePhaseSample(store, st.SiteMeterDriver, store.Get(st.SiteMeterDriver, telemetry.DerMeter)), st, 0) if st.SiteFusePhases > 0 { perPhase = math.Max(importW, exportW) / float64(st.SiteFusePhases) } From 3efb3ed708af8dcd0ddcbd1f06119052ee05039a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 18:43:57 +0100 Subject: [PATCH 03/34] fix(control): battery fuse cut lands on the line, not half a margin below applyFuseGuard cut a battery charge or discharge by the overage plus half of safety_margin_a. The guard now predicts from the held targets and keeps its 30 s hold, so the extra cut only parked the phase below the line the user set. Cut by the overage only. Co-Authored-By: Claude Opus 5.5 --- .changeset/c-battery-fuse-no-buffer.md | 5 + go/internal/control/dispatch.go | 23 ++--- .../control/ev_battery_priority_test.go | 4 +- go/internal/control/fuse_saver_test.go | 95 ++++++++++++------- .../control/testdata/golden/seeded_fuse.json | 24 ++--- 5 files changed, 88 insertions(+), 63 deletions(-) create mode 100644 .changeset/c-battery-fuse-no-buffer.md diff --git a/.changeset/c-battery-fuse-no-buffer.md b/.changeset/c-battery-fuse-no-buffer.md new file mode 100644 index 000000000..aab7466e0 --- /dev/null +++ b/.changeset/c-battery-fuse-no-buffer.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When the home battery cuts its charge or discharge to protect a fuse phase, it now cuts only by the overage, so the phase lands on the fuse less `safety_margin_a`. It used to cut half the safety margin more on top. Sites without a safety margin see no change. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 9089ffe5f..3c41fd910 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -3693,20 +3693,11 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta return out } - // Headroom buffer: shrink targets by `overage + half the configured - // safety margin` so post-clamp the grid sits *below* the threshold - // instead of riding right at it. Without this, the next dispatch - // tick lets the planner re-ramp into the threshold and the system - // oscillates at the boundary — the operator's "safety margin" - // becomes the active steady-state instead of a buffer below it. - // - // Half-margin chosen so the post-clamp phase amps still cluster - // near the threshold (operator wants the fuse used efficiently) - // but with enough buffer to absorb load fluctuations and per-phase - // imbalance on the next tick. Operators wanting tighter or looser - // buffer just adjust safety_margin_a. - buffer := state.fuseSafetyMarginW() * 0.5 - + // Cut by the overage only, so the grid lands on the line, not below it. + // The prediction from the held targets above keeps the next dispatch + // from re-ramping through the line, and the 30 s hold below keeps the + // planner at the clamped total. A site that wants headroom sets + // safety_margin_a, which lowers the line itself. switch { case importOverage > 0: // Too much import (aggregate or per-phase) → shrink charging. @@ -3723,7 +3714,7 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta // still flip idle batteries to discharge. return out } - newTotal := totalCharge - importOverage - buffer + newTotal := totalCharge - importOverage if newTotal < 0 { newTotal = 0 } @@ -3751,7 +3742,7 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta // is the lever in that scenario (annotateCurtailment). return out } - newTotal := totalDischarge - exportOverage - buffer + newTotal := totalDischarge - exportOverage if newTotal < 0 { newTotal = 0 } diff --git a/go/internal/control/ev_battery_priority_test.go b/go/internal/control/ev_battery_priority_test.go index 582a67eee..bb9fa5947 100644 --- a/go/internal/control/ev_battery_priority_test.go +++ b/go/internal/control/ev_battery_priority_test.go @@ -48,8 +48,8 @@ func TestIssue1868BatteryChargeYieldsBeforeTheCar(t *testing.T) { t.Fatalf("%s left the car's relief in the battery: %+v", mode, target) } // Charge mode asks for a raise. The fuse guard caps it at L1's - // working limit less half the margin (3758.5 W), and the car's - // headroom then takes the same 69 W again. + // working limit (3931 W), and the car's headroom then takes the + // same 69 W again. if mode == ModeCharge && target.TargetW < 3600 { t.Fatalf("small overage needlessly stopped battery charging: %+v", target) } diff --git a/go/internal/control/fuse_saver_test.go b/go/internal/control/fuse_saver_test.go index 205e25505..c1475845a 100644 --- a/go/internal/control/fuse_saver_test.go +++ b/go/internal/control/fuse_saver_test.go @@ -477,9 +477,8 @@ func TestPerPhaseClampHonorsSafetyMargin(t *testing.T) { } // Aggregate effFuseW = 11040 − 2×230×3 = 9660. Predicted = // 5000 + 4000 − 0 = 9000 → no aggregate overage. Per-phase wins. - // Buffer = half-margin = 0.5×2×230×3 = 690 W (post-fix headroom). - // Charge 4000 − 1240 − 690 = 2070. - expected := 2070.0 + // Charge 4000 − 1240 = 2760, which puts L1 on 14 A. + expected := 2760.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("charge after safety-margin clamp = %.0f W, want %.0f W", guarded[0].TargetW, expected) @@ -561,10 +560,10 @@ func TestPerPhaseReliefMatchesSinglePhaseSiteOnExport(t *testing.T) { if !guarded[0].Clamped { t.Fatalf("export-side per-phase overage must clamp the discharge target") } - // Discharge 5000 − overage 2070 − buffer (half the 230 W margin) - // = 2815 W. Demanding 2070 × 3 leaves nothing: the discharge is - // zeroed and the site swings to 520 W of import. - expected := -2815.0 + // Discharge 5000 − overage 2070 = 2930 W. Demanding 2070 × 3 leaves + // nothing: the discharge is zeroed and the site swings to 520 W of + // import. + expected := -2930.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("discharge after 1Φ export clamp = %.1f W, want %.1f W", guarded[0].TargetW, expected) @@ -656,33 +655,63 @@ func TestFuseSaverFiresInIdleMode(t *testing.T) { } } -// Operator-report 2026-04-30: with safety_margin_a=1 (threshold = 15 A) -// the system stabilised with phase amps oscillating between 15.0 and -// 15.8 A — fuse guard reactively scaled targets just enough to clear -// the trip, then the planner re-ramped, then it re-tripped. Half-margin -// buffer keeps post-clamp aggregate below the threshold by an explicit -// margin so the next tick has room before re-tripping. -func TestFuseGuardLeavesHeadroomBelowThreshold(t *testing.T) { - store, state, _ := setupPerPhase(-7000, 12, 18, 8, 0.6, 10000) - state.SiteFuseSafetyA = 1.0 - state.SiteFusePhases = 3 - soc := 0.6 - store.Update("bat", telemetry.DerBattery, -5000, &soc, nil) - store.DriverHealthMut("bat").RecordSuccess() +// A guard cut lands the worst phase on the line, In − safety_margin_a, and +// stays there. It takes no extra headroom below the line: the prediction +// from held targets and the 30 s hold keep the next dispatch from +// re-ramping through it. 16 A fuse, margin 1.2 A, so the line is 14.8 A. +// The battery follows each order; the loop runs 60 ticks of 5 s, so the +// hold expires and fires again several times. +func TestFuseGuardCutLandsOnTheLine(t *testing.T) { + const marginA, lineA = 1.2, 14.8 + for _, tc := range []struct { + name string + houseA [3]float64 // signed: + import, − export + requestW float64 + roomA float64 // worst phase's room to the line before the battery moves + }{ + {"charge on import", [3]float64{13, 7, 5}, 4000, lineA - 13}, + {"discharge on export", [3]float64{-8, -12, -5}, -5000, lineA - 12}, + } { + t.Run(tc.name, func(t *testing.T) { + st := NewState(0, 50, "meter") + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = 16, 230, 3, marginA + st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: 10000, MaxDischargeW: 10000}} + now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + st.clock = func() time.Time { return now } + store := telemetry.NewStore() + wantW := math.Copysign(tc.roomA*690, tc.requestW) + batW := 0.0 + for tick := 0; tick < 60; tick++ { + a := tc.houseA + data := map[string]any{} + for i := range a { + a[i] += batW / 690 + data[fmt.Sprintf("l%d_a", i+1)] = a[i] + data[fmt.Sprintf("l%d_w", i+1)] = a[i] * 230 + } + raw, _ := json.Marshal(data) + store.Update("meter", telemetry.DerMeter, (a[0]+a[1]+a[2])*230, nil, raw) + store.DriverHealthMut("meter").RecordSuccess() + soc := 0.5 + store.Update("bat", telemetry.DerBattery, batW, &soc, nil) + store.DriverHealthMut("bat").RecordSuccess() + + worstA := 0.0 + for _, x := range a { + worstA = math.Max(worstA, math.Abs(x)) + } + if tick > 0 && math.Abs(worstA-lineA) > 0.01 { + t.Fatalf("tick %d: worst phase %.3f A, want %.1f A (the line, not below it)", tick, worstA, lineA) + } - out := applyFuseGuard( - []DispatchTarget{{Driver: "bat", TargetW: -5000}}, - store, state, 11040) - if !out[0].Clamped { - t.Fatalf("expected per-phase export clamp") - } - // perPhase = (18−15) × 230 × 3 = 2070 W - // half-margin buffer = 0.5 × 1A × 230V × 3p = 345 W - // totalDischarge = 5000 → newTotal = 5000 − 2070 − 345 = 2585 - expected := -2585.0 - if math.Abs(out[0].TargetW-expected) > 1 { - t.Errorf("post-clamp target = %.0f W, want %.0f W (overage + half-margin)", - out[0].TargetW, expected) + out := applyFuseGuard([]DispatchTarget{{Driver: "bat", TargetW: tc.requestW}}, store, st, 16*230*3) + if math.Abs(out[0].TargetW-wantW) > 1 { + t.Fatalf("tick %d: target %.1f W, want %.1f W (cut by the overage only)", tick, out[0].TargetW, wantW) + } + batW = out[0].TargetW + now = now.Add(5 * time.Second) + } + }) } } diff --git a/go/internal/control/testdata/golden/seeded_fuse.json b/go/internal/control/testdata/golden/seeded_fuse.json index 9862640e4..0464f7cbb 100644 --- a/go/internal/control/testdata/golden/seeded_fuse.json +++ b/go/internal/control/testdata/golden/seeded_fuse.json @@ -6496,17 +6496,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1335.8333333333328, + "target_w": -1393.3333333333328, "clamped": true }, { "driver": "pixii", - "target_w": -1335.8333333333328, + "target_w": -1393.3333333333328, "clamped": true }, { "driver": "sungrow", - "target_w": -1335.8333333333328, + "target_w": -1393.3333333333328, "clamped": true } ], @@ -6524,7 +6524,7 @@ "fuse_ev_max_w": 0, "fuse_hold_latched": true, "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 4007.499999999998, + "fuse_hold_max_discharge_w": 4179.999999999998, "per_phase_overage_w": 23.000000000000327, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, @@ -6533,9 +6533,9 @@ }, "site_totals": { "battery_current_w": -4249, - "battery_target_sum_w": -4007.499999999998, + "battery_target_sum_w": -4179.999999999998, "raw_grid_w": -14716, - "projected_grid_w": -14474.499999999998 + "projected_grid_w": -14646.999999999998 } }, { @@ -7900,17 +7900,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 4493.333333333333, + "target_w": 4608.333333333333, "clamped": true }, { "driver": "pixii", - "target_w": 4493.333333333333, + "target_w": 4608.333333333333, "clamped": true }, { "driver": "sungrow", - "target_w": 4493.333333333333, + "target_w": 4608.333333333333, "clamped": true } ], @@ -7927,7 +7927,7 @@ "fuse_saturated": false, "fuse_ev_max_w": 0, "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 13480, + "fuse_hold_max_charge_w": 13825, "fuse_hold_max_discharge_w": 0, "per_phase_overage_w": 0, "effective_import_ceiling_w": 10350, @@ -7937,9 +7937,9 @@ }, "site_totals": { "battery_current_w": 853, - "battery_target_sum_w": 13480, + "battery_target_sum_w": 13825, "raw_grid_w": -7899, - "projected_grid_w": 4728 + "projected_grid_w": 5073 } }, { From f93b81fd0698c51d4b75f487ef57178409dd67c5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 18:53:43 +0100 Subject: [PATCH 04/34] docs(changeset): name the limit the battery cut lands on The changeset said the phase lands on the fuse less safety_margin_a. On #1891 as it stands, the phase line is the fuse less max(1 A, safety_margin_a), so at the 0.5 A default a 16 A phase settles at 15 A, not 15.5 A. Say what this PR changes, cutting only by the overage, without naming a line that depends on step 4. Co-Authored-By: Claude Opus 5.5 --- .changeset/c-battery-fuse-no-buffer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/c-battery-fuse-no-buffer.md b/.changeset/c-battery-fuse-no-buffer.md index aab7466e0..de59208a1 100644 --- a/.changeset/c-battery-fuse-no-buffer.md +++ b/.changeset/c-battery-fuse-no-buffer.md @@ -2,4 +2,4 @@ "ftw": patch --- -When the home battery cuts its charge or discharge to protect a fuse phase, it now cuts only by the overage, so the phase lands on the fuse less `safety_margin_a`. It used to cut half the safety margin more on top. Sites without a safety margin see no change. +When the home battery cuts its charge or discharge to protect the fuse, it now cuts only by the overage, so the grid settles on the limit instead of below it. It used to cut half of `safety_margin_a` more on top. Sites without a safety margin see no change. From fab8f6f030a232a032ac1ae53aaeae94f49cbfb3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 19:05:16 +0100 Subject: [PATCH 05/34] fix(config): fuse safety_margin_a defaults to 0 Core now works to the fuse rating unless a site sets a margin. Every explicit safety_margin_a is kept. A stored value of exactly 0.5 A, the old default, adds one load-time notice and changes nothing. The peak-limit error names the margin only when it is above 0, and the settings field shows 0 with help that says the margin is opt-in. Co-Authored-By: Claude Opus 5.5 --- .changeset/d-fuse-margin-default-0.md | 5 ++ go/cmd/ftw/control_state.go | 5 +- go/cmd/ftw/main.go | 4 +- go/internal/config/config.go | 55 +++++++++++++--------- go/internal/config/config_test.go | 39 +++++++++++++-- go/internal/config/retired_control_test.go | 48 +++++++++++++++++++ go/internal/config/storage.go | 1 + go/internal/control/dispatch.go | 8 +++- go/internal/control/peak_limit_test.go | 21 ++++++++- go/internal/control/sitesim_test.go | 54 +++++++++++++-------- web/settings-shell.test.mjs | 17 ++++++- web/settings/tabs/control.js | 4 +- 12 files changed, 205 insertions(+), 56 deletions(-) create mode 100644 .changeset/d-fuse-margin-default-0.md diff --git a/.changeset/d-fuse-margin-default-0.md b/.changeset/d-fuse-margin-default-0.md new file mode 100644 index 000000000..e0e5fcd4a --- /dev/null +++ b/.changeset/d-fuse-margin-default-0.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +`fuse.safety_margin_a` now defaults to 0, so FTW works to the fuse rating unless you set a margin. A margin is opt-in, for an inverter whose own phase limiter trips at the rating. FTW keeps any margin you have saved. If yours is exactly 0.5 A, FTW's old default, the log says so once at startup; set 0 to work to the rating. diff --git a/go/cmd/ftw/control_state.go b/go/cmd/ftw/control_state.go index 89c03dad9..ff90c439d 100644 --- a/go/cmd/ftw/control_state.go +++ b/go/cmd/ftw/control_state.go @@ -31,9 +31,8 @@ func newControlStateFromConfig(cfg *config.Config) *control.State { ctrl.SiteFuseAmps = cfg.Fuse.MaxAmps ctrl.SiteFuseVoltage = cfg.Fuse.Voltage ctrl.SiteFusePhases = cfg.Fuse.Phases - // EffectiveSafetyMarginA distinguishes nil ("unset, use default") - // from explicit 0 ("operator chose to disable"). The earlier - // `<= 0 -> default` shortcut clobbered the disable case. + // EffectiveSafetyMarginA resolves nil to the default (0: work to the + // fuse rating) and keeps any explicit value, 0 included. ctrl.SiteFuseSafetyA = cfg.Fuse.EffectiveSafetyMarginA() // PV surplus absorber underlay (opt-in). cap == 0 keeps it off. ctrl.PVSurplusAbsorbSoCCap = cfg.Site.PVSurplusAbsorbSoCCap diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 57e59b4df..f54b28457 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1164,8 +1164,8 @@ func main() { ctrl.SiteFuseAmps = newCfg.Fuse.MaxAmps ctrl.SiteFuseVoltage = newCfg.Fuse.Voltage ctrl.SiteFusePhases = newCfg.Fuse.Phases - // Mirror the startup-path default semantics — nil → 0.5, - // explicit 0 → disabled. See EffectiveSafetyMarginA. + // Mirror the startup path: nil → 0 (work to the fuse rating), an + // explicit value wins. See EffectiveSafetyMarginA. ctrl.SiteFuseSafetyA = newCfg.Fuse.EffectiveSafetyMarginA() ctrl.MaxExportW = newCfg.Site.MaxExportW // Lowering the fuse can strand a peak limit that was legal when it diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 731455fdc..75580b720 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -54,8 +54,9 @@ type Config struct { LoadWarnings []string `yaml:"-" json:"-"` LoadpointWarnings []string `yaml:"-" json:"-"` // Retired names settings of removed features that loading dropped so - // they cannot steer the site. Startup warns once for each; - // DropRetiredSettings deletes them from stored settings. + // they cannot steer the site, and stored values that match an old + // default. Startup warns once for each; DropRetiredSettings deletes the + // removed settings from stored settings and leaves the values alone. Retired []string `yaml:"-" json:"-"` // Used once at startup to end an old calendar's persisted away selection. RetiredCalendarEnabled bool `yaml:"-" json:"-"` @@ -868,11 +869,16 @@ type Site struct { MaxExportW float64 `yaml:"max_export_w,omitempty" json:"max_export_w,omitempty"` } -// DefaultFuseSafetyMarginA is the fall-back per-phase amp headroom -// applied when fuse.safety_margin_a is unset (nil) in the YAML. -// Single source of truth — main.go routes through Fuse.Effective- -// SafetyMarginA() rather than re-declaring it. -const DefaultFuseSafetyMarginA = 0.5 +// DefaultFuseSafetyMarginA is the per-phase margin applied when +// fuse.safety_margin_a is unset (nil). Core works to the fuse rating by +// default; a margin is opt-in. Single source of truth: main.go routes +// through Fuse.EffectiveSafetyMarginA() rather than re-declaring it. +const DefaultFuseSafetyMarginA = 0.0 + +// oldDefaultFuseSafetyMarginA was the default until FTW worked to the fuse +// rating. A stored value equal to it may be the settings form's old prefill +// rather than a choice, so loading names it once and keeps it. +const oldDefaultFuseSafetyMarginA = 0.5 // Fuse describes the shared breaker limit used by the fuse guard. type Fuse struct { @@ -880,15 +886,11 @@ type Fuse struct { Phases int `yaml:"phases" json:"phases"` Voltage float64 `yaml:"voltage" json:"voltage"` - // SafetyMarginA reserves headroom (per-phase amps) below MaxAmps - // inside the dispatch fuse guard. Pointer so we can distinguish - // "unset" (nil → DefaultFuseSafetyMarginA) from "explicitly - // disabled" (non-nil 0.0). Inverters often have their own per- - // phase current protection that trips before the breaker; without - // a margin the dispatch can ride right up to MaxAmps and the - // inverter cuts to 0 W in one tick, then dispatch ramps back up — - // visible as a flap. 0.5 A × 230 V × 3 phases ≈ 345 W of aggregate - // headroom. + // SafetyMarginA is an opt-in per-phase margin below MaxAmps: each + // phase's working limit is MaxAmps − SafetyMarginA. Pointer so an + // explicit value, 0 included, always wins over the default (nil → + // DefaultFuseSafetyMarginA, which is 0). Set it for an inverter whose + // own per-phase limiter trips at the rating and cuts to 0 W. SafetyMarginA *float64 `yaml:"safety_margin_a,omitempty" json:"safety_margin_a,omitempty"` } @@ -897,10 +899,9 @@ func (f Fuse) MaxPowerW() float64 { return f.MaxAmps * f.Voltage * float64(f.Phases) } -// EffectiveSafetyMarginA returns the per-phase amp headroom to apply, -// resolving nil ("unset → use default") vs an explicit value (including -// 0.0 to disable the margin entirely). Single read site so the default -// can never drift across consumers. +// EffectiveSafetyMarginA returns the per-phase margin to apply: the +// explicit value when set, otherwise DefaultFuseSafetyMarginA. Single read +// site so the default can never drift across consumers. func (f Fuse) EffectiveSafetyMarginA() float64 { if f.SafetyMarginA == nil { return DefaultFuseSafetyMarginA @@ -908,6 +909,17 @@ func (f Fuse) EffectiveSafetyMarginA() float64 { return *f.SafetyMarginA } +// noteOldFuseMargin adds one notice when fuse.safety_margin_a holds FTW's +// old default. The value stays: from April to September 2026 the settings +// form saved its 0.5 A prefill on any save, but a value saved later is the +// user's choice, and the stored document cannot tell the two apart. +func (c *Config) noteOldFuseMargin() { + if m := c.Fuse.SafetyMarginA; m != nil && *m == oldDefaultFuseSafetyMarginA { + c.Retired = append(c.Retired, + "fuse.safety_margin_a is 0.5 A, FTW's old default. FTW now works to the fuse rating. Set 0 to use it.") + } +} + // Driver is one driver entry. Each driver is a Lua script loaded by // the driver host at startup (or on hot-reload via the file watcher). type Driver struct { @@ -1491,6 +1503,7 @@ func Parse(data []byte, baseDir string) (*Config, error) { var retiredControl retiredControlSettings _ = doc.Decode(&retiredControl) c.applyRetiredControl(retiredControl) + c.noteOldFuseMargin() // An omitted app_link section follows the new default. An explicit YAML // null was a valid opt-out before that default changed, so retain it as an // explicit disabled section instead of letting applyDefaults turn it on. @@ -2060,7 +2073,7 @@ func (c *Config) Validate() error { } // safety_margin_a must be in [0, max_amps) when explicitly set. // Negative would *raise* the per-phase threshold above the breaker - // rating (defeating the guard); >= max_amps zeroes the headroom + // rating (defeating the guard); >= max_amps leaves no working limit // and silently disables the per-phase clamp — both are real safety // holes if reached through a typo'd config. nil (unset) is OK and // resolves to DefaultFuseSafetyMarginA at the consumer. diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index 86bb85cb9..d628ee75a 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -1260,12 +1260,43 @@ func TestNotificationsMaskSecrets(t *testing.T) { } } +// An unset margin works to the fuse rating: the default is 0 and a margin +// is opt-in (owner rule, 27 Oct 2026). func TestFuseSafetyMarginNilUsesDefault(t *testing.T) { - // Field omitted in YAML → nil pointer → default 0.5 A. f := Fuse{MaxAmps: 16, Voltage: 230, Phases: 3} - if got := f.EffectiveSafetyMarginA(); got != DefaultFuseSafetyMarginA { - t.Errorf("nil margin: got %v, want %v (DefaultFuseSafetyMarginA)", - got, DefaultFuseSafetyMarginA) + if got := f.EffectiveSafetyMarginA(); got != 0 || DefaultFuseSafetyMarginA != 0 { + t.Errorf("nil margin: got %v (default %v), want 0", got, DefaultFuseSafetyMarginA) + } +} + +// Every explicit margin is kept. Exactly 0.5 A was the old default, which +// the settings form once saved on any save, so loading adds one notice for +// it and changes nothing. +func TestFuseSafetyMarginLoadKeepsExplicitValues(t *testing.T) { + for _, tc := range []struct { + line string + want float64 + wantNotice bool + }{ + {"", 0, false}, + {" safety_margin_a: 0\n", 0, false}, + {" safety_margin_a: 0.5\n", 0.5, true}, + {" safety_margin_a: 0.4\n", 0.4, false}, + {" safety_margin_a: 1.2\n", 1.2, false}, + } { + yaml := strings.Replace(minimalYAML, " max_amps: 16\n", " max_amps: 16\n"+tc.line, 1) + c, err := Parse([]byte(yaml), "/tmp") + if err != nil { + t.Fatalf("%q: %v", tc.line, err) + } + if got := c.Fuse.EffectiveSafetyMarginA(); got != tc.want { + t.Errorf("%q: margin %v, want %v", tc.line, got, tc.want) + } + notice := len(c.Retired) == 1 && strings.Contains(c.Retired[0], "old default") && + strings.Contains(c.Retired[0], "Set 0") + if notice != tc.wantNotice || (!tc.wantNotice && len(c.Retired) != 0) { + t.Errorf("%q: notices %q, want the old-default notice: %v", tc.line, c.Retired, tc.wantNotice) + } } } diff --git a/go/internal/config/retired_control_test.go b/go/internal/config/retired_control_test.go index 0eb9f03f8..ca0897e03 100644 --- a/go/internal/config/retired_control_test.go +++ b/go/internal/config/retired_control_test.go @@ -132,3 +132,51 @@ func TestStoredRetiredControlSettingsMigrateAndAreDeleted(t *testing.T) { t.Fatalf("second DropRetiredSettings = %v, %v; want nothing", removed, err) } } + +// A stored 0.5 A margin keeps steering the site and stays in state.db; the +// notice comes from loading it, so dropping retired settings leaves it alone. +func TestStoredOldDefaultFuseMarginIsKeptWithANotice(t *testing.T) { + dir := t.TempDir() + path, database := filepath.Join(dir, "config.yaml"), filepath.Join(dir, "state.db") + yaml := strings.Replace(minimalYAML, " max_amps: 16\n", " max_amps: 16\n safety_margin_a: 0.5\n", 1) + if err := os.WriteFile(path, []byte(yaml), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + st, err := state.Open(database) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, err := InitializeStorage(path, database, cfg, st); err != nil { + t.Fatal(err) + } + for range 2 { + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + if loaded.ConfigDatabase == "" { + t.Fatal("loaded from the YAML file, not from stored settings") + } + if got := loaded.Fuse.EffectiveSafetyMarginA(); got != 0.5 { + t.Fatalf("stored margin = %v, want 0.5", got) + } + if len(loaded.Retired) != 1 || !strings.Contains(loaded.Retired[0], "safety_margin_a is 0.5 A") { + t.Fatalf("notices = %q, want one naming the old 0.5 A default", loaded.Retired) + } + if removed, err := DropRetiredSettings(st, path, loaded); err != nil || len(removed) != 0 { + t.Fatalf("DropRetiredSettings = %v, %v; the margin is a setting, not a retired one", removed, err) + } + } + after, _, err := st.Configuration() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(after.Document), `"safety_margin_a":0.5`) { + t.Fatalf("stored settings lost the margin: %s", after.Document) + } +} diff --git a/go/internal/config/storage.go b/go/internal/config/storage.go index 501b03310..58a7655d5 100644 --- a/go/internal/config/storage.go +++ b/go/internal/config/storage.go @@ -64,6 +64,7 @@ func decodeStored(c state.Configuration, database, baseDir string) (*Config, err } _ = json.Unmarshal(c.Document, &legacyControl) cfg.applyRetiredControl(legacyControl.Config) + cfg.noteOldFuseMargin() // This is the same typed config that was validated on save. Do not apply new // defaults during a storage-only reload: nil, false and zero stay distinct. if err := cfg.Validate(); err != nil { diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 3c41fd910..7f23fc497 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -1083,11 +1083,15 @@ func (s *State) SetPeakLimit(w float64) error { return fmt.Errorf("peak_limit_w must be ≥ 0, got %.0f W", w) } if ceiling := s.peakLimitCeilingW(); ceiling > 0 && w > ceiling { + margin := "" + if s.SiteFuseSafetyA > 0 { + margin = fmt.Sprintf(", less a %.1f A safety margin", s.SiteFuseSafetyA) + } return fmt.Errorf( "peak_limit_w %.0f W is above the site's import ceiling %.0f W "+ - "(fuse %.0f A × %.0f V × %d phases, less a %.1f A safety margin) "+ + "(fuse %.0f A × %.0f V × %d phases%s) "+ "— a peak limit above the fuse can never bind", - w, ceiling, s.SiteFuseAmps, s.SiteFuseVoltage, s.SiteFusePhases, s.SiteFuseSafetyA) + w, ceiling, s.SiteFuseAmps, s.SiteFuseVoltage, s.SiteFusePhases, margin) } s.PeakLimitW = w return nil diff --git a/go/internal/control/peak_limit_test.go b/go/internal/control/peak_limit_test.go index d8a7be220..b1c7f7861 100644 --- a/go/internal/control/peak_limit_test.go +++ b/go/internal/control/peak_limit_test.go @@ -7,8 +7,8 @@ import ( ) // SetPeakLimit is the one place the peak-shaving threshold is validated -// against the site's fuse. A 16 A / 230 V / 3-phase site is 11040 W; the -// default 0.5 A safety margin costs 345 W, so the highest limit that can +// against the site's fuse. A 16 A / 230 V / 3-phase site is 11040 W; an +// opt-in 0.5 A safety margin costs 345 W, so the highest limit that can // still be the first thing to bind is 10695 W. func fusedState() *State { st := NewState(0, 50, "ferroamp") @@ -63,6 +63,23 @@ func TestSetPeakLimitRejectsValueInsideTheSafetyMargin(t *testing.T) { } } +// The message names the margin only when the site has one. With the +// default 0 the ceiling is the fuse itself. +func TestSetPeakLimitMessageNamesOnlyARealMargin(t *testing.T) { + st := fusedState() + if err := st.SetPeakLimit(20000); err == nil || !strings.Contains(err.Error(), "less a 0.5 A safety margin") { + t.Fatalf("with a 0.5 A margin the message must name it, got %v", err) + } + st.SiteFuseSafetyA = 0 + err := st.SetPeakLimit(20000) + if err == nil || strings.Contains(err.Error(), "margin") || !strings.Contains(err.Error(), "11040 W") { + t.Fatalf("with no margin the message must give the fuse as the ceiling and no margin, got %v", err) + } + if err := st.SetPeakLimit(11040); err != nil { + t.Fatalf("with no margin the fuse itself is the ceiling, got %v", err) + } +} + // Zero is a real threshold in peak shaving — "correct everything above // 0 W of import" — not the zero-means-disabled convention that // PeakImportCeilingW and MaxExportW use. Dispatch has always read it that diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 6022cd157..b37c46a1e 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -83,7 +83,7 @@ func (s *simSite) boot() { s.state = NewState(0, 42, "meter") s.state.clock = func() time.Time { return s.p.Now } s.state.SlewRateW, s.state.MinDispatchIntervalS = 3000, 2 - s.state.SiteFuseAmps, s.state.SiteFuseVoltage, s.state.SiteFusePhases, s.state.SiteFuseSafetyA = s.p.Fuse.RatingA, s.p.Voltage, 3, .5 + s.state.SiteFuseAmps, s.state.SiteFuseVoltage, s.state.SiteFusePhases, s.state.SiteFuseSafetyA = s.p.Fuse.RatingA, s.p.Voltage, 3, 0 if b := s.p.Battery; b != nil { s.state.DriverLimits = map[string]PowerLimits{"battery": {MaxChargeW: b.MaxChargeW, MaxDischargeW: b.MaxDischargeW}} } @@ -604,6 +604,24 @@ func simScenarios() []simScenario { c.AllowedStepsW = append(steps, c.AllowedStepsW[1:]...) } } + replay1761 := simScenario{name: "#1761 replay", run: 20 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW, p.Car.MaxW = houseA(1, 1, 1), 4200 + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 9400, MaxDischargeW: 9400, CapacityWh: 20000, SoC: .3, Latency: 3 * time.Second} + p.Events = []sitesim.Event{{At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}} + }, + lp: func(c *loadpoint.Config) { + c.MaxChargeW, c.AllowedStepsW = 10350, c.AllowedStepsW[:len(c.AllowedStepsW)-1] + }, + setup: planBatteryCharge(9400), + expect: []simExpect{{9*time.Minute + 50*time.Second, func(s *simSite) error { + p := s.p + offerW := p.EVSE.OfferA * p.Voltage * float64(p.EVSE.OfferPhases) + if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || math.Abs(p.Battery.W-9400) > 200 { + return fmt.Errorf("not the #1761 state before the car takes its offer: offer %.0f W, car %.0f W, battery %.0f W", offerW, p.EVSE.DrawW, p.Battery.W) + } + return nil + }}}} return []simScenario{ {name: "export start", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW, p.PVW = houseA(2, 2, 2), -22*690 }}, @@ -622,24 +640,8 @@ func simScenarios() []simScenario { plant: slowCharger(time.Minute)}, {name: "slow cloud charger 120 s", run: 15 * time.Minute, plant: slowCharger(2 * time.Minute)}, - {name: "#1761 replay", run: 20 * time.Minute, - plant: func(p *sitesim.Plant) { - p.HouseW, p.Car.MaxW = houseA(1, 1, 1), 4200 - p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 9400, MaxDischargeW: 9400, CapacityWh: 20000, SoC: .3, Latency: 3 * time.Second} - p.Events = []sitesim.Event{{At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}} - }, - lp: func(c *loadpoint.Config) { - c.MaxChargeW, c.AllowedStepsW = 10350, c.AllowedStepsW[:len(c.AllowedStepsW)-1] - }, - setup: planBatteryCharge(9400), - expect: []simExpect{{9*time.Minute + 50*time.Second, func(s *simSite) error { - p := s.p - offerW := p.EVSE.OfferA * p.Voltage * float64(p.EVSE.OfferPhases) - if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || math.Abs(p.Battery.W-9400) > 200 { - return fmt.Errorf("not the #1761 state before the car takes its offer: offer %.0f W, car %.0f W, battery %.0f W", offerW, p.EVSE.DrawW, p.Battery.W) - } - return nil - }}}}, + replay1761, + withSafetyMarginA(replay1761, .5), {name: "meter every 30 s after a cut", known: "a meter slower than evSettle makes every reading a gap, ev_fuse_budget.go:141", run: 10 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW = houseA(3, 3, 3) @@ -761,6 +763,20 @@ func simScenarios() []simScenario { } } +// withSafetyMarginA runs sc with an opt-in fuse.safety_margin_a. The +// default is 0; a site that keeps the old 0.5 A must still pass. +func withSafetyMarginA(sc simScenario, a float64) simScenario { + setup := sc.setup + sc.name = fmt.Sprintf("%s, safety_margin_a %.1f A", sc.name, a) + sc.setup = func(s *simSite) { + if setup != nil { + setup(s) + } + s.state.SiteFuseSafetyA = a + } + return sc +} + // runSimScenario runs sc from start and returns the site, with the first // broken invariant or expectation in failure. func runSimScenario(sc simScenario, start time.Time) *simSite { diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index 6f940efbc..5c625044b 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -522,7 +522,7 @@ async function electricalShell(fuse) { const rig = loadShell(structuredClone(original)); rig.loadTab('./settings/tabs/control.js'); const fields = {}; - for (const [key, fallback] of Object.entries({ phases: 3, voltage: 230, max_amps: 16, safety_margin_a: 0.5 })) { + for (const [key, fallback] of Object.entries({ phases: 3, voltage: 230, max_amps: 16, safety_margin_a: 0 })) { fields[key] = Object.assign(stubElement(), { dataset: { path: 'fuse.' + key }, type: 'number', value: String(fuse[key] ?? fallback), }); @@ -563,6 +563,21 @@ describe('electricity connection settings', () => { }); } + it('shows an unset safety margin as 0 and offers a margin as opt-in', () => { + const rig = loadShell({}); + rig.loadTab('./settings/tabs/control.js'); + const config = { site: {}, fuse: { max_amps: 16 } }; + const getByPath = (obj, path, dflt) => path.split('.').reduce((node, key) => node?.[key], obj) ?? dflt; + const html = rig.tabs.control.render({ + config, getByPath, escHtml: s => String(s ?? ''), + help: text => '?', + field: (label, path, type, dflt) => '', + }); + assert.match(html, /data-path="fuse\.safety_margin_a" value="0"/); + assert.match(html, /Optional\. Amps to keep below the fuse rating[^"]*0 works to the rating\./); + assert.doesNotMatch(html, /Defaults to 0\.5 A/); + }); + it('only replaces custom voltage when the user picks a standard connection', async () => { const rig = await electricalShell({ phases: 2, voltage: 240, max_amps: 20, safety_margin_a: 0 }); rig.connection.value = '3'; diff --git a/web/settings/tabs/control.js b/web/settings/tabs/control.js index e5611b15d..40d9a7f4b 100644 --- a/web/settings/tabs/control.js +++ b/web/settings/tabs/control.js @@ -96,8 +96,8 @@ '
' + field("Phase-to-neutral voltage (V)", "fuse.voltage", "number", 230) + '
' + - decimalField("Safety margin (A)", "fuse.safety_margin_a", 0.5, - "Headroom below max amps so the inverter's own per-phase limiter doesn't trip first. Defaults to 0.5 A.", + decimalField("Safety margin (A)", "fuse.safety_margin_a", 0, + "Optional. Amps to keep below the fuse rating, for an inverter whose own phase limiter trips at the rating. 0 works to the rating.", "0.1") + ''; }, From 94f19b3edf2617981e699464f697fe1426145b16 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 19:22:17 +0100 Subject: [PATCH 06/34] docs(changeset): say what the default change moves on its own The changeset said FTW works to the fuse rating unless a site sets a margin. On this PR's base every per-phase line still sits 1 A under the rating; that holds only once #1910 and the step-4 amendment of #1891 land. The changeset now names what this PR changes: the default, and the whole-site import and export limits. Co-Authored-By: Claude Opus 5.5 --- .changeset/d-fuse-margin-default-0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/d-fuse-margin-default-0.md b/.changeset/d-fuse-margin-default-0.md index e0e5fcd4a..3a82a6eb7 100644 --- a/.changeset/d-fuse-margin-default-0.md +++ b/.changeset/d-fuse-margin-default-0.md @@ -2,4 +2,4 @@ "ftw": patch --- -`fuse.safety_margin_a` now defaults to 0, so FTW works to the fuse rating unless you set a margin. A margin is opt-in, for an inverter whose own phase limiter trips at the rating. FTW keeps any margin you have saved. If yours is exactly 0.5 A, FTW's old default, the log says so once at startup; set 0 to work to the rating. +`fuse.safety_margin_a` now defaults to 0. On a site with no saved margin, the limits FTW puts on the site's total import and export rise to the fuse rating, 0.5 A per phase higher than before. A margin is opt-in, for an inverter whose own phase limiter trips at the rating. FTW keeps any margin you have saved. If yours is exactly 0.5 A, FTW's old default, the log says so once at startup; set 0 to drop it. From bb9703e806f14ea4c937e6d2ee0c9cccfb04fbfd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 21:57:47 +0100 Subject: [PATCH 07/34] fix(control): a landed EV cut covers nothing later; a raise that no longer fits comes off The site meter now tells Core how much of a charger's offers it already shows. A move shaped like an order landing (every phase alike, or one phase alone for a one-phase car) moves that level toward the latest offer. A cut the meter shows no longer covers an overage that an outside step causes after it, and no longer nets against a raise sent after it. Where a landing and a house step fall in one reading, a cut covers a phase that has risen since only up to the overage it had then. C4: our own rise on its way, as the meter tells it, is judged as the reading it will make. Above 1.2 x In, or over the line on a phase whose heat budget is spent, it comes off on this reading, down to the line. Co-Authored-By: Claude Opus 5.5 --- .changeset/s1-ev-cut-landed-and-unshown.md | 5 + go/internal/control/ev_fuse_budget.go | 237 ++++++++++++++++-- .../control/ev_fuse_cut_netting_test.go | 71 +++++- go/internal/control/sitesim_property_test.go | 5 +- 4 files changed, 289 insertions(+), 29 deletions(-) create mode 100644 .changeset/s1-ev-cut-landed-and-unshown.md diff --git a/.changeset/s1-ev-cut-landed-and-unshown.md b/.changeset/s1-ev-cut-landed-and-unshown.md new file mode 100644 index 000000000..2085a736f --- /dev/null +++ b/.changeset/s1-ev-cut-landed-and-unshown.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 4150ceb0a..203ba4d5e 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -178,6 +178,143 @@ type chargerOffers struct { // charger as 0 before it; a cut takes it as shown, since the reading is // all Core knows of it. found sentCommand + // carPhase is the phase a car on one phase draws on, 1-3, as the + // loadpoint saw it on the site meter; 0 when unknown. + carPhase int + // shownA is the current per phase of the charger's offers that the + // site meter shows, as far as it can tell (seeOffers); unsure while it + // cannot. + shownA float64 + unsure bool +} + +// evMoveA is half a charger step. An order landing shows on the site meter +// as every phase moving the same way, by evMoveA or more on their mean and +// no more than a step apart, or as one phase alone moving a whole step +// while the others hold within evMoveA (a car on one phase). Meter noise +// rarely moves that far. +const evMoveA = evStepA / 2 + +// chargerMoveA is the move per phase from one reading to the next when it +// has the shape of an order landing (evMoveA), signed: alikeA when every +// phase moved alike, oneA and its phase when one phase moved alone. +func chargerMoveA(prev, now []float64) (alikeA, oneA float64, phase int) { + var sum, held, moved, d1 float64 + lo, hi, p1 := math.Inf(1), math.Inf(-1), 0 + for i := range now { + d := now[i] - prev[i] + sum, lo, hi = sum+d, math.Min(lo, d), math.Max(hi, d) + switch { + case math.Abs(d) >= 2*evMoveA: + moved, d1, p1 = moved+1, d, i + case math.Abs(d) < evMoveA: + held++ + } + } + n := float64(len(now)) + switch { + case hi-lo <= 2*evMoveA && (lo > 0 && sum/n >= evMoveA || hi < 0 && sum/n <= -evMoveA): + return sum / n, 0, 0 + case moved == 1 && held == n-1: + return 0, d1, p1 + 1 + } + return 0, 0, 0 +} + +// seeOffers reads a new site reading against the last one. With one charger +// in the pool and no change of phases in its log, a move shaped like an +// order landing (chargerMoveA) moves the current the meter shows of the +// charger (shownA): a drop of either shape, and a rise in a shape the +// offers can take on the meter: on the car's phase for a car the loadpoint +// has seen draw on one phase, on one phase for a one-phase order, and +// otherwise on every phase alike or, for a car not yet seen, on one. +// The shown current moves only toward the latest offer, and stays within +// the offers the reading may show. While our own battery order moves, a +// move of every phase alike may be the battery's, so no reading counts; +// after a reading that did not count, the shown current is known again +// once only one offer may show. A house load that moves in a charger's +// shape reads as ours. It can only take cover off a cut, or leave our rise +// to the meter as before. +func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { + lim := &s.evLimit + amps, _, valid := phaseCurrentsA(meter, s, gridW) + var now [3]float64 + copy(now[:], amps) + prev, read := lim.phasesA, valid && lim.phasesSeen && len(lim.offers) == 1 && len(amps) >= 2 && !s.batteryOrderMoves() + lim.phasesA, lim.phasesSeen = now, valid + var alikeA, oneA float64 + var phase int + if read { + alikeA, oneA, phase = chargerMoveA(prev[:len(amps)], now[:len(amps)]) + } + for id, c := range lim.offers { + if read && c.readable() { + moveA := math.Min(0, alikeA) + math.Min(0, oneA) + switch { + case c.carPhase > 0: + if c.carPhase == phase { + moveA += math.Max(0, oneA) + } + case c.log[0].n == 1: + moveA += math.Max(0, oneA) + default: + moveA += math.Max(0, alikeA) + math.Max(0, oneA) + } + // The charger moves toward its latest offer, never past it. + if latestA := c.log[len(c.log)-1].a; moveA < 0 && c.shownA > latestA { + c.shownA = math.Max(latestA, c.shownA+moveA) + } else if moveA > 0 && c.shownA < latestA { + c.shownA = math.Min(latestA, c.shownA+moveA) + } + } else { + c.unsure = true + } + low, high := c.offerRangeA(lim.readingAt) + c.shownA = math.Min(high, math.Max(low, c.shownA)) + c.unsure = c.unsure && low < high + lim.offers[id] = c + } +} + +// batteryOrderMoves reports whether one of our battery orders of the last +// evSettle moves the site's phases by evMoveA or more. +func (s *State) batteryOrderMoves() bool { + for _, log := range s.batteryCommands { + if low, high, _, ok := log.span(s.now()); ok && high-low >= evMoveA*s.SiteFuseVoltage*float64(s.SiteFusePhases) { + return true + } + } + return false +} + +// readable reports whether the site meter can tell what it shows of the +// charger's offers: every offer the log holds lands on the same phases. +func (c chargerOffers) readable() bool { + for _, o := range c.log { + if o.n != c.log[0].n { + return false + } + } + return len(c.log) > 0 +} + +// offerRangeA is the lowest and highest current per phase among the +// offers a reading taken at `at` may show: the one in force settle before +// it and each one sent since. Before the first logged offer the charger +// counts as before. +func (c chargerOffers) offerRangeA(at time.Time) (low, high float64) { + from := at.Add(-c.settle) + low, high = c.found.a, c.found.a + for _, o := range c.log { + switch { + case o.at.After(at): + case !o.at.After(from): + low, high = o.a, o.a + default: + low, high = math.Min(low, o.a), math.Max(high, o.a) + } + } + return low, high } // pendingLoad is load we ordered that a site reading may not show yet: @@ -237,7 +374,14 @@ func (s *State) pendingLoad(at time.Time) pendingLoad { // 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) { +// +// Where the meter tells what it shows of the charger (seeOffers), a cut +// covers no more than the meter has still to show: a cut the meter shows +// covers no overage that comes after it. aheadA is then the rise per phase +// the meter has still to show of the latest offers, which a raise's own +// trigger reads (stepEVMeterLimit), and rise counts at least that much. +// Elsewhere aheadA is 0. +func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingLoad, aheadA, latestW float64) { lim := &s.evLimit for _, c := range lim.offers { w, a := c.log.toCome(at.Add(-c.settle), at, c.found) @@ -258,15 +402,24 @@ func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingL if latest.n == 1 && len(lim.offers) > 1 { coverA = 0 } + riseA, ahead := math.Max(0, a), 0.0 + if perPhase && lim.phasesSeen && len(lim.offers) == 1 && s.SiteFusePhases >= 2 && c.readable() && !c.unsure { + low, high := c.offerRangeA(at) + toComeA := latest.a - math.Min(high, math.Max(low, c.shownA)) + coverA = math.Min(coverA, math.Max(0, -toComeA)) + ahead = math.Max(0, toComeA) + riseA = math.Max(riseA, ahead) + } 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) + rise.phaseA[i] += riseA cover.phaseA[i] += coverA } + aheadA += ahead latestW += latest.w } - return rise, cover, latestW + return rise, cover, aheadA, latestW } // recordEVOffers keeps each plugged charger's offers, with the current each @@ -285,7 +438,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) continue } c := s.evLimit.offers[point.ID] - c.settle, c.declared = evUnknownSettle, false + c.settle, c.declared, c.carPhase = evUnknownSettle, false, point.CarPhase if r := store.Get(point.DriverName, telemetry.DerEV); r != nil { if declared := telemetry.DeclaredPowerMaxAge(r.Data); declared > 0 { c.settle, c.declared = max(evSettle, declared), true @@ -296,7 +449,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) 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.found, c.shownA = sent, sent.a } c.log = c.log.keep(sent, read.Add(-c.settle)) } @@ -343,13 +496,17 @@ type evMeterLimit struct { offers map[string]chargerOffers // each plugged charger's, by loadpoint phaseRoomA float64 // below the working limit on the busiest phase at readingAt; an idle pool's as a start reads it 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 + phasesA [3]float64 // the phase currents at readingAt, when phasesSeen + phasesSeen bool + cutForA [3]float64 // each phase's highest reading that a cut still on its way was sent on, when cutForSet + cutForSet bool + 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 batteryYield evBatteryYield } @@ -372,7 +529,10 @@ type evPool struct { // 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). +// stay at or below fuseHourFactor (unshownOrders), but never once the meter +// shows it. Our own rise on its way cuts before the meter shows it where it +// would read above fuseInstantFactor, or over the working limit on a phase +// whose heat budget is spent. // 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 below the working limit. Each reading acts once, so a silent @@ -408,6 +568,7 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea first, prevAt := !lim.set, lim.readingAt gap := first || at.Sub(prevAt) > evSettle lim.readingAt = at + s.seeOffers(meter, gridW) prev := math.Inf(1) if !first { prev = lim.capW @@ -460,9 +621,9 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea 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 { + rise, cover, aheadA, latestW := s.unshownOrders(at, r.perPhase) + fuseCutW, over := r.fuseOverW+rise.w-cover.w, r.fuseOverW > 0 + if r.perPhase && lim.phasesSeen { // 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 @@ -471,9 +632,39 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea 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 + if coverA <= 0 { + lim.cutForSet = false + } + riseA := max(rise.phaseA[0], rise.phaseA[1], rise.phaseA[2]) + lineA, instantA, cutA := s.fuseLineA(), fuseInstantFactor*s.SiteFuseAmps, 0.0 + for i, a := range lim.phasesA[:s.SiteFusePhases] { + // Cuts on their way cover a phase up to the reading they were + // sent for. A phase that has risen since by more than noise + // carries an overage that came later: they cover only the + // part of it that phase had then. + phaseCoverA := coverA + if lim.cutForSet && a > lim.cutForA[i]+evMoveA { + phaseCoverA = math.Min(coverA, math.Max(0, lim.cutForA[i]-lineA)) + } + // Where the heat budget is spent, the overage the meter shows + // comes off with our rise on its way. Elsewhere our own rise on + // its way is judged as the reading it will make: above + // fuseInstantFactor, or over the line on a phase whose heat + // budget is spent, it comes off now, as far as it takes the + // phase over the line. Under that, the heat budget judges it + // once the meter shows it, as any other reading. + hot, needA := s.fuseHeat.sq[i] > lineA*lineA, 0.0 + if landA := a + aheadA - phaseCoverA; landA > instantA || hot && landA > lineA { + needA = math.Min(aheadA-phaseCoverA, landA-lineA) + } + if a > lineA && (hot || a > instantA) { + needA = math.Max(needA, a+riseA-phaseCoverA-lineA) + } + cutA = math.Max(cutA, needA) + } + fuseCutW, over = cutA*s.SiteFuseVoltage*pool.phases, over || cutA > 0 } - over, capW, byImport := r.fuseOverW > 0, math.Inf(1), false + capW, byImport := math.Inf(1), false if over { capW = latestW if fuseCutW > 0 { @@ -514,13 +705,21 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea lim.capW, lim.roomSince = math.Min(prev, math.Max(0, capW)), time.Time{} if lim.capW < prev { lim.cutAt = now + if lim.phasesSeen { + for i, a := range lim.phasesA { + if !lim.cutForSet || a > lim.cutForA[i] { + lim.cutForA[i] = a + } + } + lim.cutForSet = true + } if r.fuseOverW > 0 && lim.fuseCutAt.IsZero() { lim.fuseCutAt = now } slog.Info("EV hard limit: site meter over limit", "grid_w", gridW, "worst_phase_a", r.worstA, "fuse_heat_a", s.fuseHeat.worstA(), "import_limit_w", s.effectiveImportCeilingW(fuseMaxW), "overage_w", r.fuseOverW, "import_overage_w", math.Max(0, -r.limitW), - "unshown_rise_w", rise.w, "unshown_cut_w", cover.w, "offer_w", latestW, "cap_w", lim.capW) + "unshown_rise_w", rise.w, "unshown_cut_w", cover.w, "rise_on_its_way_a", aheadA, "offer_w", latestW, "cap_w", lim.capW) } return } diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go index 88dfb337b..5792db391 100644 --- a/go/internal/control/ev_fuse_cut_netting_test.go +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -194,19 +194,20 @@ func TestEVFuseSevereOverageCutsWhateverIsOnItsWay(t *testing.T) { } } -// A cut covers only within the run over the limit it was sent in. A fast +// A cut the meter shows covers no overage that comes after it. 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. +// for 180 s. The meter shows the cut land as every phase falling 7 A. A +// second step on L1, 30 s later in a new run or 10 s later in the same +// one, then cuts once L1's heat budget is spent, about 22-28 s at 29 A on +// the 24 A line. Before, a landed cut still covered the step in the same +// run until the run's first cut had had 180 s: L1 sat at 29 A for 2 min +// 55 s (#1914, TestSiteSimRandomHouse seed 1 at margin 0). 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}, + {30 * time.Second, 30 * time.Second}, {10 * time.Second, 30 * time.Second}, } { t.Run(fmt.Sprintf("step_after_%v", tc.after), func(t *testing.T) { s := fuseHeatSite(t) @@ -354,3 +355,59 @@ func TestEVFuseRestartTakesFoundOfferAsShown(t *testing.T) { t.Fatalf("cuts %v while the first travels", cuts) } } + +// A cut whose landing the meter cannot tell from an outside step covers a +// phase only up to the reading it was sent on. A house step takes L2 to +// 26 A; once its heat budget is spent, Core cuts 2 A. The second the cut +// lands, the house adds 8 A on L3, so the meter shows L1 and L2 falling +// 2 A and L3 rising 6 A: no charger's shape. L3 had no overage when the +// cut went out, so the cut covers none of L3's 2 A over the 24 A line, and +// L3 cuts once its own heat budget is spent (about 61 s from 20 A). Before, +// the landed cut covered L3 until the charger's 180 s had passed +// (TestSiteSimRandomHouse seed 68 at margin 0). +func TestEVFuseCutCoversOnlyThePhaseOverageItWasSentFor(t *testing.T) { + s := fuseHeatSite(t) + s.run(time.Minute) + s.addHouseA(0, 6, 0) // L2 26 A + if took, offer := s.firstCut(2 * time.Minute); took < 0 || offer != 8970 { + t.Fatalf("L2 cut after %v to %.0f W, want 8,970 W", took, offer) + } + s.run(s.chargerDelay - time.Second) + s.addHouseA(0, 0, 8) // lands with the cut: L3 26 A + var over time.Duration + for range 4 * 60 { + s.step() + if s.phaseA()[2] > 24 { + over += time.Second + } + } + if a := s.phaseA(); over > 70*time.Second || a[2] > 24 { + t.Fatalf("L3 over the 24 A line for %v, phases %.1f A after; cuts %v", over, a, offerWatts(s.cutsSince(time.Time{}))) + } + t.Logf("L3 over the working limit for %v", over) +} + +// C4 (27 Oct triage, seeds 207537 and 207539): our own rise on its way that +// no longer fits cuts before the meter shows it. On a 16 A fuse with no +// margin, a charger that declares 40 s takes the car from 6 A to 16 A with +// L1 at 6 A: the step fits under the line. 30 s later the house adds 9.7 A +// on L1 and 3 A on L2 before the raise lands. L1 reads 15.7 A, under the +// line, but the raise would take it to 25.7 A, over 1.2 x In: on that +// reading the raise comes off as far as it takes L1 over the line, to 6 A +// in whole amps. Before, Core waited for the meter to show it. +func TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce(t *testing.T) { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps = 16 + for range 60 { + r.read([3]float64{6, 6, 6}) + } + r.offerW = 11040 // 6 -> 16 A + for i := range 30 { + if r.read([3]float64{6, 6, 6}); r.offerW != 11040 { + t.Fatalf("raise cut to %.0f W after %d s with L1 at 6 A", r.offerW, i+1) + } + } + if r.read([3]float64{15.7, 9, 6}); r.offerW != 4140 { + t.Fatalf("offer %.0f W with L1 at 15.7 A and 10 A on its way, want 4,140 W", r.offerW) + } +} diff --git a/go/internal/control/sitesim_property_test.go b/go/internal/control/sitesim_property_test.go index 519a18fbc..b498e485e 100644 --- a/go/internal/control/sitesim_property_test.go +++ b/go/internal/control/sitesim_property_test.go @@ -13,9 +13,8 @@ import ( // randomHouseKnown names the seeds that break an invariant on master, by // kind, and the finding. A listed kind that passes fails the test until its -// line goes. The fuse's own kind is never listed. Seed 2 no longer reaches -// the case where a landed cut still covers the next overage; that cost is -// pinned in TestEVFuseLandedCutCoversOnlyItsRun. +// line goes. The fuse's own kind is never listed. A landed cut covers no +// later overage (TestEVFuseLandedCutCoversOnlyItsRun). var randomHouseKnown = map[uint64]map[string]string{} // Core keeps a random house under the fuse. For each fixed seed the house From ad1f994bcb71535424aa1905bf0f083224956cc0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 22:23:46 +0100 Subject: [PATCH 08/34] fix(control): a one-phase house move is not the car's cut landing seeOffers kept one shown level for every phase, so a house load moving on one phase alone read as a three-phase cut landing and took the cut's cover off the phase it was sent for. With a 40 s charger Core then stacked a second cut and left L1 2 A under the line for over 2 min. The shown level is now kept per phase: a three-phase order moves every phase alike, and a phase that moves alone moves only its own level. The loadpoint now reports a car it has seen draw on all three phases as CarPhases 3. For such a car a one-phase move is never the car, so a house step on one phase while a raise travels no longer hides the raise, and C4 cuts on that reading. Co-Authored-By: Claude Opus 5.5 --- .changeset/s1-ev-cut-landed-and-unshown.md | 2 +- go/internal/control/ev_fuse_budget.go | 113 +++++++++++------- .../control/ev_fuse_cut_netting_test.go | 73 ++++++++++- go/internal/control/sitesim_test.go | 2 +- go/internal/loadpoint/loadpoint.go | 10 +- go/internal/loadpoint/one_phase_car.go | 5 +- go/internal/loadpoint/one_phase_car_test.go | 4 +- 7 files changed, 154 insertions(+), 55 deletions(-) diff --git a/.changeset/s1-ev-cut-landed-and-unshown.md b/.changeset/s1-ev-cut-landed-and-unshown.md index 2085a736f..3e8cdb1fa 100644 --- a/.changeset/s1-ev-cut-landed-and-unshown.md +++ b/.changeset/s1-ev-cut-landed-and-unshown.md @@ -2,4 +2,4 @@ "ftw": patch --- -The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. +The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A house load that moves on one phase alone is not taken for the car's cut landing, so it no longer stacks a second cut on a slow charger. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. For a car the site meter has shown on three phases, this also holds when the house steps up on one phase alone. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 203ba4d5e..da5028585 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -3,6 +3,7 @@ package control import ( "log/slog" "math" + "slices" "time" "github.com/srcfl/ftw/go/internal/loadpoint" @@ -178,13 +179,14 @@ type chargerOffers struct { // charger as 0 before it; a cut takes it as shown, since the reading is // all Core knows of it. found sentCommand - // carPhase is the phase a car on one phase draws on, 1-3, as the - // loadpoint saw it on the site meter; 0 when unknown. - carPhase int - // shownA is the current per phase of the charger's offers that the + // carPhases is how many phases the car draws on, as the loadpoint saw + // it on the site meter: 1 or 3, or 0 when unknown. carPhase is the phase + // a car on one phase draws on, 1-3. + carPhases, carPhase int + // shownA is the current on each phase of the charger's offers that the // site meter shows, as far as it can tell (seeOffers); unsure while it // cannot. - shownA float64 + shownA [3]float64 unsure bool } @@ -224,10 +226,14 @@ func chargerMoveA(prev, now []float64) (alikeA, oneA float64, phase int) { // seeOffers reads a new site reading against the last one. With one charger // in the pool and no change of phases in its log, a move shaped like an // order landing (chargerMoveA) moves the current the meter shows of the -// charger (shownA): a drop of either shape, and a rise in a shape the -// offers can take on the meter: on the car's phase for a car the loadpoint -// has seen draw on one phase, on one phase for a one-phase order, and -// otherwise on every phase alike or, for a car not yet seen, on one. +// charger on each phase (shownA), in a shape the offers can take there. A +// car the loadpoint has seen draw on one phase, or a one-phase order, is +// one level on every phase: every phase dropping alike moves it, and so +// does a move on the car's phase alone, or on any one phase while that +// phase is not known. A three-phase order moves every phase alike; for a +// car not yet seen on three phases, a phase that moves alone moves only +// its own level, since a one-phase car would draw there. A house load on +// one phase then never moves the level of the other two. // The shown current moves only toward the latest offer, and stays within // the offers the reading may show. While our own battery order moves, a // move of every phase alike may be the battery's, so no reading counts; @@ -249,28 +255,41 @@ func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { } for id, c := range lim.offers { if read && c.readable() { - moveA := math.Min(0, alikeA) + math.Min(0, oneA) + var moveA [3]float64 switch { - case c.carPhase > 0: - if c.carPhase == phase { - moveA += math.Max(0, oneA) + case c.carPhase > 0 || c.log[0].n == 1: + // One car on one phase: one level, moved by a drop of + // either shape or by its phase alone. + m := math.Min(0, alikeA) + if c.carPhase == 0 || c.carPhase == phase { + m += oneA } - case c.log[0].n == 1: - moveA += math.Max(0, oneA) + moveA = [3]float64{m, m, m} default: - moveA += math.Max(0, alikeA) + math.Max(0, oneA) + // A three-phase order moves every phase alike. A car not + // yet seen may draw on one phase: a phase that moves alone + // moves only its own level. + moveA = [3]float64{alikeA, alikeA, alikeA} + if c.carPhases != 3 && phase > 0 { + moveA[phase-1] += oneA + } } // The charger moves toward its latest offer, never past it. - if latestA := c.log[len(c.log)-1].a; moveA < 0 && c.shownA > latestA { - c.shownA = math.Max(latestA, c.shownA+moveA) - } else if moveA > 0 && c.shownA < latestA { - c.shownA = math.Min(latestA, c.shownA+moveA) + latestA := c.log[len(c.log)-1].a + for i, m := range moveA { + if m < 0 && c.shownA[i] > latestA { + c.shownA[i] = math.Max(latestA, c.shownA[i]+m) + } else if m > 0 && c.shownA[i] < latestA { + c.shownA[i] = math.Min(latestA, c.shownA[i]+m) + } } } else { c.unsure = true } low, high := c.offerRangeA(lim.readingAt) - c.shownA = math.Min(high, math.Max(low, c.shownA)) + for i := range c.shownA { + c.shownA[i] = math.Min(high, math.Max(low, c.shownA[i])) + } c.unsure = c.unsure && low < high lim.offers[id] = c } @@ -376,12 +395,12 @@ func (s *State) pendingLoad(at time.Time) pendingLoad { // it covers no phase. // // Where the meter tells what it shows of the charger (seeOffers), a cut -// covers no more than the meter has still to show: a cut the meter shows -// covers no overage that comes after it. aheadA is then the rise per phase -// the meter has still to show of the latest offers, which a raise's own -// trigger reads (stepEVMeterLimit), and rise counts at least that much. -// Elsewhere aheadA is 0. -func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingLoad, aheadA, latestW float64) { +// covers no more than the meter has still to show on each phase: a cut the +// meter shows covers no overage that comes after it. aheadA is then the +// rise on each phase the meter has still to show of the latest offers, +// which a raise's own trigger reads (stepEVMeterLimit), and rise counts at +// least that much. Elsewhere aheadA is 0. +func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingLoad, aheadA [3]float64, latestW float64) { lim := &s.evLimit for _, c := range lim.offers { w, a := c.log.toCome(at.Add(-c.settle), at, c.found) @@ -402,21 +421,24 @@ func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingL if latest.n == 1 && len(lim.offers) > 1 { coverA = 0 } - riseA, ahead := math.Max(0, a), 0.0 + riseA := math.Max(0, a) + phaseCoverA, phaseRiseA, ahead := [3]float64{coverA, coverA, coverA}, [3]float64{riseA, riseA, riseA}, [3]float64{} if perPhase && lim.phasesSeen && len(lim.offers) == 1 && s.SiteFusePhases >= 2 && c.readable() && !c.unsure { low, high := c.offerRangeA(at) - toComeA := latest.a - math.Min(high, math.Max(low, c.shownA)) - coverA = math.Min(coverA, math.Max(0, -toComeA)) - ahead = math.Max(0, toComeA) - riseA = math.Max(riseA, ahead) + for i := range min(s.SiteFusePhases, len(ahead)) { + toComeA := latest.a - math.Min(high, math.Max(low, c.shownA[i])) + phaseCoverA[i] = math.Min(coverA, math.Max(0, -toComeA)) + ahead[i] = math.Max(0, toComeA) + phaseRiseA[i] = math.Max(riseA, ahead[i]) + } } rise.w += math.Max(0, w) cover.w += math.Max(0, math.Min(-w, -runW)) for i := range rise.phaseA { - rise.phaseA[i] += riseA - cover.phaseA[i] += coverA + rise.phaseA[i] += phaseRiseA[i] + cover.phaseA[i] += phaseCoverA[i] + aheadA[i] += ahead[i] } - aheadA += ahead latestW += latest.w } return rise, cover, aheadA, latestW @@ -438,7 +460,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) continue } c := s.evLimit.offers[point.ID] - c.settle, c.declared, c.carPhase = evUnknownSettle, false, point.CarPhase + c.settle, c.declared, c.carPhases, c.carPhase = evUnknownSettle, false, point.CarPhases, point.CarPhase if r := store.Get(point.DriverName, telemetry.DerEV); r != nil { if declared := telemetry.DeclaredPowerMaxAge(r.Data); declared > 0 { c.settle, c.declared = max(evSettle, declared), true @@ -449,7 +471,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) 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, c.shownA = sent, sent.a + c.found, c.shownA = sent, [3]float64{sent.a, sent.a, sent.a} } c.log = c.log.keep(sent, read.Add(-c.settle)) } @@ -625,14 +647,15 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea fuseCutW, over := r.fuseOverW+rise.w-cover.w, r.fuseOverW > 0 if r.perPhase && lim.phasesSeen { // 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]) + // most a rise adds to a phase. A cut covers each phase by what it + // takes off there, the same on every phase unless the meter tells + // the phases apart (unshownOrders). Past the current a gG fuse + // carries for an hour, nothing on its way holds back a cut. + coverA := cover.phaseA if s.fuseHeat.worstA() > fuseHourFactor*s.SiteFuseAmps { - coverA = 0 + coverA = [3]float64{} } - if coverA <= 0 { + if slices.Max(coverA[:s.SiteFusePhases]) <= 0 { lim.cutForSet = false } riseA := max(rise.phaseA[0], rise.phaseA[1], rise.phaseA[2]) @@ -642,9 +665,9 @@ func (s *State) stepEVMeterLimit(store *telemetry.Store, meter *telemetry.DerRea // sent for. A phase that has risen since by more than noise // carries an overage that came later: they cover only the // part of it that phase had then. - phaseCoverA := coverA + phaseCoverA, aheadA := coverA[i], aheadA[i] if lim.cutForSet && a > lim.cutForA[i]+evMoveA { - phaseCoverA = math.Min(coverA, math.Max(0, lim.cutForA[i]-lineA)) + phaseCoverA = math.Min(phaseCoverA, math.Max(0, lim.cutForA[i]-lineA)) } // Where the heat budget is spent, the overage the meter shows // comes off with our rise on its way. Elsewhere our own rise on diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go index 5792db391..dcd70dafe 100644 --- a/go/internal/control/ev_fuse_cut_netting_test.go +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -70,6 +70,9 @@ type cutRig struct { offerW float64 phases int // the phases the offer lands on snap func(w float64) float64 // nil: whole amps on three phases + // carPhases is the loadpoint's verdict on the car: 0 unknown, 1 one + // phase, 3 three phases. + carPhases int } func newCutRig(t *testing.T, offerW, declaredS float64) *cutRig { @@ -101,7 +104,7 @@ func (r *cutRig) deliver(gridW float64, data []byte) float64 { 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 + points[0].CommandedPhases, points[0].CarPhases = r.phases, r.carPhases capW, _ := EVFuseBudget(r.store, r.state, points, configs, 17250) if capW < r.offerW { r.offerW = math.Floor(capW/690) * 690 @@ -411,3 +414,71 @@ func TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce(t *testing.T) { t.Fatalf("offer %.0f W with L1 at 15.7 A and 10 A on its way, want 4,140 W", r.offerW) } } + +// The C4 case as the triage wrote it, with only L1 moving, for a car the +// loadpoint has seen draw on three phases. A house step on L1 alone is then +// not the raise landing: the raise would take L1 to 25.7 A, over 1.2 x In, +// so it comes off on that reading. For a car not yet seen, a step on one +// phase may be a one-phase car taking the raise; the meter judges it then. +func TestEVFuseRaiseOnItsWayCutsForACarSeenOnThreePhases(t *testing.T) { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps, r.carPhases = 16, 3 + for range 60 { + r.read([3]float64{6, 6, 6}) + } + r.offerW = 11040 // 6 -> 16 A + for range 30 { + r.read([3]float64{6, 6, 6}) + } + if r.read([3]float64{15.7, 6, 6}); r.offerW > 4140 { + t.Fatalf("offer %.0f W with L1 at 15.7 A and 10 A on its way, want 4,140 W or less", r.offerW) + } +} + +// A house load that moves on one phase alone is not a three-phase cut +// landing. While a cut travels to a charger that declares 40 s, such a move +// must not take the cut's cover off the phase it was sent for. Before, a +// 3 A load switching off on L2 read as the cut landing: Core cut again to +// 7,590 W and left L1 2 A under the line for over 2 min. A 1.5 A load +// cycling on L2 gave three cuts and two rises; one cut is enough. +func TestEVFuseOnePhaseHouseMoveIsNotTheCutLanding(t *testing.T) { + t.Run("drop_on_L2", func(t *testing.T) { + s := fuseHeatSite(t) + s.chargerDelay, s.chargerMaxAgeS = 40*time.Second, 40 + s.addHouseA(0, 3, 0) // L2 23 A + s.run(2 * time.Minute) + stepAt := s.now + s.addHouseA(6, 0, 0) // L1 26 A + if took, offer := s.firstCut(2 * time.Minute); took < 0 || offer != 8970 { + t.Fatalf("first cut after %v to %.0f W, want 8,970 W", took, offer) + } + s.run(10 * time.Second) + s.addHouseA(0, -3, 0) + s.run(5 * time.Minute) + if cuts := offerWatts(s.cutsSince(stepAt)); len(cuts) != 1 || s.offer() != 8970 { + t.Fatalf("cuts %v, offer %.0f W after; want one cut to 8,970 W", cuts, s.offer()) + } + }) + t.Run("cycling_on_L2", func(t *testing.T) { + s := fuseHeatSite(t) + s.chargerDelay, s.chargerMaxAgeS = 40*time.Second, 40 + s.run(time.Minute) + stepAt := s.now + s.addHouseA(5, 0, 0) // L1 25 A on the 24 A line + for i := range 20 * 60 { + if i%7 == 0 { + s.addHouseA(0, []float64{1.5, -1.5}[i/7%2], 0) + } + s.step() + } + var offers []float64 + for _, o := range s.offersSince(stepAt) { + if n := len(offers); n == 0 || o.w != offers[n-1] { + offers = append(offers, o.w) + } + } + if len(offers) != 2 || offers[1] != 9660 { + t.Fatalf("offers %v, want one cut to 9,660 W and no rise", offers) + } + }) +} diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index caa84aa04..09e8c1a42 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -507,7 +507,7 @@ func fieldNightLoadpoint(c *loadpoint.Config) { // carPhasesUnknown checks that the session's car was never marked one-phase. func carPhasesUnknown(s *simSite) error { - if st, _ := s.mgr.State(s.lp.ID); st.CarPhases != 0 { + if st, _ := s.mgr.State(s.lp.ID); st.CarPhases == 1 { return fmt.Errorf("car marked one-phase on L%d; car draws %.0f W on %d phases", st.CarPhase, s.p.EVSE.DrawW, s.p.EVSE.DrawPhases) } return nil diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index e532d9d3a..ef16f9de2 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -204,8 +204,9 @@ type State struct { CarMaxW float64 `json:"-"` CarChargeLimit float64 `json:"-"` // CarPhases is 1 when this session's car draws on one phase of a - // three-phase order, seen on the site meter, and 0 when unknown; a car - // profile's phases may set it later. CarPhase is that phase, 1-3. The + // three-phase order, seen on the site meter, 3 when it draws on all + // three, and 0 when unknown; a car profile's phases may set it later. + // CarPhase is the phase of a one-phase car, 1-3. The // plan counts the car's one-phase levels (OnePhaseCarSteps) and the // order names the current per phase it should take. CarPhases int `json:"-"` @@ -1382,8 +1383,11 @@ func (lp *loadpointRuntime) snapshot() State { CommandedPhases: lp.commandedPhases, CarPhase: lp.carPhases.phase, } - if lp.carPhases.phase > 0 { + switch { + case lp.carPhases.phase > 0: st.CarPhases = 1 + case lp.carPhases.three: + st.CarPhases = 3 } if lp.VehicleCapacityWh <= 0 { st.VehicleCapacityWh = 60000 diff --git a/go/internal/loadpoint/one_phase_car.go b/go/internal/loadpoint/one_phase_car.go index 031bbeed3..e91c6463e 100644 --- a/go/internal/loadpoint/one_phase_car.go +++ b/go/internal/loadpoint/one_phase_car.go @@ -51,7 +51,7 @@ type phaseTest struct { } // carPhases is one session's evidence. phase is 1-3 once the car is seen on -// that phase alone. +// that phase alone; three is set once it is seen on all three. type carPhases struct { orderA float64 // per phase, the order the next rise is measured from // rising is set once the order has gone above orderA, and before is the @@ -67,6 +67,7 @@ type carPhases struct { seen bool test *phaseTest phase int + three bool } // phaseVerdict reads one meter reading against the baseline of a rise of @@ -196,7 +197,7 @@ func (m *Manager) observePhases(id string, meter SitePhases, drawA, voltage floa if t.verdict == 0 || now.Sub(t.since) < onePhaseHold { return } - s.phase = max(t.verdict, 0) + s.phase, s.three = max(t.verdict, 0), t.verdict < 0 s.test = nil } diff --git a/go/internal/loadpoint/one_phase_car_test.go b/go/internal/loadpoint/one_phase_car_test.go index ae002be08..4d84b9e07 100644 --- a/go/internal/loadpoint/one_phase_car_test.go +++ b/go/internal/loadpoint/one_phase_car_test.go @@ -83,8 +83,8 @@ func TestOnePhaseCarMarkedFromTheMeter(t *testing.T) { return threePhase(16)(d) } run(125*time.Second, 200*time.Second, allThree, 11040) - if st := state(); st.CarPhases != 0 { - t.Fatalf("a rise on all three phases left the mark on L%d", st.CarPhase) + if st := state(); st.CarPhases != 3 || st.CarPhase != 0 { + t.Fatalf("a rise on all three phases: car phases %d on L%d, want 3", st.CarPhases, st.CarPhase) } order(4830) run(205*time.Second, 210*time.Second, still, 0) From 43d38ff7bebee778574e8c2e02b335ad9beea8fd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 22:39:31 +0100 Subject: [PATCH 09/34] fix(control): a battery charge rise fits each phase before it is sent The battery fuse guard judged a rise from the battery's own reading and the meter's phases, so it took a rise the meter did not show yet as already shown. With the meter a poll behind the battery, a PV surplus that grew each tick stacked rises past the line, then the relief cut them back: 16.4 A on a 16 A fuse and a charge that rose and fell (C2, seeds 207530, 207535). fitBatteryRisesToFuse caps charge above the battery's latest order to (fuseLineA - worst phase - own unshown rises) x V x phases. The unshown rises are the battery's orders of the last evSettle and the chargers' raises, counted as evShownW counts them, so the car comes first. Holding an order is never a rise and the check never cuts; an overage stays with the relief. No margin and no floor: the order ends at the line. Co-Authored-By: Claude Opus 5.5 --- .changeset/s2-battery-rise-per-phase.md | 5 + .../control/battery_rise_phase_test.go | 172 ++++++++++++++++++ go/internal/control/dispatch.go | 99 +++++++++- go/internal/control/ev_fuse_budget.go | 16 +- 4 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 .changeset/s2-battery-rise-per-phase.md create mode 100644 go/internal/control/battery_rise_phase_test.go diff --git a/.changeset/s2-battery-rise-per-phase.md b/.changeset/s2-battery-rise-per-phase.md new file mode 100644 index 000000000..306154792 --- /dev/null +++ b/.changeset/s2-battery-rise-per-phase.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A battery charge rise now fits each phase before FTW sends it. The room is the fuse's working limit less the phase's reading and less FTW's own rises the meter does not show yet: the car's raises and the battery's earlier orders. The car's raise comes first. This stops the battery from charging into a phase the meter shows full, and from stacking a second rise on one the meter has not shown yet, which pushed a phase over the fuse and made the charge rise and fall around the limit. Holding a charge is never a rise, so this check never cuts a charge. diff --git a/go/internal/control/battery_rise_phase_test.go b/go/internal/control/battery_rise_phase_test.go new file mode 100644 index 000000000..290f51c5c --- /dev/null +++ b/go/internal/control/battery_rise_phase_test.go @@ -0,0 +1,172 @@ +package control + +import ( + "fmt" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// C2 of the 27 Oct rig triage: a battery charge rise fits each phase's room, +// (fuseLineA − measured phase − our unshown rises) × V × phases, before it is +// sent. 16 A fuse, 230 V, 3 phases. + +// riseStore holds one meter reading with these phase currents, all import, +// and the battery's reading. +func riseStore(t *testing.T, phaseA [3]float64, batW float64) *telemetry.Store { + t.Helper() + data := map[string]any{} + var totalW float64 + for i, a := range phaseA { + data[fmt.Sprintf("l%d_a", i+1)] = math.Abs(a) + data[fmt.Sprintf("l%d_w", i+1)] = a * 230 + totalW += a * 230 + } + return phaseDirectionStore(t, totalW, batW, data) +} + +// riseState asks the battery for askW of charge (Charge mode at a charge +// limit of askW), so the fuse path alone shapes the order. +func riseState(askW float64) *State { + st := phaseDirectionState(0) + st.Mode = ModeCharge + now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + st.clock = func() time.Time { return now } + st.batteryCommands = map[string]commandLog{} + ask(st, askW) + return st +} + +func ask(st *State, w float64) { + st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: w, MaxDischargeW: 10000}} +} + +func batteryOrder(t *testing.T, store *telemetry.Store, st *State) float64 { + t.Helper() + for _, o := range ComputeDispatch(store, st, map[string]float64{"bat": 15200}, 16*230*3) { + if o.Driver == "bat" { + return o.TargetW + } + } + t.Fatal("no battery target") + return 0 +} + +// Seed 207530 at 897 s: L3 reads 16.04 A, over the 16 A line, with the +// battery at 491 W after a cut to 491 W. A plan target of 3350 W is no rise +// room: the order stays at or below the battery's reading. +func TestBatteryRiseIntoPhaseOverTheLine(t *testing.T) { + store := riseStore(t, [3]float64{8.5, 10.5, 16.04}, 491) + st := riseState(3350) + sent(st, "bat", 3349, 491) + if got := batteryOrder(t, store, st); got > 491+1e-6 { + t.Fatalf("order %.0f W with L3 at 16.04 A and the battery at 491 W; want <= 491 W", got) + } +} + +// Seed 207535 at 37-41 s, with no margin. L3 reads 14.27 A with the battery +// at 12 W: 871 W fits under the line and goes out whole. Two seconds later +// the battery reads 871 W, but the meter's reading is from before the +// battery moved and still shows L3 at 14.27 A. The 871 W rise is ours and +// not shown, so a further ask may add only what is left above it: the order +// stays at or below 12 W + (16 − 14.27) × 690 W. +func TestBatteryRiseCountsOwnUnshownRise(t *testing.T) { + phases := [3]float64{13.77, 13.97, 14.27} + limitW := 12 + (16-14.27)*690 + + st := riseState(871) + sent(st, "bat", 12) + if got := batteryOrder(t, riseStore(t, phases, 12), st); math.Abs(got-871) > 1e-6 { + t.Fatalf("first ask: order %.1f W, want the whole 871 W (it fits under %.1f W)", got, limitW) + } + + st = riseState(1500) + sent(st, "bat", 12, 871) + got := batteryOrder(t, riseStore(t, phases, 871), st) + if got > limitW+1e-6 { + t.Fatalf("order %.1f W while the meter does not show our 871 W yet: L3 would reach %.2f A; want <= %.1f W", + got, 14.27+(got-12)/690, limitW) + } + if got < 871-1e-6 { + t.Fatalf("order %.1f W cut below our 871 W hold; a rise check never cuts", got) + } +} + +// Closed loop, battery only, no margin: house loads 13.77/13.97/14.27 A and +// a charge ask that grows 300 W a tick from 600 W to 3000 W, as a PV +// surplus does. The battery reaches each order one tick later; the meter +// shows the battery one tick after the battery's own reading does. L3 +// never passes the 16 A line, the order never moves down (no hunting), and +// it ends at the line: 1193.7 W. +func TestBatteryRiseClosedLoopStopsAtTheLine(t *testing.T) { + house := [3]float64{13.77, 13.97, 14.27} + st := riseState(600) + now := st.now() + st.clock = func() time.Time { return now } + var orders, l3 []float64 + batW, meterBatW := 0.0, 0.0 + for tick := 0; tick < 40; tick++ { + ask(st, math.Min(3000, 600+300*float64(tick))) + var a [3]float64 + for i := range a { + a[i] = house[i] + meterBatW/690 + } + order := batteryOrder(t, riseStore(t, a, batW), st) + orders = append(orders, math.Round(order*10)/10) + l3 = append(l3, math.Round(a[2]*1000)/1000) + meterBatW, batW = batW, order + now = now.Add(2 * time.Second) + } + for i := range orders { + if l3[i] > 16+1e-3 { + t.Errorf("tick %d: L3 %.3f A over the 16 A line", i, l3[i]) + } + if i > 0 && orders[i] < orders[i-1]-0.5 { + t.Errorf("tick %d: order fell %.1f -> %.1f W", i, orders[i-1], orders[i]) + } + } + if want := (16 - 14.27) * 690; math.Abs(orders[len(orders)-1]-want) > 1 { + t.Errorf("final order %.1f W, want %.1f W: L3 at the line", orders[len(orders)-1], want) + } + if t.Failed() { + t.Logf("orders %v", orders) + t.Logf("L3 %v", l3) + } +} + +// Car first: the car's raise from 6 A to 10 A per phase went out 2 s ago +// and the meter does not show it yet: L3 reads 11 A with the car at 6 A. +// The raise takes L3 to 15 A, so the battery's 2000 W charge ask gets the +// 1 A left, 690 W. Once the meter shows the car at 10 A and the battery at +// 690 W, L3 sits at the 16 A line and the battery holds. With the car's demand +// and without it (a raise still on its way after the demand left). +func TestBatteryRiseLeavesRoomForUnshownCarRaise(t *testing.T) { + for _, demand := range []bool{true, false} { + t.Run(fmt.Sprintf("demand=%v", demand), func(t *testing.T) { + st := riseState(2000) + now := st.now() + st.clock = func() time.Time { return now } + sent(st, "bat", 0) + var log commandLog + log = log.keep(sentCommand{at: now.Add(-evSettle - time.Second), w: 4140, a: 6, n: 3}, now.Add(-evSettle)) + log = log.keep(sentCommand{at: now.Add(-2 * time.Second), w: 6900, a: 10, n: 3}, now.Add(-evSettle)) + st.evLimit.offers = map[string]chargerOffers{"car": {log: log, settle: evSettle}} + st.EVCommandedW = 6900 + if demand { + st.evFuseDemands = []evFuseDemand{{"car", 6900, "3p"}} + } + got := batteryOrder(t, riseStore(t, [3]float64{9, 9, 11}, 0), st) + if got > 690+1e-6 { + t.Fatalf("battery %.1f W beside the car's unshown 4 A: L3 would reach %.2f A", got, 11+4+got/690) + } + + now = now.Add(evSettle) + got = batteryOrder(t, riseStore(t, [3]float64{13 + got/690, 13 + got/690, 15 + got/690}, got), st) + if math.Abs(got-690) > 1 { + t.Fatalf("battery %.1f W once the meter shows the car, want 690 W: L3 at the 16 A line", got) + } + }) + } +} diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index ff6ffc27b..7c8ba79a6 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -2385,6 +2385,7 @@ func applyDispatchSafetyPipeline( } // ---- Fuse guard (bidirectional, #145) ---- targets = applyFuseGuard(targets, store, state, fuseMaxW) + targets = fitBatteryRisesToFuse(targets, store, state) // ---- Plan/exec sign-mismatch floor (energy planner modes only) ---- // Operator-report 2026-04-28 (08:00-08:15 CEST): planner_arbitrage @@ -3900,6 +3901,91 @@ func livePhaseSample(store *telemetry.Store, driver string, meter *telemetry.Der return phaseSample{meter: meter, totalW: totalW} } +// signedPhasesA is the sample's phase currents, import positive +// (phaseCurrentsA). When no phase reads as export while the total does, the +// meter gives no direction per phase, and every phase takes the total's. +// false without usable phase currents. +func (p phaseSample) signedPhasesA(state *State) ([]float64, bool) { + if p.meter == nil || state == nil { + return nil, false + } + amps, _, valid := phaseCurrentsA(p.meter, state, p.totalW) + if !valid || len(amps) == 0 { + return nil, false + } + exports := p.totalW < 0 + for _, a := range amps { + exports = exports && a >= 0 + } + if exports { + for i := range amps { + amps[i] = -amps[i] + } + } + return amps, true +} + +// fitBatteryRisesToFuse keeps battery charge rises inside the room the site +// meter shows on its busiest phase: the working limit (fuseLineA) less the +// phase's reading and less our own rises that reading may not show yet, the +// chargers' offers (counted as evShownW does) and the batteries' orders +// (pendingBatteryCommandsW). A three-phase battery puts its power evenly on +// every phase. A rise is charge above the battery's latest order, or above +// its reading before our first order. Holding an order is never a rise, and +// a discharge we end uses room first. Nothing here cuts below the hold: an +// overage is the relief's (applyFuseGuard, forceFuseDischarge), which reads +// the battery's own power. Without usable phase currents the aggregate guard +// is the check. +func fitBatteryRisesToFuse(targets []DispatchTarget, store *telemetry.Store, state *State) []DispatchTarget { + if store == nil || state == nil || len(targets) == 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases <= 0 { + return targets + } + meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) + if meter == nil { + return targets + } + amps, ok := livePhaseSample(store, state.SiteMeterDriver, meter).signedPhasesA(state) + if !ok { + return targets + } + now := state.now() + perA := state.SiteFuseVoltage * float64(state.SiteFusePhases) + unshownA := state.pendingBatteryCommandsW(now) / perA + for _, c := range state.evLimit.offers { + _, a := c.log.unshown(now, c.batterySettle()) + unshownA += a // A one-phase offer may land on any phase. + } + worstA := math.Inf(-1) + for _, a := range amps { + worstA = math.Max(worstA, a) + } + roomW := (state.fuseLineA() - worstA - unshownA) * perA + holds := make([]float64, len(targets)) + var riseW float64 + for i, t := range targets { + var base float64 + if _, _, latest, sent := state.batteryCommands[t.Driver].span(now); sent { + base = latest + } else if r := store.Get(t.Driver, telemetry.DerBattery); r != nil { + base = r.RawW + } + roomW -= math.Max(0, math.Min(0, t.TargetW)-math.Min(0, base)) + holds[i] = math.Min(t.TargetW, math.Max(0, base)) + riseW += t.TargetW - holds[i] + } + if riseW <= 0 || roomW >= riseW { + return targets + } + scale := math.Max(0, roomW) / riseW + out := append([]DispatchTarget(nil), targets...) + for i := range out { + if out[i].TargetW > holds[i] { + out[i].TargetW, out[i].Clamped = holds[i]+(out[i].TargetW-holds[i])*scale, true + } + } + return out +} + // phaseMoveW is the batteries' change from the raw power their readings hold // to the targets. The phase currents are raw, so the move is too. func phaseMoveW(targets []DispatchTarget, batteries map[string]*telemetry.DerReading) float64 { @@ -3932,24 +4018,17 @@ func phaseMoveW(targets []DispatchTarget, batteries map[string]*telemetry.DerRea // export, and a battery on one phase, which is read as balanced. 0, 0 // without usable phase currents. func phaseFuseReliefW(p phaseSample, state *State, moveW float64) (importW, exportW float64) { - if p.meter == nil || state == nil { + if state == nil { return 0, 0 } - amps, _, valid := phaseCurrentsA(p.meter, state, p.totalW) - if !valid || len(amps) == 0 { + amps, ok := p.signedPhasesA(state) + if !ok { return 0, 0 } perA := state.SiteFuseVoltage * float64(state.SiteFusePhases) // aggregate W per phase ampere limitA := state.fuseLineA() - exports := p.totalW < 0 - for _, a := range amps { - exports = exports && a >= 0 - } hi, lo := math.Inf(-1), math.Inf(1) for _, a := range amps { - if exports { - a = -a - } a += moveW / perA hi, lo = math.Max(hi, a), math.Min(lo, a) } diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index da5028585..37b108dc8 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -359,17 +359,23 @@ func (s *State) evShownW(at time.Time) float64 { var w float64 for _, c := range s.evLimit.offers { if n := len(c.log); n > 0 { - settle := evSettle - if c.declared { - settle = c.settle - } - unshown, _ := c.log.unshown(at, settle) + unshown, _ := c.log.unshown(at, c.batterySettle()) w += math.Max(0, c.log[n-1].w-unshown) } } return w } +// batterySettle is how long the battery side counts a raise of this +// charger's as unshown: its declared response time, else evSettle +// (evShownW). +func (c chargerOffers) batterySettle() time.Duration { + if c.declared { + return c.settle + } + return evSettle +} + func (s *State) pendingLoad(at time.Time) pendingLoad { var p pendingLoad for _, c := range s.evLimit.offers { From c94fa56c3b945a7f42d4c93e060e79cb98f91d85 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 23:09:07 +0100 Subject: [PATCH 10/34] fix(control): battery rise check counts slow batteries and direction-less phases A battery order now stays unshown until evSettle after the battery's own reading reaches it, so a battery that ramps slower than evSettle no longer gets a second rise on top of an order the meter cannot show yet. On a meter with phase currents but no phase power while the total exports, a phase counts as export only when the other phases cannot carry the export alone; any other phase counts as import. Before, every phase flipped to export, so a three-phase charge could rise into a phase that imports beside two exporting ones. Co-Authored-By: Claude Opus 5.5 --- .changeset/s2-battery-rise-per-phase.md | 2 +- docs/safety.md | 5 +- .../control/battery_rise_phase_test.go | 64 +++++++++++++++++ go/internal/control/dispatch.go | 72 +++++++++++++++++-- .../golden/early_exit_protections.json | 2 +- 5 files changed, 135 insertions(+), 10 deletions(-) diff --git a/.changeset/s2-battery-rise-per-phase.md b/.changeset/s2-battery-rise-per-phase.md index 306154792..f416fc33a 100644 --- a/.changeset/s2-battery-rise-per-phase.md +++ b/.changeset/s2-battery-rise-per-phase.md @@ -2,4 +2,4 @@ "ftw": patch --- -A battery charge rise now fits each phase before FTW sends it. The room is the fuse's working limit less the phase's reading and less FTW's own rises the meter does not show yet: the car's raises and the battery's earlier orders. The car's raise comes first. This stops the battery from charging into a phase the meter shows full, and from stacking a second rise on one the meter has not shown yet, which pushed a phase over the fuse and made the charge rise and fall around the limit. Holding a charge is never a rise, so this check never cuts a charge. +A battery charge rise now fits each phase before FTW sends it. The room is the fuse's working limit less the phase's reading and less FTW's own rises the meter does not show yet: the car's raises and the battery's earlier orders. The car's raise comes first. A battery order stays counted until the meter has had time to show the battery's own reading of it, so a battery that ramps slowly does not get a second rise on top. On a meter that reports phase currents without phase power, a phase counts as export only when the other phases cannot carry the exported total alone; any other phase counts as import. Near the line this keeps the battery from charging into a phase the meter shows full, and from stacking a rise on one the meter has not shown yet, which pushed a phase over the fuse and made the charge rise and fall around the limit. Holding a charge is never a rise, so this check never cuts a charge. A frozen meter is not covered here. diff --git a/docs/safety.md b/docs/safety.md index 11981bd67..6229cb646 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -66,7 +66,10 @@ tariff import ceiling still applies. A phase's current takes its direction from the same phase's power. It counts as export only when that power shows it clearly and the phases' -powers add up to the meter's total; otherwise it counts as import. An +powers add up to the meter's total; otherwise it counts as import. The +battery's fuse checks make one exception for a meter with no phase power +while the total exports: a phase counts as export when the other phases' +currents cannot carry that export alone. An exporting phase is never cut for: less EV load would raise its export. A rise or start on it reads its current as it is, without its heat. diff --git a/go/internal/control/battery_rise_phase_test.go b/go/internal/control/battery_rise_phase_test.go index 290f51c5c..522880bd9 100644 --- a/go/internal/control/battery_rise_phase_test.go +++ b/go/internal/control/battery_rise_phase_test.go @@ -170,3 +170,67 @@ func TestBatteryRiseLeavesRoomForUnshownCarRaise(t *testing.T) { }) } } + +// A meter that reports current magnitudes and no phase power, as Tibber +// Pulse does (l1_a..l3_a only). L1 reads 15.5 A, L2 and L3 8.7 A, and the +// total exports 1300 W. L2 and L3 alone can carry that export, so L1 may be +// a load importing 15.5 A beside them, and a three-phase charge rise adds to +// it: the order stays at or below (16 − 15.5) × 690 W. When the currents add +// up to the export, every phase must export, and charge may soak it up. +func TestBatteryRiseMagnitudeOnlyMeter(t *testing.T) { + st := riseState(3000) + sent(st, "bat", 0) + store := phaseDirectionStore(t, -1300, 0, map[string]any{"l1_a": 15.5, "l2_a": 8.7, "l3_a": 8.7}) + if got, limit := batteryOrder(t, store, st), (16-15.5)*690; got > limit+1e-6 { + t.Fatalf("order %.0f W: L1 would reach %.2f A if it imports; want <= %.0f W", got, 15.5+got/690, limit) + } + + st = riseState(3000) + sent(st, "bat", 0) + store = phaseDirectionStore(t, -12075, 0, map[string]any{"l1_a": 17.5, "l2_a": 17.5, "l3_a": 17.5}) + if got := batteryOrder(t, store, st); math.Abs(got-3000) > 1e-6 { + t.Fatalf("order %.0f W with every phase exporting 17.5 A; want the whole 3000 W ask", got) + } +} + +// A battery that ramps 50 W a tick toward its order, so it has not reached +// an order older than evSettle, and a meter that shows the battery two ticks +// after the battery reads it. The order counts as unshown until evSettle +// after the battery reads it: L3 never passes the 16 A line and the order +// never falls. +func TestBatteryRiseSlowBatteryLaggingMeter(t *testing.T) { + house := [3]float64{13.77, 13.97, 14.27} + st := riseState(3000) + now := st.now() + st.clock = func() time.Time { return now } + var hist, orders []float64 + batW, maxL3 := 0.0, 0.0 + for tick := 0; tick < 120; tick++ { + meterBatW := 0.0 + if n := len(hist); n >= 2 { + meterBatW = hist[n-2] + } + var a [3]float64 + for i := range a { + a[i] = house[i] + meterBatW/690 + } + order := batteryOrder(t, riseStore(t, a, batW), st) + orders = append(orders, math.Round(order)) + maxL3 = math.Max(maxL3, house[2]+batW/690) + hist = append(hist, batW) + batW = math.Min(order, batW+50) + now = now.Add(2 * time.Second) + } + if maxL3 > 16+1e-3 { + t.Errorf("L3 reached %.3f A, over the 16 A line", maxL3) + } + for i := 1; i < len(orders); i++ { + if orders[i] < orders[i-1]-0.5 { + t.Errorf("tick %d: order fell %.0f -> %.0f W", i, orders[i-1], orders[i]) + break + } + } + if t.Failed() { + t.Logf("orders %v", orders) + } +} diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 7c8ba79a6..593a4fb5f 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -398,6 +398,9 @@ type State struct { // batteryCommands and evLimit.offers are our own recent commands, the // only device input the EV hard limit and the EV/battery split use. batteryCommands map[string]commandLog + // batteryReadings is each battery's own reading at our last commands, + // for the power the site meter may not show yet (unshownBatteryW). + batteryReadings map[string]commandLog // batteryFuseRelief is the discharge the fuse guard added to each // battery's last dispatched command (forceFuseDischarge). It ends once // the site fits without it, so an EV rise or start never counts it as @@ -2434,6 +2437,7 @@ func applyDispatchSafetyPipeline( recordDispatchTargets(targets, state, opts.updatePrevTargets, opts.recordDispatch) if opts.recordDispatch { state.recordFuseRelief(beforeRelief, targets) + state.recordBatteryReadings(targets, store) } return targets } @@ -3903,8 +3907,12 @@ func livePhaseSample(store *telemetry.Store, driver string, meter *telemetry.Der // signedPhasesA is the sample's phase currents, import positive // (phaseCurrentsA). When no phase reads as export while the total does, the -// meter gives no direction per phase, and every phase takes the total's. -// false without usable phase currents. +// meter gives no direction per phase. A phase then counts as export only +// when it must: when the other phases' currents, at most V × A of real power +// each, cannot carry the exported total on their own (tolerance as in +// phasePowersAddUp). A phase that may import counts as import, +// phaseCurrentsA's safe side, since a reactive load can draw its whole +// current beside exporting phases. false without usable phase currents. func (p phaseSample) signedPhasesA(state *State) ([]float64, bool) { if p.meter == nil || state == nil { return nil, false @@ -3914,22 +3922,72 @@ func (p phaseSample) signedPhasesA(state *State) ([]float64, bool) { return nil, false } exports := p.totalW < 0 + var sumW float64 for _, a := range amps { exports = exports && a >= 0 + sumW += a * state.SiteFuseVoltage } - if exports { - for i := range amps { - amps[i] = -amps[i] + if !exports { + return amps, true + } + tolW := math.Max(250, sumW/10) + for i, a := range amps { + if sumW-a*state.SiteFuseVoltage < -p.totalW-tolW { + amps[i] = -a } } return amps, true } +// unshownBatteryW is the batteries' increase the site meter may not show +// yet: each battery's latest order less the least the meter may show of it, +// the lowest order of the last evSettle (as pendingBatteryCommandsW) or the +// lowest of the battery's own readings over that time, the current one +// included. A battery that ramps slower than evSettle has not reached an +// order older than evSettle, so the order stays unshown until evSettle after +// the battery reads it. +func (s *State) unshownBatteryW(now time.Time, store *telemetry.Store) float64 { + var unshown float64 + for driver, log := range s.batteryCommands { + low, _, latest, ok := log.span(now) + if !ok { + continue + } + if read, _, _, seen := s.batteryReadings[driver].span(now); seen { + low = math.Min(low, read) + } + if r := store.Get(driver, telemetry.DerBattery); r != nil { + low = math.Min(low, r.RawW) + } + unshown += math.Max(0, latest-low) + } + return unshown +} + +// recordBatteryReadings logs each dispatched battery's reading for +// unshownBatteryW. +func (s *State) recordBatteryReadings(targets []DispatchTarget, store *telemetry.Store) { + if store == nil { + return + } + now := s.now() + for _, t := range targets { + r := store.Get(t.Driver, telemetry.DerBattery) + if r == nil { + continue + } + if s.batteryReadings == nil { + s.batteryReadings = map[string]commandLog{} + } + s.batteryReadings[t.Driver] = s.batteryReadings[t.Driver].add(now, r.RawW) + } +} + // fitBatteryRisesToFuse keeps battery charge rises inside the room the site // meter shows on its busiest phase: the working limit (fuseLineA) less the // phase's reading and less our own rises that reading may not show yet, the // chargers' offers (counted as evShownW does) and the batteries' orders -// (pendingBatteryCommandsW). A three-phase battery puts its power evenly on +// (unshownBatteryW). A three-phase battery puts its power evenly on // every phase. A rise is charge above the battery's latest order, or above // its reading before our first order. Holding an order is never a rise, and // a discharge we end uses room first. Nothing here cuts below the hold: an @@ -3950,7 +4008,7 @@ func fitBatteryRisesToFuse(targets []DispatchTarget, store *telemetry.Store, sta } now := state.now() perA := state.SiteFuseVoltage * float64(state.SiteFusePhases) - unshownA := state.pendingBatteryCommandsW(now) / perA + unshownA := state.unshownBatteryW(now, store) / perA for _, c := range state.evLimit.offers { _, a := c.log.unshown(now, c.batterySettle()) unshownA += a // A one-phase offer may land on any phase. diff --git a/go/internal/control/testdata/golden/early_exit_protections.json b/go/internal/control/testdata/golden/early_exit_protections.json index e1147757e..c8e9929d2 100644 --- a/go/internal/control/testdata/golden/early_exit_protections.json +++ b/go/internal/control/testdata/golden/early_exit_protections.json @@ -775,7 +775,7 @@ "fuse_hold_latched": false, "fuse_hold_max_charge_w": 0, "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 2300, + "per_phase_overage_w": 2070, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, From a6bdd9e5a66dee3886d5e290657f0798b28d28c5 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 23:33:06 +0100 Subject: [PATCH 11/34] fix(control): the car comes before battery charge With battery_covers_ev off, dispatch took the car out of the meter reading before the battery decided. The battery then saw the PV the car was using as export and charged from it while the site imported for the car (rig seeds 207535, 207539, 207530: about 10 kW import). The battery now charges only from the surplus the meter shows after the car. Where the house alone has surplus, the battery sees the meter with the car included, and any discharge stops at once, since it would feed the car. A planned PV charge on the energy path takes only the surplus the car leaves; a planned grid purchase keeps its charge. Cover off still never discharges the battery for the car. Co-Authored-By: Claude Opus 5.5 --- .changeset/p1-car-before-battery-charge.md | 5 + go/internal/control/control_test.go | 48 +++-- go/internal/control/dispatch.go | 47 +++-- .../control/ev_car_before_battery_test.go | 183 ++++++++++++++++++ go/internal/control/ev_site_test.go | 19 +- .../testdata/golden/seeded_ev_boost.json | 20 +- .../control/testdata/golden/slew_limiter.json | 6 +- 7 files changed, 276 insertions(+), 52 deletions(-) create mode 100644 .changeset/p1-car-before-battery-charge.md create mode 100644 go/internal/control/ev_car_before_battery_test.go diff --git a/.changeset/p1-car-before-battery-charge.md b/.changeset/p1-car-before-battery-charge.md new file mode 100644 index 000000000..9895c3b79 --- /dev/null +++ b/.changeset/p1-car-before-battery-charge.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The car comes before home battery charging, also when the battery does not cover the car. The battery now charges only from the solar surplus the car leaves. Before, with battery cover off, FTW took the car out of the meter reading, so the battery charged from solar the car was using while the site imported up to 10 kW. Battery cover off still means the battery never discharges into the car. A planned grid purchase keeps its charge. diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index dc9a400d5..6a2f97f89 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -4063,12 +4063,23 @@ func TestPlannerSelfIdleGateLeavesSurplusOnlyEVReserve(t *testing.T) { st.MinDispatchIntervalS = 0 st.SlotDirective = func(time.Time) (SlotDirective, bool) { return dir, true } - targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) - if len(targets) != 1 { - t.Fatalf("want 1 target, got %d", len(targets)) + // The PI may take a few ticks to get there; it must never pass it. + var last float64 + for i := 0; i < 10; i++ { + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + if len(targets) != 1 { + t.Fatalf("tick %d: want 1 target, got %d", i, len(targets)) + } + last = targets[0].TargetW + if last > 1000+1 { + t.Fatalf("tick %d: TargetW = %f — battery took the surplus-only EV reserve", i, last) + } + store.Update("ferroamp", telemetry.DerBattery, last, ptrF64(0.5), nil) + store.Update("ferroamp", telemetry.DerMeter, -3000+last, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() } - if math.Abs(targets[0].TargetW-1000) > 1 { - t.Errorf("TargetW = %f — idle gate must leave surplus-only EV reserve; want ~1000", targets[0].TargetW) + if math.Abs(last-1000) > 1 { + t.Errorf("TargetW = %f — idle gate must leave surplus-only EV reserve; want ~1000", last) } } @@ -5497,20 +5508,19 @@ func TestEnergyDispatchClampsDischargeWhenEVActuallyCharging(t *testing.T) { // charge battery, EV is on surplus_only at 2.5 kW with 11 kW max, // and 3 kW is currently exporting at the real meter. // -// dispatch.go:685 subtracts EVChargingW from gridW before the home- -// battery decision so the battery sees "what export would exist -// without the EV", which is 3000 + 2500 = 5500 W in this scenario. -// The reserve cap then carves out reserveRemaining for the EV to -// step up into. +// The car comes before battery charge, so the battery sees the +// export the car leaves: the real 3000 W, not the 5500 W there would +// be without the car. The reserve cap then carves out +// reserveRemaining for the EV to step up into. // // Pre-fix (reserve = full MaxChargeW = 11000): reserveRemaining -// = 11000 - 2500 = 8500. ceiling = 5500 - 8500 = -3000 → 0. Battery +// = 11000 - 2500 = 8500. ceiling = 3000 - 8500 → 0. Battery // idles; surplus dumps to grid. // // Post-fix (reserve = CurrentPowerW + EVRampHeadroomW = 4500): -// reserveRemaining = 4500 - 2500 = 2000. ceiling = 5500 - 2000 = -// 3500. Battery picks up the bulk of the surplus while leaving 2 kW -// of headroom for the EV to ramp up. +// reserveRemaining = 4500 - 2500 = 2000. ceiling = 3000 - 2000 = +// 1000. Battery takes the surplus beyond 2 kW of headroom for the +// EV to ramp up, and none of the car's own 2.5 kW. // // The test sets EVSurplusOnlyReserveW directly to the value // loadpoint.SurplusReserveW would produce; the helper itself is @@ -5542,12 +5552,12 @@ func TestEnergyDispatchAbsorbsSurplusBeyondEVReserveActualDraw(t *testing.T) { t.Fatalf("want 1 target, got %d", len(targets)) } got := targets[0].TargetW - // Want ≈ 3500 W. Allow ±400 W slack for time drift / formula edge. - if got < 3100 { - t.Errorf("TargetW = %.0f W — battery should absorb the surplus that the EV's adaptive reserve no longer hoards (want ≈ 3500 W, was 0 pre-fix)", got) + // Want ≈ 1000 W. Allow ±100 W slack for time drift / formula edge. + if got < 900 { + t.Errorf("TargetW = %.0f W — battery should absorb the surplus that the EV's adaptive reserve no longer hoards (want ≈ 1000 W, was 0 pre-fix)", got) } - if got > 3900 { - t.Errorf("TargetW = %.0f W — battery overshoot, must leave EVRampHeadroomW (2 kW) of headroom for EV ramp-up (want ≈ 3500 W)", got) + if got > 1100 { + t.Errorf("TargetW = %.0f W — battery overshoot, must leave the car's draw and EVRampHeadroomW (2 kW) of headroom for EV ramp-up (want ≈ 1000 W)", got) } } diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 593a4fb5f..b415ffdbf 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -370,7 +370,8 @@ type State struct { EVCurtailHeadroomW float64 // BatteryCoversEV lets the battery discharge into the EV. Off, EV // load is taken out of the meter before the PI so the battery does - // not feed the car. + // not feed the car. Either way the car comes before battery charge: + // the battery charges only from the surplus the car leaves. BatteryCoversEV bool // BatteryBoostEVChargingW is the live charging draw belonging to @@ -1489,12 +1490,16 @@ func ComputeDispatch( // discharge keeps its old opt-in guard so Self does not fill the home // battery from a car by default. gridW := rawGridW - if state.Mode != ModeSelfConsumption && !state.BatteryCoversEV { - gridW -= state.uncoveredEVChargingW() - } if !state.BatteryCoversEV { gridW -= vehicleFlow.V2XDischargeW } + // The car comes before battery charge: charge comes only from what the + // meter shows left after the car. The car leaves the signal below so + // the battery never discharges for it. + chargeGridW := gridW + if state.Mode != ModeSelfConsumption && !state.BatteryCoversEV { + gridW -= state.uncoveredEVChargingW() + } // ---- Gather online batteries ---- batteries := make([]batteryInfo, 0, len(driverCapacities)) @@ -1567,6 +1572,14 @@ func ComputeDispatch( for _, b := range onlineBats { currentTotal += b.currentW } + // Without the car the meter may show export the car is using. Where + // the battery would charge, it sees the meter with the car instead: + // it charges only from the surplus after the car, and never turns to + // discharge for it. + noCarGridW := gridW + if gridW < chargeGridW { + gridW = math.Max(gridW, math.Min(chargeGridW, currentTotal)) + } surplus := newSurplusAccounting(rawGridW, gridW, currentTotal, state) // passive_arbitrage idle slot + live PV surplus: don't absorb. Forecasts @@ -1749,15 +1762,19 @@ func ComputeDispatch( // Leave PV for a surplus-only EV. A planned import of at least // 100 W keeps its charge. Discharge is left alone. - if state.EVSurplusOnlyReserveW > 0 && targetTotalW > 0 { - deliberateGridCharge := currentDirective.HasPlannedGridW && currentDirective.PlannedGridW >= 100 - if !deliberateGridCharge { - ceiling := surplus.chargeCeilingAfterEVReserveW() - if targetTotalW > ceiling { - targetTotalW = ceiling - } + deliberateGridCharge := currentDirective.HasPlannedGridW && currentDirective.PlannedGridW >= 100 + if state.EVSurplusOnlyReserveW > 0 && targetTotalW > 0 && !deliberateGridCharge { + ceiling := surplus.chargeCeilingAfterEVReserveW() + if targetTotalW > ceiling { + targetTotalW = ceiling } } + // The car before battery charge: a charge the plan makes from PV + // takes only the surplus the car leaves. + pvCharge := currentDirective.HasPlannedGridW && !deliberateGridCharge + if targetTotalW > 0 && pvCharge && gridW > noCarGridW { + targetTotalW = math.Min(targetTotalW, currentTotal-gridW) + } // Charge slots only. If PV drops, the energy budget would hold the // planned charge on grid import until the replan, about 10 minutes @@ -1765,7 +1782,8 @@ func ComputeDispatch( // Below 100 W is meter noise. if currentDirective.HasPlannedGridW && targetTotalW > 0 { const planGridDeadband = 100.0 - projectedGridW := gridW + (targetTotalW - currentTotal) + // The plan's grid leaves out the car the battery does not cover. + projectedGridW := noCarGridW + (targetTotalW - currentTotal) gridErr := projectedGridW - currentDirective.PlannedGridW if gridErr > planGridDeadband { adjusted := targetTotalW - gridErr @@ -1989,6 +2007,11 @@ func ComputeDispatch( "mode", string(effectiveMode)) } totalCorrection = allowed - currentTotal + // The house alone has surplus, so a discharge would feed the + // car. Stop it now rather than over several PI steps. + if gridW > noCarGridW && currentTotal+totalCorrection < 0 { + totalCorrection = -currentTotal + } // Surplus-only EV reserve cap: stack on top of the meter clamp. // Mirror the energy-path cap so the battery target on the diff --git a/go/internal/control/ev_car_before_battery_test.go b/go/internal/control/ev_car_before_battery_test.go new file mode 100644 index 000000000..dbd687e02 --- /dev/null +++ b/go/internal/control/ev_car_before_battery_test.go @@ -0,0 +1,183 @@ +package control + +import ( + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// runCarBeforeBattery drives planner_self in a charge slot against a plant +// whose meter is the site without the battery plus the battery's last +// target. siteW is house plus car minus PV, site-signed. It returns every +// target the battery received. +func runCarBeforeBattery(t *testing.T, coversEV bool, siteW, carW, startBatteryW float64) []float64 { + t.Helper() + now := time.Now() + dir := SlotDirective{ + SlotStart: now, + SlotEnd: now.Add(15 * time.Minute), + BatteryEnergyWh: 350, // a PV charge slot: 1.4 kW for 15 minutes + Strategy: "self_consumption", + } + store := seedStore(siteW+startBatteryW, []struct { + name string + currentW, soc float64 + }{ + {"ferroamp", startBatteryW, 0.5}, + }) + st := NewState(0, 50, "ferroamp") + st.Mode = ModePlannerSelf + st.UseEnergyDispatch = true + st.SlotDirective = func(time.Time) (SlotDirective, bool) { return dir, true } + st.EVChargingW = carW + st.BatteryCoversEV = coversEV + st.SlewRateW = 100000 + st.MinDispatchIntervalS = 0 + const fuseW = 3 * 25 * 230 // keep the fuse out of this policy check + batteryW := startBatteryW + var got []float64 + for i := 0; i < 20; i++ { + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), fuseW) + if len(targets) == 1 { + batteryW = targets[0].TargetW + got = append(got, batteryW) + } + store.Update("ferroamp", telemetry.DerBattery, batteryW, ptrF64(0.5), nil) + store.Update("ferroamp", telemetry.DerMeter, siteW+batteryW, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + } + return got +} + +func lastOr(v []float64, def float64) float64 { + if len(v) == 0 { + return def + } + return v[len(v)-1] +} + +// Seeds 207535, 207539 and 207530: PV 5 kW, house 3.6 kW, car 11 kW. The +// site imports about 10 kW, yet with battery_covers_ev off the battery +// charged from the PV the car was using. The car comes first: the battery +// charges only from what is left after the car, and is never discharged +// for the car. +func TestCarBeforeBatteryChargeWhenCoverIsOff(t *testing.T) { + const pvW, houseW, carW = 5000.0, 3611.0, 11040.0 + got := runCarBeforeBattery(t, false, houseW+carW-pvW, carW, 632) + for i, w := range got { + if w > 632+1 { + t.Fatalf("tick %d: battery charge rose to %.0f W while the site imports for the car", i, w) + } + if w < -1 { + t.Fatalf("tick %d: battery discharged %.0f W into the car with battery_covers_ev off", i, w) + } + } + if final := lastOr(got, 632); math.Abs(final) > 1 { + t.Errorf("battery settled at %.0f W, want 0 W: the car uses all PV", final) + } +} + +// With PV beyond house and car, the battery takes exactly the surplus the +// car leaves. +func TestCarBeforeBatteryChargesOnlyTheSurplusAfterTheCar(t *testing.T) { + for _, coversEV := range []bool{false, true} { + const pvW, houseW, carW = 16000.0, 3000.0, 11040.0 + got := runCarBeforeBattery(t, coversEV, houseW+carW-pvW, carW, 0) + const wantW = pvW - houseW - carW + for i, w := range got { + if w > wantW+60 { + t.Fatalf("covers_ev=%v tick %d: battery charge %.0f W takes PV the car uses (surplus after car %.0f W)", coversEV, i, w, wantW) + } + } + if final := lastOr(got, 0); math.Abs(final-wantW) > 60 { + t.Errorf("covers_ev=%v: battery settled at %.0f W, want about %.0f W", coversEV, final, wantW) + } + } +} + +// battery_covers_ev off still covers the house, never the car. +func TestCarBeforeBatteryCoverOffDischargesForHouseOnly(t *testing.T) { + const pvW, houseW, carW = 1000.0, 3000.0, 11040.0 + got := runCarBeforeBattery(t, false, houseW+carW-pvW, carW, 0) + const wantW = pvW - houseW // -2000 W: the house, not the car + for i, w := range got { + if w < wantW-60 { + t.Fatalf("tick %d: battery discharged %.0f W, past the house's %.0f W", i, w, wantW) + } + } + if final := lastOr(got, 0); math.Abs(final-wantW) > 60 { + t.Errorf("battery settled at %.0f W, want about %.0f W", final, wantW) + } +} + +// battery_covers_ev on lets the battery feed the car instead of charging. +func TestCarBeforeBatteryCoverOnDischargesForTheCar(t *testing.T) { + const pvW, houseW, carW = 5000.0, 3611.0, 11040.0 + got := runCarBeforeBattery(t, true, houseW+carW-pvW, carW, 632) + for i, w := range got { + if w > 632+1 { + t.Fatalf("tick %d: battery charge rose to %.0f W while the site imports", i, w) + } + } + if final := lastOr(got, 632); final > -1000 { + t.Errorf("battery settled at %.0f W, want a discharge that covers the car", final) + } +} + +// The energy path follows the same rule: a planned PV charge takes only +// the surplus the car leaves, while a planned purchase keeps its charge. +func TestCarBeforeBatteryEnergyPathPVChargeOnly(t *testing.T) { + const pvW, houseW, carW = 8000.0, 500.0, 6000.0 + for _, tc := range []struct { + name string + plannedW float64 + wantW float64 + }{ + {"pv charge", -1000, pvW - houseW - carW}, + {"planned purchase", 2000, 4000}, + } { + for _, coversEV := range []bool{false, true} { + now := time.Now() + dir := SlotDirective{ + SlotStart: now, + SlotEnd: now.Add(15 * time.Minute), + BatteryEnergyWh: 1000, // 4 kW for 15 minutes + Strategy: "cheap", + PlannedGridW: tc.plannedW, + HasPlannedGridW: true, + } + store := seedStore(houseW+carW-pvW, []struct { + name string + currentW, soc float64 + }{ + {"ferroamp", 0, 0.5}, + }) + st := NewState(0, 50, "ferroamp") + st.Mode = ModePlannerCheap + st.UseEnergyDispatch = true + st.SlotDirective = func(time.Time) (SlotDirective, bool) { return dir, true } + st.EVChargingW = carW + st.BatteryCoversEV = coversEV + st.SlewRateW = 100000 + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 3*25*230) + if len(targets) != 1 { + t.Fatalf("%s covers_ev=%v: want 1 target, got %+v", tc.name, coversEV, targets) + } + got := targets[0].TargetW + if coversEV { + // The plan's grid check sees the car on the meter and + // backs the charge off further; only the car-first bound + // matters here. + if tc.plannedW < 100 && got > tc.wantW+1 { + t.Errorf("%s covers_ev=true: battery target %.0f W takes PV the car uses (want at most %.0f W)", tc.name, got, tc.wantW) + } + continue + } + if math.Abs(got-tc.wantW) > 1 { + t.Errorf("%s covers_ev=false: battery target %.0f W, want %.0f W", tc.name, got, tc.wantW) + } + } + } +} diff --git a/go/internal/control/ev_site_test.go b/go/internal/control/ev_site_test.go index 6a079b0d8..8458c3998 100644 --- a/go/internal/control/ev_site_test.go +++ b/go/internal/control/ev_site_test.go @@ -178,10 +178,12 @@ func TestEVSiteScheduledEVMayImportOnCheapNight(t *testing.T) { } } -func TestEVSiteSurplusOnlyDoesNotClaimPVSoakWhenTogetherTheyWouldImport(t *testing.T) { +func TestEVSiteSurplusOnlyTakesPVSoakWithoutImport(t *testing.T) { // Battery plan 4 kW of 7.5 kW leftover — soak, not Pixii buying. - // The old live reader treated any meter import as grid-charge and - // offered the full leftover, so a 3Φ snap held while soak+EV imported. + // The car comes before battery charge (owner decision P1, 27 Oct + // 2026), so the car may take the soak; the battery takes what the + // car leaves. Together they never import, and the battery never + // discharges for the car. start := evComboSiteStart() site := newSiteClock(t, evSiteConfig{ Start: start, @@ -191,14 +193,17 @@ func TestEVSiteSurplusOnlyDoesNotClaimPVSoakWhenTogetherTheyWouldImport(t *testi PVW: evComboPVW, }) site.run(12) - const soakHeadroom = 3500.0 // leftover 7500 − soak 4000 for _, rec := range site.ticks { if rec.N < 4 { continue } - if rec.EVW > soakHeadroom+loadpoint.SitePowerEpsW { - t.Fatalf("tick %d: surplus-only EV %.0f W claimed PV-soak (headroom %.0f); ticks=%s", - rec.N, rec.EVW, soakHeadroom, site.dumpTicks()) + if rec.GridW > loadpoint.SitePowerEpsW { + t.Fatalf("tick %d: surplus-only EV %.0f W and battery %.0f W import %.0f W; ticks=%s", + rec.N, rec.EVW, rec.BatW, rec.GridW, site.dumpTicks()) + } + if rec.BatW < -loadpoint.SitePowerEpsW { + t.Fatalf("tick %d: battery discharged %.0f W beside surplus-only EV %.0f W; ticks=%s", + rec.N, rec.BatW, rec.EVW, site.dumpTicks()) } } } diff --git a/go/internal/control/testdata/golden/seeded_ev_boost.json b/go/internal/control/testdata/golden/seeded_ev_boost.json index f4fd9c77b..89eadd288 100644 --- a/go/internal/control/testdata/golden/seeded_ev_boost.json +++ b/go/internal/control/testdata/golden/seeded_ev_boost.json @@ -1913,12 +1913,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 588, + "target_w": 0, "clamped": false }, { "driver": "sungrow", - "target_w": 588, + "target_w": 0, "clamped": false } ], @@ -1941,9 +1941,9 @@ }, "site_totals": { "battery_current_w": -3117, - "battery_target_sum_w": 1176, + "battery_target_sum_w": 0, "raw_grid_w": -1466, - "projected_grid_w": 2827 + "projected_grid_w": 1651 } }, { @@ -2506,14 +2506,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 5000, - "clamped": true + "target_w": 3950, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, @@ -2531,9 +2529,9 @@ }, "site_totals": { "battery_current_w": 1982, - "battery_target_sum_w": 5000, + "battery_target_sum_w": 3950, "raw_grid_w": -2307, - "projected_grid_w": 711 + "projected_grid_w": -339 } }, { diff --git a/go/internal/control/testdata/golden/slew_limiter.json b/go/internal/control/testdata/golden/slew_limiter.json index 21eec3639..60263128f 100644 --- a/go/internal/control/testdata/golden/slew_limiter.json +++ b/go/internal/control/testdata/golden/slew_limiter.json @@ -10653,7 +10653,7 @@ }, { "driver": "sungrow", - "target_w": 635.7936507936507, + "target_w": 0, "clamped": false } ], @@ -10678,9 +10678,9 @@ }, "site_totals": { "battery_current_w": -2923, - "battery_target_sum_w": 491.7936507936507, + "battery_target_sum_w": -144, "raw_grid_w": -1903, - "projected_grid_w": 1511.7936507936506 + "projected_grid_w": 876 } }, { From eb553dd7ff2a11cfaa569ee6dd4a64ce9ca476b9 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 27 Oct 2026 23:52:33 +0100 Subject: [PATCH 12/34] fix(control): peak shaving drops a running charge for the car Manual peak_shaving gave zero error for any import under the peak limit, so a battery charge that was running when the car started held while the site imported for the car, with battery_covers_ev on or off. When the site imports while the car and the battery both charge, the charge now comes down by the import, to 0 at most. Under the peak limit the battery still never discharges. Co-Authored-By: Claude Opus 5.5 --- .changeset/p1-car-before-battery-charge.md | 2 +- go/internal/control/dispatch.go | 6 ++ .../control/ev_car_before_battery_test.go | 68 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/.changeset/p1-car-before-battery-charge.md b/.changeset/p1-car-before-battery-charge.md index 9895c3b79..ed9f223d8 100644 --- a/.changeset/p1-car-before-battery-charge.md +++ b/.changeset/p1-car-before-battery-charge.md @@ -2,4 +2,4 @@ "ftw": patch --- -The car comes before home battery charging, also when the battery does not cover the car. The battery now charges only from the solar surplus the car leaves. Before, with battery cover off, FTW took the car out of the meter reading, so the battery charged from solar the car was using while the site imported up to 10 kW. Battery cover off still means the battery never discharges into the car. A planned grid purchase keeps its charge. +The car comes before home battery charging, also when the battery does not cover the car. The battery now charges only from the solar surplus the car leaves. Before, with battery cover off, FTW took the car out of the meter reading, so the battery charged from solar the car was using while the site imported up to 10 kW. Battery cover off still means the battery never discharges into the car. A planned grid purchase keeps its charge. In manual peak shaving, a battery charge that was running when the car started now comes off while the site imports, instead of holding under the peak limit. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index b415ffdbf..f671cbf6b 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -1877,6 +1877,12 @@ func ComputeDispatch( errW = gridW - state.PeakLimitW } else if gridW < 0 { errW = gridW // exporting → charge with surplus + } else if chargeGridW > 0 && currentTotal > 0 && state.EVChargingW > evActiveThresholdW { + // The car before battery charge: the site imports while + // the car and the battery both charge. Take the charge + // down by the import, to 0 at most; under the peak limit + // the battery does not discharge. + errW = math.Min(chargeGridW, currentTotal) } else { errW = 0 } diff --git a/go/internal/control/ev_car_before_battery_test.go b/go/internal/control/ev_car_before_battery_test.go index dbd687e02..bce40ca2d 100644 --- a/go/internal/control/ev_car_before_battery_test.go +++ b/go/internal/control/ev_car_before_battery_test.go @@ -181,3 +181,71 @@ func TestCarBeforeBatteryEnergyPathPVChargeOnly(t *testing.T) { } } } + +// runPeakShavingCar drives manual peak_shaving against the same plant as +// runCarBeforeBattery. The peak limit sits far above the site, so the +// import the car causes lands in peak_shaving's dead zone. +func runPeakShavingCar(t *testing.T, coversEV bool, siteW, carW, startBatteryW float64) []float64 { + t.Helper() + store := seedStore(siteW+startBatteryW, []struct { + name string + currentW, soc float64 + }{ + {"ferroamp", startBatteryW, 0.5}, + }) + st := NewState(0, 50, "ferroamp") + st.Mode = ModePeakShaving + st.PeakLimitW = 30000 + st.EVChargingW = carW + st.BatteryCoversEV = coversEV + st.SlewRateW = 100000 + st.MinDispatchIntervalS = 0 + const fuseW = 3 * 25 * 230 + batteryW := startBatteryW + var got []float64 + for i := 0; i < 20; i++ { + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), fuseW) + if len(targets) == 1 { + batteryW = targets[0].TargetW + } + got = append(got, batteryW) + store.Update("ferroamp", telemetry.DerBattery, batteryW, ptrF64(0.5), nil) + store.Update("ferroamp", telemetry.DerMeter, siteW+batteryW, nil, nil) + store.DriverHealthMut("ferroamp").RecordSuccess() + } + return got +} + +// The 207535 shape in manual peak_shaving. The battery was charging 871 W +// from the house surplus when the car started. The site now imports for +// the car, inside peak_shaving's dead zone, so nothing used to cut the +// charge. The car comes first: the charge goes, and the battery never +// discharges, because the import is under the peak limit. +func TestCarBeforeBatteryPeakShavingDropsARunningCharge(t *testing.T) { + const pv, house, car = 5000.0, 3611.0, 11040.0 + for _, cover := range []bool{false, true} { + got := runPeakShavingCar(t, cover, house+car-pv, car, 871) + for i, w := range got { + if w < -1 { + t.Fatalf("cover=%v tick %d: battery discharges %.0f W under the peak limit: %v", cover, i, w, got) + } + } + if final := lastOr(got, 871); final > 1 { + t.Fatalf("cover=%v: battery still charges %.0f W while the site imports %.0f W for the car: %v", + cover, final, house+car-pv+final, got) + } + } +} + +// With more PV than house and car together, peak_shaving still charges +// from what the car leaves: 18000 − 3611 − 11040 = 3349 W. +func TestCarBeforeBatteryPeakShavingChargesTheSurplusAfterTheCar(t *testing.T) { + const pv, house, car = 18000.0, 3611.0, 11040.0 + want := pv - house - car + for _, cover := range []bool{false, true} { + got := runPeakShavingCar(t, cover, house+car-pv, car, 0) + if final := lastOr(got, 0); math.Abs(final-want) > 60 { + t.Fatalf("cover=%v: battery settled at %.0f W, want the %.0f W left after the car: %v", cover, final, want, got) + } + } +} From ef7ca516382bc6520c6e55423924281739ebbfd1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 00:06:56 +0100 Subject: [PATCH 13/34] fix(control): after a restart the battery credits the car the meter shows After a restart, the 0 A orders FTW sent while the meter was stale counted as the EV base the meter shows. With the car still drawing 8.2 kW from before, a new 6.2 kW order then counted as a 9 A rise to come, and the battery was held in discharge for the car (seed 207532). Until a charger's first order of this run can have landed, the battery side now counts the charger's live measured draw as in the reading, never above the latest order and never above the consumption the site meter shows. The car's own limit does not read it. Co-Authored-By: Claude Opus 5.5 --- .changeset/s4-ev-base-after-restart.md | 5 + .../control/ev_base_after_restart_test.go | 156 ++++++++++++++++++ go/internal/control/ev_battery_headroom.go | 16 +- go/internal/control/ev_fuse_budget.go | 42 ++++- 4 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 .changeset/s4-ev-base-after-restart.md create mode 100644 go/internal/control/ev_base_after_restart_test.go diff --git a/.changeset/s4-ev-base-after-restart.md b/.changeset/s4-ev-base-after-restart.md new file mode 100644 index 000000000..919abe45d --- /dev/null +++ b/.changeset/s4-ev-base-after-restart.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. diff --git a/go/internal/control/ev_base_after_restart_test.go b/go/internal/control/ev_base_after_restart_test.go new file mode 100644 index 000000000..49efc3088 --- /dev/null +++ b/go/internal/control/ev_base_after_restart_test.go @@ -0,0 +1,156 @@ +package control + +import ( + "encoding/json" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/sitesim" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// restartFixture is seed 207532 just after Core restarted: a 16 A site with +// no margin, a battery FTW found discharging 5 kW, and a charger whose first +// logged offers of this run are 0 A, sent while the meter was stale, then +// 6,210 W (9 A on three phases) 2 s ago. The charger takes 40 s to apply an +// order, so the car still draws what it drew before the restart. evW is +// what the charger measures; the site meter reads gridW with the phase +// currents phaseA, and the PV inverter pvW. +func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pvW, evW float64) (*telemetry.Store, *State) { + t.Helper() + store := telemetry.NewStore() + raw, _ := json.Marshal(map[string]any{"l1_a": phaseA[0], "l2_a": phaseA[1], "l3_a": phaseA[2]}) + store.Update("meter", telemetry.DerMeter, gridW, nil, raw) + store.RecordDriverSuccess("meter") + store.Update("sungrow", telemetry.DerBattery, batteryW, ptrF64(.3), nil) + store.RecordDriverSuccess("sungrow") + if pvW != 0 { + store.Update("pv", telemetry.DerPV, pvW, nil, nil) + store.RecordDriverSuccess("pv") + } + store.Update("charger", telemetry.DerEV, evW, nil, nil) + store.RecordDriverSuccess("charger") + + state := NewState(0, 0, "meter") + now := time.Now() + state.clock = func() time.Time { return now } + state.SiteFuseAmps, state.SiteFuseVoltage, state.SiteFusePhases, state.SiteFuseSafetyA = 16, 230, 3, 0 + state.DriverLimits = map[string]PowerLimits{"sungrow": {MaxChargeW: 5000, MaxDischargeW: 5000}} + state.batteryCommands = map[string]commandLog{"sungrow": commandLog{}.add(now.Add(-2*time.Second), batteryW)} + state.evFuseDemands = []evFuseDemand{{"garage", 6210, "3p"}} + state.EVCommandedW = 6210 + found := sentCommand{at: now.Add(-6 * time.Second), w: 0, a: 0, n: 3} + state.evLimit.set = true + state.evLimit.offers = map[string]chargerOffers{"garage": { + log: commandLog{found, {at: now.Add(-4 * time.Second), w: 0, a: 0, n: 3}, {at: now.Add(-2 * time.Second), w: 6210, a: 9, n: 3}}, + settle: evUnknownSettle, found: found, driver: "charger", + }} + return store, state +} + +// C10 (27 Oct triage, seed 207532): after a restart, the 0 A offers sent +// while the meter was stale counted as the EV base the meter shows, while +// the meter showed the car at 8.2 kW. The new 6.2 kW order then counted as +// a 9 A rise to come, and the battery was held in discharge for the car +// with battery_covers_ev off, down toward its floor. The car's measured +// draw is in the reading: the 6.2 kW order is a cut, and ending the +// discharge needs no room for it. +func TestEVBaseAfterRestartCreditsMeasuredDraw(t *testing.T) { + store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + if got := targetW(out, "sungrow"); got < -1 { + t.Fatalf("battery held at %.0f W for a car the meter already shows at 8.2 kW; want 0 W", got) + } +} + +// The credit stays on the safe side. A charger reading the meter cannot +// contain counts only up to the consumption the meter shows: here the +// charger claims 8.2 kW, but the site consumes 680 W, so 5,530 W of the +// 6,210 W order is still a rise to come, and the battery keeps the +// discharge that the busiest phase has no room to end. +func TestEVBaseAfterRestartCreditStaysInsideTheMeter(t *testing.T) { + store, state := restartFixture(t, 680, [3]float64{4.97, -1, -1}, 0, 0, 8222) + state.batteryCommands["sungrow"] = commandLog{}.add(state.now().Add(-time.Second), -5000) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + riseA := (6210 - 680) / 690.0 + want := -(5000 - (16-4.97-riseA)*690) + if got := targetW(out, "sungrow"); math.Abs(got-want) > 1 { + t.Fatalf("battery %.0f W, want %.0f W: the charger's reading counts only up to the meter's 680 W", got, want) + } +} + +// The credit never exceeds the latest offer, needs a live charger reading, +// and ends once the charger's first offer of this run has had its settle +// time: then the offers alone say what the meter shows. +func TestEVFoundDrawWBounds(t *testing.T) { + for _, tc := range []struct { + name string + edit func(*telemetry.Store, *State) + want float64 + }{ + {"latest offer bounds the draw", func(*telemetry.Store, *State) {}, 6210}, + {"measured draw below the offer", func(store *telemetry.Store, _ *State) { + store.Update("charger", telemetry.DerEV, 4000, nil, nil) + }, 4000}, + {"charger offline", func(store *telemetry.Store, _ *State) { + store.DriverHealthMut("charger").SetOffline() + }, 0}, + {"no restart", func(_ *telemetry.Store, state *State) { + c := state.evLimit.offers["garage"] + c.found = sentCommand{} + state.evLimit.offers["garage"] = c + }, 0}, + {"first offer settled", func(_ *telemetry.Store, state *State) { + c := state.evLimit.offers["garage"] + c.found.at = state.now().Add(-c.settle) + state.evLimit.offers["garage"] = c + }, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) + tc.edit(store, state) + if got := state.evFoundDrawW(store, state.now(), 1e6, 0); math.Abs(got-tc.want) > 1e-6 { + t.Fatalf("credit %.0f W, want %.0f W", got, tc.want) + } + }) + } +} + +// Seed 207532's shape on the plant: Core restarts beside a car drawing +// 12 A from before, on a charger that takes 40 s to apply an order. For +// its first 6 s the meter is silent and the inverter unreachable, holding +// the 5 kW discharge it had (C1), so Core logs 0 A offers. Then Core asks +// 9 A for the car. PV covers the house, the plan is stale and +// battery_covers_ev is off: the battery ends its discharge as fast as its +// slew allows, and no phase passes the fuse line on the way. +func TestSiteSimRestartEndsDischargeForACarAlreadyDrawing(t *testing.T) { + p := simPlant(simStart) + p.Fuse.RatingA, p.HouseW, p.PVW = 16, houseA(4, 9, 2), -6000 + p.EVSE.Delay, p.EVSE.ReadbackLag = 40*time.Second, 0 + p.EVSE.OfferA, p.EVSE.OfferPhases = 12, 3 + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .3, Latency: 2 * time.Second, W: -5000} + p.Events = []sitesim.Event{ + {Kind: sitesim.MeterSilent, For: 6 * time.Second}, + {Kind: sitesim.DriverOffline, Device: "battery", For: 6 * time.Second}, + } + s := &simSite{p: p, lp: simLoadpoint()} + s.setup = func(s *simSite) { + s.state.Mode = ModePlannerSelf + s.state.SiteFuseSafetyA = 0 + s.ctrl.SetManualHold(s.lp.ID, loadpoint.ManualHold{PowerW: 6210, PhaseMode: "3p", Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) + } + s.boot() + s.run(14 * time.Second) + if s.offerW != 6210 || s.p.EVSE.DrawW != 8280 { + t.Fatalf("not the seed's state: offer %.0f W, car %.0f W", s.offerW, s.p.EVSE.DrawW) + } + if s.batteryCmdW < -1 { + t.Fatalf("battery held at %.0f W 8 s after the meter returned, with the car already in the reading", s.batteryCmdW) + } + s.run(2 * time.Minute) + if s.failure != "" { + t.Fatal(s.failure) + } +} diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 0ea1d3201..2c27e43cd 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -7,16 +7,17 @@ import ( ) // clampEVBatteryHeadroom keeps optional battery charge out of room an EV -// still needs. It reads only the site meter and our own commands; no charger -// reading takes part, and a battery's own reading only bounds how much of a -// discharge we ordered it may keep. The EV still needs its demand less the -// offers the meter surely shows (evShownW). +// still needs. It reads the site meter and our own commands. A battery's own +// reading only bounds how much of a discharge we ordered it may keep. A +// charger's reading counts only before any of its offers of this run has +// surely landed, and only up to what the meter shows (evFoundDrawW). The EV +// still needs its demand less the offers the meter surely shows (evShownW) +// and that draw. func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) []DispatchTarget { if store == nil || state == nil || len(targets) == 0 || fuseMaxW <= 0 { return targets } now := state.now() - offeredW := state.evShownW(now) var demandW float64 phases := 3.0 // No phase placement promise for 1p or auto chargers. for _, demand := range state.evFuseDemands { @@ -28,7 +29,6 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st if len(state.evFuseDemands) == 0 { return targets } - needW := math.Max(0, demandW-offeredW) meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) if meter == nil { return floorPositiveEVBatteryTargets(targets) @@ -37,6 +37,10 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st if !finiteFuseValue(gridW) || !finiteFuseValue(fuseMaxW) { return floorPositiveEVBatteryTargets(targets) } + offeredW := state.evShownW(now) + consumptionW := gridW - sumOnlineSignedW(store, telemetry.DerBattery) - sumOnlineSignedW(store, telemetry.DerPV) - store.SumOnlineV2XW() + offeredW += state.evFoundDrawW(store, now, consumptionW, offeredW) + needW := math.Max(0, demandW-offeredW) latest := make(map[string]float64, len(targets)) lowest := make(map[string]float64, len(targets)) targeted := make(map[string]bool, len(targets)) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 37b108dc8..63c97047a 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -179,6 +179,10 @@ type chargerOffers struct { // charger as 0 before it; a cut takes it as shown, since the reading is // all Core knows of it. found sentCommand + // driver is the charger's driver, whose power reading only the battery + // side reads, and only before any offer of this run has surely landed + // (evFoundDrawW). + driver string // carPhases is how many phases the car draws on, as the loadpoint saw // it on the site meter: 1 or 3, or 0 when unknown. carPhase is the phase // a car on one phase draws on, 1-3. @@ -366,6 +370,42 @@ func (s *State) evShownW(at time.Time) float64 { return w } +// evFoundDrawW is EV draw the battery side may count as shown beyond +// evShownW (shownW) while a charger has no order of this run that has +// surely landed: after a restart or a plug-in, until its first logged offer +// is older than its settle time. Core then does not know which offer the +// charger runs on, and its first offers (often 0 A) say nothing of what the +// meter shows. A car seen drawing in that window is in the reading: each +// such charger counts its live measured draw, the lower of raw and smoothed, +// never above its latest offer, and in all no more than the consumption the +// site meter shows (consumptionW) less shownW. The car's own limit never +// reads this; only how much of our latest offers the battery reserves as a +// rise to come. +func (s *State) evFoundDrawW(store *telemetry.Store, at time.Time, consumptionW, shownW float64) float64 { + if store == nil || !finiteFuseValue(consumptionW) { + return 0 + } + var w float64 + for _, c := range s.evLimit.offers { + n := len(c.log) + if n == 0 || c.found.at.IsZero() || at.Sub(c.found.at) >= c.settle || c.driver == "" { + continue + } + r := store.Get(c.driver, telemetry.DerEV) + if r == nil || !store.DriverHealth(c.driver).TelemetryLive() { + continue + } + drawW := math.Min(r.RawW, r.SmoothedW) + if !finiteFuseValue(drawW) { + continue + } + unshown, _ := c.log.unshown(at, c.batterySettle()) + latest := c.log[n-1].w + w += math.Max(0, math.Min(latest, drawW)-math.Max(0, latest-unshown)) + } + return math.Max(0, math.Min(w, consumptionW-shownW)) +} + // batterySettle is how long the battery side counts a raise of this // charger's as unshown: its declared response time, else evSettle // (evShownW). @@ -466,7 +506,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) continue } c := s.evLimit.offers[point.ID] - c.settle, c.declared, c.carPhases, c.carPhase = evUnknownSettle, false, point.CarPhases, point.CarPhase + c.settle, c.declared, c.carPhases, c.carPhase, c.driver = evUnknownSettle, false, point.CarPhases, point.CarPhase, point.DriverName if r := store.Get(point.DriverName, telemetry.DerEV); r != nil { if declared := telemetry.DeclaredPowerMaxAge(r.Data); declared > 0 { c.settle, c.declared = max(evSettle, declared), true From e53aa4b046e41c1f7cd7e0ee821d8b70f6ff42ee Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 00:33:05 +0100 Subject: [PATCH 14/34] fix(control): credit a found car only from a current charger sample Review of #1926 found two ways the restart credit let the battery end a discharge into room the car's pending rise still needs: - The charger reading counted with no check that it was current. A held reading after a failed poll, or a sample older than the site reading, could show a car that a fast charger had already stopped. The draw now counts only when the last poll succeeded and the charger measured it within one control interval of the site reading. - The consumption bound subtracted the smoothed battery, PV and V2X values, which lag an output that just fell. It now takes the higher of raw and smoothed for each, so the bound stays at or below what the meter shows. New tests pin both, plus the shown-offer subtractions and a window that outlasts evSettle. Co-Authored-By: Claude Opus 5.5 --- .changeset/s4-ev-base-after-restart.md | 2 +- .../control/ev_base_after_restart_test.go | 98 ++++++++++++++++++- go/internal/control/ev_battery_headroom.go | 3 +- go/internal/control/ev_fuse_budget.go | 49 ++++++++-- 4 files changed, 137 insertions(+), 15 deletions(-) diff --git a/.changeset/s4-ev-base-after-restart.md b/.changeset/s4-ev-base-after-restart.md index 919abe45d..8fcee1181 100644 --- a/.changeset/s4-ev-base-after-restart.md +++ b/.changeset/s4-ev-base-after-restart.md @@ -2,4 +2,4 @@ "ftw": patch --- -After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. +After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. The draw counts only when the charger measured it within one control interval of the site reading and its last poll succeeded. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. diff --git a/go/internal/control/ev_base_after_restart_test.go b/go/internal/control/ev_base_after_restart_test.go index 49efc3088..772ac34bc 100644 --- a/go/internal/control/ev_base_after_restart_test.go +++ b/go/internal/control/ev_base_after_restart_test.go @@ -91,8 +91,9 @@ func TestEVFoundDrawWBounds(t *testing.T) { want float64 }{ {"latest offer bounds the draw", func(*telemetry.Store, *State) {}, 6210}, - {"measured draw below the offer", func(store *telemetry.Store, _ *State) { + {"measured draw below the offer", func(store *telemetry.Store, state *State) { store.Update("charger", telemetry.DerEV, 4000, nil, nil) + store.Get("charger", telemetry.DerEV).UpdatedAt = state.now() }, 4000}, {"charger offline", func(store *telemetry.Store, _ *State) { store.DriverHealthMut("charger").SetOffline() @@ -107,12 +108,103 @@ func TestEVFoundDrawWBounds(t *testing.T) { c.found.at = state.now().Add(-c.settle) state.evLimit.offers["garage"] = c }, 0}, + {"a 40 s charger's window outlasts evSettle", func(_ *telemetry.Store, state *State) { + c := state.evLimit.offers["garage"] + c.settle, c.declared = 40*time.Second, true + c.found.at = state.now().Add(-30 * time.Second) + c.log[0].at = c.found.at + state.evLimit.offers["garage"] = c + }, 6210}, + {"the shown part of the latest offer is not counted twice", func(_ *telemetry.Store, state *State) { + c := state.evLimit.offers["garage"] + c.found.at = state.now().Add(-60 * time.Second) + c.log = commandLog{c.found, {at: state.now().Add(-25 * time.Second), w: 6210, a: 9, n: 3}} + state.evLimit.offers["garage"] = c + }, 0}, } { t.Run(tc.name, func(t *testing.T) { store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) tc.edit(store, state) - if got := state.evFoundDrawW(store, state.now(), 1e6, 0); math.Abs(got-tc.want) > 1e-6 { - t.Fatalf("credit %.0f W, want %.0f W", got, tc.want) + shownW := state.evShownW(state.now()) + if got := state.evFoundDrawW(store, store.Get("meter", telemetry.DerMeter), state.now(), shownW); math.Abs(got-tc.want) > 1e-6 { + t.Fatalf("credit %.0f W beside %.0f W shown, want %.0f W", got, shownW, tc.want) + } + }) + } +} + +// Offers the meter already shows come off the consumption the credit may +// fill: here another charger's 5 kW is shown, so of the 8,780 W the meter +// shows as consumption only 3,780 W is left for this car. +func TestEVFoundDrawWLeavesOutShownOffers(t *testing.T) { + store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) + if got := state.evFoundDrawW(store, store.Get("meter", telemetry.DerMeter), state.now(), 5000); math.Abs(got-3780) > 1e-6 { + t.Fatalf("credit %.0f W, want 3780 W", got) + } +} + +// A charger reading the meter may not show gives no credit. A fast charger +// applied the 0 A sent while the meter was silent, so the car draws +// nothing, but its reading still shows the 8.2 kW from before: held after a +// failed poll, or a sample older than the site reading (an OCPP +// MeterValues interval). The house draws 7 kW on balanced phases, the +// battery runs the -5 kW discharge Core ordered in this run, and the meter +// shows 2.9 A per phase. The 9 A order still to come leaves room to end +// only (16 - 2.9 - 9) × 690 W of the discharge. +func TestEVBaseAfterRestartNeedsACurrentChargerSample(t *testing.T) { + for _, tc := range []struct { + name string + edit func(*telemetry.Store, *State) + }{ + {"last poll failed", func(store *telemetry.Store, _ *State) { + store.RecordDriverError("charger", "timeout") + }}, + {"sample older than the site reading", func(store *telemetry.Store, state *State) { + data, _ := json.Marshal(map[string]any{"power_observed_at": state.now().Add(-10 * time.Second).Format(time.RFC3339Nano)}) + store.Update("charger", telemetry.DerEV, 8222, nil, data) + store.Get("charger", telemetry.DerEV).UpdatedAt = state.now() + store.RecordDriverSuccess("charger") + }}, + {"site reading older than the sample", func(store *telemetry.Store, state *State) { + store.Get("meter", telemetry.DerMeter).UpdatedAt = state.now().Add(-10 * time.Second) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + store, state := restartFixture(t, 2000, [3]float64{2.9, 2.9, 2.9}, -5000, 0, 8222) + tc.edit(store, state) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + got := targetW(out, "sungrow") + if afterA := 2.9 + (got+5000)/690 + 9; afterA > 16+1e-6 { + t.Fatalf("battery ends its discharge to %.0f W on a charger reading the meter may not show: %.2f A per phase once the car's order lands", got, afterA) + } + }) + } +} + +// The consumption bound reads the devices as the meter does. A battery +// that just ended a discharge, or PV that just dropped, still shows its old +// output in the smoothed value; counting that would raise the consumption +// the meter shows, and the credit with it, by the lag. +func TestEVBaseAfterRestartConsumptionReadsCurrentOutput(t *testing.T) { + for _, tc := range []struct { + name string + pvW float64 + driver string + kind telemetry.DerType + consumptionW float64 + }{ + {"battery ended its discharge", 0, "sungrow", telemetry.DerBattery, 680}, + {"PV dropped", -500, "pv", telemetry.DerPV, 1180}, + } { + t.Run(tc.name, func(t *testing.T) { + store, state := restartFixture(t, 680, [3]float64{4.97, -1, -1}, 0, tc.pvW, 8222) + store.Get(tc.driver, tc.kind).SmoothedW = -3000 + state.batteryCommands["sungrow"] = commandLog{}.add(state.now().Add(-time.Second), -5000) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + riseA := (6210 - tc.consumptionW) / 690 + want := -(5000 - (16-4.97-riseA)*690) + if got := targetW(out, "sungrow"); math.Abs(got-want) > 1 { + t.Fatalf("battery %.0f W, want %.0f W: the meter shows %.0f W of consumption", got, want, tc.consumptionW) } }) } diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 2c27e43cd..a2d57eda4 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -38,8 +38,7 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st return floorPositiveEVBatteryTargets(targets) } offeredW := state.evShownW(now) - consumptionW := gridW - sumOnlineSignedW(store, telemetry.DerBattery) - sumOnlineSignedW(store, telemetry.DerPV) - store.SumOnlineV2XW() - offeredW += state.evFoundDrawW(store, now, consumptionW, offeredW) + offeredW += state.evFoundDrawW(store, meter, now, offeredW) needW := math.Max(0, demandW-offeredW) latest := make(map[string]float64, len(targets)) lowest := make(map[string]float64, len(targets)) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 63c97047a..ef650fa5b 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -375,16 +375,27 @@ func (s *State) evShownW(at time.Time) float64 { // surely landed: after a restart or a plug-in, until its first logged offer // is older than its settle time. Core then does not know which offer the // charger runs on, and its first offers (often 0 A) say nothing of what the -// meter shows. A car seen drawing in that window is in the reading: each -// such charger counts its live measured draw, the lower of raw and smoothed, +// meter shows. A car seen drawing in that window is in the reading when the +// charger measured it within one control interval of the site reading: a +// fast charger may have applied a 0 A offer since an older sample, and a +// held reading after a failed poll says nothing of now. Each such charger +// counts its measured draw, the lowest of the sample, raw and smoothed, // never above its latest offer, and in all no more than the consumption the -// site meter shows (consumptionW) less shownW. The car's own limit never -// reads this; only how much of our latest offers the battery reserves as a -// rise to come. -func (s *State) evFoundDrawW(store *telemetry.Store, at time.Time, consumptionW, shownW float64) float64 { - if store == nil || !finiteFuseValue(consumptionW) { +// site meter shows (siteConsumptionW) less shownW. The car's own limit +// never reads this; only how much of our latest offers the battery reserves +// as a rise to come. +func (s *State) evFoundDrawW(store *telemetry.Store, meter *telemetry.DerReading, at time.Time, shownW float64) float64 { + if store == nil || meter == nil { return 0 } + consumptionW := siteConsumptionW(store, meter.RawW) + if !finiteFuseValue(consumptionW) { + return 0 + } + skew := time.Duration(s.ControlIntervalS) * time.Second + if skew <= 0 { + skew = 2 * time.Second // config's default control_interval_s + } var w float64 for _, c := range s.evLimit.offers { n := len(c.log) @@ -392,10 +403,14 @@ func (s *State) evFoundDrawW(store *telemetry.Store, at time.Time, consumptionW, continue } r := store.Get(c.driver, telemetry.DerEV) - if r == nil || !store.DriverHealth(c.driver).TelemetryLive() { + if h := store.DriverHealth(c.driver); r == nil || !h.TelemetryLive() || h.LastReadFailed() { continue } - drawW := math.Min(r.RawW, r.SmoothedW) + p, ok := qualifiedFuseObservation(r, at) + if !ok || p.Seen().Before(meter.UpdatedAt.Add(-skew)) || p.At.After(meter.UpdatedAt.Add(skew)) { + continue + } + drawW := math.Min(p.PowerW, math.Min(r.RawW, r.SmoothedW)) if !finiteFuseValue(drawW) { continue } @@ -406,6 +421,22 @@ func (s *State) evFoundDrawW(store *telemetry.Store, at time.Time, consumptionW, return math.Max(0, math.Min(w, consumptionW-shownW)) } +// siteConsumptionW is the consumption a site reading of gridW shows: grid +// less each live battery, PV and V2X reading, each at the higher of raw and +// smoothed. A smoothed value lags an output that just fell; the higher one +// keeps the estimate at or below what the meter shows. +func siteConsumptionW(store *telemetry.Store, gridW float64) float64 { + w := gridW + for _, t := range []telemetry.DerType{telemetry.DerBattery, telemetry.DerPV, telemetry.DerV2X} { + for _, r := range store.ReadingsByType(t) { + if store.DriverHealth(r.Driver).TelemetryLive() { + w -= math.Max(r.RawW, r.SmoothedW) + } + } + } + return w +} + // batterySettle is how long the battery side counts a raise of this // charger's as unshown: its declared response time, else evSettle // (evShownW). From bbb5f1bf062c2e3ab2d04ab6c6748190844a4712 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 01:04:32 +0100 Subject: [PATCH 15/34] test(control): run the site sim at the new 0 A default margin simFuseSafetyA follows the config default, now 0. fitScenario takes the room under the line and sets the house from simFuseSafetyA, so its exact fit stays twelve whole steps at any margin. "charger's own load balancer" passes at margin 0, so its known line goes. Co-Authored-By: Claude Opus 5.5 --- .../control/fuse_line_property_test.go | 27 ++++++++++--------- go/internal/control/sitesim_test.go | 4 +-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/go/internal/control/fuse_line_property_test.go b/go/internal/control/fuse_line_property_test.go index 1cc28fe92..a4aa32241 100644 --- a/go/internal/control/fuse_line_property_test.go +++ b/go/internal/control/fuse_line_property_test.go @@ -225,10 +225,11 @@ func testFuseLineIdleStart(t *testing.T, inA float64, phases int, marginA, lineA } } -// fitScenario runs a 3 × 16 A site, safety margin 0.5 A, with houseLoadA of -// house on each phase. At 3.5 A twelve steps of the car fit exactly under -// the 15.5 A line. The car asks for 16 A. -func fitScenario(houseLoadA float64, run time.Duration, noise func() float64) *simSite { +// fitScenario runs a 3 × 16 A site with the default safety margin and +// enough house on each phase to leave roomA under the line. With 12 A of +// room, twelve steps of the car fit exactly. The car asks for 16 A. +func fitScenario(roomA float64, run time.Duration, noise func() float64) *simSite { + houseLoadA := 16 - simFuseSafetyA - roomA sc := simScenario{name: "exact fit", run: run, plant: func(p *sitesim.Plant) { p.Fuse.RatingA = 16 @@ -240,12 +241,12 @@ func fitScenario(houseLoadA float64, run time.Duration, noise func() float64) *s // Closed loop: the car climbs to the line and stays there for 30 min. func TestSiteSimCarEndsAtTheLine(t *testing.T) { - s := fitScenario(3.5, 30*time.Minute, nil) + s := fitScenario(12, 30*time.Minute, nil) if s.failure != "" { t.Fatal(s.failure) } lineA := s.state.fuseLineA() - if want := (lineA - 3.5) * 690; s.p.EVSE.DrawW != want || s.peakA > lineA+1e-6 { + if want := 12.0 * 690; s.p.EVSE.DrawW != want || s.peakA > lineA+1e-6 { t.Fatalf("car %.0f W, peak phase %.3f A; want %.0f W with every phase at most %.1f A", s.p.EVSE.DrawW, s.peakA, want, lineA) } var last float64 @@ -275,16 +276,16 @@ func TestSiteSimCarEndsAtTheLine(t *testing.T) { // faster hunt fails. func TestSiteSimNoisyLineCyclesSlowly(t *testing.T) { for _, tc := range []struct { - houseA, sigma float64 - cutsPerHour float64 // the most allowed after the climb - dropSteps float64 // the furthest below the top + roomA, sigma float64 + cutsPerHour float64 // the most allowed after the climb + dropSteps float64 // the furthest below the top }{ - {3.25, .1, 0, 0}, {3.75, .2, 0, 0}, // part of a step left - {3.5, .03, 5, 1}, {3.5, .1, 5, 1}, {3.5, .2, 5, 2}, // exact fit + {12.25, .1, 0, 0}, {11.75, .2, 0, 0}, // part of a step left + {12, .03, 5, 1}, {12, .1, 5, 1}, {12, .2, 5, 2}, // exact fit } { - t.Run(fmt.Sprintf("house_%.2fA/noise_%.2fA", tc.houseA, tc.sigma), func(t *testing.T) { + t.Run(fmt.Sprintf("room_%.2fA/noise_%.2fA", tc.roomA, tc.sigma), func(t *testing.T) { rng := rand.New(rand.NewPCG(1, 0)) - s := fitScenario(tc.houseA, 2*time.Hour, func() float64 { return tc.sigma * rng.NormFloat64() }) + s := fitScenario(tc.roomA, 2*time.Hour, func() float64 { return tc.sigma * rng.NormFloat64() }) if s.failure != "" { t.Fatal(s.failure) } diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 04dd9690c..943ef7fdf 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -21,7 +21,7 @@ import ( const simCoreEvery = 2 * time.Second // simFuseSafetyA is the site's fuse.safety_margin_a, the config default. -const simFuseSafetyA = .5 +const simFuseSafetyA = 0 // simSite runs Core's control tick against a sitesim.Plant in main.go's // order: site import, charger tick, then battery dispatch. Core's settings @@ -647,7 +647,7 @@ func simScenarios() []simScenario { plant: func(p *sitesim.Plant) { p.HouseW, p.PVW = houseA(2, 2, 2), -22*690 }}, {name: "export start, unsigned phase currents", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW, p.PVW, p.Meter.Unsigned = houseA(2, 2, 2), -22*690, true }}, - {name: "charger's own load balancer", known: "a cut comes off an offer the car does not draw, ev_fuse_budget.go:172", run: 20 * time.Minute, + {name: "charger's own load balancer", run: 20 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW, p.EVSE.LoadBalancerA = houseA(12, 12, 12), 25 p.Events = []sitesim.Event{ From 3cf26fda0e796f88aea627b63090bf6509034359 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 01:04:32 +0100 Subject: [PATCH 16/34] docs(changeset): say what margin 0 moves once the fuse stack lands With the stack under it, the default moves the car's and the battery's per-phase line as well as the site totals. 0.5 A stops being the default, so #1910's changeset no longer calls it that. Co-Authored-By: Claude Opus 5.5 --- .changeset/b-ev-fuse-line-at-rating.md | 2 +- .changeset/d-fuse-margin-default-0.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/b-ev-fuse-line-at-rating.md b/.changeset/b-ev-fuse-line-at-rating.md index f28801459..a886e5268 100644 --- a/.changeset/b-ev-fuse-line-at-rating.md +++ b/.changeset/b-ev-fuse-line-at-rating.md @@ -2,4 +2,4 @@ "ftw": patch --- -The car now charges up to the fuse rating less `fuse.safety_margin_a`, not a fixed 1 A below it. A charging car rises one step once that step fits under the line, instead of needing two steps of room and keeping one free; an idle car starts with all the room it has. Home battery charge beside a car uses the same line, with no 1 A floor. On a three-phase site with the default 0.5 A margin, a car near the fuse gets 0.5 A to 1.5 A more on each phase. Cuts above the line still wait for the fuse's heat budget, and the hour's import limit keeps its own margins. A car never rises or starts into battery discharge that the fuse guard added. +The car now charges up to the fuse rating less `fuse.safety_margin_a`, not a fixed 1 A below it. A charging car rises one step once that step fits under the line, instead of needing two steps of room and keeping one free; an idle car starts with all the room it has. Home battery charge beside a car uses the same line, with no 1 A floor. On a three-phase site with a 0.5 A margin, a car near the fuse gets 0.5 A to 1.5 A more on each phase. Cuts above the line still wait for the fuse's heat budget, and the hour's import limit keeps its own margins. A car never rises or starts into battery discharge that the fuse guard added. diff --git a/.changeset/d-fuse-margin-default-0.md b/.changeset/d-fuse-margin-default-0.md index 3a82a6eb7..2d91f84de 100644 --- a/.changeset/d-fuse-margin-default-0.md +++ b/.changeset/d-fuse-margin-default-0.md @@ -2,4 +2,4 @@ "ftw": patch --- -`fuse.safety_margin_a` now defaults to 0. On a site with no saved margin, the limits FTW puts on the site's total import and export rise to the fuse rating, 0.5 A per phase higher than before. A margin is opt-in, for an inverter whose own phase limiter trips at the rating. FTW keeps any margin you have saved. If yours is exactly 0.5 A, FTW's old default, the log says so once at startup; set 0 to drop it. +`fuse.safety_margin_a` now defaults to 0. On a site with no saved margin, FTW works to the fuse rating: the car, the home battery's charge and fuse relief, and the site's total import and export limits each move up 0.5 A per phase, to the rating. A margin is opt-in, for an inverter whose own phase limiter trips at the rating. FTW keeps any margin you have saved. If yours is exactly 0.5 A, FTW's old default, the log says so once at startup; set 0 to drop it. From 4ecebfd205d1abcb103713fb5a35b47cef9f81ed Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 01:21:54 +0100 Subject: [PATCH 17/34] test(control): keep the load-balancer bug as a known line above the line At the 0 A default the scenario passes only because the charger's own balancer sits at 25 A, the rating and so the line. The bug is still in Core: a cut comes off an offer the car does not draw. Two known variants keep it tracked and turn red once it is fixed: the stored 0.5 A margin (over 24.5 A for more than 11 s) and a balancer at 25.5 A at margin 0 (over 25 A for more than 11 s). Co-Authored-By: Claude Opus 5.5 --- go/internal/control/sitesim_test.go | 37 ++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 943ef7fdf..9757eea3e 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -609,6 +609,23 @@ func simDay(p *sitesim.Plant) { } func simScenarios() []simScenario { + // A charger whose own load balancer holds each phase to balancerA. + loadBalancer := func(balancerA float64) simScenario { + name := "charger's own load balancer" + if balancerA != 25 { + name = fmt.Sprintf("%s at %.1f A", name, balancerA) + } + return simScenario{name: name, run: 20 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW, p.EVSE.LoadBalancerA = houseA(12, 12, 12), balancerA + p.Events = []sitesim.Event{ + {At: 5 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(18, 12, 12) }}, + {At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(4, 4, 4) }}, + {At: 15 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(20, 20, 20) }}, + } + }} + } + const loadBalancerBug = "a cut comes off an offer the car does not draw, stepEVMeterLimit (ev_fuse_budget.go:769)" slowCharger := func(delay time.Duration) func(*sitesim.Plant) { return func(p *sitesim.Plant) { p.HouseW, p.EVSE.Delay = houseA(15, 15, 15), delay } } @@ -647,15 +664,11 @@ func simScenarios() []simScenario { plant: func(p *sitesim.Plant) { p.HouseW, p.PVW = houseA(2, 2, 2), -22*690 }}, {name: "export start, unsigned phase currents", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW, p.PVW, p.Meter.Unsigned = houseA(2, 2, 2), -22*690, true }}, - {name: "charger's own load balancer", run: 20 * time.Minute, - plant: func(p *sitesim.Plant) { - p.HouseW, p.EVSE.LoadBalancerA = houseA(12, 12, 12), 25 - p.Events = []sitesim.Event{ - {At: 5 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(18, 12, 12) }}, - {At: 10 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(4, 4, 4) }}, - {At: 15 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(20, 20, 20) }}, - } - }}, + loadBalancer(25), + // The balancer sits at the rating, so at the default 0 A margin it + // never lets the car past Core's line. Above the line the bug shows. + withKnown(withSafetyMarginA(loadBalancer(25), .5), loadBalancerBug), + withKnown(loadBalancer(25.5), loadBalancerBug), {name: "slow cloud charger 60 s", run: 15 * time.Minute, plant: slowCharger(time.Minute)}, {name: "slow cloud charger 120 s", run: 15 * time.Minute, @@ -783,6 +796,12 @@ func simScenarios() []simScenario { } } +// withKnown marks sc as failing on the finding known. +func withKnown(sc simScenario, known string) simScenario { + sc.known = known + return sc +} + // withSafetyMarginA runs sc with an opt-in fuse.safety_margin_a. The // default is 0; a site that keeps the old 0.5 A must still pass. func withSafetyMarginA(sc simScenario, a float64) simScenario { From 281f9f63fb8b477a8ecaea087df6236cf570ac8b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 04:18:08 +0100 Subject: [PATCH 18/34] test(control): our battery order's move is not the car's cut landing The guard in seeOffers that stops reading the meter while our battery order moves had no test; removing it left every test green. A battery order that lands and goes back while a cut travels to a 40 s charger now must not lead to a second cut. Co-Authored-By: Claude Opus 5.5 --- .../control/ev_fuse_cut_netting_test.go | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go index dcd70dafe..a19722814 100644 --- a/go/internal/control/ev_fuse_cut_netting_test.go +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -482,3 +482,33 @@ func TestEVFuseOnePhaseHouseMoveIsNotTheCutLanding(t *testing.T) { } }) } + +// While our own battery order moves the phases, a move of every phase +// alike may be the battery's, so it is not read as the car's cut landing. +// A 3 A cut travels to a charger that declares 40 s. A battery order of +// 1 A a phase discharge lands and goes back: L1 reads 28, 27, then 28 A +// again. Read as the cut landing, the 1 A drop took a third of the cut's +// cover, and Core cut a second time, to 8,280 W, for an overage the first +// cut already covers. +func TestEVFuseBatteryOrderMoveIsNotTheCutLanding(t *testing.T) { + r := newCutRig(t, 11040, 40) // 16 A + for range 120 { + r.read([3]float64{20, 15, 15}) + } + for i := 0; i < 120 && r.offerW == 11040; i++ { + r.read([3]float64{28, 15, 15}) // 3 A over the 25 A line + } + if r.offerW != 8970 { + t.Fatalf("heat cut to %.0f W, want 8,970 W", r.offerW) + } + r.state.batteryCommands = map[string]commandLog{"bat": commandLog{}.add(r.now.Add(-time.Minute), 0).add(r.now, -690)} + for range 3 { + r.read([3]float64{27, 14, 14}) + } + r.state.batteryCommands["bat"] = r.state.batteryCommands["bat"].add(r.now, 0) + for range 8 { + if r.read([3]float64{28, 15, 15}); r.offerW != 8970 { + t.Fatalf("offer %.0f W after our battery order came and went, want the one cut to 8,970 W", r.offerW) + } + } +} From f29e96c300e5753cf5cb1a7136c2bdc0491a5d45 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 04:20:23 +0100 Subject: [PATCH 19/34] test(control): the car-first discharge stop has its own test Removing the PI path's immediate discharge stop left every named test green; only the golden replay caught it. A peak_shaving loop with battery_covers_ev off, starting from a 2.9 kW discharge while the house alone has surplus, now requires that no order discharges into the car. The comment on the plan back-off said the plan's grid leaves out the car. It counts the car it planned; the comment now says so. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/dispatch.go | 5 ++++- go/internal/control/ev_car_before_battery_test.go | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 9c3922683..cc24ee053 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -1791,7 +1791,10 @@ func ComputeDispatch( // Below 100 W is meter noise. if currentDirective.HasPlannedGridW && targetTotalW > 0 { const planGridDeadband = 100.0 - // The plan's grid leaves out the car the battery does not cover. + // As before the car came first, the projection leaves out the + // car the battery does not cover, while the plan's grid counts + // the car it planned. The car's draw never starts this + // back-off. projectedGridW := noCarGridW + (targetTotalW - currentTotal) gridErr := projectedGridW - currentDirective.PlannedGridW if gridErr > planGridDeadband { diff --git a/go/internal/control/ev_car_before_battery_test.go b/go/internal/control/ev_car_before_battery_test.go index bce40ca2d..65e899c62 100644 --- a/go/internal/control/ev_car_before_battery_test.go +++ b/go/internal/control/ev_car_before_battery_test.go @@ -249,3 +249,18 @@ func TestCarBeforeBatteryPeakShavingChargesTheSurplusAfterTheCar(t *testing.T) { } } } + +// On the PI path with battery_covers_ev off, a discharge stops at once +// when the house alone has surplus: what it gives now goes to the car. The +// battery was discharging 2,900 W; 5 kW of PV beats the 3.6 kW house, and +// the car draws 11 kW. No order may discharge, not even as a PI step on +// the way to 0 W. +func TestCarBeforeBatteryCoverOffStopsADischargeForTheCarAtOnce(t *testing.T) { + const pv, house, car = 5000.0, 3611.0, 11040.0 + got := runPeakShavingCar(t, false, house+car-pv, car, -2900) + for i, w := range got { + if w < -1 { + t.Fatalf("tick %d: battery discharges %.0f W into the car with battery_covers_ev off: %v", i, w, got) + } + } +} From c9b52c3636c60ec19b41aa8c69e9cff4856743fc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 04:25:38 +0100 Subject: [PATCH 20/34] fix(control): the battery rise check reads only live battery power fitBatteryRisesToFuse, unshownBatteryW and recordBatteryReadings read the battery's cached RawW, which survives the driver going offline, and added three direct telemetry reads that the repo-hygiene check refuses. They now use Store.OnlineRawW, as phaseSample.moveW does: an offline battery's last value no longer sets the rise base or the shown level. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/dispatch.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index b75c30321..19bc2aafe 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -4065,8 +4065,8 @@ func (s *State) unshownBatteryW(now time.Time, store *telemetry.Store) float64 { if read, _, _, seen := s.batteryReadings[driver].span(now); seen { low = math.Min(low, read) } - if r := store.Get(driver, telemetry.DerBattery); r != nil { - low = math.Min(low, r.RawW) + if w, _, live := store.OnlineRawW(driver, telemetry.DerBattery); live { + low = math.Min(low, w) } unshown += math.Max(0, latest-low) } @@ -4081,14 +4081,14 @@ func (s *State) recordBatteryReadings(targets []DispatchTarget, store *telemetry } now := s.now() for _, t := range targets { - r := store.Get(t.Driver, telemetry.DerBattery) - if r == nil { + w, _, live := store.OnlineRawW(t.Driver, telemetry.DerBattery) + if !live { continue } if s.batteryReadings == nil { s.batteryReadings = map[string]commandLog{} } - s.batteryReadings[t.Driver] = s.batteryReadings[t.Driver].add(now, r.RawW) + s.batteryReadings[t.Driver] = s.batteryReadings[t.Driver].add(now, w) } } @@ -4133,8 +4133,8 @@ func fitBatteryRisesToFuse(targets []DispatchTarget, store *telemetry.Store, sta var base float64 if _, _, latest, sent := state.batteryCommands[t.Driver].span(now); sent { base = latest - } else if r := store.Get(t.Driver, telemetry.DerBattery); r != nil { - base = r.RawW + } else if w, _, live := store.OnlineRawW(t.Driver, telemetry.DerBattery); live { + base = w } roomW -= math.Max(0, math.Min(0, t.TargetW)-math.Min(0, base)) holds[i] = math.Min(t.TargetW, math.Max(0, base)) From ab027102ccbaf217db6f01625ce4c8d891babcd4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 05:05:22 +0100 Subject: [PATCH 21/34] fix(control): judge a raise's landing from the meter's rise C4 counted a raise as landed only when a reading-to-reading move had a charger's shape. A slow ramp, or a house load on another phase that moved in the landing reading, left the whole raise counted as on its way on top of a reading that already held it, and Core cut a raise that fit under the line. The rise still to come now reads the meter's own rise since the raise went out: the smallest across the phases for a three-phase order, the car's phase for a one-phase car. For a three-phase order, a move on one phase alone no longer moves the shown level of a car not yet seen. A load cycling on the phase over the line took the cut's cover off on each drop and never gave it back, so Core cut again and again. Co-Authored-By: Claude Opus 5.5 --- .changeset/s1-ev-cut-landed-and-unshown.md | 2 +- go/internal/control/ev_fuse_budget.go | 88 ++++++++++--- .../control/ev_fuse_cut_netting_test.go | 120 +++++++++++++++--- 3 files changed, 171 insertions(+), 39 deletions(-) diff --git a/.changeset/s1-ev-cut-landed-and-unshown.md b/.changeset/s1-ev-cut-landed-and-unshown.md index 3e8cdb1fa..837c8c5e2 100644 --- a/.changeset/s1-ev-cut-landed-and-unshown.md +++ b/.changeset/s1-ev-cut-landed-and-unshown.md @@ -2,4 +2,4 @@ "ftw": patch --- -The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A house load that moves on one phase alone is not taken for the car's cut landing, so it no longer stacks a second cut on a slow charger. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. For a car the site meter has shown on three phases, this also holds when the house steps up on one phase alone. +The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A house load that moves on one phase alone is not taken for the car's cut landing, so it no longer stacks a second cut on a slow charger. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. FTW judges how much of the raise has landed from the meter's smallest rise across the phases, so a house step on one phase does not hide a raise that has not landed. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index d6d5601e1..34534c1b3 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -188,6 +188,14 @@ type chargerOffers struct { // cannot. shownA [3]float64 unsure bool + // riseBaseA is the site meter's phase currents when a raise went out + // with no other raise still to show, and riseFromA the most of the + // charger's offers that reading may have shown. riseAheadA measures the + // rise still to come against them; riseSet is false while no raise is + // on its way. + riseBaseA [3]float64 + riseFromA float64 + riseSet bool } // evMoveA is half a charger step. An order landing shows on the site meter @@ -230,17 +238,16 @@ func chargerMoveA(prev, now []float64) (alikeA, oneA float64, phase int) { // car the loadpoint has seen draw on one phase, or a one-phase order, is // one level on every phase: every phase dropping alike moves it, and so // does a move on the car's phase alone, or on any one phase while that -// phase is not known. A three-phase order moves every phase alike; for a -// car not yet seen on three phases, a phase that moves alone moves only -// its own level, since a one-phase car would draw there. A house load on -// one phase then never moves the level of the other two. -// The shown current moves only toward the latest offer, and stays within -// the offers the reading may show. While our own battery order moves, a -// move of every phase alike may be the battery's, so no reading counts; -// after a reading that did not count, the shown current is known again -// once only one offer may show. A house load that moves in a charger's -// shape reads as ours. It can only take cover off a cut, or leave our rise -// to the meter as before. +// phase is not known. A three-phase order moves only on every phase alike: +// a phase that moves alone is a house load, or a car not yet seen that +// draws on one phase, and Core cannot tell which. The shown current moves +// only toward the latest offer, and stays within the offers the reading +// may show. While our own battery order moves, a move of every phase alike +// may be the battery's, so no reading counts; after a reading that did not +// count, the shown current is known again once only one offer may show. A +// house load that moves in a charger's shape reads as ours. It can only +// take cover off a cut. The rise a raise has still to show reads the meter +// differently (riseAheadA). func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { lim := &s.evLimit amps, _, valid := phaseCurrentsA(meter, s, gridW) @@ -266,13 +273,11 @@ func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { } moveA = [3]float64{m, m, m} default: - // A three-phase order moves every phase alike. A car not - // yet seen may draw on one phase: a phase that moves alone - // moves only its own level. + // A three-phase order moves every phase alike. Read as the + // car, a load cycling on the phase over the line took the + // cut's cover off there on each drop and never gave it + // back, and Core cut again and again. moveA = [3]float64{alikeA, alikeA, alikeA} - if c.carPhases != 3 && phase > 0 { - moveA[phase-1] += oneA - } } // The charger moves toward its latest offer, never past it. latestA := c.log[len(c.log)-1].a @@ -291,6 +296,9 @@ func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { c.shownA[i] = math.Min(high, math.Max(low, c.shownA[i])) } c.unsure = c.unsure && low < high + if ahead := s.riseAheadA(c); low >= high || ahead == [3]float64{} { + c.riseSet = false + } lim.offers[id] = c } } @@ -397,9 +405,9 @@ func (s *State) pendingLoad(at time.Time) pendingLoad { // Where the meter tells what it shows of the charger (seeOffers), a cut // covers no more than the meter has still to show on each phase: a cut the // meter shows covers no overage that comes after it. aheadA is then the -// rise on each phase the meter has still to show of the latest offers, -// which a raise's own trigger reads (stepEVMeterLimit), and rise counts at -// least that much. Elsewhere aheadA is 0. +// rise on each phase the meter has still to show of the latest offers +// (riseAheadA), which a raise's own trigger reads (stepEVMeterLimit), and +// rise counts at least that much. Elsewhere aheadA is 0. func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingLoad, aheadA [3]float64, latestW float64) { lim := &s.evLimit for _, c := range lim.offers { @@ -425,10 +433,10 @@ func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingL phaseCoverA, phaseRiseA, ahead := [3]float64{coverA, coverA, coverA}, [3]float64{riseA, riseA, riseA}, [3]float64{} if perPhase && lim.phasesSeen && len(lim.offers) == 1 && s.SiteFusePhases >= 2 && c.readable() && !c.unsure { low, high := c.offerRangeA(at) + ahead = s.riseAheadA(c) for i := range min(s.SiteFusePhases, len(ahead)) { toComeA := latest.a - math.Min(high, math.Max(low, c.shownA[i])) phaseCoverA[i] = math.Min(coverA, math.Max(0, -toComeA)) - ahead[i] = math.Max(0, toComeA) phaseRiseA[i] = math.Max(riseA, ahead[i]) } } @@ -444,6 +452,42 @@ func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingL return rise, cover, aheadA, latestW } +// riseAheadA is the rise on each phase the site meter has still to show +// of the charger's latest offer: the raise since riseFromA less the meter's +// own rise since the raise went out, within the offers the reading may +// show. A three-phase order takes the smallest rise across the phases: a +// house step on some phases does not show the raise on the rest. A car on +// one phase takes its own phase's rise, or the largest where the phase is +// not known. A landing counts in whatever shape it comes, a slow ramp or +// beside a house load that moves in the same reading. A house load that +// drops while the raise travels still reads as raise not shown yet, by +// as much as it dropped. +func (s *State) riseAheadA(c chargerOffers) (ahead [3]float64) { + lim := &s.evLimit + n := min(s.SiteFusePhases, len(ahead)) + if !c.riseSet || !lim.phasesSeen || n < 2 || len(c.log) == 0 { + return ahead + } + latest := c.log[len(c.log)-1] + rises := make([]float64, n) + for i := range rises { + rises[i] = lim.phasesA[i] - c.riseBaseA[i] + } + riseA, phases := slices.Min(rises), ahead[:n] + switch { + case c.carPhase > 0 && c.carPhase <= n: + riseA, phases = rises[c.carPhase-1], ahead[c.carPhase-1:c.carPhase] + case c.carPhases == 1 || latest.n == 1: + riseA = slices.Max(rises) + } + low, high := c.offerRangeA(lim.readingAt) + toComeA := math.Max(0, latest.a-math.Min(high, math.Max(low, c.riseFromA+riseA))) + for i := range phases { + phases[i] = toComeA + } + return ahead +} + // 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 @@ -472,6 +516,8 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) 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, c.shownA = sent, [3]float64{sent.a, sent.a, sent.a} + } else if _, high := c.offerRangeA(now); sent.a > high && !c.riseSet && s.evLimit.phasesSeen { + c.riseBaseA, c.riseFromA, c.riseSet = s.evLimit.phasesA, high, true } c.log = c.log.keep(sent, read.Add(-c.settle)) } diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go index a19722814..d7291b1ae 100644 --- a/go/internal/control/ev_fuse_cut_netting_test.go +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -415,23 +415,24 @@ func TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce(t *testing.T) { } } -// The C4 case as the triage wrote it, with only L1 moving, for a car the -// loadpoint has seen draw on three phases. A house step on L1 alone is then -// not the raise landing: the raise would take L1 to 25.7 A, over 1.2 x In, -// so it comes off on that reading. For a car not yet seen, a step on one -// phase may be a one-phase car taking the raise; the meter judges it then. -func TestEVFuseRaiseOnItsWayCutsForACarSeenOnThreePhases(t *testing.T) { - r := newCutRig(t, 4140, 40) - r.state.SiteFuseAmps, r.carPhases = 16, 3 - for range 60 { - r.read([3]float64{6, 6, 6}) - } - r.offerW = 11040 // 6 -> 16 A - for range 30 { - r.read([3]float64{6, 6, 6}) - } - if r.read([3]float64{15.7, 6, 6}); r.offerW > 4140 { - t.Fatalf("offer %.0f W with L1 at 15.7 A and 10 A on its way, want 4,140 W or less", r.offerW) +// The C4 case as the triage wrote it, with only L1 moving. L2 and L3 have +// not risen, so the three-phase raise has not landed, whether or not the +// loadpoint has seen the car draw on three phases: the raise would take L1 +// to 25.7 A, over 1.2 x In, so it comes off on that reading. +func TestEVFuseRaiseOnItsWayCutsOnAOnePhaseHouseStep(t *testing.T) { + for _, carPhases := range []int{0, 3} { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps, r.carPhases = 16, carPhases + for range 60 { + r.read([3]float64{6, 6, 6}) + } + r.offerW = 11040 // 6 -> 16 A + for range 30 { + r.read([3]float64{6, 6, 6}) + } + if r.read([3]float64{15.7, 6, 6}); r.offerW > 4140 { + t.Fatalf("car phases %d: offer %.0f W with L1 at 15.7 A and 10 A on its way, want 4,140 W or less", carPhases, r.offerW) + } } } @@ -512,3 +513,88 @@ func TestEVFuseBatteryOrderMoveIsNotTheCutLanding(t *testing.T) { } } } + +// A raise that fits lands, and no phase passes the line, so nothing cuts. +// 16 A fuse, charger that declares 40 s, car from 6 A to 14 A while every +// phase has 8 A of room. Before, C4 counted a landing only when the move +// had a charger's shape. A house load on L2 that switched off in the +// landing reading, or a car that ramped 0.4 A a reading, left the whole +// raise counted as on its way on top of a reading that already held it: +// 14 + 8 A read as over 1.2 x In, and Core cut to 5,520 or 6,900 W. +func TestEVFuseRaiseThatLandedIsNotCountedTwice(t *testing.T) { + for _, tc := range []struct { + name string + l2 float64 // L2 before the landing; it reads 14 A after + carPhases int + }{ + {"clean", 6, 0}, + {"house_off_on_L2_1.2A", 7.2, 0}, + {"house_off_on_L2_2A", 8, 0}, + {"house_off_on_L2_2A_car_seen_on_three_phases", 8, 3}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps, r.carPhases = 16, tc.carPhases + for range 60 { + r.read([3]float64{6, tc.l2, 6}) + } + r.offerW = 9660 // 6 -> 14 A + for range 10 { + r.read([3]float64{6, tc.l2, 6}) + } + for i := range 120 { + if r.read([3]float64{14, 14, 14}); r.offerW != 9660 { + t.Fatalf("raise that landed at 14/14/14 A cut to %.0f W after %d s", r.offerW, i+1) + } + } + }) + } + for _, carPhases := range []int{0, 3} { + t.Run(fmt.Sprintf("slow_ramp_car_phases_%d", carPhases), func(t *testing.T) { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps, r.carPhases = 16, carPhases + for range 60 { + r.read([3]float64{6, 6, 6}) + } + r.offerW = 9660 + r.read([3]float64{6, 6, 6}) + a := 6.0 + for i := range 120 { + a = math.Min(14, a+0.4) + if r.read([3]float64{a, a, a}); r.offerW != 9660 { + t.Fatalf("raise ramping to 14 A cut to %.0f W at %.1f A after %d s", r.offerW, a, i+1) + } + } + }) + } +} + +// A one-phase load that cycles on a phase is not a three-phase cut landing, +// even for a car not yet seen on three phases. The house falls from 18 to +// 10 A a phase, so the car rises toward the line, while 1.5 A on L2 +// toggles. Before, each drop of the load read as the cut landing on L2 and +// took its cover off there; the load's return could not give it back, so +// Core cut again and again: 9,660 down to 4,830 W and up, 21 cuts in 20 min +// with a 40 s charger. #1913 cut once. +func TestEVFuseLoadCyclingOnTheOverPhaseDoesNotHunt(t *testing.T) { + for _, period := range []int{3, 11} { + t.Run(fmt.Sprintf("every_%ds", period), func(t *testing.T) { + s := newEVMeterSite(t, 0, 18*690) + s.chargerDelay, s.chargerMaxAgeS = 40*time.Second, 40 + s.run(5 * time.Minute) + s.setHouse(10 * 690) + stepAt := s.now + loadA := 1.5 + for i := range 20 * 60 { + if i%period == 0 { + s.addHouseA(0, loadA, 0) + loadA = -loadA + } + s.step() + } + if cuts := offerWatts(s.cutsSince(stepAt)); len(cuts) > 1 { + t.Fatalf("cuts %v; one is enough", cuts) + } + }) + } +} From 53b8aa3410be12a209a2a7802822e03d40865584 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 05:10:32 +0100 Subject: [PATCH 22/34] fix(control): a car not yet seen may take a raise on one phase The smallest rise across the phases read a one-phase car not yet seen as a raise that had not landed: its phase rose, the other two did not. Core then cut the car from 11040 W to 0 with every phase under the line (field matrix, car1p cases). One phase that has risen alone since the raise went out, the others within half a step, now counts as that car taking the raise, as the shape tracker read it before. The changeset again says what holds: the one-phase house step case is closed for a car seen on three phases. Co-Authored-By: Claude Opus 5.5 --- .changeset/s1-ev-cut-landed-and-unshown.md | 2 +- go/internal/control/ev_fuse_budget.go | 7 +++- .../control/ev_fuse_cut_netting_test.go | 35 +++++++++---------- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/.changeset/s1-ev-cut-landed-and-unshown.md b/.changeset/s1-ev-cut-landed-and-unshown.md index 837c8c5e2..3e8cdb1fa 100644 --- a/.changeset/s1-ev-cut-landed-and-unshown.md +++ b/.changeset/s1-ev-cut-landed-and-unshown.md @@ -2,4 +2,4 @@ "ftw": patch --- -The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A house load that moves on one phase alone is not taken for the car's cut landing, so it no longer stacks a second cut on a slow charger. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. FTW judges how much of the raise has landed from the meter's smallest rise across the phases, so a house step on one phase does not hide a raise that has not landed. +The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A house load that moves on one phase alone is not taken for the car's cut landing, so it no longer stacks a second cut on a slow charger. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. For a car the site meter has shown on three phases, this also holds when the house steps up on one phase alone. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 34534c1b3..1747f0b96 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -458,7 +458,9 @@ func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingL // show. A three-phase order takes the smallest rise across the phases: a // house step on some phases does not show the raise on the rest. A car on // one phase takes its own phase's rise, or the largest where the phase is -// not known. A landing counts in whatever shape it comes, a slow ramp or +// not known. For a car not yet seen on three phases, one phase that has +// risen alone, the others within evMoveA, may be the car taking the raise +// on one phase. A landing counts in whatever shape it comes, a slow ramp or // beside a house load that moves in the same reading. A house load that // drops while the raise travels still reads as raise not shown yet, by // as much as it dropped. @@ -474,11 +476,14 @@ func (s *State) riseAheadA(c chargerOffers) (ahead [3]float64) { rises[i] = lim.phasesA[i] - c.riseBaseA[i] } riseA, phases := slices.Min(rises), ahead[:n] + _, oneA, _ := chargerMoveA(c.riseBaseA[:n], lim.phasesA[:n]) switch { case c.carPhase > 0 && c.carPhase <= n: riseA, phases = rises[c.carPhase-1], ahead[c.carPhase-1:c.carPhase] case c.carPhases == 1 || latest.n == 1: riseA = slices.Max(rises) + case c.carPhases != 3 && oneA > 0: + riseA = oneA // A car not yet seen may draw on the one phase that rose. } low, high := c.offerRangeA(lim.readingAt) toComeA := math.Max(0, latest.a-math.Min(high, math.Max(low, c.riseFromA+riseA))) diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go index d7291b1ae..9ee869a12 100644 --- a/go/internal/control/ev_fuse_cut_netting_test.go +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -415,24 +415,23 @@ func TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce(t *testing.T) { } } -// The C4 case as the triage wrote it, with only L1 moving. L2 and L3 have -// not risen, so the three-phase raise has not landed, whether or not the -// loadpoint has seen the car draw on three phases: the raise would take L1 -// to 25.7 A, over 1.2 x In, so it comes off on that reading. -func TestEVFuseRaiseOnItsWayCutsOnAOnePhaseHouseStep(t *testing.T) { - for _, carPhases := range []int{0, 3} { - r := newCutRig(t, 4140, 40) - r.state.SiteFuseAmps, r.carPhases = 16, carPhases - for range 60 { - r.read([3]float64{6, 6, 6}) - } - r.offerW = 11040 // 6 -> 16 A - for range 30 { - r.read([3]float64{6, 6, 6}) - } - if r.read([3]float64{15.7, 6, 6}); r.offerW > 4140 { - t.Fatalf("car phases %d: offer %.0f W with L1 at 15.7 A and 10 A on its way, want 4,140 W or less", carPhases, r.offerW) - } +// The C4 case as the triage wrote it, with only L1 moving, for a car the +// loadpoint has seen draw on three phases. A house step on L1 alone is then +// not the raise landing: the raise would take L1 to 25.7 A, over 1.2 x In, +// so it comes off on that reading. For a car not yet seen, a step on one +// phase may be a one-phase car taking the raise; the meter judges it then. +func TestEVFuseRaiseOnItsWayCutsForACarSeenOnThreePhases(t *testing.T) { + r := newCutRig(t, 4140, 40) + r.state.SiteFuseAmps, r.carPhases = 16, 3 + for range 60 { + r.read([3]float64{6, 6, 6}) + } + r.offerW = 11040 // 6 -> 16 A + for range 30 { + r.read([3]float64{6, 6, 6}) + } + if r.read([3]float64{15.7, 6, 6}); r.offerW > 4140 { + t.Fatalf("offer %.0f W with L1 at 15.7 A and 10 A on its way, want 4,140 W or less", r.offerW) } } From 8a10b27dcaa60fbac0f5f3b852b5e0f1303ef814 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 06:00:30 +0100 Subject: [PATCH 23/34] fix(control): judge a one-phase landing reading by reading For a car not yet seen on three phases, a raise counted as landed on one phase only while the other phases stayed within 0.5 A of their reading when the raise went out. That window spans the charger's whole response time, so 0.6 A of house drift on another phase made the full raise count as still to come on top of a reading that already showed it, and Core cut the car with every phase under the line (field matrix with 0.7 A on L2: car1p cut 11100 -> 0 W). Each phase now keeps its rise over the readings in which the other phases moved less than a step (aloneA). A rise of a step or more there may be the car on that phase. Drift between readings, or under a step in the landing reading, no longer hides it; a house load that moves a step or more on another phase in the same reading still does. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/ev_fuse_budget.go | 50 +++++++++++++++---- .../control/ev_fuse_cut_netting_test.go | 42 ++++++++++++++++ 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 1747f0b96..95b41bd1e 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -196,6 +196,10 @@ type chargerOffers struct { riseBaseA [3]float64 riseFromA float64 riseSet bool + // aloneA is each phase's rise since riseBaseA counted only over the + // readings in which the other phases moved less than a step: the rise + // a car on that one phase may have made (riseAheadA). + aloneA [3]float64 } // evMoveA is half a charger step. An order landing shows on the site meter @@ -261,6 +265,9 @@ func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { alikeA, oneA, phase = chargerMoveA(prev[:len(amps)], now[:len(amps)]) } for id, c := range lim.offers { + if read && c.riseSet { + c.aloneA = addAloneA(c.aloneA, prev[:len(amps)], now[:len(amps)]) + } if read && c.readable() { var moveA [3]float64 switch { @@ -303,6 +310,22 @@ func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { } } +// addAloneA adds to each phase's alone rise its move from one reading to +// the next when every other phase moved less than a step (evStepA): no +// charger step landed there, so the move may be a car on that one phase. +func addAloneA(aloneA [3]float64, prev, now []float64) [3]float64 { + for p := range now { + held := true + for q := range now { + held = held && (q == p || math.Abs(now[q]-prev[q]) < evStepA) + } + if held { + aloneA[p] += now[p] - prev[p] + } + } + return aloneA +} + // batteryOrderMoves reports whether one of our battery orders of the last // evSettle moves the site's phases by evMoveA or more. func (s *State) batteryOrderMoves() bool { @@ -458,12 +481,15 @@ func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingL // show. A three-phase order takes the smallest rise across the phases: a // house step on some phases does not show the raise on the rest. A car on // one phase takes its own phase's rise, or the largest where the phase is -// not known. For a car not yet seen on three phases, one phase that has -// risen alone, the others within evMoveA, may be the car taking the raise -// on one phase. A landing counts in whatever shape it comes, a slow ramp or -// beside a house load that moves in the same reading. A house load that -// drops while the raise travels still reads as raise not shown yet, by -// as much as it dropped. +// not known. For a car not yet seen on three phases, a phase that has +// risen a step or more alone may be the car taking the raise on one phase: +// its rise over the readings in which the other phases moved less than a +// step (aloneA). Each reading is judged against the one before, so house +// drift on another phase while the raise travels, or under a step in the +// landing reading, does not hide it. A landing counts in whatever shape +// it comes, a slow ramp or beside a house load that moves in the same +// reading. A house load that drops while the raise travels still reads as +// raise not shown yet, by as much as it dropped. func (s *State) riseAheadA(c chargerOffers) (ahead [3]float64) { lim := &s.evLimit n := min(s.SiteFusePhases, len(ahead)) @@ -476,14 +502,18 @@ func (s *State) riseAheadA(c chargerOffers) (ahead [3]float64) { rises[i] = lim.phasesA[i] - c.riseBaseA[i] } riseA, phases := slices.Min(rises), ahead[:n] - _, oneA, _ := chargerMoveA(c.riseBaseA[:n], lim.phasesA[:n]) switch { case c.carPhase > 0 && c.carPhase <= n: riseA, phases = rises[c.carPhase-1], ahead[c.carPhase-1:c.carPhase] case c.carPhases == 1 || latest.n == 1: riseA = slices.Max(rises) - case c.carPhases != 3 && oneA > 0: - riseA = oneA // A car not yet seen may draw on the one phase that rose. + case c.carPhases != 3: + // A car not yet seen may draw on a phase that rose alone. + for i := range rises { + if c.aloneA[i] >= 2*evMoveA { + riseA = math.Max(riseA, math.Min(c.aloneA[i], rises[i])) + } + } } low, high := c.offerRangeA(lim.readingAt) toComeA := math.Max(0, latest.a-math.Min(high, math.Max(low, c.riseFromA+riseA))) @@ -522,7 +552,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) if n == 0 && !s.evLimit.set { c.found, c.shownA = sent, [3]float64{sent.a, sent.a, sent.a} } else if _, high := c.offerRangeA(now); sent.a > high && !c.riseSet && s.evLimit.phasesSeen { - c.riseBaseA, c.riseFromA, c.riseSet = s.evLimit.phasesA, high, true + c.riseBaseA, c.riseFromA, c.riseSet, c.aloneA = s.evLimit.phasesA, high, true, [3]float64{} } c.log = c.log.keep(sent, read.Add(-c.settle)) } diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go index 9ee869a12..6a6e0479b 100644 --- a/go/internal/control/ev_fuse_cut_netting_test.go +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -568,6 +568,48 @@ func TestEVFuseRaiseThatLandedIsNotCountedTwice(t *testing.T) { } } +// A one-phase car the loadpoint has not seen yet takes a three-phase raise +// on L1 while the house drifts on L2. 25 A fuse, 40 s charger: the car goes +// from 6 to 16 A, L1 lands at 24 A, and no phase is ever over the line. +// Before, the one-phase landing counted only while L2 stayed within 0.5 A +// of its reading when the raise went out, over the whole response time: +// 0.6 A of drift made the raise count as still to come, L1 read 34 A, and +// Core cut to 4,830 W one second after the landing (field matrix with +// 0.7 A on L2 every 17 s: car1p cut 11,100 -> 0 W). The landing now counts +// when L2 moves less than a step in the reading it lands on, or drifts +// before it. +func TestEVFuseUnseenOnePhaseCarLandsBesideDrift(t *testing.T) { + for _, tc := range []struct { + name string + before, landA float64 // L2 drift before the landing, and in its reading + }{ + {"no_drift", 0, 0}, + {"drift_0.6A_before", 0.6, 0}, + {"drift_1A_before", 1, 0}, + {"drift_-0.6A_before", -0.6, 0}, + {"drift_0.7A_in_the_landing_reading", 0, 0.7}, + {"drift_-0.7A_in_the_landing_reading", 0, -0.7}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newCutRig(t, 4140, 40) // 6 A a phase ordered + for range 120 { + r.read([3]float64{14, 5, 5}) // car 6 A on L1, house 8 A + } + r.offerW = 11040 // 16 A + r.read([3]float64{14, 5, 5}) + for range 10 { + r.read([3]float64{14, 5 + tc.before, 5}) + } + l2 := 5 + tc.before + tc.landA + for i := range 120 { + if r.read([3]float64{24, l2, 5}); r.offerW != 11040 { + t.Fatalf("one-phase car landed at 24 A on a 25 A fuse, L2 %.1f A: cut to %.0f W after %d s", l2, r.offerW, i+1) + } + } + }) + } +} + // A one-phase load that cycles on a phase is not a three-phase cut landing, // even for a car not yet seen on three phases. The house falls from 18 to // 10 A a phase, so the car rises toward the line, while 1.5 A on L2 From 6b10baa1d8ad158e37415efa7e8317727fcffb07 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 06:19:41 +0100 Subject: [PATCH 24/34] fix(control): restart credit keeps room for the car's rise to its max After a restart Core does not know the offer in force on the charger, so a car drawing below it may still rise before Core's order lands. The credit now counts only the draw less that possible rise past the latest offer, with the rise measured to the loadpoint's max_charge_w; a charger with no max_charge_w gets no credit. The charger and output reads move into health-gated telemetry accessors (OnlineLowerW, SumOnlineHigherW). Tests cover the raw and smoothed guards, a 10 s OCPP sample cadence on the plant, and the review's car rising under a 16 A offer in force. Co-Authored-By: Claude Opus 5.5 --- .changeset/s4-ev-base-after-restart.md | 2 +- .../control/ev_base_after_restart_test.go | 167 +++++++++++++++--- go/internal/control/ev_battery_headroom.go | 2 +- go/internal/control/ev_fuse_budget.go | 63 +++---- go/internal/control/sitesim_test.go | 18 +- go/internal/telemetry/store.go | 30 ++++ go/internal/telemetry/telemetry_test.go | 43 +++++ 7 files changed, 264 insertions(+), 61 deletions(-) diff --git a/.changeset/s4-ev-base-after-restart.md b/.changeset/s4-ev-base-after-restart.md index 8fcee1181..4e7dcd404 100644 --- a/.changeset/s4-ev-base-after-restart.md +++ b/.changeset/s4-ev-base-after-restart.md @@ -2,4 +2,4 @@ "ftw": patch --- -After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. The draw counts only when the charger measured it within one control interval of the site reading and its last poll succeeded. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. +After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. FTW does not know the charger's offer in force then, so the battery still keeps room for the car to rise from its draw to the charger's max_charge_w; a charger with no max_charge_w gets no such credit. The draw counts only when the charger measured it within one control interval of the site reading and its last poll succeeded, so a charger that samples every 30 s or more rarely gets it. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. diff --git a/go/internal/control/ev_base_after_restart_test.go b/go/internal/control/ev_base_after_restart_test.go index 772ac34bc..a4691b164 100644 --- a/go/internal/control/ev_base_after_restart_test.go +++ b/go/internal/control/ev_base_after_restart_test.go @@ -15,9 +15,10 @@ import ( // no margin, a battery FTW found discharging 5 kW, and a charger whose first // logged offers of this run are 0 A, sent while the meter was stale, then // 6,210 W (9 A on three phases) 2 s ago. The charger takes 40 s to apply an -// order, so the car still draws what it drew before the restart. evW is -// what the charger measures; the site meter reads gridW with the phase -// currents phaseA, and the PV inverter pvW. +// order, so the car still draws what it drew before the restart. Its +// max_charge_w is 11,040 W (16 A). evW is what the charger measures; the +// site meter reads gridW with the phase currents phaseA, and the PV +// inverter pvW. func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pvW, evW float64) (*telemetry.Store, *State) { t.Helper() store := telemetry.NewStore() @@ -45,7 +46,7 @@ func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pv state.evLimit.set = true state.evLimit.offers = map[string]chargerOffers{"garage": { log: commandLog{found, {at: now.Add(-4 * time.Second), w: 0, a: 0, n: 3}, {at: now.Add(-2 * time.Second), w: 6210, a: 9, n: 3}}, - settle: evUnknownSettle, found: found, driver: "charger", + settle: evUnknownSettle, found: found, driver: "charger", maxW: 11040, }} return store, state } @@ -54,14 +55,35 @@ func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pv // while the meter was stale counted as the EV base the meter shows, while // the meter showed the car at 8.2 kW. The new 6.2 kW order then counted as // a 9 A rise to come, and the battery was held in discharge for the car -// with battery_covers_ev off, down toward its floor. The car's measured -// draw is in the reading: the 6.2 kW order is a cut, and ending the -// discharge needs no room for it. +// with battery_covers_ev off, down toward its floor (-3,599 W here). The +// car's measured draw is in the reading: the 6.2 kW order is a cut. Until +// it lands the car may still rise to the 16 A of an offer Core does not +// know, so the battery keeps only the discharge that rise needs on L2. func TestEVBaseAfterRestartCreditsMeasuredDraw(t *testing.T) { store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) - if got := targetW(out, "sungrow"); got < -1 { - t.Fatalf("battery held at %.0f W for a car the meter already shows at 8.2 kW; want 0 W", got) + riseA := (11040 - 8222) / 690.0 + want := -(5000 - (16-4.97-riseA)*690) + if got := targetW(out, "sungrow"); math.Abs(got-want) > 1 { + t.Fatalf("battery %.0f W, want %.0f W: only the car's rise to its 16 A max needs room", got, want) + } +} + +// Review of #1926: a car below the offer in force. The car draws 8.2 kW, +// fresh and inside what the meter shows, but the charger's offer from +// before the restart, or its own default, is 16 A. Until Core's 6.2 kW +// order lands the car may rise to 11 kW (a ramp, a heater). The battery +// keeps the discharge that rise needs. +func TestEVBaseAfterRestartCarBelowOfferInForce(t *testing.T) { + houseA := 3.0 + evW, batW := 8222.0, -5000.0 + gridW := houseA*690 + evW + batW + phaseA := gridW / 690 + store, state := restartFixture(t, gridW, [3]float64{phaseA, phaseA, phaseA}, batW, 0, evW) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + got := targetW(out, "sungrow") + if afterA := phaseA + (got-batW)/690 + (11040-evW)/690; afterA > 16+1e-6 { + t.Fatalf("battery ends discharge to %.0f W; a car rising to its offer in force puts %.2f A on each phase", got, afterA) } } @@ -81,20 +103,46 @@ func TestEVBaseAfterRestartCreditStaysInsideTheMeter(t *testing.T) { } } -// The credit never exceeds the latest offer, needs a live charger reading, -// and ends once the charger's first offer of this run has had its settle -// time: then the offers alone say what the meter shows. +// setMaxW sets the fixture charger's max_charge_w. +func setMaxW(state *State, w float64) { + c := state.evLimit.offers["garage"] + c.maxW = w + state.evLimit.offers["garage"] = c +} + +// The credit never exceeds the latest offer, keeps room for the car's rise +// to its max, takes the lowest of the sample, raw and smoothed draw, needs +// a live charger reading, and ends once the charger's first offer of this +// run has had its settle time: then the offers alone say what the meter +// shows. func TestEVFoundDrawWBounds(t *testing.T) { for _, tc := range []struct { name string edit func(*telemetry.Store, *State) want float64 }{ - {"latest offer bounds the draw", func(*telemetry.Store, *State) {}, 6210}, + {"the car may still rise to its max", func(*telemetry.Store, *State) {}, 8222 - (11040 - 6210)}, + {"no max_charge_w", func(_ *telemetry.Store, state *State) { setMaxW(state, 0) }, 0}, + {"latest offer bounds the draw", func(_ *telemetry.Store, state *State) { setMaxW(state, 6210) }, 6210}, {"measured draw below the offer", func(store *telemetry.Store, state *State) { + setMaxW(state, 6210) store.Update("charger", telemetry.DerEV, 4000, nil, nil) store.Get("charger", telemetry.DerEV).UpdatedAt = state.now() }, 4000}, + {"smoothed draw below the sample", func(store *telemetry.Store, state *State) { + setMaxW(state, 6210) + store.Get("charger", telemetry.DerEV).SmoothedW = 5000 + }, 5000}, + {"raw draw below the sample", func(store *telemetry.Store, state *State) { + setMaxW(state, 6210) + r := store.Get("charger", telemetry.DerEV) + r.Data = json.RawMessage(`{"control_power_w":8222}`) + r.RawW = 5000 + }, 5000}, + {"control sample below raw and smoothed", func(store *telemetry.Store, state *State) { + setMaxW(state, 6210) + store.Get("charger", telemetry.DerEV).Data = json.RawMessage(`{"control_power_w":5000}`) + }, 5000}, {"charger offline", func(store *telemetry.Store, _ *State) { store.DriverHealthMut("charger").SetOffline() }, 0}, @@ -114,7 +162,7 @@ func TestEVFoundDrawWBounds(t *testing.T) { c.found.at = state.now().Add(-30 * time.Second) c.log[0].at = c.found.at state.evLimit.offers["garage"] = c - }, 6210}, + }, 8222 - (11040 - 6210)}, {"the shown part of the latest offer is not counted twice", func(_ *telemetry.Store, state *State) { c := state.evLimit.offers["garage"] c.found.at = state.now().Add(-60 * time.Second) @@ -126,7 +174,8 @@ func TestEVFoundDrawWBounds(t *testing.T) { store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) tc.edit(store, state) shownW := state.evShownW(state.now()) - if got := state.evFoundDrawW(store, store.Get("meter", telemetry.DerMeter), state.now(), shownW); math.Abs(got-tc.want) > 1e-6 { + meter := store.Get("meter", telemetry.DerMeter) + if got := state.evFoundDrawW(store, meter.RawW, meter.UpdatedAt, state.now(), shownW); math.Abs(got-tc.want) > 1e-6 { t.Fatalf("credit %.0f W beside %.0f W shown, want %.0f W", got, shownW, tc.want) } }) @@ -138,7 +187,9 @@ func TestEVFoundDrawWBounds(t *testing.T) { // shows as consumption only 3,780 W is left for this car. func TestEVFoundDrawWLeavesOutShownOffers(t *testing.T) { store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) - if got := state.evFoundDrawW(store, store.Get("meter", telemetry.DerMeter), state.now(), 5000); math.Abs(got-3780) > 1e-6 { + setMaxW(state, 6210) + meter := store.Get("meter", telemetry.DerMeter) + if got := state.evFoundDrawW(store, meter.RawW, meter.UpdatedAt, state.now(), 5000); math.Abs(got-3780) > 1e-6 { t.Fatalf("credit %.0f W, want 3780 W", got) } } @@ -210,16 +261,27 @@ func TestEVBaseAfterRestartConsumptionReadsCurrentOutput(t *testing.T) { } } -// Seed 207532's shape on the plant: Core restarts beside a car drawing -// 12 A from before, on a charger that takes 40 s to apply an order. For -// its first 6 s the meter is silent and the inverter unreachable, holding -// the 5 kW discharge it had (C1), so Core logs 0 A offers. Then Core asks -// 9 A for the car. PV covers the house, the plan is stale and -// battery_covers_ev is off: the battery ends its discharge as fast as its -// slew allows, and no phase passes the fuse line on the way. -func TestSiteSimRestartEndsDischargeForACarAlreadyDrawing(t *testing.T) { +// The consumption bound subtracts each output at the higher of raw and +// smoothed. A battery whose reading already shows a new 3 kW discharge, +// smoothed still at 1 kW, may be ahead of the site reading; so may PV. +func TestSiteConsumptionWTakesTheSmallerOutput(t *testing.T) { + store, _ := restartFixture(t, 680, [3]float64{4.97, -1, -1}, -3000, -2000, 8222) + store.Get("sungrow", telemetry.DerBattery).SmoothedW = -1000 + store.Get("pv", telemetry.DerPV).SmoothedW = -500 + if got := siteConsumptionW(store, 680); math.Abs(got-2180) > 1e-6 { + t.Fatalf("consumption %.0f W, want 2180 W: 680 W less -1,000 W battery and -500 W PV", got) + } +} + +// restartSim is seed 207532's shape on the plant: Core restarts beside a +// car drawing 12 A from before, on a charger that takes 40 s to apply an +// order. For its first 6 s the meter is silent and the inverter +// unreachable, holding the 5 kW discharge it had (C1), so Core logs 0 A +// offers. Then Core asks 9 A for the car. PV covers the house total, the +// plan is stale and battery_covers_ev is off. +func restartSim(house [3]float64) *simSite { p := simPlant(simStart) - p.Fuse.RatingA, p.HouseW, p.PVW = 16, houseA(4, 9, 2), -6000 + p.Fuse.RatingA, p.HouseW, p.PVW = 16, house, -6000 p.EVSE.Delay, p.EVSE.ReadbackLag = 40*time.Second, 0 p.EVSE.OfferA, p.EVSE.OfferPhases = 12, 3 p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .3, Latency: 2 * time.Second, W: -5000} @@ -233,16 +295,67 @@ func TestSiteSimRestartEndsDischargeForACarAlreadyDrawing(t *testing.T) { s.state.SiteFuseSafetyA = 0 s.ctrl.SetManualHold(s.lp.ID, loadpoint.ManualHold{PowerW: 6210, PhaseMode: "3p", Persistent: true, StartedAt: s.p.Now, UpdatedAt: s.p.Now}) } + return s +} + +// The seed on the plant: the battery ends its discharge as fast as its +// slew allows, down to what the car's rise to its 16 A max still needs on +// L2 (the 9 A of house load PV does not cover there), and no phase passes +// the fuse line on the way. +func TestSiteSimRestartEndsDischargeForACarAlreadyDrawing(t *testing.T) { + s := restartSim(houseA(4, 9, 2)) s.boot() s.run(14 * time.Second) if s.offerW != 6210 || s.p.EVSE.DrawW != 8280 { t.Fatalf("not the seed's state: offer %.0f W, car %.0f W", s.offerW, s.p.EVSE.DrawW) } - if s.batteryCmdW < -1 { - t.Fatalf("battery held at %.0f W 8 s after the meter returned, with the car already in the reading", s.batteryCmdW) + if want := -(9*230 - 2000.0) * 3; s.batteryCmdW < want-1 { + t.Fatalf("battery held at %.0f W 8 s after the meter returned, with the car already in the reading; want %.0f W", s.batteryCmdW, want) + } + s.run(2 * time.Minute) + if s.failure != "" { + t.Fatal(s.failure) + } +} + +// The seed's charger is OCPP and samples every 10 s (MeterValues). Its +// sample counts only on the ticks just after it, so the battery holds its +// discharge between samples. It still ends it before the 9 A order counts +// as shown (evSettle after it went out, about 28 s in), and no phase +// passes the fuse line. A charger sampling every 30 s or more gets no +// credit in that time: the fix is partial for it. +func TestSiteSimRestartTenSecondSamples(t *testing.T) { + s := restartSim(houseA(4, 9, 2)) + s.evEvery = 10 * time.Second + s.boot() + s.run(20 * time.Second) + if s.offerW != 6210 || s.p.EVSE.DrawW != 8280 { + t.Fatalf("not the seed's state: offer %.0f W, car %.0f W", s.offerW, s.p.EVSE.DrawW) + } + if want := -(9*230 - 2000.0) * 3; s.batteryCmdW < want-1 { + t.Fatalf("battery held at %.0f W 14 s after the meter returned; want %.0f W", s.batteryCmdW, want) } s.run(2 * time.Minute) if s.failure != "" { t.Fatal(s.failure) } } + +// Review of #1926 on the plant: the charger's offer in force is 16 A, and +// the car limits itself to 12 A at the restart, then lifts that 14 s +// later, before the 40 s charger has applied Core's order. L2 carries 12 A +// of house. The battery keeps the discharge the car's rise needs: no phase +// reaches 1.2 x In, and time over In stays within the heat model. +func TestSiteSimRestartCarRisesUnderOfferInForce(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + s.p.EVSE.OfferA, s.p.Car.MaxW = 16, 8280 + s.p.Events = append(s.p.Events, sitesim.Event{At: 14 * time.Second, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}) + s.boot() + s.run(2 * time.Minute) + if s.failure != "" { + t.Fatal(s.failure) + } + if s.peakA >= fuseInstantFactor*16 { + t.Fatalf("a phase reached %.2f A", s.peakA) + } +} diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index a2d57eda4..7098a2c74 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -38,7 +38,7 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st return floorPositiveEVBatteryTargets(targets) } offeredW := state.evShownW(now) - offeredW += state.evFoundDrawW(store, meter, now, offeredW) + offeredW += state.evFoundDrawW(store, gridW, meter.UpdatedAt, now, offeredW) needW := math.Max(0, demandW-offeredW) latest := make(map[string]float64, len(targets)) lowest := make(map[string]float64, len(targets)) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index b3f036221..eb320f7ef 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -181,8 +181,10 @@ type chargerOffers struct { found sentCommand // driver is the charger's driver, whose power reading only the battery // side reads, and only before any offer of this run has surely landed - // (evFoundDrawW). + // (evFoundDrawW). maxW is the loadpoint's max_charge_w, the most the car + // may draw on an offer Core does not know; zero when not configured. driver string + maxW float64 // carPhases is how many phases the car draws on, as the loadpoint saw // it on the site meter: 1 or 3, or 0 when unknown. carPhase is the phase // a car on one phase draws on, 1-3. @@ -403,23 +405,31 @@ func (s *State) evShownW(at time.Time) float64 { // evFoundDrawW is EV draw the battery side may count as shown beyond // evShownW (shownW) while a charger has no order of this run that has -// surely landed: after a restart or a plug-in, until its first logged offer -// is older than its settle time. Core then does not know which offer the -// charger runs on, and its first offers (often 0 A) say nothing of what the -// meter shows. A car seen drawing in that window is in the reading when the -// charger measured it within one control interval of the site reading: a -// fast charger may have applied a 0 A offer since an older sample, and a -// held reading after a failed poll says nothing of now. Each such charger -// counts its measured draw, the lowest of the sample, raw and smoothed, -// never above its latest offer, and in all no more than the consumption the +// surely landed: from the first offer Core logs for it with no fuse limit +// set (at Core's start, or at a plug-in once no other managed charger was +// plugged), until that offer is older than the charger's settle time. Core +// then does not know which offer the charger runs on, and its first offers +// (often 0 A) say nothing of what the meter shows. A car seen drawing in +// that window is in the reading when the charger measured it within one +// control interval of the site reading: a fast charger may have applied a +// 0 A offer since an older sample, and a held reading after a failed poll +// says nothing of now. A charger that samples less often (OCPP MeterValues +// every 10 s or more) counts only on the ticks just after a sample, and +// holds its battery's discharge between them. The measured draw is the +// lowest of the sample, raw and smoothed. Until the latest offer lands, the +// car may still rise from that draw to the offer in force, up to the +// loadpoint's max_charge_w; that rise past the latest offer stays reserved, +// and a charger with no max_charge_w counts nothing. Each charger counts no +// more than its latest offer, and in all no more than the consumption the // site meter shows (siteConsumptionW) less shownW. The car's own limit // never reads this; only how much of our latest offers the battery reserves // as a rise to come. -func (s *State) evFoundDrawW(store *telemetry.Store, meter *telemetry.DerReading, at time.Time, shownW float64) float64 { - if store == nil || meter == nil { +// gridW is the site reading, taken at meterAt. +func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at time.Time, shownW float64) float64 { + if store == nil { return 0 } - consumptionW := siteConsumptionW(store, meter.RawW) + consumptionW := siteConsumptionW(store, gridW) if !finiteFuseValue(consumptionW) { return 0 } @@ -430,24 +440,25 @@ func (s *State) evFoundDrawW(store *telemetry.Store, meter *telemetry.DerReading var w float64 for _, c := range s.evLimit.offers { n := len(c.log) - if n == 0 || c.found.at.IsZero() || at.Sub(c.found.at) >= c.settle || c.driver == "" { + if n == 0 || c.found.at.IsZero() || at.Sub(c.found.at) >= c.settle || c.driver == "" || !(c.maxW > 0) || !finiteFuseValue(c.maxW) { continue } - r := store.Get(c.driver, telemetry.DerEV) - if h := store.DriverHealth(c.driver); r == nil || !h.TelemetryLive() || h.LastReadFailed() { + lowerW, live := store.OnlineLowerW(c.driver, telemetry.DerEV) + if !live { continue } - p, ok := qualifiedFuseObservation(r, at) - if !ok || p.Seen().Before(meter.UpdatedAt.Add(-skew)) || p.At.After(meter.UpdatedAt.Add(skew)) { + p, ok := qualifiedFuseObservation(store.Get(c.driver, telemetry.DerEV), at) + if !ok || p.Seen().Before(meterAt.Add(-skew)) || p.At.After(meterAt.Add(skew)) { continue } - drawW := math.Min(p.PowerW, math.Min(r.RawW, r.SmoothedW)) + drawW := math.Min(p.PowerW, lowerW) if !finiteFuseValue(drawW) { continue } unshown, _ := c.log.unshown(at, c.batterySettle()) latest := c.log[n-1].w - w += math.Max(0, math.Min(latest, drawW)-math.Max(0, latest-unshown)) + inLatestW := math.Min(latest, drawW-math.Max(0, c.maxW-latest)) + w += math.Max(0, inLatestW-math.Max(0, latest-unshown)) } return math.Max(0, math.Min(w, consumptionW-shownW)) } @@ -457,15 +468,7 @@ func (s *State) evFoundDrawW(store *telemetry.Store, meter *telemetry.DerReading // smoothed. A smoothed value lags an output that just fell; the higher one // keeps the estimate at or below what the meter shows. func siteConsumptionW(store *telemetry.Store, gridW float64) float64 { - w := gridW - for _, t := range []telemetry.DerType{telemetry.DerBattery, telemetry.DerPV, telemetry.DerV2X} { - for _, r := range store.ReadingsByType(t) { - if store.DriverHealth(r.Driver).TelemetryLive() { - w -= math.Max(r.RawW, r.SmoothedW) - } - } - } - return w + return gridW - store.SumOnlineHigherW(telemetry.DerBattery, telemetry.DerPV, telemetry.DerV2X) } // batterySettle is how long the battery side counts a raise of this @@ -616,7 +619,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) continue } c := s.evLimit.offers[point.ID] - c.settle, c.declared, c.carPhases, c.carPhase, c.driver = evUnknownSettle, false, point.CarPhases, point.CarPhase, point.DriverName + c.settle, c.declared, c.carPhases, c.carPhase, c.driver, c.maxW = evUnknownSettle, false, point.CarPhases, point.CarPhase, point.DriverName, point.MaxChargeW if r := store.Get(point.DriverName, telemetry.DerEV); r != nil { if declared := telemetry.DeclaredPowerMaxAge(r.Data); declared > 0 { c.settle, c.declared = max(evSettle, declared), true diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 09e8c1a42..36abc4603 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -45,6 +45,10 @@ type simSite struct { // evMaxAge is the power_max_age_s the charger driver declares outside // the field matrix; zero declares none. evMaxAge time.Duration + // evEvery is how often the charger samples its draw, as OCPP + // MeterValues do: between samples each poll repeats the last sample + // with its own time. Zero samples on every poll. + evEvery time.Duration // meterNoise is added to each phase current Core reads, not to the // plant's own; nil reads the plant exactly. meterNoise func() float64 @@ -240,8 +244,18 @@ func (s *simSite) step() { s.emit("battery", telemetry.DerBattery, b.W, &b.SoC, nil) } if e := tick.EV; e != nil { - s.ev, s.evAt = *e, now - s.emit(s.lp.DriverName, telemetry.DerEV, e.W, nil, s.evData()) + if s.evEvery <= 0 || s.evAt.IsZero() || now.Sub(s.evAt) >= s.evEvery { + s.ev, s.evAt = *e, now + } + data := s.evData() + if s.evEvery > 0 { + data = maps.Clone(data) + if data == nil { + data = map[string]any{} + } + data["control_power_observed_at"] = s.evAt.Format(time.RFC3339Nano) + } + s.emit(s.lp.DriverName, telemetry.DerEV, s.ev.W, nil, data) } if !now.Before(s.nextTick) { s.nextTick = now.Add(s.coreEvery()) diff --git a/go/internal/telemetry/store.go b/go/internal/telemetry/store.go index 32e668501..b87d97cb7 100644 --- a/go/internal/telemetry/store.go +++ b/go/internal/telemetry/store.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "math" + "slices" "strings" "sync" "time" @@ -688,6 +689,35 @@ func (s *Store) SumOnlineV2XW() float64 { return sum } +// OnlineLowerW returns the lower of a driver's raw and smoothed power of +// one type, while the driver is still reporting and its latest poll +// succeeded. A held reading after a failed poll returns false. +func (s *Store) OnlineLowerW(driver string, t DerType) (float64, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + r, found := s.readings[key(driver, t)] + h, known := s.health[driver] + if !found || !known || !h.TelemetryLive() || h.LastReadFailed() { + return 0, false + } + w := math.Min(r.RawW, r.SmoothedW) + return w, !math.IsNaN(w) && !math.IsInf(w, 0) +} + +// SumOnlineHigherW sums the higher of raw and smoothed power over the +// readings of these types whose drivers are still reporting. +func (s *Store) SumOnlineHigherW(types ...DerType) float64 { + s.mu.RLock() + defer s.mu.RUnlock() + var sum float64 + for _, r := range s.readings { + if h, ok := s.health[r.Driver]; ok && h.TelemetryLive() && slices.Contains(types, r.DerType) { + sum += math.Max(r.RawW, r.SmoothedW) + } + } + return sum +} + // OnlineMeterW returns a site meter's latest power and when it arrived, // while its driver is still reporting. A watchdog-offline meter returns // false, so its last value never counts as a new reading. diff --git a/go/internal/telemetry/telemetry_test.go b/go/internal/telemetry/telemetry_test.go index 2ac3d4412..56895f170 100644 --- a/go/internal/telemetry/telemetry_test.go +++ b/go/internal/telemetry/telemetry_test.go @@ -572,6 +572,49 @@ func TestSumOnlineV2XWIsSignedAndSkipsOffline(t *testing.T) { } } +func TestOnlineLowerWNeedsALivePoll(t *testing.T) { + s := NewStore() + s.Update("charger", DerEV, 8000, nil, nil) + s.DriverHealthMut("charger").RecordSuccess() + s.Get("charger", DerEV).SmoothedW = 5000 + if w, ok := s.OnlineLowerW("charger", DerEV); !ok || w != 5000 { + t.Fatalf("got %v %v, want the lower 5000 W", w, ok) + } + s.Get("charger", DerEV).RawW = 4000 + if w, ok := s.OnlineLowerW("charger", DerEV); !ok || w != 4000 { + t.Fatalf("got %v %v, want the lower 4000 W", w, ok) + } + s.DriverHealthMut("charger").RecordError("timeout") + if _, ok := s.OnlineLowerW("charger", DerEV); ok { + t.Fatal("a reading held after a failed poll counted") + } + s.DriverHealthMut("charger").RecordSuccess() + s.DriverHealthMut("charger").SetOffline() + if _, ok := s.OnlineLowerW("charger", DerEV); ok { + t.Fatal("an offline driver's reading counted") + } + if _, ok := s.OnlineLowerW("missing", DerEV); ok { + t.Fatal("a missing reading counted") + } +} + +func TestSumOnlineHigherWSumsTheNamedTypes(t *testing.T) { + s := NewStore() + s.Update("battery", DerBattery, -3000, nil, nil) + s.Update("pv", DerPV, -2000, nil, nil) + s.Update("ev", DerEV, 7000, nil, nil) + s.Update("offline", DerBattery, -9000, nil, nil) + for _, d := range []string{"battery", "pv", "ev"} { + s.DriverHealthMut(d).RecordSuccess() + } + s.DriverHealthMut("offline").SetOffline() + s.Get("battery", DerBattery).SmoothedW = -1000 + s.Get("pv", DerPV).SmoothedW = -2500 + if got := s.SumOnlineHigherW(DerBattery, DerPV); got != -3000 { + t.Fatalf("got %v, want -1000 W battery plus -2000 W PV", got) + } +} + func TestWatchdogPerDriverOverride(t *testing.T) { s := NewStore() // Two drivers: one with the site-default tolerance, one with a From 19d8a2983a9e6f58bb0cd4d2c45dcf58adfd8c37 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 06:25:50 +0100 Subject: [PATCH 25/34] test(control): Charge now landing shown before Core logs its offer Site rig s1-16, s1-25 and s15-25 failed on c9b52c36: the charger answered within a second, and the meter reading that showed the car at 15.9 A came in 7 ms before the tick that first logged the 16 A offer. The reading could not show an offer logged after it, so the landing counted as a raise still to come, 15.9 + 16 A passed 1.2 x In, and Core cut 11,040 W to 5,520 W with 9 A of room on every phase. The same run of the base at 281f9f63, without this branch's commits, fails the same way; #1923's ab027102, merged here in c0a52740, reads the meter's own rise and fixes it. This test pins that rig shape. Co-Authored-By: Claude Opus 5.5 --- .../control/ev_fuse_offer_logged_late_test.go | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 go/internal/control/ev_fuse_offer_logged_late_test.go diff --git a/go/internal/control/ev_fuse_offer_logged_late_test.go b/go/internal/control/ev_fuse_offer_logged_late_test.go new file mode 100644 index 000000000..d8674a3b2 --- /dev/null +++ b/go/internal/control/ev_fuse_offer_logged_late_test.go @@ -0,0 +1,60 @@ +package control + +import ( + "encoding/json" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Site rig s1 and s15 (28 Oct 2026, c9b52c36): Charge now from idle on a +// 25 A or 16 A fuse, 3 A of house per phase covered by the battery, so every phase +// reads 0 A. The charger answers in about a second. Core logs an offer when +// the next control tick reads the loadpoint's command, 2 s after it went +// out, and that tick's meter reading came in 7 ms before it. The reading +// already shows the car at 15.9 A, but it is older than the logged offer. +// The landing then read as a raise not shown yet: 15.9 + 16 A passed +// 1.2 x In, and Core cut 11,040 W to 5,520 W with 9 A of room on every +// phase ("EV hard limit: site meter over limit ... worst_phase_a=15.9 +// rise_on_its_way_a=[16 16 16] cap_w=5520"). The car then crept back 1 A +// at a time and held 9,660 W on fuse_limit. Since #1923's ab027102 the +// rise still to come reads the meter's own rise since the raise went out. +func TestEVFuseRaiseShownBeforeItsOfferIsLogged(t *testing.T) { + for _, tc := range []struct { + name string + fuseA float64 + phases int + offerW float64 + landedA [3]float64 + }{ + {"s1_25A_fuse", 25, 3, 11040, [3]float64{15.9, 15.9, 15.9}}, + {"s1_16A_fuse", 16, 3, 10350, [3]float64{15, 15, 15}}, + {"s15_one_phase_charger_on_L1", 25, 1, 3680, [3]float64{16, 0, 0}}, + } { + t.Run(tc.name, func(t *testing.T) { + r := newCutRig(t, 0, 0) + r.phases, r.state.SiteFuseAmps = tc.phases, tc.fuseA + // Each reading comes in 7 ms before the tick that reads it. + read := func(phaseA [3]float64) { + t.Helper() + data, _ := json.Marshal(map[string]any{"l1_a": phaseA[0], "l2_a": phaseA[1], "l3_a": phaseA[2]}) + r.now = r.now.Add(2 * time.Second) + r.store.Update("meter", telemetry.DerMeter, (phaseA[0]+phaseA[1]+phaseA[2])*230, nil, data) + r.store.Get("meter", telemetry.DerMeter).UpdatedAt = r.now.Add(-7 * time.Millisecond) + _, _, points, configs := evMeterBudgetFixture(t, 0, nil, r.offerW, "3p") + points[0].CommandedPhases = r.phases + if capW, _ := EVFuseBudget(r.store, r.state, points, configs, tc.fuseA*690); capW < r.offerW { + t.Fatalf("offer %.0f W cut to %.0f W with the car at %v A on a %.0f A fuse", r.offerW, capW, phaseA, tc.fuseA) + } + } + for range 15 { + read([3]float64{}) + } + r.offerW = tc.offerW // sent at the last tick, logged at this one + for range 60 { + read(tc.landedA) + } + }) + } +} From caacc8d4435a4db0bf5608dbf09eb1fa057e5d1a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 06:45:38 +0100 Subject: [PATCH 26/34] fix(control): restart credit counts no sample newer than the meter The battery side's restart credit (evFoundDrawW) had two gaps. A charger sample measured after the site reading counted as part of it. A car that rose in that gap was credited for draw the meter did not show yet, and the battery ended its discharge into room the rise had taken. With a 3 s meter, L2 reached 19.30 A. A sample now counts only when measured at or before the site reading. The rise reserve took max_charge_w as the most the unknown offer in force can be. A charger still on an offer from before a restart that lowered max_charge_w, or on its own higher default, could rise past it: 19.00 A per phase on the unit fixture, 19.30 A on the plant. The reserve now runs to the larger of max_charge_w and the site fuse's rating. Co-Authored-By: Claude Opus 5.5 --- .changeset/s4-ev-base-after-restart.md | 2 +- .../control/ev_base_after_restart_test.go | 86 +++++++++++++++++-- go/internal/control/ev_fuse_budget.go | 31 ++++--- 3 files changed, 96 insertions(+), 23 deletions(-) diff --git a/.changeset/s4-ev-base-after-restart.md b/.changeset/s4-ev-base-after-restart.md index 4e7dcd404..42917a3a5 100644 --- a/.changeset/s4-ev-base-after-restart.md +++ b/.changeset/s4-ev-base-after-restart.md @@ -2,4 +2,4 @@ "ftw": patch --- -After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. FTW does not know the charger's offer in force then, so the battery still keeps room for the car to rise from its draw to the charger's max_charge_w; a charger with no max_charge_w gets no such credit. The draw counts only when the charger measured it within one control interval of the site reading and its last poll succeeded, so a charger that samples every 30 s or more rarely gets it. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. +After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. FTW does not know the charger's offer in force then: one from before the restart, under an older max_charge_w, or the charger's own default. So the battery still keeps room for the car to rise from its draw to the larger of the charger's max_charge_w and the site fuse's rating. On a site whose fuse allows more than the car's draw plus FTW's latest order, that leaves no credit; a charger with no max_charge_w gets none either. The draw counts only when the charger measured it within one control interval before the site reading, not after it, and its last poll succeeded, so a charger that samples every 30 s or more rarely gets it. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. diff --git a/go/internal/control/ev_base_after_restart_test.go b/go/internal/control/ev_base_after_restart_test.go index a4691b164..98e484038 100644 --- a/go/internal/control/ev_base_after_restart_test.go +++ b/go/internal/control/ev_base_after_restart_test.go @@ -33,6 +33,8 @@ func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pv } store.Update("charger", telemetry.DerEV, evW, nil, nil) store.RecordDriverSuccess("charger") + // The charger measured its draw with the site reading, not after it. + store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt state := NewState(0, 0, "meter") now := time.Now() @@ -110,6 +112,13 @@ func setMaxW(state *State, w float64) { state.evLimit.offers["garage"] = c } +// setRiseCapW puts both max_charge_w and the site fuse at w, the most an +// offer in force may be, so a case can test the other bounds alone. +func setRiseCapW(state *State, w float64) { + setMaxW(state, w) + state.SiteFuseAmps = w / (state.SiteFuseVoltage * float64(state.SiteFusePhases)) +} + // The credit never exceeds the latest offer, keeps room for the car's rise // to its max, takes the lowest of the sample, raw and smoothed draw, needs // a live charger reading, and ends once the charger's first offer of this @@ -123,24 +132,31 @@ func TestEVFoundDrawWBounds(t *testing.T) { }{ {"the car may still rise to its max", func(*telemetry.Store, *State) {}, 8222 - (11040 - 6210)}, {"no max_charge_w", func(_ *telemetry.Store, state *State) { setMaxW(state, 0) }, 0}, - {"latest offer bounds the draw", func(_ *telemetry.Store, state *State) { setMaxW(state, 6210) }, 6210}, + {"max_charge_w below the fuse: the offer in force may still be the fuse's", func(_ *telemetry.Store, state *State) { + setMaxW(state, 7360) + }, 8222 - (11040 - 6210)}, + {"a charger sample measured after the site reading", func(store *telemetry.Store, state *State) { + store.Get("meter", telemetry.DerMeter).UpdatedAt = state.now().Add(-time.Second) + store.Get("charger", telemetry.DerEV).UpdatedAt = state.now() + }, 0}, + {"latest offer bounds the draw", func(_ *telemetry.Store, state *State) { setRiseCapW(state, 6210) }, 6210}, {"measured draw below the offer", func(store *telemetry.Store, state *State) { - setMaxW(state, 6210) + setRiseCapW(state, 6210) store.Update("charger", telemetry.DerEV, 4000, nil, nil) - store.Get("charger", telemetry.DerEV).UpdatedAt = state.now() + store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt }, 4000}, {"smoothed draw below the sample", func(store *telemetry.Store, state *State) { - setMaxW(state, 6210) + setRiseCapW(state, 6210) store.Get("charger", telemetry.DerEV).SmoothedW = 5000 }, 5000}, {"raw draw below the sample", func(store *telemetry.Store, state *State) { - setMaxW(state, 6210) + setRiseCapW(state, 6210) r := store.Get("charger", telemetry.DerEV) r.Data = json.RawMessage(`{"control_power_w":8222}`) r.RawW = 5000 }, 5000}, {"control sample below raw and smoothed", func(store *telemetry.Store, state *State) { - setMaxW(state, 6210) + setRiseCapW(state, 6210) store.Get("charger", telemetry.DerEV).Data = json.RawMessage(`{"control_power_w":5000}`) }, 5000}, {"charger offline", func(store *telemetry.Store, _ *State) { @@ -187,7 +203,7 @@ func TestEVFoundDrawWBounds(t *testing.T) { // shows as consumption only 3,780 W is left for this car. func TestEVFoundDrawWLeavesOutShownOffers(t *testing.T) { store, state := restartFixture(t, 780, [3]float64{2.03, 4.97, .47}, -5000, -3000, 8222) - setMaxW(state, 6210) + setRiseCapW(state, 6210) meter := store.Get("meter", telemetry.DerMeter) if got := state.evFoundDrawW(store, meter.RawW, meter.UpdatedAt, state.now(), 5000); math.Abs(got-3780) > 1e-6 { t.Fatalf("credit %.0f W, want 3780 W", got) @@ -348,8 +364,17 @@ func TestSiteSimRestartTenSecondSamples(t *testing.T) { // reaches 1.2 x In, and time over In stays within the heat model. func TestSiteSimRestartCarRisesUnderOfferInForce(t *testing.T) { s := restartSim(houseA(4, 12, 2)) - s.p.EVSE.OfferA, s.p.Car.MaxW = 16, 8280 - s.p.Events = append(s.p.Events, sitesim.Event{At: 14 * time.Second, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}) + carRisesUnderOldOffer(t, s, 8280, 14*time.Second) +} + +// carRisesUnderOldOffer runs s with the charger still on the 16 A offer it +// had before the restart, and a car that draws fromW until it lifts its own +// limit at riseAt. No phase may reach 1.2 x In, and the sim's fuse checks +// must pass. +func carRisesUnderOldOffer(t *testing.T, s *simSite, fromW float64, riseAt time.Duration) { + t.Helper() + s.p.EVSE.OfferA, s.p.Car.MaxW = 16, fromW + s.p.Events = append(s.p.Events, sitesim.Event{At: riseAt, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}) s.boot() s.run(2 * time.Minute) if s.failure != "" { @@ -359,3 +384,46 @@ func TestSiteSimRestartCarRisesUnderOfferInForce(t *testing.T) { t.Fatalf("a phase reached %.2f A", s.peakA) } } + +// Third review of #1926: a site meter that samples every 3 s. The car draws +// 10 A under the 16 A offer in force and lifts its own limit 11 s in. The +// charger reads the rise before the next meter sample, so its sample is +// newer than the site reading, which still shows the old draw. That sample +// does not count as in the reading, and the battery keeps the discharge +// the rise needs. Before: L2 reached 19.30 A. +func TestSiteSimRestartCarRisesBetweenMeterSamples(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + s.p.Meter.Every = 3 * time.Second + carRisesUnderOldOffer(t, s, 6900, 11*time.Second) +} + +// Third review of #1926: max_charge_w is not the most an offer in force +// may be. The owner lowered it to 7,360 W (10.67 A) and restarted; the +// charger still runs the 16 A Core sent before. The car draws 7 kW and may +// rise to 11,040 W before the 6.2 kW order lands. The rise reserve runs to +// the site fuse, so the battery keeps the discharge that rise needs. +func TestEVBaseAfterRestartMaxChargeLoweredAtRestart(t *testing.T) { + house := 3.0 + evW, batW := 7000.0, -5000.0 + gridW := house*690 + evW + batW + phaseA := gridW / 690 + store, state := restartFixture(t, gridW, [3]float64{phaseA, phaseA, phaseA}, batW, 0, evW) + setMaxW(state, 7360) + out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "sungrow", TargetW: 0}}, store, state, 16*230*3) + got := targetW(out, "sungrow") + if afterA := phaseA + (got-batW)/690 + (11040-evW)/690; afterA > 16+1e-6 { + t.Fatalf("battery ends discharge to %.0f W; the car rising to the old 16 A offer puts %.2f A on each phase", got, afterA) + } +} + +// The plant form: max_charge_w is now 8,280 W (12 A), and the car lifts +// its own 12 A limit 14 s in, before the 40 s charger applies Core's order. +// Before: L2 reached 19.30 A. +func TestSiteSimRestartMaxChargeLoweredAtRestart(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + s.lp.MaxChargeW, s.lp.AllowedStepsW = 8280, []float64{0} + for a := 6.0; a <= 12; a++ { + s.lp.AllowedStepsW = append(s.lp.AllowedStepsW, a*690) + } + carRisesUnderOldOffer(t, s, 8280, 14*time.Second) +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index eb320f7ef..acabf847f 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -181,8 +181,8 @@ type chargerOffers struct { found sentCommand // driver is the charger's driver, whose power reading only the battery // side reads, and only before any offer of this run has surely landed - // (evFoundDrawW). maxW is the loadpoint's max_charge_w, the most the car - // may draw on an offer Core does not know; zero when not configured. + // (evFoundDrawW). maxW is the loadpoint's max_charge_w; zero when not + // configured. driver string maxW float64 // carPhases is how many phases the car draws on, as the loadpoint saw @@ -411,15 +411,19 @@ func (s *State) evShownW(at time.Time) float64 { // then does not know which offer the charger runs on, and its first offers // (often 0 A) say nothing of what the meter shows. A car seen drawing in // that window is in the reading when the charger measured it within one -// control interval of the site reading: a fast charger may have applied a -// 0 A offer since an older sample, and a held reading after a failed poll -// says nothing of now. A charger that samples less often (OCPP MeterValues -// every 10 s or more) counts only on the ticks just after a sample, and -// holds its battery's discharge between them. The measured draw is the -// lowest of the sample, raw and smoothed. Until the latest offer lands, the -// car may still rise from that draw to the offer in force, up to the -// loadpoint's max_charge_w; that rise past the latest offer stays reserved, -// and a charger with no max_charge_w counts nothing. Each charger counts no +// control interval before the site reading: a fast charger may have applied +// a 0 A offer since an older sample, a held reading after a failed poll +// says nothing of now, and a sample newer than the site reading may hold a +// rise the meter does not show yet. A charger that samples less often (OCPP +// MeterValues every 10 s or more) counts only on the ticks just after a +// sample, and holds its battery's discharge between them. The measured draw +// is the lowest of the sample, raw and smoothed. Until the latest offer +// lands, the car may still rise from that draw to the offer in force. Core +// does not know that offer: one from before a restart, under an older +// max_charge_w, or the charger's own default. The reserve takes it as the +// larger of the loadpoint's max_charge_w and the site fuse's rating, and +// keeps the rise past the latest offer; a charger with no max_charge_w +// counts nothing. Each charger counts no // more than its latest offer, and in all no more than the consumption the // site meter shows (siteConsumptionW) less shownW. The car's own limit // never reads this; only how much of our latest offers the battery reserves @@ -448,7 +452,7 @@ func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at continue } p, ok := qualifiedFuseObservation(store.Get(c.driver, telemetry.DerEV), at) - if !ok || p.Seen().Before(meterAt.Add(-skew)) || p.At.After(meterAt.Add(skew)) { + if !ok || p.Seen().Before(meterAt.Add(-skew)) || p.At.After(meterAt) { continue } drawW := math.Min(p.PowerW, lowerW) @@ -457,7 +461,8 @@ func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at } unshown, _ := c.log.unshown(at, c.batterySettle()) latest := c.log[n-1].w - inLatestW := math.Min(latest, drawW-math.Max(0, c.maxW-latest)) + capW := math.Max(c.maxW, s.siteFuseMaxW()) + inLatestW := math.Min(latest, drawW-math.Max(0, capW-latest)) w += math.Max(0, inLatestW-math.Max(0, latest-unshown)) } return math.Max(0, math.Min(w, consumptionW-shownW)) From 551febda5d5077e5bf14ffebd0920d205b150529 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 07:39:08 +0100 Subject: [PATCH 27/34] fix(control): restart credit holds against a charger clock behind Core's The restart credit compared a charger's sample time, stamped by the charger's own clock (OCPP MeterValues), with the time the site reading arrived on Core's clock. A charger clock 1 s or more behind Core's let a rise sampled after the reading count as already in it, and L2 reached 19.30 A on the third review's plant case. A sample now counts only when stamped evClockAllowance (5 s) before the reading arrived, and the draw is the lowest of that sample and every sample since. noteDraw keeps the charger's samples while the restart window is open. Also tests the max_charge_w half of the rise cap: a 22 kW charger on a 3 x 25 A fuse keeps the reserve up to max_charge_w. Co-Authored-By: Claude Opus 5.5 --- .changeset/s4-ev-base-after-restart.md | 2 +- .../control/ev_base_after_restart_test.go | 119 ++++++++++++++-- go/internal/control/ev_fuse_budget.go | 134 ++++++++++++++---- go/internal/control/sitesim_test.go | 8 +- 4 files changed, 216 insertions(+), 47 deletions(-) diff --git a/.changeset/s4-ev-base-after-restart.md b/.changeset/s4-ev-base-after-restart.md index 42917a3a5..02227f9ac 100644 --- a/.changeset/s4-ev-base-after-restart.md +++ b/.changeset/s4-ev-base-after-restart.md @@ -2,4 +2,4 @@ "ftw": patch --- -After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. FTW does not know the charger's offer in force then: one from before the restart, under an older max_charge_w, or the charger's own default. So the battery still keeps room for the car to rise from its draw to the larger of the charger's max_charge_w and the site fuse's rating. On a site whose fuse allows more than the car's draw plus FTW's latest order, that leaves no credit; a charger with no max_charge_w gets none either. The draw counts only when the charger measured it within one control interval before the site reading, not after it, and its last poll succeeded, so a charger that samples every 30 s or more rarely gets it. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. +After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. FTW does not know the charger's offer in force then: one from before the restart, under an older max_charge_w, or the charger's own default. So the battery still keeps room for the car to rise from its draw to the larger of the charger's max_charge_w and the site fuse's rating. On a site whose fuse allows more than the car's draw plus FTW's latest order, that leaves no credit; a charger with no max_charge_w gets none either. The draw counts only when the charger stamped a sample at least 5 s, and at most one control interval more, before the site reading arrived, and its last poll succeeded. The 5 s cover a charger clock running behind FTW's, as OCPP chargers stamp their own samples. The lowest sample since then counts, so a rise the meter does not show yet never does. A charger that samples every 30 s or more rarely gets the credit. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. diff --git a/go/internal/control/ev_base_after_restart_test.go b/go/internal/control/ev_base_after_restart_test.go index 98e484038..29c9ec0f4 100644 --- a/go/internal/control/ev_base_after_restart_test.go +++ b/go/internal/control/ev_base_after_restart_test.go @@ -2,6 +2,7 @@ package control import ( "encoding/json" + "maps" "math" "testing" "time" @@ -16,9 +17,9 @@ import ( // logged offers of this run are 0 A, sent while the meter was stale, then // 6,210 W (9 A on three phases) 2 s ago. The charger takes 40 s to apply an // order, so the car still draws what it drew before the restart. Its -// max_charge_w is 11,040 W (16 A). evW is what the charger measures; the -// site meter reads gridW with the phase currents phaseA, and the PV -// inverter pvW. +// max_charge_w is 11,040 W (16 A). evW is what the charger measures, +// stamped evClockAllowance before the site reading arrived; the site meter +// reads gridW with the phase currents phaseA, and the PV inverter pvW. func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pvW, evW float64) (*telemetry.Store, *State) { t.Helper() store := telemetry.NewStore() @@ -31,10 +32,7 @@ func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pv store.Update("pv", telemetry.DerPV, pvW, nil, nil) store.RecordDriverSuccess("pv") } - store.Update("charger", telemetry.DerEV, evW, nil, nil) - store.RecordDriverSuccess("charger") - // The charger measured its draw with the site reading, not after it. - store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt + setChargerSample(store, evW, nil) state := NewState(0, 0, "meter") now := time.Now() @@ -105,6 +103,24 @@ func TestEVBaseAfterRestartCreditStaysInsideTheMeter(t *testing.T) { } } +// chargerData is the fixture charger's data: its sample stamped +// evClockAllowance before the site reading arrived, with extra fields. +func chargerData(store *telemetry.Store, extra map[string]any) json.RawMessage { + at := store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-evClockAllowance) + d := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)} + maps.Copy(d, extra) + raw, _ := json.Marshal(d) + return raw +} + +// setChargerSample makes w the fixture charger's reading (chargerData), +// arriving with the site reading. +func setChargerSample(store *telemetry.Store, w float64, extra map[string]any) { + store.Update("charger", telemetry.DerEV, w, nil, chargerData(store, extra)) + store.RecordDriverSuccess("charger") + store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt +} + // setMaxW sets the fixture charger's max_charge_w. func setMaxW(state *State, w float64) { c := state.evLimit.offers["garage"] @@ -120,10 +136,11 @@ func setRiseCapW(state *State, w float64) { } // The credit never exceeds the latest offer, keeps room for the car's rise -// to its max, takes the lowest of the sample, raw and smoothed draw, needs -// a live charger reading, and ends once the charger's first offer of this -// run has had its settle time: then the offers alone say what the meter -// shows. +// to its max, takes the lowest of the sample, raw and smoothed draw and of +// every sample since the site reading, needs a live charger reading +// stamped evClockAllowance before the site reading, and ends once the +// charger's first offer of this run has had its settle time: then the +// offers alone say what the meter shows. func TestEVFoundDrawWBounds(t *testing.T) { for _, tc := range []struct { name string @@ -138,12 +155,35 @@ func TestEVFoundDrawWBounds(t *testing.T) { {"a charger sample measured after the site reading", func(store *telemetry.Store, state *State) { store.Get("meter", telemetry.DerMeter).UpdatedAt = state.now().Add(-time.Second) store.Get("charger", telemetry.DerEV).UpdatedAt = state.now() + store.Get("charger", telemetry.DerEV).Data = chargerData(store, map[string]any{"control_power_observed_at": state.now().Format(time.RFC3339Nano)}) + }, 0}, + {"a sample stamped less than evClockAllowance before the site reading", func(store *telemetry.Store, _ *State) { + at := store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-evClockAllowance + time.Second) + store.Get("charger", telemetry.DerEV).Data = chargerData(store, map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)}) + }, 0}, + {"only a sample older than one control interval before the reading", func(store *telemetry.Store, state *State) { + // The car may have stopped and started again since that sample; + // the current one, 1 s before the reading, may be ahead of it. + risenSince(store, state, 8222, 11040) + c := state.evLimit.offers["garage"] + c.draws[0].at = c.draws[0].at.Add(-10 * time.Second) + c.draws[0].seen = c.draws[0].at + state.evLimit.offers["garage"] = c }, 0}, + {"a sample since the site reading reads less", func(store *telemetry.Store, state *State) { + risenSince(store, state, 8222, 6000) + }, 6000 - (11040 - 6210)}, + // Review of #1926: OCPP stamps MeterValues with the charger's own + // clock. One running 1 s behind Core's stamps a rise sampled after + // the site reading as 1 s before it. The draw stays the one sampled + // before the reading. + {"a rise stamped before the reading by a charger clock behind Core's", func(store *telemetry.Store, state *State) { + risenSince(store, state, 6900, 11040) + }, 6900 - (11040 - 6210)}, {"latest offer bounds the draw", func(_ *telemetry.Store, state *State) { setRiseCapW(state, 6210) }, 6210}, {"measured draw below the offer", func(store *telemetry.Store, state *State) { setRiseCapW(state, 6210) - store.Update("charger", telemetry.DerEV, 4000, nil, nil) - store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt + setChargerSample(store, 4000, nil) }, 4000}, {"smoothed draw below the sample", func(store *telemetry.Store, state *State) { setRiseCapW(state, 6210) @@ -152,12 +192,12 @@ func TestEVFoundDrawWBounds(t *testing.T) { {"raw draw below the sample", func(store *telemetry.Store, state *State) { setRiseCapW(state, 6210) r := store.Get("charger", telemetry.DerEV) - r.Data = json.RawMessage(`{"control_power_w":8222}`) + r.Data = chargerData(store, map[string]any{"control_power_w": 8222}) r.RawW = 5000 }, 5000}, {"control sample below raw and smoothed", func(store *telemetry.Store, state *State) { setRiseCapW(state, 6210) - store.Get("charger", telemetry.DerEV).Data = json.RawMessage(`{"control_power_w":5000}`) + store.Get("charger", telemetry.DerEV).Data = chargerData(store, map[string]any{"control_power_w": 5000}) }, 5000}, {"charger offline", func(store *telemetry.Store, _ *State) { store.DriverHealthMut("charger").SetOffline() @@ -198,6 +238,20 @@ func TestEVFoundDrawWBounds(t *testing.T) { } } +// risenSince keeps the fixture charger's sample of fromW, stamped +// evClockAllowance before the site reading, as noteDraw does, and makes +// its current reading toW, stamped 1 s before the site reading arrived. +func risenSince(store *telemetry.Store, state *State, fromW, toW float64) { + setChargerSample(store, fromW, nil) + c := state.evLimit.offers["garage"] + c.noteDraw(store, state.now()) + state.evLimit.offers["garage"] = c + at := store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-time.Second) + setChargerSample(store, toW, map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)}) + // The filter's smoothed value trails the rise; this case reads the sample. + store.Get("charger", telemetry.DerEV).SmoothedW = toW +} + // Offers the meter already shows come off the consumption the credit may // fill: here another charger's 5 kW is shown, so of the 8,780 W the meter // shows as consumption only 3,780 W is left for this car. @@ -427,3 +481,38 @@ func TestSiteSimRestartMaxChargeLoweredAtRestart(t *testing.T) { } carRisesUnderOldOffer(t, s, 8280, 14*time.Second) } + +// Review of #1926: OCPP stamps MeterValues with the charger's own clock, +// often cut to the whole second. With the charger's clock 1-3 s behind +// Core's, the third review's case (3 s meter, the car at 10 A under a 16 A +// offer in force lifting its own limit 11 s in) stamps the rise before the +// site reading that does not show it yet. The draw stays the one sampled +// before that reading. Before: L2 reached 19.30 A. +func TestSiteSimRestartChargerClockBehind(t *testing.T) { + for _, lag := range []time.Duration{time.Second, 2 * time.Second, 3 * time.Second} { + t.Run(lag.String(), func(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + s.p.Meter.Every = 3 * time.Second + s.evClockLag = lag + carRisesUnderOldOffer(t, s, 6900, 11*time.Second) + }) + } +} + +// Review of #1926: a 22 kW charger (32 A) on a 3 x 25 A main fuse. The +// offer in force may be the charger's 32 A, so the rise reserve runs to +// max_charge_w, not to the fuse's rating: of the 14 kW the car draws, +// 22,080 - 11,000 W may still come on top of Core's latest 11 kW offer. +func TestEVFoundDrawWMaxChargeAboveFuse(t *testing.T) { + evW := 14000.0 + store, state := restartFixture(t, 9000, [3]float64{13, 13, 13}, -5000, 0, evW) + state.SiteFuseAmps = 25 + setMaxW(state, 22080) + c := state.evLimit.offers["garage"] + c.log[len(c.log)-1].w = 11000 + state.evLimit.offers["garage"] = c + meter := store.Get("meter", telemetry.DerMeter) + if got, want := state.evFoundDrawW(store, meter.RawW, meter.UpdatedAt, state.now(), 0), evW-(22080-11000); math.Abs(got-want) > 1e-6 { + t.Fatalf("credit %.0f W, want %.0f W", got, want) + } +} diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index acabf847f..e34208170 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -185,6 +185,8 @@ type chargerOffers struct { // configured. driver string maxW float64 + // draws are the charger's samples while that window is open (noteDraw). + draws []evDraw // carPhases is how many phases the car draws on, as the loadpoint saw // it on the site meter: 1 or 3, or 0 when unknown. carPhase is the phase // a car on one phase draws on, 1-3. @@ -409,26 +411,36 @@ func (s *State) evShownW(at time.Time) float64 { // set (at Core's start, or at a plug-in once no other managed charger was // plugged), until that offer is older than the charger's settle time. Core // then does not know which offer the charger runs on, and its first offers -// (often 0 A) say nothing of what the meter shows. A car seen drawing in -// that window is in the reading when the charger measured it within one -// control interval before the site reading: a fast charger may have applied -// a 0 A offer since an older sample, a held reading after a failed poll -// says nothing of now, and a sample newer than the site reading may hold a -// rise the meter does not show yet. A charger that samples less often (OCPP -// MeterValues every 10 s or more) counts only on the ticks just after a -// sample, and holds its battery's discharge between them. The measured draw -// is the lowest of the sample, raw and smoothed. Until the latest offer -// lands, the car may still rise from that draw to the offer in force. Core -// does not know that offer: one from before a restart, under an older -// max_charge_w, or the charger's own default. The reserve takes it as the -// larger of the loadpoint's max_charge_w and the site fuse's rating, and -// keeps the rise past the latest offer; a charger with no max_charge_w -// counts nothing. Each charger counts no -// more than its latest offer, and in all no more than the consumption the -// site meter shows (siteConsumptionW) less shownW. The car's own limit -// never reads this; only how much of our latest offers the battery reserves -// as a rise to come. -// gridW is the site reading, taken at meterAt. +// (often 0 A) say nothing of what the meter shows. +// +// A car seen drawing in that window is in the site reading when the +// charger sampled that draw before the reading, and no sample since reads +// less. The charger stamps its samples with its own clock (OCPP +// MeterValues), often cut to the whole second, and the meter's reading +// arrives some time after it was measured; so a sample counts as before +// the reading only when stamped evClockAllowance before its arrival, and +// no more than one control interval before that: a fast charger may have +// applied a 0 A offer since an older sample. The draw is the lowest of +// that sample and every sample since (noteDraw, each the lowest of the +// sample, raw and smoothed), up to the charger's current reading. So a +// rise sampled after the reading never counts while the charger's clock +// runs behind Core's by less than evClockAllowance less the meter's own +// delay. A held reading after a failed poll says nothing of now. A charger +// that samples less often (OCPP MeterValues every 10 s or more) counts +// only on the ticks from evClockAllowance to one control interval more +// after a sample, and holds its battery's discharge between them. +// +// Until the latest offer lands, the car may still rise from that draw to +// the offer in force. Core does not know that offer: one from before a +// restart, under an older max_charge_w, or the charger's own default. The +// reserve takes it as the larger of the loadpoint's max_charge_w and the +// site fuse's rating, and keeps the rise past the latest offer; a charger +// with no max_charge_w counts nothing. Each charger counts no more than its +// latest offer, and in all no more than the consumption the site meter +// shows (siteConsumptionW) less shownW. The car's own limit never reads +// this; only how much of our latest offers the battery reserves as a rise +// to come. +// gridW is the site reading, which arrived at meterAt. func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at time.Time, shownW float64) float64 { if store == nil { return 0 @@ -441,22 +453,26 @@ func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at if skew <= 0 { skew = 2 * time.Second // config's default control_interval_s } + edge := meterAt.Add(-evClockAllowance) + from := edge.Add(-skew) var w float64 for _, c := range s.evLimit.offers { n := len(c.log) - if n == 0 || c.found.at.IsZero() || at.Sub(c.found.at) >= c.settle || c.driver == "" || !(c.maxW > 0) || !finiteFuseValue(c.maxW) { + if n == 0 || !c.foundOpen(at) || c.driver == "" || !(c.maxW > 0) || !finiteFuseValue(c.maxW) { continue } - lowerW, live := store.OnlineLowerW(c.driver, telemetry.DerEV) - if !live { + now, ok := readEVDraw(store, c.driver, at) + if !ok || now.seen.Before(from) { continue } - p, ok := qualifiedFuseObservation(store.Get(c.driver, telemetry.DerEV), at) - if !ok || p.Seen().Before(meterAt.Add(-skew)) || p.At.After(meterAt) { - continue + drawW, before := now.w, !now.at.After(edge) + for _, d := range c.draws { + if d.seen.Before(from) { + continue + } + drawW, before = math.Min(drawW, d.w), before || !d.at.After(edge) } - drawW := math.Min(p.PowerW, lowerW) - if !finiteFuseValue(drawW) { + if !before { continue } unshown, _ := c.log.unshown(at, c.batterySettle()) @@ -468,6 +484,64 @@ func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at return math.Max(0, math.Min(w, consumptionW-shownW)) } +// evClockAllowance is how much earlier than the site reading's arrival a +// charger's sample must be stamped to count as in that reading +// (evFoundDrawW). It covers a charger clock running behind Core's, a stamp +// cut to the whole second, and the meter's own delay from measurement to +// arrival, together. +const evClockAllowance = 5 * time.Second + +// evDraw is one charger sample: when the charger measured it (its own +// clock), when it last vouched for it, and the lowest of the sample, raw +// and smoothed. +type evDraw struct { + at, seen time.Time + w float64 +} + +// readEVDraw is the charger's current sample, while its telemetry is live, +// its last poll succeeded and the sample passes qualifiedFuseObservation. +func readEVDraw(store *telemetry.Store, driver string, at time.Time) (evDraw, bool) { + lowerW, live := store.OnlineLowerW(driver, telemetry.DerEV) + if !live { + return evDraw{}, false + } + p, ok := qualifiedFuseObservation(store.Get(driver, telemetry.DerEV), at) + w := math.Min(p.PowerW, lowerW) + return evDraw{at: p.At, seen: p.Seen(), w: w}, ok && finiteFuseValue(w) +} + +// foundOpen is true while the charger has no order of this run that has +// surely landed (evFoundDrawW). +func (c chargerOffers) foundOpen(at time.Time) bool { + return !c.found.at.IsZero() && at.Sub(c.found.at) < c.settle +} + +// noteDraw keeps the charger's samples while foundOpen, for evFoundDrawW, +// over telemetry.ControlWindowDuration. +func (c *chargerOffers) noteDraw(store *telemetry.Store, at time.Time) { + if !c.foundOpen(at) || c.driver == "" { + c.draws = nil + return + } + d, ok := readEVDraw(store, c.driver, at) + if !ok { + return + } + if n := len(c.draws); n > 0 && !d.at.After(c.draws[n-1].at) { + if last := &c.draws[n-1]; d.at.Equal(last.at) { + last.w = math.Min(last.w, d.w) + if d.seen.After(last.seen) { + last.seen = d.seen + } + } + return + } + c.draws = append(slices.DeleteFunc(c.draws, func(e evDraw) bool { + return at.Sub(e.seen) > telemetry.ControlWindowDuration + }), d) +} + // siteConsumptionW is the consumption a site reading of gridW shows: grid // less each live battery, PV and V2X reading, each at the higher of raw and // smoothed. A smoothed value lags an output that just fell; the higher one @@ -611,7 +685,8 @@ func (s *State) riseAheadA(c chargerOffers) (ahead [3]float64) { // 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 -// none. Our own commands only: a charger's power reading never counts. +// none. Our own commands only: a charger's power reading never counts. The +// samples kept for the battery side (noteDraw) take no part in the limit. func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) { now := s.now() read := now @@ -641,6 +716,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) } c.log = c.log.keep(sent, read.Add(-c.settle)) } + c.noteDraw(store, now) offers[point.ID] = c } s.evLimit.offers = offers diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 36abc4603..3f9d4f103 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -49,6 +49,10 @@ type simSite struct { // MeterValues do: between samples each poll repeats the last sample // with its own time. Zero samples on every poll. evEvery time.Duration + // evClockLag is how far the charger's clock runs behind Core's: it + // stamps each sample that much early, as an OCPP charger stamps + // MeterValues with its own clock (or cuts the stamp to whole seconds). + evClockLag time.Duration // meterNoise is added to each phase current Core reads, not to the // plant's own; nil reads the plant exactly. meterNoise func() float64 @@ -248,12 +252,12 @@ func (s *simSite) step() { s.ev, s.evAt = *e, now } data := s.evData() - if s.evEvery > 0 { + if s.evEvery > 0 || s.evClockLag > 0 { data = maps.Clone(data) if data == nil { data = map[string]any{} } - data["control_power_observed_at"] = s.evAt.Format(time.RFC3339Nano) + data["control_power_observed_at"] = s.evAt.Add(-s.evClockLag).Format(time.RFC3339Nano) } s.emit(s.lp.DriverName, telemetry.DerEV, s.ev.W, nil, data) } From a5cdeaeb6c26f738a5c5543476425f4907253bf2 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 07:52:37 +0100 Subject: [PATCH 28/34] docs(control): the fuse margin is opt-in, and only the fuse ceilings move The SiteFuseSafetyA comment read as if the margin were needed by default; it now says zero is the default and when to set one. The changeset named "the site's total import and export limits", which reads as the hourly import_limit; that limit does not move. It now names the fuse's import and export ceilings. Co-Authored-By: Claude Opus 5.5 --- .changeset/d-fuse-margin-default-0.md | 2 +- go/internal/control/dispatch.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/d-fuse-margin-default-0.md b/.changeset/d-fuse-margin-default-0.md index 2d91f84de..4231621c6 100644 --- a/.changeset/d-fuse-margin-default-0.md +++ b/.changeset/d-fuse-margin-default-0.md @@ -2,4 +2,4 @@ "ftw": patch --- -`fuse.safety_margin_a` now defaults to 0. On a site with no saved margin, FTW works to the fuse rating: the car, the home battery's charge and fuse relief, and the site's total import and export limits each move up 0.5 A per phase, to the rating. A margin is opt-in, for an inverter whose own phase limiter trips at the rating. FTW keeps any margin you have saved. If yours is exactly 0.5 A, FTW's old default, the log says so once at startup; set 0 to drop it. +`fuse.safety_margin_a` now defaults to 0. On a site with no saved margin, FTW works to the fuse rating: the car, the home battery's charge and fuse relief, and the fuse's total import and export ceilings each move up 0.5 A per phase, to the rating. A margin is opt-in, for an inverter whose own phase limiter trips at the rating. FTW keeps any margin you have saved. If yours is exactly 0.5 A, FTW's old default, the log says so once at startup; set 0 to drop it. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 5f7df7068..da09c8f95 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -327,9 +327,10 @@ type State struct { SiteFuseVoltage float64 SiteFusePhases int - // SiteFuseSafetyA is amps held back from the breaker. Without it the - // inverter's own limiter trips first, output falls to 0, and dispatch - // ramps from idle. Zero disables the margin. + // SiteFuseSafetyA is an opt-in margin in amps per phase below the + // breaker; zero, the default, works to the rating. Set it for an + // inverter whose own limiter trips at the rating: output falls to 0 + // and dispatch ramps from idle. SiteFuseSafetyA float64 // Peak limit — enforced only in PeakShaving mode From 358da3babcacb56efa019c805638b021e652c073 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 08:01:27 +0100 Subject: [PATCH 29/34] test(control): battery beside a minimum-step car holds the fuse line (rig s18-16) On #1910 and #1891 the battery rises beside the car to the working limit, the fuse guard cuts it by the overage plus half of safety_margin_a and holds 30 s, and the battery rises again: it hunts between 15.0 and 15.5 A on a 16 A site. Where it stands when the house moves is chance, which is why rig s18-16 failed 3 of 57 runs on those heads. #1913's cut by the overage only keeps it on the line. Co-Authored-By: Claude Opus 5.5 --- .../ev_battery_fuse_hunt_sitesim_test.go | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 go/internal/control/ev_battery_fuse_hunt_sitesim_test.go diff --git a/go/internal/control/ev_battery_fuse_hunt_sitesim_test.go b/go/internal/control/ev_battery_fuse_hunt_sitesim_test.go new file mode 100644 index 000000000..049d0a0a6 --- /dev/null +++ b/go/internal/control/ev_battery_fuse_hunt_sitesim_test.go @@ -0,0 +1,83 @@ +package control + +import ( + "fmt" + "math" + "math/rand/v2" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/sitesim" +) + +// The site rig's s18 at 16 A: a 3 A house, Charge now for a car whose only +// step is 6 A on three phases, and a 6 kW battery charge hold beside it. The +// battery may charge up to the fuse's working limit (fuseLineA) beside the +// car. Its fuse guard must then land it on that line and keep it there; a +// guard that cuts below the line and lets it rise again after its hold makes +// the battery hunt, and where it stands when the house moves is chance. The +// rig judges that move: 0.1 A more house must come off the battery. +func TestBatteryBesideMinimumCarHoldsTheFuseLine(t *testing.T) { + const carW = 6 * 690.0 + start := simStart + p := simPlant(start) + p.Fuse.RatingA, p.HouseW = 16, houseA(3, 3, 3) + p.EVSE.Delay, p.EVSE.ReadbackLag = 2*time.Second, 0 + p.Car.MaxW = carW + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 6000, MaxDischargeW: 6000, CapacityWh: 10000, SoC: .4, Latency: 2 * time.Second} + p.Events = []sitesim.Event{{At: 8 * time.Minute, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.HouseW = houseA(3.1, 3.1, 3.1) }}} + lp := simLoadpoint() + lp.MinChargeW, lp.MaxChargeW, lp.AllowedStepsW = carW, carW, []float64{0, carW} + // The rig's car draws a few watts either side of its offer, so the meter + // reads the phases a hundredth of an amp either side of the line. + jitter := rand.New(rand.NewPCG(18, 16)) + s := &simSite{p: p, lp: lp, meterNoise: func() float64 { return (jitter.Float64() - .5) * .02 }, + setup: func(s *simSite) { + s.state.SetBatteryManualHold(BatteryManualHold{PowerW: 6000, ExpiresAt: start.Add(time.Hour)}) + }} + s.boot() + + // The battery each second, from 3 min, once car and battery run. + var battery []float64 + s.run(3 * time.Minute) + for s.p.Now.Before(start.Add(11*time.Minute)) && s.failure == "" { + s.step() + battery = append(battery, s.p.Battery.W) + } + if s.failure != "" { + t.Fatal(s.failure) + } + if s.p.EVSE.DrawW < carW-.1 { + t.Fatalf("car draws %.0f W, want its %.0f W step", s.p.EVSE.DrawW, carW) + } + // Five minutes of a steady house: the battery stays where it is. + steady := battery[:5*60] + lo, hi := math.Inf(1), math.Inf(-1) + for _, w := range steady { + lo, hi = math.Min(lo, w), math.Max(hi, w) + } + if hi-lo > 60 { + var moves []string + last := math.NaN() + for _, c := range s.commands { + if c.device == "battery" && c.at.After(start.Add(3*time.Minute)) && c.at.Before(start.Add(8*time.Minute)) && !(math.Abs(c.w-last) < 50) { + moves, last = append(moves, fmt.Sprintf("%s %.0f", c.at.Sub(start), c.w)), c.w + } + } + t.Errorf("battery hunts under a steady house: %.0f..%.0f W; commands %v", lo, hi, moves) + } + // The rig's judge: the 20 s before the step against the 20 s before 11 min. + mean := func(w []float64) float64 { + var sum float64 + for _, x := range w { + sum += x + } + return sum / float64(len(w)) + } + before, after := mean(battery[5*60-20:5*60]), mean(battery[len(battery)-20:]) + if after > before-40 { + t.Errorf("battery %.0f W before 0.1 A more house, %.0f W after; want at least 40 W less", before, after) + } + t.Logf("battery %.0f..%.0f W steady, %.0f -> %.0f W across the step; peak phase %.2f A", + lo, hi, before, after, s.peakA) +} From fdfbfec6b44a5bc031fbf4dd26309aba069a33a8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 08:19:16 +0100 Subject: [PATCH 30/34] test(config): an unset fuse margin reaches Core as 0 A mutation that restored a hidden 0.5 A margin at startup passed every suite. This test pins the value newControlStateFromConfig hands Core. Co-Authored-By: Claude Opus 5.5 --- go/cmd/ftw/control_state_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/go/cmd/ftw/control_state_test.go b/go/cmd/ftw/control_state_test.go index 8c516be71..5f4fad63d 100644 --- a/go/cmd/ftw/control_state_test.go +++ b/go/cmd/ftw/control_state_test.go @@ -167,6 +167,29 @@ func TestBatteryLimitConfigBothZeroUsesDefault(t *testing.T) { } } +// A config with no fuse.safety_margin_a reaches Core at the fuse rating, and +// an explicit value still wins. +func TestUnsetFuseMarginReachesCoreAsZero(t *testing.T) { + cfg := parseBatteryLimitConfig(t, "", " max_charge_w: 10000\n") + ctrl := newControlStateFromConfig(cfg) + if ctrl.SiteFuseSafetyA != 0 { + t.Fatalf("unset margin reached Core as %v A, want 0", ctrl.SiteFuseSafetyA) + } + // 63 A × 230 V × 3 = 43470 W is the ceiling with no margin. + if err := ctrl.SetPeakLimit(43470); err != nil { + t.Fatalf("peak limit at the fuse: %v", err) + } + if err := ctrl.SetPeakLimit(43471); err == nil { + t.Fatal("a peak limit 1 W over the fuse was accepted") + } + + half := 0.5 + cfg.Fuse.SafetyMarginA = &half + if got := newControlStateFromConfig(cfg).SiteFuseSafetyA; got != 0.5 { + t.Fatalf("explicit 0.5 A reached Core as %v A", got) + } +} + // A "NaN" saved from an MQTT command must not come back as the PI setpoint // on every boot. func TestRestoredGridTargetIgnoresNonFinite(t *testing.T) { From dbc4332eba8f3dc639a58f1201e4344aca720593 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 08:52:42 +0100 Subject: [PATCH 31/34] test(site): s18 judges own commands apart from the house's step At margin 0 Core holds the phases at the 16 A line. Rig s18-16's 0.1 A house step at small_step then lands over the line until Core cuts: on fdfbfec6 the phase read 16.101 A for 3 s and Core cut the battery 4851 -> 4719 W within 2 s. The criterion "own commands stay inside the rated fuse" counted that stretch as Core's and failed. The owner's rule judges an overage between In and 1.2 x In by the I2t model. The criterion now skips the 10 s after the step, the same gap the minor window already leaves. On the fdfbfec6 record Core's own commands peak at 16.008 A before the step and 16.005 A after it, inside 16.01 A. Co-Authored-By: Claude Opus 5.5 --- go/test/site/ev_battery_priority_test.go | 11 ++++++-- go/test/site/rig_unit_test.go | 36 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/go/test/site/ev_battery_priority_test.go b/go/test/site/ev_battery_priority_test.go index ab11ca8af..c0ef73233 100644 --- a/go/test/site/ev_battery_priority_test.go +++ b/go/test/site/ev_battery_priority_test.go @@ -2,6 +2,8 @@ package site +import "math" + // #1868: the car has only [0, 4140] W, while the battery can charge at // 6000 W. The actual devices run over Modbus and OCPP; marks and the // physical meter record let the judge see which device yielded. @@ -49,15 +51,20 @@ func judgeS18(d *runData) (metrics, []criterion) { carMin := func(w []*sample) float64 { return minOf(w, func(r *sample) float64 { return carW(r, 0) }) } restart := d.firstT(back, func(r *sample) bool { return carW(r, 0) >= 4100 }) batteryBefore, batteryAfter := meanOf(pre, batSigned), meanOf(d.window(none-20, none), batSigned) + // Core holds the phases at the fuse line, so the house's 0.1 A step + // lands over it until Core cuts; the I²t model judges that stretch. + // Own commands are the rest, as in the minor window. + ownI := math.Max(maxOf(d.window(t0, small), maxAbsI), maxOf(minor, maxAbsI)) m := metrics{"battery_before_w": batteryBefore, "battery_after_w": batteryAfter, "restart_s": restart, "car_before_w": carMin(pre), "car_small_step_w": carMin(minor), "car_recovered_w": carMin(recovered), - "fuse_blown": anyBlown(d.samples), "max_i_before_house_overload": maxOf(d.window(t0, none), maxAbsI)} + "fuse_blown": anyBlown(d.samples), "max_i_before_house_overload": maxOf(d.window(t0, none), maxAbsI), + "max_i_own_commands": ownI} return m, []criterion{ crit("setup: car and battery charge together", len(pre) > 0 && carMin(pre) >= 4100 && batteryBefore > 1000), crit("battery yields before the minimum-step car", len(minor) > 0 && carMin(minor) >= 4100 && batteryAfter < batteryBefore-40), crit("car stops when house leaves no room", len(paused) > 0 && maxOf(paused, func(r *sample) float64 { return carW(r, 0) }) < 100), crit("car restarts automatically within 150 s", le(restart, 150) && len(recovered) > 0 && carMin(recovered) >= 4100), - crit("own commands stay inside the rated fuse", maxOf(d.window(t0, none), maxAbsI) <= 16.01), + crit("own commands stay inside the rated fuse", ownI <= 16.01), crit("fuse holds", !anyBlown(d.samples)), } } diff --git a/go/test/site/rig_unit_test.go b/go/test/site/rig_unit_test.go index 7e921c2ad..021c82cd2 100644 --- a/go/test/site/rig_unit_test.go +++ b/go/test/site/rig_unit_test.go @@ -139,3 +139,39 @@ func TestInvariantsTellStepsFromOwnRaises(t *testing.T) { t.Fatalf("without a step: %v", f) } } + +// s18 at margin 0: Core holds the phases at the 16 A line, so the house's own +// 0.1 A step lands over it until Core cuts. That stretch is the house's, not +// an own command. The same overage at any other time is Core's and fails. +func TestS18OwnCommandsSkipTheHouseStep(t *testing.T) { + const own = "own commands stay inside the rated fuse" + mk := func(overFrom float64) *runData { + d := &runData{marks: map[string]float64{"charge_now": 0, "small_step": 100, "no_room": 200, "room_returns": 300, "end": 450}} + for i := 0; i <= 450; i++ { + a := 16.0 + if float64(i) >= overFrom && float64(i) < overFrom+4 { + a = 16.1 + } + d.samples = append(d.samples, &sample{T: float64(i), PhaseA: [3]float64{a, a, a}}) + } + return d + } + ok := func(d *runData) bool { + _, cs := judgeS18(d) + for _, c := range cs { + if c.Name == own { + return c.OK + } + } + t.Fatalf("no %q criterion", own) + return false + } + if !ok(mk(100)) { + t.Error("4 s at 16.1 A right after the house's step failed") + } + for _, at := range []float64{50, 150} { + if ok(mk(at)) { + t.Errorf("4 s at 16.1 A from %.0f s with no outside step passed", at) + } + } +} From dcf498247af3dfa5fa14bb01faaff4de2f0e0fcf Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 09:39:25 +0100 Subject: [PATCH 32/34] fix(control): no cut of an unseen car, no landing from a 1p house move Two regressions against #1913 from the final review. A car the loadpoint has not seen on three phases may draw on one phase Core does not know. The alone rise (aloneA) missed its landing when any other phase moved a step in the same reading, so the whole raise counted as still to come and C4 cut a car with every phase under the line, at most session starts with a 10 s meter. riseAheadA now takes the largest rise for such a car, as for a one-phase car whose phase is not known, and the smallest only once the car is seen on three phases. A one-phase order never knows the car's phase, so a house load moving alone on any phase read as its cut landing and took the cover off a cut still on its way. A one-phase move now counts only on a known car phase. Co-Authored-By: Claude Opus 5.5 --- .changeset/s1-ev-cut-landed-and-unshown.md | 2 +- go/internal/control/ev_fuse_budget.go | 63 +++++-------------- .../control/ev_fuse_cut_netting_test.go | 53 +++++++++++++--- 3 files changed, 60 insertions(+), 58 deletions(-) diff --git a/.changeset/s1-ev-cut-landed-and-unshown.md b/.changeset/s1-ev-cut-landed-and-unshown.md index 3e8cdb1fa..202e88fbf 100644 --- a/.changeset/s1-ev-cut-landed-and-unshown.md +++ b/.changeset/s1-ev-cut-landed-and-unshown.md @@ -2,4 +2,4 @@ "ftw": patch --- -The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A house load that moves on one phase alone is not taken for the car's cut landing, so it no longer stacks a second cut on a slow charger. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. For a car the site meter has shown on three phases, this also holds when the house steps up on one phase alone. +The car's fuse cut now acts on what the site meter shows. A cut the meter already shows no longer counts as still on its way, so when the house adds load after it, FTW cuts again once the fuse's heat budget is spent instead of waiting up to three minutes. A house load that moves on one phase alone is not taken for the car's cut landing, so it no longer stacks a second cut on a slow charger. A raise that is on its way but no longer fits now comes off before it lands, when it would take a phase above 1.2 times the fuse rating, or over the line on a phase whose heat budget is spent. For a car the site meter has shown on three phases, this also holds when the house steps up on one phase alone. Until then, a rise on any phase counts as the raise landing, so a car on one phase is not cut while every phase is under the line. diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 95b41bd1e..060db68fa 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -196,10 +196,6 @@ type chargerOffers struct { riseBaseA [3]float64 riseFromA float64 riseSet bool - // aloneA is each phase's rise since riseBaseA counted only over the - // readings in which the other phases moved less than a step: the rise - // a car on that one phase may have made (riseAheadA). - aloneA [3]float64 } // evMoveA is half a charger step. An order landing shows on the site meter @@ -241,8 +237,9 @@ func chargerMoveA(prev, now []float64) (alikeA, oneA float64, phase int) { // charger on each phase (shownA), in a shape the offers can take there. A // car the loadpoint has seen draw on one phase, or a one-phase order, is // one level on every phase: every phase dropping alike moves it, and so -// does a move on the car's phase alone, or on any one phase while that -// phase is not known. A three-phase order moves only on every phase alike: +// does a move on the car's phase alone where that phase is known. A move on +// one phase while the car's phase is not known may be a house load, so it +// does not count. A three-phase order moves only on every phase alike: // a phase that moves alone is a house load, or a car not yet seen that // draws on one phase, and Core cannot tell which. The shown current moves // only toward the latest offer, and stays within the offers the reading @@ -265,17 +262,14 @@ func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { alikeA, oneA, phase = chargerMoveA(prev[:len(amps)], now[:len(amps)]) } for id, c := range lim.offers { - if read && c.riseSet { - c.aloneA = addAloneA(c.aloneA, prev[:len(amps)], now[:len(amps)]) - } if read && c.readable() { var moveA [3]float64 switch { case c.carPhase > 0 || c.log[0].n == 1: // One car on one phase: one level, moved by a drop of - // either shape or by its phase alone. + // every phase alike or by its known phase alone. m := math.Min(0, alikeA) - if c.carPhase == 0 || c.carPhase == phase { + if c.carPhase == phase { m += oneA } moveA = [3]float64{m, m, m} @@ -310,22 +304,6 @@ func (s *State) seeOffers(meter *telemetry.DerReading, gridW float64) { } } -// addAloneA adds to each phase's alone rise its move from one reading to -// the next when every other phase moved less than a step (evStepA): no -// charger step landed there, so the move may be a car on that one phase. -func addAloneA(aloneA [3]float64, prev, now []float64) [3]float64 { - for p := range now { - held := true - for q := range now { - held = held && (q == p || math.Abs(now[q]-prev[q]) < evStepA) - } - if held { - aloneA[p] += now[p] - prev[p] - } - } - return aloneA -} - // batteryOrderMoves reports whether one of our battery orders of the last // evSettle moves the site's phases by evMoveA or more. func (s *State) batteryOrderMoves() bool { @@ -478,18 +456,14 @@ func (s *State) unshownOrders(at time.Time, perPhase bool) (rise, cover pendingL // riseAheadA is the rise on each phase the site meter has still to show // of the charger's latest offer: the raise since riseFromA less the meter's // own rise since the raise went out, within the offers the reading may -// show. A three-phase order takes the smallest rise across the phases: a -// house step on some phases does not show the raise on the rest. A car on -// one phase takes its own phase's rise, or the largest where the phase is -// not known. For a car not yet seen on three phases, a phase that has -// risen a step or more alone may be the car taking the raise on one phase: -// its rise over the readings in which the other phases moved less than a -// step (aloneA). Each reading is judged against the one before, so house -// drift on another phase while the raise travels, or under a step in the -// landing reading, does not hide it. A landing counts in whatever shape -// it comes, a slow ramp or beside a house load that moves in the same -// reading. A house load that drops while the raise travels still reads as -// raise not shown yet, by as much as it dropped. +// show. A car the loadpoint has seen draw on three phases takes the +// smallest rise across the phases: a house step on some phases does not +// show the raise on the rest. A car on one phase takes its own phase's +// rise. Any other car takes the largest rise: until the loadpoint has seen +// it, it may draw on one phase Core does not know. A landing counts in +// whatever shape it comes, a slow ramp or beside a house load that moves +// in the same reading. A house load that drops while the raise travels +// still reads as raise not shown yet, by as much as it dropped. func (s *State) riseAheadA(c chargerOffers) (ahead [3]float64) { lim := &s.evLimit n := min(s.SiteFusePhases, len(ahead)) @@ -505,15 +479,8 @@ func (s *State) riseAheadA(c chargerOffers) (ahead [3]float64) { switch { case c.carPhase > 0 && c.carPhase <= n: riseA, phases = rises[c.carPhase-1], ahead[c.carPhase-1:c.carPhase] - case c.carPhases == 1 || latest.n == 1: + case c.carPhases != 3 || latest.n == 1: riseA = slices.Max(rises) - case c.carPhases != 3: - // A car not yet seen may draw on a phase that rose alone. - for i := range rises { - if c.aloneA[i] >= 2*evMoveA { - riseA = math.Max(riseA, math.Min(c.aloneA[i], rises[i])) - } - } } low, high := c.offerRangeA(lim.readingAt) toComeA := math.Max(0, latest.a-math.Min(high, math.Max(low, c.riseFromA+riseA))) @@ -552,7 +519,7 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) if n == 0 && !s.evLimit.set { c.found, c.shownA = sent, [3]float64{sent.a, sent.a, sent.a} } else if _, high := c.offerRangeA(now); sent.a > high && !c.riseSet && s.evLimit.phasesSeen { - c.riseBaseA, c.riseFromA, c.riseSet, c.aloneA = s.evLimit.phasesA, high, true, [3]float64{} + c.riseBaseA, c.riseFromA, c.riseSet = s.evLimit.phasesA, high, true } c.log = c.log.keep(sent, read.Add(-c.settle)) } diff --git a/go/internal/control/ev_fuse_cut_netting_test.go b/go/internal/control/ev_fuse_cut_netting_test.go index 6a6e0479b..fdc60ce6a 100644 --- a/go/internal/control/ev_fuse_cut_netting_test.go +++ b/go/internal/control/ev_fuse_cut_netting_test.go @@ -103,7 +103,11 @@ func (r *cutRig) deliver(gridW float64, data []byte) float64 { 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") + mode := "3p" + if r.phases == 1 { + mode = "1p" + } + _, _, points, configs := evMeterBudgetFixture(r.t, 0, nil, r.offerW, mode) points[0].CommandedPhases, points[0].CarPhases = r.phases, r.carPhases capW, _ := EVFuseBudget(r.store, r.state, points, configs, 17250) if capW < r.offerW { @@ -394,13 +398,14 @@ func TestEVFuseCutCoversOnlyThePhaseOverageItWasSentFor(t *testing.T) { // no longer fits cuts before the meter shows it. On a 16 A fuse with no // margin, a charger that declares 40 s takes the car from 6 A to 16 A with // L1 at 6 A: the step fits under the line. 30 s later the house adds 9.7 A -// on L1 and 3 A on L2 before the raise lands. L1 reads 15.7 A, under the -// line, but the raise would take it to 25.7 A, over 1.2 x In: on that -// reading the raise comes off as far as it takes L1 over the line, to 6 A -// in whole amps. Before, Core waited for the meter to show it. +// on L1 and 3 A on L2 before the raise lands. For a car the loadpoint has +// seen draw on three phases, L1 reads 15.7 A, under the line, but the raise +// would take it to 25.7 A, over 1.2 x In: on that reading the raise comes +// off as far as it takes L1 over the line, to 6 A in whole amps. Before, +// Core waited for the meter to show it. func TestEVFuseRaiseOnItsWayThatNoLongerFitsCutsAtOnce(t *testing.T) { r := newCutRig(t, 4140, 40) - r.state.SiteFuseAmps = 16 + r.state.SiteFuseAmps, r.carPhases = 16, 3 for range 60 { r.read([3]float64{6, 6, 6}) } @@ -575,9 +580,9 @@ func TestEVFuseRaiseThatLandedIsNotCountedTwice(t *testing.T) { // of its reading when the raise went out, over the whole response time: // 0.6 A of drift made the raise count as still to come, L1 read 34 A, and // Core cut to 4,830 W one second after the landing (field matrix with -// 0.7 A on L2 every 17 s: car1p cut 11,100 -> 0 W). The landing now counts -// when L2 moves less than a step in the reading it lands on, or drifts -// before it. +// 0.7 A on L2 every 17 s: car1p cut 11,100 -> 0 W). A house load of a +// step or more on L2 in the landing reading hid it too. Until the loadpoint +// has seen the car on three phases, the largest rise counts as the landing. func TestEVFuseUnseenOnePhaseCarLandsBesideDrift(t *testing.T) { for _, tc := range []struct { name string @@ -589,6 +594,8 @@ func TestEVFuseUnseenOnePhaseCarLandsBesideDrift(t *testing.T) { {"drift_-0.6A_before", -0.6, 0}, {"drift_0.7A_in_the_landing_reading", 0, 0.7}, {"drift_-0.7A_in_the_landing_reading", 0, -0.7}, + {"house_1.2A_on_L2_in_the_landing_reading", 0, 1.2}, + {"house_-1.2A_on_L2_in_the_landing_reading", 0, -1.2}, } { t.Run(tc.name, func(t *testing.T) { r := newCutRig(t, 4140, 40) // 6 A a phase ordered @@ -639,3 +646,31 @@ func TestEVFuseLoadCyclingOnTheOverPhaseDoesNotHunt(t *testing.T) { }) } } + +// A one-phase order's car phase is not known, so a house load that moves +// alone on another phase is not its cut landing. 25 A fuse, a charger that +// declares 40 s, 16 A ordered on one phase: L1 reads 28 A and the heat cut +// takes it to 13 A. While that cut travels, a 3 A load on L2 switches off. +// Before, that move took 3 A of cover off the cut still on its way, and +// Core cut again for an overage the first cut already covered. +func TestEVFuseOnePhaseOrderHouseMoveIsNotTheCutLanding(t *testing.T) { + r := newCutRig(t, 3680, 40) // 16 A on one phase + r.phases = 1 + r.snap = func(w float64) float64 { return math.Floor(w/230) * 230 } + for range 120 { + r.read([3]float64{20, 15, 12}) + } + for i := 0; i < 120 && r.offerW == 3680; i++ { + r.read([3]float64{28, 15, 12}) // 3 A over the 25 A line + } + first := r.offerW + if first >= 3680 { + t.Fatalf("no heat cut with L1 3 A over the line") + } + r.read([3]float64{28, 15, 12}) + for i := range 20 { + if r.read([3]float64{28, 12, 12}); r.offerW != first { + t.Fatalf("offer %.0f W %d s after a 3 A house load on L2 switched off, want the one cut to %.0f W", r.offerW, i+1, first) + } + } +} From 040ebb104ced1b2929c3acd4fe3e623da66ab97a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 09:56:16 +0100 Subject: [PATCH 33/34] fix(control): judge a restart sample by its arrival, not its stamp The 5 s clock allowance let the restart credit count a charger sample taken 5-7 s before the site reading. A car that stopped after that sample (a fast charger applying the 0 A sent while the meter was stale, or the car pausing) still counted, the battery ended the discharge the car's return needed, and L2 reached 21 A on a 16 A fuse when it resumed. Core now judges a sample by when it first arrived on Core's clock. It counts as in the reading only when it arrived evMeterDelay (1 s) before the reading, and no more than one control interval before that. A sample already there when Core began to watch the charger arrived at a time Core does not know, so it never counts. The charger's stamp only tells samples apart, so a charger clock behind Core's no longer matters. Co-Authored-By: Claude Opus 5.5 --- .changeset/s4-ev-base-after-restart.md | 2 +- .../control/ev_base_after_restart_test.go | 70 ++++++++--- go/internal/control/ev_fuse_budget.go | 114 ++++++++++++------ 3 files changed, 128 insertions(+), 58 deletions(-) diff --git a/.changeset/s4-ev-base-after-restart.md b/.changeset/s4-ev-base-after-restart.md index 02227f9ac..b9eb43572 100644 --- a/.changeset/s4-ev-base-after-restart.md +++ b/.changeset/s4-ev-base-after-restart.md @@ -2,4 +2,4 @@ "ftw": patch --- -After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. FTW does not know the charger's offer in force then: one from before the restart, under an older max_charge_w, or the charger's own default. So the battery still keeps room for the car to rise from its draw to the larger of the charger's max_charge_w and the site fuse's rating. On a site whose fuse allows more than the car's draw plus FTW's latest order, that leaves no credit; a charger with no max_charge_w gets none either. The draw counts only when the charger stamped a sample at least 5 s, and at most one control interval more, before the site reading arrived, and its last poll succeeded. The 5 s cover a charger clock running behind FTW's, as OCPP chargers stamp their own samples. The lowest sample since then counts, so a rise the meter does not show yet never does. A charger that samples every 30 s or more rarely gets the credit. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. +After a restart, the home battery no longer stays in discharge for a car the meter already shows charging. Until the charger's first order of the new run can have landed, the battery counts the car's measured draw as part of the meter reading, never above FTW's latest order to the car and never above the consumption the site meter shows. FTW does not know the charger's offer in force then: one from before the restart, under an older max_charge_w, or the charger's own default. So the battery still keeps room for the car to rise from its draw to the larger of the charger's max_charge_w and the site fuse's rating. On a site whose fuse allows more than the car's draw plus FTW's latest order, that leaves no credit; a charger with no max_charge_w gets none either. The draw counts only when the sample reached FTW at least 1 s, and at most one control interval more, before the site reading did, and the charger's last poll succeeded. FTW judges a sample by when it arrived, not by the charger's own stamp, so a charger clock running behind FTW's, as OCPP chargers stamp their own samples, does not matter. A sample already there when FTW started does not count. The lowest sample since then counts, so a rise the meter does not show yet never does. A charger that samples every 30 s or more rarely gets the credit. Before, the 0 A orders FTW sent while the meter was still starting counted as the car's draw, so a lower order looked like a rise and the battery kept discharging toward its floor. diff --git a/go/internal/control/ev_base_after_restart_test.go b/go/internal/control/ev_base_after_restart_test.go index 29c9ec0f4..830eb906d 100644 --- a/go/internal/control/ev_base_after_restart_test.go +++ b/go/internal/control/ev_base_after_restart_test.go @@ -18,8 +18,8 @@ import ( // 6,210 W (9 A on three phases) 2 s ago. The charger takes 40 s to apply an // order, so the car still draws what it drew before the restart. Its // max_charge_w is 11,040 W (16 A). evW is what the charger measures, -// stamped evClockAllowance before the site reading arrived; the site meter -// reads gridW with the phase currents phaseA, and the PV inverter pvW. +// arriving evMeterDelay before the site reading; the site meter reads +// gridW with the phase currents phaseA, and the PV inverter pvW. func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pvW, evW float64) (*telemetry.Store, *State) { t.Helper() store := telemetry.NewStore() @@ -47,6 +47,7 @@ func restartFixture(t *testing.T, gridW float64, phaseA [3]float64, batteryW, pv state.evLimit.offers = map[string]chargerOffers{"garage": { log: commandLog{found, {at: now.Add(-4 * time.Second), w: 0, a: 0, n: 3}, {at: now.Add(-2 * time.Second), w: 6210, a: 9, n: 3}}, settle: evUnknownSettle, found: found, driver: "charger", maxW: 11040, + watchFrom: found.at, // Core saw the sample arrive }} return store, state } @@ -103,10 +104,10 @@ func TestEVBaseAfterRestartCreditStaysInsideTheMeter(t *testing.T) { } } -// chargerData is the fixture charger's data: its sample stamped -// evClockAllowance before the site reading arrived, with extra fields. +// chargerData is the fixture charger's data: its sample stamped on the +// charger's clock 5 s before the site reading arrived, with extra fields. func chargerData(store *telemetry.Store, extra map[string]any) json.RawMessage { - at := store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-evClockAllowance) + at := store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-5 * time.Second) d := map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)} maps.Copy(d, extra) raw, _ := json.Marshal(d) @@ -114,11 +115,11 @@ func chargerData(store *telemetry.Store, extra map[string]any) json.RawMessage { } // setChargerSample makes w the fixture charger's reading (chargerData), -// arriving with the site reading. +// arriving evMeterDelay before the site reading. func setChargerSample(store *telemetry.Store, w float64, extra map[string]any) { store.Update("charger", telemetry.DerEV, w, nil, chargerData(store, extra)) store.RecordDriverSuccess("charger") - store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt + store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-evMeterDelay) } // setMaxW sets the fixture charger's max_charge_w. @@ -137,8 +138,8 @@ func setRiseCapW(state *State, w float64) { // The credit never exceeds the latest offer, keeps room for the car's rise // to its max, takes the lowest of the sample, raw and smoothed draw and of -// every sample since the site reading, needs a live charger reading -// stamped evClockAllowance before the site reading, and ends once the +// every sample since the site reading, needs a live charger reading that +// arrived evMeterDelay before the site reading, and ends once the // charger's first offer of this run has had its settle time: then the // offers alone say what the meter shows. func TestEVFoundDrawWBounds(t *testing.T) { @@ -157,9 +158,10 @@ func TestEVFoundDrawWBounds(t *testing.T) { store.Get("charger", telemetry.DerEV).UpdatedAt = state.now() store.Get("charger", telemetry.DerEV).Data = chargerData(store, map[string]any{"control_power_observed_at": state.now().Format(time.RFC3339Nano)}) }, 0}, - {"a sample stamped less than evClockAllowance before the site reading", func(store *telemetry.Store, _ *State) { - at := store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-evClockAllowance + time.Second) - store.Get("charger", telemetry.DerEV).Data = chargerData(store, map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)}) + // Final review of #1926: the charger's stamp does not say when the + // sample was measured on Core's clock; its arrival bounds that. + {"a sample that arrived with the site reading, however early its stamp", func(store *telemetry.Store, _ *State) { + store.Get("charger", telemetry.DerEV).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt }, 0}, {"only a sample older than one control interval before the reading", func(store *telemetry.Store, state *State) { // The car may have stopped and started again since that sample; @@ -167,7 +169,8 @@ func TestEVFoundDrawWBounds(t *testing.T) { risenSince(store, state, 8222, 11040) c := state.evLimit.offers["garage"] c.draws[0].at = c.draws[0].at.Add(-10 * time.Second) - c.draws[0].seen = c.draws[0].at + c.draws[0].got = c.draws[0].got.Add(-10 * time.Second) + c.draws[0].seen = c.draws[0].got state.evLimit.offers["garage"] = c }, 0}, {"a sample since the site reading reads less", func(store *telemetry.Store, state *State) { @@ -238,16 +241,18 @@ func TestEVFoundDrawWBounds(t *testing.T) { } } -// risenSince keeps the fixture charger's sample of fromW, stamped -// evClockAllowance before the site reading, as noteDraw does, and makes -// its current reading toW, stamped 1 s before the site reading arrived. +// risenSince keeps the fixture charger's sample of fromW, arrived +// evMeterDelay before the site reading, as noteDraw does, and makes its +// current reading toW, stamped 1 s before the site reading but arriving +// with it. func risenSince(store *telemetry.Store, state *State, fromW, toW float64) { setChargerSample(store, fromW, nil) c := state.evLimit.offers["garage"] c.noteDraw(store, state.now()) state.evLimit.offers["garage"] = c - at := store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(-time.Second) - setChargerSample(store, toW, map[string]any{"control_power_observed_at": at.Format(time.RFC3339Nano)}) + meterAt := store.Get("meter", telemetry.DerMeter).UpdatedAt + setChargerSample(store, toW, map[string]any{"control_power_observed_at": meterAt.Add(-time.Second).Format(time.RFC3339Nano)}) + store.Get("charger", telemetry.DerEV).UpdatedAt = meterAt // The filter's smoothed value trails the rise; this case reads the sample. store.Get("charger", telemetry.DerEV).SmoothedW = toW } @@ -499,6 +504,35 @@ func TestSiteSimRestartChargerClockBehind(t *testing.T) { } } +// Final review of #1926: a sample from before the car stopped. A fast +// charger (2 s) applies the 0 A sent while the meter was silent at about +// 3 s, and the car resumes only at 11 s, after Core's 9 A order has +// landed. The charger samples every 10 s (OCPP MeterValues), at 2 s and +// 12 s. The readings from 6 s on show the car at 0 W. Core began to watch +// the charger at 7 s and does not know when the 8,280 W sample arrived, so +// it gives no credit, and the battery keeps the discharge the car's return +// needs. Judged by the charger's stamp against a 5 s allowance, that +// sample counted at 9 s, the battery ended its discharge, and L2 reached +// 21 A when the car resumed. With no credit at all, the peak is 18.10 A. +func TestSiteSimRestartOldSampleOfACarThatStopped(t *testing.T) { + s := restartSim(houseA(4, 12, 2)) + s.p.PVW = 0 + s.p.EVSE.Delay = 2 * time.Second + s.evEvery = 10 * time.Second + s.p.Events = append(s.p.Events, + sitesim.Event{At: 3 * time.Second, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.Car.MaxW = 0 }}, + sitesim.Event{At: 11 * time.Second, Kind: sitesim.Change, Do: func(p *sitesim.Plant) { p.Car.MaxW = 11040 }}) + s.boot() + s.evAt = s.p.Now.Add(2*time.Second - s.evEvery) // samples at 2 s and 12 s + s.run(time.Minute) + if s.failure != "" { + t.Fatal(s.failure) + } + if s.peakA > 18.11 { + t.Fatalf("a phase reached %.2f A; with no credit it peaks at 18.10 A", s.peakA) + } +} + // Review of #1926: a 22 kW charger (32 A) on a 3 x 25 A main fuse. The // offer in force may be the charger's 32 A, so the rise reserve runs to // max_charge_w, not to the fuse's rating: of the 14 kW the car draws, diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 3eb4ae82c..c782d3819 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -185,8 +185,11 @@ type chargerOffers struct { // configured. driver string maxW float64 - // draws are the charger's samples while that window is open (noteDraw). - draws []evDraw + // draws are the charger's samples while that window is open (noteDraw), + // and watchFrom the receipt of the first reading noteDraw took there: + // a sample already there then may have arrived at any earlier time. + draws []evDraw + watchFrom time.Time // carPhases is how many phases the car draws on, as the loadpoint saw // it on the site meter: 1 or 3, or 0 when unknown. carPhase is the phase // a car on one phase draws on, 1-3. @@ -394,19 +397,19 @@ func (s *State) evShownW(at time.Time) float64 { // A car seen drawing in that window is in the site reading when the // charger sampled that draw before the reading, and no sample since reads // less. The charger stamps its samples with its own clock (OCPP -// MeterValues), often cut to the whole second, and the meter's reading -// arrives some time after it was measured; so a sample counts as before -// the reading only when stamped evClockAllowance before its arrival, and -// no more than one control interval before that: a fast charger may have -// applied a 0 A offer since an older sample. The draw is the lowest of -// that sample and every sample since (noteDraw, each the lowest of the -// sample, raw and smoothed), up to the charger's current reading. So a -// rise sampled after the reading never counts while the charger's clock -// runs behind Core's by less than evClockAllowance less the meter's own -// delay. A held reading after a failed poll says nothing of now. A charger -// that samples less often (OCPP MeterValues every 10 s or more) counts -// only on the ticks from evClockAllowance to one control interval more -// after a sample, and holds its battery's discharge between them. +// MeterValues), which may run behind Core's by any amount, so Core judges +// a sample by when it first arrived on Core's clock (noteDraw): a sample +// arrives after it was measured. It counts as before the reading only +// when Core saw it arrive evMeterDelay before the reading did (arrivedBy), +// and no more than one control interval before that: a fast charger may +// have applied a 0 A offer, or the car paused, since an older sample. The +// draw is the lowest of that sample and every sample since (noteDraw, each +// the lowest of the sample, raw and smoothed), up to the charger's current +// reading. So a rise sampled after the reading never counts, whatever the +// charger's clock. A held reading after a failed poll says nothing of now. +// A charger that samples less often (OCPP MeterValues every 10 s or more) counts +// only on the ticks from evMeterDelay to one control interval more after a +// sample arrives, and holds its battery's discharge between them. // // Until the latest offer lands, the car may still rise from that draw to // the offer in force. Core does not know that offer: one from before a @@ -431,7 +434,7 @@ func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at if skew <= 0 { skew = 2 * time.Second // config's default control_interval_s } - edge := meterAt.Add(-evClockAllowance) + edge := meterAt.Add(-evMeterDelay) from := edge.Add(-skew) var w float64 for _, c := range s.evLimit.offers { @@ -440,15 +443,24 @@ func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at continue } now, ok := readEVDraw(store, c.driver, at) - if !ok || now.seen.Before(from) { + if !ok { continue } - drawW, before := now.w, !now.at.After(edge) + for _, d := range c.draws { + if d.at.Equal(now.at) { + d.merge(now) + now = d + } + } + if now.seen.Before(from) { + continue + } + drawW, before := now.w, c.arrivedBy(now, edge) for _, d := range c.draws { if d.seen.Before(from) { continue } - drawW, before = math.Min(drawW, d.w), before || !d.at.After(edge) + drawW, before = math.Min(drawW, d.w), before || c.arrivedBy(d, edge) } if !before { continue @@ -462,31 +474,55 @@ func (s *State) evFoundDrawW(store *telemetry.Store, gridW float64, meterAt, at return math.Max(0, math.Min(w, consumptionW-shownW)) } -// evClockAllowance is how much earlier than the site reading's arrival a -// charger's sample must be stamped to count as in that reading -// (evFoundDrawW). It covers a charger clock running behind Core's, a stamp -// cut to the whole second, and the meter's own delay from measurement to -// arrival, together. -const evClockAllowance = 5 * time.Second +// evMeterDelay is how much earlier than the site reading a charger's +// sample must arrive to count as in that reading (evFoundDrawW): the site +// meter's own delay from measurement to arrival. +const evMeterDelay = time.Second -// evDraw is one charger sample: when the charger measured it (its own -// clock), when it last vouched for it, and the lowest of the sample, raw -// and smoothed. +// evDraw is one charger sample: its stamp on the charger's own clock, +// which only tells samples apart; when it first arrived on Core's clock; +// when the charger last vouched for it, on Core's clock (its arrival, or a +// later confirmed reading); and the lowest of the sample, raw and smoothed. type evDraw struct { - at, seen time.Time - w float64 + at, got, seen time.Time + confirmed bool + w float64 +} + +// merge folds a repeat of the same sample into e: the lowest draw, the +// first arrival, and a later receipt only where the charger confirms it. +func (e *evDraw) merge(d evDraw) { + e.w = math.Min(e.w, d.w) + if d.got.Before(e.got) { + e.got = d.got + } + if d.confirmed && d.seen.After(e.seen) { + e.seen, e.confirmed = d.seen, true + } } // readEVDraw is the charger's current sample, while its telemetry is live, -// its last poll succeeded and the sample passes qualifiedFuseObservation. +// its last poll succeeded and the sample passes qualifiedFuseObservation, +// taken as arriving with the charger's current reading. func readEVDraw(store *telemetry.Store, driver string, at time.Time) (evDraw, bool) { lowerW, live := store.OnlineLowerW(driver, telemetry.DerEV) if !live { return evDraw{}, false } - p, ok := qualifiedFuseObservation(store.Get(driver, telemetry.DerEV), at) + r := store.Get(driver, telemetry.DerEV) + p, ok := qualifiedFuseObservation(r, at) + if !ok { + return evDraw{}, false + } w := math.Min(p.PowerW, lowerW) - return evDraw{at: p.At, seen: p.Seen(), w: w}, ok && finiteFuseValue(w) + return evDraw{at: p.At, got: r.UpdatedAt, seen: r.UpdatedAt, confirmed: !p.ConfirmedAt.IsZero(), w: w}, finiteFuseValue(w) +} + +// arrivedBy reports whether Core saw sample d arrive by edge. A sample +// already there when Core began to watch the charger arrived at a time +// Core does not know, so it never counts as arrived. +func (c chargerOffers) arrivedBy(d evDraw, edge time.Time) bool { + return !c.watchFrom.IsZero() && d.got.After(c.watchFrom) && !d.got.After(edge) } // foundOpen is true while the charger has no order of this run that has @@ -496,22 +532,22 @@ func (c chargerOffers) foundOpen(at time.Time) bool { } // noteDraw keeps the charger's samples while foundOpen, for evFoundDrawW, -// over telemetry.ControlWindowDuration. +// over telemetry.ControlWindowDuration, each with its first arrival. func (c *chargerOffers) noteDraw(store *telemetry.Store, at time.Time) { if !c.foundOpen(at) || c.driver == "" { - c.draws = nil + c.draws, c.watchFrom = nil, time.Time{} return } d, ok := readEVDraw(store, c.driver, at) if !ok { return } + if c.watchFrom.IsZero() { + c.watchFrom = d.got + } if n := len(c.draws); n > 0 && !d.at.After(c.draws[n-1].at) { if last := &c.draws[n-1]; d.at.Equal(last.at) { - last.w = math.Min(last.w, d.w) - if d.seen.After(last.seen) { - last.seen = d.seen - } + last.merge(d) } return } From 2b30933be9428de8f332e825e00c7782b820a4b4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 28 Oct 2026 10:03:20 +0100 Subject: [PATCH 34/34] test(site): judge overages at the fuse rating, not fuse - 1 A At margin 0 the car in s8-60-25 settles at the rating, as the owner's rule says, and the rig's old 1 A hidden margin failed it: 271 s above 24 A against 67 s allowed, with no second over 25 A. siterig/full has the same threshold in overage() (s9, s10, s11), the random invariants and s15bat. Port #1907's judge hunks: s3, s8, s9-s11, s15bat and the invariants now count time over the fuse rating. TestS8JudgesAtTheFuse and #1907's TestInvariantsJudgeAtTheFuse fail on the old judges. Co-Authored-By: Claude Opus 5.5 --- go/test/site/README.md | 2 +- go/test/site/evaluate_test.go | 3 +- go/test/site/random_test.go | 6 +-- go/test/site/rig_unit_test.go | 76 +++++++++++++++++++++++++++++++++- go/test/site/scenarios_test.go | 8 ++-- go/test/site/verdicts_test.go | 35 +++++++--------- 6 files changed, 99 insertions(+), 31 deletions(-) diff --git a/go/test/site/README.md b/go/test/site/README.md index c09718fcf..c5e600b07 100644 --- a/go/test/site/README.md +++ b/go/test/site/README.md @@ -73,7 +73,7 @@ its sources (`provenance` in [catalog_test.go](catalog_test.go)). ## Scenarios -"Over the limit" is the worst phase above the fuse − 1 A, measured through +"Over the limit" is the worst phase above the fuse rating, measured through the fuse. A device's response time counts as 2 s, the meter and control tick as 2 s more. diff --git a/go/test/site/evaluate_test.go b/go/test/site/evaluate_test.go index 1e3caaee4..9a2e95a13 100644 --- a/go/test/site/evaluate_test.go +++ b/go/test/site/evaluate_test.go @@ -650,8 +650,7 @@ func le(p *float64, limit float64) bool { return p != nil && *p <= limit } func (d *runData) overage(t0, t1, fuse float64) metrics { w := d.window(t0, t1) m := metrics{"max_i": round(maxOf(w, maxAbsI), 2)} - m["over_limit_longest_s"], m["over_limit_total_s"] = d.runsOver(t0, t1, fuse-1) - m["over_fuse_longest_s"], _ = d.runsOver(t0, t1, fuse) + m["over_limit_longest_s"], m["over_limit_total_s"] = d.runsOver(t0, t1, fuse) m["fuse_damage_max"] = round(maxOf(w, damage), 4) m["fuse_blown"] = anyBlown(w) return m diff --git a/go/test/site/random_test.go b/go/test/site/random_test.go index aeaa2b10d..a0c7f3377 100644 --- a/go/test/site/random_test.go +++ b/go/test/site/random_test.go @@ -217,7 +217,6 @@ type eventWindow struct{ from, to float64 } func judgeInvariants(d *runData) (metrics, []criterion) { fuse := d.metaF("fuse") - limit := fuse - 1 h := d.timing() t0, t1 := d.marks["start"], d.marks["end"] run := d.window(t0, t1) @@ -269,7 +268,7 @@ func judgeInvariants(d *runData) (metrics, []criterion) { var firstBad float64 var start *sample for i, r := range run { - over := maxAbsI(r) > limit + over := maxAbsI(r) > fuse if over && start == nil { start = r } @@ -367,7 +366,8 @@ func judgeInvariants(d *runData) (metrics, []criterion) { for _, hw := range holds { run4 = 0 for i, r := range d.window(hw.from+grace, hw.to) { - roomA := limit - (maxAbsI(r) - carW(r, 0)/(3*volt)) + // Room below fuse - 1 A, the car's line in Core. + roomA := fuse - 1 - (maxAbsI(r) - carW(r, 0)/(3*volt)) bad := carW(r, 0) < 0.5*1380 && roomA >= 7 && !inFault(r.T) && !nearRestart(r.T) if bad && i > 0 { run4++ diff --git a/go/test/site/rig_unit_test.go b/go/test/site/rig_unit_test.go index 021c82cd2..5afd0dc0b 100644 --- a/go/test/site/rig_unit_test.go +++ b/go/test/site/rig_unit_test.go @@ -4,6 +4,8 @@ package site import ( "encoding/json" + "slices" + "strings" "testing" ) @@ -113,7 +115,7 @@ func TestInvariantsTellStepsFromOwnRaises(t *testing.T) { for i := 0; i <= 100; i++ { a := 10.0 if i >= 50 && i < 56 { - a = 26 // 6 s over the 24 A limit + a = 26 // 6 s over the 25 A fuse } d.samples = append(d.samples, &sample{T: float64(i), PhaseA: [3]float64{a, a, a}, Bat: plantDevice{SoCPct: 50}}) } @@ -175,3 +177,75 @@ func TestS18OwnCommandsSkipTheHouseStep(t *testing.T) { } } } + +// s8 at margin 0: the car settles at the fuse rating, as the owner's rule +// says. A phase at exactly the fuse for the whole run passes; 0.1 A above +// it for longer than the charger's delay + 7 s fails. Before, the judge +// counted every second above fuse - 1 A, and s8-60-25 failed at 25 A. +func TestS8JudgesAtTheFuse(t *testing.T) { + const over = "no longer than the charger's delay + 7 s" + ok := func(a float64) bool { + d := &runData{marks: map[string]float64{"charge_now": 0, "end": 300}, meta: map[string]any{"fuse": 25.0, "charger_delay_s": 60.0}} + for i := 0; i <= 300; i++ { + d.samples = append(d.samples, &sample{T: float64(i), PhaseA: [3]float64{a, 15, 15}}) + } + _, cs := judgeS8(d) + for _, c := range cs { + if strings.HasSuffix(c.Name, over) { + return c.OK + } + } + t.Fatalf("no criterion ending %q", over) + return false + } + if !ok(25) { + t.Error("a phase at exactly the 25 A fuse failed") + } + if ok(25.1) { + t.Error("a phase 0.1 A over the 25 A fuse for 300 s passed") + } +} + +// The limit is the fuse rating: a phase may sit at exactly the fuse until +// the end of the run, and 0.1 A above it fails once the time budget is up. +func TestInvariantsJudgeAtTheFuse(t *testing.T) { + const fuse = 25.0 + const own = "over the limit at most 3 s after FTW's own raise" + const step = "over the limit no longer than an outside step allows" + for _, c := range []struct { + name string + a float64 + to int // the level holds from 50 s until this second + withStep bool + want []string + }{ + {"at the fuse", fuse, 101, false, nil}, + {"at the fuse after a step", fuse, 101, true, nil}, + // 20 s is past the 3 s for FTW's own raise, and past the + // 2 + 2 + 3 s a step allows while the battery can answer. + {"0.1 A over", fuse + 0.1, 70, false, []string{own}}, + {"0.1 A over after a step", fuse + 0.1, 70, true, []string{step}}, + } { + d := &runData{marks: map[string]float64{"start": 0, "end": 100}, meta: map[string]any{"fuse": fuse}} + for i := 0; i <= 100; i++ { + a := 10.0 + if i >= 50 && i < c.to { + a = c.a + } + d.samples = append(d.samples, &sample{T: float64(i), PhaseA: [3]float64{a, a, a}, Bat: plantDevice{SoCPct: 50}}) + } + if c.withStep { + d.events = append(d.events, houseEventLine{T: 49.5, Event: houseEvent{Do: "loads"}}) + } + var failed []string + _, cs := judgeInvariants(d) + for _, x := range cs { + if !x.OK { + failed = append(failed, x.Name) + } + } + if !slices.Equal(failed, c.want) { + t.Errorf("%s: failed %v, want %v", c.name, failed, c.want) + } + } +} diff --git a/go/test/site/scenarios_test.go b/go/test/site/scenarios_test.go index c1cb69f9d..294124cca 100644 --- a/go/test/site/scenarios_test.go +++ b/go/test/site/scenarios_test.go @@ -1010,7 +1010,7 @@ func judgeS15bat(d *runData) (metrics, []criterion) { start := d.firstT(t0, func(r *sample) bool { return carW(r, 0) >= 0.95*1380 }) over, longest := 0.0, 0.0 for _, r := range d.window(t0, t1) { - if maxAbsI(r) > fuse-1 { + if maxAbsI(r) > fuse { over++ longest = math.Max(longest, over) } else { @@ -1018,13 +1018,13 @@ func judgeS15bat(d *runData) (metrics, []criterion) { } } m := metrics{"battery_rise_w_20s_before": math.Round(rise), "car_start_s": roundp(start, 1), - "car_w_last60": math.Round(meanOf(d.window(t1-60, t1), func(r *sample) float64 { return carW(r, 0) })), - "over_fuse_minus1_longest_s": longest, "max_abs_i": round(maxOf(d.window(t0, t1), maxAbsI), 1)} + "car_w_last60": math.Round(meanOf(d.window(t1-60, t1), func(r *sample) float64 { return carW(r, 0) })), + "over_fuse_longest_s": longest, "max_abs_i": round(maxOf(d.window(t0, t1), maxAbsI), 1)} return m, []criterion{ crit("the battery order rose over 1 150 W in the 20 s before Charge now", rise > 1150), // the setup crit("car charging within 10 s of Charge now", le(start, 10)), crit("fuse holds", !anyBlown(d.window(t0, t1))), - crit("over fuse - 1 A at most 3 s", longest <= 3), + crit("over the fuse at most 3 s", longest <= 3), } } diff --git a/go/test/site/verdicts_test.go b/go/test/site/verdicts_test.go index 9cb180db0..cfc2c678f 100644 --- a/go/test/site/verdicts_test.go +++ b/go/test/site/verdicts_test.go @@ -85,11 +85,10 @@ func judgeS3(d *runData) (metrics, []criterion) { "pre_step_max_i": round(maxOf(pre, maxAbsI), 2), } m["post_step_max_i"] = round(maxOf(d.window(tStep, tBack), maxAbsI), 2) - m["over_fuse_longest_s"], m["over_fuse_total_s"] = d.runsOver(tStep, tBack, fuse) - overLongest, overTotal := d.runsOver(tStep, tBack, fuse-1) - m["over_fuse_minus1_longest_s"], m["over_fuse_minus1_total_s"] = overLongest, overTotal - m["i2_over_fuse_minus1_s"], m["i2_peak_a"] = d.i2Over(tCN, t1, fuse-1) - m["back_under_fuse_minus1_s"] = roundp(d.firstT(tStep, func(r *sample) bool { return maxAbsI(r) <= fuse-1 && r.T > tStep+1 }), 1) + overLongest, overTotal := d.runsOver(tStep, tBack, fuse) + m["over_fuse_longest_s"], m["over_fuse_total_s"] = overLongest, overTotal + m["i2_over_fuse_s"], m["i2_peak_a"] = d.i2Over(tCN, t1, fuse) + m["back_under_fuse_s"] = roundp(d.firstT(tStep, func(r *sample) bool { return maxAbsI(r) <= fuse && r.T > tStep+1 }), 1) last := d.window(tBack-60, tBack) m["post_step_car_w_last60"] = math.Round(meanOf(last, func(r *sample) float64 { return carW(r, 0) })) m["post_step_bat_w_last60"] = math.Round(meanOf(last, batSigned)) @@ -105,15 +104,12 @@ func judgeS3(d *runData) (metrics, []criterion) { // Device response + meter and tick (2 + 2 s) + 3 s. allowed := d.metaF("charger_delay_s") + 2 + 2 + 3 m["allowed_over_s"] = allowed - m["run_over_fuse_minus1_longest_s"], _ = d.runsOver(tCN, t1, fuse-1) + m["run_over_fuse_longest_s"], _ = d.runsOver(tCN, t1, fuse) m["run_max_i"] = round(maxOf(after, maxAbsI), 2) - // Before the step, layers with different margins may hold a phase - // between fuse - 1 A and fuse - 0.5 A; reported, not judged. - m["pre_step_over_fuse_minus1_longest_s"], _ = d.runsOver(tCN, tStep, fuse-1) - m["pre_step_over_fuse_minus_half_longest_s"], _ = d.runsOver(tCN, tStep, fuse-0.5) + m["pre_step_over_fuse_longest_s"], _ = d.runsOver(tCN, tStep, fuse) return m, []criterion{ crit("fuse holds", !isBlown), - crit("over fuse - 1 A no longer than the response time + 3 s after the step", overLongest <= allowed), + crit("over the fuse no longer than the response time + 3 s after the step", overLongest <= allowed), } } @@ -412,10 +408,9 @@ func judgeS8(d *runData) (metrics, []criterion) { fuse := d.metaF("fuse") t0, t1 := d.marks["charge_now"], d.marks["end"] m := d.carSummary(t0, t1, maxW) - m["over_fuse_longest_s"], m["over_fuse_total_s"] = d.runsOver(t0, t1, fuse) - overLongest, overTotal := d.runsOver(t0, t1, fuse-1) - m["over_fuse_minus1_longest_s"], m["over_fuse_minus1_total_s"] = overLongest, overTotal - m["i2_over_fuse_minus1_s"], m["i2_peak_a"] = d.i2Over(t0, t1, fuse-1) + overLongest, overTotal := d.runsOver(t0, t1, fuse) + m["over_fuse_longest_s"], m["over_fuse_total_s"] = overLongest, overTotal + m["i2_over_fuse_s"], m["i2_peak_a"] = d.i2Over(t0, t1, fuse) var prs [][2]float64 for i, p := range connector1(profiles(d.frames, t0, t1)) { if i >= 20 { @@ -433,7 +428,7 @@ func judgeS8(d *runData) (metrics, []criterion) { } return m, []criterion{ crit("fuse holds", !m["fuse_blown"].(bool)), - crit("over fuse - 1 A no longer than the charger's delay + 7 s", overLongest <= allowed), + crit("over the fuse no longer than the charger's delay + 7 s", overLongest <= allowed), } } @@ -459,8 +454,8 @@ func judgeS9(d *runData) (metrics, []criterion) { m["profiles_after_switch"] = seq return m, []criterion{ crit("fuse holds", !before["fuse_blown"].(bool) && !after["fuse_blown"].(bool)), - crit("over fuse - 1 A at most 3 s on 3p", before["over_limit_longest_s"].(float64) <= 3), - crit("over fuse - 1 A at most 3 s after the switch to 1p", after["over_limit_longest_s"].(float64) <= 3), + crit("over the fuse at most 3 s on 3p", before["over_limit_longest_s"].(float64) <= 3), + crit("over the fuse at most 3 s after the switch to 1p", after["over_limit_longest_s"].(float64) <= 3), } } @@ -500,7 +495,7 @@ func judgeS10(d *runData) (metrics, []criterion) { } return m, []criterion{ crit("fuse holds", !before["fuse_blown"].(bool) && !after["fuse_blown"].(bool)), - crit("over fuse - 1 A at most 3 s after the step", after["over_limit_longest_s"].(float64) <= 3), + crit("over the fuse at most 3 s after the step", after["over_limit_longest_s"].(float64) <= 3), } } @@ -547,7 +542,7 @@ func judgeS11(d *runData) (metrics, []criterion) { } return m, []criterion{ crit("an offer within 60 s of the restart", le(firstPosS, 60)), - crit("over fuse - 1 A at most 3 s", run["over_limit_longest_s"].(float64) <= 3), + crit("over the fuse at most 3 s", run["over_limit_longest_s"].(float64) <= 3), crit("fuse holds", !run["fuse_blown"].(bool)), } }