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."""
self.stopping = True
_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
# the site-wide form of the write is legitimate here - see
# write_setpoint(). Each unit's target is zeroed too, so a heartbeat
# racing this cannot re-emit the old share.
await self.command_site_zero()
self.mqtt.close()
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:
inv.target = 0.0
await self.write_setpoint(0.0, force=True)
self.mqtt.close()
# -- UI ------------------------------------------------------------------
def checks(self, when: datetime | None = None) -> list:
@@ -2572,8 +2590,11 @@ class Controller:
self.log_event(f"sign-disagreement failsafe cleared by "
f"operator on {inv.name}")
if not self.auto:
self.target = 0.0
await self.write_setpoint(0.0, force=True)
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
# 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}
if what == "maint_start":
for msg in self.maint.force_start(now):
@@ -2582,8 +2603,7 @@ class Controller:
if what == "maint_abort":
for msg in self.maint.abort(now, "operator"):
self.log_event(msg)
self.target = 0.0
await self.write_setpoint(0.0, force=True)
await self.command_site_zero()
return {"phase": self.maint.phase}
return None