Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md
T
Claude Opus 5 2271e147c6
checks / unit (pull_request) Successful in 6m20s
SAFETY-43: delinquency measured against the ask, not against a flat 500 W
`delivery.delinquent()` named a unit whose `short_by()` gap exceeded an
ABSOLUTE `min_w = Tuning.saturation_w = 500 W`. OPT-09's SoC-aware weights
shrink a low-headroom unit's share, and shrinking the share shrinks the gap, so
a unit standing at +125 W and delivering 0 W - 100 % delinquent - was never
named. Measured end to end on the real loop (test_packshare.py part 8b):

    load 1500 W, B wedged at 15 %, equal weights    named on 199/200 cycles
    load 1500 W, B wedged at 15 %, OPT-09 weights   named on   0/200 cycles

⚠️ The suppressed regime is the quiet one: at a load the healthy sibling covers,
the meter is satisfied, `unmet_w` is 0 and no action-log line fires, so this
alarm is the ONLY instrument that would have told the operator.

WHAT CHANGED, in one line:

    named if  short_by() > min_w  OR  short_by() > frac * share

⚠️ `min_w` WAS NOT LOWERED. It exists to stop normal saturation being reported
as delinquency, and lowering it trades a missed alarm for a false one on every
unit at once - SAFETY-41's four-attempt regime. This is a SECOND way to be
named, not a looser first one: all seven of test_delivery.py part 1's existing
fixtures answer identically under both rules, and the absolute arm's suppression
window is still exactly 1000 W / 6500 W, still asserted in part 8b.

THE FRACTION IS DERIVED, 0.85. admin/goodwe:sim/delivery_window.py gained a
`--fractions` axis and grades the constant over the SAME 2640 no-fault runs that
produced `delivery_hold_s`'s 35.0 s floor. Worst continuous legitimate episode,
worst of three families, twenty fractions:

    frac   0.05  0.10  0.15  0.20  0.25  0.30  0.35  0.40  0.45  0.50
    worst  90.0  90.0  70.0  70.0  60.0  55.0  55.0  60.0  45.0  45.0  s
    frac   0.55  0.60  0.65  0.70  0.75  0.80 *0.85  0.90  0.95  0.99
    worst  40.0  40.0  45.0  45.0  45.0  45.0  40.0  40.0  40.0  40.0  s
    absolute arm alone: 35.0 s (SAFETY-42 §3.3, reproduced in the same runs)

⚠️ NO CLIFF ANYWHERE: 0.45-0.99 is flat within one control cycle, and it is not
monotone - §3.3's own mechanism (c). ⚠️ "Zero false namings" is NOT achievable
at any fraction and the record says so; what is zero is false ALARMS - 90.0 s
worst against a 185 s hold, so nothing comes within 95 s of notifying anybody.
N=1 control 0.0 s at every fraction in all 2640 one-inverter runs; flapping
0.0 s in all 840.

DRIVEN. Part 8b pinned the SUPPRESSED behaviour and said "if it ever goes red
because the alarm fired, read the card that made it fire". It now grades BOTH
rules on one run - `share_frac=None` is SAFETY-42's rule exactly:

    OPT-09 weights, absolute arm alone   named on   0/200
    OPT-09 weights, the shipped rule     named on 200/200

POSITIVE CONTROL. Same fixture, same weights, same 125 W share, one legitimate
fact changed: a pack at its own 10 % cut-out is named on 0/200 (account() still
gates it), and a unit delivering HALF its share is named on 0/200.

N=1 BYTE-IDENTICAL, asserted: `plan()` handed a `weights` mapping that raises on
any read still answers identically at a roster of one, and the same mapping
raises at a roster of two; `delinquent()` answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.

MUTATION, run on fresh :ro trees: M12 (drop the relative arm), M13 (frac -> 1.0)
and M14 (frac -> 0.05) are all red on named checks. The constant cannot move in
either direction unseen.

⚠️ CFG-07 gets ONE CONTROL CYCLE WORSE and is deliberately not fixed here: the
worst legitimate episode goes 35.0 -> 40.0 s, so the schema's `int(30,900)`
false-tripping band widens from [30,35] to [30,40]. CFG-07 should be re-derived
against 40.0 s.

No option value, no schema bound and no default changed. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 03:14:16 +02:00

48 KiB
Raw Blame History

SAFETY-42 — a unit that stops delivering its share, the discriminator the card asked for, and why that discriminator does not exist

Status: DECIDED 2026-09-06. A new per-inverter check, app/delivery.py, names an inverter that is standing at a discharge share it is not producing while at least one sibling is not in that state and nothing this add-on can read explains it. It reports and does not act. At a roster of one it is inert by construction.

⚠️ This record also FALSIFIES the discriminator the card proposed. SAFETY-42 asked for "saturation persisting past N while |grid error| stays above X, with N and X derived". That predicate was built and run on the rig before anything was written, and it fails in both directions — not for want of better N and X, but because of what the quantity is. §2 is the measurement. This is the SAFETY-39 shape: saying the criterion is wrong was worth more than five near-misses at satisfying it.

Filed with SAFETY-42, card 6a9d498a49082c0086e525c1.


0. What is uncovered, and why it is structural rather than a threshold

A unit delivering 0 W against a non-zero share is seen by nothing:

