diff --git a/.changeset/per-phase-relief-uses-site-phase-count.md b/.changeset/per-phase-relief-uses-site-phase-count.md new file mode 100644 index 00000000..ac4dedfa --- /dev/null +++ b/.changeset/per-phase-relief-uses-site-phase-count.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The per-phase fuse guard now sizes its correction from the site's own phase count and voltage. When one phase runs over the breaker, the guard converts that overage into battery watts, and it used to assume every site had three phases and 230 V per phase. On a single-phase site that asked the battery for three times the relief the site needed, enough to push the meter through zero and into a violation the other way; the phase count and voltage the operator configured now do the conversion. The guard also reads only the phases the site has: current reported on an L2 that a single-phase site does not own no longer fires it. A site whose fuse is described by amps alone gets no per-phase clamp rather than one computed from invented numbers — the same rule the fuse safety margin already follows. Three-phase 230 V sites, which is what configuration defaults to, are unaffected. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index dbbb9d7c..6f948e56 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -3474,25 +3474,22 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta } predicted := currentGrid - currentBat + sumTarget - // Per-phase overage: the worst single-phase amperage above the fuse - // trip threshold (less the safety margin), expressed as AGGREGATE - // battery action needed to bring it back. Assumes a 3Φ-balanced - // battery — each unit of total battery action contributes 1/3 to - // each phase, so a worst-phase overage of N watts requires 3 × N - // watts of total battery action to bring it under. Conservative - // for 1Φ batteries (Pixii Home etc.): they over-correct on the - // other phases, less import / export there, still safe. + // 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. // - // `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; + // 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 := perPhaseOverageW(store, state) * 3.0 + perPhase := perPhaseReliefW(store, state) // 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. @@ -3708,6 +3705,17 @@ func (s *State) effectiveExportCeilingW(fuseMaxW float64) float64 { // 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 @@ -3716,6 +3724,9 @@ func perPhaseOverageW(store *telemetry.Store, state *State) float64 { if state == nil || state.SiteFuseAmps <= 0 || state.SiteMeterDriver == "" { return 0 } + if state.SiteFuseVoltage <= 0 || state.SiteFusePhases <= 0 { + return 0 + } r := store.Get(state.SiteMeterDriver, telemetry.DerMeter) if r == nil || len(r.Data) == 0 { return 0 @@ -3734,8 +3745,14 @@ func perPhaseOverageW(store *telemetry.Store, state *State) float64 { // 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 _, p := range []*float64{d.L1A, d.L2A, d.L3A} { + for i := 0; i < phases; i++ { + p := phaseAmps[i] if p == nil { continue } @@ -3751,11 +3768,30 @@ func perPhaseOverageW(store *telemetry.Store, state *State) float64 { if maxA <= threshold { return 0 } - v := state.SiteFuseVoltage - if v <= 0 { - v = 230 // back-compat for tests / e2e that wire only SiteFuseAmps + 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. +// +// 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 (maxA - threshold) * v + return overage * float64(state.SiteFusePhases) } // applyPlanSignFloor enforces "executed battery total must agree in sign @@ -4012,21 +4048,23 @@ func forceFuseDischarge( // aggregate, not per-phase. effImportW := state.effectiveImportCeilingW(fuseMaxW) overage := predicted - effImportW - // Per-phase overage trumps aggregate when bigger — but ONLY on the - // import side. perPhaseOverageW is direction-agnostic (uses |amps|), - // so an export-side phase trip would otherwise cause this function - // to command MORE discharge, pushing the over-current phase further - // over the breaker. applyFuseGuard's exportOverage branch already - // shrinks discharge for that case before we run. + // Per-phase relief trumps aggregate when bigger — but ONLY on the + // import side. This function's single lever is MORE discharge, and + // perPhaseReliefW is direction-agnostic (its overage uses |amps|), + // so honouring it during export would push the over-current phase + // further over the breaker. applyFuseGuard's exportOverage branch + // already shrinks discharge for that case before we run. The + // direction limit is stated here, at the one call that needs it, + // rather than baked into a second copy of the conversion. // // Gate on `currentGrid` (live aggregate at the meter) rather than // `predicted`. Per-phase amps and currentGrid come from the same // DerMeter sample; predicted mixes in sumTarget which can swing // across 0 and silently flip the gate. - if currentGrid >= 0 { - perPhaseOverage := perPhaseOverageW(store, state) * 3.0 - if perPhaseOverage > overage { - overage = perPhaseOverage + importSide := currentGrid >= 0 + if importSide { + if relief := perPhaseReliefW(store, state); relief > overage { + overage = relief } } if overage <= 0 { diff --git a/go/internal/control/fuse_saver_test.go b/go/internal/control/fuse_saver_test.go index c59b664a..7fd86c63 100644 --- a/go/internal/control/fuse_saver_test.go +++ b/go/internal/control/fuse_saver_test.go @@ -224,10 +224,18 @@ func TestFuseSaverBypassesSlew(t *testing.T) { // ---- Per-phase clamp ---- // setupPerPhase wires a meter that emits l1_a/l2_a/l3_a in -// DerReading.Data and a single battery. Aggregate gridW stays -// deliberately under the fuse to prove the per-phase clamp fires -// independently of the aggregate guard. +// DerReading.Data and a single battery on a 3Φ 230 V site. Aggregate +// gridW stays deliberately under the fuse to prove the per-phase clamp +// fires independently of the aggregate guard. func setupPerPhase(aggGridW float64, l1A, l2A, l3A float64, batSoC float64, maxDischargeW float64) (*telemetry.Store, *State, map[string]float64) { + return setupPerPhaseSite(3, 230, aggGridW, l1A, l2A, l3A, batSoC, maxDischargeW) +} + +// setupPerPhaseSite is setupPerPhase with the site's fuse description +// spelled out. Phase count and per-phase voltage drive the conversion +// from a worst-phase overage into aggregate battery watts, so a test +// that cares about that conversion states them. +func setupPerPhaseSite(phases int, voltage float64, aggGridW float64, l1A, l2A, l3A float64, batSoC float64, maxDischargeW float64) (*telemetry.Store, *State, map[string]float64) { s := telemetry.NewStore() data, _ := json.Marshal(map[string]any{ "l1_a": l1A, @@ -241,7 +249,8 @@ func setupPerPhase(aggGridW float64, l1A, l2A, l3A float64, batSoC float64, maxD s.DriverHealthMut("bat").RecordSuccess() st := NewState(0, 50, "meter") st.SiteFuseAmps = 16 - st.SiteFuseVoltage = 230 + st.SiteFuseVoltage = voltage + st.SiteFusePhases = phases st.DriverLimits = map[string]PowerLimits{ "bat": {MaxChargeW: 10000, MaxDischargeW: maxDischargeW}, } @@ -455,6 +464,139 @@ func TestPerPhaseClampSafetyMarginZeroIsBackCompat(t *testing.T) { } } +// ---- Per-phase relief is scaled by the site's phase count ---- +// +// A worst-phase overage is watts on one phase; the dispatcher commands +// aggregate battery watts. The conversion between them is the site's +// 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 +// this site is also exactly the aggregate overage — the two numbers +// must agree when there is only one phase. + +// Import side, battery idle: the forced discharge equals the overage +// 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 + fuseMaxW := 16 * 230.0 * 1 + + out := fuseSaverFromZero(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 + 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 + // 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 "+ + "into export", postGridW) + } +} + +// Export side — the direction forceFuseDischarge deliberately skips +// (more discharge would worsen it) and applyFuseGuard owns. The same +// phase-count law applies: shrink the discharge by the overage once. +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 + fuseMaxW := 16 * 230.0 * 1 + + // The battery is already discharging what the meter shows, and the + // target holds it there — so the aggregate export overage is the + // per-phase one. On a site with one phase the two must agree. + setBatteryLiveW(store, "bat", -5000, 0.6) + + targets := []DispatchTarget{{Driver: "bat", TargetW: -5000}} + guarded := applyFuseGuard(targets, store, state, fuseMaxW) + 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 + // zeroed and the site swings to 520 W of import. + expected := -2987.5 + if math.Abs(guarded[0].TargetW-expected) > 1 { + t.Errorf("discharge after 1Φ export clamp = %.1f W, want %.1f W", + guarded[0].TargetW, expected) + } +} + +// setBatteryLiveW drives a battery's smoothed reading to w. The store +// Kalman-filters every sample, so one update lands about three quarters +// of the way there; repeat until the filter has converged. +func setBatteryLiveW(s *telemetry.Store, driver string, w, soc float64) { + for i := 0; i < 12; i++ { + s.Update(driver, telemetry.DerBattery, w, &soc, nil) + } + s.DriverHealthMut(driver).RecordSuccess() +} + +// A 1Φ site has no L2 and no L3. Current reported on a phase the site +// does not have is not a breaker this guard defends — reading it fires +// the clamp on a site that is nowhere near its fuse. +func TestPerPhaseClampReadsConfiguredPhasesOnly(t *testing.T) { + store, state, caps := setupPerPhaseSite(1, 230, 2300, 10, 24, 24, 0.6, 10000) + out := fuseSaverFromZero(store, state, caps, 16*230.0*1) + if out != nil { + t.Errorf("1Φ site with L1 at 10 A is under its fuse; l2_a/l3_a must "+ + "not fire the clamp. got %v", out) + } +} + +// 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) + 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") + } + // (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 + 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) + } +} + +// Amps alone do not describe a fuse. Without a phase count and a +// voltage there is no conversion to watts, so the per-phase clamp +// stays off rather than inventing 230 V / 3Φ — the same law +// fuseSafetyMarginW follows. Production config always fills both +// (defaults 230 V / 3Φ, validation rejects <= 0). +func TestPerPhaseClampOffWhenFuseDescriptionIncomplete(t *testing.T) { + for _, tc := range []struct { + name string + phases int + voltage float64 + }{ + {"no phase count", 0, 230}, + {"no voltage", 3, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + store, state, caps := setupPerPhaseSite( + tc.phases, tc.voltage, 9000, 18, 12, 8, 0.6, 10000) + if out := fuseSaverFromZero(store, state, caps, 11040); out != nil { + t.Errorf("incomplete fuse description must not clamp per phase, got %v", out) + } + }) + } +} + // End-to-end via ComputeDispatch: idle mode + grid surge → returns // non-empty discharge targets. Idle mode would normally return []. func TestFuseSaverFiresInIdleMode(t *testing.T) {