Skip to content

fix(control): idle stops the fleet instead of stopping the commands - #817

Merged
frahlg merged 2 commits into
masterfrom
agent/idle-means-stop
Aug 5, 2026
Merged

fix(control): idle stops the fleet instead of stopping the commands#817
frahlg merged 2 commits into
masterfrom
agent/idle-means-stop

Conversation

@frahlg

@frahlg frahlg commented Aug 5, 2026

Copy link
Copy Markdown
Member

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:

  • Ferroamp — the 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. drivers/ferroamp.lua has documented this
    in its zero branch ever since, and re-publishes forced idle on every command
    it receives — it just never heard from this mode.
  • Sungrow — holds until told otherwise.

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.

ModeIdle now builds a 0 W target for every battery it may command and runs
it through applyDispatchSafetyPipeline, on every tick. The mode key stays
idle
/api/modes and the Home Assistant contract are untouched, and so
is every automation built on them.

The two decisions, both written into the tooltip

Label: IdleStop batteries

Tooltip: "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 an
11.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 that
the curtail path cannot tell which battery mode is selected) and
TestIdleDoesNotWithdrawTheEVReserve.

"Hands off" is not a new mode. The per-driver observe_only config already
occupies 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_w following because the
recorder 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, which
receives exactly the zeros fuseSaverFromZero gives it today; nothing outside
the 43 idle records would move.

Result: 25 of 611 moved. Zero unpredicted, zero predicted-but-absent. No
import_ceiling_binding / export_ceiling_binding flips, as predicted.

scenario grid W before after projected grid W
early_exit/idle_nothing_binds_quiet 3000 no targets ferroamp @ 0 W 3000 → 3000
incident/B9_idle_mode_never_moves 5000 no targets ferroamp @ 0 W 5000 → 5000
seeded_fuse/002 14791 no targets ferroamp sungrow @ 0 W 14791 → 16648
seeded_fuse/034 12656 no targets ferroamp sungrow @ 0 W 12656 → 12653
seeded_fuse/038 14974 no targets ferroamp pixii sungrow @ 0 W 14974 → 14474
seeded_fuse/054 14101 no targets ferroamp sungrow @ 0 W 14101 → 9375
seeded_fuse/058 17645 no targets ferroamp @ 0 W 17645 → 14987
seeded_fuse/062 20047 no targets ferroamp pixii sungrow @ 0 W 20047 → 15547
seeded_reactive/003_idle 240 no targets ferroamp @ 0 W 240 → 2465
seeded_reactive/009_idle -4188 no targets ferroamp @ 0 W -4188 → -569
seeded_reactive/015_idle -601 no targets ferroamp @ 0 W -601 → -1658
seeded_reactive/021_idle -6003 no targets ferroamp pixii sungrow @ 0 W -6003 → -7848
seeded_reactive/027_idle 1893 no targets ferroamp @ 0 W 1893 → 5552
seeded_reactive/033_idle -1112 no targets ferroamp @ 0 W -1112 → -1596
seeded_reactive/039_idle -5371 no targets ferroamp @ 0 W -5371 → -2524
seeded_reactive/045_idle 176 no targets ferroamp sungrow @ 0 W 176 → -4231
seeded_reactive/051_idle -1951 no targets ferroamp @ 0 W -1951 → -2385
seeded_reactive/057_idle -696 no targets ferroamp @ 0 W -696 → 192
seeded_reactive/063_idle -2944 no targets ferroamp pixii sungrow @ 0 W -2944 → -8884
seeded_reactive/069_idle -3162 no targets ferroamp @ 0 W -3162 → -3537
seeded_reactive/075_idle -197 no targets ferroamp @ 0 W -197 → 751
seeded_reactive/081_idle -61 no targets ferroamp @ 0 W -61 → -3767
seeded_reactive/087_idle 1306 no targets ferroamp pixii sungrow @ 0 W 1306 → 2202
seeded_reactive/093_idle -3819 no targets ferroamp @ 0 W -3819 → -2784
seeded_reactive/099_idle 5005 no targets ferroamp sungrow @ 0 W 5005 → 9935

Re-recorded in its own commit (70074a1f) so the watts are reviewable apart
from 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 the
exit 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.luapower_w = 0modbus_write(47100, 0), commented
    "stop forcible mode (device resumes self-consumption)".
  • drivers/ferroamp_modbus.luapower_w == 0 → mode 0, commented "release
    to 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 documented
rule 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:

  • holdFleetAtZero only commands drivers that are online and report
    DerBattery. The driver that hard-rejects a battery command — sungrow.lua
    on a string inverter, code = "no_battery" — never enters the set, since it
    emits no battery reading.
  • isCommandRefusal already excludes ErrObserveOnly and ErrControlBlocked.
  • The "default held" carve-out lives on the release path
    (set_self_consumption), which idle never invokes: idle sends action: 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 verify clean · npm test 297/297 · new idle_hold_test.go covers the
issued 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 ModeCatalog and need no web edit.

🤖 Generated with Claude Code

frahlg and others added 2 commits August 5, 2026 08:53
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>
@frahlg
frahlg merged commit 65cad56 into master Aug 5, 2026
13 checks passed
@frahlg
frahlg deleted the agent/idle-means-stop branch August 5, 2026 07:06
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