checks / unit (pull_request) Successful in 6m7s
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
593 lines
34 KiB
Markdown
593 lines
34 KiB
Markdown
# SAFETY-42 — a unit that stops delivering its share, the discriminator the card asked for, and why that discriminator does not exist
|
||
|
||
**Status: DECIDED 2026-09-06.** A new per-inverter check, `app/delivery.py`,
|
||
names an inverter that is **standing at a discharge share it is not producing**
|
||
while at least one sibling is not in that state and nothing this add-on can read
|
||
explains it. It **reports and does not act**. At a roster of one it is inert by
|
||
construction.
|
||
|
||
⚠️ **This record also FALSIFIES the discriminator the card proposed.** SAFETY-42
|
||
asked for *"saturation persisting past N while |grid error| stays above X, with N
|
||
and X derived"*. That predicate was built and run on the rig before anything was
|
||
written, and it fails in **both** directions — not for want of better N and X,
|
||
but because of what the quantity is. §2 is the measurement. This is the
|
||
SAFETY-39 shape: saying the criterion is wrong was worth more than five
|
||
near-misses at satisfying it.
|
||
|
||
Filed with SAFETY-42, card `6a9d498a49082c0086e525c1`.
|
||
|
||
---
|
||
|
||
## 0. What is uncovered, and why it is structural rather than a threshold
|
||
|
||
A unit delivering **0 W against a non-zero share** is seen by nothing:
|
||
|
||
| guard | why it is silent |
|
||
|---|---|
|
||
| [[SAFETY-30]] per-unit sign | gates on **opposition ≥ 250 W on both sides**. A unit at 0 W never opens it — it is not opposing, it is **absent** |
|
||
| SAFETY-30 site sign | the sum still **agrees with the meter in direction**; only the magnitude is short |
|
||
| [[SAFETY-31]] circulation | `Σ\|Bᵢ\| − \|ΣBᵢ\|` is **zero** when one unit does nothing — circulation needs two units pulling *against* each other |
|
||
| the saturation freeze | **fires** — and `clamp.py`:93-96 says of exactly this: *"A loop can be legally saturated all day — a cold pack, a curtailed inverter, an export limit — so a saturation-shaped alarm is one an operator learns to ignore"* |
|
||
|
||
So the one signal that does fire is the one this codebase documents as
|
||
unreadable, and that documentation is **right**. This card therefore needed a
|
||
different signal, not a louder one.
|
||
|
||
It is [[HW-01]]'s shape. On 2026-09-02 the comms subsystem failed and the power
|
||
stage went on executing its last instruction for **6 h 25 m** with all three
|
||
layers spectators. At N=2 the same fault leaves a healthy sibling regulating
|
||
alone, the site short by up to one unit's share, and nothing saying so.
|
||
|
||
## 1. The rig
|
||
|
||
Everything in §2 and §3 was measured on `admin/goodwe`'s two-master rig
|
||
(TEST-19), used **read-only** and run container-free, from a harness in a
|
||
scratch tree. Real and imported, never transcribed: the control law
|
||
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
|
||
`allocator.plan`), the per-unit envelope the shipped `supervised` and `device`
|
||
claims build, the sign predicate (`watchdog.disagrees`), the circulating sensor
|
||
(`circulation.circulating_w`), the check under test (`delivery.delinquent`,
|
||
`short_by`, `account`), the wire (`frame_check.setpoint_atomic`, the firmware's
|
||
own encoder, in and back out through `plantsim.parse`/`decode`), and the plant
|
||
(`plantsim.Plant`). The per-unit reading is **holding register 0x0518**, the one
|
||
the live `batt_entity` binds.
|
||
|
||
Modelled, and therefore not evidence about the thing modelled: the master's
|
||
failsafe machine (absent), and the two fault injections —
|
||
|
||
* **wedged** — the write never reaches the plant, so its latched command stands.
|
||
Started at 0 W, which is *"delivering 0 W against a non-zero share"*.
|
||
* **at its floor** — the write arrives and `Plant.limit()` zeroes it, because
|
||
`soc <= soc_min`. This is the benign case the acceptance criterion names.
|
||
|
||
### ⚠️ Inverter B does not physically exist
|
||
|
||
`plantsim.INVERTER_B` is **inverter A copied**, `measured=False` (NOTES.md
|
||
§1.1b; the datasheet-and-serial figures 4600 W / 20.48 kWh / floor 10 % are
|
||
deliberately *not* in the model). **Every two-inverter figure in this document
|
||
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
|
||
margin that costs.
|
||
|
||
### Reproducing it
|
||
|
||
The harness is **not committed here**, for the reason SAFETY-30's and
|
||
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
|
||
other repository and are not present in this one's CI image. What it does is
|
||
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
|
||
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
|
||
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
|
||
envelopes of `±min(max_w, rating)`; put each share on its own wire through
|
||
`Inverter.command()`, honouring a configurable **write stagger** so B's number
|
||
can land up to four cycles after A's (the serialised write path); advance
|
||
`Site.settle()` one control cycle; and record, per unit, the command standing
|
||
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
|
||
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
|
||
cycles** on which it names anybody — not a count of naming samples, because the
|
||
window is cleared by any cycle that does not.
|
||
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
|
||
trees. Run it with `python3 -B` and purge.
|
||
|
||
## 2. The card's discriminator, measured — and it does not work
|
||
|
||
### 2.1 It fires on the benign case, and no (N, X) can stop it
|
||
|
||
`Plant.limit()` returns 0 W for a discharge command into a pack at its own
|
||
floor. A wedged unit that never receives its command also produces 0 W. The two
|
||
are not merely similar:
|
||
|
||
> **Load 2000 W, equal weights, 2000 W per-unit cap, 120 control cycles.
|
||
> The two arms are IDENTICAL on all 120 cycles** in (grid, site target, each
|
||
> unit's share, each unit's measured power), to the last emitted watt. Both
|
||
> report `reason == "saturated-freeze"` on **116 of the 120** cycles (the other
|
||
> four are `slew-limited`, before it engages on cycle 4), both hold |grid error|
|
||
> over 300 W on **all 120** and settle at **1200 W**, and `circulating_w` is
|
||
> **0.0 W** in both.
|
||
|
||
A predicate whose inputs are *saturation duration* and *|grid error|* reads the
|
||
same number on both arms at every cycle. There is no N and no X. And *"a
|
||
legitimately saturated site — cold pack at its floor — does **not** alarm"* is
|
||
this card's own acceptance criterion.
|
||
|
||
`test_delivery.py` part 4 asserts that identity on the add-on's own end-to-end
|
||
fixture, so it goes red if it ever stops being true.
|
||
|
||
### 2.2 It is silent on the headline case, because a healthy sibling erases it
|
||
|
||
The standing grid error is exactly the thing a working sibling removes.
|
||
|
||
> **Load 800 W, equal weights, 2000 W per-unit cap, B wedged, 200 cycles.**
|
||
> Over the last 30 cycles the loop is saturation-frozen on **30 of 30** — so
|
||
> "saturation persisting past N" is true for every N — while the worst
|
||
> **|grid error| in that window is 30 W**. B is standing at a **+770 W**
|
||
> discharge share and delivering **0 W**.
|
||
|
||
Any X above 30 W leaves the card's predicate silent for the whole run, on the
|
||
exact fault it was written for. The card's own sentence — *"a healthy sibling
|
||
regulating alone, the site short by up to one unit's rating"* — describes the
|
||
case, and the grid error is the one number that cannot report it.
|
||
|
||
### 2.3 What that leaves
|
||
|
||
The derivation instruction stands and is honoured in §3. What could not be
|
||
derived was a threshold on the wrong quantity. Both failures are written into
|
||
`delivery.py`'s docstring as well, because the next person to read the card will
|
||
propose the same predicate.
|
||
|
||
## 3. The quantity that does work
|
||
|
||
Per unit, for a share that asks it to **discharge**:
|
||
|
||
```
|
||
short_by(i) = share_i − measured_i # watts that did not arrive
|
||
```
|
||
|
||
and the reduction that makes it readable:
|
||
|
||
> a unit is **delinquent** while it is short by more than `saturation_w` with
|
||
> **no account for it**, and the set of units in that state is a **proper
|
||
> subset** of the units that were asked to discharge.
|
||
|
||
### 3.1 The account — the only thing that separates a wedged unit from a cold pack
|
||
|
||
§2.1 says there is nothing in the power domain. There is exactly one non-power
|
||
fact this add-on holds about a pack: **its own state of charge against its own
|
||
`bms_soc_floor`**, which SAFETY-32 made a required per-entry key. `account()`
|
||
asks it through **`peakguard.discharge_permitted()`** — public since SAFETY-32
|
||
precisely so that this question has one answer — and writes its own sentence,
|
||
because that function's wording names the *peak guard's* floor and would be a
|
||
false statement here.
|
||
|
||
Three things account for a shortfall, and each is a state already being handled:
|
||
|
||
| account | why it is not this fault |
|
||
|---|---|
|
||
| the unit is not live | `live_units()` has already dropped it from the split and the action log says so — firing inside that is the SAFETY-15/19 failure |
|
||
| the unit is parked by a sign trip | it is commanded 0 W on purpose; not delivering is compliance |
|
||
| `discharge_permitted(soc, bms_soc_floor)` | the pack is at or below its own cut-out and physically cannot |
|
||
|
||
⚠️ **The floor passed in is `bms_soc_floor` alone, NOT
|
||
`peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor`.** That
|
||
conjunction is right for the peak guard and wrong here: `peak_soc_floor` bounds
|
||
a money-saving discharge only (SAFETY-25), ordinary self-consumption discharges
|
||
straight past it, and using it would **explain away** a wedged unit sitting
|
||
anywhere between the two floors.
|
||
|
||
⚠️ **Every unknown accounts for the shortfall**, i.e. fails toward silence —
|
||
the opposite direction to `discharge_permitted()`'s own caller, and the right
|
||
one here: there it withholds a discharge, here it withholds an **alarm**. The
|
||
cost is bounded, because the same absence stops the site through `inputs_ok`
|
||
(an unreadable SoC) or is refused by the roster schema (an unstated floor).
|
||
|
||
### 3.2 The proper subset — what only exists at N ≥ 2
|
||
|
||
A derate this add-on cannot see — thermal, grid-code, a DC-side restriction —
|
||
hits **every** unit that was asked. One box that has stopped listening does not.
|
||
So if every asked unit is short and unexplained, this module says **nothing**:
|
||
it has no instrument that tells that from a plant which simply cannot deliver,
|
||
and reporting it anyway would be the saturation-shaped alarm produced by the
|
||
guard written to replace it.
|
||
|
||
**At N = 1 the subset is never proper, for every input**, so the check is inert
|
||
there — not by a roster guard, but by the algebra, the same way
|
||
`circulation.circulating_w()` is identically zero at N ≤ 1. That is AC 5, and
|
||
§3.3's control arm measures it rather than asserting it.
|
||
|
||
### 3.3 The window — the one number this card chose
|
||
|
||
**2640 closed-loop runs at a roster of two in which NOTHING is faulty** — every
|
||
unit executes the share the allocator gave it — over 6 gains × 5 slews, the
|
||
whole range `config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w`
|
||
`int(0,5000)` (0 = follow the rail), across three families:
|
||
|
||
* **1050 load reversals** — 7 sizes (±300 W … ±5000 W) × 5 write staggers;
|
||
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes;
|
||
* **750 weighted reversals** — 5 splits (0.5, 0.7, 0.9, 0.99, 0.1) × 5 staggers
|
||
on a ±2200 W reversal. ⚠️ **This family did not exist in SAFETY-30's campaign
|
||
and it is the one that dominates here**, for a mechanical reason: an
|
||
asymmetric split gives one unit a much larger step than its sibling, so the
|
||
big one is still ramping while the small one has settled — which is precisely
|
||
the *proper subset* this check keys on.
|
||
|
||
Every run is executed a second time at a **roster of one** on the same stimulus.
|
||
**The N=1 arm is the control**, and it is what separates *"a property of the
|
||
check"* from *"a property of the second inverter"*.
|
||
|
||
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
|
||
threshold:
|
||
|
||
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|
||
|---:|---:|---:|---:|---:|
|
||
| 100 W | 40.0 s (608) | 0.0 s (0) | 75.0 s (576) | **0.0 s (0)** |
|
||
| 250 W | 55.0 s (490) | 0.0 s (0) | 60.0 s (480) | **0.0 s (0)** |
|
||
| **500 W** | **20.0 s (206)** | **0.0 s (0)** | **30.0 s (368)** | **0.0 s (0)** |
|
||
| 700 W | 20.0 s (125) | 0.0 s (0) | 20.0 s (174) | **0.0 s (0)** |
|
||
| 1000 W | 15.0 s (70) | 0.0 s (0) | 15.0 s (111) | **0.0 s (0)** |
|
||
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
|
||
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
|
||
|
||
Three findings worth stating rather than leaving in the table:
|
||
|
||
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
|
||
threshold.** That is the compatibility promise measured, not argued.
|
||
2. **The flapping family is zero everywhere too** — and it is the family that
|
||
defeated SAFETY-30's site window (980 s at two inverters, 1000 s at one, no
|
||
window inside the billing quarter surviving it). It cannot touch this check,
|
||
because a common stimulus makes both units short at the same time and the
|
||
subset stops being proper. That is the proper-subset rule earning its place
|
||
on a case it was not designed for.
|
||
3. **The curve is NOT monotone** — 55.0 s at 250 W against 40.0 s at 100 W —
|
||
and that is a property of the reduction rather than noise: lowering the
|
||
threshold puts MORE units into the short set, which can turn a proper subset
|
||
into the whole roster and **break** the run. Both the numerator and the
|
||
denominator move. Do not read this table as a sensitivity curve.
|
||
|
||
#### The watts side is not a new option — it is `Tuning.saturation_w`
|
||
|
||
`main.py` hands the check `self.tuning.saturation_w` (500 W) and there is no
|
||
`delivery_w` key anywhere. That is a derivation, not a saving:
|
||
|
||
* it is already **the gap at which `compute()` itself concludes the inverter is
|
||
not following** and freezes the integrator. Below it the shipped control law
|
||
calls the divergence ordinary tracking, and an alarm under a threshold the
|
||
control law calls normal is an alarm about nothing;
|
||
* `saturation_cycles` (3) consecutive cycles over that same gap are needed
|
||
before the freeze engages, so **every cycle this counts is a cycle the freeze
|
||
has already seen**. This is a reduction over the freeze's input, not a second
|
||
opinion about it;
|
||
* CFG-03 already tracks three places a default has to be kept in step. A fourth
|
||
copy could drift, and the drift that matters has a direction: an operator who
|
||
raises `saturation_w` has told the loop that a bigger gap is ordinary
|
||
tracking, and a check that stayed put would then alarm on it.
|
||
|
||
**Rejected:** a `delivery_w` option, for the three reasons above. A margin above
|
||
`saturation_w` (the way `circulation_w` sits 1.75× above its knee) was also
|
||
rejected: there is no knee here — the table above is not monotone — so a margin
|
||
would be a preference, and all the margin this check needs is in the window.
|
||
|
||
#### `delivery_hold_s: 185` — schema `int(30,900)`
|
||
|
||
Four bounds, and the value clears all four.
|
||
|
||
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
|
||
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
|
||
reversals, 0.0 s on flapping).
|
||
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_S`).** A unit not
|
||
executing its share for less than the firmware's own veto window is a *link*
|
||
fault the add-on already answers by dropping it from the split. Reporting
|
||
inside that window would be a second, louder alarm about a state already
|
||
being handled — the SAFETY-15/SAFETY-19 failure exactly.
|
||
* **margin, from inverter B not existing — and it is MEASURED, not felt.** The
|
||
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
|
||
*different* second unit through the weighted family (750 runs each):
|
||
|
||
| B lag | B ramp | worst legitimate | runs naming |
|
||
|---:|---:|---:|---:|
|
||
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
|
||
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
|
||
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
|
||
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
|
||
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
|
||
|
||
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
|
||
The shortfall lasts as long as the unit's first-order lag holds it more than
|
||
`saturation_w` below its share; at these step sizes the ramp is never the
|
||
binding term. So the number to carry margin against is B's lag, and a unit
|
||
**four times slower than A** produces 90.0 s.
|
||
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
|
||
|
||
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
|
||
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
|
||
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
|
||
reader must not be able to think this was carried across — and not 150 or 200.
|
||
|
||
**What it costs, stated rather than hidden:** up to 185 s of a site running
|
||
short by one unit's share before anybody is told. Against a fault that ran
|
||
6 h 25 m unnoticed, and against a guard that takes **no action**, the window
|
||
buys its margin with reporting latency and nothing else.
|
||
|
||
## 4. It reports. It does not act — and the strongest argument is for acting
|
||
|
||
Card requirement 3 asks which, and why. The answer is recorded with the
|
||
evidence **against** it first, because "alarm-only" is the weaker-looking answer.
|
||
|
||
**Command it harder — no.** HW-01 settles it: the power stage executed its last
|
||
instruction for 6 h 25 m through every command this add-on sent. A command is
|
||
not a remedy for a box that is not listening.
|
||
|
||
**Stop the site, as SAFETY-31 does — no**, and the asymmetry with SAFETY-31 is
|
||
exact. Circulating power is stopped because *this controller is one of the two
|
||
participants*: removing our half halves the loop and un-hides the residue at a
|
||
meter that was netting it to zero. Here this controller is **not** a participant,
|
||
nothing is netting, and the sibling is the only thing still delivering. Parking
|
||
it takes the site from "short by one unit" to "short by all of them".
|
||
|
||
**Re-allocate its share to the sibling — no, and NOT because the loop already
|
||
does it.** That comfortable answer is false, and the rig says so. Dropping the
|
||
wedged unit from `live` — the mechanism `live_units()` already uses for a dead
|
||
link — is a real improvement at loads the site cannot cover on one unit's
|
||
*share*:
|
||
|
||
| B wedged, per-unit cap 2000 W | kept in the split | dropped from the split |
|
||
|---|---|---|
|
||
| load 800 W | settles inside 100 W from cycle 6 | from cycle 8 |
|
||
| load 1500 W | **800 W delivered, 700 W error, NEVER inside 100 W in 150 cycles** | 1500 W delivered, inside 100 W from cycle 14 |
|
||
| load 2000 W | **800 W delivered, 1200 W error, never settles** | 2000 W delivered, inside 100 W from cycle 8 |
|
||
|
||
The mechanism is the saturation freeze doing its job on a site comparison that a
|
||
non-delivering unit has poisoned: `|site target − site batt|` stays over
|
||
`saturation_w` forever, so the target cannot rise and the sibling's *share*
|
||
never grows. That is not three cycles of latency; it is the difference between a
|
||
site that recovers and one that does not.
|
||
|
||
**What refuses it anyway** is that the only re-allocation this add-on can
|
||
express is **all or nothing**. There is no way to say *"give the sibling the
|
||
watts that did not arrive and leave this unit the ones that did"*. And a unit
|
||
delivering **part** of its share is short by more than `saturation_w` too, with
|
||
nothing here to explain it — so this detector names it, and cannot tell a 50 %
|
||
derate from a wedged box, because no instrument this add-on has can:
|
||
|
||
| B derated to 50 %, load 3000 W, cap 2000 W | site delivers | B contributes | grid error |
|
||
|---|---:|---:|---:|
|
||
| kept in the split | 2400 W | 800 W | 600 W |
|
||
| **dropped** | **2000 W** | **0 W** | **1000 W — 400 W worse** |
|
||
|
||
**SAFETY-42 names B in that run.** So the remedy would make the site worse, in a
|
||
case the detector cannot rule out, on a live control law, driven by a brand-new
|
||
alarm. A **partial** re-allocation is what would make acting safe, and the
|
||
allocator has no way to express it today. That is a card, and this section is
|
||
its evidence.
|
||
|
||
`test_delivery.py` part 5 asserts the decision as a **counterfactual** rather
|
||
than by reading the code: the same fixture run with the report tripping and with
|
||
it never tripping produces byte-identical grid, site target, shares and measured
|
||
power on every cycle.
|
||
|
||
## 5. Requirement 4: the saturation freeze is untouched
|
||
|
||
`control.py` is not modified by this card, at all. The freeze still shapes the
|
||
loop; this reports on it. `test_delivery.py` part 6 asserts what each
|
||
neighbouring guard concludes on the same fixture — SAFETY-30 per-unit silent,
|
||
SAFETY-30 site silent, SAFETY-31 identically zero, the freeze firing at the
|
||
same counter on **both** arms — which is the honest version of "every existing
|
||
guard is silent", including the one that is not.
|
||
|
||
## 6. Surviving a restart (SAFETY-34)
|
||
|
||
`persistent_notification` is **in memory**. Home Assistant restarts, the card is
|
||
gone, and the fault runs on — precisely how the 2026-09-02 outage went unseen
|
||
for 6 h 25 m with the detector correct throughout. It matters more here than
|
||
anywhere else in this add-on, because SAFETY-02 and SAFETY-31 at least leave a
|
||
**stopped** site for somebody to notice; this one leaves a site that looks like
|
||
it is working.
|
||
|
||
* durable half: `unit/<name>/delivery_tripped` / `delivery_reason`, namespaced
|
||
per unit for `store.unit_key()`'s reason, written **reason first**.
|
||
* visible half: `_delivery_announced`, a per-**process** set of unit names,
|
||
deliberately not persisted. A restart empties the set and keeps the latch, so
|
||
the alarm is re-raised on the first cycle of every process — **one code path**
|
||
for a fresh trip and for a restart into an existing one, `check_circulating()`'s
|
||
argument.
|
||
* **no flat mirror**, where SAFETY-02 has one. `store.py`'s rule 2 keeps the flat
|
||
keys so a rollback still finds its latch, and the version being rolled back to
|
||
drives exactly one actuator — a roster at which this check is inert.
|
||
* one **notification id per unit**, built from the same `unit_slugs()` TEL-07
|
||
announces the MQTT entities under. Two wedged inverters must produce two cards,
|
||
not one replacing the other.
|
||
* it **latches**, and not for SAFETY-02's reason. Nothing here removes its own
|
||
evidence, because nothing here changes what is commanded. It latches so that a
|
||
unit which stopped delivering for minutes and then resumed cannot erase its own
|
||
record — the intermittent fault is the one an operator most needs told about.
|
||
Cleared by the same **Start** press as the others.
|
||
* the status row is `ok: False, warn: True` — this list's **caution**. Every
|
||
other latched row means the site is at 0 W and a red cross is the truth; this
|
||
one means the site is still running and quietly short, and a red cross would
|
||
say the controller had stopped.
|
||
|
||
## 7. What this does NOT catch, said plainly
|
||
|
||
* **Charge shares are not graded.** The account gate needs a bound this
|
||
controller reads in the commanded direction. There is one for discharge
|
||
(`bms_soc_floor`, required of every roster entry since SAFETY-32) and **none**
|
||
for charge. A pack in constant-voltage taper legitimately takes hundreds of
|
||
watts less than it is told for tens of minutes, and arming that direction would
|
||
fire on the taper of every §12 maintenance charge. The same fault in the other
|
||
direction wants the same treatment; what it needs first is a per-pack charge
|
||
ceiling this add-on does not have. **That is a card.**
|
||
* **Every unit wedged at once is not caught.** The subset is not proper, and
|
||
there is no instrument here that separates that from a plant which simply
|
||
cannot deliver. The site is short at the meter in that case, which is where the
|
||
existing guards can see it.
|
||
* **A pack that refuses above its configured floor is reported.** That is
|
||
deliberate: either the unit has stopped delivering, or `bms_soc_floor` does not
|
||
describe that battery. The notification says both, in those words.
|
||
* **A unit with a share of 0 W is not graded**, and neither is it a peer. With
|
||
only one unit asked to discharge there is nothing to compare against.
|
||
|
||
## 8. Mutation ledger
|
||
|
||
Every run on a **freshly copied tree of its own, `__pycache__` purged and the
|
||
count printed, mounted `:ro`, `python3 -B`** — MUTATION.md's rule, followed
|
||
because the first SAFETY-30 battery reused one directory, a container mounted it
|
||
mid-copy, read a missing `config.yaml` as a failing check, and the whole run was
|
||
discarded. **The baseline is green** (23/23) or nothing below is a result. Byte
|
||
deltas measured, not estimated; the `+0 B` ones are marked, since a
|
||
size-preserving edit is what TEST-15 turned into a false survivor once already.
|
||
|
||
| # | file | mutation | Δ | result |
|
||
|---|---|---|---|---|
|
||
| M1 | `delivery.py` | the SoC account removed — a cold pack at its floor alarms | −31 B | **RED** — `test_delivery.py`. This is the card |
|
||
| M2 | `delivery.py` | the proper-subset rule removed — a whole-site cause names every unit | −34 B | **RED** |
|
||
| **M3** | `delivery.py` | **the `len(asked) < 2` fast path relaxed to `< 1`** | **+0 B** | **SURVIVOR — and an EQUIVALENT MUTANT.** See below |
|
||
| M4 | `delivery.py` | the strict bound relaxed to `>=` — a gap the control law calls tracking is named | +1 B | **RED** |
|
||
| **M5** | `delivery.py` | `share <= 0` relaxed to `share == 0` — charge shares graded | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then **RED**. See below |
|
||
| M6 | `delivery.py` | `delivery_hold_s` 185 → a round 300 | **+0 B** | **RED** — the CFG-03 pin |
|
||
| M7 | `main.py` | the watts threshold no longer `Tuning.saturation_w` (×4) | +6 B | **RED** |
|
||
| M8 | `main.py` | the latch never persisted — nothing survives a restart | −35 B | **RED** |
|
||
| M9 | `main.py` | `_delivery_announced` never set — one notification per cycle | −34 B | **RED** |
|
||
| M10 | `main.py` | `peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor` used instead of the pack's own cut-out | +151 B | **RED** — the between-the-floors fixture |
|
||
| **M11** | `main.py` | **`inv.is_live(now)[0]` replaced by a literal `True`** | −15 B | **SURVIVOR at review — GAP CLOSED**, then **RED**. See below |
|
||
| M12 | `inverter.py` | the latch not restored from the store on start-up | −46 B | **RED** |
|
||
|
||
**11 of 12 red. One survivor, argued rather than left.**
|
||
|
||
### M5 was a real gap, and the two obvious checks did not close it
|
||
|
||
`test_delivery.py` already asserted `short_by(-2000, 0) is None` and
|
||
`short_by(-2000, -100) is None` — and **both stayed green under the mutant**.
|
||
For a charge share that is *under*-delivered, `share − measured` is negative and
|
||
the `gap > 0` test answers `None` anyway, so those two inputs cannot separate
|
||
`share <= 0` from `share == 0`. The one input that can is a unit moving **more**
|
||
than its charge share — `-2000 W` told, `-3000 W` measured, `share − measured` =
|
||
`+1000 W` — which the mutant reports as a shortfall. It is not one; it is
|
||
something else driving that pack, which is SAFETY-30's and SAFETY-31's question.
|
||
The check now uses that input and the mutant is red.
|
||
|
||
This is MUTATION.md's third failure mode in miniature: **the assertion ran, the
|
||
sentence was true, and the data could not reach the failure it named.**
|
||
|
||
### M11 was a wiring gap the pure checks structurally could not see
|
||
|
||
`account(live=False, …)` was already graded — as a **function**. What was not
|
||
graded was that `cycle()` actually *asks* `inv.is_live(now)`, and replacing that
|
||
call with a literal `True` left every check green, because **no fixture had a
|
||
unit whose writes were being rejected**. A dropped unit still has its last
|
||
*accepted* command standing on the wire (`write_setpoint` leaves `last_write`
|
||
alone on a rejected write) and still measures nothing, so it looks exactly like a
|
||
wedged one to every number this check reads — and reporting it would be the
|
||
second, louder alarm about a state already being handled that this module takes
|
||
its 30 s floor from. `test_delivery.py` part 6 now builds that unit (with
|
||
`rejected_since` back-dated past `LINK_STALE_S`, because the fixture runs in two
|
||
seconds and the timer is `test_inverter.py`'s to grade) and the mutant is red.
|
||
|
||
### M3 is recorded as a survivor because it is one — and it is EQUIVALENT
|
||
|
||
`len(asked) < 2` → `len(asked) < 1`. With **one** asked unit, `unaccounted` is a
|
||
subset of a one-element set, so it is either empty — `not unaccounted` returns
|
||
`{}` — or equal to `asked` — `len(unaccounted) == len(asked)` returns `{}`.
|
||
There is no third case, and with **zero** asked units both spellings return `{}`
|
||
as well. **No input separates them**, so no check can, and one written to try
|
||
would be a check about nothing.
|
||
|
||
⚠️ **And finding it corrected the module's own docstring, which is the more
|
||
useful half.** That paragraph claimed `len(asked) < 2` was *what makes N=1
|
||
inert*. It is not: the **proper-subset rule** is, and the line is a fast path and
|
||
a signpost. A false attribution of the mechanism in the file's own prose is
|
||
exactly what this repository keeps finding, and a mutation that survives is how
|
||
it surfaced. The line stays — it tells a reader at a glance that a one-inverter
|
||
install never reaches any of this — with the equivalence written down beside it,
|
||
the way `circulation.circulating_w()` records its own `max(0.0, …)`.
|
||
|
||
### Not separately mutated, and said rather than left implicit
|
||
|
||
`account()`'s `parked` branch is graded as a function (part 1) but its **wiring**
|
||
— `parked=inv.sign_wd.tripped` — is not mutated on its own. Part 6's reversed-unit
|
||
fixture exercises the path end to end and asserts the account it produces, which
|
||
is weaker than a mutation and is stated as such.
|
||
|
||
## 9. Round 2 — the thresholds this check reads, and which of them are pinned at a **changed** config
|
||
|
||
Everything in §8 mutated the *logic*. This section asks a narrower question that
|
||
§8 never asked, and it found two live survivors of the whole 23-file suite:
|
||
|
||
> for each number `main.py` hands this reduction, is the check still red when
|
||
> that number is replaced by a **literal equal to the value the fixtures use**?
|
||
|
||
That is not pedantry about style. Two of these numbers are **options**, and
|
||
`delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in `config.yaml`
|
||
both make a promise about them *in shipped, operator-readable prose*: there is no
|
||
fourth copy of a default to keep in step, and no way for the two to drift,
|
||
because an operator who raises `saturation_w` has told the loop that a bigger gap
|
||
is ordinary tracking. A literal at the call site keeps every fixture green and
|
||
removes exactly that promise — and the promise is why the design was chosen over
|
||
a fourth option key.
|
||
|
||
### The enumeration
|
||
|
||
| # | what the call site reads | mutated to | Δ | before round 2 | now |
|
||
|---|---|---|---|---|---|
|
||
| R5 | `self.tuning.saturation_w` | `250.0` | −19 B | **SURVIVED all 23 suites** | red — part 4b(a) |
|
||
| R6 | `bms_floor=inv.bms_soc_floor` | `10.0` | −13 B | **SURVIVED all 23 suites** | red — part 4b(b) |
|
||
| R7 | `arm(self.delivery_cfg)` | `arm(DeliveryConfig())` | −1 B | red | red — part 4 already ran at a hold of 1 s, not the shipped 185 s |
|
||
| R8 | `inv.is_live(now)[0]` | `inv.is_live(now, 3600.0)[0]` | +8 B | red | red — part 6's link-dead fixture |
|
||
| R9 | `bms_floor=inv.bms_soc_floor` | the **conjoined** floor, `max(pack cut-out, site peak_soc_floor)` | +105 B | red | red — and now red **twice**: the pre-existing between-floors check at shipped floors, and part 4b(b)'s shallow arm at a cut-out that is not shipped |
|
||
|
||
**R7 and R8 were already pinned across a changed setting, and R5 and R6 were
|
||
not.** The difference is entirely in the fixtures, not in the code:
|
||
`delivery_hold_s` had to be moved off 185 s for any of this file to run inside a
|
||
two-second test, and part 6 had to back-date `rejected_since` past
|
||
`LINK_STALE_S` to build a dropped unit at all. Nothing ever forced
|
||
`saturation_w` off 500 or a `bms_soc_floor` off 10 — so those two sat at the
|
||
shipped value in every fixture, and a literal equal to the shipped value is
|
||
invisible to a fixture that only ever uses the shipped value. **That is this
|
||
repository's recurring defect stated in one line: a fixture whose values cannot
|
||
reach the failure it names.**
|
||
|
||
`LINK_STALE_S` (R8) is the one number here with **no option behind it** — it is a
|
||
module constant in `inverter.py` and no test reassigns it — so "pinned across a
|
||
changed config" is not available for it, and is not claimed. What part 6 pins is
|
||
that *widening* the window changes the answer. Spelling `is_live(now, 30.0)` at
|
||
the call site would be an equivalent mutant today and would only drift if the
|
||
constant moved, which is a source change and not a config change.
|
||
|
||
### Why R5 and R6 are not equivalent mutants — the inputs that separate them
|
||
|
||
Both are separating inputs found by asking where the shipped answer and the
|
||
literal's answer can differ, and both are configs the shipped schema accepts.
|
||
|
||
**R5, watts.** Two copies of inverter A, B wedged, `saturation_w: 1800` (inside
|
||
its own schema `int(100,2000)`), load 1600 W. B's standing share and its
|
||
shortfall then land strictly **between** `250.0` and the configured 1800 W:
|
||
|
||
* head → `named = {}` — the operator raised the threshold and this check moved
|
||
with them, which is the whole promise;
|
||
* R5 → `named = {inverter-b}` — an alarm at a gap the control law has been told
|
||
is ordinary tracking.
|
||
|
||
At the shipped `saturation_w: 500` **both** name it, which is why every fixture
|
||
in part 4 is blind: their gaps are all above both thresholds.
|
||
|
||
**R6, percent.** The literal is invisible because `bms_soc_floor: 10` is what
|
||
`config.yaml`'s own `inverters:` example states and what every fixture in
|
||
`test_delivery.py` used. Two arms, on either side of it, both inside the schema
|
||
`int(0,50)` and both `validate.py` rule-8-valid:
|
||
|
||
* a pack at **15 %** against its own **25 %** cut-out is genuinely cold. Head
|
||
stays silent; R6 grades it against 10 %, finds it "permitted", and **names an
|
||
empty pack** — the crying-wolf failure this card exists to remove.
|
||
* a pack at **8 %** against its own **5 %** cut-out is genuinely wedged. Head
|
||
names it; R6 grades it against 10 %, calls it a cold pack, and **loses the
|
||
fault**.
|
||
|
||
One arm on each side is what leaves no constant at all: a single deeper arm
|
||
kills `10.0` but not `50.0`, a single shallower arm kills `10.0` but not `0.0`.
|
||
The two arms are byte-identical in the power domain on every cycle — the
|
||
configured cut-out is the only thing that differs, which is §3.1's claim made
|
||
again at cut-outs that are not the shipped one.
|
||
|
||
### What is still not pinned this way, said rather than left implicit
|
||
|
||
`parked=inv.sign_wd.tripped` and `standing[inv.name]`/`inv.batt` are wiring, not
|
||
thresholds: there is no "value the fixture happens to use" for a boolean or for a
|
||
measurement, so this question does not apply to them, and §8's last note already
|
||
records that the `parked` wiring is graded end-to-end rather than by mutation.
|