fix(control): the deadband exit may not strand a blocked charge - #815
Merged
Conversation
floorBlockedCharge (#809) runs inside applyDispatchSafetyPipeline. The three early exits return through fuseSaverEarlyExit and never reach it, and an exit that issues no target withdraws nothing: main.go only sends commands for the targets ComputeDispatch returned, and a driver holds its last accepted setpoint until it gets another one. Only the deadband exit turns that into a trap that does not end, because its own condition can be satisfied by the violation. An idle arbitrage slot over a 2 kW solar surplus: the battery absorbs it, the meter reads -50 W, the error stays inside the 60 W deadband, and the tick that would have stopped the charge walks away for as long as the sun holds. The fix is the charge-side mirror of the carve-out already on the line above it. That condition already declines to exit when noSelfDischarge is armed and a battery is measured discharging; it now also declines when a charge authority is closed and a battery is measured charging. The tick then runs the normal control law, which reaches floorBlockedCharge and commands the 0 W the block already decided. Gated on measured charge rather than on the block alone, so a tick with nothing to withdraw stays quiet and no driver is handed a command it could refuse. Idle and holdoff are left alone, with tests for why: the site-wide block cannot be armed at the idle exit, and idle withholds commands from every battery equally by contract; the holdoff window is bounded by MinDispatchIntervalS and then the normal path runs. Golden corpus: no record moves. None of the 33 no-dispatch records has a closed charge direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Follow-up to the P2 Codex raised on #809 and I deliberately deferred there. The finding is correct, and the reason it was not a one-line addition is the question underneath it.
The finding
floorBlockedCharge(#809) runs insideapplyDispatchSafetyPipeline. The three early exits — idle, holdoff and the reactive deadband — return throughfuseSaverEarlyExitand never reach it.I confirmed all three strand a blocked charge before touching anything. Each of these returns zero targets on master while a charge authority is closed and the battery is measured charging:
charge_capable=false, meter -30 W, battery +2000 Wcharge_capable=false, battery +2000 WDoes "no target" mean "hold the previous command"?
It does, and the code says so in one line.
main.go:2686iterates the returned slice:No target, no command. And a driver holds its last accepted setpoint until it gets another one — that is precisely why #791 bounded each command with a deadline and why #800 walks a driver that cannot actuate to its declared default. #800's PR body states the consequence outright for the fault case: the driver "held its last accepted setpoint for as long as the fault lasted".
There is no path that withdraws a command without issuing a new one. I looked for one.
state.LastTargetsis the only thing that resembles a withdrawal, and it is not one: its three readers are the RLS learning loop (main.go:2421), the history snapshot (main.go:3811) and/api/status+ the support report. It is a record of what was issued, never a source of what to issue.That also answers what the idle exit is doing when it clears
LastTargets. It is not commanding zero — it cannot, it returnsnil. It is saying "there is no dispatch decision in force", where holdoff and the deadband mean "the previous decision still stands". Both readings are honest about the same silence, and the RLS loop needs the difference: on a suppressed tick the driver really is still holding that command, so re-feeding the(last_command, actual)pair is right, and on an idle tick there is no decision to attribute. So the other two exits do not owe the same clearing. They owe something else.Withdraw, or command zero?
Command zero — but the tick issues it, not the exit.
The alternative I rejected was to teach
fuseSaverEarlyExitto emit explicit zero targets for blocked drivers. It is the smaller-looking diff and it is the wrong shape:dispatch.go:1321and the holdoff at:1354; the site meter is read at:1359, batteries are gathered at:1400, andnoSelfChargeis computed at:1485. Reaching the block from there means hoisting the meter read, the battery gather and the gate computation above both exits. Those blocks are not pure — they mutatestate.EVChargingWandstate.liveEVChargingW— so hoisting them changes what a suppressed tick does to state, on the live control path, for a fix that is meant to be narrow.applyPlanSignFloor,applyBatteryBoostReserveorforceFuseDischarge. A new door to the drivers is exactly what an early exit should not grow.The fix instead makes the tick decline to leave. The mirror already exists on the line above. That condition has always refused the deadband when
noSelfDischargeis armed and a battery is measured discharging — because a small grid error can be small because the battery is doing the forbidden thing. The charge side of that sentence was missing:The tick then runs the normal control law and reaches
floorBlockedCharge, which commands the 0 W the block already decided. No new command path, no new enumeration, and the full pipeline ends in the sameforceFuseDischargethe early exit's fuse-saver would have run — safety does not thin out on the path that now stays.anyBlockedBatteryChargingreads both offloorBlockedCharge's authorities,noSelfChargeand the per-driverchargeBlocked, so the exit condition and the floor cannot drift apart.Why the deadband is the one that has to be fixed
Because its own condition can be satisfied by the violation. An idle arbitrage slot over a 2 kW solar surplus: the battery absorbs it, so the meter reads -50 W, so the error sits inside the 60 W deadband, so the tick that would stop the charge walks away — and the charge that made the error small is still running on the next tick, and the next. The site swallows the surplus the slot exists to export, for as long as the sun holds. Nothing breaks the loop.
Idle and holdoff are silences with an outside end to them. The deadband's silence is fed by what it is failing to stop.
Idle and holdoff: no change, and the tests that would catch me
Holdoff — bounded, and the bound is what the fix would buy. It arms only after a dispatch actually happened, and
fuseSaverEarlyExitrefreshesLastDispatchonly when the fuse-saver fires, so the window closes afterMinDispatchIntervalS(default 5 s) and the normal path runs.TestHoldoffDelaysTheChargeBlockButDoesNotDefeatItpins both halves with identical inputs: quiet inside the window, 0 W the moment it expires. It fails on master, because on master the expiry leads to the deadband exit and the block is never applied at all.There is a tempting half-fix here:
plannerSelfExportSurplusGateandplannerSelfNoChargeStalePlanare known at the holdoff exit — onlyarbitrageFamilyIdleLiveExportGateis not, because it needs the meter and the battery sum. Consulting the two that are available would fix two authorities out of three and leave the third armed as a trap. That is the enumeration #809 argued against, arriving through a different door.Idle — the site-wide block cannot be armed there.
effectiveModereachesModeIdleby exactly two routes: the operator's own idle mode, where the planner branch never ran and all three gates hold their zero value; and a planner slot whosePlanTargetreturned"idle", which is insidecase state.Mode.IsPlannerMode()(so notplanner_self, whose branch forcesModeSelfConsumption) and inside the!arbitrageFamilyIdleSlotarm (so the arbitrage gate's precondition is false). What remains is the per-driver report, and idle withholds commands from acharge_capable=falsebattery exactly as it withholds them from any other — that is idle's declared contract, "Do nothing — no dispatch" (modes_catalog.go:47).TestIdleExitWithholdsCommandsFromBlockedAndUnblockedAlikepins that equality, so if somebody later decides idle must withdraw the previous command on entry, the test moves and says so. That is a decision about what idle mode means, and it is worth having separately — it is the same silence for an unblocked battery charging at 5 kW when the operator switches to idle.Not turning quiet ticks into commands
The predicate is gated on a battery measured charging, not on the block existing. That matters for #800/#805: an explicit zero is a command, and a driver that refuses it now counts as refusing. If the deadband fell through whenever a block were merely armed, every deadband tick of an idle arbitrage slot — all night, battery at 0 W — would issue commands and put a refusal counter behind each one.
Measured charge also mirrors
anyBatteryDischarging's ±1 W test exactly, and it makes the fall-through self-terminating: the tick commands 0, the battery ramps down, the predicate goes false, the site goes quiet again.What remains, and is correct: a driver that keeps refusing the stop reaches #800's three-refusal threshold and is walked to its autonomous default and out of the fleet. A battery that will not stop charging when the site has closed the charge direction should end up there.
Three tests guard the quiet side and pass either way by design —
TestDeadbandExitStaysQuietWhenNothingIsBlocked(no gate, battery charging: still silent),...WhenTheBlockedBatteryIsIdle(block armed, nothing to withdraw),...WhenTheBlockedBatteryIsDischarging(the floor is one-sided and so is the exit that feeds it).TestDeadbandDischargeCarveOutStillFiresguards the precedent this fix is built on.Golden corpus
Predicted before looking: nothing moves. The change only removes an early return, so the only records that can move are the 33 that dispatch nothing — and a record moves only if it takes the deadband exit with a charge authority closed and a battery measured charging.
I checked the 33 first. 25 are
idlemode, 1 is the holdoff record, and the 8 deadband records are allpeak_shaving, where none of the threenoSelfChargegates is a planner-mode gate that can fire. No record in the corpus hascharge_blocked: trueon any battery, in any family. So nothing should move.Verified:
TestGoldenCorpusReplaypasses untouched. 590 records in 8 families, 0.01 W tolerance, no file re-recorded.Tests
go/internal/control/deadband_charge_block_test.go. Four fail against master:TestDeadbandExitMayNotStrandBlockedCharge— the reproduction, watt for watt from the P2.TestDeadbandExitMayNotStrandStalePlanChargeBlock— the second site-wide authority; asserts the gate armed so it cannot pass vacuously.TestDeadbandExitMayNotStrandChargeBlockedDriver— the per-driver authority.TestHoldoffDelaysTheChargeBlockButDoesNotDefeatIt— the bound that justifies leaving holdoff alone.Plus
TestAnyBlockedBatteryChargingContract, an eight-case table pinning the predicate against both authorities and all three measured directions.make verifyclean, as the pre-commit gate on the commit.Contention
Only
go/internal/control/dispatch.goand a new test file. No open PR touchesdispatch.go— checked the file list of every open PR. #798 ownsgo/internal/drivers/registry.goand #797 ownsgo/internal/config; neither is here.🤖 Generated with Claude Code