SAFETY-137: the shutdown and Stop 0 W is the last write every unit hears
The 0 W of shutdown and of the Stop button now waits (bounded, 2 s) for a write already on its way to the same unit, for every driver, GoodWe included. Once shutdown has begun, cycle, heartbeat and safety-stop writes are dropped right before the write is created. One shared wait (_await_inflight) serves the 0 W and SMA-10's _release_last. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,13 @@ RELEASE_BUDGET_S = WRITE_BUDGET_S
|
||||
FALLBACK_BUDGET_S = 2.0
|
||||
HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S
|
||||
|
||||
# SAFETY-137. How long a LAST write (the Stop 0 W, the shutdown 0 W, a release)
|
||||
# waits for a write already on its way to the same unit. Half the release
|
||||
# budget, as `_release_last` always waited. For a GoodWe unit that is
|
||||
# SETTLE_BUDGET_S + WRITE_BUDGET_S = 6 s at worst, which is HAND_BACK_WORST_S:
|
||||
# the wind-down is no slower than SMA-10's worst case.
|
||||
SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2
|
||||
|
||||
# SAFETY-34: a Core-only restart wipes persistent_notification and does NOT
|
||||
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
|
||||
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
|
||||
@@ -4020,7 +4027,7 @@ class Controller:
|
||||
return rec.authorises(inv.name, value, self.alloc_tick)
|
||||
|
||||
async def write_setpoint(self, value: float, *, force: bool = False,
|
||||
unit=None) -> None:
|
||||
unit=None, last: bool = False) -> None:
|
||||
"""Put `value` on ONE inverter, or - with `unit=None` - on all of them.
|
||||
|
||||
⚠️ `unit=None` IS FOR A NUMBER THAT IS NOT A SHARE. The stop button,
|
||||
@@ -4059,16 +4066,23 @@ class Controller:
|
||||
targets = [unit]
|
||||
if not targets:
|
||||
return
|
||||
await asyncio.gather(*(self._write_unit(inv, value, force=force)
|
||||
await asyncio.gather(*(self._write_unit(inv, value, force=force,
|
||||
last=last)
|
||||
for inv in targets))
|
||||
|
||||
async def _await_inflight(self, inv, budget_s: float) -> None:
|
||||
"""Wait, at most `budget_s`, for the writes already on their way to
|
||||
`inv` (SAFETY-137: every driver, GoodWe too). A write still running at
|
||||
the bound is left alone, not cancelled: its own WRITE_BUDGET_S ends it."""
|
||||
pending = [t for t in inv.inflight if not t.done()]
|
||||
if pending:
|
||||
await asyncio.wait(pending, timeout=budget_s)
|
||||
|
||||
async def _release_last(self, inv) -> bool:
|
||||
"""`release()`, after a write already on its way to `inv` has finished
|
||||
(at most half the budget is spent waiting for it), so the release is the
|
||||
LAST thing the unit hears and a late write cannot undo it."""
|
||||
pending = [t for t in inv.inflight if not t.done()]
|
||||
if pending:
|
||||
await asyncio.wait(pending, timeout=RELEASE_BUDGET_S / 2)
|
||||
await self._await_inflight(inv, SETTLE_BUDGET_S)
|
||||
return await self._driver(inv).release()
|
||||
|
||||
async def _hand_back(self, inv, *, force: bool = False) -> bool:
|
||||
@@ -4141,7 +4155,8 @@ class Controller:
|
||||
_LOG.debug("The fallback write to %s failed: %r", inv.name, err)
|
||||
return False
|
||||
|
||||
async def _write_unit(self, inv, value: float, *, force: bool = False) -> None:
|
||||
async def _write_unit(self, inv, value: float, *, force: bool = False,
|
||||
last: bool = False) -> None:
|
||||
if not inv.driver.configured():
|
||||
return
|
||||
# --- SMA-10: A UNIT THAT HANDS BACK IS NOT WRITTEN WHILE THE ADD-ON IS
|
||||
@@ -4284,6 +4299,21 @@ class Controller:
|
||||
value, inv.name, shadow.WIRE_W)
|
||||
value = shadow.WIRE_W
|
||||
|
||||
# --- SAFETY-137: THE 0 W THAT ENDS THINGS IS THE LAST WRITE THE UNIT
|
||||
# HEARS. `last` is the Stop 0 W and the shutdown 0 W (and nothing else).
|
||||
# It first waits, bounded, for a write already on its way to this unit,
|
||||
# so that write cannot land after it. And once shutdown has begun every
|
||||
# other write (cycle, heartbeat, a safety stop) is dropped here: the
|
||||
# check sits right before the write is created with no await between,
|
||||
# so a write that starts after it cannot exist, and one that started
|
||||
# before is in `inflight` for the wait above. The idle gate for a
|
||||
# hand-back unit is higher up and does the same for those.
|
||||
if last:
|
||||
await self._await_inflight(inv, SETTLE_BUDGET_S)
|
||||
elif self.stopping:
|
||||
_LOG.debug("Shutting down, so %+.0f W to %s is not sent.",
|
||||
value, inv.name)
|
||||
return
|
||||
changed = (inv.last_write is None or value != inv.last_write)
|
||||
if not (changed or force):
|
||||
return
|
||||
@@ -4295,11 +4325,11 @@ class Controller:
|
||||
# `changed` is still true.
|
||||
try:
|
||||
_w = self._driver(inv).write(value)
|
||||
if inv.driver.hands_back:
|
||||
# SMA-10: kept, so a release can wait for it (`_release_last`)
|
||||
_w = asyncio.ensure_future(_w)
|
||||
inv.inflight.add(_w)
|
||||
_w.add_done_callback(inv.inflight.discard)
|
||||
# SMA-10 / SAFETY-137: kept for every driver, so a release or a last
|
||||
# 0 W can wait for it (`_await_inflight`).
|
||||
_w = asyncio.ensure_future(_w)
|
||||
inv.inflight.add(_w)
|
||||
_w.add_done_callback(inv.inflight.discard)
|
||||
ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S)
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok = False
|
||||
@@ -7209,17 +7239,17 @@ class Controller:
|
||||
call takes no longer than its slowest bound.
|
||||
"""
|
||||
if not any(inv.driver.hands_back for inv in self.inverters):
|
||||
await self.command_site_zero()
|
||||
await self.command_site_zero(last=True)
|
||||
return
|
||||
self.target = 0.0
|
||||
for inv in self.inverters:
|
||||
inv.target = 0.0
|
||||
await asyncio.gather(*(
|
||||
self._hand_back(inv, force=True) if inv.driver.hands_back
|
||||
else self._write_unit(inv, 0.0, force=True)
|
||||
else self._write_unit(inv, 0.0, force=True, last=True)
|
||||
for inv in self.inverters))
|
||||
|
||||
async def command_site_zero(self) -> None:
|
||||
async def command_site_zero(self, *, last: bool = False) -> 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
|
||||
@@ -7241,7 +7271,7 @@ class Controller:
|
||||
self.target = 0.0
|
||||
for inv in self.inverters:
|
||||
inv.target = 0.0
|
||||
await self.write_setpoint(0.0, force=True)
|
||||
await self.write_setpoint(0.0, force=True, last=last)
|
||||
|
||||
# -- UI ------------------------------------------------------------------
|
||||
def checks(self, when: datetime | None = None) -> list:
|
||||
|
||||
Reference in New Issue
Block a user