Repository navigation
fix(control): one SoC floor for the stale-plan fallback, fuse relief and the hourly import limit - #1878
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d666cf1c81
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| dischargeBlocked = dischargeBlocked || lim.dischargeCap() == 0 || | ||
| staleFloor && soc < lim.socFloor() |
There was a problem hiding this comment.
Enforce the stale-plan floor after slew and across deadband exits
When a battery is already discharging as its SoC falls below the configured floor, marking it discharge-blocked only protects the allocator's output. With stale planning, 8% SoC, a 10% floor, measured battery power of -2000 W and the default 500 W slew limit, the allocator parks the battery at zero but the slew stage restores a -1500 W command. The safety pipeline does not floor that existing negative target. If the battery is balancing the house within the grid deadband, dispatch can instead return without withdrawing its previous discharge command at all. Carry this floor through the final safety checks and bypass the deadband when a below-floor battery is still discharging.
Useful? React with 👍 / 👎.
| fusePartW := math.Max(0, overage-limitOnlyW) | ||
| out := allocateFuseRelief(targets, fusePartW, lowers) | ||
| return allocateFuseRelief(out, overage-fusePartW, limitLowers) |
There was a problem hiding this comment.
Preserve reserve-eligible headroom when allocating fuse relief
When multiple batteries have different boost-reserve eligibility, allocating the fuse portion proportionally first can consume headroom needed exclusively by the import-limit portion. For example, with two idle batteries each capable of 5 kW discharge, one below the boost reserve and one above it, a 3 kW fuse overage plus 5 kW of limit-only relief produces targets of -1500 W and -5000 W: only 6.5 kW of the required 8 kW is delivered. Targets of -3000 W and -5000 W would satisfy both constraints without violating the reserve exception. Allocate with both bounds jointly, or rebalance the fuse allocation when the second pass exhausts reserve-eligible headroom.
Useful? React with 👍 / 👎.
…and the hourly import limit Control now gets PowerLimits.SoCMin, the higher of batteries.<n>.soc_min and the running planner's soc_min (10 % default). Core still stops at 5 %. - A planner mode with no plan no longer discharges below the floor. - Fuse relief and the hourly import limit stop at the floor. - The part of the relief that only the hourly import limit asks for keeps the battery-boost reserve; a real fuse overage may still pass it. Item A4 of the stable plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the limit part first A battery that drifts under the stale-plan floor while discharging kept going: the deadband exit returned no targets, so the driver held its last setpoint, and outside the deadband slew handed back part of the discharge. The deadband exit now falls through when such a battery is discharging, and the safety pipeline moves its negative target to 0 W after slew. Split relief gave the fuse part first across all batteries, which used up the room that only batteries above the boost reserve can give the hourly limit. Give the limit-only part first, then the fuse part down to the SoC floor. The fuse part is still covered whenever the fleet can cover it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b2ac4e2 to
f87151b
Compare
# Conflicts: # go/internal/control/dispatch.go
…-consumption #1876 decides whether the distributor parked a pack by reusing fuseTargetBounds. This branch makes that function honour the owner's soc_min, so plain self-consumption at 8 % with soc_min 10 % was stopped at 0 W. soc_min is a planner rule; the stale-plan floor applies it on its own. Give the stop its own check at Core's 5 % floor, as on master. TestSelfConsumptionStillDischargesUnderSoCMin fails without this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merged master (#1876) into this branch. Two changes:
|
… discharge stops at the floor Review of #1916 found three gaps after the merge of #1878 and master: - The owner floor max(5 %, soc_min) applied in planner modes only when the plan was stale. Most seed orders under it (207534, 207536) came on a fresh plan. Drop the PlanStale condition; manual holds and plain self_consumption keep Core's 5 % floor. - A discharge already running at or under 5 % inside the deadband got no target, so the driver kept its setpoint and drained on. The deadband exit now falls through when such a battery is measured discharging. - dischargeParked (#1876) used a strict < on 5 %, so slew handed back part of the discharge at exactly 5 %. Make it <=, as clampWithSoC. Rename belowStaleFloor to belowOwnerFloor to match. One golden record changes: seeded_siblings/034_soc_floor, where the sibling now takes the share of a pack under the floor in planner_arbitrage, as it already did with a stale plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Site rig, short set, on c38d964: 9 pass, 0 known, 0 fail of 9 in 1687 s. Run locally with 🤖 Generated with Claude Code |
Joins #1878's split of fuse relief (fuse part, import-limit-only part) with the stack's per-phase relief: fuseReliefPartsFromSnapshot takes the phase sample and returns both parts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(control): one SoC floor for the stale-plan fallback, fuse relief and the hourly import limit Control now gets PowerLimits.SoCMin, the higher of batteries.<n>.soc_min and the running planner's soc_min (10 % default). Core still stops at 5 %. - A planner mode with no plan no longer discharges below the floor. - Fuse relief and the hourly import limit stop at the floor. - The part of the relief that only the hourly import limit asks for keeps the battery-boost reserve; a real fuse overage may still pass it. Item A4 of the stable plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): hold the stale-plan floor on a running discharge; give the limit part first A battery that drifts under the stale-plan floor while discharging kept going: the deadband exit returned no targets, so the driver held its last setpoint, and outside the deadband slew handed back part of the discharge. The deadband exit now falls through when such a battery is discharging, and the safety pipeline moves its negative target to 0 W after slew. Split relief gave the fuse part first across all batteries, which used up the room that only batteries above the boost reserve can give the hourly limit. Give the limit-only part first, then the fuse part down to the SoC floor. The fuse part is still covered whenever the fleet can cover it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): no discharge order at exactly the SoC floor clampWithSoC, the stale-plan floor and fuseTargetBounds compared SoC with a strict <, so a battery at exactly 5 % (or exactly soc_min) still got discharge orders. Use <= in all three. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): a blocked-discharge stop keeps Core's 5 % floor in self-consumption #1876 decides whether the distributor parked a pack by reusing fuseTargetBounds. This branch makes that function honour the owner's soc_min, so plain self-consumption at 8 % with soc_min 10 % was stopped at 0 W. soc_min is a planner rule; the stale-plan floor applies it on its own. Give the stop its own check at Core's 5 % floor, as on master. TestSelfConsumptionStillDischargesUnderSoCMin fails without this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): planner modes keep soc_min with a fresh plan; a running discharge stops at the floor Review of #1916 found three gaps after the merge of #1878 and master: - The owner floor max(5 %, soc_min) applied in planner modes only when the plan was stale. Most seed orders under it (207534, 207536) came on a fresh plan. Drop the PlanStale condition; manual holds and plain self_consumption keep Core's 5 % floor. - A discharge already running at or under 5 % inside the deadband got no target, so the driver kept its setpoint and drained on. The deadband exit now falls through when such a battery is measured discharging. - dischargeParked (#1876) used a strict < on 5 %, so slew handed back part of the discharge at exactly 5 %. Make it <=, as clampWithSoC. Rename belowStaleFloor to belowOwnerFloor to match. One golden record changes: seeded_siblings/034_soc_floor, where the sibling now takes the share of a pack under the floor in planner_arbitrage, as it already did with a stale plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(control): standby draw at the SoC floor no longer breaks the deadband A pack parked at its floor reads tens of watts of standby draw. Any reading below -1 W made batteryBreaksLimit skip the deadband, so the PI chased meter noise and sent an order every tick. Now only a discharge past 50 W, or any discharge after FTW's own last order was a discharge, falls through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Item A4 of the stable plan (one SoC floor in control).
Problem
Control knew only Core's hard 5 % floor.
planner.soc_min(the "House reserve", 10 % by default) reached only the planner, andbatteries.<n>.soc_minreached only drivers that readdischarge_floor_soc(Ferroamp).Change
PowerLimits.SoCMinis the higher ofbatteries.<n>.soc_minand the running planner'ssoc_min(10 % if unset).socFloor()returns max(5 %, SoCMin).driverLimitsFromfills it at boot and on reload.fuseTargetBoundsusessocFloor(), so fuse relief and the hourly limit stop at the floor. Below it, the car is the lever for the fuse.clampWithSoC, so manual holds and operator modes do not obey a planner setting.forceFuseDischargeWithSnapshotnow splits the relief. The fuse part may still go past the boost reserve. The part that only the hourly import limit asks for keeps the reserve. The limit part goes first, to the batteries above the reserve; the fuse part then takes what is left down to the SoC floor. In the other order, the fuse part used up room that only batteries above the reserve can give the limit.applyBatteryBoostReserveand the new pass shareboostReserveLowerW. The total relief and its other callers do not change.Tests
go/internal/control/soc_floor_test.goandTestSoCMinReachesDispatchingo/cmd/ftw/battery_soc_max_test.go. The control tests come from the audit repros in caa-149 (TestReproPlannerFallbackIgnoresHouseReserve) and caa-193 (TestReproImport*). The audit tests assumed a 10 % floor that control could not see, so these setSoCMin: 0.10. All five control tests fail on master 83c2b24 and pass here.TestStaleFloorStopsRunningDischarge(inside the deadband, and after a 500 W slew),TestStaleFloorSiblingTakesShare,TestSelfConsumptionStillDischargesUnderSoCMinandTestSplitReliefUsesBothBatteries. The first and last fail on d666cf1 (no target with -2000 W still set; -1500 W after slew; bat -1501 W instead of -3000 W) and pass here.go test -race ./internal/control/... ./cmd/ftw/... ./internal/mpc/...: okgo vet ./internal/control/... ./cmd/ftw/...andgo build ./...: cleanI did not run the site rig. The rig runner posts its own status.
Risk
Medium. Batteries now stop earlier: at 10 % rather than 5 % on any site with the planner on, even when its
soc_minis unset. Fuse relief below that floor now depends on the EV cut. That matches the owner's 27 Oct decision. If a site has no EV to cut, the breaker remains the last guard, as it is today below 5 %. The self-tune step and the EV battery headroom also readfuseTargetBounds, so they too stop at the floor. This PR touches the same functions as A5 (fuseReliefFromSnapshot), so one of the two will need a rebase.🤖 Generated with Claude Code