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.
## 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
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: 500both 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.
## 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
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.02026-09-06 19:43:18 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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, whichclamp.py:93-96documents 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, bothderived". 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 itsown floor; a wedged unit produces 0 W too. Load 2000 W, equal weights, 2000 W
per-unit cap, 120 cycles:
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.pypart 4 asserts that identity on the add-on's own end-to-endfixture, so it goes red if it stops being true.
What replaces it
Per unit, for a discharge share,
share − measured; made readable by areduction that only exists at N ≥ 2:
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 throughpeakguard.discharge_permitted()rather than transcribed. ⚠️bms_soc_flooralone, not
pack_floor_pct()'s conjunction withpeak_soc_floor— thatfloor 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.
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
livegenuinely recovers a site the shipped pathdoes not. Measured, B wedged, 2000 W per-unit cap:
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_wwith nothing to explain it, so this detector names it too — and cannot tell it
from a wedged box, because no instrument here can:
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.pyis not modified. Requirement 4 met by not touching it.The numbers, derived
The watts side is not a new option.
main.pyhands the checkTuning.saturation_w— already the gap at whichcompute()itself concludes theinverter 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— schemaint(30,900). From 2640 closed-looptwo-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:
compatibility promise measured, not argued;
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;
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_Bis 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:
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_Sand 4.9× inside the 900 s quarter. Deliberately not180 — that is SAFETY-02's site hold.
SAFETY-34
unit/<name>/delivery_tripped+delivery_reasonin/data/state.json,reason first;
_delivery_announcedis per-process, so the notification isre-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: thecontroller has not stopped.
⚠️ Schema change
delivery_hold_sjoinsoptions:andschema:. The Supervisor keys the builtimage by
version:, so this needs a bump before it ships — the 0.7.0heading in the CHANGELOG makes the same point about
bms_soc_floor. The key isadditive 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
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.
nothing here separates that from a plant that simply cannot deliver.
either the unit stopped delivering or
bms_soc_floordoes not describe thatbattery. The notification says both.
express it today.
Verification
The suite: 23/23 green, in the CI image (
goodwe-addon-ci:latest), treecopied to a plain path and bind-mounted
:ro,__pycache__purged first (2directories found and removed, 0 remaining) and
python3 -Bthroughout —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:
len(asked) < 2→< 1>=share <= 0→share == 0(charge shares graded)delivery_hold_s185 → a round 300Tuning.saturation_w_delivery_announcednever set — one alarm per cyclepeak_soc_floor's conjunction used instead of the pack's cut-outinv.is_live(now)[0]→ a literalTrue11 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 Noneandshort_by(-2000, -100) is Noneboth stayedgreen under it: for a charge share that is under-delivered
share − measuredis negative and the
gap > 0test answersNoneanyway. The one input thatseparates the two spellings is a unit moving more than its charge share
(−2000 W told, −3000 W measured →
+1000 W), which the mutant calls ashortfall. 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 thatcycle()asks. A unit whose writes are being rejected still has its lastaccepted command standing (
write_setpointleaveslast_writealone on arejected 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 claimedlen(asked) < 2was what makes N=1 inert. Itis 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()'sparkedbranch is graded as a function; its wiring is exercised end to endby 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.pyandin
DECISIONS/SAFETY-42-unit-delivery.mdis two copies of inverter A.🤖 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-unitdiscrimination, 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 thiscommit are comments only — verified by diffing the hunk with comment lines
stripped, which comes back empty.
The blocker:
self.tuning.saturation_w→250.0is deadReproduced first, exactly as reported. On
eae9e23, with the tree mounted:roin the CI image,
python3 -B, and__pycache__purged and counted at 0 beforeevery run:
self.tuning.saturation_w→250.0rc=0Not 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 notphysically exist), B wedged,
saturation_w: 1800, which is inside its ownshipped schema
int(100,2000)and is read back out ofconfig.yamlby the checkrather 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:
named = ()named = ('inverter-b',)At the shipped
saturation_w: 500both name it, which is precisely why everyexisting 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_wand at the raised one, and the two must answerdifferently. That kills not just
250.0but every literal, because nosingle 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_Wand thedelivery_hold_sblock inconfig.yamlpromise, 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
self.tuning.saturation_w250.0bms_floor=inv.bms_soc_floor10.0arm(self.delivery_cfg)arm(DeliveryConfig())inv.is_live(now)[0]inv.is_live(now, 3600.0)[0]bms_floor=inv.bms_soc_floormax(pack cut-out, sitepeak_soc_floor)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_shad 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_sincepastLINK_STALE_Sto build a dropped unit at all. Nothing ever forcedsaturation_woff 500 or a
bms_soc_flooroff 10.R8 is the honest exception and is not claimed as pinned-across-config.
LINK_STALE_Sis a module constant ininverter.pywith no option behind it andno 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 bean 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: 10is the numberconfig.yaml's owninverters:example statesand the number every fixture in
test_delivery.pyused, sobms_floor=10.0atthe call site answered identically everywhere. It is badly non-equivalent in both
directions on configs the shipped schema
int(0,50)accepts:literal grades it against 10 %, finds it "permitted", and names an empty
pack: the crying-wolf failure this card exists to remove;
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.0but not50.0; a shallower one alone kills10.0but not0.0).Both arms are
validate.pyrule-8-valid —peak_soc_floorabove every unit'scut-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/homelaband mounted
:ro,python3 -B,__pycache__purged and the artefact countprinted as 0 before every run, each mutant's
main.pysha256 and byte deltaprinted 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, theseparating inputs and the deltas;
CHANGELOG.mdcarries the operator-facingline.
Not merging — over to you.
🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
f9d0606747to0581648c73The 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_01BwRLw7eKYj2byVdwB6kbjUadmin referenced this pull request2026-09-06 20:37:19 +02:00