Skip to content
Merged
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.
6 changes: 6 additions & 0 deletions docs/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 15 additions & 10 deletions go/internal/control/ev_battery_headroom.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,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))
Expand All @@ -61,16 +65,17 @@ func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, st
// discharge needs room past our own raises the meter may not show, the
// EV's included, not past what the car still wants.
riseW := math.Max(0, state.EVCommandedW-offeredW)
ceilingW := state.importRiseCeilingW(fuseMaxW) - meter.RawW
ceilingW := state.importRiseCeilingW(fuseMaxW) - gridW
spare, releaseSpare := ceilingW-needW, ceilingW-riseW
// 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.
if meter.RawW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 {
if phaseSpare, known := evBatteryPhaseSpareW(meter, state, needW/(state.SiteFuseVoltage*phases)); known {
// 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 gridW >= -state.effectiveExportCeilingW(fuseMaxW) && state.SiteFuseVoltage > 0 {
if phaseSpare, known := evBatteryPhaseSpareW(meter, state, gridW, needW/(state.SiteFuseVoltage*phases)); known {
spare = math.Min(spare, phaseSpare)
}
if phaseSpare, known := evBatteryPhaseSpareW(meter, state, riseW/(state.SiteFuseVoltage*phases)); known {
if phaseSpare, known := evBatteryPhaseSpareW(meter, state, gridW, riseW/(state.SiteFuseVoltage*phases)); known {
releaseSpare = math.Min(releaseSpare, phaseSpare)
}
}
Expand All @@ -83,7 +88,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 {
Expand Down Expand Up @@ -162,8 +167,8 @@ func keepEndingDischarge(out []DispatchTarget, latest, lowest map[string]float64
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
}
Expand Down
14 changes: 8 additions & 6 deletions go/internal/control/ev_fuse_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -608,10 +608,12 @@ 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.
func worstPhaseA(meter *telemetry.DerReading, state *State) (worstA float64, present, valid bool) {
amps, present, valid := phaseCurrentsA(meter, state)
// 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, totalW float64) (worstA float64, present, valid bool) {
amps, present, valid := phaseCurrentsA(meter, state, totalW)
for _, a := range amps {
worstA = math.Max(worstA, a)
}
Expand Down
96 changes: 67 additions & 29 deletions go/internal/control/fuse_heat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -108,40 +113,72 @@ 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)
}
}

// 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.
func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, present, valid bool) {
// 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. 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. 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"`
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}[:state.SiteFusePhases]
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
}
amps = append(amps, math.Abs(*a))
signed := math.Abs(*a)
if w := powers[i]; signs && *w < -math.Max(exportSignMinW, signed*state.SiteFuseVoltage/4) {
signed = -signed
}
amps = append(amps, signed)
Comment on lines +156 to +160

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 Account for reactive current before granting export headroom

When a phase carries reactive current, negating its entire measured current based on the sign of active power makes levelA and startLevelA overestimate how much EV load can safely cancel it. For example, a phase exporting 230 W at 230 V while carrying 15 A has only 1 A of active export; adding a unity-power-factor 16 A charging load produces approximately 21.2 A, not the 1 A predicted by -15 + 16. The new calculation permits that start on a 16 A fuse and only reacts after the overload is measured. Derive cancellation headroom from the active and reactive components, or use a conservative bound when those components are unavailable.

Useful? React with 👍 / 👎.

}
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.
Expand All @@ -150,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, at, online := store.OnlineMeterW(state.SiteMeterDriver)
if meter == nil || !online || !at.Equal(meter.UpdatedAt) { // One reading's phases and total.
return loadpoint.SitePhases{}, false
}
amps, _, valid := phaseCurrentsA(meter, state)
amps, _, valid := phaseCurrentsA(meter, state, totalW)
if !valid || len(amps) != 3 {
return loadpoint.SitePhases{}, false
}
Expand Down
69 changes: 64 additions & 5 deletions go/internal/control/fuse_heat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -500,3 +500,62 @@ 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. 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
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}, -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, 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)
}
}
}

// 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) {
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)
}
}

// 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) }
7 changes: 4 additions & 3 deletions go/internal/control/sitesim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading