diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index bf949d2..102dae9 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -46,8 +46,10 @@ threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to **0 of 200** at a load its sibling can carry alone. The alarm returns once the healthy pack saturates - swept, that is a 5000 W load on the fixture, **below** the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS -UNAFFECTED**: `allocator.plan()` returns before weights are read at all. The -closure is card SAFETY-43. +UNAFFECTED**: `allocator.plan()` returns before weights are read at all. +⚠️ **CLOSED BY SAFETY-43 ON THIS BRANCH** - see its section below; the regression +above is history, and the record of it is kept because the measurement that +found it is what the fix is graded against. **PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits @@ -97,6 +99,65 @@ what happens when nobody does. | _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen | | _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed | | _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce | +| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression | + +⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A +TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit +whose shortfall exceeded an **absolute** `min_w = saturation_w = 500 W`. OPT-09's +weights shrink a low-headroom unit's share, and shrinking the share shrinks the +gap - so a unit standing at +125 W and delivering **0 W**, 100 % delinquent, was +never named. It now names a unit whose gap exceeds `min_w` **OR** exceeds +`DELIVERY_SHARE_FRACTION` (0.85) of that unit's **own share**. Measured on the +same closed-loop fixture, same weights, same detector: + + load 1500 W OPT-09 weights the old rule named on 0/200 cycles + load 1500 W OPT-09 weights the shipped one named on 200/200 cycles + +⚠️ **`saturation_w` WAS NOT LOWERED, AND THAT IS THE POINT.** Lowering it would +report normal saturation as delinquency on every unit at once - SAFETY-41's +four-attempt false-trip regime, *"i'm getting really annoyed by the notifications +in my HA"*. This ADDS a second way to be named: all seven of `test_delivery.py` +part 1's existing fixtures answer identically under the old rule and the new one, +and the absolute arm's suppression window is still exactly 1000 W / 6500 W. + +**0.85 is derived, not chosen.** `admin/goodwe:sim/delivery_window.py --campaign +--fractions` grades it over the SAME 2640 no-fault runs that produced +`delivery_hold_s`'s floor. The worst continuous legitimate episode is **35.0 s** +under the absolute arm alone (SAFETY-42 §3.3, reproduced in the same run) and +**40.0 s** at 0.85. ⚠️ **THE CURVE HAS NO CLIFF AND THAT IS THE FINDING**: swept +at twenty fractions from 0.05 to 0.99, everything from **0.45 to 0.99** is 40.0 +or 45.0 s - within one control cycle of every other - and below 0.45 it rises +gradually to 90.0 s at 0.05. ⚠️ **"Zero false namings" is not achievable at any +fraction and the record says so**; what is zero is false ALARMS - the longest +legitimate episode in the whole sweep is 90.0 s against a 185 s hold, so nothing +comes within 95 s of notifying anybody at any fraction measured, and 145 s at the +shipped one. The N=1 control arm is 0.0 s at every fraction in all 2640 +one-inverter runs, and the flapping family is 0.0 s in all 840. + +⚠️ **[[CFG-07]] GETS ONE CONTROL CYCLE WORSE AND IS NOT FIXED HERE.** +`delivery_hold_s` is declared `int(30,900)` while the measured legitimate worst +was 35.0 s; it is now 40.0 s, so the band of schema-legal false-tripping values +widens from `[30, 35]` to `[30, 40]`. CFG-07 should be re-derived against 40.0 s. +⚠️ **IT MUST CLOSE BEFORE INVERTER B IS COMMISSIONED (~2026-09-12)**, not before +the next deploy: the band is UNREACHABLE at N=1 - `delinquent()` answers `{}` for +every input at a roster of one, so no `delivery_hold_s` can false-trip on the +install running today - and commissioning B is the moment it becomes reachable. +⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE** - it exists as a card +reference here and in SAFETY-43's record and nowhere else, so whoever takes it +writes `DECISIONS/CFG-07-*.md` first. +**SAFETY-43 CHANGES NO OPTION VALUE, NO SCHEMA BOUND AND NO DEFAULT** - the +fraction is a derived constant with a committed harness behind it, deliberately +not a knob, for the reason there is no `delivery_w`. The one edit to +`config.yaml` is a COMMENT: the `delivery_hold_s` block described the check as +"short by more than `saturation_w`", which is now only half of it. + +**No behaviour change at one inverter, asserted rather than argued**: `plan()` is +handed a `weights` mapping that raises if anything reads it and still answers +identically at a roster of one (and the same mapping raises at a roster of two, +so the tripwire is real), and `delinquent()` answers `{}` on all 84 single-unit +(share, measured, rule) combinations including 0 of 125 W. +`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` carries the curve, the +positive controls and the CFG-07 assessment. ⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst diff --git a/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md b/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md index 16f6233..8034ecc 100644 --- a/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md +++ b/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md @@ -161,17 +161,18 @@ its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after this card that ask does not arrive in the first place, which removes the benign arm from the detector's input rather than teaching it a new discriminator. -⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWS SAFETY-42's DETECTION WINDOW. THIS -IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** An earlier draft of +⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW. +THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43 +CLOSED IT - see the block at the end of this section.** An earlier draft of this section said "no envelope closes and no weight moves - OPT-09 cannot see it at all". The second clause was **false**, and it was load-bearing for the claim that the two cards do not overlap. A weight is a function of the pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever its headroom differs from its sibling's - which is the ordinary case this card -exists to exploit. And `delivery.delinquent()` (`app/delivery.py:484`) -names a unit only while its `short_by()` gap exceeds `min_w = -Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is -**500 W in absolute watts**. Shrinking a unit's share shrinks its gap. Shrink the +exists to exploit. And `delivery.delinquent()` +named a unit only while its `short_by()` gap exceeded `min_w = +Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing +else beside it. Shrinking a unit's share shrinks its gap. Shrink the share far enough and the gap drops below 500 W while the unit is still delivering nothing, and the alarm stops firing. @@ -200,20 +201,47 @@ a 9600 W site; above it - once the healthy pack is at its own rating and `_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm returns. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included. -`test_packshare.py` part 8b grades all of it and **pins the current, -unflattering behaviour**, so that the day it changes a named check moves. The -closure - `delinquent()` measured against the ask rather than a flat 500 W - is -card **SAFETY-43**. +`test_packshare.py` part 8b graded all of it and **pinned the unflattering +behaviour**, so that the day it changed a named check would move. It has moved. -So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's -detection window for a low-headroom unit.** The two cards do not merely meet from -opposite sides; this one takes something away from the other one. Closing the gap -means making `delinquent()` relative to the ask rather than absolute, which is -SAFETY-42's code and a design change - it is filed as its own card, deliberately -not done here, and this paragraph is its input. Until then this is a **disclosed -residual**, in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's own "the -discriminator does not exist": at two inverters, a wedged unit with materially -less energy headroom than its sibling can go unreported at ordinary house loads. +### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL + +This section used to end *"until then this is a disclosed residual [...] at two +inverters, a wedged unit with materially less energy headroom than its sibling +can go unreported at ordinary house loads"*. That sentence is **retired, not +softened**, and what replaces it is a measurement rather than an intention. + +`delivery.delinquent()` is now relative to the ask **as well as** absolute: + + named if short_by() > min_w OR short_by() > frac * share + +with `frac = delivery.DELIVERY_SHARE_FRACTION`. A unit delivering 0 of 125 W is +100 % delinquent and is named; a unit delivering 0 of 1500 W was already named +and still is. On the **same fixture, under the same weights**, part 8b now +grades both rules in one run - `share_frac=None` is SAFETY-42's absolute arm, +exactly: + + load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles + load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles + +⚠️ **`min_w` WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE.** The +absolute arm's suppression window is still exactly the arithmetic above - 1000 W +at equal weights, 6500 W at these - and part 8b still asserts both numbers. +Lowering `saturation_w` would have traded a missed alarm for a false one on +every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a +**second** way to be named and left the first where it was. + +⚠️ **AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED.** This card stops the site +ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a +wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the +loop did not move. The derivation of `frac` - swept over the same 2640 no-fault +runs that produced `delivery_hold_s`'s floor - is in +`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md`. + +So the seam, stated accurately **as of SAFETY-43**: OPT-09 stops the ask, and it +still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as +described above - but it no longer narrows the DETECTOR, because the detector no +longer reads only that arm. ⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without connecting it.** That module's rejection of re-allocation rests on *"the only diff --git a/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md b/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md index 8c71b58..4613648 100644 --- a/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md +++ b/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md @@ -142,6 +142,16 @@ take the **longest run of consecutive cycles** on which it names anybody — not count of naming samples, because the window is cleared by any cycle that does not. +⚠️ **SINCE [[SAFETY-43]] THE ROWS OF §3.3's TABLE ARE GRADED WITH +`share_frac=None`** — `delinquent()`'s ABSOLUTE arm alone, which is this card's +rule exactly. Said here because it is the difference between §3.3 still meaning +what it is labelled as and quietly becoming a table about a different predicate: +SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows +(`--fractions`), and the numbers below are unchanged and were re-measured to +confirm it. `DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` §3 carries +those rows and what they cost — the worst legitimate episode goes from 35.0 s to +40.0 s at the shipped fraction, which does not move `delivery_hold_s`. + ⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The command standing before the write" is the **controller's** standing command — `Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the diff --git a/goodwe_controller/DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md b/goodwe_controller/DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md new file mode 100644 index 0000000..ea2bb87 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md @@ -0,0 +1,381 @@ +# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W + +**Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap +exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction +is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3. + +⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md +§1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and +`test_packshare.py`'s plant is modelled in that file. **Every two-inverter +figure in this document is two copies of A.** Nothing here is a measurement of +inverter B. + +## 0. The defect, and why it is the quiet regime that matters + +[[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is +a function of a pack's **energy headroom**, not of what the pack delivers, so a +**wedged** unit — one that accepts its share and produces nothing — is weighted +down exactly like a legitimately low-headroom one. `delinquent()` read an +**absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share +shrinks the gap. Measured end to end on the real loop (`test_packshare.py` +part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real +`delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its +own 10 % cut-out, so no envelope closes and `account()` explains nothing — a +SAFETY-42 **true positive** by that card's own definition): + + load 1500 W equal weights shares {a:1500, b:1500} named on 199/200 + load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200 + +A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the +absolute threshold disagreed. + +⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling +covers, the meter is satisfied (grid within 5 W of zero on that fixture), +`unmet_w` is 0 because both units *accepted* their shares, no envelope closes and +no action-log line fires. The delinquency alarm is the **only** instrument that +would have told the operator the site was quietly running one inverter. + +## 1. What was NOT done, and it is the larger half of this card + +⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which +the control law itself concludes the inverter is not following — and it exists to +stop **normal saturation** being reported as delinquency. Lowering it trades a +missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four +attempts** on notification cadence in this system and the owner's words were +*"i'm getting really annoyed by the notifications in my HA"*. + +So the change is an **`or`**, never a looser first arm: + + named if short_by() > min_w OR short_by() > frac * share + +Everything `min_w` named before is still named. Nothing `min_w` refused becomes +noisy **unless the unit is delivering almost none of its ask**, which is not +saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its +existing `delinquent()` fixtures under **both** rules — `share_frac=None` is +SAFETY-42's rule exactly — and requires identical answers on every one. + +The absolute arm's suppression window is therefore **unchanged**: still +`site target < 2·min_w` = 1000 W at equal weights, still +`site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's +fixture. Part 8b still asserts both numbers, now as evidence that the first arm +did not move. + +## 2. The evidence is a diff between two rules on ONE run + +`delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**. +That parameter exists for one reason: so the red and the green come out of one +run over one tree instead of out of two commits that a reader has to trust. +`test_packshare.py` part 8b grades every cycle twice: + + load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles + load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles + + load 1500 W equal weights absolute arm alone named on 199/200 cycles + load 1500 W equal weights the shipped rule named on 200/200 cycles + +The equal-weights arm gains **exactly one cycle** — the first, where the slew +limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold +(the absolute bound is strict) and the relative arm names a box delivering none +of its 500 W. That single cycle is the whole of what this card costs an arm that +was already firing. + +⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the +**suppressed** behaviour, and its comment said: *"if it ever goes red because the +alarm fired, read the card that made it fire before 'fixing' this line."* This is +that card. The pin now asserts the opposite answer and keeps the old one beside +it, measured rather than remembered. + +## 3. ⭐ Deriving the fraction + +⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen. +`admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed — +gained a second grading axis: the same **2640 no-fault runs at N=2** (three +families × 6 gains × 5 slews × write staggers; every unit executes the share the +allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped +`min_w = 500 W` for each candidate fraction, on every cycle of every run — so a +pass grades every fraction it is given, and the absolute arm beside them, over +one tree and one set of runs. Every row in a pass is comparable to every other +row in it, run for run. + + cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \ + python3 -B delivery_window.py --selftest --campaign --fractions + +⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL +TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten +`(0.25 … 0.99)` and then the ten that resolve the low end +`(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm +control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax), +which is what says the two passes are one campaign. A table a reviewer cannot +reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in +miniature, so the committed `FRACTIONS` was widened to all twenty in review: the +command above now reproduces every row below. + +### 3.1 The curve + +Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually +sees — its window is cleared by any cycle that does not name), runs naming in +brackets: + +| `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** | +|---:|---:|---:|---:|---:| +| absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** | +| 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** | +| 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** | +| 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** | +| 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** | +| 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** | +| 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** | +| 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** | +| 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** | +| 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** | +| 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** | +| 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** | +| 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** | +| 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** | +| 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** | +| 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** | +| 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** | +| **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** | +| 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** | +| 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** | +| 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** | + +Worst of the three families, which is what `delivery_hold_s` has to cover: + + frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50 + worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s + + frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99 + worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s + +⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** — +30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4 +split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every +row below it, which is what makes those rows a comparison rather than a claim. + +### 3.2 What the curve says + +* ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From + **0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle + (5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at + 0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not + 0.90?" is asking about a difference this campaign cannot resolve, and the + honest answer is that it cannot. +* ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and + 0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is + SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more + sensitive rule puts MORE units into the short set, which can turn a proper + subset into the WHOLE roster and END the episode.** The reduction is not + monotone in its own threshold. Do not read this column as a sensitivity curve + in the ordinary sense. +* **The shoulder is at about 0.45**, and it is a shoulder rather than an edge: + the reversal family's naming count saturates at 840 of 1050 runs from 0.30 + upward, so below the shoulder what grows is the LENGTH of an episode, not the + number of runs that have one. +* **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.** + The compatibility promise is measured for the new arm, not argued. +* **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common + stimulus makes both units short at once and the subset stops being proper — + the property that defeated SAFETY-30's site window still holds. + +### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction + +The card asked for *the largest fraction that yields zero false namings across +the whole no-fault campaign*. **There is none.** Every fraction adds naming +cycles: the reversal family goes from 460 runs naming to 840, the weighted family +from 403 to 635. A relative arm that never fired on a legitimate transient would +be a relative arm that never fired at all — the write stagger lands B's number up +to four cycles late, and a unit whose new command has not arrived is, for those +cycles, delivering a small fraction of what is standing on its wire. + +What **is** zero at every fraction is **false alarms**: + +> the longest continuous legitimate episode anywhere in the 20-point sweep is +> **90.0 s** — at 0.05, the most sensitive fraction measured — against a +> `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within +> **95 s** of notifying an operator, **at any fraction measured**. At the +> shipped 0.85 it is 40.0 s, i.e. 145 s of slack. + +That is the quantity SAFETY-41 cares about and it is the one this card had to +protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst +legitimate episode, where the absolute arm alone gave 5.29×. + +### 3.4 The bounds 0.85 answers to + +* **LOWER, from the campaign** — the shoulder at about **0.45**, below which the + added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40 + above it. +* **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm + can only fire on a unit that is *importing* while asked to discharge, which is + SAFETY-30's fault and not this one, so a unit delivering **none** of its share + (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a + wedged box reporting a small standby figure instead of a clean zero. +* **UPPER, from the hold** — every fraction in the flat range leaves + `delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst + legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it. + **`delivery_hold_s` IS NOT CHANGED BY THIS CARD.** +* ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The + campaign cannot separate them. 0.85 is the midpoint of the argument above — + 0.40 of margin above the shoulder, 0.15 below the ceiling — and a + re-derivation that moved it to 0.90 would be moving a number this campaign + never resolved. It is not moved for that reason. + +## 4. The positive control — what the relative arm must still NOT say + +A card that adds a way to raise an alarm has to show what it did **not** add. +`test_packshare.py` part 8b runs the same fixture, the same weights and the same +125 W share with the one fact that makes the shortfall legitimate changed: + +| fixture | B's share | named | why it is silent | +|---|---|---|---| +| B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault | +| B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** | +| B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms | + +### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did + +**Corrected in review.** The first draft of this section claimed the wedged and +cut-out arms had *"identical power traces — 0 W against a positive share, 100 % +of the ask missing"* and that *"what separates them is `account()`"*. **Every +part of that was false**, and the check that carried it was **vacuous**: + +* at its own cut-out the pack's usable energy **above that cut-out is zero**, so + `packshare.weights()` answers **0.0** for it, `_fill()`'s preferred pass gives + it nothing, the healthy sibling covers the load alone so the last-resort pass + never runs, and its share is **0 W on all 200 cycles**; +* a unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns `{}` + **before either arm is evaluated** and `account()` is **never consulted**; +* the traces are not identical — the cut-out arm's share is **0 W on every + cycle** (asserted in part 8b and printed on its line) while the wedged arm's + is **125 W** at the last cycle (asserted in the GREEN check) and takes four + distinct values 77/125/133/154 W across the 200, measured by re-running the + same loop with per-cycle recording during review — and "100 % of the ask" is + 0/0. + +Measured, by deleting the gate the claim rested on: + + cut-out arm, account() live named 0/200, distinct B shares: [0] + cut-out arm, account() DELETED named 0/200, distinct B shares: [0] + wedged arm, account() live named 200/200, shares [77,125,133,154] + +⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out +**OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never +reached — a **second, independent protection sitting upstream of `account()`**: +the allocator declines to ask before the detector has to decline to alarm. Part +8b now asserts the 0 W share and prints it, and runs the arm a second time with +`account()` deleted to show which mechanism is doing the work. + +⚠️ **It was invisible for one reason worth keeping**: that line was the only one +in part 8b that did not print its shares. `inverter-b+0` would have been on +screen from the first run. Every line in that part prints them now, and the +derated control's vacuity guard now asserts **its own** unit is asked, rather +than re-asserting a different run's 200/200 — which is exactly why the old guard +could not see this. + +**The account gate is graded where it is real**, on a positive 125 W share, in +`test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same +0 of 125 W with the shortfall explained names nobody). That part also carries +that the relative bound is **strict** (a gap of exactly `frac · share` is not +over it) and that the proper-subset rule still refuses a site-wide cause (both +units delivering none of a 125 W share names nobody). + +## 5. N=1 is byte-identical, and it is asserted rather than argued + +The live install is **N=1**. Two independent statements, each graded against the +thing that would break it (`test_packshare.py` part 8b): + +1. **`allocator.plan()` returns before `weights` is read at all.** Graded by + handing it a `weights` mapping that **raises on any read**: at a roster of one + it answers identically to the same call with no weights; at a roster of two + the same mapping is read and raises, so the tripwire is a real one. +2. **`delinquent()` answers `{}` at a roster of one for every input** — 84 + combinations of (share, measured, rule), including **0 of 125 W**, the exact + input this card added an arm for. `len(asked) < 2` returns before either + threshold is compared, so the fraction is never even multiplied. + +Nothing in this card is reachable at one inverter. It must land before inverter B +is commissioned (~2026-09-12), not before the next deploy. + +## 5b. Mutation — the fraction is pinned from BOTH directions + +⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied +fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc` +could go stale — MUTATION.md's defect, and all three of these are **reds**, the +direction that defect cannot fabricate. Recorded in `test_packshare.py` part 11 +as M12–M14. + +| mutation | named checks | where | +|---|---:|---| +| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. | +| **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. | +| **M14** `DELIVERY_SHARE_FRACTION` → **0.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. | + +M13 and M14 are the pair that matters: the constant cannot be moved in **either** +direction without a named check moving with it. + +### ⚠️ Two of these counts were wrong, and the reason is worth keeping + +**Corrected in review.** + +* **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed + the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any + tree where the relative arm is broken or absent that raises `KeyError` and + **aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded + coverage of a mutation run read *smaller* than the truth. The direction is safe + (an abort is a red, and MUTATION.md's defect can only fabricate a green), but a + figure that does not reproduce is a figure that does not reproduce. It is a + `.get` now, with the reason written beside it, and the counts above are from + re-runs with the guard in place. +* **M14 was recorded as 4 named checks in `test_delivery.py` and it is 5** — part + 1's pre-existing *"a shortfall AT the threshold is not one"* also goes red. That + one under-claimed, so it never inflated the evidence; it is still a recorded + number that did not reproduce. + +## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here + +CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s +schema while the measured worst legitimate episode at the shipped threshold is +**35.0 s** — so the schema offers an operator a value (30) that is **below a +measured legitimate episode** and would report on ordinary saturation. + +**This card makes it worse, and by exactly one control cycle.** The relative arm +takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped +fraction, so the band of schema-legal but false-tripping values widens from +`[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not +35 s. It is not fixed here: raising a schema floor is an option-surface change +with its own compatibility argument (an existing config carrying `35` would start +being refused), and folding it into a safety fix would hide it. **CFG-07 should +be re-derived against 40.0 s, not 35.0 s.** + +⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).** +Not before the next deploy. The band is **unreachable at N=1** — `delinquent()` +returns `{}` for every input at a roster of one, so no value of +`delivery_hold_s` can false-trip on the install that is running today — and the +live install is N=1 until B exists. Commissioning B is the moment the band +becomes reachable, and that is the deadline this card hands it. + +⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a +`[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else — +so there is no file for the 40.0 s to be recorded in, and a reader who goes +looking will find a card reference with no record behind it. Whoever takes +CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is +**40.0 s**, measured here, and the harness that produces it is +`admin/goodwe:sim/delivery_window.py --campaign --fractions`. + +## 7. Provenance of every number in this document + +| number | where it came from | +|---|---| +| 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run | +| 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 | +| every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them | +| 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there | +| 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card | +| 4.62× | 185 / 40.0, printed by the campaign | +| 0/200 on both positive controls, and B's 0 W share at the cut-out | part 8b, run and asserted | +| 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed | +| 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only | +| M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded | +| 84 single-unit rosters | part 8b, run | + +⚠️ Every two-inverter figure above is **two copies of inverter A**. diff --git a/goodwe_controller/app/delivery.py b/goodwe_controller/app/delivery.py index b6822a9..f355913 100644 --- a/goodwe_controller/app/delivery.py +++ b/goodwe_controller/app/delivery.py @@ -414,6 +414,94 @@ DELIVERY_OPTION_DEFAULTS = { MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w" +# --------------------------------------------------------------------------- +# SAFETY-43. THE SECOND WAY TO BE NAMED, AND WHY IT IS A FRACTION OF THE ASK. +# +# `min_w` above is an ABSOLUTE gap, and OPT-09 made that a hole. `weights()` +# shrinks a low-headroom unit's share, and shrinking the share shrinks the gap: +# a unit standing at +125 W and delivering 0 W is 100 % delinquent and 125 W +# short, so an absolute 500 W never names it. Measured end to end +# (`test_packshare.py` part 8b, real `compute()`, real `plan()`, real +# `delinquent()`, B wedged at a healthy 15 % against its own 10 % cut-out): +# +# load 1500 W, equal weights the wedged unit named on 199/200 cycles +# load 1500 W, OPT-09 weights named on 0/200 cycles +# +# ⚠️ AND THE SUPPRESSED REGIME IS THE QUIET ONE. At a load the healthy sibling +# covers, the meter is satisfied, `unmet_w` is 0 and no action-log line fires, +# so this alarm is the ONLY instrument that would have told the operator. +# +# ⚠️ THE FIX IS NOT A SMALLER `min_w`, AND THAT IS THE WHOLE SHAPE OF THIS +# CARD. `min_w` is `Tuning.saturation_w` and it exists to stop NORMAL +# SATURATION being reported as delinquency - the crying-wolf failure the whole +# module is written against, and the one SAFETY-41 spent four attempts on +# ("i'm getting really annoyed by the notifications in my HA"). Lowering it +# trades a missed alarm for a false one on every unit at once. A fraction of +# the unit's OWN SHARE is a SECOND way to be named that leaves the first +# exactly where it was: `gap > min_w OR gap > frac * share`. Nothing that was +# silent before this card becomes noisy unless the unit is delivering almost +# none of what it was asked for, which is not saturation-shaped at all. +# +# WHERE 0.85 COMES FROM - MEASURED ON THE SAME CAMPAIGN AS `delivery_hold_s`. +# `admin/goodwe:sim/delivery_window.py --campaign --fractions` grades this +# constant over the SAME 2640 no-fault runs (three families x 6 gains x 5 slews +# x the write staggers, every unit executing the share it was given) that +# produced the 35.0 s worst legitimate episode, at the shipped `min_w`, and +# reports what the relative arm ADDS. Worst continuous legitimate episode, +# worst of the three families: +# +# frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50 +# worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s +# +# frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99 +# worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s +# +# absolute arm alone: 35.0 s - SAFETY-42 §3.3's figure, re-measured in the +# SAME runs, which is what makes every row above a comparison and not a claim. +# N=1 control: 0.0 s at EVERY fraction, in all 2640 one-inverter runs. +# Flapping family: 0.0 s at EVERY fraction, in all 840 runs. +# +# ⚠️ READ THE CURVE, NOT THE ROW. THERE IS NO CLIFF ANYWHERE IN IT. From 0.45 +# to 0.99 every figure is 40.0 or 45.0 s - within ONE CONTROL CYCLE (5.0 s) of +# every other - and below 0.45 it rises gradually to 90.0 s at 0.05. It is not +# monotone either (0.40 reports 60.0 s where 0.35 reports 55.0), which is +# §3.3's own mechanism (c) rather than noise in the plant: a more sensitive rule +# puts MORE units into the short set, which can turn a proper subset into the +# WHOLE roster and END the episode. So this constant is INSENSITIVE over the +# whole usable range, and that is the finding rather than a caveat on it. 0.85 +# sits in the flat part, 0.40 above the shoulder at ~0.45 and 0.15 below 1.0. +# +# ⚠️ AND "ZERO FALSE NAMINGS" IS NOT AVAILABLE AT ANY FRACTION - said here +# because the card asked for exactly that. Every fraction adds naming CYCLES to +# the no-fault campaign; a relative arm that never fired on a legitimate +# transient would be one that never fired at all, because the modelled write +# stagger lands B's number up to four cycles late and a unit whose new command +# has not arrived IS delivering a small fraction of what is standing on its +# wire. What is zero at every fraction is false ALARMS: the longest legitimate +# episode anywhere in the 20-point sweep is 90.0 s - at 0.05, the most sensitive +# fraction measured and far below anything this card would ship - against a +# `delivery_hold_s` of 185 s, so NOTHING in 2640 no-fault runs comes within 95 s +# of notifying an operator at any fraction measured. At 0.85 the hold is 4.62x +# the worst legitimate episode where the absolute arm alone gave 5.29x. That is +# the price, in full. +# +# ⚠️ THE DIRECTION IS THE OPPOSITE OF `min_w`'s AND IT IS EASY TO GET BACKWARDS. +# A SMALLER fraction names MORE units - it is the more sensitive setting and the +# more dangerous one - so the campaign puts a LOWER bound on this number, not an +# upper one. It is bounded ABOVE by the card's own case: at 1.0 the arm can only +# fire on a unit that is IMPORTING while asked to discharge, which is +# SAFETY-30's fault and not this one, so a unit delivering NONE of its share +# (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a +# wedged box reporting a small standby figure rather than a clean zero. +# +# ⚠️ AND IT IS NOT AN OPTION, FOR `delivery_w`'s REASON ONE PARAGRAPH UP. An +# operator has no instrument to derive it with, a knob here is a knob on how +# loud this add-on is, and SAFETY-41 is the record of what that costs. It is a +# derived constant with a committed harness behind it, and the way to move it +# is to re-run the campaign. +DELIVERY_SHARE_FRACTION = 0.85 + + @dataclass(frozen=True) class DeliveryConfig: """Defaults, not constants - config.yaml carries both. See CFG-03 above.""" @@ -532,12 +620,30 @@ def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None: return None -def delinquent(units, min_w: float) -> dict: +def delinquent(units, min_w: float, + share_frac: float = DELIVERY_SHARE_FRACTION) -> dict: """Which units are short with NO ACCOUNT, when the roster disagrees with them. - `units` one `UnitDelivery` per inverter, in roster order. - `min_w` the shortfall that counts - `control.Tuning.saturation_w`. See - `MIN_W_IS_SATURATION_W`. + `units` one `UnitDelivery` per inverter, in roster order. + `min_w` the ABSOLUTE shortfall that counts - `control.Tuning. + saturation_w`. See `MIN_W_IS_SATURATION_W`. + `share_frac` SAFETY-43. The RELATIVE shortfall that counts, as a fraction + of that unit's own share. See `DELIVERY_SHARE_FRACTION`; it + is a parameter rather than a read of the constant so the + campaign that derived it can sweep it over the shipped code. + ⚠️ `None` MEANS THE ABSOLUTE ARM ALONE - SAFETY-42's rule, + exactly - and it exists for ONE caller: the campaign in + `admin/goodwe:sim/delivery_window.py`, which has to be able + to measure what this card ADDS against what it replaced, run + for run, over ONE tree. Nothing in the add-on passes it, and + `main.py` passes nothing at all. + + ⚠️ THE TWO ARMS ARE AN `or` AND THE ORDER OF THAT SENTENCE MATTERS. The + absolute arm is untouched by SAFETY-43: everything `min_w` named before it + is still named, and nothing `min_w` refused becomes noisy unless the unit + is delivering almost NONE of its ask. Lowering `min_w` would have been the + other change - the one that reports normal saturation on every unit at once + - and `DELIVERY_SHARE_FRACTION` says at length why it was refused. Returns `{name: sentence}`, empty when there is nothing to say. The sentence is the operator's first line and it carries the peer clause, because "B is @@ -579,7 +685,13 @@ def delinquent(units, min_w: float) -> dict: gaps = {} for u in asked: gap = short_by(u.share_w, u.measured_w) - if gap is not None and gap > float(min_w): + if gap is None: + continue + # SAFETY-43: absolute OR relative. `share_w` is > 0 for every member + # of `asked`, so the second comparison is always well formed. + if gap > float(min_w) or (share_frac is not None + and gap > float(share_frac) + * float(u.share_w)): gaps[u.name] = gap unaccounted = {u.name: gaps[u.name] for u in asked @@ -595,7 +707,13 @@ def delinquent(units, min_w: float) -> dict: out[u.name] = ( f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W " f"and delivering {float(u.measured_w):+.0f} W - " - f"{unaccounted[u.name]:.0f} W of its share is not arriving - while " + f"{unaccounted[u.name]:.0f} W of its share " + # SAFETY-43. The PROPORTION is in the sentence because the watts + # alone stopped being the whole story the moment a unit could be + # named on the relative arm: "125 W is not arriving" reads like a + # rounding error, and "125 W (100 % of its share)" is the fault. + f"({100.0 * unaccounted[u.name] / float(u.share_w):.0f} % of it) " + f"is not arriving - while " f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in " f"that state. Nothing this add-on reads explains it") return out diff --git a/goodwe_controller/app/packshare.py b/goodwe_controller/app/packshare.py index a353d96..adf603b 100644 --- a/goodwe_controller/app/packshare.py +++ b/goodwe_controller/app/packshare.py @@ -57,8 +57,8 @@ deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on. never rises. That module DETECTS it; this one cannot fix it, and saying so is worth more than a weighting scheme that looks like it might. -⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's -DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2 +⚠️ AND IT WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's +DETECTION WINDOW - A DETECTION REGRESSION AT N=2, NOW CLOSED BY SAFETY-43 ======================================================================= This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said with it - that a wedged unit's weight does not move, so OPT-09 cannot see the @@ -68,27 +68,40 @@ pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever its headroom differs from its sibling's - the ordinary case this module exists to exploit. -`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds +`delivery.delinquent()` named a unit only while its `short_by()` gap exceeded `min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real `compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its -own 10 % floor, so `envelope()` does not close and `account()` explains -nothing): at a 1500 W load the healthy pack can carry alone, equal weights name -the wedged unit on 199 of 200 cycles and these weights name it on NONE. The -suppression window widens from `site target < 2 * min_w` = 1000 W to -`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has +own 10 % cut-out, so `envelope()` does not close and `account()` explains +nothing): at a 1500 W load the healthy pack can carry alone, equal weights named +the wedged unit on 199 of 200 cycles and these weights named it on NONE. The +suppression window widened from `site target < 2 * min_w` = 1000 W to +`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it had an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass -hands the wedged unit a big share again and the alarm returns. +hands the wedged unit a big share again and the absolute arm names it. - ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included. + ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE - KEPT HERE BECAUSE IT IS A MEASUREMENT, EVEN THOUGH SAFETY-43 HAS SINCE MADE THE WINDOW IRRELEVANT TO THE DETECTOR. Swept on this fixture with the ABSOLUTE arm alone, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returned BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm named the wedged unit 199/200 at every load swept, 1000 W included. - ⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK - **AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That - is a disclosed residual, not a fixed thing. Closing it means making - `delinquent()` relative to the ask rather than absolute - SAFETY-42's code - and a design change, filed as its own card and deliberately NOT done here. - Do not "fix" part 8b by making it assert the alarm still fires; it does not, - and a green check that says otherwise would be worse than the gap. + ⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to + the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a + unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W. + On the SAME fixture, under the SAME weights, the wedged unit is now named + on 200 of 200 cycles. Part 8b grades both rules on one run: + `share_frac=None` is SAFETY-42's absolute arm alone and still says nothing, + which is what keeps the paragraph above honest rather than nostalgic. + + ⚠️ AND `min_w` WAS NOT LOWERED. The absolute arm's suppression window is + still exactly the arithmetic above - 1000 W at equal weights, 6500 W at + these - because a lower `saturation_w` would report NORMAL SATURATION as + delinquency on every unit at once, which is the false-trip regime SAFETY-41 + spent four attempts in. What changed is that there is a SECOND way to be + named; nothing that was quiet before became noisy. + + ⚠️ WHAT THIS MODULE STILL CANNOT DO IS UNCHANGED. OPT-09 stops the site + ASKING a pack whose own SoC says it cannot deliver; it does not, and will + not, re-allocate a wedged unit's watts to its sibling - `delivery.py`'s + "IT REPORTS. IT DOES NOT ACT" block is the measurement behind that, and + SAFETY-43 did not touch it. The alarm is louder; the loop is identical. WHY ENERGY AND NOT POWER ======================== diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index b67fdd6..b7864a5 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -548,8 +548,10 @@ options: # only ever ran at 500 could not tell this wiring from a hard-coded number. # # How long ONE inverter may stand at a discharge share it is not producing - - # short by more than `saturation_w` - while at least one sibling is NOT in - # that state, before the add-on names it. It REPORTS: nothing is commanded + # short by more than `saturation_w`, OR short by more than a fixed FRACTION + # of its own share (SAFETY-43; the fraction is derived and is not an option, + # see app/delivery.py) - while at least one sibling is NOT in that state, + # before the add-on names it. It REPORTS: nothing is commanded # differently, the loop is untouched, and the site goes on running short by # that unit's share until somebody looks. app/delivery.py carries why, with # the rig measurements on both sides of the decision. diff --git a/goodwe_controller/test_delivery.py b/goodwe_controller/test_delivery.py index e05f79f..f7cf11e 100644 --- a/goodwe_controller/test_delivery.py +++ b/goodwe_controller/test_delivery.py @@ -33,8 +33,9 @@ from datetime import datetime, timedelta, timezone from app.control import Tuning from app.delivery import ( - DELIVERY_OPTION_DEFAULTS, REASON_CODE, DeliveryConfig, DeliveryWatchdog, - UnitDelivery, account, delinquent, short_by, + DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE, + DeliveryConfig, DeliveryWatchdog, UnitDelivery, account, delinquent, + short_by, ) from app.inverter import LINK_STALE_S from app.main import Controller @@ -151,6 +152,77 @@ check("a unit given no share is not asked and cannot be named, and it is not " "a peer either - with only one unit asked there is nothing to compare", delinquent([U("a", 0, 0), U("b", 2000, 0)], 500) == {}) +print() +print(" SAFETY-43: THE RELATIVE ARM - short OF ITS OWN ASK, not just short") + +# ⚠️ `share_frac=None` IS SAFETY-42's RULE EXACTLY, and every check in this part +# above this line is run under BOTH rules below to show the card took nothing +# away. What it ADDS is one thing: a unit can now be named for delivering almost +# none of a SMALL share. OPT-09's weights make small shares ordinary - a +# low-headroom unit is weighted down to 125 W of a 1630 W site target - so +# before this card a wedged unit could be 100 % delinquent and 125 W short, and +# 125 W is under a 500 W threshold. test_packshare.py part 8b measures that end +# to end on the real loop; what is graded here is the predicate. +_ABS = None # SAFETY-42's rule + +_tiny = [U("a", 1505, 1505), U("b", 125, 0)] +check("A UNIT DELIVERING 0 OF 125 W IS NAMED, AND UNDER THE OLD RULE IT WAS " + "NOT. Same three units, same `min_w`, one input changed: the absolute arm " + "alone answers {} because 125 W < 500 W, and the shipped rule names it " + "because 100 % of its ask is not arriving", + delinquent(_tiny, 500, _ABS) == {} + and list(delinquent(_tiny, 500)) == ["b"]) +# ⚠️ `.get`, NOT `["b"]`, AND THAT IS NOT STYLE. A bare subscript here raises +# KeyError on any tree where the relative arm is broken or absent, which ABORTS +# THE FILE at part 1 and leaves parts 2-8 UNGRADED. The direction is safe - an +# abort is a red, and MUTATION.md's defect can only fabricate a green - but the +# recorded coverage of a mutation run then reads larger than it was, which is +# the shape M11's entry in test_packshare.py part 11 already discloses. Found +# in review of this card, on M12 and M13. +_tiny_said = delinquent(_tiny, 500).get("b", "(not named - see the check above)") +print(f" {_tiny_said}") +check("...and the sentence carries the PROPORTION as well as the watts, because " + "'125 W is not arriving' reads like a rounding error and '125 W (100 % of " + "it)' is the fault", + "125 W of its share (100 % of it) is not arriving" in _tiny_said) + +check("THE ACCOUNT GATE IS UNTOUCHED BY IT - the same 0 of 125 W with the " + "shortfall EXPLAINED names nobody. A relative criterion that could talk " + "its way past `account()` would report every pack sitting at its own " + "cut-out on a small share, which is the crying-wolf failure this module " + "exists to avoid", + delinquent([U("a", 1505, 1505), U("b", 125, 0, "its pack is empty")], + 500) == {}) +check("...and so is the PROPER-SUBSET rule: both units delivering none of a " + "small share is a SITE condition and is still nobody's fault to name", + delinquent([U("a", 125, 0), U("b", 125, 0)], 500) == {}) +check("...and the relative bound is STRICT, like the absolute one. A gap of " + "EXACTLY the fraction of the share is not over it; one watt more is", + delinquent([U("a", 2000, 2000), + U("b", 1000, 1000 * (1.0 - DELIVERY_SHARE_FRACTION))], + 5000) == {} + and list(delinquent([U("a", 2000, 2000), + U("b", 1000, + 1000 * (1.0 - DELIVERY_SHARE_FRACTION) - 1)], + 5000)) == ["b"]) + +_both_rules = [(delinquent(u, w, _ABS), delinquent(u, w)) for u, w in ( + ([U("a", 2000, 2000), U("b", 2000, 0)], 500), + ([U("a", 2000, 2000), U("b", 2000, 0, "its pack is empty")], 500), + ([U("a", 2000, 0), U("b", 2000, 0)], 500), + ([U("a", 2000, 2000), U("b", 2000, 0), U("c", 2000, 0)], 500), + ([U("a", 2000, 0, "at its floor"), U("b", 2000, 0)], 500), + ([U("a", 2000, 2000), U("b", 2000, 1500)], 500), + ([U("a", 0, 0), U("b", 2000, 0)], 500), +)] +check(f"⚠️ AND IT TOOK NOTHING AWAY: all {len(_both_rules)} fixtures above this " + f"block answer IDENTICALLY under SAFETY-42's rule and the shipped one. " + f"The relative arm is an `or`, so it can only ever ADD a naming - and on " + f"every case this part already graded it adds none. That is the " + f"difference between adding a second way to be named and lowering " + f"`min_w`, which would have moved all seven", + all(a == b for a, b in _both_rules)) + print() print(" AC 5: AT ONE INVERTER IT IS INERT, AND BY ALGEBRA RATHER THAN BY A") print(" ROSTER GUARD - `len(asked) < 2` is unreachable-by-construction there") @@ -323,9 +395,14 @@ class _DeliveryHass: same in both. """ - def __init__(self, load_w=_LOAD_W, b_soc=50.0): + def __init__(self, load_w=_LOAD_W, b_soc=50.0, b_frac=0.0): self.load_w = float(load_w) self.b_soc = float(b_soc) + # SAFETY-43. What fraction of its command B actually delivers. 0.0 is a + # WEDGED unit and is every arm in this file but part 4b(a) - see there + # for why that one arm needs a unit that is short WITHOUT being 100 % + # short. + self.b_frac = float(b_frac) self.writes = [] self.calls = [] self.ctrl = None @@ -343,8 +420,11 @@ class _DeliveryHass: # A obeys. B produces NOTHING, whatever it is told - and whether that is # a wedged power stage or an empty pack is not a question its power # register can answer. + b = self.ctrl.inverters[1] + b_cmd = float((b.last_write if b.last_write is not None else b.target) + or 0.0) return [float((a.last_write if a.last_write is not None else a.target) - or 0.0), 0.0] + or 0.0), self.b_frac * b_cmd] async def number(self, entity, invert=False): m = self.measured() @@ -630,15 +710,27 @@ print(" schema. Same load, same wedged unit, two configured thresholds.") # load is what puts B's shortfall BETWEEN the two: above the shipped threshold, # below the raised one. The gap itself is printed by the run that produces it # and is never written down here; what is asserted is the ORDERING. +# +# ⚠️ AND B IS DERATED HERE RATHER THAN WEDGED, WHICH SAFETY-43 MADE NECESSARY. +# `delinquent()` now names a unit whose gap exceeds `min_w` OR a fraction of its +# own share, so a unit delivering NONE of its ask is named at every `min_w` +# there is - which is the whole point of that card, and which would make the +# raised arm below fire for a reason that has nothing to do with the watts. A +# unit delivering HALF of what it is asked for is short in absolute watts and +# comfortably inside the fraction, so it is the fixture that still isolates +# `saturation_w`. `_SAT_B_FRAC` is asserted below to be inside the relative +# arm's silence, so a future change to the fraction turns this red rather than +# quietly turning the pin vacuous. _SAT_RAISED = 1800 _SAT_LOAD_W = 1600.0 +_SAT_B_FRAC = 0.5 _SAT_SHIPPED = int(read_option("saturation_w")) _sat_lo, _sat_hi = (int(x) for x in re.match( r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups()) -async def _at_saturation_w(sat): - hass = _DeliveryHass(load_w=_SAT_LOAD_W) +async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC): + hass = _DeliveryHass(load_w=_SAT_LOAD_W, b_frac=b_frac) c = _ctrl(hass, saturation_w=sat) return c, await _drive(c, watch=_sample) @@ -674,6 +766,17 @@ check("the raised arm REACHED the comparison: B was asked for a discharge " and not _b_up.sign_wd.tripped and account(live=True, parked=False, soc=_b_up.soc, bms_floor=_b_up.bms_soc_floor) is None) +check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, " + "which is what leaves the watts as the only moving part. B delivers half " + "of what it is asked for, so its gap is half its share and that is " + "inside `DELIVERY_SHARE_FRACTION`. ⚠️ A FIXTURE WHERE THE RELATIVE ARM " + "FIRED WOULD NAME B AT EVERY `saturation_w` AND THIS PIN WOULD BE " + "VACUOUS, so it is asserted rather than assumed", + _gap_up / float(_b_up.standing_w()) <= DELIVERY_SHARE_FRACTION + and _gap_ship / float(_b_ship.standing_w()) <= DELIVERY_SHARE_FRACTION + and delinquent([U("a", 1000, 1000), + U("b", float(_b_up.standing_w()), _b_up.batt)], + _SAT_RAISED) == {}) check("...and its shortfall lands STRICTLY BETWEEN the two configured " "thresholds, which is the one place the two answers can differ. Part 4's " "fixtures are all above both, which is exactly why they cannot see this", diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py index f1b555e..b7b5462 100644 --- a/goodwe_controller/test_packshare.py +++ b/goodwe_controller/test_packshare.py @@ -41,7 +41,9 @@ from app.arbiter import ( ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve, ) from app.control import Tuning, compute -from app.delivery import UnitDelivery, account, delinquent +from app.delivery import ( + DELIVERY_SHARE_FRACTION, UnitDelivery, account, delinquent, +) from app.peakguard import discharge_permitted fails = [] @@ -605,67 +607,87 @@ check("...and SAFETY-42 is where the case this card CANNOT reach is handled. A " "site ASKING a pack whose own SoC says it cannot deliver; SAFETY-42 " "alarms when a pack that said it could does not. ⚠️ THE WEIGHT, HOWEVER, " "DOES MOVE - this check used to say it did not - and part 8b grades what " - "that costs the alarm", + "that cost the alarm, and what SAFETY-43 did about it", not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding) # ============================================================================== print() -print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH IS " - "A REGRESSION AND IS PINNED HERE AS ONE") +print("8b. ⚠️ WHAT THE WEIGHTS DID TO SAFETY-42's DETECTION WINDOW, AND WHAT " + "SAFETY-43 DID ABOUT IT - GRADED UNDER BOTH RULES, ON ONE FIXTURE") -# ⚠️ READ THIS BEFORE READING THE CHECKS: THEY ASSERT THE UNFLATTERING ANSWER ON -# PURPOSE. An earlier draft of §5 of the decision said a wedged unit's weight -# does not move, so OPT-09 "cannot see it at all". The first clause is about the -# ENVELOPE and is true; the second is FALSE. A wedged unit's weight moves -# whenever its energy headroom differs from its sibling's - it is a pack fact, -# not a delivery fact - and `delivery.delinquent()` names a unit only while its -# `short_by()` gap exceeds `min_w` (`Tuning.saturation_w`, 500 W) IN ABSOLUTE -# WATTS. Shrinking a unit's share shrinks its gap. Shrink it below 500 W and the -# alarm stops firing on a unit that is still delivering nothing. +# ⚠️ THIS PART USED TO PIN THE UNFLATTERING ANSWER ON PURPOSE, AND SAFETY-43 IS +# THE CARD THAT INVERTED IT. What it said - and it was true when it was written +# - was: a wedged unit's weight MOVES whenever its energy headroom differs from +# its sibling's (it is a pack fact, not a delivery fact), `delivery.delinquent()` +# named a unit only while its `short_by()` gap exceeded `min_w` +# (`Tuning.saturation_w`, 500 W) IN ABSOLUTE WATTS, and shrinking a unit's share +# shrinks its gap. Shrink it below 500 W and the alarm stopped firing on a unit +# that was still delivering nothing. The old text ended "if it ever goes red +# because the alarm fired, read the card that made it fire before 'fixing' this +# line". SAFETY-43 is that card, this is that red, and the checks below are the +# same three measurements with their answers the other way round. # -# So the seam is not "OPT-09 cannot see it". It is: OPT-09 stops the ask AND -# narrows SAFETY-42's detection window on the same unit. At equal weights the -# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is -# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W. +# ⚠️ AND THE EVIDENCE IS THE DIFF BETWEEN TWO RULES ON ONE RUN, NOT A NUMBER +# THAT CHANGED IN A COMMIT. `delinquent()` takes `share_frac`, and +# `share_frac=None` is SAFETY-42's rule EXACTLY - the absolute arm alone. So +# `wedged_loop` grades every cycle twice, once under each rule, and the red and +# the green sit side by side in one output: # -# ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included. +# load 1500 W, OPT-09 weights, absolute arm only named on 0/200 cycles +# load 1500 W, OPT-09 weights, the shipped rule named on 200/200 cycles # -# ⚠️ THIS IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE. Making -# `delinquent()` relative to the ask rather than absolute is SAFETY-42's code and -# a design change; it is filed as card SAFETY-43. What this part does is GRADE THE -# CURRENT BEHAVIOUR so that the day it changes, a named check moves. +# A future reader who wants to know what this card did runs this file. +# +# ⚠️ WHAT SAFETY-43 DID NOT DO IS LOWER `min_w`, and the check on the window +# arithmetic below is what holds that open: the ABSOLUTE arm's suppression +# window is still 500 W wide and still widens to 6500 W under these weights. +# Nothing that was quiet became noisy; a SECOND way to be named was added, and +# it is a fraction of the unit's OWN ask, so it fires on a unit delivering none +# of 125 W exactly as it fires on one delivering none of 1500 W. # # ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION, # checked below before anything is concluded from it: B is WEDGED - it accepts # its share and produces nothing - at a HEALTHY 15 % against its own 10 % floor, # so `envelope()` does not close and `account()` explains nothing. This is # exactly the case §5 says the envelope cannot reach, which is why it is the -# case where the weights' side effect matters. +# case where the weights' side effect mattered. # # ⚠️ TWO COPIES OF A. Inverter B does not exist; the plant below is modelled in # this file the way part 8's is, and B's ratings are datasheet. WEDGED_A_SOC, WEDGED_B_SOC = 90.0, 15.0 # both healthy, both above their floor MIN_W = Tuning().saturation_w # what `delinquent()` is handed +ABSOLUTE_ONLY = None # `share_frac=None` - SAFETY-42's rule -def wedged_loop(load_w, weighted, cycles=200): +def wedged_loop(load_w, weighted, cycles=200, share_frac=DELIVERY_SHARE_FRACTION, + b_delivers=0.0, b_soc=WEDGED_B_SOC, no_account=False): """The closed loop with unit B WEDGED at a healthy SoC. Real everything. Real `compute()`, real `plan()`, real `packshare.weights()`, real `delivery.account()` and real `delivery.delinquent()`. The only model is the - plant: A obeys within one cycle inside its rating, B accepts whatever it is - told and delivers 0 W - which is the wedged box, and is what makes this a + plant: A obeys within one cycle inside its rating, B delivers `b_delivers` + of whatever it is told - 0.0 is the wedged box, and is what makes this a SAFETY-42 case rather than an OPT-09 one. `weighted=False` is equal weights - what shipped. Envelopes are left OPEN in both arms deliberately: neither pack's SoC closes one here, so filing them would change nothing and would blur which half of the card is being graded. + `share_frac` is SAFETY-43's relative arm. `ABSOLUTE_ONLY` grades the same + cycles under SAFETY-42's rule, which is how the red and the green below come + out of ONE run over ONE tree instead of out of two commits. + + `no_account=True` DELETES the account gate - every unit is handed + `accounted=None` - and it exists to answer "would this arm still be silent + if `account()` did nothing at all?". A control that answers the same way + with the gate removed is not grading the gate, and the cut-out arm below is + exactly that case. See it there; it is the point of that block. + Returns `(cycles on which delinquent() named somebody, last cycle)`. """ - packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, WEDGED_B_SOC, B_KWH)] + packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, b_soc, B_KWH)] tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W) prev, i_w, sat = 0.0, None, 0 power = [0.0, 0.0] @@ -678,13 +700,15 @@ def wedged_loop(load_w, weighted, cycles=200): r = plan(d.target_w, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=w) assert r.ok, r.refused # THE MODELLED PLANT: A within one cycle inside its rating; B WEDGED. - power = [max(-A_W, min(A_W, float(r.shares[A]))), 0.0] + power = [max(-A_W, min(A_W, float(r.shares[A]))), + b_delivers * max(-B_W, min(B_W, float(r.shares[Bee])))] grid = load_w - sum(power) who = delinquent( [UnitDelivery(p.name, r.shares[p.name], m, + None if no_account else account(live=True, parked=False, soc=p.soc, bms_floor=p.bms_floor)) - for p, m in zip(packs, power)], MIN_W) + for p, m in zip(packs, power)], MIN_W, share_frac) named += bool(who) last = (d.target_w, dict(r.shares), grid, who) return named, last @@ -698,75 +722,271 @@ check("PRECONDITION: the wedged unit is a SAFETY-42 TRUE POSITIVE and NOT an " not packshare.envelope(_WEDGED_PACK).binding and account(live=True, parked=False, soc=WEDGED_B_SOC, bms_floor=FLOOR) is None) +check("PRECONDITION: the shipped fraction is a fraction, and it is STRICTLY " + "below 1.0 - at 1.0 the relative arm could only fire on a unit IMPORTING " + "while asked to discharge, which is SAFETY-30's fault and not this one, " + "and this card's own case (a unit delivering NONE of its share, gap over " + "share exactly 1.0) would be missed", + 0.0 < DELIVERY_SHARE_FRACTION < 1.0) _wedge = {} for _load in (1500.0, 6000.0): for _weighted in (False, True): - _n, _t = wedged_loop(_load, _weighted) - _wedge[(_load, _weighted)] = (_n, _t) - print(f" load {_load:.0f} W, B wedged at 15 %, " - f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} " - f"named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W, shares " - + ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items())) - + f", grid {_t[2]:+.0f} W, named=" - + (str(sorted(_t[3])) if _t[3] else "(nobody)")) + for _frac in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION): + _n, _t = wedged_loop(_load, _weighted, share_frac=_frac) + _wedge[(_load, _weighted, _frac)] = (_n, _t) + print(f" load {_load:.0f} W, B wedged at 15 %, " + f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} " + + ("absolute arm only" if _frac is ABSOLUTE_ONLY + else f"frac {_frac:.2f} ") + + f", named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W," + + " shares " + + ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items())) + + f", grid {_t[2]:+.0f} W, named=" + + (str(sorted(_t[3])) if _t[3] else "(nobody)")) -check("AT EQUAL WEIGHTS - WHAT SHIPPED - the wedged unit is named on every " - "cycle but the first, at a load its sibling can carry on its own. The " - "loop winds the site target to twice the load, the 50/50 split stands " - "+1500 W on a box producing nothing, and 1500 W > 500 W is what " - "`delinquent()` reads", - _wedge[(1500.0, False)][0] == 199 - and sorted(_wedge[(1500.0, False)][1][3]) == [Bee] - and _wedge[(1500.0, False)][1][1] == {A: 1500, Bee: 1500}) +check("⚠️ THE CARD, RED: UNDER SAFETY-42's RULE AND OPT-09's WEIGHTS THE WEDGED " + "UNIT IS NAMED ON NONE OF THE 200. Same wedged box, same healthy SoC, " + "same load, same detector: the split moves to 1505/125, the gap on the " + "wedged unit falls to 125 W, and 125 W < 500 W so the absolute arm " + "returns {}. This is the defect SAFETY-43 was filed for, and it is " + "measured here rather than remembered", + _wedge[(1500.0, True, ABSOLUTE_ONLY)][0] == 0 + and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][3] == {} + and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][1] == {A: 1505, Bee: 125}) -check("⚠️ AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200. Same wedged " - "box, same healthy SoC, same load, same detector: the split moves to " - "1505/125, the gap on the wedged unit falls to 125 W, and 125 W < 500 W " - "so `delinquent()` returns {}. THIS CHECK PINS A REGRESSION - it asserts " - "what the code DOES, not what anyone wants, and if it ever goes red " - "because the alarm fired, read the card that made it fire before " - "'fixing' this line", - _wedge[(1500.0, True)][0] == 0 - and _wedge[(1500.0, True)][1][3] == {} - and _wedge[(1500.0, True)][1][1] == {A: 1505, Bee: 125}) +check("⚠️ THE CARD, GREEN: THE SHIPPED RULE NAMES IT ON ALL 200, ON THE SAME " + "RUN. The unit is standing at +125 W and delivering 0 W - 100 % of its " + "ask is not arriving - and `gap > frac * share` does not care that 125 W " + "is a small number. The ONLY input that moved between this check and the " + "one above is `share_frac`", + _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200 + and sorted(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee] + and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][1] \ + == {A: 1505, Bee: 125}) + +check("AT EQUAL WEIGHTS - WHAT SHIPPED BEFORE OPT-09 - the wedged unit was " + "already named under the absolute rule on every cycle but the first, at " + "a load its sibling can carry on its own. The loop winds the site target " + "to twice the load, the 50/50 split stands +1500 W on a box producing " + "nothing, and 1500 W > 500 W is what the absolute arm reads", + _wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 199 + and sorted(_wedge[(1500.0, False, ABSOLUTE_ONLY)][1][3]) == [Bee] + and _wedge[(1500.0, False, ABSOLUTE_ONLY)][1][1] == {A: 1500, Bee: 1500}) + +check("...and the shipped rule adds exactly the FIRST cycle to that arm and " + "nothing else - 199 becomes 200. On cycle one the slew limit holds the " + "split at 500/500, a 500 W gap is NOT over a 500 W threshold (the " + "absolute bound is strict), and the relative arm names a box delivering " + "none of its 500 W. That one cycle is the whole of what this card costs " + "an arm that was already firing", + _wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0] == 200 + and _wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0] + - _wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 1) check("...and this is the regime where NOTHING ELSE TELLS THE OPERATOR " "ANYTHING: the meter is satisfied (grid within 5 W of zero), `unmet_w` is " "0 because both units accepted their shares, no envelope closed and no " - "action-log line fires. The site is quietly running one inverter and the " - "one instrument built to say so has gone quiet with it", - abs(_wedge[(1500.0, True)][1][2]) <= 5.0 - and abs(_wedge[(1500.0, False)][1][2]) <= 5.0) + "action-log line fires. The site is quietly running one inverter, and " + "before this card the one instrument built to say so had gone quiet with " + "it", + abs(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0 + and abs(_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0) _w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0) _window_equal = MIN_W * 2.0 _window_opt09 = MIN_W * sum(_w.values()) / _w[Bee] -print(f" suppression window on this fixture: equal weights, site target " - f"< {_window_equal:.0f} W; OPT-09 weights, site target " - f"< {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site") -check("...and the arithmetic behind it, stated as a window rather than as one " - "fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a " - "fraction f of the site target is invisible while `f * target < min_w`. " - "Equal weights suppress below 1000 W; these weights suppress below " - "6500 W of a 9600 W site. ⚠️ BOTH ARE PROPORTIONAL-REGIME BOUNDS AND " - "NEITHER IS REACHABLE: the healthy pack's own rating binds first, so the " - "alarm returns BELOW 6500 W (swept: 0/200 to a 3000 W load, 41/200 at " - "4900 W, 195/200 at 5000 W) and the equal arm never suppresses at all. " - "That is the regression, and it is a property " - "of the threshold being absolute rather than of any number chosen here", - abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0) +print(f" the ABSOLUTE arm's suppression window is UNCHANGED by SAFETY-43: " + f"equal weights, site target < {_window_equal:.0f} W; OPT-09 weights, " + f"site target < {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site") +check("⚠️ AND `min_w` WAS NOT LOWERED, WHICH IS THE HALF OF THIS CARD THAT IS " + "EASIEST TO GET WRONG. The absolute arm still suppresses below 1000 W at " + "equal weights and below 6500 W at these ones - the same arithmetic this " + "part has always printed - because SAFETY-43 ADDED a second way to be " + "named rather than loosening the first. Lowering `saturation_w` would " + "have reported normal saturation on every unit at once, which is the " + "false-trip regime SAFETY-41 spent four attempts in", + abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0 + and MIN_W == Tuning().saturation_w == 500.0) + +check("⚠️ AND THE ABSOLUTE ARM WAS NEVER SILENT EVERYWHERE - which is what a " + "check that only ever ran the quiet load would be pinning. Push the load " + "past the healthy pack's OWN RATING and `_fill()`'s last-resort pass " + "hands the wedged unit 3000 W anyway, the gap goes back over 500 W, and " + "both arms named it under SAFETY-42's rule too. The window was real and " + "it had an upper edge; the hole was the quiet regime below that edge, " + "and the shipped rule now names the unit on every cycle of all four runs", + _wedge[(6000.0, True, ABSOLUTE_ONLY)][0] == 195 + and _wedge[(6000.0, False, ABSOLUTE_ONLY)][0] == 199 + and _wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][0] == 200 + and _wedge[(6000.0, False, DELIVERY_SHARE_FRACTION)][0] == 200 + and sorted(_wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee]) + +print() +print(" THE POSITIVE CONTROL: what the relative arm must STILL NOT say") + +# ⚠️ A CARD THAT ADDS A WAY TO RAISE AN ALARM HAS TO SHOW WHAT IT DID NOT ADD, +# or it has traded a missed alarm for a false one - which in this system is not +# a rhetorical risk. SAFETY-41 took four attempts on notification cadence and +# the owner's words were "i'm getting really annoyed by the notifications in my +# HA". So: the same fixture, the same weights, the same tiny 125 W share, with +# the ONE fact that makes the shortfall legitimate changed. + +# ⚠️ AND THE FIRST DRAFT OF THIS BLOCK WAS VACUOUS, WHICH IS WHY IT NOW PRINTS +# ITS SHARES. It ran the cut-out arm, saw 0/200, and concluded "`account()` +# explains it" and "identical trace to the wedged arm". BOTH WERE FALSE. At its +# own cut-out the pack's usable energy ABOVE that cut-out is zero, so +# `weights()` gives it 0.0, `_fill()`'s preferred pass gives it nothing, the +# healthy sibling covers the load alone so the LAST-RESORT pass never runs, and +# its share is 0 W on ALL 200 CYCLES. A unit with a 0 W share is not in `asked`, +# so `len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()` +# is never consulted - measured below by deleting the gate and getting the same +# answer. The wedged arm's share is the 125 W printed on its own line above; +# this one's is 0, printed and asserted below. Nothing was identical and nothing +# reached the comparison. +# +# ⚠️ THE FACT THAT REPLACES IT IS BETTER, NOT WEAKER: at its own cut-out OPT-09 +# STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached. +# That is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the +# allocator declines to ask before the detector has to decline to alarm. The +# account gate itself is graded where it is REAL, on a positive 125 W share, in +# `test_delivery.py` part 1 ("THE ACCOUNT GATE IS UNTOUCHED BY IT"). +# +# ⚠️ AND IT WAS INVISIBLE FOR ONE REASON: this was the only line in part 8b that +# did not print its shares. `inverter-b+0` would have been on screen from the +# first run. Every line in this part prints them now. +_sat_named, _sat_last = wedged_loop(1500.0, True, b_soc=FLOOR) +_sat_nogate, _sat_nogate_last = wedged_loop(1500.0, True, b_soc=FLOOR, + no_account=True) +print(f" load 1500 W, B AT ITS OWN {FLOOR:.0f} % CUT-OUT, OPT-09 weights, " + f"frac {DELIVERY_SHARE_FRACTION:.2f}: named on {_sat_named:3d}/200 " + f"cycles, shares " + + ", ".join(f"{u}{v:+d}" for u, v in sorted(_sat_last[1].items())) + + ", named=" + + (str(sorted(_sat_last[3])) if _sat_last[3] else "(nobody)") + + f"; with account() DELETED: {_sat_nogate:3d}/200") +# ⚠️ AND THE `no_account=True` SWITCH ITSELF WAS GRADED BY NOTHING. It is used +# in exactly one place - the fixture above - where it PROVABLY cannot change the +# answer, because a cut-out pack is never asked and the gate is never reached. +# Review neutered the switch so it removed nothing: the file stayed GREEN and +# printed "with account() DELETED: 0/200" while `account()` had not been +# deleted. A line asserting a mechanism that did not happen - B1's shape, one +# level down, in the fix for B1. +# +# The switch DOES bite, on a fixture already reachable from this helper: at the +# cut-out with EQUAL weights, `plan()` hands B a 50/50 share, so the gate is the +# only thing keeping it silent. Measured: gate on 0/200, gate deleted 200/200. +# That proves the switch removes the gate, AND gives this part the end-to-end +# account-gate control on a POSITIVE share that it otherwise has to outsource to +# test_delivery.py part 1. +_eq_gate, _eq_last = wedged_loop(1500.0, False, b_soc=FLOOR) +_eq_nogate, _ = wedged_loop(1500.0, False, b_soc=FLOOR, no_account=True) +print(f" ...and the SWITCH is real: same cut-out, EQUAL weights so B IS " + f"asked ({_eq_last[1][Bee]:+d} W last cycle) - gate on {_eq_gate:3d}/200, " + f"gate DELETED {_eq_nogate:3d}/200") +check("⭐ THE `no_account` SWITCH ACTUALLY REMOVES THE GATE, on a fixture where " + "the gate is the only thing keeping the pack silent. Without this the " + "line above prints 'with account() DELETED' about a run in which nothing " + "was deleted - which is exactly the defect this block was rewritten for", + _eq_gate == 0 and _eq_nogate == 200 and _eq_last[1][Bee] > 0) + +check("A PACK AT ITS OWN CUT-OUT IS NEVER ASKED, SO THE RELATIVE ARM IS NEVER " + "REACHED. Its usable energy above its own cut-out is zero, `weights()` " + "answers 0.0 for it, the healthy sibling covers the load so `_fill()`'s " + "last-resort pass never runs, and its share is 0 W on every one of the " + "200 cycles - so it is not in `asked` and `len(asked) < 2` returns {} " + "before either arm is compared. ⚠️ THE SHARE IS ASSERTED, not inferred " + "from the silence: a control that cannot say WHY it is quiet is a control " + "that cannot fail", + _sat_named == 0 and _sat_last[3] == {} and _sat_last[1][Bee] == 0) +check("⚠️ AND THAT IS A SECOND, INDEPENDENT PROTECTION - NOT THE ACCOUNT GATE, " + "WHICH THIS ARM DOES NOT TOUCH. Delete `account()` entirely, hand every " + "unit `accounted=None`, and this arm is STILL silent on all 200 cycles, " + "because the protection is UPSTREAM of it: the allocator declines to ask " + "before the detector has to decline to alarm. ⚠️ SO DO NOT READ THIS " + "BLOCK AS EVIDENCE ABOUT `account()` - it is evidence about the ask. The " + "account gate on a POSITIVE share is graded in test_delivery.py part 1", + _sat_nogate == 0 and _sat_nogate_last[3] == {}) + +_derate_named, _derate_last = wedged_loop(1500.0, True, b_delivers=0.5) +print(f" load 1500 W, B delivering HALF its share, OPT-09 weights, frac " + f"{DELIVERY_SHARE_FRACTION:.2f}: named on {_derate_named:3d}/200 cycles, " + f"shares " + + ", ".join(f"{u}{v:+d}" for u, v in sorted(_derate_last[1].items())) + + ", named=" + + (str(sorted(_derate_last[3])) if _derate_last[3] else "(nobody)")) +check("...and ORDINARY TRACKING ON A SMALL SHARE IS NOT DELINQUENCY EITHER. " + "The same unit at the same weights delivering HALF of what it is asked " + "for is short by 50 % - well inside the shipped fraction - and its gap " + "in watts is far under `min_w`, so NEITHER arm names it. A relative " + "criterion that fired here would be the saturation-shaped alarm this " + "whole module is written against", + _derate_named == 0 and _derate_last[3] == {}) + +check("...and the DERATED control is not vacuous, which is asserted on the ONE " + "thing that could make it so: unlike the cut-out arm above, its unit IS " + "asked - a positive share on every cycle - so it really does reach both " + "arms and is turned away by the size of its shortfall. ⚠️ THIS GUARD USED " + "TO RE-ASSERT THE WEDGED ARM's 200/200 INSTEAD, WHICH IS WHY IT DID NOT " + "CATCH THE CUT-OUT ARM BEING VACUOUS: a check on a DIFFERENT run cannot " + "say anything about whether THIS one reached the comparison", + _derate_last[1][Bee] > 0 + and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200) + +print() +print(" N=1 IS BYTE-IDENTICAL, AND THAT IS ASSERTED RATHER THAN ARGUED") + +# ⚠️ THE LIVE INSTALL IS N=1 AND THIS CARD MUST NOT REACH IT. Two independent +# statements, and each is checked against the thing that would break it: +# 1. `plan()` RETURNS BEFORE `weights` IS READ AT ALL, so no weighting - and +# no change to how one is derived - can move a one-inverter share. Graded +# with a `weights` object that RAISES if anything reads it. +# 2. `delinquent()` returns {} at a roster of one for every input, including +# the input the RELATIVE arm was built for. The proper-subset algebra runs +# before either arm, so the fraction is never even multiplied. + + +class _Poison(dict): + """A `weights` that cannot be read without saying so. Not a mock: a tripwire.""" + + def get(self, *a, **k): + raise AssertionError("plan() read weights at a roster of one") + + def __getitem__(self, k): + raise AssertionError("plan() read weights at a roster of one") + + +_n1_plain = plan(1234.5, {A: _B(-A_W, A_W)}) +_n1_poison = plan(1234.5, {A: _B(-A_W, A_W)}, weights=_Poison()) +check("`plan()` at a roster of ONE returns the site target unread, and it " + "returns it WITHOUT TOUCHING `weights` - handed a mapping that raises on " + "any read, it answers identically to the same call with no weights at " + "all. The N=1 pass-through is above the weighting, so OPT-09 could not " + "move it and SAFETY-43 does not either", + _n1_poison.shares == _n1_plain.shares == {A: 1234.5} + and _n1_poison.unmet_w == _n1_plain.unmet_w) +try: + plan(1234.5, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=_Poison()) +except AssertionError: + _poison_bites = True +else: + _poison_bites = False +check("...and the tripwire is a real one: the SAME poisoned mapping at a roster " + "of TWO is read and raises. A check that could not fail would be " + "measuring nothing", + _poison_bites) + +_n1_rel = [delinquent([UnitDelivery(A, s, m)], MIN_W, f) + for s in (0, 125, 500, 2000, 5000, -2000, None) + for m in (0, 60, 125, 2000, -2000, None) + for f in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION)] +check(f"...and `delinquent()` answers {{}} at a roster of one on all " + f"{len(_n1_rel)} of (share, measured, rule) - INCLUDING 0 of 125 W, the " + f"exact input this card added an arm for. `len(asked) < 2` returns " + f"before either threshold is compared, so the one-inverter install this " + f"add-on is running today cannot tell that SAFETY-43 happened", + all(r == {} for r in _n1_rel)) -check("⚠️ AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY - which is " - "what a check that only ever ran the quiet load would be pinning. Push " - "the load past the healthy pack's OWN RATING and `_fill()`'s last-resort " - "pass hands the wedged unit 3000 W anyway, the gap goes back over 500 W, " - "and BOTH arms name it. The window is real and it has an upper edge; the " - "hole is the quiet regime below that edge", - _wedge[(6000.0, True)][0] == 195 - and sorted(_wedge[(6000.0, True)][1][3]) == [Bee] - and _wedge[(6000.0, False)][0] == 199 - and sorted(_wedge[(6000.0, False)][1][3]) == [Bee]) # ============================================================================== print() @@ -978,9 +1198,12 @@ print(" a preference and are honest about being one.") print(" * A WEDGED UNIT IS NOT REACHABLE BY THE ENVELOPE. Its SoC reads") print(" healthy, so nothing closes, and SAFETY-42 owns the detection.") print(" ⚠️ BUT IT IS REACHABLE BY THE WEIGHTS, WHICH MOVE ON ENERGY") -print(" HEADROOM AND NOT ON DELIVERY - part 8b grades what that does") -print(" to SAFETY-42's window, and the answer is a REGRESSION that is") -print(" pinned rather than fixed.") +print(" HEADROOM AND NOT ON DELIVERY. That narrowed SAFETY-42's") +print(" detection window - a regression this file PINNED rather than") +print(" fixed - and SAFETY-43 closed it by making delinquency relative") +print(" to the ask as well as absolute. Part 8b now grades both rules") +print(" on one fixture: the absolute arm still says nothing, and the") +print(" shipped one names the unit on all 200 cycles.") # ============================================================================== print() @@ -1023,13 +1246,41 @@ MUT = [ "mutation took it down with a TypeError three lines into part 1"), ("M11 weights(): keep the shape, force every weight to 1.0 <- THE PROOF " "THAT PART 8b CAN GO RED", - "test_packshare 8b, three named checks: the wedged unit goes back to being " - "named on 199/200 at 1500 W, the split reverts to 1500/1500 and the " - "window collapses from 6500 W to 1000 W. ⚠️ RUN, NOT REASONED ABOUT - a " - "check that pins an UNFLATTERING behaviour has to be shown capable of " - "failing, or it is indistinguishable from a check that pins nothing. " + "test_packshare 8b, FIVE named checks - re-run for SAFETY-43 rather than " + "carried across, because the part it grades was rewritten: the split " + "reverts to 1500/1500 so the absolute arm names the unit on 199/200 " + "(taking the card's RED check down), the shipped rule's 200/200 comes " + "from the wrong shares (taking the GREEN one down), the OPT-09 window " + "collapses from 6500 W to 1000 W, the 6000 W arm's 195 becomes 199, and " + "the derated positive control moves from 0/200 to 3/200. ⚠️ RUN, NOT " + "REASONED ABOUT - a check that pins a behaviour has to be shown capable " + "of failing, or it is indistinguishable from a check that pins nothing. " "(It also takes parts 1, 3, 7 and 8 down, which is M3's territory; 8b is " "the part that names the SAFETY-42 interaction)"), + # --- SAFETY-43's three, and they pin the new arm from BOTH sides -------- + ("M12 delinquent(): drop the relative arm, keep `gap > min_w` <- THE " + "CARD ITSELF REVERTED", + "SEVEN named checks: test_delivery 1 (THREE - the 0-of-125 W naming, the " + "proportion in the sentence, and the strict relative bound) and " + "test_packshare 8b (FOUR - the GREEN check goes back to 0/200 under " + "OPT-09's weights, the equal arm's 200 back to 199, the 6000 W arm back " + "to 195/199, and the derated control's vacuity guard falls with them). " + "test_alloc and test_control stay green, which is the point - nothing " + "outside this check moves"), + ("M13 DELIVERY_SHARE_FRACTION 0.85 -> 1.0 <- THE TOO-LOOSE DIRECTION", + "SEVEN named checks: test_delivery 1 (TWO) and test_packshare 8b (FIVE), " + "including the PRECONDITION that the fraction is strictly below 1.0. At " + "1.0 the arm can only fire on a unit IMPORTING while asked to discharge, " + "so the card's own case - 0 of 125 W, gap over share exactly 1.0 - is " + "missed and the whole card is a no-op"), + ("M14 DELIVERY_SHARE_FRACTION 0.85 -> 0.05 <- THE TOO-SENSITIVE ONE", + "SIX named checks: test_delivery 1 (TWO - the strict bound, and 'IT TOOK " + "NOTHING AWAY' stops being true) and 4b (THREE - the `saturation_w` pin " + "goes VACUOUS, because the relative arm names B at every threshold), plus " + "test_packshare 8b's DERATED positive control, where a unit delivering " + "half its share starts being named. ⚠️ THIS IS THE ROW THAT MATTERS - the " + "fraction is pinned from BOTH directions, so it is a derived number with " + "checks around it rather than a literal nobody can move"), ] for _n, _w in MUT: print(f" caught {_n}") @@ -1039,8 +1290,17 @@ check("every mutation in the table above was RUN and every one of them is " "M6: the whole defect this card fixes - a parameter nothing passes - is " "invisible to every behavioural fixture in this repository, because they " "all build their own claim list and call `resolve()` themselves. That is " - "why the call site is pinned statically as well", - len(MUT) == 11) + "why the call site is pinned statically as well. ⚠️ M12-M14 ARE " + "SAFETY-43's and were run the same way, on trees copied fresh and " + "mounted :ro so no `.pyc` could go stale - MUTATION.md's defect. All " + "three are REDS, which is the direction that defect cannot fabricate. " + "⚠️ AND THEIR COUNTS WERE RE-RUN IN REVIEW AND TWO OF THEM MOVED: " + "test_delivery.py part 1 used to subscript `delinquent(...)['b']` for a " + "print, which raised KeyError under M12 and M13 and ABORTED THE FILE at " + "part 1, leaving parts 2-8 ungraded and the recorded coverage smaller " + "than the truth. It is a `.get` now. M14's count was simply wrong: five " + "named checks in test_delivery, not four", + len(MUT) == 14) print() if fails: