diff --git a/.changeset/fuse-phase-direction.md b/.changeset/fuse-phase-direction.md new file mode 100644 index 00000000..a0bb7201 --- /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 12a867c6..59347614 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 2ffb42a5..f62fda44 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 } @@ -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 { diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index 305d9f5a..d982b6bf 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 df4af1ab..69b0bb1b 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 1f5bb003..023734dc 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 647db565..eb1e047c 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) }