From f9e7fbf86dc391b7c0219edc836eab72970f7a76 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 4 Sep 2026 14:11:11 +0200 Subject: [PATCH 1/5] SAFETY-32: bms_soc_floor per pack, rule 8 per unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bms_soc_floor` describes A BATTERY, not a site. At two packs of different models the cut-outs differ, and one number for both graded the shallower pack against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under `peak_soc_floor: 20` 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. Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as already shipped and are untouched. * config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry. Nothing is inherited; the flat key stays the single-inverter shape's own cut-out. A per-entry DEFAULT was declined - a default is one number for every pack, which is this card's defect - and the decision is recorded. * inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None, not a borrowed number and not 0.0 (the most permissive value the key has). * 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. * validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8 grades EVERY unit and NAMES the one it refuses. Graded with the offender SECOND and with the same two numbers in the other order. * main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack reduction of the same option; the floor-limited notification names the pack that stopped the site. `pack_floor_soc` - the emptiest pack, and its None-on-missing behaviour - is untouched. * tools/supervisor_shape_probe.py: RE-RUN and repaired. 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, asserting the key name. `all cases passed` on 2026.08.0. Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR reduces to `peak_soc_floor` for every pack. The reduction is written for `unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid pairs answer the option, the pair rule 8 refuses answers the cut-out. `peak_soc_floor` is still one number for the site, so the "deeper pack needlessly withheld" half is filed as an open question, not closed. Full 19-file suite green. Twelve source mutations through tools/mutation_run.sh: eleven red, one argued survivor. The battery also found `_flat_bms_floor()` defaulting in two places, each covering for the other. No measurement of inverter B appears anywhere: it does not exist yet. Every second-pack figure is simulated and labelled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 62 +++ .../DECISIONS/SAFETY-32-per-pack-bms-floor.md | 215 +++++++++ goodwe_controller/DOCS.md | 2 +- goodwe_controller/app/inverter.py | 85 +++- goodwe_controller/app/main.py | 54 ++- goodwe_controller/app/peakguard.py | 127 +++++ goodwe_controller/app/validate.py | 102 +++- goodwe_controller/config.yaml | 35 +- goodwe_controller/test_inverter.py | 42 +- goodwe_controller/test_peakguard.py | 453 +++++++++++++++++- goodwe_controller/test_validate.py | 9 +- .../tools/supervisor_shape_probe.py | 70 ++- 12 files changed, 1193 insertions(+), 63 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 8af1c83..77f4905 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -179,6 +179,68 @@ 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`. +* **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. + +⚠️ **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..9c8d2fd --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -0,0 +1,215 @@ +# 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. + +## 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. + +## 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. + +⚠️ **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. + +--- + +## 7. The probe transcript, re-run 2026-09-04 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. + +``` +== 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": 3000, "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/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..ba8e253 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. @@ -871,28 +920,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..eaf5436 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 ( @@ -3160,6 +3161,374 @@ 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) + +# --- 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 ------- +_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("'", '"')) + +# --- 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) + + # ============================================================================== print() print("11. mutation: what defeats these guards, and which check bites") @@ -3298,6 +3667,78 @@ 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")) + for label, caught, where in MUT: print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}") print(f" {where}") @@ -3312,6 +3753,12 @@ 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]) and len(MUT) == 21) 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..2be9b3a 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) == []) diff --git a/goodwe_controller/tools/supervisor_shape_probe.py b/goodwe_controller/tools/supervisor_shape_probe.py index 713d65b..9050e8f 100644 --- a/goodwe_controller/tools/supervisor_shape_probe.py +++ b/goodwe_controller/tools/supervisor_shape_probe.py @@ -40,8 +40,15 @@ 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. """ import sys, json, copy sys.path.insert(0, "/usr/src/supervisor") @@ -167,13 +174,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 +203,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) -- 2.54.0 From ffbabea8ce30c65e2831573291f26b6adb2103dc Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 4 Sep 2026 17:09:58 +0200 Subject: [PATCH 2/5] 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 77f4905..015be06 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -235,6 +235,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 eaf5436..f2a2f3f 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. @@ -3301,6 +3309,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) @@ -3446,17 +3578,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 @@ -3739,6 +4001,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}") @@ -3758,7 +4075,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 " -- 2.54.0 From a4c95f83ce1b5d72972ef3749518be06cf027155 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Fri, 4 Sep 2026 20:23:36 +0200 Subject: [PATCH 3/5] SAFETY-32 round 3: a fixture of N packs cannot separate reductions that agree on N Both blockers were the recurring defect sitting inside the fix written to close it: a check whose TEXT claims more than its FIXTURE can observe. Both survived a green 19-file suite on 8501e05. max(floors) -> max(floors[-2:]) +5 bytes. 12c2's only counterfactual raised the LAST pack, and every window ending at the roster's end follows the last pack. The check said "the reduction reads every entry"; the data reached "it reads the last entry". self.packs -> self.packs[:2] +4 bytes. All three 12g runs drove a TWO-pack roster, where [:2] IS the roster. The check said "what is reduced is the ROSTER and not a POSITION in it"; the true claim was the narrower "no SINGLE-POSITION slice passes all three runs". The prose is cut back to what the old data reaches, and new data added for the rest. No app/ file is touched - app/peakguard.py and app/main.py are byte-identical to 8501e05. 12c2 - the pure function: * a ROSTER SET: the same four simulated cut-outs with the deepest moved to each of the four positions. * a computed FAMILY TABLE of fourteen rewrites, printed, with the roster that kills each. A member with an empty kill list takes the check RED naming it. * a LENGTH SWEEP: 78 rosters, lengths 1-12, deepest at every position of each. Closes every head window max(floors[:w]) and tail window max(floors[-w:]) of width 1-11 in one property. 12g - the call site: * _floor_reaching_decide() takes *floors; extra units join through the same second_inverter() hook. * THREE-pack runs with the deep cut-out at index 0, 1 and 2. Every positional slice of a three-pack roster drops a position, and the run whose deep pack sits there answers the option where the roster answers the cut-out. * a POSITIONAL-SLICE TABLE computed against the REAL Controller.packs each of the six cycles ran and the REAL PeakDecision.soc_floor it produced. * an EXACT STRUCTURAL PIN on the roster argument's identity. 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 GENERAL LAW, and why the pin is there: 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. This round's own battery found self.packs[:3] (+4) and self.packs[-3:] (+5) surviving all six behavioural runs; the structural pin is what closes them, and one more pack would only have moved the hole. Battery: 46 mutations over the reduction, the call site, Controller.packs and pack_floor_pct()'s conjunction. Bytecode purged and counted 23 -> 0 for each. 43 red, 3 survivors, all three recorded: max(floors[:12]) / max(floors[-12:]) NOT CAUGHT - needs a 13-pack roster, reported with its size, row 27. max(set(floors)) EQUIVALENT - max is duplicate-invariant. (list(reversed(self.packs)) is equivalent too and is red on the structural pin.) In-process mutation rows 24-27 record all of it, including the not-caught one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 21 + .../DECISIONS/SAFETY-32-per-pack-bms-floor.md | 104 +++- goodwe_controller/test_peakguard.py | 522 +++++++++++++++++- 3 files changed, 621 insertions(+), 26 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 015be06..6d66482 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -230,6 +230,27 @@ thing that stops. Rule 8's own failure mode, reached through rule 8. 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 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 bb9a5ee..41404f0 100644 --- a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -191,6 +191,95 @@ command a forced discharge to 20 % on a pack whose BMS cuts out at 35 %. 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 @@ -270,10 +359,23 @@ per-pack bound. Nothing here touched it. 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. +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. --- diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index f2a2f3f..ddcd2be 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -3398,10 +3398,173 @@ print(f" ...raise ONLY the last pack 35 % -> 45 %: " 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", + "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 @@ -3594,23 +3757,34 @@ _floor_call = ast.unparse(next(k.value for k in _call.keywords print(f" main.py's decide(soc_floor=...) is `{_floor_call}`") -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 +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. Both packs are held at the SAME SoC and 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): - second_inverter(ctl) - for inv, fl in zip(ctl.inverters, (a_floor, b_floor)): + 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( @@ -3662,13 +3836,16 @@ check("⚠️ THE CALL-SITE PIN, AS BEHAVIOUR. Change the cut-out of a NON-FIRST _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", +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 @@ -3703,6 +3880,197 @@ check("...and the precondition that makes the three runs comparable (habit 1): " == [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. @@ -4056,6 +4424,94 @@ MUT.append(("main.py grading the site against ONE roster position - " "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")) + for label, caught, where in MUT: print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}") print(f" {where}") @@ -4076,17 +4532,33 @@ check("...and SAFETY-32's own eight defeats are each caught too - the flat " "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 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 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) == 28) +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 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 " -- 2.54.0 From 8beba0a3e768273263b357a4ebf75d7ef917353d Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 5 Sep 2026 00:05:09 +0200 Subject: [PATCH 4/5] SAFETY-32 round 4: the same family at the SIBLING ARGUMENT, and the family enumerated Rounds 2 and 3 hardened the FLOOR axis - the max inside binding_soc_floor(), the roster handed to it, Controller.packs, pack_floor_pct()'s conjunction. peakguard.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 bytes -> ... vals[-2:] +5 bytes -> ... vals[:3] +4 bytes 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: three packs at 50/60/11 %, emptiest THIRD, answers 11.0 shipped and 50.0 mutated. 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. Round 3's machinery transfers directly. New test_peakguard.py part 12i: - six REAL Controller.cycle()s, emptiest pack at every position of a three-pack roster and both positions of a two-pack one, observable read off PeakDecision.soc; cut-outs held flat so the floor axis cannot move the answers - computed slice and non-slice tables, printed, red naming any live survivor - a LENGTH SWEEP over the real property, 78 rosters of 1..12 real inverters, closing head/tail widths 1-11 and asserting width 12 is unreached - a NONE-GUARD sweep over the same 78 - THREE exact structural pins: the vals comprehension iterates the bare self.inverters, and both any() and min() reduce the whole vals - closing every slice width at every roster length, which no bounded fixture can SPIKE-04: pack B is 4 x Dyness DL5.0C, DoD 90 -> floor 10 % [FROM DATASHEET AND SERIAL - inverter B does not exist and nothing about it is measured]. Pack A is DoD 90 -> 10 % and that one IS measured. So production is DEGENERATE on the floor axis and a differing floor exists ONLY in the fixtures - which is the argument for keeping them, not for simplifying them. Recorded in CHANGELOG and DECISIONS so nobody later collapses them to one number. The SoC axis has no such degeneracy. AND THE FAMILY, not the member. 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. FOUR were not, and three of them are SAFETY-30's, all any(i.sign_wd.tripped for i in self.inverters), each +4 bytes and green across 19 files - because the only fixture that reached them was a roster of ONE, where self.inverters[:1] IS self.inverters: - cycle()'s stale-input hold: a LATCHED unit must take the roster past it or its failsafe SET stops being re-filed the moment a poll is missed - publish()'s status vocabulary: a site holding an inverter at 0 W published "running" - status_payload()'s banner: the one headline on the page New test_watchdog.py part 8 grades all three with the latch at EACH position plus a no-latch counterfactual. 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 needed. Two rows reported OPEN rather than closed: _grade_store_migration()'s second disjunct (now red on 12j, argued equivalent because a unit tripped at startup implies its namespaced key is set) and cycle()'s undeliverable-unit LOG LINE, where sorted(...)[:1] is still green - it truncates names in one event message; the refusal itself is a different expression and is graded. MUTATION.md gains habits 10 and 11: a fixture of N members cannot separate two reductions that agree on N, and mutate the SIBLINGS of anything you harden. No shipped behaviour changed - tests and documentation only. 19/19 green; all three named survivors and all round-3 mutations verified RED. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 65 ++ .../DECISIONS/SAFETY-32-per-pack-bms-floor.md | 167 +++++ goodwe_controller/MUTATION.md | 32 + goodwe_controller/test_peakguard.py | 668 +++++++++++++++++- goodwe_controller/test_watchdog.py | 175 +++++ 5 files changed, 1106 insertions(+), 1 deletion(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 6d66482..216cc45 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -302,6 +302,71 @@ thing that stops. Rule 8's own failure mode, reached through rule 8. 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. + ⚠️ **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 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 41404f0..c75cb86 100644 --- a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -311,6 +311,173 @@ 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 **every** comprehension in `Controller` +that iterates the roster — 26 of them across 23 methods, computed from main.py's +AST, printed, and required to iterate the roster **whole**. A new reduction added +over `self.inverters[:1]` anywhere in the class 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. + ## 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 diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index a4bd351..a4dd296 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -300,6 +300,38 @@ 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?* + 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/test_peakguard.py b/goodwe_controller/test_peakguard.py index ddcd2be..e2b7dce 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -4159,6 +4159,590 @@ check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack 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 ------------------------- +# ⚠️ THIS CARD HAS NOW BEEN 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 2 hardened `binding_soc_floor()`; round 3 found +# the call site's argument; round 4's blocker was `pack_floor_soc`, the SIBLING +# ARGUMENT of the same `decide()` call. Closing them one at a time has cost four +# rounds, so this block stops enumerating them by hand. +# +# EVERY comprehension in `Controller` that iterates the roster is listed here, +# COMPUTED from main.py's AST, PRINTED, and required to iterate the roster +# WHOLE. That is the family: `min`, `max`, `sum`, `any`, `all`, `sorted` and the +# list builders all take their roster through one of these, so a `[:1]`, a +# `[-1:]` or a `[:2]` anywhere in the class takes this line red on the line it +# was written - at every roster length, in every configuration, whether or not +# any fixture in this repository reaches it. +# +# ⚠️ WHAT IT DOES NOT COVER, said plainly. It grades the ITERABLE and nothing +# else: not which attribute is read off each unit, not which reduction is +# applied, and not the direct index sites (`self.inverters[0]` in `_only()`, +# `actuator_of()` and the store migration, `self.inverters[self.maint_index]` in +# `maint_unit`), which are not comprehensions and are graded where they live. +# The behavioural halves above are what say what these reductions MEAN. +_ctrl_cls = next(n for n in ast.walk(ast.parse(_mainsrc)) + if isinstance(n, ast.ClassDef) and n.name == "Controller") +_ROSTER_ATTRS = ("inverters", "packs") + + +def _is_roster(node): + return (isinstance(node, ast.Attribute) and node.attr in _ROSTER_ATTRS + and isinstance(node.value, ast.Name) and node.value.id == "self") + + +def _mentions_roster(node): + return any(_is_roster(n) for n in ast.walk(node)) + + +_roster_comps = [] +for _fn in ast.walk(_ctrl_cls): + if not isinstance(_fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for _n in ast.walk(_fn): + if isinstance(_n, (ast.ListComp, ast.SetComp, ast.DictComp, + ast.GeneratorExp)): + for _g in _n.generators: + if _mentions_roster(_g.iter): + _roster_comps.append((_fn.name, _g.iter.lineno, + ast.unparse(_g.iter))) +_roster_comps.sort(key=lambda r: (r[1], r[2])) + + +def _slices_roster(node): + """Is the roster SUBSCRIPTED anywhere inside this expression? + + ⚠️ THE TEST IS ON THE SUBSCRIPT, NOT ON THE SPELLING. `enumerate(...)`, + `zip(...)` and `pack_permissions(roster, floor)` all hand the WHOLE roster + on and are legitimate; `self.inverters[:1]` is the family this card has been + rejected over three times. Requiring the iterable to unparse to exactly + `self.inverters` would refuse the first three and grade the spelling instead + of the thing. + """ + return any(isinstance(n, ast.Subscript) and _is_roster(n.value) + for n in ast.walk(node)) + + +_sliced = [r for r in _roster_comps + if _slices_roster(ast.parse(r[2], mode="eval").body)] +print(f" EVERY comprehension in Controller that iterates the roster " + f"({len(_roster_comps)} of them, in " + f"{len({m for m, _l, _s in _roster_comps})} methods), and what each " + f"iterates:") +for _mname, _ln, _src in _roster_comps: + print(f" main.py:{_ln:<5} {_mname:<26} for ... in {_src}" + + ("" if _src in ("self.inverters", "self.packs") + else " [whole roster, wrapped]")) +check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY. Every " + "comprehension in `Controller` that iterates the roster iterates it " + "WHOLE - `self.inverters` or `self.packs`, bare. This is the check that " + "would have caught rounds 2, 3 and 4's blockers on the round they were " + "written instead of one review each: `min(... for v in vals[:2])` is not " + "here (it reduces a derived list, which 12i pins directly), but every " + "`for inv in self.inverters[:1]` in the class is. A new reduction added " + "over a slice of the roster is red on the line it is written, at every " + "roster length and with no fixture required. ⚠️ THE ITERABLE ONLY - what " + "is read off each unit and what reduction is applied are graded by the " + "runs, and the direct index sites are not comprehensions and not here", + len(_roster_comps) > 0 and not _sliced) +check("...and the enumeration is not vacuously empty and its predicate is not " + "trivially true (habit 7): it found more than twenty roster " + "comprehensions across more than fifteen methods, it REJECTS the same " + "expressions with a slice on them - bare and wrapped in `enumerate()` " + "alike - and it ACCEPTS the three legitimate wrapped forms this class " + "actually uses. A predicate that answered the same thing for both would " + "make the line above a description rather than a check", + len(_roster_comps) >= 20 + and len({m for m, _l, _s in _roster_comps}) >= 15 + and all(_slices_roster(ast.parse(f"self.{a}{sl}", mode="eval").body) + and _slices_roster(ast.parse(f"enumerate(self.{a}{sl})", + mode="eval").body) + for a in _ROSTER_ATTRS + for sl in ("[:1]", "[-1:]", "[:2]", "[1:]", "[:-1]", "[::2]")) + and not any(_slices_roster(ast.parse(e, mode="eval").body) + for e in ("self.inverters", "self.packs", + "enumerate(self.inverters)", + "peakguard.pack_permissions(self.packs, f)"))) +# ⚠️ AND THE TWO THIS ENUMERATION ACTUALLY FOUND, recorded here because the +# lesson is the finding and not the fix. Mutating every roster reduction in +# `Controller` to `[:1]` one at a time turned up TWO that survived a green +# 19-file suite, both `any(i.sign_wd.tripped for i in self.inverters)` and both +# SAFETY-30's rather than this card's: +# +# `cycle()`'s stale-input hold +4 bytes, 19/19 green - a LATCHED unit must +# take the roster past the hold, or its failsafe SET stops being re-filed +# the moment a poll is missed. Graded now in test_watchdog.py part 8, on +# a two-inverter roster with the latch at EACH position in turn. +# `publish()`'s status vocabulary +4 bytes, 19/19 green - a site with one +# inverter parked by its own failsafe claim published `running`. Same +# part, same two positions. +# +# Both were invisible for the same 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`. + + + + # ============================================================================== print() print("11. mutation: what defeats these guards, and which check bites") @@ -4512,6 +5096,74 @@ MUT.append(("`binding_soc_floor()` reduced over a window 12 packs wide or " "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 comprehension in the " + "class 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 the roster added ANYWHERE ELSE in `Controller` " + "over a slice - which is how rounds 2, 3 and 4 each began", + bool(_roster_comps) and not _sliced, + "part 12j: every roster comprehension in the class, enumerated " + "from main.py's AST and required to iterate the roster whole. ⚠️ " + "Mutating each of them to `[:1]` found FOUR that survived a green " + "19-file suite, none of them this card's: three `sign_wd.tripped` " + "reductions (SAFETY-30's stale-input hold, `publish()`'s status " + "vocabulary and `status_payload()`'s banner - now graded in " + "test_watchdog.py part 8) and the store-migration guard's redundant " + "second disjunct. 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}") @@ -4546,7 +5198,7 @@ check("...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUIT "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) == 28) + 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 " @@ -4559,6 +5211,20 @@ check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row "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_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)}") -- 2.54.0 From 19decf433f9280c1ac8051a80096486a6d6778b4 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 5 Sep 2026 05:36:16 +0200 Subject: [PATCH 5/5] SAFETY-32 round 5: stop closing sites, build an instrument that finds its own scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time the reviewer found the same shape one step over: the reduction, the call site, the sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the `Controller` class boundary; round 5's four blockers were reductions in validate.py, over a list DERIVED from the roster three calls away from it. Closing four more sites by hand would have produced a fifth rejection, so this round widens the instrument instead and lets it enumerate. ⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by calling the shipped validator both ways on the real tree: roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000 clean -> 1 problem, "...max_w (inverter-b) is 2000 W, so the SAFETY-02 sign-disagreement watchdog can never fire" min(caps[:1]) +4 B -> 0 problems. The site STARTS. caps_ix[:1] +4 B -> 0 problems. the SAME roster with the small cap FIRST (2000, 5000) clean -> 1 problem, naming inverter-a min(caps[-1:]) +5 B -> 0 problems. ⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]` mutant still refuses, because last IS min there. A fixture of N members cannot separate two reductions that agree on N members - which is why the behavioural half below is a SET and not a roster. THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`) Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK - and the roster is found by transitive closure from ONE seed, the `inverters` option key: through returns, through lists ACCUMULATED in a loop over a roster (which is exactly 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 roster reductions across 9 modules, out of 23 files parsed: main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10, peakguard 4, inverter 3, store 3. 14 modules reduce over no roster at all. ⚠️ 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 at all about two others - which is the argument for computing the scope, made with data. The pin rediscovers ALL FOUR blockers without being told where to look, and finds a FIFTH beside them (validate.py:783, rule 5's own message) that no review round named. Its counterfactual runs at two depths: every enumerated site's expression mutated with each of the four cuts this card has been defeated by (183 x 4 = 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. THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run with the pin NEUTRALISED: 145 of 183 a behavioural fixture separates the site from a slice of itself 38 of 183 ONLY the structural claim. Behaviourally UNGRADED. All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record and in part 12j itself: 15 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 reaches but not with enough units to separate. NONE of the 38 is closed behaviourally by this card and none is counted as covered. 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, 14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires AND names the unit the number came off. Beside it a COMPUTED family table of twelve candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] - each compared against what the shipped validator actually answered 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 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. `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 before the fix: two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems. The site started, the status page said `armed`, and the per-inverter watchdog could not trip on EITHER unit. Identical sentence to the blocker, one branch over. two 5000 W units with NO flat key -> REFUSED 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 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. MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit re-read FROM DISK with the new text present and the old absent, byte delta printed) B1 validate min(caps) -> min(caps[:1]) +4 B RED test_validate.py B2 validate min(caps) -> min(caps[-1:]) +5 B RED test_validate.py B3 validate caps_ix -> caps_ix[:1] +4 B RED test_validate.py B4 validate rule 6 min -> min(caps_ix[-1:]) +5 B RED test_validate.py B5 validate rule 5 msg -> min(caps_ix[:1]) +4 B RED test_validate.py B6 validate rule 5b msg -> min(caps_ix[-1:]) +5 B RED test_validate.py B7 CONTROL: weakest -> max(0.0, weakest) +10 B GREEN, as intended R3a peakguard max(floors) -> min(floors) 0 B RED test_peakguard.py R3b peakguard max(floors) -> max(floors[-2:]) +5 B RED test_peakguard.py R3c main call site packs -> self.packs[:2] +4 B RED test_peakguard.py R4a main pack_floor_soc -> vals[:2] +4 B RED test_peakguard.py R4b main pack_floor_soc -> vals[-2:] +5 B RED test_peakguard.py R4c main pack_floor_soc -> vals[:3] +4 B RED test_peakguard.py R4d main cycle() any(...) -> self.inverters[:1] +4 B RED test_watchdog.py B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached. B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and it stays green, so the battery is not merely red for everything. R3a is a ZERO-byte delta, so the shipped function was called both ways as well: packs floors [15.0, 30.0, 20.0], peak_soc_floor 15 binding_soc_floor(...) = 30.0 the 0-byte mutant would answer 15.0 (+15.0 pp) -> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %. ⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for keeping them, not for simplifying them - collapsing them deletes the only data in this repository that can see the reduction at all. Restated in config.yaml's example, in the probe and in the fixtures. ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here - 4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and labelled. Only inverter A's 10 % is read off hardware. REBASED onto 67e649a. Two conflicts with SPIKE-04a, both in the blocks this card had rewritten (config.yaml's roster example and the probe's C/D sections). Resolved to carry BOTH claims, and the probe was RE-RUN rather than hand-merged: `all cases passed` on ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, transcript updated with the date. MUTATION.md habit 8 - a committed transcript stitched together out of two runs is not a measurement. Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0 before and after. CI-06: test_peakguard.py run four times in isolation, green each time (288 ok lines); test_validate.py three times, green each time. MUTATION.md gains habits 12 and 13: build the instrument once the same shape has been found twice AND make it discover its own scope, and say what the instrument cannot see with sizes in the same breath. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 85 +++ .../DECISIONS/SAFETY-32-per-pack-bms-floor.md | 240 +++++- goodwe_controller/MUTATION.md | 31 + goodwe_controller/app/validate.py | 35 +- goodwe_controller/test_peakguard.py | 683 ++++++++++++++---- goodwe_controller/test_validate.py | 242 +++++++ .../tools/supervisor_shape_probe.py | 9 + 7 files changed, 1188 insertions(+), 137 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 216cc45..43b2219 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -366,6 +366,91 @@ thing that stops. Rule 8's own failure mode, reached through rule 8. 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 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 c75cb86..2b0cdc9 100644 --- a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -470,13 +470,227 @@ quietly or left unmentioned.** ### And the family closed rather than the member -`test_peakguard.py` part 12j enumerates **every** comprehension in `Controller` -that iterates the roster — 26 of them across 23 methods, computed from main.py's -AST, printed, and required to iterate the roster **whole**. A new reduction added -over `self.inverters[:1]` anywhere in the class 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. +`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 @@ -546,7 +760,7 @@ A's measured 10 % while the 35 % cut-out that moves along the roster is invented --- -## 7. The probe transcript, re-run 2026-09-04 on 2026.08.0 +## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0 Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring): @@ -561,6 +775,14 @@ 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 @@ -568,7 +790,7 @@ nothing. == 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": 3000, "bms_soc_floor": 10}] + [{"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 diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index a4dd296..c95bbed 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -332,6 +332,37 @@ Cheap habits fall out of it, and all of them are now used in this repository: 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/validate.py b/goodwe_controller/app/validate.py index ba8e253..7ce3720 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -806,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.") diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index e2b7dce..2577647 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -4618,127 +4618,563 @@ check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide() "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 ------------------------- -# ⚠️ THIS CARD HAS NOW BEEN REJECTED THREE TIMES FOR THE SAME SHAPE ONE STEP +# --- 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's blocker was `pack_floor_soc`, the SIBLING -# ARGUMENT of the same `decide()` call. Closing them one at a time has cost four -# rounds, so this block stops enumerating them by hand. +# 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. # -# EVERY comprehension in `Controller` that iterates the roster is listed here, -# COMPUTED from main.py's AST, PRINTED, and required to iterate the roster -# WHOLE. That is the family: `min`, `max`, `sum`, `any`, `all`, `sorted` and the -# list builders all take their roster through one of these, so a `[:1]`, a -# `[-1:]` or a `[:2]` anywhere in the class takes this line red on the line it -# was written - at every roster length, in every configuration, whether or not -# any fixture in this repository reaches it. +# 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 IT DOES NOT COVER, said plainly. It grades the ITERABLE and nothing -# else: not which attribute is read off each unit, not which reduction is -# applied, and not the direct index sites (`self.inverters[0]` in `_only()`, -# `actuator_of()` and the store migration, `self.inverters[self.maint_index]` in -# `maint_unit`), which are not comprehensions and are graded where they live. -# The behavioural halves above are what say what these reductions MEAN. -_ctrl_cls = next(n for n in ast.walk(ast.parse(_mainsrc)) - if isinstance(n, ast.ClassDef) and n.name == "Controller") -_ROSTER_ATTRS = ("inverters", "packs") +# 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 _is_roster(node): - return (isinstance(node, ast.Attribute) and node.attr in _ROSTER_ATTRS - and isinstance(node.value, ast.Name) and node.value.id == "self") +def _roster_sites(sources): + """Every roster-carrying expression in reduction/iteration position. - -def _mentions_roster(node): - return any(_is_roster(n) for n in ast.walk(node)) - - -_roster_comps = [] -for _fn in ast.walk(_ctrl_cls): - if not isinstance(_fn, (ast.FunctionDef, ast.AsyncFunctionDef)): - continue - for _n in ast.walk(_fn): - if isinstance(_n, (ast.ListComp, ast.SetComp, ast.DictComp, - ast.GeneratorExp)): - for _g in _n.generators: - if _mentions_roster(_g.iter): - _roster_comps.append((_fn.name, _g.iter.lineno, - ast.unparse(_g.iter))) -_roster_comps.sort(key=lambda r: (r[1], r[2])) - - -def _slices_roster(node): - """Is the roster SUBSCRIPTED anywhere inside this expression? - - ⚠️ THE TEST IS ON THE SUBSCRIPT, NOT ON THE SPELLING. `enumerate(...)`, - `zip(...)` and `pack_permissions(roster, floor)` all hand the WHOLE roster - on and are legitimate; `self.inverters[:1]` is the family this card has been - rejected over three times. Requiring the iterable to unparse to exactly - `self.inverters` would refuse the first three and grade the spelling instead - of the thing. + Returns `(rows, meta)`; each row is + `(module, lineno, col, end_lineno, end_col, qualname, kind, source, sliced)`. """ - return any(isinstance(n, ast.Subscript) and _is_roster(n.value) - for n in ast.walk(node)) + 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) -_sliced = [r for r in _roster_comps - if _slices_roster(ast.parse(r[2], mode="eval").body)] -print(f" EVERY comprehension in Controller that iterates the roster " - f"({len(_roster_comps)} of them, in " - f"{len({m for m, _l, _s in _roster_comps})} methods), and what each " - f"iterates:") -for _mname, _ln, _src in _roster_comps: - print(f" main.py:{_ln:<5} {_mname:<26} for ... in {_src}" - + ("" if _src in ("self.inverters", "self.packs") - else " [whole roster, wrapped]")) -check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY. Every " - "comprehension in `Controller` that iterates the roster iterates it " - "WHOLE - `self.inverters` or `self.packs`, bare. This is the check that " - "would have caught rounds 2, 3 and 4's blockers on the round they were " - "written instead of one review each: `min(... for v in vals[:2])` is not " - "here (it reduces a derived list, which 12i pins directly), but every " - "`for inv in self.inverters[:1]` in the class is. A new reduction added " - "over a slice of the roster is red on the line it is written, at every " - "roster length and with no fixture required. ⚠️ THE ITERABLE ONLY - what " - "is read off each unit and what reduction is applied are graded by the " - "runs, and the direct index sites are not comprehensions and not here", - len(_roster_comps) > 0 and not _sliced) -check("...and the enumeration is not vacuously empty and its predicate is not " - "trivially true (habit 7): it found more than twenty roster " - "comprehensions across more than fifteen methods, it REJECTS the same " - "expressions with a slice on them - bare and wrapped in `enumerate()` " - "alike - and it ACCEPTS the three legitimate wrapped forms this class " - "actually uses. A predicate that answered the same thing for both would " - "make the line above a description rather than a check", - len(_roster_comps) >= 20 - and len({m for m, _l, _s in _roster_comps}) >= 15 - and all(_slices_roster(ast.parse(f"self.{a}{sl}", mode="eval").body) - and _slices_roster(ast.parse(f"enumerate(self.{a}{sl})", - mode="eval").body) - for a in _ROSTER_ATTRS - for sl in ("[:1]", "[-1:]", "[:2]", "[1:]", "[:-1]", "[::2]")) - and not any(_slices_roster(ast.parse(e, mode="eval").body) - for e in ("self.inverters", "self.packs", - "enumerate(self.inverters)", - "peakguard.pack_permissions(self.packs, f)"))) -# ⚠️ AND THE TWO THIS ENUMERATION ACTUALLY FOUND, recorded here because the -# lesson is the finding and not the fix. Mutating every roster reduction in -# `Controller` to `[:1]` one at a time turned up TWO that survived a green -# 19-file suite, both `any(i.sign_wd.tripped for i in self.inverters)` and both -# SAFETY-30's rather than this card's: +_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. # -# `cycle()`'s stale-input hold +4 bytes, 19/19 green - a LATCHED unit must -# take the roster past the hold, or its failsafe SET stops being re-filed -# the moment a poll is missed. Graded now in test_watchdog.py part 8, on -# a two-inverter roster with the latch at EACH position in turn. -# `publish()`'s status vocabulary +4 bytes, 19/19 green - a site with one -# inverter parked by its own failsafe claim published `running`. Same -# part, same two positions. +# ⚠️ 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. # -# Both were invisible for the same 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`. +# 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. + @@ -5144,25 +5580,24 @@ MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - " 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, + 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 comprehension in the " - "class at once")) + "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 the roster added ANYWHERE ELSE in `Controller` " - "over a slice - which is how rounds 2, 3 and 4 each began", - bool(_roster_comps) and not _sliced, - "part 12j: every roster comprehension in the class, enumerated " - "from main.py's AST and required to iterate the roster whole. ⚠️ " - "Mutating each of them to `[:1]` found FOUR that survived a green " - "19-file suite, none of them this card's: three `sign_wd.tripped` " - "reductions (SAFETY-30's stale-input hold, `publish()`'s status " - "vocabulary and `status_payload()`'s banner - now graded in " - "test_watchdog.py part 8) and the store-migration guard's redundant " - "second disjunct. THIS row is the one that closes the family " - "rather than the member")) +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}") diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index 2be9b3a..d482732 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -924,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/tools/supervisor_shape_probe.py b/goodwe_controller/tools/supervisor_shape_probe.py index 9050e8f..85237d4 100644 --- a/goodwe_controller/tools/supervisor_shape_probe.py +++ b/goodwe_controller/tools/supervisor_shape_probe.py @@ -49,6 +49,15 @@ DECISIONS/CFG-04-inverter-roster.md: 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") -- 2.54.0