SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist #59

Merged
admin merged 3 commits from SAFETY-42 into release/1.0 2026-09-06 19:43:18 +02:00
Owner

SAFETY-42 — a unit that stops delivering its share

A unit standing at a non-zero share and producing nothing is seen by nothing:
SAFETY-30's per-unit gate needs opposition on both sides of 250 W and a unit
at 0 W is not opposing, it is absent; the site sum still agrees with the
meter in direction; circulating power is exactly zero when one unit does nothing;
and the only remaining trace is saturated-freeze, which clamp.py:93-96
documents as "an alarm an operator learns to ignore". It is HW-01's shape,
which ran 6 h 25 m with all three layers spectators.

⚠️ Read this first: the card's own discriminator was built, measured, and does not work

SAFETY-42 asks for "saturation persisting past N while |grid error| > X, both
derived"
. That was built on the two-master rig before anything else, and it
fails in both directions. Neither failure is a matter of better N and X.

It fires on the benign case, and the two cases are the same trace.
plantsim.Plant.limit() returns 0 W for a discharge command into a pack at its
own floor; a wedged unit produces 0 W too. Load 2000 W, equal weights, 2000 W
per-unit cap, 120 cycles:

the two arms are IDENTICAL on all 120 cycles in grid, site target, every
share and every measured watt. Both report saturated-freeze on 116 of them,
both hold |grid error| over 300 W on all 120 and settle at 1200 W, and
circulating_w is 0.0 W in both.

The predicate's inputs do not differ, so nothing computed from them can. And "a
cold pack at its floor must not alarm"
is the card's own AC 2.

It is silent on the headline case, because a healthy sibling erases the grid
error.
Load 800 W, same config, B wedged, 200 cycles: over the last 30 the loop
is saturation-frozen on 30 of 30 while the worst |grid error| is 30 W,
with B standing at +770 W and delivering 0 W. Any X above 30 W is silent
for the whole run on the exact fault the card is about.

test_delivery.py part 4 asserts that identity on the add-on's own end-to-end
fixture, so it goes red if it stops being true.

What replaces it

Per unit, for a discharge share, share − measured; made readable by a
reduction that only exists at N ≥ 2:

a unit is named while it is short by more than saturation_w with no
account for it
, and the units in that state are a proper subset of the
units asked to discharge.

  • The account keeps the cold pack out, and is the only thing that can — the
    discriminator has to be a non-power fact, and there is exactly one: the
    pack's own SoC against its own bms_soc_floor, asked through
    peakguard.discharge_permitted() rather than transcribed. ⚠️ bms_soc_floor
    alone, not pack_floor_pct()'s conjunction with peak_soc_floor — that
    floor bounds a money-saving discharge only (SAFETY-25), and using it would
    explain away a wedged unit anywhere between the two. There is a fixture for
    exactly that band.
  • The proper subset keeps a whole-site cause out — a derate this add-on
    cannot see hits every unit, one wedged box does not — and it is what makes
    this inert at N = 1, by algebra rather than by a roster guard.

It reports. It does not act — and the strongest argument is for acting

Dropping the wedged unit from live genuinely recovers a site the shipped path
does not. Measured, B wedged, 2000 W per-unit cap:

load kept in the split dropped
1500 W 800 W delivered, 700 W error, never inside 100 W in 150 cycles 1500 W, inside 100 W from cycle 14
2000 W 800 W delivered, 1200 W error, never settles 2000 W, inside 100 W from cycle 8

The freeze pins the site target on a comparison the missing unit has poisoned,
so the sibling's share never grows. That is not latency; it is the difference
between recovering and not.

What refuses it anyway: the only re-allocation this add-on can express is
all or nothing. A unit derated to 50 % is short by more than saturation_w
with nothing to explain it, so this detector names it too — and cannot tell it
from a wedged box, because no instrument here can:

B derated to 50 %, load 3000 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

So the remedy would make the site worse in a case the detector cannot rule out,
on a live control law. A partial re-allocation is what would make acting
safe, and the allocator cannot express it. That is a card. Part 5 asserts the
decision as a counterfactual: the same fixture with the report tripping and
never tripping is byte-identical in every commanded and measured watt.

⚠️ control.py is not modified. Requirement 4 met by not touching it.

The numbers, derived

The watts side is not a new option. main.py hands the check
Tuning.saturation_w — already the gap at which compute() itself concludes the
inverter is not following, and one the freeze needs 3 consecutive cycles of
before it engages. No fourth CFG-03 copy, and it cannot drift.

delivery_hold_s: 185 — schema int(30,900). From 2640 closed-loop
two-inverter runs in which nothing is faulty (6 gains × 5 slews over the whole
schema range; 1050 load reversals × 5 write staggers, 840 flapping loads, 750
asymmetric splits
— a family SAFETY-30's campaign did not have, and the one
that dominates here), plus 1890 runs at a roster of one as the control:

threshold reversals flapping weighted N=1 control
250 W 55.0 s 0.0 s 60.0 s 0.0 s
500 W 20.0 s 0.0 s 30.0 s 0.0 s
1000 W 15.0 s 0.0 s 15.0 s 0.0 s
  • the N=1 column is zero at every threshold in all 1890 runs — the
    compatibility promise measured, not argued;
  • the flapping column is zero too, and that is the family that defeated
    SAFETY-30's site window (980 s at N=2, 1000 s at N=1). A common stimulus makes
    both units short at once and the subset stops being proper;
  • ⚠️ the curve is not monotone (55.0 s at 250 W against 40.0 s at 100 W).
    Lowering the threshold can turn a proper subset into the whole roster and
    break the run. Do not read it as a sensitivity curve.

⚠️ The margin is measured, not felt, because inverter B does not physically
exist.
plantsim.INVERTER_B is inverter A copied, measured=False
(NOTES.md §1.1b), so every two-inverter figure here is two copies of A.
Sweeping a different second unit through the weighted family:

B lag B ramp worst legitimate
10.0 s (A) 800 W/s (A) 30.0 s
20.0 s 800 W/s 45.0 s
10.0 s 400 W/s 30.0 s
40.0 s 200 W/s 90.0 s

The ramp does not move it at all; the lag moves it roughly linearly. So
185 s is 6.2× the measured worst, 2.06× a unit four times slower than A,
6.2× LINK_STALE_S and 4.9× inside the 900 s quarter. Deliberately not
180 — that is SAFETY-02's site hold.

SAFETY-34

unit/<name>/delivery_tripped + delivery_reason in /data/state.json,
reason first; _delivery_announced is per-process, so the notification is
re-raised on the first cycle after every restart, through one code path with
the fresh trip. One notification id per unit. No flat mirror — a rollback
lands on a version that drives one actuator, where this check is inert. Cleared
by the same Start press. The status row is warn, not a failure: the
controller has not stopped.

⚠️ Schema change

delivery_hold_s joins options: and schema:. The Supervisor keys the built
image by version:, so this needs a bump before it ships — the 0.7.0
heading in the CHANGELOG makes the same point about bms_soc_floor. The key is
additive and defaulted, so an existing options blob stays valid; what a missing
bump costs is that nobody's Supervisor re-reads the schema. I have not bumped
it
— that is a release decision.

Stated gaps

  • Charge shares are not graded. There is no configured charge ceiling
    anywhere in this add-on, so a pack in constant-voltage taper — legitimately
    hundreds of watts short for tens of minutes — could not be accounted for, and
    arming that direction would fire on the taper of every maintenance charge.
    Same fault, other direction; it needs a per-pack ceiling first. That is a
    card.
  • Every unit wedged at once is not caught — the subset is not proper, and
    nothing here separates that from a plant that simply cannot deliver.
  • A pack that refuses above its configured floor IS reported, deliberately:
    either the unit stopped delivering or bms_soc_floor does not describe that
    battery. The notification says both.
  • A partial re-allocation would let this act safely. The allocator cannot
    express it today.

Verification

The suite: 23/23 green, in the CI image (goodwe-addon-ci:latest), tree
copied to a plain path and bind-mounted :ro, __pycache__ purged first (2
directories found and removed, 0 remaining) and python3 -B throughout —
TEST-15's discipline, where the purge is the load-bearing half. CI on the
first commit was green
(checks / unit (pull_request)).

Mutation battery: 12 mutations, one FRESHLY COPIED TREE EACH — MUTATION.md's
rule, followed because the first SAFETY-30 battery reused one directory, a
container mounted it mid-copy and the whole run was discarded. Each tree purged
(5 artefacts, printed), mounted :ro, python3 -B, baseline green (23/23)
before anything below, byte deltas measured:

# mutation Δ result
M1 the SoC account removed — a cold pack at its floor alarms −31 B RED — this is the card
M2 the proper-subset rule removed −34 B RED
M3 len(asked) < 2 → < 1 +0 B SURVIVOR — equivalent mutant, argued
M4 the strict bound relaxed to >= +1 B RED
M5 share <= 0 → share == 0 (charge shares graded) +0 B survivor → GAP CLOSED → RED
M6 delivery_hold_s 185 → a round 300 +0 B RED — the CFG-03 pin
M7 the watts threshold no longer Tuning.saturation_w +6 B RED
M8 the latch never persisted −35 B RED
M9 _delivery_announced never set — one alarm per cycle −34 B RED
M10 peak_soc_floor's conjunction used instead of the pack's cut-out +151 B RED
M11 inv.is_live(now)[0] → a literal True −15 B survivor → GAP CLOSED → RED
M12 the latch not restored from the store −46 B RED

11 of 12 red; the twelfth is argued, not left.

M5 was a real gap and the two obvious checks did not close it.
short_by(-2000, 0) is None and short_by(-2000, -100) is None both stayed
green under it: for a charge share that is under-delivered share − measured
is negative and the gap > 0 test answers None anyway. The one input that
separates the two spellings is a unit moving more than its charge share
(−2000 W told, −3000 W measured → +1000 W), which the mutant calls a
shortfall. 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 graded as a function; nothing graded that
cycle() asks. A unit whose writes are being rejected still has its last
accepted command standing (write_setpoint leaves last_write alone on a
rejected write) and still measures nothing — indistinguishable from a wedged one
to every number this check reads. There is a fixture for it now.

