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/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/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go index 844701386..86c5cb597 100644 --- a/go/internal/control/ev_battery_headroom.go +++ b/go/internal/control/ev_battery_headroom.go @@ -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)) @@ -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) } } @@ -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 { @@ -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 } diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 41ee6891b..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 @@ -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) } diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index 55807f8ce..80db61c75 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 } @@ -108,24 +113,33 @@ 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 @@ -133,15 +147,38 @@ 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}[: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) } 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. @@ -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 } diff --git a/go/internal/control/fuse_heat_test.go b/go/internal/control/fuse_heat_test.go index 7d2494a81..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) } @@ -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) } 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 8cae8baeb..e4221bb2d 100644 --- a/go/internal/control/testdata/sitesim_field_known.txt +++ b/go/internal/control/testdata/sitesim_field_known.txt @@ -7,30 +7,28 @@ # 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). 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 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/fast2s/car3p/L1/surplus idle export +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 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 1d9d9a17d..60967ea80 100644 --- a/go/test/site/scenarios_test.go +++ b/go/test/site/scenarios_test.go @@ -39,8 +39,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,