From b2b76768de8d8d35cdf1168b8a79d12c91148e63 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 14:54:18 +0100 Subject: [PATCH 1/3] fix(control): phase current takes its direction from the phase's power Fuse protection read each phase's current as a magnitude, so 20 A of solar export counted as 20 A of import and a car never started on an exporting site. A phase now takes its direction from the same phase's power in the meter reading (P1 and the SDM630 report current as a magnitude); without that phase's power the current counts as import. An exporting phase counts at its reading for a rise and a start, and is never over the limit for an EV cut, since less EV load would raise its export. The sitesim meter now reports phase power. The two "export start" scenarios, two field-matrix rows and the rig's s2-16 pass. Six cloud rows that stopped failing on export now start and fail on the late readback; they carry that label. Five rows with phase_mode unset and no step table never order a step, a separate cause, labelled nostep. Co-Authored-By: Claude Opus 5.5 --- .changeset/fuse-phase-direction.md | 5 ++ go/internal/control/ev_battery_headroom.go | 5 +- go/internal/control/ev_fuse_budget.go | 6 +- go/internal/control/fuse_heat.go | 63 ++++++++++++------- go/internal/control/fuse_heat_test.go | 36 +++++++++++ go/internal/control/sitesim_test.go | 7 ++- .../control/testdata/sitesim_field_known.txt | 28 ++++----- go/internal/sitesim/sitesim.go | 7 ++- go/test/site/scenarios_test.go | 3 +- 9 files changed, 110 insertions(+), 50 deletions(-) create mode 100644 .changeset/fuse-phase-direction.md diff --git a/.changeset/fuse-phase-direction.md b/.changeset/fuse-phase-direction.md new file mode 100644 index 000000000..a0bb72012 --- /dev/null +++ b/.changeset/fuse-phase-direction.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +A car can now start and rise while the house exports. Fuse protection read each phase's current as a magnitude, so 20 A of solar export counted as 20 A of import and the car never started. A phase now takes its direction from the same phase's power in the meter reading, and new load on an exporting phase has more room, not less. When a meter reports no phase power, its current still counts as import. diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 12a867c61..593476149 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -53,8 +53,9 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st // Battery charge stays under the level the EV may rise to. spare := state.importRiseCeilingW(fuseMaxW) - meter.RawW - needW // During measured export overflow, the existing fuse pipeline may - // require charge to reduce export. Magnitudes alone cannot label that - // worst phase as import; leave this direction to the existing guard. + // require charge to reduce export; leave that direction to it. The + // phases carry their direction (phaseCurrentsA): the worst phase is the + // one importing most, and an exporting phase leaves the charge more room. if meter.RawW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 { if phaseSpare, known := evBatteryPhaseSpareW(meter, state, needW/(state.SiteFuseVoltage*phases)); known { spare = math.Min(spare, phaseSpare) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 41ee6891b..a06c3d3cc 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -608,8 +608,10 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW return r } -// worstPhaseA reads the site's configured phases. present is false when the -// meter reports no phase currents; valid is false when they cannot be used. +// worstPhaseA is the highest import on the site's configured phases, with +// direction (phaseCurrentsA), and 0 when every phase exports. present is +// false when the meter reports no phase currents; valid is false when they +// cannot be used. func worstPhaseA(meter *telemetry.DerReading, state *State) (worstA float64, present, valid bool) { amps, present, valid := phaseCurrentsA(meter, state) for _, a := range amps { diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index 55807f8ce..d850af962 100644 --- a/go/internal/control/fuse_heat.go +++ b/go/internal/control/fuse_heat.go @@ -58,9 +58,10 @@ func (h *fuseHeat) step(at time.Time, amps []float64) { copy(h.amps[:], amps) } -// overA is how far the phases the budget no longer covers sit above limitA -// on this reading: a phase whose filtered current is over limitA, or one -// above instantA. Zero while the budget covers every phase. +// overA is how far the importing phases the budget no longer covers sit +// above limitA on this reading: a phase whose filtered current is over +// limitA, or one above instantA. Zero while the budget covers every phase. +// An exporting phase is never over: less EV load would raise its export. func (h *fuseHeat) overA(amps []float64, limitA, instantA float64) float64 { over := 0.0 for i, a := range amps { @@ -71,15 +72,19 @@ func (h *fuseHeat) overA(amps []float64, limitA, instantA float64) float64 { return over } -// levelA is the highest phase current, taking each phase at the higher of -// the reading and its heat, plus what we sent that it may not show yet. -// A running pool's rises read it, so a rise never spends heat the last -// minute has already used, nor room an earlier rise will take. An idle -// start reads startLevelA. +// levelA is the highest phase current, with its direction, plus what we +// sent that it may not show yet. An importing phase counts at the higher of +// the reading and its heat; an exporting one at its reading, as new load +// lowers its current. A running pool's rises read it, so a rise never +// spends heat the last minute has already used, nor room an earlier rise +// will take. An idle start reads startLevelA. func (h *fuseHeat) levelA(amps []float64, pendingA [3]float64) float64 { - level := 0.0 + level := math.Inf(-1) for i, a := range amps { - level = math.Max(level, math.Max(a, math.Sqrt(h.sq[i]))+pendingA[i]) + if a >= 0 { + a = math.Max(a, math.Sqrt(h.sq[i])) + } + level = math.Max(level, a+pendingA[i]) } return level } @@ -88,15 +93,15 @@ func (h *fuseHeat) levelA(amps []float64, pendingA [3]float64) float64 { // reading plus what we sent that it may not show yet. A phase's heat counts // only while it is over limitA, so a car that just stopped is not counted // twice, in its heat and in the start it asks for, while a real overage -// still blocks the start until the filter has cooled under the limit. +// still blocks the start until the filter has cooled under the limit. An +// exporting phase counts at its reading. func (h *fuseHeat) startLevelA(amps []float64, pendingA [3]float64, limitA float64) float64 { - level := 0.0 + level := math.Inf(-1) for i, a := range amps { - a += pendingA[i] - if h.sq[i] > limitA*limitA { // As overA reads it. - a = math.Max(a, math.Sqrt(h.sq[i])+pendingA[i]) + if a >= 0 && h.sq[i] > limitA*limitA { // As overA reads it. + a = math.Max(a, math.Sqrt(h.sq[i])) } - level = math.Max(level, a) + level = math.Max(level, a+pendingA[i]) } return level } @@ -117,15 +122,20 @@ func (s *State) stepFuseHeat(meter *telemetry.DerReading) { } } -// phaseCurrentsA reads the site's configured phases as magnitudes. present -// is false when the meter reports no phase currents; valid is false when -// they cannot be used. +// phaseCurrentsA reads the site's configured phases, import positive. A +// phase's direction is its power's sign in the same reading: P1 and many +// meters report current as a magnitude. Without that phase's power the +// current counts as import, the safe side. present is false when the meter +// reports no phase currents; valid is false when they cannot be used. func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, present, valid bool) { // DER data uses snake case; separate fields retain absent-vs-zero. var data struct { - L1 *float64 `json:"l1_a"` - L2 *float64 `json:"l2_a"` - L3 *float64 `json:"l3_a"` + L1 *float64 `json:"l1_a"` + L2 *float64 `json:"l2_a"` + L3 *float64 `json:"l3_a"` + L1W *float64 `json:"l1_w"` + L2W *float64 `json:"l2_w"` + L3W *float64 `json:"l3_w"` } if json.Unmarshal(meter.Data, &data) != nil || (data.L1 == nil && data.L2 == nil && data.L3 == nil) { return nil, false, false @@ -133,11 +143,16 @@ func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, if !finiteFuseValue(state.SiteFuseAmps) || !finiteFuseValue(state.SiteFuseVoltage) || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases < 1 || state.SiteFusePhases > 3 { return nil, true, false } - for _, a := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] { + powers := []*float64{data.L1W, data.L2W, data.L3W} + for i, a := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] { if a == nil || !finiteFuseValue(*a) { return nil, true, false } - amps = append(amps, math.Abs(*a)) + signed := math.Abs(*a) + if w := powers[i]; w != nil && finiteFuseValue(*w) && *w < 0 { + signed = -signed + } + amps = append(amps, signed) } return amps, true, true } diff --git a/go/internal/control/fuse_heat_test.go b/go/internal/control/fuse_heat_test.go index 7d2494a81..d3231e837 100644 --- a/go/internal/control/fuse_heat_test.go +++ b/go/internal/control/fuse_heat_test.go @@ -500,3 +500,39 @@ func TestStartLevelReadsHeatOnlyOverTheLimit(t *testing.T) { }) } } + +// A phase's current takes its direction from the same phase's power: P1 +// reports current as a magnitude, Pixii signs it. Without the power the +// current counts as import. +func TestPhaseCurrentsTakeDirectionFromPhasePower(t *testing.T) { + _, state := fuseHeatState() + for _, tc := range []struct { + name string + data map[string]float64 + want []float64 + }{ + {"p1", map[string]float64{"l1_a": 20, "l2_a": 3, "l3_a": 5, "l1_w": -4600, "l2_w": 690, "l3_w": 0}, []float64{-20, 3, 5}}, + {"signed", map[string]float64{"l1_a": -20, "l2_a": -3, "l3_a": 5, "l1_w": -4600, "l2_w": -690, "l3_w": 1150}, []float64{-20, -3, 5}}, + {"no phase power", map[string]float64{"l1_a": -20, "l2_a": 3, "l3_a": 5}, []float64{20, 3, 5}}, + } { + raw, _ := json.Marshal(tc.data) + amps, present, valid := phaseCurrentsA(&telemetry.DerReading{Data: raw}, state) + if !present || !valid || len(amps) != 3 || amps[0] != tc.want[0] || amps[1] != tc.want[1] || amps[2] != tc.want[2] { + t.Errorf("%s: %v (present %v, valid %v), want %v", tc.name, amps, present, valid, tc.want) + } + } +} + +// An exporting phase counts at its reading for a rise and a start: new load +// lowers its current, and its heat from an earlier import does not count. +func TestExportingPhaseLevelIsItsReading(t *testing.T) { + h := fuseHeat{set: true} + h.sq = [3]float64{20 * 20, 4, 4} + amps := []float64{-12, 2, 2} + if got := h.levelA(amps, [3]float64{3, 3, 3}); got != 5 { + t.Fatalf("rise level %.1f A, want L2's 2 A plus 3 A on its way", got) + } + if got := h.startLevelA(amps, [3]float64{}, 24); got != 2 { + t.Fatalf("start level %.1f A, want L2's 2 A", got) + } +} diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 8e4bea715..35ce0f966 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -215,7 +215,8 @@ func (s *simSite) step() { } if s.downUntil.IsZero() { if m := tick.Meter; m != nil { - s.emit("meter", telemetry.DerMeter, m.W, nil, map[string]any{"l1_a": m.PhaseA[0], "l2_a": m.PhaseA[1], "l3_a": m.PhaseA[2]}) + s.emit("meter", telemetry.DerMeter, m.W, nil, map[string]any{"l1_a": m.PhaseA[0], "l2_a": m.PhaseA[1], "l3_a": m.PhaseA[2], + "l1_w": m.PhaseW[0], "l2_w": m.PhaseW[1], "l3_w": m.PhaseW[2]}) } if b := tick.Battery; b != nil { s.emit("battery", telemetry.DerBattery, b.W, &b.SoC, nil) @@ -597,9 +598,9 @@ func simScenarios() []simScenario { } } return []simScenario{ - {name: "export start", known: "export counted as import, fuse_heat.go:120", run: 5 * time.Minute, + {name: "export start", run: 5 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW, p.PVW = houseA(2, 2, 2), -22*690 }}, - {name: "export start, unsigned phase currents", known: "export counted as import, fuse_heat.go:120", run: 5 * time.Minute, + {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, plant: func(p *sitesim.Plant) { diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index eacc80a97..88187b10c 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -7,8 +7,8 @@ # phases; the planner gets steps a three-phase order can deliver. # #1795 srcfl/ftw#1795: a charger drawing against its order reads not # following. -# export srcfl/ftw#1782, draft: an exporting phase counts as import, so -# surplus never finds room to start. +# nostep no fix yet: with phase_mode unset and no step table, surplus +# never orders a step, though export now leaves room for one. # readback no fix yet: surplus raises again before the charger applied the # last raise, then pauses when both land (surplus_reserve.go:128). # relief srcfl/ftw#1789, draft: the battery ends its relief before the @@ -21,18 +21,16 @@ pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/chargenow+battery own-raise relief pr1763/25A/3p/3p-15A/easee40s/car3p/6-2-2/pause+battery own-raise relief pr1763/25A/3p/3p-15A/fast2s/car3p/6-2-2/pause+battery own-raise relief probe/25A/auto/mixed/easee40s/car3p/balanced/surplus+goal+export2500W idle readback -timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle export -timing/16A/3p/3p/easee120s/car3p/L1/surplus idle export -timing/16A/3p/3p/easee40s/car3p/L1/surplus idle export -timing/16A/3p/3p/fast2s/car3p/L1/surplus idle export -timing/16A/auto/mixed/cloud60s/car3p/L1/surplus idle export -timing/16A/auto/mixed/easee120s/car3p/L1/surplus idle export -timing/16A/auto/mixed/easee40s/car3p/L1/surplus idle export -timing/16A/auto/mixed/fast2s/car3p/L1/surplus idle export -timing/16A/empty/none/cloud60s/car3p/L1/surplus idle export -timing/16A/empty/none/easee120s/car3p/L1/surplus idle export -timing/16A/empty/none/easee40s/car3p/L1/surplus idle export -timing/16A/empty/none/fast2s/car3p/L1/surplus idle export +timing/16A/3p/3p/cloud60s/car3p/L1/surplus idle readback +timing/16A/3p/3p/easee120s/car3p/L1/surplus idle readback +timing/16A/3p/3p/easee40s/car3p/L1/surplus idle readback +timing/16A/auto/mixed/cloud60s/car3p/L1/surplus idle readback +timing/16A/auto/mixed/easee120s/car3p/L1/surplus idle readback +timing/16A/auto/mixed/easee40s/car3p/L1/surplus idle readback +timing/16A/empty/none/cloud60s/car3p/L1/surplus idle nostep +timing/16A/empty/none/easee120s/car3p/L1/surplus idle nostep +timing/16A/empty/none/easee40s/car3p/L1/surplus idle nostep +timing/16A/empty/none/fast2s/car3p/L1/surplus idle nostep timing/25A/3p/3p/cloud60s/car3p/L1/surplus idle readback timing/25A/3p/3p/easee120s/car3p/L1/surplus idle readback timing/25A/3p/3p/easee40s/car3p/L1/surplus idle readback @@ -42,4 +40,4 @@ timing/25A/auto/mixed/easee40s/car3p/L1/surplus idle readback timing/25A/empty/none/cloud60s/car3p/L1/surplus idle readback timing/25A/empty/none/easee120s/car3p/L1/surplus idle readback timing/25A/empty/none/easee40s/car3p/L1/surplus idle readback -timing/25A/empty/none/fast2s/car3p/L1/surplus idle export +timing/25A/empty/none/fast2s/car3p/L1/surplus idle nostep diff --git a/go/internal/sitesim/sitesim.go b/go/internal/sitesim/sitesim.go index 0ed284435..9e4a58642 100644 --- a/go/internal/sitesim/sitesim.go +++ b/go/internal/sitesim/sitesim.go @@ -68,10 +68,12 @@ type Tick struct { Restart time.Duration // > 0: Core goes down now for this long } -// MeterReading is one site meter reading. +// MeterReading is one site meter reading. Phase power keeps its sign +// when the currents lose theirs, as on P1. type MeterReading struct { W float64 PhaseA [3]float64 + PhaseW [3]float64 } // BatteryReading is what the battery reports. @@ -123,7 +125,8 @@ func (p *Plant) Step() Tick { if p.Meter.due(p.Now) { r := &MeterReading{PhaseA: p.A} for i, a := range p.A { - r.W += a * p.Voltage + r.PhaseW[i] = a * p.Voltage + r.W += r.PhaseW[i] if p.Meter.Unsigned { r.PhaseA[i] = math.Abs(a) } diff --git a/go/test/site/scenarios_test.go b/go/test/site/scenarios_test.go index 4c42c9c68..21c3cbbf1 100644 --- a/go/test/site/scenarios_test.go +++ b/go/test/site/scenarios_test.go @@ -38,8 +38,7 @@ var scenarios = []scenario{ {name: "s1-25", dir: "s1-chargenow-25A", minutes: 3.6, short: true, run: func(c *runCtx) (map[string]any, error) { return s1(c, 25) }, judge: judgeS1}, {name: "s2-16", dir: "s2-export-16A", minutes: 3.1, - run: func(c *runCtx) (map[string]any, error) { return s2(c, 16) }, judge: judgeS2, - known: map[string]string{"car starts within 60 s": "export counted as import (review fuse finding 1), next beta"}}, + run: func(c *runCtx) (map[string]any, error) { return s2(c, 16) }, judge: judgeS2}, {name: "s3-16", dir: "s3-step-16A", minutes: 6.7, short: true, run: func(c *runCtx) (map[string]any, error) { return s3(c, 16) }, judge: judgeS3}, {name: "s3-25", dir: "s3-step-25A", minutes: 6.7, short: true, From 542d14ef4fcd78cd7817e7ebfc35094ba6569238 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 15:11:30 +0100 Subject: [PATCH 2/3] fix(control): a phase exports only when its power shows it clearly Review of the direction fix: with no check, a driver whose phase power had the wrong sign would turn 40 A of import into export, and the EV would never be cut. A phase now counts as export only when every phase power is present, they add up to the reading's total within a tenth or 250 W, and the phase's power is below -25 W and a quarter of what its current could carry. Otherwise it counts as import, as before the fix: a phase near 0 W, a reactive phase, a mismatched driver. Three more field-matrix rows (25 A, phase_mode unset, no step table, cloud chargers) never order a step either; they carry nostep, not readback. docs/safety.md says how the direction is read. Co-Authored-By: Claude Opus 5.5 --- docs/safety.md | 6 +++ go/internal/control/fuse_heat.go | 32 +++++++++++++--- go/internal/control/fuse_heat_test.go | 37 ++++++++++++++----- .../control/testdata/sitesim_field_known.txt | 6 +-- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/docs/safety.md b/docs/safety.md index d58a855e0..c53c4ae6b 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -59,6 +59,12 @@ current holds it back only while it is over the limit. Where the meter reports phase currents, they replace total import as the EVs' fuse check; a 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 +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. + The implementation and tests in [`go/internal/control`](../go/internal/control) are authoritative. Planner-side limits in [`go/internal/mpc`](../go/internal/mpc) reduce infeasible plans but do not diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index d850af962..e14220341 100644 --- a/go/internal/control/fuse_heat.go +++ b/go/internal/control/fuse_heat.go @@ -124,9 +124,13 @@ func (s *State) stepFuseHeat(meter *telemetry.DerReading) { // phaseCurrentsA reads the site's configured phases, import positive. A // phase's direction is its power's sign in the same reading: P1 and many -// meters report current as a magnitude. Without that phase's power the -// current counts as import, the safe side. present is false when the meter -// reports no phase currents; valid is false when they cannot be used. +// meters report current as a magnitude. A phase exports only when its power +// says so clearly, beyond a quarter of what its current could carry, and +// the phases' powers add up to the reading's total. Otherwise its current +// counts as import, the safe side: a phase near 0 W, a reactive phase, a +// meter without phase power, or a driver whose phase power has the wrong +// sign. present is false when the meter reports no phase currents; valid +// is false when they cannot be used. func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, present, valid bool) { // DER data uses snake case; separate fields retain absent-vs-zero. var data struct { @@ -143,13 +147,14 @@ func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, if !finiteFuseValue(state.SiteFuseAmps) || !finiteFuseValue(state.SiteFuseVoltage) || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases < 1 || state.SiteFusePhases > 3 { return nil, true, false } - powers := []*float64{data.L1W, data.L2W, data.L3W} + powers := []*float64{data.L1W, data.L2W, data.L3W}[:state.SiteFusePhases] + signs := phasePowersAddUp(powers, meter.RawW) for i, a := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] { if a == nil || !finiteFuseValue(*a) { return nil, true, false } signed := math.Abs(*a) - if w := powers[i]; w != nil && finiteFuseValue(*w) && *w < 0 { + if w := powers[i]; signs && *w < -math.Max(exportSignMinW, signed*state.SiteFuseVoltage/4) { signed = -signed } amps = append(amps, signed) @@ -157,6 +162,23 @@ func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, return amps, true, true } +// exportSignMinW is the least export a phase's power must show before its +// current counts as export. +const exportSignMinW = 25.0 + +// phasePowersAddUp reports whether every phase power is present and their +// sum is the reading's total, within a tenth of the phases' power or 250 W. +func phasePowersAddUp(powers []*float64, totalW float64) bool { + var sum, abs float64 + for _, w := range powers { + if w == nil || !finiteFuseValue(*w) { + return false + } + sum, abs = sum+*w, abs+math.Abs(*w) + } + return finiteFuseValue(totalW) && math.Abs(sum-totalW) <= math.Max(250, abs/10) +} + // SitePhases is the site meter's last reading of its phase currents, for // the loadpoints' one-phase car check. false when the meter reports no // usable phase currents or the site has fewer than three phases. diff --git a/go/internal/control/fuse_heat_test.go b/go/internal/control/fuse_heat_test.go index d3231e837..b87f3639b 100644 --- a/go/internal/control/fuse_heat_test.go +++ b/go/internal/control/fuse_heat_test.go @@ -502,27 +502,46 @@ func TestStartLevelReadsHeatOnlyOverTheLimit(t *testing.T) { } // A phase's current takes its direction from the same phase's power: P1 -// reports current as a magnitude, Pixii signs it. Without the power the -// current counts as import. +// reports current as a magnitude, Pixii signs it. It counts as export only +// when the power says so clearly and the phases add up to the total; +// otherwise as import. func TestPhaseCurrentsTakeDirectionFromPhasePower(t *testing.T) { _, state := fuseHeatState() for _, tc := range []struct { - name string - data map[string]float64 - want []float64 + name string + data map[string]float64 + totalW float64 + want []float64 }{ - {"p1", map[string]float64{"l1_a": 20, "l2_a": 3, "l3_a": 5, "l1_w": -4600, "l2_w": 690, "l3_w": 0}, []float64{-20, 3, 5}}, - {"signed", map[string]float64{"l1_a": -20, "l2_a": -3, "l3_a": 5, "l1_w": -4600, "l2_w": -690, "l3_w": 1150}, []float64{-20, -3, 5}}, - {"no phase power", map[string]float64{"l1_a": -20, "l2_a": 3, "l3_a": 5}, []float64{20, 3, 5}}, + {"p1", map[string]float64{"l1_a": 20, "l2_a": 3, "l3_a": 5, "l1_w": -4600, "l2_w": 690, "l3_w": 0}, -3910, []float64{-20, 3, 5}}, + {"signed", map[string]float64{"l1_a": -20, "l2_a": -3, "l3_a": 5, "l1_w": -4600, "l2_w": -690, "l3_w": 1150}, -4140, []float64{-20, -3, 5}}, + {"no phase power", map[string]float64{"l1_a": -20, "l2_a": 3, "l3_a": 5}, -2760, []float64{20, 3, 5}}, + {"phase power against the total", map[string]float64{"l1_a": 40, "l2_a": 1, "l3_a": 1, "l1_w": -9200, "l2_w": 230, "l3_w": 230}, 9660, []float64{40, 1, 1}}, + {"near 0 W", map[string]float64{"l1_a": 0.2, "l2_a": 3, "l3_a": 5, "l1_w": -10, "l2_w": 690, "l3_w": 1150}, 1830, []float64{0.2, 3, 5}}, + {"reactive", map[string]float64{"l1_a": 10, "l2_a": 3, "l3_a": 5, "l1_w": -300, "l2_w": 690, "l3_w": 1150}, 1540, []float64{10, 3, 5}}, } { raw, _ := json.Marshal(tc.data) - amps, present, valid := phaseCurrentsA(&telemetry.DerReading{Data: raw}, state) + amps, present, valid := phaseCurrentsA(&telemetry.DerReading{RawW: tc.totalW, Data: raw}, state) if !present || !valid || len(amps) != 3 || amps[0] != tc.want[0] || amps[1] != tc.want[1] || amps[2] != tc.want[2] { t.Errorf("%s: %v (present %v, valid %v), want %v", tc.name, amps, present, valid, tc.want) } } } +// An exporting phase is never over the limit for an EV cut, hot or above +// the instant limit: less EV load would raise its export. An importing +// phase beside it still cuts. +func TestExportingPhaseIsNeverOverForACut(t *testing.T) { + h := fuseHeat{set: true} + h.sq = [3]float64{32 * 32, 26 * 26, 2 * 2} + if got := h.overA([]float64{-32, 2, 2}, 24, 30); got != 0 { + t.Fatalf("export of 32 A cuts %.1f A, want 0", got) + } + if got := h.overA([]float64{-32, 26, 2}, 24, 30); got != 2 { + t.Fatalf("L2 at 26 A beside export cuts %.1f A, want 2", got) + } +} + // An exporting phase counts at its reading for a rise and a start: new load // lowers its current, and its heat from an earlier import does not count. func TestExportingPhaseLevelIsItsReading(t *testing.T) { diff --git a/go/internal/control/testdata/sitesim_field_known.txt b/go/internal/control/testdata/sitesim_field_known.txt index 88187b10c..30d97684e 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -37,7 +37,7 @@ timing/25A/3p/3p/easee40s/car3p/L1/surplus idle readback timing/25A/auto/mixed/cloud60s/car3p/L1/surplus idle readback timing/25A/auto/mixed/easee120s/car3p/L1/surplus idle readback timing/25A/auto/mixed/easee40s/car3p/L1/surplus idle readback -timing/25A/empty/none/cloud60s/car3p/L1/surplus idle readback -timing/25A/empty/none/easee120s/car3p/L1/surplus idle readback -timing/25A/empty/none/easee40s/car3p/L1/surplus idle readback +timing/25A/empty/none/cloud60s/car3p/L1/surplus idle nostep +timing/25A/empty/none/easee120s/car3p/L1/surplus idle nostep +timing/25A/empty/none/easee40s/car3p/L1/surplus idle nostep timing/25A/empty/none/fast2s/car3p/L1/surplus idle nostep From 09059108042f10beab977aa6ab26e4c88f21dab0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 25 Oct 2026 15:19:49 +0100 Subject: [PATCH 3/3] fix(control): the sign guard takes the site total from its caller The check that phase powers add up to the total read meter.RawW in fuse_heat.go, a new direct read of cached telemetry. phaseCurrentsA now takes the total from its caller: the EV hard limit and its rooms pass the total they already read, the battery headroom clamp reads it once instead of four times, and SitePhases uses the health-gated OnlineMeterW, so a stale meter gives no phases. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/ev_battery_headroom.go | 18 +++++++++++------- go/internal/control/ev_fuse_budget.go | 8 ++++---- go/internal/control/fuse_heat.go | 17 +++++++++-------- go/internal/control/fuse_heat_test.go | 16 ++++++++++------ 4 files changed, 34 insertions(+), 25 deletions(-) diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 593476149..f39730c01 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -33,7 +33,11 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st } needW := math.Max(0, demandW-offeredW) meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - if meter == nil || !finiteFuseValue(meter.RawW) || !finiteFuseValue(fuseMaxW) { + if meter == nil { + return floorPositiveEVBatteryTargets(targets) + } + gridW := meter.RawW + if !finiteFuseValue(gridW) || !finiteFuseValue(fuseMaxW) { return floorPositiveEVBatteryTargets(targets) } latest := make(map[string]float64, len(targets)) @@ -51,13 +55,13 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st } } // Battery charge stays under the level the EV may rise to. - spare := state.importRiseCeilingW(fuseMaxW) - meter.RawW - needW + spare := state.importRiseCeilingW(fuseMaxW) - gridW - needW // During measured export overflow, the existing fuse pipeline may // require charge to reduce export; leave that direction to it. The // phases carry their direction (phaseCurrentsA): the worst phase is the // one importing most, and an exporting phase leaves the charge more room. - if meter.RawW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 { - if phaseSpare, known := evBatteryPhaseSpareW(meter, state, needW/(state.SiteFuseVoltage*phases)); known { + if gridW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 { + if phaseSpare, known := evBatteryPhaseSpareW(meter, state, gridW, needW/(state.SiteFuseVoltage*phases)); known { spare = math.Min(spare, phaseSpare) } } @@ -70,7 +74,7 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st offeredCharge += math.Max(0, targets[i].TargetW) finalCharge += math.Max(0, out[i].TargetW) } - exportFloor := math.Min(offeredCharge, math.Max(0, -meter.RawW-state.effectiveExportCeilingW(fuseMaxW))) + exportFloor := math.Min(offeredCharge, math.Max(0, -gridW-state.effectiveExportCeilingW(fuseMaxW))) if exportFloor > finalCharge && offeredCharge > finalCharge { scale := (exportFloor - finalCharge) / (offeredCharge - finalCharge) for i := range out { @@ -122,8 +126,8 @@ func capBatteryCharge(targets []DispatchTarget, latest map[string]float64, pendi return out } -func evBatteryPhaseSpareW(meter *telemetry.DerReading, state *State, evIncreaseA float64) (float64, bool) { - worst, present, valid := worstPhaseA(meter, state) +func evBatteryPhaseSpareW(meter *telemetry.DerReading, state *State, gridW, evIncreaseA float64) (float64, bool) { + worst, present, valid := worstPhaseA(meter, state, gridW) if !present { return 0, false } diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index a06c3d3cc..51e492c4f 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -360,7 +360,7 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State if !finiteFuseValue(gridW) { return 0, true } - state.stepFuseHeat(meter) + state.stepFuseHeat(meter, gridW) lim := &state.evLimit pool, plugged := evPoolFor(state, points, configs) if !plugged { @@ -586,7 +586,7 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW } r.hourly = s.importHourMeasured() } - amps, present, valid := phaseCurrentsA(meter, s) + amps, present, valid := phaseCurrentsA(meter, s, gridW) if present && !valid { r.phaseRoomKnown = true // No room on phases it cannot read. return r @@ -612,8 +612,8 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW // direction (phaseCurrentsA), and 0 when every phase exports. present is // false when the meter reports no phase currents; valid is false when they // cannot be used. -func worstPhaseA(meter *telemetry.DerReading, state *State) (worstA float64, present, valid bool) { - amps, present, valid := phaseCurrentsA(meter, state) +func worstPhaseA(meter *telemetry.DerReading, state *State, totalW float64) (worstA float64, present, valid bool) { + amps, present, valid := phaseCurrentsA(meter, state, totalW) for _, a := range amps { worstA = math.Max(worstA, a) } diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index e14220341..4beaf863e 100644 --- a/go/internal/control/fuse_heat.go +++ b/go/internal/control/fuse_heat.go @@ -113,11 +113,11 @@ func (h *fuseHeat) worstA() float64 { // stepFuseHeat folds each new site reading into the model, whether or not a // car is plugged in. -func (s *State) stepFuseHeat(meter *telemetry.DerReading) { +func (s *State) stepFuseHeat(meter *telemetry.DerReading, totalW float64) { if s.fuseHeat.set && !meter.UpdatedAt.After(s.fuseHeat.at) { return } - if amps, _, valid := phaseCurrentsA(meter, s); valid { + if amps, _, valid := phaseCurrentsA(meter, s, totalW); valid { s.fuseHeat.step(meter.UpdatedAt, amps) } } @@ -129,9 +129,9 @@ func (s *State) stepFuseHeat(meter *telemetry.DerReading) { // the phases' powers add up to the reading's total. Otherwise its current // counts as import, the safe side: a phase near 0 W, a reactive phase, a // meter without phase power, or a driver whose phase power has the wrong -// sign. present is false when the meter reports no phase currents; valid -// is false when they cannot be used. -func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, present, valid bool) { +// sign. totalW is the same reading's site total. present is false when the +// meter reports no phase currents; valid is false when they cannot be used. +func phaseCurrentsA(meter *telemetry.DerReading, state *State, totalW float64) (amps []float64, present, valid bool) { // DER data uses snake case; separate fields retain absent-vs-zero. var data struct { L1 *float64 `json:"l1_a"` @@ -148,7 +148,7 @@ func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, return nil, true, false } powers := []*float64{data.L1W, data.L2W, data.L3W}[:state.SiteFusePhases] - signs := phasePowersAddUp(powers, meter.RawW) + signs := phasePowersAddUp(powers, totalW) for i, a := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] { if a == nil || !finiteFuseValue(*a) { return nil, true, false @@ -187,10 +187,11 @@ func SitePhases(store *telemetry.Store, state *State) (loadpoint.SitePhases, boo return loadpoint.SitePhases{}, false } meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - if meter == nil { + totalW, _, online := store.OnlineMeterW(state.SiteMeterDriver) + if meter == nil || !online { return loadpoint.SitePhases{}, false } - amps, _, valid := phaseCurrentsA(meter, state) + amps, _, valid := phaseCurrentsA(meter, state, totalW) if !valid || len(amps) != 3 { return loadpoint.SitePhases{}, false } diff --git a/go/internal/control/fuse_heat_test.go b/go/internal/control/fuse_heat_test.go index b87f3639b..1264c2c17 100644 --- a/go/internal/control/fuse_heat_test.go +++ b/go/internal/control/fuse_heat_test.go @@ -85,17 +85,17 @@ func TestFuseHeatCountsGapAtLastCurrentSeen(t *testing.T) { } { store, state := fuseHeatState() t0 := time.Unix(1_800_000_000, 0) - state.stepFuseHeat(meterAt(store, t0.Add(-time.Hour), [3]float64{20, 20, 20})) - state.stepFuseHeat(meterAt(store, t0, [3]float64{tc.before, 20, 20})) + state.stepFuseHeat0(meterAt(store, t0.Add(-time.Hour), [3]float64{20, 20, 20})) + state.stepFuseHeat0(meterAt(store, t0, [3]float64{tc.before, 20, 20})) held := state.fuseHeat.sq silent := store.Get("meter", telemetry.DerMeter) for range 30 { - state.stepFuseHeat(silent) // ticks with no new reading + state.stepFuseHeat0(silent) // ticks with no new reading } if state.fuseHeat.sq != held { t.Fatalf("%s: heat moved to %v without a reading", tc.name, state.fuseHeat.sq) } - state.stepFuseHeat(meterAt(store, t0.Add(gap), [3]float64{tc.after, 20, 20})) + state.stepFuseHeat0(meterAt(store, t0.Add(gap), [3]float64{tc.after, 20, 20})) a := tc.before want := a*a - (a*a-held[0])*math.Exp(-gap.Seconds()/60) if got := state.fuseHeat.sq[0]; math.Abs(got-want) > 1e-9 { @@ -110,7 +110,7 @@ func TestFuseHeatCountsGapAtLastCurrentSeen(t *testing.T) { // A Core restart has no memory: the first reading sets the heat. func TestFuseHeatStartsAtFirstReadingAfterRestart(t *testing.T) { store, state := fuseHeatState() - state.stepFuseHeat(meterAt(store, time.Unix(1_800_000_000, 0), [3]float64{26, 3, 0})) + state.stepFuseHeat0(meterAt(store, time.Unix(1_800_000_000, 0), [3]float64{26, 3, 0})) if state.fuseHeat.sq != [3]float64{676, 9, 0} { t.Fatalf("first reading set %v", state.fuseHeat.sq) } @@ -521,7 +521,7 @@ func TestPhaseCurrentsTakeDirectionFromPhasePower(t *testing.T) { {"reactive", map[string]float64{"l1_a": 10, "l2_a": 3, "l3_a": 5, "l1_w": -300, "l2_w": 690, "l3_w": 1150}, 1540, []float64{10, 3, 5}}, } { raw, _ := json.Marshal(tc.data) - amps, present, valid := phaseCurrentsA(&telemetry.DerReading{RawW: tc.totalW, Data: raw}, state) + amps, present, valid := phaseCurrentsA(&telemetry.DerReading{Data: raw}, state, tc.totalW) if !present || !valid || len(amps) != 3 || amps[0] != tc.want[0] || amps[1] != tc.want[1] || amps[2] != tc.want[2] { t.Errorf("%s: %v (present %v, valid %v), want %v", tc.name, amps, present, valid, tc.want) } @@ -555,3 +555,7 @@ func TestExportingPhaseLevelIsItsReading(t *testing.T) { t.Fatalf("start level %.1f A, want L2's 2 A", got) } } + +// stepFuseHeat0 folds a reading with phase currents but no phase power, so +// its total does not matter. +func (s *State) stepFuseHeat0(meter *telemetry.DerReading) { s.stepFuseHeat(meter, 0) }