Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md
T
Claude 090a8ecc35
checks / unit (pull_request) Successful in 6m7s
SAFETY-42 round 2: pin the two thresholds at a config that is not the shipped one
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
2026-09-06 19:25:45 +02:00

34 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 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.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. ⚠️ 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.