Skip to content

fix(control): the deadband exit may not strand a blocked charge - #815

Merged
frahlg merged 1 commit into
masterfrom
agent/early-exit-blocked-charge
Aug 5, 2026
Merged

fix(control): the deadband exit may not strand a blocked charge#815
frahlg merged 1 commit into
masterfrom
agent/early-exit-blocked-charge

Conversation

@frahlg

@frahlg frahlg commented Aug 5, 2026

Copy link
Copy Markdown
Member

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 inside applyDispatchSafetyPipeline. The three early exits — idle, holdoff and the reactive deadband — return through fuseSaverEarlyExit and 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:

exit scenario targets on master
deadband idle arbitrage slot, meter -50 W, battery +2000 W, tolerance 60 W none
deadband planner_self stale plan, meter -40 W, battery +2500 W none
deadband charge_capable=false, meter -30 W, battery +2000 W none
idle charge_capable=false, battery +2000 W none
holdoff idle arbitrage slot, battery +2000 W none

Does "no target" mean "hold the previous command"?

It does, and the code says so in one line. main.go:2686 iterates the returned slice:

for _, t := range finalTargets {
    ...
    actuation.dispatchCommand(ctx, reg, "driver send", t.Driver, payload, driverCmdTimeout, tickNow)
}

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.LastTargets is 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 returns nil. 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 fuseSaverEarlyExit to emit explicit zero targets for blocked drivers. It is the smaller-looking diff and it is the wrong shape:

  • The two exits that need it most do not have the inputs. The idle exit is at dispatch.go:1321 and the holdoff at :1354; the site meter is read at :1359, batteries are gathered at :1400, and noSelfCharge is 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 mutate state.EVChargingW and state.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.
  • The zero it emitted would be an unconditioned zero: a second command-issuing path that never sees applyPlanSignFloor, applyBatteryBoostReserve or forceFuseDischarge. A new door to the drivers is exactly what an early exit should not grow.
  • It would have to enumerate which drivers to zero, which is the trap fix(control): the slew limiter may not re-open a closed charge block #809 spent its PR body on.

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 noSelfDischarge is 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:

if !surplusActive && math.Abs(errW) < state.GridToleranceW &&
    !(noSelfDischarge && anyBatteryDischarging(onlineBats)) &&
    !anyBlockedBatteryCharging(onlineBats, noSelfCharge) {

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 same forceFuseDischarge the early exit's fuse-saver would have run — safety does not thin out on the path that now stays.

anyBlockedBatteryCharging reads both of floorBlockedCharge's authorities, noSelfCharge and the per-driver chargeBlocked, 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 fuseSaverEarlyExit refreshes LastDispatch only when the fuse-saver fires, so the window closes after MinDispatchIntervalS (default 5 s) and the normal path runs. TestHoldoffDelaysTheChargeBlockButDoesNotDefeatIt pins 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: plannerSelfExportSurplusGate and plannerSelfNoChargeStalePlan are known at the holdoff exit — only arbitrageFamilyIdleLiveExportGate is 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. effectiveMode reaches ModeIdle by 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 whose PlanTarget returned "idle", which is inside case state.Mode.IsPlannerMode() (so not planner_self, whose branch forces ModeSelfConsumption) and inside the !arbitrageFamilyIdleSlot arm (so the arbitrage gate's precondition is false). What remains is the per-driver report, and idle withholds commands from a charge_capable=false battery exactly as it withholds them from any other — that is idle's declared contract, "Do nothing — no dispatch" (modes_catalog.go:47). TestIdleExitWithholdsCommandsFromBlockedAndUnblockedAlike pins 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). TestDeadbandDischargeCarveOutStillFires guards 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 idle mode, 1 is the holdoff record, and the 8 deadband records are all peak_shaving, where none of the three noSelfCharge gates is a planner-mode gate that can fire. No record in the corpus has charge_blocked: true on any battery, in any family. So nothing should move.

Verified: TestGoldenCorpusReplay passes 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 verify clean, as the pre-commit gate on the commit.

Contention

Only go/internal/control/dispatch.go and a new test file. No open PR touches dispatch.go — checked the file list of every open PR. #798 owns go/internal/drivers/registry.go and #797 owns go/internal/config; neither is here.

🤖 Generated with Claude Code

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>
@frahlg
frahlg merged commit 4007fdb into master Aug 5, 2026
13 checks passed
@frahlg
frahlg deleted the agent/early-exit-blocked-charge branch August 5, 2026 06:20
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