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 setuponly
|
||||||
from . import web
|
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 (
|
from .ctl_inputs import (
|
||||||
InputsMixin, FC_HOLD_KEY, FORECAST_READ_S, METER_QUIET_REPEAT_S,
|
InputsMixin, FC_HOLD_KEY, FORECAST_READ_S, METER_QUIET_REPEAT_S,
|
||||||
TARIFF_MAX_AGE_S, TARIFF_REFRESH_S, _mono,
|
TARIFF_MAX_AGE_S, TARIFF_REFRESH_S, _mono,
|
||||||
@@ -118,29 +122,9 @@ _LOG = logging.getLogger("goodwe")
|
|||||||
# function CFG-04 changes.
|
# function CFG-04 changes.
|
||||||
__all__ = ["UNIT_A", "Controller", "amain", "main"]
|
__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
|
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
|
# SAFETY-34: a Core-only restart wipes persistent_notification without
|
||||||
# restarting this add-on, so a latched alarm is re-asserted this often.
|
# 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)
|
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
|
# 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.
|
# 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
|
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)
|
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,
|
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||||
peak_db_path: str = "/data/tariff.db",
|
peak_db_path: str = "/data/tariff.db",
|
||||||
price_history_path: str = "/data/price_history.json",
|
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.
|
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
|
||||||
self.loop_pass_mono = None
|
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:
|
def log_event(self, msg: str, hint: str | None = None) -> None:
|
||||||
"""`msg` goes on the action log, the add-on's own technical record.
|
"""`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
|
`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_words = text
|
||||||
self._why_waiting_since = None
|
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:
|
def _provider_gate(self, now: datetime) -> None:
|
||||||
"""PRICE-01: `cheap`, the gate and the gap from the standing plan. No
|
"""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
|
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()
|
self.loop_pass_mono = time.monotonic()
|
||||||
await asyncio.sleep(1)
|
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:
|
def publish(self) -> None:
|
||||||
# ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and
|
# ⚠️ 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);
|
# `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards);
|
||||||
@@ -4613,36 +4075,6 @@ class Controller(InputsMixin):
|
|||||||
await self.command_site_release()
|
await self.command_site_release()
|
||||||
self.mqtt.close()
|
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 ------------------------------------------------------------------
|
# -- UI ------------------------------------------------------------------
|
||||||
def checks(self, when: datetime | None = None) -> list:
|
def checks(self, when: datetime | None = None) -> list:
|
||||||
"""The status rows. `when` is the reference time for anything billed by
|
"""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
|
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):
|
class ControlTaskDied(RuntimeError):
|
||||||
"""The control task ended while nothing had asked it to stop."""
|
"""The control task ended while nothing had asked it to stop."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user