guard why it is silent
SAFETY-30 per-unit sign gates on opposition ≥ 250 W on both sides. A unit at 0 W never opens it — it is not opposing, it is absent
SAFETY-30 site sign the sum still agrees with the meter in direction; only the magnitude is short
SAFETY-31 circulation Σ|Bᵢ| − |ΣBᵢ| is zero when one unit does nothing — circulation needs two units pulling against each other
the saturation freeze fires — and clamp.py:93-96 says of exactly this: "A loop can be legally saturated all day — a cold pack, a curtailed inverter, an export limit — so a saturation-shaped alarm is one an operator learns to ignore"

So the one signal that does fire is the one this codebase documents as unreadable, and that documentation is right. This card therefore needed a different signal, not a louder one.

It is HW-01's shape. On 2026-09-02 the comms subsystem failed and the power stage went on executing its last instruction for 6 h 25 m with all three layers spectators. At N=2 the same fault leaves a healthy sibling regulating alone, the site short by up to one unit's share, and nothing saying so.

1. The rig

Everything in §2 and §3 was measured on admin/goodwe's two-master rig (TEST-19), used read-only and run container-free, from a harness in a scratch tree. Real and imported, never transcribed: the control law (control.compute, Tuning), the allocator (arbiter.allocate_record → allocator.plan), the per-unit envelope the shipped supervised and device claims build, the sign predicate (watchdog.disagrees), the circulating sensor (circulation.circulating_w), the check under test (delivery.delinquent, short_by, account), the wire (frame_check.setpoint_atomic, the firmware's own encoder, in and back out through plantsim.parse/decode), and the plant (plantsim.Plant). The per-unit reading is holding register 0x0518, the one the live batt_entity binds.

Modelled, and therefore not evidence about the thing modelled: the master's failsafe machine (absent), and the two fault injections —

  • wedged — the write never reaches the plant, so its latched command stands. Started at 0 W, which is "delivering 0 W against a non-zero share".
  • at its floor — the write arrives and Plant.limit() zeroes it, because soc <= soc_min. This is the benign case the acceptance criterion names.

⚠️ Inverter B does not physically exist

plantsim.INVERTER_B is inverter A copied, measured=False (NOTES.md §1.1b; the datasheet-and-serial figures 4600 W / 20.48 kWh / floor 10 % are deliberately not in the model). Every two-inverter figure in this document is two copies of inverter A. §3.3 is the sensitivity sweep that sizes the margin that costs.

Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)

⚠️ THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE DEFECT TEST-21 FOUND. A shipped safety constant whose floor the next reviewer cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three days. The paragraph this replaces was true — what the harness does is small enough to restate — and it was not enough: a restatement in prose leaves every axis it does not name to the next person's judgement, and §3.3 below now records how far that moves the number. It moved. See §3.3's second table.

It is committed in the other repository, admin/goodwe, as sim/delivery_window.py, beside sitesim.py — the container-free two-master harness it is built on. From clean checkouts of both repos:

cd goodwe/sim
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag

stdlib only. No container, no HA, no esphome, no rig and no rig lock — it is a plantsim campaign, not a rig job. About fifteen minutes for the whole thing.

⚠️ AND IT IS IN admin/goodwe RATHER THAN HERE, WHICH IS CI-08's RULE APPLIED RATHER THAN IGNORED. It imports plantsim (the plant, the per-unit wire, Site) and frame_check (the firmware's own encoder), both owned by that repo and neither present in this one's CI image. Committed here it had two possible fates and both are the coupling CI-08 has been broken by three times: skipped for want of its imports — a green that establishes nothing — or the plant and the encoder restated in this tree, which is a second copy of something the other repo owns, free to drift, and drift in a fixture is invisible until the number it defends is wrong. There the dependency runs one way and stays one-directional: this repo's control, arbiter and delivery are imported live out of whichever checkout is under test ($GOODWE_ADDON_REPO), never copied — so a change to delinquent() moves the number on the next run instead of silently disagreeing with a fixture. That is the seam sitesim.py already stands on, which is the other reason the campaign is its neighbour and not a third arrangement.

⚠️ THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS DELIBERATE. admin/goodwe's .gitea/workflows/checks.yml gained one step — python3 -B delivery_window.py --selftest, seconds, beside the existing norig, no lock TEST-19 and TEST-20 steps, in the workflow that already checks out this repo for two_writers and accepts that boundary in writing. So a change here that moved saturation_w, max_w, delivery_hold_s or the shape of delinquent() turns that step red rather than leaving a stale number in this record. The campaign (--campaign --blag, ~15 minutes) is not gated: it is a campaign, not a gate, and failure-injection.yml's jobs run on the rig runner, which a container-free simulation has no business holding. What TEST-21 asked for is a command a reviewer can run from a clean checkout — that is the block above.