M3 is an equivalent mutant, and finding it corrected the module's own prose.
delinquent()'s docstring claimed len(asked) < 2 was what makes N=1 inert. It
is not — the proper-subset rule is, and that line is a fast path. A false
attribution of the mechanism in the file's own prose is exactly what this
repository keeps finding, and a survivor is how it surfaced.

Not separately mutated and said so rather than left implicit: account()'s
parked branch is graded as a function; its wiring is exercised end to end
by part 6's reversed-unit fixture, which is weaker than a mutation.

⚠️ Nothing here has touched the rig, the ESP32, the inverter, the witness Pi or
live Home Assistant. Every two-inverter figure in this PR, in delivery.py and
in DECISIONS/SAFETY-42-unit-delivery.md is two copies of inverter A.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## SAFETY-42 — a unit that stops delivering its share A unit standing at a non-zero share and producing nothing is seen by **nothing**: SAFETY-30's per-unit gate needs **opposition** on both sides of 250 W and a unit at 0 W is not opposing, it is **absent**; the site sum still agrees with the meter in direction; circulating power is exactly zero when one unit does nothing; and the only remaining trace is `saturated-freeze`, which `clamp.py:93-96` documents as *"an alarm an operator learns to ignore"*. It is HW-01's shape, which ran **6 h 25 m** with all three layers spectators. ## ⚠️ Read this first: the card's own discriminator was built, measured, and does not work SAFETY-42 asks for *"saturation persisting past N while `|grid error|` > X, both derived"*. That was built on the two-master rig **before** anything else, and it fails in **both** directions. Neither failure is a matter of better N and X. **It fires on the benign case, and the two cases are the same trace.** `plantsim.Plant.limit()` returns 0 W for a discharge command into a pack at its own floor; a wedged unit produces 0 W too. Load 2000 W, equal weights, 2000 W per-unit cap, 120 cycles: > **the two arms are IDENTICAL on all 120 cycles** in grid, site target, every > share and every measured watt. Both report `saturated-freeze` on 116 of them, > both hold `|grid error|` over 300 W on all 120 and settle at **1200 W**, and > `circulating_w` is **0.0 W** in both. The predicate's inputs do not differ, so nothing computed from them can. And *"a cold pack at its floor must **not** alarm"* is the card's own AC 2. **It is silent on the headline case, because a healthy sibling erases the grid error.** Load 800 W, same config, B wedged, 200 cycles: over the last 30 the loop is saturation-frozen on **30 of 30** while the worst `|grid error|` is **30 W**, with B standing at **+770 W** and delivering **0 W**. Any X above 30 W is silent for the whole run on the exact fault the card is about. `test_delivery.py` part 4 asserts that identity on the add-on's own end-to-end fixture, so it goes red if it stops being true. ## What replaces it Per unit, for a **discharge** share, `share − measured`; made readable by a reduction that only exists at N ≥ 2: > a unit is named while it is short by more than `saturation_w` with **no > account for it**, and the units in that state are a **proper subset** of the > units asked to discharge. * **The account** keeps the cold pack out, and is the only thing that can — the discriminator has to be a **non-power** fact, and there is exactly one: the pack's own SoC against its own `bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than transcribed. ⚠️ `bms_soc_floor` **alone**, not `pack_floor_pct()`'s conjunction with `peak_soc_floor` — that floor bounds a money-saving discharge only (SAFETY-25), and using it would explain away a wedged unit anywhere between the two. There is a fixture for exactly that band. * **The proper subset** keeps a whole-site cause out — a derate this add-on cannot see hits every unit, one wedged box does not — and it is what makes this **inert at N = 1**, by algebra rather than by a roster guard. ## It reports. It does not act — and the strongest argument is for acting Dropping the wedged unit from `live` genuinely recovers a site the shipped path does not. Measured, B wedged, 2000 W per-unit cap: | load | kept in the split | dropped | |---|---|---| | 1500 W | 800 W delivered, 700 W error, **never inside 100 W in 150 cycles** | 1500 W, inside 100 W from cycle 14 | | 2000 W | 800 W delivered, 1200 W error, **never settles** | 2000 W, inside 100 W from cycle 8 | The freeze pins the site target on a comparison the missing unit has poisoned, so the sibling's **share** never grows. That is not latency; it is the difference between recovering and not. **What refuses it anyway:** the only re-allocation this add-on can express is **all or nothing**. A unit derated to 50 % is short by more than `saturation_w` with nothing to explain it, so this detector names it too — and cannot tell it from a wedged box, because no instrument here can: | B derated to 50 %, load 3000 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** | So the remedy would make the site worse in a case the detector cannot rule out, on a live control law. A **partial** re-allocation is what would make acting safe, and the allocator cannot express it. That is a card. Part 5 asserts the decision as a **counterfactual**: the same fixture with the report tripping and never tripping is byte-identical in every commanded and measured watt. ⚠️ **`control.py` is not modified.** Requirement 4 met by not touching it. ## The numbers, derived **The watts side is not a new option.** `main.py` hands the check `Tuning.saturation_w` — already the gap at which `compute()` itself concludes the inverter is not following, and one the freeze needs 3 consecutive cycles of before it engages. No fourth CFG-03 copy, and it cannot drift. **`delivery_hold_s: 185`** — schema `int(30,900)`. From **2640** closed-loop two-inverter runs in which nothing is faulty (6 gains × 5 slews over the whole schema range; 1050 load reversals × 5 write staggers, 840 flapping loads, **750 asymmetric splits** — a family SAFETY-30's campaign did not have, and the one that dominates here), plus **1890 runs at a roster of one as the control**: | threshold | reversals | flapping | weighted | **N=1 control** | |---:|---:|---:|---:|---:| | 250 W | 55.0 s | 0.0 s | 60.0 s | **0.0 s** | | **500 W** | **20.0 s** | **0.0 s** | **30.0 s** | **0.0 s** | | 1000 W | 15.0 s | 0.0 s | 15.0 s | **0.0 s** | * the **N=1 column is zero at every threshold in all 1890 runs** — the compatibility promise measured, not argued; * the **flapping column is zero too**, and that is the family that defeated SAFETY-30's site window (980 s at N=2, 1000 s at N=1). A common stimulus makes both units short at once and the subset stops being proper; * ⚠️ **the curve is not monotone** (55.0 s at 250 W against 40.0 s at 100 W). Lowering the threshold can turn a proper subset into the whole roster and **break** the run. Do not read it as a sensitivity curve. ⚠️ **The margin is measured, not felt, because inverter B does not physically exist.** `plantsim.INVERTER_B` is inverter A **copied**, `measured=False` (NOTES.md §1.1b), so every two-inverter figure here is **two copies of A**. Sweeping a different second unit through the weighted family: | B lag | B ramp | worst legitimate | |---:|---:|---:| | 10.0 s (A) | 800 W/s (A) | **30.0 s** | | 20.0 s | 800 W/s | 45.0 s | | 10.0 s | 400 W/s | **30.0 s** | | 40.0 s | 200 W/s | **90.0 s** | The **ramp does not move it at all**; the **lag** moves it roughly linearly. So 185 s is **6.2×** the measured worst, **2.06×** a unit four times slower than A, **6.2×** `LINK_STALE_S` and **4.9×** inside the 900 s quarter. Deliberately not 180 — that is SAFETY-02's site hold. ## SAFETY-34 `unit/<name>/delivery_tripped` + `delivery_reason` in `/data/state.json`, reason first; `_delivery_announced` is per-**process**, so the notification is re-raised on the first cycle after every restart, through **one code path** with the fresh trip. **One notification id per unit.** **No flat mirror** — a rollback lands on a version that drives one actuator, where this check is inert. Cleared by the same **Start** press. The status row is `warn`, not a failure: the controller has **not** stopped. ## ⚠️ Schema change `delivery_hold_s` joins `options:` and `schema:`. The Supervisor keys the built image by `version:`, so **this needs a bump before it ships** — the 0.7.0 heading in the CHANGELOG makes the same point about `bms_soc_floor`. The key is additive and defaulted, so an existing options blob stays valid; what a missing bump costs is that nobody's Supervisor re-reads the schema. **I have not bumped it** — that is a release decision. ## Stated gaps * **Charge shares are not graded.** There is no configured charge ceiling anywhere in this add-on, so a pack in constant-voltage taper — legitimately hundreds of watts short for tens of minutes — could not be accounted for, and arming that direction would fire on the taper of every maintenance charge. Same fault, other direction; it needs a per-pack ceiling first. **That is a card.** * **Every unit wedged at once is not caught** — the subset is not proper, and nothing here separates that from a plant that simply cannot deliver. * **A pack that refuses above its configured floor IS reported**, deliberately: either the unit stopped delivering or `bms_soc_floor` does not describe that battery. The notification says both. * **A partial re-allocation** would let this act safely. The allocator cannot express it today. ## Verification **The suite: 23/23 green**, in the CI image (`goodwe-addon-ci:latest`), tree copied to a plain path and bind-mounted `:ro`, `__pycache__` purged first (2 directories found and removed, 0 remaining) and `python3 -B` throughout — TEST-15's discipline, where the purge is the load-bearing half. **CI on the first commit was green** (`checks / unit (pull_request)`). **Mutation battery: 12 mutations, one FRESHLY COPIED TREE EACH** — MUTATION.md's rule, followed because the first SAFETY-30 battery reused one directory, a container mounted it mid-copy and the whole run was discarded. Each tree purged (5 artefacts, printed), mounted `:ro`, `python3 -B`, baseline green (23/23) before anything below, byte deltas **measured**: | # | mutation | Δ | result | |---|---|---|---| | M1 | the SoC account removed — a cold pack at its floor alarms | −31 B | **RED** — this is the card | | M2 | the proper-subset rule removed | −34 B | **RED** | | **M3** | `len(asked) < 2` → `< 1` | **+0 B** | **SURVIVOR — equivalent mutant, argued** | | M4 | the strict bound relaxed to `>=` | +1 B | **RED** | | **M5** | `share <= 0` → `share == 0` (charge shares graded) | **+0 B** | **survivor → GAP CLOSED → RED** | | M6 | `delivery_hold_s` 185 → a round 300 | **+0 B** | **RED** — the CFG-03 pin | | M7 | the watts threshold no longer `Tuning.saturation_w` | +6 B | **RED** | | M8 | the latch never persisted | −35 B | **RED** | | M9 | `_delivery_announced` never set — one alarm per cycle | −34 B | **RED** | | M10 | `peak_soc_floor`'s conjunction used instead of the pack's cut-out | +151 B | **RED** | | **M11** | `inv.is_live(now)[0]` → a literal `True` | −15 B | **survivor → GAP CLOSED → RED** | | M12 | the latch not restored from the store | −46 B | **RED** | **11 of 12 red; the twelfth is argued, not left.** **M5 was a real gap and the two obvious checks did not close it.** `short_by(-2000, 0) is None` and `short_by(-2000, -100) is None` both stayed green under it: for a charge share that is *under*-delivered `share − measured` is negative and the `gap > 0` test answers `None` anyway. The one input that separates the two spellings is a unit moving **more** than its charge share (−2000 W told, −3000 W measured → `+1000 W`), which the mutant calls a shortfall. 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 graded as a *function*; nothing graded that `cycle()` **asks**. A unit whose writes are being rejected still has its last *accepted* command standing (`write_setpoint` leaves `last_write` alone on a rejected write) and still measures nothing — indistinguishable from a wedged one to every number this check reads. There is a fixture for it now. **M3 is an equivalent mutant, and finding it corrected the module's own prose.** `delinquent()`'s docstring claimed `len(asked) < 2` was what makes N=1 inert. It is not — the **proper-subset rule** is, and that line is a fast path. A false attribution of the mechanism in the file's own prose is exactly what this repository keeps finding, and a survivor is how it surfaced. Not separately mutated and said so rather than left implicit: `account()`'s `parked` branch is graded as a function; its **wiring** is exercised end to end by part 6's reversed-unit fixture, which is weaker than a mutation. ⚠️ Nothing here has touched the rig, the ESP32, the inverter, the witness Pi or live Home Assistant. Every two-inverter figure in this PR, in `delivery.py` and in `DECISIONS/SAFETY-42-unit-delivery.md` is **two copies of inverter A**. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

