From 8501e055f2a07db54f80828def95b821ac48fa23 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 4 Sep 2026 17:09:58 +0200 Subject: [PATCH] SAFETY-32 fix round: grade the reduction on packs whose floors DIFFER MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC 6 was not met for the reduction main.py actually hands decide(). The reviewer removed the per-pack grading twice and the 19-file suite stayed green both times. No shipped behaviour changes here - app/ is untouched. What changes is what observes it. WHY BOTH SURVIVED, and it is this repository's recurring defect landing on this card's own Decision 2: 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/last are four spellings of one answer. The only check using a cut-out ABOVE the EMS floor - 12c's `_n1_deep` - ran on a roster of ONE, where every reduction agrees. So no check ever evaluated binding_soc_floor() on two packs whose floors differ. And the degeneracy is not a defence: this PR's own argument ("unreachable is a claim about today's rule ordering, and validate_startup_config() is also sim/inject.py's entry point") justifies writing the reduction and equally obliges grading it. * test_peakguard.py part 12c2 - NEW. binding_soc_floor() on a roster whose per-pack floors ALL DIFFER: four SIMULATED cut-outs 30/25/40/35 %, each strictly above peak_soc_floor, so max (40), min (25), first (30), last (35) and the mean (32.5) are five different numbers and the extremes sit at INTERIOR roster positions. Two packs structurally cannot separate those - there max==last and min==first - and neither can three; four is the smallest roster that can. Precondition is its own check (habit 1); the counterfactual raises ONE pack - the last, neither extreme - past the old maximum and the answer follows (habit 7); peak_soc_floor is then lifted above every cut-out to grade the other side of the conjunction. * test_peakguard.py part 12g - REWRITTEN as behaviour (habit 4). The old assertion was `"self.packs" in `, which `self.packs[:1]` still satisfies. It now drives REAL Controller.cycle()s on a two-pack roster, both packs at the same SoC, and reads PeakDecision.soc_floor - the argument, echoed back by the shipped decide(). The cut-outs are the only thing varied, and the SAME PAIR is carried in BOTH ORDERS. * ⚠️ TWO MORE SURVIVORS, found by this round's own battery and recorded rather than quietly repaired. (a) The first cut of 12g varied only pack B's cut-out, so `self.packs[-1:]` (+5 bytes) agreed with the roster on both runs and stayed green across all 19 files; the swapped pair closes it, and between the two directions no single-position slice passes all three runs. (b) binding_soc_floor()'s EMPTY-roster branch rewritten to `return None` (-43 bytes) was green too - a defensive branch nothing in the suite reached. Both directions of it are pinned now. * wired_over_limit() gained a prepare(ctl) hook, inside its retry loop so every attempt grades the same site. * In-process mutation rows 22, 22b and 23 record all four survivors. Nine further source mutations went through the harness on the fixed tree (binding_soc_floor -> floors[0] / floors[-1], pack_floor_pct conjoining with min, pack_permissions grading against packs[0], `any` -> `all` on the unknown floor, Controller.packs reversed, decide(soc=) taking inverters[0].soc, and the two survivors above). Seven were red on first run; the two survivors are now red as well. Three of the nine have a ZERO byte delta and still went red, which is the direction stale bytecode cannot fabricate - and the harness purged anyway. MANDATORY PROOF, all in the CI image over a tree rsynced to a plain path and bind-mounted :ro, bytecode entry count printed before and after the purge: each of the four mutations now takes test_peakguard.py RED, and the unmutated tree is 19/19 green (twice, plus test_peakguard.py five times in isolation - card CI-06's flake did not reappear). The zero-byte one was proved to have RUN rather than assumed: dis.get_instructions LOAD_GLOBALs were [float, pack_floor_pct, any, min] and binding_soc_floor() returned 25.0 on the 12c2 roster where the shipped code returns 40.0. No measurement of inverter B appears anywhere. It does not physically exist. Every second-pack and fourth-pack figure added here is SIMULATED and labelled at its definition; only inverter A's 10 % is read off hardware. No hardware was touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 45 +++ .../DECISIONS/SAFETY-32-per-pack-bms-floor.md | 110 ++++++ goodwe_controller/test_peakguard.py | 348 +++++++++++++++++- 3 files changed, 493 insertions(+), 10 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 5fb2231..47c9ee9 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -58,6 +58,51 @@ thing that stops. Rule 8's own failure mode, reached through rule 8. 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. ⚠️ **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 diff --git a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md index 9c8d2fd..bb9a5ee 100644 --- a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -81,6 +81,116 @@ 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. + ## 4. ⚠️ THE HONEST LIMITATION, and it is the reason this record exists **Under any configuration rule 8 accepts, `binding_soc_floor()` answers diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index ba5401b..4273fdf 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -1063,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 @@ -1079,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. @@ -3291,6 +3299,130 @@ check("...and on the pair rule 8 REFUSES - a cut-out above the EMS floor - the " "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 every entry, and the 40 % " + "above was not a coincidence of position", + binding_soc_floor(_deeper, SOC_FLOOR) == 45.0 + and binding_soc_floor(_many_packs, SOC_FLOOR) == 40.0) +# ...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) @@ -3436,17 +3568,147 @@ check("...and with the key absent entirely the one pack takes the SHIPPED " 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}`") -check("main.py hands `decide()` the PER-PACK reduction of `peak_soc_floor`, " - "not the bare option: `binding_soc_floor(self.packs, ...)`, so the floor " - "the site is graded against can never sit below the deepest cut-out on " - "the roster. The key and the one defaults mirror are still in that " - "expression - graded three parts above - because this card moved the " - "REDUCTION and not the source", - "binding_soc_floor" in _floor_call and "self.packs" in _floor_call - and '"peak_soc_floor"' in _floor_call.replace("'", '"')) + + +def _floor_reaching_decide(tag, a_floor, b_floor, peak_floor=None): + """Drive ONE REAL `Controller.cycle()` on a TWO-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. Both packs are held at the SAME SoC and the + same everything else; the only things that vary between calls are the packs' + own cut-outs. + """ + opts = {} if peak_floor is None else {"peak_soc_floor": peak_floor} + + def _prep(ctl): + second_inverter(ctl) + for inv, fl in zip(ctl.inverters, (a_floor, b_floor)): + 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 what is " + "reduced is the ROSTER and not a POSITION in it. `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; this is the line " + "that closes it, and between them the two directions leave no " + "single-position slice that can pass all three runs", + _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 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 @@ -3729,6 +3991,61 @@ MUT.append(("the per-pack notification naming a fixed roster position", 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")) + for label, caught, where in MUT: print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}") print(f" {where}") @@ -3748,7 +4065,18 @@ check("...and SAFETY-32's own eight defeats are each caught too - the flat " "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]) and len(MUT) == 21) + all(m[1] for m in MUT[13:21])) +check("...⚠️ AND SO ARE THE FOUR DEFEATS THAT SURVIVED A GREEN 19-FILE SUITE, " + "which is why this fix round exists: `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), green " + "on the FIRST CUT OF THE FIX ITSELF; and the empty-roster branch " + "rewritten (-43 bytes). None of the four is a new guard - no shipped " + "behaviour changed - they are checks whose DATA could not reach the " + "failure they named, which is the defect this repository has now shipped " + "in six consecutive cards. The last two were found by this round's own " + "battery and are recorded rather than quietly repaired", + all(m[1] for m in MUT[21:24]) and len(MUT) == 24) 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 "