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 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-09 00:29:44 +01:00
co-authored by Claude
parent fb0730fe6f
commit 058ecb7caa
2 changed files with 616 additions and 586 deletions
+611
View File
@@ -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)
+5 -586
View File
@@ -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."""