SAFETY-43: delinquency measured against the ask, not against a flat 500 W
checks / unit (pull_request) Successful in 6m20s

`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
This commit is contained in:
Claude Opus 5
2026-09-08 03:14:16 +02:00
parent 6719bd882f
commit 2271e147c6
9 changed files with 948 additions and 145 deletions
+123 -6
View File
@@ -414,6 +414,93 @@ DELIVERY_OPTION_DEFAULTS = {
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
# ---------------------------------------------------------------------------
# SAFETY-43. THE SECOND WAY TO BE NAMED, AND WHY IT IS A FRACTION OF THE ASK.
#
# `min_w` above is an ABSOLUTE gap, and OPT-09 made that a hole. `weights()`
# shrinks a low-headroom unit's share, and shrinking the share shrinks the gap:
# a unit standing at +125 W and delivering 0 W is 100 % delinquent and 125 W
# short, so an absolute 500 W never names it. Measured end to end
# (`test_packshare.py` part 8b, real `compute()`, real `plan()`, real
# `delinquent()`, B wedged at a healthy 15 % against its own 10 % cut-out):
#
# load 1500 W, equal weights the wedged unit named on 199/200 cycles
# load 1500 W, OPT-09 weights named on 0/200 cycles
#
# ⚠️ AND 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.
#
# ⚠️ THE FIX IS NOT A SMALLER `min_w`, AND THAT IS THE WHOLE SHAPE OF THIS
# CARD. `min_w` is `Tuning.saturation_w` and it exists to stop NORMAL
# SATURATION being reported as delinquency - the crying-wolf failure the whole
# module is written against, and the one SAFETY-41 spent four attempts on
# ("i'm getting really annoyed by the notifications in my HA"). Lowering it
# trades a missed alarm for a false one on every unit at once. A fraction of
# the unit's OWN SHARE is a SECOND way to be named that leaves the first
# exactly where it was: `gap > min_w OR gap > frac * share`. Nothing that was
# silent before this card becomes noisy unless the unit is delivering almost
# none of what it was asked for, which is not saturation-shaped at all.
#
# WHERE 0.85 COMES FROM - MEASURED ON THE SAME CAMPAIGN AS `delivery_hold_s`.
# `admin/goodwe:sim/delivery_window.py --campaign --fractions` grades this
# constant over the SAME 2640 no-fault runs (three families x 6 gains x 5 slews
# x the write staggers, every unit executing the share it was given) that
# produced the 35.0 s worst legitimate episode, at the shipped `min_w`, and
# reports what the relative arm ADDS. Worst continuous legitimate episode,
# worst of the three families:
#
# 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's figure, re-measured in the
# SAME runs, which is what makes every row above a comparison and not a claim.
# N=1 control: 0.0 s at EVERY fraction, in all 2640 one-inverter runs.
# Flapping family: 0.0 s at EVERY fraction, in all 840 runs.
#
# ⚠️ READ THE CURVE, NOT THE ROW. THERE IS NO CLIFF ANYWHERE IN IT. From 0.45
# to 0.99 every figure is 40.0 or 45.0 s - within ONE CONTROL CYCLE (5.0 s) of
# every other - and below 0.45 it rises gradually to 90.0 s at 0.05. It is not
# monotone either (0.40 reports 60.0 s where 0.35 reports 55.0), which is
# §3.3's own mechanism (c) rather than noise in the plant: a more sensitive rule
# puts MORE units into the short set, which can turn a proper subset into the
# WHOLE roster and END the episode. So this constant is INSENSITIVE over the
# whole usable range, and that is the finding rather than a caveat on it. 0.85
# sits in the flat part, 0.40 above the shoulder at ~0.45 and 0.15 below 1.0.
#
# ⚠️ AND "ZERO FALSE NAMINGS" IS NOT AVAILABLE AT ANY FRACTION - said here
# because the card asked for exactly that. Every fraction adds naming CYCLES to
# the no-fault campaign; a relative arm that never fired on a legitimate
# transient would be one that never fired at all, because the modelled write
# stagger lands B's number up to four cycles late and a unit whose new command
# has not arrived IS delivering a small fraction of what is standing on its
# wire. What is zero at every fraction is false ALARMS: the longest legitimate
# episode anywhere in the 20-point sweep is 90.0 s (at 0.05, twelve times more
# sensitive than shipped) against a `delivery_hold_s` of 185 s, so NOTHING in
# 2640 no-fault runs comes within 95 s of notifying an operator at any fraction
# measured. At 0.85 the hold is 4.62x the worst legitimate episode where the
# absolute arm alone gave 5.29x. That is the price, in full.
#
# ⚠️ THE DIRECTION IS THE OPPOSITE OF `min_w`'s AND IT IS EASY TO GET BACKWARDS.
# A SMALLER fraction names MORE units - it is the more sensitive setting and the
# more dangerous one - so the campaign puts a LOWER bound on this number, not an
# upper one. It is bounded ABOVE by the card's own case: at 1.0 the arm can only
# fire on a unit that is IMPORTING while asked to discharge, which is
# SAFETY-30's fault and not this one, so a unit delivering NONE of its share
# (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
# wedged box reporting a small standby figure rather than a clean zero.
#
# ⚠️ AND IT IS NOT AN OPTION, FOR `delivery_w`'s REASON ONE PARAGRAPH UP. An
# operator has no instrument to derive it with, a knob here is a knob on how
# loud this add-on is, and SAFETY-41 is the record of what that costs. It is a
# derived constant with a committed harness behind it, and the way to move it
# is to re-run the campaign.
DELIVERY_SHARE_FRACTION = 0.85
@dataclass(frozen=True)
class DeliveryConfig:
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
@@ -532,12 +619,30 @@ def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
return None
def delinquent(units, min_w: float) -> dict:
def delinquent(units, min_w: float,
share_frac: float = DELIVERY_SHARE_FRACTION) -> dict:
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
`units` one `UnitDelivery` per inverter, in roster order.
`min_w` the shortfall that counts - `control.Tuning.saturation_w`. See
`MIN_W_IS_SATURATION_W`.
`units` one `UnitDelivery` per inverter, in roster order.
`min_w` the ABSOLUTE shortfall that counts - `control.Tuning.
saturation_w`. See `MIN_W_IS_SATURATION_W`.
`share_frac` SAFETY-43. The RELATIVE shortfall that counts, as a fraction
of that unit's own share. See `DELIVERY_SHARE_FRACTION`; it
is a parameter rather than a read of the constant so the
campaign that derived it can sweep it over the shipped code.
⚠️ `None` MEANS THE ABSOLUTE ARM ALONE - SAFETY-42's rule,
exactly - and it exists for ONE caller: the campaign in
`admin/goodwe:sim/delivery_window.py`, which has to be able
to measure what this card ADDS against what it replaced, run
for run, over ONE tree. Nothing in the add-on passes it, and
`main.py` passes nothing at all.
⚠️ THE TWO ARMS ARE AN `or` AND THE ORDER OF THAT SENTENCE MATTERS. The
absolute arm is untouched by SAFETY-43: everything `min_w` named before it
is still named, and nothing `min_w` refused becomes noisy unless the unit
is delivering almost NONE of its ask. Lowering `min_w` would have been the
other change - the one that reports normal saturation on every unit at once
- and `DELIVERY_SHARE_FRACTION` says at length why it was refused.
Returns `{name: sentence}`, empty when there is nothing to say. The sentence
is the operator's first line and it carries the peer clause, because "B is
@@ -579,7 +684,13 @@ def delinquent(units, min_w: float) -> dict:
gaps = {}
for u in asked:
gap = short_by(u.share_w, u.measured_w)
if gap is not None and gap > float(min_w):
if gap is None:
continue
# SAFETY-43: absolute OR relative. `share_w` is > 0 for every member
# of `asked`, so the second comparison is always well formed.
if gap > float(min_w) or (share_frac is not None
and gap > float(share_frac)
* float(u.share_w)):
gaps[u.name] = gap
unaccounted = {u.name: gaps[u.name] for u in asked
@@ -595,7 +706,13 @@ def delinquent(units, min_w: float) -> dict:
out[u.name] = (
f"{REASON_CODE}: {u.name} is standing at {float(u.share_w):+.0f} W "
f"and delivering {float(u.measured_w):+.0f} W - "
f"{unaccounted[u.name]:.0f} W of its share is not arriving - while "
f"{unaccounted[u.name]:.0f} W of its share "
# SAFETY-43. The PROPORTION is in the sentence because the watts
# alone stopped being the whole story the moment a unit could be
# named on the relative arm: "125 W is not arriving" reads like a
# rounding error, and "125 W (100 % of its share)" is the fault.
f"({100.0 * unaccounted[u.name] / float(u.share_w):.0f} % of it) "
f"is not arriving - while "
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
f"that state. Nothing this add-on reads explains it")
return out
+30 -17
View File
@@ -57,8 +57,8 @@ deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
never rises. That module DETECTS it; this one cannot fix it, and saying so
is worth more than a weighting scheme that looks like it might.
⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
⚠️ AND IT WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's
DETECTION WINDOW - A DETECTION REGRESSION AT N=2, NOW CLOSED BY SAFETY-43
=======================================================================
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
@@ -68,27 +68,40 @@ pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
moves whenever its headroom differs from its sibling's - the ordinary case this
module exists to exploit.
`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds
`delivery.delinquent()` named a unit only while its `short_by()` gap exceeded
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
own 10 % floor, so `envelope()` does not close and `account()` explains
nothing): at a 1500 W load the healthy pack can carry alone, equal weights name
the wedged unit on 199 of 200 cycles and these weights name it on NONE. The
suppression window widens from `site target < 2 * min_w` = 1000 W to
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has
own 10 % cut-out, so `envelope()` does not close and `account()` explains
nothing): at a 1500 W load the healthy pack can carry alone, equal weights named
the wedged unit on 199 of 200 cycles and these weights named it on NONE. The
suppression window widened from `site target < 2 * min_w` = 1000 W to
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it had
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
hands the wedged unit a big share again and the alarm returns.
hands the wedged unit a big share again and the absolute arm names it.
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE - KEPT HERE BECAUSE IT IS A MEASUREMENT, EVEN THOUGH SAFETY-43 HAS SINCE MADE THE WINDOW IRRELEVANT TO THE DETECTOR. Swept on this fixture with the ABSOLUTE arm alone, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returned BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm named the wedged unit 199/200 at every load swept, 1000 W included.
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
is a disclosed residual, not a fixed thing. Closing it means making
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
and a design change, filed as its own card and deliberately NOT done here.
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
and a green check that says otherwise would be worse than the gap.
⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to
the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a
unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W.
On the SAME fixture, under the SAME weights, the wedged unit is now named
on 200 of 200 cycles. Part 8b grades both rules on one run:
`share_frac=None` is SAFETY-42's absolute arm alone and still says nothing,
which is what keeps the paragraph above honest rather than nostalgic.
⚠️ AND `min_w` WAS NOT LOWERED. The absolute arm's suppression window is
still exactly the arithmetic above - 1000 W at equal weights, 6500 W at
these - because a lower `saturation_w` would report NORMAL SATURATION as
delinquency on every unit at once, which is the false-trip regime SAFETY-41
spent four attempts in. What changed is that there is a SECOND way to be
named; nothing that was quiet before became noisy.
⚠️ WHAT THIS MODULE STILL CANNOT DO IS UNCHANGED. OPT-09 stops the site
ASKING a pack whose own SoC says it cannot deliver; it does not, and will
not, re-allocate a wedged unit's watts to its sibling - `delivery.py`'s
"IT REPORTS. IT DOES NOT ACT" block is the measurement behind that, and
SAFETY-43 did not touch it. The alarm is louder; the loop is identical.
WHY ENERGY AND NOT POWER
========================