Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-42-unit-delivery.md
T
glenn schrooyenandClaude Opus 5 ccc4bf100c
checks / unit (pull_request) Successful in 5m54s
SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
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
2026-09-06 15:46:00 +02:00

24 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