Round 2 — the blocker is closed, and asking the same question of the rest found a second one

Pushed f9d0606. Nothing in round 1 is weakened: the cold-pack-vs-wedged-unit
discrimination, the derived N and X, the named alarm that survives a restart and
the untouched saturation freeze are all as they were. app/ changes in this
commit are comments only — verified by diffing the hunk with comment lines
stripped, which comes back empty.

The blocker: self.tuning.saturation_w → 250.0 is dead

Reproduced first, exactly as reported. On eae9e23, with the tree mounted :ro
in the CI image, python3 -B, and __pycache__ purged and counted at 0 before
every run:

self.tuning.saturation_w → 250.0 −19 B SURVIVED all 23 suites, rc=0

Not a test-file survivor — a whole-suite one.

The separating input is the one you named. Two-inverter fixture (two copies of
inverter A
— plantsim.INVERTER_B, measured=False; inverter B does not
physically exist), B wedged, saturation_w: 1800, which is inside its own
shipped schema int(100,2000) and is read back out of config.yaml by the check
rather than typed into it, and a load of 1600 W. B's shortfall then lands
strictly between the literal and the configured value — the test asserts that
ordering and prints the two gaps rather than writing either number down:

  • head → named = ()
  • mut-R5 → named = ('inverter-b',)

