Skip to content

fix(control): one SoC floor for the stale-plan fallback, fuse relief and the hourly import limit - #1878

Merged
frahlg merged 5 commits into
masterfrom
a4-one-soc-floor
Oct 28, 2026
Merged

frahlg merged 5 commits into
masterfrom
a4-one-soc-floor

Conversation

@frahlg

@frahlg frahlg commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

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, and batteries.<n>.soc_min reached only drivers that read discharge_floor_soc (Ferroamp).

  • No plan: every planner mode falls back to self-consumption. With the battery at 8 % and the site importing 2 kW, it ordered -1200 W, below the 10 % reserve.
  • Hourly import limit: 25 A site, every phase at 11.6 A, meter at 8 kW, ceiling at 5 kW. At 6 % SoC the battery got -3000 W through the fuse-relief path. With a 30 % battery-boost reserve it still got -3000 W, because that path runs after the reserve clamp and is meant only for a real fuse emergency.
  • Fuse relief: a 2 kW fuse overage discharged at 8 % SoC with a 10 % floor (-1960 W).

Change

  • PowerLimits.SoCMin is the higher of batteries.<n>.soc_min and the running planner's soc_min (10 % if unset). socFloor() returns max(5 %, SoCMin). driverLimitsFrom fills it at boot and on reload.
  • fuseTargetBounds uses socFloor(), so fuse relief and the hourly limit stop at the floor. Below it, the car is the lever for the fuse.
  • In a planner mode with a stale plan, a battery below its floor counts as discharge-blocked. A sibling above its floor takes the share. I used the existing discharge-blocked flag rather than clampWithSoC, so manual holds and operator modes do not obey a planner setting.
  • The floor also stops a discharge that is already running when SoC drifts under it. The deadband exit falls through while such a battery is discharging (a tick with no targets leaves the driver on its last setpoint), and the safety pipeline moves its negative target to 0 W after slew (slew anchors on measured power and would hand part of the discharge back).
  • forceFuseDischargeWithSnapshot now 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. applyBatteryBoostReserve and the new pass share boostReserveLowerW. The total relief and its other callers do not change.

Tests

  • New go/internal/control/soc_floor_test.go and TestSoCMinReachesDispatch in go/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 set SoCMin: 0.10. All five control tests fail on master 83c2b24 and pass here.
  • Review follow-up (b2ac4e2) adds TestStaleFloorStopsRunningDischarge (inside the deadband, and after a 500 W slew), TestStaleFloorSiblingTakesShare, TestSelfConsumptionStillDischargesUnderSoCMin and TestSplitReliefUsesBothBatteries. 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/...: ok
  • go vet ./internal/control/... ./cmd/ftw/... and go build ./...: clean

I 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_min is 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 read fuseTargetBounds, 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

@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-27T13:08:33.800767Z d666cf1 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread go/internal/control/dispatch.go Outdated
Comment on lines +1516 to +1517
dischargeBlocked = dischargeBlocked || lim.dischargeCap() == 0 ||
staleFloor && soc < lim.socFloor()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread go/internal/control/dispatch.go Outdated
Comment on lines +4655 to +4657
fusePartW := math.Max(0, overage-limitOnlyW)
out := allocateFuseRelief(targets, fusePartW, lowers)
return allocateFuseRelief(out, overage-fusePartW, limitLowers)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

frahlg and others added 2 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>
frahlg and others added 2 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>
@frahlg

frahlg commented Oct 27, 2026

Copy link
Copy Markdown
Member Author

Merged master (#1876) into this branch. Two changes:

go test -race ./internal/control ./cmd/ftw passes.

frahlg added a commit that referenced this pull request Oct 27, 2026
… 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 commented Oct 28, 2026

Copy link
Copy Markdown
Member Author

Site rig, short set, on c38d964: 9 pass, 0 known, 0 fail of 9 in 1687 s. Run locally with make siterig on a separate host from the Mac mini runner, because the runner's queue had not reached this PR. The verdicts include s18-16 (car first, battery yields), s7s-25 (restart during Charge now), s8-60-25 and s1/s15 Charge now. The runner did not post this; there is no siterig/short status on this head.

🤖 Generated with Claude Code

@frahlg
frahlg merged commit 7747b9a into master Oct 28, 2026
23 checks passed
frahlg added a commit that referenced this pull request Oct 28, 2026
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>
frahlg added a commit that referenced this pull request Oct 28, 2026
* 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>
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