From 5b587de8e696b4d4431f411cd521273a281262d8 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 16:28:01 +0200 Subject: [PATCH 1/4] 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 cb55a26b9c025404c8738c2dcdb1935ae1a5b73a Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 16:38:59 +0200 Subject: [PATCH 2/4] fix(control): the hourly import ceiling reports import_limit, not fuse_limit Core folded the clock hour's import ceiling into the EV cap and the battery allocator under the fuse's name. A car held back by the hour's budget said fuse_limit, a boost it stopped said "Fuse protection", and the web page and apps blamed the main fuse while the phases were low. EVFuseBudget now records which limit left less room at each site reading, and EVLimitReason names it. The loadpoint reads it once per tick with the cap (SetFuseLimitReason) and labels a capped command import_limit or fuse_limit. The allocator marks FuseSaturatedByImport when the hour's limit is the ceiling it hit, and the boost stops with import_limit_block. fuse_limit now means the main fuse alone; the web page says so and gives the hour's limit its own words. The site simulation's new row is a night with a 6 kW hourly ceiling, a heavy first 50 minutes, a Core restart at 02:09 and phases at 1.5 A near the end of the hour. On master the idle car says fuse_limit; now it says import_limit and starts within 16 s of the new hour. Co-Authored-By: Claude Opus 5.5 --- .changeset/import-limit-reason.md | 5 ++ go/cmd/ftw/main.go | 10 +++- go/internal/api/api_control_feedback.go | 4 +- go/internal/control/control_test.go | 21 ++++++++- go/internal/control/dispatch.go | 14 +++++- go/internal/control/ev_fuse_budget.go | 20 +++++++- go/internal/control/ev_fuse_budget_test.go | 1 + go/internal/control/import_hour_test.go | 23 ++++++++++ go/internal/control/sitesim_test.go | 46 ++++++++++++++++--- go/internal/loadpoint/battery_boost.go | 1 + go/internal/loadpoint/controller.go | 32 +++++++++++-- .../controller_commanded_reason_test.go | 21 +++++++++ .../loadpoint/controller_fuse_budget.go | 3 +- go/internal/loadpoint/loadpoint.go | 8 ++-- go/internal/loadpoint/manual_status.go | 4 +- web/app.js | 10 +++- web/control-feedback.js | 6 ++- web/control-feedback.test.mjs | 8 +++- web/ev-commanded-reason.test.mjs | 9 ++-- web/loadpoint-battery-boost.test.mjs | 2 +- web/loadpoints.js | 1 + 21 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 .changeset/import-limit-reason.md diff --git a/.changeset/import-limit-reason.md b/.changeset/import-limit-reason.md new file mode 100644 index 000000000..bc23be79d --- /dev/null +++ b/.changeset/import-limit-reason.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +When the hourly import limit holds a car back, FTW now says so instead of blaming the main fuse. The charger's reason is `import_limit` ("Limited by the hourly import limit") whenever this hour's import budget leaves less room than the fuse, and a battery boost stopped by that budget says "Hourly import limit stopped boost". `fuse_limit` now means the main fuse alone, and the web page says "Limited by the main fuse". diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 2d1fa4047..18cc4c43d 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -2010,13 +2010,18 @@ func main() { defer ctrlMu.Unlock() return control.EVFusePhaseRoomA(ctrl) }) + lpController.SetFuseLimitReason(func() string { + ctrlMu.Lock() + defer ctrlMu.Unlock() + return control.EVLimitReason(ctrl) + }) // Every lease is preflighted and re-evaluated on each loadpoint tick. // This closure owns site/battery health because those concepts belong // to core, not to the protocol-neutral loadpoint package. lpController.SetBatteryBoostSafety(func(id string, lease loadpoint.BatteryBoostLease) loadpoint.BatteryBoostStopReason { ctrlMu.Lock() mode := ctrl.Mode - fuseBlocked := ctrl.FuseSaturated + fuseBlocked, importBlocked := ctrl.FuseSaturated, ctrl.FuseSaturatedByImport _, batteryHeld := ctrl.GetBatteryManualHold(time.Now()) siteMeter := ctrl.SiteMeterDriver ctrlMu.Unlock() @@ -2026,6 +2031,9 @@ func main() { if batteryHeld { return loadpoint.BatteryBoostStoppedBatteryHold } + if fuseBlocked && importBlocked { + return loadpoint.BatteryBoostStoppedImportLimit + } if fuseBlocked { return loadpoint.BatteryBoostStoppedFuseSafety } diff --git a/go/internal/api/api_control_feedback.go b/go/internal/api/api_control_feedback.go index f1547bd54..ca84538ed 100644 --- a/go/internal/api/api_control_feedback.go +++ b/go/internal/api/api_control_feedback.go @@ -339,6 +339,8 @@ func setControlStatus(f *ControlFeedback) { case "battery_full", "battery_nearly_full", "battery_nearly_empty", "core_limit", "fuse_limit", "fuse_cooldown", "charger_limit": status = "limited" + case "import_limit": + status = "limited" case "device_limit", "offered_current_lower": status, severity = "limited", "warning" case "setpoint_changed", "power_below_target", "power_above_target", "power_wrong_direction", @@ -430,7 +432,7 @@ func classifyControlFeedback(f *ControlFeedback, cmd telemetry.CommandEvidence, set("limited", "device_limit", "warning") return } - if coreReason == "site_meter_stale" || coreReason == "site_phase_currents_stale" || coreReason == "fuse_cooldown" || coreReason == "fuse_limit" || coreReason == "charger_limit" { + if coreReason == "site_meter_stale" || coreReason == "site_phase_currents_stale" || coreReason == "fuse_cooldown" || coreReason == "fuse_limit" || coreReason == "import_limit" || coreReason == "charger_limit" { set("limited", coreReason, "warning") return } diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index ab4260693..e3de918f2 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -1656,8 +1656,8 @@ func TestJointFuseAllocatorScalesBothBatteryAndEV(t *testing.T) { if got < 0 { t.Errorf("TargetW = %.0f W — battery shouldn't be discharging here", got) } - if !st.FuseSaturated { - t.Errorf("FuseSaturated = false; want true after joint scaling") + if !st.FuseSaturated || st.FuseSaturatedByImport { + t.Errorf("FuseSaturated = %v, by import %v; want the fuse after joint scaling", st.FuseSaturated, st.FuseSaturatedByImport) } // EV cap is the post-republish verdict: after applyFuseGuard + // forceFuseDischarge constrain battery further (and may swing it @@ -1676,6 +1676,23 @@ func TestJointFuseAllocatorScalesBothBatteryAndEV(t *testing.T) { } } +// The same site under a 9 kW hourly import ceiling: the hour's limit, not +// the fuse, sets the ceiling the allocator hits, and says so. +func TestJointFuseAllocatorNamesTheHoursImportLimit(t *testing.T) { + now := time.Now() + dir := SlotDirective{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), BatteryEnergyWh: 1250, Strategy: "arbitrage"} + store := seedStore(8200, []struct { + name string + currentW, soc float64 + }{{"ferroamp", 0, 0.5}}) + st := newStateWithEnergyDispatch(dir, "ferroamp") + st.EVChargingW, st.BatteryCoversEV, st.PeakImportCeilingW = 8000, true, 9000 + ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11000) + if !st.FuseSaturated || !st.FuseSaturatedByImport { + t.Fatalf("FuseSaturated = %v, by import %v; want the hour's import limit", st.FuseSaturated, st.FuseSaturatedByImport) + } +} + // Counter-test: when battery wants to discharge, no scaling — discharge // helps the fuse rather than competing with the EV. func TestJointFuseAllocatorIgnoresDischarge(t *testing.T) { diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 28b5ca5a8..eed8315e8 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -551,6 +551,9 @@ type State struct { // loadpoint controller's read of FuseEVMaxW and as the trigger for // an MPC reactive replan from main.go. FuseSaturated bool + // FuseSaturatedByImport says the clock hour's import limit, not the + // fuse, set the ceiling FuseSaturated hit. + FuseSaturatedByImport bool // DriverLimits maps driver name → per-battery charge/discharge cap. // Missing entries and unset fields fall through to the global @@ -2040,7 +2043,7 @@ func ComputeDispatch( // The ceiling gives up the worst phase's imbalance: a charger's own load // balancer cuts the car on that phase while the total still fits. state.FuseEVMaxW = 0 - state.FuseSaturated = false + state.FuseSaturated, state.FuseSaturatedByImport = false, false plannedEVW := scheduledEVPowerW(state) evInMeterW := state.evInMeterW() if fuseMaxW > 0 && math.Max(evInMeterW, plannedEVW) > 0 { @@ -2069,7 +2072,7 @@ func ComputeDispatch( totalCorrection = Bn + B*scale - currentTotal state.FuseEVMaxW = E * scale } - state.FuseSaturated = true + state.FuseSaturated, state.FuseSaturatedByImport = true, state.importLimitSets(ceilingW) } } @@ -3772,6 +3775,13 @@ func (s *State) fuseSafetyMarginW() float64 { return s.SiteFuseSafetyA * s.SiteFuseVoltage * float64(s.SiteFusePhases) } +// importLimitSets reports whether the clock hour's import limit is the +// ceiling ceilingW, rather than the fuse. +func (s *State) importLimitSets(ceilingW float64) bool { + limitW, _, ok := s.importHourLimitsW() + return ok && limitW <= ceilingW +} + // effectiveImportCeilingW returns the binding ceiling for grid import in // watts: the fuse limit minus its safety margin, further capped by the // clock hour's import limit when the operator has opted into a tariff diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 2ffb42a5c..7709e52bc 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -211,6 +211,7 @@ type evMeterLimit struct { overSince time.Time // first reading of a run over the hour's import limit overOffers map[string]float64 // each charger's offer when that run began idleWh float64 // import since roomSince, for the ceiling's start test + byImport bool // the hour's import limit, not the fuse, left less room at readingAt } type evPool struct { @@ -282,6 +283,8 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo lim.phaseRoomA = r.phaseRoomA } step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases + // The label follows whichever limit leaves less room. + lim.byImport = math.Min(r.limitW, r.riseW) < r.fuseW // The fuse cuts on every reading, by its heat budget where the meter // reports phase currents. The hour's budget absorbs a short overage of // less than a step: it cuts once the overage has lasted evSettle, so one @@ -298,6 +301,7 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo } } if cutW < 0 { + lim.byImport = cutW < r.fuseCutW // The meter may still show any offer of the last evSettle. Taking // the overage from the highest cuts each measured watt once. lim.capW, lim.roomSince = math.Min(prev, math.Max(0, peakW+cutW)), time.Time{} @@ -376,6 +380,16 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first lim.capW = math.Min(lim.capW, ceilingRoomW) } +// EVLimitReason names the limit behind the managed EVs' cap for the +// loadpoints' reason: "import_limit" when the clock hour's import ceiling +// left less room than the fuse at the last site reading, else "fuse_limit". +func EVLimitReason(state *State) string { + if state != nil && state.evLimit.set && state.evLimit.byImport { + return "import_limit" + } + return "fuse_limit" +} + // 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 { @@ -524,13 +538,15 @@ func qualifiedFuseObservation(r *telemetry.DerReading, now time.Time) (telemetry func publishBatterylessEVFuseBudget(store *telemetry.Store, state *State, fuseMaxW float64) { meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - state.FuseEVMaxW, state.FuseSaturated = 0, false + state.FuseEVMaxW, state.FuseSaturated, state.FuseSaturatedByImport = 0, false, false evInMeterW := state.evInMeterW() demand := math.Max(evInMeterW, scheduledEVPowerW(state)) if meter == nil || demand <= 0 { return } - cap := evHeadroomW(state.phaseImportCeilingW(fuseMaxW, meter), math.Max(meter.RawW, meter.SmoothedW), evInMeterW) + ceilingW := state.phaseImportCeilingW(fuseMaxW, meter) + cap := evHeadroomW(ceilingW, math.Max(meter.RawW, meter.SmoothedW), evInMeterW) state.FuseEVMaxW = math.Min(demand, cap) state.FuseSaturated = demand > cap + state.FuseSaturatedByImport = state.FuseSaturated && state.importLimitSets(ceilingW) } diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index 1360591d9..f073c3719 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -127,6 +127,7 @@ func (s *evMeterSite) boot() { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.mgr.Configs(), 17250) }) s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) }) + s.ctrl.SetFuseLimitReason(func() string { return EVLimitReason(s.state) }) s.ctrl.SetManualHold(cfg.ID, loadpoint.ManualHold{PowerW: 10350, PhaseMode: "3p", Persistent: true}) if s.onBoot != nil { s.onBoot() diff --git a/go/internal/control/import_hour_test.go b/go/internal/control/import_hour_test.go index b91ff11bf..0d793190c 100644 --- a/go/internal/control/import_hour_test.go +++ b/go/internal/control/import_hour_test.go @@ -152,6 +152,29 @@ func TestImportCeilingHouseStepLateInHourStepsCarDown(t *testing.T) { } } +// The car held below its want by the hour's import ceiling says +// import_limit, not fuse_limit: at 23:00 in #1674 the phases sit near 12 A +// on a 25 A fuse. Held by the fuse instead, it says fuse_limit. +func TestImportCeilingSaysImportLimitNotFuse(t *testing.T) { + for _, tc := range []struct { + name string + ceilingW, houseW float64 + want string + }{ + {"hour's ceiling", 7500, 900, "import_limit"}, + {"fuse", 0, 9000, "fuse_limit"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHourSite(t, tc.ceilingW, tc.houseW, clock(16, 22, 0)) + h.run(70 * time.Minute) + st, _ := h.mgr.State("garage") + if h.evW <= 0 || h.evW >= 10350 || st.CommandedReason != tc.want { + t.Fatalf("car draws %.0f W of 10,350 W, reason %q, want %q", h.evW, st.CommandedReason, tc.want) + } + }) + } +} + // A Core restart mid-hour keeps the hour's import from state.db. A sauna // spent most of the budget; without the record the box would count the // first half-hour at the ceiling and let the car overrun the hour. diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 1f5bb0039..5319360c7 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -46,6 +46,9 @@ type simSite struct { defaulted bool commands []simCommand + hourStart time.Time // the plant's clock hour + hourWh float64 // its import, as the plant measured it + heat [3]float64 // the phases' I², filtered as Core's heat model does heatSet bool overSince, needSince time.Time @@ -91,6 +94,7 @@ func (s *simSite) boot() { return EVFuseBudget(s.store, s.state, s.mgr.States(), s.ctrl.FuseConfigs(), s.fuseMaxW()) }) s.ctrl.SetFusePhaseRoomA(func() (float64, bool) { return EVFusePhaseRoomA(s.state) }) + s.ctrl.SetFuseLimitReason(func() string { return EVLimitReason(s.state) }) s.ctrl.SetSiteSurplusForEV(func() (float64, bool) { if !s.meterFresh() { return 0, false @@ -287,6 +291,10 @@ func (s *simSite) responseTime(slowest bool) time.Duration { // check holds the invariants for this second. func (s *simSite) check() { p, now := s.p, s.p.Now + if hour := ImportHourStart(now.Add(-sitesim.Step)); !hour.Equal(s.hourStart) { + s.hourStart, s.hourWh = hour, 0 + } + s.hourWh += math.Max(0, (p.A[0]+p.A[1]+p.A[2])*p.Voltage) * sitesim.Step.Hours() if p.Fuse.AnyBlown() { s.fail("the main fuse blew: damage %.2f", p.Fuse.Damage) } @@ -346,8 +354,9 @@ func (s *simSite) check() { // wantsCharge reports whether the car should charge now: it is plugged // and asks, Core and the meter are running, and the uncontrolled load -// leaves room for the car's minimum on its phases (and, for surplus -// charging, exports at least that much). +// leaves room for the car's minimum on its phases and under the hour's +// import ceiling (and, for surplus charging, exports at least that much). +// Under the ceiling Core starts nothing in an hour's last quarter. func (s *simSite) wantsCharge() bool { p, e := s.p, s.p.EVSE if p.Car == nil || !p.Car.Plugged || p.Offline("evse") || !s.downUntil.IsZero() || p.Now.Sub(s.meterAt) > max(p.Meter.Every, sitesim.Step)+sitesim.Step { @@ -362,7 +371,14 @@ func (s *simSite) wantsCharge() bool { return false } } - return !s.surplus || -baseW >= e.MinA*p.Voltage*float64(phases) + minW := e.MinA * p.Voltage * float64(phases) + if ceilingW := s.state.PeakImportCeilingW; ceilingW > 0 { + left := s.hourStart.Add(time.Hour).Sub(p.Now) + if left < importHourRiseMinLeft || baseW+minW > (ceilingW-s.hourWh)/left.Hours() { + return false + } + } + return !s.surplus || -baseW >= minW } func (s *simSite) fail(format string, args ...any) { @@ -398,7 +414,7 @@ type simScenario struct { type simExpect struct { at time.Duration - check func(*sitesim.Plant) error + check func(*simSite) error } var simStart = time.Date(2026, 10, 21, 12, 0, 0, 0, time.Local) @@ -510,7 +526,8 @@ func simScenarios() []simScenario { c.MaxChargeW, c.AllowedStepsW = 10350, c.AllowedStepsW[:len(c.AllowedStepsW)-1] }, setup: planBatteryCharge(9400), - expect: []simExpect{{9*time.Minute + 50*time.Second, func(p *sitesim.Plant) error { + expect: []simExpect{{9*time.Minute + 50*time.Second, func(s *simSite) error { + p := s.p offerW := p.EVSE.OfferA * p.Voltage * float64(p.EVSE.OfferPhases) if offerW != 10350 || math.Abs(p.EVSE.DrawW-4200) > 50 || math.Abs(p.Battery.W-9400) > 200 { return fmt.Errorf("not the #1761 state before the car takes its offer: offer %.0f W, car %.0f W, battery %.0f W", offerW, p.EVSE.DrawW, p.Battery.W) @@ -547,6 +564,23 @@ func simScenarios() []simScenario { p.HouseW = houseA(13.7, 12, 12) p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 10000, SoC: .07, Latency: 2 * time.Second} }}, + {name: "end of a heavy hour, low phases", + start: time.Date(2026, 10, 21, 2, 0, 0, 0, time.Local), run: 65 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW = houseA(10, 10, 10) + p.Events = []sitesim.Event{ + {At: 9 * time.Minute, Kind: sitesim.CoreRestart, For: 20 * time.Second}, + {At: 50 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(1.5, 1.5, 1.5) }}, + } + }, + setup: func(s *simSite) { s.state.PeakImportCeilingW = 6000 }, + expect: []simExpect{{57 * time.Minute, func(s *simSite) error { + st, _ := s.mgr.State(s.lp.ID) + if s.p.EVSE.DrawW > 0 || st.CommandedReason != "import_limit" { + return fmt.Errorf("near the end of the hour: car draws %.0f W, reason %q, want idle on import_limit", s.p.EVSE.DrawW, st.CommandedReason) + } + return nil + }}}}, {name: "a day", start: time.Date(2026, 10, 21, 0, 0, 0, 0, time.Local), run: 24 * time.Hour, plant: simDay}, } } @@ -566,7 +600,7 @@ func runSimScenario(sc simScenario, start time.Time) *simSite { s.boot() for _, e := range sc.expect { s.run(start.Add(e.at).Sub(p.Now)) - if err := e.check(p); err != nil { + if err := e.check(s); err != nil { s.fail("%v", err) } } diff --git a/go/internal/loadpoint/battery_boost.go b/go/internal/loadpoint/battery_boost.go index 266dc9b4e..40a2e2926 100644 --- a/go/internal/loadpoint/battery_boost.go +++ b/go/internal/loadpoint/battery_boost.go @@ -32,6 +32,7 @@ const ( BatteryBoostStoppedBatteryHold BatteryBoostStopReason = "battery_hold" BatteryBoostStoppedCoreMode BatteryBoostStopReason = "core_mode" BatteryBoostStoppedFuseSafety BatteryBoostStopReason = "fuse_safety_block" + BatteryBoostStoppedImportLimit BatteryBoostStopReason = "import_limit_block" BatteryBoostStoppedRestartInvalid BatteryBoostStopReason = "restart_lease_invalid" ) diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 7c9811fcf..2848e30b1 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -49,9 +49,12 @@ type Controller struct { // fusePhaseRoomA is the current chargers may still add on the busiest // phase. nil, or false, leaves the cap alone to limit them. fusePhaseRoomA func() (float64, bool) + // fuseLimitReason names the site limit behind the cap. + fuseLimitReason func() string // tickFuseCaps belongs to the serial tick, after one shared budget read. tickFuseCaps map[string]float64 + tickFuseReason string tickChoices map[string]tickChoice // serial tick tickDirectiveActive bool tickDirectiveOK bool @@ -527,6 +530,29 @@ func (c *Controller) SetFusePhaseRoomA(f func() (float64, bool)) { c.fusePhaseRoomA = f } +// SetFuseLimitReason wires control.EVLimitReason: "import_limit" when the +// clock hour's import ceiling holds the cap, else "fuse_limit". Read once +// per tick, after the cap. +func (c *Controller) SetFuseLimitReason(f func() string) { + if c == nil { + return + } + c.fuseLimitReason = f +} + +// capReason is the reason a command the cap lowered carries. +func (c *Controller) capReason() string { + if c.tickFuseReason != "" { + return c.tickFuseReason + } + if c.fuseLimitReason != nil { + if r := c.fuseLimitReason(); r != "" { + return r + } + } + return "fuse_limit" +} + // SetPerPhaseMeterAmps does nothing. Deprecated: control.EVFuseBudget reads // the phase currents; this stays until the last test caller is gone. func (c *Controller) SetPerPhaseMeterAmps(func() (l1, l2, l3 float64, ok bool)) {} @@ -618,12 +644,12 @@ func (c *Controller) applyFuseClamp(lpCfg Config, wantW float64, phaseMode strin if snapped > 0 && snapped >= lpCfg.MinChargeW { slog.Info("loadpoint fuse-clamp: ramped down", "lp", lpCfg.ID, "want_w", wantW, "fuse_cap_w", cap, "snapped_w", snapped) - return snapped, "fuse_limit" + return snapped, c.capReason() } slog.Info("loadpoint fuse-clamp: below minimum step", "lp", lpCfg.ID, "want_w", wantW, "fuse_cap_w", cap, "min_step_w", lpCfg.MinChargeW) - return 0, "fuse_limit" + return 0, c.capReason() } // SetNearTermPeakSurplusW wires the short-horizon "peak surplus over @@ -1207,7 +1233,7 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat if dispatchAllowed { c.tickDirective, c.tickDirectiveOK = c.currentDirective(now) c.tickDirectiveActive = true - defer func() { c.tickFuseCaps = nil; c.tickChoices = nil; c.tickDirectiveActive = false }() + defer func() { c.tickFuseCaps, c.tickFuseReason = nil, ""; c.tickChoices = nil; c.tickDirectiveActive = false }() } choices := make(map[string]tickChoice, len(observations)) for _, observed := range observations { diff --git a/go/internal/loadpoint/controller_commanded_reason_test.go b/go/internal/loadpoint/controller_commanded_reason_test.go index 57084ea28..035597cd2 100644 --- a/go/internal/loadpoint/controller_commanded_reason_test.go +++ b/go/internal/loadpoint/controller_commanded_reason_test.go @@ -101,6 +101,27 @@ func TestCommandedReasonFuseCapZero(t *testing.T) { } } +// A cap the hour's import ceiling sets carries its own name, never the +// fuse's, also when it overrides a hold. +func TestCommandedReasonImportLimit(t *testing.T) { + base := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) + for _, capW := range []float64{0, 6210} { + sender := &fakeSender{} + cfg := reasonLoadpoint() + samples := map[string]EVSample{cfg.DriverName: {Connected: true, PowerW: 0, RequestActive: true}} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 11040, PhaseMode: "3p", Persistent: true}) + c.SetFuseEVMax(func() (float64, bool) { return capW, true }) + c.SetFuseLimitReason(func() string { return "import_limit" }) + + c.Tick(context.Background(), base) + + if w, r := commandedReason(t, c, cfg.ID); w != capW || r != "import_limit" { + t.Errorf("cap %.0f W from the hour's ceiling: got (%.0f, %q), want (%.0f, import_limit)", capW, w, r, capW) + } + } +} + func TestCommandedReasonFuseLimitOverridesManualHold(t *testing.T) { base := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) sender := &fakeSender{} diff --git a/go/internal/loadpoint/controller_fuse_budget.go b/go/internal/loadpoint/controller_fuse_budget.go index b03444ab7..e81c3df03 100644 --- a/go/internal/loadpoint/controller_fuse_budget.go +++ b/go/internal/loadpoint/controller_fuse_budget.go @@ -19,7 +19,7 @@ type fuseRequest struct { // limit here: control.EVFuseBudget reads the site meter for the phases that // FuseConfigs names. func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample) { - c.tickFuseCaps = nil + c.tickFuseCaps, c.tickFuseReason = nil, "" if c.fuseEVMax == nil { return } @@ -27,6 +27,7 @@ func (c *Controller) freezeFuseBudget(now time.Time, samples map[string]EVSample if !ok || total < 0 { return } + c.tickFuseReason = c.capReason() if !finite(total) { total = 0 } diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 2e5b6b5f6..765197a6c 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -266,8 +266,8 @@ type State struct { // CommandedReason names the dispatch branch that decided CommandedW: // "plan", "no_plan_budget", "pv_surplus", "pv_surplus_pause", - // "fuse_limit", "site_meter_stale", "manual_hold", "wake_kick". Empty - // until the first dispatch tick. The UI renders the specific cause + // "fuse_limit", "import_limit", "site_meter_stale", "manual_hold", + // "wake_kick". Empty until the first dispatch tick. The UI renders the specific cause // instead of a generic "paused by the box". CommandedReason string `json:"commanded_reason,omitempty"` @@ -565,8 +565,8 @@ func (m *Manager) SetCommandedW(id string, w float64) { // dispatch branch chose that value — the fact operators were left to // reverse-engineer from amp readings (#1009). Reasons are short stable // tokens: "plan", "no_plan_budget", "pv_surplus", "pv_surplus_pause", -// "fuse_limit", "site_meter_stale", "manual_hold", "wake_kick". Empty keeps -// whatever was recorded before (used by the legacy SetCommandedW wrapper). +// "fuse_limit", "import_limit", "site_meter_stale", "manual_hold", +// "wake_kick". Empty keeps whatever was recorded before (used by the legacy SetCommandedW wrapper). // No-op for an unknown id. func (m *Manager) SetCommanded(id string, w float64, reason string) { m.setCommandedForManual(id, w, reason, time.Time{}) diff --git a/go/internal/loadpoint/manual_status.go b/go/internal/loadpoint/manual_status.go index 427160876..c008c5c8c 100644 --- a/go/internal/loadpoint/manual_status.go +++ b/go/internal/loadpoint/manual_status.go @@ -40,7 +40,7 @@ type ManualStatus struct { // current, in its words (reason_no_current_label). Empty when it has none. ChargerReason string `json:"charger_reason,omitempty"` // LimitReason names the clamp behind ManualLimited: "fuse_limit", - // "fuse_cooldown" or "site_meter_stale". + // "import_limit", "fuse_cooldown" or "site_meter_stale". LimitReason string `json:"limit_reason,omitempty"` } @@ -108,7 +108,7 @@ const ( // before the hold was installed and says nothing about it. func holdClampReason(reason string) bool { switch reason { - case "fuse_limit", "fuse_cooldown", "site_meter_stale", "charger_limit": + case "fuse_limit", "import_limit", "fuse_cooldown", "site_meter_stale", "charger_limit": return true } return false diff --git a/web/app.js b/web/app.js index 29621381d..9a795e1ee 100644 --- a/web/app.js +++ b/web/app.js @@ -2968,7 +2968,9 @@ ? "Charging on plan until " + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned : "Charging."; if (lp.commanded_reason === "fuse_limit") { - text += " Rate is limited by site power limits right now."; + text += " Rate is limited by the main fuse right now."; + } else if (lp.commanded_reason === "import_limit") { + text += " Rate is limited by the hourly import limit right now."; } } else if (lp.goal_complete === true) { text = "The car confirmed this charging goal is complete."; @@ -2993,7 +2995,10 @@ // The specific pauses beat the generic branches — this is the // line that stops an operator debugging cable and charger while // the box is protecting the main fuse (#1009). - text = "Paused by site power limits — the house is using the headroom; charging resumes on its own." + kwPlanned; + text = "Paused by the main fuse — the house is using the headroom; charging resumes on its own." + kwPlanned; + tone = "var(--text)"; + } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "import_limit") { + text = "Paused by the hourly import limit — this hour’s import budget is used; charging resumes on its own." + kwPlanned; tone = "var(--text)"; } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "site_meter_stale") { text = "Paused for safety: site-meter data is stale — charging resumes when telemetry recovers."; @@ -3338,6 +3343,7 @@ battery_hold: "a battery hold took priority", core_mode: "the site mode does not allow it", fuse_safety_block: "fuse protection stopped it", + import_limit_block: "the hourly import limit stopped it", restart_lease_invalid: "the saved lease was no longer safe after a restart", }; diff --git a/web/control-feedback.js b/web/control-feedback.js index 18a184e63..69c97b1ea 100644 --- a/web/control-feedback.js +++ b/web/control-feedback.js @@ -63,7 +63,8 @@ const REASON_TITLES = { no_plan_budget: 'Waiting for the plan', pv_surplus_pause: 'Waiting for spare solar', wake_kick: 'Starting the charger', vehicle_complete: 'Car is full', vehicle_limit_completion: 'Car reached its limit', vehicle_not_requesting: 'Car not taking power', battery_full: 'Battery full', battery_nearly_full: 'Battery nearly full', battery_nearly_empty: 'Battery nearly empty', - core_limit: 'Limited by FTW', fuse_limit: 'Limited by site power limits', fuse_cooldown: 'Waiting after a site power limit', + core_limit: 'Limited by FTW', fuse_limit: 'Limited by the main fuse', fuse_cooldown: 'Waiting after a site power limit', + import_limit: 'Limited by the hourly import limit', charger_limit: 'Limited by FTW’s charger setting', device_limit: 'Charger limit', offered_current_lower: 'Charger offers less current', device_fault: 'Device fault', telemetry_stale: 'No fresh readings', readings_lost: 'Lost control', command_failed: 'Commands failing', command_unconfirmed: 'Command not confirmed', default_failed: 'Cannot hand back control', @@ -92,7 +93,8 @@ function sentence(row) { case 'battery_nearly_full': return `Taking ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery charges slower when it is nearly full.`; case 'battery_nearly_empty': return `Giving ${controlPower(actual)} of ${controlPower(row.sent_w)} at ${percent(row.battery_soc)}. A battery limits discharge when it is nearly empty.`; case 'core_limit': return `FTW’s safety limits set the target to ${target(row)}.`; - case 'fuse_limit': return `FTW lowered the rate to ${target(row)} to stay within the site’s fuse and import limits.`; + case 'fuse_limit': return `FTW lowered the rate to ${target(row)} to protect the main fuse.`; + case 'import_limit': return `FTW lowered the rate to ${target(row)} to keep this hour’s grid import within its limit.`; case 'fuse_cooldown': return 'FTW waits a moment after a site power limit before it raises the rate.'; case 'charger_limit': return 'The charger limit in FTW’s settings caps the rate.'; case 'device_limit': return `The charger’s own limit is ${amps(row.device_limit_a)}, below the ${amps(row.requested_a)} FTW asked for.`; diff --git a/web/control-feedback.test.mjs b/web/control-feedback.test.mjs index bdef3a5ea..b504dd07c 100644 --- a/web/control-feedback.test.mjs +++ b/web/control-feedback.test.mjs @@ -147,6 +147,10 @@ test('limited positive EV offer does not explain prolonged zero delivery', () => assert.equal(missing.title,'Not following'); assert.match(missing.text,/6.9 kW charge.*no power/); const capped=controlStatus(row({kind:'ev',status:'limited',reason:'fuse_limit',sent_w:6900,actual_w:6800})); - assert.equal(capped.title,'Limited by site power limits'); - assert.match(capped.text,/fuse and import limits/); + assert.equal(capped.title,'Limited by the main fuse'); + assert.match(capped.text,/protect the main fuse/); + const hour=controlStatus(row({kind:'ev',status:'limited',reason:'import_limit',sent_w:4140,actual_w:4100})); + assert.equal(hour.title,'Limited by the hourly import limit'); + assert.match(hour.text,/this hour’s grid import/); + assert.doesNotMatch(hour.text,/fuse/); }); diff --git a/web/ev-commanded-reason.test.mjs b/web/ev-commanded-reason.test.mjs index 745ec3bc5..dd2a094e0 100644 --- a/web/ev-commanded-reason.test.mjs +++ b/web/ev-commanded-reason.test.mjs @@ -9,7 +9,9 @@ const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); test('every pause reason has its own sentence', () => { assert.match(source, /commanded_reason === "fuse_cooldown" \|\| lp\.commanded_reason === "fuse_limit"/); - assert.match(source, /Paused by site power limits/); + assert.match(source, /Paused by the main fuse/); + assert.match(source, /commanded_reason === "import_limit"/); + assert.match(source, /Paused by the hourly import limit/); assert.match(source, /commanded_reason === "site_meter_stale"/); assert.match(source, /Paused for safety: site-meter data is stale/); assert.match(source, /commanded_reason === "pv_surplus_pause"/); @@ -19,6 +21,7 @@ test('every pause reason has its own sentence', () => { assert.match(source, /commanded_reason === "no_plan_budget"/); assert.match(source, /The ready time has passed, so FTW is not charging/); assert.match(source, /Battery level is assumed, not read from the car/); - // A fuse-limited ongoing charge says so too. - assert.match(source, /Rate is limited by site power limits right now/); + // A limited ongoing charge names its limit too. + assert.match(source, /Rate is limited by the main fuse right now/); + assert.match(source, /Rate is limited by the hourly import limit right now/); }); diff --git a/web/loadpoint-battery-boost.test.mjs b/web/loadpoint-battery-boost.test.mjs index 67d4e3b36..822b29d5a 100644 --- a/web/loadpoint-battery-boost.test.mjs +++ b/web/loadpoint-battery-boost.test.mjs @@ -36,7 +36,7 @@ test('every controller stop reason has words', () => { 'cancelled', 'expired', 'vehicle_unplugged', 'ev_target_reached', 'departure_reached', 'surplus_only', 'site_safety_block', 'loadpoint_driver_unavailable', 'battery_unavailable', 'battery_reserve_reached', 'battery_hold', 'core_mode', - 'fuse_safety_block', 'restart_lease_invalid', + 'fuse_safety_block', 'import_limit_block', 'restart_lease_invalid', ]) { assert.match(app, new RegExp(reason + ': "')); } diff --git a/web/loadpoints.js b/web/loadpoints.js index dcd40c84a..031159e0d 100644 --- a/web/loadpoints.js +++ b/web/loadpoints.js @@ -109,6 +109,7 @@ battery_hold: 'Battery hold took priority', core_mode: 'Core mode does not allow boost', fuse_safety_block: 'Fuse protection stopped boost', + import_limit_block: 'Hourly import limit stopped boost', restart_lease_invalid: 'Saved lease was no longer safe', }; From 1a79330a252abbe996c0b61523a2d138b1871bf1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 17:47:13 +0200 Subject: [PATCH 3/4] fix(control): an idle car held by the hour's start test says import_limit Review of #1783 (Codex): after a quiet part of the hour, the hour's limits can sit above what the fuse allows, so the label compared them and said fuse_limit. An idle car is then kept off by the ceiling's own start test, which caps the start level at the ceiling itself. Under a measured hour the idle start now names the limit by that test: the start level against this reading, and import_limit when the dwell's mean import fails it. TestImportCeilingStartTestSaysImportLimit: 40 quiet minutes under a 7.5 kW ceiling, then an 8 kW house and a car plugged in. It said fuse_limit; it says import_limit. Co-Authored-By: Claude Opus 5.5 --- go/internal/control/ev_fuse_budget.go | 6 +++++- go/internal/control/import_hour_test.go | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 7709e52bc..093e62aeb 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -346,6 +346,10 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first perReadingW := r.fuseW if !r.hourly { perReadingW = math.Min(perReadingW, r.riseW) + } else { + // The start test below holds an idle car to the ceiling itself, + // whatever slack the hour has: name it by this reading. + lim.byImport = r.startLevelW-gridW < r.fuseW } room := 0.0 if fresh { @@ -374,7 +378,7 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first } ceilingRoomW := r.startLevelW - lim.idleWh/dwell.Hours() - pending if ceilingRoomW < pool.minW { - lim.capW, lim.roomSince, lim.idleWh = 0, at, 0 // Judge the next dwell. + lim.capW, lim.roomSince, lim.idleWh, lim.byImport = 0, at, 0, true // Judge the next dwell. return } lim.capW = math.Min(lim.capW, ceilingRoomW) diff --git a/go/internal/control/import_hour_test.go b/go/internal/control/import_hour_test.go index 0d793190c..627f5933c 100644 --- a/go/internal/control/import_hour_test.go +++ b/go/internal/control/import_hour_test.go @@ -428,6 +428,24 @@ func TestImportCeilingNoStartOnSlackAlone(t *testing.T) { } } +// A quiet first 40 minutes leave the hour so much slack that its limits sit +// above what the 25 A fuse allows. An 8 kW house still keeps an idle car from +// starting, by the ceiling's own start test, and the car says import_limit. +func TestImportCeilingStartTestSaysImportLimit(t *testing.T) { + h := newHourSite(t, 7500, 0, clock(16, 21, 0)) + h.unplugged = true + h.run(40 * time.Minute) + h.setHouse(8000) + h.plugIn() + h.run(5 * time.Minute) + if h.offer() != 0 { + t.Fatalf("the car started at %.0f W beside 8 kW under a 7.5 kW ceiling", h.offer()) + } + if st, _ := h.mgr.State("garage"); st.CommandedReason != "import_limit" { + t.Fatalf("idle under the ceiling says %q, want import_limit", st.CommandedReason) + } +} + // A reading after a clock jump of years counts only the hour it falls in. func TestImportCeilingClockJumpCountsOnlyItsHour(t *testing.T) { var now time.Time From c60da23df78a0355f496fb3e3691898e19c7d7cc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 21 Oct 2026 17:57:57 +0200 Subject: [PATCH 4/4] fix(control): fuse protection as one function Fredrik's rule of 21 October 2026: fuse protection counts the site meter's current per phase, with direction, and our own raises that cannot show yet. Nothing else. Core read the fuse in nine places with five margins and two phase-current sources. fusePhases (fuse_phase.go) is now the one reading for every charger and battery decision: the meter's signed current per phase (its total shared evenly when it reports none), one margin (safety_margin_a, at least 1 A), room for a rise (limit less level and our pending raises) and an overage for a cut (once a phase's heat budget is spent, or above 1.2 x In), import and export apart. - EVFuseBudget reads it for rises, starts and cuts; the meter total no longer stands beside the phases. - fitBatteryRisesToFuse replaces clampEVBatteryHeadroom: a battery's rise fits the phases' room after what the cars still need, EV first, with or without a car; a discharge that would end keeps what the room needs, down to the battery's floor; the export side the same with the signs turned. It sets FuseSaturated. - forceFuseDischarge relieves the import overage net of the EV cuts we sent during the run over the limit, at most evSettle. The hour's limit nets the same cuts. Deleted: applyFuseGuard's per-phase part, latch and half-margin buffer (what is left is applySiteLimits: the hour's limit and max_export_w); the aggregate fuse checks beside the phases; perPhaseOverageW and perPhaseReliefW; clampEVBatteryHeadroom and its own phase reader; the joint allocator, phaseImportCeilingW and the #1536 imbalance proxy; republishFuseEVCapAfterFuseDischarge and the dead FuseEVMaxW; pendingEVCutW; the meter-cadence gap rule (only a stale reading starts afresh); and cmd/ftw's second phase-current source, the meter_l*_a metrics. Also: fuse relief stops at the battery's soc_min, else the planner's (PowerLimits.SoCMin); an idle car starts when its minimum fits; a second car plugging in no longer stops the first; a timed-out command counts as sent. Site simulation: Pixii total/phase disagreement, both sides relieving one overage and the 16 A idle start pass; the #1761 replay is unchanged. Golden corpus re-recorded: 107 of 551 records moved, none ends with a phase further over its limit. Co-Authored-By: Claude Opus 5.5 --- .changeset/fuse-one-function.md | 5 + go/cmd/ftw/control_state.go | 7 +- go/cmd/ftw/main.go | 28 +- go/cmd/ftw/modbus_owner_test.go | 2 +- go/cmd/ftw/site_dispatch_safety.go | 69 +- go/cmd/ftw/site_meter_stale_test.go | 27 +- go/internal/api/api.go | 6 +- go/internal/config/config.go | 2 +- go/internal/control/battery_boost_test.go | 1 + go/internal/control/battery_full_test.go | 1 + ...room_test.go => battery_fuse_rise_test.go} | 25 +- go/internal/control/control_test.go | 207 +-- go/internal/control/dispatch.go | 551 ++------ go/internal/control/ev_battery_headroom.go | 144 --- .../control/ev_battery_sequence_test.go | 95 +- go/internal/control/ev_fuse_budget.go | 254 ++-- go/internal/control/ev_fuse_budget_test.go | 28 +- go/internal/control/ev_plan_priority_test.go | 31 +- go/internal/control/export_ceiling_test.go | 12 +- .../control/forecast_scenarios_test.go | 3 +- go/internal/control/fuse_heat.go | 69 +- go/internal/control/fuse_heat_test.go | 26 +- go/internal/control/fuse_phase.go | 426 ++++++- go/internal/control/fuse_phase_test.go | 212 ++-- go/internal/control/fuse_saver_test.go | 438 +++---- go/internal/control/golden_dump_test.go | 43 +- go/internal/control/golden_test.go | 34 +- go/internal/control/idle_hold_test.go | 5 +- go/internal/control/peak_limit_test.go | 2 +- go/internal/control/selftune_dispatch_test.go | 4 +- go/internal/control/sitesim_test.go | 41 +- go/internal/control/stale_load_soc_test.go | 1 + .../golden/early_exit_protections.json | 211 ++-- .../testdata/golden/incident_scenarios.json | 227 ++-- .../testdata/golden/seeded_ev_boost.json | 470 +++---- .../control/testdata/golden/seeded_fuse.json | 1111 +++++++---------- .../testdata/golden/seeded_planner.json | 945 +++++--------- .../testdata/golden/seeded_reactive.json | 594 +++------ .../testdata/golden/seeded_siblings.json | 474 +++---- .../control/testdata/golden/slew_limiter.json | 1099 ++++++---------- .../testdata/golden/targeted_invariants.json | 184 +-- go/internal/loadpoint/controller.go | 4 +- .../controller_commanded_reason_test.go | 22 + go/internal/sitesim/sitesim.go | 4 +- 44 files changed, 3065 insertions(+), 5079 deletions(-) create mode 100644 .changeset/fuse-one-function.md rename go/internal/control/{ev_battery_headroom_test.go => battery_fuse_rise_test.go} (84%) delete mode 100644 go/internal/control/ev_battery_headroom.go diff --git a/.changeset/fuse-one-function.md b/.changeset/fuse-one-function.md new file mode 100644 index 000000000..7c015300c --- /dev/null +++ b/.changeset/fuse-one-function.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Fuse protection now reads the main fuse the same way for the car and the home battery: the site meter's current on each phase, with its direction, under one margin (your safety margin, at least 1 A). A battery's charge or a car may rise only into the room the phases show. When a phase goes over, the car is cut and the battery gives back only what the car's cut does not cover, so the two never answer the same overage twice. The meter's total no longer counts against the fuse when the meter reports phase currents, which a Pixii total that disagrees with its phases used to trip. A car now starts when its minimum fits, a second car plugging in no longer stops the first, and fuse relief no longer discharges a battery below its soc_min (or the planner's, 10 % by default). diff --git a/go/cmd/ftw/control_state.go b/go/cmd/ftw/control_state.go index f97581f66..ec87976b7 100644 --- a/go/cmd/ftw/control_state.go +++ b/go/cmd/ftw/control_state.go @@ -24,10 +24,9 @@ func newControlStateFromConfig(cfg *config.Config) *control.State { ctrl.InverterGroups = inverterGroupsFrom(cfg.Drivers) ctrl.SupportsPVCurtail = supportsPVCurtailFrom(cfg.Drivers) ctrl.SolarFeedDrivers = solarFeedDriversFrom(cfg.Drivers) - ctrl.DriverLimits = driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries) - // Per-phase fuse params for the per-phase clamp inside applyFuseGuard - // + forceFuseDischarge. Reads l1_a/l2_a/l3_a from the meter driver - // when SiteFuseAmps > 0; otherwise the per-phase clamp is disabled. + ctrl.DriverLimits = driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries, cfg.Planner) + // The fuse that control.fusePhases reads with the site meter's + // l1_a/l2_a/l3_a for every charger and battery decision. ctrl.SiteFuseAmps = cfg.Fuse.MaxAmps ctrl.SiteFuseVoltage = cfg.Fuse.Voltage ctrl.SiteFusePhases = cfg.Fuse.Phases diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 18cc4c43d..eb18815df 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1150,7 +1150,7 @@ func main() { ctrl.InverterGroups = inverterGroupsFrom(newCfg.Drivers) ctrl.SupportsPVCurtail = supportsPVCurtailFrom(newCfg.Drivers) ctrl.SolarFeedDrivers = solarFeedDriversFrom(newCfg.Drivers) - ctrl.DriverLimits = driverLimitsFrom(newCfg.ModbusControlDrivers(), newCfg.Batteries) + ctrl.DriverLimits = driverLimitsFrom(newCfg.ModbusControlDrivers(), newCfg.Batteries, newCfg.Planner) // Fuse params + safety margin: previously startup-only. // Hot-reload them so operators can tune the per-phase margin // from the UI without restarting (e.g. raising it after the @@ -1650,7 +1650,7 @@ func main() { } // Plumb the site fuse so the DP joint-plans battery + EV under // the fuse from the start (instead of producing plans that - // dispatch later has to scale via the joint allocator). + // dispatch later has to scale to the phases' room). mpcSvc.FuseMaxW = cfg.Fuse.MaxPowerW() mpcSvc.PhaseImbalanceW = func() float64 { cfgMu.RLock() @@ -2824,8 +2824,8 @@ func main() { ticker := time.NewTicker(controlInterval) defer ticker.Stop() var saveCount uint64 - // Track FuseSaturated edge so we replan once when the joint allocator - // kicks in — the plan was made without knowledge of the live overage, + // Track FuseSaturated edge so we replan once when dispatch holds the + // battery or EV back — the plan was made without knowledge of the live overage, // and a replan with current EV/PV/load state usually finds a feasible // schedule that doesn't fight the fuse. var prevFuseSaturated bool @@ -3236,8 +3236,8 @@ func main() { // "EV dispatch first" block above. // ---- Trigger MPC replan on fuse-saturation rising edge ---- - // The joint allocator (control.dispatch) just throttled - // battery and EV to fit under the fuse. The plan was built + // Dispatch just held battery charge or EV need back to fit + // the site's room (control.fitBatteryRisesToFuse). The plan was built // without seeing this overage, so let MPC redraw with current // state — usually it finds a slot allocation that doesn't // require both battery charge and full-bore EV simultaneously. @@ -3615,12 +3615,22 @@ func driverCapacitiesFrom(drvList []config.Driver, loadpoints []config.Loadpoint return out } -// driverLimitsFrom gives dispatch the same per-driver limits as the planner. -func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Battery) map[string]control.PowerLimits { +// driverLimitsFrom gives dispatch the same per-driver limits as the planner, +// with each battery's floor: its soc_min, else the planner's (default 10 %). +func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Battery, planner *config.Planner) map[string]control.PowerLimits { + floor := 0.10 + if planner != nil && planner.SoCMin > 0 { + floor = planner.SoCMin + } out := map[string]control.PowerLimits{} for _, d := range drivers { if !d.ObserveOnly { - out[d.Name] = batteryPowerLimits(d, batteries[d.Name]) + limits := batteryPowerLimits(d, batteries[d.Name]) + limits.SoCMin = floor + if b := batteries[d.Name]; b.SoCMin != nil { + limits.SoCMin = *b.SoCMin + } + out[d.Name] = limits } } return out diff --git a/go/cmd/ftw/modbus_owner_test.go b/go/cmd/ftw/modbus_owner_test.go index 1675e7934..28343c2e1 100644 --- a/go/cmd/ftw/modbus_owner_test.go +++ b/go/cmd/ftw/modbus_owner_test.go @@ -19,7 +19,7 @@ func TestExternalModbusOwnerKeepsTelemetryButLeavesPlannerAndDispatch(t *testing if got := driverCapacitiesFrom(cfg.Drivers, nil, evCatalog(), false); got["sungrow"] != 10000 { t.Fatalf("telemetry capacity lost: %v", got) } - if len(driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries)) != 0 { + if len(driverLimitsFrom(cfg.ModbusControlDrivers(), cfg.Batteries, cfg.Planner)) != 0 { t.Fatal("external battery remains in dispatch limits") } if !config.ObserveOnlyDriverSet(cfg)["sungrow"] { diff --git a/go/cmd/ftw/site_dispatch_safety.go b/go/cmd/ftw/site_dispatch_safety.go index b65583d31..156dd8676 100644 --- a/go/cmd/ftw/site_dispatch_safety.go +++ b/go/cmd/ftw/site_dispatch_safety.go @@ -25,9 +25,9 @@ type siteDispatchDecision struct { func (d siteDispatchDecision) Allowed() bool { return d.Reason == siteDispatchReady } // evaluateSiteDispatchFreshnessAt rejects physical dispatch when the site -// meter itself is stale. Per-phase currents are optional until the configured -// meter has emitted at least one of them; once available, every configured -// phase becomes a required fuse-safety input and must remain fresh. +// meter itself is stale. Its phase currents are optional; once a reading +// reports any, every configured phase is a required fuse-safety input. They +// come with the reading fuse protection reads, so they are as fresh as it. func evaluateSiteDispatchFreshnessAt( tel *telemetry.Store, siteMeter string, @@ -46,8 +46,10 @@ func evaluateSiteDispatchFreshnessAt( if fuseAmps <= 0 { return siteDispatchDecision{} } - _, available, fresh := sitePhaseCurrentsAt(tel, siteMeter, phaseCount, timeout, now) - if available && !fresh { + if phaseCount <= 0 { + phaseCount = 3 + } + if _, present, valid := control.SitePhaseCurrents(meter, phaseCount); present && !valid { return siteDispatchDecision{Reason: siteDispatchPhaseCurrentsStale} } return siteDispatchDecision{} @@ -78,63 +80,6 @@ func siteDispatchNow( return evaluateSiteDispatchFreshnessAt(tel, siteMeter, fuseAmps, phases, timeout, now) } -// sitePhaseCurrentsAt returns the configured phases in L1/L2/L3 order. -// available is false when this meter has never exposed phase-current -// telemetry, preserving the documented optional-per-phase behavior. If any -// phase has appeared, fresh is true only when all configured phases exist and -// are within the same watchdog window as the aggregate meter. -func sitePhaseCurrentsAt( - tel *telemetry.Store, - siteMeter string, - phaseCount int, - timeout time.Duration, - now time.Time, -) (amps [3]float64, available, fresh bool) { - if tel == nil || siteMeter == "" { - return amps, false, false - } - return phaseCurrentsFromSnapshots( - tel.LatestMetricsByDriver(siteMeter), phaseCount, timeout, now, - ) -} - -func phaseCurrentsFromSnapshots( - metrics []telemetry.MetricSnapshot, - phaseCount int, - timeout time.Duration, - now time.Time, -) (amps [3]float64, available, fresh bool) { - if phaseCount <= 0 { - phaseCount = 3 - } - if phaseCount > len(amps) { - phaseCount = len(amps) - } - - byName := make(map[string]telemetry.MetricSnapshot, len(metrics)) - for _, metric := range metrics { - byName[metric.Name] = metric - } - phaseMetricNames := [...]string{"meter_l1_a", "meter_l2_a", "meter_l3_a"} - fresh = true - for i := 0; i < phaseCount; i++ { - metric, ok := byName[phaseMetricNames[i]] - if !ok { - fresh = false - continue - } - available = true - amps[i] = metric.Value - if now.Sub(metric.UpdatedAt) > timeout { - fresh = false - } - } - if !available { - return amps, false, false - } - return amps, true, fresh -} - // staleSiteDefaultTracker emits one DefaultMode request per registered driver // on entry to a stale state. Drivers added while the gate remains closed are // defaulted once when first observed. Recovery clears the latch so a future diff --git a/go/cmd/ftw/site_meter_stale_test.go b/go/cmd/ftw/site_meter_stale_test.go index 74e8f42a4..91159b503 100644 --- a/go/cmd/ftw/site_meter_stale_test.go +++ b/go/cmd/ftw/site_meter_stale_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "sync" "testing" "time" @@ -26,26 +27,12 @@ func TestEvaluateSiteDispatchFreshnessRejectsStaleMeter(t *testing.T) { } } -func TestEvaluateSiteDispatchFreshnessRejectsStalePhaseCurrents(t *testing.T) { - now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) - metrics := []telemetry.MetricSnapshot{ - {Name: "meter_l1_a", Value: 8, UpdatedAt: now}, - {Name: "meter_l2_a", Value: 9, UpdatedAt: now.Add(-2 * time.Minute)}, - {Name: "meter_l3_a", Value: 7, UpdatedAt: now}, - } - amps, available, fresh := phaseCurrentsFromSnapshots(metrics, 3, time.Minute, now) - if !available { - t.Fatal("phase telemetry should be available") - } - if fresh { - t.Fatalf("phase telemetry unexpectedly fresh: %v", amps) - } -} - +// The phase currents come with the site reading. Once it reports any, a +// reading that misses one of the configured phases stops dispatch, and a +// complete one lets it go again. func TestEvaluateSiteDispatchFreshnessPhaseRecovery(t *testing.T) { tel := telemetry.NewStore() - tel.Update("meter", telemetry.DerMeter, 1200, nil, nil) - tel.EmitMetric("meter", "meter_l1_a", 8, "A", "", "") + tel.Update("meter", telemetry.DerMeter, 1200, nil, json.RawMessage(`{"l1_a":8}`)) now := time.Now() blocked := evaluateSiteDispatchFreshnessAt(tel, "meter", 16, 3, time.Minute, now) @@ -54,9 +41,7 @@ func TestEvaluateSiteDispatchFreshnessPhaseRecovery(t *testing.T) { blocked.Reason, siteDispatchPhaseCurrentsStale) } - tel.EmitMetric("meter", "meter_l2_a", 9, "A", "", "") - tel.EmitMetric("meter", "meter_l3_a", 7, "A", "", "") - tel.Update("meter", telemetry.DerMeter, 1100, nil, nil) + tel.Update("meter", telemetry.DerMeter, 1100, nil, json.RawMessage(`{"l1_a":8,"l2_a":9,"l3_a":7}`)) recovered := evaluateSiteDispatchFreshnessAt(tel, "meter", 16, 3, time.Minute, time.Now()) if !recovered.Allowed() { t.Fatalf("complete fresh phase telemetry remained blocked: %q", recovered.Reason) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index d9e2a9b4b..43ebba9d3 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1887,9 +1887,9 @@ func (s *Server) handleSetPeakLimit(w http.ResponseWriter, r *http.Request) { // ---- /api/peak_import_ceiling ---- // // Import ceiling on each clock hour's mean import, in every mode. Default -// 0 = disabled. When > 0, dispatch's import-side clamps (joint EV/battery -// allocator, applyFuseGuard import branch, forceFuseDischarge, the EV hard -// limit) use the tighter of the fuse and the hour's import limit. +// 0 = disabled. When > 0, dispatch's import-side clamps (the battery's rise +// cap, applySiteLimits, forceFuseDischarge, the EV hard limit) hold the +// hour's import limit as well as the fuse. // Persisted in state.db so the operator's tariff stays armed across // restarts. See control.State.PeakImportCeilingW. func (s *Server) handleSetPeakImportCeiling(w http.ResponseWriter, r *http.Request) { diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 8f470691f..ba75296c3 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -938,7 +938,7 @@ type Driver struct { // with since v0.x. On a hybrid inverter that can actually deliver // more (e.g. Ferroamp 10-15 kW, Sungrow 8-10 kW on 32 A), lifting // the per-driver cap is the right move — site-wide fuse protection - // (applyFuseGuard) still enforces the grid-boundary budget above + // (control.fusePhases) still enforces the phases' budget above // whatever per-battery cap you set. Issue #145. MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` MaxDischargeW float64 `yaml:"max_discharge_w,omitempty" json:"max_discharge_w,omitempty"` diff --git a/go/internal/control/battery_boost_test.go b/go/internal/control/battery_boost_test.go index 6e7d8d6b2..bec3c1290 100644 --- a/go/internal/control/battery_boost_test.go +++ b/go/internal/control/battery_boost_test.go @@ -100,6 +100,7 @@ func TestFuseEmergencyRemainsSuperiorToBatteryBoostReserve(t *testing.T) { state := NewState(0, 0, "ferroamp") state.BatteryBoostReserveSoC = 0.30 state.DriverLimits = map[string]PowerLimits{"battery": {MaxDischargeW: 5000}} + describeFuse(state, 5000) targets := applyDispatchSafetyPipeline( []DispatchTarget{{Driver: "battery", TargetW: -1000}}, store, state, map[string]float64{"battery": 10000}, 5000, diff --git a/go/internal/control/battery_full_test.go b/go/internal/control/battery_full_test.go index 585b1f934..72bf7c5df 100644 --- a/go/internal/control/battery_full_test.go +++ b/go/internal/control/battery_full_test.go @@ -119,6 +119,7 @@ func TestFullBatteryStillProvidesFuseRelief(t *testing.T) { currentW, soc float64 }{{"battery", 0, 1}}) st.Mode = ModeIdle + describeFuse(st, 13800) got := ComputeDispatch(tel, st, capacities, 13800) if len(got) != 1 || got[0].TargetW >= 0 { t.Fatalf("full stop blocked fuse relief: %+v", got) diff --git a/go/internal/control/ev_battery_headroom_test.go b/go/internal/control/battery_fuse_rise_test.go similarity index 84% rename from go/internal/control/ev_battery_headroom_test.go rename to go/internal/control/battery_fuse_rise_test.go index 9fafb4872..a9f1b5fe8 100644 --- a/go/internal/control/ev_battery_headroom_test.go +++ b/go/internal/control/battery_fuse_rise_test.go @@ -47,7 +47,12 @@ func sent(state *State, driver string, watts ...float64) { log = log.add(at, w) } if driver == "ev" { + for i := range log { + log[i].three = true + } state.evOffers, state.EVCommandedW = log, watts[len(watts)-1] + state.evLimit.offers = map[string]chargerOffers{"car": {driver: "car", log: log, settle: evSettle}} + state.evCommanded = map[string]float64{"car": watts[len(watts)-1]} return } state.batteryCommands[driver] = log @@ -68,7 +73,7 @@ func TestEVBatteryChargeYieldsToEVNeed(t *testing.T) { store, state := evSplitFixture(t, 7140, nil, 10350, "3p") sent(state, "ev", 4140, 4140) sent(state, "first", 0, 0) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state) if got, want := targetW(out, "first"), 16560.0-7140-6210; math.Abs(got-want) > .01 { t.Fatalf("battery %.1f W, want %.1f W", got, want) } @@ -80,7 +85,7 @@ func TestEVBatteryRecentEVRiseIsStillNeed(t *testing.T) { store, state := evSplitFixture(t, 7140, nil, 10350, "3p") sent(state, "ev", 4140, 10350) sent(state, "first", 0) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state) if got, want := targetW(out, "first"), 16560.0-7140-6210; math.Abs(got-want) > .01 { t.Fatalf("battery %.1f W spent the unseen EV rise, want %.1f W", got, want) } @@ -93,7 +98,7 @@ func TestEVBatteryReductionDoesNotFundSibling(t *testing.T) { sent(state, "ev", 5000) sent(state, "first", 4000, 0) sent(state, "second", 0) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}, {Driver: "second", TargetW: 8000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: 0}, {Driver: "second", TargetW: 8000}}, store, state) if got, want := targetW(out, "second"), 16560.0-12000-1000; got > want+.01 { t.Fatalf("sibling %.1f W used an unseen reduction, want <= %.1f W", got, want) } @@ -106,7 +111,7 @@ func TestEVBatteryStoppingDischargeReservesItsSupply(t *testing.T) { sent(state, "ev", 0) sent(state, "first", -5000) sent(state, "second", 0) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 0}, {Driver: "second", TargetW: 8000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: 0}, {Driver: "second", TargetW: 8000}}, store, state) if got, want := targetW(out, "second"), 16560.0-6000-2000-5000; got > want+.01 { t.Fatalf("sibling %.1f W, want <= %.1f W", got, want) } @@ -119,7 +124,7 @@ func TestEVBatteryOwnPendingRiseCountsOnce(t *testing.T) { store, state := evSplitFixture(t, 9000, nil, 2000, "3p") sent(state, "ev", 0) sent(state, "first", 2000, 6000) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: tc.target}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: tc.target}}, store, state) if got := targetW(out, "first"); math.Abs(got-tc.want) > .01 { t.Fatalf("order %.0f W became %.1f W, want %.1f W", tc.target, got, tc.want) } @@ -131,7 +136,7 @@ func TestEVBatterySinglePhaseNeedUsesWorstPhase(t *testing.T) { store, state := evSplitFixture(t, 4600, []float64{10, 5, 5}, 3680, "1p") sent(state, "ev", 0) sent(state, "first", 0) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 3000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: 3000}}, store, state) if got := targetW(out, "first"); got != 0 { t.Fatalf("battery %.1f W; L1 10 A + 16 A car leaves no room under 24 A", got) } @@ -142,7 +147,7 @@ func TestEVBatterySinglePhaseNeedUsesWorstPhase(t *testing.T) { func TestEVBatteryWithoutCommandsReservesDischarge(t *testing.T) { store, state := evSplitFixture(t, 5000, nil, 4140, "3p") sent(state, "ev", 0) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state) if got, want := targetW(out, "first"), 16560.0-5000-4140-5000; got > want+.01 { t.Fatalf("battery %.1f W, want <= %.1f W", got, want) } @@ -151,7 +156,7 @@ func TestEVBatteryWithoutCommandsReservesDischarge(t *testing.T) { func TestEVBatteryUnknownMeterClampsOnlyPositiveOffers(t *testing.T) { store, state := evSplitFixture(t, 0, nil, 4140, "3p") store.Remove("meter") - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 3000}, {Driver: "second", TargetW: -2000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: 3000}, {Driver: "second", TargetW: -2000}}, store, state) if targetW(out, "first") != 0 || targetW(out, "second") != -2000 { t.Fatalf("unknown meter: %+v", out) } @@ -162,7 +167,7 @@ func TestEVBatteryKeepsChargeNeededForMeasuredExportRelief(t *testing.T) { store, state := evSplitFixture(t, -19000, nil, 4140, "3p") state.MaxExportW = 10000 sent(state, "ev", 0) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 12000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: 12000}}, store, state) if got := targetW(out, "first"); got < 9000-.01 { t.Fatalf("export relief cut to %.1f W, want >= 9,000 W", got) } @@ -176,7 +181,7 @@ func TestEVBatterySplitIgnoresDeviceReadings(t *testing.T) { sent(state, "ev", 4140, 4140) sent(state, "first", 0, 0) readings(store) - return targetW(clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state, 17250), "first") + return targetW(fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: 8000}}, store, state), "first") } want := run(func(*telemetry.Store) {}) for name, readings := range map[string]func(*telemetry.Store){ diff --git a/go/internal/control/control_test.go b/go/internal/control/control_test.go index e3de918f2..c90b09779 100644 --- a/go/internal/control/control_test.go +++ b/go/internal/control/control_test.go @@ -308,6 +308,7 @@ func TestChargeModeRespectsFuseGuard(t *testing.T) { }) st := NewState(0, 50, "ferroamp") st.Mode = ModeCharge + describeFuse(st, 11040) targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) if len(targets) != 1 { t.Fatalf("expected 1 target, got %d", len(targets)) @@ -315,8 +316,9 @@ func TestChargeModeRespectsFuseGuard(t *testing.T) { if !targets[0].Clamped { t.Fatalf("charge target should be clamped by fuse guard: %+v", targets[0]) } - if math.Abs(targets[0].TargetW-1040) > 1 { - t.Fatalf("charge target = %.0f W, want remaining fuse headroom 1040 W", targets[0].TargetW) + // 16 A less the 1 A margin is 10,350 W on three phases. + if math.Abs(targets[0].TargetW-350) > 1 { + t.Fatalf("charge target = %.0f W, want the remaining 350 W of phase room", targets[0].TargetW) } } @@ -711,23 +713,21 @@ func TestComputeDispatchPreservesExplicitZeroDirectionLimits(t *testing.T) { }) } -// ---- Fuse guard ---- +// ---- Site limits: the hour's import limit and max_export_w ---- -// Old-world test updated for the bidirectional predicted-grid guard -// (#145). Previous semantics ("PV + discharge > fuse → scale") assumed -// zero load and treated discharge as always pushing past the fuse. The -// new guard predicts site-boundary flow from live telemetry, which is -// physically accurate AND covers the charge side symmetrically. -func TestFuseGuardScalesDischargeWhenExportExceedsFuse(t *testing.T) { +// Predicted export over max_export_w scales discharge back. The prediction +// holds load and PV at the meter's reading (#145). +func TestSiteLimitsScaleDischargeOverMaxExport(t *testing.T) { s := telemetry.NewStore() s.Update("meter", telemetry.DerMeter, -8000, nil, nil) // grid exporting 8 kW (PV dominant) s.DriverHealthMut("meter").RecordSuccess() s.Update("a", telemetry.DerBattery, 0, nil, nil) s.DriverHealthMut("a").RecordSuccess() targets := []DispatchTarget{{Driver: "a", TargetW: -6000}} // add 6 kW discharge on top - // Predicted grid = -8000 - 0 + (-6000) = -14000 (exporting). Fuse 11040. + // Predicted grid = -8000 - 0 + (-6000) = -14000 (exporting). Limit 11040. st := NewState(0, 50, "meter") - scaled := applyFuseGuard(targets, s, st, 11040) + st.MaxExportW = 11040 + scaled := applySiteLimits(targets, s, st) if !scaled[0].Clamped { t.Error("expected clamped=true") } @@ -737,8 +737,8 @@ func TestFuseGuardScalesDischargeWhenExportExceedsFuse(t *testing.T) { } } -// Small charge under the fuse → unchanged. No scaling. -func TestFuseGuardPassesThroughWhenWithinFuse(t *testing.T) { +// Small charge under the limits → unchanged. No scaling. +func TestSiteLimitsPassThroughWhenWithin(t *testing.T) { s := telemetry.NewStore() s.Update("meter", telemetry.DerMeter, 0, nil, nil) s.DriverHealthMut("meter").RecordSuccess() @@ -746,16 +746,15 @@ func TestFuseGuardPassesThroughWhenWithinFuse(t *testing.T) { s.DriverHealthMut("a").RecordSuccess() targets := []DispatchTarget{{Driver: "a", TargetW: 3000}} st := NewState(0, 50, "meter") - scaled := applyFuseGuard(targets, s, st, 11040) + st.PeakImportCeilingW, st.MaxExportW = 11040, 11040 + scaled := applySiteLimits(targets, s, st) if scaled[0].TargetW != 3000 || scaled[0].Clamped { - t.Errorf("within fuse: got %f clamped=%v, want 3000 false", scaled[0].TargetW, scaled[0].Clamped) + t.Errorf("within limits: got %f clamped=%v, want 3000 false", scaled[0].TargetW, scaled[0].Clamped) } } -// Charge side now protected (#145). With high load and aggressive -// charge targets, predicted grid import can exceed the fuse — the -// guard must scale charge down. -func TestFuseGuardScalesChargingWhenImportExceedsFuse(t *testing.T) { +// Predicted import over the hour's import limit scales charge down. +func TestSiteLimitsScaleChargingOverHoursImportLimit(t *testing.T) { s := telemetry.NewStore() // Grid currently importing 8 kW (load-dominated, night/no PV). s.Update("meter", telemetry.DerMeter, 8000, nil, nil) @@ -768,10 +767,11 @@ func TestFuseGuardScalesChargingWhenImportExceedsFuse(t *testing.T) { {Driver: "a", TargetW: 5000}, {Driver: "b", TargetW: 5000}, } - // Predicted = 8000 - 0 + 10000 = 18000 W. Fuse 11040. + // Predicted = 8000 - 0 + 10000 = 18000 W. Limit 11040. // Overage = 6960. Total charge = 10000. New total = 3040. Scale = 0.304. st := NewState(0, 50, "meter") - scaled := applyFuseGuard(targets, s, st, 11040) + st.PeakImportCeilingW = 11040 + scaled := applySiteLimits(targets, s, st) var totalCharge float64 for _, tgt := range scaled { if tgt.TargetW > 0 { @@ -788,16 +788,14 @@ func TestFuseGuardScalesChargingWhenImportExceedsFuse(t *testing.T) { } } -// Mirror of the charging test with no grid reading: guard can't predict -// reliably, stays conservative by NOT scaling (leave the decision to -// the upstream per-battery cap). Guards against a bare-metal test -// setup accidentally triggering the guard. -func TestFuseGuardNoOpWithoutMeterReading(t *testing.T) { +// With no grid reading the prediction is the targets alone, here inside +// the limit. Guards against a bare-metal test setup triggering the clamp. +func TestSiteLimitsNoOpWithoutMeterReading(t *testing.T) { s := telemetry.NewStore() targets := []DispatchTarget{{Driver: "a", TargetW: 5000}} - // No meter reading → currentGrid defaults to 0 → predicted = 5000 which is fine. st := NewState(0, 50, "does-not-exist") - scaled := applyFuseGuard(targets, s, st, 11040) + st.PeakImportCeilingW = 11040 + scaled := applySiteLimits(targets, s, st) if scaled[0].TargetW != 5000 || scaled[0].Clamped { t.Errorf("absent meter → no prediction-based clamp, got %f clamped=%v", scaled[0].TargetW, scaled[0].Clamped) @@ -1458,6 +1456,12 @@ func newStateWithEnergyDispatch(dir SlotDirective, siteMeter string) *State { return st } +// describeFuse gives st a balanced three-phase 230 V fuse of fuseMaxW in +// total, as config describes it. fusePhases reads the fuse from these. +func describeFuse(st *State, fuseMaxW float64) { + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases = fuseMaxW/690, 230, 3 +} + // Core conversion: 200 Wh allocated, whole 15-min slot remaining, // no energy delivered yet → target = 200 × 3600 / 900 = 800 W. func TestEnergyDispatchConvertsWhToW(t *testing.T) { @@ -1508,7 +1512,7 @@ func TestEnergyDispatchDoesNotAbsorbPVSurprise(t *testing.T) { }{ {"ferroamp", 0, 0.5}, }) - // PV telemetry so applyFuseGuard has something to count. + // PV telemetry so the site limits have something to count. store.Update("pv-1", telemetry.DerPV, -4800, nil, nil) store.DriverHealthMut("pv-1").RecordSuccess() @@ -1603,20 +1607,11 @@ func TestEnergyDispatchBatteryCoversEVOnLetsPlanRun(t *testing.T) { } } -// Joint fuse-budget allocator: when EV draw + plan's battery charge would -// exceed the fuse, both should be scaled proportionally — battery charge -// reduced AND state.FuseEVMaxW published so the loadpoint controller can -// curtail the EV. Operator report: oscillation loop where plan ramps -// battery, fuse guard cuts it, plan ramps again. -// -// Scenario: house 200 W, EV at 8 kW, plan wants battery +5 kW, fuse 11 kW. -// Naive: total grid = 200+8000+5000 = 13.2 kW → fuse busts. -// Joint allocator: scale = (11000-200-0)/(5000+8000) ≈ 0.831. -// -// battery charge → ~4150 W -// FuseEVMaxW → ~6650 W -// sum + house ≈ 11.0 kW. Fuse respected. -func TestJointFuseAllocatorScalesBothBatteryAndEV(t *testing.T) { +// The plan's battery charge must fit the phase room beside a car already +// drawing. House 200 W and the car at 8 kW put 8.2 kW on an 11 kW fuse +// (16 A less the 1 A margin is 10,310 W), so the planned 5 kW charge gets +// 2,110 W and the site reports itself saturated. +func TestBatteryChargeFitsPhaseRoomBesideACar(t *testing.T) { now := time.Now() dir := SlotDirective{ SlotStart: now, @@ -1624,10 +1619,6 @@ func TestJointFuseAllocatorScalesBothBatteryAndEV(t *testing.T) { BatteryEnergyWh: 1250, // ~5 kW for 15 min Strategy: "arbitrage", } - // rawGridW = 13.2 kW (importing — house 200 + EV 8000 + plan-imminent - // battery 5000, but battery currentW=0 means it's not yet charging, - // so right now grid is 200+8000=8200. Plan WANTS battery to add 5kW. - // Use 8200 to model the live state at the start of the tick.) store := seedStore(8200, []struct { name string currentW, soc float64 @@ -1637,48 +1628,23 @@ func TestJointFuseAllocatorScalesBothBatteryAndEV(t *testing.T) { st := newStateWithEnergyDispatch(dir, "ferroamp") st.EVChargingW = 8000 st.BatteryCoversEV = true // not the toggle under test; let plan run - const fuseMaxW = 11000 + describeFuse(st, fuseMaxW) targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), fuseMaxW) if len(targets) != 1 { t.Fatalf("want 1 target, got %d", len(targets)) } - got := targets[0].TargetW - // Allocator contract: FuseEVMaxW published so loadpoint can throttle. - // In *this* tick, EV is still at 8 kW (hasn't seen the cap yet), so the - // existing fuse guard further clamps battery to ~2800 W (= 11000 − - // 8200). Within 1 follow-up tick (after EV throttles to ~6650 W), - // battery climbs to ~4150 W. Joint stable point: 4150 + 6650 = 10800 - // + 200 house = 11 kW = fuse. - if got > 4500 { - t.Errorf("TargetW = %.0f W — battery charge not cut by joint allocator (want ≤ 4500)", got) - } - if got < 0 { - t.Errorf("TargetW = %.0f W — battery shouldn't be discharging here", got) + if got, want := targets[0].TargetW, fuseMaxW-690-8200.0; got < 0 || got > want+1 { + t.Errorf("TargetW = %.0f W, want at most the %.0f W of phase room", got, want) } if !st.FuseSaturated || st.FuseSaturatedByImport { - t.Errorf("FuseSaturated = %v, by import %v; want the fuse after joint scaling", st.FuseSaturated, st.FuseSaturatedByImport) - } - // EV cap is the post-republish verdict: after applyFuseGuard + - // forceFuseDischarge constrain battery further (and may swing it - // into discharge), the cap accounts for the now-greater fuse - // headroom. Bound below by ~6000 (the original allocator's - // pessimistic cap) and above by the current EV draw — a cap - // greater than the EV is currently drawing would be loose. - if st.FuseEVMaxW < 6000 || st.FuseEVMaxW > st.EVChargingW { - t.Errorf("FuseEVMaxW = %.0f — want in [6000, %.0f] (post-republish cap)", - st.FuseEVMaxW, st.EVChargingW) - } - // Sanity: post-dispatch projected grid stays at or below fuse. - projected := 8200.0 + got - if projected > fuseMaxW+50 { - t.Errorf("projected grid %.0f W > fuse %.0f after dispatch", projected, float64(fuseMaxW)) + t.Errorf("FuseSaturated = %v, by import %v; want the fuse", st.FuseSaturated, st.FuseSaturatedByImport) } } // The same site under a 9 kW hourly import ceiling: the hour's limit, not -// the fuse, sets the ceiling the allocator hits, and says so. -func TestJointFuseAllocatorNamesTheHoursImportLimit(t *testing.T) { +// the fuse, holds the charge back, and says so. +func TestBatteryChargeNamesTheHoursImportLimit(t *testing.T) { now := time.Now() dir := SlotDirective{SlotStart: now, SlotEnd: now.Add(15 * time.Minute), BatteryEnergyWh: 1250, Strategy: "arbitrage"} store := seedStore(8200, []struct { @@ -1687,6 +1653,7 @@ func TestJointFuseAllocatorNamesTheHoursImportLimit(t *testing.T) { }{{"ferroamp", 0, 0.5}}) st := newStateWithEnergyDispatch(dir, "ferroamp") st.EVChargingW, st.BatteryCoversEV, st.PeakImportCeilingW = 8000, true, 9000 + describeFuse(st, 11000) ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11000) if !st.FuseSaturated || !st.FuseSaturatedByImport { t.Fatalf("FuseSaturated = %v, by import %v; want the hour's import limit", st.FuseSaturated, st.FuseSaturatedByImport) @@ -4256,68 +4223,10 @@ func TestPlannerSelfResetsEnergyBookkeepingOnEntry(t *testing.T) { } } -// Regression for the post-forceFuseDischarge republish of FuseEVMaxW. -// Before the fix, the joint allocator computed FuseEVMaxW assuming the -// battery target it produced is what gets dispatched. But the reactive -// fuse-saver (PR #208) runs LAST and may swing battery from charge to -// discharge, freeing fuse headroom — yet FuseEVMaxW stayed at the -// original (too-conservative) value, throttling EV unnecessarily for -// one tick. With the fix, FuseEVMaxW reflects the post-saver battery -// totals and the EV gets the headroom it actually has. -func TestFuseEVMaxWRecomputedAfterForceFuseDischarge(t *testing.T) { - now := time.Now() - dir := SlotDirective{ - SlotStart: now, - SlotEnd: now.Add(15 * time.Minute), - BatteryEnergyWh: 1250, // plan: charge 5 kW - Strategy: "arbitrage", - } - // Construct a scenario where the joint allocator engages (EV + - // battery charge over fuse), AND the fuse-saver subsequently - // flips battery to discharge. Easiest: rawGridW already over - // fuseMaxW so applyFuseGuard zeros battery charge AND - // forceFuseDischarge then drives it negative. - store := seedStore(13000, []struct { - name string - currentW, soc float64 - }{ - {"ferroamp", 0, 0.6}, - }) - st := newStateWithEnergyDispatch(dir, "ferroamp") - st.EVChargingW = 8000 - st.BatteryCoversEV = true - const fuseMaxW = 11000 - targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), fuseMaxW) - if len(targets) != 1 { - t.Fatalf("want 1 target, got %d", len(targets)) - } - if !st.FuseSaturated { - t.Fatalf("FuseSaturated = false; expected joint allocator to engage") - } - // The published FuseEVMaxW must reflect the post-saver battery - // total. Concretely: with battery driven into discharge, the EV's - // available headroom is greater than the joint allocator's initial - // guess. So FuseEVMaxW should be ≥ the initial scaled value. - if st.FuseEVMaxW <= 0 { - t.Errorf("FuseEVMaxW = %.0f after republish; should publish a positive cap", st.FuseEVMaxW) - } - // Specifically: with battery target negative (discharging), - // projected_grid = H + postBat + E ≤ fuse implies E_cap = fuse − H − postBat. - // postBat is whatever the saver landed on; verify the published - // value never exceeds the actual EV draw or goes negative. - if st.FuseEVMaxW > st.EVChargingW { - t.Errorf("FuseEVMaxW = %.0f exceeds current EV draw %.0f — implies the cap is loose", - st.FuseEVMaxW, st.EVChargingW) - } -} - -// BatteryCoversEV mode regression: the joint allocator's H computation -// uses rawGridW directly, so its math is independent of the -// BatteryCoversEV branch (which only affects PI's gridW). Both modes -// should produce the same allocator behaviour given identical raw -// telemetry. Documents the design choice — protects against a future -// refactor that conflates the two. -func TestJointFuseAllocatorWithBatteryCoversEV(t *testing.T) { +// BatteryCoversEV only moves the PI's grid reading. The phase room the +// battery's charge must fit is the meter's either way, so both modes hold +// the planned charge back beside the car. +func TestPhaseRoomIndependentOfBatteryCoversEV(t *testing.T) { now := time.Now() dir := SlotDirective{ SlotStart: now, @@ -4335,6 +4244,7 @@ func TestJointFuseAllocatorWithBatteryCoversEV(t *testing.T) { st := newStateWithEnergyDispatch(dir, "ferroamp") st.EVChargingW = 8000 st.BatteryCoversEV = coversEV + describeFuse(st, 11000) return st, store } st1, store1 := mkState(true) @@ -4342,23 +4252,11 @@ func TestJointFuseAllocatorWithBatteryCoversEV(t *testing.T) { const fuseMaxW = 11000 _ = ComputeDispatch(store1, st1, caps(map[string]float64{"ferroamp": 15200}), fuseMaxW) _ = ComputeDispatch(store2, st2, caps(map[string]float64{"ferroamp": 15200}), fuseMaxW) - // Both must engage the joint allocator: same fuse-vs-EV+battery - // arithmetic, regardless of operator-toggle for "let battery cover EV". + // The same meter, the same room. if !st1.FuseSaturated || !st2.FuseSaturated { t.Errorf("FuseSaturated must engage in both modes: covers=%v others=%v", st1.FuseSaturated, st2.FuseSaturated) } - // The PI / energy-path branch produces different battery TARGETS - // in the two modes (that's the BatteryCoversEV behaviour). But the - // JOINT allocator's E_cap formula depends only on rawGridW + E, - // so the published FuseEVMaxW should be in the same ballpark - // (within ~500 W, since post-republish accounts for the different - // battery targets the two modes produce). - delta := math.Abs(st1.FuseEVMaxW - st2.FuseEVMaxW) - if delta > 1500 { - t.Errorf("FuseEVMaxW differs by %.0f W between BatteryCoversEV true/false (%v vs %v) — math should be mode-independent", - delta, st1.FuseEVMaxW, st2.FuseEVMaxW) - } } // Plan/exec sign-mismatch floor — operator-report 2026-04-28 (08:00–08:15 @@ -4773,11 +4671,12 @@ func TestBatteryManualHoldRespectsFuseGuard(t *testing.T) { st.Mode = ModeSelfConsumption st.SlewRateW = 100000 st.SetBatteryManualHold(BatteryManualHold{PowerW: 5000, ExpiresAt: time.Now().Add(60 * time.Second)}) + describeFuse(st, 2000) targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 2000) if len(targets) != 1 { t.Fatalf("expected 1 target, got %d", len(targets)) } - if targets[0].TargetW > 2000+1 { + if targets[0].TargetW > 2000-690+1 { t.Errorf("fuse guard must clamp +5000 charge under fuseMaxW=2000, got %f", targets[0].TargetW) } if !targets[0].Clamped { diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index eed8315e8..a21f90dd6 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -227,8 +227,14 @@ type PowerLimits struct { MaxDischargeW float64 MaxChargeWSet bool MaxDischargeWSet bool + // SoCMin is the battery's floor, 0..1: its soc_min, else the planner's. + // Fuse relief discharges no further. Zero leaves the 5 % empty guard. + SoCMin float64 } +// socFloor is the SoC fuse relief stops discharging at. +func (l PowerLimits) socFloor() float64 { return math.Max(0.05, l.SoCMin) } + func effectivePowerLimit(value float64, set bool) float64 { if set && value >= 0 { return value @@ -540,16 +546,9 @@ type State struct { PVExportGuardActive bool PVExportResidualW float64 - // FuseEVMaxW is the joint allocator's verdict for the EV's allowed - // wattage this tick. Only meaningful when FuseSaturated is true. - // Read by the loadpoint controller (via a hook) to curtail the EV - // command so battery and EV cooperatively share the fuse budget. - // Recomputed every dispatch tick. - FuseEVMaxW float64 // FuseSaturated signals that the joint allocator had to scale battery - // and/or EV demand to fit under the fuse this tick. Used to gate the - // loadpoint controller's read of FuseEVMaxW and as the trigger for - // an MPC reactive replan from main.go. + // and/or EV demand to fit under the site's ceiling this tick. main.go + // replans on it and stops a battery boost. FuseSaturated bool // FuseSaturatedByImport says the clock hour's import limit, not the // fuse, set the ceiling FuseSaturated hit. @@ -564,21 +563,6 @@ type State struct { // Issue #145. DriverLimits map[string]PowerLimits - // FuseHold* — hysteresis state from applyFuseGuard. After a clamp - // fires, the latched maximum aggregate-battery magnitude (for the - // direction that tripped) is held for ~30 s so the planner can't - // immediately re-ramp into the same threshold and oscillate at - // the boundary. Each new fire extends the window. - // - // Without this, the dispatch + slew + planner feedback loop - // stabilises with phase amps riding right at the trip threshold: - // every tick scales the discharge JUST enough to clear the trip, - // the next tick the planner re-asks for more, and the system - // hovers permanently at the boundary instead of leaving headroom. - FuseHoldMaxDischargeW float64 - FuseHoldMaxChargeW float64 - FuseHoldUntil time.Time - // ManualHold pins the battery pool or one battery to a fixed power // for a bounded duration, bypassing both the active manual mode and // the MPC. Hot-installed via POST /api/battery/manual_hold; it is @@ -2036,46 +2020,6 @@ func ComputeDispatch( } } - // Share the site ceiling between EV and battery charge. An accepted - // EV plan reserves its demand before optional battery charge, including - // while the car ramps up. Unscheduled EV draw retains proportional sharing. - // Infer house net power from measured EV draw, never from planned demand. - // The ceiling gives up the worst phase's imbalance: a charger's own load - // balancer cuts the car on that phase while the total still fits. - state.FuseEVMaxW = 0 - state.FuseSaturated, state.FuseSaturatedByImport = false, false - plannedEVW := scheduledEVPowerW(state) - evInMeterW := state.evInMeterW() - if fuseMaxW > 0 && math.Max(evInMeterW, plannedEVW) > 0 { - // Use the effective import ceiling (fuse minus safety margin, - // or the hour's import limit when tighter) so PeakImportCeilingW - // throttles the EV through the same surface as the fuse. Steady-state - // with peak active and BatteryCoversEV=false: the EV settles - // at this scaled rate; the battery's transient discharge to - // bridge the ramp-down is handled by forceFuseDischarge below. - ceilingW := state.phaseImportCeilingW(fuseMaxW, siteMeter) - targetTotal := currentTotal + totalCorrection - B := math.Max(0, targetTotal) - Bn := math.Min(0, targetTotal) - E := math.Max(evInMeterW, plannedEVW) - H := rawGridW - currentTotal - evInMeterW - projectedGrid := H + targetTotal + E - if projectedGrid > ceilingW && (B+E) > 0 { - if plannedEVW > 0 { - // A timed EV goal takes its share before optional battery charge. - available := evHeadroomW(ceilingW, rawGridW-currentTotal+Bn, evInMeterW) - state.FuseEVMaxW = math.Min(E, available) - newBattery := Bn + math.Min(B, math.Max(0, available-state.FuseEVMaxW)) - totalCorrection = newBattery - currentTotal - } else { - scale := math.Max(0, math.Min(1, (ceilingW-H-Bn)/(B+E))) - totalCorrection = Bn + B*scale - currentTotal - state.FuseEVMaxW = E * scale - } - state.FuseSaturated, state.FuseSaturatedByImport = true, state.importLimitSets(ceilingW) - } - } - // ---- Per-group PV surplus for DC-local charge routing (#143) ---- // Empty when no drivers carry an inverter-group tag → distributeProportional // falls through to its capacity-only split (today's behavior). @@ -2333,8 +2277,11 @@ func applyDispatchSafetyPipeline( fuseMaxW float64, opts dispatchSafetyOptions, ) []DispatchTarget { - // ---- Fuse guard (bidirectional, #145) ---- - targets = applyFuseGuard(targets, store, state, fuseMaxW) + // ---- The hour's import limit and the export limit (#145) ---- + if state != nil { + state.FuseSaturated, state.FuseSaturatedByImport = false, false + } + targets = applySiteLimits(targets, store, state) // ---- Plan/exec sign-mismatch floor (energy planner modes only) ---- // Operator-report 2026-04-28 (08:00-08:15 CEST): planner_arbitrage @@ -2376,8 +2323,7 @@ func applyDispatchSafetyPipeline( } targets = floorMissingSoCDischarge(targets, store) targets = state.floorFullBatteryCharge(targets) - targets = clampEVBatteryHeadroom(targets, store, state, fuseMaxW) - republishFuseEVCapAfterFuseDischarge(targets, store, state, fuseMaxW) + targets = fitBatteryRisesToFuse(targets, store, state) recordDispatchTargets(targets, state, opts.updatePrevTargets, opts.recordDispatch) return targets } @@ -2402,49 +2348,6 @@ func floorMissingSoCDischarge(targets []DispatchTarget, store *telemetry.Store) return targets } -func republishFuseEVCapAfterFuseDischarge(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) { - // The joint allocator computes FuseEVMaxW assuming the battery target it - // produced is what gets dispatched. forceFuseDischarge may flip that target - // from charge to discharge, freeing additional fuse headroom for the EV. - if state == nil || !state.FuseSaturated || state.EVChargingW <= 0 || fuseMaxW <= 0 { - return - } - peakBindingW := fuseMaxW - state.fuseSafetyMarginW() - if state.effectiveImportCeilingW(fuseMaxW) < peakBindingW { - return - } - - var currentBat, postBat float64 - seen := make(map[string]struct{}, len(targets)) - for _, t := range targets { - if _, ok := seen[t.Driver]; ok { - continue - } - seen[t.Driver] = struct{}{} - postBat += t.TargetW - if r := store.Get(t.Driver, telemetry.DerBattery); r != nil { - currentBat += r.SmoothedW - } - } - - siteMeter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - ceilingW := state.phaseImportCeilingW(fuseMaxW, siteMeter) - var rawGridW float64 - if siteMeter != nil { - rawGridW = siteMeter.SmoothedW - } - H := rawGridW - currentBat - state.EVChargingW - headroom := ceilingW - H - postBat - if headroom < 0 { - headroom = 0 - } - newCap := headroom - if demand := math.Max(state.EVChargingW, scheduledEVPowerW(state)); newCap > demand { - newCap = demand - } - state.FuseEVMaxW = newCap -} - func recordDispatchTargets(targets []DispatchTarget, state *State, updatePrevTargets, recordDispatch bool) { if state == nil { return @@ -3554,23 +3457,27 @@ func clampWithSoC(target float64, b batteryInfo) (float64, bool) { return clamped, wasClamped } -// applyFuseGuard scales charge down on predicted import over the fuse, -// and discharge down on predicted export over it. The old guard only -// looked at PV plus discharge and assumed no load (#145). -func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) []DispatchTarget { - if fuseMaxW <= 0 || state == nil { +// applySiteLimits scales charge down on predicted import over the clock +// hour's import limit, and discharge down on predicted export over the +// operator's max_export_w. Both are totals the meter bills or an inverter +// trips on, not the fuse: fusePhases guards the fuse on each phase, in +// forceFuseDischarge and fitBatteryRisesToFuse. The prediction holds load, +// PV and uncontrolled batteries at the meter's reading (#145). +func applySiteLimits(targets []DispatchTarget, store *telemetry.Store, state *State) []DispatchTarget { + if state == nil { return targets } - siteMeter := state.SiteMeterDriver - // Aggregate live battery power for the batteries this dispatch is - // about to control so we can hold load+pv+uncontrolled-batteries - // constant. Offline or otherwise untargeted batteries are already - // reflected in currentGrid; subtracting them here without adding a - // replacement target would double-remove their contribution and miss - // fuse overages. + importLimitW, _, hourly := state.importHourLimitsW() + if !hourly && state.MaxExportW <= 0 { + return targets + } + // Live battery power of the batteries this dispatch is about to + // control. Offline or untargeted batteries are already in the meter; + // subtracting them without a replacement target would hide an overage. seenBat := make(map[string]struct{}, len(targets)) - var currentBat float64 + var currentBat, sumTarget float64 for _, t := range targets { + sumTarget += t.TargetW if _, seen := seenBat[t.Driver]; seen { continue } @@ -3580,185 +3487,36 @@ func applyFuseGuard(targets []DispatchTarget, store *telemetry.Store, state *Sta } } var currentGrid float64 - if siteMeter != "" { - if r := store.Get(siteMeter, telemetry.DerMeter); r != nil { - currentGrid = r.SmoothedW - } - } - var sumTarget float64 - for _, t := range targets { - sumTarget += t.TargetW + if r := store.Get(state.SiteMeterDriver, telemetry.DerMeter); r != nil { + currentGrid = r.SmoothedW } predicted := currentGrid - currentBat + sumTarget - - // Per-phase relief: the worst single-phase amperage above the fuse - // trip threshold (less the safety margin), converted by - // perPhaseReliefW into the AGGREGATE battery action needed to bring - // it back — the site's configured phase count does that conversion. - // - // This guard uses relief in BOTH directions. `perPhaseOverageW` is - // direction-agnostic — phase amps from the meter are absolute - // magnitudes. Attribute to whichever side the AGGREGATE METER is - // currently flowing (currentGrid), not to the post-target - // `predicted`. Per-phase amps and currentGrid are read from the - // same DerMeter sample, so they're internally consistent; - // `predicted` mixes in `sumTarget` (a hypothetical future state) - // and can swing across 0 on a large planner request, attributing - // a current-export overage to the import path (or vice versa) and - // pushing the grid further into the violating direction. - perPhase := perPhaseReliefW(store, state) - // Aggregate budget honours the safety margin too — keep dispatch - // commands strictly inside the breaker envelope so the inverter's - // own per-phase limiter doesn't fire first and cause a flap. - // - // Import ceiling is the tighter of (fuse − safety margin) and the - // clock hour's import limit; export ceiling is the tighter of (fuse − - // safety margin) and the operator's max_export_w protection limit — - // set when an inverter trips on sustained export below the breaker - // (recurring Ferroamp 0x8030 fault). Both share one enforcement surface. - effImportW := state.effectiveImportCeilingW(fuseMaxW) - effExportW := state.effectiveExportCeilingW(fuseMaxW) - importOverage := predicted - effImportW - exportOverage := -effExportW - predicted - if perPhase > 0 { - if currentGrid >= 0 { - if perPhase > importOverage { - importOverage = perPhase - } - } else { - if perPhase > exportOverage { - exportOverage = perPhase - } - } - } - - out := make([]DispatchTarget, len(targets)) - copy(out, targets) - - // Hold-mode hysteresis: a recent clamp latched a max-magnitude per - // direction. Re-apply it now even if the live overage is zero so - // the planner can't ramp back through the boundary on the next - // tick. Window is refreshed every time the clamp fires, so the - // hold persists as long as the planner keeps trying to push past. - now := state.now() - if state.FuseHoldUntil.After(now) { - if state.FuseHoldMaxDischargeW > 0 { - var totalDischarge float64 - for _, t := range out { - if t.TargetW < 0 { - totalDischarge += -t.TargetW - } - } - if totalDischarge > state.FuseHoldMaxDischargeW { - scale := state.FuseHoldMaxDischargeW / totalDischarge - for i := range out { - if out[i].TargetW < 0 { - out[i].TargetW *= scale - out[i].Clamped = true - } - } - } - } - if state.FuseHoldMaxChargeW > 0 { - var totalCharge float64 - for _, t := range out { - if t.TargetW > 0 { - totalCharge += t.TargetW - } - } - if totalCharge > state.FuseHoldMaxChargeW { - scale := state.FuseHoldMaxChargeW / totalCharge - for i := range out { - if out[i].TargetW > 0 { - out[i].TargetW *= scale - out[i].Clamped = true - } - } - } - } - } else if !state.FuseHoldUntil.IsZero() { - // Hold window expired — reset the latch so a future planner - // re-ramp doesn't get permanently capped by stale state. - state.FuseHoldMaxDischargeW = 0 - state.FuseHoldMaxChargeW = 0 - state.FuseHoldUntil = time.Time{} - } - - if importOverage <= 0 && exportOverage <= 0 { - return out - } - - // Headroom buffer: shrink targets by `overage + half the configured - // safety margin` so post-clamp the grid sits *below* the threshold - // instead of riding right at it. Without this, the next dispatch - // tick lets the planner re-ramp into the threshold and the system - // oscillates at the boundary — the operator's "safety margin" - // becomes the active steady-state instead of a buffer below it. - // - // Half-margin chosen so the post-clamp phase amps still cluster - // near the threshold (operator wants the fuse used efficiently) - // but with enough buffer to absorb load fluctuations and per-phase - // imbalance on the next tick. Operators wanting tighter or looser - // buffer just adjust safety_margin_a. - buffer := state.fuseSafetyMarginW() * 0.5 - - switch { - case importOverage > 0: - // Too much import (aggregate or per-phase) → shrink charging. - var totalCharge float64 + out := append([]DispatchTarget(nil), targets...) + scale := func(overW float64, charge bool) bool { + var total float64 for _, t := range out { - if t.TargetW > 0 { - totalCharge += t.TargetW + if (t.TargetW > 0) == charge && t.TargetW != 0 { + total += math.Abs(t.TargetW) } } - if totalCharge <= 0 { - // No charge commands to pull back — the overage is load-driven - // and nothing this layer can do. Leave targets untouched; - // the reactive fuse-saver below (forceFuseDischarge) can - // still flip idle batteries to discharge. - return out - } - newTotal := totalCharge - importOverage - buffer - if newTotal < 0 { - newTotal = 0 + if total <= 0 { + return false // Load or PV alone is over: nothing here can help. } - scale := newTotal / totalCharge + k := math.Max(0, total-overW) / total for i := range out { - if out[i].TargetW > 0 { - out[i].TargetW *= scale + if (out[i].TargetW > 0) == charge && out[i].TargetW != 0 { + out[i].TargetW *= k out[i].Clamped = true } } - // Latch the new charge cap for the hold window. Each fire - // extends the window so the planner can't ramp through. - state.FuseHoldMaxChargeW = newTotal - state.FuseHoldUntil = now.Add(30 * time.Second) - case exportOverage > 0: - var totalDischarge float64 - for _, t := range out { - if t.TargetW < 0 { - totalDischarge += -t.TargetW - } - } - if totalDischarge <= 0 { - // Nothing to scale — PV alone is pushing past the fuse. - // Fuse-guard can't curtail PV; pv_limit_w from the plan - // is the lever in that scenario (annotateCurtailment). - return out - } - newTotal := totalDischarge - exportOverage - buffer - if newTotal < 0 { - newTotal = 0 - } - scale := newTotal / totalDischarge - for i := range out { - if out[i].TargetW < 0 { - out[i].TargetW *= scale - out[i].Clamped = true - } + return true + } + if hourly && predicted > importLimitW { + if scale(predicted-importLimitW, true) { + state.FuseSaturated, state.FuseSaturatedByImport = true, true } - state.FuseHoldMaxDischargeW = newTotal - state.FuseHoldUntil = now.Add(30 * time.Second) + } else if state.MaxExportW > 0 && predicted < -state.MaxExportW { + scale(-state.MaxExportW-predicted, false) } return out } @@ -3775,13 +3533,6 @@ func (s *State) fuseSafetyMarginW() float64 { return s.SiteFuseSafetyA * s.SiteFuseVoltage * float64(s.SiteFusePhases) } -// importLimitSets reports whether the clock hour's import limit is the -// ceiling ceilingW, rather than the fuse. -func (s *State) importLimitSets(ceilingW float64) bool { - limitW, _, ok := s.importHourLimitsW() - return ok && limitW <= ceilingW -} - // effectiveImportCeilingW returns the binding ceiling for grid import in // watts: the fuse limit minus its safety margin, further capped by the // clock hour's import limit when the operator has opted into a tariff @@ -3821,108 +3572,6 @@ func (s *State) effectiveExportCeilingW(fuseMaxW float64) float64 { return eff } -// perPhaseOverageW returns the wattage by which the worst single phase -// exceeds the per-phase fuse amperage (less the safety margin). 0 when -// within limits, when per-phase data isn't available, or when the -// per-phase clamp is disabled (state.SiteFuseAmps == 0). The meter -// driver must emit l1_a / l2_a / l3_a in DerReading.Data — Pixii, -// Ferroamp, and Sungrow all do this today. -// -// Only the configured phases are read, in L1/L2/L3 order, matching -// sitePhaseCurrentsAt in cmd/ftw: a 1Φ site has no L2, so a stray -// l2_a in the meter payload is not a breaker this site owns. -// -// Same law as fuseSafetyMarginW: amps become watts only through the -// configured per-phase voltage. No hardcoded 230 V here — a site whose -// fuse description is incomplete gets no per-phase clamp rather than an -// invented one. Config fills both fields (fuse.voltage defaults to 230, -// fuse.phases to 3, and validation rejects <= 0), so this only affects -// harnesses that wire SiteFuseAmps alone. -// -// Direction-agnostic: phase amps from the meter are absolute magnitudes, -// so this function reports overage regardless of whether the breaker is -// being approached on the import or export side. The caller attributes -// the overage to whichever direction the aggregate grid is flowing. -func perPhaseOverageW(store *telemetry.Store, state *State) float64 { - if state == nil || state.SiteFuseAmps <= 0 || state.SiteMeterDriver == "" { - return 0 - } - r := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - return perPhaseOverageReadingW(r, state) -} - -// perPhaseOverageReadingW is the snapshot form used when one control decision -// must keep the aggregate reading and its phase data from the same meter sample. -func perPhaseOverageReadingW(r *telemetry.DerReading, state *State) float64 { - if state == nil || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases <= 0 { - return 0 - } - if r == nil || len(r.Data) == 0 { - return 0 - } - var d struct { - L1A *float64 `json:"l1_a"` - L2A *float64 `json:"l2_a"` - L3A *float64 `json:"l3_a"` - } - if err := json.Unmarshal(r.Data, &d); err != nil { - return 0 - } - // Use absolute magnitude — drivers like Pixii decode per-phase amps - // as signed i16, so export shows up as negative numbers and a naive - // `*p > maxA` walk starting from 0 leaves maxA = 0 (no fire) even - // when a phase is at -17 A. The fuse trips on current magnitude - // regardless of direction, and the caller attributes the overage - // to the active aggregate-grid direction. - phaseAmps := [...]*float64{d.L1A, d.L2A, d.L3A} - phases := state.SiteFusePhases - if phases > len(phaseAmps) { - phases = len(phaseAmps) - } - maxA := 0.0 - for i := 0; i < phases; i++ { - p := phaseAmps[i] - if p == nil { - continue - } - a := math.Abs(*p) - if a > maxA { - maxA = a - } - } - threshold := state.SiteFuseAmps - state.SiteFuseSafetyA - if threshold < 0 { - threshold = 0 - } - if maxA <= threshold { - return 0 - } - return (maxA - threshold) * state.SiteFuseVoltage -} - -// perPhaseReliefW converts a worst-phase overage into the AGGREGATE -// battery action needed to clear it. One conversion, one law: a battery -// spreads its action evenly across the site's phases, so each watt of -// aggregate action relieves 1/SiteFusePhases watts on the worst phase, -// and an overage of N watts needs N × SiteFusePhases watts of action. -// -// The phase count is the site's, from config — not the constant 3 the -// two call sites used to carry separately. On a 1Φ site the aggregate -// meter and the single phase are the same wire, so demanding 3 × N -// there overshoots by 2 × N and can push the grid through zero into a -// violation on the opposite side. -// -// Conservative for a 1Φ battery on a 3Φ site (Pixii Home etc.): it -// over-corrects on the phases it does not sit on — less import/export -// there, still safe. -func perPhaseReliefW(store *telemetry.Store, state *State) float64 { - overage := perPhaseOverageW(store, state) - if overage <= 0 { - return 0 - } - return overage * float64(state.SiteFusePhases) -} - // applyPlanSignFloor enforces "executed battery total must agree in sign // with the plan's intent for this slot" — the safety rail described at // the call-site comment. Only active in planner_cheap / planner_arbitrage. @@ -4085,13 +3734,15 @@ func holdFleetAtZero(store *telemetry.Store, capacities map[string]float64) []Di } // fuseTargetBounds returns the safe command interval for one battery reading. -// A battery that is empty or reports a blocked direction may still be moving -// in that direction, but core must not send a command that asks it to continue. +// A battery at its floor (lim.SoCMin, else 5 %) or reporting a blocked +// direction may still be moving in that direction, but core must not send a +// command that asks it to continue. Fuse relief never discharges below the +// floor: there the lever is the EV. func fuseTargetBounds(r *telemetry.DerReading, lim PowerLimits) (lower, upper float64) { lower = -lim.dischargeCap() upper = lim.chargeCap() dischargeBlocked, chargeBlocked := batteryDirectionBlocks(r.Data) - if r.SoC == nil || *r.SoC < 0.05 || dischargeBlocked { + if r.SoC == nil || *r.SoC <= lim.socFloor() || dischargeBlocked { lower = 0 } if chargeBlocked { @@ -4408,12 +4059,12 @@ func fuseSaverFromLivePower( // site is safe. Preserve the deadband in that case: this early-return path // exists to answer a fuse condition, not to take control of an externally // moved battery merely because its reading sits outside a command cap. - requiredRelief := fuseReliefFromSnapshot(rawLive, batteryReadings, meter, state, fuseMaxW, false) + requiredRelief := fuseReliefFromSnapshot(rawLive, batteryReadings, meter, state) if requiredRelief <= 0 { return nil } _, _, predictedRaw := predictedGridFromSnapshot(rawLive, batteryReadings, meter) - maxRelief := predictedRaw + state.effectiveExportCeilingW(fuseMaxW) + maxRelief := state.batteryExportRoomW(meter, predictedRaw, 0) safeBaseline = fuseSafeBaselineForRelief( rawLive, safeBaseline, @@ -4426,7 +4077,7 @@ func fuseSaverFromLivePower( if len(safeBaseline) == 0 { return nil } - out := forceFuseDischargeWithSnapshot(safeBaseline, batteryReadings, meter, state, driverCapacities, fuseMaxW, true) + out := forceFuseDischargeWithSnapshot(safeBaseline, batteryReadings, meter, state, driverCapacities, fuseMaxW) rawByDriver := make(map[string]DispatchTarget, len(rawLive)) for _, t := range rawLive { rawByDriver[t.Driver] = t @@ -4443,55 +4094,56 @@ func fuseSaverFromLivePower( return nil } -// fuseReliefFromSnapshot returns the import-side relief required for one -// target set without reading a second telemetry version during the decision. +// fuseReliefFromSnapshot returns the import the batteries must give up for +// one target set, from one meter reading and one battery snapshot. Each +// limit nets the EV cuts we sent that the reading may not show, so the cars +// and the batteries never both take the same overage (#1674). +// +// - The fuse: the most a phase's import is over its limit once its heat +// budget is spent (fusePhases), spread over the phases as a battery +// spreads its power, plus what the targets add over the batteries' live +// power. Our own rises, a discharge we end among them, are +// fitBatteryRisesToFuse's. +// - The clock hour's import limit: the predicted import above it. func fuseReliefFromSnapshot( targets []DispatchTarget, batteryReadings map[string]*telemetry.DerReading, meter *telemetry.DerReading, state *State, - fuseMaxW float64, - accountPhaseTargetDelta bool, ) float64 { - if fuseMaxW <= 0 || len(targets) == 0 || state == nil { + if len(targets) == 0 || state == nil || meter == nil { return 0 } - currentBat, sumTarget, predicted := predictedGridFromSnapshot(targets, batteryReadings, meter) - currentGrid := 0.0 - if meter != nil { - currentGrid = meter.SmoothedW - } - - // The fuse acts on every reading. The hour's import limit is a budget: - // the part of its overage that an EV cut we sent will remove waits for - // that cut while the meter may not show it yet. Relief for the same watts - // would discharge the battery into export once the cut lands (#1674). - // Per-phase relief remains fuse-only and may act only while the matching - // aggregate meter sample is on the import side. - fuseW := math.Max(0, fuseMaxW-state.fuseSafetyMarginW()) - overage := predicted - fuseW - if ceilingW := state.effectiveImportCeilingW(fuseMaxW); ceilingW < fuseW { - overage = math.Max(overage, predicted-ceilingW-state.pendingEVCutW()) - } - if currentGrid >= 0 { - relief := perPhaseOverageReadingW(meter, state) * float64(state.SiteFusePhases) - if relief > 0 && accountPhaseTargetDelta { - // The early fuse baseline can only hold or reduce each included - // target. Count that import reduction against the raw phase relief so - // a command-cap clamp is not followed by the same correction twice. - relief += sumTarget - currentBat - if relief < 0 { - relief = 0 - } - } - if relief > overage { - overage = relief + overage := 0.0 + if f, ok := state.fusePhasesAt(meter); ok { + _, evCutA := state.evCutsSent(state.evLimit.fuseOverSince, meter.UpdatedAt) + if overA := f.importOverA(evCutA); overA > 0 { + overage = f.watts(overA) + sumTarget - currentBat } } + if limitW, _, ok := state.importHourLimitsW(); ok { + evCutW, _ := state.evCutsSent(state.evLimit.overSince, meter.UpdatedAt) + overage = math.Max(overage, predicted-limitW-evCutW) + } return overage } +// batteryExportRoomW is the most import the batteries may give up from a +// prediction before export passes a limit: the phases' export room at the +// reading (fusePhases), plus the import the targets add over live power, +// and max_export_w on the total. +Inf when neither is set. +func (s *State) batteryExportRoomW(meter *telemetry.DerReading, predicted, addedW float64) float64 { + room := math.Inf(1) + if f, ok := s.fusePhasesAt(meter); ok { + room = f.watts(f.exportRoomA()) + addedW + } + if s.MaxExportW > 0 { + room = math.Min(room, predicted+s.MaxExportW) + } + return room +} + // predictedGridFromSnapshot holds non-targeted site power constant and replaces // only the captured batteries with their proposed targets. func predictedGridFromSnapshot( @@ -4517,9 +4169,9 @@ func predictedGridFromSnapshot( return currentBat, sumTarget, currentGrid - currentBat + sumTarget } -// forceFuseDischarge adds discharge when predicted import still exceeds -// the fuse after applyFuseGuard. That guard can only shrink a charge. -// It cannot help an idle battery under a surprise load. PR #206 pinned +// forceFuseDischarge relieves an import overage (fuseReliefFromSnapshot) +// by shrinking charge, then adding discharge down to each battery's +// floor. The rise cap cannot help an idle battery under a surprise load. PR #206 pinned // an EV at 5.5 kW on an idle slot and the meter went over the fuse. // A pack that cannot discharge may cancel charge down to 0 and no further. // Spikes inside one tick remain the breaker's job. @@ -4541,7 +4193,7 @@ func forceFuseDischarge( readings[target.Driver] = store.Get(target.Driver, telemetry.DerBattery) } meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - return forceFuseDischargeWithSnapshot(targets, readings, meter, state, driverCapacities, fuseMaxW, false) + return forceFuseDischargeWithSnapshot(targets, readings, meter, state, driverCapacities, fuseMaxW) } // forceFuseDischargeWithSnapshot keeps all math for one decision on the same @@ -4554,17 +4206,16 @@ func forceFuseDischargeWithSnapshot( state *State, driverCapacities map[string]float64, fuseMaxW float64, - accountPhaseTargetDelta bool, ) []DispatchTarget { if fuseMaxW <= 0 || len(targets) == 0 || state == nil { return targets } - overage := fuseReliefFromSnapshot(targets, batteryReadings, meter, state, fuseMaxW, accountPhaseTargetDelta) + overage := fuseReliefFromSnapshot(targets, batteryReadings, meter, state) if overage <= 0 { return targets } - _, _, predicted := predictedGridFromSnapshot(targets, batteryReadings, meter) - exportRoom := predicted + state.effectiveExportCeilingW(fuseMaxW) + currentBat, sumTarget, predicted := predictedGridFromSnapshot(targets, batteryReadings, meter) + exportRoom := state.batteryExportRoomW(meter, predicted, sumTarget-currentBat) if exportRoom <= 0 { return targets } diff --git a/go/internal/control/ev_battery_headroom.go b/go/internal/control/ev_battery_headroom.go deleted file mode 100644 index 12a867c61..000000000 --- a/go/internal/control/ev_battery_headroom.go +++ /dev/null @@ -1,144 +0,0 @@ -package control - -import ( - "math" - - "github.com/srcfl/ftw/go/internal/telemetry" -) - -// clampEVBatteryHeadroom keeps optional battery charge out of room an EV -// still needs. It reads only the site meter and our own commands; no charger -// or battery reading takes part. The EV still needs its demand less the -// lowest offer of the last evSettle, which the meter surely shows. -func clampEVBatteryHeadroom(targets []DispatchTarget, store *telemetry.Store, state *State, fuseMaxW float64) []DispatchTarget { - if store == nil || state == nil || len(targets) == 0 || fuseMaxW <= 0 { - return targets - } - now := state.now() - // This tick's new offer is not in the meter yet; the lowest logged one is. - offeredW, _, _, ok := state.evOffers.span(now) - if !ok { - offeredW = 0 - } - var demandW float64 - phases := 3.0 // No phase placement promise for 1p or auto chargers. - for _, demand := range state.evFuseDemands { - demandW += demand.powerW - if demand.phaseMode != "3p" { - phases = 1 - } - } - if len(state.evFuseDemands) == 0 { - return targets - } - needW := math.Max(0, demandW-offeredW) - meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - if meter == nil || !finiteFuseValue(meter.RawW) || !finiteFuseValue(fuseMaxW) { - return floorPositiveEVBatteryTargets(targets) - } - latest := make(map[string]float64, len(targets)) - targeted := make(map[string]bool, len(targets)) - for _, target := range targets { - if targeted[target.Driver] { - return floorPositiveEVBatteryTargets(targets) - } - targeted[target.Driver] = true - if _, _, last, sent := state.batteryCommands[target.Driver].span(now); sent { - latest[target.Driver] = last - } else { - // Without a recent order it may be discharging and stop. - latest[target.Driver] = -state.DriverLimits[target.Driver].dischargeCap() - } - } - // 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. - 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) - } - } - out := capBatteryCharge(targets, latest, state.pendingBatteryCommandsW(now), spare) - // Charge needed to bring measured export inside its hard ceiling is - // not optional. Preserve that part of the already validated offer; - // this stage never invents or increases an original battery command. - var offeredCharge, finalCharge float64 - for i := range targets { - 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))) - if exportFloor > finalCharge && offeredCharge > finalCharge { - scale := (exportFloor - finalCharge) / (offeredCharge - finalCharge) - for i := range out { - if targets[i].TargetW > out[i].TargetW && targets[i].TargetW > 0 { - out[i].TargetW += (targets[i].TargetW - out[i].TargetW) * scale - } - } - } - return out -} - -// capBatteryCharge keeps each battery's latest order: holding it is never an -// increase. A shortfall of measured room takes positive charge back; new -// charge needs room left after our increases the meter may not show yet. A -// reduction this tick funds nothing, as the meter does not show it, and only -// positive targets change. -func capBatteryCharge(targets []DispatchTarget, latest map[string]float64, pendingW, spare float64) []DispatchTarget { - if !finiteFuseValue(spare) || !finiteFuseValue(pendingW) { - return floorPositiveEVBatteryTargets(targets) - } - out := append([]DispatchTarget(nil), targets...) - holds := make([]float64, len(out)) - var holdW, increaseW float64 - for i, target := range out { - last := latest[target.Driver] - spare -= math.Max(0, math.Min(0, target.TargetW)-math.Min(0, last)) // Lost discharge. - if target.TargetW > 0 { - holds[i] = math.Min(target.TargetW, math.Max(0, last)) - holdW += holds[i] - increaseW += target.TargetW - holds[i] - } - } - holdScale, increaseScale := 1.0, 0.0 - if spare < 0 && holdW > 0 { - holdScale = math.Max(0, 1+spare/holdW) - } - if room := spare - pendingW; room > 0 && increaseW > 0 { - increaseScale = math.Min(1, room/increaseW) - } - for i := range out { - if out[i].TargetW <= 0 { - continue - } - capped := holds[i]*holdScale + (out[i].TargetW-holds[i])*increaseScale - if capped < out[i].TargetW { - out[i].TargetW, out[i].Clamped = capped, true - } - } - return out -} - -func evBatteryPhaseSpareW(meter *telemetry.DerReading, state *State, evIncreaseA float64) (float64, bool) { - worst, present, valid := worstPhaseA(meter, state) - if !present { - return 0, false - } - if !valid { - return math.Inf(-1), true - } - margin := math.Max(1, state.SiteFuseSafetyA) // Same working margin as the EV phase guard. - return (state.SiteFuseAmps - margin - worst - evIncreaseA) * state.SiteFuseVoltage * float64(state.SiteFusePhases), true -} - -func floorPositiveEVBatteryTargets(targets []DispatchTarget) []DispatchTarget { - out := append([]DispatchTarget(nil), targets...) - for i := range out { - if out[i].TargetW > 0 { - out[i].TargetW, out[i].Clamped = 0, true - } - } - return out -} diff --git a/go/internal/control/ev_battery_sequence_test.go b/go/internal/control/ev_battery_sequence_test.go index 8100ee000..0821b4c93 100644 --- a/go/internal/control/ev_battery_sequence_test.go +++ b/go/internal/control/ev_battery_sequence_test.go @@ -120,7 +120,7 @@ func TestEVBatteryCommandOrdersStayWithinMeasuredHeadroom(t *testing.T) { sent(state, "ev", ev) sent(state, "first", first) sent(state, "second", second) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: rng.Float64() * 10000}, {Driver: "second", TargetW: rng.Float64() * 10000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: rng.Float64() * 10000}, {Driver: "second", TargetW: rng.Float64() * 10000}}, store, state) for _, order := range [][2]int{{0, 1}, {1, 0}} { batteries := [2]float64{first, second} for _, i := range order { @@ -152,7 +152,7 @@ func TestEVBatteryPendingAndNewOrdersShareOnePhysicalBudget(t *testing.T) { points := []loadpoint.State{{ID: "car", DriverName: "car", PluggedIn: true, CommandedKnown: true, CommandedW: ev}} capW, _ := EVFuseBudget(store, state, points, []loadpoint.Config{{ID: "car", DriverName: "car", PhaseMode: "3p"}}, 17250) actualEV := math.Max(ev, math.Min(requestedEV, capW)) - out := clampEVBatteryHeadroom([]DispatchTarget{{Driver: "first", TargetW: rng.Float64() * 10000}, {Driver: "second", TargetW: rng.Float64() * 10000}}, store, state, 17250) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "first", TargetW: rng.Float64() * 10000}, {Driver: "second", TargetW: rng.Float64() * 10000}}, store, state) worst := house + actualEV + math.Max(pendingFirst, out[0].TargetW) + math.Max(pendingSecond, out[1].TargetW) if worst > 16560.001 { t.Fatalf("sample %d: old/new battery orders and EV reach %.1f W; pending %.1f/%.1f EV %.1f targets %+v", sample, worst, pendingFirst, pendingSecond, actualEV, out) @@ -486,11 +486,11 @@ func TestCeilingOverageWaitsOnceWhenTheCarDrawsLessThanItsOffer(t *testing.T) { } } -// The fuse never waits for a car. A house step past the heat budget's -// instant limit (31 A on the 25 A fuse) cuts the car at once and gets -// battery relief on the first reading, which it keeps until the car's cut -// shows on the meter. Then the battery lets go, and nothing exports. -func TestFuseOverageGetsBatteryReliefUntilEVCutShows(t *testing.T) { +// A house step past the heat budget's instant limit (31 A on the 25 A fuse) +// cuts the car at once. The battery leaves the watts that cut removes, so +// the car and the battery never take one overage twice and nothing exports +// when the cut lands; the meter is back inside the fuse with the car's cut. +func TestFuseOverageLeftToTheCarsCut(t *testing.T) { s := newIdleBatteryEVSite(t, 3000, 0) s.run(5 * time.Minute) if s.offer() != 10350 || s.batteryW != 0 { @@ -500,56 +500,65 @@ func TestFuseOverageGetsBatteryReliefUntilEVCutShows(t *testing.T) { s.houseW += 8000 // 21,350 W, 31 A on each phase. s.run(15 * time.Second) cutShows := stepAt.Add(time.Second + s.chargerDelay) - t.Logf("first battery target %.0f W; offer %.0f W", since(s.targetW, stepAt.Add(time.Second))[0].w, s.offer()) - for _, b := range since(s.targetW, stepAt.Add(time.Second)) { - if b.at.Before(cutShows) && b.w >= 0 { - t.Fatalf("%v: no battery relief before the car's cut shows (target %.0f W)", b.at.Sub(stepAt), b.w) + for _, b := range since(s.targetW, stepAt) { + if b.w < 0 { + t.Fatalf("%v: the battery discharged %.0f W for watts the car's cut removes", b.at.Sub(stepAt), -b.w) } } - for _, m := range since(s.meterW, stepAt.Add(2*time.Second)) { - if m.w > 17250 || m.w < 0 { - t.Fatalf("%v: meter %.0f W, want within the 25 A breaker and no export", m.at.Sub(stepAt), m.w) + for _, m := range since(s.meterW, cutShows.Add(time.Second)) { + if m.w > 16560 || m.w < 0 { + t.Fatalf("%v: meter %.0f W, want inside the fuse's 24 A and no export", m.at.Sub(stepAt), m.w) } } - if s.offer() >= 10350 || s.batteryW != 0 { - t.Fatalf("after the cut: offer %.0f W battery %.0f W, want a cut car and an idle battery", s.offer(), s.batteryW) + if s.offer() >= 10350 { + t.Fatalf("the car was not cut: offer %.0f W", s.offer()) } } -// Only the hour's import limit leaves watts to an EV cut, only those the cut -// removes, and only during the first evSettle of the overage it was sent -// for. The fuse acts on every reading, on each phase too. -func TestFuseReliefLeavesOnlyCeilingWattsToCommandedEVCut(t *testing.T) { +// The battery's relief nets the EV cuts we sent during the run over a limit, +// for the hour's import limit and for the fuse alike, so the car and the +// battery never both take one overage (#1674). A run older than evSettle, +// or no run, nets nothing. +func TestFuseReliefNetsEVCutsSent(t *testing.T) { now := time.Date(2026, 10, 17, 16, 30, 0, 0, time.Local) - hotPhase := json.RawMessage(`{"l1_a":25,"l2_a":18,"l3_a":18}`) + hotPhase := map[string]float64{"l1_a": 27, "l2_a": 18, "l3_a": 18} cases := []struct { name string + ceilingW float64 gridW, commandedW float64 - overFor time.Duration // 0: the meter shows no run over the limit - data json.RawMessage + runFor time.Duration // the run over the limit; 0: none + phaseA map[string]float64 wantW float64 }{ - {"ceiling overage the stop removes", 13660, 0, 5 * time.Second, nil, 0}, - {"fuse overage", 18000, 0, 5 * time.Second, nil, 18000 - 16560}, - {"phase over the fuse", 13660, 0, 5 * time.Second, hotPhase, (25 - 24) * 230 * 3}, - {"ceiling overage beyond the cut", 13660, 4140, 5 * time.Second, nil, 13660 - 7500 - (9660 - 4140)}, - {"no cut", 13660, 9660, 5 * time.Second, nil, 13660 - 7500}, - {"overage older than evSettle", 13660, 0, evSettle, nil, 13660 - 7500}, - {"cut sent before this overage", 13660, 0, 0, nil, 13660 - 7500}, + {"ceiling overage the stop removes", 7500, 13660, 0, 5 * time.Second, nil, 0}, + {"ceiling overage beyond the cut", 7500, 13660, 4140, 5 * time.Second, nil, 13660 - 7500 - (9660 - 4140)}, + {"no cut", 7500, 13660, 9660, 5 * time.Second, nil, 13660 - 7500}, + {"overage older than evSettle", 7500, 13660, 0, evSettle, nil, 13660 - 7500}, + {"cut sent before this overage", 7500, 13660, 0, 0, nil, 13660 - 7500}, + {"phase over the fuse, no cut", 0, 13660, 9660, 5 * time.Second, hotPhase, (27 - 24) * 230 * 3}, + {"phase over the fuse, the stop covers it", 0, 13660, 0, 5 * time.Second, hotPhase, 0}, + {"phase over the fuse beyond the cut", 0, 13660, 8280, 5 * time.Second, hotPhase, (27 - 24 - 2) * 230 * 3}, } for _, tc := range cases { st := NewState(0, 0, "meter") st.clock = func() time.Time { return now } st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = 25, 230, 3, 1 - st.PeakImportCeilingW = 7500 - if tc.overFor > 0 { - st.evLimit.overSince, st.evLimit.overOffers = now.Add(-tc.overFor), map[string]float64{"easee": 9660} + st.PeakImportCeilingW = tc.ceilingW + log := commandLog{{at: now.Add(-time.Hour), w: 9660, three: true}} + if tc.runFor > 0 { + log = append(log, sentCommand{at: now.Add(-tc.runFor), w: tc.commandedW, three: true}) + st.evLimit.overSince, st.evLimit.fuseOverSince = now.Add(-tc.runFor-time.Second), now.Add(-tc.runFor-time.Second) + } else { + log = append(log, sentCommand{at: now.Add(-time.Minute), w: tc.commandedW, three: true}) } + st.evLimit.offers = map[string]chargerOffers{"car": {driver: "easee", log: log, settle: evUnknownSettle}} st.SetEVCommanded([]loadpoint.State{{ID: "car", DriverName: "easee", PluggedIn: true, CommandedKnown: true, CommandedW: tc.commandedW}}) - targets := []DispatchTarget{{Driver: "pixii"}} - batteries := map[string]*telemetry.DerReading{"pixii": {}} - meter := &telemetry.DerReading{SmoothedW: tc.gridW, Data: tc.data} - got := fuseReliefFromSnapshot(targets, batteries, meter, st, 17250, false) + store := telemetry.NewStore() + data, _ := json.Marshal(tc.phaseA) + store.Update("meter", telemetry.DerMeter, tc.gridW, nil, data) + meter := store.Get("meter", telemetry.DerMeter) + meter.UpdatedAt = now.Add(-time.Second) + got := fuseReliefFromSnapshot([]DispatchTarget{{Driver: "pixii"}}, map[string]*telemetry.DerReading{"pixii": {}}, meter, st) if math.Abs(math.Max(0, got)-tc.wantW) > 1e-6 { t.Errorf("%s: relief %.0f W, want %.0f W", tc.name, got, tc.wantW) } @@ -580,6 +589,7 @@ func TestUnpluggedCarIsNoCutTheBatteryWaitsFor(t *testing.T) { data, _ := json.Marshal(map[string]any{"l1_a": a, "l2_a": a, "l3_a": a}) store.Update("meter", telemetry.DerMeter, gridW, nil, data) store.Get("meter", telemetry.DerMeter).UpdatedAt = now + store.Get("meter", telemetry.DerMeter).SmoothedW = gridW store.RecordDriverSuccess("meter") points := []loadpoint.State{ {ID: "a", DriverName: "easee-a", PluggedIn: true, CommandedKnown: true, CommandedW: 5520}, @@ -590,7 +600,7 @@ func TestUnpluggedCarIsNoCutTheBatteryWaitsFor(t *testing.T) { st.SetEVCommanded(points) targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {}} - return math.Max(0, fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250, false)) + return math.Max(0, fuseReliefFromSnapshot(targets, batteries, store.Get("meter", telemetry.DerMeter), st)) } // 400 W over the ceiling, less than a step: the guard waits. if got := tick(7900, true, 5520); math.Abs(got-400) > 1e-6 { @@ -598,7 +608,8 @@ func TestUnpluggedCarIsNoCutTheBatteryWaitsFor(t *testing.T) { } // Car b is unplugged and the house adds 6 kW: 880 W over, no cut sent. if got := tick(7900-5520+6000, false, 5520); math.Abs(got-880) > 1e-6 { - t.Fatalf("after car b left: relief %.0f W, want 880 W; pending cut %.0f W", got, st.pendingEVCutW()) + cutW, _ := st.evCutsSent(st.evLimit.overSince, now) + t.Fatalf("after car b left: relief %.0f W, want 880 W; cuts sent %.0f W", got, cutW) } // 2,000 W over, and car a is stopped: that cut may still wait. if got := tick(9500, false, 0); got != 0 { @@ -640,6 +651,7 @@ func TestCutOnStayingCarStillWaitsWhenAnotherLeaves(t *testing.T) { data, _ := json.Marshal(map[string]any{"l1_a": a, "l2_a": a, "l3_a": a}) store.Update("meter", telemetry.DerMeter, gridW, nil, data) store.Get("meter", telemetry.DerMeter).UpdatedAt = now + store.Get("meter", telemetry.DerMeter).SmoothedW = gridW store.RecordDriverSuccess("meter") aOfferW := 10350.0 // a's offer before this tick if tick > 0 { @@ -654,10 +666,11 @@ func TestCutOnStayingCarStillWaitsWhenAnotherLeaves(t *testing.T) { st.SetEVCommanded(points) targets := []DispatchTarget{{Driver: "pixii"}} batteries := map[string]*telemetry.DerReading{"pixii": {SmoothedW: batteryW}} - relief := fuseReliefFromSnapshot(targets, batteries, &telemetry.DerReading{SmoothedW: gridW}, st, 17250, false) + relief := fuseReliefFromSnapshot(targets, batteries, store.Get("meter", telemetry.DerMeter), st) batteryW = -math.Max(0, relief) // The battery follows within a tick. if batteryW < 0 && tick < 10 { - t.Fatalf("%ds: battery %.0f W for watts a's stop removes; pending cut %.0f W", tick, batteryW, st.pendingEVCutW()) + cutW, _ := st.evCutsSent(st.evLimit.overSince, now) + t.Fatalf("%ds: battery %.0f W for watts a's stop removes; cuts sent %.0f W", tick, batteryW, cutW) } } if lowestMeter < 0 { diff --git a/go/internal/control/ev_fuse_budget.go b/go/internal/control/ev_fuse_budget.go index 4c4eb54c9..ec669dff7 100644 --- a/go/internal/control/ev_fuse_budget.go +++ b/go/internal/control/ev_fuse_budget.go @@ -32,6 +32,10 @@ const evUnknownSettle = 3 * time.Minute // pool starts again. It stays above Easee's ~30 s contactor minimum. const evRestartDwell = 35 * time.Second +// evMinPhaseA is the least current a car charges at on each phase +// (IEC 61851). +const evMinPhaseA = 6.0 + // evPhaseMarginA is the working margin below the breaker on each phase. One // amp on the pool's phases is also the rise step and the gap between the cut // and rise thresholds. @@ -39,9 +43,10 @@ const evPhaseMarginA = 1.0 // sentCommand is one of our own commands, never a device reading. type sentCommand struct { - at time.Time - w float64 - a float64 // EV offers: current on each phase it may land on + at time.Time + w float64 + a float64 // EV offers: current on each phase it may land on + three bool // EV offers: on all three phases } // commandLog keeps a device's commands for evSettle, plus the one in force @@ -111,9 +116,47 @@ func (l commandLog) unshown(at time.Time, settle time.Duration) (w, a float64) { return math.Max(0, highW-lowW), math.Max(0, highA-lowA) } +// shownLow is the lowest offer a reading taken at `at` may show: the one in +// force settle before it, or one sent since. Before our first offer the +// charger counts as 0. +func (l commandLog) shownLow(at time.Time, settle time.Duration) float64 { + from := at.Add(-settle) + low, settled := math.Inf(1), 0.0 + for _, c := range l { + if !c.at.After(from) { + settled = c.w + continue + } + if !c.at.After(at) { + low = math.Min(low, c.w) + } + } + return math.Min(low, settled) +} + +// unshownCut is how much less a charger may draw than a reading taken at +// `at` shows once latestW lands: the highest offer in force since a run over +// a limit began, less latestW. three is false when any of those offers may +// sit on one phase. +func (l commandLog) unshownCut(since, at time.Time, latestW float64) (w float64, three bool) { + high, three := 0.0, true + for _, c := range l { + if c.at.After(at) { + break + } + if c.at.Before(since) { + high, three = c.w, c.three // In force when the run began. + continue + } + high, three = math.Max(high, c.w), three && c.three + } + return math.Max(0, high-latestW), three +} + // chargerOffers is one charger's offers, kept for the time its driver // declares its power takes to show. type chargerOffers struct { + driver string log commandLog settle time.Duration } @@ -155,14 +198,14 @@ func (s *State) recordEVOffers(store *telemetry.Store, points []loadpoint.State) continue } c := s.evLimit.offers[point.ID] - c.settle = evUnknownSettle + c.driver, c.settle = point.DriverName, evUnknownSettle if r := store.Get(point.DriverName, telemetry.DerEV); r != nil { if declared := telemetry.DeclaredPowerMaxAge(r.Data); declared > 0 { c.settle = max(evSettle, declared) } } w := math.Max(0, point.CommandedW) - sent := sentCommand{at: now, w: w, a: s.offerPhaseA(w, point.CommandedPhases)} + sent := sentCommand{at: now, w: w, a: s.offerPhaseA(w, point.CommandedPhases), three: point.CommandedPhases == 3 && s.SiteFusePhases == 3} if n := len(c.log); n == 0 || c.log[n-1].w != sent.w || c.log[n-1].a != sent.a { c.log = c.log.keep(sent, read.Add(-c.settle)) } @@ -185,10 +228,8 @@ 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 level -// (see fuseHeat.levelA), and every charger's offers that reading may not -// show. -// false when the site meter reports no phase currents. +// busiest phase: fusePhases.roomA at the last site reading, less every +// charger's offers that reading may not show. false before a reading. func EVFusePhaseRoomA(state *State) (float64, bool) { if state == nil || !state.evLimit.set || !state.evLimit.phaseRoomKnown { return 0, false @@ -208,9 +249,9 @@ type evMeterLimit struct { roomSince time.Time // first reading of the current run with room to start offers map[string]chargerOffers // each plugged charger's, by loadpoint phaseRoomA float64 // below the working limit on the busiest phase at readingAt - phaseRoomKnown bool // the meter reports phase currents + phaseRoomKnown bool // a fuse is configured and the reading has power overSince time.Time // first reading of a run over the hour's import limit - overOffers map[string]float64 // each charger's offer when that run began + fuseOverSince time.Time // first reading of a run with a phase over the fuse idleWh float64 // import since roomSince, for the ceiling's start test byImport bool // the hour's import limit, not the fuse, left less room at readingAt } @@ -218,21 +259,20 @@ type evMeterLimit struct { type evPool struct { offerW, wantW, minW, phases float64 offerKnown bool - offers map[string]float64 // per charger driver } // EVFuseBudget returns the managed EVs' common hard cap, which the loadpoint // controller divides between cars. Callers hold the control lock, and the -// shared freshness gate must already allow dispatch. A fuse overage in total -// import cuts at once; a phase overage cuts once that phase's heat budget is -// spent, or at once above fuseInstantFactor. Otherwise the cap holds; it -// rises one step on a fresh reading with room for two, at least evSettle -// after the last cut. Room counts every -// offer sent above what the reading may show as flowing until its charger -// can have applied it; cuts read the meter alone. EVFusePhaseRoomA gives -// the same room per phase to the split between cars. An idle pool starts only after the meter has kept showing -// room for its minimum plus a step. Each reading acts once, so a silent -// meter can never raise or restart a charge. +// shared freshness gate must already allow dispatch. The fuse is +// fusePhases: a phase overage cuts once that phase's heat budget is spent, +// or at once above fuseInstantFactor. Otherwise the cap holds; it rises one +// step on a fresh reading with room for two, at least evSettle after the +// last cut. Room counts every offer sent above what the reading may show as +// flowing until its charger can have applied it; cuts read the meter alone. +// EVFusePhaseRoomA gives the same room per phase to the split between cars. +// An idle pool starts once the meter has kept showing room for its minimum +// for evRestartDwell. Each reading acts once, so a silent meter can never +// raise or restart a charge. func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State, configs []loadpoint.Config, fuseMaxW float64) (float64, bool) { if store == nil || state == nil || !finiteFuseValue(fuseMaxW) || fuseMaxW <= 0 { return 0, true @@ -245,7 +285,6 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State if !finiteFuseValue(gridW) { return 0, true } - state.stepFuseHeat(meter) lim := &state.evLimit pool, plugged := evPoolFor(state, points, configs) if !plugged { @@ -265,7 +304,9 @@ func EVFuseBudget(store *telemetry.Store, state *State, points []loadpoint.State func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, pool evPool, fuseMaxW float64) { lim, now, at := &s.evLimit, s.now(), meter.UpdatedAt first, prevAt := !lim.set, lim.readingAt - gap := first || at.Sub(prevAt) > evSettle + // Only a reading after a stale meter starts afresh; a slow meter's + // cadence changes nothing else. + gap := first || at.Sub(prevAt) > telemetry.ControlPowerMaxAge(meter.Data) lim.readingAt = at prev := math.Inf(1) if !first { @@ -276,7 +317,7 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo if _, high, _, ok := s.evOffers.span(now); ok { peakW = math.Max(peakW, high) } - r := s.evMeterRoom(meter, gridW, pool.phases, fuseMaxW, s.pendingLoad(at)) + r := s.evMeterRoom(meter, gridW, pool.phases, s.pendingLoad(at)) _, fresh := qualifiedFuseObservation(meter, now) fresh = fresh && r.measured lim.phaseRoomA, lim.phaseRoomKnown = 0, r.phaseRoomKnown @@ -286,16 +327,22 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo step := evPhaseMarginA * s.SiteFuseVoltage * pool.phases // The label follows whichever limit leaves less room. lim.byImport = math.Min(r.limitW, r.riseW) < r.fuseW - // The fuse cuts on every reading, by its heat budget where the meter - // reports phase currents. The hour's budget absorbs a short overage of + // The fuse cuts on every reading, by each phase's heat budget. The + // hour's budget absorbs a short overage of // less than a step: it cuts once the overage has lasted evSettle, so one // reading over the ceiling changes nothing. cutW := r.fuseCutW + switch { + case cutW >= 0: + lim.fuseOverSince = time.Time{} + case lim.fuseOverSince.IsZero() || gap: + lim.fuseOverSince = at + } if r.limitW >= 0 { lim.overSince = time.Time{} } else { if lim.overSince.IsZero() || gap { - lim.overSince, lim.overOffers = at, pool.offers + lim.overSince = at } if !r.hourly || gap || -r.limitW >= step || at.Sub(lim.overSince) >= evSettle { cutW = math.Min(cutW, r.limitW) @@ -337,26 +384,27 @@ func (s *State) stepEVMeterLimit(meter *telemetry.DerReading, gridW float64, poo } // stepEVIdleStart starts an idle pool once the meter has kept showing room -// for evRestartDwell: room on every reading for its minimum plus a step -// below the fuse. Under a measured hour, the ceiling's part is judged on the -// dwell's mean import, the house's load rather than one reading of it, and -// the minimum needs no step of margin there: a short overage waits. +// for evRestartDwell: room on every reading for its minimum below the fuse, +// and for its minimum plus a step below an unmeasured hour's ceiling. It +// starts a step below the room where the minimum still fits. Under a +// measured hour, the ceiling's part is judged on the dwell's mean import, +// the house's load rather than one reading of it: a short overage waits. func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first, gap, fresh bool, at, prevAt time.Time, pending, step float64) { lim := &s.evLimit lim.capW = 0 - perReadingW := r.fuseW - if !r.hourly { - perReadingW = math.Min(perReadingW, r.riseW) - } else { + fuseRoom, ceilingRoom := 0.0, math.Inf(1) + if r.hourly { // The start test below holds an idle car to the ceiling itself, // whatever slack the hour has: name it by this reading. lim.byImport = r.startLevelW-gridW < r.fuseW } - room := 0.0 if fresh { - room = math.Max(0, perReadingW-pending) + fuseRoom = r.fuseW - pending + if !r.hourly { + ceilingRoom = r.riseW - pending - step + } } - if room < pool.minW+step { + if fuseRoom < pool.minW || ceilingRoom < pool.minW { lim.roomSince = time.Time{} return } @@ -373,7 +421,7 @@ func (s *State) stepEVIdleStart(r evMeterRoom, gridW float64, pool evPool, first if dwell < evRestartDwell { return } - lim.capW = room - step + lim.capW = math.Min(math.Max(pool.minW, fuseRoom-step), ceilingRoom) if !r.hourly { return } @@ -398,21 +446,21 @@ func EVLimitReason(state *State) string { // 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 highest phase level, and what we sent; rises and starts - fuseCutW float64 // what the fuse needs cut now, negative; see fuseHeat + fuseW float64 // fusePhases.roomA after what we sent; rises and starts + fuseCutW float64 // what the fuse needs cut now, negative; see fusePhases 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 + measured bool // false when the meter's phase currents cannot be read worstA float64 - phaseRoomA float64 // below the working limit on the highest phase level - phaseRoomKnown bool // the meter reports phase currents + phaseRoomA float64 // fusePhases.roomA + phaseRoomKnown bool } -func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW float64, sent pendingLoad) evMeterRoom { - inf, totalW := math.Inf(1), math.Max(0, fuseMaxW-s.fuseSafetyMarginW())-gridW - r := evMeterRoom{fuseW: totalW - sent.w, fuseCutW: totalW, limitW: inf, riseW: inf, startLevelW: inf} +func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases float64, sent pendingLoad) evMeterRoom { + inf := math.Inf(1) + r := evMeterRoom{limitW: inf, riseW: inf, startLevelW: inf} if limitW, riseW, ok := s.importHourLimitsW(); ok { r.limitW, r.riseW = limitW-gridW, riseW-gridW-sent.w // A car that could run only on one hour's slack would stop when @@ -420,35 +468,17 @@ func (s *State) evMeterRoom(meter *telemetry.DerReading, gridW, phases, fuseMaxW r.startLevelW = math.Min(riseW, s.PeakImportCeilingW) r.hourly = s.importHourMeasured() } - amps, present, valid := phaseCurrentsA(meter, s) - if present && !valid { - r.phaseRoomKnown = true // No room on phases it cannot read. - return r - } - r.measured = true - if present { - limitA := s.SiteFuseAmps - evPhaseMarginA - for _, a := range amps { - r.worstA = math.Max(r.worstA, a) - } - r.fuseW = math.Min(r.fuseW, (limitA-s.fuseHeat.levelA(amps, sent.phaseA))*s.SiteFuseVoltage*phases) - r.phaseRoomA, r.phaseRoomKnown = limitA-s.fuseHeat.levelA(amps, [3]float64{}), true - // The phases measure the fuse itself; total import stands in only without them. - r.fuseCutW = -s.fuseHeat.overA(amps, limitA, fuseInstantFactor*s.SiteFuseAmps) * s.SiteFuseVoltage * phases + f, ok := s.fusePhasesAt(meter) + if !ok { + return r // No room and no cut: the fuse is not described. } + r.measured, r.phaseRoomKnown, r.worstA = !f.blind, true, f.highestA() + r.fuseW = f.watts(f.roomA(sent.phaseA)) / float64(f.n) * phases + r.phaseRoomA = f.roomA([3]float64{}) + r.fuseCutW = -f.watts(f.importOverA([3]float64{})) / float64(f.n) * phases 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) - for _, a := range amps { - worstA = math.Max(worstA, a) - } - return worstA, present, valid -} - // pendingBatteryCommandsW is battery increase we ordered that the meter may // not show yet. Signed, so a stopped discharge counts too. func (s *State) pendingBatteryCommandsW(now time.Time) float64 { @@ -462,41 +492,42 @@ func (s *State) pendingBatteryCommandsW(now time.Time) float64 { } // SetEVCommanded stores this tick's final charger offers, after every -// charger clamp, in total and per driver. +// charger clamp, in total and per driver, and logs each plugged charger's +// offer at the time it was sent. func (s *State) SetEVCommanded(points []loadpoint.State) { s.EVCommandedW = loadpoint.CommandedEVPowerW(points) s.evCommanded = loadpoint.CommandedEVPowerByDriver(points) -} - -// pendingEVCutW is the EV reduction we sent during the current run over the -// hour's import limit: for each charger still plugged, its offer when the -// run began less its offer this tick. A car that leaves drops out, since no -// cut of ours removed it; a car that joins has no start offer. Our own -// commands only. A cut that has reached the meter ends the run, and no run -// waits longer than evSettle, so a car that draws less than its offer cannot -// hold an overage through a second cut. -func (s *State) pendingEVCutW() float64 { - lim := &s.evLimit - if lim.overSince.IsZero() || s.now().Sub(lim.overSince) >= evSettle { - return 0 - } - var cutW float64 - for driver, startW := range lim.overOffers { - if nowW, plugged := s.evCommanded[driver]; plugged { - cutW += math.Max(0, startW-math.Max(0, nowW)) + now := s.now() + for _, point := range points { + if !point.PluggedIn || !point.CommandedKnown || !finiteFuseValue(point.CommandedW) { + continue + } + c := s.evLimit.offers[point.ID] + c.driver = point.DriverName + if c.settle == 0 { + c.settle = evUnknownSettle + } + w := math.Max(0, point.CommandedW) + sent := sentCommand{at: now, w: w, a: s.offerPhaseA(w, point.CommandedPhases), three: point.CommandedPhases == 3 && s.SiteFusePhases == 3} + if n := len(c.log); n == 0 || c.log[n-1].w != sent.w || c.log[n-1].a != sent.a { + c.log = c.log.keep(sent, now.Add(-c.settle)) } + if s.evLimit.offers == nil { + s.evLimit.offers = map[string]chargerOffers{} + } + s.evLimit.offers[point.ID] = c } - return cutW } -// evPoolFor sums the plugged managed chargers. Before the first command after -// a restart their offer is unknown, and the pool counts as idle. +// evPoolFor sums the plugged managed chargers. A charger's offer is unknown +// before its first command after a restart or a plug-in; it adds nothing, +// and the pool counts as idle only while no offer is known. func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Config) (evPool, bool) { byID := make(map[string]loadpoint.Config, len(configs)) for _, cfg := range configs { byID[cfg.ID] = cfg } - pool := evPool{phases: 3, wantW: math.Inf(1), offerKnown: true} + pool := evPool{phases: 3, wantW: math.Inf(1)} plugged := false for _, point := range points { if !point.PluggedIn { @@ -509,18 +540,19 @@ func evPoolFor(state *State, points []loadpoint.State, configs []loadpoint.Confi if cfg.PhaseMode != "" && cfg.PhaseMode != "3p" { pool.phases = 1 } - if cfg.MinChargeW > 0 && (pool.minW == 0 || cfg.MinChargeW < pool.minW) { - pool.minW = cfg.MinChargeW + minW := cfg.MinChargeW + if cfg.PhaseMode == "3p" && state.SiteFuseVoltage > 0 { + minW = math.Max(minW, evMinPhaseA*state.SiteFuseVoltage*3) + } + if minW > 0 && (pool.minW == 0 || minW < pool.minW) { + pool.minW = minW } - pool.offerKnown = pool.offerKnown && point.CommandedKnown + pool.offerKnown = pool.offerKnown || point.CommandedKnown } if !plugged { return pool, false } - pool.offers = loadpoint.CommandedEVPowerByDriver(points) - if pool.offerKnown { - pool.offerW = loadpoint.CommandedEVPowerW(points) - } + pool.offerW = loadpoint.CommandedEVPowerW(points) if state.evFuseDemandsSet { pool.wantW = 0 for _, demand := range state.evFuseDemands { @@ -541,17 +573,21 @@ func qualifiedFuseObservation(r *telemetry.DerReading, now time.Time) (telemetry return p, ok && !p.Seen().After(now) && now.Sub(p.Seen()) <= telemetry.ControlWindowDuration } +// publishBatterylessEVFuseBudget marks a site without batteries saturated +// when the EVs want more than the site's room. func publishBatterylessEVFuseBudget(store *telemetry.Store, state *State, fuseMaxW float64) { meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) - state.FuseEVMaxW, state.FuseSaturated, state.FuseSaturatedByImport = 0, false, false + state.FuseSaturated, state.FuseSaturatedByImport = false, false evInMeterW := state.evInMeterW() demand := math.Max(evInMeterW, scheduledEVPowerW(state)) if meter == nil || demand <= 0 { return } - ceilingW := state.phaseImportCeilingW(fuseMaxW, meter) - cap := evHeadroomW(ceilingW, math.Max(meter.RawW, meter.SmoothedW), evInMeterW) - state.FuseEVMaxW = math.Min(demand, cap) - state.FuseSaturated = demand > cap - state.FuseSaturatedByImport = state.FuseSaturated && state.importLimitSets(ceilingW) + gridW := math.Max(meter.RawW, meter.SmoothedW) + ceilingW, byImport := state.siteImportCeilingW(meter, gridW) + if math.IsInf(ceilingW, 1) { + return + } + state.FuseSaturated = demand > evHeadroomW(ceilingW, gridW, evInMeterW) + state.FuseSaturatedByImport = state.FuseSaturated && byImport } diff --git a/go/internal/control/ev_fuse_budget_test.go b/go/internal/control/ev_fuse_budget_test.go index f073c3719..cbbd4079b 100644 --- a/go/internal/control/ev_fuse_budget_test.go +++ b/go/internal/control/ev_fuse_budget_test.go @@ -715,20 +715,22 @@ func TestEVHardLimitTrimsPhaseOverageFromOffer(t *testing.T) { } // An overage larger than the offer less one step stops the car; a smaller -// one never does. +// one never does. Without phase currents the total stands in, shared over +// the phases, under the one 1 A margin. func TestEVHardLimitStopsOnlyWhenOverageExceedsOfferLessMinimum(t *testing.T) { store, state, points, configs := evMeterBudgetFixture(t, 30000, nil, 10350, "3p") if got, _ := EVFuseBudget(store, state, points, configs, 17250); got != 0 { t.Fatalf("13 kW overage left %.1f W", got) } store, state, points, configs = evMeterBudgetFixture(t, 17250+500, nil, 10350, "3p") - if got, _ := EVFuseBudget(store, state, points, configs, 17250); math.Abs(got-9850) > .01 { - t.Fatalf("500 W overage gave %.1f W, want 9,850 W", got) + if got, _ := EVFuseBudget(store, state, points, configs, 17250); math.Abs(got-9160) > .01 { + t.Fatalf("500 W over the fuse gave %.1f W, want 10,350 - 500 - 690 = 9,160 W", got) } } // An idle car starts with a step of margin under the measured room, and a -// single-phase or auto car may land its whole offer on the worst phase. +// single-phase or auto car may land its whole offer on the worst phase. A +// three-phase car needs 6 A on each phase. func TestEVHardLimitIdleStartNeedsRoomOnDeclaredPhases(t *testing.T) { for _, tc := range []struct { name, phase string @@ -739,7 +741,7 @@ func TestEVHardLimitIdleStartNeedsRoomOnDeclaredPhases(t *testing.T) { {"legacy_default", "", 5, (24 - 5 - 1) * 690}, {"1p_room", "1p", 5, (24 - 5 - 1) * 230}, {"auto_room", "auto", 5, (24 - 5 - 1) * 230}, - {"3p_below_minimum", "3p", 21.5, 0}, // 1,725 W < 1,380 + 690 W + {"3p_below_minimum", "3p", 21.5, 0}, // 2.5 A < 6 A on each phase } { t.Run(tc.name, func(t *testing.T) { store, state, points, configs := evMeterBudgetFixture(t, tc.worstA*690, []float64{tc.worstA, tc.worstA, tc.worstA}, 0, tc.phase) @@ -780,3 +782,19 @@ func TestEVHardLimitIgnoresChargerReadings(t *testing.T) { t.Fatalf("a car reading earned %.1f W before our first command", got) } } + +// A second car plugging in has no offer of ours yet. It adds nothing to the +// pool, and the pool is not idle: the first car keeps its charge. +func TestEVHardLimitSecondCarDoesNotStopTheFirst(t *testing.T) { + store, state, points, configs := evMeterBudgetFixture(t, 6900, []float64{10, 10, 10}, 6210, "3p") + if got, _ := EVFuseBudget(store, state, points, configs, 17250); got < 6210 { + t.Fatalf("setup: cap %.0f W, want the first car's 6,210 W", got) + } + points = append(points, loadpoint.State{ID: "second", DriverName: "charger-2", PluggedIn: true}) + configs = append(configs, loadpoint.Config{ID: "second", DriverName: "charger-2", PhaseMode: "3p", MinChargeW: 4140, MaxChargeW: 11040}) + store.Update("meter", telemetry.DerMeter, 6900, nil, store.Get("meter", telemetry.DerMeter).Data) + store.Get("meter", telemetry.DerMeter).UpdatedAt = store.Get("meter", telemetry.DerMeter).UpdatedAt.Add(time.Second) + if got, _ := EVFuseBudget(store, state, points, configs, 17250); got < 6210 { + t.Fatalf("a second car plugging in cut the pool to %.0f W", got) + } +} diff --git a/go/internal/control/ev_plan_priority_test.go b/go/internal/control/ev_plan_priority_test.go index bbbd667fd..1e385ed60 100644 --- a/go/internal/control/ev_plan_priority_test.go +++ b/go/internal/control/ev_plan_priority_test.go @@ -24,6 +24,7 @@ func TestFuseLimitedManualDemandMakesBatteryYield(t *testing.T) { name string currentW, soc float64 }{{"battery", 0, .5}}) + describeFuse(st, 5000) targets := ComputeDispatch(store, st, caps(map[string]float64{"battery": 10000}), 5000) if len(targets) != 1 || targets[0].TargetW > 650.001 { t.Fatalf("battery used space requested by the car: %v", targets) @@ -81,18 +82,17 @@ func TestScheduledEVKeepsBudgetBeforeBatteryCharge(t *testing.T) { st := newStateWithEnergyDispatch(dir, "ferroamp") st.EVChargingW = draw st.BatteryCoversEV = true + describeFuse(st, 11000) targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11000) if len(targets) != 1 { t.Fatalf("targets %v", targets) } - if !st.FuseSaturated || math.Abs(st.FuseEVMaxW-8000) > 1 { - t.Fatalf("EV budget lost while drawing %.0f: cap %.0f saturated %v", draw, st.FuseEVMaxW, st.FuseSaturated) + if !st.FuseSaturated { + t.Fatalf("EV budget lost while drawing %.0f", draw) } - if targets[0].TargetW < 0 || targets[0].TargetW > 2800.1 { - t.Fatalf("optional battery charge exceeds remaining 2800 W: %v", targets) - } - if 200+st.FuseEVMaxW+targets[0].TargetW > 11000.1 { - t.Fatal("planned EV and battery exceed fuse") + // 11,000 W less the 1 A margin on three phases, the house and the car. + if roomW := 11000 - 690 - 200 - 8000.0; targets[0].TargetW < 0 || targets[0].TargetW > roomW+0.1 { + t.Fatalf("optional battery charge exceeds remaining %.0f W: %v", roomW, targets) } }) } @@ -125,14 +125,15 @@ func TestScheduledEVReservesOnPowerNotMean(t *testing.T) { name string currentW, soc float64 }{{"ferroamp", 0, .5}}) + describeFuse(st, 11000) targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11000) if len(targets) != 1 { t.Fatalf("targets %v", targets) } - if !st.FuseSaturated || math.Abs(st.FuseEVMaxW-10350) > 1 { - t.Fatalf("on-power not reserved: cap %.0f saturated %v", st.FuseEVMaxW, st.FuseSaturated) + if !st.FuseSaturated { + t.Fatal("on-power not reserved") } - if targets[0].TargetW < 0 || targets[0].TargetW > 450.1 { + if targets[0].TargetW < 0 || targets[0].TargetW > 0.1 { t.Fatalf("optional battery charge took EV on-power: %v", targets) } } @@ -178,9 +179,10 @@ func TestChargerOfferReservesFuseBeforeOptionalBatteryCharge(t *testing.T) { if got := scheduledEVPowerW(st); got != 11000 { t.Fatalf("ramping offer lost in %s: %v", mode, got) } + describeFuse(st, 5000) targets := ComputeDispatch(store, st, caps(map[string]float64{"battery": 10000}), 5000) - if len(targets) != 1 || targets[0].TargetW > 0.001 || (st.FuseSaturated && st.FuseEVMaxW < 10999) { - t.Fatalf("battery took the EV's offered room: targets=%v evcap=%v", targets, st.FuseEVMaxW) + if len(targets) != 1 || targets[0].TargetW > 0.001 { + t.Fatalf("battery took the EV's offered room: targets=%v", targets) } if mode.IsPlannerMode() && !st.PlanStale { t.Fatal("EV-only directive claimed a fresh battery plan") @@ -195,10 +197,11 @@ func TestBatterylessGoalIntentPublishesJointFuseBudget(t *testing.T) { LoadpointEnergyWh: map[string]float64{"car": 11000.0 / 60}, LoadpointMaxPowerW: map[string]float64{"car": 11000}}, "ferroamp") st.EVCommandedW = 11000 store := seedStore(4000, nil) + describeFuse(st, 10000) if targets := ComputeDispatch(store, st, nil, 10000); len(targets) != 0 { t.Fatalf("invented a battery: %v", targets) } - if !st.FuseSaturated || st.FuseEVMaxW > 6000.001 || st.FuseEVMaxW <= 0 { - t.Fatalf("no-battery cap=%v saturated=%v", st.FuseEVMaxW, st.FuseSaturated) + if !st.FuseSaturated { + t.Fatal("11 kW of EV demand beside 4 kW of house fits a 10 kW site") } } diff --git a/go/internal/control/export_ceiling_test.go b/go/internal/control/export_ceiling_test.go index 3e312fd60..ac1bc49e8 100644 --- a/go/internal/control/export_ceiling_test.go +++ b/go/internal/control/export_ceiling_test.go @@ -7,7 +7,7 @@ import ( ) // A site exporting 8 kW via battery discharge, with max_export_w = 5 kW set -// below the fuse: the fuse guard must scale the discharge back so predicted +// below the fuse: the site limits must scale the discharge back so predicted // export lands at the export ceiling, not at the (looser) fuse limit. // Models the recurring Ferroamp 0x8030 fault: ~8 kW sustained midday export // tripped the EnergyHub even though the 16 A fuse (~11 kW) was never hit. @@ -21,7 +21,7 @@ func TestExportCeilingScalesBackDischarge(t *testing.T) { state.MaxExportW = 5000 targets := []DispatchTarget{{Driver: "bat", TargetW: -8000}} - out := applyFuseGuard(targets, store, state, 11040) // fuse would allow 8 kW + out := applySiteLimits(targets, store, state) // fuse would allow 8 kW if len(out) != 1 { t.Fatalf("expected 1 target, got %d", len(out)) @@ -36,9 +36,9 @@ func TestExportCeilingScalesBackDischarge(t *testing.T) { } } -// With max_export_w unset (0), the export ceiling is the fuse alone, so an -// 8 kW export under an 11 kW fuse is left untouched — back-compat guard so -// the new knob is strictly opt-in. +// With max_export_w unset (0), no total export limit applies, so an 8 kW +// export under an 11 kW fuse is left untouched — the knob is strictly +// opt-in. func TestExportCeilingDisabledExportsUpToFuse(t *testing.T) { store := telemetry.NewStore() store.Update("meter", telemetry.DerMeter, -8000, nil, nil) @@ -47,7 +47,7 @@ func TestExportCeilingDisabledExportsUpToFuse(t *testing.T) { state := NewState(0, 50, "meter") // MaxExportW left 0 targets := []DispatchTarget{{Driver: "bat", TargetW: -8000}} - out := applyFuseGuard(targets, store, state, 11040) + out := applySiteLimits(targets, store, state) if got := out[0].TargetW; got != -8000 { t.Fatalf("export under fuse with no ceiling should be untouched, got %.1f", got) diff --git a/go/internal/control/forecast_scenarios_test.go b/go/internal/control/forecast_scenarios_test.go index 5e8fb94bf..f49a8027c 100644 --- a/go/internal/control/forecast_scenarios_test.go +++ b/go/internal/control/forecast_scenarios_test.go @@ -579,7 +579,7 @@ func TestScenario_E22_FuseNeverExceeded(t *testing.T) { const fuseMaxW = 11040.0 // Representative adversarial states that could theoretically bust the fuse. - // Only modes that route through applyFuseGuard are included. + // Only modes that route through the dispatch safety pipeline are included. type invariantCase struct { name string gridW float64 @@ -634,6 +634,7 @@ func TestScenario_E22_FuseNeverExceeded(t *testing.T) { if c.dir != nil { st.SlotDirective = c.dir } + describeFuse(st, fuseMaxW) targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), fuseMaxW) projected := c.gridW + totalTarget(targets) if projected > fuseMaxW+50 { diff --git a/go/internal/control/fuse_heat.go b/go/internal/control/fuse_heat.go index d982b6bf8..b18287c67 100644 --- a/go/internal/control/fuse_heat.go +++ b/go/internal/control/fuse_heat.go @@ -57,59 +57,40 @@ func (h *fuseHeat) step(at time.Time, amps []float64) { copy(h.amps[:], amps) } -// 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 { - if h.sq[i] > limitA*limitA || a > instantA { - over = math.Max(over, a-limitA) - } - } - return over -} - -// 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 := math.Inf(-1) - for i, a := range amps { - if a >= 0 { - a = math.Max(a, math.Sqrt(h.sq[i])) - } - level = math.Max(level, a+pendingA[i]) - } - return level -} - // worstA is the highest filtered phase current. func (h *fuseHeat) worstA() float64 { return math.Sqrt(max(h.sq[0], h.sq[1], h.sq[2])) } -// stepFuseHeat folds each new site reading into the model, whether or not a -// car is plugged in. -func (s *State) stepFuseHeat(meter *telemetry.DerReading) { +// stepFuseHeat folds each new site reading into the model once, at amps, +// whichever decision reads it first. +func (s *State) stepFuseHeat(meter *telemetry.DerReading, amps []float64) { if s.fuseHeat.set && !meter.UpdatedAt.After(s.fuseHeat.at) { return } - if amps, _, valid := phaseCurrentsA(meter, s); valid { - s.fuseHeat.step(meter.UpdatedAt, amps) - } + s.fuseHeat.step(meter.UpdatedAt, amps) } -// 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. +// phaseCurrentsA reads the site's configured phases with SitePhaseCurrents. +// valid is also false when the fuse is not described. func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, present, valid bool) { + if !finiteFuseValue(state.SiteFuseAmps) || !finiteFuseValue(state.SiteFuseVoltage) || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 { + _, present, _ = SitePhaseCurrents(meter, 3) + return nil, present, false + } + return SitePhaseCurrents(meter, state.SiteFusePhases) +} + +// SitePhaseCurrents reads a site reading's current on each of the first +// phases, import positive, as fuse protection reads it. 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 reading has no phase currents; +// valid is false when any of the phases is missing or unreadable. +func SitePhaseCurrents(meter *telemetry.DerReading, phases int) (amps []float64, present, valid bool) { + if meter == nil { + return nil, false, false + } // DER data uses snake case; separate fields retain absent-vs-zero. var data struct { L1 *float64 `json:"l1_a"` @@ -122,11 +103,11 @@ func phaseCurrentsA(meter *telemetry.DerReading, state *State) (amps []float64, 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 { + if phases < 1 || phases > 3 { return nil, true, false } powers := []*float64{data.L1W, data.L2W, data.L3W} - for i, a := range []*float64{data.L1, data.L2, data.L3}[:state.SiteFusePhases] { + for i, a := range []*float64{data.L1, data.L2, data.L3}[:phases] { if a == nil || !finiteFuseValue(*a) { return nil, true, false } diff --git a/go/internal/control/fuse_heat_test.go b/go/internal/control/fuse_heat_test.go index 69b0bb1bc..34018ade4 100644 --- a/go/internal/control/fuse_heat_test.go +++ b/go/internal/control/fuse_heat_test.go @@ -107,17 +107,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.fusePhasesAt(meterAt(store, t0.Add(-time.Hour), [3]float64{20, 20, 20})) + state.fusePhasesAt(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.fusePhasesAt(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.fusePhasesAt(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 { @@ -132,7 +132,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.fusePhasesAt(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) } @@ -483,12 +483,24 @@ func TestEVHardLimitHoldsWhenTotalImportDisagreesWithPhases(t *testing.T) { } } + // Without phase currents the total stands in, shared over the phases, + // under the same heat budget: one reading over cuts nothing, and a + // sustained one cuts its overage once the budget is spent. store, state, points, configs = evMeterBudgetFixture(t, 0, nil, 10350, "3p") state.SiteFuseSafetyA = 0.5 now = t0 state.clock = func() time.Time { return now } read(t0, 15334, nil) - if capW := read(t0.Add(time.Second), 19174, nil); math.Abs(capW-8081) > .01 { - t.Fatalf("without phase currents 19,174 W gave %.1f W, want 10,350 - 2,269 = 8,081 W", capW) + if capW := read(t0.Add(time.Second), 19174, nil); capW < 10350 { + t.Fatalf("without phase currents one 19,174 W reading cut the cap to %.1f W", capW) } + for i := 2; i <= 120; i++ { + if capW := read(t0.Add(time.Duration(i)*time.Second), 19174, nil); capW < 10350 { + if math.Abs(capW-(10350-(19174-16560))) > .01 || i < 10 { + t.Fatalf("a sustained 19,174 W cut to %.1f W after %d s, want 10,350 - 2,614 = 7,736 W once the budget is spent", capW, i) + } + return + } + } + t.Fatal("a sustained 19,174 W never cut") } diff --git a/go/internal/control/fuse_phase.go b/go/internal/control/fuse_phase.go index da041b83e..2adf893af 100644 --- a/go/internal/control/fuse_phase.go +++ b/go/internal/control/fuse_phase.go @@ -1,84 +1,390 @@ package control import ( - "encoding/json" "math" "time" "github.com/srcfl/ftw/go/internal/telemetry" ) -// phaseImbalanceA is how far the worst phase sits above the mean phase -// current of one meter sample. The aggregate fuse budget assumes balanced -// phases, but the breaker, and a charger's own load balancer, act on the -// worst phase. A three-phase battery or charger adds the same current to -// every phase, so this gap is import the aggregate budget cannot use. +// fusePhases is the main fuse as every charger and battery decision reads +// it: the site meter's current on each configured phase, import positive +// (phaseCurrentsA), under one working limit. Our own commands that a reading +// may not show yet are the callers' to add. A meter without phase currents +// stands in its total, shared evenly over the phases. // -// Meters report phase magnitudes, so the mean uses |aggregate|. Phases -// flowing in opposite directions make the gap look smaller than it is; the -// per-phase fuse guards still act on a real overage. A single-phase battery -// or charger is outside this model, as it is for perPhaseReliefW. -func phaseImbalanceA(r *telemetry.DerReading, state *State) (float64, bool) { - if state == nil || state.SiteFuseAmps <= 0 || state.SiteFuseVoltage <= 0 || state.SiteFusePhases <= 1 { - return 0, false - } - if r == nil || len(r.Data) == 0 { - return 0, false - } - var d struct { - L1A *float64 `json:"l1_a"` - L2A *float64 `json:"l2_a"` - L3A *float64 `json:"l3_a"` - } - if err := json.Unmarshal(r.Data, &d); err != nil { - return 0, false - } - phaseAmps := [...]*float64{d.L1A, d.L2A, d.L3A} - phases := min(state.SiteFusePhases, len(phaseAmps)) - worst := 0.0 - for i := 0; i < phases; i++ { - p := phaseAmps[i] - if p == nil || math.IsNaN(*p) || math.IsInf(*p, 0) { - return 0, false - } - worst = math.Max(worst, math.Abs(*p)) - } - if math.IsNaN(r.RawW) || math.IsInf(r.RawW, 0) { - return 0, false - } - mean := math.Abs(r.RawW) / (state.SiteFuseVoltage * float64(phases)) - return math.Max(0, worst-mean), true +// A raise needs room on its phases: the limit less each phase's level and +// what we sent that it may not show yet. A cut waits for the phase's heat +// budget (fuseHeat), or not at all above fuseInstantFactor, and then takes +// the overage off the lever that lowers that phase's current. +type fusePhases struct { + n int + voltage float64 + limitA float64 + amps [3]float64 // the reading, import positive + level [3]float64 // what a raise starts from: an importing phase at the higher of its reading and heat + over [3]float64 // above the limit with the heat budget spent: positive import, negative export + measured bool // the meter reports phase currents + blind bool // it reports phase currents that cannot be read: nothing may rise } -// phaseImportCeilingW is the import ceiling that keeps the worst phase -// inside the fuse less its margin. A tariff peak below it still binds, but -// the imbalance is never taken off the peak: that is a billing total, not a -// breaker. Never above effectiveImportCeilingW. -func (s *State) phaseImportCeilingW(fuseMaxW float64, meter *telemetry.DerReading) float64 { - ceiling := s.effectiveImportCeilingW(fuseMaxW) - imbalanceA, ok := phaseImbalanceA(meter, s) - if !ok || imbalanceA <= 0 { - return ceiling - } - phaseBudget := fuseMaxW - s.fuseSafetyMarginW() - imbalanceA*s.SiteFuseVoltage*float64(s.SiteFusePhases) - return math.Max(0, math.Min(ceiling, phaseBudget)) +// fuseMarginA is the one working margin below the fuse on every phase: the +// configured safety margin, at least evPhaseMarginA. +func (s *State) fuseMarginA() float64 { return math.Max(evPhaseMarginA, s.SiteFuseSafetyA) } + +// fusePhasesAt reads one site reading and folds it into the heat model. +// false when no fuse is configured or the reading has no usable power. +func (s *State) fusePhasesAt(meter *telemetry.DerReading) (fusePhases, bool) { + if meter == nil || !finiteFuseValue(s.SiteFuseAmps) || !finiteFuseValue(s.SiteFuseVoltage) || + s.SiteFuseAmps <= 0 || s.SiteFuseVoltage <= 0 || s.SiteFusePhases < 1 || s.SiteFusePhases > 3 { + return fusePhases{}, false + } + f := fusePhases{n: s.SiteFusePhases, voltage: s.SiteFuseVoltage, limitA: s.SiteFuseAmps - s.fuseMarginA()} + amps, present, valid := phaseCurrentsA(meter, s) + f.measured, f.blind = present && valid, present && !valid + if !f.measured { + totalW := meter.RawW + if !finiteFuseValue(totalW) { + return fusePhases{}, false + } + amps = make([]float64, f.n) + for i := range amps { + amps[i] = totalW / (f.voltage * float64(f.n)) + } + } + s.stepFuseHeat(meter, amps) + copy(f.amps[:], amps) + instantA := fuseInstantFactor * s.SiteFuseAmps + for i, a := range amps { + f.level[i] = a + if a >= 0 { + f.level[i] = math.Max(a, math.Sqrt(s.fuseHeat.sq[i])) + } + if s.fuseHeat.sq[i] > f.limitA*f.limitA || math.Abs(a) > instantA { + switch { + case a > f.limitA: + f.over[i] = a - f.limitA + case a < -f.limitA: + f.over[i] = a + f.limitA + } + } + } + return f, true +} + +// roomA is the current a raise may still add on its busiest phase, after +// pendingA, from each phase's level: a charger's slow rise must not spend +// heat the last minute already used. Negative is a shortfall. Never +// positive when the phases cannot be read. +func (f fusePhases) roomA(pendingA [3]float64) float64 { return f.room(f.level, pendingA) } + +// readingRoomA is roomA from the reading alone, for a battery: it answers +// within a tick, so a cut follows any overage at once and the heat's lag +// would only make it hunt. +func (f fusePhases) readingRoomA() float64 { return f.room(f.amps, [3]float64{}) } + +func (f fusePhases) room(level, pendingA [3]float64) float64 { + room := math.Inf(1) + for i := range f.n { + room = math.Min(room, f.limitA-level[i]-pendingA[i]) + } + if f.blind { + room = math.Min(room, 0) + } + return room +} + +// exportRoomA is the export a discharge may still add on its busiest +// exporting phase. +func (f fusePhases) exportRoomA() float64 { + room := math.Inf(1) + for i := range f.n { + room = math.Min(room, f.limitA+f.amps[i]) + } + if f.blind { + room = math.Min(room, 0) + } + return room +} + +// importOverA is the most any phase's import stays above the limit after +// creditA, the cuts already sent that the reading may not show. +func (f fusePhases) importOverA(creditA [3]float64) float64 { + over := 0.0 + for i := range f.n { + over = math.Max(over, f.over[i]-creditA[i]) + } + return over +} + +// exportOverA is the most any phase's export is above the limit. +func (f fusePhases) exportOverA() float64 { + over := 0.0 + for i := range f.n { + over = math.Max(over, -f.over[i]) + } + return over +} + +// watts is a current on every phase as total watts: a three-phase battery +// or charger spreads its power evenly. +func (f fusePhases) watts(a float64) float64 { return a * f.voltage * float64(f.n) } + +// highestA is the reading's highest import, for logs. +func (f fusePhases) highestA() float64 { + high := math.Inf(-1) + for i := range f.n { + high = math.Max(high, f.amps[i]) + } + return high +} + +// siteImportCeilingW is the import the batteries and EVs may bring the site +// to from gridW: the hour's import limit, or the import at which the busiest +// phase meets the fuse's limit on the reading with the rest spread evenly, +// whichever is lower. byImport is true when it is the hour's. +Inf when +// neither is set. +func (s *State) siteImportCeilingW(meter *telemetry.DerReading, gridW float64) (ceilingW float64, byImport bool) { + ceilingW = math.Inf(1) + if f, ok := s.fusePhasesAt(meter); ok { + ceilingW = gridW + f.watts(f.readingRoomA()) + } + if limitW, _, ok := s.importHourLimitsW(); ok && limitW <= ceilingW { + return limitW, true + } + return ceilingW, false +} + +// evCutsSent is the EV reduction we sent during a run over a limit that +// began at since, which a reading taken at `at` may not show yet: for each +// charger still plugged, its highest offer since the run began less this +// tick's, in total watts and in current on every phase. No run waits longer +// than evSettle, the shortest a charger takes, so a cut that does not land +// leaves the overage to the next lever. A charger that leaves drops out: no +// cut of ours removed it. A one-phase cut may have landed on any phase, so +// it relieves none for certain. +func (s *State) evCutsSent(since, at time.Time) (w float64, phaseA [3]float64) { + if since.IsZero() || at.Sub(since) >= evSettle { + return 0, phaseA + } + for _, c := range s.evLimit.offers { + latestW, plugged := s.evCommanded[c.driver] + if !plugged { + continue + } + cutW, three := c.log.unshownCut(since, at, math.Max(0, latestW)) + w += cutW + if three && s.SiteFuseVoltage > 0 { + for i := range phaseA { + phaseA[i] += cutW / (s.SiteFuseVoltage * 3) + } + } + } + return w, phaseA +} + +// evNeedW is the EV demand a reading taken at `at` may not show, before any +// optional battery charge: the chargers' demand, or the plan's while no +// charger has spoken (scheduledEVPowerW), less each charger's lowest offer +// the reading may show. phases is 1 when a demand may land on one phase. +func (s *State) evNeedW(at time.Time) (w, phases float64) { + w, phases = scheduledEVPowerW(s), 3 + for _, demand := range s.evFuseDemands { + if demand.phaseMode != "3p" { + phases = 1 + } + } + for _, c := range s.evLimit.offers { + w -= c.log.shownLow(at, c.settle) + } + return math.Max(0, w), phases +} + +// fitBatteryRisesToFuse keeps each battery rise inside the room the meter +// shows: new import (charge, or less discharge) over our latest order fits +// the phases' room after what the EVs still need, EV first, and the hour's +// rise level; new export fits the phases' export room. Holding an order is +// never a rise. Optional charge yields to any shortfall at once, EV first, +// so the car keeps its offer; an overage beyond that waits for the fuse's +// heat budget in forceFuseDischarge, except an export overage, which takes +// discharge back here. Only the site meter and our own commands count. +// FuseSaturated reports that the EVs' need or a battery's charge did not +// fit; main.go replans on it and stops a battery boost. +func fitBatteryRisesToFuse(targets []DispatchTarget, store *telemetry.Store, state *State) []DispatchTarget { + if store == nil || state == nil || len(targets) == 0 { + return targets + } + _, hourRiseW, hourly := state.importHourLimitsW() + meter := store.Get(state.SiteMeterDriver, telemetry.DerMeter) + f, fused := state.fusePhasesAt(meter) + if !fused && !hourly && (meter != nil || state.siteFuseMaxW() <= 0) { + return targets + } + var gridW float64 + if meter != nil { + gridW = meter.RawW + } + if meter == nil || !finiteFuseValue(gridW) { + return floorPositiveEVBatteryTargets(targets) // A limit binds that nothing measures. + } + now := state.now() + latest := make(map[string]float64, len(targets)) + for _, target := range targets { + if _, seen := latest[target.Driver]; seen { + return floorPositiveEVBatteryTargets(targets) + } + _, _, last, sent := state.batteryCommands[target.Driver].span(now) + switch r := store.Get(target.Driver, telemetry.DerBattery); { + case sent: + latest[target.Driver] = last + case r != nil: + // Without a recent order the meter shows what it does now. + latest[target.Driver] = r.SmoothedW + default: + // Unknown: it may be discharging and stop. + latest[target.Driver] = -state.DriverLimits[target.Driver].dischargeCap() + } + } + needW, needPhases := state.evNeedW(meter.UpdatedAt) + fuseSpare, hourSpare := math.Inf(1), math.Inf(1) + if fused { + fuseSpare = f.watts(f.readingRoomA()) - needW*float64(f.n)/needPhases + } + if hourly { + hourSpare = hourRiseW - gridW - needW + } + spare := math.Min(fuseSpare, hourSpare) + keep, keepCharge := make(map[string]float64, len(targets)), make(map[string]float64, len(targets)) + for _, t := range targets { + if r := store.Get(t.Driver, telemetry.DerBattery); r != nil { + lower, upper := fuseTargetBounds(r, state.DriverLimits[t.Driver]) + keep[t.Driver], keepCharge[t.Driver] = math.Max(0, math.Min(0, t.TargetW)-lower), math.Max(0, upper-math.Max(0, t.TargetW)) + } + } + out := capBatteryCharge(targets, latest, state.pendingBatteryCommandsW(now), spare, keep) + saturated := spare < 0 + for i := range out { + saturated = saturated || out[i].TargetW < targets[i].TargetW + } + if saturated { + state.FuseSaturated, state.FuseSaturatedByImport = true, hourSpare < fuseSpare + } + if !fused { + return out + } + // Export: the same rule with the signs turned. An export overage the + // heat budget no longer covers takes discharge back. + exportSpare := math.Max(0, f.watts(f.exportRoomA())) + if over := f.exportOverA(); over > 0 { + exportSpare = -f.watts(over) + } + turned := make([]DispatchTarget, len(out)) + turnedLatest := make(map[string]float64, len(latest)) + for i, t := range out { + turned[i] = DispatchTarget{Driver: t.Driver, TargetW: -t.TargetW, Clamped: t.Clamped} + } + for driver, w := range latest { + turnedLatest[driver] = -w + } + turned = capBatteryCharge(turned, turnedLatest, state.pendingBatteryExportW(now), exportSpare, keepCharge) + for i := range out { + out[i].TargetW, out[i].Clamped = -turned[i].TargetW, turned[i].Clamped + } + return out } -// PhaseImbalanceW is the live phase gap as aggregate watts: import the -// planner must leave unused so the worst phase stays inside the fuse. Zero -// when the meter reports no phase currents or its sample is older than -// maxAge. Caller holds the control mutex. +// capBatteryCharge keeps each battery's latest order: holding it is never an +// increase. A shortfall of measured room takes positive charge back; new +// charge needs room left after our increases the meter may not show yet. A +// reduction this tick funds nothing, as the meter does not show it. A +// discharge we end raises import too: where taking charge back leaves the +// room short, a battery keeps as much of it as the shortfall needs, up to +// keepW, what its floor still allows. +func capBatteryCharge(targets []DispatchTarget, latest map[string]float64, pendingW, spare float64, keepW map[string]float64) []DispatchTarget { + if !finiteFuseValue(spare) || !finiteFuseValue(pendingW) { + return floorPositiveEVBatteryTargets(targets) + } + out := append([]DispatchTarget(nil), targets...) + holds, lost := make([]float64, len(out)), make([]float64, len(out)) + var holdW, increaseW, lostW float64 + for i, target := range out { + last := latest[target.Driver] + lost[i] = math.Max(0, math.Min(0, target.TargetW)-math.Min(0, last)) + lostW, spare = lostW+lost[i], spare-lost[i] + if target.TargetW > 0 { + holds[i] = math.Min(target.TargetW, math.Max(0, last)) + holdW += holds[i] + increaseW += target.TargetW - holds[i] + } + } + holdScale, increaseScale := 1.0, 0.0 + if spare < 0 && holdW > 0 { + holdScale = math.Max(0, 1+spare/holdW) + } + if room := spare - pendingW; room > 0 && increaseW > 0 { + increaseScale = math.Min(1, room/increaseW) + } + for i := range out { + if out[i].TargetW <= 0 { + continue + } + capped := holds[i]*holdScale + (out[i].TargetW-holds[i])*increaseScale + if capped < out[i].TargetW { + out[i].TargetW, out[i].Clamped = capped, true + } + } + if short := -spare - holdW; short > 0 && lostW > 0 { + k := math.Min(1, short/lostW) + for i := range out { + if keep := math.Min(lost[i]*k, keepW[out[i].Driver]); keep > 0 { + out[i].TargetW, out[i].Clamped = math.Min(0, out[i].TargetW)-keep, true + } + } + } + return out +} + +func floorPositiveEVBatteryTargets(targets []DispatchTarget) []DispatchTarget { + out := append([]DispatchTarget(nil), targets...) + for i := range out { + if out[i].TargetW > 0 { + out[i].TargetW, out[i].Clamped = 0, true + } + } + return out +} + +// pendingBatteryExportW is battery export we ordered that the meter may not +// show yet: a cut charge or a new discharge. +func (s *State) pendingBatteryExportW(now time.Time) float64 { + var pending float64 + for _, log := range s.batteryCommands { + if _, high, latest, ok := log.span(now); ok { + pending += math.Max(0, high-latest) + } + } + return pending +} + +// PhaseImbalanceW is how far the busiest phase sits above the mean phase +// current, as total watts: import the planner leaves unused so that phase +// stays inside the fuse. Zero when the meter reports no phase currents or +// its reading is older than maxAge. Caller holds the control mutex. func PhaseImbalanceW(store *telemetry.Store, state *State, maxAge time.Duration, now time.Time) float64 { - if store == nil || state == nil || state.SiteMeterDriver == "" { + if store == nil || state == nil || state.SiteMeterDriver == "" || state.SiteFusePhases <= 1 { return 0 } r := store.Get(state.SiteMeterDriver, telemetry.DerMeter) if r == nil || now.Sub(r.UpdatedAt) > maxAge { return 0 } - imbalanceA, ok := phaseImbalanceA(r, state) - if !ok { + amps, _, valid := phaseCurrentsA(r, state) + if !valid { return 0 } - return imbalanceA * state.SiteFuseVoltage * float64(state.SiteFusePhases) + high, sum := math.Inf(-1), 0.0 + for _, a := range amps { + high, sum = math.Max(high, a), sum+a + } + return math.Max(0, high-sum/float64(len(amps))) * state.SiteFuseVoltage * float64(len(amps)) } diff --git a/go/internal/control/fuse_phase_test.go b/go/internal/control/fuse_phase_test.go index 7335c7484..77987cb15 100644 --- a/go/internal/control/fuse_phase_test.go +++ b/go/internal/control/fuse_phase_test.go @@ -9,141 +9,137 @@ import ( "github.com/srcfl/ftw/go/internal/telemetry" ) -// A 25 A three-phase site. The car is planned at 11 kW but draws 8.3 kW -// because the charger's load balancer cut it on L1, while the battery -// charges 4.75 kW. The total still fits under the fuse, so the old -// aggregate check let the battery keep the headroom the car needed. -const ( - phaseSiteFuseA = 25.0 - phaseSiteMarginA = 0.5 - phaseSiteVoltage = 230.0 - phaseSiteFuseW = phaseSiteFuseA * phaseSiteVoltage * 3 - phaseSiteEVPlanW = 11000.0 - phaseSiteEVNowW = 8300.0 - phaseSiteBatW = 4750.0 -) - -func phaseSiteStore(t *testing.T, houseA [3]float64) *telemetry.Store { +// phaseMeter is one site reading with signed phase currents and powers. +func phaseMeter(t *testing.T, at time.Time, phaseA [3]float64) *telemetry.DerReading { t.Helper() - symmetricA := (phaseSiteEVNowW + phaseSiteBatW) / (3 * phaseSiteVoltage) - houseW := (houseA[0] + houseA[1] + houseA[2]) * phaseSiteVoltage data, err := json.Marshal(map[string]float64{ - "l1_a": houseA[0] + symmetricA, - "l2_a": houseA[1] + symmetricA, - "l3_a": houseA[2] + symmetricA, + "l1_a": phaseA[0], "l2_a": phaseA[1], "l3_a": phaseA[2], + "l1_w": phaseA[0] * 230, "l2_w": phaseA[1] * 230, "l3_w": phaseA[2] * 230, }) if err != nil { t.Fatal(err) } - store := telemetry.NewStore() - store.Update("meter", telemetry.DerMeter, houseW+phaseSiteEVNowW+phaseSiteBatW, nil, data) - store.DriverHealthMut("meter").RecordSuccess() - soc := 0.5 - store.Update("pixii", telemetry.DerBattery, phaseSiteBatW, &soc, nil) - store.DriverHealthMut("pixii").RecordSuccess() - return store + return &telemetry.DerReading{RawW: (phaseA[0] + phaseA[1] + phaseA[2]) * 230, Data: data, UpdatedAt: at} } -func phaseSiteState() *State { - now := time.Now() - dir := SlotDirective{ - SlotStart: now, - SlotEnd: now.Add(15 * time.Minute), - BatteryEnergyWh: phaseSiteBatW / 4, - LoadpointEnergyWh: map[string]float64{"easee": phaseSiteEVPlanW / 4}, - LoadpointMaxPowerW: map[string]float64{"easee": phaseSiteEVPlanW}, - Strategy: "arbitrage", - } - st := newStateWithEnergyDispatch(dir, "meter") - st.SiteFuseAmps = phaseSiteFuseA - st.SiteFuseVoltage = phaseSiteVoltage - st.SiteFusePhases = 3 - st.SiteFuseSafetyA = phaseSiteMarginA - st.EVChargingW = phaseSiteEVNowW - st.DriverLimits = map[string]PowerLimits{"pixii": {MaxChargeW: 6000, MaxDischargeW: 6000}} +func phaseState(fuseA, marginA float64) *State { + st := NewState(0, 0, "meter") + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases, st.SiteFuseSafetyA = fuseA, 230, 3, marginA return st } -func TestScheduledEVGetsWorstPhaseBeforeBatteryCharge(t *testing.T) { - houseA := [3]float64{4, 0.5, 0.5} // 1150 W, mostly on L1 - store := phaseSiteStore(t, houseA) - st := phaseSiteState() - - targets := ComputeDispatch(store, st, caps(map[string]float64{"pixii": 15000}), phaseSiteFuseW) - if len(targets) != 1 { - t.Fatalf("targets %v", targets) +// One reading of the fuse for every decision: one margin, at least 1 A; +// current with direction; room for import, room for export; an overage +// only once its heat budget is spent. +func TestFusePhasesReadsDirectionRoomAndOverage(t *testing.T) { + t0 := time.Unix(1_800_000_000, 0) + st := phaseState(25, 0.5) + f, ok := st.fusePhasesAt(phaseMeter(t, t0, [3]float64{20, -15, 5})) + if !ok || f.limitA != 24 { + t.Fatalf("limit %.1f A, want 25 less the 1 A margin", f.limitA) } - if !st.FuseSaturated || math.Abs(st.FuseEVMaxW-phaseSiteEVPlanW) > 1 { - t.Fatalf("planned car lost its share: cap %.0f saturated %v", st.FuseEVMaxW, st.FuseSaturated) + if got := f.roomA([3]float64{}); got != 4 { + t.Fatalf("import room %.1f A, want 24 - 20 = 4 A", got) } - batW := targets[0].TargetW - if batW < 0 { - t.Fatalf("battery forced to discharge: %v", targets) + if got := f.roomA([3]float64{1, 1, 1}); got != 3 { + t.Fatalf("import room after 1 A pending %.1f A, want 3 A", got) } - worstA := houseA[0] + (batW+phaseSiteEVPlanW)/(3*phaseSiteVoltage) - if worstA > phaseSiteFuseA-phaseSiteMarginA+0.01 { - t.Fatalf("L1 at %.2f A with battery %.0f W and car %.0f W; limit %.1f A", - worstA, batW, phaseSiteEVPlanW, phaseSiteFuseA-phaseSiteMarginA) + if got := f.exportRoomA(); got != 9 { + t.Fatalf("export room %.1f A, want 24 - 15 = 9 A", got) } - if batW < 3000 { - t.Fatalf("battery gave up more than L1 needs: %.0f W", batW) + if f.importOverA([3]float64{}) != 0 || f.exportOverA() != 0 { + t.Fatalf("no phase is over: import %.1f A, export %.1f A", f.importOverA([3]float64{}), f.exportOverA()) } -} - -func TestBalancedHouseKeepsBatteryCharge(t *testing.T) { - store := phaseSiteStore(t, [3]float64{5.0 / 3, 5.0 / 3, 5.0 / 3}) // same 1150 W, balanced - st := phaseSiteState() - targets := ComputeDispatch(store, st, caps(map[string]float64{"pixii": 15000}), phaseSiteFuseW) - if len(targets) != 1 { - t.Fatalf("targets %v", targets) + // 26 A import on L1: the first reading sets the heat, so it is over at + // once; a cut already sent covers part of it. + st = phaseState(25, 1.5) + f, _ = st.fusePhasesAt(phaseMeter(t, t0, [3]float64{26, -10, 0})) + if f.limitA != 23.5 || f.importOverA([3]float64{}) != 2.5 || f.importOverA([3]float64{1, 1, 1}) != 1.5 { + t.Fatalf("limit %.1f A, over %.1f A, want 23.5 A and 2.5 A (1.5 A after a 1 A cut)", f.limitA, f.importOverA([3]float64{})) } - if st.FuseSaturated { - t.Fatalf("balanced phases fit under the fuse; cap %.0f", st.FuseEVMaxW) + + // A short spike from 20 A spends no heat budget yet: no overage, and + // no room either. + st = phaseState(25, 0) + st.fusePhasesAt(phaseMeter(t, t0, [3]float64{20, 0, 0})) + f, _ = st.fusePhasesAt(phaseMeter(t, t0.Add(time.Second), [3]float64{27, 0, 0})) + if f.importOverA([3]float64{}) != 0 || f.roomA([3]float64{}) != -3 { + t.Fatalf("spike: over %.1f A, room %.1f A; want 0 A and -3 A", f.importOverA([3]float64{}), f.roomA([3]float64{})) } - if math.Abs(targets[0].TargetW-phaseSiteBatW) > 1 { - t.Fatalf("battery target %.0f W, want planned %.0f W", targets[0].TargetW, phaseSiteBatW) + + // Export beyond the limit, heat spent: an export overage, not import. + st = phaseState(25, 0) + f, _ = st.fusePhasesAt(phaseMeter(t, t0, [3]float64{-27, 0, 0})) + if f.importOverA([3]float64{}) != 0 || f.exportOverA() != 3 { + t.Fatalf("export: import over %.1f A, export over %.1f A; want 0 and 3 A", f.importOverA([3]float64{}), f.exportOverA()) } } -func TestPhaseImportCeiling(t *testing.T) { - st := phaseSiteState() - store := phaseSiteStore(t, [3]float64{4, 0.5, 0.5}) - meter := store.Get("meter", telemetry.DerMeter) - aggregate := phaseSiteFuseW - phaseSiteMarginA*3*phaseSiteVoltage - imbalanceW := (4 - 5.0/3) * 3 * phaseSiteVoltage - - if got := st.phaseImportCeilingW(phaseSiteFuseW, meter); math.Abs(got-(aggregate-imbalanceW)) > 1 { - t.Fatalf("ceiling %.0f W, want %.0f W", got, aggregate-imbalanceW) - } - if got := PhaseImbalanceW(store, st, time.Minute, time.Now()); math.Abs(got-imbalanceW) > 1 { - t.Fatalf("imbalance %.0f W, want %.0f W", got, imbalanceW) - } - if got := PhaseImbalanceW(store, st, time.Minute, time.Now().Add(2*time.Minute)); got != 0 { - t.Fatalf("stale meter gave imbalance %.0f W", got) +// Without phase currents the total stands in, shared over the phases. +// Phase currents that cannot be read leave no room for a rise. +func TestFusePhasesWithoutReadablePhases(t *testing.T) { + t0 := time.Unix(1_800_000_000, 0) + st := phaseState(25, 0) + f, ok := st.fusePhasesAt(&telemetry.DerReading{RawW: 13800, UpdatedAt: t0}) + if !ok || f.measured || f.roomA([3]float64{}) != 4 { + t.Fatalf("total 13.8 kW: room %.1f A (measured %v), want 24 - 20 = 4 A from the total", f.roomA([3]float64{}), f.measured) + } + data, _ := json.Marshal(map[string]float64{"l1_a": 10, "l2_a": 10}) + f, ok = st.fusePhasesAt(&telemetry.DerReading{RawW: 4600, Data: data, UpdatedAt: t0.Add(time.Second)}) + if !ok || !f.blind || f.roomA([3]float64{}) > 0 || f.readingRoomA() > 0 { + t.Fatalf("L3 missing: room %.1f A (blind %v), want none", f.roomA([3]float64{}), f.blind) + } + if _, ok := phaseState(0, 0).fusePhasesAt(&telemetry.DerReading{RawW: 1000}); ok { + t.Fatal("no fuse configured, yet a fuse was read") } +} - // A tariff peak is a billing total, not a breaker: the imbalance is - // not taken off it. - st.PeakImportCeilingW = 9000 - if got := st.phaseImportCeilingW(phaseSiteFuseW, meter); got != 9000 { - t.Fatalf("peak ceiling %.0f W, want 9000 W", got) +// The site ceiling for batteries and EVs is the hour's import limit or the +// import at which the busiest phase meets the fuse, whichever is lower. +func TestSiteImportCeiling(t *testing.T) { + t0 := time.Unix(1_800_000_000, 0) + st := phaseState(25, 0) + meter := phaseMeter(t, t0, [3]float64{20, 5, 5}) + if got, byImport := st.siteImportCeilingW(meter, meter.RawW); got != meter.RawW+4*690 || byImport { + t.Fatalf("ceiling %.0f W (by import %v), want the reading plus 4 A on three phases", got, byImport) + } + st.PeakImportCeilingW = 6000 + if got, byImport := st.siteImportCeilingW(meter, meter.RawW); got != 6000 || !byImport { + t.Fatalf("ceiling %.0f W (by import %v), want the hour's 6,000 W", got, byImport) } - st.PeakImportCeilingW = 0 +} - // A meter without every configured phase gives the aggregate ceiling. - partial := telemetry.NewStore() - data, _ := json.Marshal(map[string]float64{"l1_a": 20, "l2_a": 10}) - partial.Update("meter", telemetry.DerMeter, 8000, nil, data) - if got := st.phaseImportCeilingW(phaseSiteFuseW, partial.Get("meter", telemetry.DerMeter)); got != aggregate { - t.Fatalf("partial phases: ceiling %.0f W, want %.0f W", got, aggregate) +// A car that takes less than its offer earns no reservation: the meter +// shows the room, and the battery may charge into it (Fredrik, 21 October +// 2026). The car's offer is in force and in the reading; the battery keeps +// its planned charge while the busiest phase stays inside the limit. +func TestBatteryKeepsRoomACarDoesNotTake(t *testing.T) { + store := telemetry.NewStore() + data, _ := json.Marshal(map[string]float64{"l1_a": 22.9, "l2_a": 19.4, "l3_a": 19.4, + "l1_w": 22.9 * 230, "l2_w": 19.4 * 230, "l3_w": 19.4 * 230}) + store.Update("meter", telemetry.DerMeter, 14200, nil, data) + st := phaseState(25, 0.5) + st.SetEVFuseDemands(nil) + st.evLimit.offers = map[string]chargerOffers{"car": {driver: "easee", settle: evSettle, + log: commandLog{{at: st.now().Add(-time.Hour), w: 11000, three: true}}}} + st.batteryCommands = map[string]commandLog{"pixii": commandLog{}.add(st.now().Add(-time.Minute), 4750)} + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "pixii", TargetW: 4750}}, store, st) + if out[0].TargetW != 4750 || out[0].Clamped { + t.Fatalf("battery %+v, want its 4,750 W held", out[0]) } +} - // Exporting on every phase is not an import imbalance. - export := telemetry.NewStore() - data, _ = json.Marshal(map[string]float64{"l1_a": -10, "l2_a": -10, "l3_a": -10}) - export.Update("meter", telemetry.DerMeter, -30*phaseSiteVoltage, nil, data) - if got := st.phaseImportCeilingW(phaseSiteFuseW, export.Get("meter", telemetry.DerMeter)); got != aggregate { - t.Fatalf("balanced export: ceiling %.0f W, want %.0f W", got, aggregate) +func TestPhaseImbalanceW(t *testing.T) { + store := telemetry.NewStore() + data, _ := json.Marshal(map[string]float64{"l1_a": 4 + 6.6, "l2_a": 0.5 + 6.6, "l3_a": 0.5 + 6.6}) + store.Update("meter", telemetry.DerMeter, 0, nil, data) + st := phaseState(25, 0.5) + want := (4 - 5.0/3) * 3 * 230 + if got := PhaseImbalanceW(store, st, time.Minute, time.Now()); math.Abs(got-want) > 1 { + t.Fatalf("imbalance %.0f W, want %.0f W", got, want) + } + if got := PhaseImbalanceW(store, st, time.Minute, time.Now().Add(2*time.Minute)); got != 0 { + t.Fatalf("stale meter gave imbalance %.0f W", got) } } diff --git a/go/internal/control/fuse_saver_test.go b/go/internal/control/fuse_saver_test.go index 0e2f48476..311ea7c25 100644 --- a/go/internal/control/fuse_saver_test.go +++ b/go/internal/control/fuse_saver_test.go @@ -5,7 +5,6 @@ import ( "fmt" "math" "testing" - "time" "github.com/srcfl/ftw/go/internal/telemetry" ) @@ -28,9 +27,14 @@ func setupFuseSaver(gridW, batW float64, batSoC float64, maxDischargeW float64) st.DriverLimits = map[string]PowerLimits{ "bat": {MaxChargeW: 10000, MaxDischargeW: maxDischargeW}, } + fuse11040(st) // The meter reports no phase currents: its total stands in. return s, st, map[string]float64{"bat": 15200} } +// fuse11040 describes a 17 A fuse on three 230 V phases. Its working +// limit, the one 1 A margin below, is 16 A: 11,040 W in all. +func fuse11040(st *State) { st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases = 17, 230, 3 } + // Idle battery + grid surge over fuse → battery is forced to discharge // to bring grid back under the fuse. This is the manual_hold-ramp // scenario from the PR description. @@ -55,43 +59,27 @@ func TestFuseSaverForcesDischargeFromIdle(t *testing.T) { } } -// In production the call chain is applyFuseGuard THEN -// forceFuseDischarge. When the planner asks for charge while the grid -// is already at the fuse, applyFuseGuard scales the charge down toward -// 0; forceFuseDischarge then sees a small (or zero) target and either -// no-ops or adds a tiny extra discharge to close the residual gap. -// The standalone-flip-to-discharge behaviour the previous version of -// this test asserted doesn't happen on the real path — applyFuseGuard -// would never let a +3 kW charge reach forceFuseDischarge with grid -// already at the fuse. -func TestFuseSaverAfterFuseGuardKeepsScaledCharge(t *testing.T) { +// In production the battery's rise is fitted to the phase room first, then +// forceFuseDischarge relieves what is over. With the grid at the fuse's +// limit a planned 3 kW charge gets almost no room, and the fuse-saver then +// has nothing more to do. +func TestFuseSaverAfterRiseCapKeepsScaledCharge(t *testing.T) { // Live grid near the fuse limit; planner asked to charge 3 kW; // battery currently idle. store, state, caps := setupFuseSaver(11000, 0, 0.6, 10000) targets := []DispatchTarget{{Driver: "bat", TargetW: 3000}} - // Step 1: applyFuseGuard scales charging down because predicted - // import (11000 + 3000 = 14000) exceeds the fuse. - guarded := applyFuseGuard(targets, store, state, 11040) + guarded := fitBatteryRisesToFuse(targets, store, state) if guarded[0].TargetW < 0 { - t.Fatalf("applyFuseGuard should NOT flip charge to discharge — "+ - "got %.0f W", guarded[0].TargetW) + t.Fatalf("the rise cap must not flip charge to discharge — got %.0f W", guarded[0].TargetW) } if guarded[0].TargetW > 100 { - t.Fatalf("applyFuseGuard should have scaled charge down toward 0, "+ - "got %.0f W (charge surviving the fuse guard is a regression)", + t.Fatalf("the rise cap should leave the charge near 0, got %.0f W (charge past the fuse is a regression)", guarded[0].TargetW) } - - // Step 2: forceFuseDischarge runs on the post-guard targets. With - // charge already at ~0 the predicted gridW after the guard is at - // the fuse limit; forceFuseDischarge no-ops or adds a small - // residual discharge. Either way it must NOT take the target - // further negative than -2960 W (the original overage). out := forceFuseDischarge(guarded, store, state, caps, 11040) if out[0].TargetW < -2960.001 { - t.Errorf("post-guard discharge over-correction: target = %.0f W, "+ - "original overage was 2960 W", out[0].TargetW) + t.Errorf("post-cap discharge over-correction: target = %.0f W", out[0].TargetW) } } @@ -195,6 +183,7 @@ func TestFuseSaverDistributesAcrossBatteries(t *testing.T) { s.Update("b", telemetry.DerBattery, 0, &soc, nil) s.DriverHealthMut("b").RecordSuccess() state := NewState(0, 50, "meter") + fuse11040(state) state.DriverLimits = map[string]PowerLimits{ "a": {MaxChargeW: 10000, MaxDischargeW: 6000}, "b": {MaxChargeW: 10000, MaxDischargeW: 4000}, @@ -260,10 +249,10 @@ func TestFuseSaverBypassesSlew(t *testing.T) { // ---- Per-phase clamp ---- -// setupPerPhase wires a meter that emits l1_a/l2_a/l3_a in -// DerReading.Data and a single battery on a 3Φ 230 V site. Aggregate -// gridW stays deliberately under the fuse to prove the per-phase clamp -// fires independently of the aggregate guard. +// setupPerPhase wires a meter that emits l1_a/l2_a/l3_a, with each phase's +// power signed the same way, and a single battery on a 3Φ 230 V site. +// Aggregate gridW stays deliberately under the fuse to prove the phases +// judge it alone. func setupPerPhase(aggGridW float64, l1A, l2A, l3A float64, batSoC float64, maxDischargeW float64) (*telemetry.Store, *State, map[string]float64) { return setupPerPhaseSite(3, 230, aggGridW, l1A, l2A, l3A, batSoC, maxDischargeW) } @@ -275,9 +264,9 @@ func setupPerPhase(aggGridW float64, l1A, l2A, l3A float64, batSoC float64, maxD func setupPerPhaseSite(phases int, voltage float64, aggGridW float64, l1A, l2A, l3A float64, batSoC float64, maxDischargeW float64) (*telemetry.Store, *State, map[string]float64) { s := telemetry.NewStore() data, _ := json.Marshal(map[string]any{ - "l1_a": l1A, - "l2_a": l2A, - "l3_a": l3A, + "l1_a": l1A, "l1_w": l1A * voltage, + "l2_a": l2A, "l2_w": l2A * voltage, + "l3_a": l3A, "l3_w": l3A * voltage, }) s.Update("meter", telemetry.DerMeter, aggGridW, nil, data) s.DriverHealthMut("meter").RecordSuccess() @@ -285,7 +274,8 @@ func setupPerPhaseSite(phases int, voltage float64, aggGridW float64, l1A, l2A, s.Update("bat", telemetry.DerBattery, 0, &soc, nil) s.DriverHealthMut("bat").RecordSuccess() st := NewState(0, 50, "meter") - st.SiteFuseAmps = 16 + fuse11040(st) + st.SiteFuseAmps = 17 // Its working limit, 1 A below, is 16 A. st.SiteFuseVoltage = voltage st.SiteFusePhases = phases st.DriverLimits = map[string]PowerLimits{ @@ -294,36 +284,26 @@ func setupPerPhaseSite(phases int, voltage float64, aggGridW float64, l1A, l2A, return s, st, map[string]float64{"bat": 15200} } -// Single-phase imbalance: aggregate is under fuse but L1 is over. -// applyFuseGuard must scale charging down to bring the worst phase -// back. (Pixii single-phase battery on L1 is the real-world case.) -func TestPerPhaseClampScalesChargingOnImbalance(t *testing.T) { - // Isolate the per-phase case: aggregate predicted (7000 + 4000 = - // 11000 W) is under the 11040 W fuse, so the legacy aggregate - // branch wouldn't fire. But L1 alone is at 18 A × 230 V = 4140 W - // (over the 16 A fuse on that phase) — only the per-phase clamp - // can catch it. - store, state, _ := setupPerPhase(7000, 18, 12, 8, 0.6, 10000) - targets := []DispatchTarget{{Driver: "bat", TargetW: 4000}} - guarded := applyFuseGuard(targets, store, state, 11040) - if !guarded[0].Clamped { - t.Errorf("Clamped flag must mark per-phase response") - } - // Per-phase overage = (18 − 16) × 230 = 460 W on the worst phase. - // Balanced-3Φ assumption ⇒ 460 × 3 = 1380 W of total battery - // reduction needed. Charge 4000 → 4000 − 1380 = 2620 W. - expected := 2620.0 - if math.Abs(guarded[0].TargetW-expected) > 1 { - t.Errorf("charge after per-phase clamp = %.0f W, want %.0f W", - guarded[0].TargetW, expected) +// Single-phase imbalance: aggregate is under the fuse but L1 is over. A +// charge rise may not go into a phase over its limit, whatever the total +// says; at 14 A it gets the 2 A that phase has left. (Pixii single-phase +// battery on L1 is the real-world case.) +func TestPerPhaseRoomCapsChargeRise(t *testing.T) { + for _, tc := range []struct { + l1A, wantW float64 + }{{18, 0}, {14, 2 * 690}} { + store, state, _ := setupPerPhase(7000, tc.l1A, 12, 8, 0.6, 10000) + guarded := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "bat", TargetW: 4000}}, store, state) + if !guarded[0].Clamped || math.Abs(guarded[0].TargetW-tc.wantW) > 1 { + t.Errorf("L1 at %.0f A: charge %.0f W (clamped %v), want %.0f W", tc.l1A, guarded[0].TargetW, guarded[0].Clamped, tc.wantW) + } } } // Per-phase overage with no charge to scale → fuse-saver forces // discharge. Reproduces the test-day failure: battery idle, EV pinned // at 16 A 3Φ, one phase pushed over by house imbalance, aggregate -// stays under fuse so applyFuseGuard wouldn't fire — but the per-phase -// path now does. +// stays under the fuse — the phases judge it. func TestPerPhaseClampFiresFuseSaverFromIdle(t *testing.T) { store, state, caps := setupPerPhase(9000, 18, 12, 8, 0.6, 10000) out := fuseSaverFromLivePower(store, state, caps, 11040) @@ -361,11 +341,9 @@ func TestPerPhaseClampDisabledWhenSiteFuseAmpsZero(t *testing.T) { } } -// Aggregate AND per-phase both over: take the larger overage. With a -// huge imbalance, per-phase × 3 dominates. +// The phases judge the fuse where the meter reports them; the total does +// not add to them. L1 at 22 A is 6 A over: 6 × 230 × 3 = 4140 W. func TestPerPhaseClampDominatesAggregateWhenLarger(t *testing.T) { - // Aggregate over by 500 W (predicted = 11540 vs 11040). But L1 is - // at 22 A → over by 6 A × 230 = 1380 W per-phase × 3 = 4140 W. store, state, caps := setupPerPhase(11540, 22, 14, 8, 0.6, 10000) out := fuseSaverFromLivePower(store, state, caps, 11040) if out == nil { @@ -379,25 +357,21 @@ func TestPerPhaseClampDominatesAggregateWhenLarger(t *testing.T) { } // Per-phase EXPORT overage: PV is exporting hard on a single phase -// (e.g. 1Φ inverter on L2), aggregate gridW negative. The per-phase -// guard must fire on the EXPORT side and shrink discharging — NOT -// trigger forceFuseDischarge, which would push more power out and -// worsen the over-current phase. Regression for PR #219 review S1. +// (e.g. 1Φ inverter on L2), aggregate gridW negative. The battery step +// must act on the EXPORT side and shrink discharging — NOT trigger +// forceFuseDischarge, which would push more power out and worsen the +// over-current phase. Regression for PR #219 review S1. func TestPerPhaseClampFiresOnExportImbalance(t *testing.T) { - // Aggregate gridW = -7000 W (heavy export). Phases (magnitude): - // L2 = 18 A (over the 16 A fuse), L1 = 12, L3 = 8. Per-phase + // Aggregate gridW = -7000 W (heavy export). Phases, exporting: + // L2 = 18 A (over the 16 A limit), L1 = 12, L3 = 8. Per-phase // overage = (18 − 16) × 230 × 3 = 1380 W of discharge to shrink. // Battery is currently discharging −5000 W; the target reduces // magnitude to bring L2 back under fuse. - store, state, _ := setupPerPhase(-7000, 12, 18, 8, 0.6, 10000) - // Set the live battery reading so applyFuseGuard's "predicted" - // reflects the current discharge state. soc := 0.6 - store.Update("bat", telemetry.DerBattery, -5000, &soc, nil) - store.DriverHealthMut("bat").RecordSuccess() - + store, state, _ := setupPerPhase(-7000, -12, -18, -8, 0.6, 10000) + setBatteryLiveW(store, "bat", -5000, soc) targets := []DispatchTarget{{Driver: "bat", TargetW: -5000}} - guarded := applyFuseGuard(targets, store, state, 11040) + guarded := fitBatteryRisesToFuse(targets, store, state) if !guarded[0].Clamped { t.Errorf("export-side per-phase overage must clamp the discharge target") } @@ -409,7 +383,7 @@ func TestPerPhaseClampFiresOnExportImbalance(t *testing.T) { } } -func TestFuseGuardPredictsAgainstControlledBatteriesOnly(t *testing.T) { +func TestSiteLimitsPredictAgainstControlledBatteriesOnly(t *testing.T) { store := telemetry.NewStore() store.Update("meter", telemetry.DerMeter, -8000, nil, nil) store.DriverHealthMut("meter").RecordSuccess() @@ -419,19 +393,20 @@ func TestFuseGuardPredictsAgainstControlledBatteriesOnly(t *testing.T) { store.DriverHealthMut("online").RecordSuccess() // Stale/offline battery readings can still exist in the store and - // are already included in the live meter reading. applyFuseGuard must + // are already included in the live meter reading. applySiteLimits must // not subtract them unless it is also replacing them with a target. store.Update("offline", telemetry.DerBattery, -3000, &soc, nil) store.DriverHealthMut("offline").SetOffline() state := NewState(0, 50, "meter") + state.MaxExportW = 10000 targets := []DispatchTarget{{Driver: "online", TargetW: -3000}} - guarded := applyFuseGuard(targets, store, state, 10000) + guarded := applySiteLimits(targets, store, state) if !guarded[0].Clamped { - t.Fatalf("expected export-side fuse guard to clamp controlled discharge") + t.Fatalf("expected the export limit to clamp controlled discharge") } // True post-dispatch grid is -8000 - current_online(0) + target(-3000) - // = -11000 W, so the 10 kW export fuse is exceeded by 1000 W. + // = -11000 W, so the 10 kW export limit is exceeded by 1000 W. expected := -2000.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("controlled discharge after fuse guard = %.0f W, want %.0f W", @@ -447,7 +422,7 @@ func TestForceFuseDischargeIgnoresExportSidePerPhase(t *testing.T) { // Heavy export with one phase over fuse. fuseSaverFromLivePower (which // calls forceFuseDischarge) MUST return nil — the export-side // overage is not its problem. - store, state, caps := setupPerPhase(-9000, 12, 18, 8, 0.6, 10000) + store, state, caps := setupPerPhase(-9000, -12, -18, -8, 0.6, 10000) out := fuseSaverFromLivePower(store, state, caps, 11040) if out != nil { t.Errorf("forceFuseDischarge fired on export-side per-phase overage; "+ @@ -455,49 +430,20 @@ func TestForceFuseDischargeIgnoresExportSidePerPhase(t *testing.T) { } } -// SiteFuseSafetyA shrinks the per-phase threshold below MaxAmps so the -// dispatch stops trying to ride right up to the breaker (where the -// inverter's own per-phase limiter trips first and causes a flap). -// Regression for PR #219 — the bug class motivating the safety margin. -func TestPerPhaseClampHonorsSafetyMargin(t *testing.T) { - // Phases (15.5, 12, 8). MaxAmps = 16, SafetyA = 1.0 → effective - // threshold = 15 A. L1 at 15.5 A is OVER the effective threshold - // (under the raw 16 A). Per-phase overage = (15.5 − 15) × 230 × 3 - // = 345 W of charge to shrink. - store, state, _ := setupPerPhase(7000, 15.5, 12, 8, 0.6, 10000) - state.SiteFuseSafetyA = 1.0 - state.SiteFusePhases = 3 - targets := []DispatchTarget{{Driver: "bat", TargetW: 4000}} - guarded := applyFuseGuard(targets, store, state, 11040) - if !guarded[0].Clamped { - t.Errorf("safety margin should pull the threshold under L1 = 15.5 A") - } - // Aggregate effFuseW = 11040 − 1.0×230×3 = 10350. Predicted = - // 7000 + 4000 − 0 = 11000 → aggregate overage = 11000 − 10350 = 650. - // Per-phase overage × 3 = 345. Aggregate wins (650 > 345). - // Buffer = half-margin = 0.5×1×230×3 = 345 W (post-fix headroom). - // Charge 4000 − 650 − 345 = 3005. - expected := 3005.0 - if math.Abs(guarded[0].TargetW-expected) > 1 { - t.Errorf("charge after safety-margin clamp = %.0f W, want %.0f W", - guarded[0].TargetW, expected) - } -} - -// SafetyMarginA = 0 must produce identical output to the pre-PR -// behaviour (locks the back-compat contract every existing per-phase -// test relies on by leaving the field unset). -func TestPerPhaseClampSafetyMarginZeroIsBackCompat(t *testing.T) { - store, state, _ := setupPerPhase(7000, 18, 12, 8, 0.6, 10000) - state.SiteFuseSafetyA = 0 - state.SiteFusePhases = 3 - targets := []DispatchTarget{{Driver: "bat", TargetW: 4000}} - guarded := applyFuseGuard(targets, store, state, 11040) - // Same math as TestPerPhaseClampScalesChargingOnImbalance. - expected := 2620.0 - if math.Abs(guarded[0].TargetW-expected) > 1 { - t.Errorf("safety_margin_a=0 must match pre-PR behaviour: got %.0f W, want %.0f W", - guarded[0].TargetW, expected) +// One margin: the configured safety_margin_a, at least 1 A, below the fuse +// on every phase. A 1.5 A margin on a 16 A fuse puts the limit at 14.5 A, +// so L1 at 15.5 A is 1 A over: 690 W of relief. A margin of 0 still leaves +// the 1 A working margin, and L1 at 15.5 A is half an amp over. +func TestPerPhaseReliefUsesTheOneMargin(t *testing.T) { + for _, tc := range []struct { + marginA, wantW float64 + }{{1.5, -690}, {0, -345}} { + store, state, caps := setupPerPhase(7000, 15.5, 12, 8, 0.6, 10000) + state.SiteFuseAmps, state.SiteFuseSafetyA = 16, tc.marginA + out := fuseSaverFromLivePower(store, state, caps, 11040) + if len(out) != 1 || math.Abs(out[0].TargetW-tc.wantW) > 1 { + t.Errorf("safety_margin_a %.1f: relief %+v, want %.0f W", tc.marginA, out, tc.wantW) + } } } @@ -508,31 +454,30 @@ func TestPerPhaseClampSafetyMarginZeroIsBackCompat(t *testing.T) { // phase count, and on a 1Φ site the aggregate meter and the single // phase are the same wire — relief there is 1 × the overage, not 3 ×. // -// A 1Φ site at 24 A on a 16 A fuse (0.5 A margin ⇒ 15.5 A threshold, -// 230 V): fuse budget 3680 W, aggregate ceiling 3565 W, meter at -// 5520 W. Worst-phase overage = (24 − 15.5) × 230 = 1955 W, which on -// this site is also exactly the aggregate overage — the two numbers -// must agree when there is only one phase. +// A 1Φ site at 24 A on a 16 A fuse (the one 1 A margin ⇒ 15 A limit, +// 230 V), meter at 5520 W. Worst-phase overage = (24 − 15) × 230 = +// 2070 W, which on this site is also exactly the aggregate overage — the +// two numbers must agree when there is only one phase. // Import side, battery idle: the forced discharge equals the overage // once, and leaves the meter on the import side of zero. func TestPerPhaseReliefMatchesSinglePhaseSiteOnImport(t *testing.T) { store, state, caps := setupPerPhaseSite(1, 230, 5520, 24, 0, 0, 0.6, 10000) - state.SiteFuseSafetyA = 0.5 + state.SiteFuseAmps, state.SiteFuseSafetyA = 16, 0.5 fuseMaxW := 16 * 230.0 * 1 out := fuseSaverFromLivePower(store, state, caps, fuseMaxW) if out == nil { t.Fatalf("1Φ site at 24 A on a 16 A fuse: expected a forced discharge") } - expected := -1955.0 + expected := -2070.0 if math.Abs(out[0].TargetW-expected) > 1 { t.Fatalf("forced discharge = %.0f W, want %.0f W (overage × 1 phase, "+ "not × 3)", out[0].TargetW, expected) } // The site has one phase, so the post-dispatch meter is that - // phase. Demanding 3 × 1955 W would have driven 5520 W of import - // to 345 W of export: relief the site never needed, pushing the + // phase. Demanding 3 × 2070 W would have driven 5520 W of import + // to 690 W of export: relief the site never needed, pushing the // same breaker up the other way. if postGridW := 5520 + out[0].TargetW; postGridW < 0 { t.Errorf("post-dispatch grid = %.0f W: relief overshot through zero "+ @@ -541,14 +486,13 @@ func TestPerPhaseReliefMatchesSinglePhaseSiteOnImport(t *testing.T) { } // Export side — the direction forceFuseDischarge deliberately skips -// (more discharge would worsen it) and applyFuseGuard owns. The same -// phase-count law applies: shrink the discharge by the overage once. +// (more discharge would worsen it) and fitBatteryRisesToFuse owns. The +// same phase-count law applies: shrink the discharge by the overage once. func TestPerPhaseReliefMatchesSinglePhaseSiteOnExport(t *testing.T) { // Meter magnitudes mirror the import case: 5520 W out, 24 A on the // one phase. Pixii-style signed per-phase amps, so L1 reads −24. store, state, _ := setupPerPhaseSite(1, 230, -5520, -24, 0, 0, 0.6, 10000) - state.SiteFuseSafetyA = 0.5 - fuseMaxW := 16 * 230.0 * 1 + state.SiteFuseAmps, state.SiteFuseSafetyA = 16, 0.5 // The battery is already discharging what the meter shows, and the // target holds it there — so the aggregate export overage is the @@ -556,14 +500,13 @@ func TestPerPhaseReliefMatchesSinglePhaseSiteOnExport(t *testing.T) { setBatteryLiveW(store, "bat", -5000, 0.6) targets := []DispatchTarget{{Driver: "bat", TargetW: -5000}} - guarded := applyFuseGuard(targets, store, state, fuseMaxW) + guarded := fitBatteryRisesToFuse(targets, store, state) if !guarded[0].Clamped { t.Fatalf("export-side per-phase overage must clamp the discharge target") } - // Discharge 5000 − overage 1955 − buffer (half the 115 W margin) - // = 2987.5 W. Demanding 1955 × 3 leaves nothing: the discharge is - // zeroed and the site swings to 520 W of import. - expected := -2987.5 + // Discharge 5000 − overage 2070 = 2930 W. Demanding 2070 × 3 leaves + // nothing: the discharge is zeroed and the site swings to import. + expected := -2930.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("discharge after 1Φ export clamp = %.1f W, want %.1f W", guarded[0].TargetW, expected) @@ -596,18 +539,18 @@ func TestPerPhaseClampReadsConfiguredPhasesOnly(t *testing.T) { } } -// Per-phase watts come from the configured voltage, the same source -// fuseSafetyMarginW uses. A 240 V site converts its amps at 240 V. +// Per-phase watts come from the configured voltage. A 240 V site +// converts its amps at 240 V. func TestPerPhaseClampUsesConfiguredVoltage(t *testing.T) { - store, state, _ := setupPerPhaseSite(3, 240, 7000, 18, 12, 8, 0.6, 10000) + store, state, _ := setupPerPhaseSite(3, 240, 7000, 14, 12, 8, 0.6, 10000) targets := []DispatchTarget{{Driver: "bat", TargetW: 4000}} - guarded := applyFuseGuard(targets, store, state, 16*240.0*3) + guarded := fitBatteryRisesToFuse(targets, store, state) if !guarded[0].Clamped { - t.Fatalf("L1 at 18 A on a 16 A fuse must clamp") + t.Fatalf("L1 at 14 A under a 16 A limit leaves 2 A: the 4 kW charge must clamp") } - // (18 − 16) × 240 = 480 W per phase × 3 = 1440 W of relief. - // Charge 4000 → 2560. At the old hardcoded 230 V it would be 2620. - expected := 2560.0 + // (16 − 14) × 240 = 480 W per phase × 3 = 1440 W of room. At a + // hardcoded 230 V it would be 1380 W. + expected := 1440.0 if math.Abs(guarded[0].TargetW-expected) > 1 { t.Errorf("charge after per-phase clamp on a 240 V site = %.0f W, "+ "want %.0f W", guarded[0].TargetW, expected) @@ -655,110 +598,29 @@ func TestFuseSaverFiresInIdleMode(t *testing.T) { } } -// Operator-report 2026-04-30: with safety_margin_a=1 (threshold = 15 A) -// the system stabilised with phase amps oscillating between 15.0 and -// 15.8 A — fuse guard reactively scaled targets just enough to clear -// the trip, then the planner re-ramped, then it re-tripped. Half-margin -// buffer keeps post-clamp aggregate below the threshold by an explicit -// margin so the next tick has room before re-tripping. -func TestFuseGuardLeavesHeadroomBelowThreshold(t *testing.T) { - store, state, _ := setupPerPhase(-7000, 12, 18, 8, 0.6, 10000) - state.SiteFuseSafetyA = 1.0 - state.SiteFusePhases = 3 - soc := 0.6 - store.Update("bat", telemetry.DerBattery, -5000, &soc, nil) - store.DriverHealthMut("bat").RecordSuccess() - - out := applyFuseGuard( - []DispatchTarget{{Driver: "bat", TargetW: -5000}}, - store, state, 11040) - if !out[0].Clamped { - t.Fatalf("expected per-phase export clamp") - } - // perPhase = (18−15) × 230 × 3 = 2070 W - // half-margin buffer = 0.5 × 1A × 230V × 3p = 345 W - // totalDischarge = 5000 → newTotal = 5000 − 2070 − 345 = 2585 - expected := -2585.0 - if math.Abs(out[0].TargetW-expected) > 1 { - t.Errorf("post-clamp target = %.0f W, want %.0f W (overage + half-margin)", - out[0].TargetW, expected) +// An export overage takes back exactly the overage: the one margin is the +// headroom, with no buffer on top. L2 at 18 A of export under a 15 A limit +// (16 A fuse, 1 A margin) is 3 A over: 2,070 W of the 5 kW discharge. +func TestExportOverageTakesBackTheOverage(t *testing.T) { + store, state, _ := setupPerPhase(-7000, -12, -18, -8, 0.6, 10000) + state.SiteFuseAmps, state.SiteFuseSafetyA = 16, 1.0 + setBatteryLiveW(store, "bat", -5000, 0.6) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "bat", TargetW: -5000}}, store, state) + if !out[0].Clamped || math.Abs(out[0].TargetW-(-2930)) > 1 { + t.Fatalf("post-clamp target = %+v, want -2,930 W", out[0]) } } -// Hold-mode hysteresis prevents the planner from immediately -// re-ramping into the threshold on the next tick. The clamp's max -// is latched for ~30 s and re-applied even when the live overage -// is zero. -func TestFuseGuardHoldsClampAcrossTicks(t *testing.T) { - // Tick 1: clamp fires, latches max. - store, state, _ := setupPerPhase(-7000, 12, 18, 8, 0.6, 10000) - state.SiteFuseSafetyA = 1.0 - state.SiteFusePhases = 3 - soc := 0.6 - store.Update("bat", telemetry.DerBattery, -5000, &soc, nil) - store.DriverHealthMut("bat").RecordSuccess() - - tick1 := applyFuseGuard( - []DispatchTarget{{Driver: "bat", TargetW: -5000}}, - store, state, 11040) - if !tick1[0].Clamped { - t.Fatalf("tick 1: expected initial clamp") - } - if state.FuseHoldMaxDischargeW <= 0 { - t.Fatalf("tick 1: hold-max should be latched, got %v", state.FuseHoldMaxDischargeW) - } - if state.FuseHoldUntil.IsZero() { - t.Fatalf("tick 1: hold-until should be set") - } - tick1Cap := state.FuseHoldMaxDischargeW - - // Tick 2: phase amps look clean (battery responded), planner - // asks for the original full discharge again. Hold-mode must - // re-apply the latched cap. - store2, state2, _ := setupPerPhase(-3000, 8, 10, 6, 0.6, 10000) - state2.SiteFuseSafetyA = 1.0 - state2.SiteFusePhases = 3 - state2.FuseHoldMaxDischargeW = state.FuseHoldMaxDischargeW - state2.FuseHoldUntil = state.FuseHoldUntil - store2.Update("bat", telemetry.DerBattery, -2500, &soc, nil) - store2.DriverHealthMut("bat").RecordSuccess() - - tick2 := applyFuseGuard( - []DispatchTarget{{Driver: "bat", TargetW: -5000}}, - store2, state2, 11040) - if !tick2[0].Clamped { - t.Errorf("tick 2: hold-mode should still clamp the re-ramp") - } - if -tick2[0].TargetW > tick1Cap+0.5 { - t.Errorf("tick 2: target magnitude %.0f exceeds latched cap %.0f — hold not enforced", - -tick2[0].TargetW, tick1Cap) - } -} - -// Hold expires after the window so future planner re-ramps aren't -// permanently capped by stale state. -func TestFuseGuardHoldExpiresAfterWindow(t *testing.T) { - store, state, _ := setupPerPhase(-3000, 8, 10, 6, 0.6, 10000) - state.SiteFuseSafetyA = 1.0 - state.SiteFusePhases = 3 - soc := 0.6 - store.Update("bat", telemetry.DerBattery, -2500, &soc, nil) - store.DriverHealthMut("bat").RecordSuccess() - - state.FuseHoldMaxDischargeW = 1000 - state.FuseHoldUntil = time.Now().Add(-1 * time.Second) - - out := applyFuseGuard( - []DispatchTarget{{Driver: "bat", TargetW: -4000}}, - store, state, 11040) - if !state.FuseHoldUntil.IsZero() { - t.Errorf("expired hold-until should be cleared, got %v", state.FuseHoldUntil) - } - if state.FuseHoldMaxDischargeW != 0 { - t.Errorf("expired hold-max should be cleared, got %v", state.FuseHoldMaxDischargeW) - } - if out[0].Clamped { - t.Errorf("post-expiry, no live overage: target should pass through, got %v", out[0]) +// Once the phases are clean the planner may ramp the discharge again, but +// only into the export room the meter shows: L2 at 14 A of export under the +// 15 A limit leaves 1 A, 690 W on three phases. +func TestExportRiseFitsPhaseRoom(t *testing.T) { + store, state, _ := setupPerPhase(-5000, -8, -14, -6, 0.6, 10000) + state.SiteFuseAmps, state.SiteFuseSafetyA = 16, 1.0 + setBatteryLiveW(store, "bat", -2500, 0.6) + out := fitBatteryRisesToFuse([]DispatchTarget{{Driver: "bat", TargetW: -5000}}, store, state) + if !out[0].Clamped || math.Abs(out[0].TargetW-(-3190)) > 1 { + t.Fatalf("re-ramp target = %+v, want -2,500 - 690 = -3,190 W", out[0]) } } @@ -780,8 +642,8 @@ func TestPeakCeilingDisabledByDefaultIsNoop(t *testing.T) { } // forceFuseDischarge fires when grid exceeds the operator's tariff peak -// even though the fuse is intact. Battery briefly bridges; the joint -// allocator has already throttled the EV via FuseEVMaxW. +// even though the fuse is intact. Battery briefly bridges while the EV +// budget throttles the car. func TestPeakCeilingForcesDischargeBelowFuse(t *testing.T) { // Grid at 8 kW import, well under the 11.04 kW fuse but over a // 5 kW tariff peak. @@ -801,51 +663,6 @@ func TestPeakCeilingForcesDischargeBelowFuse(t *testing.T) { } } -// Joint EV/battery allocator caps EV draw against the peak ceiling in a -// planner mode where BatteryCoversEV=false (the EV throttles, the battery -// is not pulled in to cover steady state). -func TestPeakCeilingClampsEVViaJointAllocator(t *testing.T) { - // House load 1 kW, EV pinned at 7 kW = 8 kW import. Peak 5 kW. - // Fuse 11 kW (won't fire). Battery idle (no charge or discharge). - store := telemetry.NewStore() - store.Update("meter", telemetry.DerMeter, 8000, nil, nil) - store.DriverHealthMut("meter").RecordSuccess() - soc := 0.6 - store.Update("bat", telemetry.DerBattery, 0, &soc, nil) - store.DriverHealthMut("bat").RecordSuccess() - st := NewState(0, 50, "meter") - st.PeakImportCeilingW = 5000 - st.EVChargingW = 7000 - st.BatteryCoversEV = false - st.DriverLimits = map[string]PowerLimits{"bat": {MaxChargeW: 5000, MaxDischargeW: 10000}} - caps := map[string]float64{"bat": 15200} - - // Run a full dispatch cycle so the joint allocator's geometry - // fires (it lives inside ComputeDispatch, not as a free function). - st.Mode = ModePlannerSelf - _ = ComputeDispatch(store, st, caps, 11040) - - // Joint allocator should have set FuseEVMaxW so that - // H + Bn + B*scale + E*scale ≤ 5000 (peak ceiling). - // H = rawGrid − currentBat − E = 8000 − 0 − 7000 = 1000. - // Bn = 0 (idle), B may be small from PI but the cap should land - // near (5000 − 1000) = 4000 W of EV draw. - if !st.FuseSaturated { - t.Errorf("FuseSaturated must be true under peak overrun, got false") - } - // EV cap must drop well below the uncapped 7000 W. Exact value - // depends on PI internals (a small steady-state discharge command - // is legitimately accounted for by the joint allocator), so the - // assertion is "capped meaningfully", not an exact number. Anything - // above ~5500 means the peak ceiling isn't biting. - if st.FuseEVMaxW == 0 { - t.Errorf("FuseEVMaxW = 0 — peak ceiling should permit *some* EV draw") - } - if st.FuseEVMaxW > 5500 { - t.Errorf("FuseEVMaxW = %.0f, want ≤ 5500 (peak 5000 binding cap)", st.FuseEVMaxW) - } -} - // Peak above fuse is a no-op: fuse is the binding ceiling. // Sanity check the helper picks the tighter of the two. func TestPeakCeilingAboveFuseFusewins(t *testing.T) { @@ -867,7 +684,7 @@ func TestPeakCeilingAboveFuseFusewins(t *testing.T) { // ComputeDispatch's legacy/reactive arm short-circuits when the grid // error is inside GridToleranceW: nothing to correct, leave the // batteries where they are. That exit used to return before -// applyFuseGuard, applyPlanSignFloor, applyBatteryBoostReserve and +// applySiteLimits, applyPlanSignFloor, applyBatteryBoostReserve and // forceFuseDischarge — so on a site sitting inside the deadband no // protection ran at all. The idle and holdoff exits already ran the // fuse-saver for exactly this reason; this one was missed. @@ -1077,6 +894,7 @@ func TestDeadbandExitSelectsOnlyNeededOutOfCapChargeClamp(t *testing.T) { setBatteryLiveW(store, "a", liveCharge, 0.6) setBatteryLiveW(store, "b", liveCharge, 0.6) state := NewState(0, 50, "meter") + fuse11040(state) state.DriverLimits = map[string]PowerLimits{ "a": {MaxChargeW: maxCharge, MaxDischargeW: 10000}, "b": {MaxChargeW: maxCharge, MaxDischargeW: 10000}, @@ -1125,6 +943,7 @@ func TestDeadbandExitSkipsCapStepThatWouldBreakExportCeiling(t *testing.T) { setBatteryLiveW(store, "oversized", liveChargeW, 0.6) setBatteryLiveW(store, "safe", 0, 0.6) state := NewState(0, 50, "meter") + fuse11040(state) state.MaxExportW = maxExportW state.DriverLimits = map[string]PowerLimits{ "oversized": {MaxChargeW: maxChargeW, MaxDischargeW: 10000}, @@ -1203,6 +1022,7 @@ func TestDeadbandExitFindsCapCombinationWithinExportBudget(t *testing.T) { setBatteryLiveW(store, "b", 7000, 0.02) // 6 kW discrete cap relief. setBatteryLiveW(store, "c", 8000, 0.02) // 7 kW discrete cap relief. state := NewState(0, 50, "meter") + fuse11040(state) state.PeakImportCeilingW = importLimit state.MaxExportW = exportLimit state.DriverLimits = map[string]PowerLimits{ @@ -1245,6 +1065,7 @@ func TestDeadbandExitChoosesCapWithEnoughContinuousHeadroom(t *testing.T) { setBatteryLiveW(store, "useful-room", 5000, 0.6) state := NewState(0, 50, "meter") + fuse11040(state) state.SiteFuseAmps = 16 state.SiteFuseVoltage = 230 state.SiteFusePhases = 3 @@ -1276,6 +1097,7 @@ func TestDeadbandExitCombinesCapsInLargeFleet(t *testing.T) { store.Update("meter", telemetry.DerMeter, gridW, nil, nil) store.DriverHealthMut("meter").RecordSuccess() state := NewState(0, 50, "meter") + fuse11040(state) state.DriverLimits = make(map[string]PowerLimits) caps := make(map[string]float64) for i := 0; i < 13; i++ { @@ -1563,6 +1385,7 @@ func TestDeadbandExitRoutesReliefAroundUnsafeLiveDischarge(t *testing.T) { json.RawMessage(`{"discharge_capable":false,"charge_capable":true}`)) setBatteryLiveW(store, "capable", 0, 0.6) state := NewState(0, 50, "meter") + fuse11040(state) state.DriverLimits = map[string]PowerLimits{ "blocked": {MaxChargeW: 5000, MaxDischargeW: 5000}, "capable": {MaxChargeW: 5000, MaxDischargeW: 5000}, @@ -1644,6 +1467,7 @@ func TestFuseSaverMixedFleetHonorsPerBatteryLowerBounds(t *testing.T) { json.RawMessage(`{"discharge_capable":false,"charge_capable":true}`)) setBatteryLiveW(store, "capable", -1000, 0.6) state := NewState(0, 50, "meter") + fuse11040(state) state.DriverLimits = map[string]PowerLimits{ "blocked": {MaxChargeW: 5000, MaxDischargeW: 5000}, "capable": {MaxChargeW: 5000, MaxDischargeW: 5000}, @@ -1725,3 +1549,19 @@ func TestDeadbandExitRecordsDispatchWhenFuseSaverFires(t *testing.T) { t.Errorf("LastTargets = %+v, want the issued targets %+v", state.LastTargets, out) } } + +// Fuse relief stops at the battery's floor, its soc_min or the planner's, +// not at the 5 % empty guard: below it the lever is the EV. +func TestFuseReliefRespectsTheBatteryFloor(t *testing.T) { + for _, tc := range []struct { + soc, floor float64 + discharges bool + }{{0.08, 0.10, false}, {0.12, 0.10, true}, {0.08, 0, true}} { + store, state, caps := setupFuseSaver(14000, 0, tc.soc, 10000) + state.DriverLimits["bat"] = PowerLimits{MaxChargeW: 10000, MaxDischargeW: 10000, SoCMin: tc.floor} + out := forceFuseDischarge([]DispatchTarget{{Driver: "bat", TargetW: 0}}, store, state, caps, 11040) + if got := out[0].TargetW < 0; got != tc.discharges { + t.Errorf("SoC %.2f, floor %.2f: target %.0f W, want discharge %v", tc.soc, tc.floor, out[0].TargetW, tc.discharges) + } + } +} diff --git a/go/internal/control/golden_dump_test.go b/go/internal/control/golden_dump_test.go index ae96c9960..17fc1839f 100644 --- a/go/internal/control/golden_dump_test.go +++ b/go/internal/control/golden_dump_test.go @@ -147,11 +147,8 @@ type goldenClampAttribution struct { PlanSignFloorZeroed bool `json:"plan_sign_floor_zeroed"` PlanStale bool `json:"plan_stale"` FuseSaturated bool `json:"fuse_saturated"` - FuseEVMaxW float64 `json:"fuse_ev_max_w"` - FuseHoldLatched bool `json:"fuse_hold_latched"` - FuseHoldMaxChargeW float64 `json:"fuse_hold_max_charge_w"` - FuseHoldMaxDischargeW float64 `json:"fuse_hold_max_discharge_w"` - PerPhaseOverageW float64 `json:"per_phase_overage_w"` + PhaseOverA float64 `json:"phase_over_a"` + PhaseExportOverA float64 `json:"phase_export_over_a"` EffectiveImportCeilingW float64 `json:"effective_import_ceiling_w"` EffectiveExportCeilingW float64 `json:"effective_export_ceiling_w"` ImportCeilingBinding bool `json:"import_ceiling_binding"` @@ -212,8 +209,15 @@ func runGoldenScenarioAt(sc goldenScenario, scenarioNow time.Time, slotDirective store := telemetry.NewStore() var meterData json.RawMessage - if sc.Inputs.MeterPhases != nil { - b, err := json.Marshal(sc.Inputs.MeterPhases) + if ph := sc.Inputs.MeterPhases; ph != nil { + // Each phase's power carries the current's sign, as meter drivers + // emit it. + v := sc.Inputs.State.SiteFuseVoltage + if v <= 0 { + v = 230 + } + b, err := json.Marshal(map[string]float64{"l1_a": ph.L1A, "l2_a": ph.L2A, "l3_a": ph.L3A, + "l1_w": ph.L1A * v, "l2_w": ph.L2A * v, "l3_w": ph.L3A * v}) if err == nil { meterData = b } @@ -290,6 +294,11 @@ func runGoldenScenarioAt(sc goldenScenario, scenarioNow time.Time, slotDirective st.SiteFuseVoltage = si.SiteFuseVoltage st.SiteFusePhases = si.SiteFusePhases st.SiteFuseSafetyA = si.SiteFuseSafetyA + if st.SiteFuseAmps == 0 && sc.Inputs.FuseMaxW > 0 { + // A record that names only the fuse's watts: a balanced + // three-phase 230 V fuse of that size, as config describes it. + st.SiteFuseAmps, st.SiteFuseVoltage, st.SiteFusePhases = sc.Inputs.FuseMaxW/690, 230, 3 + } st.PVSurplusAbsorbSoCCap = si.PVSurplusAbsorbSoCCap st.PVSurplusAbsorbThresholdW = si.PVSurplusAbsorbThresholdW if len(limits) > 0 { @@ -331,7 +340,12 @@ func runGoldenScenarioAt(sc goldenScenario, scenarioNow time.Time, slotDirective // Pre-dispatch attribution reads (pure). intent := planSignIntent(st) - perPhase := perPhaseOverageW(store, st) + var phaseOverA, phaseExportOverA float64 + if meter := store.Get(st.SiteMeterDriver, telemetry.DerMeter); meter != nil { + if f, ok := st.fusePhasesAt(meter); ok { + phaseOverA, phaseExportOverA = f.importOverA([3]float64{}), f.exportOverA() + } + } effImp := st.effectiveImportCeilingW(sc.Inputs.FuseMaxW) effExp := st.effectiveExportCeilingW(sc.Inputs.FuseMaxW) @@ -374,11 +388,8 @@ func runGoldenScenarioAt(sc goldenScenario, scenarioNow time.Time, slotDirective PlanSignFloorZeroed: planSignFloorZeroed, PlanStale: st.PlanStale, FuseSaturated: st.FuseSaturated, - FuseEVMaxW: st.FuseEVMaxW, - FuseHoldLatched: st.FuseHoldUntil.After(st.now()), - FuseHoldMaxChargeW: st.FuseHoldMaxChargeW, - FuseHoldMaxDischargeW: st.FuseHoldMaxDischargeW, - PerPhaseOverageW: perPhase, + PhaseOverA: phaseOverA, + PhaseExportOverA: phaseExportOverA, EffectiveImportCeilingW: effImp, EffectiveExportCeilingW: effExp, ImportCeilingBinding: len(targets) > 0 && math.Abs(projected-effImp) <= 25, @@ -1007,8 +1018,8 @@ func earlyExitScenarios() []goldenScenario { })) // Export side. The worst phase is 10 A over the breaker, and relief must // NOT fire: this function's only lever is more discharge, which would - // push that phase further over. applyFuseGuard owns the export side, and - // it has no charge target to shrink here. The record exists so a change + // push that phase further over. fitBatteryRisesToFuse owns the export + // side. The record exists so a change // that drops forceFuseDischarge's import-only gate is visible as watts // appearing on a tick that must stay quiet. out = append(out, mk("deadband_per_phase_export_side_quiet", goldenInputs{ @@ -1141,7 +1152,7 @@ func earlyExitScenarios() []goldenScenario { // beyond where the battery happens to be: it re-anchors on the measured output // (SmoothedW) and walks a step from there, so a target of 0 W becomes // ±SlewRateW whenever the battery is not already still. Nothing downstream -// floors the charge side of that — applyFuseGuard only shrinks, +// floors the charge side of that — applySiteLimits only shrinks, // floorNegativeTargets is discharge-only, and planSignIntent returns 0 for an // idle slot. // diff --git a/go/internal/control/golden_test.go b/go/internal/control/golden_test.go index cde350a90..3cc302e05 100644 --- a/go/internal/control/golden_test.go +++ b/go/internal/control/golden_test.go @@ -220,7 +220,7 @@ func assertGoldenCoverage(t *testing.T, all []goldenRecord, byID map[string]gold if pp, ok := byID["targeted/per_phase_overage_import"]; !ok { t.Error("missing targeted/per_phase_overage_import") } else { - if pp.ClampAttributions.PerPhaseOverageW <= 0 { + if pp.ClampAttributions.PhaseOverA <= 0 { t.Errorf("per-phase overage not attributed: %+v", pp.ClampAttributions) } if sumGoldenTargets(pp) >= 0 { @@ -240,15 +240,12 @@ func assertGoldenCoverage(t *testing.T, all []goldenRecord, byID map[string]gold assertGoldenEarlyExitCoverage(t, all, byID) // Generic scans across the whole corpus. - var nFuseSaturated, nHoldLatched, nPerPhase, nStale, nBlocked, nEmpty int + var nFuseSaturated, nPerPhase, nStale, nBlocked, nEmpty int for _, r := range all { if r.ClampAttributions.FuseSaturated { nFuseSaturated++ } - if r.ClampAttributions.FuseHoldLatched { - nHoldLatched++ - } - if r.ClampAttributions.PerPhaseOverageW > 0 { + if r.ClampAttributions.PhaseOverA > 0 { nPerPhase++ } if r.ClampAttributions.PlanStale { @@ -264,9 +261,9 @@ func assertGoldenCoverage(t *testing.T, all []goldenRecord, byID map[string]gold } } } - t.Logf("coverage: fuse_saturated=%d hold_latched=%d per_phase=%d stale_plan=%d blocked_batteries=%d no_dispatch=%d", - nFuseSaturated, nHoldLatched, nPerPhase, nStale, nBlocked, nEmpty) - if nFuseSaturated == 0 || nHoldLatched == 0 || nPerPhase == 0 || nStale == 0 { + t.Logf("coverage: fuse_saturated=%d per_phase=%d stale_plan=%d blocked_batteries=%d no_dispatch=%d", + nFuseSaturated, nPerPhase, nStale, nBlocked, nEmpty) + if nFuseSaturated == 0 || nPerPhase == 0 || nStale == 0 { t.Error("coverage gap: one of the generic families never fired its clamp") } } @@ -506,14 +503,14 @@ func assertGoldenEarlyExitCoverage(t *testing.T, all []goldenRecord, byID map[st t.Errorf("missing %s", id) continue } - overage := r.ClampAttributions.PerPhaseOverageW - if overage <= 0 { + overA := r.ClampAttributions.PhaseOverA + if overA <= 0 { t.Errorf("%s: no per-phase overage recorded, so the record no longer exercises the breaker", id) continue } - if got, want := sumGoldenTargets(r), -overage*phases; math.Abs(got-want) > 1 { - t.Errorf("%s: fleet total = %.0f W, want %.0f W — %.0f W over on the worst phase, across %.0f phases", - id, got, want, overage, phases) + if got, want := sumGoldenTargets(r), -overA*r.Inputs.State.SiteFuseVoltage*phases; math.Abs(got-want) > 1 { + t.Errorf("%s: fleet total = %.0f W, want %.0f W — %.2f A over on the worst phase, across %.0f phases", + id, got, want, overA, phases) } if float64(r.Inputs.State.SiteFusePhases) != phases { t.Errorf("%s: recorded with site_fuse_phases=%d, not the %.0f the assertion names", @@ -528,7 +525,7 @@ func assertGoldenEarlyExitCoverage(t *testing.T, all []goldenRecord, byID map[st if r, ok := byID["early_exit/deadband_per_phase_export_side_quiet"]; !ok { t.Error("missing early_exit/deadband_per_phase_export_side_quiet") } else { - if r.ClampAttributions.PerPhaseOverageW <= 0 { + if r.ClampAttributions.PhaseExportOverA <= 0 { t.Errorf("export-side control no longer has a phase over the breaker: %+v", r.ClampAttributions) } if len(r.PerDriverTargets) != 0 { @@ -688,11 +685,8 @@ func diffGoldenRecord(want, got goldenRecord) []string { addBool("plan_sign_floor_zeroed", wa.PlanSignFloorZeroed, ga.PlanSignFloorZeroed) addBool("plan_stale", wa.PlanStale, ga.PlanStale) addBool("fuse_saturated", wa.FuseSaturated, ga.FuseSaturated) - addWatts("fuse_ev_max_w", wa.FuseEVMaxW, ga.FuseEVMaxW) - addBool("fuse_hold_latched", wa.FuseHoldLatched, ga.FuseHoldLatched) - addWatts("fuse_hold_max_charge_w", wa.FuseHoldMaxChargeW, ga.FuseHoldMaxChargeW) - addWatts("fuse_hold_max_discharge_w", wa.FuseHoldMaxDischargeW, ga.FuseHoldMaxDischargeW) - addWatts("per_phase_overage_w", wa.PerPhaseOverageW, ga.PerPhaseOverageW) + addWatts("phase_over_a", wa.PhaseOverA, ga.PhaseOverA) + addWatts("phase_export_over_a", wa.PhaseExportOverA, ga.PhaseExportOverA) addWatts("effective_import_ceiling_w", wa.EffectiveImportCeilingW, ga.EffectiveImportCeilingW) addWatts("effective_export_ceiling_w", wa.EffectiveExportCeilingW, ga.EffectiveExportCeilingW) addBool("import_ceiling_binding", wa.ImportCeilingBinding, ga.ImportCeilingBinding) diff --git a/go/internal/control/idle_hold_test.go b/go/internal/control/idle_hold_test.go index 00f8f4328..8cecd8499 100644 --- a/go/internal/control/idle_hold_test.go +++ b/go/internal/control/idle_hold_test.go @@ -150,6 +150,7 @@ func TestIdleHoldStillYieldsToTheFuseSaver(t *testing.T) { {name: "ferroamp", currentW: 0, soc: 0.60, online: true}, }) st.DriverLimits = map[string]PowerLimits{"ferroamp": {MaxChargeW: 10000, MaxDischargeW: 10000}} + describeFuse(st, 11040) targets := ComputeDispatch(store, st, capacities, 11040) @@ -160,8 +161,8 @@ func TestIdleHoldStillYieldsToTheFuseSaver(t *testing.T) { if !tg.Clamped { t.Error("a fuse-saver discharge must be marked clamped so the dispatch trace names it") } - // Predicted overage: 14000 − 11040 = 2960 W. - if want := -2960.0; math.Abs(tg.TargetW-want) > 1 { + // Predicted overage: 14000 − (11040 − 690 of margin) = 3650 W. + if want := -3650.0; math.Abs(tg.TargetW-want) > 1 { t.Errorf("ferroamp target = %.0f W, want %.0f W", tg.TargetW, want) } } diff --git a/go/internal/control/peak_limit_test.go b/go/internal/control/peak_limit_test.go index 596212a4e..48a78c527 100644 --- a/go/internal/control/peak_limit_test.go +++ b/go/internal/control/peak_limit_test.go @@ -122,7 +122,7 @@ func TestSetPeakLimitRejectsNonFinite(t *testing.T) { } // An undescribed fuse yields no ceiling rather than an invented one — -// the same back-compat rule fuseSafetyMarginW and perPhaseOverageW keep +// the same back-compat rule fuseSafetyMarginW and fusePhasesAt keep // for harnesses that wire only some of the fuse fields. func TestSetPeakLimitWithoutFuseChecksOnlyTheSign(t *testing.T) { st := NewState(0, 50, "ferroamp") // no SiteFuse* wired diff --git a/go/internal/control/selftune_dispatch_test.go b/go/internal/control/selftune_dispatch_test.go index 807d3a4f1..17b4902b1 100644 --- a/go/internal/control/selftune_dispatch_test.go +++ b/go/internal/control/selftune_dispatch_test.go @@ -51,12 +51,13 @@ func TestSelfTuneStepYieldsToTheFuseGuard(t *testing.T) { store, st, capacities := selfTuneSite(10000, []idleBattery{ {name: "tuned", currentW: 0, soc: 0.50, online: true}, }) + describeFuse(st, 11040) targets := SelfTuneDispatch(store, st, capacities, 11040, "tuned", 3000) tg := targetFor(t, targets, "tuned") // 10 kW now + 3 kW step would import 13 kW through an 11.04 kW fuse. - if predicted := 10000 + tg.TargetW; predicted > 11040+1 { + if predicted := 10000 + tg.TargetW; predicted > 11040-690+1 { t.Fatalf("tuned target = %.0f W predicts %.0f W import past the 11040 W fuse", tg.TargetW, predicted) } if !tg.Clamped { @@ -72,6 +73,7 @@ func TestSelfTuneHoldStillYieldsToTheFuseSaver(t *testing.T) { {name: "tuned", currentW: 0, soc: 0.50, online: true}, {name: "sibling", currentW: 0, soc: 0.60, online: true}, }) + describeFuse(st, 11040) targets := SelfTuneDispatch(store, st, capacities, 11040, "tuned", 1000) diff --git a/go/internal/control/sitesim_test.go b/go/internal/control/sitesim_test.go index 073dbbf8b..525afbf29 100644 --- a/go/internal/control/sitesim_test.go +++ b/go/internal/control/sitesim_test.go @@ -55,6 +55,7 @@ type simSite struct { limitSince time.Time // a phase above the fuse less its margin since ownRaise bool // that run followed a raise we sent peakA float64 + minBatteryW float64 // the battery's lowest power since the start overLimit time.Duration // time with a phase over the fuse less its margin idleWithRoom time.Duration // time the car was idle with room for it failure string @@ -315,9 +316,12 @@ func (s *simSite) check() { s.heat[i] = a * a } over = over || math.Abs(a) > fuseInstantFactor*fuseA || (math.Abs(a) > limitA && s.heat[i] > limitA*limitA) - overLimit = overLimit || math.Abs(a) > limitA + overLimit = overLimit || math.Abs(a) > limitA+.01 // At the limit is not over it. s.peakA = math.Max(s.peakA, math.Abs(a)) } + if p.Battery != nil { + s.minBatteryW = math.Min(s.minBatteryW, p.Battery.W) + } s.heatSet = true if overLimit { s.overLimit += sitesim.Step @@ -535,7 +539,7 @@ func simScenarios() []simScenario { } return nil }}}}, - {name: "meter every 30 s after a cut", known: "a meter slower than evSettle makes every reading a gap, ev_fuse_budget.go:141", run: 10 * time.Minute, + {name: "meter every 30 s after a cut", known: "the heat model holds the spike before a 30 s silence for 30 s, fuse_heat.go step; the car restarts after about 2 min", run: 10 * time.Minute, plant: func(p *sitesim.Plant) { p.HouseW = houseA(3, 3, 3) p.Events = []sitesim.Event{ @@ -582,6 +586,39 @@ func simScenarios() []simScenario { } return nil }}}}, + {name: "Pixii total disagrees with its phases", run: 10 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW = houseA(1, 1, 1) + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 3000, MaxDischargeW: 5000, CapacityWh: 20000, SoC: .5, Latency: 2 * time.Second} + p.Events = []sitesim.Event{ + {At: 2 * time.Minute, Do: func(p *sitesim.Plant) { p.Meter.TotalBiasW = 5000 }}, + {At: 8 * time.Minute, Do: func(p *sitesim.Plant) { p.Meter.TotalBiasW = 0 }}, + } + }, + setup: planBatteryCharge(3000), + expect: []simExpect{{7 * time.Minute, func(s *simSite) error { + if p := s.p; p.EVSE.DrawW < 11000 || p.Battery.W < 2900 { + return fmt.Errorf("a total 5 kW above its phases (%.1f A) moved the car to %.0f W or the battery to %.0f W", p.A[0], p.EVSE.DrawW, p.Battery.W) + } + return nil + }}}}, + {name: "car and battery relieve one overage", run: 10 * time.Minute, + plant: func(p *sitesim.Plant) { + p.HouseW = houseA(4, 4, 4) + p.Battery = &sitesim.Battery{Phases: 3, MaxChargeW: 5000, MaxDischargeW: 5000, CapacityWh: 20000, SoC: .5, Latency: 2 * time.Second} + p.Events = []sitesim.Event{{At: 3 * time.Minute, Do: func(p *sitesim.Plant) { p.HouseW = houseA(12, 12, 12) }}} + }, + setup: planBatteryCharge(0), + expect: []simExpect{{9 * time.Minute, func(s *simSite) error { + if s.minBatteryW < -100 { + return fmt.Errorf("the battery discharged %.0f W for an overage the car's cut covers", -s.minBatteryW) + } + return nil + }}}}, + {name: "idle start on a 16 A fuse", run: 5 * time.Minute, + plant: func(p *sitesim.Plant) { + p.Fuse.RatingA, p.HouseW = 16, houseA(8.5, 3, 3) + }}, {name: "a day", start: time.Date(2026, 10, 21, 0, 0, 0, 0, time.Local), run: 24 * time.Hour, plant: simDay}, } } diff --git a/go/internal/control/stale_load_soc_test.go b/go/internal/control/stale_load_soc_test.go index c74e61b6d..5b00f84e0 100644 --- a/go/internal/control/stale_load_soc_test.go +++ b/go/internal/control/stale_load_soc_test.go @@ -155,6 +155,7 @@ func TestMissingSoCFuseReliefUsesKnownBattery(t *testing.T) { st := NewState(0, 50, "meter") st.Mode = ModeIdle st.MinDispatchIntervalS = 0 + describeFuse(st, 11040) targets := ComputeDispatch(store, st, caps(map[string]float64{"unknown": 15200, "known": 15200}), 11040) knownDischarges := false for _, target := range targets { diff --git a/go/internal/control/testdata/golden/early_exit_protections.json b/go/internal/control/testdata/golden/early_exit_protections.json index e1147757e..cf1d7fff7 100644 --- a/go/internal/control/testdata/golden/early_exit_protections.json +++ b/go/internal/control/testdata/golden/early_exit_protections.json @@ -1,7 +1,7 @@ { "family": "early_exit_protections", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "68c5b6125898bb9da70728b8921259c87e96171d", + "ftw_commit": "2fad850dddba752294301b4555a251cbd71a220c", "record_count": 21, "records": [ { @@ -65,11 +65,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5000, "effective_export_ceiling_w": 11040, "import_ceiling_binding": true, @@ -135,11 +132,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 9000, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -200,7 +194,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -960, + "target_w": -1649.9999999999993, "clamped": true } ], @@ -213,21 +207,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 2.391304347826086, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -960, + "battery_target_sum_w": -1649.9999999999993, "raw_grid_w": 12000, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -283,11 +274,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -379,11 +367,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 6000, "effective_export_ceiling_w": 11040, "import_ceiling_binding": true, @@ -449,11 +434,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5000, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -519,7 +501,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1380, + "target_w": -2070, "clamped": true } ], @@ -532,11 +514,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 460, + "phase_over_a": 3, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -544,9 +523,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1380, + "battery_target_sum_w": -2070, "raw_grid_w": 0, - "projected_grid_w": -1380 + "projected_grid_w": -2070 } }, { @@ -607,11 +586,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -690,11 +666,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 460, + "phase_over_a": 2, + "phase_export_over_a": 0, "effective_import_ceiling_w": 10350, "effective_export_ceiling_w": 10350, "import_ceiling_binding": false, @@ -771,11 +744,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 2300, + "phase_over_a": 0, + "phase_export_over_a": 11, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -841,7 +811,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1610, + "target_w": -1840, "clamped": true } ], @@ -854,21 +824,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1610, + "phase_over_a": 8, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5750, "effective_export_ceiling_w": 5750, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1610, + "battery_target_sum_w": -1840, "raw_grid_w": 7360, - "projected_grid_w": 5750 + "projected_grid_w": 5520 } }, { @@ -929,11 +896,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5750, "effective_export_ceiling_w": 5750, "import_ceiling_binding": false, @@ -1009,7 +973,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2070, + "target_w": -2760, "clamped": true } ], @@ -1022,11 +986,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 690, + "phase_over_a": 4, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1034,9 +995,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -2070, + "battery_target_sum_w": -2760, "raw_grid_w": 20, - "projected_grid_w": -2050 + "projected_grid_w": -2740 } }, { @@ -1099,12 +1060,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 4000, "effective_export_ceiling_w": 11040, "import_ceiling_binding": true, @@ -1170,7 +1128,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2760, + "target_w": -3450, "clamped": true } ], @@ -1182,12 +1140,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 920, + "fuse_saturated": true, + "phase_over_a": 5, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1195,9 +1150,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -2760, + "battery_target_sum_w": -3450, "raw_grid_w": 1000, - "projected_grid_w": -1760 + "projected_grid_w": -2450 } }, { @@ -1253,7 +1208,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1150, + "target_w": -1380, "clamped": true } ], @@ -1265,22 +1220,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1150, + "fuse_saturated": true, + "phase_over_a": 6, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5750, "effective_export_ceiling_w": 5750, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1150, + "battery_target_sum_w": -1380, "raw_grid_w": 6900, - "projected_grid_w": 5750 + "projected_grid_w": 5520 } }, { @@ -1347,11 +1299,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 8000, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1425,11 +1374,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 4000, "effective_export_ceiling_w": 11040, "import_ceiling_binding": true, @@ -1495,7 +1441,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3450, + "target_w": -4140, "clamped": true } ], @@ -1508,11 +1454,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1150, + "phase_over_a": 6, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1520,9 +1463,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -3450, + "battery_target_sum_w": -4140, "raw_grid_w": 2000, - "projected_grid_w": -1450 + "projected_grid_w": -2140 } }, { @@ -1578,7 +1521,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1610, + "target_w": -1840, "clamped": true } ], @@ -1591,21 +1534,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1610, + "phase_over_a": 8, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5750, "effective_export_ceiling_w": 5750, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1610, + "battery_target_sum_w": -1840, "raw_grid_w": 7360, - "projected_grid_w": 5750 + "projected_grid_w": 5520 } }, { @@ -1666,11 +1606,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 9000, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, diff --git a/go/internal/control/testdata/golden/incident_scenarios.json b/go/internal/control/testdata/golden/incident_scenarios.json index ddee0ae59..5118b0299 100644 --- a/go/internal/control/testdata/golden/incident_scenarios.json +++ b/go/internal/control/testdata/golden/incident_scenarios.json @@ -1,7 +1,7 @@ { "family": "incident_scenarios", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "15f9edc3b973d7b9151e88d63575a9b7d615a0b3", + "ftw_commit": "2fad850dddba752294301b4555a251cbd71a220c", "record_count": 27, "records": [ { @@ -79,11 +79,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -165,11 +162,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -253,11 +247,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -347,11 +338,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -439,11 +427,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -531,11 +516,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -607,11 +589,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -689,11 +668,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -765,11 +741,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -841,11 +814,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -929,11 +899,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1015,11 +982,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1090,7 +1054,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3040, + "target_w": 2350, "clamped": true } ], @@ -1102,22 +1066,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 3040, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": 3040, + "battery_target_sum_w": 2350, "raw_grid_w": 8000, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -1181,11 +1142,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1256,7 +1214,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3540, + "target_w": 2849.9999999999995, "clamped": true } ], @@ -1268,22 +1226,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 3540, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": 3540, + "battery_target_sum_w": 2849.9999999999995, "raw_grid_w": 7500, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -1345,11 +1300,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1410,7 +1362,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 1040, + "target_w": 349.99999999999966, "clamped": true } ], @@ -1422,22 +1374,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 1040, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": 1040, + "battery_target_sum_w": 349.99999999999966, "raw_grid_w": 10000, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -1511,11 +1460,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1592,7 +1538,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 4000.0000033333336, + "target_w": 4000, "clamped": false } ], @@ -1603,11 +1549,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1615,9 +1558,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": 4000.0000033333336, + "battery_target_sum_w": 4000, "raw_grid_w": -2000, - "projected_grid_w": 2000.0000033333336 + "projected_grid_w": 2000 } }, { @@ -1673,11 +1616,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1766,11 +1706,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1854,11 +1791,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1940,11 +1874,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2026,11 +1957,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2112,11 +2040,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2198,11 +2123,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2274,11 +2196,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, diff --git a/go/internal/control/testdata/golden/seeded_ev_boost.json b/go/internal/control/testdata/golden/seeded_ev_boost.json index f4fd9c77b..f9d6f15fc 100644 --- a/go/internal/control/testdata/golden/seeded_ev_boost.json +++ b/go/internal/control/testdata/golden/seeded_ev_boost.json @@ -1,7 +1,7 @@ { "family": "seeded_ev_boost", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "303f86818a95f056f68e0b652db131a33074a421", + "ftw_commit": "2fad850dddba752294301b4555a251cbd71a220c", "record_count": 50, "records": [ { @@ -80,11 +80,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -155,7 +152,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 5000, + "target_w": 4768.999999999998, "clamped": true } ], @@ -168,11 +165,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": true, - "fuse_ev_max_w": 4276, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -180,9 +174,9 @@ }, "site_totals": { "battery_current_w": -2101, - "battery_target_sum_w": 5000, + "battery_target_sum_w": 4768.999999999998, "raw_grid_w": 3480, - "projected_grid_w": 10581 + "projected_grid_w": 10349.999999999998 } }, { @@ -262,11 +256,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -368,11 +359,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -455,12 +443,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3704.0000041155554, + "target_w": 3704, "clamped": false }, { "driver": "sungrow", - "target_w": 3704.0000041155554, + "target_w": 3704, "clamped": false } ], @@ -471,11 +459,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -483,9 +468,9 @@ }, "site_totals": { "battery_current_w": 5916, - "battery_target_sum_w": 7408.000008231111, + "battery_target_sum_w": 7408, "raw_grid_w": -3380, - "projected_grid_w": -1887.9999917688892 + "projected_grid_w": -1888 } }, { @@ -559,11 +544,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -641,11 +623,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -742,11 +721,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -848,11 +824,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -943,11 +916,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1037,11 +1007,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1131,11 +1098,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1224,11 +1188,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1311,12 +1272,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 4633.163265306122, + "target_w": 4195.5, "clamped": true }, { "driver": "sungrow", - "target_w": 4447.836734693878, + "target_w": 4195.5, "clamped": true } ], @@ -1330,21 +1291,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": true, - "fuse_ev_max_w": 2067, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 9081, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -4875, - "battery_target_sum_w": 9081, + "battery_target_sum_w": 8391, "raw_grid_w": -2916, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -1418,7 +1376,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1627, + "target_w": -937, "clamped": true } ], @@ -1431,21 +1389,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 1627, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 3762, - "battery_target_sum_w": -1627, + "battery_target_sum_w": -937, "raw_grid_w": -5651, - "projected_grid_w": -11040 + "projected_grid_w": -10350 } }, { @@ -1513,11 +1468,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1600,12 +1552,9 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": true, - "fuse_ev_max_w": 7276, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": false, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1695,7 +1644,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -494.3225806451613, + "target_w": -587, "clamped": true }, { @@ -1714,11 +1663,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 1277, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1726,9 +1672,9 @@ }, "site_totals": { "battery_current_w": 6936, - "battery_target_sum_w": -494.3225806451613, + "battery_target_sum_w": -587, "raw_grid_w": -2827, - "projected_grid_w": -10257.322580645161 + "projected_grid_w": -10350 } }, { @@ -1813,11 +1759,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1929,11 +1872,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2028,7 +1968,7 @@ }, { "driver": "sungrow", - "target_w": -2898.5, + "target_w": -5000, "clamped": true } ], @@ -2042,11 +1982,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 5797, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2054,9 +1991,9 @@ }, "site_totals": { "battery_current_w": 1539, - "battery_target_sum_w": -2898.5, + "battery_target_sum_w": -5000, "raw_grid_w": -3704, - "projected_grid_w": -8141.5 + "projected_grid_w": -10243 } }, { @@ -2125,11 +2062,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2219,11 +2153,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2331,11 +2262,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2424,11 +2352,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2519,11 +2444,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2607,7 +2529,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -4616.000004277494, + "target_w": -4616, "clamped": false } ], @@ -2618,11 +2540,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2630,9 +2549,9 @@ }, "site_totals": { "battery_current_w": -2895, - "battery_target_sum_w": -4616.000004277494, + "battery_target_sum_w": -4616, "raw_grid_w": -3933, - "projected_grid_w": -5654.000004277494 + "projected_grid_w": -5654 } }, { @@ -2717,11 +2636,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2805,11 +2721,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2897,7 +2810,7 @@ }, { "driver": "sungrow", - "target_w": -4955.17808723505, + "target_w": -4955.178082191781, "clamped": false } ], @@ -2910,11 +2823,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2922,9 +2832,9 @@ }, "site_totals": { "battery_current_w": 1004, - "battery_target_sum_w": -4955.17808723505, + "battery_target_sum_w": -4955.178082191781, "raw_grid_w": 6871, - "projected_grid_w": 911.82191276495 + "projected_grid_w": 911.821917808219 } }, { @@ -2992,11 +2902,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3073,7 +2980,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3931, + "target_w": 3241, "clamped": true } ], @@ -3085,22 +2992,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 3931, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -1389, - "battery_target_sum_w": 3931, + "battery_target_sum_w": 3241, "raw_grid_w": 5720, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -3161,7 +3065,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3983.9999999999995, + "target_w": -3293.999999999998, "clamped": true } ], @@ -3174,21 +3078,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 3984, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 3750, - "battery_target_sum_w": -3983.9999999999995, + "battery_target_sum_w": -3293.999999999998, "raw_grid_w": -3306, - "projected_grid_w": -11040 + "projected_grid_w": -10349.999999999998 } }, { @@ -3267,11 +3168,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3348,7 +3246,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 4320.0000043968, + "target_w": 4320, "clamped": false } ], @@ -3359,11 +3257,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3371,9 +3266,9 @@ }, "site_totals": { "battery_current_w": -1903, - "battery_target_sum_w": 4320.0000043968, + "battery_target_sum_w": 4320, "raw_grid_w": 1130, - "projected_grid_w": 7353.0000043968 + "projected_grid_w": 7353 } }, { @@ -3478,11 +3373,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3554,11 +3446,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3646,7 +3535,7 @@ }, { "driver": "sungrow", - "target_w": 3883.938938307094, + "target_w": 3974.6031746031745, "clamped": false } ], @@ -3658,12 +3547,9 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": true, - "fuse_ev_max_w": 3574, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": false, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3671,9 +3557,9 @@ }, "site_totals": { "battery_current_w": -1659, - "battery_target_sum_w": 8883.938938307094, + "battery_target_sum_w": 8974.603174603175, "raw_grid_w": -325, - "projected_grid_w": 10217.938938307094 + "projected_grid_w": 10308.603174603175 } }, { @@ -3759,12 +3645,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1076.5, + "target_w": -731.5000000000001, "clamped": true }, { "driver": "sungrow", - "target_w": -1076.5, + "target_w": -731.5000000000001, "clamped": true } ], @@ -3778,21 +3664,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 2153, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 3315, - "battery_target_sum_w": -2153, + "battery_target_sum_w": -1463.0000000000002, "raw_grid_w": -5572, - "projected_grid_w": -11040 + "projected_grid_w": -10350 } }, { @@ -3879,11 +3762,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3966,12 +3846,9 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": true, - "fuse_ev_max_w": 6022, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": false, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4062,11 +3939,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4151,11 +4025,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4245,11 +4116,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4339,7 +4207,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -4237, + "target_w": -5000, "clamped": true }, { @@ -4358,11 +4226,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 8474, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4370,9 +4235,9 @@ }, "site_totals": { "battery_current_w": -2344, - "battery_target_sum_w": -4237, + "battery_target_sum_w": -5000, "raw_grid_w": -4910, - "projected_grid_w": -6803 + "projected_grid_w": -7566 } }, { @@ -4436,11 +4301,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4530,11 +4392,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4631,11 +4490,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4707,11 +4563,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4794,12 +4647,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 1899.5, + "target_w": 1768.1971112999154, "clamped": true }, { "driver": "sungrow", - "target_w": 1899.5, + "target_w": 1340.8028887000853, "clamped": true } ], @@ -4813,21 +4666,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": true, - "fuse_ev_max_w": 4212, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 3799, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 315, - "battery_target_sum_w": 3799, + "battery_target_sum_w": 3109.000000000001, "raw_grid_w": 7556, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } } ] diff --git a/go/internal/control/testdata/golden/seeded_fuse.json b/go/internal/control/testdata/golden/seeded_fuse.json index 0100f4d67..473e8940b 100644 --- a/go/internal/control/testdata/golden/seeded_fuse.json +++ b/go/internal/control/testdata/golden/seeded_fuse.json @@ -1,7 +1,7 @@ { "family": "seeded_fuse", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "303f86818a95f056f68e0b652db131a33074a421", + "ftw_commit": "2fad850dddba752294301b4555a251cbd71a220c", "record_count": 80, "records": [ { @@ -69,30 +69,26 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3245.228082551051, + "target_w": -2165, "clamped": true }, { "driver": "sungrow", - "target_w": -3647.771917448948, - "clamped": true + "target_w": -2326.4444444444443, + "clamped": false } ], "clamp_attributions": { "clamped_drivers": [ - "ferroamp", - "sungrow" + "ferroamp" ], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1011.9999999999997, + "fuse_saturated": true, + "phase_over_a": 4.399999999999999, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5620, "effective_export_ceiling_w": 5580, "import_ceiling_binding": false, @@ -100,9 +96,9 @@ }, "site_totals": { "battery_current_w": 1432, - "battery_target_sum_w": -6892.999999999999, + "battery_target_sum_w": -4491.444444444444, "raw_grid_w": 8815, - "projected_grid_w": 490.0000000000009 + "projected_grid_w": 2891.5555555555557 } }, { @@ -209,11 +205,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1195.9999999999998, + "phase_over_a": 0, + "phase_export_over_a": 6.199999999999999, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -291,22 +284,21 @@ }, { "driver": "sungrow", - "target_w": 0, - "clamped": false + "target_w": -582, + "clamped": true } ], "clamp_attributions": { - "clamped_drivers": [], + "clamped_drivers": [ + "sungrow" + ], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -314,9 +306,9 @@ }, "site_totals": { "battery_current_w": -1857, - "battery_target_sum_w": 0, + "battery_target_sum_w": -582, "raw_grid_w": 14791, - "projected_grid_w": 16648 + "projected_grid_w": 16066 } }, { @@ -383,7 +375,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": 1670, "clamped": true } ], @@ -391,16 +383,13 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 7.286956521739132, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 7395, "import_ceiling_binding": false, @@ -408,9 +397,9 @@ }, "site_totals": { "battery_current_w": 1670, - "battery_target_sum_w": 0, + "battery_target_sum_w": 1670, "raw_grid_w": -21588, - "projected_grid_w": -23258 + "projected_grid_w": -21588 } }, { @@ -488,12 +477,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0.12463768115942031, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -576,11 +562,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 253.00000000000034, + "phase_over_a": 0, + "phase_export_over_a": 2.1000000000000014, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -658,12 +641,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1035, + "fuse_saturated": true, + "phase_over_a": 5.5, + "phase_export_over_a": 0, "effective_import_ceiling_w": 4300, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -757,12 +737,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": 1018, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": -0, "clamped": true } ], @@ -771,16 +751,13 @@ "ferroamp", "sungrow" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 184.00000000000017, + "phase_over_a": 0, + "phase_export_over_a": 1.8000000000000007, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -788,9 +765,9 @@ }, "site_totals": { "battery_current_w": -224, - "battery_target_sum_w": 0, + "battery_target_sum_w": 1018, "raw_grid_w": -23992, - "projected_grid_w": -23768 + "projected_grid_w": -22750 } }, { @@ -870,12 +847,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2534.523809523809, - "clamped": true + "target_w": -2017.0238095238092, + "clamped": false }, { "driver": "pixii", - "target_w": -2534.523809523809, + "target_w": -3227, "clamped": true }, { @@ -886,7 +863,6 @@ ], "clamp_attributions": { "clamped_drivers": [ - "ferroamp", "pixii", "sungrow" ], @@ -894,12 +870,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 345, + "fuse_saturated": true, + "phase_over_a": 2, + "phase_export_over_a": 0, "effective_import_ceiling_w": 10695, "effective_export_ceiling_w": 10695, "import_ceiling_binding": false, @@ -907,9 +880,9 @@ }, "site_totals": { "battery_current_w": -3018, - "battery_target_sum_w": -10069.047619047618, + "battery_target_sum_w": -10244.02380952381, "raw_grid_w": 11913, - "projected_grid_w": 4861.952380952382 + "projected_grid_w": 4686.976190476191 } }, { @@ -994,11 +967,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1064,7 +1034,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3857, + "target_w": -3227.000000000001, "clamped": true } ], @@ -1076,22 +1046,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 598.0000000000003, + "fuse_saturated": true, + "phase_over_a": 3.1000000000000014, + "phase_export_over_a": 0, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -1088, - "battery_target_sum_w": -3857, + "battery_target_sum_w": -3227.000000000001, "raw_grid_w": 19674, - "projected_grid_w": 16905 + "projected_grid_w": 17535 } }, { @@ -1171,11 +1138,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5771, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -1270,22 +1234,21 @@ }, { "driver": "sungrow", - "target_w": -2803.2, - "clamped": false + "target_w": -3629, + "clamped": true } ], "clamp_attributions": { - "clamped_drivers": [], + "clamped_drivers": [ + "sungrow" + ], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 5.639130434782608, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 8474, "import_ceiling_binding": false, @@ -1293,9 +1256,9 @@ }, "site_totals": { "battery_current_w": -157, - "battery_target_sum_w": -8701.6, + "battery_target_sum_w": -9527.400000000001, "raw_grid_w": 14241, - "projected_grid_w": 5696.4 + "projected_grid_w": 4870.5999999999985 } }, { @@ -1380,11 +1343,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0.6289855072463766, "effective_import_ceiling_w": 7447, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1469,17 +1429,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3627.6666666666665, + "target_w": -3857.6666666666665, "clamped": true }, { "driver": "pixii", - "target_w": -3627.6666666666665, + "target_w": -3857.6666666666665, "clamped": true }, { "driver": "sungrow", - "target_w": -3627.6666666666665, + "target_w": -3857.6666666666665, "clamped": true } ], @@ -1493,22 +1453,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 10.128985507246377, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -4584, - "battery_target_sum_w": -10883, + "battery_target_sum_w": -11573, "raw_grid_w": 23549, - "projected_grid_w": 17250 + "projected_grid_w": 16560 } }, { @@ -1587,12 +1544,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -507.50000000000006, + "target_w": -324.99999999999994, "clamped": true }, { "driver": "sungrow", - "target_w": -507.50000000000006, + "target_w": -0, "clamped": true } ], @@ -1606,21 +1563,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 1015, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 2.436231884057971, "effective_import_ceiling_w": 5287, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -2006, - "battery_target_sum_w": -1015.0000000000001, + "battery_target_sum_w": -324.99999999999994, "raw_grid_w": -18241, - "projected_grid_w": -17250 + "projected_grid_w": -16560 } }, { @@ -1688,30 +1642,24 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3402.6442494330904, - "clamped": true + "target_w": -1518.5290322580647, + "clamped": false }, { "driver": "sungrow", - "target_w": -3145.9557505669086, - "clamped": true + "target_w": -959.0709677419356, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp", - "sungrow" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1356.9999999999998, + "fuse_saturated": true, + "phase_over_a": 6.899999999999999, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5311, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1719,9 +1667,9 @@ }, "site_totals": { "battery_current_w": 2317, - "battery_target_sum_w": -6548.5999999999985, + "battery_target_sum_w": -2477.6000000000004, "raw_grid_w": 7991, - "projected_grid_w": -874.5999999999985 + "projected_grid_w": 3196.3999999999996 } }, { @@ -1789,11 +1737,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 2.8594202898550733, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1883,17 +1828,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -644.0000000000001, + "target_w": -323.0000000000001, "clamped": true }, { "driver": "pixii", - "target_w": -644.0000000000001, + "target_w": -323.0000000000001, "clamped": true }, { "driver": "sungrow", - "target_w": -644.0000000000001, + "target_w": -3823, "clamped": true } ], @@ -1907,12 +1852,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 644.0000000000001, + "fuse_saturated": true, + "phase_over_a": 3.3000000000000007, + "phase_export_over_a": 0, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, "import_ceiling_binding": false, @@ -1920,9 +1862,9 @@ }, "site_totals": { "battery_current_w": 1308, - "battery_target_sum_w": -1932.0000000000005, + "battery_target_sum_w": -4469, "raw_grid_w": 13971, - "projected_grid_w": 10731 + "projected_grid_w": 8194 } }, { @@ -2018,17 +1960,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": -0, "clamped": true }, { "driver": "pixii", - "target_w": 0, + "target_w": -0, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": 3541, "clamped": true } ], @@ -2038,16 +1980,13 @@ "pixii", "sungrow" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 920, + "phase_over_a": 0, + "phase_export_over_a": 4, "effective_import_ceiling_w": 16560, "effective_export_ceiling_w": 16560, "import_ceiling_binding": false, @@ -2055,9 +1994,9 @@ }, "site_totals": { "battery_current_w": 1091, - "battery_target_sum_w": 0, + "battery_target_sum_w": 3541, "raw_grid_w": -20267, - "projected_grid_w": -21358 + "projected_grid_w": -17817 } }, { @@ -2121,11 +2060,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2203,11 +2139,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 5.142028985507245, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2285,12 +2218,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 505.99999999999983, + "fuse_saturated": true, + "phase_over_a": 3.1999999999999993, + "phase_export_over_a": 0, "effective_import_ceiling_w": 6067, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -2379,12 +2309,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": 420, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": 3739, "clamped": true } ], @@ -2393,16 +2323,13 @@ "ferroamp", "sungrow" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 8.901449275362317, "effective_import_ceiling_w": 6700, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -2410,9 +2337,9 @@ }, "site_totals": { "battery_current_w": 4159, - "battery_target_sum_w": 0, + "battery_target_sum_w": 4159, "raw_grid_w": -22702, - "projected_grid_w": -26861 + "projected_grid_w": -22702 } }, { @@ -2480,30 +2407,24 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2820.0999999999995, - "clamped": true + "target_w": -1026.1, + "clamped": false }, { "driver": "sungrow", - "target_w": -2820.0999999999995, - "clamped": true + "target_w": -1026.1, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp", - "sungrow" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1195.9999999999998, + "fuse_saturated": true, + "phase_over_a": 5.699999999999999, + "phase_export_over_a": 0, "effective_import_ceiling_w": 10695, "effective_export_ceiling_w": 10695, "import_ceiling_binding": false, @@ -2511,9 +2432,9 @@ }, "site_totals": { "battery_current_w": 4591, - "battery_target_sum_w": -5640.199999999999, + "battery_target_sum_w": -2052.2, "raw_grid_w": 11072, - "projected_grid_w": 840.8000000000011 + "projected_grid_w": 4428.8 } }, { @@ -2581,11 +2502,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 7.3463768115942045, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2676,12 +2594,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 4.865217391304348, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -2770,12 +2685,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1026.5, + "target_w": -249.1749174917494, "clamped": true }, { "driver": "sungrow", - "target_w": -1026.5, + "target_w": -1113.825082508251, "clamped": true } ], @@ -2789,21 +2704,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 2053, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 656, - "battery_target_sum_w": -2053, + "battery_target_sum_w": -1363.0000000000005, "raw_grid_w": -14541, - "projected_grid_w": -17250 + "projected_grid_w": -16560 } }, { @@ -2866,12 +2778,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 4.763768115942028, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2949,11 +2858,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3019,7 +2925,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -5000, + "target_w": -1954.999999999999, "clamped": true } ], @@ -3031,12 +2937,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, "import_ceiling_binding": false, @@ -3044,9 +2947,9 @@ }, "site_totals": { "battery_current_w": -2369, - "battery_target_sum_w": -5000, + "battery_target_sum_w": -1954.999999999999, "raw_grid_w": 22237, - "projected_grid_w": 19606 + "projected_grid_w": 22651 } }, { @@ -3137,17 +3040,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": 2682.924961715161, "clamped": true }, { "driver": "pixii", - "target_w": 0, + "target_w": -0, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": 2867.075038284839, "clamped": true } ], @@ -3157,16 +3060,13 @@ "pixii", "sungrow" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0.5637681159420289, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -3174,9 +3074,9 @@ }, "site_totals": { "battery_current_w": 5161, - "battery_target_sum_w": 0, + "battery_target_sum_w": 5550, "raw_grid_w": -16949, - "projected_grid_w": -22110 + "projected_grid_w": -16560 } }, { @@ -3239,12 +3139,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 6.959420289855071, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3361,11 +3258,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 206.99999999999966, + "phase_over_a": 0, + "phase_export_over_a": 1.8999999999999986, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3454,11 +3348,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -3564,17 +3455,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": -0, "clamped": true }, { "driver": "pixii", - "target_w": 0, + "target_w": 953, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": 2088, "clamped": true } ], @@ -3584,16 +3475,13 @@ "pixii", "sungrow" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 275.99999999999983, + "phase_over_a": 0, + "phase_export_over_a": 2.1999999999999993, "effective_import_ceiling_w": 5718, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -3601,9 +3489,9 @@ }, "site_totals": { "battery_current_w": 3026, - "battery_target_sum_w": 0, + "battery_target_sum_w": 3041, "raw_grid_w": -19353, - "projected_grid_w": -22379 + "projected_grid_w": -19338 } }, { @@ -3704,11 +3592,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 10350, "effective_export_ceiling_w": 10350, "import_ceiling_binding": false, @@ -3808,11 +3693,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1334.0000000000002, + "phase_over_a": 0, + "phase_export_over_a": 6.300000000000001, "effective_import_ceiling_w": 10695, "effective_export_ceiling_w": 10695, "import_ceiling_binding": false, @@ -3897,8 +3779,8 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, - "clamped": false + "target_w": -32.89908256880733, + "clamped": true }, { "driver": "pixii", @@ -3907,22 +3789,22 @@ }, { "driver": "sungrow", - "target_w": 0, - "clamped": false + "target_w": -1760.100917431192, + "clamped": true } ], "clamp_attributions": { - "clamped_drivers": [], + "clamped_drivers": [ + "ferroamp", + "sungrow" + ], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 8138, "import_ceiling_binding": false, @@ -3930,9 +3812,9 @@ }, "site_totals": { "battery_current_w": 500, - "battery_target_sum_w": 0, + "battery_target_sum_w": -1792.9999999999995, "raw_grid_w": 14974, - "projected_grid_w": 14474 + "projected_grid_w": 12681 } }, { @@ -4004,7 +3886,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": 1384, "clamped": true } ], @@ -4012,16 +3894,13 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 713.0000000000003, + "phase_over_a": 0, + "phase_export_over_a": 3.1000000000000014, "effective_import_ceiling_w": 16560, "effective_export_ceiling_w": 16560, "import_ceiling_binding": false, @@ -4029,9 +3908,9 @@ }, "site_totals": { "battery_current_w": 1384, - "battery_target_sum_w": 0, + "battery_target_sum_w": 1384, "raw_grid_w": -21722, - "projected_grid_w": -23106 + "projected_grid_w": -21722 } }, { @@ -4109,12 +3988,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0.2260869565217387, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 8579, "import_ceiling_binding": false, @@ -4192,11 +4068,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4269,12 +4142,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3832.5, + "target_w": -4177.5, "clamped": true }, { "driver": "sungrow", - "target_w": -3832.5, + "target_w": -4177.5, "clamped": true } ], @@ -4287,22 +4160,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 3.5420289855072475, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -5911, - "battery_target_sum_w": -7665, + "battery_target_sum_w": -8355, "raw_grid_w": 19004, - "projected_grid_w": 17250 + "projected_grid_w": 16560 } }, { @@ -4393,17 +4263,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -313, + "target_w": -0, "clamped": true }, { "driver": "pixii", - "target_w": -313, + "target_w": -249.0000000000008, "clamped": true }, { "driver": "sungrow", - "target_w": -313, + "target_w": -0, "clamped": true } ], @@ -4418,21 +4288,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 939, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 6597, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 2664, - "battery_target_sum_w": -939, + "battery_target_sum_w": -249.0000000000008, "raw_grid_w": -13647, - "projected_grid_w": -17250 + "projected_grid_w": -16560 } }, { @@ -4512,12 +4379,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 3.4782608695652186, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4595,11 +4459,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4689,17 +4550,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2273, + "target_w": -1175.6666666666665, "clamped": true }, { "driver": "pixii", - "target_w": -2273, + "target_w": -1175.6666666666665, "clamped": true }, { "driver": "sungrow", - "target_w": -2273, + "target_w": -1175.6666666666665, "clamped": true } ], @@ -4713,22 +4574,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 505.99999999999983, + "fuse_saturated": true, + "phase_over_a": 2.1999999999999993, + "phase_export_over_a": 0, "effective_import_ceiling_w": 16560, "effective_export_ceiling_w": 8534, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -2009, - "battery_target_sum_w": -6819, + "battery_target_sum_w": -3526.9999999999995, "raw_grid_w": 21370, - "projected_grid_w": 16560 + "projected_grid_w": 19852 } }, { @@ -4800,7 +4658,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1550, + "target_w": -0, "clamped": true } ], @@ -4808,16 +4666,13 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": false, + "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 1550, - "per_phase_overage_w": 1150, + "phase_over_a": 0, + "phase_export_over_a": 6, "effective_import_ceiling_w": 4886, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -4825,9 +4680,9 @@ }, "site_totals": { "battery_current_w": -3716, - "battery_target_sum_w": -1550, + "battery_target_sum_w": 0, "raw_grid_w": -15970, - "projected_grid_w": -13804 + "projected_grid_w": -12254 } }, { @@ -4895,30 +4750,24 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -5000, - "clamped": true + "target_w": -3102.7, + "clamped": false }, { "driver": "sungrow", - "target_w": -5000, - "clamped": true + "target_w": -3102.7, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp", - "sungrow" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1288.0000000000002, + "fuse_saturated": true, + "phase_over_a": 6.600000000000001, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4926,9 +4775,9 @@ }, "site_totals": { "battery_current_w": 2407, - "battery_target_sum_w": -10000, + "battery_target_sum_w": -6205.4, "raw_grid_w": 14354, - "projected_grid_w": 1947 + "projected_grid_w": 5741.6 } }, { @@ -5013,11 +4862,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0.7956521739130427, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5107,17 +4953,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3318.6666666666665, + "target_w": -960.9999999999998, "clamped": true }, { "driver": "pixii", - "target_w": -3318.6666666666665, + "target_w": -2865, "clamped": true }, { "driver": "sungrow", - "target_w": -3318.6666666666665, + "target_w": -960.9999999999998, "clamped": true } ], @@ -5131,22 +4977,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 45.99999999999984, + "fuse_saturated": true, + "phase_over_a": 0.1999999999999993, + "phase_export_over_a": 0, "effective_import_ceiling_w": 16560, "effective_export_ceiling_w": 16560, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -2745, - "battery_target_sum_w": -9956, + "battery_target_sum_w": -4787, "raw_grid_w": 23771, - "projected_grid_w": 16560 + "projected_grid_w": 21729 } }, { @@ -5218,7 +5061,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": -0, "clamped": true } ], @@ -5231,11 +5074,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1080.9999999999998, + "phase_over_a": 0, + "phase_export_over_a": 4.699999999999999, "effective_import_ceiling_w": 5953, "effective_export_ceiling_w": 7823, "import_ceiling_binding": false, @@ -5313,30 +5153,24 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1645.1999999999998, - "clamped": true + "target_w": -782.6999999999998, + "clamped": false }, { "driver": "sungrow", - "target_w": -1645.1999999999998, - "clamped": true + "target_w": -782.6999999999998, + "clamped": false } ], "clamp_attributions": { - "clamped_drivers": [ - "ferroamp", - "sungrow" - ], + "clamped_drivers": [], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 575, + "fuse_saturated": true, + "phase_over_a": 2.5, + "phase_export_over_a": 0, "effective_import_ceiling_w": 10350, "effective_export_ceiling_w": 10350, "import_ceiling_binding": false, @@ -5344,9 +5178,9 @@ }, "site_totals": { "battery_current_w": 3297, - "battery_target_sum_w": -3290.3999999999996, + "battery_target_sum_w": -1565.3999999999996, "raw_grid_w": 8104, - "projected_grid_w": 1516.6000000000004 + "projected_grid_w": 3241.6000000000004 } }, { @@ -5431,11 +5265,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 3.0217391304347814, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5524,11 +5355,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -5629,17 +5457,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": -0, "clamped": true }, { "driver": "pixii", - "target_w": 0, + "target_w": 900, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": 2562, "clamped": true } ], @@ -5649,16 +5477,13 @@ "pixii", "sungrow" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 7.760869565217391, "effective_import_ceiling_w": 6526, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -5666,9 +5491,9 @@ }, "site_totals": { "battery_current_w": 2166, - "battery_target_sum_w": 0, + "battery_target_sum_w": 3462, "raw_grid_w": -21915, - "projected_grid_w": -24081 + "projected_grid_w": -20619 } }, { @@ -5763,12 +5588,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0.47101449275362306, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5851,11 +5673,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 943.0000000000003, + "phase_over_a": 0, + "phase_export_over_a": 4.100000000000001, "effective_import_ceiling_w": 10350, "effective_export_ceiling_w": 10350, "import_ceiling_binding": false, @@ -5926,12 +5745,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 1.5724637681159415, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -6032,17 +5848,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1366.6666666666665, + "target_w": -804.7087342433058, "clamped": true }, { "driver": "pixii", - "target_w": -1366.6666666666665, + "target_w": -1927.6601775192064, "clamped": true }, { "driver": "sungrow", - "target_w": -1366.6666666666665, + "target_w": -677.6310882374879, "clamped": true } ], @@ -6057,21 +5873,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 4100, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 814, - "battery_target_sum_w": -4100, + "battery_target_sum_w": -3410, "raw_grid_w": -12336, - "projected_grid_w": -17250 + "projected_grid_w": -16560 } }, { @@ -6151,8 +5964,8 @@ }, { "driver": "pixii", - "target_w": -1823.019801980198, - "clamped": false + "target_w": -2837, + "clamped": true }, { "driver": "sungrow", @@ -6161,17 +5974,16 @@ } ], "clamp_attributions": { - "clamped_drivers": [], + "clamped_drivers": [ + "pixii" + ], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 2.608695652173914, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6179,9 +5991,9 @@ }, "site_totals": { "battery_current_w": -75, - "battery_target_sum_w": -7365, + "battery_target_sum_w": -8378.980198019803, "raw_grid_w": 12150, - "projected_grid_w": 4860 + "projected_grid_w": 3846.0198019801974 } }, { @@ -6288,11 +6100,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 184.00000000000017, + "phase_over_a": 0, + "phase_export_over_a": 1.8000000000000007, "effective_import_ceiling_w": 7497, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6392,22 +6201,21 @@ }, { "driver": "sungrow", - "target_w": 0, - "clamped": false + "target_w": -1287.0000000000005, + "clamped": true } ], "clamp_attributions": { - "clamped_drivers": [], + "clamped_drivers": [ + "sungrow" + ], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 16560, "effective_export_ceiling_w": 16560, "import_ceiling_binding": false, @@ -6415,9 +6223,9 @@ }, "site_totals": { "battery_current_w": 4500, - "battery_target_sum_w": 0, + "battery_target_sum_w": -1287.0000000000005, "raw_grid_w": 20047, - "projected_grid_w": 15547 + "projected_grid_w": 14260 } }, { @@ -6513,17 +6321,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2088.5, + "target_w": -2403.331993569131, "clamped": true }, { "driver": "pixii", - "target_w": -2088.5, + "target_w": -0, "clamped": true }, { "driver": "sungrow", - "target_w": -2088.5, + "target_w": -1776.6680064308678, "clamped": true } ], @@ -6538,11 +6346,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 6265.5, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0.10000000000000142, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, "import_ceiling_binding": false, @@ -6550,9 +6355,9 @@ }, "site_totals": { "battery_current_w": -4249, - "battery_target_sum_w": -6265.5, + "battery_target_sum_w": -4179.999999999999, "raw_grid_w": -14716, - "projected_grid_w": -16732.5 + "projected_grid_w": -14647 } }, { @@ -6620,12 +6425,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1150, + "fuse_saturated": true, + "phase_over_a": 6, + "phase_export_over_a": 0, "effective_import_ceiling_w": 6574, "effective_export_ceiling_w": 7458, "import_ceiling_binding": false, @@ -6720,11 +6522,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 6510, "effective_export_ceiling_w": 6372, "import_ceiling_binding": false, @@ -6797,12 +6596,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 8.552173913043475, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 5214, "import_ceiling_binding": false, @@ -6896,12 +6692,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1720.5, + "target_w": -1403.1836243226974, "clamped": true }, { "driver": "sungrow", - "target_w": -1720.5, + "target_w": -1780.816375677303, "clamped": true } ], @@ -6915,21 +6711,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 3441, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0.1999999999999993, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -3322, - "battery_target_sum_w": -3441, + "battery_target_sum_w": -3184.0000000000005, "raw_grid_w": -17131, - "projected_grid_w": -17250 + "projected_grid_w": -16993 } }, { @@ -7007,12 +6800,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 7.121739130434783, + "phase_export_over_a": 0, "effective_import_ceiling_w": 7019, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7095,11 +6885,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1080.9999999999998, + "phase_over_a": 0, + "phase_export_over_a": 5.199999999999999, "effective_import_ceiling_w": 10695, "effective_export_ceiling_w": 10695, "import_ceiling_binding": false, @@ -7165,7 +6952,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3174.000000000001, + "target_w": -1139.000000000001, "clamped": true } ], @@ -7177,12 +6964,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 1058.0000000000002, + "fuse_saturated": true, + "phase_over_a": 5.600000000000001, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -7190,9 +6974,9 @@ }, "site_totals": { "battery_current_w": 2725, - "battery_target_sum_w": -3174.000000000001, + "battery_target_sum_w": -1139.000000000001, "raw_grid_w": 14295, - "projected_grid_w": 8396 + "projected_grid_w": 10431 } }, { @@ -7259,7 +7043,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": 183, "clamped": true } ], @@ -7267,16 +7051,13 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 6.492753623188406, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 7364, "import_ceiling_binding": false, @@ -7284,9 +7065,9 @@ }, "site_totals": { "battery_current_w": 183, - "battery_target_sum_w": 0, + "battery_target_sum_w": 183, "raw_grid_w": -21040, - "projected_grid_w": -21223 + "projected_grid_w": -21040 } }, { @@ -7349,12 +7130,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 5203, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7471,11 +7249,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 184.00000000000017, + "phase_over_a": 0, + "phase_export_over_a": 1.8000000000000007, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7560,17 +7335,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2627, + "target_w": -3989, "clamped": true }, { "driver": "pixii", - "target_w": -2627, + "target_w": -2856.999999999999, "clamped": true }, { "driver": "sungrow", - "target_w": -2627, + "target_w": -2856.999999999999, "clamped": true } ], @@ -7584,22 +7359,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0.21449275362318687, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -8423, - "battery_target_sum_w": -7881, + "battery_target_sum_w": -9702.999999999998, "raw_grid_w": 16708, - "projected_grid_w": 17250 + "projected_grid_w": 15428.000000000002 } }, { @@ -7683,12 +7455,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": 273, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": -0, "clamped": true } ], @@ -7697,16 +7469,13 @@ "ferroamp", "sungrow" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 345, + "phase_over_a": 0, + "phase_export_over_a": 2, "effective_import_ceiling_w": 16905, "effective_export_ceiling_w": 16905, "import_ceiling_binding": false, @@ -7714,9 +7483,9 @@ }, "site_totals": { "battery_current_w": -1107, - "battery_target_sum_w": 0, + "battery_target_sum_w": 273, "raw_grid_w": -23409, - "projected_grid_w": -22302 + "projected_grid_w": -22029 } }, { @@ -7817,11 +7586,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 10350, "effective_export_ceiling_w": 10350, "import_ceiling_binding": false, @@ -7917,32 +7683,33 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 5000, - "clamped": false + "target_w": 4571.2304773025835, + "clamped": true }, { "driver": "pixii", - "target_w": 5000, - "clamped": false + "target_w": 4682.539045394833, + "clamped": true }, { "driver": "sungrow", - "target_w": 5000, - "clamped": false + "target_w": 4571.2304773025835, + "clamped": true } ], "clamp_attributions": { - "clamped_drivers": [], + "clamped_drivers": [ + "ferroamp", + "pixii", + "sungrow" + ], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 10350, "effective_export_ceiling_w": 10350, "import_ceiling_binding": false, @@ -7950,9 +7717,9 @@ }, "site_totals": { "battery_current_w": 853, - "battery_target_sum_w": 15000, + "battery_target_sum_w": 13825, "raw_grid_w": -7899, - "projected_grid_w": 6248 + "projected_grid_w": 5073 } }, { @@ -8020,12 +7787,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0.8999999999999986, + "phase_export_over_a": 0, "effective_import_ceiling_w": 4624, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -8131,17 +7895,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -716.6666666666667, + "target_w": 2602, "clamped": true }, { "driver": "pixii", - "target_w": -716.6666666666667, + "target_w": 2637, "clamped": true }, { "driver": "sungrow", - "target_w": -716.6666666666667, + "target_w": -0, "clamped": true } ], @@ -8156,21 +7920,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 2150, - "per_phase_overage_w": 1173.0000000000002, + "phase_over_a": 0, + "phase_export_over_a": 6.100000000000001, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 1653, - "battery_target_sum_w": -2150, + "battery_target_sum_w": 5239, "raw_grid_w": -13447, - "projected_grid_w": -17250 + "projected_grid_w": -9861 } } ] diff --git a/go/internal/control/testdata/golden/seeded_planner.json b/go/internal/control/testdata/golden/seeded_planner.json index 68c8f64df..f21aba7fe 100644 --- a/go/internal/control/testdata/golden/seeded_planner.json +++ b/go/internal/control/testdata/golden/seeded_planner.json @@ -1,7 +1,7 @@ { "family": "seeded_planner", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "15f9edc3b973d7b9151e88d63575a9b7d615a0b3", + "ftw_commit": "2fad850dddba752294301b4555a251cbd71a220c", "record_count": 100, "records": [ { @@ -81,11 +81,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -161,11 +158,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -248,12 +242,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -4250, + "target_w": -4560.599520383693, "clamped": true }, { "driver": "sungrow", - "target_w": -4250, + "target_w": -3249.4004796163067, "clamped": true } ], @@ -267,21 +261,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 8500, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -73, - "battery_target_sum_w": -8500, + "battery_target_sum_w": -7810, "raw_grid_w": -2613, - "projected_grid_w": -11040 + "projected_grid_w": -10350 } }, { @@ -379,11 +370,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -452,7 +440,7 @@ "strategy": "self_consumption", "planned_grid_w": -2028, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap": 0.93, + "live_pv_surplus_soc_cap": 0.9346559122157528, "elapsed_s": 357, "remaining_s": 689 } @@ -471,11 +459,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -559,11 +544,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -647,11 +629,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -756,11 +735,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -835,7 +811,7 @@ "strategy": "self_consumption", "planned_grid_w": 2456, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap": 0.86, + "live_pv_surplus_soc_cap": 0.8587472677682063, "elapsed_s": 110, "remaining_s": 220 } @@ -859,11 +835,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -953,11 +926,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1039,11 +1009,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1142,11 +1109,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1251,11 +1215,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1363,11 +1324,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1438,7 +1396,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3376.7441889520824, + "target_w": -3376.7441860465115, "clamped": false } ], @@ -1449,11 +1407,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1461,9 +1416,9 @@ }, "site_totals": { "battery_current_w": -465, - "battery_target_sum_w": -3376.7441889520824, + "battery_target_sum_w": -3376.7441860465115, "raw_grid_w": 1767, - "projected_grid_w": -1144.7441889520824 + "projected_grid_w": -1144.7441860465115 } }, { @@ -1536,12 +1491,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -4275.610107612486, + "target_w": -4275.610104181533, "clamped": false }, { "driver": "sungrow", - "target_w": -4453.760528763006, + "target_w": -4453.760525189096, "clamped": false } ], @@ -1552,11 +1507,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1564,9 +1516,9 @@ }, "site_totals": { "battery_current_w": 460, - "battery_target_sum_w": -8729.370636375492, + "battery_target_sum_w": -8729.37062937063, "raw_grid_w": 3414, - "projected_grid_w": -5775.370636375492 + "projected_grid_w": -5775.370629370629 } }, { @@ -1646,11 +1598,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1749,11 +1698,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1825,7 +1771,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.84, + "pv_surplus_absorb_soc_cap": 0.8417180764157632, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -1842,12 +1788,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -462.4999999999991, + "target_w": -117.5, "clamped": true }, { "driver": "sungrow", - "target_w": -462.4999999999991, + "target_w": -117.5, "clamped": true } ], @@ -1861,21 +1807,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 924.9999999999982, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 6081, - "battery_target_sum_w": -924.9999999999982, + "battery_target_sum_w": -235, "raw_grid_w": -4034, - "projected_grid_w": -11039.999999999998 + "projected_grid_w": -10350 } }, { @@ -1964,11 +1907,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2056,11 +1996,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2148,11 +2085,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2251,11 +2185,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2360,11 +2291,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2469,11 +2397,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2561,11 +2486,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2628,7 +2550,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap": 0.92, + "live_pv_surplus_soc_cap": 0.9169474781726529, "elapsed_s": 392, "remaining_s": 582 } @@ -2649,11 +2571,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2755,11 +2674,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2861,11 +2777,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2942,7 +2855,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2761, + "target_w": -2071, "clamped": true } ], @@ -2955,21 +2868,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 2761, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 2040, - "battery_target_sum_w": -2761, + "battery_target_sum_w": -2071, "raw_grid_w": -6239, - "projected_grid_w": -11040 + "projected_grid_w": -10350 } }, { @@ -3043,11 +2953,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3129,11 +3036,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3238,11 +3142,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3319,7 +3220,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3855.6213055737335, + "target_w": 3855.621301775148, "clamped": false } ], @@ -3330,11 +3231,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3342,9 +3240,9 @@ }, "site_totals": { "battery_current_w": -1381, - "battery_target_sum_w": 3855.6213055737335, + "battery_target_sum_w": 3855.621301775148, "raw_grid_w": -1360, - "projected_grid_w": 3876.6213055737335 + "projected_grid_w": 3876.621301775148 } }, { @@ -3403,7 +3301,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -600, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap": 0.77, + "live_pv_surplus_soc_cap": 0.7709719503840469, "elapsed_s": 225, "remaining_s": 623 } @@ -3424,11 +3322,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3533,11 +3428,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3627,11 +3519,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3691,7 +3580,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.73, + "pv_surplus_absorb_soc_cap": 0.7315028887007622, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -3719,11 +3608,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3798,7 +3684,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": -23, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap": 0.91, + "live_pv_surplus_soc_cap": 0.9066230931563072, "elapsed_s": 184, "remaining_s": 309 } @@ -3822,11 +3708,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3909,27 +3792,27 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, - "clamped": false + "target_w": 734.2923630209435, + "clamped": true }, { "driver": "sungrow", - "target_w": 0, - "clamped": false + "target_w": 333.7076369790565, + "clamped": true } ], "clamp_attributions": { - "clamped_drivers": [], + "clamped_drivers": [ + "ferroamp", + "sungrow" + ], "all_targets_zero_clamped": false, "plan_sign_intent": -1, "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3937,9 +3820,9 @@ }, "site_totals": { "battery_current_w": 4727, - "battery_target_sum_w": 0, + "battery_target_sum_w": 1068, "raw_grid_w": -6691, - "projected_grid_w": -11418 + "projected_grid_w": -10350 } }, { @@ -4011,11 +3894,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4103,11 +3983,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4215,11 +4092,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4302,12 +4176,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3724.5, + "target_w": 3150.1141552511417, "clamped": true }, { "driver": "sungrow", - "target_w": 3724.5, + "target_w": 3608.8858447488587, "clamped": true } ], @@ -4320,22 +4194,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 7449, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 953, - "battery_target_sum_w": 7449, + "battery_target_sum_w": 6759, "raw_grid_w": 4544, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -4407,11 +4278,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4493,11 +4361,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4585,11 +4450,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4688,11 +4550,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4797,11 +4656,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4891,11 +4747,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4964,7 +4817,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap": 0.6, + "live_pv_surplus_soc_cap": 0.600069383012964, "elapsed_s": 82, "remaining_s": 541 } @@ -4985,11 +4838,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5077,11 +4927,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5147,7 +4994,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.82, + "pv_surplus_absorb_soc_cap": 0.8170109976831964, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -5180,11 +5027,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5289,11 +5133,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5392,11 +5233,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5465,7 +5303,7 @@ "strategy": "arbitrage", "planned_grid_w": 58, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap": 0.69, + "live_pv_surplus_soc_cap": 0.6876649891490324, "elapsed_s": 85, "remaining_s": 572 } @@ -5484,11 +5322,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5551,7 +5386,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap": 0.84, + "live_pv_surplus_soc_cap": 0.8360543416220111, "elapsed_s": 582, "remaining_s": 307 } @@ -5570,11 +5405,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5656,11 +5488,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5759,11 +5588,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5862,11 +5688,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5948,11 +5771,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6034,11 +5854,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6126,11 +5943,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6196,7 +6010,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.72, + "pv_surplus_absorb_soc_cap": 0.720847646748382, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -6213,12 +6027,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 977.8064516129032, + "target_w": 977.8064516129025, "clamped": false }, { "driver": "sungrow", - "target_w": 1548.1935483870968, + "target_w": 1548.1935483870957, "clamped": false } ], @@ -6229,11 +6043,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6241,9 +6052,9 @@ }, "site_totals": { "battery_current_w": -2798, - "battery_target_sum_w": 2526, + "battery_target_sum_w": 2525.999999999998, "raw_grid_w": -3154, - "projected_grid_w": 2170 + "projected_grid_w": 2169.999999999998 } }, { @@ -6302,7 +6113,7 @@ "strategy": "self_consumption", "planned_grid_w": 2519, "has_planned_grid_w": true, - "live_pv_surplus_soc_cap": 0.63, + "live_pv_surplus_soc_cap": 0.626232970478788, "elapsed_s": 270, "remaining_s": 706 } @@ -6321,11 +6132,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6421,11 +6229,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6513,11 +6318,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6605,11 +6407,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6714,11 +6513,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6796,11 +6592,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6888,11 +6681,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6981,12 +6771,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 4669, + "target_w": 4324, "clamped": true }, { "driver": "sungrow", - "target_w": 4669, + "target_w": 4324, "clamped": true } ], @@ -6999,22 +6789,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 9338, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -2886, - "battery_target_sum_w": 9338, + "battery_target_sum_w": 8648, "raw_grid_w": -1184, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -7070,7 +6857,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.86, + "pv_surplus_absorb_soc_cap": 0.8640085831651351, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7079,7 +6866,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap": 0.92, + "live_pv_surplus_soc_cap": 0.9237344109254809, "elapsed_s": 253, "remaining_s": 707 } @@ -7103,11 +6890,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7196,12 +6980,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2370.9296403164058, + "target_w": -2370.9296384238523, "clamped": false }, { "driver": "sungrow", - "target_w": -2469.71837532959, + "target_w": -2469.7183733581796, "clamped": false } ], @@ -7212,11 +6996,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7224,9 +7005,9 @@ }, "site_totals": { "battery_current_w": 1577, - "battery_target_sum_w": -4840.648015645996, + "battery_target_sum_w": -4840.648011782032, "raw_grid_w": 6446, - "projected_grid_w": 28.351984354004344 + "projected_grid_w": 28.351988217968028 } }, { @@ -7279,7 +7060,7 @@ "strategy": "passive_arbitrage", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap": 0.82, + "live_pv_surplus_soc_cap": 0.8242908060209763, "elapsed_s": 190, "remaining_s": 608 } @@ -7287,7 +7068,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -5000, + "target_w": -4427, "clamped": true } ], @@ -7300,11 +7081,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7312,9 +7090,9 @@ }, "site_totals": { "battery_current_w": -1468, - "battery_target_sum_w": -5000, + "battery_target_sum_w": -4427, "raw_grid_w": -7391, - "projected_grid_w": -10923 + "projected_grid_w": -10350 } }, { @@ -7364,7 +7142,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.79, + "pv_surplus_absorb_soc_cap": 0.7862699024545199, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7392,11 +7170,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7465,7 +7240,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap": 0.91, + "live_pv_surplus_soc_cap": 0.906954730436936, "elapsed_s": 479, "remaining_s": 733 } @@ -7484,11 +7259,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7554,7 +7326,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.78, + "pv_surplus_absorb_soc_cap": 0.779134585689955, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -7571,12 +7343,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3743.903874385292, + "target_w": -3303.7447904996643, "clamped": true }, { "driver": "sungrow", - "target_w": -2125.0961256147093, + "target_w": -1875.255209500336, "clamped": true } ], @@ -7590,21 +7362,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 5869.000000000001, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 4842, - "battery_target_sum_w": -5869.000000000002, + "battery_target_sum_w": -5179, "raw_grid_w": -329, - "projected_grid_w": -11040.000000000002 + "projected_grid_w": -10350 } }, { @@ -7693,11 +7462,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7780,12 +7546,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 699.5, + "target_w": 354.5, "clamped": true }, { "driver": "sungrow", - "target_w": 699.5, + "target_w": 354.5, "clamped": true } ], @@ -7798,22 +7564,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 1399, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -4483, - "battery_target_sum_w": 1399, + "battery_target_sum_w": 709, "raw_grid_w": 5158, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -7884,7 +7647,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap": 0.67, + "live_pv_surplus_soc_cap": 0.6692593883812832, "elapsed_s": 90, "remaining_s": 718 } @@ -7908,11 +7671,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7996,11 +7756,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8089,12 +7846,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -5000, + "target_w": -4934.829166103527, "clamped": true }, { "driver": "sungrow", - "target_w": -5000, + "target_w": -4864.170833896473, "clamped": true } ], @@ -8108,11 +7865,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8120,9 +7874,9 @@ }, "site_totals": { "battery_current_w": 139, - "battery_target_sum_w": -10000, + "battery_target_sum_w": -9799, "raw_grid_w": -412, - "projected_grid_w": -10551 + "projected_grid_w": -10350 } }, { @@ -8211,11 +7965,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8320,11 +8071,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8425,11 +8173,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8513,11 +8258,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8588,7 +8330,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3276, + "target_w": 2586, "clamped": true } ], @@ -8600,22 +8342,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 3276, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -3029, - "battery_target_sum_w": 3276, + "battery_target_sum_w": 2586, "raw_grid_w": 4735, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -8680,7 +8419,7 @@ "strategy": "self_consumption", "planned_grid_w": 0, "has_planned_grid_w": false, - "live_pv_surplus_soc_cap": 0.79, + "live_pv_surplus_soc_cap": 0.7909721045648893, "elapsed_s": 138, "remaining_s": 562 } @@ -8704,11 +8443,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8791,12 +8527,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 4230.5, + "target_w": 3885.5, "clamped": true }, { "driver": "sungrow", - "target_w": 4230.5, + "target_w": 3885.5, "clamped": true } ], @@ -8809,22 +8545,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 8461, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -1189, - "battery_target_sum_w": 8461, + "battery_target_sum_w": 7771, "raw_grid_w": 1390, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -8919,11 +8652,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9011,11 +8741,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9075,7 +8802,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.77, + "pv_surplus_absorb_soc_cap": 0.7655297392494168, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9103,11 +8830,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9196,12 +8920,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3411, + "target_w": -2982.684885782831, "clamped": true }, { "driver": "sungrow", - "target_w": -3411, + "target_w": -3149.315114217169, "clamped": true } ], @@ -9215,21 +8939,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 6822, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 1203, - "battery_target_sum_w": -6822, + "battery_target_sum_w": -6132, "raw_grid_w": -3015, - "projected_grid_w": -11040 + "projected_grid_w": -10350 } }, { @@ -9290,7 +9011,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2422, + "target_w": -1732, "clamped": true } ], @@ -9303,21 +9024,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 2422, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 727, - "battery_target_sum_w": -2422, + "battery_target_sum_w": -1732, "raw_grid_w": -7891, - "projected_grid_w": -11040 + "projected_grid_w": -10350 } }, { @@ -9378,7 +9096,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -4768.48030502767, + "target_w": -4768.480300187617, "clamped": false } ], @@ -9389,11 +9107,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9401,9 +9116,9 @@ }, "site_totals": { "battery_current_w": 3711, - "battery_target_sum_w": -4768.48030502767, + "battery_target_sum_w": -4768.480300187617, "raw_grid_w": 7142, - "projected_grid_w": -1337.4803050276696 + "projected_grid_w": -1337.480300187617 } }, { @@ -9481,11 +9196,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9545,7 +9257,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.81, + "pv_surplus_absorb_soc_cap": 0.8143981399846733, "pv_surplus_absorb_threshold_w": 0 }, "legacy_plan": { @@ -9567,11 +9279,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9643,7 +9352,7 @@ "site_fuse_voltage": 0, "site_fuse_phases": 0, "site_fuse_safety_a": 0, - "pv_surplus_absorb_soc_cap": 0.73, + "pv_surplus_absorb_soc_cap": 0.7275713488711221, "pv_surplus_absorb_threshold_w": 0 }, "slot": { @@ -9660,12 +9369,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 2361.854606161241, + "target_w": 2232.923446889023, "clamped": false }, { "driver": "sungrow", - "target_w": 2460.265214751293, + "target_w": 2325.9619238427326, "clamped": false } ], @@ -9676,11 +9385,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9688,9 +9394,9 @@ }, "site_totals": { "battery_current_w": 4391, - "battery_target_sum_w": 4822.119820912534, + "battery_target_sum_w": 4558.885370731756, "raw_grid_w": -835, - "projected_grid_w": -403.8801790874659 + "projected_grid_w": -667.1146292682442 } }, { @@ -9779,11 +9485,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, diff --git a/go/internal/control/testdata/golden/seeded_reactive.json b/go/internal/control/testdata/golden/seeded_reactive.json index 3eca0de8b..1ac39c3eb 100644 --- a/go/internal/control/testdata/golden/seeded_reactive.json +++ b/go/internal/control/testdata/golden/seeded_reactive.json @@ -1,7 +1,7 @@ { "family": "seeded_reactive", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "68c5b6125898bb9da70728b8921259c87e96171d", + "ftw_commit": "2fad850dddba752294301b4555a251cbd71a220c", "record_count": 68, "records": [ { @@ -86,11 +86,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -162,11 +159,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -272,11 +266,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -354,11 +345,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -473,11 +461,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -573,11 +558,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -678,11 +660,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -754,11 +733,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -864,11 +840,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -957,11 +930,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1052,12 +1022,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -164.5, + "target_w": -513.5316518298714, "clamped": true }, { "driver": "sungrow", - "target_w": -164.5, + "target_w": -505.4683481701286, "clamped": true } ], @@ -1070,22 +1040,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -4044, - "battery_target_sum_w": -329, + "battery_target_sum_w": -1019, "raw_grid_w": 7325, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -1147,11 +1114,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1225,11 +1189,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1295,11 +1256,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1389,11 +1347,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1499,11 +1454,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1581,11 +1533,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1659,11 +1608,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1754,17 +1700,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 4490, + "target_w": 4224.615384615385, "clamped": true }, { "driver": "pixii", - "target_w": 4490, + "target_w": 4224.615384615385, "clamped": true }, { "driver": "sungrow", - "target_w": 2694, + "target_w": 2534.7692307692305, "clamped": true } ], @@ -1778,22 +1724,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 11674, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -5058, - "battery_target_sum_w": 11674, + "battery_target_sum_w": 10984, "raw_grid_w": -5692, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -1867,11 +1810,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1945,11 +1885,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2039,11 +1976,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2134,12 +2068,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3275.5, + "target_w": 2927.5986380933305, "clamped": true }, { "driver": "sungrow", - "target_w": 3275.5, + "target_w": 2933.401361906669, "clamped": true } ], @@ -2152,22 +2086,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 6551, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -2706, - "battery_target_sum_w": 6551, + "battery_target_sum_w": 5861, "raw_grid_w": 1783, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -2247,11 +2178,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2363,11 +2291,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2468,11 +2393,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2561,11 +2483,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2637,11 +2556,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2731,11 +2647,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2831,11 +2744,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2947,11 +2857,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3040,11 +2947,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3154,11 +3058,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3264,11 +3165,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3359,17 +3257,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 4178, + "target_w": 3485.8952216465173, "clamped": true }, { "driver": "pixii", - "target_w": 4178, + "target_w": 4171.179044329304, "clamped": true }, { "driver": "sungrow", - "target_w": 4178, + "target_w": 4186.925734024179, "clamped": true } ], @@ -3383,22 +3281,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 12534, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 1793, - "battery_target_sum_w": 12534, + "battery_target_sum_w": 11844, "raw_grid_w": 299, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -3466,11 +3361,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3548,11 +3440,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3632,11 +3521,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3721,17 +3607,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 529.4444444444445, + "target_w": 1216.0000000000002, "clamped": true }, { "driver": "pixii", - "target_w": 529.4444444444445, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": 847.1111111111112, + "target_w": 0, "clamped": true } ], @@ -3745,22 +3631,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 1906, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -3268, - "battery_target_sum_w": 1906, + "battery_target_sum_w": 1216.0000000000002, "raw_grid_w": 5866, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -3822,11 +3705,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3910,11 +3790,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4026,11 +3903,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4102,11 +3976,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4218,11 +4089,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4312,11 +4180,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4396,11 +4261,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4491,17 +4353,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 328.8888888888889, + "target_w": 137.22222222222223, "clamped": true }, { "driver": "pixii", - "target_w": 328.8888888888889, + "target_w": 137.22222222222223, "clamped": true }, { "driver": "sungrow", - "target_w": 526.2222222222223, + "target_w": 219.55555555555557, "clamped": true } ], @@ -4515,22 +4377,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 1184, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -8444, - "battery_target_sum_w": 1184, + "battery_target_sum_w": 494.00000000000006, "raw_grid_w": 1412, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -4592,11 +4451,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4712,11 +4568,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4782,11 +4635,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4877,12 +4727,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3976.1538461538457, + "target_w": 4045.9254225163195, "clamped": true }, { "driver": "sungrow", - "target_w": 6361.846153846153, + "target_w": 5602.0745774836805, "clamped": true } ], @@ -4895,22 +4745,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 10338, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 1309, - "battery_target_sum_w": 10338, + "battery_target_sum_w": 9648, "raw_grid_w": 2011, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -4972,11 +4819,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5090,11 +4934,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5166,11 +5007,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5265,11 +5103,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5347,11 +5182,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5440,11 +5272,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5536,11 +5365,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5613,12 +5439,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 577, + "target_w": 231.99999999999997, "clamped": true }, { "driver": "sungrow", - "target_w": 577, + "target_w": 231.99999999999997, "clamped": true } ], @@ -5631,22 +5457,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 1154, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -4716, - "battery_target_sum_w": 1154, + "battery_target_sum_w": 463.99999999999994, "raw_grid_w": 5170, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -5742,11 +5565,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5855,11 +5675,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5965,11 +5782,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6041,11 +5855,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6123,11 +5934,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6199,11 +6007,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6277,11 +6082,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6372,12 +6174,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -38.5, + "target_w": -200.71373493975904, "clamped": true }, { "driver": "sungrow", - "target_w": -38.5, + "target_w": -566.286265060241, "clamped": true } ], @@ -6390,22 +6192,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -4150, - "battery_target_sum_w": -77, + "battery_target_sum_w": -767, "raw_grid_w": 6967, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -6490,11 +6289,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, diff --git a/go/internal/control/testdata/golden/seeded_siblings.json b/go/internal/control/testdata/golden/seeded_siblings.json index f14522be2..c6d0c7d65 100644 --- a/go/internal/control/testdata/golden/seeded_siblings.json +++ b/go/internal/control/testdata/golden/seeded_siblings.json @@ -1,7 +1,7 @@ { "family": "seeded_siblings", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "68c5b6125898bb9da70728b8921259c87e96171d", + "ftw_commit": "2fad850dddba752294301b4555a251cbd71a220c", "record_count": 60, "records": [ { @@ -91,11 +91,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -207,11 +204,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -295,11 +289,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -412,11 +403,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -518,11 +506,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -636,11 +621,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -743,11 +725,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -861,11 +840,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -949,11 +925,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1060,12 +1033,12 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 2568.0000044676126, + "target_w": 2568, "clamped": false }, { "driver": "sungrow", - "target_w": 4066.0000070737206, + "target_w": 4066, "clamped": false } ], @@ -1076,11 +1049,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1088,9 +1058,9 @@ }, "site_totals": { "battery_current_w": 850, - "battery_target_sum_w": 6634.000011541333, + "battery_target_sum_w": 6634, "raw_grid_w": -5208, - "projected_grid_w": 576.0000115413332 + "projected_grid_w": 576 } }, { @@ -1171,11 +1141,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1289,11 +1256,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1395,11 +1359,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1513,11 +1474,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1613,11 +1571,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1730,11 +1685,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1825,11 +1777,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1943,11 +1892,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2047,11 +1993,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2162,7 +2105,7 @@ }, { "driver": "sungrow", - "target_w": 3287.6712395762556, + "target_w": 3287.671232876712, "clamped": false } ], @@ -2176,11 +2119,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2188,9 +2128,9 @@ }, "site_totals": { "battery_current_w": -479, - "battery_target_sum_w": 8287.671239576255, + "battery_target_sum_w": 8287.671232876712, "raw_grid_w": -5443, - "projected_grid_w": 3323.671239576255 + "projected_grid_w": 3323.6712328767117 } }, { @@ -2264,11 +2204,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2381,11 +2318,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2474,11 +2408,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2590,11 +2521,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2707,11 +2635,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2825,11 +2750,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2915,11 +2837,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3032,11 +2951,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3127,11 +3043,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3237,12 +3150,12 @@ }, { "driver": "pixii", - "target_w": 3243.243246846847, + "target_w": 3243.2432432432433, "clamped": false }, { "driver": "sungrow", - "target_w": 3113.5135169729733, + "target_w": 3113.5135135135138, "clamped": false } ], @@ -3255,11 +3168,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3267,9 +3177,9 @@ }, "site_totals": { "battery_current_w": -4513, - "battery_target_sum_w": 6656.75676381982, + "battery_target_sum_w": 6656.756756756757, "raw_grid_w": -5457, - "projected_grid_w": 5712.75676381982 + "projected_grid_w": 5712.756756756757 } }, { @@ -3359,11 +3269,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3475,11 +3382,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3563,11 +3467,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3680,11 +3581,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3772,7 +3670,7 @@ }, { "driver": "sungrow", - "target_w": -3200.0000029653334, + "target_w": -3200, "clamped": false } ], @@ -3785,11 +3683,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3797,9 +3692,9 @@ }, "site_totals": { "battery_current_w": -1969, - "battery_target_sum_w": -3200.0000029653334, + "battery_target_sum_w": -3200, "raw_grid_w": 5401, - "projected_grid_w": 4169.999997034667 + "projected_grid_w": 4170 } }, { @@ -3903,11 +3798,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4009,11 +3901,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4125,11 +4014,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4213,11 +4099,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4324,12 +4207,12 @@ "per_driver_targets": [ { "driver": "pixii", - "target_w": 3142.0000053333333, + "target_w": 3142, "clamped": false }, { "driver": "sungrow", - "target_w": 3142.0000053333333, + "target_w": 3142, "clamped": false } ], @@ -4340,11 +4223,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4352,9 +4232,9 @@ }, "site_totals": { "battery_current_w": -3448, - "battery_target_sum_w": 6284.000010666667, + "battery_target_sum_w": 6284, "raw_grid_w": -5478, - "projected_grid_w": 4254.000010666667 + "projected_grid_w": 4254 } }, { @@ -4435,11 +4315,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4553,11 +4430,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4657,11 +4531,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4775,11 +4646,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4875,11 +4743,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4992,11 +4857,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5085,11 +4947,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5203,11 +5062,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5309,11 +5165,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5419,12 +5272,12 @@ }, { "driver": "pixii", - "target_w": 4800.000008448, + "target_w": 4800, "clamped": false }, { "driver": "sungrow", - "target_w": 4800.000008448, + "target_w": 4800, "clamped": false } ], @@ -5437,11 +5290,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5449,9 +5299,9 @@ }, "site_totals": { "battery_current_w": -1477, - "battery_target_sum_w": 9600.000016896, + "battery_target_sum_w": 9600, "raw_grid_w": -3251, - "projected_grid_w": 7826.000016896 + "projected_grid_w": 7826 } }, { @@ -5525,11 +5375,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5642,11 +5489,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5737,11 +5581,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5855,11 +5696,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5972,11 +5810,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6091,11 +5926,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6179,11 +6011,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6296,11 +6125,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6391,11 +6217,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6501,12 +6324,12 @@ }, { "driver": "pixii", - "target_w": 1920.0000032, + "target_w": 1920, "clamped": false }, { "driver": "sungrow", - "target_w": 3840.0000064, + "target_w": 3840, "clamped": false } ], @@ -6519,11 +6342,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6531,9 +6351,9 @@ }, "site_totals": { "battery_current_w": -3067, - "battery_target_sum_w": 6060.000009599999, + "battery_target_sum_w": 6060, "raw_grid_w": -3266, - "projected_grid_w": 5861.000009599999 + "projected_grid_w": 5861 } } ] diff --git a/go/internal/control/testdata/golden/slew_limiter.json b/go/internal/control/testdata/golden/slew_limiter.json index 896d7f298..4a272495b 100644 --- a/go/internal/control/testdata/golden/slew_limiter.json +++ b/go/internal/control/testdata/golden/slew_limiter.json @@ -1,7 +1,7 @@ { "family": "slew_limiter", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "15f9edc3b973d7b9151e88d63575a9b7d615a0b3", + "ftw_commit": "2fad850dddba752294301b4555a251cbd71a220c", "record_count": 127, "records": [ { @@ -65,11 +65,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -149,11 +146,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -227,11 +221,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -311,11 +302,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -389,11 +377,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -473,11 +458,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -551,11 +533,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -635,11 +614,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -711,11 +687,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -793,11 +766,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -869,11 +839,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -951,11 +918,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1029,11 +993,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1113,11 +1074,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1189,11 +1147,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1277,11 +1232,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1365,11 +1317,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1453,11 +1402,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1541,11 +1487,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1629,11 +1572,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1715,11 +1655,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1809,11 +1746,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1897,11 +1831,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2003,11 +1934,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2097,11 +2025,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2193,11 +2118,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2295,11 +2217,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2387,11 +2306,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2479,11 +2395,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2573,11 +2486,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2661,11 +2571,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2749,11 +2656,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -2814,7 +2718,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1960, + "target_w": -2650.000000000001, "clamped": true } ], @@ -2826,22 +2730,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 6.739130434782609, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 2000, - "battery_target_sum_w": -1960, + "battery_target_sum_w": -2650.000000000001, "raw_grid_w": 15000, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -2892,7 +2793,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1960, + "target_w": -2650.000000000001, "clamped": true } ], @@ -2904,22 +2805,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 6.739130434782609, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 2000, - "battery_target_sum_w": -1960, + "battery_target_sum_w": -2650.000000000001, "raw_grid_w": 15000, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -2980,7 +2878,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1960, + "target_w": -2649.999999999999, "clamped": true } ], @@ -2992,22 +2890,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 7.4637681159420275, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 2500, - "battery_target_sum_w": -1960, + "battery_target_sum_w": -2649.999999999999, "raw_grid_w": 15500, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -3058,7 +2953,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 2040, + "target_w": 0, "clamped": true } ], @@ -3066,26 +2961,23 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 2040, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 3.840579710144926, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 4000, - "battery_target_sum_w": 2040, + "battery_target_sum_w": 0, "raw_grid_w": 13000, - "projected_grid_w": 11040 + "projected_grid_w": 9000 } }, { @@ -3142,7 +3034,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": -0, "clamped": true } ], @@ -3155,11 +3047,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 6.739130434782609, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3225,7 +3114,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2760, + "target_w": -1950, "clamped": true } ], @@ -3237,12 +3126,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 920, + "fuse_saturated": true, + "phase_over_a": 5, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3250,9 +3136,9 @@ }, "site_totals": { "battery_current_w": 1500, - "battery_target_sum_w": -2760, + "battery_target_sum_w": -1950, "raw_grid_w": 1000, - "projected_grid_w": -3260 + "projected_grid_w": -2450 } }, { @@ -3316,11 +3202,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3394,11 +3277,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3478,11 +3358,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3556,11 +3433,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3640,11 +3514,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 17250, "effective_export_ceiling_w": 17250, "import_ceiling_binding": false, @@ -3735,11 +3606,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3849,11 +3717,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -3939,11 +3804,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4017,11 +3879,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4095,11 +3954,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4184,22 +4040,23 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2005.528455284553, - "clamped": false + "target_w": -3637, + "clamped": true }, { "driver": "pixii", - "target_w": 273.7909317514909, + "target_w": -82.77102407921403, "clamped": true }, { "driver": "sungrow", - "target_w": 2245.7375235330624, + "target_w": 0, "clamped": true } ], "clamp_attributions": { "clamped_drivers": [ + "ferroamp", "pixii", "sungrow" ], @@ -4207,22 +4064,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 2519.5284552845533, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 5.2623188405797094, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 3455, - "battery_target_sum_w": 514.0000000000002, + "battery_target_sum_w": -3719.771024079214, "raw_grid_w": 13981, - "projected_grid_w": 11040 + "projected_grid_w": 6806.228975920786 } }, { @@ -4327,11 +4181,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4453,11 +4304,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4546,12 +4394,9 @@ "plan_sign_intent": -1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 7.733333333333334, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4658,11 +4503,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4746,11 +4588,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4828,11 +4667,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -4946,11 +4782,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5040,11 +4873,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5163,11 +4993,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5251,11 +5078,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5345,11 +5169,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5447,11 +5268,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5548,11 +5366,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5660,11 +5475,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5752,11 +5564,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5863,11 +5672,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -5962,17 +5768,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 504.49717972717156, + "target_w": 0, "clamped": true }, { "driver": "pixii", - "target_w": -560.3799976687259, + "target_w": -3423, "clamped": true }, { "driver": "sungrow", - "target_w": 290.8828179415545, + "target_w": 0, "clamped": true } ], @@ -5986,22 +5792,19 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 2158, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 4.904347826086955, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 2929, - "battery_target_sum_w": 235.00000000000017, + "battery_target_sum_w": -3423, "raw_grid_w": 13734, - "projected_grid_w": 11040 + "projected_grid_w": 7382 } }, { @@ -6100,11 +5903,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6199,11 +5999,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6323,11 +6120,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6451,11 +6245,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6544,12 +6335,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -35.98585322723254, + "target_w": -0, "clamped": true }, { "driver": "sungrow", - "target_w": -52.01414677276747, + "target_w": -0, "clamped": true } ], @@ -6558,26 +6349,23 @@ "ferroamp", "sungrow" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": false, + "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 88, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 7.428985507246377, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -4524, - "battery_target_sum_w": -88, + "battery_target_sum_w": 0, "raw_grid_w": -15476, - "projected_grid_w": -11040 + "projected_grid_w": -10952 } }, { @@ -6672,11 +6460,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6791,11 +6576,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6861,11 +6643,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -6972,11 +6751,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7101,11 +6877,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7231,11 +7004,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7312,7 +7082,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": 3000, "clamped": true } ], @@ -7320,16 +7090,13 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 3.327536231884057, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7337,9 +7104,9 @@ }, "site_totals": { "battery_current_w": 5377, - "battery_target_sum_w": 0, + "battery_target_sum_w": 3000, "raw_grid_w": -12646, - "projected_grid_w": -18023 + "projected_grid_w": -15023 } }, { @@ -7409,11 +7176,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7480,7 +7244,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -4534, + "target_w": -5000, "clamped": true } ], @@ -7492,22 +7256,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 4.2623188405797094, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": -2283, - "battery_target_sum_w": -4534, + "battery_target_sum_w": -5000, "raw_grid_w": 13291, - "projected_grid_w": 11040 + "projected_grid_w": 10574 } }, { @@ -7592,7 +7353,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 292, + "target_w": 0, "clamped": true }, { @@ -7602,7 +7363,7 @@ }, { "driver": "sungrow", - "target_w": 1161, + "target_w": 0, "clamped": true } ], @@ -7616,12 +7377,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 4.831884057971013, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7629,9 +7387,9 @@ }, "site_totals": { "battery_current_w": 5505, - "battery_target_sum_w": 1005, + "battery_target_sum_w": -448, "raw_grid_w": 13684, - "projected_grid_w": 9184 + "projected_grid_w": 7731 } }, { @@ -7722,12 +7480,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": true, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 5.610144927536233, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7835,11 +7590,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -7947,11 +7699,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8031,11 +7780,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8114,12 +7860,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -549.8649227568003, + "target_w": -501.81693445945285, "clamped": true }, { "driver": "sungrow", - "target_w": -3742.1350772432006, + "target_w": -4480.183065540547, "clamped": true } ], @@ -8132,22 +7878,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 8.13623188405797, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 632, - "battery_target_sum_w": -4292.000000000001, + "battery_target_sum_w": -4982, "raw_grid_w": 15964, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -8227,11 +7970,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8338,11 +8078,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8431,12 +8168,9 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": true, - "fuse_ev_max_w": 6215, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": false, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8555,11 +8289,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8620,7 +8351,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 1127, + "target_w": 0, "clamped": true } ], @@ -8628,16 +8359,13 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 4.589855072463767, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8645,9 +8373,9 @@ }, "site_totals": { "battery_current_w": 4127, - "battery_target_sum_w": 1127, + "battery_target_sum_w": 0, "raw_grid_w": 13517, - "projected_grid_w": 10517 + "projected_grid_w": 9390 } }, { @@ -8703,11 +8431,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8826,11 +8551,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -8938,11 +8660,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9050,11 +8769,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9180,11 +8896,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9300,11 +9013,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9416,11 +9126,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9510,11 +9217,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9604,11 +9308,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 4.530434782608694, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9732,11 +9433,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9824,11 +9522,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -9925,11 +9620,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10041,11 +9733,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10133,11 +9822,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10245,11 +9931,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10357,11 +10040,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10487,11 +10167,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10565,11 +10242,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10666,11 +10340,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10752,11 +10423,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10881,11 +10549,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -10992,11 +10657,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -11122,11 +10784,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -11221,11 +10880,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -11309,11 +10965,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -11414,17 +11067,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -318.56464460784315, + "target_w": -0, "clamped": true }, { "driver": "pixii", - "target_w": -36.435355392156865, + "target_w": -0, "clamped": true }, { "driver": "sungrow", - "target_w": 4004, + "target_w": 4254, "clamped": true } ], @@ -11439,21 +11092,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 355, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 5.5782608695652165, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, - "export_ceiling_binding": true + "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 490, - "battery_target_sum_w": 3649, + "battery_target_sum_w": 4254, "raw_grid_w": -14199, - "projected_grid_w": -11040 + "projected_grid_w": -10435 } }, { @@ -11563,11 +11213,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -11674,11 +11321,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -11761,12 +11405,9 @@ "plan_sign_intent": -1, "plan_sign_floor_zeroed": true, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 3.431884057971015, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -11833,7 +11474,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -502, + "target_w": -0, "clamped": true } ], @@ -11841,16 +11482,13 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 4.823188405797101, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -11858,9 +11496,9 @@ }, "site_totals": { "battery_current_w": -3502, - "battery_target_sum_w": -502, + "battery_target_sum_w": 0, "raw_grid_w": -13678, - "projected_grid_w": -10678 + "projected_grid_w": -10176 } }, { @@ -11929,39 +11567,37 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 1757.7999999999993, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": -598.7999999999998, - "clamped": false + "target_w": -1276.1320596127111, + "clamped": true } ], "clamp_attributions": { "clamped_drivers": [ - "ferroamp" + "ferroamp", + "sungrow" ], "all_targets_zero_clamped": false, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 1757.7999999999993, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 2.8811594202898547, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 2457, - "battery_target_sum_w": 1158.9999999999995, + "battery_target_sum_w": -1276.1320596127111, "raw_grid_w": 12338, - "projected_grid_w": 11040 + "projected_grid_w": 8604.867940387288 } }, { @@ -12052,12 +11688,9 @@ "plan_sign_intent": 1, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 3.850724637681161, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -12182,11 +11815,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -12276,11 +11906,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -12381,17 +12008,17 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 0, + "target_w": 2547, "clamped": true }, { "driver": "pixii", - "target_w": 0, + "target_w": 3663, "clamped": true }, { "driver": "sungrow", - "target_w": 0, + "target_w": 2535, "clamped": true } ], @@ -12401,16 +12028,13 @@ "pixii", "sungrow" ], - "all_targets_zero_clamped": true, + "all_targets_zero_clamped": false, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": true, + "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 3.6420289855072454, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -12418,9 +12042,9 @@ }, "site_totals": { "battery_current_w": 8745, - "battery_target_sum_w": 0, + "battery_target_sum_w": 8745, "raw_grid_w": -12863, - "projected_grid_w": -21608 + "projected_grid_w": -12863 } }, { @@ -12507,11 +12131,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, diff --git a/go/internal/control/testdata/golden/targeted_invariants.json b/go/internal/control/testdata/golden/targeted_invariants.json index 467ca3418..f2bb6ab10 100644 --- a/go/internal/control/testdata/golden/targeted_invariants.json +++ b/go/internal/control/testdata/golden/targeted_invariants.json @@ -1,7 +1,7 @@ { "family": "targeted_invariants", "generated_by": "FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run TestGoldenDump", - "ftw_commit": "68c5b6125898bb9da70728b8921259c87e96171d", + "ftw_commit": "2fad850dddba752294301b4555a251cbd71a220c", "record_count": 18, "records": [ { @@ -82,11 +82,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -177,11 +174,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -273,11 +267,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -369,11 +360,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -457,11 +445,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -522,7 +507,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1460, + "target_w": -2150, "clamped": true } ], @@ -534,22 +519,19 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 3.1159420289855078, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1460, + "battery_target_sum_w": -2150, "raw_grid_w": 12500, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -600,7 +582,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -1960, + "target_w": -2649.9999999999986, "clamped": true } ], @@ -613,21 +595,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 3.840579710144926, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -1960, + "battery_target_sum_w": -2649.9999999999986, "raw_grid_w": 13000, - "projected_grid_w": 11040 + "projected_grid_w": 10350.000000000002 } }, { @@ -683,7 +662,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3360, + "target_w": -3450, "clamped": true } ], @@ -695,12 +674,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 920, + "fuse_saturated": true, + "phase_over_a": 5, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -708,9 +684,9 @@ }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": -3360, + "battery_target_sum_w": -3450, "raw_grid_w": 1000, - "projected_grid_w": -2360 + "projected_grid_w": -2450 } }, { @@ -782,7 +758,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -2240, + "target_w": -0, "clamped": true } ], @@ -790,16 +766,13 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": -1, - "plan_sign_floor_zeroed": false, + "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 2240, - "per_phase_overage_w": 920, + "phase_over_a": 0, + "phase_export_over_a": 5, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -807,9 +780,9 @@ }, "site_totals": { "battery_current_w": -2000, - "battery_target_sum_w": -2240, + "battery_target_sum_w": 0, "raw_grid_w": -6000, - "projected_grid_w": -6240 + "projected_grid_w": -4000 } }, { @@ -883,11 +856,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -971,11 +941,8 @@ "plan_sign_floor_zeroed": true, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1049,11 +1016,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1127,11 +1091,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1231,11 +1192,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, @@ -1318,12 +1276,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 2476.129032258064, + "target_w": 1736.6220735785953, "clamped": true }, { "driver": "sungrow", - "target_w": 1563.8709677419351, + "target_w": 1613.3779264214047, "clamped": true } ], @@ -1337,21 +1295,18 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": true, - "fuse_ev_max_w": 6000, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 4039.999999999999, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, - "import_ceiling_binding": true, + "import_ceiling_binding": false, "export_ceiling_binding": false }, "site_totals": { "battery_current_w": 0, - "battery_target_sum_w": 4039.999999999999, + "battery_target_sum_w": 3350, "raw_grid_w": 7000, - "projected_grid_w": 11040 + "projected_grid_w": 10350 } }, { @@ -1430,12 +1385,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": -3628.7625395418668, + "target_w": -3628.7625418060206, "clamped": true }, { "driver": "sungrow", - "target_w": -3371.2374604581337, + "target_w": -3371.2374581939803, "clamped": true } ], @@ -1449,11 +1404,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": false, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 7000, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 8000, "import_ceiling_binding": false, @@ -1461,9 +1413,9 @@ }, "site_totals": { "battery_current_w": -2000, - "battery_target_sum_w": -7000, + "battery_target_sum_w": -7000.000000000001, "raw_grid_w": -3000, - "projected_grid_w": -8000 + "projected_grid_w": -8000.000000000001 } }, { @@ -1526,12 +1478,9 @@ "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, - "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": true, - "fuse_hold_max_charge_w": 2000, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "fuse_saturated": true, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 6000, "effective_export_ceiling_w": 11040, "import_ceiling_binding": true, @@ -1613,11 +1562,8 @@ "plan_sign_floor_zeroed": false, "plan_stale": true, "fuse_saturated": false, - "fuse_ev_max_w": 0, - "fuse_hold_latched": false, - "fuse_hold_max_charge_w": 0, - "fuse_hold_max_discharge_w": 0, - "per_phase_overage_w": 0, + "phase_over_a": 0, + "phase_export_over_a": 0, "effective_import_ceiling_w": 11040, "effective_export_ceiling_w": 11040, "import_ceiling_binding": false, diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index 2848e30b1..e6405152e 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -509,8 +509,8 @@ func (c *Controller) sendCycleWithDeadline( return c.send(cmdCtx, driver, payload) } -// SetFuseEVMax wires the joint allocator's verdict from control.State. -// Called once at startup from main.go. The returned (cap_w, true) is +// SetFuseEVMax wires control.EVFuseBudget, the EVs' share of the site's +// room. Called once at startup from main.go. The returned (cap_w, true) is // honored as a hard upper bound on this tick's EV command; (_, false) // means no constraint. Pass nil to disable. func (c *Controller) SetFuseEVMax(f func() (float64, bool)) { diff --git a/go/internal/loadpoint/controller_commanded_reason_test.go b/go/internal/loadpoint/controller_commanded_reason_test.go index 035597cd2..11ba80240 100644 --- a/go/internal/loadpoint/controller_commanded_reason_test.go +++ b/go/internal/loadpoint/controller_commanded_reason_test.go @@ -2,6 +2,7 @@ package loadpoint import ( "context" + "fmt" "testing" "time" ) @@ -176,3 +177,24 @@ func TestCommandedReasonManualHold(t *testing.T) { t.Errorf("hold: want (11000, charger_limit), got (%.0f, %q)", w, r) } } + +// A command that timed out may still have run: an Easee Cloud write can land +// after the driver gave up ("driver command may have run: context deadline +// exceeded"). The offer stays recorded as sent, with its phases, so fuse +// protection counts it as pending until the charger can have applied it. +func TestTimedOutCommandStillCountsAsSent(t *testing.T) { + base := time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) + sender := &fakeSender{err: fmt.Errorf("driver command may have run: %w", context.DeadlineExceeded)} + cfg := reasonLoadpoint() + samples := map[string]EVSample{cfg.DriverName: {Connected: true, PowerW: 0, RequestActive: true}} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 6900, PhaseMode: "3p", Persistent: true}) + + c.Tick(context.Background(), base) + + st, _ := c.manager.State(cfg.ID) + if len(sender.calls) == 0 || !st.CommandedKnown || st.CommandedW != 6900 || st.CommandedPhases != 3 { + t.Fatalf("after a timed-out send: %d calls, offer %.0f W known %v on %d phases; want 6,900 W on 3", + len(sender.calls), st.CommandedW, st.CommandedKnown, st.CommandedPhases) + } +} diff --git a/go/internal/sitesim/sitesim.go b/go/internal/sitesim/sitesim.go index eb1e047c1..7d05847d3 100644 --- a/go/internal/sitesim/sitesim.go +++ b/go/internal/sitesim/sitesim.go @@ -131,6 +131,7 @@ func (p *Plant) Step() Tick { r.PhaseA[i] = math.Abs(a) } } + r.W += p.Meter.TotalBiasW tick.Meter = r } if b := p.Battery; b != nil && !p.Offline("battery") { @@ -159,7 +160,8 @@ type timedW struct { // second when Every is zero. type Meter struct { Every time.Duration - Unsigned bool // phase currents as magnitudes + Unsigned bool // phase currents as magnitudes + TotalBiasW float64 // added to the total alone, as a Pixii total that disagrees with its phases last time.Time silentUntil time.Time }