Repository navigation
fix(control): no discharge at or under the SoC floor - #1916
Merged
Merged
Conversation
…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>
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>
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. |
# 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>
… 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>
This was referenced Oct 27, 2026
…band 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>
# Conflicts: # go/internal/control/dispatch.go # go/internal/control/soc_floor_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Item C7 of the random-rig triage (27 Oct).
Problem
Core let batteries discharge at and under the SoC floor in three ways.
<. Seeds 207534 and 207536 got discharge orders at exactly 5.0 %.clampWithSoClet them through because 0.05 < 0.05 is false. fix(control): slew no longer reopens discharge on a blocked battery #1876'sdischargeParkedhas the same<, so slew still handed back part of a running discharge at exactly 5 %.status.dispatchat SoC 0.05, 207534 has 186 fresh-plan samples against 2 stale, and 207536 has 495 against 4. Seeds 207531 and 207532 (5.5–5.8 %, stale plan) were already covered by fix(control): one SoC floor for the stale-plan fallback, fuse relief and the hourly import limit #1878.ComputeDispatchreturns no targets, and the driver keeps its last discharge setpoint at and under 5 %. fix(control): one SoC floor for the stale-plan fallback, fuse relief and the hourly import limit #1878 handled this only for a stale plan. In a closed-loop test (10 kWh, 2 s ticks) the battery kept discharging in 55 of 60 ticks and fell to 4.38 %.Owner rule: the floor is max(5 %, soc_min), and no discharge order goes out at the floor.
Change
All in
go/internal/control/dispatch.go:<becomes<=inclampWithSoC(Core's 5 % floor), the owner-floor check inComputeDispatch,fuseTargetBounds(fuse relief and the hourly import limit) anddischargeParked(fix(control): slew no longer reopens discharge on a blocked battery #1876's slew stop).socFloor()applies in every planner mode, with or without a fresh plan (the&& state.PlanStalecondition is gone). Manual holds and plainself_consumptionkeep Core's 5 % floor, so fix(control): one SoC floor for the stale-plan fallback, fuse relief and the hourly import limit #1878'sTestSelfConsumptionStillDischargesUnderSoCMinstill holds.anyAtHardFloorDischargingfor 5 %,anyBelowOwnerFloorDischargingfor soc_min). The clamps then send 0 W. "Really discharging" means the pack reads more than 50 W of discharge (floorStandbyW), or it reads any discharge and FTW's own last order to it (PrevTargets) was a discharge. A pack parked at its floor reads tens of watts of standby draw. With the earlier -1 W test that draw turned the deadband off for good, and the PI sent an order every tick to chase meter noise.belowStaleFlooris renamedbelowOwnerFloor, since it no longer depends on the plan being stale.The task asked for
clampWithSoCto compare againstsocFloor(). I did not do that: it would apply soc_min inself_consumptionand manual holds too, which #1878 rules out. Planner modes get soc_min through the owner-floor check instead. If the owner wants soc_min in every mode, that is a separate decision.Tests
New in
go/internal/control/soc_floor_test.go. Each fails before this PR (on #1878 merged with master) and passes after, unless noted:TestClampWithSoCBlocksDischargeAtFloor: SoC exactly 0.05 → 0 W; 0.051 still discharges.TestFreshPlanNoDischargeAtFloor: fresh arbitrage plan, SoC exactly 0.05 → no discharge (before: -2000 W).TestStaleFloorZeroAtAndUnderFloor: stale plan, soc_min 10 %, SoC 0.10 → 0 W (before: -2700 W).TestFuseTargetBoundsAtFloor: SoC exactly at the 10 % floor → lower bound 0 (before: -5000 W).TestFreshPlanKeepsSoCMin: planner_self, fresh plan, soc_min 10 %, energy dispatch on and off. SoC 0.08 and 0.10 → no discharge (before: -1200 W). SoC 0.12 still discharges.TestHardFloorStopsRunningDischarge: battery at -2000 W, SoC 0.05 and 0.04,self_consumptionandplanner_self. Inside the deadband (before: no target, setpoint stays) and after slew (before: -1500 W at 0.05).TestHardFloorDeadbandClosedLoop: SoC walks down through 5 %, driver keeps its setpoint. Before: 55 of 60 ticks discharged at or under 5 %. After: none.TestFuseReliefPipelineAtHardFloor: fuse relief through the full safety pipeline at exactly 5 % orders no discharge; 5.1 % may. Already passes before; it guards thefuseTargetBoundschange.TestHardFloorStandbyRandomWalk:self_consumption, SoC 0.05 and 0.049, -15 W standby, grid random walk within ±45 W, 50 W deadband, 300 ticks. Expects 0 orders. With the -1 W test (09f0c73): 300 of 300 ticks sent an order.TestOwnerFloorStandbyRandomWalk: the same in planner_self with a fresh plan, soc_min 10 %, SoC 0.10, energy dispatch on and off. Expects 0 orders. With the -1 W test: 300 of 300.TestHardFloorStopsOwnSmallDischarge: FTW's own last order was -30 W and the pack reads -30 W at 5 %, inside the deadband → a 0 W order. It passes with the -1 W test too. It fails if thePrevTargetsbranch is removed, so a small discharge FTW ordered cannot hide under the standby threshold.The second reviewer's adversarial file (
adv_c7_review_test.go) also runs clean on this head: 0 of 300 orders in every standby case, and no tick at or under 10 % discharges in its closed-loop fresh-plan test.Mutation checks: putting back
&& state.PlanStale, the deadband check,<indischargeParked, or dropping thePrevTargetsbranch each makes at least one test fail.One golden record changes:
seeded_siblings/034_soc_floor(planner_arbitrage, fresh plan, one pack at 3 %). The pack under the floor now hands its share to the sibling (sungrow -3200 → -5000 W), as it already did with a stale plan.go test -race -count=1 ./internal/control/... ./cmd/ftw/... ./internal/mpc/...: okmake verify: clean (the Docker step skipped, no daemon).github/scripts/check-telemetry-reads.shand.github/check-no-planning-docs.shagainst master: no new reads, no planning docsI did not run the site rig. The rig runner posts its own status.
Risk
Medium. One case needs the owner's sign-off (below).
A running discharge stops in one tick at the floor, with no fuse keep. On a fresh plan, a planner-mode battery that reaches soc_min goes from its running discharge to 0 W in one tick. No ramp applies, and the fuse keep in
floorBlockedDischargedoes not apply either: the owner-floor stop runs before it, andfuseTargetBoundsrefuses fuse relief at the same floor. This follows the owner rule that relief never discharges below max(5 %, soc_min).The second reviewer's
TestAdvOwnerFloorStopOverFusegives the size of it: 16 A fuse, 3 phases, 14 A on each phase while the battery covers the house at -5000 W, planner_self with a fresh plan, soc_min 10 %.self_consumptionself_consumptionself_consumptionSo the stop with no keep already existed under 5 % in every mode. This PR moves it to exactly 5 %, and on planner sites with a fresh plan up to soc_min, where the battery still holds energy. The rig shows this is not rare: discharge orders at SoC ≤ 10 % number 552 in 207534, 1073 in 207536 and 414 in 207532, nearly all on fresh plans.
The EV cut still applies. The loadpoint fuse clamp (
applyFuseClamp) sizes the car from measured phase room, so once the battery stops, the car is cut back. In 207532 (car at 8.2 kW) that moves 4–5 kW onto the car's fuse budget at 10 %. With no car or other load to cut, nothing FTW controls can bring the phase back under the line; the house load holds it there and the main fuse is the last protection.Smaller risks:
Owner decision
On a fresh plan, a planner-mode battery at soc_min stops in one tick, with no ramp and no fuse keep below the floor, even when the stop puts a phase over 1.2 × In (16 A fuse, 14 A per phase, -5000 W battery: 21.25 A after the stop, against 6.75 A on #1878). The EV cut still applies. Confirm that relief never goes below max(5 %, soc_min) in this case too, or say if the battery should keep discharging under soc_min (down to 5 %) to hold a phase under the line.
Stack and merge order
Stacked on #1878 (
a4-one-soc-floor), which already contains master, including #1876 (7840c33). This branch contains #1878's head (635653e). No CI runs on this branch while it is stacked; it runs once the base moves to master.Merge #1878 first, then this one. Once #1878 is squash-merged, merge master into this branch and set the base to
master.Other open PRs on
dispatch.go: #1782, #1789, #1891, #1910, #1913, #1914, #1921, #1924 and #1925. Only #1789 refers to any of the functions changed here, as a new caller offuseTargetBounds, and it does not change that function. A merge should be clean.🤖 Generated with Claude Code