⚠️ --selftest IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS FIRST. A campaign whose N=1 column and whose flapping column are 0.0 s everywhere is indistinguishable from a campaign whose detector was never wired up — sitesim.broadcast_onset exists for the same reason. So the selftest drives a genuinely wedged unit through the same loop and refuses to proceed unless the shipped delinquent() names it for longer than the shipped hold (it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because that arm passes.

What it does, unchanged from the restatement it replaces: build two plantsim.Inverters 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.

⚠️ SINCE SAFETY-43 THE ROWS OF §3.3's TABLE ARE GRADED WITH share_frac=None — delinquent()'s ABSOLUTE arm alone, which is this card's rule exactly. Said here because it is the difference between §3.3 still meaning what it is labelled as and quietly becoming a table about a different predicate: SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows (--fractions), and the numbers below are unchanged and were re-measured to confirm it. DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md §3 carries those rows and what they cost — the worst legitimate episode goes from 35.0 s to 40.0 s at the shipped fraction, which does not move delivery_hold_s.

⚠️ ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING. "The command standing before the write" is the controller's standing command — Inverter.standing_w(), i.e. last_write — and not the far end of the wire. A write that has been issued but has not landed yet is standing as far as this add-on is concerned, which is the whole of the write-stagger axis. Grading against the plant's latch instead reads a number no add-on has, and it makes both the stagger and a wedged unit invisible: a unit whose writes never arrive shows a standing command of 0 W, is never asked, and is never named. That is not a conservative harness, it is a harness that cannot fail — and the rebuild did exactly this on its first run, which is how the falsifiability arm earned its place above.

⚠️ MUTATION.md's note applies: this harness writes __pycache__ into both trees. Run it with python3 -B and purge.

2. The card's discriminator, measured — and it does not work

2.1 It fires on the benign case, and no (N, X) can stop it

Plant.limit() returns 0 W for a discharge command into a pack at its own floor. A wedged unit that never receives its command also produces 0 W. The two are not merely similar:

Load 2000 W, equal weights, 2000 W per-unit cap, 120 control cycles. The two arms are IDENTICAL on all 120 cycles in (grid, site target, each unit's share, each unit's measured power), to the last emitted watt. Both report reason == "saturated-freeze" on 116 of the 120 cycles (the other four are slew-limited, before it engages on cycle 4), both hold |grid error| over 300 W on all 120 and settle at 1200 W, and circulating_w is 0.0 W in both.

A predicate whose inputs are saturation duration and |grid error| reads the same number on both arms at every cycle. There is no N and no X. And "a legitimately saturated site — cold pack at its floor — does not alarm" is this card's own acceptance criterion.

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

2.2 It is silent on the headline case, because a healthy sibling erases it

The standing grid error is exactly the thing a working sibling removes.

Load 800 W, equal weights, 2000 W per-unit cap, B wedged, 200 cycles. Over the last 30 cycles the loop is saturation-frozen on 30 of 30 — so "saturation persisting past N" is true for every N — while the worst |grid error| in that window is 30 W. B is standing at a +770 W discharge share and delivering 0 W.

Any X above 30 W leaves the card's predicate silent for the whole run, on the exact fault it was written for. The card's own sentence — "a healthy sibling regulating alone, the site short by up to one unit's rating" — describes the case, and the grid error is the one number that cannot report it.

2.3 What that leaves

The derivation instruction stands and is honoured in §3. What could not be derived was a threshold on the wrong quantity. Both failures are written into delivery.py's docstring as well, because the next person to read the card will propose the same predicate.

3. The quantity that does work

Per unit, for a share that asks it to discharge:

short_by(i) = share_i − measured_i          # watts that did not arrive

and the reduction that makes it readable:

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

3.1 The account — the only thing that separates a wedged unit from a cold pack

§2.1 says there is nothing in the power domain. There is exactly one non-power fact this add-on holds about a pack: its own state of charge against its own bms_soc_floor, which SAFETY-32 made a required per-entry key. account() asks it through peakguard.discharge_permitted() — public since SAFETY-32 precisely so that this question has one answer — and writes its own sentence, because that function's wording names the peak guard's floor and would be a false statement here.

Three things account for a shortfall, and each is a state already being handled:

account why it is not this fault
the unit is not live live_units() has already dropped it from the split and the action log says so — firing inside that is the SAFETY-15/19 failure
the unit is parked by a sign trip it is commanded 0 W on purpose; not delivering is compliance
discharge_permitted(soc, bms_soc_floor) the pack is at or below its own cut-out and physically cannot

⚠️ The floor passed in is bms_soc_floor alone, NOT peakguard.pack_floor_pct()'s conjunction with peak_soc_floor. That conjunction is right for the peak guard and wrong here: peak_soc_floor bounds a money-saving discharge only (SAFETY-25), ordinary self-consumption discharges straight past it, and using it would explain away a wedged unit sitting anywhere between the two floors.

⚠️ Every unknown accounts for the shortfall, i.e. fails toward silence — the opposite direction to discharge_permitted()'s own caller, and the right one here: there it withholds a discharge, here it withholds an alarm. The cost is bounded, because the same absence stops the site through inputs_ok (an unreadable SoC) or is refused by the roster schema (an unstated floor).

3.2 The proper subset — what only exists at N ≥ 2

A derate this add-on cannot see — thermal, grid-code, a DC-side restriction — hits every unit that was asked. One box that has stopped listening does not. So if every asked unit is short and unexplained, this module says nothing: it has no instrument that tells that from a plant which simply cannot deliver, and reporting it anyway would be the saturation-shaped alarm produced by the guard written to replace it.

At N = 1 the subset is never proper, for every input, so the check is inert there — not by a roster guard, but by the algebra, the same way circulation.circulating_w() is identically zero at N ≤ 1. That is AC 5, and §3.3's control arm measures it rather than asserting it.

3.3 The window — the one number this card chose

2640 closed-loop runs at a roster of two in which NOTHING is faulty — every unit executes the share the allocator gave it — over 6 gains × 5 slews, the whole range config.yaml accepts for gain float(0.05,1.0) and slew_w int(0,5000) (0 = follow the rail), across three families:

  • 1050 load reversals — 7 sizes (±300 W … ±5000 W) × 5 write staggers;
  • 840 flapping loads — 7 periods (10 s … 600 s) × 4 amplitudes;
  • 750 weighted reversals — 5 splits (0.5, 0.7, 0.9, 0.99, 0.1) × 5 staggers on a ±2200 W reversal. ⚠️ This family did not exist in SAFETY-30's campaign and it is the one that dominates here, for a mechanical reason: an asymmetric split gives one unit a much larger step than its sibling, so the big one is still ramping while the small one has settled — which is precisely the proper subset this check keys on.

Every run is executed a second time at a roster of one on the same stimulus. The N=1 arm is the control, and it is what separates "a property of the check" from "a property of the second inverter".

Worst CONTINUOUS legitimate episode, as a function of the shortfall threshold. ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not committed — SUPERSEDED, and kept here because a record that quietly replaced its own numbers would be worth less than one that shows both:

threshold reversals (of 1050) flapping (of 840) weighted (of 750) N=1 control (1890 runs)
100 W 40.0 s (608) 0.0 s (0) 75.0 s (576) 0.0 s (0)
250 W 55.0 s (490) 0.0 s (0) 60.0 s (480) 0.0 s (0)
500 W 20.0 s (206) 0.0 s (0) 30.0 s (368) 0.0 s (0)
700 W 20.0 s (125) 0.0 s (0) 20.0 s (174) 0.0 s (0)
1000 W 15.0 s (70) 0.0 s (0) 15.0 s (111) 0.0 s (0)
1500 W 10.0 s (51) 0.0 s (0) 10.0 s (50) 0.0 s (0)
2000 W 10.0 s (24) 0.0 s (0) 5.0 s (23) 0.0 s (0)

⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE

admin/goodwe:sim/delivery_window.py --campaign, same three families, same counts, same thresholds, the shipped delinquent():

threshold reversals (of 1050) flapping (of 840) weighted (of 750) N=1 control (2640 runs)
100 W 120.0 s (770) 0.0 s (0) 165.0 s (685) 0.0 s (0)
250 W 65.0 s (687) 0.0 s (0) 100.0 s (590) 0.0 s (0)
500 W 30.0 s (460) 0.0 s (0) 35.0 s (403) 0.0 s (0)
700 W 25.0 s (342) 0.0 s (0) 30.0 s (318) 0.0 s (0)
1000 W 15.0 s (4) 0.0 s (0) 25.0 s (131) 0.0 s (0)
1500 W 0.0 s (0) 0.0 s (0) 20.0 s (43) 0.0 s (0)
2000 W 0.0 s (0) 0.0 s (0) 0.0 s (0) 0.0 s (0)

The floor is 35.0 s, not 30.0 s — weighted family, and the worst single run is gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90 on the ±2200 W reversal. The argmax is printed with the table on purpose (see below): a worst case sitting on an edge of the grid would be a number about the grid.

⚠️ AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the argmax's other axes held fixed:

stagger  0 -> 1 -> 2 -> 3 -> 4      0.0  15.0  25.0  30.0  35.0 s
weights (A) at stagger 4            0.5 -> 20.0   0.7/0.9/0.99 -> 0.0
                                    0.10 -> 35.0 s

Strictly increasing in stagger and maximal at 4, the largest modelled stagger; and maximal at the most asymmetric split. It is interior only in gain (2nd of 6) and slew_w (3rd of 5).

⚠️ SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF THE MODELLED SERIALISED-WRITE STAGGER. That stagger is declared MODELLED at sim/delivery_window.py:60-64 — it is a term of this figure, not a detail of it. A site whose two writes land in the same cycle does not produce this episode at all.

This does not move delivery_hold_s: 185 s must cover the worst case the modelled site can produce, and a stagger of four cycles is inside what a real two-inverter write path can do. But a reader entitled to ask "is 35.0 s about the plant or about the grid?" deserves the answer, and the answer is: partly about the grid, and the grid's shape is the record's own, fixed by §3.3 before this rebuild ran. Found in review of TEST-21; the PR body had claimed the argmax was interior, and it is not.

WHAT REPRODUCED, EXACTLY:

  • the N=1 control column is 0.0 s at every threshold, in every run. That is §3.2's algebra measured, and it is the claim the compatibility promise rests on. (This rebuild ran the control arm over all three families — 2640 runs — where the original ran 1890, i.e. the two families whose stimulus is not weighted. The weighted stimulus at a roster of one is the same stimulus with inert weights, and it too reports nothing at any threshold.)
  • the flapping family is 0.0 s at every threshold, in all 840 runs. The proper-subset rule really does defeat the family that defeated SAFETY-30's site window.
  • 45.0 s at twice A's lag, to the cycle. See the B-lag table below.

WHAT DID NOT:

  • the headline. 35.0 s against a recorded 30.0 s — one control cycle worse, and 20.0 s → 30.0 s on the reversal family, two cycles worse.
  • the non-monotonicity. §3.3 finding 3 read the original 55.0 s at 250 W against 40.0 s at 100 W as a property of the reduction. This rebuild's curve is monotone decreasing on both N=2 families. The mechanism finding 3 describes is real and is still the reason not to read either table as a sensitivity curve — lowering the threshold really can turn a proper subset into the whole roster and break the run — but this grid does not exhibit it, so the claim now rests on the mechanism alone and no longer on a measurement. That is a weaker claim than the one this record made, and it is written down as weaker.
  • the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s / 75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure of how much of the original table was a property of axes nobody wrote down.

⚠️ WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED. §3.3 fixes the counts of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4 amplitudes; 5 staggers; and the 5 splits, which it lists) and the ranges (config.yaml's gain float(0.05,1.0) and slew_w int(0,5000)). It does not fix the individual values, the run length, or the per-unit cap. The rebuild spread them over the admissible ranges and prints every one of them above its own table, so the caveat travels with the number instead of living in somebody's memory. The residual disagreement — one to two control cycles at the shipped threshold — is inside the sensitivity to that choice; the low-threshold rows are not, and are reported as not.

⚠️ AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED. The one substantive correction made during the rebuild was made against the doc's number, not towards it, and it is recorded in §1: grading on the plant's latch rather than the controller's standing command produced a harness that could not name even a wedged unit. Fixing that made the numbers larger. Nothing else was tuned.

Findings 1-3 below are the original campaign's, kept with finding 3 now qualified by the re-run above:

  1. The N=1 column is zero everywhere, in all 1890 runs and at every threshold. That is the compatibility promise measured, not argued.
  2. The flapping family is zero everywhere too — and it is the family that defeated SAFETY-30's site window (980 s at two inverters, 1000 s at one, no window inside the billing quarter surviving it). It cannot touch this check, because a common stimulus makes both units short at the same time and the subset stops being proper. That is the proper-subset rule earning its place on a case it was not designed for.
  3. The curve is NOT monotone — 55.0 s at 250 W against 40.0 s at 100 W — and that is a property of the reduction rather than noise: lowering the threshold puts MORE units into the short set, which can turn a proper subset into the whole roster and break the run. Both the numerator and the denominator move. Do not read this table as a sensitivity curve.

The watts side is not a new option — it is Tuning.saturation_w

main.py hands the check self.tuning.saturation_w (500 W) and there is no delivery_w key anywhere. That is a derivation, not a saving:

  • it is already the gap at which compute() itself concludes the inverter is not following and freezes the integrator. Below it the shipped control law calls the divergence ordinary tracking, and an alarm under a threshold the control law calls normal is an alarm about nothing;
  • saturation_cycles (3) consecutive cycles over that same gap are needed before the freeze engages, so every cycle this counts is a cycle the freeze has already seen. This is a reduction over the freeze's input, not a second opinion about it;
  • CFG-03 already tracks three places a default has to be kept in step. A fourth copy could drift, and the drift that matters has a direction: an operator who raises saturation_w has told the loop that a bigger gap is ordinary tracking, and a check that stayed put would then alarm on it.

Rejected: a delivery_w option, for the three reasons above. A margin above saturation_w (the way circulation_w sits 1.75× above its knee) was also rejected: there is no knee here — the table above is not monotone — so a margin would be a preference, and all the margin this check needs is in the window.

delivery_hold_s: 185 — schema int(30,900)

Four bounds, and the value clears all four.

  • lower, from the rig — 35.0 s (TEST-21, 2026-09-07; the original record said 30.0 s and it did not reproduce). The worst continuous legitimate episode at the shipped threshold across all 2640 runs — weighted family, worst single run gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90 on the ±2200 W reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with admin/goodwe:sim/delivery_window.py --campaign (§1). ⚠️ THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE. At 30.0 s it coincided with the firmware's veto below and a reader could not tell which of the two was doing the work. At 35.0 s the rig's number is on its own, above the firmware's — so the rig is what stops delivery_hold_s going low, and the schema's int(30,900) floor no longer sits above the worst legitimate episode. An operator who sets it to the schema minimum of 30 s is now below a measured legitimate episode and will get false reports; the option is marked derived, do not change and this is what that marking is for. Raising the schema floor to 35 is a change to a shipped option's contract and is left to its own card rather than smuggled in here.

  • lower, from the firmware — 30 s (inverter.LINK_STALE_S). A unit not executing its share for less than the firmware's own veto window is a link fault the add-on already answers by dropping it from the split. Reporting inside that window would be a second, louder alarm about a state already being handled — the SAFETY-15/SAFETY-19 failure exactly.

  • margin, from inverter B not existing. ⚠️ THIS ROW IS A SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL WORDING ("MEASURED, not felt") OVERSTATED IT. Inverter B is not installed (~2026-09-12; NOTES.md §1.1b). The campaign's B is A copied, measured= False, so the 35.0 s above is two identical machines and every row below is a what-if: what a slower second unit would cost this window. No row here is B's lag. What it is genuinely evidence for is the SHAPE of the margin — which term the window has to be sized against — and that is what it is used for. Sweeping a different second unit through the weighted family (750 runs each). Second column is the original record, third is TEST-21's re-run with the committed harness (--blag):

    B lag B ramp 2026-09-06 (uncommitted) TEST-21 re-run runs naming
    10.0 s (A) 800 W/s (A) 30.0 s 35.0 s 403/750
    20.0 s 800 W/s 45.0 s 45.0 s 454/750
    10.0 s 400 W/s 30.0 s 35.0 s 411/750
    20.0 s 400 W/s 45.0 s 50.0 s 454/750
    40.0 s 200 W/s 90.0 s 75.0 s 478/750

    The LAG moves it roughly linearly and the RAMP barely moves it, which is the original finding and it survives — with one correction: "the RAMP does not move it at all" is too strong. Halving the ramp is worth 0 s at A's lag and +5 s at twice it (45.0 → 50.0), so the ramp is a second-order term rather than no term. The shortfall lasts as long as the unit's first-order lag holds it more than saturation_w below its share; at these step sizes the ramp is rarely the binding one. So the number to carry margin against is still B's lag — and a hypothetical unit four times slower than A produces 75.0 s, where the original record said 90.0 s. ⚠️ Note the direction: this row moved in the margin's favour, while the floor above moved against it. Neither was adjusted to suit the other.

  • upper, from the bill — 900 s, the quarter that sets the capacity tariff.

185 s is thirty-seven control cycles. Against the TEST-21 re-run it is 5.29× the measured worst legitimate episode (35.0 s), 2.47× the hypothetical four-times-slower second unit (75.0 s), 6.2× the firmware veto (30 s) and 4.9× inside the quarter (900 s). ⚠️ Deliberately not 180 — that is SAFETY-02's site hold, and a reader must not be able to think this was carried across — and not 150 or 200.

⚠️ 185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE. The record claimed 6.2× the measured worst; the re-run makes that 5.29×. All four bounds still clear, none of them narrowly, and the ordering of the argument is unchanged: the rig's floor is the binding one, the quarter is the ceiling, and 185 sits between them with margin at both ends. So the value is not changed — a shipped safety constant is not moved because its margin went from 6.2× to 5.3× while every bound still holds, and moving it would invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS changed is the claim: this record no longer says 6.2×, and no longer says 30.0 s, because neither is what the harness produces.

What it costs, stated rather than hidden: up to 185 s of a site running short by one unit's share before anybody is told. Against a fault that ran 6 h 25 m unnoticed, and against a guard that takes no action, the window buys its margin with reporting latency and nothing else.

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

Card requirement 3 asks which, and why. The answer is recorded with the evidence against it first, because "alarm-only" is the weaker-looking answer.

Command it harder — no. HW-01 settles it: the power stage executed its last instruction for 6 h 25 m through every command this add-on sent. A command is not a remedy for a box that is not listening.

Stop the site, as SAFETY-31 does — no, and the asymmetry with SAFETY-31 is exact. Circulating power is stopped because this controller is one of the two participants: removing our half halves the loop and un-hides the residue at a meter that was netting it to zero. Here this controller is not a participant, nothing is netting, and the sibling is the only thing still delivering. Parking it takes the site from "short by one unit" to "short by all of them".

Re-allocate its share to the sibling — no, and NOT because the loop already does it. That comfortable answer is false, and the rig says so. Dropping the wedged unit from live — the mechanism live_units() already uses for a dead link — is a real improvement at loads the site cannot cover on one unit's share:

B wedged, per-unit cap 2000 W kept in the split dropped from the split
load 800 W settles inside 100 W from cycle 6 from cycle 8
load 1500 W 800 W delivered, 700 W error, NEVER inside 100 W in 150 cycles 1500 W delivered, inside 100 W from cycle 14
load 2000 W 800 W delivered, 1200 W error, never settles 2000 W delivered, inside 100 W from cycle 8

The mechanism is the saturation freeze doing its job on a site comparison that a non-delivering unit has poisoned: |site target − site batt| stays over saturation_w forever, so the target cannot rise and the sibling's share never grows. That is not three cycles of latency; it is the difference between a site that recovers and one that does not.

What refuses it anyway is that the only re-allocation this add-on can express is all or nothing. There is no way to say "give the sibling the watts that did not arrive and leave this unit the ones that did". And a unit delivering part of its share is short by more than saturation_w too, with nothing here to explain it — so this detector names it, and cannot tell a 50 % derate from a wedged box, because no instrument this add-on has can:

B derated to 50 %, load 3000 W, cap 2000 W site delivers B contributes grid error
kept in the split 2400 W 800 W 600 W
dropped 2000 W 0 W 1000 W — 400 W worse

SAFETY-42 names B in that run. So the remedy would make the site worse, in a case the detector cannot rule out, on a live control law, driven by a brand-new alarm. A partial re-allocation is what would make acting safe, and the allocator has no way to express it today. That is a card, and this section is its evidence.

test_delivery.py part 5 asserts the decision as a counterfactual rather than by reading the code: the same fixture run with the report tripping and with it never tripping produces byte-identical grid, site target, shares and measured power on every cycle.

5. Requirement 4: the saturation freeze is untouched

control.py is not modified by this card, at all. The freeze still shapes the loop; this reports on it. test_delivery.py part 6 asserts what each neighbouring guard concludes on the same fixture — SAFETY-30 per-unit silent, SAFETY-30 site silent, SAFETY-31 identically zero, the freeze firing at the same counter on both arms — which is the honest version of "every existing guard is silent", including the one that is not.

6. Surviving a restart (SAFETY-34)

persistent_notification is in memory. Home Assistant restarts, the card is gone, and the fault runs on — precisely how the 2026-09-02 outage went unseen for 6 h 25 m with the detector correct throughout. It matters more here than anywhere else in this add-on, because SAFETY-02 and SAFETY-31 at least leave a stopped site for somebody to notice; this one leaves a site that looks like it is working.

  • durable half: unit/<name>/delivery_tripped / delivery_reason, namespaced per unit for store.unit_key()'s reason, written reason first.
  • visible half: _delivery_announced, a per-process set of unit names, deliberately not persisted. A restart empties the set and keeps the latch, so the alarm is re-raised on the first cycle of every process — one code path for a fresh trip and for a restart into an existing one, check_circulating()'s argument.
  • no flat mirror, where SAFETY-02 has one. store.py's rule 2 keeps the flat keys so a rollback still finds its latch, and the version being rolled back to drives exactly one actuator — a roster at which this check is inert.
  • one notification id per unit, built from the same unit_slugs() TEL-07 announces the MQTT entities under. Two wedged inverters must produce two cards, not one replacing the other.
  • it latches, and not for SAFETY-02's reason. Nothing here removes its own evidence, because nothing here changes what is commanded. It latches so that a unit which stopped delivering for minutes and then resumed cannot erase its own record — the intermittent fault is the one an operator most needs told about. Cleared by the same Start press as the others.
  • the status row is ok: False, warn: True — this list's caution. Every other latched row means the site is at 0 W and a red cross is the truth; this one means the site is still running and quietly short, and a red cross would say the controller had stopped.

7. What this does NOT catch, said plainly

  • Charge shares are not graded. The account gate needs a bound this controller reads in the commanded direction. There is one for discharge (bms_soc_floor, required of every roster entry since SAFETY-32) and none for charge. A pack in constant-voltage taper legitimately takes hundreds of watts less than it is told for tens of minutes, and arming that direction would fire on the taper of every §12 maintenance charge. The same fault in the other direction wants the same treatment; what it needs first is a per-pack charge ceiling this add-on does not have. That is a card.
  • Every unit wedged at once is not caught. The subset is not proper, and there is no instrument here that separates that from a plant which simply cannot deliver. The site is short at the meter in that case, which is where the existing guards can see it.
  • A pack that refuses above its configured floor is reported. That is deliberate: either the unit has stopped delivering, or bms_soc_floor does not describe that battery. The notification says both, in those words.
  • A unit with a share of 0 W is not graded, and neither is it a peer. With only one unit asked to discharge there is nothing to compare against.

8. Mutation ledger

Every run on a freshly copied tree of its own, __pycache__ purged and the count printed, mounted :ro, python3 -B — MUTATION.md's rule, followed because the first SAFETY-30 battery reused one directory, a container mounted it mid-copy, read a missing config.yaml as a failing check, and the whole run was discarded. The baseline is green (23/23) or nothing below is a result. Byte deltas measured, not estimated; the +0 B ones are marked, since a size-preserving edit is what TEST-15 turned into a false survivor once already.

# file mutation Δ result
M1 delivery.py the SoC account removed — a cold pack at its floor alarms −31 B RED — test_delivery.py. This is the card
M2 delivery.py the proper-subset rule removed — a whole-site cause names every unit −34 B RED
M3 delivery.py the len(asked) < 2 fast path relaxed to < 1 +0 B SURVIVOR — and an EQUIVALENT MUTANT. See below
M4 delivery.py the strict bound relaxed to >= — a gap the control law calls tracking is named +1 B RED
M5 delivery.py share <= 0 relaxed to share == 0 — charge shares graded +0 B SURVIVOR on the first battery — GAP CLOSED, then RED. See below
M6 delivery.py delivery_hold_s 185 → a round 300 +0 B RED — the CFG-03 pin
M7 main.py the watts threshold no longer Tuning.saturation_w (×4) +6 B RED
M8 main.py the latch never persisted — nothing survives a restart −35 B RED
M9 main.py _delivery_announced never set — one notification per cycle −34 B RED
M10 main.py peakguard.pack_floor_pct()'s conjunction with peak_soc_floor used instead of the pack's own cut-out +151 B RED — the between-the-floors fixture
M11 main.py inv.is_live(now)[0] replaced by a literal True −15 B SURVIVOR at review — GAP CLOSED, then RED. See below
M12 inverter.py the latch not restored from the store on start-up −46 B RED

11 of 12 red. One survivor, argued rather than left.

M5 was a real gap, and the two obvious checks did not close it

test_delivery.py already asserted short_by(-2000, 0) is None and short_by(-2000, -100) is None — and both stayed green under the mutant. For a charge share that is under-delivered, share − measured is negative and the gap > 0 test answers None anyway, so those two inputs cannot separate share <= 0 from share == 0. The one input that can is a unit moving more than its charge share — -2000 W told, -3000 W measured, share − measured = +1000 W — which the mutant reports as a shortfall. It is not one; it is something else driving that pack, which is SAFETY-30's and SAFETY-31's question. The check now uses that input and the mutant is red.

This is MUTATION.md's third failure mode in miniature: the assertion ran, the sentence was true, and the data could not reach the failure it named.

M11 was a wiring gap the pure checks structurally could not see

account(live=False, …) was already graded — as a function. What was not graded was that cycle() actually asks inv.is_live(now), and replacing that call with a literal True left every check green, because no fixture had a unit whose writes were being rejected. A dropped unit still has its last accepted command standing on the wire (write_setpoint leaves last_write alone on a rejected write) and still measures nothing, so it looks exactly like a wedged one to every number this check reads — and reporting it would be the second, louder alarm about a state already being handled that this module takes its 30 s floor from. test_delivery.py part 6 now builds that unit (with rejected_since back-dated past LINK_STALE_S, because the fixture runs in two seconds and the timer is test_inverter.py's to grade) and the mutant is red.

M3 is recorded as a survivor because it is one — and it is EQUIVALENT

len(asked) < 2 → len(asked) < 1. With one asked unit, unaccounted is a subset of a one-element set, so it is either empty — not unaccounted returns {} — or equal to asked — len(unaccounted) == len(asked) returns {}. There is no third case, and with zero asked units both spellings return {} as well. No input separates them, so no check can, and one written to try would be a check about nothing.

⚠️ And finding it corrected the module's own docstring, which is the more useful half. That paragraph claimed len(asked) < 2 was what makes N=1 inert. It is not: the proper-subset rule is, and the line is a fast path and a signpost. A false attribution of the mechanism in the file's own prose is exactly what this repository keeps finding, and a mutation that survives is how it surfaced. The line stays — it tells a reader at a glance that a one-inverter install never reaches any of this — with the equivalence written down beside it, the way circulation.circulating_w() records its own max(0.0, …).

Not separately mutated, and said rather than left implicit

account()'s parked branch is graded as a function (part 1) but its wiring — parked=inv.sign_wd.tripped — is not mutated on its own. Part 6's reversed-unit fixture exercises the path end to end and asserts the account it produces, which is weaker than a mutation and is stated as such.

9. Round 2 — the thresholds this check reads, and which of them are pinned at a changed config

Everything in §8 mutated the logic. This section asks a narrower question that §8 never asked, and it found two live survivors of the whole 23-file suite:

for each number main.py hands this reduction, is the check still red when that number is replaced by a literal equal to the value the fixtures use?

That is not pedantry about style. Two of these numbers are options, and delivery.MIN_W_IS_SATURATION_W and the delivery_hold_s block in config.yaml both make a promise about them in shipped, operator-readable prose: there is no fourth copy of a default to keep in step, and no way for the two to drift, because an operator who raises saturation_w has told the loop that a bigger gap is ordinary tracking. A literal at the call site keeps every fixture green and removes exactly that promise — and the promise is why the design was chosen over a fourth option key.

The enumeration

# what the call site reads mutated to Δ before round 2 now
R5 self.tuning.saturation_w 250.0 −19 B SURVIVED all 23 suites red — part 4b(a)
R6 bms_floor=inv.bms_soc_floor 10.0 −13 B SURVIVED all 23 suites red — part 4b(b)
R7 arm(self.delivery_cfg) arm(DeliveryConfig()) −1 B red red — part 4 already ran at a hold of 1 s, not the shipped 185 s
R8 inv.is_live(now)[0] inv.is_live(now, 3600.0)[0] +8 B red red — part 6's link-dead fixture
R9 bms_floor=inv.bms_soc_floor the conjoined floor, max(pack cut-out, site peak_soc_floor) +105 B red red — and now red twice: the pre-existing between-floors check at shipped floors, and part 4b(b)'s shallow arm at a cut-out that is not shipped

R7 and R8 were already pinned across a changed setting, and R5 and R6 were not. The difference is entirely in the fixtures, not in the code: delivery_hold_s had to be moved off 185 s for any of this file to run inside a two-second test, and part 6 had to back-date rejected_since past LINK_STALE_S to build a dropped unit at all. Nothing ever forced saturation_w off 500 or a bms_soc_floor off 10 — so those two sat at the shipped value in every fixture, and a literal equal to the shipped value is invisible to a fixture that only ever uses the shipped value. That is this repository's recurring defect stated in one line: a fixture whose values cannot reach the failure it names.

LINK_STALE_S (R8) is the one number here with no option behind it — it is a module constant in inverter.py and no test reassigns it — so "pinned across a changed config" is not available for it, and is not claimed. What part 6 pins is that widening the window changes the answer. Spelling is_live(now, 30.0) at the call site would be an equivalent mutant today and would only drift if the constant moved, which is a source change and not a config change.

Why R5 and R6 are not equivalent mutants — the inputs that separate them

Both are separating inputs found by asking where the shipped answer and the literal's answer can differ, and both are configs the shipped schema accepts.

R5, watts. Two copies of inverter A, B wedged, saturation_w: 1800 (inside its own schema int(100,2000)), load 1600 W. B's standing share and its shortfall then land strictly between 250.0 and the configured 1800 W:

  • head → named = {} — the operator raised the threshold and this check moved with them, which is the whole promise;
  • R5 → named = {inverter-b} — an alarm at a gap the control law has been told is ordinary tracking.

At the shipped saturation_w: 500 both name it, which is why every fixture in part 4 is blind: their gaps are all above both thresholds.

R6, percent. The literal is invisible because bms_soc_floor: 10 is what config.yaml's own inverters: example states and what every fixture in test_delivery.py used. Two arms, on either side of it, both inside the schema int(0,50) and both validate.py rule-8-valid:

  • a pack at 15 % against its own 25 % cut-out is genuinely cold. Head stays silent; R6 grades it against 10 %, finds it "permitted", and names an empty pack — the crying-wolf failure this card exists to remove.
  • a pack at 8 % against its own 5 % cut-out is genuinely wedged. Head names it; R6 grades it against 10 %, calls it a cold pack, and loses the fault.

One arm on each side is what leaves no constant at all: a single deeper arm kills 10.0 but not 50.0, a single shallower arm kills 10.0 but not 0.0. The two arms are byte-identical in the power domain on every cycle — the configured cut-out is the only thing that differs, which is §3.1's claim made again at cut-outs that are not the shipped one.

What is still not pinned this way, said rather than left implicit

parked=inv.sign_wd.tripped and standing[inv.name]/inv.batt are wiring, not thresholds: there is no "value the fixture happens to use" for a boolean or for a measurement, so this question does not apply to them, and §8's last note already records that the parked wiring is graded end-to-end rather than by mutation.