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:
co-authored by
Claude
parent
fb0730fe6f
commit
058ecb7caa
@@ -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)
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user