From f612d94b0f7463c4e9b7e00fe363fec165344259 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 4 Aug 2026 20:46:42 +0200 Subject: [PATCH 1/2] fix(control): the slew limiter may not re-open a closed charge block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The limiter anchors every target on the battery's MEASURED output, not on the previous command. That is deliberate and right — it lets dispatch pivot the instant a setpoint reverses instead of ramping away from a stale command. But it also means a battery physically charging drags its own command back toward that charge, whatever the tick above decided. noSelfCharge pins the fleet total to 0 W. The limiter then walks it back. Nothing downstream put it right: applyFuseGuard only shrinks toward zero, floorNegativeTargets covers the discharge side only, and planSignIntent reports "idle, no opinion" for an idle slot. A passive-arbitrage idle slot, meter at -2000 W, battery live at +2000 W, SlewRateW 500, commanded +1500 W of charging on the tick whose entire purpose was to let that surplus reach the meter. The same mechanism re-opened a battery that had just reported charge_capable=false, after the distributor had already handed its share to a capable sibling — the fleet charged that share twice. The fix is a floor, floorBlockedCharge, applied after slew beside the discharge-side floor it mirrors. It bounds the output rather than enumerating the reasons the output was closed. The alternative was to complete the snap-to-zero carve-out inside the slew loop, which today names plannerSelfExportSurplusGate and manual hold. That is the enumeration, and it is how this bug was born: two charge gates were added to ComputeDispatch afterwards and neither was added to the list. It is also weaker than it looks — it only fires when the pre-slew target is already within 1 W of zero, so a total pinned to 0 by a +800/-800 split would slip through it in both directions. A floor cannot be born that way and cannot be split that way. Placed after applyFuseGuard rather than before it, so the guard's predicted grid is unchanged and the blast radius is exactly "targets that command charge into a closed direction". Nothing between the floor and the driver raises charge: applyBatteryBoostReserve touches negative targets only, and forceFuseDischarge only forces discharge. The discharge-side twin of the per-driver half is deliberately left alone. A dischargeBlocked battery re-opened by slew is the same shape, but flooring it has to answer whether the fuse emergency in forceFuseDischarge outranks a driver's "I cannot discharge" — a question this change does not need to answer, because charge has no such override. Every new test runs at a rate a site actually runs (250-1500 W) with the battery measured mid-charge; at SlewRateW 100000, the way most older dispatch tests neutralise the limiter, all four bug tests pass against the broken code. The golden corpus deliberately recorded this bug in #799. It moves 11 records and is re-recorded in the next commit, so the fix and the fixture update read apart. Co-Authored-By: Claude Opus 5 --- .changeset/slew-cannot-reopen-charge-block.md | 5 + go/internal/control/dispatch.go | 77 ++++++ go/internal/control/slew_charge_floor_test.go | 260 ++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 .changeset/slew-cannot-reopen-charge-block.md create mode 100644 go/internal/control/slew_charge_floor_test.go diff --git a/.changeset/slew-cannot-reopen-charge-block.md b/.changeset/slew-cannot-reopen-charge-block.md new file mode 100644 index 00000000..15da0d72 --- /dev/null +++ b/.changeset/slew-cannot-reopen-charge-block.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +The slew limiter can no longer re-open a charge block another stage closed. Because the limiter anchors each target on the battery's measured output rather than on the previous command, a battery physically charging pulled its own command back up after the tick had already pinned the fleet to 0 W — so a passive-arbitrage idle slot with the meter exporting 2000 W and the battery at +2000 W commanded 1500 W of charging on a tick that forbids charging, and a battery reporting `charge_capable=false` was commanded to charge anyway while a capable sibling absorbed the same share. A charge floor now runs after the limiter, mirroring the discharge-side floor already there: the site-wide charge block (planner_self's export-surplus gate, planner_self's stale plan, the arbitrage-family idle live-export gate) and a driver's own charge-capability report both survive to the hardware. diff --git a/go/internal/control/dispatch.go b/go/internal/control/dispatch.go index 6f948e56..bd702a85 100644 --- a/go/internal/control/dispatch.go +++ b/go/internal/control/dispatch.go @@ -2221,14 +2221,36 @@ func ComputeDispatch( return applyDispatchSafetyPipeline(raw, store, state, driverCapacities, fuseMaxW, dispatchSafetyOptions{ manualHoldActive: manualHoldActive, noSelfDischarge: noSelfDischarge, + noSelfCharge: noSelfCharge, + chargeBlocked: chargeBlockedDrivers(onlineBats), updatePrevTargets: true, recordDispatch: true, }) } +// chargeBlockedDrivers is the set of online batteries that told us this tick +// they cannot charge. distributeProportional already parks them at 0; this +// carries the same fact past the slew limiter, which anchors on measured +// power and knows nothing about capability. +func chargeBlockedDrivers(bats []batteryInfo) map[string]bool { + var out map[string]bool + for _, b := range bats { + if !b.chargeBlocked { + continue + } + if out == nil { + out = make(map[string]bool, len(bats)) + } + out[b.driver] = true + } + return out +} + type dispatchSafetyOptions struct { manualHoldActive bool noSelfDischarge bool + noSelfCharge bool + chargeBlocked map[string]bool updatePrevTargets bool recordDispatch bool } @@ -2264,6 +2286,7 @@ func applyDispatchSafetyPipeline( if opts.noSelfDischarge { targets = floorNegativeTargets(targets) } + targets = floorBlockedCharge(targets, opts.noSelfCharge, opts.chargeBlocked) // A battery-boost lease may never draw the stationary fleet below its // explicit reserve. Apply this immediately before forceFuseDischarge: @@ -3175,6 +3198,60 @@ func floorNegativeTargets(targets []DispatchTarget) []DispatchTarget { return targets } +// floorBlockedCharge is the charge-side mirror of floorNegativeTargets: a +// target may not command charge into a direction this tick already closed. +// +// It exists because the slew limiter anchors every target on the battery's +// MEASURED output rather than on the command, so a battery physically +// charging at +2000 W is pulled back toward +2000 W from whatever the stages +// above decided — including the 0 W that noSelfCharge pinned two hundred +// lines earlier. Nothing below used to undo that: applyFuseGuard only shrinks +// toward zero, floorNegativeTargets covers the discharge side only, and +// planSignIntent reports "idle, no opinion" for an idle slot. A +// passive-arbitrage idle slot with the meter at -2000 W, the battery live at +// +2000 W and SlewRateW=500 therefore commanded +1500 W of charging on the +// tick whose entire purpose was to let that surplus reach the meter. +// +// Two authorities close the charge direction, both decided before slew runs: +// +// - noSelfCharge — the site-wide block: planner_self's export-surplus gate, +// planner_self's stale plan, and the arbitrage-family idle live-export +// gate. It pins the fleet TOTAL to zero; this bounds each command. +// - chargeBlocked — the driver's own capability report. Its share was +// already handed to capable siblings, so re-opening it double-counts the +// charge as well as ignoring the hardware. +// +// This is a bound on the output, not a list of the reasons the output was +// closed. The snap-to-zero carve-out inside the slew loop is the list +// version, and it names only plannerSelfExportSurplusGate and manual hold; +// the two charge gates added to ComputeDispatch after it were never added to +// it. That is how this bug was born, and a floor cannot be born that way. It +// only ever moves a target toward zero, so it can neither create motion nor +// widen a clamp applied above it — and nothing after it raises charge: +// applyBatteryBoostReserve touches negative targets only, and +// forceFuseDischarge only forces discharge. +// +// The discharge-side twin of the per-driver half is deliberately NOT here: a +// dischargeBlocked battery re-opened by slew is the same shape, but flooring +// it has to answer whether the fuse emergency in forceFuseDischarge outranks +// a driver's "I cannot discharge". Charge has no such override, so it is the +// half that can be fixed without deciding that. +func floorBlockedCharge(targets []DispatchTarget, noSelfCharge bool, chargeBlocked map[string]bool) []DispatchTarget { + if !noSelfCharge && len(chargeBlocked) == 0 { + return targets + } + for i := range targets { + if targets[i].TargetW <= 0 { + continue + } + if noSelfCharge || chargeBlocked[targets[i].Driver] { + targets[i].TargetW = 0 + targets[i].Clamped = true + } + } + return targets +} + // coverLoadChargeSlot reports whether the current plan slot is a charge-from- // PV-surplus slot: the DP meant to soak surplus (PlannedGridW below the // grid-charge import band), NOT buy from the grid. Such a slot carries no hard diff --git a/go/internal/control/slew_charge_floor_test.go b/go/internal/control/slew_charge_floor_test.go new file mode 100644 index 00000000..1830c4d2 --- /dev/null +++ b/go/internal/control/slew_charge_floor_test.go @@ -0,0 +1,260 @@ +package control + +// The slew limiter may not re-open a charge direction another stage closed. +// +// Every test here runs at a slew rate a site actually runs (250-1500 W, next +// to NewState's 500 W default) with the battery MEASURED mid-charge. That +// combination is the whole bug: the limiter anchors on measured power, so a +// battery physically charging drags its own command back up regardless of +// what the tick decided. Pinning these at SlewRateW = 100000, the way most of +// the older dispatch tests do, would make all of them pass against the broken +// code. + +import ( + "encoding/json" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// chargingBattery seeds one battery reporting live charge power, optionally +// with a capability block, plus the site meter. +func seedChargeFloorSite(t *testing.T, gridW float64, bats []struct { + name string + currentW, soc float64 + capability string +}) *telemetry.Store { + t.Helper() + s := telemetry.NewStore() + s.Update("meter", telemetry.DerMeter, gridW, nil, nil) + s.DriverHealthMut("meter").RecordSuccess() + for _, b := range bats { + soc := b.soc + var data json.RawMessage + if b.capability != "" { + data = json.RawMessage(b.capability) + } + s.Update(b.name, telemetry.DerBattery, b.currentW, &soc, data) + s.DriverHealthMut(b.name).RecordSuccess() + } + return s +} + +func idleArbitrageSlot(now time.Time, strategy string) SlotDirective { + return SlotDirective{ + SlotStart: now.Add(-7 * time.Minute), + SlotEnd: now.Add(8 * time.Minute), + BatteryEnergyWh: 0, + Strategy: strategy, + } +} + +// The reproduction from the harvest, watt for watt: a passive-arbitrage idle +// slot with the meter exporting 2000 W and the battery measured at +2000 W. +// The slot's charge block pins the fleet total to 0; before the post-slew +// floor the limiter walked that back to +1500 W, so the site charged on a +// tick whose whole purpose was to let the surplus reach the meter. +func TestSlewMayNotReopenArbitrageIdleChargeBlock(t *testing.T) { + now := time.Now() + store := seedChargeFloorSite(t, -2000, []struct { + name string + currentW, soc float64 + capability string + }{ + {"ferroamp", 2000, 0.55, ""}, + }) + st := NewState(0, 60, "meter") + st.Mode = ModePlannerPassiveArbitrage + st.UseEnergyDispatch = true + st.SlewRateW = 500 + st.MinDispatchIntervalS = 0 + st.SlotDirective = func(time.Time) (SlotDirective, bool) { + return idleArbitrageSlot(now, "passive_arbitrage"), true + } + + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + got := targetsByDriver(targets) + if got["ferroamp"].TargetW > 0.01 { + t.Errorf("ferroamp TargetW = %.1f W — an idle arbitrage slot over an exporting meter forbids charge; slew re-opened it", + got["ferroamp"].TargetW) + } +} + +// Same law, planner_arbitrage rather than passive: the gate covers the whole +// arbitrage family. +func TestSlewMayNotReopenPlannerArbitrageIdleChargeBlock(t *testing.T) { + now := time.Now() + store := seedChargeFloorSite(t, -2000, []struct { + name string + currentW, soc float64 + capability string + }{ + {"ferroamp", 2000, 0.55, ""}, + }) + st := NewState(0, 60, "meter") + st.Mode = ModePlannerArbitrage + st.UseEnergyDispatch = true + st.SlewRateW = 500 + st.MinDispatchIntervalS = 0 + st.SlotDirective = func(time.Time) (SlotDirective, bool) { + return idleArbitrageSlot(now, "arbitrage"), true + } + + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + got := targetsByDriver(targets) + if got["ferroamp"].TargetW > 0.01 { + t.Errorf("ferroamp TargetW = %.1f W — idle arbitrage slot over an exporting meter must not charge", got["ferroamp"].TargetW) + } +} + +// planner_self with no fresh plan is discharge-only: it may cover live import +// but may not buy or absorb into the pack until a plan arrives. The stale-plan +// block is the second of the two charge gates the slew loop's snap-to-zero +// carve-out never learned about. +func TestSlewMayNotReopenStalePlanChargeBlock(t *testing.T) { + store := seedChargeFloorSite(t, -1500, []struct { + name string + currentW, soc float64 + capability string + }{ + {"ferroamp", 2500, 0.60, ""}, + }) + st := NewState(0, 60, "meter") + st.Mode = ModePlannerSelf + st.UseEnergyDispatch = true + st.SlewRateW = 500 + st.MinDispatchIntervalS = 0 + st.SlotDirective = func(time.Time) (SlotDirective, bool) { return SlotDirective{}, false } + st.PlanTarget = func(time.Time) (string, float64, bool) { return "", 0, false } + + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + got := targetsByDriver(targets) + if got["ferroamp"].TargetW > 0.01 { + t.Errorf("ferroamp TargetW = %.1f W — planner_self on a stale plan is discharge-only; slew re-opened the charge block", + got["ferroamp"].TargetW) + } + if !st.PlanStale { + t.Errorf("scenario did not arm the stale-plan gate — the test proves nothing") + } +} + +// The per-driver half. A battery that reports charge_capable=false is parked +// at 0 W by the distributor and its share handed to a capable sibling; the +// limiter then anchored it on its own live charge and commanded +1500 W into +// hardware that just said it cannot take it — while the sibling was already +// absorbing that same share. +func TestSlewMayNotReopenChargeBlockedBatterysParkedTarget(t *testing.T) { + store := seedChargeFloorSite(t, -2500, []struct { + name string + currentW, soc float64 + capability string + }{ + {"ferroamp", 2000, 0.50, `{"discharge_capable":true,"charge_capable":false}`}, + {"sungrow", 0, 0.40, ""}, + }) + st := NewState(0, 50, "meter") + st.Mode = ModeSelfConsumption + st.SlewRateW = 500 + st.MinDispatchIntervalS = 0 + + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200, "sungrow": 9600}), 11040) + got := targetsByDriver(targets) + if got["ferroamp"].TargetW > 0.01 { + t.Errorf("ferroamp TargetW = %.1f W — the driver reported it cannot charge; slew re-opened its parked target", + got["ferroamp"].TargetW) + } + // The capable sibling keeps its legitimate one-step ramp: it was measured + // at 0 W and the limiter allows one rate's worth of charge per tick. + if math.Abs(got["sungrow"].TargetW-500) > 0.01 { + t.Errorf("sungrow TargetW = %.1f W, want 500 W — the capable sibling's ramp must survive the floor", + got["sungrow"].TargetW) + } +} + +// The floor must not swallow charge nobody blocked. Same slew shape, plain +// self-consumption: the ramp toward the surplus is the correct answer and +// stays. +func TestChargeFloorLeavesUnblockedChargeAlone(t *testing.T) { + store := seedChargeFloorSite(t, -2500, []struct { + name string + currentW, soc float64 + capability string + }{ + {"ferroamp", 2000, 0.50, ""}, + }) + st := NewState(0, 50, "meter") + st.Mode = ModeSelfConsumption + st.SlewRateW = 500 + st.MinDispatchIntervalS = 0 + + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + got := targetsByDriver(targets) + if math.Abs(got["ferroamp"].TargetW-2500) > 0.01 { + t.Errorf("ferroamp TargetW = %.1f W, want 2500 W — no gate closed charge here, the ramp must survive", + got["ferroamp"].TargetW) + } +} + +// The floor is one-sided. On a tick that forbids charging, a battery measured +// mid-DISCHARGE still ramps back toward 0 at the slew rate rather than being +// snapped to it — the charge block says nothing about discharge, and covering +// live load must not become collateral damage. +func TestChargeFloorLeavesDischargeAlone(t *testing.T) { + now := time.Now() + store := seedChargeFloorSite(t, -2000, []struct { + name string + currentW, soc float64 + capability string + }{ + {"ferroamp", -2000, 0.55, ""}, + }) + st := NewState(0, 60, "meter") + st.Mode = ModePlannerPassiveArbitrage + st.UseEnergyDispatch = true + st.SlewRateW = 500 + st.MinDispatchIntervalS = 0 + st.SlotDirective = func(time.Time) (SlotDirective, bool) { + return idleArbitrageSlot(now, "passive_arbitrage"), true + } + + targets := ComputeDispatch(store, st, caps(map[string]float64{"ferroamp": 15200}), 11040) + got := targetsByDriver(targets) + if math.Abs(got["ferroamp"].TargetW-(-1500)) > 0.01 { + t.Errorf("ferroamp TargetW = %.1f W, want -1500 W — the charge floor must not touch a discharging battery's ramp", + got["ferroamp"].TargetW) + } +} + +// A direct unit test of the floor's contract, so the shape is pinned +// independently of everything ComputeDispatch does above it: it moves only +// positive targets, only for blocked drivers, and never away from zero. +func TestFloorBlockedChargeContract(t *testing.T) { + in := []DispatchTarget{ + {Driver: "a", TargetW: 1500}, + {Driver: "b", TargetW: -1500}, + {Driver: "c", TargetW: 0}, + } + out := floorBlockedCharge(append([]DispatchTarget(nil), in...), false, map[string]bool{"a": true}) + got := targetsByDriver(out) + if got["a"].TargetW != 0 || !got["a"].Clamped { + t.Errorf("blocked driver a = %.1f W (clamped=%v), want 0 W clamped", got["a"].TargetW, got["a"].Clamped) + } + if got["b"].TargetW != -1500 || got["b"].Clamped { + t.Errorf("driver b = %.1f W (clamped=%v), want -1500 W untouched", got["b"].TargetW, got["b"].Clamped) + } + if got["c"].TargetW != 0 || got["c"].Clamped { + t.Errorf("driver c = %.1f W (clamped=%v), want 0 W unclamped", got["c"].TargetW, got["c"].Clamped) + } + + // The site-wide flag covers every driver, blocked list or not. + all := floorBlockedCharge(append([]DispatchTarget(nil), in...), true, nil) + gotAll := targetsByDriver(all) + if gotAll["a"].TargetW != 0 || gotAll["c"].TargetW != 0 { + t.Errorf("noSelfCharge left charge standing: a=%.1f c=%.1f", gotAll["a"].TargetW, gotAll["c"].TargetW) + } + if gotAll["b"].TargetW != -1500 { + t.Errorf("noSelfCharge moved a discharge target: b=%.1f", gotAll["b"].TargetW) + } +} From 1d4935ec9d83afde2769abc265d95d35ecb27ec1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 4 Aug 2026 20:49:02 +0200 Subject: [PATCH 2/2] test(control): re-record the 11 slew records the charge floor moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Predicted before looking, verified after. Eleven records move, all in the slew_limiter family, all the same law: a target the tick had closed, walked back open by the limiter's measured-power anchor. The eight the corpus named as bugs when #799 recorded them: | record | before | after | |---|---|---| | bug_no_self_charge_idle_slot_leak_250 | +1750 W | 0 W | | bug_no_self_charge_idle_slot_leak_500 | +1500 W | 0 W | | bug_no_self_charge_idle_slot_leak_1500 | +500 W | 0 W | | bug_no_self_charge_idle_slot_leak_with_pv | +2000 W | 0 W | | bug_no_self_charge_arbitrage_idle_slot_leak | +1500 W | 0 W | | bug_no_self_charge_idle_slot_leak_two_batteries | +1500 / +1000 W | 0 / 0 W | | bug_no_self_charge_stale_plan_leak | +2000 W | 0 W | | blocked_sibling_slew_reopens_parked_charge | +1500 / +500 W | 0 / +500 W | The first seven are the site-wide charge block: the fleet total was already pinned to 0 W, so 0 W is the answer the tick computed and the limiter overwrote. The eighth is per-driver: ferroamp reported charge_capable=false and goes to 0; sungrow keeps +500 W, which is its own legitimate one-step ramp from a 0 W anchor and not something anything closed. Three more were predicted by shape rather than by name — the seeded half of the family, where the same gates fire on randomly generated sites: | record | before | after | why | |---|---|---|---| | seeded_039_planner_arbitrage | +3000 W | 0 W | idle arbitrage slot, meter exporting 12.6 kW, battery measured +5377 W | | seeded_068_planner_self | +2525 W | 0 W | stale plan, battery measured +4025 W | | seeded_078_planner_passive_arbitrage | +600 / +787 W | 0 / 0 W | idle slot (28 Wh), meter exporting 3.1 kW, both batteries measured charging | Every one of the eleven is a tick where the site charged while the meter exported, or charged hardware that said it could not. None moved that was not predicted. The records predicted NOT to move did not: carveout_export_surplus_gate_snaps_to_zero and its discharge twin are already 0 W — they are the A/B partners whose difference from the bug rows WAS the bug, and they stay put. no_self_charge_idle_slot_slew_3000_reaches_zero stays at 0 W: a 3000 W rate already reached zero in one step, which is why it never leaked. blocked_sibling_slew_reopens_parked_target stays at -1500 W — the discharge-side twin this change deliberately does not touch. The seven other families are NOT re-recorded. None of their records moved, and rewriting their ftw_commit would claim they were re-examined under this change when they were not. Re-recording them does churn ~150 lines of sub-tolerance float noise (e.g. 3704.0000041155554 -> 3704), which is worth knowing about but is not this commit's business. One record inside slew_limiter changes without moving: seeded_023_planner_arbitrage's derived battery_target_sum_w goes from 235.00000000000006 to 235 while its three per-driver targets stay byte-identical. Isolated by re-dumping with the floor disabled: the shift is already present on this branch's base and belongs to the fuse-relief refactor below it, 6e-14 W, fourteen orders of magnitude under the 0.01 W tolerance. 590 records in 8 families, unchanged. Tolerance unchanged at 0.01 W. slew_limiter.json sha256 f7ff7a54bc49321a5c232d58eed9230b2e25e4b3b7474544e32b46bf0b78885b Co-Authored-By: Claude Opus 5 --- .../control/testdata/golden/slew_limiter.json | 94 +++++++++---------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/go/internal/control/testdata/golden/slew_limiter.json b/go/internal/control/testdata/golden/slew_limiter.json index 819a9f3c..472adad6 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": "8fe82e4a3432956aa82b30534a24cc1d4daf74eb", + "ftw_commit": "fe2c904fe03665775f79265fc3302f4f30808fc7", "record_count": 155, "records": [ { @@ -1440,7 +1440,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 1750, + "target_w": 0, "clamped": true } ], @@ -1448,7 +1448,7 @@ "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, @@ -1465,9 +1465,9 @@ }, "site_totals": { "battery_current_w": 2000, - "battery_target_sum_w": 1750, + "battery_target_sum_w": 0, "raw_grid_w": -2000, - "projected_grid_w": -2250 + "projected_grid_w": -4000 } }, { @@ -1528,7 +1528,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 1500, + "target_w": 0, "clamped": true } ], @@ -1536,7 +1536,7 @@ "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, @@ -1553,9 +1553,9 @@ }, "site_totals": { "battery_current_w": 2000, - "battery_target_sum_w": 1500, + "battery_target_sum_w": 0, "raw_grid_w": -2000, - "projected_grid_w": -2500 + "projected_grid_w": -4000 } }, { @@ -1616,7 +1616,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 500, + "target_w": 0, "clamped": true } ], @@ -1624,7 +1624,7 @@ "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, @@ -1641,9 +1641,9 @@ }, "site_totals": { "battery_current_w": 2000, - "battery_target_sum_w": 500, + "battery_target_sum_w": 0, "raw_grid_w": -2000, - "projected_grid_w": -3500 + "projected_grid_w": -4000 } }, { @@ -1796,7 +1796,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 2000, + "target_w": 0, "clamped": true } ], @@ -1804,7 +1804,7 @@ "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, @@ -1821,9 +1821,9 @@ }, "site_totals": { "battery_current_w": 2500, - "battery_target_sum_w": 2000, + "battery_target_sum_w": 0, "raw_grid_w": -2500, - "projected_grid_w": -3000 + "projected_grid_w": -5000 } }, { @@ -1884,7 +1884,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 1500, + "target_w": 0, "clamped": true } ], @@ -1892,7 +1892,7 @@ "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, @@ -1909,9 +1909,9 @@ }, "site_totals": { "battery_current_w": 2000, - "battery_target_sum_w": 1500, + "battery_target_sum_w": 0, "raw_grid_w": -2000, - "projected_grid_w": -2500 + "projected_grid_w": -4000 } }, { @@ -1984,12 +1984,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 1500, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": 1000, + "target_w": 0, "clamped": true } ], @@ -1998,7 +1998,7 @@ "ferroamp", "sungrow" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, @@ -2015,9 +2015,9 @@ }, "site_totals": { "battery_current_w": 3500, - "battery_target_sum_w": 2500, + "battery_target_sum_w": 0, "raw_grid_w": -3500, - "projected_grid_w": -4500 + "projected_grid_w": -7000 } }, { @@ -2084,7 +2084,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 2000, + "target_w": 0, "clamped": true } ], @@ -2092,7 +2092,7 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": true, @@ -2109,9 +2109,9 @@ }, "site_totals": { "battery_current_w": 2500, - "battery_target_sum_w": 2000, + "battery_target_sum_w": 0, "raw_grid_w": -1500, - "projected_grid_w": -2000 + "projected_grid_w": -4000 } }, { @@ -2276,7 +2276,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 1500, + "target_w": 0, "clamped": true }, { @@ -2307,9 +2307,9 @@ }, "site_totals": { "battery_current_w": 2000, - "battery_target_sum_w": 2000, + "battery_target_sum_w": 500, "raw_grid_w": -2500, - "projected_grid_w": -2500 + "projected_grid_w": -4000 } }, { @@ -6861,7 +6861,7 @@ }, "site_totals": { "battery_current_w": 2929, - "battery_target_sum_w": 235.00000000000006, + "battery_target_sum_w": 235, "raw_grid_w": 13734, "projected_grid_w": 11040 } @@ -8579,7 +8579,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 3000, + "target_w": 0, "clamped": true } ], @@ -8587,7 +8587,7 @@ "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, @@ -8604,9 +8604,9 @@ }, "site_totals": { "battery_current_w": 5377, - "battery_target_sum_w": 3000, + "battery_target_sum_w": 0, "raw_grid_w": -12646, - "projected_grid_w": -15023 + "projected_grid_w": -18023 } }, { @@ -11635,7 +11635,7 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 2525, + "target_w": 0, "clamped": true } ], @@ -11643,7 +11643,7 @@ "clamped_drivers": [ "ferroamp" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": true, @@ -11660,9 +11660,9 @@ }, "site_totals": { "battery_current_w": 4025, - "battery_target_sum_w": 2525, + "battery_target_sum_w": 0, "raw_grid_w": -298, - "projected_grid_w": -1798 + "projected_grid_w": -4323 } }, { @@ -12700,12 +12700,12 @@ "per_driver_targets": [ { "driver": "ferroamp", - "target_w": 600, + "target_w": 0, "clamped": true }, { "driver": "sungrow", - "target_w": 787, + "target_w": 0, "clamped": true } ], @@ -12714,7 +12714,7 @@ "ferroamp", "sungrow" ], - "all_targets_zero_clamped": false, + "all_targets_zero_clamped": true, "plan_sign_intent": 0, "plan_sign_floor_zeroed": false, "plan_stale": false, @@ -12731,9 +12731,9 @@ }, "site_totals": { "battery_current_w": 2887, - "battery_target_sum_w": 1387, + "battery_target_sum_w": 0, "raw_grid_w": -3096, - "projected_grid_w": -4596 + "projected_grid_w": -5983 } }, {