At the shipped saturation_w: 500 both name it, which is precisely why every
existing fixture is blind — part 4's gaps are all above both thresholds.

The fix is a pair of arms rather than one, and that matters: the same fault runs
at the shipped saturation_w and at the raised one, and the two must answer
differently. That kills not just 250.0 but every literal, because no
single constant can be both below the shipped arm's gap and above the raised
arm's. The call site is pinned to the configured value, not to a number that
happens to agree with it at the default.

Why it mattered beyond the score, restated where a reader will hit it

delivery.MIN_W_IS_SATURATION_W and the delivery_hold_s block in config.yaml
promise, in shipped operator-readable prose, that there is no fourth copy of a
threshold to keep in step and no way for the two to drift. That sentence is only
true while the call site passes the configured value. Both files now say so
and name where it is checked, and the call site itself carries the same warning
with both survivors written out beside it.

⚠️ The same question, asked of every other number this check reads — and one more survivor

# what the call site reads mutated to Δ before 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), both arms
R7 arm(self.delivery_cfg) arm(DeliveryConfig()) −1 B red red
R8 inv.is_live(now)[0] inv.is_live(now, 3600.0)[0] +8 B red red
R9 bms_floor=inv.bms_soc_floor the conjoined floor, max(pack cut-out, site peak_soc_floor) +105 B red red twice

Pinned across a CHANGED config: R7 and R8. Not pinned, and now fixed: R5 and
R6.
The difference is entirely in the fixtures, not the code — delivery_hold_s
had to be moved off the shipped 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.

R8 is the honest exception and is not claimed as pinned-across-config.
LINK_STALE_S is a module constant in inverter.py with no option behind it and
no test reassigns it, so there is no config to change. What part 6 pins is that
widening the window changes the answer; spelling is_live(now, 30.0) would be
an equivalent mutant today and would only drift on a source change.

