MAINT-20 part 2 r2: app/main.py comments, lines 1001-1500 (_refusal_from_stale .. site_circulating_w)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-08 18:06:00 +01:00
co-authored by Claude
parent f114a19dcc
commit 6492d179d6
+30 -59
View File
@@ -1191,22 +1191,13 @@ class Controller:
def _grade_store_migration(self, store) -> None:
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
Graded, not performed: a latch that silently fails to move is a
DISARMED SAFETY GUARD on an install that believes it is protected
(state file says `sign_tripped: true`, banner green, controller resumes
into the fault). The pre-DEV-02 flat value is compared with what the
roster came up with; a disagreement re-arms the latch by hand from the
flat value and writes the store. The fail-safe direction for a latch is
ON.
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 again: the namespaced key says untripped, the
flat key says tripped; performed on this site 2026-09-02). The gate
could go because `handle_action()` now clears the flat keys with the
namespaced ones and the trip mirrors them, so FLAT TRIPPED AND NO UNIT
TRIPPED means a downgrade-window trip or a lost migration.
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:
@@ -1218,24 +1209,15 @@ class Controller:
if not legacy_tripped or not self.inverters:
return
# The question is "is any namespaced latch set THAT A MACHINE READS"
# (SAFETY-44, second half), asked of the STORE's keys, not the roster's
# objects: re-arming A off a flat key that B's own trip mirrored would
# latch a healthy inverter on every restart (DEV-02's defect).
# Three cases: on the roster, a machine reads it, stop. Off-roster and
# named `UNIT_A`, the broken migration's fingerprint (the only name it
# wrote; `roster_names()` never hands out `inverter-a` otherwise): the
# flat latch misdirected, repair onto `inverters[0]`. Off-roster, other
# name: a real unit out of today's config; stop, unless a stranded
# `UNIT_A` latch is also present. Three variables, not one shared flag
# (one key defeated the first fix; MUTATION.md habit 4, instance 17).
# This guard is the ONLY repair for sites upgraded before the fix, as
# `migrate_unit_keys()` returns at the version check.
# Residual: a first unit really NAMED `inverter-a`, latched, then
# removed from the roster is re-armed on `inverters[0]` (a healthy unit
# held at 0 W; the conservative direction). The reason shown is the
# flat mirror's, which carries whatever tripped LAST, so on a
# multi-unit site one inverter's evidence may print under another's
# name. DECISIONS/MAIN-orchestration.md.
# (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():
@@ -1429,20 +1411,15 @@ class Controller:
# SAFETY-30: which comparison each sign detector is making
def _arm_sign_watchdogs(self) -> None:
"""Tell every sign detector which of SAFETY-30's two questions it asks.
A pure function of `len(self.inverters)` and the only place the
roster decides anything about this check (one method, not a
conditional at two call sites).
At ONE inverter (6.2, AC 8) the unit's detector is armed against THE
METER with the shipped pair and the site detector is not armed: the
same comparison, thresholds, reason string, keys and `failsafe` claim
as before. At TWO OR MORE each unit is armed against ITS OWN measured
power (`Inverter.batt`, register 0x0518) under the per-unit pair, and
the site detector against the meter under the site pair.
Called again at the top of EVERY `cycle()`: fixtures append an
`Inverter` after construction, and a detector frozen at construction
would be graded against the meter at a roster of two. `arm()` touches
no timer or latch, so repeating it is safe."""
"""Tell every sign detector which of SAFETY-30's two questions it asks;
a pure function of `len(self.inverters)`. At ONE inverter (6.2, AC 8)
the unit's detector is armed against THE METER with the shipped pair
and the site detector is not armed. At TWO OR MORE each unit is armed
against ITS OWN measured power (`Inverter.batt`) under the per-unit
pair, and the site detector against the meter under the site pair.
Called at the top of EVERY `cycle()` (fixtures append an `Inverter`
after construction); `arm()` touches no timer or latch.
DECISIONS/MAIN-orchestration.md."""
one = len(self.inverters) <= 1
for inv in self.inverters:
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
@@ -1463,19 +1440,13 @@ class Controller:
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
"""OPT-09. `(charge_w, discharge_w)`: how hard a trade may be SIZED:
what the site can push AND STILL HAVE SOMEWHERE TO PUT (a roster of
two used to size at the sum of ratings, and unplaceable watts came
back as `unmet_w` for the integrator to chase).
The bounds are arbitrage's own (`arbitrage_soc_floor` / `_ceiling`),
NOT the BMS cut-out: sizing a trade is the trade's business; the
`packshare.envelope()` pack claim uses the physical cut-out
(SAFETY-25). Different questions, different numbers. It sizes the ask,
it does not route it: weights are a preference, so a pack between its
arbitrage floor and its cut-out can still take part of a sell (a card
of its own). 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."""
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)