From 6492d179d62fddb44e59ef84dd9add5f7a715224 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 8 Nov 2026 18:06:00 +0100 Subject: [PATCH] MAINT-20 part 2 r2: app/main.py comments, lines 1001-1500 (_refusal_from_stale .. site_circulating_w) Co-Authored-By: Claude --- goodwe_controller/app/main.py | 89 ++++++++++++----------------------- 1 file changed, 30 insertions(+), 59 deletions(-) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 4bd7d05..8434f1e 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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)