From 5b587de8e696b4d4431f411cd521273a281262d8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 16:28:01 +0200 Subject: [PATCH 1/2] fix(control): phase current takes its direction from the phase's power MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fuse protection read every phase current as a magnitude. Under 20 A of solar export a phase counted as 20 A of import, so on a 25 A fuse a car never started, and on a 16 A fuse 8 A of export was enough to block it. phaseCurrentsA now signs each phase by the same phase's power in the reading (l1_w, l2_w, l3_w, which P1, Pixii, SolarEdge and SDM630 emit). Without that power the current counts as import, the safe side. Rises and starts read the level with direction: an exporting phase counts at its reading, so new load there has more room. An importing phase still counts at the higher of its reading and its heat. The heat filter keeps I², and only an importing phase can be over for the EV cut. The site simulation's meter now emits signed phase power, as P1 does, also when its currents lose their sign. Both export rows pass and lose their known line. Co-Authored-By: Claude Opus 5.5 --- .changeset/fuse-phase-direction.md | 5 +++ go/internal/control/ev_fuse_budget.go | 9 ++--- go/internal/control/fuse_heat.go | 50 ++++++++++++++++++--------- go/internal/control/fuse_heat_test.go | 22 ++++++++++++ go/internal/control/sitesim_test.go | 7 ++-- go/internal/sitesim/sitesim.go | 7 ++-- 6 files changed, 74 insertions(+), 26 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_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 2ffb42a5c..61ff25e71 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -185,8 +185,9 @@ func (s *State) offerPhaseA(w float64, phases int) float64 { } // EVFusePhaseRoomA is the current the chargers may still add on the -// busiest phase: below the fuse less its margin, the last reading's -// current or heat, and every charger's offers that reading may not show. +// busiest phase: below the fuse less its margin, the last reading's level +// (see fuseHeat.levelA), and every charger's offers that reading may not +// show. // false when the site meter reports no phase currents. func EVFusePhaseRoomA(state *State) (float64, bool) { if state == nil || !state.evLimit.set || !state.evLimit.phaseRoomKnown { @@ -379,7 +380,7 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first // evMeterRoom is the measured room in EV watts. The fuse's part holds on // every reading. The import ceiling's parts are +Inf while it is off. type evMeterRoom struct { - fuseW float64 // below the fuse less its margin and the worst phase, read or heat, and what we sent; rises and starts + fuseW float64 // below the fuse less its margin and the highest phase level, and what we sent; rises and starts fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat limitW float64 // below the hour's import limit; negative is an overage riseW float64 // below the level an increase may reach, less what we sent @@ -387,7 +388,7 @@ type evMeterRoom struct { hourly bool // the hour's import is measured, so a short overage may wait measured bool // false when the meter reports phase currents that cannot be read worstA float64 - phaseRoomA float64 // below the working limit on the busiest phase, read or heat + phaseRoomA float64 // below the working limit on the highest phase level phaseRoomKnown bool // the meter reports phase currents } diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index 305d9f5ab..d982b6bf8 100644 --- a/go/internal/control/fuse_heat.go +++ b/go/internal/control/fuse_heat.go @@ -57,9 +57,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 { @@ -70,14 +71,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. -// Rises and starts read it, so a rise never spends heat the last minute has -// already used, nor room an earlier rise will take. +// 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, so a rise never spends heat the last minute has +// already used. An exporting phase counts at its reading: new load lowers +// its current. Rises and starts read it, so a rise never takes room an +// earlier rise will take either. 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 } @@ -98,15 +104,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 @@ -114,11 +125,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 df4af1ab8..69b0bb1bc 100644 --- a/go/internal/control/fuse_heat_test.go +++ b/go/internal/control/fuse_heat_test.go @@ -68,6 +68,28 @@ func meterAt(store *telemetry.Store, at time.Time, phaseA [3]float64) *telemetry return r } +// 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) + } + } +} + // While the meter is silent the heat holds. A gap counts at the last // current seen: the reading after it neither fills the gap with its own // current nor erases an overload from before it. diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 1f5bb0039..023734dcf 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -190,7 +190,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) @@ -483,9 +484,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/sitesim/sitesim.go b/go/internal/sitesim/sitesim.go index 647db565b..eb1e047c1 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) } From 888a7d44798bcdec7ff420846242f4c5be207588 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 18:04:22 +0200 Subject: [PATCH 2/2] docs(control): the battery's phase room reads currents with direction Review of #1782: evBatteryPhaseSpareW reads worstPhaseA, which now takes each phase's direction, so an exporting phase counts as 0 and leaves the battery's charge more room. The comment said magnitudes could not label the worst phase; it now says what the code does. No code change. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/ev_battery_headroom.go | 5 +++-- go/internal/control/ev_fuse_budget.go | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) 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 61ff25e71..f62fda448 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -421,8 +421,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 {