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