Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuse-phase-direction.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions go/internal/control/ev_battery_headroom.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 9 additions & 6 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -379,15 +380,15 @@ 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
startLevelW float64 // import an idle start may reach: the rise level, at most the ceiling
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
}

Expand Down Expand Up @@ -420,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 {
Expand Down
50 changes: 33 additions & 17 deletions go/internal/control/fuse_heat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -98,27 +104,37 @@ 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
}
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
Comment on lines +133 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize phase-current signs at the driver boundary

For meters reporting unsigned phase currents, this introduces sign normalization inside Core control rather than at the driver boundary, contrary to the non-negotiable invariant in AGENTS.md. Consequently, the EV guard interprets a different current convention from other consumers of the same telemetry, such as phaseImbalanceA and perPhaseOverageW, which still use magnitudes. Normalize the phase-current direction in the upstream drivers and update the bundled snapshot, so Core consumers receive one consistent convention rather than independently adapting vendor readings.

AGENTS.md reference: AGENTS.md:L63-L64

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l1_w/l2_w/l3_w already carry the site sign: the drivers convert them at the boundary (P1 takes each phase's export from its import, Pixii and SolarEdge emit them signed). Core flips no vendor sign here. It combines two driver values, the current's size and the same phase power's direction, as the fuse rule of 21 October says; without phase power the current counts as import. The other Core readers (perPhaseOverageW, phaseImbalanceA) move to this one reader in #1789.

}
amps = append(amps, signed)
}
return amps, true, true
}
22 changes: 22 additions & 0 deletions go/internal/control/fuse_heat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions go/internal/control/sitesim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions go/internal/sitesim/sitesim.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
Loading