R6 is the second survivor, and it is the same defect wearing a different hat.
bms_soc_floor: 10 is the number config.yaml's own inverters: example states
and the number every fixture in test_delivery.py used, so bms_floor=10.0 at
the call site answered identically everywhere. It is badly non-equivalent in both
directions on configs the shipped schema int(0,50) accepts:

  • a pack at 15 % against its own 25 % cut-out is genuinely cold — the
    literal 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 — the
    literal calls it a cold pack and loses the fault.

One arm on each side of 10 is what leaves no constant at all (a deeper arm alone
kills 10.0 but not 50.0; a shallower one alone kills 10.0 but not 0.0).
Both arms are validate.py rule-8-valid — peak_soc_floor above every unit's
cut-out — so neither is a state only a refused config could produce, and the two
arms are byte-identical in the power domain on every cycle, so the configured
cut-out is demonstrably the only thing that differs. That last one also re-kills
R9 at a floor pair that is not the shipped one, where the pre-existing
between-floors check only ever ran at 10 % and 20 %.

Not thresholds, so the question does not apply: parked=inv.sign_wd.tripped,
standing[inv.name], inv.batt. Recorded rather than left implicit.

How it was run

CI image goodwe-addon-ci, tree rsynced to a plain path under /home/homelab
and mounted :ro, python3 -B, __pycache__ purged and the artefact count
printed as 0 before every run, each mutant's main.py sha256 and byte delta
printed after re-reading it back off disk (TEST-15/MUTATION.md). No hardware, no
rig, no deploy.

Final head: 23/23 test files PASS, and 0 bytecode files written during the
run. DECISIONS/SAFETY-42-unit-delivery.md §9 carries the enumeration, the
separating inputs and the deltas; CHANGELOG.md carries the operator-facing
line.

Not merging — over to you.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## Round 2 — the blocker is closed, and asking the same question of the rest found a second one Pushed `f9d0606`. Nothing in round 1 is weakened: the cold-pack-vs-wedged-unit discrimination, the derived N and X, the named alarm that survives a restart and the untouched saturation freeze are all as they were. `app/` changes in this commit are **comments only** — verified by diffing the hunk with comment lines stripped, which comes back empty. ### The blocker: `self.tuning.saturation_w` → `250.0` is dead Reproduced first, exactly as reported. On `eae9e23`, with the tree mounted `:ro` in the CI image, `python3 -B`, and `__pycache__` purged and counted at 0 before every run: | | | | |---|---|---| | `self.tuning.saturation_w` → `250.0` | −19 B | **SURVIVED all 23 suites**, `rc=0` | Not a test-file survivor — a whole-suite one. The separating input is the one you named. Two-inverter fixture (**two copies of inverter A** — `plantsim.INVERTER_B`, `measured=False`; inverter B does not physically exist), B wedged, `saturation_w: 1800`, which is inside its own shipped schema `int(100,2000)` and is read back out of `config.yaml` by the check rather than typed into it, and a load of 1600 W. B's shortfall then lands **strictly between** the literal and the configured value — the test asserts that ordering and prints the two gaps rather than writing either number down: * head → `named = ()` * mut-R5 → `named = ('inverter-b',)` At the shipped `saturation_w: 500` **both** name it, which is precisely why every existing fixture is blind — part 4's gaps are all above both thresholds. The fix is a pair of arms rather than one, and that matters: the same fault runs at the shipped `saturation_w` and at the raised one, and the two must answer **differently**. That kills not just `250.0` but *every* literal, because no single constant can be both below the shipped arm's gap and above the raised arm's. The call site is pinned to the configured value, not to a number that happens to agree with it at the default. ### Why it mattered beyond the score, restated where a reader will hit it `delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in `config.yaml` promise, in shipped operator-readable prose, that there is no fourth copy of a threshold to keep in step and no way for the two to drift. That sentence is only true while the call site passes the **configured** value. Both files now say so and name where it is checked, and the call site itself carries the same warning with both survivors written out beside it. ### ⚠️ The same question, asked of every other number this check reads — and one more survivor | # | what the call site reads | mutated to | Δ | before | 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), both arms | | R7 | `arm(self.delivery_cfg)` | `arm(DeliveryConfig())` | −1 B | red | red | | R8 | `inv.is_live(now)[0]` | `inv.is_live(now, 3600.0)[0]` | +8 B | red | red | | R9 | `bms_floor=inv.bms_soc_floor` | the **conjoined** floor, `max(pack cut-out, site `peak_soc_floor`)` | +105 B | red | red **twice** | **Pinned across a CHANGED config: R7 and R8. Not pinned, and now fixed: R5 and R6.** The difference is entirely in the fixtures, not the code — `delivery_hold_s` *had* to be moved off the shipped 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. **R8 is the honest exception and is not claimed as pinned-across-config.** `LINK_STALE_S` is a module constant in `inverter.py` with no option behind it and no test reassigns it, so there is no config to change. What part 6 pins is that *widening* the window changes the answer; spelling `is_live(now, 30.0)` would be an equivalent mutant today and would only drift on a source change. **R6 is the second survivor, and it is the same defect wearing a different hat.** `bms_soc_floor: 10` is the number `config.yaml`'s own `inverters:` example states *and* the number every fixture in `test_delivery.py` used, so `bms_floor=10.0` at the call site answered identically everywhere. It is badly non-equivalent in both directions on configs the shipped schema `int(0,50)` accepts: * a pack at **15 %** against its own **25 %** cut-out is genuinely cold — the literal 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 — the literal calls it a cold pack and **loses the fault**. One arm on each side of 10 is what leaves no constant at all (a deeper arm alone kills `10.0` but not `50.0`; a shallower one alone kills `10.0` but not `0.0`). Both arms are `validate.py` rule-8-valid — `peak_soc_floor` above every unit's cut-out — so neither is a state only a refused config could produce, and the two arms are **byte-identical in the power domain on every cycle**, so the configured cut-out is demonstrably the only thing that differs. That last one also re-kills R9 at a floor pair that is not the shipped one, where the pre-existing between-floors check only ever ran at 10 % and 20 %. Not thresholds, so the question does not apply: `parked=inv.sign_wd.tripped`, `standing[inv.name]`, `inv.batt`. Recorded rather than left implicit. ### How it was run CI image `goodwe-addon-ci`, tree rsynced to a plain path under `/home/homelab` and mounted `:ro`, `python3 -B`, `__pycache__` purged and the artefact count **printed as 0** before every run, each mutant's `main.py` sha256 and byte delta printed after re-reading it back off disk (TEST-15/MUTATION.md). No hardware, no rig, no deploy. Final head: **23/23 test files PASS**, and 0 bytecode files written during the run. `DECISIONS/SAFETY-42-unit-delivery.md` §9 carries the enumeration, the separating inputs and the deltas; `CHANGELOG.md` carries the operator-facing line. Not merging — over to you. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin force-pushed SAFETY-42 from f9d0606747 to 0581648c73 2026-09-06 19:25:12 +02:00 Compare
admin added 3 commits 2026-09-06 19:32:00 +02:00
A unit standing at a non-zero share and producing nothing is seen by NOTHING.
SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a
unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the
meter in direction. Circulating power is exactly zero when one unit does
nothing. The only trace left is `reason == "saturated-freeze"`, which
clamp.py:93-96 documents as the alarm an operator learns to ignore. It is
HW-01's shape, which ran 6 h 25 m with all three layers spectators.

⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE
MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while
|grid error| > X, both derived". Run on the two-master rig it fails in BOTH
directions, and no (N, X) fixes either:

  FALSE POSITIVE  `plantsim.Plant.limit()` returns 0 W for a discharge command
      into a pack at its own floor, and a wedged unit produces 0 W too. Load
      2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE
      IDENTICAL ON ALL 120 in grid, site target, every share and every measured
      watt. Both saturated-freeze on 116 of them, both hold |grid error| over
      300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The
      predicate's inputs do not differ, so no threshold on them can differ. And
      "a cold pack at its floor must not alarm" is the card's own AC 2.

  FALSE NEGATIVE  the standing grid error is what a HEALTHY SIBLING ERASES.
      Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop
      is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing
      at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run
      on the exact fault the card describes.

WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable
because of a reduction that only exists at N>=2:

    a unit is named while it is short by more than `saturation_w` with NO
    ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units
    asked to discharge.

THE ACCOUNT keeps the cold pack out and is the only thing that can: the two
cases are identical in the power domain, so the discriminator has to be a
non-power fact, and there is exactly one - the pack's own SoC against its own
`bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than
transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with
`peak_soc_floor`: that floor bounds a money-saving discharge only and using it
would explain away a wedged unit anywhere between the two.

THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see
hits every unit; one box that stopped listening does not - and it is what makes
this INERT AT N=1, by algebra rather than by a roster guard.

IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping
the wedged unit from `live` genuinely recovers a site the shipped path does not:
at load 1500 W, kept in the split the site settles at 800 W with a 700 W error
and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the
sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What
refuses it is that the only re-allocation this add-on can express is
all-or-nothing: a unit derated to 50 % is short by more than saturation_w with
nothing to explain it, so this detector names it too, and at load 3000 W
dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE.
A partial re-allocation is what would make acting safe and the allocator cannot
express it. That is a card.

control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it.

NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so
there is no fourth CFG-03 copy and it cannot drift from the number the control
law itself calls "not following". `delivery_hold_s: 185` comes off 2640
closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over
the whole schema range, across load reversals, a flapping load and ASYMMETRIC
SPLITS - a family SAFETY-30's campaign did not have and the one that dominates
here) plus 1890 runs at a roster of ONE as the control:

    threshold   reversals   flapping   weighted   N=1 control
     * 500 W      20.0 s      0.0 s      30.0 s      0.0 s

The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping
column - the family that defeated SAFETY-30's site window - is zero too, because
a common stimulus makes both units short at once and the subset stops being
proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W):
lowering the threshold can turn a proper subset into the whole roster and BREAK
the run.

⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY
EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter
figure above is two copies of A. Sweeping a different second unit: the worst
legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and scales
with its LAG - 45.0 s at twice A's measured 10 s, 90.0 s at four times it.
185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x
LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is
SAFETY-02's site hold.

SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per
unit, re-raised on the first cycle of every process, cleared by the Start press.
No flat mirror: a rollback lands on a version that drives one actuator, where
this check is inert.

⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a
version bump before it ships. Additive and defaulted, so an existing options blob
stays valid.

STATED GAPS: charge shares are not graded (no configured charge ceiling exists,
so a constant-voltage taper cannot be explained and arming it would fire on every
maintenance charge); every unit wedged at once is not caught (the subset is not
proper and no instrument here separates that from a plant that cannot).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
The battery is 12 mutations, one freshly copied tree each, purged, `:ro`,
`python3 -B`, byte deltas measured. First round: 9 red, 3 survivors. All three
are now accounted for and the ledger is in DECISIONS §8.

M5 - `share <= 0` relaxed to `share == 0`, +0 B - WAS A REAL GAP AND THE TWO
OBVIOUS CHECKS DID NOT CLOSE IT. `short_by(-2000, 0) is None` and
`short_by(-2000, -100) is None` 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 neither input can separate the two spellings. The
one 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. That input is now the check, and M5 is RED.
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 - `inv.is_live(now)[0]` replaced by a literal `True`, -15 B - was a WIRING
gap the pure checks structurally could not see. `account(live=False, ...)` was
graded as a FUNCTION; nothing graded that `cycle()` asks the question, because no
fixture had a unit whose writes were being rejected. Such a 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 the allocator has already handled and logged.
`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. M11 is RED.

