DEV-02: zero every unit's target when the operator stops, not just the site's
checks / unit (pull_request) Successful in 4m12s

A hazard THIS CARD CREATED, found by re-reading the operator paths rather than
by a failing check - so a check now exists for it.

Before DEV-02 the heartbeat re-emitted `self.target`, so the stop button
zeroing that one scalar was enough: the next refresh carried the zero. The
heartbeat now re-emits `inv.target` - it has to, or it would put the SITE
number on every inverter, which is the ARCH-01 broadcast fault arriving once
every `heartbeat_s` on a path with no allocator in it.

So a stop that zeroed only `self.target` left each unit's LAST SHARE standing.
No cycle has run yet at that moment, so nothing else would have cleared it, and
the very next heartbeat - up to `heartbeat_s` later - would put it back on the
wire. The operator presses stop, watches the inverter go to zero, and sees it
resume seconds later.

`command_site_zero()` moves both together and carries the reason. It is one
method rather than two lines at three call sites precisely because the two
lines have to stay together: shutdown(), the stop button and maint_abort all
mean the same thing, and 0 W is the one value that may legitimately be written
site-wide.

test_inverter.py part 6 presses stop with 995/995 standing and then runs the
SHIPPED `send_heartbeats()` before any cycle, which is the racing refresh. The
mutation that zeroes the site target alone takes it red.

