checks / unit (pull_request) Successful in 6m22s
§3.3 already set the standard - "a worst case sitting on an edge of the grid
would be a number about the grid" - and then did not check its own argmax
against it. Review did. Reproduced here independently before writing it down:
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
0.10 -> 35.0 s
Strictly increasing in stagger, maximal at the largest modelled stagger, and
maximal at the most asymmetric split. Interior only in `gain` and `slew_w`.
⚠️ SO THE HEADLINE IS 0.0 s AT stagger=0. The 35.0 s is ENTIRELY a product of
the modelled serialised-write stagger, which is declared MODELLED in the harness
and is a TERM of the figure rather than a detail of it. A site whose two writes
land in the same cycle does not produce this episode at all.
It does not move `delivery_hold_s`: 185 s must cover the worst the modelled site
can produce, and four cycles of stagger is inside what a real two-inverter write
path can do. What changes is that a reader asking "is 35.0 s about the plant or
about the grid?" now gets the answer instead of the invitation.
⚠️ THE PR BODY HAD CLAIMED THE ARGMAX WAS "interior to the grid, not on an edge
of an axis I had to choose". That was wrong, and it is the claim the doc's own
standard exists to test. The grid is the record's own, fixed by §3.3 before this
rebuild ran, which is the mitigating half and is stated too.
Also: `nl.yaml` wrote "35.0 s" beside "5,29 keer" and "75,0 s" in one sentence.
Dutch takes the comma.
Suite: 26 files, all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
798 lines
47 KiB
Markdown
798 lines
47 KiB
Markdown
# SAFETY-42 — a unit that stops delivering its share, the discriminator the card asked for, and why that discriminator does not exist
|
||
|
||
**Status: DECIDED 2026-09-06.** A new per-inverter check, `app/delivery.py`,
|
||
names an inverter that is **standing at a discharge share it is not producing**
|
||
while at least one sibling is not in that state and nothing this add-on can read
|
||
explains it. It **reports and does not act**. At a roster of one it is inert by
|
||
construction.
|
||
|
||
⚠️ **This record also FALSIFIES the discriminator the card proposed.** SAFETY-42
|
||
asked for *"saturation persisting past N while |grid error| stays above X, with N
|
||
and X derived"*. That predicate was built and run on the rig before anything was
|
||
written, and it fails in **both** directions — not for want of better N and X,
|
||
but because of what the quantity is. §2 is the measurement. This is the
|
||
SAFETY-39 shape: saying the criterion is wrong was worth more than five
|
||
near-misses at satisfying it.
|
||
|
||
Filed with SAFETY-42, card `6a9d498a49082c0086e525c1`.
|
||
|
||
---
|
||
|
||
## 0. What is uncovered, and why it is structural rather than a threshold
|
||
|
||
A unit delivering **0 W against a non-zero share** is seen by nothing:
|
||
|
||
| guard | why it is silent |
|
||
|---|---|
|
||
| [[SAFETY-30]] per-unit sign | gates on **opposition ≥ 250 W on both sides**. A unit at 0 W never opens it — it is not opposing, it is **absent** |
|
||
| SAFETY-30 site sign | the sum still **agrees with the meter in direction**; only the magnitude is short |
|
||
| [[SAFETY-31]] circulation | `Σ\|Bᵢ\| − \|ΣBᵢ\|` is **zero** when one unit does nothing — circulation needs two units pulling *against* each other |
|
||
| the saturation freeze | **fires** — and `clamp.py`:93-96 says of exactly this: *"A loop can be legally saturated all day — a cold pack, a curtailed inverter, an export limit — so a saturation-shaped alarm is one an operator learns to ignore"* |
|
||
|
||
So the one signal that does fire is the one this codebase documents as
|
||
unreadable, and that documentation is **right**. This card therefore needed a
|
||
different signal, not a louder one.
|
||
|
||
It is [[HW-01]]'s shape. On 2026-09-02 the comms subsystem failed and the power
|
||
stage went on executing its last instruction for **6 h 25 m** with all three
|
||
layers spectators. At N=2 the same fault leaves a healthy sibling regulating
|
||
alone, the site short by up to one unit's share, and nothing saying so.
|
||
|
||
## 1. The rig
|
||
|
||
Everything in §2 and §3 was measured on `admin/goodwe`'s two-master rig
|
||
(TEST-19), used **read-only** and run container-free, from a harness in a
|
||
scratch tree. Real and imported, never transcribed: the control law
|
||
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
|
||
`allocator.plan`), the per-unit envelope the shipped `supervised` and `device`
|
||
claims build, the sign predicate (`watchdog.disagrees`), the circulating sensor
|
||
(`circulation.circulating_w`), the check under test (`delivery.delinquent`,
|
||
`short_by`, `account`), the wire (`frame_check.setpoint_atomic`, the firmware's
|
||
own encoder, in and back out through `plantsim.parse`/`decode`), and the plant
|
||
(`plantsim.Plant`). The per-unit reading is **holding register 0x0518**, the one
|
||
the live `batt_entity` binds.
|
||
|
||
Modelled, and therefore not evidence about the thing modelled: the master's
|
||
failsafe machine (absent), and the two fault injections —
|
||
|
||
* **wedged** — the write never reaches the plant, so its latched command stands.
|
||
Started at 0 W, which is *"delivering 0 W against a non-zero share"*.
|
||
* **at its floor** — the write arrives and `Plant.limit()` zeroes it, because
|
||
`soc <= soc_min`. This is the benign case the acceptance criterion names.
|
||
|
||
### ⚠️ Inverter B does not physically exist
|
||
|
||
`plantsim.INVERTER_B` is **inverter A copied**, `measured=False` (NOTES.md
|
||
§1.1b; the datasheet-and-serial figures 4600 W / 20.48 kWh / floor 10 % are
|
||
deliberately *not* in the model). **Every two-inverter figure in this document
|
||
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
|
||
margin that costs.
|
||
|
||
### Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)
|
||
|
||
⚠️ **THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE
|
||
DEFECT TEST-21 FOUND.** A shipped safety constant whose floor the next reviewer
|
||
cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three
|
||
days. The paragraph this replaces was true — what the harness does *is* small
|
||
enough to restate — and it was not enough: a restatement in prose leaves every
|
||
axis it does not name to the next person's judgement, and §3.3 below now records
|
||
how far that moves the number. **It moved. See §3.3's second table.**
|
||
|
||
It is committed in the **other** repository, `admin/goodwe`, as
|
||
`sim/delivery_window.py`, beside `sitesim.py` — the container-free two-master
|
||
harness it is built on. From clean checkouts of both repos:
|
||
|
||
```
|
||
cd goodwe/sim
|
||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
|
||
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag
|
||
```
|
||
|
||
stdlib only. No container, no HA, no esphome, **no rig and no rig lock** — it is
|
||
a `plantsim` campaign, not a rig job. About fifteen minutes for the whole thing.
|
||
|
||
⚠️ **AND IT IS IN `admin/goodwe` RATHER THAN HERE, WHICH IS CI-08's RULE
|
||
APPLIED RATHER THAN IGNORED.** It imports `plantsim` (the plant, the per-unit
|
||
wire, `Site`) and `frame_check` (the firmware's own encoder), both **owned by
|
||
that repo** and neither present in this one's CI image. Committed here it had
|
||
two possible fates and both are the coupling CI-08 has been broken by three
|
||
times: skipped for want of its imports — a green that establishes nothing — or
|
||
the plant and the encoder **restated in this tree**, which is a second copy of
|
||
something the other repo owns, free to drift, and drift in a fixture is
|
||
invisible until the number it defends is wrong. There the dependency runs one
|
||
way and stays one-directional: **this** repo's `control`, `arbiter` and
|
||
`delivery` are imported live out of whichever checkout is under test
|
||
(`$GOODWE_ADDON_REPO`), never copied — so a change to `delinquent()` moves the
|
||
number on the next run instead of silently disagreeing with a fixture. That is
|
||
the seam `sitesim.py` already stands on, which is the other reason the campaign
|
||
is its neighbour and not a third arrangement.
|
||
|
||
⚠️ **THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS
|
||
DELIBERATE.** `admin/goodwe`'s `.gitea/workflows/checks.yml` gained one step —
|
||
`python3 -B delivery_window.py --selftest`, seconds, beside the existing
|
||
`norig, no lock` TEST-19 and TEST-20 steps, in the workflow that **already**
|
||
checks out this repo for `two_writers` and accepts that boundary in writing. So
|
||
a change **here** that moved `saturation_w`, `max_w`, `delivery_hold_s` or the
|
||
shape of `delinquent()` turns that step red rather than leaving a stale number
|
||
in this record. The **campaign** (`--campaign --blag`, ~15 minutes) is not
|
||
gated: it is a campaign, not a gate, and `failure-injection.yml`'s jobs run on
|
||
the **rig** runner, which a container-free simulation has no business holding.
|
||
What TEST-21 asked for is a command a reviewer can run from a clean checkout —
|
||
that is the block above.
|
||
|
||
⚠️ **`--selftest` IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS
|
||
FIRST.** A campaign whose N=1 column and whose flapping column are 0.0 s
|
||
everywhere is indistinguishable from a campaign whose detector was never wired
|
||
up — `sitesim.broadcast_onset` exists for the same reason. So the selftest
|
||
drives a genuinely **wedged** unit through the same loop and refuses to proceed
|
||
unless the shipped `delinquent()` names it for longer than the shipped hold
|
||
(it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because
|
||
that arm passes.
|
||
|
||
What it does, unchanged from the restatement it replaces: build two
|
||
`plantsim.Inverter`s from `INVERTER_A` / `INVERTER_B`; close the loop with the
|
||
add-on's own `compute()` on the SITE sum (`Controller.site_batt_w`) and
|
||
`arbiter.allocate_record()` over per-unit envelopes of `±min(max_w, rating)`;
|
||
put each share on its own wire through `Inverter.command()`, honouring a
|
||
configurable **write stagger** so B's number can land up to four cycles after
|
||
A's (the serialised write path); advance `Site.settle()` one control cycle; and
|
||
record, per unit, the command standing *before* the write and that unit's 0x0518
|
||
power. Then grade each cycle with the **shipped** `delivery.delinquent()` and
|
||
take the **longest run of consecutive cycles** on which it names anybody — not a
|
||
count of naming samples, because the window is cleared by any cycle that does
|
||
not.
|
||
|
||
⚠️ **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
|
||
wire. A write that has been issued but has not landed yet is standing as far as
|
||
this add-on is concerned, which is the whole of the write-stagger axis. Grading
|
||
against the plant's latch instead reads a number no add-on has, and it makes
|
||
both the stagger *and a wedged unit* invisible: a unit whose writes never arrive
|
||
shows a standing command of 0 W, is never `asked`, and is never named. That is
|
||
not a conservative harness, it is a harness that cannot fail — and the rebuild
|
||
did exactly this on its first run, which is how the falsifiability arm earned
|
||
its place above.
|
||
|
||
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
|
||
trees. Run it with `python3 -B` and purge.
|
||
|
||
## 2. The card's discriminator, measured — and it does not work
|
||
|
||
### 2.1 It fires on the benign case, and no (N, X) can stop it
|
||
|
||
`Plant.limit()` returns 0 W for a discharge command into a pack at its own
|
||
floor. A wedged unit that never receives its command also produces 0 W. The two
|
||
are not merely similar:
|
||
|
||
> **Load 2000 W, equal weights, 2000 W per-unit cap, 120 control cycles.
|
||
> The two arms are IDENTICAL on all 120 cycles** in (grid, site target, each
|
||
> unit's share, each unit's measured power), to the last emitted watt. Both
|
||
> report `reason == "saturated-freeze"` on **116 of the 120** cycles (the other
|
||
> four are `slew-limited`, before it engages on cycle 4), both hold |grid error|
|
||
> over 300 W on **all 120** and settle at **1200 W**, and `circulating_w` is
|
||
> **0.0 W** in both.
|
||
|
||
A predicate whose inputs are *saturation duration* and *|grid error|* reads the
|
||
same number on both arms at every cycle. There is no N and no X. And *"a
|
||
legitimately saturated site — cold pack at its floor — does **not** alarm"* is
|
||
this card's own acceptance criterion.
|
||
|
||
`test_delivery.py` part 4 asserts that identity on the add-on's own end-to-end
|
||
fixture, so it goes red if it ever stops being true.
|
||
|
||
### 2.2 It is silent on the headline case, because a healthy sibling erases it
|
||
|
||
The standing grid error is exactly the thing a working sibling removes.
|
||
|
||
> **Load 800 W, equal weights, 2000 W per-unit cap, B wedged, 200 cycles.**
|
||
> Over the last 30 cycles the loop is saturation-frozen on **30 of 30** — so
|
||
> "saturation persisting past N" is true for every N — while the worst
|
||
> **|grid error| in that window is 30 W**. B is standing at a **+770 W**
|
||
> discharge share and delivering **0 W**.
|
||
|
||
Any X above 30 W leaves the card's predicate silent for the whole run, on the
|
||
exact fault it was written for. The card's own sentence — *"a healthy sibling
|
||
regulating alone, the site short by up to one unit's rating"* — describes the
|
||
case, and the grid error is the one number that cannot report it.
|
||
|
||
### 2.3 What that leaves
|
||
|
||
The derivation instruction stands and is honoured in §3. What could not be
|
||
derived was a threshold on the wrong quantity. Both failures are written into
|
||
`delivery.py`'s docstring as well, because the next person to read the card will
|
||
propose the same predicate.
|
||
|
||
## 3. The quantity that does work
|
||
|
||
Per unit, for a share that asks it to **discharge**:
|
||
|
||
```
|
||
short_by(i) = share_i − measured_i # watts that did not arrive
|
||
```
|
||
|
||
and the reduction that makes it readable:
|
||
|
||
> a unit is **delinquent** while it is short by more than `saturation_w` with
|
||
> **no account for it**, and the set of units in that state is a **proper
|
||
> subset** of the units that were asked to discharge.
|
||
|
||
### 3.1 The account — the only thing that separates a wedged unit from a cold pack
|
||
|
||
§2.1 says there is nothing in the power domain. There is exactly one non-power
|
||
fact this add-on holds about a pack: **its own state of charge against its own
|
||
`bms_soc_floor`**, which SAFETY-32 made a required per-entry key. `account()`
|
||
asks it through **`peakguard.discharge_permitted()`** — public since SAFETY-32
|
||
precisely so that this question has one answer — and writes its own sentence,
|
||
because that function's wording names the *peak guard's* floor and would be a
|
||
false statement here.
|
||
|
||
Three things account for a shortfall, and each is a state already being handled:
|
||
|
||
| account | why it is not this fault |
|
||
|---|---|
|
||
| the unit is not live | `live_units()` has already dropped it from the split and the action log says so — firing inside that is the SAFETY-15/19 failure |
|
||
| the unit is parked by a sign trip | it is commanded 0 W on purpose; not delivering is compliance |
|
||
| `discharge_permitted(soc, bms_soc_floor)` | the pack is at or below its own cut-out and physically cannot |
|
||
|
||
⚠️ **The floor passed in is `bms_soc_floor` alone, NOT
|
||
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`.** That
|
||
conjunction is right for the peak guard and wrong here: `peak_soc_floor` bounds
|
||
a money-saving discharge only (SAFETY-25), ordinary self-consumption discharges
|
||
straight past it, and using it would **explain away** a wedged unit sitting
|
||
anywhere between the two floors.
|
||
|
||
⚠️ **Every unknown accounts for the shortfall**, i.e. fails toward silence —
|
||
the opposite direction to `discharge_permitted()`'s own caller, and the right
|
||
one here: there it withholds a discharge, here it withholds an **alarm**. The
|
||
cost is bounded, because the same absence stops the site through `inputs_ok`
|
||
(an unreadable SoC) or is refused by the roster schema (an unstated floor).
|
||
|
||
### 3.2 The proper subset — what only exists at N ≥ 2
|
||
|
||
A derate this add-on cannot see — thermal, grid-code, a DC-side restriction —
|
||
hits **every** unit that was asked. One box that has stopped listening does not.
|
||
So if every asked unit is short and unexplained, this module says **nothing**:
|
||
it has no instrument that tells that from a plant which simply cannot deliver,
|
||
and reporting it anyway would be the saturation-shaped alarm produced by the
|
||
guard written to replace it.
|
||
|
||
**At N = 1 the subset is never proper, for every input**, so the check is inert
|
||
there — not by a roster guard, but by the algebra, the same way
|
||
`circulation.circulating_w()` is identically zero at N ≤ 1. That is AC 5, and
|
||
§3.3's control arm measures it rather than asserting it.
|
||
|
||
### 3.3 The window — the one number this card chose
|
||
|
||
**2640 closed-loop runs at a roster of two in which NOTHING is faulty** — every
|
||
unit executes the share the allocator gave it — over 6 gains × 5 slews, the
|
||
whole range `config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w`
|
||
`int(0,5000)` (0 = follow the rail), across three families:
|
||
|
||
* **1050 load reversals** — 7 sizes (±300 W … ±5000 W) × 5 write staggers;
|
||
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes;
|
||
* **750 weighted reversals** — 5 splits (0.5, 0.7, 0.9, 0.99, 0.1) × 5 staggers
|
||
on a ±2200 W reversal. ⚠️ **This family did not exist in SAFETY-30's campaign
|
||
and it is the one that dominates here**, for a mechanical reason: an
|
||
asymmetric split gives one unit a much larger step than its sibling, so the
|
||
big one is still ramping while the small one has settled — which is precisely
|
||
the *proper subset* this check keys on.
|
||
|
||
Every run is executed a second time at a **roster of one** on the same stimulus.
|
||
**The N=1 arm is the control**, and it is what separates *"a property of the
|
||
check"* from *"a property of the second inverter"*.
|
||
|
||
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
|
||
threshold. **ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not
|
||
committed — SUPERSEDED, and kept here because a record that quietly replaced its
|
||
own numbers would be worth less than one that shows both:**
|
||
|
||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|
||
|---:|---:|---:|---:|---:|
|
||
| 100 W | 40.0 s (608) | 0.0 s (0) | 75.0 s (576) | **0.0 s (0)** |
|
||
| 250 W | 55.0 s (490) | 0.0 s (0) | 60.0 s (480) | **0.0 s (0)** |
|
||
| **500 W** | **20.0 s (206)** | **0.0 s (0)** | **30.0 s (368)** | **0.0 s (0)** |
|
||
| 700 W | 20.0 s (125) | 0.0 s (0) | 20.0 s (174) | **0.0 s (0)** |
|
||
| 1000 W | 15.0 s (70) | 0.0 s (0) | 15.0 s (111) | **0.0 s (0)** |
|
||
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
|
||
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
|
||
|
||
#### ⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE
|
||
|
||
`admin/goodwe:sim/delivery_window.py --campaign`, same three families, same
|
||
counts, same thresholds, the shipped `delinquent()`:
|
||
|
||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640 runs)** |
|
||
|---:|---:|---:|---:|---:|
|
||
| 100 W | 120.0 s (770) | 0.0 s (0) | 165.0 s (685) | **0.0 s (0)** |
|
||
| 250 W | 65.0 s (687) | 0.0 s (0) | 100.0 s (590) | **0.0 s (0)** |
|
||
| **500 W** | **30.0 s (460)** | **0.0 s (0)** | **35.0 s (403)** | **0.0 s (0)** |
|
||
| 700 W | 25.0 s (342) | 0.0 s (0) | 30.0 s (318) | **0.0 s (0)** |
|
||
| 1000 W | 15.0 s (4) | 0.0 s (0) | 25.0 s (131) | **0.0 s (0)** |
|
||
| 1500 W | 0.0 s (0) | 0.0 s (0) | 20.0 s (43) | **0.0 s (0)** |
|
||
| 2000 W | 0.0 s (0) | 0.0 s (0) | 0.0 s (0) | **0.0 s (0)** |
|
||
|
||
**The floor is 35.0 s, not 30.0 s** — weighted family, and the worst single run
|
||
is `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W reversal. The
|
||
argmax is printed with the table on purpose (see below): a worst case sitting on
|
||
an edge of the grid would be a number about the grid.
|
||
|
||
⚠️ **AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the
|
||
argmax's other axes held fixed:**
|
||
|
||
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
|
||
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
|
||
0.10 -> 35.0 s
|
||
|
||
Strictly increasing in stagger and maximal at 4, the largest modelled stagger;
|
||
and maximal at the most asymmetric split. It is interior only in `gain` (2nd of
|
||
6) and `slew_w` (3rd of 5).
|
||
|
||
⚠️ **SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF
|
||
THE MODELLED SERIALISED-WRITE STAGGER.** That stagger is declared MODELLED at
|
||
`sim/delivery_window.py:60-64` — it is a term of this figure, not a detail of
|
||
it. A site whose two writes land in the same cycle does not produce this episode
|
||
at all.
|
||
|
||
This does not move `delivery_hold_s`: 185 s must cover the worst case the
|
||
modelled site can produce, and a stagger of four cycles is inside what a real
|
||
two-inverter write path can do. But a reader entitled to ask "is 35.0 s about
|
||
the plant or about the grid?" deserves the answer, and the answer is: **partly
|
||
about the grid, and the grid's shape is the record's own, fixed by §3.3 before
|
||
this rebuild ran.** Found in review of TEST-21; the PR body had claimed the
|
||
argmax was interior, and it is not.
|
||
|
||
**WHAT REPRODUCED, EXACTLY:**
|
||
|
||
* the **N=1 control column is 0.0 s at every threshold**, in every run. That is
|
||
§3.2's algebra measured, and it is the claim the compatibility promise rests
|
||
on. (This rebuild ran the control arm over all three families — 2640 runs —
|
||
where the original ran 1890, i.e. the two families whose stimulus is not
|
||
weighted. The weighted stimulus at a roster of one is the same stimulus with
|
||
inert weights, and it too reports nothing at any threshold.)
|
||
* the **flapping family is 0.0 s at every threshold**, in all 840 runs. The
|
||
proper-subset rule really does defeat the family that defeated SAFETY-30's
|
||
site window.
|
||
* **45.0 s at twice A's lag**, to the cycle. See the B-lag table below.
|
||
|
||
**WHAT DID NOT:**
|
||
|
||
* the headline. **35.0 s against a recorded 30.0 s** — one control cycle worse,
|
||
and 20.0 s → 30.0 s on the reversal family, two cycles worse.
|
||
* **the non-monotonicity.** §3.3 finding 3 read the original 55.0 s at 250 W
|
||
against 40.0 s at 100 W as a property of the reduction. **This rebuild's
|
||
curve is monotone decreasing on both N=2 families.** The *mechanism* finding 3
|
||
describes is real and is still the reason not to read either table as a
|
||
sensitivity curve — lowering the threshold really can turn a proper subset
|
||
into the whole roster and break the run — but **this grid does not exhibit
|
||
it**, so the claim now rests on the mechanism alone and no longer on a
|
||
measurement. That is a weaker claim than the one this record made, and it is
|
||
written down as weaker.
|
||
* the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s /
|
||
75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure
|
||
of how much of the original table was a property of axes nobody wrote down.
|
||
|
||
⚠️ **WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED.** §3.3 fixes the
|
||
*counts* of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4
|
||
amplitudes; 5 staggers; and the 5 splits, which it lists) and the *ranges*
|
||
(`config.yaml`'s `gain` `float(0.05,1.0)` and `slew_w` `int(0,5000)`). It does
|
||
**not** fix the individual values, the run length, or the per-unit cap. The
|
||
rebuild spread them over the admissible ranges and **prints every one of them
|
||
above its own table**, so the caveat travels with the number instead of living
|
||
in somebody's memory. The residual disagreement — one to two control cycles at
|
||
the shipped threshold — is inside the sensitivity to that choice; the
|
||
low-threshold rows are not, and are reported as not.
|
||
|
||
⚠️ **AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED.** The one substantive
|
||
correction made during the rebuild was made *against* the doc's number, not
|
||
towards it, and it is recorded in §1: grading on the plant's latch rather than
|
||
the controller's standing command produced a harness that could not name even a
|
||
wedged unit. Fixing that made the numbers **larger**. Nothing else was tuned.
|
||
|
||
Findings 1-3 below are the original campaign's, kept with finding 3 now
|
||
qualified by the re-run above:
|
||
|
||
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
|
||
threshold.** That is the compatibility promise measured, not argued.
|
||
2. **The flapping family is zero everywhere too** — and it is the family that
|
||
defeated SAFETY-30's site window (980 s at two inverters, 1000 s at one, no
|
||
window inside the billing quarter surviving it). It cannot touch this check,
|
||
because a common stimulus makes both units short at the same time and the
|
||
subset stops being proper. That is the proper-subset rule earning its place
|
||
on a case it was not designed for.
|
||
3. **The curve is NOT monotone** — 55.0 s at 250 W against 40.0 s at 100 W —
|
||
and that is a property of the reduction rather than noise: lowering the
|
||
threshold puts MORE units into the short set, which can turn a proper subset
|
||
into the whole roster and **break** the run. Both the numerator and the
|
||
denominator move. Do not read this table as a sensitivity curve.
|
||
|
||
#### The watts side is not a new option — it is `Tuning.saturation_w`
|
||
|
||
`main.py` hands the check `self.tuning.saturation_w` (500 W) and there is no
|
||
`delivery_w` key anywhere. That is a derivation, not a saving:
|
||
|
||
* it is already **the gap at which `compute()` itself concludes the inverter is
|
||
not following** and freezes the integrator. Below it the shipped control law
|
||
calls the divergence ordinary tracking, and an alarm under a threshold the
|
||
control law calls normal is an alarm about nothing;
|
||
* `saturation_cycles` (3) consecutive cycles over that same gap are needed
|
||
before the freeze engages, so **every cycle this counts is a cycle the freeze
|
||
has already seen**. This is a reduction over the freeze's input, not a second
|
||
opinion about it;
|
||
* CFG-03 already tracks three places a default has to be kept in step. A fourth
|
||
copy could drift, and the drift that matters has a direction: an operator who
|
||
raises `saturation_w` has told the loop that a bigger gap is ordinary
|
||
tracking, and a check that stayed put would then alarm on it.
|
||
|
||
**Rejected:** a `delivery_w` option, for the three reasons above. A margin above
|
||
`saturation_w` (the way `circulation_w` sits 1.75× above its knee) was also
|
||
rejected: there is no knee here — the table above is not monotone — so a margin
|
||
would be a preference, and all the margin this check needs is in the window.
|
||
|
||
#### `delivery_hold_s: 185` — schema `int(30,900)`
|
||
|
||
Four bounds, and the value clears all four.
|
||
|
||
* **lower, from the rig — 35.0 s** (TEST-21, 2026-09-07; **the original record
|
||
said 30.0 s and it did not reproduce**). The worst continuous legitimate
|
||
episode at the shipped threshold across all 2640 runs — weighted family, worst
|
||
single run `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W
|
||
reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with
|
||
`admin/goodwe:sim/delivery_window.py --campaign` (§1).
|
||
⚠️ **THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE.**
|
||
At 30.0 s it coincided with the firmware's veto below and a reader could not
|
||
tell which of the two was doing the work. At 35.0 s the rig's number is on its
|
||
own, above the firmware's — so **the rig is what stops `delivery_hold_s` going
|
||
low**, and the schema's `int(30,900)` floor no longer sits above the worst
|
||
legitimate episode. An operator who sets it to the schema minimum of 30 s is
|
||
now **below** a measured legitimate episode and will get false reports; the
|
||
option is marked *derived, do not change* and this is what that marking is
|
||
for. Raising the schema floor to 35 is a change to a shipped option's
|
||
contract and is left to its own card rather than smuggled in here.
|
||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||
executing its share for less than the firmware's own veto window is a *link*
|
||
fault the add-on already answers by dropping it from the split. Reporting
|
||
inside that window would be a second, louder alarm about a state already
|
||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||
* **margin, from inverter B not existing.** ⚠️ **THIS ROW IS A SENSITIVITY
|
||
STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL
|
||
WORDING ("MEASURED, not felt") OVERSTATED IT.** Inverter B is not installed
|
||
(~2026-09-12; NOTES.md §1.1b). The campaign's B is **A copied**, `measured=
|
||
False`, so the 35.0 s above is **two identical machines** and every row below
|
||
is a *what-if*: what a slower second unit would cost this window. **No row
|
||
here is B's lag.** What it is genuinely evidence for is the SHAPE of the
|
||
margin — which term the window has to be sized against — and that is what it
|
||
is used for. Sweeping a *different* second unit through the weighted family
|
||
(750 runs each). Second column is the original record, third is TEST-21's
|
||
re-run with the committed harness (`--blag`):
|
||
|
||
| B lag | B ramp | 2026-09-06 (uncommitted) | **TEST-21 re-run** | runs naming |
|
||
|---:|---:|---:|---:|---:|
|
||
| 10.0 s (A) | 800 W/s (A) | 30.0 s | **35.0 s** | 403/750 |
|
||
| 20.0 s | 800 W/s | 45.0 s | **45.0 s** | 454/750 |
|
||
| 10.0 s | 400 W/s | 30.0 s | **35.0 s** | 411/750 |
|
||
| 20.0 s | 400 W/s | 45.0 s | **50.0 s** | 454/750 |
|
||
| 40.0 s | 200 W/s | 90.0 s | **75.0 s** | 478/750 |
|
||
|
||
**The LAG moves it roughly linearly and the RAMP barely moves it**, which is
|
||
the original finding and it survives — with one correction: *"the RAMP does
|
||
not move it at all"* is too strong. Halving the ramp is worth 0 s at A's lag
|
||
and **+5 s at twice it** (45.0 → 50.0), so the ramp is a second-order term
|
||
rather than no term. The shortfall lasts as long as the unit's first-order lag
|
||
holds it more than `saturation_w` below its share; at these step sizes the
|
||
ramp is rarely the binding one. So the number to carry margin against is still
|
||
B's lag — and a hypothetical unit **four times slower than A** produces
|
||
**75.0 s**, where the original record said 90.0 s. ⚠️ Note the direction: this
|
||
row moved **in the margin's favour**, while the floor above moved against it.
|
||
Neither was adjusted to suit the other.
|
||
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
|
||
|
||
**185 s** is thirty-seven control cycles. Against the TEST-21 re-run it is
|
||
**5.29×** the measured worst legitimate episode (35.0 s), **2.47×** the
|
||
hypothetical four-times-slower second unit (75.0 s), **6.2×** the firmware veto
|
||
(30 s) and **4.9×** inside the quarter (900 s). ⚠️ Deliberately **not 180** —
|
||
that is SAFETY-02's site hold, and a reader must not be able to think this was
|
||
carried across — and not 150 or 200.
|
||
|
||
⚠️ **185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE.** The
|
||
record claimed 6.2× the measured worst; the re-run makes that **5.29×**. All
|
||
four bounds still clear, none of them narrowly, and the *ordering* of the
|
||
argument is unchanged: the rig's floor is the binding one, the quarter is the
|
||
ceiling, and 185 sits between them with margin at both ends. So the value is
|
||
**not changed** — a shipped safety constant is not moved because its margin
|
||
went from 6.2× to 5.3× while every bound still holds, and moving it would
|
||
invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS
|
||
changed is the claim: this record no longer says 6.2×, and no longer says 30.0
|
||
s, because neither is what the harness produces.
|
||
|
||
**What it costs, stated rather than hidden:** up to 185 s of a site running
|
||
short by one unit's share before anybody is told. Against a fault that ran
|
||
6 h 25 m unnoticed, and against a guard that takes **no action**, the window
|
||
buys its margin with reporting latency and nothing else.
|
||
|
||
## 4. It reports. It does not act — and the strongest argument is for acting
|
||
|
||
Card requirement 3 asks which, and why. The answer is recorded with the
|
||
evidence **against** it first, because "alarm-only" is the weaker-looking answer.
|
||
|
||
**Command it harder — no.** HW-01 settles it: the power stage executed its last
|
||
instruction for 6 h 25 m through every command this add-on sent. A command is
|
||
not a remedy for a box that is not listening.
|
||
|
||
**Stop the site, as SAFETY-31 does — no**, and the asymmetry with SAFETY-31 is
|
||
exact. Circulating power is stopped because *this controller is one of the two
|
||
participants*: removing our half halves the loop and un-hides the residue at a
|
||
meter that was netting it to zero. Here this controller is **not** a participant,
|
||
nothing is netting, and the sibling is the only thing still delivering. Parking
|
||
it takes the site from "short by one unit" to "short by all of them".
|
||
|
||
**Re-allocate its share to the sibling — no, and NOT because the loop already
|
||
does it.** That comfortable answer is false, and the rig says so. Dropping the
|
||
wedged unit from `live` — the mechanism `live_units()` already uses for a dead
|
||
link — is a real improvement at loads the site cannot cover on one unit's
|
||
*share*:
|
||
|
||
| B wedged, per-unit cap 2000 W | kept in the split | dropped from the split |
|
||
|---|---|---|
|
||
| load 800 W | settles inside 100 W from cycle 6 | from cycle 8 |
|
||
| load 1500 W | **800 W delivered, 700 W error, NEVER inside 100 W in 150 cycles** | 1500 W delivered, inside 100 W from cycle 14 |
|
||
| load 2000 W | **800 W delivered, 1200 W error, never settles** | 2000 W delivered, inside 100 W from cycle 8 |
|
||
|
||
The mechanism is the saturation freeze doing its job on a site comparison that a
|
||
non-delivering unit has poisoned: `|site target − site batt|` stays over
|
||
`saturation_w` forever, so the target cannot rise and the sibling's *share*
|
||
never grows. That is not three cycles of latency; it is the difference between a
|
||
site that recovers and one that does not.
|
||
|
||
**What refuses it anyway** is that the only re-allocation this add-on can
|
||
express is **all or nothing**. There is no way to say *"give the sibling the
|
||
watts that did not arrive and leave this unit the ones that did"*. And a unit
|
||
delivering **part** of its share is short by more than `saturation_w` too, with
|
||
nothing here to explain it — so this detector names it, and cannot tell a 50 %
|
||
derate from a wedged box, because no instrument this add-on has can:
|
||
|
||
| B derated to 50 %, load 3000 W, cap 2000 W | site delivers | B contributes | grid error |
|
||
|---|---:|---:|---:|
|
||
| kept in the split | 2400 W | 800 W | 600 W |
|
||
| **dropped** | **2000 W** | **0 W** | **1000 W — 400 W worse** |
|
||
|
||
**SAFETY-42 names B in that run.** So the remedy would make the site worse, in a
|
||
case the detector cannot rule out, on a live control law, driven by a brand-new
|
||
alarm. A **partial** re-allocation is what would make acting safe, and the
|
||
allocator has no way to express it today. That is a card, and this section is
|
||
its evidence.
|
||
|
||
`test_delivery.py` part 5 asserts the decision as a **counterfactual** rather
|
||
than by reading the code: the same fixture run with the report tripping and with
|
||
it never tripping produces byte-identical grid, site target, shares and measured
|
||
power on every cycle.
|
||
|
||
## 5. Requirement 4: the saturation freeze is untouched
|
||
|
||
`control.py` is not modified by this card, at all. The freeze still shapes the
|
||
loop; this reports on it. `test_delivery.py` part 6 asserts what each
|
||
neighbouring guard concludes on the same fixture — SAFETY-30 per-unit silent,
|
||
SAFETY-30 site silent, SAFETY-31 identically zero, the freeze firing at the
|
||
same counter on **both** arms — which is the honest version of "every existing
|
||
guard is silent", including the one that is not.
|
||
|
||
## 6. Surviving a restart (SAFETY-34)
|
||
|
||
`persistent_notification` is **in memory**. Home Assistant restarts, the card is
|
||
gone, and the fault runs on — precisely how the 2026-09-02 outage went unseen
|
||
for 6 h 25 m with the detector correct throughout. It matters more here than
|
||
anywhere else in this add-on, because SAFETY-02 and SAFETY-31 at least leave a
|
||
**stopped** site for somebody to notice; this one leaves a site that looks like
|
||
it is working.
|
||
|
||
* durable half: `unit/<name>/delivery_tripped` / `delivery_reason`, namespaced
|
||
per unit for `store.unit_key()`'s reason, written **reason first**.
|
||
* visible half: `_delivery_announced`, a per-**process** set of unit names,
|
||
deliberately not persisted. A restart empties the set and keeps the latch, so
|
||
the alarm is re-raised on the first cycle of every process — **one code path**
|
||
for a fresh trip and for a restart into an existing one, `check_circulating()`'s
|
||
argument.
|
||
* **no flat mirror**, where SAFETY-02 has one. `store.py`'s rule 2 keeps the flat
|
||
keys so a rollback still finds its latch, and the version being rolled back to
|
||
drives exactly one actuator — a roster at which this check is inert.
|
||
* one **notification id per unit**, built from the same `unit_slugs()` TEL-07
|
||
announces the MQTT entities under. Two wedged inverters must produce two cards,
|
||
not one replacing the other.
|
||
* it **latches**, and not for SAFETY-02's reason. Nothing here removes its own
|
||
evidence, because nothing here changes what is commanded. It latches so that a
|
||
unit which stopped delivering for minutes and then resumed cannot erase its own
|
||
record — the intermittent fault is the one an operator most needs told about.
|
||
Cleared by the same **Start** press as the others.
|
||
* the status row is `ok: False, warn: True` — this list's **caution**. Every
|
||
other latched row means the site is at 0 W and a red cross is the truth; this
|
||
one means the site is still running and quietly short, and a red cross would
|
||
say the controller had stopped.
|
||
|
||
## 7. What this does NOT catch, said plainly
|
||
|
||
* **Charge shares are not graded.** The account gate needs a bound this
|
||
controller reads in the commanded direction. There is one for discharge
|
||
(`bms_soc_floor`, required of every roster entry since SAFETY-32) and **none**
|
||
for charge. A pack in constant-voltage taper legitimately takes hundreds of
|
||
watts less than it is told for tens of minutes, and arming that direction would
|
||
fire on the taper of every §12 maintenance charge. The same fault in the other
|
||
direction wants the same treatment; what it needs first is a per-pack charge
|
||
ceiling this add-on does not have. **That is a card.**
|
||
* **Every unit wedged at once is not caught.** The subset is not proper, and
|
||
there is no instrument here that separates that from a plant which simply
|
||
cannot deliver. The site is short at the meter in that case, which is where the
|
||
existing guards can see it.
|
||
* **A pack that refuses above its configured floor is reported.** That is
|
||
deliberate: either the unit has stopped delivering, or `bms_soc_floor` does not
|
||
describe that battery. The notification says both, in those words.
|
||
* **A unit with a share of 0 W is not graded**, and neither is it a peer. With
|
||
only one unit asked to discharge there is nothing to compare against.
|
||
|
||
## 8. Mutation ledger
|
||
|
||
Every run on a **freshly copied tree of its own, `__pycache__` purged and the
|
||
count printed, mounted `:ro`, `python3 -B`** — MUTATION.md's rule, followed
|
||
because the first SAFETY-30 battery reused one directory, a container mounted it
|
||
mid-copy, read a missing `config.yaml` as a failing check, and the whole run was
|
||
discarded. **The baseline is green** (23/23) or nothing below is a result. Byte
|
||
deltas measured, not estimated; the `+0 B` ones are marked, since a
|
||
size-preserving edit is what TEST-15 turned into a false survivor once already.
|
||
|
||
| # | file | mutation | Δ | result |
|
||
|---|---|---|---|---|
|
||
| M1 | `delivery.py` | the SoC account removed — a cold pack at its floor alarms | −31 B | **RED** — `test_delivery.py`. This is the card |
|
||
| M2 | `delivery.py` | the proper-subset rule removed — a whole-site cause names every unit | −34 B | **RED** |
|
||
| **M3** | `delivery.py` | **the `len(asked) < 2` fast path relaxed to `< 1`** | **+0 B** | **SURVIVOR — and an EQUIVALENT MUTANT.** See below |
|
||
| M4 | `delivery.py` | the strict bound relaxed to `>=` — a gap the control law calls tracking is named | +1 B | **RED** |
|
||
| **M5** | `delivery.py` | `share <= 0` relaxed to `share == 0` — charge shares graded | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then **RED**. See below |
|
||
| M6 | `delivery.py` | `delivery_hold_s` 185 → a round 300 | **+0 B** | **RED** — the CFG-03 pin |
|
||
| M7 | `main.py` | the watts threshold no longer `Tuning.saturation_w` (×4) | +6 B | **RED** |
|
||
| M8 | `main.py` | the latch never persisted — nothing survives a restart | −35 B | **RED** |
|
||
| M9 | `main.py` | `_delivery_announced` never set — one notification per cycle | −34 B | **RED** |
|
||
| M10 | `main.py` | `peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor` used instead of the pack's own cut-out | +151 B | **RED** — the between-the-floors fixture |
|
||
| **M11** | `main.py` | **`inv.is_live(now)[0]` replaced by a literal `True`** | −15 B | **SURVIVOR at review — GAP CLOSED**, then **RED**. See below |
|
||
| M12 | `inverter.py` | the latch not restored from the store on start-up | −46 B | **RED** |
|
||
|
||
**11 of 12 red. One survivor, argued rather than left.**
|
||
|
||
### M5 was a real gap, and the two obvious checks did not close it
|
||
|
||
`test_delivery.py` already asserted `short_by(-2000, 0) is None` and
|
||
`short_by(-2000, -100) is None` — and **both stayed green under the mutant**.
|
||
For a charge share that is *under*-delivered, `share − measured` is negative and
|
||
the `gap > 0` test answers `None` anyway, so those two inputs cannot separate
|
||
`share <= 0` from `share == 0`. The one input that can is a unit moving **more**
|
||
than its charge share — `-2000 W` told, `-3000 W` measured, `share − measured` =
|
||
`+1000 W` — which the mutant reports as a shortfall. It is not one; it is
|
||
something else driving that pack, which is SAFETY-30's and SAFETY-31's question.
|
||
The check now uses that input and the mutant is red.
|
||
|
||
This is MUTATION.md's third failure mode in miniature: **the assertion ran, the
|
||
sentence was true, and the data could not reach the failure it named.**
|
||
|
||
### M11 was a wiring gap the pure checks structurally could not see
|
||
|
||
`account(live=False, …)` was already graded — as a **function**. What was not
|
||
graded was that `cycle()` actually *asks* `inv.is_live(now)`, and replacing that
|
||
call with a literal `True` left every check green, because **no fixture had a
|
||
unit whose writes were being rejected**. A dropped unit still has its last
|
||
*accepted* command standing on the wire (`write_setpoint` leaves `last_write`
|
||
alone on a rejected write) and still measures nothing, so it looks exactly like a
|
||
wedged one to every number this check reads — and reporting it would be the
|
||
second, louder alarm about a state already being handled that this module takes
|
||
its 30 s floor from. `test_delivery.py` part 6 now builds that unit (with
|
||
`rejected_since` back-dated past `LINK_STALE_S`, because the fixture runs in two
|
||
seconds and the timer is `test_inverter.py`'s to grade) and the mutant is red.
|
||
|
||
### M3 is recorded as a survivor because it is one — and it is EQUIVALENT
|
||
|
||
`len(asked) < 2` → `len(asked) < 1`. With **one** asked unit, `unaccounted` is a
|
||
subset of a one-element set, so it is either empty — `not unaccounted` returns
|
||
`{}` — or equal to `asked` — `len(unaccounted) == len(asked)` returns `{}`.
|
||
There is no third case, and with **zero** asked units both spellings return `{}`
|
||
as well. **No input separates them**, so no check can, and one written to try
|
||
would be a check about nothing.
|
||
|
||
⚠️ **And finding it corrected the module's own docstring, which is the more
|
||
useful half.** That paragraph claimed `len(asked) < 2` was *what makes N=1
|
||
inert*. It is not: the **proper-subset rule** is, and the line is a fast path and
|
||
a signpost. A false attribution of the mechanism in the file's own prose is
|
||
exactly what this repository keeps finding, and a mutation that survives is how
|
||
it surfaced. The line stays — it tells a reader at a glance that a one-inverter
|
||
install never reaches any of this — with the equivalence written down beside it,
|
||
the way `circulation.circulating_w()` records its own `max(0.0, …)`.
|
||
|
||
### Not separately mutated, and said rather than left implicit
|
||
|
||
`account()`'s `parked` branch is graded as a function (part 1) but its **wiring**
|
||
— `parked=inv.sign_wd.tripped` — is not mutated on its own. Part 6's reversed-unit
|
||
fixture exercises the path end to end and asserts the account it produces, which
|
||
is weaker than a mutation and is stated as such.
|
||
|
||
## 9. Round 2 — the thresholds this check reads, and which of them are pinned at a **changed** config
|
||
|
||
Everything in §8 mutated the *logic*. This section asks a narrower question that
|
||
§8 never asked, and it found two live survivors of the whole 23-file suite:
|
||
|
||
> for each number `main.py` hands this reduction, is the check still red when
|
||
> that number is replaced by a **literal equal to the value the fixtures use**?
|
||
|
||
That is not pedantry about style. Two of these numbers are **options**, and
|
||
`delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in `config.yaml`
|
||
both make a promise about them *in shipped, operator-readable prose*: there is no
|
||
fourth copy of a default to keep in step, and no way for the two to drift,
|
||
because an operator who raises `saturation_w` has told the loop that a bigger gap
|
||
is ordinary tracking. A literal at the call site keeps every fixture green and
|
||
removes exactly that promise — and the promise is why the design was chosen over
|
||
a fourth option key.
|
||
|
||
### The enumeration
|
||
|
||
| # | what the call site reads | mutated to | Δ | before round 2 | now |
|
||
|---|---|---|---|---|---|
|
||
| R5 | `self.tuning.saturation_w` | `250.0` | −19 B | **SURVIVED all 23 suites** | red — part 4b(a) |
|
||
| R6 | `bms_floor=inv.bms_soc_floor` | `10.0` | −13 B | **SURVIVED all 23 suites** | red — part 4b(b) |
|
||
| R7 | `arm(self.delivery_cfg)` | `arm(DeliveryConfig())` | −1 B | red | red — part 4 already ran at a hold of 1 s, not the shipped 185 s |
|
||
| R8 | `inv.is_live(now)[0]` | `inv.is_live(now, 3600.0)[0]` | +8 B | red | red — part 6's link-dead fixture |
|
||
| R9 | `bms_floor=inv.bms_soc_floor` | the **conjoined** floor, `max(pack cut-out, site peak_soc_floor)` | +105 B | red | red — and now red **twice**: the pre-existing between-floors check at shipped floors, and part 4b(b)'s shallow arm at a cut-out that is not shipped |
|
||
|
||
**R7 and R8 were already pinned across a changed setting, and R5 and R6 were
|
||
not.** The difference is entirely in the fixtures, not in the code:
|
||
`delivery_hold_s` had to be moved off 185 s for any of this file to run inside a
|
||
two-second test, and part 6 had to back-date `rejected_since` past
|
||
`LINK_STALE_S` to build a dropped unit at all. Nothing ever forced
|
||
`saturation_w` off 500 or a `bms_soc_floor` off 10 — so those two sat at the
|
||
shipped value in every fixture, and a literal equal to the shipped value is
|
||
invisible to a fixture that only ever uses the shipped value. **That is this
|
||
repository's recurring defect stated in one line: a fixture whose values cannot
|
||
reach the failure it names.**
|
||
|
||
`LINK_STALE_S` (R8) is the one number here with **no option behind it** — it is a
|
||
module constant in `inverter.py` and no test reassigns it — so "pinned across a
|
||
changed config" is not available for it, and is not claimed. What part 6 pins is
|
||
that *widening* the window changes the answer. Spelling `is_live(now, 30.0)` at
|
||
the call site would be an equivalent mutant today and would only drift if the
|
||
constant moved, which is a source change and not a config change.
|
||
|
||
### Why R5 and R6 are not equivalent mutants — the inputs that separate them
|
||
|
||
Both are separating inputs found by asking where the shipped answer and the
|
||
literal's answer can differ, and both are configs the shipped schema accepts.
|
||
|
||
**R5, watts.** Two copies of inverter A, B wedged, `saturation_w: 1800` (inside
|
||
its own schema `int(100,2000)`), load 1600 W. B's standing share and its
|
||
shortfall then land strictly **between** `250.0` and the configured 1800 W:
|
||
|
||
* head → `named = {}` — the operator raised the threshold and this check moved
|
||
with them, which is the whole promise;
|
||
* R5 → `named = {inverter-b}` — an alarm at a gap the control law has been told
|
||
is ordinary tracking.
|
||
|
||
At the shipped `saturation_w: 500` **both** name it, which is why every fixture
|
||
in part 4 is blind: their gaps are all above both thresholds.
|
||
|
||
**R6, percent.** The literal is invisible because `bms_soc_floor: 10` is what
|
||
`config.yaml`'s own `inverters:` example states and what every fixture in
|
||
`test_delivery.py` used. Two arms, on either side of it, both inside the schema
|
||
`int(0,50)` and both `validate.py` rule-8-valid:
|
||
|
||
* a pack at **15 %** against its own **25 %** cut-out is genuinely cold. Head
|
||
stays silent; R6 grades it against 10 %, finds it "permitted", and **names an
|
||
empty pack** — the crying-wolf failure this card exists to remove.
|
||
* a pack at **8 %** against its own **5 %** cut-out is genuinely wedged. Head
|
||
names it; R6 grades it against 10 %, calls it a cold pack, and **loses the
|
||
fault**.
|
||
|
||
One arm on each side is what leaves no constant at all: a single deeper arm
|
||
kills `10.0` but not `50.0`, a single shallower arm kills `10.0` but not `0.0`.
|
||
The two arms are byte-identical in the power domain on every cycle — the
|
||
configured cut-out is the only thing that differs, which is §3.1's claim made
|
||
again at cut-outs that are not the shipped one.
|
||
|
||
### What is still not pinned this way, said rather than left implicit
|
||
|
||
`parked=inv.sign_wd.tripped` and `standing[inv.name]`/`inv.batt` are wiring, not
|
||
thresholds: there is no "value the fixture happens to use" for a boolean or for a
|
||
measurement, so this question does not apply to them, and §8's last note already
|
||
records that the `parked` wiring is graded end-to-end rather than by mutation.
|