M3 - `len(asked) < 2` relaxed to `< 1`, +0 B - IS STILL A SURVIVOR, and it is an
EQUIVALENT MUTANT. 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 {}); with zero asked units both
spellings return {} as well. No input separates them, so no check can.

⚠️ AND FINDING IT CORRECTED THE MODULE'S OWN PROSE, WHICH IS THE MORE USEFUL
HALF. `delinquent()`'s docstring claimed `len(asked) < 2` was what makes N=1
inert. It is not - the PROPER-SUBSET rule is, and that 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, with the equivalence written down beside it the way
circulation.circulating_w() records its own max(0.0, ...).

Also recorded, rather than left implicit: `account()`'s `parked` branch is graded
as a function but its WIRING is not mutated on its own; part 6's reversed-unit
fixture exercises it end to end, which is weaker, and the ledger says so.

Baseline 23/23 green before and after. app/main.py is byte-identical to the
previous commit - both closures are a test and a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
THE BLOCKER. At the `delinquent()` call site, `self.tuning.saturation_w` ->
`250.0` (-19 B) was a survivor of all 23 suites. It is not an equivalent
mutant: two copies of inverter A, B wedged, `saturation_w: 1800` (inside its
own schema int(100,2000)) and a load of 1600 W put B's shortfall strictly
between the literal and the configured value - head names nobody, the mutant
names B. At the shipped 500 both name it, which is exactly why every fixture
in part 4 was blind: their gaps are all above both thresholds.

It matters beyond a mutation score. `delivery.py`'s MIN_W_IS_SATURATION_W note
and the `delivery_hold_s` block in config.yaml both promise an operator, in
shipped prose, that there is no fourth copy of a threshold to keep in step and
no way for the two to drift - raise `saturation_w` and this check moves with
you. A literal at the call site removes exactly that promise, silently, and
the promise is why the design was chosen over a `delivery_w` key.

ASKING THE SAME QUESTION OF EVERY OTHER NUMBER THIS CHECK READS FOUND A SECOND
SURVIVOR OF ALL 23 SUITES: `bms_floor=inv.bms_soc_floor` -> `10.0` (-13 B),
invisible because config.yaml's own example states 10 % and every fixture in
this file used it. Two arms bracket it: a pack at 15 % against its own 25 %
cut-out is cold and must stay unnamed (the literal names an empty pack), and a
pack at 8 % against its own 5 % cut-out is wedged and must be named (the
literal explains the fault away). One arm on each side leaves no constant at
all.

`delivery_hold_s` and `is_live()`'s window were ALREADY pinned - the fixtures
had to move them to run - and `LINK_STALE_S` has no option behind it, so
"pinned across a changed config" is not available for it and is not claimed.
DECISIONS section 9 enumerates all of it with the deltas and which check kills
which.

Verified in the CI image, tree mounted :ro, `python3 -B`, `__pycache__` purged
and counted before every run (TEST-15):
  R5 saturation_w -> 250.0                    -19 B  killed by part 4b(a)
  R6 bms_soc_floor -> 10.0                    -13 B  killed by part 4b(b), both arms
  R7 arm(self.delivery_cfg) -> DeliveryConfig()  -1 B  killed (already)
  R8 is_live(now) -> is_live(now, 3600.0)      +8 B  killed (already)
  R9 pack cut-out -> conjoined site floor    +105 B  killed twice
  head: 23/23 test files PASS, no bytecode written during the run.

No shipped behaviour changes: the only edits to app/ are comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin merged commit 464d398051 into release/1.0 2026-09-06 19:43:18 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#59