diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 8af1c83..43b2219 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -179,6 +179,284 @@ two power-moving phases on one grid connection pulling against each other. is loop and allocator territory. Pinned as a running check (`test_maintenance.py` part 12) so it cannot be forgotten. +**`bms_soc_floor` describes a BATTERY, so at two packs it stops being one number +(SAFETY-32).** The peak guard's energy backstop rests on two figures: the EMS +floor `peak_soc_floor` (the depth a money-saving discharge may never reach) and +`bms_soc_floor`, the percent **that pack's own BMS** cuts out at — register +`0x0560`, read as 10 on the reference GW5000S-BP. Rule 8 refuses an EMS floor at +or below the cut-out, because such a floor can never bind: the BMS wins and the +status page reports a backstop that does not exist. With ONE cut-out for two +packs of different models that rule graded the EMS floor against whichever +battery happened to be written into the flat key, and a site with cut-outs of +10 % and 30 % under `peak_soc_floor: 20` **started** — the guard would then +command a forced discharge that pack B's BMS, not the operator's floor, is the +thing that stops. Rule 8's own failure mode, reached through rule 8. + +* **`bms_soc_floor` becomes a per-entry roster key, and it is REQUIRED there.** + Nothing is inherited: an entry that omits it has no cut-out, does not borrow + the flat one, and is refused by name at startup. A per-entry *default* was + considered and declined — a default is one number for every pack, which is the + defect this card removes; `DECISIONS/SAFETY-32-per-pack-bms-floor.md` records + the decision and its migration cost. The flat key is unchanged and is now + documented as the single-inverter shape's own cut-out. +* **Rule 8 grades EVERY unit and NAMES the one it refuses**, through a new + `unit_bms_floors_indexed()` that carries the **roster** index the value came + off. CFG-04 had just fixed a rule 5/6 bug of exactly this shape — a caps-list + index fed to a function that indexes the roster, so the message named the wrong + inverter — and this reader skips nothing, so there is no second list for an + index to desynchronise from. Graded on a roster whose offender is the SECOND + entry *and* on the same two numbers in the other order. +* **The permission is answered PER PACK.** `Controller.pack_floor_soc` — the + emptiest measured pack — is untouched and is still what `decide()` grades; + what changed is the floor. `peakguard.pack_permissions()` answers one + `(name, reason)` per pack against that pack's own floor, and the floor-limited + notification now names the pack that stopped the site rather than "the + battery". At a roster of one the alarm is byte-for-byte what SAFETY-25 shipped. +* **An unstated cut-out withholds rather than defaults.** `None`, not `0.0` — + `discharge_permitted()` already treats an unknown floor as no permission, so + the absence costs a shave and never a pack, including on `sim/inject.py`'s path + which does not run the startup gate. +* ⚠️ **Stated rather than hidden:** under any config rule 8 accepts, the per-pack + *floor* reduces to `peak_soc_floor` for every pack — rule 8 makes it so. The + reduction is written for `unit_caps_indexed()`'s reason ("unreachable" is a + claim about rule ordering, not a property of a function) and is graded on both + sides: 1775 rule-8-valid `(peak_soc_floor, bms_soc_floor)` pairs answer the + option, and the pair rule 8 refuses answers the cut-out. `peak_soc_floor` + itself is still one number for the site, so the "deeper pack needlessly + withheld" half is filed as an open question, not closed. +* **`tools/supervisor_shape_probe.py` re-run and repaired.** MUTATION.md habit 8: + a committed transcript is a check and it decays. The required key broke §C and + left §D printing PASS on a refusal about a *different* key. §D is now an + isolated counterfactual over an accepted, merged baseline, one key varied per + case, asserting the key **name** in the refusal — `all cases passed` on + `amd64-hassio-supervisor:2026.08.0`. +* ⚠️ **SECOND FIX ROUND — the round-2 fix carried the defect it was written to + close, in its prose.** Two more rewrites survived a green 19-file suite on + `8501e05`: `binding_soc_floor()`'s `max(floors)` → `max(floors[-2:])` (+5 + bytes) and the call site's `self.packs` → `self.packs[:2]` (+4 bytes). Both are + checks whose TEXT claimed more than their FIXTURE could reach — 12c2's only + counterfactual varied the **last** pack, which every window ending at the + roster's end follows, and all three 12g runs drove a **two**-pack roster, where + `[:2]` *is* the roster. **The general law, now stated in the file: a fixture of + N packs cannot separate two reductions that agree on N packs**, and the regress + does not terminate in a fixture — `[:1]` needed two packs, `[:2]` three, `[:3]` + four. So the reduction is now graded by a **length sweep** (78 simulated + rosters, lengths 1–12, deepest cut-out at every position, closing head and tail + windows of width 1–11 at once) plus a **computed family table** of fourteen + rewrites that goes red naming any member no roster kills; the call site is + graded by **three-pack runs** with the deep cut-out at index 0, 1 and 2, plus + an **exact structural pin** (the roster argument must unparse to `self.packs`) + for the slice widths no bounded roster can reach. Round 3's own battery found + `self.packs[:3]` / `[-3:]` surviving all six behavioural runs — the structural + pin is what closes them — and one row is recorded **NOT CAUGHT**: a reduction + over a window twelve packs wide, which needs a 13-pack roster to observe. + No shipped behaviour changed in this round; only what observes it. +* **Twelve source mutations, eleven red, one argued survivor**, through + `tools/mutation_run.sh` (bytecode purged). The battery also found a real + weakness of its own: `_flat_bms_floor()` had the default in two places and each + covered for the other, so deleting either survived — masking, in three lines of + option reading. One defaulting line now. +* ⚠️ **FIX ROUND — that battery was INCOMPLETE, and the review found what it + missed.** Neither of the two mutations below appears in the twelve rows above: + the ledger graded "the reduction dropped" and "the bare option handed over" + and never asked what happens when the reduction is simply the *wrong* one, or + when the roster handed to it is *sliced*. No shipped behaviour changed here; + two *checks* whose data could not reach the failure they named did. `max(floors)` → `min(floors)` in + `binding_soc_floor()` (zero byte delta) and `self.packs` → `self.packs[:1]` at + the `decide()` call site (+4 bytes) were each green across all 19 files. Both + survived for the same reason: **under any rule-8-valid config every pack's + `pack_floor_pct()` equals `peak_soc_floor`**, so `floors` is a list of + identical numbers and `max`, `min`, first and last are four spellings of one + answer — and the one check using a cut-out above the EMS floor ran on a roster + of ONE, where every reduction agrees. The second also walked through part 12g, + which asserted `"self.packs" in `: a slice still + contains that substring, so the pin passed on the mutation that grades the + whole site against pack A. MUTATION.md habit 4 — a source-text test is a check + on how code is spelled, not on what it does. + * **New part 12c2** evaluates `binding_soc_floor()` on a roster whose per-pack + floors **all differ** — four simulated cut-outs of 30/25/40/35 %, every one + above `peak_soc_floor`, chosen so that `max` (40), `min` (25), first (30), + last (35) and the mean (32.5) are five different numbers and the two extremes + sit at *interior* roster positions. Two packs cannot separate those four; + four is the smallest roster that can. The precondition is its own check + (habit 1) and one pack — the last, neither deepest nor shallowest — is then + raised past the old maximum and the answer follows it (habit 7). + * **Part 12g is now behaviour, not source text.** Real `Controller.cycle()`s on + a two-pack roster, both packs at the same SoC, with the cut-outs as the only + thing varied: move the **non-first** pack's cut-out and the floor reaching + `decide()` moves 20 % → 35 %, with the site's verdict flipping from a sized + discharge to FLOOR-LIMITED. `self.packs[:1]` answers 20 % both times. Which + option key the floor comes from stays pinned on the source where it already + was, because that claim genuinely is about the code's shape. + * ⚠️ **Two MORE survivors, found by this round's own battery and recorded + rather than quietly repaired.** (a) The first cut of part 12g varied only + pack B's cut-out, so `self.packs[-1:]` — grade the site against the **last** + pack — agreed with the full roster on both runs and stayed green across all + 19 files (+5 bytes). A two-run fixture pins one slice and not the other. + Part 12g now runs the **same pair of cut-outs in both orders**, so the + deeper one is on the second pack in one run and on the first in another and + the site floor is 35 % in both; no single-position slice passes all three + runs. (b) `binding_soc_floor()`'s empty-roster branch rewritten to + `return None` (−43 bytes) was also green — a defensive branch nothing in the + suite reached. Both directions of it are pinned now, with the note that it + cannot permit anything either way because `pack_floor_soc` is `None` at an + empty roster. + +* ⚠️ **THIRD FIX ROUND — the same family was alive at the SIBLING ARGUMENT of + the same call.** Rounds 2 and 3 hardened the FLOOR axis; `decide()` is handed + a second reduction over the same roster beside it, `soc=self.pack_floor_soc`, + and `peakguard.py`'s own docstring names the two as a pair. Three rewrites of + `Controller.pack_floor_soc` were green across all 19 files on `52380c0` and + again after the rebase onto `3934ad7`: `min(float(v) for v in vals)` → + `vals[:2]` (+4), `vals[-2:]` (+5) and `vals[:3]` (+4), while `[:1]`, `[-1:]`, + `[1:]`, `[:-1]`, `max()` and the mean were all red. **The suite's separating + power on the SoC axis stopped at exactly N=2** — the state the floor axis was + in before round 3. It changes a real answer: a three-pack roster at + 50 / 60 / 11 % with the emptiest pack THIRD answers 11 % shipped and 50 % + mutated, so the site is graded for discharge PERMISSION against a pack 39 + points fuller than the emptiest and spends a pack its own BMS stops. Round 3's + machinery transfers directly — new **part 12i**: six real `cycle()`s with the + emptiest pack at every position of a three-pack roster and both positions of a + two-pack one (the observable read off `PeakDecision.soc`, cut-outs held flat + so the floor axis cannot be what moved the answers); computed slice and + non-slice tables that go red naming any member nothing kills; a **length + sweep** of 78 real rosters, lengths 1–12, closing head and tail windows of + width 1–11 and recording width 12 as honestly unreached; a **none-guard + sweep** over the same 78; and **three exact structural pins** — the `vals` + comprehension must iterate the bare `self.inverters`, and both `any()` and + `min()` must reduce the whole `vals` — which close every slice width at every + roster length. No shipped behaviour changed; only what observes it. +* ⚠️ **SPIKE-04: production is DEGENERATE on the floor axis, so the + differing-floor fixtures are the entire exposure.** Pack B is 4 × Dyness + DL5.0C, DoD 90 → floor 10 % — **FROM THE DATASHEET AND THE SERIAL; inverter B + does not exist and nothing about it is measured.** Pack A is DoD 90 → 10 % as + well, and that one *is* measured. Both packs therefore floor at the same value + in the field, `max(10, 10)` is 10 whatever it reduces, and a differing floor + exists **only in the fixtures**. That is the argument for keeping them, not for + simplifying them: anyone who collapses `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to + one number deletes the only data this repository has that can see the + reduction at all, and part 12c2's precondition check goes red if they do. The + **SoC** axis has no such degeneracy — two batteries at the same state of + charge is a coincidence, not a configuration — which is what makes + `pack_floor_soc` the half of the pair that will be live from the day inverter + B is wired up. +* ⚠️ **The FAMILY enumerated rather than the member closed, and it found four + more survivors in code this card never touched.** Every reduction over + `self.inverters` / `self.packs` in `Controller` was found from the AST and + mutated to a slice against the full suite. Eighteen were already graded + (`site_batt_w`, `site_rating_w`, `site_max_w`, `site_dev_bound`, + `site_circulating_w`, `inputs_ok`, `units`, `Controller.packs`, `maint_unit`, + the maintenance-due minimum, the store-migration roster, `validate_unit_ranges` + and the round-2/3 sites). **Four were not, and three of them are SAFETY-30's, + all `any(i.sign_wd.tripped for i in self.inverters)`** — `cycle()`'s + stale-input hold, `publish()`'s status vocabulary and `status_payload()`'s + banner — each +4 bytes and green across all 19 files, for the reason every + blocker on this card was invisible: **the only fixture that reached them was a + roster of ONE, where `self.inverters[:1]` IS `self.inverters`.** The first has + teeth: a latched unit must take the roster past that hold or its `failsafe` + SET stops being re-filed the moment a poll is missed, which is main.py's own + sentence for it. New **test_watchdog.py part 8** grades all three with the + latch at EACH position plus a no-latch counterfactual, and new **part 12j** + pins all 26 roster comprehensions in `Controller` to iterating the roster + whole — so a reduction added over a slice anywhere in the class is red on the + line it is written, with no fixture required. Two rows are reported open + rather than closed: `_grade_store_migration()`'s second disjunct (now red on + 12j, and argued to be an equivalent mutant because a unit tripped at startup + implies its namespaced key is set) and `cycle()`'s undeliverable-unit **log + line**, where `sorted(...)[:1]` (+4 bytes) is still green — it truncates names + in one event message; the refusal itself is a different expression and is + graded, so the site still stops. +* ⚠️ **FOURTH FIX ROUND — the same family one MODULE over, and the pin widened + until it discovers its own scope.** Round 4 enumerated the family and closed + it inside the `Controller` class; the review found four more survivors + immediately, in **validate.py**, on a 19/19 green suite: `min(caps)` → + `min(caps[:1])` (+4) and `→ min(caps[-1:])` (+5) at `:779`, the caps + comprehension over `caps_ix[:1]` (+4) at `:778`, and + `min(caps_ix[-1:], key=…)` (+5) at `:893`. **None is an equivalent mutant.** + With `max_w: 5000` on inverter-a beside `2000` on inverter-b and + `sign_disagree_w: 3000`, the shipped validator refuses — *"the SAFETY-02 + sign-disagreement watchdog can never fire"* — and all four **accept**: the + site starts with a watchdog reporting `armed` that cannot trip on pack B. + Rule 5's own failure mode reached through rule 5; rule 6 loses its refusal the + same way. **No shipped behaviour changed by the pin; what changed is that a + hand-written scope stopped being trusted.** + * **`test_peakguard.py` part 12j now parses every `.py` under `app/`** — no + module is named in the check — and finds the roster by transitive closure + from **one seed**, the `inverters` option key: through returns, through + lists **accumulated in a loop over a roster** (which is what `caps`, + `roster` and `problems` are, and what round 4's rule could not express), + through attributes and properties, and **through call arguments**, which is + how it walks into `peakguard`, `arbiter`, `allocator`, `circulation`, + `maintenance` and `store` without being told they exist. A slice of a roster + stays a roster so a mutation cannot cut the taint; an index is one unit and + does not. **183 reductions across 9 modules** out of 23 files parsed — `main.py` 96, + `allocator.py` 24, `validate.py` 19, `arbiter.py` 14, `circulation.py` 10, + `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, `store.py` 3. + * **The hand-written module list would have been wrong in BOTH directions.** + It named `watchdog.py`, which reduces over no roster at all — SAFETY-30's + sign watchdogs are strictly per-unit — and omitted `maintenance.py` and + `store.py`, which both do. A vacuous claim about one module and no claim + about two others. + * **The pin rediscovers all four blockers without being told where to look,** + and finds a fifth beside them (`validate.py:783`, rule 5's message) that no + review round named. Its counterfactual runs at two depths: every enumerated + site's own expression mutated with each of the four cuts this card has been + defeated by (732 spellings, all reported), and `[:1]` written into + `app/validate.py` at each blocker position with the **whole** analysis + re-run over the mutated source — a sliced site required at that exact line, + where the clean package reports none. + * **And the behavioural half, because a shape pin grades an identity and never + a meaning.** `test_validate.py` grows a **roster SET** for the three rules + that reduce over `unit_caps_indexed()` — widths 2, 3, 4, 5 with the one + small cap at every position, 42 runs of the real validator — plus a + **computed family table** of twelve candidate reductions each compared with + what the shipped validator actually said on the same roster. Only `min` + agrees everywhere; a member no roster kills is printed as a named survivor. + * ⚠️ **A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's** — + found by reading, **not** by the pin, which is the shape of what the pin + cannot see. `unit_sign_disagree_w` was graded against + `_number(opts, "max_w", 2000.0)` three lines under a branch that had already + stopped doing that. **Both directions were measured:** two 2000 W units + beside a leftover `max_w: 5000` and `unit_sign_disagree_w: 3000` produced + **zero problems** — the site started with a per-unit watchdog that could not + trip on either unit — and two 5000 W units with no flat key were **refused** + against a 2000 W default the operator never wrote. It takes the weakest + unit's own cap now and names that unit; at a roster of one it is the same + comparison, boundary and sentence it always was. + * ⚠️ **What the pin cannot see, with sizes**, in + `DECISIONS/SAFETY-32-per-pack-bms-floor.md` §4d: semantics (`min` vs `max` + is a zero-byte edit); direct index sites that are not reductions; a + subscript by a *variable* (`weights[u]` is why); a slice through an + intermediate call; anything outside `app/`, including `sim/inject.py`; and + the taint's own seam at `*args` and unresolvable receivers, where everything + downstream goes **unenumerated rather than reported unreachable**. + `cycle()`'s undeliverable-unit log line is still green at +4 bytes and still + filed open, unchanged from round 4. + * **`tools/supervisor_shape_probe.py` re-run again (2026-09-05, 2026.08.0).** + The rebase onto `67e649a` merged SPIKE-04a's `max_w: 4600` into the same + §C/§D blocks this card had rewritten, and a transcript stitched by hand out + of two runs is not a measurement. `all cases passed`, exit 0, with B's + rating reading 4600 — its **datasheet** figure, still not a measurement of + any unit. + * ⚠️ **THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE.** All 183 + sites mutated to `[:1]`, one at a time, against the full 19-file suite in + `goodwe-addon-ci` (`:ro`, `-B`, fresh rsync per mutant, 0 `.pyc` before and + after): **183/183 RED** — which is tautological on its own, because the pin + makes every slice red. The 51 whose first red was `test_peakguard.py` were + re-run with the pin **neutralised**: **145 of 183 have a behavioural fixture + that separates them from a slice of themselves; 38 have only the structural + claim.** All 38 are listed by site, with what a `[:1]` there would cost, in + §4d and in `test_peakguard.py` part 12j — 15 of them are rows and counts on + the operator's page, 3 are `len(targets)` arithmetic beside a write path + that IS graded, 6 are allocator internals ALLOC-01's fixtures reach but not + with enough units to separate. **None of the 38 is closed behaviourally by + this card, and none is counted as covered.** + +⚠️ **No measurement of inverter B appears anywhere in this change.** It does not +exist yet. Every second-pack figure — in fixtures, in the probe, in +config.yaml's commented example — is **simulated and labelled**; only inverter +A's 10 % is read off hardware. + **At two inverters the sign watchdog stops comparing one unit against the whole site meter, and becomes two checks instead of one (SAFETY-30).** `disagrees(commanded_w, grid_w, ...)` compared the command **this controller put diff --git a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md new file mode 100644 index 0000000..2b0cdc9 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -0,0 +1,816 @@ +# SAFETY-32 — `bms_soc_floor` per pack, and the two judgement calls it left open + +**Status:** decided and implemented on branch `SAFETY-32`. +**Card:** SAFETY-32 — *"One `bms_soc_floor` for two packs grades the shallower +pack against the deeper one's cut-out"*, scoped by the card's own PM PROOFREAD +to its **third** bullet only. Bullets 1 (aggregate anti-windup) and 2 (per-instance +`achievable_w`) were verified as already shipped by DEV-02 and CFG-04 and were +**not** touched — `main.py`'s `actual_w=self.site_batt_w` comment is the record +of why the first of those must stay site-level, and it still reads as written. + +--- + +## 1. The defect, stated in the terms the code uses + +`bms_soc_floor` **describes a battery**: it is the percent that pack's own BMS +cuts out at, register `0x0560` on the reference GW5000S-BP, read as 10. It was a +flat option because there was one pack. + +`peak_soc_floor` **describes the site's policy**: the depth a money-saving +discharge may never reach. Rule 8 refuses `peak_soc_floor <= bms_soc_floor`, +because such a floor can never bind — the BMS wins and the status page reports a +backstop that does not exist. + +At two packs of different models the two cut-outs differ, and one number for both +is wrong **in the direction that empties a pack**: with cut-outs of 10 % and 30 % +and `peak_soc_floor: 20`, the add-on **started**, and the guard would command a +forced discharge that pack B's *BMS* — not the operator's floor — is the thing +that stops. That is rule 8's own failure mode, reached through rule 8. + +--- + +## 2. DECISION 1 — required per entry, not defaulted from `PEAK_OPTION_DEFAULTS` + +The card left this open and asked for the reason to be recorded. + +**Decided: REQUIRED of every roster entry**, in `config.yaml`'s schema +(`bms_soc_floor: int(0,50)`, no trailing `?`) and in `validate.py` rule 8, which +refuses a missing one **by name**. + +The reason is that a default here is not a convenience, it is **this card's own +defect wearing a different hat**. A default is one number applied to every pack, +which is exactly what the flat key was. An add-on that answers "10 %" for a pack +nobody has told it about has invented a property of somebody's battery, and the +failure mode of the invention is the one this card exists to remove — the +shallower pack protected by a floor that belongs to the deeper one. CFG-03's +rule ("defaults live in exactly one place") points the same way: the one mirror +`PEAK_OPTION_DEFAULTS["bms_soc_floor"]` is the default for the **flat** key, i.e. +for the single-inverter shape, and reusing it per entry would make one dict the +default in two shapes with two meanings. + +It is also the treatment `max_w` already gets, for the same reason and by the +same argument (`inverter.py`'s `build_roster()` docstring): the per-unit numbers +that describe hardware are required, and the per-unit *sensors* are optional +because an absent sensor is a real state. + +**The cost is stated rather than hidden.** This is a schema change: a roster +config written before this branch is refused by the Supervisor until each entry +gains the key, and every roster fixture in the suite needed it. Nothing in +production carries a roster today — `inverters:` ships `[]` and the second +inverter is ~8 days out — so the migration is paid by fixtures and by whoever +writes the first real roster, at a startup refusal that names the entry. + +**What is NOT inherited.** An entry that omits the key does not borrow the flat +one. `Inverter.bms_soc_floor` is then `None`, `peakguard.pack_floor_pct()` +answers `None`, and `discharge_permitted()` already treats an unknown floor as +**no permission** — so even in the window before rule 8 runs (e.g. `sim/inject.py`, +which does not run the startup gate) the absence costs a shave and never a pack. +`0.0` was rejected explicitly: it is the most permissive value the key has. + +## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors + +`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack — +**is unchanged**, exactly as the card instructed. What changed is the floor it is +graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack +floors, each of them `max(peak_soc_floor, that pack's own cut-out)`, with a +`max()`. The two reductions pair: a minimum SoC against a maximum floor is the +conservative combination, and both move the verdict towards refusing a shave and +away from spending a pack. `min(soc) <= max(floor)` is implied by "some pack is +at or below its own floor", so the site verdict is never more permissive than +grading each pack separately would be. The converse is not true and is not +claimed — a site can be refused while every pack individually has headroom, which +costs a shave. + +### 3a. ⚠️ FIX ROUND — the reduction was written and argued, and NOT GRADED + +PR #48 was rejected because the argument in §4 below justified *writing* the +reduction and was then not carried into a check. Neither of the two mutations +below appears anywhere in §6's twelve-row battery: it graded *"the reduction +dropped"* and *"the bare option handed over"*, and never asked what happens when +the reduction is the **wrong one**, or when the roster handed to it is +**sliced**. Both were applied to `app/` by the reviewer and **all 19 files +stayed green**: + +| mutation | Δ | why it survived | +|---|---|---| +| `binding_soc_floor()`: `max(floors)` → `min(floors)` | **0 bytes** | every roster in the suite was rule-8-valid or one pack long, so `floors` held **identical numbers** and `max`/`min`/first/last were four spellings of one answer | +| the `decide()` call site: `self.packs` → `self.packs[:1]` | **+4 bytes** | part 12g asserted `"self.packs" in ` — and `self.packs[:1]` **contains that substring** | + +The zero-byte one was proved to have actually run rather than assumed: +`dis.get_instructions` showed the `LOAD_GLOBAL`s as `[float, pack_floor_pct, +any, min]`, and `binding_soc_floor([Pack("a",50,10), Pack("b",50,35)], 20.0)` +returned `20.0` where the shipped code returns `35.0`. + +**The common cause is this repository's recurring defect** — a check whose DATA +cannot reach the failure it names. The one check that used a cut-out **above** +the EMS floor (12c's `_n1_deep`) ran on a roster of **ONE**, where every possible +reduction agrees. So no check in the suite ever evaluated +`binding_soc_floor()` on two packs whose floors **differ**. + +**And the rule-8 degeneracy is not a defence, by §4's own words.** If +"unreachable is a claim about today's rule ordering, not a property of this +function, and `validate_startup_config()` is also `sim/inject.py`'s entry point" +is enough to *write* the reduction, it is enough to *grade* it. Through +`sim/inject.py`, which does not run rule 8 at all, `min(floors)` lets the guard +command a forced discharge to 20 % on a pack whose BMS cuts out at 35 %. + +**What was added — no shipped behaviour changed, only what observes it:** + +* **`test_peakguard.py` part 12c2** evaluates `binding_soc_floor()` on a roster + whose per-pack floors **all differ**. Four **simulated** cut-outs — + `30, 25, 40, 35` %, every one strictly above `peak_soc_floor: 20`, so + `pack_floor_pct()` selects the cut-out for each and `floors` *is* that tuple in + that order. The four candidate reductions are then four different numbers: + + max = 40 <- the shipped answer + min = 25 + first = 30 <- "grade the site against pack A", inside the function + last = 35 + mean = 32.5 + + The extremes sit at **interior** roster positions (index 2 is the max, index 1 + the min). A two-pack roster structurally cannot separate these — there `max` + and `last` coincide and so do `min` and `first` — and neither can three. Four + is the smallest roster that separates all four. The precondition is its own + check (habit 1), one pack is then raised past the old maximum (habit 7: the + **last** pack, which was neither extreme, so the line can only move if the + reduction read it), and `peak_soc_floor` is lifted above every cut-out to grade + the other side of the conjunction. + +* **Part 12g is behaviour now, not source text** (MUTATION.md habit 4). Real + `Controller.cycle()`s on a two-pack roster, both packs held at the same SoC and + everything else identical, with the packs' own cut-outs as the only thing + varied: + + cut-outs A 10 / B 18 % -> decide() got soc_floor=20 %, floor_limited=False + cut-outs A 10 / B 35 % -> decide() got soc_floor=35 %, floor_limited=True + + The observable is `PeakDecision.soc_floor` — the argument, echoed back by the + shipped `decide()` — so the whole path from `Controller.inverters` to the + guard's argument is under test, and the site's **verdict flips** as well as the + number. `self.packs[:1]` answers 20 % both times. **Which** option key the floor + comes from stays pinned on the source in part 4, because that claim genuinely + is about the code's shape; that the option still governs is now graded + behaviourally too. + +* ⚠️ **A THIRD SURVIVOR, FOUND WHILE FIXING THE FIRST TWO, AND RECORDED RATHER + THAN QUIETLY REPAIRED.** The first cut of part 12g varied only **pack B's** + cut-out — so `self.packs[-1:]`, grade the site against the **last** pack, + agreed with the full roster on both runs and stayed green across all 19 files + (+5 bytes). A two-run fixture that only ever moves the second pack pins `[:1]` + and not `[-1:]`; it is the same defect one step along, and it was in the fix + for this defect. Part 12g now runs the **same pair of cut-outs in both + orders**: + + cut-outs A 10 / B 18 % -> soc_floor=20 %, floor_limited=False + cut-outs A 10 / B 35 % -> soc_floor=35 %, floor_limited=True + ...the SAME PAIR SWAPPED, A 35 / B 18 % -> soc_floor=35 %, floor_limited=True + + `[:1]` answers 20 % on the second run, `[-1:]` answers 20 % on the third, and + the roster answers 35 % on both. No single-position slice passes all three. + This is the mirror `12h` already ran on the SoC axis, which is where the shape + was borrowed from. + +* ⚠️ **AND A FOURTH, from the same battery: the EMPTY-roster branch.** Rewriting + `binding_soc_floor([])`'s `float(soc_floor)` to `return None` (−43 bytes) was + green across all 19 files. It is defensive code — `Controller.inverters` is + never empty on a started controller, and `inputs_ok` refuses the cycle before + the guard is reached if it were — so nothing in the suite went near it. It is + **pinned rather than argued**, in both directions (the option when there is + one, `None` when there is not), because a branch with no check is a branch + that can be rewritten under a green suite. The check also records why it + cannot permit anything either way: `pack_floor_soc` is `None` at an empty + roster, and `discharge_permitted()` refuses on a `None` SoC whatever the floor + is. + +* **`wired_over_limit()` gained a `prepare(ctl)` hook**, inside its retry loop so + every attempt grades the same site, which is the only window in which a second + inverter can join the roster before the telegrams are fed. + +* **In-process mutation rows 22, 22b and 23** record all four survivors, so a + later edit that re-degenerates any of these fixtures goes red on a row rather + than on nothing. + +### 3b. ⚠️ SECOND FIX ROUND — the fix carried the defect it was written to close + +Round 2 closed both named survivors, and both of round 3's blockers are the +**same defect sitting inside that fix**: a check whose TEXT claims more than its +FIXTURE can observe. Applied to `app/` on `8501e05`, **all 19 files stayed +green** for both: + +| mutation | Δ | the sentence that was too broad | why the fixture could not see it | +|---|---|---|---| +| `binding_soc_floor()`: `max(floors)` → `max(floors[-2:])` | **+5 bytes** | *"So the reduction reads **every entry**"* | 12c2's only counterfactual raised the **last** pack (`_deeper[-1]`), and **every window that ends at the roster's end follows the last pack**. On `(30,25,40,35)` both answer 40; on `_deeper` both answer 45. | +| the `decide()` call site: `self.packs` → `self.packs[:2]` | **+4 bytes** | *"what is reduced is the **ROSTER** and not a POSITION in it"* | all three `_floor_reaching_decide` runs drove a **two-pack** roster, where `self.packs[:2]` **is** `self.packs` — by exactly the argument 12c2 itself makes about two packs being unable to separate max/min/first/last. | + +Round 2's *report* stated the narrow, true claim ("no **single-position** slice +passes all three runs"). The *check in the file* stated the broad one. That gap +is the whole of round 3. + +**THE GENERAL LAW, stated once so the next card can use it:** + +> **A fixture of N packs cannot separate two reductions that agree on N packs.** +> Two cannot separate max from min from first from last. Three cannot separate +> max from max-of-the-last-two. A single counterfactual that varies one position +> cannot separate the reduction from any window containing that position. + +Its corollary is the reason this section is long: **the regress does not +terminate in a fixture.** Closing `[:1]` took two packs; `[:2]` took three; +`[:3]` takes four — and a four-pack run hands `[:4]` the same free pass. Round 3 +therefore does two different things rather than adding one more pack. + +**What was added — no shipped behaviour changed, only what observes it:** + +* **Part 12c2 gained a ROSTER SET and a computed FAMILY TABLE.** The same four + simulated cut-outs with the **deepest moved to each of the four positions**, + and fourteen enumerated rewrites of `max(floors)` each checked against every + roster. The table is **computed and printed**, and a member with an empty kill + list takes the check **red naming it** — so a family member can no longer be + added without a fixture that reaches it. `max(floors[-2:])` is killed only by + the rosters whose deepest sits at index 0 or 1, which is precisely the data the + single last-pack counterfactual could not supply. + +* **Part 12c2 gained a LENGTH SWEEP.** `binding_soc_floor()` is a pure function + over a list, which is the one place a sweep is cheap: **78 simulated rosters, + lengths 1 through 12, the deepest cut-out at every position of each.** That + closes *every* head window `max(floors[:w])` and *every* tail window + `max(floors[-w:])` of width **1 to 11** in one property rather than one + mutation at a time — and the check asserts that width **12 is NOT reached**, so + the limit is a red line rather than a silence. + +* **Part 12g drives THREE-pack rosters**, with the deep cut-out at index 0, 1 + **and** 2. Every positional slice of a three-pack roster is a proper subset of + `{0,1,2}` and so drops a position; the run whose deep pack sits there answers + the option, 20 %, where the roster answers 35 %. `self.packs[:2]` goes red on + the index-2 run. `_floor_reaching_decide()` now takes `*floors`, and the extra + units join through the same `second_inverter()` hook the two-pack runs use. + +* **Part 12g gained a POSITIONAL-SLICE TABLE**, computed against the **real** + `Controller.packs` each of the six cycles actually ran and the **real** + `PeakDecision.soc_floor` it produced — so it measures the shipped call site's + own answer, not a re-derivation, and a survivor takes the check red. + +* ⚠️ **AND A FIFTH AND SIXTH SURVIVOR, FOUND BY ROUND 3'S OWN BATTERY AND + CLOSED STRUCTURALLY RATHER THAN BY ONE MORE PACK.** `self.packs[:3]` (+4) and + `self.packs[-3:]` (+5) survive **all six** behavioural runs, because the + longest roster those runs drive is three packs long. Part 12g therefore also + pins the roster **argument's identity** on the AST: it must unparse to exactly + `self.packs`, which is false of every slice at every length. ⚠️ This is **not** + the substring test round 2 removed — `"self.packs" in expr` is *true* of every + slice; `ast.unparse(arg) == "self.packs"` is *false* of all of them. The two + halves are separated and each says what it reaches: the **runs** grade the + VALUE and reach every slice up to N=3; the **pin** grades the ARGUMENT'S + IDENTITY at any N and is blind to two things the runs are not — a rewrite of + what `Controller.packs` *returns* (the runs' `len(ctl.packs) == 3` catches + that) and the reduction *inside* `binding_soc_floor()` (12c2's). + +* ⚠️ **AND ONE ROW IS RECORDED AS NOT CAUGHT.** A reduction over a window + **twelve packs wide** is indistinguishable from `max(floors)` on every roster + in the file, because the sweep stops at twelve. Unlike the call site there is + no identity claim to pin — the claim is semantic, "the maximum", and a shape + pin would refuse honest refactors of the expression. It is reported **with its + size** (it needs a 13-pack roster, on a site whose roster is ONE and whose + second unit is ~8 days out) rather than hidden. + +* **Two rewrites are EQUIVALENT, not uncaught.** `max(set(floors))` and + `list(reversed(self.packs))` cannot change any answer — `max` is order- and + duplicate-invariant. The second is nonetheless red on the structural pin. + +* **In-process mutation rows 24 – 27** record all four of round 3's findings, + including the not-caught one, so a later edit that re-degenerates any of these + fixtures goes red on a row rather than on nothing. + +## 4. ⚠️ THE HONEST LIMITATION, and it is the reason this record exists + +**Under any configuration rule 8 accepts, `binding_soc_floor()` answers +`peak_soc_floor` for every pack.** Rule 8 refuses `peak_soc_floor <= bms_soc_floor` +on *every* unit, so `max(peak_soc_floor, bms_i) == peak_soc_floor` everywhere the +add-on will actually start. The per-pack **floor** is therefore non-degenerate +only against a config rule 8 refuses. + +This is not a defect and it is not hidden. It is the same argument +`validate.unit_caps_indexed()` makes about itself, in that function's own words: + +> *"Rule 9(d) already refuses such a config so the two lists cannot desynchronise +> in shipped use — but 'unreachable' is a claim about today's rule ordering, not +> a property of this function, and `validate_startup_config()` is also +> `sim/inject.py`'s entry point."* + +CFG-04's index desynchronisation is what happened the last time that reasoning +was left implicit. So the reduction is written, and it is graded on both sides: +`test_peakguard.py` part 12c sweeps **1775** rule-8-valid `(peak_soc_floor, +bms_soc_floor)` pairs and asserts the answer is the option in every one of them +(that is AC 5, measured over the whole option space rather than at the shipped +point), and then asserts that on the pair rule 8 *refuses* the answer is the +**cut-out** — because without that second line the sweep would be satisfied by +`soc_floor` typed straight through, and the mutation that deletes the `max()` +could never bite. It does bite: M2 on the PR. + +**What IS live at a rule-8-valid roster** is the rest of the card: rule 8 now +refuses the two-model config that used to start; the permission is answered per +pack, so the alarm names the pack that stopped the site rather than "the +battery"; and an entry with no stated cut-out is withheld instead of defaulted. + +## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis + +Rounds 2 and 3 hardened the **floor** axis: the `max` inside +`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and +`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the +**sibling argument of the same call**: + +```python +self.peak_guard = peakguard.decide( + ..., + soc=self.pack_floor_soc, # MINIMUM <- round 4 + soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3 +``` + +`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with +the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as +its weaker half. On `52380c0`, with all 19 files green and verified again after +the rebase onto `3934ad7`: + +| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now | +|---|---|---|---| +| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED | +| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED | +| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED | +| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED | + +The suite's separating power on the SoC axis stopped at exactly **N = 2** — +precisely the state the floor axis was in before round 3, and which round 3 +itself named as its blocker 2. A fixture of N packs cannot separate reductions +that agree on N packs. + +**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest +pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. The +site is then graded for discharge *permission* against a pack 39 points fuller +than the emptiest, and spends a pack its own BMS stops — through +`sim/inject.py`, which runs no validator rule at all. The verdict flips with it: +11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes +no forced discharge, where the mutant is permitted and sizes a positive one. + +### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure + +SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE +DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and +nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that +one is measured*, off register `0x0560` on the reference GW5000S-BP. + +So **production is degenerate on the floor axis**: both packs floor at the same +value, `max(10, 10) == 10`, and a differing floor exists **only in the +fixtures**. That is not an argument for simplifying the fixtures to matching +values — it is the argument for keeping them. `binding_soc_floor()`'s reduction +is observable *nowhere else*, and the moment a third pack, a replacement pack or +a different chemistry arrives it is observable in the field. **Anyone who +"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only +data this repository has that can see the reduction at all**; part 12c2's +precondition check goes red if they do, and this paragraph is why. + +The **SoC axis has no such degeneracy**. Two batteries at the same state of +charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of +the pair that will be non-degenerate in the field from the day inverter B is +wired up. + +### The treatment, transferred from round 3 + +* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at + every position of a three-pack roster and both positions of a two-pack one, + the observable read off `PeakDecision.soc` (the argument, echoed) rather than + off a spy or a grep. Cut-outs held flat across all of them, so the floor axis + cannot be what moved the answers. +* **the positional-slice table and the non-slice table** — computed against the + real `Controller.inverters` each cycle carried and the real decision it + produced, printed, with a member that nothing kills taking the check red. +* **the length sweep** — the real property on real rosters of 1 to 12 inverters, + the empty pack at every position. Closes head and tail windows of width 1-11 + at once. Width 12 and wider is **honestly not reached**, and asserted to be + unreached so a later sweep that lengthens the rosters must rewrite the + sentence. +* **the exact structural pin** — three of them, because there are three + reductions over roster length in this one property: `vals` is built by one + comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces + the whole list; `min(... for v in vals)` reduces the whole list. Every slice + of either is red at **every** roster length, including the `[:3]` and `[:12]` + the runs and the sweep cannot see. + +Unlike the floor axis, **there is no uncaught row here**: the claim at +`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right +instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum") +and a shape pin there would refuse honest refactors. That asymmetry is why one +carries an open row and the other does not. + +## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded + +This card has now been rejected three times for the same shape one step over, so +round 4 stopped closing members and enumerated the **family**. Every reduction +over `self.inverters` / `self.packs` in `Controller` was found from the AST and +mutated to a slice, one at a time, against the full 19-file suite. + +| # | site | reduction | consumer | mutation | result | +|---|---|---|---|---|---| +| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) | +| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) | +| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) | +| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) | +| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) | +| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) | +| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) | +| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) | +| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) | +| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) | +| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) | +| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) | +| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) | +| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) | +| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) | +| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) | +| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) | +| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) | +| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) | +| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) | +| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below | +| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** | + +**Rows 18-20 are the finding, and they are not this card's code.** All three are +`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and +all three survived a green 19-file suite for exactly the reason every blocker on +this card survived: *the only fixture that reached them was a roster of ONE, +where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has +built a two-inverter roster with only inverter B latched since SAFETY-30 +shipped — the precise data all three needed — and none of them read it. + +Row 18 is the one with teeth. A latched unit must take the whole roster past the +stale-input hold, because that unit's `failsafe` SET is the thing parking it; +main.py's own comment says *"the trip would stop acting the moment a poll was +missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's +SoC returns early, and B's last share stands for the heartbeat to re-emit. +test_watchdog.py part 8 now drives that with the latch at **each** position, plus +the no-latch counterfactual that proves the hold really fires. + +**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there +is now red on 12j's enumeration, but I could not construct a *reachable* state in +which it changes an answer: `_grade_store_migration()` runs at startup, before +any cycle, so a unit is tripped in memory only if its own `unit//sign_tripped` +key was truthy — which makes `namespaced_latched` True and short-circuits the +disjunct. I believe it is an equivalent mutant at that call site rather than a +live hole, and I am recording the argument instead of asserting the conclusion. + +**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u +in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in +one event-log line**. It is +4 bytes and no check bites. The refusal itself — +`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different +expression and *is* graded (test_peakguard.py part 10h-1b), so the site still +stops; what a `[:1]` costs is an operator being told one name when two units have +no writer. It is not a roster comprehension (it iterates `resolution.shares`), so +12j does not cover it and does not pretend to. Closing it needs a three-inverter +fixture with two writer-less entries, which is ALLOC-01's fixture and not this +card's. **Filed as open, with its size and its consequence, rather than closed +quietly or left unmentioned.** + +### And the family closed rather than the member + +`test_peakguard.py` part 12j enumerates the family from the AST and requires +every member to take the roster **whole**, so a new reduction over a slice is red +**on the line it is written**, at every roster length, with no fixture required. +That check is what would have caught rounds 2, 3 and 4's blockers on the round +they were written instead of one review round each; it is also what found rows +18-21. + +⚠️ **AND IT WAS STILL TOO NARROW — see §4d.** Round 4's version enumerated the +26 roster comprehensions inside the `Controller` class, because that is where +the four known sites were. Round 5's blockers were four reductions in +`validate.py`, one module over, over a list *derived* from the roster three +calls away from it. The pin now parses every `.py` under `app/` and finds the +roster by transitive closure from one option key: **183 reductions across 9 +modules.** + +## 4d. ⚠️ ROUND 5 — the same family one MODULE over, and an instrument that discovers its own scope + +Round 4 enumerated the family and closed it **inside the `Controller` class**, +because that is where the four known sites lived. The review found four more +survivors immediately, in `validate.py`, on a 19/19 green suite: + +| site | mutation | delta | suite | +|---|---|---|---| +| `validate.py:779` | `min(caps)` → `min(caps[:1])` | +4 B | **GREEN** | +| `validate.py:779` | `min(caps)` → `min(caps[-1:])` | +5 B | **GREEN** | +| `validate.py:778` | `caps = [cap for _, cap in caps_ix[:1]]` | +4 B | **GREEN** | +| `validate.py:893` | `min(caps_ix[-1:], key=lambda p: p[1])` | +5 B | **GREEN** | + +**None of them is an equivalent mutant — a safety refusal disappears.** With +`max_w: 5000` on inverter-a beside `2000` on inverter-b and +`sign_disagree_w: 3000`, the shipped validator answers one problem — +*"sign_disagree_w is 3000 W but max_w (inverter-b) is 2000 W, so the SAFETY-02 +sign-disagreement watchdog can never fire"* — and every one of the four answers +**zero**. The site starts, with a watchdog reporting `armed` on the status page +that cannot trip on pack B. That is rule 5's own failure mode reached **through** +rule 5, which is the sentence `test_inverter.py:1657` says the rule exists to +prevent. Rule 6 loses its refusal the same way. + +### Why round 4's pin could not see them, and why the fix is not four more checks + +`caps` and `caps_ix` are not comprehensions over `self.inverters`; they are lists +**derived** from the roster, three function calls away from it, in a module the +enumeration never opened. Closing them by hand would have produced a fifth +rejection with the same shape one step over — that is the whole record of this +card. **A hand-written scope is the same defect as a hand-written fixture** +(MUTATION.md habit 12): a check whose data cannot reach the failure it names. + +### The instrument + +`test_peakguard.py` part 12j now parses **every `.py` under `app/`** off disk — +no module is named anywhere in the check — and finds the roster by transitive +closure from **one seed**, the `inverters` option key: + +* a function whose returns are roster-carrying is **roster-producing** + (`roster_entries` → `build_roster` → `unit_caps_indexed` → `unit_caps`); +* a name assigned one carries it, **and so does a list accumulated in a loop + over one** — which is exactly what `caps`, `roster` and `problems` are, and + what round 4's rule could not express; +* an attribute or property answering one carries it (`self.inverters`, + `Controller.packs`, `Controller.units`, `live_units()`); +* **an argument at a call site taints that parameter in the callee**, which is + how the pin walks into `peakguard.binding_soc_floor`, `arbiter.resolve`, + `allocator.plan`, `circulation.circulating_w`, `maintenance.arbitrate` and + `store.migrate_maint_keys` without being told they exist; +* and **a slice of a roster is still a roster**, so a mutation cannot cut the + taint and hide every reduction downstream of itself. An **index** is one unit + and is not. + +It then requires, at every place a roster-carrying value is reduced or iterated, +that the whole of it arrives: no `[:1]`, `[-1:]`, `[:2]` or `[0]` between the +roster and the reduction. + +### What it found — and the hand-written list was wrong in BOTH directions + +**183 reductions across 9 modules**, out of 23 `.py` files parsed: +`main.py` 96, `allocator.py` 24, `validate.py` 19, `arbiter.py` 14, +`circulation.py` 10, `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, +`store.py` 3 — and 14 modules with no roster reduction at all. + +The card's own suggested module list named `watchdog.py`, which reduces over no +roster at all (SAFETY-30's sign watchdogs are strictly per-unit — there is +nothing to slice), and did **not** name `maintenance.py` or `store.py`, which +both do. A pin built from that list would have made a vacuous claim about one +module and no claim at all about two others. That is the argument for computing +the scope, made with data rather than with an assertion. + +The pin **independently rediscovers all four blockers** — it is told one option +key and nothing else — and finds a **fifth** beside them at +`validate.py:783`, the `min(caps_ix, key=...)` inside rule 5's message, which no +review round named. + +### The counterfactual, at two depths + +* **Per site, all of them.** Every enumerated site's own expression, in its own + scope, mutated with each of the four cuts this card has actually been defeated + by. **183 sites × 4 cuts = 732** mutated spellings; every one reported. The same run asserts + the predicate **accepts** the legitimate wrapped forms the package really uses + (`enumerate`, `zip`, `sorted`, `pack_permissions(packs, floor)`) and does not + treat a keyed lookup (`weights[u]` in `allocator._fill`) as a slice — which is + what four false SLICED reports cost before `shortens()` was written. +* **End to end, on the real files.** `[:1]` written into `app/validate.py` at + each blocker position, the **whole** analysis re-run over the mutated source, + and a sliced site required at that exact line — where the clean package + reports none. This is the check that would have taken PR #48 red four rounds + ago, with no fixture reaching it. + +### The sweep, and the number that must not be quoted alone + +All 183 sites were mutated to `[:1]`, one at a time, +4 bytes each, and each +mutant run against the full 19-file suite in `goodwe-addon-ci` (mounted `:ro`, +`python3 -B`, a fresh rsync per mutant; 0 `.pyc` in the tree before and after — +a read-only mount cannot write one, so TEST-15 is closed by construction here +rather than by discipline). **183 of 183 RED.** + +⚠️ **On its own that is tautological**: with the pin in the tree every slice is +red *by the pin*. So all 51 mutants whose first red was `test_peakguard.py` were +re-run with the pin **neutralised** (`_SLICED` blanked), leaving only the +behavioural checks: + +| | | +|---|---| +| **145 of 183** | a behavioural fixture in this repository separates the site from a slice of itself — red with the pin turned off | +| **38 of 183** | **nothing but this pin sees it.** Structurally closed by this card; behaviourally **UNGRADED**, and listed rather than counted as covered | + +The 38: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1, +`circulation.py` 1, `maintenance.py` 1. Grouped by what a `[:1]` there costs: + +* **The operator's page — 15, the largest group.** `Controller.checks` + (3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, 3400, 3458) and + `status_payload` (3556, 3566, 3578). A slice reports one unit's row where two + exist, or "1 of 2 live" reading 1 of 1. Nothing commands differently; what is + lost is the evidence a human uses to notice that it should. +* **The write path's own counts.** `write_setpoint` 1267/1272/1280 — three + `len(targets)` guards. The write itself iterates `targets` at 1287 and *is* + graded (test_inverter.py); these three are its log arithmetic and refusal + count. +* **The allocator's internals.** `_fill`'s zero-weight last-resort pool (365), + `plan`'s `dropped` (526, 653), `free` (584), `frac_order` (591). ALLOC-01 + drives two and three units through the *weighted* pass; the last-resort pass + and the largest-remainder tie-break are reached, but not with enough units for + a slice to change the answer. +* **Startup and FSM plumbing.** `__init__` 235/351, `_only` 831, + `_maint_button` 805, `cycle`'s maintenance zip 1505, `send_heartbeats` 3015, + `check_output_clamp` 2445/2452, `check_circulating` 2701, + `maintenance.arbitrate` 547, `arbiter.site_resolve` 517, + `circulation.opposing` 242. +* **Two refusal-TEXT sites.** `validate._roster_problems`'s `len(entries)` (554) + and `refuse()`'s numbering loop (1263) — both shape the words of a refusal + that still happens. +* **`_grade_store_migration` (698)**, already row 21 of §4c, argued there to be + an equivalent mutant at that call site. The argument is unchanged and is still + an argument, not a proof. + +⚠️ **None of the 38 is closed behaviourally by this card, and saying so is the +point.** Closing sites one at a time is what cost this card four review rounds. +The pin closes the **family** — a slice at any of them is red on the line it is +written — and this list is what a shape pin can never promise. + +### And the behavioural half, because a shape pin cannot grade a meaning + +The pin grades an **identity** ("the whole roster arrives"). `min` → `max` is a +zero-byte edit and invisible to it, forever. So `test_validate.py` grows a +**roster SET** for the three rules that reduce over `unit_caps_indexed()`: +rosters of width 2, 3, 4 and 5 with the one small cap at **every position** — +14 rosters × 3 rules = 42 runs of the real validator — plus a **computed family +table** of twelve candidate reductions (`max`, `first`, `last`, the mean, and +`[:1]`, `[-1:]`, `[:2]`, `[:3]`, `[1:]`, `[:-1]`, `[::2]`) each compared against +what the shipped validator actually said on the same roster. Only `min` agrees +everywhere; any member no roster kills is **printed as a named survivor** and +takes the check red. + +### ⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's + +Found by reading the three branches side by side — **not** by the pin, and said +that way because it is the shape of what the pin cannot see. Rule 5's sibling +branch, `unit_sign_disagree_w` (SAFETY-30's per-inverter sign watchdog), was +graded against `_number(opts, "max_w", 2000.0)` — the **flat** option — three +lines under a branch that had already stopped doing that. Both directions were +measured before the fix: + +* a roster of two 2000 W units beside a leftover `max_w: 5000` from a + single-inverter install, with `unit_sign_disagree_w: 3000`, produced **zero + problems**. The site started, the status page said `armed`, and the per-unit + watchdog could not trip on **either** unit. Identical sentence to the blocker, + reached through the branch beside it; +* and a roster of two 5000 W units with no flat key was **refused** at 3000 W + against a 2000 W *default the operator never wrote*, for a threshold both + units can reach. + +It takes the same `weakest` the branch above takes, from the same list, and +names the same unit. At a roster of one `unit_caps()` is `[max_w]`, so the +shipped single-inverter refusal is the same comparison, the same boundary and +the same sentence. Both directions are asserted in `test_validate.py`. + +### ⚠️ WHAT THE PIN CANNOT SEE — open rows, with sizes + +1. **Semantics.** `min` vs `max` vs the mean: zero-byte edits, invisible to any + shape pin. Only the behavioural sets above can see them, and they only see + the rules they drive. +2. **Direct index sites that are not reductions.** `self.inverters[0]` in + `_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in + `maint_unit`, `entries[index]` in `_unit_label()`. Single-unit lookups, meant + to be, graded where they live. +3. **A subscript by a NAME.** `weights[u]`, `cap[u]`, `add[u]` in + `allocator._fill` are keyed lookups into a roster-derived mapping and are + accepted deliberately, so `caps[i]` with a variable `i` would pass. Not in + the family this card has been defeated by; buying it costs four false reports. +4. **A slice through an intermediate call.** `sorted(roster)[:1]` subscripts a + call result, not a roster reference. Nothing in `app/` is written that way + today; if something is, it would not be seen. +5. **Anything outside `app/`.** `tools/`, `sim/` and the checks themselves are + not parsed — and `sim/inject.py` reaches `validate_startup_config()`. +6. **The taint's own seam.** It crosses a call boundary only where the callee is + a plain function this package defines and the argument is positional or + keyword. A roster handed through `*args`, stored in a dict and read back, or + passed to a method on an unresolvable object, is lost at that hop — and + everything downstream goes **unenumerated** rather than reported unreachable. + That is the shape of the remaining exposure: not zero, and not measurable + from inside the check. +7. **`cycle()`'s undeliverable-unit log line** (row 22 of §4c) is still green at + +4 bytes, unchanged from round 4 and still filed open. + +## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card + +`peak_soc_floor` is still **one number for the site**, so the card's own +"or the deeper pack is needlessly withheld" half is not closed and this record +does not pretend it is. With cut-outs of 10 % and 18 %, rule 8 obliges the +operator to set the EMS floor above 18, and the 10 % pack is then held above its +own need. Closing that means `peak_soc_floor` becoming a per-entry key too, which +is a second option migration and a product decision about what the site-wide +number then means. The card scoped itself to `bms_soc_floor` and explicitly told +this developer not to widen it, so it is filed here as the open question it is +rather than smuggled in. + +**Also still not in this card, and named by the shipped code already:** the +charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction +for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling` +takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's +per-pack bound. Nothing here touched it. + +## 6. What was changed, and one thing that was found on the way + +* `config.yaml` — `bms_soc_floor: int(0,50)` required per `inverters:` entry; + the flat key and its default are untouched and are documented as the + single-inverter shape's own cut-out. +* `app/inverter.py` — `Inverter.bms_soc_floor`; `_entry_bms_floor()` (`None`, not + a borrowed number and not `0.0`); `_flat_bms_floor()`. +* `app/peakguard.py` — `Pack`, `pack_floor_pct()`, `pack_permissions()`, + `binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the + one place the question is answered. +* `app/validate.py` — `unit_bms_floors_indexed()`; rule 8 grades every unit and + names it via the **roster** index the value came off. +* `app/main.py` — `Controller.packs`; the `decide(soc_floor=…)` reduction; the + floor-limited notification names the pack(s) at or below their own floor, at a + roster of two or more only. +* `tools/supervisor_shape_probe.py` — **re-run, and repaired.** MUTATION.md + habit 8: a committed transcript is a check and it decays. Making the key + required broke §C outright and, worse, left §D printing **PASS** on a refusal + about the *wrong key* — its "no `max_w`" case was answered `Missing option + 'bms_soc_floor'`. §D is now an isolated counterfactual over a **merged, + accepted** baseline with exactly one key varied per case, asserting the key's + **name** in the refusal, and it carries a `bms_soc_floor` case in both + directions. Re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`: + `all cases passed`. +* `app/inverter.py`, found by the mutation battery — `_flat_bms_floor()` was + written with the default in **two** places (the `.get()` fallback *and* the + blank test). Deleting either one survived, because each covered for the other: + MUTATION.md's masking section in three lines of option reading. It has one + defaulting line now, and M12 takes six checks red. + +* **Round 3 changed no `app/` file at all.** `test_peakguard.py` only: + parts 12c2 and 12g. Its battery was **46 mutations across + `binding_soc_floor()`'s reduction, the `decide()` call site, `Controller.packs` + and `pack_floor_pct()`'s conjunction** — bytecode purged and counted before and + after each (23 → 0). **43 red, 3 survivors, all three recorded above and in the + in-process rows**: `max(floors[:12])` and `max(floors[-12:])`, which need a + 13-pack roster to observe and are reported with that size rather than hidden; + and `max(set(floors))`, which is **equivalent** — `max` is duplicate-invariant, + so it cannot change an answer. `list(reversed(self.packs))` is equivalent too + (`max` is order-invariant) and is nonetheless red on the structural pin. + +⚠️ **No measurement of inverter B appears anywhere in this change.** It does not +physically exist. Every second-pack number in a fixture, in the probe and in +config.yaml's commented example is **simulated and labelled as such**; only the +10 % on inverter A is read off hardware. The three-pack runs added in round 3 are simulated +twice over: there is no inverter C at all, and the shallow slots carry inverter +A's measured 10 % while the 35 % cut-out that moves along the roster is invented. + +--- + +## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0 + +Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring): + +``` + docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" \ + --entrypoint python3 \ + ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \ + /probe/supervisor_shape_probe.py +``` + +It reads `config.yaml` and runs Supervisor's own `AppOptions` validator in a +throwaway container. It touches no live Supervisor, POSTs nothing and installs +nothing. + +⚠️ **RE-RUN AGAIN ON 2026-09-05**, because the rebase onto `67e649a` merged +SPIKE-04a's `max_w: 4600` into the same §C/§D blocks this card had rewritten, +and MUTATION.md habit 8 says a committed transcript that a document quotes is a +check. A hand-merged transcript is not a measurement of anything. The block +below is the output of that run, not of the 2026-09-04 one: B's rating reads +**4600** — its datasheet figure, still not a measurement of any unit — and every +case still passes. + +``` +== C. AC2: a two-inverter blob an operator would POST == + PASS two inverters + [{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000, "bms_soc_floor": 10}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_so + +== D. what the schema itself refuses, per entry == + PASS the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere else to come from + [{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600, "bms_soc_floor": 10}] + PASS an entry with no name is refused, and the refusal NAMES it + Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller) + PASS an entry with no setpoint_entity is refused, and the refusal NAMES it + Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller) + PASS an entry with no max_w is refused, and the refusal NAMES it + Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller) + PASS an entry with no bms_soc_floor (SAFETY-32) is refused, and the refusal NAMES it + Missing option 'bms_soc_floor' in inverters in GoodWe RS485 Controller (local_goodwe_controller) + PASS an entry with max_w over the range is refused, and the refusal NAMES it + value must be at most 5000.0 + PASS an entry with bms_soc_floor over the range (SAFETY-32) is refused, and the refusal NAMES it + value must be at most 50.0 + PASS a JSON STRING where the list belongs is refused + Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller) + +== E. the ROLLBACK direction, and the operator's UI == + PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse + ['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert'] + PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer + [{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer + +== all cases passed == +``` diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index a57fb5f..79a1536 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -565,7 +565,7 @@ help - see "Why the tuning is what it is". | `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap | | `peak_cap_w` | The site's capacity-tariff target | | `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` | -| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start | +| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start. ⚠️ **At a declared `inverters:` roster this key moves INTO each entry and is REQUIRED there** (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused **by name** at startup, and the flat key below is then not read at all | | `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up | On a capacity-tariff site the maintenance charge is capped by the headroom left diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index a4bd351..c95bbed 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -300,6 +300,69 @@ Cheap habits fall out of it, and all of them are now used in this repository: assert the **shape** of that second report, not only its verdict, so the check cannot silently revert to grading the first path. +10. **A fixture of N members cannot separate two reductions that agree on N + members — so grade the FAMILY, and grade it from the SOURCE.** This is the + only lesson SAFETY-32 cost four review rounds to learn, and it generalises + past packs to every roster, list and window in this repository. Two packs + cannot tell `max` from `min` from first from last. Three cannot tell `max` + from `max(x[-2:])`. **The regress does not terminate in a fixture**: `[:1]` + needs two members, `[:2]` three, `[:3]` four, and adding one more member + moves the hole rather than closing it. Three things follow, and all three + are now in `test_peakguard.py` parts 12c2, 12g, 12i and 12j: + * the fixture is a **SET** — the extreme at every position of the roster, + not one roster with the extreme somewhere; + * the family table is **COMPUTED and PRINTED**, so a member no fixture kills + shows up as a named survivor and takes the check red, instead of being + absent from a list nobody re-derived; + * where the claim is an **IDENTITY** ("this roster", "this list"), pin it + **exactly on the AST** — `ast.unparse(arg) == "self.packs"` is false of + every slice, where `"self.packs" in src` is true of all of them. Where the + claim is **SEMANTIC** ("the maximum"), a shape pin would refuse honest + refactors, so report the unreached width **with its size** instead. + +11. **When a check of this shape is found, mutate its SIBLINGS before shipping + the fix.** SAFETY-32 was rejected three times for the same defect one step + over: the reduction, then the roster handed to it, then the reduction handed + to the *other argument of the same call*. Enumerating the family from the + AST and mutating each member took one afternoon and found **four more + survivors in code the card never touched** — three of SAFETY-30's + `any(i.sign_wd.tripped for i in self.inverters)` reductions, invisible + because the only fixture that reached them was a roster of ONE, where + `self.inverters[:1]` **is** `self.inverters`. Ask of every fix: *what is the + sibling of the thing I just hardened, and does any fixture separate it from + a slice of itself?* + +12. **When the same shape has been found twice, STOP CLOSING SITES AND BUILD + THE INSTRUMENT — and make it DISCOVER its own scope.** SAFETY-32 was + rejected four times, and each round closed exactly the site it was handed: + the reduction, then the roster handed to it, then the *sibling argument* of + the same call, then the same family one *module* over, in validate.py. + Round 4 did enumerate — and enumerated inside the `Controller` class, + because that is where the four known sites lived. **A hand-written scope is + the same defect as a hand-written fixture**: it is a check whose data cannot + reach the failure it names, and it goes on passing while the thing it was + supposed to cover sits in the file nobody added to the list. Round 5's pin + parses every `.py` under `app/`, seeds itself from ONE option key, and + propagates through returns, accumulator loops, attributes and *call + arguments* to find the roster wherever it went — 183 reductions across 9 + modules. It rediscovered all four blockers without being told where to look, + and it named two modules the hand-written list would not have contained + (`maintenance.py`, `store.py`) while *omitting* one the list did contain + (`watchdog.py`, which is strictly per-unit and reduces over no roster at + all). **The list was wrong in both directions**, which is exactly what a + computed scope is for. + +13. **Say what the instrument CANNOT see, with sizes, in the same breath.** A + structural pin grades an **identity** ("this whole roster arrives"); it can + never grade a **meaning** (`min` vs `max` is a zero-byte edit and invisible + to it). Ship the pin AND the behavioural set, name the gap, and give every + unreached case its byte delta — `cycle()`'s undeliverable-unit log line + (+4 B, still green), a subscript by a *variable* rather than a constant, a + slice taken through an intermediate call, anything outside `app/`. **An + enumerated list with an honest gap is worth more than a claim of + completeness a reviewer falsifies in ten minutes**, and every round of this + card that claimed the latter was rejected for it. + And the mutation-table consequence: **a variant is not covered because a near-miss of it is.** DEV-02's table listed the *actuator* variant of the per-unit claim defect (`device`/`supervised` filed without `inv`), which the diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index 1d0babe..1a5cf73 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -15,6 +15,11 @@ The scalars, and what each one was silently asserting: SAFETY-24's pack floor and arbitrage's bounds. An average lets the emptier pack be driven below its own floor; a maximum parks the fuller one. + `bms_soc_floor` SAFETY-32. ONE cut-out percent for every pack, and it + describes A BATTERY. Two packs of different models + have different cut-outs, so the flat key graded the + shallower pack against the deeper one's BMS - wrong + in the direction that empties a pack. `batt` ONE battery power into the anti-windup saturation detector - see the SITE/UNIT note at the bottom of this docstring, because this one goes the OTHER WAY. @@ -117,6 +122,12 @@ import math from datetime import datetime from .clamp import ClampResult +# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported +# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s +# shipped default and a literal here would be the fourth defaults site that +# ticket exists to prevent. peakguard imports arbiter/control/tariff and none +# of those imports this module, so the direction is one-way. +from .peakguard import PEAK_OPTION_DEFAULTS from .store import unit_key from .watchdog import SignWatchdog, SignWatchdogConfig @@ -170,7 +181,7 @@ class Inverter: def __init__(self, name, *, setpoint_entity="", soc_entity="", batt_entity="", batt_invert=False, max_w=2000.0, - store=None, sign_cfg=None): + bms_soc_floor=None, store=None, sign_cfg=None): self.name = str(name) self.setpoint_entity = str(setpoint_entity or "") self.soc_entity = str(soc_entity or "") @@ -179,6 +190,19 @@ class Inverter: # The supervised cap on what may be written to THIS setpoint entity. # See the module docstring on why this is a number and not a `Tuning`. self.max_w = float(max_w) + # SAFETY-32. THIS PACK'S OWN BMS CUT-OUT, in percent, or None when the + # roster entry did not state one. + # + # ⚠️ NOT DEFAULTED HERE, AND None RATHER THAN A NUMBER. A cut-out is a + # property of the battery in the cupboard; inventing one for a pack that + # did not state it is precisely this card's defect - a pack graded + # against a floor that belongs to a different battery - rebuilt in the + # constructor. `peakguard.pack_floor_pct()` treats None as an unknown + # floor and `discharge_permitted()` treats an unknown floor as NO + # permission, so the absence costs a shave; validate.py rule 8 refuses + # the config by name long before that, at startup. + self.bms_soc_floor = (None if bms_soc_floor is None + else float(bms_soc_floor)) # --- the device's own reported range, read from its own entity ------- # None until `hass.limits()` has answered. NOT guessed at: ESPHome is @@ -339,8 +363,9 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list: TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is `inverters:` empty? - EMPTY the five flat keys - `setpoint_entity`, `soc_entity`, - `batt_entity`, `batt_invert`, `max_w` - describe the ONE inverter, + EMPTY the six flat keys - `setpoint_entity`, `soc_entity`, + `batt_entity`, `batt_invert`, `max_w`, `bms_soc_floor` (SAFETY-32) + - describe the ONE inverter, exactly as the shipped scalars did. This is what every install that upgrades into this version reads, without an operator touching anything, and it is byte-identical to what DEV-02 @@ -353,8 +378,8 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list: `soc_entity` has no SoC sensor; it does NOT borrow the flat one, because "this unit's number came from somewhere else" is the defect DEV-02 spent a card removing and a fallback here would - rebuild it in three lines. `name`, `setpoint_entity` and `max_w` - are required of every entry - the schema enforces it on the + rebuild it in three lines. `name`, `setpoint_entity`, `max_w` and + `bms_soc_floor` (SAFETY-32) are required of every entry - the schema enforces it on the Supervisor path, `validate.py` rule 9 enforces it for sim/inject.py and the checks, which reach this function with any dict at all. @@ -380,6 +405,12 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list: batt_entity=opts.get("batt_entity", ""), batt_invert=bool(opts.get("batt_invert")), max_w=float(opts.get("max_w", 2000)), + # SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape + # - the one pack the flat options describe is the one pack whose + # cut-out `bms_soc_floor` states. Defaulted from CFG-03's single + # mirror exactly as validate.py rule 8 defaults it, so an install + # that never set the key reads the same 10 % in both places. + bms_soc_floor=_flat_bms_floor(opts), store=store, sign_cfg=sign_cfg)] roster = [] @@ -401,10 +432,54 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list: # ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did # not state is not the flat one, it is a rating rule 9 refuses. max_w=_entry_max_w(entry), + # ⚠️ NOT `opts["bms_soc_floor"]`, FOR `max_w`'S REASON AND THIS + # CARD'S. An entry that omits its cut-out has NOT got the flat one; + # it has none, and rule 8 refuses it by name. Borrowing here would be + # SAFETY-32's own defect written in three lines - the shallower pack + # graded against the deeper pack's BMS. + bms_soc_floor=_entry_bms_floor(entry), store=store, sign_cfg=sign_cfg)) return roster +def _flat_bms_floor(opts: dict): + """The single-inverter shape's own cut-out, or None if it is unparseable. + + Defaulted from `PEAK_OPTION_DEFAULTS` when the key is absent or blank, which + is `validate._number()`'s behaviour on the same key - the two have to agree + or the validator would grade a number the roster does not build. + """ + # ⚠️ ONE defaulting line, not two. The first cut wrote the fallback into the + # `.get()` AND into the blank test below it, and a mutation that deleted + # either one survived - each was covering for the other, which is + # MUTATION.md's masking section in three lines of configuration reading. + raw = opts.get("bms_soc_floor") + if raw is None or raw == "": + return float(PEAK_OPTION_DEFAULTS["bms_soc_floor"]) + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _entry_bms_floor(entry: dict): + """One entry's OWN cut-out, or None when it did not state a usable one. + + None rather than a borrowed number and rather than 0.0 - `_entry_max_w()` + can answer 0.0 because a zero rating commands nothing, but a zero FLOOR is + the most permissive value the key has and would read as "this pack may be + emptied". Rule 8 refuses the missing key by name; until it does, None + withholds. + """ + raw = entry.get("bms_soc_floor") + if raw is None or raw == "": + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + def _entry_max_w(entry: dict) -> float: """One entry's supervised cap, or 0.0 when it did not state a usable one. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index f102b5d..c5ff410 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1065,6 +1065,26 @@ class Controller: return None return float(min(float(v) for v in vals)) + @property + def packs(self) -> list: + """SAFETY-32. Each pack as its own THREE numbers, in roster order. + + `pack_floor_soc` above is the site's ONE verdict and stays exactly what + DEV-02 made it - the emptiest measured pack. This is the thing that + reduction cannot produce: by the time `min()` has answered, WHICH pack + the number came from and how far that pack was from ITS OWN cut-out have + both been thrown away, and two packs at 15 % against cut-outs of 10 % and + 20 % have opposite answers that are indistinguishable afterwards. + + ⚠️ EVERY unit, live or not. Same reason as `inputs_ok`: a pack whose SoC + did not answer is a pack nothing can grade, and dropping it from this + list would let the site spend the packs that DID answer while one sat + unmeasured. `peakguard.discharge_permitted()` treats a None SoC as no + permission, which is where that must be decided. + """ + return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor) + for inv in self.inverters] + @property def inputs_ok(self) -> bool: """The site meter, and EVERY pack's own two readings. @@ -1881,9 +1901,21 @@ class Controller: # protect. None stays None: `decide()` treats it as floor-limited, # which costs a shave rather than a pack. soc=self.pack_floor_soc, - soc_floor=float(self.o.get( + # ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN + # CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is + # still read from that one key with CFG-03's one mirror behind it; + # what `binding_soc_floor()` adds is the conjunction with each pack's + # OWN `bms_soc_floor`, so the floor this site is graded against can + # never be below the deepest cut-out on the roster. MAXIMUM here + # pairs with the MINIMUM in `pack_floor_soc`: both reductions move + # the verdict towards refusing a shave and away from spending a pack. + # Under any config rule 8 accepts it IS the option, for every pack - + # asserted over the whole schema range in test_peakguard.py part 12 + # rather than assumed, because "another rule refuses that" is a claim + # about rule ordering and sim/inject.py does not run rule 8 at all. + soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get( "peak_soc_floor", - peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])), + peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))), max_hold_s=self.p1.averager.max_hold_s, # ⚠️ SAFETY-20. The device range goes in TOO, and this call site is # the one that needed it most: `standing` is `last_write`, i.e. the @@ -2596,6 +2628,22 @@ class Controller: # notification that matters into a traceback. _at_floor = ("at the floor" if g.soc_floor is None else f"at {float(g.soc_floor):.0f} %") + # ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor + # the decision was actually taken with, so this re-grades nothing and + # cannot disagree with the alarm it is printed inside. At a roster of one + # the sentence is omitted entirely and this notification is byte-for-byte + # what SAFETY-25 shipped - there is exactly one pack and naming it would + # be noise on every existing install. + _blocked = [name for name, why + in peakguard.pack_permissions(self.packs, g.soc_floor) + if why is not None] + _which = ("" if len(self.inverters) < 2 else + f"\n\nSAFETY-32 - WHICH PACK: this is graded per pack against " + f"that pack's own BMS cut-out (bms_soc_floor), and the " + f"pack(s) at or below their own floor right now are " + f"{', '.join(_blocked) if _blocked else '(none - see the reason above)'}" + f". The other packs on the roster are not the reason the guard " + f"stopped and raising their cut-out will not change this.") await self.hass.call("persistent_notification", "create", { "notification_id": "goodwe_peak_soc_floor", "title": "GoodWe: the capacity-peak guard has run out of battery", @@ -2615,7 +2663,7 @@ class Controller: f"{_at_floor} - ordinary self-consumption has no SoC " f"floor at all and will go on discharging past it, down to the " f"pack's own BMS cut-out. What has stopped is the guard buying " - f"you a lower bill, not the pack being spent."), + f"you a lower bill, not the pack being spent." + _which), }) async def check_circulating(self) -> None: diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index e8b75ed..524c4df 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -360,6 +360,29 @@ distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears - a loop-side guard would be the third - at which point the migration buys enough to be worth its cost. +------------------------------------------------------------------------------ +SAFETY-32: ONE `bms_soc_floor` FOR TWO PACKS GRADES ONE AGAINST THE OTHER'S BMS + +`bms_soc_floor` above describes A PACK, not a site, and it was a flat option +because there was one pack. At two, two batteries of different models have +different cut-outs, and one number for both is wrong in the direction that +empties a battery: the SHALLOWER pack (the higher cut-out) is protected by a +floor that belongs to the DEEPER one. So it became a roster key - `Pack` carries +each unit's own value, `pack_floor_pct()` conjoins it with the site's EMS floor, +and validate.py rule 8 grades EVERY unit and NAMES the one it refuses. + +⚠️ `peak_soc_floor` DID NOT MOVE, AND THE RESIDUAL IS RECORDED RATHER THAN +HIDDEN. It is one number for the site, so with cut-outs of 10 % and 18 % the +operator must set it above 18 and the deeper pack is then withheld above its own +need. That half - a per-pack EMS floor - is NOT this card and must not be +smuggled in with it; DECISIONS/SAFETY-32-per-pack-bms-floor.md states it as the +open question it is. + +⚠️ AND THE MIN() OVER MEASURED SoC IS STILL RIGHT. `Controller.pack_floor_soc` +reduces to the emptiest pack and this card does not touch it. What changed is +the FLOOR it is graded against, and the addition of a per-pack answer beside the +site's one verdict - see `pack_permissions()`. + ------------------------------------------------------------------------------ AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE @@ -572,6 +595,110 @@ def discharge_permitted(soc, soc_floor) -> str | None: return None +@dataclass(frozen=True) +class Pack: + """SAFETY-32. ONE pack's three numbers for the floor question, NAMED. + + `name` the roster name, because a refusal that does not say WHICH pack + stopped the site sends an operator to the wrong device - the + same defect CFG-04's `unit_caps_indexed()` was written for. + `soc` THIS pack's measured state of charge. None when its sensor did + not answer, and None is not a full battery. + `bms_floor` THIS pack's OWN cut-out percent (`bms_soc_floor`), which is a + property of the hardware in the cupboard and NOT of the site. + None when the roster entry did not state one - see + `pack_floor_pct()` for why that is withheld and not 0 %. + + ⚠️ IT CARRIES THE CUT-OUT, NOT THE EMS FLOOR. `peak_soc_floor` is one number + for the whole site (the depth a money-saving discharge may never reach) and + stays a flat option; `bms_soc_floor` describes a battery and became a roster + key here, because two packs of different models have different cut-outs and + grading both against one of them is wrong in the direction that empties the + shallower one. + """ + + name: str + soc: float | None + bms_floor: float | None + + +def pack_floor_pct(pack, soc_floor): + """The percent THIS pack may not be spent below, or None if that is unknown. + + `max(the site's EMS floor, this pack's own cut-out)` - the CONJUNCTION, and + the direction matters: the EMS floor is what the operator asked for and the + cut-out is what the hardware will do anyway, so a pack is protected by + whichever is higher. Taking the site floor alone is the shipped defect this + card removes (the shallower pack graded against the deeper one's cut-out); + taking the cut-out alone would throw SAFETY-24's margin away. + + ⚠️ AN UNSTATED CUT-OUT IS None, NOT ZERO, AND NOT THE FLAT OPTION. 0 % would + be a floor that permits everything - the most permissive value there is, + invented from an absence - and borrowing the flat `bms_soc_floor` is CFG-04's + "nothing is inherited" broken in one line, which is this card's whole subject + restated. `discharge_permitted()` already treats a None floor as NO + permission, so an entry that forgot the key costs a shave rather than a pack. + + ⚠️ AND UNDER A rule-8-VALID CONFIG THIS IS `soc_floor`, FOR EVERY PACK. + validate.py rule 8 refuses `peak_soc_floor <= bms_soc_floor` on EVERY unit, + so `max()` selects the EMS floor everywhere the add-on will actually start - + which is exactly why the reduction is written here rather than assumed at the + call site. "Another rule refuses that config" is a claim about today's rule + ordering, not a property of this function, and `validate_startup_config()` is + also `sim/inject.py`'s entry point. `unit_caps_indexed()`'s docstring records + the same argument, and CFG-04's index desynchronisation is what happened the + last time it was left implicit. + """ + if soc_floor is None or pack.bms_floor is None: + return None + return max(float(soc_floor), float(pack.bms_floor)) + + +def pack_permissions(packs, soc_floor) -> list[tuple[str, str | None]]: + """SAFETY-32. Per pack: None if it may be spent, else the reason it may not. + + ONE `(name, reason)` pair per pack, in roster order, from the SAME + `discharge_permitted()` the site verdict uses - a second transcription of + "may this pack be spent" is a second answer to it, which is the argument + that made that predicate public in the first place. + + ⚠️ THIS IS THE OBSERVABLE THE MIN() CANNOT PRODUCE. `Controller.pack_floor_soc` + reduces the measured SoC to the emptiest pack BEFORE anything is graded, and + that reduction is right for the site's one verdict - but it has thrown away + which pack it came from and how far that pack was from ITS OWN floor by the + time the answer exists. Two packs at 15 % against floors of 10 % and 20 % + have opposite answers and are indistinguishable after the `min()`. + """ + return [(p.name, discharge_permitted(p.soc, pack_floor_pct(p, soc_floor))) + for p in packs] + + +def binding_soc_floor(packs, soc_floor): + """The ONE floor `decide()` may grade `pack_floor_soc` against, at a roster. + + MAXIMUM over the packs' own floors, and it is the conservative reduction that + pairs with the MINIMUM `pack_floor_soc` reduction: `min(soc) <= max(floor)` + is implied by "some pack is at or below its own floor", so this verdict is + never more permissive than grading each pack separately would be. The other + direction is not true and is not claimed - a site can be refused here while + every pack individually has headroom, which costs a shave and never a pack. + + None when ANY pack's floor is unknown, for `Controller.site_dev_bound()`'s + reason and in the same direction: `discharge_permitted()` treats a None floor + as no permission, so an unknown floor withholds rather than defaults. + + ⚠️ AT N=1 THIS IS `soc_floor` ITSELF under any config rule 8 accepts, and + test_peakguard.py part 12 asserts that over the whole schema range rather + than assuming it. + """ + if not packs: + return None if soc_floor is None else float(soc_floor) + floors = [pack_floor_pct(p, soc_floor) for p in packs] + if any(f is None for f in floors): + return None + return max(floors) + + def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision: """The whole guard. Pure: no clock, no I/O, no state carried between calls. diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index 6504c9e..7ce3720 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -132,7 +132,7 @@ NOT READY banner already names the problem, (b) does not apply. installed base is on a fixed or static tariff. Refusing to start without a price feed would lock those sites out of the add-on entirely. - 8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)] + 8. `peak_soc_floor` at or below `bms_soc_floor`, ON ANY UNIT. [(a)] SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor` is the EMS-side bound on how far down it may take the pack. The BMS has its @@ -142,6 +142,12 @@ NOT READY banner already names the problem, (b) does not apply. of binding while its status page reports one - rule 5's "a disarmed guard that advertises itself as armed", on the guard whose failure mode is a pack that is quietly never available when it is wanted. + ⚠️ SAFETY-32: `bms_soc_floor` DESCRIBES A BATTERY, so at a roster it is a + per-entry key and this rule grades EVERY unit and NAMES the one it refuses. + One number for two packs of different models refuses the wrong config and + accepts the wrong one - cut-outs of 10 % and 30 % under `peak_soc_floor: 20` + used to start. A missing per-entry cut-out is refused here rather than + defaulted: a cut-out invented for a pack IS this defect. ⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly equal, the EMS floor and the pack's cut-out act at the same percent, so the @@ -386,6 +392,49 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]: return caps +def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]: + """SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX. + + `(roster index, the raw value as configured, the parsed float or None)`, one + triple per unit. The raw value travels too because the refusal quotes what + the operator actually wrote, and `!r` of a re-read `opts` key is the wrong + string the moment the value came off a roster entry. + + ⚠️ THE INDEX IS THE ROSTER'S, NOT THIS LIST'S, AND THAT IS CFG-04'S BUG + WRITTEN DOWN. Rules 5 and 6 fed a CAPS-list index into `_unit_label()`, + which indexes the ROSTER, so on a roster whose first entry was malformed the + refusal named the WRONG INVERTER - an operator sent to the wrong device by + the very message that exists to stop that. `unit_caps_indexed()` exists + because of it. This function goes one step further and SKIPS NOTHING: every + entry produces a triple, including the ones with no usable floor, because a + missing cut-out is itself what rule 8 has to refuse BY NAME. There is + therefore no second list for an index to desynchronise from. + + ⚠️ AND NOTHING IS INHERITED. At a declared roster the FLAT `bms_soc_floor` is + not read at all - CFG-04's rule, and this card's subject: a pack that borrows + a sibling's cut-out is graded against a battery it is not. At the flat shape + the flat key IS this one pack's cut-out, defaulted from CFG-03's single + mirror exactly as `inverter._flat_bms_floor()` defaults it. + """ + entries = roster_entries(opts) + if not entries: + return [(0, opts.get("bms_soc_floor"), + _number(opts, "bms_soc_floor", + PEAK_OPTION_DEFAULTS["bms_soc_floor"]))] + out: list[tuple[int, object, object]] = [] + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + # Rule 9(a)/(b) already refuses a non-dict entry by index, and it + # runs FIRST. Naming it twice would hand the operator two sentences + # about one line of YAML. + continue + raw = entry.get("bms_soc_floor") + out.append((index, raw, + None if raw is None or raw == "" + else _number(entry, "bms_soc_floor", None))) + return out + + def _unit_label(opts: dict, index: int) -> str: """"" at a single-inverter config, " (inverter-b)" at a roster. @@ -757,21 +806,48 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str] # second inverter is added - which is the day nobody is watching the startup # log. This is `meter_max_age_s`'s rule read the same way: the failure it # prevents is silent. + # + # ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32 + # ROUND 5. This branch used to compare against `max_w = _number(opts, + # "max_w", 2000.0)`, the FLAT option, which on a roster config is either + # ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the + # site) or a leftover from a single-inverter install (so it compared against + # a number no unit has). Both directions were live and both were measured: + # + # a roster of two 2000 W units beside a leftover `max_w: 5000` and + # `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED, + # with a per-inverter watchdog reporting `armed` on the status page and + # incapable of tripping on EITHER unit. That is the identical sentence the + # branch above exists to refuse, reached through the branch beside it; + # + # and a roster of two 5000 W units with no flat key was REFUSED at + # `unit_sign_disagree_w: 3000` against a 2000 W default the operator never + # wrote, for a threshold both units can reach. + # + # `unit_caps()`'s own docstring already said why: there is no such thing as + # a site rating on the wire, and this watchdog grades ONE UNIT's standing + # command against THAT unit's `supervised` bound. So it takes the same + # `weakest` the branch above takes, from the same list, and names the same + # unit. At a roster of one `unit_caps()` is `[max_w]` and this is the + # identical comparison it always was - which is what keeps the shipped + # single-inverter refusal byte-for-byte what it was. unit_sign_w = _number(opts, "unit_sign_disagree_w", float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])) + unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0]) + if caps_ix else "") if unit_sign_w is None: problems.append(f"unit_sign_disagree_w " f"{opts.get('unit_sign_disagree_w')!r} is not a number") - elif max_w is not None and max_w > 0 and unit_sign_w >= max_w: + elif weakest is not None and weakest > 0 and unit_sign_w >= weakest: problems.append( - f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is " - f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement " + f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}" + f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement " "watchdog can never fire: no inverter is ever commanded past max_w, " f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two " "inverters that is the guard which catches a unit doing the " "OPPOSITE of what it was told while its sibling covers for it at " "the meter - the case the site-level watchdog is structurally " - f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W " + f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W " f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the " "measured default), or raise max_w.") @@ -871,28 +947,39 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str] # in-code mirror, pinned to config.yaml by test_peakguard.py, and main.py # reads the same one - exactly as maintenance.MAINT_OPTION_DEFAULTS now is # for the `maintenance_*` family. + # + # ⚠️ SAFETY-32 - GRADED ONCE PER UNIT, AND THE REFUSAL NAMES THE UNIT. One + # `bms_soc_floor` for two packs compares the EMS floor against whichever + # battery happened to be written into the flat key: a site with cut-outs of + # 10 % and 30 % and `peak_soc_floor: 20` STARTED, and the guard would then + # command a forced discharge that the second pack's BMS - not the operator's + # floor - is the thing that stops. That is the disarmed-backstop state this + # rule exists to refuse, reached through the rule itself. So the loop is over + # units, and `_unit_label()` is fed the ROSTER index the value came off (see + # `unit_bms_floors_indexed()`), because CFG-04 has just fixed the version of + # this that named the wrong inverter. soc_floor = _number(opts, "peak_soc_floor", PEAK_OPTION_DEFAULTS["peak_soc_floor"]) - bms_floor = _number(opts, "bms_soc_floor", - PEAK_OPTION_DEFAULTS["bms_soc_floor"]) - if soc_floor is None or bms_floor is None: - problems.append( - "peak_soc_floor and bms_soc_floor must both be numbers (got " - f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})") - elif soc_floor <= bms_floor: - problems.append( - f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out " - f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced " - "discharge has no EMS-side bound at all - THE BMS WINS, and it is " - "the pack's protection rather than yours. The guard would report a " - "SoC floor on its status page while being incapable of ever being " - "the thing that stops the discharge, and the symptom of that is a " - "battery repeatedly sitting at the BMS limit with no record of why " - f"- which looks exactly like the SoC drift the maintenance cycle " - f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % " - f"(the shipped default is " - f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct " - "bms_soc_floor if it does not match this pack.") + for _ix, _raw, bms_floor in unit_bms_floors_indexed(opts): + where = _unit_label(opts, _ix) + if soc_floor is None or bms_floor is None: + problems.append( + f"peak_soc_floor and bms_soc_floor{where} must both be numbers " + f"(got {opts.get('peak_soc_floor')!r} / {_raw!r})") + elif soc_floor <= bms_floor: + problems.append( + f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out " + f"(bms_soc_floor{where}) is {bms_floor:.0f} %, so SAFETY-07's forced " + "discharge has no EMS-side bound at all - THE BMS WINS, and it is " + "the pack's protection rather than yours. The guard would report a " + "SoC floor on its status page while being incapable of ever being " + "the thing that stops the discharge, and the symptom of that is a " + "battery repeatedly sitting at the BMS limit with no record of why " + f"- which looks exactly like the SoC drift the maintenance cycle " + f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % " + f"(the shipped default is " + f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct " + f"bms_soc_floor{where} if it does not match this pack.") # --- warnings: real, but not worth refusing over ------------------------- integ = opts.get("integrator_max_w") diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 0e99f52..bf54354 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -56,11 +56,13 @@ options: # soc_entity: sensor.goodwe_a_soc # batt_entity: sensor.goodwe_a_battery_power # max_w: 5000 + # bms_soc_floor: 10 # - name: inverter-b # setpoint_entity: number.goodwe_b_setpoint_w # soc_entity: sensor.goodwe_b_soc # batt_entity: sensor.goodwe_b_battery_power # max_w: 4600 + # bms_soc_floor: 10 # # ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A # PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES, @@ -74,17 +76,38 @@ options: # what settles that once the device range is read; see goodwe/NOTES.md §1.1b, # which records both readings and picks neither. # + # ⚠️ AND THE `bms_soc_floor: 10` BESIDE IT IS FROM THE SAME PLACE - THE + # DATASHEET AND THE SERIAL, NOT A PACK ANYONE HAS READ. No second inverter + # exists on this site (SPIKE-04, ≈ 2026-09-12 for B); the cut-out must be read + # off the pack that is actually installed (register 0x0560 on the reference + # unit) before that pack is commanded. It reads 10 here because pack A reads + # 10, which makes this example DEGENERATE ON THE FLOOR AXIS: a site whose two + # packs floor at the SAME percent cannot separate `min`, `max`, first or last + # over the roster, and every SAFETY-32 fixture that DIFFERS the floors differs + # it for exactly that reason. Do not "simplify" those fixtures to match this + # example. + # # ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and # that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the # rating became per-instance precisely so that no inverter is ever graded # against a sibling's numbers; an entry silently inheriting `soc_entity` would - # rebuild that defect in YAML. `name`, `setpoint_entity` and `max_w` are - # therefore REQUIRED of every entry (the schema below enforces it, and + # rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and + # `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and # validate.py rule 9 enforces it again for the callers the Supervisor never # sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH # SENSOR - `inputs_ok` already stops the site for that - and never means # "use the other one's". # + # ⚠️ SAFETY-32: `bms_soc_floor` IS PER ENTRY AND IS REQUIRED, NOT DEFAULTED. + # It is the percent THAT pack's own BMS cuts out at, and two batteries of + # different models do not share one. A default would be this add-on inventing + # a cut-out for a battery it has not been told about, which is the same defect + # as inheriting one: the shallower pack gets protected by a floor that belongs + # to the deeper pack, and validate.py rule 8 - which refuses a peak_soc_floor + # the BMS would win against - would be grading the wrong number. Rule 8 grades + # EVERY entry and names the one it refuses. The flat key below is unchanged and + # is the single-inverter shape's own cut-out. + # # ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a # roster is declared is two config sites aimed at actuators, one of which # nothing writes; if it names an entity an entry also names, SAFETY-28's @@ -367,6 +390,13 @@ options: # DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below # it: such a floor can never bind, the BMS wins, and the status page would # report an energy backstop that does not exist. + # + # ⚠️ SAFETY-32 - THIS KEY DESCRIBES ONE PACK, so it is the SINGLE-INVERTER + # shape's number and nothing more. Declare an `inverters:` roster and each + # entry states its own `bms_soc_floor`; this one is not read at all and is + # not inherited by any entry. Rule 8 then grades every unit separately, which + # is the whole of that card: one cut-out for two packs of different models + # protects the shallower pack with the deeper pack's number. bms_soc_floor: 10 # ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the @@ -512,6 +542,7 @@ schema: - name: str setpoint_entity: str max_w: int(100,5000) + bms_soc_floor: int(0,50) soc_entity: str? batt_entity: str? batt_invert: bool? diff --git a/goodwe_controller/test_inverter.py b/goodwe_controller/test_inverter.py index 094f8b9..1330436 100644 --- a/goodwe_controller/test_inverter.py +++ b/goodwe_controller/test_inverter.py @@ -203,6 +203,16 @@ A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a", # realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a # placeholder for it either - the placeholder era ended on 2026-09-04. B_RATING_HYPOTHESIS = 3000.0 +# SAFETY-32. Each pack's OWN BMS cut-out percent, now a REQUIRED roster key. +# ⚠️ 10 IS MEASURED (register 0x0560 on the reference GW5000S-BP and the shipped +# `bms_soc_floor` default); 15 IS SIMULATED - there is no second pack. They +# DIFFER on purpose: a fixture whose two per-unit numbers are equal cannot show +# a check that read the wrong unit's, which is MUTATION.md habit 1 and the defect +# that has now bitten four consecutive cards. Both sit below the shipped +# `peak_soc_floor` of 20, so rule 8 accepts this roster. +A_BMS_FLOOR = 10 +B_BMS_FLOOR_SIM = 15 + A_RATING = 5000.0 BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid", @@ -1418,13 +1428,16 @@ _TWO_OPTS.pop("batt_entity") _TWO_OPTS["inverters"] = [ dict(name="inverter-a", setpoint_entity="number.sp_a", soc_entity="sensor.soc_a", batt_entity="sensor.batt_a", - max_w=A_RATING), + max_w=A_RATING, bms_soc_floor=A_BMS_FLOOR), # ⚠️ 3000 W IS A HYPOTHESIS AND IS MARKED AS ONE WHEREVER IT APPEARS. It is # here because it DIFFERS from A's 5000 W, which is the only property any # check below uses. SPIKE-04 measures the real device. + # ⚠️ AND SO IS ITS 15 % CUT-OUT (SAFETY-32). There is no second pack to read + # register 0x0560 off; the value is SIMULATED, and it differs from A's 10 % + # for MUTATION.md habit 1's reason and no other. dict(name="inverter-b", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b", batt_entity="sensor.batt_b", - max_w=B_RATING_HYPOTHESIS), + max_w=B_RATING_HYPOTHESIS, bms_soc_floor=B_BMS_FLOOR_SIM), ] _c9, _hass9 = built(_TWO_OPTS) print(f" the roster built from `inverters:` -> {[repr(i) for i in _c9.inverters]}") @@ -1500,14 +1513,19 @@ _decl9 = dict(ln.strip().lstrip("- ").split(": ", 1) for ln in _entry9.splitlines()[1:] if ": " in ln) print(f" the shipped per-entry schema: {_decl9}") check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, " - "`setpoint_entity` and `max_w` are REQUIRED - no trailing `?` - so the " - "Supervisor refuses a half-written entry before the add-on sees it, and " - "an entry can never fall back on a sibling's rating because there is no " - "entry without one", - set(_decl9) == {"name", "setpoint_entity", "max_w", "soc_entity", - "batt_entity", "batt_invert"} + "`setpoint_entity`, `max_w` and `bms_soc_floor` are REQUIRED - no " + "trailing `?` - so the Supervisor refuses a half-written entry before the " + "add-on sees it, and an entry can never fall back on a sibling's rating " + "or on a sibling's BMS cut-out because there is no entry without one. " + "⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS " + "`int(0,50)?`, and the difference is the whole of that card: an optional " + "per-pack cut-out is a DEFAULTED one, and a default is one number for " + "two batteries - the shallower pack protected by the deeper pack's BMS", + set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor", + "soc_entity", "batt_entity", "batt_invert"} and not any(_decl9[k].endswith("?") - for k in ("name", "setpoint_entity", "max_w")) + for k in ("name", "setpoint_entity", "max_w", + "bms_soc_floor")) and all(_decl9[k].endswith("?") for k in ("soc_entity", "batt_entity", "batt_invert"))) @@ -1589,9 +1607,11 @@ check("...and two inverters on two DIFFERENT entities are accepted, because " "that is the configuration this card exists for and a validator that " "fires on the legitimate case gets switched off", validate_startup_config(_opts(setpoint_entity="", inverters=[ - dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING), + dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING, + bms_soc_floor=A_BMS_FLOOR), dict(name="inverter-b", setpoint_entity="number.sp_b", - max_w=B_RATING_HYPOTHESIS)])) == []) + max_w=B_RATING_HYPOTHESIS, + bms_soc_floor=B_BMS_FLOOR_SIM)])) == []) # --- the rest of rule 9 ------------------------------------------------------- _p_flat = validate_startup_config(_opts(inverters=[ diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index ea80156..2577647 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -127,7 +127,7 @@ from app.arbiter import ( from app.control import Tuning, compute, peak_at_risk from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w from app import main as app_main -from app.inverter import Inverter +from app.inverter import Inverter, build_roster from app.main import Controller from app.p1 import ( PLAUSIBLE_MAX_W, P1Ingest, QUARTER_S, QuarterAverager, make_sample, @@ -136,8 +136,9 @@ from app.p1 import ( from app.peakguard import ( BLIND_COVERAGE, BLIND_FROZEN, BLIND_NO_BLOCK, CEILING_CAP, CEILING_MONTH_PEAK, FLOOR_AT_SOC_FLOOR, FLOOR_SOC_UNREADABLE, - PEAK_OPTION_DEFAULTS, PeakDecision, blindness, decide, discharge_permitted, - excess_w, quarter_ceiling_w, uncovered_s, + PEAK_OPTION_DEFAULTS, Pack, PeakDecision, binding_soc_floor, blindness, + decide, discharge_permitted, excess_w, pack_floor_pct, pack_permissions, + quarter_ceiling_w, uncovered_s, ) from app.validate import validate_startup_config from app.tariff import ( @@ -1062,9 +1063,15 @@ check("...and the status page says NOT COVERED rather than 'idle' or a green " # Controller rather than left to flake. It is bounded at three attempts: a fourth # would mean something is wrong that is not a boundary. def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0, - standing_w=None, soc=55.0): + standing_w=None, soc=55.0, prepare=None): """A real Controller driven one real cycle over a real over-limit quarter. + `prepare(ctl)` runs after the Controller is built and before the telegrams + are fed, which is the only window in which a SECOND inverter can be put on + the roster and given its own readings. It is inside the retry loop on + purpose: each attempt builds a fresh Controller, so a hook that mutated only + the first one would leave attempts 2-4 grading a different site. + ⚠️ THE LOAD IS DERIVED FROM WHERE IN THE QUARTER THE WALL CLOCK LANDS, and it has to be. `excess = 900 × (projected − ceiling) ÷ remaining`, so a fixed 5200 W fixture asks for 1700 W with ten minutes left and 300 kW with five @@ -1078,6 +1085,8 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0, store=_Store(store) if store is not None else None) ctl.auto, ctl.soc, ctl.batt = True, soc, batt_w ctl.dev_min, ctl.dev_max = -LIVE_MAX_W, LIVE_MAX_W + if prepare is not None: + prepare(ctl) if standing_w is not None: # The command STANDING ON THE WIRE, which is what B3 below needs to # differ from the measured battery power. @@ -3160,6 +3169,2016 @@ check("...and the roster it was restored to is the shipped one, with a healthy " c_src.units == (app_main.UNIT_A,) and c_src.live_units() == (app_main.UNIT_A,)) +# ============================================================================== +print() +print("12. SAFETY-32 - one bms_soc_floor for two packs grades one against the") +print(" other's BMS. Per-entry cut-out, per-pack permission, rule 8 per unit") + +# ⚠️ THE TWO FLOORS DIFFER, AND THAT IS THE FIRST CHECK RATHER THAN AN +# ASSUMPTION. This card's acceptance criteria say it in words because the repo +# has now shipped SIX checks whose fixture could not reach the failure it named, +# two of them - DEV-02's `_b2.max_w` and CFG-04's blank `soc_entity` - being +# EXACTLY this shape: a per-unit claim compared against a number the fixture +# happened to have twice. MUTATION.md habit 1: write the precondition as its own +# check, so a later edit that makes them equal goes RED instead of making every +# claim below quietly vacuous. +# +# ⚠️ AND ONLY ONE OF THESE NUMBERS IS MEASURED. `A_PACK_FLOOR` is config.yaml's +# shipped `bms_soc_floor` - register 0x0560 on the reference GW5000S-BP. The two +# B values are SIMULATED: there is no second pack on this site (~8 days out) and +# nothing here is a measurement of one. They are chosen for what they have to +# straddle, and that is stated on each. +A_PACK_FLOOR = BMS_FLOOR +# BELOW peak_soc_floor, so a roster carrying it is one rule 8 ACCEPTS. +B_PACK_FLOOR_SIM = SOC_FLOOR - 2.0 +# ABOVE peak_soc_floor: the shallower pack, whose own BMS would win. This is the +# configuration the flat key used to accept and this card refuses. +B_PACK_FLOOR_DEEP_SIM = SOC_FLOOR + 15.0 +# A state of charge ABOVE the site floor and BELOW the shallow pack's cut-out - +# the ONE band in which the two packs disagree. Outside it they answer the same +# and the fixture would observe nothing. +SOC_BETWEEN = SOC_FLOOR + 5.0 +print(f" floors: A {A_PACK_FLOOR:.0f} % (measured), B {B_PACK_FLOOR_SIM:.0f} % " + f"/ {B_PACK_FLOOR_DEEP_SIM:.0f} % (SIMULATED - inverter B does not exist), " + f"peak_soc_floor {SOC_FLOOR:.0f} %, probe SoC {SOC_BETWEEN:.0f} %") +check("PRECONDITION (MUTATION.md habit 1): the two per-pack floors are " + "DIFFERENT numbers and they straddle peak_soc_floor, and the probe SoC " + "sits strictly between them. Two equal floors cannot show a pack graded " + "against the wrong one, which is the defect this card is about and the " + "defect six checks in this repository have already shipped", + A_PACK_FLOOR != B_PACK_FLOOR_DEEP_SIM + and A_PACK_FLOOR < SOC_FLOOR < B_PACK_FLOOR_DEEP_SIM + and A_PACK_FLOOR < SOC_BETWEEN < B_PACK_FLOOR_DEEP_SIM + and B_PACK_FLOOR_SIM != A_PACK_FLOOR and B_PACK_FLOOR_SIM < SOC_FLOOR) + +# --- 12a. AC 1: the permission is answered PER PACK -------------------------- +# ⚠️ BOTH PACKS ARE AT THE SAME STATE OF CHARGE. That is the whole design of +# this fixture: SoC is held EQUAL so it cannot be what separates the two +# answers, and the only thing left that can is each pack's own floor. The +# shipped `min()` over SoC - which this card does NOT change - reduces these two +# readings to one number before anything is graded, so after it the two packs +# are literally indistinguishable. +_pk_a = Pack("inverter-a", SOC_BETWEEN, A_PACK_FLOOR) +_pk_b = Pack("inverter-b", SOC_BETWEEN, B_PACK_FLOOR_DEEP_SIM) +_perm = pack_permissions([_pk_a, _pk_b], SOC_FLOOR) +print(f" per-pack at the SAME {SOC_BETWEEN:.0f} %: " + + "; ".join(f"{n}: {'PERMITTED' if w is None else w}" for n, w in _perm)) +check("⚠️ AC 1. Two packs AT THE SAME STATE OF CHARGE, graded against their OWN " + "cut-outs, get OPPOSITE answers: the deeper pack is still permitted and " + "the shallower one is WITHHELD. The SoC is equal on purpose - it is the " + "floors that differ, so this line can only be green if each pack was " + "graded against its own number", + _perm == [("inverter-a", None), + ("inverter-b", FLOOR_AT_SOC_FLOOR)]) +check("...and the refusal is asserted by its CONTENT, not by the fact that one " + "happened (MUTATION.md habit 6): `not None` is satisfied by an unreadable " + "SoC, by an unconfigured floor and by the at-floor case alike, and those " + "are three different operator actions", + _perm[1][1] == FLOOR_AT_SOC_FLOOR + and _perm[1][1] != FLOOR_SOC_UNREADABLE) + +# ⚠️ THE COUNTERFACTUAL, AND IT VARIES EXACTLY ONE THING (habit 7). Same two +# packs, same SoC, same site floor - only B's cut-out moves to A's. Both are +# then permitted. Without this line, 12a would also pass for an implementation +# that refused the second entry always, or the named one, or every roster of two. +_perm_same = pack_permissions( + [_pk_a, Pack("inverter-b", SOC_BETWEEN, A_PACK_FLOOR)], SOC_FLOOR) +print(f" ...and with B's cut-out set to A's: " + + "; ".join(f"{n}: {'PERMITTED' if w is None else 'withheld'}" + for n, w in _perm_same)) +check("...and the counterfactual: give B the SAME cut-out as A and BOTH are " + "permitted. One thing varied, from an accepted baseline - so 12a's " + "difference came from the floor and not from the position, the name, or " + "the roster being two long", + [w for _, w in _perm_same] == [None, None]) + +# ⚠️ AND THE OTHER AXIS, because a per-pack grader could still read pack[0]'s +# SoC for every pack. Same floors, DIFFERENT SoC: the emptier pack is withheld. +_perm_soc = pack_permissions( + [Pack("inverter-a", SOC_FLOOR + 10.0, A_PACK_FLOOR), + Pack("inverter-b", SOC_FLOOR - 1.0, A_PACK_FLOOR)], SOC_FLOOR) +check("...and with the floors EQUAL and the two SoC readings different, it is " + "the emptier pack that is withheld - so the grader reads each pack's own " + "SoC as well as its own floor. Two mutations, two axes, and a fixture " + "that is degenerate on one of them cannot see the other", + [w for _, w in _perm_soc] == [None, FLOOR_AT_SOC_FLOOR]) + +# --- 12b. an unstated cut-out is WITHHELD, not defaulted --------------------- +# ⚠️ AT 100 % STATE OF CHARGE, which is the strongest form of the claim: a full +# battery is the one value that disarms every other rung in this file, so if the +# missing floor is what refuses THIS pack, nothing else can be doing it. +_perm_none = pack_permissions([Pack("inverter-b", 100.0, None)], SOC_FLOOR) +print(f" a pack with NO stated cut-out, at 100 %: {_perm_none[0][1]}") +check("a roster entry that states no `bms_soc_floor` is WITHHELD even at a full " + "battery - not defaulted to the flat key, not defaulted to 0 %, and not " + "quietly permitted. An invented cut-out IS this card's defect, and 0 % is " + "the most permissive value the key has", + _perm_none[0][1] is not None + and pack_floor_pct(Pack("b", 100.0, None), SOC_FLOOR) is None + and binding_soc_floor([_pk_a, Pack("b", 100.0, None)], SOC_FLOOR) is None) + +# --- 12c. AC 5: at a roster of ONE, the floor is the option, unchanged ------- +# ⚠️ SWEPT OVER THE WHOLE SCHEMA, NOT ASSERTED ON THE SHIPPED PAIR. "N=1 is +# byte-identical" is a claim about every configuration the schema accepts, and +# the shipped pair is one point in it. Every rule-8-VALID (peak, bms) pair is +# tried; the reduction must answer the option itself in all of them, or some +# install's floor moved under it. +_SW_LO, _SW_HI = int(read_schema_min("peak_soc_floor")), int(read_schema_max("peak_soc_floor")) +_BMS_HI = int(read_schema_max("bms_soc_floor")) +_n1_pairs = [(pk, bm) for pk in range(_SW_LO, _SW_HI + 1) + for bm in range(0, _BMS_HI + 1) if bm < pk] +_n1_bad = [(pk, bm) for pk, bm in _n1_pairs + if binding_soc_floor([Pack("inverter-a", 50.0, float(bm))], + float(pk)) != float(pk)] +print(f" N=1 sweep: {len(_n1_pairs)} rule-8-valid (peak_soc_floor, " + f"bms_soc_floor) pairs over the shipped schema, {len(_n1_bad)} disagree") +check("⚠️ AC 5, SWEPT. At a roster of ONE the floor the guard grades against IS " + "`peak_soc_floor`, for every pair of values the schema accepts that rule " + "8 would also accept. The reduction is new code on the path of every " + "existing install, so 'nothing changes at N=1' is measured over the whole " + "option space rather than at the shipped point", + len(_n1_pairs) > 500 and _n1_bad == []) +# ...and the case rule 8 refuses is the case the reduction is FOR, so it must +# NOT answer the option there - otherwise the max() above is dead in both +# directions and the mutation that deletes it can never bite. +_n1_deep = binding_soc_floor( + [Pack("inverter-a", 50.0, B_PACK_FLOOR_DEEP_SIM)], SOC_FLOOR) +check("...and on the pair rule 8 REFUSES - a cut-out above the EMS floor - the " + "reduction answers the CUT-OUT and not the option, which is the only " + "direction in which it does anything. Without this line the sweep above " + "would be satisfied by `soc_floor` typed straight through", + _n1_deep == B_PACK_FLOOR_DEEP_SIM and _n1_deep != SOC_FLOOR) + +# --- 12c2. THE REDUCTION ITSELF, ON A ROSTER WHOSE FLOORS ALL DIFFER --------- +# ⚠️ THIS IS THE CHECK PR #48 DID NOT HAVE, AND ITS ABSENCE IS THIS REPOSITORY'S +# RECURRING DEFECT LANDING ON THIS CARD. Every other fixture above grades +# `binding_soc_floor()` on a roster of ONE (12c) or on a config rule 8 accepts, +# where `pack_floor_pct()` answers `peak_soc_floor` for EVERY pack - so `floors` +# is a list of IDENTICAL numbers and `max`, `min`, `floors[0]` and `floors[-1]` +# are four spellings of one answer. A reviewer changed `max(floors)` to +# `min(floors)` and all 19 files stayed GREEN. The data could not reach the +# failure the check names. +# +# ⚠️ AND THE DEGENERACY IS NOT A DEFENCE, BY THIS CARD'S OWN ARGUMENT. +# `pack_floor_pct()`'s docstring, main.py's call-site comment and DECISIONS §4 +# all say "unreachable is a claim about today's rule ordering, not a property of +# this function, and `validate_startup_config()` is also `sim/inject.py`'s entry +# point". That argument justifies WRITING the reduction; it equally OBLIGES +# grading it. Via `sim/inject.py`, which does not run rule 8 at all, `min` here +# lets the guard command a forced discharge down to 20 % on a pack whose BMS +# cuts out at 35 %. So the reduction is graded on a roster rule 8 would refuse - +# deliberately, because that is the only roster on which it does anything. +# +# ⚠️ THE FOUR NUMBERS, AND WHY THESE FOUR. Every one is ABOVE `peak_soc_floor`, +# so `pack_floor_pct()` selects the CUT-OUT for each and `floors` IS this tuple +# in this order. Read as a list, the four candidate reductions are four +# DIFFERENT numbers: +# +# floors = (30, 25, 40, 35) +# max(floors) = 40 <- the shipped answer, the deepest cut-out +# min(floors) = 25 +# floors[0] = 30 <- "grade the site against pack A", survivor 2's +# defect expressed inside the function +# floors[-1] = 35 +# mean = 32.5 +# +# so no two of them can be confused, and the extremes sit at INTERIOR roster +# positions (index 2 is the max, index 1 the min) - which is what a two-pack +# fixture structurally cannot do, since there `max` and `last` coincide and so +# do `min` and `first`. Three packs cannot do it either. Four is the smallest +# roster that separates all four. +# +# ⚠️ ALL FOUR ARE SIMULATED. There is ONE pack on this site; inverter B is ~8 +# days out and does not physically exist, and a roster of four certainly does +# not. Nothing here is a measurement of anything. `binding_soc_floor()` is a +# pure function over a list and these are the inputs that make its answer +# observable - the only measured cut-out in this file is inverter A's 10 %. +_MANY_FLOORS_SIM = (30.0, 25.0, 40.0, 35.0) +_many_packs = [Pack(f"sim-{i}", 50.0, f) for i, f in enumerate(_MANY_FLOORS_SIM)] +_many_derived = [pack_floor_pct(pk, SOC_FLOOR) for pk in _many_packs] +_many_answers = {"max": max(_MANY_FLOORS_SIM), "min": min(_MANY_FLOORS_SIM), + "first": _MANY_FLOORS_SIM[0], "last": _MANY_FLOORS_SIM[-1], + "mean": sum(_MANY_FLOORS_SIM) / len(_MANY_FLOORS_SIM)} +print(f" a SIMULATED roster of {len(_many_packs)} with cut-outs " + f"{[f'{f:.0f}' for f in _MANY_FLOORS_SIM]} % over peak_soc_floor " + f"{SOC_FLOOR:.0f} % -> per-pack floors {[f'{f:.0f}' for f in _many_derived]} %") +print(" candidate reductions: " + + ", ".join(f"{k}={v:g}" for k, v in _many_answers.items()) + + f"; binding_soc_floor() -> {binding_soc_floor(_many_packs, SOC_FLOOR):g}") +check("PRECONDITION (MUTATION.md habit 1): on this roster max, min, first, last " + "and the mean are FIVE DIFFERENT numbers, and every cut-out is strictly " + "above peak_soc_floor so the per-pack floors ARE the cut-outs. A later " + "edit that makes any two of them equal takes this line red rather than " + "leaving the claim below quietly vacuous - which is exactly what a " + "rule-8-valid roster does to it", + len(set(_many_answers.values())) == 5 + and all(f > SOC_FLOOR for f in _MANY_FLOORS_SIM) + and _many_derived == list(_MANY_FLOORS_SIM)) +check("⚠️ `binding_soc_floor()` answers the MAXIMUM of the packs' own floors - " + "the DEEPEST cut-out on the roster - and on this roster that is not the " + "minimum, not the first pack's, not the last pack's and not the mean. " + "This is the conservative pairing with the MINIMUM in `pack_floor_soc`: " + "`min(soc) <= max(floor)` is implied by 'some pack is at or below its own " + "floor', so the site verdict is never more permissive than grading each " + "pack separately. `min(floors)` here would let a forced discharge be " + "sized down to 25 % on the pack whose BMS cuts out at 40 %", + binding_soc_floor(_many_packs, SOC_FLOOR) == _many_answers["max"] + and binding_soc_floor(_many_packs, SOC_FLOOR) != _many_answers["min"] + and binding_soc_floor(_many_packs, SOC_FLOOR) != _many_answers["first"] + and binding_soc_floor(_many_packs, SOC_FLOOR) != _many_answers["last"] + and binding_soc_floor(_many_packs, SOC_FLOOR) != _many_answers["mean"]) +# ⚠️ THE COUNTERFACTUAL, ONE THING VARIED FROM AN ACCEPTED BASELINE (habit 7). +# The pack that is raised is the LAST one, which is neither the max nor the min +# above - so this line can only move if the reduction really looked at it. It is +# raised past the old maximum, and the answer follows it. +_deeper = list(_many_packs) +_deeper[-1] = Pack("sim-3", 50.0, 45.0) +print(f" ...raise ONLY the last pack 35 % -> 45 %: " + f"{binding_soc_floor(_many_packs, SOC_FLOOR):g} -> " + f"{binding_soc_floor(_deeper, SOC_FLOOR):g}") +check("...and the counterfactual: raise ONE pack - the LAST, which was neither " + "the deepest nor the shallowest - past the old deepest, and the answer " + "follows it to 45 %. So the reduction reads the LAST entry, and the 40 % " + "above was not a coincidence of that one position. ⚠️ THE LAST ENTRY IS " + "ALL THIS REACHES: every window ending at the roster's end contains it, " + "so `max(floors[-2:])` follows this line exactly. It claimed 'every " + "entry' and could not see that; the table below is the data that can", + binding_soc_floor(_deeper, SOC_FLOOR) == 45.0 + and binding_soc_floor(_many_packs, SOC_FLOOR) == 40.0) +# ⚠️ AND THAT COUNTERFACTUAL IS NOT ENOUGH EITHER, WHICH IS ROUND 3's BLOCKER 1. +# It varies only the LAST pack, and EVERY window that contains the last two +# entries follows it - `max(floors[-2:])` answers 40 on the baseline and 45 on +# `_deeper`, exactly as the shipped reduction does. It SURVIVED all 19 files. +# The sentence above used to claim "the reduction reads every entry"; the DATA +# reaches only "it reads the last entry", and the claim has been cut back to +# that. This repository's recurring defect is a check whose text is broader than +# its fixture, and it had landed inside the check written to close it. +# +# ⚠️ THE GENERAL LAW, and the thing to carry to the next card: A FIXTURE OF N +# PACKS CANNOT SEPARATE TWO REDUCTIONS THAT AGREE ON N PACKS. Two packs cannot +# separate max from min from first from last. THREE cannot separate max from +# max-of-the-last-two. A single roster cannot separate a reduction from any +# window that happens to contain its answer. So the fixture is not one roster +# but a SET, and the property is not "this number is right" but "each member of +# an enumerated family of rewrites is separated from `max` somewhere in the set". +# +# THE SET: the same four cut-outs, with the deepest one moved to EACH position +# in turn. A slice that omits position i answers below the deepest on the roster +# whose deepest sits at i, so no proper subset of {0,1,2,3} survives all four; +# min, mean and median are separated by the baseline. The table is COMPUTED and +# PRINTED, so a member no roster kills shows up as a survivor and takes the +# check red naming it, rather than being absent from a list nobody re-derived. +# +# ⚠️ WHAT IT DOES NOT CLAIM. It grades the LISTED members. A rewrite outside the +# family - one that sorts first, filters on pack NAME, or rounds - is not +# covered, and the printed table is the exact extent of the claim. +_DEEPEST_SIM = 50.0 +_reduction_rosters = [("baseline ", tuple(_MANY_FLOORS_SIM))] +for _i in range(len(_MANY_FLOORS_SIM)): + _fl = list(_MANY_FLOORS_SIM) + _fl[_i] = _DEEPEST_SIM + _reduction_rosters.append((f"deepest at index {_i} ", tuple(_fl))) + + +def _sim_packs(floors): + """The same simulated roster shape 12c2 uses, at whatever floors are given.""" + return [Pack(f"sim-{i}", 50.0, f) for i, f in enumerate(floors)] + + +_REDUCTION_FAMILY = { + "min(floors)": lambda fl: min(fl), + "floors[0] (== max(floors[:1]))": lambda fl: fl[0], + "floors[-1] (== max(floors[-1:]))": lambda fl: fl[-1], + "mean(floors)": lambda fl: sum(fl) / len(fl), + "median(floors), upper": lambda fl: sorted(fl)[len(fl) // 2], + "max(floors[:2])": lambda fl: max(fl[:2]), + "max(floors[-2:])": lambda fl: max(fl[-2:]), + "max(floors[:3])": lambda fl: max(fl[:3]), + "max(floors[-3:])": lambda fl: max(fl[-3:]), + "max(floors[1:])": lambda fl: max(fl[1:]), + "max(floors[:-1])": lambda fl: max(fl[:-1]), + "max(floors[1:-1])": lambda fl: max(fl[1:-1]), + "max(floors[::2])": lambda fl: max(fl[::2]), + "min(floors[-2:])": lambda fl: min(fl[-2:]), +} +_reduction_kills = { + _nm: [_rn.strip() for _rn, _fl in _reduction_rosters if _fn(_fl) != max(_fl)] + for _nm, _fn in _REDUCTION_FAMILY.items()} +print(" THE ROSTER SET (all SIMULATED), and binding_soc_floor() on each:") +for _rn, _fl in _reduction_rosters: + print(f" {_rn} floors {[f'{f:.0f}' for f in _fl]} -> " + f"{binding_soc_floor(_sim_packs(_fl), SOC_FLOOR):g} " + f"(max() {max(_fl):g})") +print(" THE REDUCTION FAMILY, and which roster separates each from max():") +for _nm, _k in _reduction_kills.items(): + print(f" {_nm:<34} killed by: " + + ("; ".join(_k) if _k else "*** NOTHING IN THIS SET - SURVIVOR ***")) +check("⚠️ THE FAMILY TABLE. Every listed rewrite of `max(floors)` answers " + "something OTHER than `max` on at least one roster in this set - " + "`max(floors[-2:])`, round 3's blocker, only on the roster whose deepest " + "cut-out sits at index 0 or 1, which is precisely the data the single " + "last-pack counterfactual above could not supply. A member with an empty " + "kill list is a live survivor and takes THIS line red naming it, so " + "adding a family member without a fixture that reaches it is not " + "possible quietly. ⚠️ THE LISTED MEMBERS ONLY - the printed table is the " + "extent of the claim", + all(_reduction_kills.values())) +check("...and `binding_soc_floor()` answers `max()` on EVERY roster in the set, " + "which is what turns each separation above into a real mutation kill: " + "rewrite the reduction as any listed member and it disagrees with this " + "line on the roster named beside it. In particular the deepest cut-out is " + "the answer at index 0, 1, 2 AND 3, so no positional window of the roster " + "is what is being read", + all(binding_soc_floor(_sim_packs(_fl), SOC_FLOOR) == max(_fl) + for _rn, _fl in _reduction_rosters) + and [binding_soc_floor(_sim_packs(_fl), SOC_FLOOR) + for _rn, _fl in _reduction_rosters[1:]] == [_DEEPEST_SIM] * 4) +check("PRECONDITION (habit 1): the set is five FOUR-pack rosters; the four " + "derived ones each carry the SAME multiset of cut-outs with the deepest " + "at a different position; every cut-out in every roster is strictly above " + "`peak_soc_floor`, so `pack_floor_pct()` selects the cut-out and `floors` " + "IS the tuple; and the deepest is UNIQUE in each, without which a " + "positional slice could agree with max by accident", + len(_reduction_rosters) == 5 + and all(len(_fl) == 4 for _rn, _fl in _reduction_rosters) + and all(f > SOC_FLOOR for _rn, _fl in _reduction_rosters for f in _fl) + and all(sorted(_fl) == sorted((_DEEPEST_SIM,) + _MANY_FLOORS_SIM[1:]) + or sorted(_fl) == sorted(_MANY_FLOORS_SIM[:1] + (_DEEPEST_SIM,) + + _MANY_FLOORS_SIM[2:]) + or sorted(_fl) == sorted(_MANY_FLOORS_SIM[:2] + (_DEEPEST_SIM,) + + _MANY_FLOORS_SIM[3:]) + or sorted(_fl) == sorted(_MANY_FLOORS_SIM[:3] + (_DEEPEST_SIM,)) + for _rn, _fl in _reduction_rosters[1:]) + and all(_fl.count(max(_fl)) == 1 for _rn, _fl in _reduction_rosters)) + +# ⚠️ AND THE FOUR-PACK SET HAS THE SAME CEILING AS EVERY OTHER FIXTURE: it +# cannot separate `max(floors)` from `max(floors[:4])`, because at N=4 that +# slice IS the list. This round's battery confirmed the identical shape one pack +# along at the call site (`self.packs[:3]` survives a three-pack run), so it is +# graded here rather than assumed away. `binding_soc_floor()` is a PURE function +# over a list, which is the one place the sweep is cheap: rosters of length 1 +# through 12, the deepest cut-out at EVERY position of each, and the answer must +# be the deepest every time. That kills every fixed-width head or tail window +# narrower than the longest roster, at once, rather than one mutation at a time. +_SWEEP_N = 12 +_SWEEP_SHALLOW, _SWEEP_DEEP = SOC_FLOOR + 5.0, SOC_FLOOR + 25.0 +_sweep_rows = [] +for _n in range(1, _SWEEP_N + 1): + for _pos in range(_n): + _fl = [_SWEEP_SHALLOW] * _n + _fl[_pos] = _SWEEP_DEEP + _sweep_rows.append((_n, _pos, tuple(_fl))) +_sweep_bad = [(n, pos, fl) for n, pos, fl in _sweep_rows + if binding_soc_floor(_sim_packs(fl), SOC_FLOOR) != max(fl)] +_head_kill = {w: any(max(fl[:w]) != max(fl) for _n, _p, fl in _sweep_rows) + for w in range(1, _SWEEP_N + 1)} +_tail_kill = {w: any(max(fl[-w:]) != max(fl) for _n, _p, fl in _sweep_rows) + for w in range(1, _SWEEP_N + 1)} +print(f" LENGTH SWEEP (all SIMULATED): {len(_sweep_rows)} rosters, lengths 1" + f"..{_SWEEP_N}, the {_SWEEP_DEEP:.0f} % cut-out at EVERY position of each " + f"among {_SWEEP_SHALLOW:.0f} % siblings -> " + f"{len(_sweep_rows) - len(_sweep_bad)} answer the deepest, " + f"{len(_sweep_bad)} do not") +print(" fixed-width windows killed by the sweep: head widths " + + ",".join(str(w) for w in sorted(_head_kill) if _head_kill[w]) + + "; tail widths " + + ",".join(str(w) for w in sorted(_tail_kill) if _tail_kill[w]) + + f"; NOT reached: head/tail width {_SWEEP_N} and wider") +check("⚠️ THE LENGTH SWEEP. Over rosters of 1 to 12 packs with the deepest " + "cut-out at every position of each, `binding_soc_floor()` answers the " + "DEEPEST every time - so every head window `max(floors[:w])` and every " + "tail window `max(floors[-w:])` of width 1 to 11 disagrees with it on at " + "least one roster here, and that is the whole positional family below " + "width 12 closed in one property rather than one mutation at a time", + not _sweep_bad + and len(_sweep_rows) == _SWEEP_N * (_SWEEP_N + 1) // 2 + and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N))) +check("⚠️ ...AND WIDTH 12 AND WIDER IS HONESTLY NOT REACHED, recorded rather " + "than papered over. No roster in this file is longer than 12, so " + "`max(floors[:12])` IS `max(floors)` on every one of them and no check " + "here can tell them apart. A 13-pack roster would close width 12 and hand " + "width 13 the same free pass: THE REGRESS DOES NOT TERMINATE IN A " + "FIXTURE, which is this round's lesson and is why 12g's call site carries " + "an EXACT STRUCTURAL pin alongside its runs. This line asserts the " + "unreachedness itself, so a later sweep that lengthens the rosters takes " + "it red and forces the sentence to be rewritten", + not _head_kill[_SWEEP_N] and not _tail_kill[_SWEEP_N] + and max(len(fl) for _n, _p, fl in _sweep_rows) == _SWEEP_N) + +# ...and the EMS floor is still the other half of the conjunction: lift it above +# every cut-out on the roster and it wins for all of them. +_over = SOC_FLOOR + 40.0 +check("...and with `peak_soc_floor` lifted above every cut-out on this roster " + "the answer is the OPTION, for all four - `pack_floor_pct()` is a " + "conjunction and this is its other side, so a reduction that dropped the " + "EMS floor entirely goes red here as well as in the N=1 sweep", + _over > max(_MANY_FLOORS_SIM) + and binding_soc_floor(_many_packs, _over) == float(_over)) + +# ...and the EMPTY roster, the SECOND thing this fix round's own battery found +# unguarded: `return None` in place of `float(soc_floor)` there (-43 bytes) was +# GREEN across all 19 files. It is a defensive branch - `Controller.inverters` is never +# empty on a started controller, and `inputs_ok` refuses the cycle before the +# guard is reached if it were - so nothing in the suite went near it. Pinned +# rather than argued, because a branch with no check is a branch that can be +# rewritten under a green suite, and BOTH directions of it are asserted: the +# option when there is one, and None when there is not. +print(f" an EMPTY roster: binding_soc_floor([], {SOC_FLOOR:.0f}) -> " + f"{binding_soc_floor([], SOC_FLOOR)}, binding_soc_floor([], None) -> " + f"{binding_soc_floor([], None)}") +check("an EMPTY roster answers the OPTION - there is no pack to conjoin with, " + "so there is nothing to raise the floor above it - and answers None when " + "the option itself is None. ⚠️ AND IT CANNOT PERMIT ANYTHING EITHER WAY: " + "`Controller.pack_floor_soc` is None at an empty roster, and " + "`discharge_permitted()` refuses on a None SoC whatever the floor is", + binding_soc_floor([], SOC_FLOOR) == float(SOC_FLOOR) + and binding_soc_floor([], None) is None + and discharge_permitted(None, binding_soc_floor([], SOC_FLOOR)) + == FLOOR_SOC_UNREADABLE) + +# --- 12d. AC 4: pack_floor_soc's None-on-missing is untouched --------------- +_c32, _h32 = built(TMP / "s32.db") +_b32, _restore32 = second_inverter(_c32) +_c32.inverters[0].soc, _c32.inverters[1].soc = SOC_BETWEEN, SOC_FLOOR + 30.0 +check("AC 4: `pack_floor_soc` is still the MINIMUM measured SoC across the " + "roster - this card changed the FLOOR, not the reduction, and DEV-02's " + "comment is the record of why the emptiest pack is right", + _c32.pack_floor_soc == SOC_BETWEEN) +_c32.inverters[1].soc = None +check("...and it is still None - never a number - when ANY pack did not answer, " + "so a silent pack is floor-limited rather than defaulted to full. The " + "per-pack grading added beside it must not have turned an absence into a " + "reading", + _c32.pack_floor_soc is None + and pack_permissions(_c32.packs, SOC_FLOOR)[1][1] == FLOOR_SOC_UNREADABLE) +_c32.inverters[1].soc = SOC_FLOOR + 30.0 +# ⚠️ ASSERTED AGAINST LITERALS THAT DIFFER ON ALL THREE AXES (habit 2). Compared +# against `[(inv.name, inv.soc, inv.bms_soc_floor) for inv in ...]` this check +# could not tell "each unit's own three numbers" from "the first unit's three, +# twice"; the tuples below are written out, and no two of them share a value. +_c32.inverters[0].bms_soc_floor = A_PACK_FLOOR +_c32.inverters[1].bms_soc_floor = B_PACK_FLOOR_SIM +print(f" Controller.packs -> {[(p.name, p.soc, p.bms_floor) for p in _c32.packs]}") +check("...and `Controller.packs` carries EACH unit's OWN name, SoC and cut-out " + "- not the first unit's repeated, which is DEV-02's `self.inverters[0]` " + "defect and the one that left all 19 files green", + [(pk.name, pk.soc, pk.bms_floor) for pk in _c32.packs] + == [("inverter-a", SOC_BETWEEN, A_PACK_FLOOR), + ("inverter-b", SOC_FLOOR + 30.0, B_PACK_FLOOR_SIM)] + and A_PACK_FLOOR != B_PACK_FLOOR_SIM and SOC_BETWEEN != SOC_FLOOR + 30.0) +_restore32() + +# --- 12e. rule 8 grades EVERY unit and NAMES it ------------------------------ +def _roster32(a_floor, b_floor, **kw): + """A two-entry roster, otherwise-startable, differing only in the floors.""" + entries = [] + for nm, sp, fl in (("inverter-a", "number.sp_a", a_floor), + ("inverter-b", "number.sp_b", b_floor)): + e = {"name": nm, "setpoint_entity": sp, "max_w": 2000} + if fl is not None: + e["bms_soc_floor"] = fl + entries.append(e) + return opts24(setpoint_entity="", inverters=entries, **kw) + + +check("the two-entry fixture with BOTH cut-outs under the EMS floor is " + "ACCEPTED, so every refusal below has nowhere else to come from " + "(MUTATION.md habit 7)", + validate_startup_config(_roster32(A_PACK_FLOOR, B_PACK_FLOOR_SIM)) == []) +# ⚠️ THE OFFENDER IS THE SECOND ENTRY. CFG-04 has just fixed a rule 5/6 bug of +# exactly this shape - a caps-list index fed to a function that indexes the +# ROSTER, so the refusal named the WRONG INVERTER - and an index hardwired to 0, +# or a loop that grades only the first entry, passes a fixture whose offender is +# first. So it is second here, and first in the mirror below. +_r_second = rule8(validate_startup_config( + _roster32(A_PACK_FLOOR, B_PACK_FLOOR_DEEP_SIM))) +# ⚠️ INDEXED THROUGH `_msg()`, NOT `[0]`. The mutation this pair exists to catch +# - rule 8 grading only the first unit - makes this list EMPTY, and a bare `[0]` +# turns the red into an IndexError traceback that kills the file before the +# remaining checks report. A mutation battery wants NAMED reds, not a stack. +def _msg(problems, i=0): + return problems[i] if len(problems) > i else "" + + +print(f" offender SECOND -> {len(_r_second)} refusal(s): " + f"{_msg(_r_second)[:150] or '(none)'}...") +check("⚠️ AC 3. Rule 8 grades EVERY unit: a roster whose SECOND pack has a " + "cut-out above `peak_soc_floor` is refused, ONCE, and the refusal NAMES " + "inverter-b. Under the flat key this config STARTED, and the guard would " + "then have commanded a forced discharge that pack B's BMS - not the " + "operator's floor - is the thing that stops", + len(_r_second) == 1 and "(inverter-b)" in _msg(_r_second) + and "(inverter-a)" not in _msg(_r_second)) +check("...and it names BOTH numbers and the shipped default, so the operator " + "can act on it without reading the source", + f"{B_PACK_FLOOR_DEEP_SIM:.0f} %" in _msg(_r_second) + and f"{SOC_FLOOR:.0f} %" in _msg(_r_second) + and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in _msg(_r_second)) +# The mirror. A grader hardwired to index 1 - or one that reports the LAST +# offender - passes the check above and fails this one. +_r_first = rule8(validate_startup_config( + _roster32(B_PACK_FLOOR_DEEP_SIM, A_PACK_FLOOR))) +check("...and with the SAME two numbers in the OTHER ORDER it names " + "inverter-a instead. The pair of checks is what makes this an index " + "check: one of them goes red for any hardwired position", + len(_r_first) == 1 and "(inverter-a)" in _msg(_r_first) + and "(inverter-b)" not in _msg(_r_first)) +# And both at once, because a loop that `break`s on the first offender reports +# one refusal for a config with two. +_r_both = rule8(validate_startup_config( + _roster32(B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_DEEP_SIM + 1.0))) +check("...and a roster where BOTH packs offend is refused TWICE, once per unit " + "and each naming its own - so an operator with two wrong packs is not " + "told about one of them and left to discover the other on the next boot", + len(_r_both) == 2 and "(inverter-a)" in _msg(_r_both, 0) + and "(inverter-b)" in _msg(_r_both, 1)) +# A missing per-entry cut-out is refused BY NAME rather than defaulted. +_r_missing = rule8(validate_startup_config(_roster32(A_PACK_FLOOR, None))) +print(f" entry with no bms_soc_floor -> {_msg(_r_missing)[:120] or '(none)'}...") +check("...and an entry that states NO `bms_soc_floor` is refused by name rather " + "than defaulted. THE DECISION THIS CARD RECORDS: a per-entry default is a " + "cut-out this add-on invented for a battery it has not been told about, " + "which is the same wrong number the flat key produced. See " + "DECISIONS/SAFETY-32-per-pack-bms-floor.md", + len(_r_missing) == 1 and "(inverter-b)" in _msg(_r_missing) + and "must both be numbers" in _msg(_r_missing)) + +# --- 12f. nothing is inherited, with the flat key SET AND DIFFERENT ---------- +# ⚠️ THE FLAT KEY IS A REAL, DIFFERENT NUMBER HERE, AND THAT IS THE CHECK. +# CFG-04 wrote this exact claim against a fixture whose flat `soc_entity` was +# BLANK, so inherited and not-inherited were the same empty string and the +# mutation that restores the fallback SURVIVED. 30 % is nothing else in this +# file, so an entry that came back holding it can only have borrowed it. +_FLAT_ODD = 30.0 +_opts_inh = {"bms_soc_floor": _FLAT_ODD, "setpoint_entity": "", + "inverters": [{"name": "inverter-a", "setpoint_entity": "number.sp_a", + "max_w": 2000, "bms_soc_floor": A_PACK_FLOOR}, + {"name": "inverter-b", "setpoint_entity": "number.sp_b", + "max_w": 2000}]} +_r_inh = build_roster(_opts_inh, _Store()) +print(f" flat bms_soc_floor {_opts_inh['bms_soc_floor']:.0f} %, roster carries " + f"{[i.bms_soc_floor for i in _r_inh]}") +check("an entry that omits `bms_soc_floor` has NO cut-out and does NOT borrow " + "the flat one - which is set to a real, DIFFERENT number right here, so " + "inherited and not-inherited are visibly different answers. A pack graded " + "against a floor that belongs to another battery is this whole card", + _r_inh[1].bms_soc_floor is None + and _r_inh[0].bms_soc_floor == A_PACK_FLOOR + and _FLAT_ODD not in [i.bms_soc_floor for i in _r_inh]) +# ...and at the FLAT shape the same key IS this one pack's cut-out. +_r_flat = build_roster({"setpoint_entity": "number.sp", + "bms_soc_floor": _FLAT_ODD}, _Store()) +check("...while at the single-inverter shape the flat key IS that one pack's " + "cut-out, because there the flat keys describe the one inverter. The two " + "readings of the same key are the two shapes, not a fallback", + len(_r_flat) == 1 and _r_flat[0].bms_soc_floor == _FLAT_ODD) +_r_dflt = build_roster({"setpoint_entity": "number.sp"}, _Store()) +check("...and with the key absent entirely the one pack takes the SHIPPED " + "default out of the one mirror - so an install that never set it reads " + "the same number the validator grades it against, and CFG-03 gains no " + "fourth defaults site", + _r_dflt[0].bms_soc_floor == BMS_FLOOR + and BMS_FLOOR == PEAK_OPTION_DEFAULTS["bms_soc_floor"]) + +# --- 12g. the call site: main.py hands decide() the per-pack reduction ------- +# ⚠️ REWRITTEN. THIS CHECK USED TO BE A SUBSTRING TEST OVER main.py's SOURCE - +# `"self.packs" in _floor_call` - AND IT WAS NOT A PIN. The mutation that grades +# the whole site against pack A's floor is `self.packs[:1]`, which still +# CONTAINS that substring: +4 bytes, all 19 files green, including this one. +# MUTATION.md habit 4, in the same costume CFG-04's `if entity:` wore: the check +# asserted against the TEXT of the thing under test instead of its behaviour. +# The AST is still unparsed and PRINTED (habit 3) because a reader wants to see +# the expression, but nothing below asserts on its spelling. Which OPTION KEY +# the floor comes from is a claim genuinely about the code's shape and is pinned +# on the source in part 4, where it already lives; what is graded here is the +# VALUE that reaches `decide()`. +_floor_call = ast.unparse(next(k.value for k in _call.keywords + if k.arg == "soc_floor")) +print(f" main.py's decide(soc_floor=...) is `{_floor_call}`") + + +def _floor_reaching_decide(tag, *floors, peak_floor=None): + """Drive ONE REAL `Controller.cycle()` on an N-pack roster and report the + floor `peakguard.decide()` was actually handed, read back off the decision + main.py itself filed (`PeakDecision.soc_floor` is the argument, echoed). + + ⚠️ NOT A SPY AND NOT A GREP. The shipped `cycle()` runs, the shipped + `binding_soc_floor()` runs, and the observable is a field of the decision + the controller stored - so the whole path from `Controller.inverters` to the + guard's argument is under test. Every pack is held at the SAME SoC and the + same everything else; the only things that vary between calls are the packs' + own cut-outs. + + ⚠️ N IS A PARAMETER, AND THAT IS THIS ROUND'S BLOCKER 2. It used to be fixed + at TWO, and a roster of two cannot distinguish a call site that reduces the + ROSTER from one that reduces a WINDOW of two positions in it - `self.packs` + and `self.packs[:2]` are the same list there, by exactly the argument 12c2 + makes about two packs being unable to separate max from min from first from + last. The extra units are appended through `second_inverter()`, the same + hook the two-pack runs use, one per floor past the first. + """ + opts = {} if peak_floor is None else {"peak_soc_floor": peak_floor} + + def _prep(ctl): + for _i in range(len(floors) - 1): + _sfx = chr(ord("b") + _i) + second_inverter(ctl, name=f"inverter-{_sfx}", + setpoint=f"number.sp_{_sfx}") + for inv, fl in zip(ctl.inverters, floors): + inv.soc, inv.batt, inv.bms_soc_floor = SOC_BETWEEN, 0.0, fl + + ctl, hs, _net, _rem = wired_over_limit( + TMP / f"s32g_{tag}.db", LIVE_MAX_W * 0.3, opts=opts, prepare=_prep) + return ctl, ctl.peak_guard + + +# BASELINE: neither cut-out is above the EMS floor, so the conjunction selects +# the option for both packs and the site floor is `peak_soc_floor`. At +# SOC_BETWEEN (5 points above it) the guard is permitted and sizes a real +# discharge. Both runs below vary one thing from THIS baseline (habit 7). +_g_ctl_shallow, _g_shallow = _floor_reaching_decide( + "shallow", A_PACK_FLOOR, B_PACK_FLOOR_SIM) +# The SECOND pack's cut-out moves above the EMS floor. Nothing else changes - +# not pack A, not either SoC, not the option. +_g_ctl_deep, _g_deep = _floor_reaching_decide( + "deepb", A_PACK_FLOOR, B_PACK_FLOOR_DEEP_SIM) +# ⚠️ AND THE SAME TWO CUT-OUTS SWAPPED BETWEEN THE PACKS, which is the mirror +# 12h already runs on the SoC axis and which THIS part needed and did not have. +# Without it, `self.packs[-1:]` - grade the site against the LAST pack - answers +# the same two numbers as the full roster on the two runs above, because pack A +# is never the deeper one there. It SURVIVED the first cut of this fix round, +# +5 bytes, all 19 files green. With the pair swapped, `[:1]` and `[-1:]` +# disagree with the roster in opposite runs, and no single-position slice is +# left that can pass all three. +_g_ctl_deepa, _g_deepa = _floor_reaching_decide( + "deepa", B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_SIM) +print(f" REAL two-pack cycles, both packs at {SOC_BETWEEN:.0f} %, " + f"peak_soc_floor {SOC_FLOOR:.0f} % (every cut-out but inverter-a's own " + f"{A_PACK_FLOOR:.0f} % is SIMULATED):") +print(f" cut-outs A {A_PACK_FLOOR:.0f} / B {B_PACK_FLOOR_SIM:.0f} % -> " + f"decide() got soc_floor={_g_shallow.soc_floor:g} %, floor_limited=" + f"{_g_shallow.floor_limited}") +print(f" cut-outs A {A_PACK_FLOOR:.0f} / B {B_PACK_FLOOR_DEEP_SIM:.0f} % -> " + f"decide() got soc_floor={_g_deep.soc_floor:g} %, floor_limited=" + f"{_g_deep.floor_limited}") +print(f" ...the SAME PAIR SWAPPED, A {B_PACK_FLOOR_DEEP_SIM:.0f} / B " + f"{B_PACK_FLOOR_SIM:.0f} % -> decide() got " + f"soc_floor={_g_deepa.soc_floor:g} %, floor_limited=" + f"{_g_deepa.floor_limited}") +check("⚠️ THE CALL-SITE PIN, AS BEHAVIOUR. Change the cut-out of a NON-FIRST " + "pack and the floor that reaches `decide()` MOVES with it: 20 % (the " + "option, because B's cut-out is under it) becomes 35 % (B's own cut-out, " + "because it is over it). A call site that graded the site against " + "`self.packs[:1]` - pack A alone - answers 20 % both times and cannot " + "produce this difference, and neither can `min(floors)`; that is the " + "+4-byte mutation that walked through the substring test this check " + "replaces", + _g_shallow.soc_floor == float(SOC_FLOOR) + and _g_deep.soc_floor == float(B_PACK_FLOOR_DEEP_SIM) + and _g_deep.soc_floor != _g_shallow.soc_floor) +check("⚠️ ...AND THE SAME PAIR SWAPPED GIVES THE SAME SITE FLOOR, so no " + "SINGLE-POSITION slice of the roster can produce these three answers. " + "`self.packs[-1:]` - the LAST pack alone - agrees with the roster on both " + "runs above and answers 20 % here, where the deeper cut-out sits on the " + "FIRST pack. That mutation SURVIVED the first cut of this fix round and " + "this is the line that closes it. ⚠️ AND THAT IS ALL THIS LINE SHOWS - " + "NOT that the whole roster is read. At N=2 a two-position WINDOW is the " + "roster: `self.packs[:2]` answers identically on all three runs above and " + "walks straight through this check, +4 bytes, 19 files green. It is " + "round 3's blocker 2, and the THREE-pack runs below are what reach it", + _g_deepa.soc_floor == float(B_PACK_FLOOR_DEEP_SIM) + and _g_deepa.soc_floor == _g_deep.soc_floor + and _g_deepa.soc_floor != _g_shallow.soc_floor + and [pk.bms_floor for pk in _g_ctl_deepa.packs] + == [B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_SIM]) +check("...and it is not just a number in a report - THE SITE'S VERDICT FLIPS. " + "With both packs at 25 % the shallow site is permitted and the guard " + "sizes a positive forced discharge; BOTH deep sites are FLOOR-LIMITED, " + "because 25 % is at or below the 35 % cut-out wherever on the roster it " + "sits. Grading the roster against one position would spend a pack its " + "BMS stops - through `sim/inject.py`, which does not run rule 8 - and " + "this line is what goes red when it does", + _g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0 + and _g_deep.floor_limited is True and _g_deep.floor_w == 0.0 + and _g_deep.soc_floor_reason == FLOOR_AT_SOC_FLOOR + and _g_deepa.floor_limited is True and _g_deepa.floor_w == 0.0 + and _g_deepa.soc_floor_reason == FLOOR_AT_SOC_FLOOR) +check("...and the precondition that makes the three runs comparable (habit 1): " + "the two deep runs carry THE SAME PAIR of cut-outs in opposite orders, " + "those two numbers really are different, and all six packs are at the " + "SAME state of charge - so neither the SoC reduction nor a roster " + "position can be what separated the answers", + B_PACK_FLOOR_SIM != B_PACK_FLOOR_DEEP_SIM + and A_PACK_FLOOR != B_PACK_FLOOR_DEEP_SIM + and sorted(pk.bms_floor for pk in _g_ctl_deepa.packs) + == sorted([B_PACK_FLOOR_SIM, B_PACK_FLOOR_DEEP_SIM]) + and [pk.soc for pk in _g_ctl_deep.packs] == [SOC_BETWEEN, SOC_BETWEEN] + and [pk.soc for pk in _g_ctl_deepa.packs] == [SOC_BETWEEN, SOC_BETWEEN] + and [pk.bms_floor for pk in _g_ctl_deep.packs] + == [A_PACK_FLOOR, B_PACK_FLOOR_DEEP_SIM] + and [pk.bms_floor for pk in _g_ctl_shallow.packs] + == [A_PACK_FLOOR, B_PACK_FLOOR_SIM] + and _g_ctl_deep.pack_floor_soc == _g_ctl_shallow.pack_floor_soc + == _g_ctl_deepa.pack_floor_soc) +# ⚠️ AND THREE RUNS ON A TWO-PACK ROSTER ARE STILL NOT ENOUGH, WHICH IS THIS +# ROUND'S BLOCKER 2. The paragraph above states the TRUE claim - no +# SINGLE-POSITION slice passes all three - and the check below it stated a +# BROADER one, "what is reduced is the ROSTER and not a POSITION in it". The +# broader one is false on this fixture: `self.packs[:2]` IS `self.packs` at a +# roster of two, so it answers identically on every run above and SURVIVED all +# 19 files (+4 bytes). The prose has been narrowed to what those three runs +# reach; what follows is the data that reaches the rest. +# +# THE FIXTURE, and why it is shaped this way. Three REAL cycles on a THREE-pack +# roster, the deep cut-out sitting at a DIFFERENT position each time - index 0, +# index 1 (the interior one a two-pack roster has no room for) and index 2. Any +# positional slice of `self.packs` is a proper subset of {0, 1, 2}, so it omits +# at least one position, and on the run whose deep pack sits at that position it +# sees only shallow cut-outs and answers the OPTION where the roster answers the +# cut-out. That is the whole argument, and it is enumerated rather than asserted +# from memory in the table below. +# +# ⚠️ SIMULATED. Two of the three units do not exist; inverter B is ~8 days out +# and there is no inverter C at all. The shallow slots carry inverter A's +# MEASURED 10 % cut-out and the deep one is the same SIMULATED 35 % the runs +# above use. Nothing here is a measurement of a second or third pack. +_G3_DEEP_SIM = B_PACK_FLOOR_DEEP_SIM +_g3_runs = [] +for _pos in range(3): + _fl3 = [A_PACK_FLOOR] * 3 + _fl3[_pos] = _G3_DEEP_SIM + _ctl3, _dec3 = _floor_reaching_decide(f"n3p{_pos}", *_fl3) + _g3_runs.append((f"3-pack, deep at index {_pos}", _fl3, _ctl3, _dec3)) +print(f" REAL THREE-pack cycles, all packs at {SOC_BETWEEN:.0f} %, " + f"peak_soc_floor {SOC_FLOOR:.0f} %, the {_G3_DEEP_SIM:.0f} % cut-out " + f"moved along the roster (units b and c are SIMULATED):") +for _rn, _fl3, _ctl3, _dec3 in _g3_runs: + print(f" {_rn}: cut-outs {[f'{f:.0f}' for f in _fl3]} % -> decide() " + f"got soc_floor={_dec3.soc_floor:g} %, " + f"floor_limited={_dec3.floor_limited}") +check("⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster of " + "THREE the floor that reaches `decide()` is the deep cut-out wherever on " + "the roster that cut-out sits - index 0, index 1 and index 2 all answer " + "35 %, and all three rosters carry the SAME multiset of cut-outs. A call " + "site that handed `binding_soc_floor()` any PROPER SUBSET of the " + "positions would answer the option, 20 %, on the run whose deep pack sits " + "at the position it dropped. `self.packs[:2]` - the +4-byte mutation that " + "walked through the two-pack runs above, because at N=2 it is the whole " + "roster - goes red on the index-2 run", + [r[3].soc_floor for r in _g3_runs] + == [float(_G3_DEEP_SIM)] * 3 + and all(len(r[2].packs) == 3 for r in _g3_runs) + and all(sorted(pk.bms_floor for pk in r[2].packs) + == sorted([A_PACK_FLOOR, A_PACK_FLOOR, _G3_DEEP_SIM]) + for r in _g3_runs) + and [[pk.bms_floor for pk in r[2].packs] for r in _g3_runs] + == [r[1] for r in _g3_runs]) +check("...and the VERDICT flips with it, on all three: 25 % is at or below the " + "35 % cut-out wherever it sits, so every one of these sites is " + "FLOOR-LIMITED and sizes no forced discharge at all - where the " + "all-shallow two-pack baseline above was permitted and sized a positive " + "one. A slice that dropped the deep position would spend, through " + "`sim/inject.py`, a pack its BMS stops", + all(r[3].floor_limited is True and r[3].floor_w == 0.0 + and r[3].soc_floor_reason == FLOOR_AT_SOC_FLOOR for r in _g3_runs) + and _g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0) +check("...and the precondition that makes the three comparable (habit 1): every " + "pack in all three rosters is at the SAME state of charge, so neither the " + "`pack_floor_soc` MINIMUM nor a roster position can be what separated the " + "answers, and the deep cut-out really is above the EMS floor while the " + "shallow one really is below it - two cut-outs on the same side of it " + "would make every line above vacuous", + all([pk.soc for pk in r[2].packs] == [SOC_BETWEEN] * 3 for r in _g3_runs) + and len({r[2].pack_floor_soc for r in _g3_runs}) == 1 + and A_PACK_FLOOR < SOC_FLOOR < _G3_DEEP_SIM) + +# ⚠️ AND THE ENUMERATION ITSELF, COMPUTED RATHER THAN CLAIMED. The lesson this +# round is here to learn is that a fixture of N packs cannot separate reductions +# that agree on N packs, so "no slice survives" is a claim about a FAMILY and a +# SET OF RUNS and has to be graded as one. Below: every positional slice of the +# roster, applied to the REAL `Controller.packs` each cycle above actually ran, +# compared against the floor that REALLY reached `decide()` on that run. A slice +# with an empty kill list is a survivor and this line goes red naming it. +# +# ⚠️ WHAT THIS DOES NOT CLAIM. It grades the members listed and nothing wider - +# a rewrite that is not a positional slice (a different reduction, a filter on +# pack NAME, a sort before the slice) is not covered here, and `min`/`mean`/the +# rest of the reduction family are graded on the pure function in 12c2 instead. +# The table is PRINTED so a reader can see exactly how far the claim reaches. +_G_RUNS = ([("2-pack, no cut-out over the floor", _g_ctl_shallow, _g_shallow), + ("2-pack, deep at index 1", _g_ctl_deep, _g_deep), + ("2-pack, deep at index 0", _g_ctl_deepa, _g_deepa)] + + [(rn, ctl, dec) for rn, _fl, ctl, dec in _g3_runs]) +_SLICE_FAMILY = { + "self.packs[:1]": lambda ps: ps[:1], + "self.packs[-1:]": lambda ps: ps[-1:], + "self.packs[:2]": lambda ps: ps[:2], + "self.packs[-2:]": lambda ps: ps[-2:], + "self.packs[1:]": lambda ps: ps[1:], + "self.packs[:-1]": lambda ps: ps[:-1], + "self.packs[1:-1]": lambda ps: ps[1:-1], + "self.packs[::2]": lambda ps: ps[::2], +} +_slice_kills = { + _nm: [rn for rn, ctl, dec in _G_RUNS + if binding_soc_floor(_fn(ctl.packs), SOC_FLOOR) != dec.soc_floor] + for _nm, _fn in _SLICE_FAMILY.items()} +print(" THE POSITIONAL-SLICE FAMILY at this call site, and which run " + "separates each from the whole roster:") +for _nm, _k in _slice_kills.items(): + print(f" {_nm:<18} killed by: " + + ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***")) +check("⚠️ EVERY positional slice in the enumerated family answers something " + "OTHER than the floor that really reached `decide()` on at least one of " + "the six runs above - and the two three-pack runs at index 2 and index 0 " + "are what kill `[:2]` and `[-2:]`, which the two-pack runs structurally " + "could not. The kill list is computed against the REAL `Controller.packs` " + "each cycle ran and the REAL `PeakDecision.soc_floor` it produced, so a " + "member with an empty list is a live survivor and takes this line red " + "naming it. This grades the LISTED members only; the printed table is its " + "extent", + all(_slice_kills.values())) +check("...and the baseline the table is measured against is the shipped call " + "site's OWN answer, not a re-derivation: on every run, the floor that " + "reached `decide()` equals `binding_soc_floor()` over the WHOLE roster " + "that cycle carried. If it did not, every kill list above would be " + "measuring the wrong thing", + all(dec.soc_floor == binding_soc_floor(ctl.packs, SOC_FLOOR) + for _rn, ctl, dec in _G_RUNS) + and len(_G_RUNS) == 6 + and sorted(len(ctl.packs) for _rn, ctl, dec in _G_RUNS) == [2, 2, 2, 3, 3, 3]) + +# ⚠️ AND HERE IS THE HONEST LIMIT OF EVERY FIXTURE ABOVE, FOUND BY THIS ROUND'S +# OWN BATTERY AND NOT ARGUED FROM: `self.packs[:3]` and `self.packs[-3:]` SURVIVE +# all six behavioural runs, +4 and +5 bytes, and they survive for the same reason +# `[:2]` survived the two-pack runs - THE LONGEST ROSTER HERE IS THREE, and a +# slice that keeps three consecutive positions IS the roster at N=3. Adding a +# four-pack run would close those two and hand `[:4]` and `[-4:]` the same free +# pass. THE REGRESS DOES NOT TERMINATE IN A FIXTURE. A bounded roster cannot +# grade an unbounded family of slices, and pretending otherwise by adding one +# more pack is precisely the move that has been rejected twice on this card. +# +# So the two halves are separated and each says what it reaches: +# +# the RUNS above grade the VALUE - that the floor reaching `decide()` really +# is the roster's deepest cut-out, wherever on the roster it sits, and that +# the site's verdict follows it. They reach every positional slice up to N=3 +# and, by construction, nothing beyond. +# +# the PIN below grades the ARGUMENT'S IDENTITY - a claim genuinely about the +# code's shape, so it is graded on the code's shape, exactly, at ANY N. It is +# NOT the substring test this part replaced: `"self.packs" in _floor_call` is +# true of every slice, whereas `ast.unparse(arg) == "self.packs"` is false of +# all of them. `self.packs[:3]` unparses to `self.packs[:3]`. +# +# ⚠️ AND THE PIN IS BLIND TO TWO THINGS THE RUNS ARE NOT, which is why both are +# here. It cannot see a change to what `Controller.packs` RETURNS - a property +# rewritten as `self.inverters[:2]` leaves this expression identical, and it is +# the three-pack runs' `len(ctl.packs) == 3` that goes red on it. And it cannot +# see the reduction INSIDE `binding_soc_floor()` at all; that is 12c2's. +_floor_node = next(k.value for k in _call.keywords if k.arg == "soc_floor") +_floor_roster_arg = (_floor_node.args[0] + if isinstance(_floor_node, ast.Call) and _floor_node.args + else None) +print(f" the ROSTER argument main.py reduces is " + f"`{ast.unparse(_floor_roster_arg)}`, an AST " + f"{type(_floor_roster_arg).__name__}") +check("⚠️ THE STRUCTURAL HALF, AND IT IS EXACT RATHER THAN A SUBSTRING. The " + "roster handed to `binding_soc_floor()` is the WHOLE property " + "`self.packs` - an attribute of self, not a Subscript, not a call, not a " + "comprehension - so EVERY slice of it is red here at every roster length, " + "including `self.packs[:3]` and `self.packs[-3:]`, which survive all six " + "runs above because the longest roster they drive is three packs long. " + "This grades the code's SHAPE and nothing about the values; what the " + "expression MEANS is graded by the runs, and which option key it reads is " + "pinned in part 4", + isinstance(_floor_node, ast.Call) + and ast.unparse(_floor_node.func) == "peakguard.binding_soc_floor" + and len(_floor_node.args) == 2 and not _floor_node.keywords + and isinstance(_floor_roster_arg, ast.Attribute) + and isinstance(_floor_roster_arg.value, ast.Name) + and _floor_roster_arg.value.id == "self" + and _floor_roster_arg.attr == "packs" + and ast.unparse(_floor_roster_arg) == "self.packs") +check("...and the counterfactual that shows this pin is not vacuous (habit 7): " + "the very slices it exists for really do unparse to something else, so " + "the equality above is doing work rather than being trivially true of " + "anything shaped like an argument", + all(ast.unparse(ast.parse(f"self.packs{sl}", mode="eval").body) + != "self.packs" + for sl in ("[:1]", "[-1:]", "[:2]", "[-2:]", "[:3]", "[-3:]", + "[1:]", "[:-1]", "[1:-1]", "[::2]")) + and ast.unparse(ast.parse("list(reversed(self.packs))", + mode="eval").body) != "self.packs") + +# ...and the OTHER half of the conjunction still reaches the same argument, so +# the reduction did not replace the source: move `peak_soc_floor` itself, with +# both cut-outs held under it, and the floor follows the OPTION. +_G_OPT_ODD = SOC_FLOOR + 22.0 +_, _g_opt = _floor_reaching_decide("opt", A_PACK_FLOOR, B_PACK_FLOOR_SIM, + peak_floor=_G_OPT_ODD) +print(f" peak_soc_floor moved to {_G_OPT_ODD:.0f} % with both cut-outs " + f"under it -> decide() got soc_floor={_g_opt.soc_floor:g} %") +check("...and with both cut-outs held below it, moving `peak_soc_floor` moves " + "the floor that reaches `decide()` too - so this card moved the " + "REDUCTION and not the SOURCE. WHICH key that is, is a claim about the " + "code's shape and stays pinned on the source in part 4; that the option " + "still governs is a claim about behaviour and is graded here", + _g_opt.soc_floor == float(_G_OPT_ODD) + and _G_OPT_ODD > B_PACK_FLOOR_SIM and _G_OPT_ODD > A_PACK_FLOOR + and _g_opt.soc_floor != _g_shallow.soc_floor) + +# --- 12h. the operator-facing half, driven through the shipped method ------- +# ⚠️ RUN, NOT GREPPED. CFG-04's review found a substring test over main.py that +# the defeating mutation walked straight through, so this drives the shipped +# `check_peak_floor_limited()` on a real two-pack Controller and reads what the +# operator's notification actually says. +async def _notify32(packs_socs, floors): + ctl, hs = built(TMP / f"s32n{len(packs_socs)}{floors[-1]:.0f}.db") + inv_b, _ = second_inverter(ctl) + for inv, so, fl in zip(ctl.inverters, packs_socs, floors): + inv.soc, inv.bms_soc_floor = so, fl + ctl.peak_guard = PeakDecision( + engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP, + projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0, + floor_limited=True, soc=min(packs_socs), + soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR), + soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x") + ctl._peak_floor_limited = False + await ctl.check_peak_floor_limited() + return hs.last_message("goodwe_peak_soc_floor") + + +_body32 = asyncio.get_event_loop_policy().new_event_loop().run_until_complete( + _notify32([SOC_FLOOR + 30.0, SOC_FLOOR - 1.0], [A_PACK_FLOOR, A_PACK_FLOOR])) +print(f" two-pack notification tail: ...{_body32[-200:]}") +check("the notification an operator reads at 19:00 NAMES THE PACK that stopped " + "the site, and names only that one. 'the battery is at its floor' on a " + "site with two batteries is an instruction to go and look at both, and " + "the wrong one is the one that is fine", + "SAFETY-32" in _body32 and "inverter-b" in _body32 + and "inverter-a" not in _body32.split("WHICH PACK")[-1]) +# The mirror again, on the SoC axis: swap which pack is empty and the name +# swaps. A hardwired `self.inverters[1].name` passes the line above. +_body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete( + _notify32([SOC_FLOOR - 1.0, SOC_FLOOR + 30.0], [A_PACK_FLOOR, A_PACK_FLOOR])) +check("...and with the SAME two readings swapped between the packs it names " + "inverter-a instead - so the sentence follows the measurement rather " + "than a position in the roster", + "inverter-a" in _body32b + and "inverter-b" not in _body32b.split("WHICH PACK")[-1]) + + +# ⚠️ AC 5, THE OTHER HALF: THE SAME METHOD ON A ROSTER OF ONE MUST PRINT WHAT IT +# ALWAYS PRINTED. This card adds a sentence to an alarm every existing install +# already receives, so "byte-identical at N=1" is asserted against the shipped +# text rather than assumed - and against the text of the very body the two-pack +# run above produced, minus its added sentence. +async def _notify32_one(): + ctl, hs = built(TMP / "s32n1.db") + ctl.inverters[0].soc = SOC_FLOOR - 1.0 + ctl.peak_guard = PeakDecision( + engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP, + projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0, + floor_limited=True, soc=SOC_FLOOR - 1.0, + soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR), + soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x") + ctl._peak_floor_limited = False + await ctl.check_peak_floor_limited() + return hs.last_message("goodwe_peak_soc_floor") + + +_body32one = asyncio.get_event_loop_policy().new_event_loop().run_until_complete( + _notify32_one()) +check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack " + "sentence at all - byte for byte the message SAFETY-25 shipped. There is " + "one battery, naming it is noise, and this alarm reaches every existing " + "install", + "SAFETY-32" not in _body32one and "WHICH PACK" not in _body32one + and _body32one == _body32.split("\n\nSAFETY-32")[0] + and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one) + + +# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR -- +# ⚠️ THIS IS ROUND 4's BLOCKER, AND IT IS THE SAME FAMILY ONE ARGUMENT OVER. +# Everything from 12c2 to 12h grades reductions on the FLOOR axis: the max +# inside `binding_soc_floor()`, the roster handed to it, `Controller.packs`, and +# `pack_floor_pct()`'s conjunction. `Controller.pack_floor_soc` is the MINIMUM +# over the same roster, handed to the SAME `peakguard.decide()` call as `soc=`, +# and until this block nothing graded its reduction past N=2. +# +# ⚠️ THE PAIRING IS DOCUMENTED, WHICH IS WHY HARDENING ONE HALF IS NOT ENOUGH. +# main.py's call site says "MAXIMUM here pairs with the MINIMUM in +# `pack_floor_soc`: both reductions move the verdict towards refusing a shave +# and away from spending a pack". A conjunction whose two halves have different +# amounts of evidence behind them is exactly as strong as its weaker half. +# +# THE SURVIVORS THIS BLOCK EXISTS FOR, all confirmed GREEN across all 19 files +# at 52380c0 and again on the rebase onto 3934ad7: +# +# min(float(v) for v in vals) -> ... for v in vals[:2] +4 bytes +# -> ... for v in vals[-2:] +5 bytes +# -> ... for v in vals[:3] +4 bytes +# +# while `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)` and the +# mean were all killed. The suite's separating power on the SoC axis stopped at +# exactly N=2 - which is the state the FLOOR axis was in before round 3, and +# which round 3 named as its own blocker 2. The evidence is a fixture of two +# packs, and A FIXTURE OF N PACKS CANNOT SEPARATE REDUCTIONS THAT AGREE ON N. +# +# IT CHANGES A REAL ANSWER, which is why it is a defect and not a tidiness +# point: a three-pack roster at 50 / 60 / 11 % with the emptiest pack THIRD. +# The shipped property answers 11 %; `vals[:2]` answers 50 %. The site is then +# graded for discharge PERMISSION against a pack 39 points fuller than the +# emptiest one, and spends a pack its own BMS stops - through `sim/inject.py`, +# which runs no validator rule at all. +# +# ⚠️ AND PRODUCTION IS DEGENERATE ON THE FLOOR AXIS, WHICH MAKES THIS AXIS THE +# LIVE ONE. SPIKE-04 reads pack B as 4 x Dyness DL5.0C at DoD 90 [FROM THE +# DATASHEET AND THE SERIAL - inverter B does not exist and nothing about it has +# been measured], so its cut-out is 10 %, the same as pack A's MEASURED 10 %. +# Two packs that floor at the same value make `binding_soc_floor()` answer the +# option whatever it reduces. The SoC axis has no such degeneracy: two batteries +# at the same state of charge is a coincidence, not a configuration. +# +# THE TREATMENT IS ROUND 3's, TRANSFERRED: a roster SET with the extreme at +# every position, a LENGTH SWEEP, and an EXACT STRUCTURAL PIN on the reduction's +# argument. Nothing below is a measurement of a second or third pack. +_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM = 11.0, 50.0, 60.0 +_S_TRIO_SIM = (_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM) + + +def _soc_reaching_decide(tag, *socs): + """Drive ONE REAL `Controller.cycle()` on an N-pack roster and report the + SoC `peakguard.decide()` was actually handed, read back off the decision + main.py itself filed (`PeakDecision.soc` is the argument, echoed). + + ⚠️ THE EXACT MIRROR OF `_floor_reaching_decide` ABOVE, on the other axis. + Not a spy and not a grep: the shipped `cycle()` runs, the shipped + `pack_floor_soc` runs, and the observable is a field of the decision the + controller stored - so the whole path from `Controller.inverters` to the + guard's argument is under test. Every pack carries the SAME cut-out + (inverter A's measured 10 %, which is below `peak_soc_floor`, so + `binding_soc_floor()` answers the option for every roster here); the only + thing that varies between calls is the packs' states of charge. That is + habit 1: with the FLOOR axis held flat, nothing but the SoC reduction can + move these answers. + """ + def _prep(ctl): + for _i in range(len(socs) - 1): + _sfx = chr(ord("b") + _i) + second_inverter(ctl, name=f"inverter-{_sfx}", + setpoint=f"number.sp_{_sfx}") + for inv, so in zip(ctl.inverters, socs): + inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR + + ctl, _hs, _net, _rem = wired_over_limit( + TMP / f"s32s_{tag}.db", LIVE_MAX_W * 0.3, prepare=_prep) + return ctl, ctl.peak_guard + + +# BASELINE: three packs, all of them ABOVE `peak_soc_floor`, so the guard is +# permitted and sizes a real forced discharge. Every run below varies one thing +# from this baseline (habit 7) - which pack is the empty one, and nothing else. +_s_ctl_ok, _s_ok = _soc_reaching_decide("ok3", _S_FULL_SIM, _S_MID_SIM, + SOC_BETWEEN) +# THE SET: the SAME THREE readings, the emptiest at EACH position in turn. A +# reduction that reads a proper subset of the positions misses the empty pack on +# the run where it sits at the position that subset drops, and answers a fuller +# pack instead. +_s3_runs = [] +for _pos in range(3): + _sc3 = [_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM] + _sc3.remove(_S_EMPTY_SIM) + _sc3.insert(_pos, _S_EMPTY_SIM) + _c3s, _d3s = _soc_reaching_decide(f"s3p{_pos}", *_sc3) + _s3_runs.append((f"3-pack, emptiest at index {_pos}", _sc3, _c3s, _d3s)) +# ...and the two-pack mirror, which is the shape production reaches first. +_s2_runs = [] +for _pos in range(2): + _sc2 = [_S_FULL_SIM, _S_EMPTY_SIM] if _pos else [_S_EMPTY_SIM, _S_FULL_SIM] + _c2s, _d2s = _soc_reaching_decide(f"s2p{_pos}", *_sc2) + _s2_runs.append((f"2-pack, emptiest at index {_pos}", _sc2, _c2s, _d2s)) +print(f" REAL cycles, every cut-out held at inverter-a's measured " + f"{A_PACK_FLOOR:.0f} %, peak_soc_floor {SOC_FLOOR:.0f} % (every pack past " + f"the first is SIMULATED):") +print(f" 3-pack baseline, none empty: SoCs " + f"{[f'{s:g}' for s in (_S_FULL_SIM, _S_MID_SIM, SOC_BETWEEN)]} % -> " + f"decide() got soc={_s_ok.soc:g} %, floor_limited={_s_ok.floor_limited}") +for _rn, _sc, _ctl_s, _dec_s in _s3_runs + _s2_runs: + print(f" {_rn}: SoCs {[f'{s:g}' for s in _sc]} % -> decide() got " + f"soc={_dec_s.soc:g} %, floor_limited={_dec_s.floor_limited}") +check("⚠️ THE POSITIONAL PIN ON THE SoC AXIS, AND IT NEEDS THREE PACKS TO " + "EXIST. On a roster of THREE the SoC that reaches `decide()` is the " + "EMPTIEST pack wherever on the roster that pack sits - index 0, index 1 " + "and index 2 all answer 11 %, and all three rosters carry the SAME " + "multiset of readings. A property that reduced any PROPER SUBSET of the " + "positions would answer a fuller pack on the run whose empty one sits at " + "the position it dropped. `vals[:2]` and `vals[-2:]` - the +4- and " + "+5-byte mutations that walked through every fixture in this file up to " + "round 3, because at N=2 they ARE the roster - go red here", + [r[3].soc for r in _s3_runs] == [_S_EMPTY_SIM] * 3 + and all(len(r[2].inverters) == 3 for r in _s3_runs) + and all(sorted(inv.soc for inv in r[2].inverters) + == sorted(_S_TRIO_SIM) for r in _s3_runs) + and [[inv.soc for inv in r[2].inverters] for r in _s3_runs] + == [r[1] for r in _s3_runs]) +check("...and the TWO-pack mirror, which is what production reaches the moment " + "inverter B is installed: swap the same two readings between the packs " + "and the answer is the emptier one both times. `vals[:1]` and `vals[-1:]` " + "- grade the site against pack A alone, or against the last pack alone - " + "each agree with the roster on one of these two runs and disagree on the " + "other, so neither survives the pair", + [r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2 + and [[inv.soc for inv in r[2].inverters] for r in _s2_runs] + == [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]]) +check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a " + "defect rather than a reporting difference. With the emptiest pack at " + "11 % - below the 20 % EMS floor - every one of those five sites is " + "FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline " + "with its emptiest pack at 25 % is permitted and sizes a positive one. A " + "reduction that dropped the empty pack's position would grade this site " + "for PERMISSION against a pack 39 points fuller and spend, through " + "`sim/inject.py`, a pack its own BMS stops", + all(r[3].floor_limited is True and r[3].floor_w == 0.0 + for r in _s3_runs + _s2_runs) + and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0 + and _s_ok.soc == SOC_BETWEEN) +check("...and the precondition that makes the six runs comparable (habit 1): " + "every pack in every roster carries the SAME cut-out, so " + "`binding_soc_floor()` answers the SAME site floor on all of them and the " + "FLOOR axis cannot be what separated the answers; the three readings " + "really are three different numbers; and the empty one is really below " + "the EMS floor while the other two are really above it - two readings on " + "the same side of it would make every line here vacuous", + len({r[3].soc_floor for r in _s3_runs + _s2_runs}) == 1 + and _s_ok.soc_floor == _s3_runs[0][3].soc_floor == float(SOC_FLOOR) + and all(inv.bms_soc_floor == A_PACK_FLOOR + for r in _s3_runs + _s2_runs for inv in r[2].inverters) + and len({_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM}) == 3 + and _S_EMPTY_SIM < SOC_FLOOR < SOC_BETWEEN < _S_MID_SIM < _S_FULL_SIM) + +# ⚠️ AND THE ENUMERATION ITSELF, COMPUTED RATHER THAN CLAIMED - 12g's table on +# the other axis. Every positional slice of `vals`, applied to the REAL +# `Controller.inverters` each cycle above actually carried, compared against the +# SoC that REALLY reached `decide()` on that run. A slice with an empty kill +# list is a live survivor and this line goes red naming it. +# +# ⚠️ WHAT THIS DOES NOT CLAIM. It grades the members listed and nothing wider - +# a rewrite that is not a positional slice (a different reduction, a filter on +# pack NAME, a sort before the slice) is not covered here. `max` and the mean +# are graded on the same runs immediately below. The table is PRINTED so a +# reader can see exactly how far the claim reaches. +_S_RUNS = ([("3-pack baseline, none empty", _s_ctl_ok, _s_ok)] + + [(rn, ctl, dec) for rn, _sc, ctl, dec in _s3_runs + _s2_runs]) + + +def _soc_reduced(vals, fn): + """`pack_floor_soc`'s body with ONE thing changed: what `min()` iterates. + + The None guard still runs over the WHOLE list, because these mutations are + inside the `min()` generator and not in the guard - the guard is graded + separately below. An empty slice makes the real `min()` raise, which is a + crashing cycle and therefore a kill; it is reported as a distinct answer + rather than swallowed. + """ + if not vals or any(v is None for v in vals): + return None + sl = fn(vals) + return float(min(float(v) for v in sl)) if sl else "min() of an empty slice" + + +_SOC_SLICE_FAMILY = { + "vals[:1]": lambda vs: vs[:1], + "vals[-1:]": lambda vs: vs[-1:], + "vals[:2]": lambda vs: vs[:2], + "vals[-2:]": lambda vs: vs[-2:], + "vals[1:]": lambda vs: vs[1:], + "vals[:-1]": lambda vs: vs[:-1], + "vals[1:-1]": lambda vs: vs[1:-1], + "vals[::2]": lambda vs: vs[::2], +} +_SOC_REDUCTION_FAMILY = { + "max(vals)": lambda vs: max(vs), + "mean(vals)": lambda vs: sum(vs) / len(vs), + "median(vals), upper": lambda vs: sorted(vs)[len(vs) // 2], + "sorted(vals)[1] (second emptiest)": lambda vs: sorted(vs)[min(1, len(vs) - 1)], +} +_soc_slice_kills = { + _nm: [rn for rn, ctl, dec in _S_RUNS + if _soc_reduced([inv.soc for inv in ctl.inverters], _fn) != dec.soc] + for _nm, _fn in _SOC_SLICE_FAMILY.items()} +_soc_red_kills = { + _nm: [rn for rn, ctl, dec in _S_RUNS + if _fn([inv.soc for inv in ctl.inverters]) != dec.soc] + for _nm, _fn in _SOC_REDUCTION_FAMILY.items()} +print(" THE POSITIONAL-SLICE FAMILY inside `pack_floor_soc`, and which run " + "separates each from the whole roster:") +for _nm, _k in _soc_slice_kills.items(): + print(f" {_nm:<12} killed by: " + + ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***")) +print(" ...and the NON-slice rewrites of the reduction itself:") +for _nm, _k in _soc_red_kills.items(): + print(f" {_nm:<34} killed by: " + + ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***")) +check("⚠️ EVERY positional slice in the enumerated family answers something " + "OTHER than the SoC that really reached `decide()` on at least one of " + "the six runs above - and it is the THREE-pack runs that kill `[:2]` and " + "`[-2:]`, which the two-pack runs structurally could not, by exactly the " + "argument 12c2 makes about N packs and reductions that agree on N. The " + "kill list is computed against the REAL `Controller.inverters` each cycle " + "carried and the REAL `PeakDecision.soc` it produced, so a member with an " + "empty list is a live survivor and takes this line red naming it. This " + "grades the LISTED members only; the printed table is its extent", + all(_soc_slice_kills.values())) +check("...and the same for the rewrites that are not slices at all: `max` - " + "the FULLEST pack, which is `binding_soc_floor()`'s reduction pasted onto " + "the wrong axis and the single most likely wrong edit here - the mean, " + "the median and the second-emptiest each disagree with the shipped answer " + "on at least one run. `max(vals)` on the index-2 run answers 60 % where " + "the site is graded at 11 %", + all(_soc_red_kills.values())) +check("...and the baseline the tables are measured against is the shipped " + "property's OWN answer, not a re-derivation: on every run, the SoC that " + "reached `decide()` equals `Controller.pack_floor_soc` on the roster that " + "cycle carried. If it did not, every kill list above would be measuring " + "the wrong thing", + all(dec.soc == ctl.pack_floor_soc for _rn, ctl, dec in _S_RUNS) + and len(_S_RUNS) == 6 + and sorted(len(ctl.inverters) for _rn, ctl, dec in _S_RUNS) + == [2, 2, 3, 3, 3, 3]) + +# ⚠️ AND THE HONEST LIMIT OF EVERY RUN ABOVE, FOUND BY THIS ROUND'S OWN BATTERY +# AND NOT ARGUED FROM: `vals[:3]` and `vals[-3:]` SURVIVE all six behavioural +# runs, for the same reason `[:2]` survived the two-pack ones - THE LONGEST +# ROSTER HERE IS THREE, and a slice that keeps three consecutive positions IS +# the roster at N=3. Adding a four-pack cycle would close those two and hand +# `[:4]` and `[-4:]` the same free pass. THE REGRESS DOES NOT TERMINATE IN A +# FIXTURE - round 3's lesson, arriving unchanged on this axis. +# +# So the same two halves, each saying what it reaches. First the SWEEP, which is +# cheap here because `pack_floor_soc` is a PROPERTY and needs no cycle to +# evaluate: rosters of 1 to 12 real `Inverter`s, the empty pack at EVERY +# position of each, and the property must answer the emptiest every time. That +# kills every fixed-width head or tail window narrower than the longest roster +# at once, rather than one mutation at a time. +_S_SWEEP_N = 12 + + +def _roster_of(socs): + """A real Controller carrying one real `Inverter` per reading given.""" + ctl, _hs = built(TMP / f"s32sw{len(socs)}.db") + for _i in range(len(socs) - 1): + second_inverter(ctl, name=f"sweep-{_i}", setpoint=f"number.sw_{_i}") + for inv, so in zip(ctl.inverters, socs): + inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR + return ctl + + +_s_sweep_rows = [] +for _n in range(1, _S_SWEEP_N + 1): + for _pos in range(_n): + _sc = [_S_FULL_SIM] * _n + _sc[_pos] = _S_EMPTY_SIM + _s_sweep_rows.append((_n, _pos, tuple(_sc), + _roster_of(_sc).pack_floor_soc)) +_s_sweep_bad = [(n, p, sc, got) for n, p, sc, got in _s_sweep_rows + if got != min(sc)] +_s_head_kill = {w: any(min(sc[:w]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows) + for w in range(1, _S_SWEEP_N + 1)} +_s_tail_kill = {w: any(min(sc[-w:]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows) + for w in range(1, _S_SWEEP_N + 1)} +print(f" LENGTH SWEEP on the real property (all SIMULATED past pack A): " + f"{len(_s_sweep_rows)} rosters, lengths 1..{_S_SWEEP_N}, the " + f"{_S_EMPTY_SIM:g} % pack at EVERY position of each among " + f"{_S_FULL_SIM:g} % siblings -> " + f"{len(_s_sweep_rows) - len(_s_sweep_bad)} answer the emptiest, " + f"{len(_s_sweep_bad)} do not") +print(" fixed-width windows killed by the sweep: head widths " + + ",".join(str(w) for w in sorted(_s_head_kill) if _s_head_kill[w]) + + "; tail widths " + + ",".join(str(w) for w in sorted(_s_tail_kill) if _s_tail_kill[w]) + + f"; NOT reached: head/tail width {_S_SWEEP_N} and wider") +check("⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`. Over rosters " + "of 1 to 12 real inverters with the empty pack at every position of each, " + "the property answers the EMPTIEST every time - so every head window " + "`min(vals[:w])` and every tail window `min(vals[-w:])` of width 1 to 11 " + "disagrees with it on at least one roster here, and that is the whole " + "positional family below width 12 closed in one property rather than one " + "mutation at a time. ⚠️ THIS IS THE REAL PROPERTY ON A REAL ROSTER, not a " + "re-implementation: `_roster_of()` appends real `Inverter` objects and " + "reads `ctl.pack_floor_soc`, so it also sees a rewrite of the " + "comprehension that builds `vals`", + not _s_sweep_bad + and len(_s_sweep_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2 + and all(_s_head_kill[w] and _s_tail_kill[w] + for w in range(1, _S_SWEEP_N))) +check("⚠️ ...AND WIDTH 12 AND WIDER IS HONESTLY NOT REACHED, recorded rather " + "than papered over - the same ceiling 12c2's sweep records on the floor " + "axis. No roster in this file is longer than 12, so `min(vals[:12])` IS " + "`min(vals)` on every one of them and no check here can tell them apart. " + "A 13-pack roster would close width 12 and hand width 13 the same free " + "pass. That is why the STRUCTURAL PIN below exists alongside these runs, " + "and this line asserts the unreachedness itself so a later sweep that " + "lengthens the rosters takes it red and forces the sentence rewritten", + not _s_head_kill[_S_SWEEP_N] and not _s_tail_kill[_S_SWEEP_N] + and max(len(sc) for _n, _p, sc, _g in _s_sweep_rows) == _S_SWEEP_N) + +# ...and the NONE GUARD is a reduction over roster length TOO, and it had +# exactly the same fixture problem: 12d sets the SECOND of two packs to None, so +# `any(v is None for v in vals[:1])` - grade only pack A for silence - answers +# "all present" there and hands a list containing None to `min()`. The sweep +# below moves the silent pack along the roster at every length, which is the +# data that reaches it. THE DIRECTION MATTERS: None must WIN, because a silent +# pack is a pack nothing can grade and `decide()` treats None as floor-limited. +def _pfs_answer(ctl): + """`pack_floor_soc`, with a raise reported as an ANSWER rather than + ending the file. A guard narrowed to a slice does not return a wrong + number - it lets a None reach `min(float(v) ...)` and raises TypeError - + and a check that dies is a check that names nothing.""" + try: + return ctl.pack_floor_soc + except Exception as exc: # noqa: BLE001 - reported + return f"raised {type(exc).__name__}" + + +_s_none_rows = [] +for _n in range(1, _S_SWEEP_N + 1): + for _pos in range(_n): + _ctl_n = _roster_of([_S_FULL_SIM] * _n) + _ctl_n.inverters[_pos].soc = None + _s_none_rows.append((_n, _pos, _pfs_answer(_ctl_n))) +print(f" NONE-GUARD SWEEP: {len(_s_none_rows)} rosters, ONE silent pack at " + f"every position of every length 1..{_S_SWEEP_N} -> " + f"{sum(1 for _n, _p, g in _s_none_rows if g is None)} answer None") +check("⚠️ THE NONE GUARD IS THE SAME FAMILY. One silent pack at ANY position of " + "ANY roster length makes `pack_floor_soc` None - never a number, and " + "never the minimum of the packs that did answer. A guard narrowed to a " + "positional slice would let the site spend the packs that answered while " + "one sat unmeasured; 12d's two-pack fixture, with the SECOND pack silent, " + "cannot see a guard narrowed to the first", + all(g is None for _n, _p, g in _s_none_rows) + and len(_s_none_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2) + +# ⚠️ THE STRUCTURAL HALF: the reduction's ARGUMENT, pinned EXACTLY, at any N. +# This is 12g's treatment applied inside a property rather than at a call site, +# and it is NOT a substring test - `"vals" in src` is true of every slice of +# `vals`, whereas `ast.unparse(iter) == "vals"` is false of all of them. +# `vals[:3]` unparses to `vals[:3]`. +# +# THREE expressions are pinned, because there are three reductions over roster +# length in this one property and the runs above reach them unevenly: +# the LIST `[inv.soc for inv in self.inverters]` - the roster itself +# the GUARD `any(v is None for v in vals)` +# the REDUCTION `min(float(v) for v in vals)` +# +# ⚠️ AND THE PIN IS BLIND TO WHAT THE RUNS SEE, which is why both halves are +# here. It cannot see `min` become `max` (identical shape, different name - +# killed by the roster set), it cannot see `inv.soc` become `inv.bms_soc_floor` +# (killed by the runs' held-flat cut-outs), and it cannot see a change inside +# `Inverter.soc` at all. +_pfs_fn = next(n for n in ast.walk(ast.parse(_mainsrc)) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + and n.name == "pack_floor_soc") +_pfs_vals = next(n.value for n in _pfs_fn.body + if isinstance(n, ast.Assign) + and [ast.unparse(t) for t in n.targets] == ["vals"]) + + +def _gen_iter_of(fn_node, name): + """The iterable of the comprehension inside the single `name(...)` call.""" + call = next(n for n in ast.walk(fn_node) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id == name) + comp = call.args[0] + return comp.generators[0].iter if len(comp.generators) == 1 else None + + +_pfs_min_iter = _gen_iter_of(_pfs_fn, "min") +_pfs_any_iter = _gen_iter_of(_pfs_fn, "any") +print(f" pack_floor_soc reduces: list over `{ast.unparse(_pfs_vals.generators[0].iter)}`" + f", any() over `{ast.unparse(_pfs_any_iter)}`, min() over " + f"`{ast.unparse(_pfs_min_iter)}`") +check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduces " + "the WHOLE list `vals` - a bare Name, not a Subscript, not a call, not a " + "second comprehension - and `vals` is built by ONE comprehension over the " + "WHOLE property `self.inverters`, an attribute of self and not a slice of " + "one. So EVERY slice of either is red here at EVERY roster length, " + "including `vals[:3]` and `vals[-3:]`, which survive all six runs above " + "because the longest roster they drive is three packs long, and including " + "`self.inverters[:2]` in the comprehension, which the sweep sees only up " + "to length 12. This grades the code's SHAPE and nothing about the values; " + "what the expression MEANS is graded by the runs", + isinstance(_pfs_min_iter, ast.Name) and _pfs_min_iter.id == "vals" + and ast.unparse(_pfs_min_iter) == "vals" + and isinstance(_pfs_vals, ast.ListComp) + and len(_pfs_vals.generators) == 1 + and not _pfs_vals.generators[0].ifs + and isinstance(_pfs_vals.generators[0].iter, ast.Attribute) + and isinstance(_pfs_vals.generators[0].iter.value, ast.Name) + and _pfs_vals.generators[0].iter.value.id == "self" + and _pfs_vals.generators[0].iter.attr == "inverters" + and ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters") +check("...and the NONE GUARD reduces that same whole list, pinned the same way. " + "`any(v is None for v in vals[:1])` is +4 bytes and leaves every other " + "line in this file identical; the sweep above reaches it up to length 12 " + "and this reaches it at every length", + isinstance(_pfs_any_iter, ast.Name) and _pfs_any_iter.id == "vals" + and ast.unparse(_pfs_any_iter) == "vals") +check("...and the counterfactual that shows these pins are not vacuous (habit " + "7): the very slices they exist for really do unparse to something else, " + "so the equalities above are doing work rather than being trivially true " + "of anything shaped like an argument", + all(ast.unparse(ast.parse(f"vals{sl}", mode="eval").body) != "vals" + and ast.unparse(ast.parse(f"self.inverters{sl}", + mode="eval").body) != "self.inverters" + for sl in ("[:1]", "[-1:]", "[:2]", "[-2:]", "[:3]", "[-3:]", + "[:12]", "[-12:]", "[1:]", "[:-1]", "[1:-1]", "[::2]")) + and ast.unparse(ast.parse("list(reversed(vals))", + mode="eval").body) != "vals") + +# ...and BOTH consumers of this property are pinned to it by name, so a reduction +# hardened here cannot be bypassed by a call site that stops using it. The peak +# guard's `soc=` is already pinned in part 4; the OTHER one is arbitrage's, and +# it is a safety consumer too - `soc_floor_pct` is what stops a sell. +_arb_calls = [n for n in ast.walk(ast.parse(_mainsrc)) + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + and n.func.attr == "decide" + and isinstance(n.func.value, ast.Name) + and n.func.value.id == "arbitrage"] +_arb_soc = ast.unparse(next(k.value for k in _arb_calls[0].keywords + if k.arg == "soc")) +print(f" main.py's arbitrage.decide(soc=...) is `{_arb_soc}` " + f"({len(_arb_calls)} call site)") +check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()` " + "is handed the SAME `self.pack_floor_soc` and grades it against " + "`soc_floor_pct` to decide whether to SELL - so the mutation this block " + "exists for spends a pack on the arbitrage path as well as the peak-guard " + "path, and hardening the property covers both. A call site that reverted " + "to a per-unit reading, or defaulted it, would take this line red", + len(_arb_calls) == 1 and _arb_soc == "self.pack_floor_soc") + +# --- 12j. THE ENUMERATION ITSELF, AS A RUNNING CHECK, ACROSS THE PACKAGE ------ +# ⚠️ THIS CARD HAS NOW BEEN REJECTED FOUR TIMES FOR THE SAME SHAPE ONE STEP +# OVER: a reduction over roster length whose fixture could not separate it from +# a slice of the roster. Round 2 hardened `binding_soc_floor()`; round 3 found +# the call site's argument; round 4 found `pack_floor_soc`, the SIBLING ARGUMENT +# of the same `decide()` call, and wrote the first version of this block - which +# enumerated the family inside `Controller` AND STOPPED AT THE CLASS BOUNDARY. +# Round 5's blocker was four survivors in validate.py, one module over, where +# rules 5 and 6 reduce over `unit_caps_indexed()`. Closing them one at a time +# has cost five rounds. This block does not do that any more. +# +# THE INSTRUMENT IS NOW PACKAGE-WIDE AND THE MODULE LIST IS COMPUTED, NOT +# WRITTEN. Every `.py` under `app/` is read off disk and parsed; nothing here +# names a module, so a module added tomorrow is graded on the day it is added. +# ⚠️ A HARDCODED MODULE LIST WOULD ITSELF BE "A CHECK WHOSE DATA CANNOT REACH +# THE FAILURE IT NAMES" (MUTATION.md), which is the defect this file exists to +# complain about: the list would go on passing while the reduction it was +# supposed to cover lived in the module nobody added to it. +# +# WHAT COUNTS AS A ROSTER, DERIVED RATHER THAN DECLARED. The seed is ONE +# expression - reading the `inverters` option - and everything else is transitive +# closure over the AST: +# * a function all of whose returns are roster-carrying is roster-producing +# (`roster_entries` -> `build_roster` -> `unit_caps_indexed` -> `unit_caps`); +# * a name assigned a roster-carrying value carries it, and so does a list +# ACCUMULATED inside a loop over one (`caps`, `roster`, `problems`); +# * an attribute or property that answers one carries it (`self.inverters`, +# `Controller.packs`, `Controller.units`, `Controller.live_units()`); +# * an argument at a call site that is handed one taints THAT PARAMETER in the +# callee, which is how the pin crosses into `peakguard.binding_soc_floor`, +# `arbiter.resolve`, `allocator.plan`, `circulation.circulating_w`, +# `maintenance.arbitrate` and `store.migrate_maint_keys` without being told +# they exist; +# * and A SLICE OF A ROSTER IS STILL A ROSTER, so a mutation cannot cut the +# taint and hide the reductions downstream of itself. +# An INDEX of a roster is NOT a roster: `self.inverters[0]` is one unit, and +# treating it as a roster would spread this analysis over every unit attribute +# in the package. +# +# THE CLAIM, in one line: at every place in `app/` where a roster-carrying value +# is REDUCED or ITERATED, the whole of it arrives. A `[:1]`, a `[-1:]`, a `[:2]` +# or a `[0]` between the roster and the reduction is red on the line it is +# written, at every roster length, in every configuration, and with no fixture +# required. +_APP_SRC = {p.stem: p.read_text(encoding="utf-8") + for p in sorted((HERE / "app").glob("*.py"))} +_ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure. +_WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed", + "filter", "iter", "set", "dict"} +_REDUCERS = {"min", "max", "sum", "any", "all", "len", "sorted", "next", "list", + "tuple", "set", "reversed", "enumerate", "zip", "map", "filter"} + + +def _roster_sites(sources): + """Every roster-carrying expression in reduction/iteration position. + + Returns `(rows, meta)`; each row is + `(module, lineno, col, end_lineno, end_col, qualname, kind, source, sliced)`. + """ + trees = {m: ast.parse(s) for m, s in sources.items()} + funcs, importmap = {}, {} + + for m, tree in trees.items(): + def walk(node, qual): + for ch in ast.iter_child_nodes(node): + if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef)): + funcs[(m, qual + ch.name)] = ch + walk(ch, qual + ch.name + ".") + elif isinstance(ch, ast.ClassDef): + walk(ch, qual + ch.name + ".") + else: + walk(ch, qual) + walk(tree, "") + for n in ast.walk(tree): + if isinstance(n, ast.ImportFrom): + if n.module: + base = n.module.split(".")[-1] + if base in trees: + for a in n.names: + importmap[(m, a.asname or a.name)] = (base, a.name) + else: + # `from . import peakguard` - a MODULE, not a name inside + # one. This branch is the only reason the pin can see + # peakguard.py, and it was missing from the first draft. + for a in n.names: + if a.name in trees: + importmap[(m, a.asname or a.name)] = (a.name, None) + elif isinstance(n, ast.Import): + for a in n.names: + base = a.name.split(".")[-1] + if base in trees: + importmap[(m, a.asname or base)] = (base, None) + + t_funcs, t_params, t_attrs, t_locals = set(), set(), set(), {} + bodies = {} + + def body_nodes(fn): + if id(fn) not in bodies: + out = [] + + def rec(n): + for ch in ast.iter_child_nodes(n): + if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef, + ast.Lambda)): + continue + out.append(ch) + rec(ch) + rec(fn) + bodies[id(fn)] = out + return bodies[id(fn)] + + def callee(m, qual, node): + f = node.func + if isinstance(f, ast.Name): + if (m, f.id) in importmap: + om, oname = importmap[(m, f.id)] + if oname and (om, oname) in funcs: + return (om, oname) + if (m, f.id) in funcs: + return (m, f.id) + elif isinstance(f, ast.Attribute): + if isinstance(f.value, ast.Name): + if f.value.id == "self" and "." in qual: + cls = qual.rsplit(".", 1)[0] + if (m, cls + "." + f.attr) in funcs: + return (m, cls + "." + f.attr) + if (m, f.value.id) in importmap: + om, _ = importmap[(m, f.value.id)] + if (om, f.attr) in funcs: + return (om, f.attr) + return None + + def derived(node, m, qual): + if node is None: + return False + if isinstance(node, ast.Subscript): + # A SLICE of a roster is still a roster - a defective one - so the + # taint survives the mutation and every reduction downstream of it + # reports too. An INDEX is one unit and is not a roster. + return (isinstance(node.slice, ast.Slice) + and derived(node.value, m, qual)) + if isinstance(node, ast.Call): + f = node.func + if (isinstance(f, ast.Attribute) and f.attr in ("get", "pop") + and node.args and isinstance(node.args[0], ast.Constant) + and node.args[0].value == _ROSTER_OPTION): + return True # THE SEED + if isinstance(f, ast.Name) and f.id in _WRAPPERS: + return any(derived(a, m, qual) for a in node.args) + tgt = callee(m, qual, node) + return bool(tgt and tgt in t_funcs) + if isinstance(node, ast.Name): + return (node.id in t_locals.get((m, qual), ()) + or (m, qual, node.id) in t_params) + if isinstance(node, ast.Attribute): + if isinstance(node.value, ast.Name) and node.value.id == "self": + if node.attr in t_attrs: + return True + cls = qual.rsplit(".", 1)[0] if "." in qual else None + return bool(cls and (m, cls + "." + node.attr) in t_funcs) + return False + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, + ast.GeneratorExp)): + return any(derived(g.iter, m, qual) for g in node.generators) + if isinstance(node, ast.IfExp): + return (derived(node.body, m, qual) + or derived(node.orelse, m, qual)) + if isinstance(node, ast.BoolOp): + return any(derived(v, m, qual) for v in node.values) + if isinstance(node, (ast.List, ast.Tuple)): + return any(isinstance(e, ast.Starred) and derived(e.value, m, qual) + for e in node.elts) + return False + + for _ in range(40): # transitive closure, to fixpoint + before = (len(t_funcs), len(t_params), len(t_attrs), + sum(len(v) for v in t_locals.values())) + for (m, qual), fn in funcs.items(): + loc = t_locals.setdefault((m, qual), set()) + for n in body_nodes(fn): + if (isinstance(n, (ast.Assign, ast.AnnAssign)) + and getattr(n, "value", None) is not None + and derived(n.value, m, qual)): + for t in (n.targets if isinstance(n, ast.Assign) + else [n.target]): + if isinstance(t, ast.Name): + loc.add(t.id) + elif (isinstance(t, ast.Attribute) + and isinstance(t.value, ast.Name) + and t.value.id == "self"): + t_attrs.add(t.attr) + if (isinstance(n, (ast.For, ast.AsyncFor)) + and derived(n.iter, m, qual)): + # A list ACCUMULATED one-per-unit is a roster too: `caps`, + # `roster`, `problems` are all built this way, and round 5's + # blocker was two reductions over one of them. + for c in ast.walk(n): + if (isinstance(c, ast.Call) + and isinstance(c.func, ast.Attribute) + and c.func.attr in ("append", "extend") + and isinstance(c.func.value, ast.Name)): + loc.add(c.func.value.id) + if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + and n.func.attr == "extend" + and isinstance(n.func.value, ast.Name) + and n.args and derived(n.args[0], m, qual)): + loc.add(n.func.value.id) + if isinstance(n, ast.Return) and derived(n.value, m, qual): + t_funcs.add((m, qual)) + if isinstance(n, ast.Call): + tgt = callee(m, qual, n) + if tgt: + cfn = funcs[tgt] + pos = [a.arg for a in + cfn.args.posonlyargs + cfn.args.args] + off = 1 if (pos and pos[0] == "self") else 0 + for j, a in enumerate(n.args): + if j + off < len(pos) and derived(a, m, qual): + t_params.add((tgt[0], tgt[1], pos[j + off])) + for kw in n.keywords: + if kw.arg and derived(kw.value, m, qual): + t_params.add((tgt[0], tgt[1], kw.arg)) + after = (len(t_funcs), len(t_params), len(t_attrs), + sum(len(v) for v in t_locals.values())) + if after == before: + break + + def shortens(sub): + """Does this subscript change HOW MANY members come out? + + A SLICE does, and so does a CONSTANT index (N in, one out). A subscript + by a NAME does not: `weights[u]`, `cap[u]` and `add[u]` in + `allocator._fill` are keyed lookups into a roster-derived MAPPING, which + is what iterating it looks like. Flagging those would make this check cry + wolf on every dict the roster builds - it did, on four sites, before this + predicate was written. + """ + sl = sub.slice + if isinstance(sl, ast.Slice): + return True + if isinstance(sl, ast.Constant) and isinstance(sl.value, int): + return True + return (isinstance(sl, ast.UnaryOp) and isinstance(sl.op, ast.USub) + and isinstance(sl.operand, ast.Constant)) + + def sliced(node, m, qual): + return any(isinstance(n, ast.Subscript) and shortens(n) + and derived(n.value, m, qual) for n in ast.walk(node)) + + def leaves(node, m, qual): + """The roster REFERENCES inside a reduced expression. + + The place a slice goes is the roster reference, not the whole argument: + `sum(i.max_w for i in self.inverters)` is defeated by slicing + `self.inverters`, and slicing the generator would be a TypeError rather + than a wrong answer - a mutation that crashes proves nothing. + """ + out = [] + + def rec(n): + if (isinstance(n, (ast.Name, ast.Attribute, ast.Call, + ast.Subscript)) and derived(n, m, qual)): + out.append(n) + if isinstance(n, ast.Call): + for a in n.args: + rec(a) + return + for c in ast.iter_child_nodes(n): + rec(c) + rec(node) + return out + + seen = {} + for (m, qual), fn in funcs.items(): + def note(kind, expr): + bad = sliced(expr, m, qual) + for lf in leaves(expr, m, qual): + key = (m, lf.lineno, lf.col_offset, lf.end_lineno, + lf.end_col_offset) + seen.setdefault(key, (qual, kind, ast.unparse(lf), bad)) + for n in body_nodes(fn): + if isinstance(n, (ast.ListComp, ast.SetComp, ast.DictComp, + ast.GeneratorExp)): + for g in n.generators: + if derived(g.iter, m, qual) or sliced(g.iter, m, qual): + note("for..in", g.iter) + elif isinstance(n, (ast.For, ast.AsyncFor)): + if derived(n.iter, m, qual) or sliced(n.iter, m, qual): + note("for stmt", n.iter) + elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id in _REDUCERS): + for a in n.args: + if derived(a, m, qual) or sliced(a, m, qual): + note(n.func.id + "()", a) + def probe(m, qual, expr_src): + """Would this expression, IN THAT SCOPE, be reported as sliced? + + The per-site counterfactual runs through here: it is the SAME predicate + the enumeration above is graded with, asked about a mutated spelling of + the site's own expression, so a predicate that stopped answering would + take both to the same place. + """ + return sliced(ast.parse(expr_src, mode="eval").body, m, qual) + + rows = sorted((k + v) for k, v in seen.items()) + return rows, (sorted(t_funcs), sorted(t_params), sorted(t_attrs), probe) + + +_SITES, (_TFUNCS, _TPARAMS, _TATTRS, _PROBE) = _roster_sites(_APP_SRC) +_SLICED = [r for r in _SITES if r[8]] +_BY_MOD = {} +for _r in _SITES: + _BY_MOD.setdefault(_r[0], []).append(_r) + + +def _slice_at(row, cut="[:1]"): + """The package source with ONE roster reference sliced, for a counterfactual.""" + m, _ln, _c, eln, ec = row[:5] + src = dict(_APP_SRC) + lines = src[m].split("\n") + lines[eln - 1] = lines[eln - 1][:ec] + cut + lines[eln - 1][ec:] + src[m] = "\n".join(lines) + return src + + +print(f" {len(_APP_SRC)} modules parsed from app/ (NOT a written list); " + f"{len(_BY_MOD)} of them reduce over a roster or a roster-derived list, " + f"and every such reduction is enumerated below " + f"({len(_SITES)} of them):") +for _m in sorted(_BY_MOD): + print(f" {_m}.py: {len(_BY_MOD[_m])}") +for _m in sorted(_BY_MOD): + for _row in _BY_MOD[_m]: + print(f" {'SLICED ' if _row[8] else ' '}{_m}.py:{_row[1]:<5}" + f" {_row[5]:<38} {_row[6]:<10} {_row[7][:56]}") +print(f" modules with NO roster reduction at all: " + f"{sorted(set(_APP_SRC) - set(_BY_MOD))}") +print(f" ...and what made each of them a roster, computed from ONE seed " + f"(`opts[{_ROSTER_OPTION!r}]`): {len(_TFUNCS)} roster-PRODUCING functions, " + f"{len(_TPARAMS)} roster-carrying PARAMETERS across module boundaries, " + f"and the attributes {_TATTRS}:") +print(f" returns a roster: " + + ", ".join(f"{m}.{q}" for m, q in _TFUNCS)) +print(f" is HANDED a roster: " + + ", ".join(f"{m}.{q}({a})" for m, q, a in _TPARAMS)) + +# ⚠️ THE PR #48 ROUND-5 BLOCKERS, REDISCOVERED BY THE INSTRUMENT RATHER THAN +# COPIED FROM THE REVIEW. If this pin did not independently name all of them, +# the pin would be too narrow and THAT would be the finding. +# +# ⚠️ MATCHED BY FUNCTION AND EXPRESSION, NOT BY LINE NUMBER. The blockers were +# reported as validate.py:778 `caps_ix`, :779 `caps`, :783 and :893 `caps_ix`; +# editing three lines into validate.py moves all four, and a check pinned to +# 778 would then be green because it found NOTHING - a check whose data cannot +# reach the failure it names, in the block that exists to complain about them. +# The lines the pin actually reports are PRINTED so a reviewer can match them +# against the review by eye. +_BLOCK_FN = "validate_startup_config" +_blockers = [r for r in _SITES + if r[0] == "validate" and r[5] == _BLOCK_FN + and r[7] in ("caps", "caps_ix")] +print(f" PR #48's round-5 blockers, as the pin names them " + f"(validate.{_BLOCK_FN}, reductions over the caps list): " + + ", ".join(f"validate.py:{r[1]} {r[6]} {r[7]}" for r in _blockers)) + +check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY, AND " + "NOW ACROSS THE WHOLE PACKAGE INSTEAD OF ONE CLASS. Every place in " + "`app/` where a roster or a roster-derived list is REDUCED or ITERATED " + "takes the whole of it: no `[:1]`, no `[-1:]`, no `[:2]`, no `[0]` " + "between the roster and the reduction. This is the check that would have " + "caught rounds 2, 3, 4 AND 5's blockers on the round they were written " + "instead of one review each - round 4's version stopped at `Controller` " + "and round 5's four survivors were one module over, in validate.py. A " + "new reduction added over a slice of a roster, in ANY module under " + "`app/`, is red on the line it is written, at every roster length and " + "with no fixture required", + len(_SITES) > 0 and not _SLICED) + +check("...and it INDEPENDENTLY REDISCOVERS EVERY ONE OF PR #48's ROUND-5 " + "BLOCKERS - the `for ... in caps_ix` the caps list is built from, the " + "`min(caps)` rule 5 reduces it with, and the `min(caps_ix, key=...)` " + "rule 5's message and rule 6's comparison each take - and it found a " + "FURTHER ONE beside them that no review round named. A pin that had to " + "be told where to look would be a transcription of the review rather " + "than an instrument, so it is told ONE option key and nothing else", + len([r for r in _blockers if r[7] == "caps_ix"]) >= 3 + and len([r for r in _blockers if r[7] == "caps"]) >= 1) + +check("...and it reaches the sites of EVERY PREVIOUS ROUND'S BLOCKER TOO: " + "round 2's `peakguard.binding_soc_floor`, round 4's " + "`cycle()`/`publish()` `any(... for i in self.inverters)`, and " + "SAFETY-30's `_grade_store_migration`. The whole point of a family is " + "that its members are found by one rule", + any(r[0] == "peakguard" and r[5] == "binding_soc_floor" for r in _SITES) + and any(r[0] == "main" and r[5] == "Controller.cycle" + and r[6] == "any()" for r in _SITES) + and any(r[0] == "main" and r[5] == "Controller.publish" + and r[6] == "any()" for r in _SITES) + and any(r[0] == "main" and r[5] == "Controller._grade_store_migration" + for r in _SITES)) + +# --- non-vacuity, habit 7: vary ONE thing from an ACCEPTED baseline ---------- +# ⚠️ EVERY ONE of the enumerated sites, mutated. Not a sample: the counterfactual +# asks the SAME predicate the enumeration is graded with whether a `[:1]` on +# THAT site's own expression, in THAT site's own scope, would be reported. A +# predicate that had quietly stopped answering would take the line above green +# and this one red. +_probe_fail = [] +for _r in _SITES: + for _cut in ("[:1]", "[-1:]", "[:2]", "[0]"): + if not _PROBE(_r[0], _r[5], _r[7] + _cut): + _probe_fail.append((_r[0], _r[1], _r[7] + _cut)) +print(f" per-site counterfactual: {len(_SITES)} sites x 4 cuts " + f"([:1], [-1:], [:2], [0]) = {len(_SITES) * 4} mutated spellings, " + f"{len(_probe_fail)} of them NOT reported") +check("...and the predicate is not trivially true: sliced at EVERY ONE of the " + "enumerated sites, in that site's own scope, with each of the four cuts " + "this card has actually been defeated by, it reports the slice. It also " + "ACCEPTS the legitimate wrapped forms the package really uses " + "(`enumerate(...)`, `zip(...)`, `sorted(...)`, " + "`pack_permissions(packs, floor)`) and REJECTS a keyed lookup being " + "treated as a slice (`weights[u]` in allocator._fill, which is what four " + "false SLICED reports cost before `shortens()` existed)", + not _probe_fail + and not _PROBE("main", "Controller.cycle", "self.inverters") + and not _PROBE("main", "Controller.cycle", "enumerate(self.inverters)") + and not _PROBE("main", "Controller.cycle", "zip(self.inverters, self.maints)") + and not _PROBE("main", "Controller.check_peak_floor_limited", + "peakguard.pack_permissions(self.packs, 10.0)") + and not _PROBE("allocator", "_fill", "[u for u in order if weights[u] > 0.0]") + and _PROBE("allocator", "_fill", "[u for u in order[:1] if weights[u] > 0.0]")) + +# --- and the END-TO-END counterfactual, on the REAL FILES -------------------- +# ⚠️ The probe above grades the predicate. This grades THE WHOLE INSTRUMENT: the +# text of a shipped module is mutated at a real byte offset, the entire analysis +# is re-run from scratch on it, and the site has to come back SLICED at exactly +# the line the mutation was written on. That is the difference between "the +# rule is right" and "the rule is wired to the files". +_e2e = [] +for _r in _blockers: + _mut, _ = _roster_sites(_slice_at(_r)) + _hit = [x for x in _mut if x[8] and x[0] == _r[0] and x[1] == _r[1]] + _e2e.append((_r[0], _r[1], _r[7], len(_hit), len([x for x in _mut if x[8]]))) +for _m, _ln, _s, _hit, _tot in _e2e: + print(f" {_m}.py:{_ln} `{_s}` -> `{_s}[:1]`: reported at that line " + f"{'YES' if _hit else 'NO'} ({_tot} sliced site(s) in the package)") +check("...and the INSTRUMENT is wired to the files, not only to itself: " + "writing `[:1]` into app/validate.py at each of the four round-5 blocker " + "positions - every reduction over the caps list - and re-running the WHOLE " + "analysis over the mutated source " + "reports a sliced site at that exact line every time, where the clean " + "package reports none. ⚠️ THIS IS THE ONE THAT WOULD HAVE CAUGHT PR #48: " + "the four mutations the reviewer ran by hand against a 19/19 green suite " + "are red HERE without any fixture reaching them", + len(_e2e) >= 4 and all(h for _m, _l, _s, h, _t in _e2e)) + + +# ⚠️ WHAT THIS INSTRUMENT CANNOT SEE, SAID PLAINLY, BECAUSE AN ENUMERATED LIST +# WITH AN HONEST GAP IS WORTH MORE THAN A CLAIM OF COMPLETENESS A REVIEWER CAN +# FALSIFY IN TEN MINUTES. Every item below is an OPEN ROW, not a closed one. +# +# IT GRADES AN IDENTITY, NOT A MEANING. The claim it makes is "the whole roster +# arrives". It says nothing about WHICH attribute is read off each unit, WHICH +# reduction is applied, or whether that reduction is the right one: +# `min(caps)` -> `max(caps)` is invisible here, and always will be, because a +# shape pin on a semantic claim would refuse honest refactors (MUTATION.md +# habit 10). The behavioural halves - 12a-12i above, test_validate.py's rule 5 +# and rule 6 roster SETS, test_watchdog.py part 8 - are what say what these +# reductions MEAN, and they are the only thing that can. +# +# AND SPECIFICALLY, THE FOUR THINGS IT WILL NOT REPORT: +# 1. DIRECT INDEX SITES THAT ARE NOT REDUCTIONS. `self.inverters[0]` in +# `_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in +# `maint_unit`, `entries[index]` in `_unit_label()`. These are single-unit +# lookups, they are meant to be, and treating an index as a roster would +# spread this analysis over every unit attribute in the package. They are +# graded where they live. +# 2. A SUBSCRIPT BY A NAME. `weights[u]`, `cap[u]`, `add[u]` in +# `allocator._fill` are keyed lookups into a roster-derived MAPPING and are +# accepted deliberately - `shortens()` above. So a mutation that wrote +# `caps[i]` with `i` a variable would pass. It is not in the family this +# card has been defeated by four times (`[:1]`, `[-1:]`, `[:2]`, `[0]`), +# and buying it would cost four false SLICED reports in allocator.py. +# 3. A SLICE TAKEN THROUGH AN INTERMEDIATE REDUCTION. `sorted(roster)[:1]` is +# a subscript on a CALL RESULT rather than on a roster reference, and the +# pin does not flag it. Nothing in `app/` is written that way today; if +# something is, this line is the record that it would not be seen. +# 4. ANYTHING OUTSIDE `app/`. `tools/`, `sim/` and the checks themselves are +# not parsed. `sim/inject.py` reaches `validate_startup_config()` and is +# graded by nothing here. +# +# AND THE DISCOVERY ITSELF HAS A SEAM: the taint crosses a call boundary only +# where the callee is a plain function this package defines and the argument is +# passed positionally or by keyword. A roster handed through `*args`, stored in a +# dict and read back out, or passed to a method on an object the analysis cannot +# resolve, is lost at that hop - and everything downstream of the hop goes +# UNENUMERATED rather than reported as unreachable. That is the shape of the +# remaining exposure: not zero, and not measurable from inside this check. +# ⚠️ AND WHAT THE ENUMERATION ACTUALLY FOUND WHEN EVERY SITE WAS MUTATED - THE +# FINDING, RECORDED, BECAUSE THE LESSON IS THE MEASUREMENT AND NOT THE FIX. +# +# All 183 sites above were mutated to `[:1]` one at a time, +4 bytes each, and +# each mutant was run against the full 19-file suite in `goodwe-addon-ci` +# (mounted `:ro`, `python3 -B`, a fresh rsync per mutant, 0 `.pyc` in the tree +# before and after - a read-only mount cannot write one, which is TEST-15 closed +# by construction rather than by discipline). **183 of 183 RED.** +# +# ⚠️ THAT NUMBER IS TAUTOLOGICAL ON ITS OWN AND MUST NOT BE QUOTED WITHOUT THE +# SECOND RUN. With the check above in the tree, every slice is red BY THIS +# CHECK. So all 51 mutants whose first red was `test_peakguard.py` were re-run +# with this pin NEUTRALISED (`_SLICED` blanked), leaving only the behavioural +# checks. The answer: +# +# 145 of 183 a BEHAVIOURAL fixture in this repository separates the site +# from a slice of itself - it goes red with the pin turned off +# 38 of 183 NOTHING but this pin sees it. Structurally closed as of this +# card; behaviourally UNGRADED, and listed below rather than +# counted as covered +# +# THE 38, BY MODULE: main.py 27, allocator.py 6, validate.py 2, arbiter.py 1, +# circulation.py 1, maintenance.py 1. Grouped by what a `[:1]` there would cost: +# +# THE OPERATOR'S PAGE - 15 of them, and the largest group by far. +# `Controller.checks` (3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, +# 3400, 3458) and `status_payload` (3556, 3566, 3578) build the rows and +# the counts an operator reads. A slice reports ONE unit's row where two +# exist, or a "1 of 2 live" that says 1 of 1. Nothing commands differently; +# what is lost is the evidence a human uses to notice that it should. +# THE WRITE PATH's OWN COUNTS - `write_setpoint` 1267/1272/1280, three +# `len(targets)` guards around the one place this add-on writes. The +# write itself iterates `targets` at 1287 and IS graded (test_inverter); +# these three are the arithmetic in its log line and its refusal count. +# THE ALLOCATOR'S INTERNALS - `_fill`'s zero-weight last-resort pool (365), +# `plan`'s `dropped` (526, 653), `free` (584) and `frac_order` (591). +# ALLOC-01's fixtures drive two and three units through the WEIGHTED pass; +# the last-resort pass and the largest-remainder tie-break are reached but +# not with enough units for a slice to change the answer. +# STARTUP AND FSM PLUMBING - `__init__` 235/351, `_only` 831, `_maint_button` +# 805, `cycle`'s maintenance zip at 1505, `send_heartbeats` 3015, +# `check_output_clamp` 2445/2452, `check_circulating` 2701, +# `maintenance.arbitrate` 547, `arbiter.site_resolve` 517, +# `circulation.opposing` 242. +# TWO REFUSAL-TEXT SITES - `validate._roster_problems`'s `len(entries)` (554) +# and `refuse()`'s numbering loop (1263). Both shape the WORDS of a +# refusal that still happens. +# AND `_grade_store_migration` (698), which round 4 already recorded as row 21 +# of the DECISIONS table and argued to be an equivalent mutant at that call +# site. The argument is unchanged and is still an argument, not a proof. +# +# ⚠️ NONE OF THESE 38 IS CLOSED BEHAVIOURALLY BY THIS CARD, AND SAYING SO IS THE +# POINT. Closing them one at a time is exactly what cost this card four review +# rounds; the pin closes the FAMILY - a slice at any of them is red on the line +# it is written - and the list above is what a shape pin can never promise. The +# honest summary is: 145 sites have a fixture that can see them, 38 have only a +# structural claim, and every one of the 38 is named here with what it would +# cost rather than left out of a count. + + + + + # ============================================================================== print() print("11. mutation: what defeats these guards, and which check bites") @@ -3298,6 +5317,288 @@ MUT.append(("the grid-following loop discharging past this floor", f"pins the claim-source set, so building the floor takes this " f"part RED instead of leaving this row quietly stale")) +# ---- SAFETY-32's own defeats ------------------------------------------------- +# ⚠️ RE-IMPLEMENTED IN PROCESS, exactly as SAFETY-07's B1/B2/B3 are: no source +# file is edited, so no `.pyc` is consulted and MUTATION.md's stale-bytecode +# defect cannot reach these rows. The SOURCE-EDIT battery is on the PR, run +# through `tools/mutation_run.sh`. + +# 14. `build_roster()` letting an entry inherit the flat `bms_soc_floor` - this +# card's own defect, rebuilt in three lines, and CFG-04's exact mutation. +MUT.append(("`build_roster()` inheriting the flat `bms_soc_floor` for an entry " + "that omits it", + _r_inh[1].bms_soc_floor is None and _FLAT_ODD != A_PACK_FLOOR, + "part 12f: 'an entry that omits bms_soc_floor has NO cut-out'. ⚠️ " + "The flat key is set to 30 %, a number nothing else in this file " + "has, precisely so inherited and not-inherited are different " + "answers - CFG-04 shipped this claim against a BLANK flat key and " + "watched the mutation survive")) + +# 15. the permission graded against the site's ONE floor, i.e. the shipped +# behaviour this card removes. Re-derived on 12a's own two packs. +_mut15 = [discharge_permitted(p.soc, SOC_FLOOR) for p in (_pk_a, _pk_b)] +MUT.append(("the permission graded against the ONE `peak_soc_floor` instead of " + "each pack's own cut-out - the shipped behaviour", + _mut15 != [w for _, w in _perm], + "part 12a: 'two packs AT THE SAME STATE OF CHARGE get OPPOSITE " + "answers'")) + +# 16. every pack graded against packs[0]'s floor - DEV-02's `self.inverters[0]` +# defect and CFG-04's index borrow, in this card's costume. +_mut16 = [discharge_permitted(p.soc, pack_floor_pct(_pk_a, SOC_FLOOR)) + for p in (_pk_a, _pk_b)] +MUT.append(("every pack graded against the FIRST pack's floor", + _mut16 != [w for _, w in _perm], + "part 12a, and it is the fixture that makes it visible: the two " + "floors differ, so borrowing pack A's answers a different question")) + +# 17. `binding_soc_floor()` returning the bare option - the `max()` dropped. +MUT.append(("`binding_soc_floor()` returning `soc_floor` unreduced", + _n1_deep != SOC_FLOOR and _n1_deep == B_PACK_FLOOR_DEEP_SIM, + "part 12c: 'on the pair rule 8 REFUSES the reduction answers the " + "CUT-OUT and not the option'. ⚠️ THE N=1 SWEEP ALONE CANNOT SEE " + "THIS - it is 1775 pairs on which the two agree, which is the " + "point of pairing it with a case on which they must not")) + +# 18. an unstated per-entry cut-out defaulted to 0 % rather than withheld - the +# most permissive value the key has, invented from an absence. +MUT.append(("an unstated per-entry cut-out defaulted to 0 %", + pack_floor_pct(Pack("b", 100.0, None), SOC_FLOOR) is None + and discharge_permitted(100.0, 0.0) is None, + "part 12b: 'a roster entry that states no bms_soc_floor is " + "WITHHELD even at a full battery'")) + +# 19. rule 8 grading only the first unit, i.e. the flat rule left in place. +MUT.append(("rule 8 grading only the FIRST unit", + _r_second != [] and "(inverter-b)" in _msg(_r_second), + "part 12e: 'a roster whose SECOND pack has a cut-out above " + "peak_soc_floor is refused'. ⚠️ The offender is the SECOND entry " + "for exactly this row")) + +# 20. rule 8 naming a hardwired unit - CFG-04's rule 5/6 bug, which is why +# `unit_bms_floors_indexed()` carries the roster index. +MUT.append(("rule 8's refusal naming a hardwired inverter", + "(inverter-b)" in _msg(_r_second) and "(inverter-a)" in _msg(_r_first), + "part 12e: the two orders. Either hardwired index passes one of " + "them and fails the other")) + +# 21. the notification naming a fixed roster position rather than the pack that +# is actually at its floor. +MUT.append(("the per-pack notification naming a fixed roster position", + "inverter-a" in _body32b + and "inverter-b" not in _body32b.split("WHICH PACK")[-1], + "part 12h: the same two readings swapped between the packs")) + +# ---- the two PR #48 SURVIVORS, and why they survived ------------------------- +# ⚠️ BOTH OF THESE WERE APPLIED TO app/ AND ALL 19 FILES STAYED GREEN. They are +# this repository's recurring defect - a check whose DATA cannot reach the +# failure it names - landing on this card's own reduction, and they are recorded +# here as rows so that a later edit which re-degenerates the fixtures goes red +# on the row rather than on nothing. + +# 22. `binding_soc_floor()` reduced with `min()` instead of `max()`. ZERO byte +# delta, and the reviewer proved by disassembly that it really ran. It +# survived because every roster in the suite was rule-8-valid or one pack +# long, so `floors` held IDENTICAL numbers and max/min/first/last were four +# spellings of one answer. 12c2's four simulated cut-outs are the data that +# reaches it. +_mut22 = [min(_MANY_FLOORS_SIM), _MANY_FLOORS_SIM[0], _MANY_FLOORS_SIM[-1], + sum(_MANY_FLOORS_SIM) / len(_MANY_FLOORS_SIM)] +MUT.append(("`binding_soc_floor()` reduced with `min()`, `first` or `last` " + "instead of `max()` - PR #48 SURVIVOR 1, zero byte delta", + binding_soc_floor(_many_packs, SOC_FLOOR) == max(_MANY_FLOORS_SIM) + and all(binding_soc_floor(_many_packs, SOC_FLOOR) != m + for m in _mut22), + "part 12c2: 'max, min, first, last and the mean are FIVE DIFFERENT " + "numbers'. ⚠️ The four cut-outs sit on a roster rule 8 would REFUSE, " + "deliberately: on one it accepts all four reductions are equal, " + "which is precisely why this mutation survived")) + +# 22b. `binding_soc_floor()`'s EMPTY-roster branch rewritten to `return None`. +# -43 bytes, green across all 19 files until 12c2's last block pinned it. +# A defensive branch nothing reaches is still a branch. +MUT.append(("`binding_soc_floor([])` answering None instead of the option", + binding_soc_floor([], SOC_FLOOR) == float(SOC_FLOOR) + and binding_soc_floor([], None) is None, + "part 12c2's last block: 'an EMPTY roster answers the OPTION ... " + "and answers None when the option itself is None'")) + +# 23. the CALL SITE grading the whole site against ONE ROSTER POSITION - +# `self.packs[:1]` (+4 bytes, PR #48's survivor) or `self.packs[-1:]` +# (+5 bytes, which survived the FIRST CUT OF THIS FIX ROUND until the +# swapped pair was added). 12g's old substring assertion +# (`"self.packs" in _floor_call`) passes on both, because a slice still +# contains that substring. Three runs close both directions at once: the +# deep cut-out is on the SECOND pack in one and on the FIRST in another, and +# the site floor is the same 35 % in both. +MUT.append(("main.py grading the site against ONE roster position - " + "`self.packs[:1]` (PR #48 SURVIVOR 2) or `self.packs[-1:]`", + _g_deep.soc_floor == float(B_PACK_FLOOR_DEEP_SIM) + and _g_deepa.soc_floor == float(B_PACK_FLOOR_DEEP_SIM) + and _g_shallow.soc_floor == float(SOC_FLOOR) + and _g_deep.floor_limited and _g_deepa.floor_limited + and not _g_shallow.floor_limited, + "part 12g, REWRITTEN as behaviour: real two-pack cycles, the SAME " + "pair of cut-outs carried in both orders, and both the floor " + "reaching decide() and the site's verdict follow the ROSTER rather " + "than a position. ⚠️ A substring test over main.py cannot see a " + "slice; MUTATION.md habit 4")) + +# ---- and the TWO ROUND-3 SURVIVORS, the same defect one pack along ----------- +# ⚠️ BOTH SURVIVED THE ROUND-2 FIX, and both are the family law above: a fixture +# of N packs cannot separate reductions that agree on N packs. Round 2 closed +# `min(floors)` and the single-position slices and then stated, in the check +# text, a BROADER claim than its data could reach - "the reduction reads every +# entry", "what is reduced is the ROSTER and not a POSITION". The two rewrites +# below satisfy every fixture round 2 had while making both of those sentences +# false. The prose has been cut back to what the old data reached and new data +# added for the rest; that is the whole of round 3. + +# 24. `max(floors)` -> `max(floors[-2:])`, +5 bytes, green across all 19 files +# on 8501e05. Entries 0 and 1 are never read, so a roster of >= 3 can be +# graded against a floor BELOW its deepest cut-out - the same safety +# inversion as `min(floors)`, one pack along. It survived because 12c2's +# only counterfactual varied the LAST pack, and every window ending at the +# roster's end follows that. 12c2's roster SET reaches it. +MUT.append(("`binding_soc_floor()` reduced over a WINDOW - `max(floors[-2:])` - " + "ROUND 3 SURVIVOR 1, +5 bytes", + binding_soc_floor(_sim_packs(_reduction_rosters[1][1]), SOC_FLOOR) + == _DEEPEST_SIM + and max(_reduction_rosters[1][1][-2:]) != _DEEPEST_SIM + and bool(_reduction_kills["max(floors[-2:])"]) + and all(_reduction_kills.values()), + "part 12c2's FAMILY TABLE: the same four cut-outs with the deepest " + "moved to each of the four positions in turn, and every listed " + "rewrite separated from max() by at least one of them. ⚠️ A SINGLE " + "counterfactual cannot do this - it can only show that ONE position " + "is read")) + +# 25. `binding_soc_floor(self.packs, ...)` -> `self.packs[:2]`, +4 bytes, green +# across all 19 files on 8501e05. Every `_floor_reaching_decide` run in 12g +# drove a TWO-pack roster, where `[:2]` IS the roster, so no run could see +# it - and 12g's own check said so about two packs and max/min/first/last +# while claiming otherwise about itself. Three-pack runs with the deep +# cut-out at index 0, 1 and 2 reach it. +MUT.append(("main.py grading the site against a WINDOW of roster positions - " + "`self.packs[:2]` - ROUND 3 SURVIVOR 2, +4 bytes", + _g3_runs[2][3].soc_floor == float(_G3_DEEP_SIM) + and binding_soc_floor(_g3_runs[2][2].packs[:2], SOC_FLOOR) + == float(SOC_FLOOR) + and all(r[3].soc_floor == float(_G3_DEEP_SIM) for r in _g3_runs) + and all(_slice_kills.values()), + "part 12g's THREE-pack runs and their POSITIONAL-SLICE TABLE: the " + "deep cut-out at index 0, 1 and 2 of a three-pack roster, so every " + "proper subset of the positions drops the deep one on some run. " + "⚠️ TWO packs are structurally blind to this; `[:2]` is the whole " + "roster there")) + +# 26. ...AND THE TWO THE BATTERY FOUND WHILE CLOSING THOSE. `self.packs[:3]` +# (+4) and `self.packs[-3:]` (+5) survive ALL SIX behavioural runs in 12g, +# because the longest roster those runs drive is three packs long and a +# three-wide window IS that roster. This is the fourth turn of the same +# screw: `[:1]` needed two packs, `[:2]` needed three, `[:3]` needs four, +# and adding a fourth pack would hand `[:4]` the free pass. They are closed +# STRUCTURALLY instead - the roster argument must unparse to exactly +# `self.packs` - and the row asserts BOTH the pin and the reason it was +# needed, so a later edit that deletes the pin cannot leave this green. +MUT.append(("main.py grading the site against a THREE-wide window - " + "`self.packs[:3]` / `self.packs[-3:]`, found by THIS round's " + "battery, invisible to every behavioural run in this file", + ast.unparse(_floor_roster_arg) == "self.packs" + and isinstance(_floor_roster_arg, ast.Attribute) + and all(binding_soc_floor(ctl.packs[:3], SOC_FLOOR) == dec.soc_floor + and binding_soc_floor(ctl.packs[-3:], SOC_FLOOR) + == dec.soc_floor for _rn, ctl, dec in _G_RUNS), + "part 12g's STRUCTURAL PIN, and ONLY that - the runs cannot see it " + "and this row asserts that they cannot. ⚠️ A bounded roster cannot " + "grade an unbounded family of slices; one more pack moves the hole " + "rather than closing it")) + +# 27. AND THE ONE THAT IS NOT CLOSED. `max(floors[:12])` and every wider window +# inside `binding_soc_floor()` are INVISIBLE to this file: the length sweep +# runs to twelve packs, so a twelve-wide window is the whole list on every +# roster it drives. Unlike the call site there is no identity claim to pin +# here - the claim is semantic, "the maximum", and a shape pin would refuse +# honest refactors of the expression. Recorded as open, with its size: it +# needs a roster of THIRTEEN packs to become observable, on a site whose +# roster is currently ONE and whose second unit is ~8 days out. +MUT.append(("`binding_soc_floor()` reduced over a window 12 packs wide or " + "wider - NOT CAUGHT, and it takes a 13-pack roster to reach", + _head_kill.get(_SWEEP_N, False) or _tail_kill.get(_SWEEP_N, False), + "NOTHING CATCHES IT, on purpose. The sweep closes widths 1-11 and " + "says so; width 12 is where this file's data stops. ⚠️ Two further " + "rewrites are EQUIVALENT rather than uncaught - `max(set(floors))` " + "and `list(reversed(self.packs))` cannot change any answer, because " + "max is order- and duplicate-invariant; the second is nonetheless " + "red on 12g's structural pin")) + +# --- ROUND 4: THE SIBLING ARGUMENT, AND THE ENUMERATION THAT FOUND THE REST -- +# 28. `pack_floor_soc` reduced over a WINDOW of roster positions. THIS ROUND'S +# BLOCKER, and the same family as rows 25-26 one ARGUMENT over: the MINIMUM +# handed to the same `decide()` call as `soc=`. Green across all 19 files at +# 52380c0 and again on the rebase onto 3934ad7. +MUT.append(("`pack_floor_soc` reduced over a WINDOW - `vals[:2]` (+4 bytes) or " + "`vals[-2:]` (+5) - ROUND 4's BLOCKER, the SoC axis of the pair", + all(_soc_slice_kills[k] for k in ("vals[:2]", "vals[-2:]", + "vals[:1]", "vals[-1:]")), + "part 12i's THREE-pack runs and their POSITIONAL-SLICE TABLE: the " + "same three readings with the emptiest at index 0, 1 and 2, so " + "every proper subset of the positions drops the empty pack on some " + "run. ⚠️ At N=2 `[:2]` IS the roster - the two-pack mirror cannot " + "see it, which is why the suite's separating power on this axis " + "stopped at exactly N=2 for four rounds")) +# 29. ...and the three-wide window, invisible to every behavioural run here. +MUT.append(("`pack_floor_soc` reduced over a THREE-wide window - `vals[:3]` / " + "`vals[-3:]` - invisible to every cycle in this file", + _s_head_kill[3] and _s_tail_kill[3] + and ast.unparse(_pfs_min_iter) == "vals", + "part 12i's LENGTH SWEEP (widths 1-11, on the REAL property over " + "rosters of 1 to 12 real inverters) and its STRUCTURAL PIN. The " + "six real cycles cannot see it - three packs is the longest roster " + "they drive - and the sweep and the pin are what do")) +# 30. the reduction itself, on the wrong axis. Zero byte delta. +MUT.append(("`pack_floor_soc` reduced with `max()` - the FULLEST pack, which is " + "`binding_soc_floor()`'s own reduction pasted onto the SoC axis - " + "or with the mean, the median or the second-emptiest", + all(_soc_red_kills.values()), + "part 12i's NON-SLICE table, on the same six real cycles: the " + "emptiest pack is 11 % where the fullest is 60 %, so every one of " + "the four disagrees on every run. ⚠️ Zero byte delta, and the most " + "likely wrong edit here precisely because the sibling reduction one " + "argument over IS a max")) +# 31. the None guard, which is a reduction over roster length too. +MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - " + "`any(v is None for v in vals[:1])`, +4 bytes", + all(g is None for _n, _p, g in _s_none_rows) + and ast.unparse(_pfs_any_iter) == "vals", + "part 12i's NONE-GUARD SWEEP - one silent pack at every position of " + "every length 1..12 - and the same structural pin. ⚠️ 12d's " + "two-pack fixture silences the SECOND pack only, so a guard " + "narrowed to the FIRST answers 'all present' there and hands a None " + "to `min()`")) +# 32. and the roster the list itself is built from. +MUT.append(("`pack_floor_soc` building `vals` from a SLICE of the roster - " + "`[inv.soc for inv in self.inverters[:2]]`, +4 bytes", + ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters" + and _s_head_kill[2] and not _SLICED, + "part 12i's sweep reads the REAL property on real rosters so it " + "sees this too, its pin is on the comprehension's own iterable, " + "and 12j's ENUMERATION covers every other roster reduction in the " + "PACKAGE at once")) +# 33. THE ENUMERATION'S OWN FINDING, and the reason this row is worth more than +# the four above: it is not this card's code. +MUT.append(("a reduction over a roster added ANYWHERE ELSE IN THE PACKAGE over " + "a slice - which is how rounds 2, 3, 4 AND 5 each began", + bool(_SITES) and not _SLICED, + "part 12j: every roster reduction in EVERY module under `app/`, " + "enumerated from the AST off one seed and required to take the " + "roster whole. ⚠️ Round 4's version stopped at the `Controller` " + "class and round 5's four blockers were one module over, in " + "validate.py; this one enumerates " + f"{len(_SITES)} sites across {len(_BY_MOD)} modules and " + "rediscovers all four without being told where to look. THIS row " + "is the one that closes the family rather than the member")) + for label, caught, where in MUT: print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}") print(f" {where}") @@ -3312,6 +5613,53 @@ check("...and the seventh is honestly reported as not caught, because it cannot "same zero into FULL freedom, which part 3 shows is why headroom_w() is " "not consumed here", not MUT[6][1] and quarter_ceiling_w(CAP_W, _zero_peak)[0] == CAP_W) +check("...and SAFETY-32's own eight defeats are each caught too - the flat " + "cut-out inherited, the site floor graded in place of the pack's, the " + "first pack's floor borrowed for all, the reduction dropped, an absent " + "cut-out defaulted to 0 %, rule 8 grading one unit, rule 8 naming a " + "hardwired one, and the alarm naming a position instead of a pack", + all(m[1] for m in MUT[13:21])) +check("...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUITE, " + "which is why this card is on its third fix round: `min(floors)` in place " + "of `max(floors)` (zero byte delta) and `self.packs[:1]` at the call site " + "(+4 bytes), both green on d9388c8; `self.packs[-1:]` (+5 bytes) and the " + "empty-roster branch rewritten (-43 bytes), green on the FIRST CUT OF THE " + "FIX; `max(floors[-2:])` (+5) and `self.packs[:2]` (+4), both green on " + "8501e05 - THE ROUND-2 FIX ITSELF; and `self.packs[:3]` / " + "`self.packs[-3:]`, green on the first cut of ROUND 3 and found by its " + "own battery. None of the seven is a new guard; no shipped behaviour " + "changed. Every one is a check whose DATA could not reach the failure its " + "TEXT named - the defect this repository has now shipped in six " + "consecutive cards and, twice running, inside the fix written to close " + "it. The last five were found by a battery rather than by argument, " + "which is the only thing that has ever found any of them", + all(m[1] for m in MUT[21:27]) and len(MUT) == 34) +check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row " + "this round could write for it: a reduction over a window TWELVE packs " + "wide is indistinguishable from `max(floors)` on every roster in this " + "file, because the length sweep stops at twelve. Thirteen packs would " + "close it and open thirteen. The call site's half of the same regress is " + "closed by an exact structural pin because the claim there is an IDENTITY " + "('this roster'); the claim here is SEMANTIC ('the maximum') and a shape " + "pin would refuse honest refactors, so the hole is reported with its size " + "instead of hidden. THIS is the row that a fifth review should argue " + "with, and it exists to be argued with", + not MUT[27][1] + and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N))) +check("...⚠️ AND ROUND 4's SIX, WHICH ARE THE SAME FAMILY AT THE SIBLING " + "ARGUMENT OF THE SAME CALL. `pack_floor_soc` is the MINIMUM over the " + "roster handed to `decide(soc=...)`; `binding_soc_floor()` is the MAXIMUM " + "handed to `decide(soc_floor=...)` beside it, and peakguard.py's own " + "docstring names them as a pair. Rounds 2 and 3 hardened one half; " + "`vals[:2]`, `vals[-2:]` and `vals[:3]` were still green on 52380c0 with " + "the other, and a three-pack roster at 50 / 60 / 11 % answers 11 % clean " + "and 50 % mutated - a discharge PERMISSION graded against a pack 39 " + "points fuller than the emptiest. The last row is the one that matters " + "most: closing the MEMBER four times has cost four review rounds, so the " + "FAMILY is now enumerated from the source, and that enumeration found " + "four more survivors in code this card never touched", + all(m[1] for m in MUT[28:34]) and len(MUT) == 34 + and _S_EMPTY_SIM < SOC_FLOOR) check("...and so is the last one, which is not a defeat at all but the EDGE OF " "THIS CARD: the grid-following loop can still take the pack past this " "floor, because the floor bounds the peak guard's own forced discharge " diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index 8565bbf..d482732 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -204,12 +204,17 @@ check("...and the refusal names the actuator and every writer aimed at it, so " _PER_INVERTER_OK = {k: v for k, v in base().items() if k != "setpoint_entity"} _PER_INVERTER_OK["inverters"] = [ {"name": "inverter-a", "setpoint_entity": "number.goodwe_setpoint", - "max_w": 2000}, + "max_w": 2000, "bms_soc_floor": 10}, # ⚠️ INVERTER B DOES NOT EXIST. 2000 W here is the SHIPPED default repeated, # not a rating measured off a second device - SPIKE-04 measures that when # there is one to measure. Nothing in this file depends on the number. + # ⚠️ SAFETY-32 made `bms_soc_floor` a REQUIRED per-entry key, and the two + # values here DIFFER (10 / 15) rather than being copied. 15 % is SIMULATED - + # there is no second pack to read a cut-out off - and the reason it is not + # simply 10 is MUTATION.md habit 1: a fixture whose two per-unit numbers are + # equal cannot show a check reading the wrong unit's. {"name": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b", - "max_w": 2000}] + "max_w": 2000, "bms_soc_floor": 15}] check("two inverters with their own entities are ACCEPTED - one writer each, " "no shared actuator, and that is CFG-04's whole purpose", validate_startup_config(_PER_INVERTER_OK, -5000, 5000) == []) @@ -919,6 +924,248 @@ for _name, _got in sorted(_prov.items()): f"against, and the 4324 W the DC reading would make it {_got}", all(_got.values())) + +# --------------------------------------------------------------------------- # +print("rules 5, 5b and 6 on a ROSTER SET - the smallest cap at EVERY position") +# ⚠️ SAFETY-32 ROUND 5, AND THE REASON THIS SECTION EXISTS AT ALL. PR #48 was +# rejected on FOUR MUTATIONS THAT SURVIVED A 19/19 GREEN SUITE, all of them in +# the three lines above: +# +# validate.py:779 min(caps) -> min(caps[:1]) +4 B GREEN +# validate.py:779 min(caps) -> min(caps[-1:]) +5 B GREEN +# validate.py:778 caps = [cap for _, cap in caps_ix[:1]] +4 B GREEN +# validate.py:893 min(caps_ix[-1:], key=lambda p: p[1]) +5 B GREEN +# +# NONE of them is an equivalent mutant: each one deletes a safety refusal. +# `max_w: 5000` on inverter-a beside `2000` on inverter-b with +# `sign_disagree_w: 3000` is refused by the shipped validator with "the +# SAFETY-02 sign-disagreement watchdog can never fire" and ACCEPTED by every one +# of the four - the site starts, with a watchdog that reports `armed` on the +# status page and cannot trip on pack B. That is rule 5's own failure mode +# reached THROUGH rule 5. +# +# WHY THE OLD FIXTURE COULD NOT SEE IT, which is the whole lesson and is now +# MUTATION.md habit 10: the fixture was ONE roster, `[5000, 2000]`. On that +# roster `min` and `last` and `[-1:]` and `[:2]` all answer 2000. A fixture of N +# members cannot separate two reductions that agree on N members, and adding a +# member moves the hole instead of closing it. +# +# SO THE FIXTURE IS A SET, NOT A ROSTER: the smallest cap at EVERY POSITION of +# rosters of width 2, 3, 4 and 5. `[:1]` needs width 2 to be caught, `[:2]` +# needs 3, `[:3]` needs 4 - the sweep is what terminates the regress that a +# single fixture cannot. And the FAMILY TABLE below is COMPUTED AND PRINTED, so +# a member no fixture kills shows up as a NAMED SURVIVOR rather than as an +# absence from a list nobody re-derived. +# +# ⚠️ THE DIFFERING CAPS ARE A FIXTURE PROPERTY AND MUST STAY ONE. In production +# both packs are expected to floor at 10 % and SPIKE-04a puts inverter B at +# 4600 W against A's 5000 W - a 400 W gap, narrower than slew_w. A fixture built +# on the real pair would be degenerate on exactly the axis these checks measure. +# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (SPIKE-04, ~2026-09-12). Nothing +# in this section is measured off a second unit; the rosters are simulated and +# are labelled so. +_BIG, _SMALL = 5000.0, 2000.0 +_RATE = 3000.0 # strictly between them, which is the point + + +def _roster_n(caps, **over): + """A roster of len(caps) units, unit-0..unit-N, each with its own cap.""" + o = base(**over) + for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"): + o.pop(k, None) + o["inverters"] = [ + dict(name=f"unit-{i}", setpoint_entity=f"number.sp{i}", + soc_entity=f"sensor.soc{i}", batt_entity=f"sensor.batt{i}", + max_w=c, bms_soc_floor=10) + for i, c in enumerate(caps)] + o.update(over) + return o + + +# The three rules that reduce over `unit_caps_indexed()`, each as +# (option name, the token its refusal is recognised by). +_CAP_RULES = ( + ("sign_disagree_w", "SAFETY-02 sign-disagreement"), + ("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"), + ("maintenance_charge_w", "maintenance charge in a cheap window"), +) +_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0, + "maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0} + +# THE SET: width x position of the one small cap. +_SET = [] +for _n in (2, 3, 4, 5): + for _pos in range(_n): + _caps = [_BIG] * _n + _caps[_pos] = _SMALL + _SET.append((_n, _pos, tuple(_caps))) + +# habit 1: the PRECONDITION as its own check. If a later edit makes the caps +# equal, this goes red rather than the claims below going quietly vacuous. +check("PRECONDITION: every fixture in the set has a UNIQUE smallest cap, that " + "cap is at the stated position, and the rate under test sits STRICTLY " + "between it and the others - two equal caps could not tell `min` from " + "`first` from `last` and the whole set would be measuring nothing", + len(_SET) == 14 + and all(c[p] == _SMALL and c.count(_SMALL) == 1 and _SMALL < _RATE < _BIG + for _n2, p, c in _SET) + and {p for _n2, p, _c in _SET} == {0, 1, 2, 3, 4}) + + +def _verdict(opts, token): + """(refused?, the unit the message NAMES, the cap it QUOTES).""" + ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p] + if not ps: + return (False, None, None) + m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0]) + return (True, m.group(1) if m else None, float(m.group(2)) if m else None) + + +# --- the shipped answer, on every member of the set ------------------------- +_rows = [] +for _opt, _token in _CAP_RULES: + for _n, _pos, _caps in _SET: + _o = _roster_n(list(_caps), **dict(_QUIET, **{_opt: _RATE})) + _rows.append((_opt, _n, _pos, _caps, _verdict(_o, _token))) +print(f" {len(_CAP_RULES)} rules x {len(_SET)} rosters = {len(_rows)} runs " + f"of the REAL validator (rate {_RATE:.0f} W, caps {_SMALL:.0f}/" + f"{_BIG:.0f} W, simulated rosters - inverter B does not exist):") +for _opt in sorted({r[0] for r in _rows}): + _got = [(r[1], r[2], r[4]) for r in _rows if r[0] == _opt] + print(f" {_opt:<22} " + " ".join( + f"n{n}p{p}:{'REFUSE ' + str(u) if ok else 'starts'}" + for n, p, (ok, u, _c) in _got)) + +check("⚠️ EVERY ONE OF THE THREE CAP RULES REFUSES ON EVERY MEMBER OF THE SET, " + "and NAMES THE UNIT THE NUMBER CAME OFF - at width 2, 3, 4 and 5, with " + "the small cap at every position in turn. A reduction that answered " + "`first`, `last`, `[:1]`, `[-1:]`, `[:2]` or `[:-1]` instead of `min` " + "misses at least one member of this set, which is what four hand-run " + "mutations against a 19/19 green suite proved the single `[5000, 2000]` " + "fixture could not do", + all(ok and unit == f"unit-{p}" and cap == _SMALL + for _o2, _n2, p, _c2, (ok, unit, cap) in _rows)) + +check("...and it is a COMPARISON, not a blanket refusal of rosters: the same " + "three rates against a roster whose caps can ALL take them start " + "cleanly, at every width. Without this the line above is satisfied by a " + "validator that refuses everything (habit 2 in the negative direction)", + all(_verdict(_roster_n([_BIG] * n, **dict(_QUIET, **{opt: _RATE})), + tok) == (False, None, None) + for opt, tok in _CAP_RULES for n in (1, 2, 3, 4, 5))) + +# --- the POSITIONAL-SLICE FAMILY TABLE, computed and printed ---------------- +# ⚠️ COMPUTED, NOT LISTED. Each candidate is applied to the SAME caps the +# validator saw, and its (unit, cap) answer is compared against what the shipped +# validator actually said on that roster. A candidate that agrees everywhere is +# a reduction this fixture set CANNOT distinguish from the shipped one, and it +# is printed as a SURVIVOR rather than left out of a table nobody re-derived. +def _pick(caps, fn): + ix = list(enumerate(caps)) + try: + i, c = fn(ix) + except (ValueError, IndexError): + return None + return (f"unit-{i}", c) + + +_FAMILY = { + "min (SHIPPED)": lambda ix: min(ix, key=lambda p: p[1]), + "max": lambda ix: max(ix, key=lambda p: p[1]), + "first": lambda ix: ix[0], + "last": lambda ix: ix[-1], + "min(caps[:1])": lambda ix: min(ix[:1], key=lambda p: p[1]), + "min(caps[-1:])": lambda ix: min(ix[-1:], key=lambda p: p[1]), + "min(caps[:2])": lambda ix: min(ix[:2], key=lambda p: p[1]), + "min(caps[:3])": lambda ix: min(ix[:3], key=lambda p: p[1]), + "min(caps[1:])": lambda ix: min(ix[1:], key=lambda p: p[1]), + "min(caps[:-1])": lambda ix: min(ix[:-1], key=lambda p: p[1]), + "min(caps[::2])": lambda ix: min(ix[::2], key=lambda p: p[1]), + "mean": lambda ix: (0, sum(c for _i, c in ix) / len(ix)), +} +_survivors = [] +print(" the family table - each candidate against what the SHIPPED " + "validator answered on the same roster:") +for _name, _fn in _FAMILY.items(): + _misses = [] + for _opt, _n, _pos, _caps, (_ok, _unit, _cap) in _rows: + _pred = _pick(list(_caps), _fn) + _pred_refuses = _pred is not None and _pred[1] > 0 and _RATE >= _pred[1] + if _opt == "maintenance_charge_w": + _pred_refuses = (_pred is not None and _pred[1] > 0 + and _RATE > _pred[1]) + _said = ((True, _pred[0], _pred[1]) if _pred_refuses + else (False, None, None)) + if _said != (_ok, _unit, _cap): + _misses.append(f"{_opt[:4]}/n{_n}p{_pos}") + if not _misses and _name != "min (SHIPPED)": + _survivors.append(_name) + print(f" {_name:<16} {'AGREES EVERYWHERE (survivor)' if not _misses else 'differs on ' + str(len(_misses)) + ' run(s), first: ' + _misses[0]}") +check("⚠️ AND THE FAMILY TABLE HAS NO SURVIVOR BUT THE SHIPPED REDUCTION. " + "Eleven candidates - max, first, last, the mean, and every positional " + "slice this card has actually been defeated by - are each computed " + "against what the real validator answered on the same roster, and every " + "one of them differs somewhere in the set. This is the check that would " + "have taken PR #48 red four rounds ago: a member no fixture kills is " + "PRINTED AS A NAMED SURVIVOR here instead of being absent from a list " + f"nobody re-derived. survivors: {_survivors}", + _survivors == []) + +# --- the ROSTER OF ONE, where every one of them is the same thing ----------- +_at_one = {n: _pick([_SMALL], f) for n, f in _FAMILY.items()} +print(f" at a ROSTER OF ONE the twelve candidates answer: " + f"{sorted({str(v) for v in _at_one.values()})}") +check("...and at a ROSTER OF ONE every candidate that still has a member to " + "reduce answers THE SAME THING - which is exactly why no " + "single-inverter fixture in this repository could ever have caught any " + "of this, and why the shipped single-inverter refusals are what they " + "always were. The only two that do NOT coincide are `caps[1:]` and " + "`caps[:-1]`, which EMPTY the list and would raise: at N=1 they are " + "crashes rather than silent wrong answers, so they are not survivors " + "there either", + {v for v in _at_one.values() if v is not None} == {("unit-0", _SMALL)} + and {n for n, v in _at_one.items() if v is None} + == {"min(caps[1:])", "min(caps[:-1])"} + and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)), + "SAFETY-02 sign-disagreement")[0]) + +# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w -------------- +# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL +# PIN - and said that way on purpose, because it is the shape of what the pin +# cannot see. `unit_sign_disagree_w` is SAFETY-30's, not this card's. It was +# graded against `_number(opts, "max_w", 2000.0)`, the FLAT option, three lines +# under a branch that had already stopped doing that. Both directions were +# measured before the fix and both are asserted here. +_leftover = _roster_n([_SMALL, _SMALL], + **dict(_QUIET, unit_sign_disagree_w=_RATE)) +_leftover["max_w"] = _BIG # what a single-inverter install leaves behind +_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement") +print(f" leftover flat max_w {_BIG:.0f} beside a roster of two " + f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}") +check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT " + "WATCHDOG SILENTLY. Two 2000 W units beside a `max_w: 5000` left over " + "from a single-inverter install and `unit_sign_disagree_w: 3000` used to " + "produce ZERO problems - the site started, the status page said `armed`, " + "and the watchdog could not trip on EITHER unit. It is refused now, " + "against the weakest unit's own cap, and the message names that unit", + _lv == (True, "unit-0", _SMALL)) +check("...and the over-refusal in the OTHER direction is gone too: a roster of " + "two 5000 W units with NO flat `max_w` was refused at 3000 W against a " + "2000 W DEFAULT the operator never wrote, for a threshold both units can " + "reach. It starts", + _verdict(_roster_n([_BIG, _BIG], + **dict(_QUIET, unit_sign_disagree_w=_RATE)), + "SAFETY-30 per-inverter sign-disagreement") + == (False, None, None)) +check("...and the flat single-inverter config it always refused is refused " + "IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is " + "the same comparison, the same boundary and the same sentence", + why(validate_startup_config(base(max_w=2000, unit_sign_disagree_w=2000), + -5000, 5000), "unit_sign_disagree_w") + and validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999), + -5000, 5000) == []) + + # --------------------------------------------------------------------------- # print() if fails: diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index d0d83a0..af55acc 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -1818,6 +1818,181 @@ check("...and no `sign_site` claim can be filed at a roster of one, so the " "arbitration is byte-identical to what shipped", "sign_site" not in _c1s.reason) + +print() +print("8. SAFETY-32 ROUND 4's ENUMERATION: the two `any(...)` reductions over") +print(" the roster whose only evidence is a latched NON-FIRST unit") +# ⚠️ THIS SECTION IS NOT SAFETY-32's SUBJECT, AND IT IS HERE BECAUSE OF IT. +# SAFETY-32 was rejected three times for the same shape one step over: a +# reduction over roster length whose fixture could not separate it from a slice +# of the roster. Round 4 therefore ENUMERATED every such reduction in +# `Controller` - min, max, sum, any, all, sorted, [0], [-1] over +# `self.inverters` / `self.packs` - and mutated each to `[:1]` to find out which +# were GRADED. Two survived a green 19-file suite, both `any(i.sign_wd.tripped +# for i in self.inverters)`, both SAFETY-30's, and this block is what closes +# them. No shipped behaviour changes; the reductions were already right. +# +# main.py `cycle()`, the stale-input hold - +4 bytes, 19/19 GREEN +# main.py `publish()`, the status vocabulary - +4 bytes, 19/19 GREEN +# +# ⚠️ WHY THE SUITE COULD NOT SEE THEM, and it is the same sentence every time: +# the only fixture that reached either was a roster of ONE, where +# `self.inverters[:1]` IS `self.inverters`. Part 3 above builds a two-inverter +# roster in which ONLY inverter B is latched - the exact data both reductions +# needed - and neither of them was read on it. A fixture of N units cannot +# separate reductions that agree on N units, one card over. +# +# ⚠️ AND BOTH ORDERS ARE RUN, for 12g's reason: a single "only B is latched" +# fixture kills `[:1]` and leaves `[-1:]` - grade the roster by its LAST unit - +# answering identically. The pair is what leaves no single-position slice. + + +async def _latched_then_input_gap(reversed_idx): + """Latch ONE unit's sign watchdog, then take a SIBLING's reading away. + + ⚠️ A REAL LATCH FROM A REAL EPISODE, not `sign_wd.tripped = True`: the same + `_SignHass` plant and the same `_drive()` part 3 uses, so the detector + reaches its verdict through the shipped path. Then one pack's SoC goes + missing for LESS than `stale_input_s`, which is the state the hold exists + for - a single missed poll must not become a fault. + + THE FAULT THIS GRADES: a LATCHED unit must take the whole roster past that + hold, because the unit's `failsafe` SET is the thing parking it. Return + early and the unit's last share stays standing for the heartbeat to + re-emit - main.py's own comment: "the trip would stop acting the moment a + poll was missed". A reduction that looked at one roster POSITION would do + exactly that whenever the latched unit was not the position it looked at. + """ + hass = _SignHass(load_w=2000.0, reversed_units={reversed_idx}) + c = _sign_site(hass) + await _drive(c) + sibling = c.inverters[1 - reversed_idx] + sibling.soc = None + c.inputs_bad_since = datetime.now(timezone.utc) + await c.cycle() + return c + + +_gap_b = asyncio.run(_latched_then_input_gap(1)) +_gap_a = asyncio.run(_latched_then_input_gap(0)) +for _tag, _c, _i in (("B latched", _gap_b, 1), ("A latched", _gap_a, 0)): + _pl = [p for p in (_c.allocation.plans if _c.allocation else ()) + if p.unit == _c.inverters[_i].name] + print(f" {_tag}, sibling's SoC missing 0 s: inputs_ok=" + f"{_c.inputs_ok}, reason={_c.reason!r}, latched unit target=" + f"{_c.inverters[_i].target}, why={_pl[0].why if _pl else '(no plan)'}") +check("PRECONDITION (habit 1): on BOTH runs the site really is in the state " + "the hold is for - one pack's reading is missing, `inputs_ok` is False, " + "the gap is younger than `stale_input_s`, and the SITE latch is NOT " + "tripped, so the only thing that can carry the roster past the hold is " + "the per-unit `any()`. And exactly ONE unit is latched on each run, at " + "the OTHER position", + _gap_b.inputs_ok is False and _gap_a.inputs_ok is False + and _gap_b.site_sign_wd.tripped is False + and _gap_a.site_sign_wd.tripped is False + and [i.sign_wd.tripped for i in _gap_b.inverters] == [False, True] + and [i.sign_wd.tripped for i in _gap_a.inverters] == [True, False]) +check("⚠️ A LATCHED UNIT TAKES THE ROSTER PAST THE STALE-INPUT HOLD FROM EITHER " + "POSITION. Neither cycle parks on `inputs missing`; both go on to file " + "claims, and the latched unit is commanded 0 W by its OWN `failsafe` " + "claim, named in the allocation record. Grade this with " + "`self.inverters[:1]` and the B-latched run returns early - the +4-byte " + "mutation that was GREEN across all 19 files - and with " + "`self.inverters[-1:]` the A-latched run does. The remedy stops acting " + "the moment a poll is missed, which is main.py's own sentence for it", + not (_gap_b.reason or "").startswith("inputs missing") + and not (_gap_a.reason or "").startswith("inputs missing") + and _gap_b.inverters[1].target == 0.0 + and _gap_a.inverters[0].target == 0.0 + and all(any(p.unit == c.inverters[i].name and "failsafe" in p.why + for p in (c.allocation.plans if c.allocation else ())) + for c, i in ((_gap_b, 1), (_gap_a, 0)))) +# ...and the counterfactual, which is what makes the two lines above a +# DIFFERENCE rather than a description: with NOTHING latched, the very same +# input gap DOES park the site on the hold. Without this the checks above would +# be satisfied by a `cycle()` that never had a hold at all. +async def _gap_no_latch(): + c = _sign_site(_SignHass(load_w=2000.0)) + await _drive(c, n=2) + c.inverters[0].soc = None + c.inputs_bad_since = datetime.now(timezone.utc) + await c.cycle() + return c + + +_gap_none = asyncio.run(_gap_no_latch()) +print(f" nothing latched, same gap: reason={_gap_none.reason!r}") +check("...and the counterfactual (habit 7): with NO unit latched the SAME gap " + "DOES hold the site - `inputs missing 0s` - so the two runs above show " + "the latch carrying the roster past a hold that is really there, rather " + "than a hold that never fires", + (_gap_none.reason or "").startswith("inputs missing") + and not any(i.sign_wd.tripped for i in _gap_none.inverters)) + +# --- and the SECOND survivor: the status vocabulary publish() hands the broker +# ⚠️ THE EXISTING CHECK IS AT A ROSTER OF ONE, which is why this one exists. +# "MQTT's controller-status sensor reports 'failsafe'" above drives a +# single-inverter Controller, where `self.inverters[:1]` is the whole roster. +# A site at 0 W reporting `running` is the failure main.py's comment names, and +# at two inverters it is reachable whenever the latched unit is not the one a +# narrowed reduction happens to read. +_pub = {} + + +def _published(ctl): + _pub.clear() + ctl.mqtt = type("P", (), {"enabled": True, + "publish": lambda _s, v: _pub.update(v), + "close": lambda _s: None})() + ctl.publish() + return dict(_pub) + + +_pb, _pa = _published(_gap_b), _published(_gap_a) +_pn = _published(_gap_none) +print(f" published status: B latched -> {_pb.get('status')!r}, " + f"A latched -> {_pa.get('status')!r}, none latched -> " + f"{_pn.get('status')!r}") +check("⚠️ AND `publish()` SAYS `failsafe` WHICHEVER UNIT IS LATCHED. The status " + "sensor is the vocabulary every dashboard already reads, and a site with " + "one inverter parked by its own failsafe claim reporting `running` is " + "the exact failure main.py's comment names. `self.inverters[:1]` " + "publishes `running` on the B-latched site - +4 bytes, GREEN across all " + "19 files - and `self.inverters[-1:]` does it on the A-latched one", + _pb.get("status") == "failsafe" and _pa.get("status") == "failsafe") +check("...and the counterfactual: the same two-inverter site with NOTHING " + "latched publishes `running`, so the two above are a difference the " + "reduction produced and not a constant", + _pn.get("status") == "running" and _gap_none.auto is True) + +# --- and the THIRD one the same enumeration found: the OPERATOR'S BANNER ----- +# ⚠️ `status_payload()`'s failsafe branch is a fourth `any(... for i in +# self.inverters)`, and `self.inverters[:1]` there was +4 bytes and GREEN across +# all 19 files too - for the third time, because the only fixture that reached +# it was a roster of ONE. The banner is the ONE sentence at the top of the page; +# a site holding an inverter at 0 W under a "Stopped" or "NOT READY" headline +# sends the operator to the wrong question. +_bn_b = _gap_b.status_payload() +_bn_a = _gap_a.status_payload() +_bn_n = _gap_none.status_payload() +print(f" banner: B latched -> {_bn_b['banner']!r}") +print(f" A latched -> {_bn_a['banner']!r}") +print(f" none -> {_bn_n['banner']!r}") +check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED, at " + "level `bad`, with the N>=2 sentence that names the unit's OWN measured " + "power rather than the meter. `self.inverters[:1]` drops the headline on " + "the B-latched site and `self.inverters[-1:]` on the A-latched one, and " + "the page then leads with whatever the next branch says", + _bn_b["level"] == _bn_a["level"] == "bad" + and _bn_b["banner"] == _bn_a["banner"] + and _bn_b["banner"].startswith("FAILSAFE") + and "its own measured power" in _bn_b["banner"]) +check("...and the counterfactual again: the same site with nothing latched " + "carries neither that level nor that sentence, so the banner really is " + "being produced by the reduction under test", + not _bn_n["banner"].startswith("FAILSAFE") + and _bn_n["banner"] != _bn_b["banner"]) + print() if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}") diff --git a/goodwe_controller/tools/supervisor_shape_probe.py b/goodwe_controller/tools/supervisor_shape_probe.py index 713d65b..85237d4 100644 --- a/goodwe_controller/tools/supervisor_shape_probe.py +++ b/goodwe_controller/tools/supervisor_shape_probe.py @@ -40,8 +40,24 @@ DECISIONS/CFG-04-inverter-roster.md: reconstruction happens to lack FIRST, which is an artefact of the reconstruction and NOT evidence about `inverters`. B is that evidence. C. A two-inverter blob validates, per-entry types enforced. - D. An entry missing `name`, `setpoint_entity` or `max_w` is refused BY NAME, - and a JSON string where the list belongs is refused outright. + D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is + refused BY NAME - one key varied from an ACCEPTED baseline, and the key's + own name asserted in the refusal - and a JSON string where the list belongs + is refused outright. + +⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED +per-entry key. That change broke §C and, worse, left §D printing PASS on a +refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C +now carries the key and §D is an isolated counterfactual asserting the key name. + +⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's +rebase onto SPIKE-04a merged two independent edits into §C and §D - the required +`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched +together by hand from two runs is not a measurement, so this one was measured: +`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a +reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it +differs from A's 10 so that the per-entry number is exercised at all, while +production is expected to have BOTH packs at 10 %. """ import sys, json, copy sys.path.insert(0, "/usr/src/supervisor") @@ -167,13 +183,25 @@ print("\n== C. AC2: a two-inverter blob an operator would POST ==") # the 5000/3000 it replaces - fine here, where nothing computes on the numbers, # and NOT fine in the test fixtures, which is why those keep a wide illustrative # pair instead of the real one. +# +# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this +# blob is also what proves an operator can still POST a two-inverter config +# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would +# not exercise a per-entry number at all, for the same reason the two ratings +# must stay different. +# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 % +# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W +# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ +# - there is no second pack on this site, and in production BOTH packs are +# expected to floor at 10 %, which is why the differing pair lives here and in +# the fixtures rather than in config.yaml's example. TWO = dict(LIVE, setpoint_entity="", inverters=[ {"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", - "max_w": 5000}, + "max_w": 5000, "bms_soc_floor": 10}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power", - "max_w": 4600}]) + "max_w": 4600, "bms_soc_floor": 15}]) ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))) show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out) # The validated blob, for anyone who wants to POST it. Optional: the probe's @@ -184,17 +212,38 @@ except OSError: pass print("\n== D. what the schema itself refuses, per entry ==") -# The rating here is filler - each entry is refused for a MISSING key, so no -# verdict below depends on the number. It carries B's datasheet 4600 W anyway, -# so that nothing in this file reads as 3000 W being inverter B's rating. -for title, entry in ( - ("no name", {"setpoint_entity": "number.b", "max_w": 4600}), - ("no setpoint_entity", {"name": "inverter-b", "max_w": 4600}), - ("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}), - ("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})): - ok, out = validate(dict(TWO, inverters=[entry])) - show(f"an entry with {title} is refused", not ok, out) -ok, out = validate(dict(TWO, inverters="[]")) +# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY +# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this +# section. These entries used to omit several keys at once and assert a bare +# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went +# on printing PASS while the refusal it printed said `Missing option +# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of +# anything, so the baseline below is COMPLETE and each case deletes exactly one +# key - or, for the range case, moves exactly one value out of range. +D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600, + "bms_soc_floor": 10} +# MERGED with the shipped defaults, exactly as §C is and as App.options is: the +# raw LIVE blob is missing root keys unrelated to this section (`maintenance_ +# start_hour` on the run that caught it), and a baseline refused for one of THOSE +# is the provenance problem §B2 is kept as a warning about. +D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)) +ok, out = validate(dict(D_BASE, inverters=[D_FULL])) +show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere " + "else to come from", ok, json.dumps(out.get("inverters")) if ok else out) +for title, entry, expect in ( + ("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"), + ("no setpoint_entity", + {k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"), + ("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"), + ("no bms_soc_floor (SAFETY-32)", + {k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"), + ("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"), + ("bms_soc_floor over the range (SAFETY-32)", + dict(D_FULL, bms_soc_floor=99), "at most 50")): + ok, out = validate(dict(D_BASE, inverters=[entry])) + show(f"an entry with {title} is refused, and the refusal NAMES it", + (not ok) and expect in str(out), out) +ok, out = validate(dict(D_BASE, inverters="[]")) show("a JSON STRING where the list belongs is refused", not ok, out)