SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
checks / unit (pull_request) Successful in 5m54s

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-06 15:46:00 +02:00
co-authored by Claude Opus 5
parent c8dcb38a95
commit ccc4bf100c
12 changed files with 2329 additions and 5 deletions
+52
View File
@@ -31,6 +31,58 @@ guessing a boundary; saying so is more useful than a guess.
## Unreleased
⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.**
`delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The
Supervisor keys the built image by `version:`, so this needs a bump before it
ships - see the 0.7.0 heading above, which makes the same point about
`bms_soc_floor`. The key is ADDITIVE and defaulted, so an existing options blob
stays valid; what a missing bump costs is that nobody's Supervisor re-reads the
schema.
**Two or more inverters only, and at one inverter it is inert by construction.**
A unit standing at a discharge share it is not producing - short by more than
`saturation_w`, with nothing this add-on can read to explain it, while at least
one sibling is NOT in that state - is named after `delivery_hold_s`. That fault
was invisible to everything: the per-unit sign check needs OPPOSITION and a unit
at 0 W is not opposing but ABSENT, the site sum still agrees with the meter in
direction, circulating power is exactly zero when one unit does nothing, and the
only remaining trace is the saturation freeze - which `clamp.py` itself calls an
alarm operators learn to ignore. It is [[HW-01]]'s shape, which ran 6 h 25 m.
⚠️ **IT REPORTS. IT DOES NOT ACT.** Nothing is commanded differently, the loop is
untouched, `control.py` is not modified, and the site goes on running short by
that unit's share. Commanding an inverter that has stopped listening achieves
nothing (HW-01), and the only re-allocation this add-on can express is
all-or-nothing - measured on the rig, dropping a unit derated to 50 % from the
split leaves the site **400 W worse** than keeping it, and this detector cannot
tell that unit from a wedged one. The status row is a CAUTION, not a failure,
because the controller has not stopped.
⚠️ **THE CARD'S OWN DISCRIMINATOR WAS FALSIFIED, AND THAT IS THE MAIN CONTENT.**
SAFETY-42 asked for "saturation persisting past N while |grid error| > X, both
derived". Built and run on the two-master rig first, it fails in both
directions, and no (N, X) fixes either: a pack at its own floor and a wedged unit
are **identical on all 120 cycles** of a run in grid, site target, every share
and every measured watt - so the benign case fires - and with a sibling that has
headroom the standing grid error collapses to **30 W** while the wedged unit
stands at a +770 W share delivering nothing, so the real case does not. What
replaces it is a per-unit shortfall plus two reductions the card did not ask for:
an ACCOUNT (the pack's own `bms_soc_floor`, the only non-power fact that
separates an empty pack from a wedged one) and a PROPER SUBSET of the units asked
to discharge (which is what keeps a whole-site derate out, and what makes this
inert at N=1 - measured zero in all 1890 one-inverter runs).
The report **survives a Home Assistant restart** ([[SAFETY-34]]) under
`unit/<name>/delivery_tripped`, with one notification id per unit, re-raised on
the first cycle of every process and cleared by the same **Start** press as the
other latches. **Charge shares are deliberately not graded** - there is no
configured charge ceiling to explain a constant-voltage taper with, and arming
that direction would fire on every maintenance charge.
`DECISIONS/SAFETY-42-unit-delivery.md` carries the campaign (2640 healthy
two-inverter runs plus 1890 at one inverter as the control), the
inverter-B sensitivity sweep the margin is sized on, and the mutation ledger.
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
`0x0518`) goes through a freshness gate: unchanged for >= 35 s *while* the