"""Controller methods that write to, refuse and hand back the inverters. Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" import asyncio import logging import time from datetime import datetime, timezone from . import control as _ctl from .clamp import clamp_to_rating, is_commandable from . import messages from . import shadow _LOG = logging.getLogger("goodwe") # DEV-02: the per-write budget, DERIVED: `heartbeat_s` max 25 s + 4 < # `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py. # DECISIONS/MAIN-orchestration.md. WRITE_BUDGET_S = 4.0 # SMA-10: the bound for handing a unit back (`driver.release()`); units are # released concurrently and one still running at the bound is abandoned. # DECISIONS/SMA-10-two-idle-states.md. RELEASE_BUDGET_S = WRITE_BUDGET_S # SMA-10: the one fallback write after a failed release (default 0 W). Worst # case RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, inside the Supervisor's 10 s. # DECISIONS/SMA-10-two-idle-states.md. FALLBACK_BUDGET_S = 2.0 # SAFETY-137: how long a LAST write waits for a write already on its way to the # same unit; half the release budget, so a GoodWe unit's worst case is # HAND_BACK_WORST_S. DECISIONS/SAFETY-137-last-write-is-zero.md. SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2 # SAFETY-73: the evidence must also span this long, not only SETTLE_CYCLES # cycles: a 1 s meter makes 12 cycles only 11 s, shorter than a healthy battery # reading's lag, and both inverters were refused after ordinary load steps. REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S # SAFETY-94: evidence restarts when the reading has moved this far toward the # ask. A refusing battery reads flat; live readings wander under 25 W at rest; # a first-order lag gains at most 0.4 tau x 100 W across the band, so the # evidence cannot span REFUSED_SPAN_S unless the lag is over 54 s. REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 async def _bounded(awaitable, budget_s: float): """SMA-10. `await awaitable`, abandoning it after `budget_s` without waiting for it to wind down (`wait_for` would). Raises `asyncio.TimeoutError`.""" task = asyncio.ensure_future(awaitable) done, _ = await asyncio.wait({task}, timeout=budget_s) if not done: task.cancel() # a result or error that arrives later has nobody to read it task.add_done_callback(lambda t: t.cancelled() or t.exception()) raise asyncio.TimeoutError return task.result() class WritesMixin: # helpers; SAFETY-52: a battery that refuses is asked for 0 W def _refusal_probe(self) -> bool: """True while a refused direction may try `PROBE_W` (every 15 min).""" if (not self.batt_refused or self.batt_refused_at is None or self._now_mono is None): return False held = self._now_mono - self.batt_refused_at if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S: self.batt_refused_at = self._now_mono return False return held >= _ctl.PROBE_EVERY_S def _refusal_start(self, sign: int, evidence: str) -> None: self.batt_refused = sign self.batt_refused_at = self._now_mono self.log_event(f"BATTERY REFUSES {evidence}", hint=messages.hint("battery_refuses")) _LOG.info("The battery refuses to give power, so the add-on asks 0 W " "and tries %.0f W every %.0f minutes: BATTERY_REFUSES.", _ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60) _LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence) def _refusal_end(self, why: str) -> None: if not self.batt_refused: return self.batt_refused, self.batt_refused_at = 0, None self.log_event(f"BATTERY REFUSES ENDED: {why}", hint=messages.hint("battery_gives")) _LOG.info("The battery refusal ends, control asks for power again: %s.", why) def _refusal_update(self, decision, asked_w, batt_w, probe, rejudged: int = 0) -> None: """Follow `compute()`'s carried refused direction, and say so. `rejudged`: the refusal held while another writer had the wire, so `compute()` judged it afresh against that writer's ask (SAFETY-107).""" now = decision.refused or 0 if rejudged: if now == rejudged: # Still refused against the ask on the wire: the same episode. # The probe clock restarts: a probe is the loop's ask and is # not on the wire while another writer is. self.batt_refused_at = self._now_mono return gave = (rejudged * (batt_w - self._site_idle_w()) >= _ctl.PROBE_ANSWER_W) self._refusal_end("the battery gives power" if gave else "another writer") if now and not self.batt_refused: self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery " f"gave {batt_w:+.0f} W for " f"{decision.sat_count} cycles") elif self.batt_refused and not now: gave = (self.batt_refused * (batt_w - self._site_idle_w()) >= _ctl.PROBE_ANSWER_W) self._refusal_end("the probe was answered" if gave and probe else "the battery gives power" if gave else "the grid now asks the other direction") def _refusal_from_stale(self, inv, now_mono) -> None: """A stale-reading stop while the loop is frozen on ~0 W is a refusal. Only at one inverter: with several, one empty battery must not stop the site (OPT-09 moves its share).""" t = self.tuning standing = inv.standing_w() if len(self.inverters) > 1: # SAFETY-73: at N>1 the same trip refuses THIS unit only. if ((self.auto or self.shadow) and not inv.refused and inv.ref_count >= t.saturation_cycles and inv.batt_fresh.held is not None and abs(inv.batt_fresh.held - inv.idle_w) < _ctl.REFUSED_BELOW_W and abs(standing) > _ctl.PROBE_W): self._now_mono = now_mono self._unit_refusal_start( inv, 1 if standing > 0 else -1, f"stale reading {inv.batt_fresh.held:+.0f} W while asked " f"{standing:+.0f} W") return if (len(self.inverters) == 1 and (self.auto or self.shadow) and not self.batt_refused and self.sat_count >= t.saturation_cycles and inv.batt_fresh.held is not None and abs(inv.batt_fresh.held - inv.idle_w) < _ctl.REFUSED_BELOW_W and abs(standing) > t.saturation_w): self._now_mono = now_mono self._refusal_start(1 if standing > 0 else -1, f"stale reading {inv.batt_fresh.held:+.0f} W " f"while asked {standing:+.0f} W") # SAFETY-73: SAFETY-52's decisions per inverter at N>1: a unit giving < # REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in that # direction by a P_SAFETY unit limit, so the allocator hands its share on; # probed at PROBE_W every PROBE_EVERY_S, one unit at a time. The ask is # PROBE_W, not `saturation_w`: a unit's share is often small and SAFETY-43 # would name the empty battery as not delivering. # DECISIONS/SAFETY-73-refusal-per-inverter.md. def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None: inv.refused, inv.refused_at = sign, self._now_mono inv.probe_from, inv.ref_count = None, 0 self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}", hint=messages.hint("unit_refuses", unit=inv.name)) _LOG.info("%s: the battery refuses power, so it is asked 0 W and the " "other inverters take over: BATTERY_REFUSES.", inv.name) _LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence) # Only a refusal to GIVE: a full battery that stops taking charge is # above its cut-out level by definition. if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None and float(inv.soc) > float(inv.bms_soc_floor)): # Owner decision 3: a refusal above the configured cut-out level # may mean that level is set wrong. One line, no safety stop. _LOG.warning("%s refuses power above its battery cut-out level, " "check its cut-out level: REFUSES_ABOVE_CUT_OUT.", inv.name) _LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level " "%.0f %%, cut-out level %.0f %%.", inv.name, float(inv.soc), float(inv.bms_soc_floor)) def _unit_refusal_end(self, inv, why: str) -> None: if not inv.refused: return inv.refused, inv.refused_at, inv.probe_from = 0, None, None inv.ref_count = 0 self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}", hint=messages.hint("unit_gives", unit=inv.name)) _LOG.info("%s: the battery refusal ends, it is asked for power again: " "%s.", inv.name, why) # The site target may stand above what was placed (the refused share # was unmet). Restart it from the watts on the wire, or the reopened # unit gets the parked remainder at once (phase A: -1059 W). placed = float(sum(i.standing_w() for i in self.inverters)) self.target = self.i_w = placed self.sat_count = 0 def _unit_probe(self, inv) -> bool: """True while `inv` may try PROBE_W. One unit probes at a time.""" now = self._now_mono if not inv.refused or inv.refused_at is None or now is None: return False if inv.probe_from is not None: if now - inv.probe_from < _ctl.PROBE_S: return True inv.probe_from, inv.refused_at = None, now return False if now - inv.refused_at < _ctl.PROBE_EVERY_S or any( i.probe_from is not None for i in self.inverters): return False inv.probe_from = now return True def _unit_refusals(self) -> None: """Engage and end each unit's refusal from its own reading.""" for inv in self.inverters: b = inv.batt if inv.refused: # SAFETY-97: given and taken are counted from the idle reading. if (b is not None and inv.refused * (b - inv.idle_w) >= _ctl.PROBE_ANSWER_W): self._unit_refusal_end( inv, "the probe was answered" if inv.probe_from is not None else "the battery gives power") continue # The other direction: the battery takes PROBE_ANSWER_W or more # the other way for SETTLE_CYCLES, counted on its own reading # so one overshoot through zero does not hand its share back. inv.ref_count = (inv.ref_count + 1 if b is not None and -inv.refused * (b - inv.idle_w) >= _ctl.PROBE_ANSWER_W else 0) if inv.ref_count >= _ctl.SETTLE_CYCLES: self._unit_refusal_end( inv, "the battery takes power the other way") continue if inv.rejected_since is not None: # SAFETY-96: its writes are refused, so its board may have gone # to 0 W on its own; that is no refusal by the battery. The # count is kept, as across a gap. continue sign = self._gave_nothing(inv) if sign: self._unit_refusal_start( inv, sign, f"asked {inv.standing_w():+.0f} W, the battery gave " f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles") def _gave_nothing(self, inv) -> int: """The refusal evidence, one cycle of it: the sign `inv` has given under REFUSED_BELOW_W while asked over PROBE_W, one way, for SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1; SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the band is centred on the unit's idle reading.""" b = inv.batt if b is None: return 0 # a gap keeps the count: the gate's trip seeds standing = inv.standing_w() sign = 1 if standing > 0 else -1 # One direction throughout: a command that swaps sign every cycle reads # ~0 through the plant's lag, which is a battery following, not # refusing (TEST-19). if (abs(standing) > _ctl.PROBE_W and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W): # SAFETY-94: a reading still moving towards the ask is a battery # catching up through its lag: start again. if (sign != inv.ref_sign or not inv.ref_count or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W): inv.ref_count, inv.ref_since, inv.ref_from = ( 0, self._now_mono, b) inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) else: inv.ref_count = 0 inv.ref_sign = sign if inv.ref_count >= _ctl.SETTLE_CYCLES and ( inv.ref_since is None or self._now_mono is None or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): return sign return 0 def _site_idle_w(self) -> float: """SAFETY-97: the site reading at idle, the sum of the units'.""" return float(sum(inv.idle_w for inv in self.inverters)) def _site_refused(self): """Owner decision 4: every unit refuses the same way, so the site law holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law.""" signs = {inv.refused for inv in self.inverters} return signs.pop() if len(signs) == 1 and 0 not in signs else None @property def shadow(self) -> bool: """SAFETY-10. Is the battery being left alone right now? A property, not a field (as `self.units`): the cycle, the write and the status page ask at three moments, and the write gate asks THIS, not a parameter passed down.""" return shadow.engaged(self.o, control_started=self.auto) def shadow_latches(self) -> tuple: """The failsafe latches that are UP, named, for the shadow banner: a standing latch is standing when the controller stops being inert, so an operator who turns shadow off is not handed a battery that will not move with no reason shown.""" out = [] if self.circ_wd.tripped: out.append("circulating power") if self.site_sign_wd.tripped and len(self.inverters) > 1: out.append("site sign watchdog") for inv in self.inverters: if inv.sign_wd.tripped: out.append(f"{inv.name} sign watchdog") return tuple(out) def write_refusal(self, value: float, inv=None) -> str | None: """Why `value` may NOT be written to `inv` right now, or None if it may. SAFETY-28: a number reaches the wire only if THE SINGLE ALLOCATOR placed it, on this tick, for THIS unit (`Allocation.authorises()`); a second writer must file a Claim. 0 W is ALWAYS authorised and tested first, reading nothing: the failsafe writes run while the allocator has REFUSED, so a gate asking permission to command zero would deadlock the failsafe. It tests `value == 0.0`, not a flag the caller sets. DEV-02: the unit is an ARGUMENT (the record holds one share per unit); the default is for N=1 callers and REFUSES at N>=2. DECISIONS/MAIN-orchestration.md.""" if value == 0.0: return None inv = self._only() if inv is None else inv rec = self.allocation if rec is None: return ("no allocation record exists yet - nothing has authorised " f"{value:+.0f} W on {inv.name}") return rec.authorises(inv.name, value, self.alloc_tick) async def write_setpoint(self, value: float, *, force: bool = False, unit=None, last: bool = False) -> None: """Put `value` on ONE inverter, or with `unit=None` on all. `unit=None` is for a number that is not a share (stop button, `shutdown()`, `maint_abort()`: 0 W means the same to every inverter). Writes are concurrent, each under its own budget (DEV-02). `last`: DECISIONS/SAFETY-137-last-write-is-zero.md.""" if unit is None: targets = [i for i in self.inverters] if len(targets) > 1 and is_commandable(value) and float(value) != 0.0: # Loud, and it commands zero rather than the value asked for: # fail toward inaction, as every other refusal here. self.log_event( f"refusing to broadcast {float(value):+.0f} W to " f"{len(targets)} inverters - that is the ARCH-01 fault; " f"commanding 0 W", hint=messages.hint("all_blocked")) _LOG.error("Refused to send %+.0f W to all %s at once, so " "every inverter is set to 0 W. This is a software " "fault.", float(value), messages.count(len(targets), "inverter")) _LOG.debug("ARCH-01: a site target handed to each unit is " "supplied by each of them, so the gain at the meter " "becomes N times the per-unit gain. Only 0 W may be " "written site-wide, and shares come from the " "allocator.") value = 0.0 else: targets = [unit] if not targets: return 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 writes already on their way to `inv`; one still running is left to its own WRITE_BUDGET_S. DECISIONS/SAFETY-137-last-write-is-zero.md.""" 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()` once any write in flight to `inv` has finished (at most SETTLE_BUDGET_S): the release is the LAST thing the unit hears. DECISIONS/SMA-10-two-idle-states.md.""" await self._await_inflight(inv, SETTLE_BUDGET_S) return await self._driver(inv).release() async def _hand_back(self, inv, *, force: bool = False) -> bool: """SMA-10. Give ONE unit back: exactly one `release()`, then nothing. True when handed back. Never raises; a failure is retried on the next idle write. ⚠️ `released` is set before the first await (one release per unit, not two). ⚠️ It is not a write: no `note_write`, no `last_write`. DECISIONS/SMA-10-two-idle-states.md.""" if inv.released: return True if not inv.driver.configured(): return False # After a failure the next try waits one heartbeat; Stop and shutdown # (`force`) always try. DECISIONS/SMA-10-two-idle-states.md. now_m = time.monotonic() if not force and now_m < inv.release_retry_at: return False inv.released = True why = "" try: ok = bool(await _bounded(self._release_last(inv), RELEASE_BUDGET_S)) except (asyncio.TimeoutError, TimeoutError): ok, why = False, f"took over {RELEASE_BUDGET_S:.0f} s" except Exception as err: # noqa: BLE001 - one unit never stops the rest ok, why = False, f"{type(err).__name__}: {err}" if ok: inv.last_write = None if inv.release_fails: _LOG.info("Handing back %s worked again after %d failed " "tries.", inv.name, inv.release_fails) inv.release_fails = 0 inv.release_retry_at = 0.0 inv.release_zero_sent = False self.log_event(f"{inv.name}: handed back", hint=messages.hint("handed_back", unit=inv.name)) return True # SMA-10: a failed hand-back is tried again, and sent 0 W once below. inv.released = False inv.release_fails += 1 inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10)) _LOG.debug("Handing back %s failed (try %d): %s", inv.name, inv.release_fails, why) if inv.release_fails == 1: # one line per failure streak _LOG.error("Handing back %s failed, so it is sent 0 W once and " "handing back is tried again.", inv.name) self.log_event(f"{inv.name}: hand-back failed", hint=messages.hint("hand_back_failed", unit=inv.name)) if not inv.release_zero_sent: # ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own # fallback timer for ever. DECISIONS/SMA-10-two-idle-states.md. inv.release_zero_sent = True try: if await _bounded(self._driver(inv).release_fallback(), FALLBACK_BUDGET_S): inv.last_write = 0.0 except Exception as err: # noqa: BLE001 _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, last: bool = False) -> None: if not inv.driver.configured(): return # SMA-10: a unit that hands back is released once and not written while # the add-on is idle (shutdown, stopped, shadow). ⚠️ A safety stop is # NOT idle: it falls through to the 0 W write. Control starting again # re-arms the unit; `stopping` never does. # DECISIONS/SMA-10-two-idle-states.md. if inv.driver.hands_back: if self.stopping or self.shadow or not self.auto: await self._hand_back(inv) return if inv.released or inv.release_fails or inv.release_zero_sent: # Control has the unit again: forget the hand-back and any # failed streak. inv.released = False inv.last_write = None inv.release_fails = 0 inv.release_retry_at = 0.0 inv.release_zero_sent = False entity = inv.driver.actuator_id() # for the log line below # SAFETY-03: the clamp is on the ONLY path to the wire (cycle, # heartbeat, stop button, shutdown(); no optimizer, maintenance or # manual path routes around it). A non-finite value is loss of input, # not an out-of-range one (11.1): coerced to 0 W. if not is_commandable(value): self.log_event(f"non-finite setpoint {value!r} - commanding 0 W", hint=messages.hint("bad_value")) _LOG.error("refusing to write a non-finite setpoint %r - commanding " "0 W and continuing to command. This is a bug upstream, " "not a limit.", value) value = 0.0 # SAFETY-28: the refusal is HERE, above the clamp and below the # non-finite coercion (a nan is loss of input, not an unauthorised # number), and asked about `inv`, not the site. # DECISIONS/MAIN-orchestration.md. refusal = self.write_refusal(value, inv) if refusal is not None: self.log_event( f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W", hint=messages.hint("write_blocked", unit=inv.name)) _LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W " "instead, which is a software fault. The reason: %s.", value, inv.name, refusal) value = 0.0 # This unit's own three numbers: `inv.max_w` (supervised cap on THIS # setpoint entity) and `inv.dev_min/dev_max` (what THIS device # reported). Read off `self.o` they were one inverter's rating applied # to every inverter. result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max) inv.clamp_last = result if result.engaged: # EVERY engagement is logged, never deduplicated: the action log # (15) is a different surface from the deduplicated notification. # The count says how hard the loop pushed. self.log_event(f"{inv.name}: {result.describe()}", hint=messages.hint("write_cut", unit=inv.name)) value = result.applied_w # SAFETY-10: shadow mode runs the whole decision path and commands # nothing, and the suppression is HERE, the last thing before the wire, # in the one method every write reaches (so no call site routes round # it). A substitution, not a return, like the two refusals above; below # SAFETY-28's gate and SAFETY-03's clamp; not a second writer (no # claim, no transport call). if self.shadow and value != shadow.WIRE_W: # UX-10: DEBUG, because it repeats every cycle in shadow mode; the # INFO line when shadow comes on says it once. _LOG.debug("Shadow mode: would send %+.0f W to %s, and holds " "%+.0f W. Nothing was sent to the inverter.", value, inv.name, shadow.WIRE_W) value = shadow.WIRE_W # SAFETY-137: `last` (the Stop and shutdown 0 W) waits, bounded, for a # write already on its way; once `stopping`, every other write is dropped # here with no await before the write is made. # DECISIONS/SAFETY-137-last-write-is-zero.md. 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 # BOUNDED, and the budget is AC 6: one hung transport would hold the # coroutine, the whole loop and every OTHER inverter's heartbeat. An # abandoned write is re-issued next tick (`last_write` moves only on an # ACCEPTED write). try: _w = self._driver(inv).write(value) # SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for # it. DECISIONS/SAFETY-137-last-write-is-zero.md. _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 _LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was " "dropped, so it is sent again next cycle.", value, inv.name, entity, WRITE_BUDGET_S) self.log_event(f"{inv.name}: write timed out after " f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick", hint=messages.hint("write_slow", unit=inv.name)) now = datetime.now(timezone.utc) inv.note_write(bool(ok), now) if ok: inv.last_write = value inv.last_write_at = now async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None: """Refresh every inverter whose own deadline has come. AC 6 lives here. ⚠️ A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` so a check can inject a hang: "a hung write to A cannot consume B's layer-1 budget"; inline, the mutation to a serial `for` loop passed the whole suite. (DECISIONS/MAIN-orchestration-archive.md)""" due = [inv for inv in self.inverters if loop_now - inv.last_beat >= heartbeat_s] if not due: return # ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT: after, a # hanging write drifts into being re-sent every tick and a raising one # never advances. (DECISIONS/MAIN-orchestration-archive.md) for inv in due: inv.last_beat = loop_now # ⚠️ CONCURRENTLY: in series, every inverter behind a hung one waits # out its whole budget, pushing a healthy sibling towards layer 1's ~30 # s veto. (DECISIONS/MAIN-orchestration-archive.md) await asyncio.gather(*( self.write_setpoint(inv.target, force=True, unit=inv) for inv in due)) async def command_site_release(self) -> None: """SMA-10. The add-on is going away (Stop, shutdown, restart, task death, config refusal): units that hand back are given back, the rest held at 0 W as `command_site_zero()` does. ⚠️ Safety stops and refusals do NOT come here: they hold 0 W for EVERY unit. A roster with no hand-back unit is `command_site_zero()` line for line. DECISIONS/SMA-10-two-idle-states.md.""" if not any(inv.driver.hands_back for inv in self.inverters): 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, last=True) for inv in self.inverters)) async def command_site_zero(self, *, last: bool = False) -> None: """Every inverter to 0 W, and every inverter's TARGET with it. ⚠️ NOT BOOKKEEPING (DEV-02): the heartbeat re-emits `inv.target`, so zeroing only `self.target` lets the next heartbeat put each unit's last share back on the wire. ⚠️ 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE (`write_setpoint()` refuses others: ARCH-01). (DECISIONS/MAIN-orchestration-archive.md)""" self.target = 0.0 for inv in self.inverters: inv.target = 0.0 await self.write_setpoint(0.0, force=True, last=last)