checks / unit (pull_request) Successful in 5m16s
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where "capping how far the loop can jump ahead of the inverter" was true because the rail itself bounded the excursion. The live install went to max_w 5000 and the constant did not move, so the loop crosses its whole output range in 10 s against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five 2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved. REPRODUCED FIRST, and one half of it is not reproducible at all. On plantsim.Plant and on an independent in-repo model: the setpoint stepping a full 1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from export into import. The -5154 W figure is NOT reproducible in either model, because both clamp the plant at its rating - so the card's AC "battery no longer exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met. THE DELIVERABLE IS THE RELATIONSHIP. SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9 slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as integrator_max_w = 0 already means follow max_w. It is the shipped default: 222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. validate.py rule 10 refuses an explicit value faster than that, graded against sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a second inverter moves the bound with no operator action. VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the refusal names the rail, the crossing time, the measurement and what to do. Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating all still start. GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51 export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250) and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W rail in under three ticks whatever the slew is. Two control-law constants moving in one release on a running house makes the next measurement uninterpretable. ⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant did - both stated in the record rather than left to be discovered. Nothing reached the inverter. The live install still runs the interim slew_w 400 applied by hand on 2026-09-05, which rule 10 accepts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
352 lines
19 KiB
Python
352 lines
19 KiB
Python
"""SAFETY-31 - the power two inverters move through each other, which the meter cannot see.
|
|
|
|
Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at
|
|
P1. The site looks idle. In reality 3 kW is going round a loop through two
|
|
conversion stages continuously, for no benefit, and the wear on both packs is
|
|
real even though every instrument in this add-on says nothing is happening.
|
|
|
|
WHY NOTHING EXISTING SEES IT, AND IT IS STRUCTURAL RATHER THAN AN OVERSIGHT
|
|
==========================================================================
|
|
Every consumer of the site measurement is a consumer of the NET. SAFETY-02's
|
|
sign-disagreement watchdog gates on `abs(grid_w) > cfg.min_w` (300 W by
|
|
default) - with the grid at ~0 that gate never opens, on any cycle, for any
|
|
duration. The peak guard bills the quarter-hour average AT THE GRID
|
|
CONNECTION. The capacity tariff bills grid exchange. `freeze.looks_frozen()`
|
|
grades P1 against the site rail. There is one meter, it reads the sum, and
|
|
circulating power is exactly the component of per-unit power that the sum
|
|
DESTROYS. So this cannot be fixed by tuning a threshold on any existing
|
|
detector; it needs a quantity none of them are computing.
|
|
|
|
THE QUANTITY
|
|
============
|
|
circulating_w = sum(|B_i|) - |sum(B_i)|
|
|
|
|
Zero when every unit pulls the same way - then sum(|B_i|) == |sum(B_i)| by the
|
|
triangle equality. `2 * min(|B_a|, |B_b|)` at N=2 when they oppose, which is
|
|
twice the power going round the loop and is the right scale: that is what both
|
|
conversion stages are processing between them. It is non-negative always, it is
|
|
zero at N<=1 identically, and it needs no threshold to be interpretable - the
|
|
number IS the phenomenon in watts.
|
|
|
|
⚠️ IT IS COMPUTED FROM MEASURED PER-INVERTER POWER, AND THAT IS THE WHOLE CARD.
|
|
=============================================================================
|
|
The obvious cheap version reads the allocation record - the shares the
|
|
allocator just handed out - and reports the same formula over those. It would
|
|
be simpler, it would never see a `None`, and it would be worth NOTHING, for two
|
|
independent reasons.
|
|
|
|
The first is that it cannot fire. ALLOC-01 shipped the sign-coherence gate as
|
|
its AC 7 (`allocator.REFUSE_SIGN`, graded by `test_alloc.py` part 6): an
|
|
allocation whose shares carry two signs is REFUSED, both units named, no shares
|
|
returned. So a sensor over the allocation record is a sensor over a set the
|
|
allocator has already guaranteed is sign-coherent. It reads zero by
|
|
construction, forever, and a check written against it passes forever.
|
|
|
|
The second is the one MUTATION.md names as the third way a check lies. A sensor
|
|
derived from the allocation record can only ever confirm THE ALLOCATOR AGREES
|
|
WITH ITSELF. Every fault in this card's list is a divergence between what was
|
|
commanded and what the hardware is doing:
|
|
|
|
* a unit ignoring or lagging its share;
|
|
* a stale share still being executed by one inverter;
|
|
* SAFETY-25's deferred loop floor, or a maintenance drain, pinning one unit
|
|
while the loop drives the other;
|
|
* an inverter doing something of its own accord - and THIS HARDWARE HAS DONE
|
|
THAT TWICE. On 2026-09-02 the comms subsystem failed and the power stage
|
|
went on executing its last instruction for 6 h 25 m (HW-01). Through all of
|
|
it the allocation record was impeccable.
|
|
|
|
In every one of those the command side is coherent and the MEASUREMENT side is
|
|
not. So the input is `Inverter.batt` - this unit's own power, read from its own
|
|
`batt_entity`, which on the live system is holding register 0x0518, the
|
|
inverter's own AC power. DEV-02 made that per-instance; before it there was one
|
|
`batt` scalar on the Controller and this sensor could not have been written at
|
|
all.
|
|
|
|
⚠️ AND THIS IS THE MEASUREMENT HALF OF A PAIR. ALLOC-01's gate is the COMMAND
|
|
half: the allocator can no longer COMMAND circulating power. This module is the
|
|
detection half: circulating power the allocator never commanded. Neither one
|
|
replaces the other and neither is redundant - the gate refuses an allocation
|
|
before it is written, and cannot see a millisecond past that; this reads the
|
|
hardware and does not care what was written. Do not "unify" them.
|
|
|
|
THE TRIP, AND WHY A THRESHOLD ALONE IS A FALSE-ALARM GENERATOR
|
|
==============================================================
|
|
A momentary opposition is LEGITIMATE. Writes to the inverters are serialised
|
|
(one `hass.call()` at a time - see `Inverter`'s docstring on what a hung write
|
|
to A costs B), so on a cycle where the site target reverses, one unit receives
|
|
its new command up to a whole control cycle before its sibling. Both then slew
|
|
through zero, with a real ramp rate and a real first-order lag, and BETWEEN THE
|
|
TWO ZERO CROSSINGS THE UNITS GENUINELY OPPOSE. Nothing is wrong; the site is
|
|
turning around.
|
|
|
|
This project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19) and
|
|
this is exactly the shape that generates it. So the trip is a threshold AND a
|
|
sustained window, and BOTH numbers were measured on the TEST-19 two-master rig
|
|
rather than chosen - see `CIRC_OPTION_DEFAULTS` for the measurements and
|
|
`DECISIONS/SAFETY-31-circulating-power.md` for the runs.
|
|
|
|
⚠️ THE WINDOW ACCUMULATES, WHICH IS WHY THIS IS NOT SAFETY-02's PROBLEM AGAIN.
|
|
The reason the sign watchdog is blind to the broadcast fight is that the
|
|
disagreement FLIPS SIGN on alternate ticks, so `disagree_since` is cleared
|
|
between every pair of them and the 180 s hold never accumulates (allocator.py's
|
|
docstring records the measurement). Circulating power cannot do that: it is a
|
|
MAGNITUDE, non-negative by construction, and a sustained loop holds it high on
|
|
every cycle rather than on every other one. An oscillation that genuinely
|
|
alternated between opposing and agreeing is not a circulating loop and should
|
|
not be reported as one.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
# CFG-03. THE ONE IN-CODE COPY OF THESE DEFAULTS. `config.yaml`'s `options:`
|
|
# block is the source of truth an operator edits and the Supervisor reads;
|
|
# `schema:` holds the bounds. This dict exists for the reason
|
|
# `maintenance.MAINT_OPTION_DEFAULTS` does - the Dockerfile does not put
|
|
# config.yaml in the image, so the running add-on cannot read it - and
|
|
# `test_watchdog.py` pins the two together by READING config.yaml, so a drift
|
|
# goes red in CI rather than shipping.
|
|
CIRC_OPTION_DEFAULTS = {
|
|
# --- THE THRESHOLD, AND IT IS READ OFF A CURVE ------------------------
|
|
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 924 closed-loop
|
|
# runs in which NOTHING is faulty - every unit executes the share the
|
|
# allocator gave it - over both families of stimulus that can produce
|
|
# legitimate opposition: single reversals (7 sizes x 5 write staggers) and a
|
|
# flapping load (7 periods x 4 amplitudes), each swept across the WHOLE
|
|
# range config.yaml accepts for the two settings that decide how fast the
|
|
# site can turn around, `gain` float(0.05,1.0) and `slew_w` int(50,5000).
|
|
# The worst CONTINUOUS opposition, as a function of the threshold:
|
|
#
|
|
# 100 W -> 505.0 s 300 W -> 10.0 s 700 W -> 5.0 s
|
|
# 200 W -> 445.0 s 350 W -> 10.0 s 1000 W -> 5.0 s
|
|
# 250 W -> 40.0 s 400 W -> 5.0 s 1700 W -> 0.0 s
|
|
#
|
|
# ⚠️ SAFETY-39 MOVED THAT SCHEMA FLOOR FROM 50 TO 0, AND THE CAMPAIGN ABOVE
|
|
# WAS NOT RE-RUN. It does not have to be, and here is the argument rather
|
|
# than the assurance. What these windows are about is how long the command
|
|
# takes to CROSS ITS RAIL - the swept worst case is the SLOWEST crossing,
|
|
# which is `slew_w` at its floor, 5000/50 = 100 cycles. `slew_w: 0` means
|
|
# "follow the rail" and resolves to `SLEW_RAIL_FRAC * max_w`, which is
|
|
# exactly 9 cycles at EVERY rail by construction. So the new value can never
|
|
# be the slowest crossing at any `max_w`, and it is strictly inside the
|
|
# envelope this campaign measured. An explicit number still spans 50..5000
|
|
# and is unchanged. If `SLEW_RAIL_FRAC` is ever loosened past 1/100, this
|
|
# paragraph stops being true and the campaign has to be re-run.
|
|
#
|
|
# THE KNEE IS AT 400 W and it is sharp. Below ~250 W a flapping load holds
|
|
# the sensor above the threshold for MINUTES at a time - 505 s at 100 W -
|
|
# and no window shorter than the 900 s billing quarter could tell that from
|
|
# a fault. At 400 W and above the worst legitimate episode collapses to 5.0
|
|
# s, ONE control cycle, and stays there. So this number is not a sensitivity
|
|
# preference; it is the edge of the regime in which the guard is
|
|
# answerable at all.
|
|
#
|
|
# 700 W is 1.75x the knee, and the margin is bought for one named reason:
|
|
# INVERTER B DOES NOT PHYSICALLY EXIST. Its ramp and lag in the rig are
|
|
# inverter A's, COPIED, carrying `measured=False` - SPIKE-04 measures the
|
|
# real ones. A slower or faster second unit moves the transient's shape, and
|
|
# the headroom above the knee is what absorbs that without re-opening this
|
|
# card. Revisit it when SPIKE-04 lands, not before.
|
|
#
|
|
# What it costs: a circulating loop smaller than 350 W per unit is not
|
|
# reported. That is deliberate - at that scale the round-trip loss is a few
|
|
# watts and the wear is proportionate to it.
|
|
"circulation_w": 700,
|
|
|
|
# --- THE WINDOW -------------------------------------------------------
|
|
# Two lower bounds, one upper, and the chosen value clears all three.
|
|
#
|
|
# LOWER, from the rig: 5.0 s. The worst continuous legitimate opposition
|
|
# above 700 W across all 924 runs is exactly one control cycle - a
|
|
# reversing site whose second write lands a cycle after the first, which
|
|
# is what the serialised write path does on every reversal.
|
|
#
|
|
# LOWER, from the firmware: `inverter.LINK_STALE_S`, 30 s. A unit still
|
|
# executing a stale share for less than that is a LINK fault, and the
|
|
# add-on already has an answer for it - liveness drops the unit and the
|
|
# survivors carry its share, with the record saying so. Firing inside that
|
|
# window would raise a second, louder alarm about a state that is already
|
|
# being handled, which is the SAFETY-15/SAFETY-19 failure exactly.
|
|
#
|
|
# UPPER, from the bill: 900 s, the quarter that sets the capacity tariff.
|
|
# A circulating loop must be caught and acted on well inside one.
|
|
#
|
|
# 75 s: fifteen control cycles - 15x the worst legitimate episode this rig
|
|
# can produce, 2.5x the firmware's own veto window, and 12x inside the
|
|
# quarter. ⚠️ IT IS DELIBERATELY NOT 60 OR 120. A round number here would be
|
|
# a guess wearing a measurement's clothes, and the three bounds above are
|
|
# what the number has to answer to.
|
|
"circulation_hold_s": 75,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CirculationConfig:
|
|
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
|
|
|
min_w: float = float(CIRC_OPTION_DEFAULTS["circulation_w"])
|
|
hold_s: float = float(CIRC_OPTION_DEFAULTS["circulation_hold_s"])
|
|
|
|
|
|
def circulating_w(powers):
|
|
"""`sum(|B_i|) - |sum(B_i)|` over MEASURED per-inverter power, in W.
|
|
|
|
`powers` one measured power per inverter, in roster order, + = discharge.
|
|
A unit whose reading did not arrive is `None`.
|
|
|
|
Returns a non-negative float, or `None` when the answer is unknown.
|
|
|
|
⚠️ N<=1 IS IDENTICALLY ZERO AND RETURNS ZERO EVEN FOR A MISSING READING
|
|
(AC 5). With one term `sum(|B|) - |sum(B)| == |B| - |B| == 0` for EVERY
|
|
value of B, including a value nobody read. Returning `None` there would be
|
|
reporting an unknown where the algebra has no room for one, and it would put
|
|
a shipped one-inverter install permanently in the "cannot tell" state on a
|
|
quantity that cannot be anything but zero. Nothing changes at the shipped
|
|
roster; that is the compatibility promise this card makes.
|
|
|
|
⚠️ AND AT N>=2 A MISSING READING IS `None`, NOT ZERO, WHICH IS THE OPPOSITE
|
|
DIRECTION AND FOR A REASON. Zero would be this sensor reporting HEALTH from
|
|
an absence - the precise shape of the 2026-09-02 outage, where a subsystem
|
|
stopped answering and its silence read as calm for 6 h 25 m. An unknown
|
|
circulating figure is an unknown, it CLEARS the trip's window rather than
|
|
accumulating it (see `CirculationWatchdog.evaluate`), and the site is
|
|
already stopped for a missing per-unit reading anyway - `inputs_ok` requires
|
|
every unit's `batt`, and §11.1 rule 1 commands 0 W without consulting this.
|
|
"""
|
|
vals = list(powers)
|
|
if len(vals) <= 1:
|
|
return 0.0
|
|
if any(v is None for v in vals):
|
|
return None
|
|
vals = [float(v) for v in vals]
|
|
# ⚠️ max(0.0, ...) IS DEFENSIVE, AND IT IS AN EQUIVALENT MUTANT - SAID HERE
|
|
# RATHER THAN LEFT FOR THE NEXT MUTATION SWEEP TO REDISCOVER. The triangle
|
|
# inequality makes this non-negative mathematically, and in floating point
|
|
# it is non-negative too: for a same-sign roster `abs(v)` is the list itself
|
|
# up to one sign, so the two sums accumulate THE SAME MAGNITUDES IN THE SAME
|
|
# ORDER and are bit-identical; for a mixed-sign roster the two differ by
|
|
# 2*min(...), which is enormous beside any rounding. Searched, not assumed -
|
|
# 700 000 random rosters of 2 to 12 units across 10 orders of magnitude
|
|
# produced no negative result.
|
|
#
|
|
# So removing this line changes no observable and no check here would go
|
|
# red. It stays because the cost is one call and the failure it would
|
|
# prevent - a power sensor publishing -1e-16 W, which is the kind of value
|
|
# somebody eventually writes a `< 0` branch against - is out of proportion
|
|
# to it. Do not report its survival as a gap in the checks.
|
|
return max(0.0, sum(abs(v) for v in vals) - abs(sum(vals)))
|
|
|
|
|
|
def opposing(powers, min_w: float = 0.0):
|
|
"""The units pulling each way right now, as `(discharging, charging)` names.
|
|
|
|
Indices, not names - the caller owns the roster order. Used for the operator
|
|
sentence: "which two inverters are pushing against each other" is the first
|
|
thing somebody woken by this alarm needs, and reconstructing it from the
|
|
number alone is impossible.
|
|
|
|
`min_w` ignores units below a magnitude, so a unit resting at 3 W of
|
|
standby draw is not named as a participant in a 3 kW loop.
|
|
"""
|
|
pos = [i for i, v in enumerate(powers)
|
|
if v is not None and float(v) > float(min_w)]
|
|
neg = [i for i, v in enumerate(powers)
|
|
if v is not None and float(v) < -float(min_w)]
|
|
return pos, neg
|
|
|
|
|
|
class CirculationWatchdog:
|
|
"""ONE PER SITE, evaluated once per control cycle.
|
|
|
|
⚠️ ONE PER SITE, WHERE SAFETY-02's IS ONE PER ACTUATOR, AND THE ASYMMETRY IS
|
|
THE WHOLE ATTRIBUTION ARGUMENT. `SignWatchdog` concludes "THE UNIT I AM
|
|
DRIVING is pushing power the wrong way" - evidence about one actuator, so
|
|
the remedy is that actuator (arbiter.CLAIM_SCOPE calls `failsafe` a UNIT
|
|
claim for exactly this reason). Circulating power is not a property any one
|
|
inverter has. It is a property of a PAIR, it is defined by a sum across the
|
|
roster, and there is no fact of the matter about which of the two is the
|
|
offender: a unit ignoring its share and a sibling faithfully executing a
|
|
share computed to sum with it produce identical readings. So the quantity is
|
|
site-level, the watchdog is site-level, and the remedy below is site-level.
|
|
|
|
Latches, for `SignWatchdog`'s reason: the trip removes its own evidence. The
|
|
moment the site stops commanding, the loop it detected either persists (a
|
|
wedged unit, now visible at the meter) or vanishes (the add-on was one of
|
|
the two participants) - and in the second case a non-latching detector would
|
|
de-assert and start the whole thing again, three minutes at a time, forever.
|
|
"""
|
|
|
|
def __init__(self, cfg: CirculationConfig | None = None, *,
|
|
tripped: bool = False, reason: str = ""):
|
|
self.cfg = cfg or CirculationConfig()
|
|
self.over_since: datetime | None = None
|
|
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED - SAFETY-34, and it is the
|
|
# reason this card exists in the shape it does. `persistent_notification`
|
|
# is IN MEMORY: Home Assistant restarts and the card is gone, with the
|
|
# fault still running. That is precisely how the 2026-09-02 outage went
|
|
# unseen for 6 h 25 m while the detector had been correct for hours. The
|
|
# latch lives in `/data/state.json` - the add-on's only durable volume -
|
|
# and `main.py` RE-RAISES the notification from it on every start.
|
|
self.tripped = bool(tripped)
|
|
self.reason = reason if self.tripped else ""
|
|
# The worst figure seen during the current episode, for the operator
|
|
# sentence. Not persisted: it is colour on a reason string that IS.
|
|
self.peak_w = 0.0
|
|
|
|
def evaluate(self, circ_w, now: datetime) -> bool:
|
|
"""Advance the detector one cycle. Returns True while tripped.
|
|
|
|
`circ_w` this cycle's `circulating_w()` over MEASURED per-unit power,
|
|
or None when a reading is missing. None CLEARS the window
|
|
rather than accumulating it - see the module docstring, and
|
|
`SignWatchdog.evaluate` on the identical decision for a stale
|
|
meter. A frozen figure held against a moving one manufactures
|
|
a fault in about a window's worth of cycles.
|
|
`now` the caller's clock, so this is testable and replayable.
|
|
"""
|
|
if self.tripped:
|
|
# Latched. Do not re-evaluate: the trip removed the evidence.
|
|
return True
|
|
|
|
if circ_w is None or float(circ_w) <= self.cfg.min_w:
|
|
self.over_since = None
|
|
self.peak_w = 0.0
|
|
return False
|
|
|
|
circ_w = float(circ_w)
|
|
self.peak_w = max(self.peak_w, circ_w)
|
|
if self.over_since is None:
|
|
self.over_since = now
|
|
return False
|
|
|
|
held = (now - self.over_since).total_seconds()
|
|
if held < self.cfg.hold_s:
|
|
return False
|
|
|
|
self.tripped = True
|
|
self.reason = (
|
|
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
|
|
f"the inverters (peak {self.peak_w:.0f} W) for {held:.0f} s "
|
|
f"continuously - the meter cannot see it, because it nets to zero "
|
|
f"there (limits: over {self.cfg.min_w:.0f} W, held over "
|
|
f"{self.cfg.hold_s:.0f} s)")
|
|
return True
|
|
|
|
def held_s(self, now: datetime) -> float:
|
|
"""How long the current opposition has stood, for the status page.
|
|
|
|
Takes the clock rather than reading one, for `SignWatchdog.held_s`'s
|
|
reason: every observable on this object is a pure function of what it
|
|
was handed, and an unclocked function is one nothing can test.
|
|
"""
|
|
if self.over_since is None:
|
|
return 0.0
|
|
return (now - self.over_since).total_seconds()
|
|
|
|
def reset(self) -> None:
|
|
self.over_since = None
|
|
self.tripped = False
|
|
self.reason = ""
|
|
self.peak_w = 0.0
|