MAINT-21 round 2: keep the move pure - revert the added DECISIONS text, restore cut comments
checks / unit (pull_request) Successful in 26m32s
infra-pin / fresh (pull_request) Successful in 1s

Router T241: the comments commit had put new rationale into
MAIN-orchestration.md and cut comment lines that the note at 56fc41f did not
hold.

- DECISIONS/MAIN-orchestration.md is the 56fc41f text plus the one sentence
  that the Controller is split into app/main.py and app/ctl_*.py.
- Comments whose content is not in a DECISIONS note at 56fc41f are back in the
  code verbatim: MAINT-05 one config, the SAFETY-02, SAFETY-42/34,
  SAFETY-30 site half, SAFETY-31 and SAFETY-18 latch blocks in __init__, the
  _grade_store_migration docstring and comment, the trade_power_w docstring,
  the rows.py docstrings.
- Kept: cuts of text already in the note, the SMA-10 / SAFETY-137 / SMA-2
  pointers, the SAFETY-32 line fix.

No code change: maint21_same_ast OK, maint20_same_code -OO same.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-09 05:48:00 +01:00
co-authored by Claude
parent 902da3f723
commit 58b6fa35f5
5 changed files with 71 additions and 35 deletions
+17 -6
View File
@@ -21,8 +21,13 @@ _LOG = logging.getLogger("goodwe")
class TasksMixin:
def _grade_store_migration(self, store) -> None:
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
Graded on EVERY start: a latch that silently fails to move is a
disarmed safety guard, so a disagreement re-arms it by hand.
Graded, not performed: a latch that silently fails to move is a
DISARMED SAFETY GUARD on an install that believes it is protected. The
pre-DEV-02 flat value is compared with what the roster came up with; a
disagreement re-arms the latch by hand (the fail-safe direction is ON).
It runs on EVERY start, not only the migrating one: gating on `rep.ran`
swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip
in the window, upgrade; performed on this site 2026-09-02).
DECISIONS/MAIN-orchestration.md."""
rep = self.store_migration
if rep.ran and not rep.ok:
@@ -33,10 +38,16 @@ class TasksMixin:
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
return
# Is any namespaced latch set THAT A MACHINE READS (SAFETY-44)? Asked
# of the STORE's keys, not the roster's objects. On the roster: stop;
# off-roster `UNIT_A`: repair onto `inverters[0]`; any other
# off-roster name: stop. DECISIONS/MAIN-orchestration.md.
# The question is "is any namespaced latch set THAT A MACHINE READS"
# (SAFETY-44), asked of the STORE's keys, not the roster's objects:
# re-arming A off a flat key that B's trip mirrored would latch a
# healthy inverter on every restart. Three cases: on the roster, stop;
# off-roster named `UNIT_A` (the broken migration's fingerprint),
# repair onto `inverters[0]`; off-roster, other name: stop unless a
# stranded `UNIT_A` latch is present. This guard is the ONLY repair for
# sites upgraded before the fix. Residual: a first unit really named
# `inverter-a`, latched and removed, is re-armed on `inverters[0]`
# (conservative). DECISIONS/MAIN-orchestration.md.
on_roster = {inv.name for inv in self.inverters}
stranded, on_roster_latched, other_off_roster = [], False, False
for k, v in data.items():
+8 -4
View File
@@ -82,10 +82,14 @@ MAINT_CHARGING_W = 50.0
class TradeMixin:
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
"""OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED,
on arbitrage's own SoC bounds, not the BMS cut-out. A pack whose SoC
did not read counts on neither side; neither side may be zero on a
trading tick (`IDLE_NO_SIZE`). DECISIONS/MAIN-orchestration.md."""
"""OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED:
what the site can push AND STILL HAVE SOMEWHERE TO PUT. The bounds are
arbitrage's own (`arbitrage_soc_floor` / `_ceiling`), NOT the BMS
cut-out that `packshare.envelope()` uses (SAFETY-25): different
questions. It sizes the ask, it does not route it. A pack whose SoC did
not read counts on neither side. Neither side may be zero on a trading
tick (`arbitrage.decide()` refuses `IDLE_NO_SIZE`: a 0 W claim is a
park order). DECISIONS/MAIN-orchestration.md."""
# The reduction lives in `packshare`; `test_packshare.py` part 9 calls
# the SAME function (a transcription in a check is a copy that drifts).
return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct)
+30 -14
View File
@@ -288,8 +288,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self.log_event("maintenance history did not move over cleanly: "
+ self.maint_migration.explain(),
hint=messages.hint("maint_history"))
# MAINT-05: one config, N machines: the options describe the
# PROCEDURE; the SCHEDULE and HISTORY are per pack, in the store.
# MAINT-05: one config, N machines: the `maintenance_*` options
# describe the PROCEDURE (same on every pack); the SCHEDULE and HISTORY
# are per pack, in the store. A per-entry config block would be a
# second shape for CFG-04 and a fifth place for `maintenance_charge_w`
# to disagree with config.yaml; two procedures would be a roster-schema
# card.
_maint_cfg = MaintConfig.from_options(opts)
_one_pack = len(self.inverters) <= 1
self.maints = [
@@ -307,9 +311,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
for i, inv in enumerate(self.inverters)]
# SAFETY-02: one watchdog per actuator, restored from ITS OWN
# namespaced keys and graded on every start.
# DECISIONS/MAIN-orchestration.md.
# SAFETY-02: one watchdog per actuator, each restored from ITS OWN
# namespaced store keys (a trip on B latches B, A keeps commanding).
# Restored, because a latch held only in memory is cleared by the
# restart an operator tries first. Graded on every start: a
# disagreement with the flat value re-arms by hand and shouts (the
# fail-safe direction is ON). DECISIONS/MAIN-orchestration.md.
self._grade_store_migration(store)
for inv in self.inverters:
if inv.sign_wd.tripped:
@@ -329,9 +336,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
hint=messages.hint("stop_restored",
unit=inv.name))
# SAFETY-42 + SAFETY-34: a latch that does NOT act, restored so the
# ALARM survives; `_delivery_announced` is per process, so a restart
# clears the flag and keeps the latch. DECISIONS/MAIN-orchestration.md.
# SAFETY-42 + SAFETY-34: a latch that does NOT act, unlike SAFETY-02's
# and SAFETY-31's (which command 0 W). It is restored so the ALARM
# survives (2026-09-02: an outage unseen for 6 h 25 m with the detector
# correct); `delivery.py` argues reporting rather than acting.
# `_delivery_announced` is per process (as `_circ_announced`): a
# restart clears the flag and keeps the latch.
self._delivery_announced = set()
for inv in self.inverters:
if inv.delivery_wd.tripped:
@@ -348,9 +358,12 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
hint=messages.hint("not_delivering",
unit=inv.name))
# SAFETY-30: the SITE half of the sign check: armed only at N>=2 (at
# one inverter it is constructed and never evaluated), restored with
# FLAT keys. DECISIONS/SAFETY-30-per-unit-sign.md.
# SAFETY-30: the SITE half of the sign check, one per site, armed only
# at N>=2: the SUM of the standing commands against P1. At a roster of
# one it is constructed and never evaluated (arming it would duplicate
# `inverters[0].sign_wd`: two latches, two banners, a false-positive
# record spent twice); `cycle()` enforces it. Restored from the store
# with FLAT keys (store.py). DECISIONS/SAFETY-30-per-unit-sign.md.
self.site_sign_wd = SignWatchdog(
self.site_sign_cfg,
tripped=bool(store.data.get("site_sign_tripped", False)),
@@ -372,12 +385,15 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._arm_sign_watchdogs()
# SAFETY-18: the frozen-but-fresh meter detector (freeze.py). NOT
# restored: it does not act. DECISIONS/MAIN-orchestration.md.
# restored from the store: it does not act, so it does not latch; a
# restart re-derives it within one `meter_freeze_s`.
self.freeze_wd = FreezeWatchdog(
FreezeWatchdogConfig(hold_s=float(opts.get("meter_freeze_s", 300))))
# SAFETY-31: one per site, RESTORED because it acts, with SITE-level
# keys. DECISIONS/MAIN-orchestration.md.
# SAFETY-31: one per site, RESTORED (unlike SAFETY-18): a trip files a
# P_SAFETY SET that stops every inverter, and a cleared latch would
# restart straight into the loop it stopped. SITE-level keys, not
# namespaced: circulating power is defined over the roster (store.py).
self.circ_wd = CirculationWatchdog(
CirculationConfig(
min_w=float(opts.get("circulation_w",
+11 -5
View File
@@ -40,8 +40,11 @@ def why_key(winner: str, claims) -> str:
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
provider: bool, max_age_s: float) -> str:
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan
(TARIFF-11), each with its own clock. "No plan" is said aloud on a site
that configured a provider. DECISIONS/MAIN-orchestration.md."""
(TARIFF-11): two sources, two clocks, and the row says so. The plan is
stamped with its own age from `tariff_at` (not moved on a kept plan,
TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's
measurement. "No plan" is said aloud on a site that configured a provider.
DECISIONS/MAIN-orchestration.md."""
# UX-08: plain words, every figure kept. The tick's sentence is "now"; the
# plan carries its own age.
if plan is None:
@@ -55,9 +58,12 @@ def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
why: str) -> str:
"""The *Arbitrage* status row: off, armed with no decision this cycle
(`why` is the loop's reason), or a decision with its age (TARIFF-11).
`on` is read from the options, never inferred from `decision is None`.
"""The *Arbitrage* status row: three states (TARIFF-11): off (not armed),
armed with no decision this cycle (`why` is the loop's reason), or a
decision with its age.
`on` is read from the options, never inferred from `decision is None`:
`cycle()` returns at the input hold before `self.arb = None`, so the old
row named an option that was ON. The age is always printed, even 0.
DECISIONS/MAIN-orchestration.md."""
# UX-08: plain words; the setting is named by its label.
if not on: