Skip to content

fix(control): no discharge at or under the SoC floor - #1916

Merged
frahlg merged 13 commits into
masterfrom
soc-floor-inclusive
Oct 28, 2026
Merged

frahlg merged 13 commits into
masterfrom
soc-floor-inclusive

Conversation

@frahlg

@frahlg frahlg commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Item C7 of the random-rig triage (27 Oct).

Problem

Core let batteries discharge at and under the SoC floor in three ways.

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 <= in clampWithSoC (Core's 5 % floor), the owner-floor check in ComputeDispatch, fuseTargetBounds (fuse relief and the hourly import limit) and dischargeParked (fix(control): slew no longer reopens discharge on a blocked battery #1876's slew stop).
  • The owner floor socFloor() applies in every planner mode, with or without a fresh plan (the && state.PlanStale condition is gone). Manual holds and plain self_consumption keep Core's 5 % floor, so fix(control): one SoC floor for the stale-plan fallback, fuse relief and the hourly import limit #1878's TestSelfConsumptionStillDischargesUnderSoCMin still holds.
  • The deadband exit falls through when a battery at or under its floor is really discharging, in any mode (anyAtHardFloorDischarging for 5 %, anyBelowOwnerFloorDischarging for 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.
  • belowStaleFloor is renamed belowOwnerFloor, since it no longer depends on the plan being stale.

The task asked for clampWithSoC to compare against socFloor(). I did not do that: it would apply soc_min in self_consumption and 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_consumption and planner_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 the fuseTargetBounds change.
  • 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 the PrevTargets branch 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, < in dischargeParked, or dropping the PrevTargets branch 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/...: ok
  • make verify: clean (the Docker step skipped, no daemon)
  • .github/scripts/check-telemetry-reads.sh and .github/check-no-planning-docs.sh against master: no new reads, no planning docs

I 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 floorBlockedDischarge does not apply either: the owner-floor stop runs before it, and fuseTargetBounds refuses fuse relief at the same floor. This follows the owner rule that relief never discharges below max(5 %, soc_min).

The second reviewer's TestAdvOwnerFloorStopOverFuse gives 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 %.

Head SoC Battery order Worst phase after
#1878 (635653e) 0.10 and 0.09 -10000 W (fuse relief) 6.75 A
this PR 0.10 and 0.09 0 W 21.25 A (1.33 × In, over the 1.2 × In line)
#1878 and this PR, self_consumption 0.049 0 W 21.25 A
#1878, self_consumption 0.05 -10000 W 6.75 A
this PR, self_consumption 0.05 0 W 21.25 A

So 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:

  • A battery stops one SoC step sooner, at the floor rather than one reading under it.
  • In planner modes with a fresh plan, a battery at or under soc_min no longer discharges, even if the plan asks for it. A sibling above the floor takes the share.
  • At its floor, a discharge of 50 W or less that FTW did not order is left alone. An order cannot stop a pack's own standby draw. A discharge FTW ordered still stops at any size.

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 of fuseTargetBounds, and it does not change that function. A merge should be clean.

🤖 Generated with Claude Code

frahlg and others added 3 commits October 27, 2026 19:20
…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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T18:37:46.154227Z e053d27 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

frahlg and others added 3 commits October 27, 2026 19:37
# 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>
@frahlg frahlg changed the title fix(control): no discharge order at exactly the SoC floor fix(control): no discharge at or under the SoC floor Oct 27, 2026
frahlg and others added 4 commits October 28, 2026 01:42
…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
@frahlg
frahlg changed the base branch from a4-one-soc-floor to master October 28, 2026 09:44
@frahlg
frahlg merged commit 8a7138d into master Oct 28, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant