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) + } +} 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 } }, {