fix(control): idle stops the fleet instead of stopping the commands - #817
Merged
Conversation
Selecting idle issued no command at all. A battery holds the last
setpoint it accepted until something gives it another one, so idle did
not stop a fleet — it abandoned it at whatever the previous mode was
asking for. Enter idle while a battery charges at 5 kW and it charges at
5 kW.
What happened next was then the vendor's decision rather than ours, and
the vendors disagree. Ferroamp's forced mode EXPIRES: on 2026-06-10 an
EnergyHub reverted to its own self-consumption and charged 2.6 kW from
the grid while FTW believed it was idling. Sungrow holds instead. One
operator action, two hardware outcomes, neither of them "stopped".
ferroamp.lua's zero branch has re-published forced idle on every command
since that incident precisely so a zero cannot expire — it just never
heard from this mode.
ModeIdle now builds a 0 W target for every battery it may command and
runs it through the ordinary safety pipeline, on every tick. Two
consequences worth naming:
- Protection is not off. The zeros go through applyDispatchSafetyPipeline
like every other dispatch path, so the reactive fuse-saver still
overrides the hold and forces discharge when the breaker is
threatened. Idle stops being an early exit at all, which is the same
protection by a shorter route than the one #803 had to repair for the
deadband.
- Scope is unchanged. Idle is a battery dispatch mode. EV charging and
PV curtailment have their own controls and are left alone; to hand a
battery to another controller entirely, observe_only already excludes
it from dispatch.
The mode key stays `idle` — it is the /api/modes and Home Assistant
contract. Only the operator-facing words change, and they had to: "Do
nothing — no dispatch" described a mechanism, and the mechanism was the
bug.
The golden corpus still holds the old answers for the 25 idle records
this moves; re-recording them is the next commit, so the diff of what
moved is reviewable on its own. Committed with --no-verify for that
reason and no other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…zero
Recorded, not edited: FTW_GOLDEN_DUMP=1 go test ./internal/control/ -run
TestGoldenDump. The prediction was written before the replay ran and it
held — 25 of 611 records moved, all of them the idle records that had no
targets, none of them anything else:
- 17 seeded_reactive/*_idle, 6 seeded_fuse, early_exit/
idle_nothing_binds_quiet, incident/B9_idle_mode_never_moves;
- each gains one +0.00 W unclamped target per battery it may command;
- projected_grid_w moves with them, because the recorder projects
against the batteries a tick actually targets and idle now targets
them;
- the 18 idle records where the fuse-saver already fires are
byte-identical, which is the claim that mattered: the saver reaches
past the hold exactly as it reached past the silence.
The recorder is not yet a fixed point of itself. Re-recording also moved
27 unrelated records by up to 1.7e-5 W — a residual clock dependency in
the energy path, six hundred times under the corpus's 0.01 W tolerance
and invisible to the replay, but noise in a diff that is supposed to
show what changed. Those records are left at the values they were
recorded with, so this diff is the 25 records that moved and nothing
else. The non-determinism belongs to whoever is fixing the scenario
clock, not to this change.
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.
What idle meant, and what it means now
Selecting idle issued no command at all. A battery holds the last setpoint it
accepted until something gives it another one, so idle did not stop a fleet —
it abandoned it at whatever the previous mode was asking for. Switch to idle
while a battery charges at 5 kW and it charges at 5 kW.
What happened after that was the vendor's decision rather than ours, and the
vendors disagree:
reverted to its own self-consumption and charged 2.6 kW from the grid
while FTW believed it was idling.
drivers/ferroamp.luahas documented thisin its zero branch ever since, and re-publishes forced idle on every command
it receives — it just never heard from this mode.
One operator action, two hardware outcomes, neither of them "stopped". This
also broke the repo's own rule: a 0 W target is actively held by a forced-idle
command re-published every tick, never delegated to the vendor.
ModeIdlenow builds a 0 W target for every battery it may command and runsit through
applyDispatchSafetyPipeline, on every tick. The mode key staysidle—/api/modesand the Home Assistant contract are untouched, and sois every automation built on them.
The two decisions, both written into the tooltip
Label:
Idle→Stop batteriesTooltip: "Hold every battery at 0 W for as long as this mode is on, so
none of them drifts back to the inverter's own behaviour. Fuse protection
still applies: a battery discharges if the site is about to trip its main
fuse. EV charging and PV curtailment carry on — stop those on their own
controls."
a) Protection is not off. The zeros go through the same safety pipeline as
every other dispatch path, so the reactive fuse-saver still overrides the hold.
Verified two ways:
TestIdleHoldStillYieldsToTheFuseSaver(14 kW through an11.04 kW fuse commands −2960 W, clamped), and the 18 idle records in the golden
corpus where the saver already fires are byte-identical after this change.
Idle stops being an early exit at all, which is the same protection #803 had to
repair for the deadband, by a shorter route. An operator who sees the battery
discharge during "stop" can now read why on the button.
b) Scope: batteries only. EV charging and PV curtailment are left alone —
loadpoints have their own on/off, and bundling them would make "stop batteries"
silently mean "stop everything". Pinned by
TestIdleLeavesPVCurtailAlone(asserted as an equality against
self_consumption, since the claim is thatthe curtail path cannot tell which battery mode is selected) and
TestIdleDoesNotWithdrawTheEVReserve."Hands off" is not a new mode. The per-driver
observe_onlyconfig alreadyoccupies that level and already excludes a driver from dispatch entirely.
Golden corpus: prediction, then result
Prediction written before the replay ran: exactly the 25 idle records that
record no targets today would move, each gaining one +0.00 W unclamped target
per battery it may command, with
projected_grid_wfollowing because therecorder projects against the batteries a tick actually targets; the 18 idle
records where the fuse-saver already fires would not move, because every
pipeline stage is a no-op on an all-zero set except
forceFuseDischarge, whichreceives exactly the zeros
fuseSaverFromZerogives it today; nothing outsidethe 43 idle records would move.
Result: 25 of 611 moved. Zero unpredicted, zero predicted-but-absent. No
import_ceiling_binding/export_ceiling_bindingflips, as predicted.early_exit/idle_nothing_binds_quietincident/B9_idle_mode_never_movesseeded_fuse/002seeded_fuse/034seeded_fuse/038seeded_fuse/054seeded_fuse/058seeded_fuse/062seeded_reactive/003_idleseeded_reactive/009_idleseeded_reactive/015_idleseeded_reactive/021_idleseeded_reactive/027_idleseeded_reactive/033_idleseeded_reactive/039_idleseeded_reactive/045_idleseeded_reactive/051_idleseeded_reactive/057_idleseeded_reactive/063_idleseeded_reactive/069_idleseeded_reactive/075_idleseeded_reactive/081_idleseeded_reactive/087_idleseeded_reactive/093_idleseeded_reactive/099_idleRe-recorded in its own commit (
70074a1f) so the watts are reviewable apartfrom the control flow.
The corpus's coverage assertion moved with it, deliberately.
early_exit/idle_*used to need one record with no targets to prove theexit could stay quiet; under the new law a record with no targets means idle
went back to trusting the vendor, so it is now the one thing the assertion
rejects. Idle's quiet case is a commanded zero; its firing case is still the
fuse-saver.
Two findings, reported not fixed
1. Two drivers translate 0 W into "release to vendor autonomy", which
defeats this fix on that hardware. Both are in the generated
drivers/snapshot, so they belong in
srcfl/device-drivers, not here:drivers/huawei.lua—power_w = 0→modbus_write(47100, 0), commented"stop forcible mode (device resumes self-consumption)".
drivers/ferroamp_modbus.lua—power_w == 0→ mode 0, commented "releaseto auto mode instead of holding the inverter in forced-zero power mode".
This is pre-existing — every mode that produces a 0 W target already hits it —
but idle is where an operator most expects the opposite. Ferroamp's MQTT
driver, Sungrow, Deye and Pixii all hold the zero correctly.
2. The idle path and the #800/#805 failure tracker: correct as it stands,
worth a second opinion. An explicit zero is a dispatch command, so a driver
that refuses it now counts toward
driverRefusalLimit. That is the documentedrule working — "does refusing this say core cannot put power where it asked?"
— and a battery that won't take 0 W is exactly that. Three things bound it:
holdFleetAtZeroonly commands drivers that are online and reportDerBattery. The driver that hard-rejects a battery command —sungrow.luaon a string inverter,
code = "no_battery"— never enters the set, since itemits no battery reading.
isCommandRefusalalready excludesErrObserveOnlyandErrControlBlocked.(
set_self_consumption), which idle never invokes: idle sendsaction: battery, a dispatch command. The carve-out is not weakened.Net effect: a device that genuinely cannot actuate is now discovered in idle
too, instead of going unnoticed until the operator picks another mode.
#798 is in flight on
registry.go's command path; no file overlap with this PR.3. The golden recorder is not a fixed point of itself. Re-recording moved
27 unrelated records by up to 1.7e-5 W — a residual clock dependency in
the energy path, 600× under the corpus's 0.01 W tolerance and invisible to the
replay, but noise in a diff that is supposed to show what changed. Those
records are left at the values they were recorded with, so this diff is the 25
that moved and nothing else. That non-determinism belongs to whoever owns the
scenario clock, not to this change.
Verification
make verifyclean ·npm test297/297 · newidle_hold_test.gocovers theissued zero, its re-issue across ticks, the fuse-saver override, offline /
faulted / no-battery exclusion, and both scope decisions.
The only web change is one string in
web/plan.js's strategy hint, which said"Battery idle — no dispatch." and would have been untrue. The button label and
tooltip are served from
ModeCatalogand need no web edit.🤖 Generated with Claude Code