19 files green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-02 14:58:54 +02:00
co-authored by Claude Opus 5
parent e306c74cb8
commit e941bf7f62
2 changed files with 69 additions and 10 deletions
+29 -9
View File
@@ -2181,14 +2181,32 @@ class Controller:
"""Deterministic wind-down. Do not skip this.""" """Deterministic wind-down. Do not skip this."""
self.stopping = True self.stopping = True
_LOG.info("shutting down - commanding 0 W on every inverter") _LOG.info("shutting down - commanding 0 W on every inverter")
# 0 W is the one value that means the same thing to every inverter, so await self.command_site_zero()
# the site-wide form of the write is legitimate here - see self.mqtt.close()
# write_setpoint(). Each unit's target is zeroed too, so a heartbeat
# racing this cannot re-emit the old share. async def command_site_zero(self) -> None:
"""Every inverter to 0 W, and every inverter's TARGET to 0 W with it.
⚠️ THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02
CREATED. Before this card the heartbeat re-emitted `self.target`, so
zeroing that one scalar was enough: the stop button set it and the next
refresh carried the zero. The heartbeat now re-emits `inv.target` - it
has to, or it would broadcast the site number - so a stop that zeroed
only `self.target` would leave each unit's LAST SHARE standing, and the
very next heartbeat (up to `heartbeat_s` later, and before any cycle has
run) would put it back on the wire. The operator would have pressed
stop, watched the inverter go to zero, and seen it resume seconds later.
Both must move together, which is why this is one method with a reason
and not two lines repeated at three call sites.
⚠️ AND 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE. It means the
same thing to every inverter; a non-zero site-wide write is the ARCH-01
broadcast fault, and `write_setpoint()` refuses it.
"""
self.target = 0.0
for inv in self.inverters: for inv in self.inverters:
inv.target = 0.0 inv.target = 0.0
await self.write_setpoint(0.0, force=True) await self.write_setpoint(0.0, force=True)
self.mqtt.close()
# -- UI ------------------------------------------------------------------ # -- UI ------------------------------------------------------------------
def checks(self, when: datetime | None = None) -> list: def checks(self, when: datetime | None = None) -> list:
@@ -2572,8 +2590,11 @@ class Controller:
self.log_event(f"sign-disagreement failsafe cleared by " self.log_event(f"sign-disagreement failsafe cleared by "
f"operator on {inv.name}") f"operator on {inv.name}")
if not self.auto: if not self.auto:
self.target = 0.0 # ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
await self.write_setpoint(0.0, force=True) # Zeroing `self.target` alone would leave each unit's last share
# standing for its next heartbeat to re-emit, which an operator
# would read as the stop button not working.
await self.command_site_zero()
return {"auto": self.auto} return {"auto": self.auto}
if what == "maint_start": if what == "maint_start":
for msg in self.maint.force_start(now): for msg in self.maint.force_start(now):
@@ -2582,8 +2603,7 @@ class Controller:
if what == "maint_abort": if what == "maint_abort":
for msg in self.maint.abort(now, "operator"): for msg in self.maint.abort(now, "operator"):
self.log_event(msg) self.log_event(msg)
self.target = 0.0 await self.command_site_zero()
await self.write_setpoint(0.0, force=True)
return {"phase": self.maint.phase} return {"phase": self.maint.phase}
return None return None
+40 -1
View File
@@ -954,6 +954,41 @@ check("...and the shipped budget is restored after the experiments, so nothing "
app_main.WRITE_BUDGET_S == WRITE_BUDGET_S) app_main.WRITE_BUDGET_S == WRITE_BUDGET_S)
# ⚠️ AND THE OPERATOR'S STOP HAS TO MOVE THE SAME TWO THINGS. This is a hazard
# THIS CARD CREATED and it is worth stating plainly: before DEV-02 the heartbeat
# re-emitted `self.target`, so the stop button zeroing that one scalar was
# enough. It now re-emits `inv.target` - it must, or it would broadcast the site
# number - so a stop that zeroed only the site target would leave each unit's
# last share standing for the very next refresh to put back on the wire, seconds
# after the operator watched the inverter go to zero.
async def _stop_then_beat():
store = MemStore()
hass = Hass()
c, _ = built({"auto_start": True}, store=store, hass=hass,
second=inverter_b(store))
c.auto = True
c.target = 1990.0
c.inverters[0].target, c.inverters[1].target = 995.0, 995.0
await c.handle_action("auto_toggle") # -> stopped
stopped_at = list(hass.writes)
# A heartbeat racing the press, before any cycle has run.
await c.send_heartbeats(1000.0, 10.0)
return c, hass, stopped_at
_c6s, _h6s, _stop_writes = run(_stop_then_beat())
print(f" stop pressed with shares standing at 995/995: wrote {_stop_writes}, "
f"then the next heartbeat wrote {_h6s.writes[len(_stop_writes):]}")
check("the stop button commands 0 W to EVERY inverter",
sorted(_stop_writes) == [("number.sp_a", 0.0), ("number.sp_b", 0.0)])
check("⚠️ AND THE NEXT HEARTBEAT DOES NOT PUT THE OLD SHARES BACK. Each unit's "
"own target was zeroed with the write, so the refresh that proves this "
"add-on is alive carries the zero rather than resurrecting the command "
"the operator just stopped",
all(v == 0.0 for _e, v in _h6s.writes)
and all(i.target == 0.0 for i in _c6s.inverters))
# ============================================================================== # ==============================================================================
print() print()
print("7. AC 7: live_units() answers per-instance liveness honestly") print("7. AC 7: live_units() answers per-instance liveness honestly")
@@ -1145,6 +1180,10 @@ MUTATIONS = [
"test_inverter.py part 4: 'the pack floor is the EMPTIEST pack'"), "test_inverter.py part 4: 'the pack floor is the EMPTIEST pack'"),
("`device`/`supervised` filed without `inv` - B's rating on A", ("`device`/`supervised` filed without `inv` - B's rating on A",
"test_peakguard.py 10g: the static routing pin, at the line"), "test_peakguard.py 10g: the static routing pin, at the line"),
("the stop button zeroing `self.target` but not each `inv.target`",
"test_inverter.py part 6: 'the next heartbeat does NOT put the old shares "
"back' - a hazard THIS CARD created, because the heartbeat re-emits a "
"share now and not the site number"),
] ]
# ⚠️ TWO OF THESE SURVIVED THE FIRST TIME AND THE CHECKS WERE CHANGED, NOT THE # ⚠️ TWO OF THESE SURVIVED THE FIRST TIME AND THE CHECKS WERE CHANGED, NOT THE
# TABLE. The serialised dispatch and the shared `last_beat` both passed while # TABLE. The serialised dispatch and the shared `last_beat` both passed while
@@ -1159,7 +1198,7 @@ for what, caught in MUTATIONS:
check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check " check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check "
f"above. A guard whose checks survive its own defeat is not a guard, and " f"above. A guard whose checks survive its own defeat is not a guard, and "
f"this repository has three separate records of exactly that", f"this repository has three separate records of exactly that",
len(MUTATIONS) == 18) len(MUTATIONS) == 19)
print() print()