From 058ecb7caa0aa3ff7b2fb6fb94a2f349e8868f50 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Mon, 9 Nov 2026 00:29:44 +0100 Subject: [PATCH] MAINT-21: app/ctl_writes.py - Controller methods that write, refuse and hand back Moves the methods named in the card and the constants only they read out of app/main.py unchanged. WritesMixin: write_setpoint, write_refusal, _write_unit, _hand_back, the refusal evidence, heartbeats and the site release/zero commands. tools/maint21_same_ast.py: OK. Co-Authored-By: Claude --- goodwe_controller/app/ctl_writes.py | 611 ++++++++++++++++++++++++++++ goodwe_controller/app/main.py | 591 +-------------------------- 2 files changed, 616 insertions(+), 586 deletions(-) create mode 100644 goodwe_controller/app/ctl_writes.py diff --git a/goodwe_controller/app/ctl_writes.py b/goodwe_controller/app/ctl_writes.py new file mode 100644 index 0000000..d307ab6 --- /dev/null +++ b/goodwe_controller/app/ctl_writes.py @@ -0,0 +1,611 @@ +"""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: every write is dispatched concurrently, each +# bounded by this, so one hung `hass.call()` cannot eat a sibling's layer-1 +# budget. DERIVED: `heartbeat_s` max 25 s + 4 < +# `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py; below +# hass.py's 10 s HTTP timeout on purpose (abandon, re-issue next tick; +# `last_write` moves only on an ACCEPTED write). +# 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. +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`: SAFETY-137.""" + 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. SAFETY-137.""" + 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.""" + 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. + 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 + 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. + 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. + _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) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 99feba6..f7ceb7a 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -103,6 +103,10 @@ from .watchdog import ( ) from . import setuponly from . import web +from .ctl_writes import ( + WritesMixin, FALLBACK_BUDGET_S, REFUSED_PROGRESS_W, REFUSED_SPAN_S, + RELEASE_BUDGET_S, SETTLE_BUDGET_S, WRITE_BUDGET_S, _bounded, +) from .ctl_inputs import ( InputsMixin, FC_HOLD_KEY, FORECAST_READ_S, METER_QUIET_REPEAT_S, TARIFF_MAX_AGE_S, TARIFF_REFRESH_S, _mono, @@ -118,29 +122,9 @@ _LOG = logging.getLogger("goodwe") # function CFG-04 changes. __all__ = ["UNIT_A", "Controller", "amain", "main"] -# DEV-02: the per-write budget: every write is dispatched concurrently, each -# bounded by this, so one hung `hass.call()` cannot eat a sibling's layer-1 -# budget. DERIVED: `heartbeat_s` max 25 s + 4 < -# `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py; below -# hass.py's 10 s HTTP timeout on purpose (abandon, re-issue next tick; -# `last_write` moves only on an ACCEPTED write). -# 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. -FALLBACK_BUDGET_S = 2.0 HAND_BACK_WORST_S = RELEASE_BUDGET_S + FALLBACK_BUDGET_S -# 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-34: a Core-only restart wipes persistent_notification without # restarting this add-on, so a latched alarm is re-asserted this often. @@ -221,17 +205,6 @@ def pack_energy_of(inv): inv.capacity_kwh, inv.max_w) -# 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 - # SAFETY-85: a battery another owner set is graded against the least it was # told over the plant's settle time, so a battery still settling is not short. SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S @@ -250,7 +223,7 @@ def _refused_interval(sign: int) -> tuple: return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf) -class Controller(InputsMixin): +class Controller(InputsMixin, WritesMixin): def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop, peak_db_path: str = "/data/tariff.db", price_history_path: str = "/data/price_history.json", @@ -859,234 +832,6 @@ class Controller(InputsMixin): # SAFETY-61. time.monotonic() at the end of the last run_control() pass. self.loop_pass_mono = None - # 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 - def log_event(self, msg: str, hint: str | None = None) -> None: """`msg` goes on the action log, the add-on's own technical record. `hint` is `messages.hint()`'s plain words for the same event, and is @@ -2574,267 +2319,6 @@ class Controller(InputsMixin): self._why_words = text self._why_waiting_since = 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`: SAFETY-137.""" - 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. SAFETY-137.""" - 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.""" - 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. - 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 - 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. - 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. - _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 - def _provider_gate(self, now: datetime) -> None: """PRICE-01: `cheap`, the gate and the gap from the standing plan. No price for now is a closed gate, so the charge waits rather than run @@ -4505,28 +3989,6 @@ class Controller(InputsMixin): self.loop_pass_mono = time.monotonic() await asyncio.sleep(1) - 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)) - def publish(self) -> None: # ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and # `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards); @@ -4613,36 +4075,6 @@ class Controller(InputsMixin): await self.command_site_release() self.mqtt.close() - 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) - # -- UI ------------------------------------------------------------------ def checks(self, when: datetime | None = None) -> list: """The status rows. `when` is the reference time for anything billed by @@ -5418,19 +4850,6 @@ MAINT_CHARGING_W = 50.0 EXIT_CONTROL_DIED = 70 -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 ControlTaskDied(RuntimeError): """The control task ended while nothing had asked it to stop."""