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
+65
View File
@@ -485,6 +485,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `unit_sign_disagree_s` | 85 | **Two or more inverters only.** How long one unit's command and its own power must stay opposed before that unit is parked |
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
#### The sign-disagreement failsafe
@@ -584,6 +585,70 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
10.5 % and 0.0 %. There is no reason to move either value without a capture that
says so.
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
The two checks above both key on **direction**. A unit sitting at **0 W against
a non-zero share** has no direction: it is not pushing the wrong way, it is
**absent**. So the per-unit sign gate never opens (it needs opposition on both
sides of `unit_sign_disagree_w`), the site sum still agrees with the meter in
direction, and circulating power is exactly **zero** — that quantity needs two
units pulling *against* each other, and one of these is pulling nowhere.
The only thing that does fire is the **saturation freeze**, and this add-on's own
code calls that an alarm operators learn to ignore, because a loop can be legally
saturated all day: a cold pack, a curtailed inverter, an export limit.
So `delivery_hold_s` grades a different quantity — **how many watts of a
discharge share did not arrive** — and makes it readable with a reduction that
only exists once there is more than one inverter:
> a unit is named while it is short of its share by more than `saturation_w`,
> **with nothing this add-on can read to explain it**, and the units in that
> state are **fewer than the units that were asked to discharge**.
Both halves matter, and the second is why this cannot fire on a
one-inverter install at all:
- **"nothing explains it"** is what keeps a legitimately empty pack out. A pack
at or below its own `bms_soc_floor` cannot discharge, and that is the *only*
fact this add-on holds that distinguishes an empty pack from a wedged one —
the two are otherwise identical at the unit and at the meter, watt for watt.
A unit the add-on has already dropped from the split, or parked by a sign
trip, is likewise accounted for.
- **"fewer than were asked"** is what keeps a whole-site cause out. A derate this
add-on cannot see — thermal, grid-code, DC-side — hits every unit. One box that
has stopped listening does not. If *every* unit that was asked is short and
unexplained, nothing is reported: there is no instrument here that tells that
from a plant which simply cannot deliver.
**It reports. It does not act.** Commanding an inverter that has stopped
listening achieves nothing — that happened on this hardware once for 6 h 25 m —
and the only re-allocation this add-on can express is all-or-nothing, which
would throw away the output of a unit delivering *part* of its share. So the
loop is untouched, the sibling keeps regulating, and the site keeps running
**short by that unit's share** until somebody looks. The status page shows it as
a caution rather than a failure for exactly that reason: the controller has not
stopped.
The report **survives a Home Assistant restart** (`unit/<name>/delivery_tripped`
in the add-on's own state file) and the notification is re-raised on the next
start, because a `persistent_notification` is in memory and that is precisely how
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
other latches.
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure,
and it **displaces** `Running — holding grid at zero`. That last sentence is a
claim the site cannot make while a unit is absent, and a page that goes on
reading healthy while the fault runs is the whole shape this check exists to
remove.
**Charge shares are not graded**, and that is a stated gap rather than an
oversight: there is no configured charge ceiling anywhere in this add-on, so a
pack in constant-voltage taper — which legitimately takes hundreds of watts less
than it is told, for tens of minutes — could not be explained, and arming that
direction would fire on the taper of every maintenance charge.
#### Saturation is counted in cycles, not seconds
The specification states the saturation window as **"> 10 s"**. This add-on counts