SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
checks / unit (pull_request) Successful in 5m54s
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:
co-authored by
Claude Opus 5
parent
c8dcb38a95
commit
ccc4bf100c
@@ -122,6 +122,7 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
from .delivery import DeliveryConfig, DeliveryWatchdog
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
@@ -166,6 +167,13 @@ UNIT_A = "inverter-a"
|
||||
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
|
||||
# reads this same mapping, so a key renamed here cannot silently stop being
|
||||
# migrated. See store.py.
|
||||
# ⚠️ SAFETY-42's PAIR IS DELIBERATELY NOT IN THIS TUPLE, AND THE ABSENCE IS THE
|
||||
# STATEMENT. The values here are the PRE-DEV-02 FLAT key names that
|
||||
# `store.migrate_unit_keys()` moves under a unit; `delivery_tripped` /
|
||||
# `delivery_reason` never existed flat, because the check that writes them
|
||||
# cannot fire at a roster of one (see delivery.py) and a one-inverter install is
|
||||
# the only install that ever had flat keys. Adding them here would make the
|
||||
# migration look for keys that cannot exist and would report them as absent.
|
||||
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
|
||||
@@ -184,7 +192,8 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
bms_soc_floor=None, store=None, sign_cfg=None):
|
||||
bms_soc_floor=None, store=None, sign_cfg=None,
|
||||
delivery_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
@@ -274,6 +283,25 @@ class Inverter:
|
||||
tripped=bool(data.get(self.key("sign_tripped"), False)),
|
||||
reason=str(data.get(self.key("sign_reason"), "") or ""))
|
||||
|
||||
# --- SAFETY-42, one instance per unit, under NAMESPACED keys ---------
|
||||
# ⚠️ PER UNIT BECAUSE THE CONCLUSION IS ABOUT A UNIT. "This box is not
|
||||
# producing what it was told, while its sibling is" names a device, and
|
||||
# naming the device is card requirement 2 - the site figure cannot say
|
||||
# which one to look at, which is the whole reason SAFETY-30 split
|
||||
# per-unit from site. The site-level REDUCTION that decides whether a
|
||||
# unit qualifies lives in `delivery.delinquent()` and is computed once
|
||||
# per cycle in `Controller.cycle()`; what is per-unit here is the
|
||||
# WINDOW and the LATCH.
|
||||
#
|
||||
# ⚠️ AND IT IS RESTORED FROM THIS UNIT'S OWN KEYS, LIKE `sign_wd`. A
|
||||
# latch held only in memory is not a latch: Home Assistant restarts, the
|
||||
# `persistent_notification` goes with it, and the fault runs on unseen.
|
||||
# That is SAFETY-34, and it is how the 2026-09-02 outage lasted 6 h 25 m.
|
||||
self.delivery_wd = DeliveryWatchdog(
|
||||
delivery_cfg or DeliveryConfig(),
|
||||
tripped=bool(data.get(self.key("delivery_tripped"), False)),
|
||||
reason=str(data.get(self.key("delivery_reason"), "") or ""))
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
@@ -436,7 +464,8 @@ def roster_names(opts: dict) -> list:
|
||||
return names
|
||||
|
||||
|
||||
def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
def build_roster(opts: dict, store, sign_cfg=None,
|
||||
delivery_cfg=None) -> list:
|
||||
"""The site's inverters, in roster order.
|
||||
|
||||
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
||||
@@ -490,7 +519,7 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# mirror exactly as validate.py rule 8 defaults it, so an install
|
||||
# that never set the key reads the same 10 % in both places.
|
||||
bms_soc_floor=_flat_bms_floor(opts),
|
||||
store=store, sign_cfg=sign_cfg)]
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
|
||||
|
||||
roster = []
|
||||
names = roster_names(opts)
|
||||
@@ -517,7 +546,7 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# SAFETY-32's own defect written in three lines - the shallower pack
|
||||
# graded against the deeper pack's BMS.
|
||||
bms_soc_floor=_entry_bms_floor(entry),
|
||||
store=store, sign_cfg=sign_cfg))
|
||||
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
|
||||
return roster
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user