No code and no option changes. What changes is a number this repository claimed
about a shipped safety constant.
`delivery_hold_s: 185` shipped resting on "30.0 s, the worst continuous
legitimate episode at the shipped threshold across all 2640 runs", plus a lag
sweep giving 45.0 s and 90.0 s - from a harness `DECISIONS/SAFETY-42` §1
deliberately did NOT commit, because it imports `plantsim` and `frame_check` out
of admin/goodwe. So the floor under a safety constant could not be re-run. That
is PROC-01's shape, with eleven instances in three days.
The harness is committed now, in admin/goodwe as `sim/delivery_window.py`, and
NOT here. `plantsim` and `frame_check` are that repo's; restating them in this
tree is CI-08's coupling, which has broken three times, and the alternative
fate - skipped for want of its imports - is a green that establishes nothing.
There the dependency runs one way: THIS repo's `control`, `arbiter` and the
shipped `delivery.delinquent()` are imported live out of whichever checkout is
under test. Its `--selftest` is now a step in that repo's `checks.yml`, so a
change here that moves `saturation_w`, `max_w`, `delivery_hold_s` or the shape
of `delinquent()` goes red there instead of leaving a stale number here.
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --blag
⚠️ THE HEADLINE DID NOT REPRODUCE: 35.0 s, not 30.0 s. Recorded as measured. The
harness was not adjusted until it agreed - the one correction made during the
rebuild was made AGAINST the doc's number (§1 records it) and made every figure
LARGER.
WHAT REPRODUCED - and it is the part the compatibility promise rests on:
* the N=1 control arm is 0.0 s at EVERY threshold in EVERY run. §3.2's algebra
measured, not argued.
* the flapping family is 0.0 s everywhere in all 840 runs - the family that
defeated SAFETY-30's site window still cannot touch this check.
* 45.0 s at twice A's lag, to the cycle.
WHAT DID NOT:
* the headline, by one control cycle (30.0 -> 35.0 s); the reversal family by
two (20.0 -> 30.0 s).
* the NON-MONOTONE curve. §3.3 finding 3 read 55.0 s at 250 W against 40.0 s
at 100 W as a property of the reduction. This rebuild's curve is monotone.
The mechanism is real and is still the reason not to read either table as a
sensitivity curve, but it is an ARGUMENT now, not a measurement, and it is
written down as weaker.
* the low-threshold rows, badly (40.0 -> 120.0 s at 100 W). Nothing ships
there, but that gap is the honest measure of how much of the original table
was a property of axes nobody wrote down. §3.3 fixes every axis COUNT and
RANGE and no individual value; the rebuild prints its choices above its own
table so the caveat travels with the number.
* the four-times-slower row moved the OTHER way: 75.0 s, not 90.0 s.
185 s IS NOT CHANGED. It is 5.29x the re-measured worst (the record claimed
6.2x), 2.47x the four-times-slower case, 6.2x the firmware's LINK_STALE_S veto
and 4.9x inside the billing quarter. All four bounds clear and none narrowly,
and the ordering of the argument is unchanged. Moving a shipped safety constant
because a margin went from 6.2x to 5.3x while every bound holds would re-open
SAFETY-41's four-attempt false-trip regime for nothing.
⚠️ ONE CONSEQUENCE AN OPERATOR CAN HIT. At 30.0 s the rig's floor coincided with
the firmware's veto and a reader could not tell which was binding. At 35.0 s the
rig's is strictly the binding one - and it is ABOVE the schema's own
`int(30,900)` minimum. `delivery_hold_s: 30` is therefore below a MEASURED
legitimate episode and would report on ordinary saturation. Both translations
now say so. Raising the schema floor is a change to a shipped option's contract
and is left to its own card rather than smuggled in here.
⚠️ THE B-LAG ROWS ARE RELABELLED THROUGHOUT. Inverter B does not physically
exist (~2026-09-12); `plantsim.INVERTER_B` is A COPIED, `measured=False`. Every
two-inverter figure in this record is two copies of A, and the lag sweep is a
SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, not a measurement of it. The old
wording - "MEASURED, not felt" - overstated exactly that, and "the RAMP does not
move it at all" was too strong: halving it is worth 0 s at A's lag and +5 s at
twice it.
Touched: DECISIONS/SAFETY-42 §1 and §3.3, app/delivery.py's provenance comment,
config.yaml's `delivery_hold_s` block, both translations, CHANGELOG Unreleased.
26/26 suites pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
686 lines
38 KiB
Python
686 lines
38 KiB
Python
"""SAFETY-42 - a unit that stops delivering its share, which nothing else can see.
|
|
|
|
At one inverter this file is inert by construction (see `delinquent()`), and
|
|
that is deliberate rather than a limitation: the whole signal it reads is a
|
|
DIFFERENCE BETWEEN SIBLINGS, and a roster of one has none.
|
|
|
|
WHAT IT CATCHES, in one sentence: an inverter that accepts its share and does
|
|
not produce it, beside a sibling that does - HW-01's shape at N=2, where the
|
|
2026-09-02 fault wedged inverter A for 6 h 25 m with every layer a spectator.
|
|
|
|
WHY EVERY EXISTING GUARD IS SILENT, AND IT IS STRUCTURAL
|
|
=======================================================
|
|
SAFETY-30 per unit gates on OPPOSITION: both the command and the unit's own
|
|
power over `unit_sign_disagree_w` (250 W) with opposite
|
|
signs. A unit sitting at 0 W never opens that gate. It is
|
|
not opposing, it is ABSENT.
|
|
SAFETY-30 site the sum of the commands still AGREES with the meter in
|
|
direction. Only the magnitude is short, and the site
|
|
comparison is a sign test.
|
|
SAFETY-31 `sum(|Bi|) - |sum(Bi)|` is ZERO when one unit does
|
|
nothing: circulation needs two units pulling AGAINST each
|
|
other, and one of these is pulling nowhere at all.
|
|
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 module therefore does not make
|
|
that signal louder; it computes a different one.
|
|
|
|
⚠️ THE ONE PLACE IT OVERLAPS SAFETY-30, AND THE OVERLAP RESOLVES ITSELF. A unit
|
|
measured at -2000 W against a +2000 W share is OPPOSING - SAFETY-30's fault -
|
|
and is ALSO short by 4000 W with nothing here to explain it. Both detectors have
|
|
evidence, and only one alarm results, because SAFETY-30's per-unit hold is the
|
|
shorter of the two (85 s against 185 s at the shipped defaults): it trips first,
|
|
files its `failsafe` SET, the share goes to 0 W, and this check's gate closes on
|
|
`share > 0` while `account()` answers "parked" as well. That is asserted on a
|
|
reversed-unit fixture in `test_delivery.py` part 6 rather than left to the
|
|
reader, because two alarms for one fault is how an operator learns to skim.
|
|
|
|
------------------------------------------------------------------------------
|
|
THE CARD'S OWN DISCRIMINATOR WAS MEASURED AND IT DOES NOT WORK - READ THIS FIRST
|
|
==============================================================================
|
|
SAFETY-42 proposed: alarm on `saturation persisting past N` while
|
|
`|grid error| > X`, with N and X derived rather than chosen. That predicate was
|
|
built and run on the two-master rig before this module was written, and it
|
|
fails in BOTH directions. Neither failure is a matter of picking better N and X;
|
|
both are properties of the quantity.
|
|
|
|
1. IT FIRES ON EVERY BENIGN SATURATION, and the two cases are not merely
|
|
similar - they are THE SAME TRACE. `plantsim.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. Run both against the same stimulus
|
|
(load 2000 W, equal weights, 2000 W per-unit cap) and every emitted watt
|
|
agrees: over 120 control cycles the two arms are IDENTICAL on all 120 in
|
|
(grid, site target, each unit's share, each unit's measured power). Both
|
|
report `reason == "saturated-freeze"` on 116 of those 120 cycles - the
|
|
other four are `slew-limited`, before the freeze engages on cycle 4 - and
|
|
both hold |grid error| over 300 W on all 120, settling at 1200 W. A
|
|
predicate reading only those
|
|
numbers cannot separate them at ANY (N, X), because nothing in its inputs
|
|
differs. And "a cold pack at its floor must 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. IT IS SILENT ON THE HEADLINE CASE, because the standing grid error is
|
|
exactly what a HEALTHY SIBLING ERASES. Same rig, load 800 W, equal weights,
|
|
2000 W per-unit cap, inverter B wedged: over the last 30 of 200 cycles the
|
|
loop is saturation-frozen on every one of them - so "saturation persisting
|
|
past N" is true for any 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: a healthy sibling regulating alone with the
|
|
site short by one unit's share.
|
|
|
|
The card's derivation instruction stands and is honoured; what could not be
|
|
derived was a threshold on the wrong quantity. This is written down rather than
|
|
quietly replaced, because the next person to read the card will propose it again.
|
|
|
|
------------------------------------------------------------------------------
|
|
THE QUANTITY THAT DOES WORK, AND WHY IT NEEDS TWO INVERTERS
|
|
===========================================================
|
|
Per unit, for a share that asks it to DISCHARGE:
|
|
|
|
short_by(i) = share_i - measured_i (watts that did not arrive)
|
|
|
|
`share_i` is the command STANDING ON THAT UNIT'S WIRE - `Inverter.standing_w()`,
|
|
the same operand SAFETY-30's per-unit comparison uses - and `measured_i` is that
|
|
unit's own power, holding register 0x0518, per-instance since DEV-02.
|
|
|
|
That number alone is the saturation signal again, per unit. What makes it
|
|
readable is the reduction over the roster:
|
|
|
|
a unit is DELINQUENT while it is short 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.
|
|
|
|
Both halves are load-bearing:
|
|
|
|
* THE ACCOUNT is what keeps the cold pack out, and it is the only thing that
|
|
can. The two cases are identical in the power domain (see 1 above), so the
|
|
discriminator has to be a fact this controller reads that is NOT power - and
|
|
there is exactly one: the pack's own state of charge against its own
|
|
`bms_soc_floor`. `account()` below asks it through `peakguard.
|
|
discharge_permitted()`, the predicate SAFETY-32 already made public, rather
|
|
than transcribing the comparison a second time.
|
|
* THE PROPER SUBSET is what keeps a site-wide cause out, and it is the half
|
|
that only exists at N>=2. A derate this add-on cannot see - a thermal
|
|
limit, a grid-code curtailment, an inverter-side DC restriction - hits the
|
|
whole roster. One box that has stopped listening does not. If EVERY unit
|
|
that was asked to deliver is short and unexplained, this module says
|
|
nothing: it cannot tell that from a plant that simply cannot do it, and
|
|
saying so anyway is the saturation-shaped alarm all over again.
|
|
|
|
⚠️ AT N=1 THE SUBSET IS NEVER PROPER AND THIS IS INERT. One asked unit is either
|
|
accounted for (nothing to say) or unaccounted for (the whole roster, so nothing
|
|
to compare it against). `delinquent()` returns `{}` for every input at a roster
|
|
of one - not by a guard on `len`, but because a proper subset of a one-element
|
|
set that contains the element is impossible. That is AC 5, and it is asserted
|
|
rather than assumed in `test_delivery.py` part 1.
|
|
|
|
------------------------------------------------------------------------------
|
|
DISCHARGE SHARES ONLY, AND THE COST OF THAT IS STATED RATHER THAN HIDDEN
|
|
=======================================================================
|
|
`short_by()` answers None for a charge share. The account gate needs a bound
|
|
this controller actually reads in the commanded direction, and 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 there is no
|
|
configured ceiling anywhere in this add-on to explain that with. Armed on charge
|
|
shares this module would alarm on the taper of every §12 maintenance charge,
|
|
which is the crying-wolf failure it exists to avoid.
|
|
|
|
What that costs, said plainly: a unit that stops ABSORBING its share is not
|
|
caught here. It is the same fault in the other direction and it wants the same
|
|
treatment; what it needs first is a per-pack charge ceiling this add-on does not
|
|
have. That is a card, and this paragraph is its evidence.
|
|
|
|
------------------------------------------------------------------------------
|
|
IT REPORTS. IT DOES NOT ACT - AND THAT IS A DECISION, NOT AN OMISSION
|
|
====================================================================
|
|
Card requirement 3 asks which, and why. Three candidates, and the reasoning is
|
|
recorded because "alarm-only" is the weaker-looking answer and is still right:
|
|
|
|
COMMAND IT HARDER - no. HW-01 settles it: on 2026-09-02 the power stage went
|
|
on executing its last instruction for 6 h 25 m through every command this
|
|
add-on sent it. A command is not a remedy for a box that is not listening,
|
|
and pretending otherwise is theatre.
|
|
|
|
RE-ALLOCATE ITS SHARE TO THE SIBLING - no, AND NOT FOR THE COMFORTABLE
|
|
REASON. The comfortable reason would be that the loop already does it, and
|
|
the rig says otherwise: with B wedged and equal weights, dropping B 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.
|
|
Measured, per-unit cap 2000 W, B wedged:
|
|
|
|
load 1500 W kept in the split: the site settles at 800 W with a 700 W
|
|
grid error and NEVER comes inside 100 W in 150 cycles -
|
|
the saturation freeze pins the site target, so A's share
|
|
never rises. Dropped: 1500 W delivered, inside 100 W from
|
|
cycle 14.
|
|
load 2000 W kept: 800 W delivered, 1200 W error, never settles.
|
|
Dropped: 2000 W delivered, inside 100 W from cycle 8.
|
|
|
|
That is not three cycles of latency; it is the difference between a site
|
|
that recovers and one that does not. It is the strongest argument on this
|
|
card and it is stated first, because the answer still has to be no.
|
|
|
|
WHAT REFUSES IT is that the only re-allocation this add-on can express is
|
|
ALL OR NOTHING. `live_units()` drops a unit entirely and the survivors carry
|
|
the whole target; 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 - it cannot tell a 50 %
|
|
derate from a wedged box, because neither can any instrument this add-on
|
|
has. Measured, B derated to 50 %, load 3000 W, per-unit cap 2000 W, and
|
|
SAFETY-42 names B in that run:
|
|
|
|
kept in the split site delivers 2400 W (B contributing 800 W),
|
|
grid error 600 W
|
|
dropped site delivers 2000 W (B contributing 0 W),
|
|
grid error 1000 W - 400 W WORSE
|
|
|
|
So the remedy would make the site worse in a case this detector cannot rule
|
|
out, on a live control law, driven by a brand-new alarm. A partial
|
|
re-allocation - the sibling gets the undelivered watts without the short
|
|
unit being commanded to zero - is what would make acting safe, and the
|
|
allocator has no way to express it today. That is a card, and this block is
|
|
its evidence.
|
|
|
|
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 in the fault, nothing is netting, and the sibling is the only
|
|
thing still delivering. Parking it would take the site from "short by one
|
|
unit" to "short by all of them" and would remove the evidence.
|
|
|
|
So: a durable latch, a named unit, an action-log row, and an operator
|
|
notification - and the loop is left exactly as it was. Requirement 4 of the card
|
|
("do not weaken the saturation freeze to make room") is met by not touching
|
|
`control.py` at all.
|
|
|
|
------------------------------------------------------------------------------
|
|
IT SURVIVES A RESTART, WHICH IS SAFETY-34 AND IS WHY THE LATCH EXISTS AT ALL
|
|
===========================================================================
|
|
`persistent_notification` is IN MEMORY. Home Assistant restarts and the card is
|
|
gone with the fault still running, which is precisely how the 2026-09-02 outage
|
|
went unseen for 6 h 25 m. So the durable half is `unit/<name>/delivery_tripped`
|
|
and `unit/<name>/delivery_reason` in `/data/state.json` - namespaced per unit,
|
|
for `store.unit_key()`'s reason - and `main.py` re-raises the notification from
|
|
it on the first cycle of every process.
|
|
|
|
⚠️ AND THAT IS WHY THIS LATCHES THOUGH IT DOES NOT ACT. A detector that cleared
|
|
itself when the unit resumed would erase its own record of an intermittent
|
|
fault, which is the one shape an operator most needs to be told about. It is
|
|
cleared by the same operator press that clears SAFETY-02's and SAFETY-31's -
|
|
a deliberate human act, after looking at why.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
from .peakguard import discharge_permitted
|
|
|
|
# The reason code. Distinct from every other one in this add-on by construction:
|
|
# nothing else uses it, and it deliberately does not contain the word
|
|
# "saturation" - see the module docstring on why a saturation-shaped alarm is
|
|
# the thing this module must not produce.
|
|
REASON_CODE = "UNIT_NOT_DELIVERING"
|
|
|
|
# CFG-03. THE ONE IN-CODE COPY OF THIS DEFAULT. `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
|
|
# `circulation.CIRC_OPTION_DEFAULTS` and `watchdog.SIGN_OPTION_DEFAULTS` do -
|
|
# the Dockerfile does not put config.yaml in the image, so the running add-on
|
|
# cannot read it - and `test_delivery.py` pins the two together by READING
|
|
# config.yaml, so a drift goes red in CI rather than shipping.
|
|
#
|
|
# ⚠️ THERE IS ONLY ONE KEY HERE, AND THE MISSING SECOND ONE IS THE POINT. The
|
|
# other half of this check is a WATTS threshold, and it is not an option and not
|
|
# a constant: it is `control.Tuning.saturation_w`, read at runtime. See
|
|
# `MIN_W_IS_SATURATION_W` below.
|
|
DELIVERY_OPTION_DEFAULTS = {
|
|
# --- THE WINDOW, AND IT IS THE ONLY NUMBER THIS CARD CHOSE ------------
|
|
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT PICKED. 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 x 5 slews, the whole range
|
|
# config.yaml accepts for `gain` float(0.05,1.0) and `slew_w` int(0,5000),
|
|
# across three families of stimulus that can produce a legitimate shortfall:
|
|
#
|
|
# 1050 load reversals 7 sizes x 5 write staggers (the serialised write
|
|
# path landing B's number up to four cycles late)
|
|
# 840 flapping loads 7 periods x 4 amplitudes
|
|
# 750 weighted splits 5 weightings x 5 staggers on a +-2200 W reversal
|
|
#
|
|
# ⚠️ THE WEIGHTED FAMILY IS NEW HERE AND IT IS THE ONE THAT DOMINATES. An
|
|
# asymmetric split hands one unit a much larger step than its sibling, so
|
|
# the big one is still ramping while the small one has settled - which is
|
|
# exactly the PROPER SUBSET this check keys on. SAFETY-30's campaign had no
|
|
# such family, so reading its numbers across would have understated this.
|
|
#
|
|
# Every run was executed a second time at a roster of ONE, on the same
|
|
# stimulus, as the control.
|
|
#
|
|
# ⚠️ RE-RUN BY TEST-21 (2026-09-07) AND THE HEADLINE DID NOT REPRODUCE. The
|
|
# harness that produced the original table was never committed, which is
|
|
# PROC-01's shape; it is committed now, in the OTHER repository, as
|
|
# `admin/goodwe:sim/delivery_window.py` (it imports `plantsim` and
|
|
# `frame_check`, which that repo owns - restating them here would be CI-08's
|
|
# coupling, and CI-08 has broken three times). Re-derive it with:
|
|
#
|
|
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
|
|
# python3 -B delivery_window.py --selftest --campaign --blag
|
|
#
|
|
# Worst CONTINUOUS legitimate episode. Second block is the re-run:
|
|
#
|
|
# threshold reversals flapping weighted N=1 control (1890 runs)
|
|
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s } 2026-09-06
|
|
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s } harness
|
|
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s } NOT
|
|
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s } COMMITTED
|
|
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s } - see
|
|
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s } DECISIONS
|
|
#
|
|
# threshold reversals flapping weighted N=1 control (2640 runs)
|
|
# 100 W 120.0 s 0.0 s 165.0 s 0.0 s } TEST-21
|
|
# 250 W 65.0 s 0.0 s 100.0 s 0.0 s } 2026-09-07
|
|
# * 500 W 30.0 s 0.0 s 35.0 s 0.0 s } committed
|
|
# 700 W 25.0 s 0.0 s 30.0 s 0.0 s } harness,
|
|
# 1000 W 15.0 s 0.0 s 25.0 s 0.0 s } re-runnable
|
|
# 1500 W 0.0 s 0.0 s 20.0 s 0.0 s }
|
|
# 2000 W 0.0 s 0.0 s 0.0 s 0.0 s }
|
|
#
|
|
# * is the shipped threshold, `Tuning.saturation_w`.
|
|
#
|
|
# ⚠️ THREE THINGS IN THAT TABLE ARE WORTH MORE THAN THE ROW THEY SIT ON.
|
|
# (a) The N=1 column is ZERO EVERYWHERE, at every threshold, IN BOTH
|
|
# CAMPAIGNS. The compatibility promise is measured, not argued, and it
|
|
# is the part that reproduced most cleanly.
|
|
# (b) The FLAPPING column is zero everywhere too, in both - and that is the
|
|
# family that defeated SAFETY-30's site window (980 s at N=2, 1000 s at
|
|
# N=1, no window inside the billing quarter surviving it). It cannot
|
|
# touch this check, because a common stimulus makes BOTH units short at
|
|
# once and the subset stops being proper.
|
|
# (c) THE ORIGINAL CURVE WAS NOT MONOTONE - 55.0 s at 250 W against 40.0 s
|
|
# at 100 W - and that was read as a property of the reduction: a lower
|
|
# threshold puts MORE units into the short set, which can turn a proper
|
|
# subset into the whole roster and BREAK the run. ⚠️ THE RE-RUN'S CURVE
|
|
# IS MONOTONE. The mechanism is real and is still the reason not to read
|
|
# either table as a sensitivity curve, but it is no longer something
|
|
# these numbers demonstrate - it is an argument now, not a measurement.
|
|
#
|
|
# THE BOUNDS THE VALUE ANSWERS TO:
|
|
#
|
|
# LOWER, from the rig - 35.0 s (TEST-21; the original record said 30.0 s
|
|
# and it did not reproduce), the worst continuous legitimate episode at
|
|
# the shipped threshold across all 2640 runs. ⚠️ THIS IS NOW STRICTLY THE
|
|
# BINDING LOWER BOUND: at 30.0 s it coincided with the firmware's veto
|
|
# below and a reader could not tell which was doing the work; at 35.0 s it
|
|
# is ABOVE it, and it is above the schema's own `int(30,900)` floor too -
|
|
# so an operator who sets `delivery_hold_s` to 30 is below a MEASURED
|
|
# legitimate episode. That is what "derived, do not change" is for.
|
|
#
|
|
# LOWER, from the firmware - `inverter.LINK_STALE_S`, 30 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 with
|
|
# a line in the action log. 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. ⚠️ THIS IS A SENSITIVITY STUDY ON
|
|
# A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, and the wording here used
|
|
# to say "MEASURED rather than GUESSED", which overstated it. B is not
|
|
# installed (~2026-09-12). The campaign's B is A COPIED
|
|
# (`plantsim.INVERTER_B`, `measured=False`), so the 35.0 s above is TWO
|
|
# IDENTICAL MACHINES and every row below is a what-if. NO ROW HERE IS B's
|
|
# LAG. Sweeping a DIFFERENT second unit through the weighted family
|
|
# (2026-09-06 -> TEST-21 re-run):
|
|
#
|
|
# B lag / ramp original re-run
|
|
# 10.0 s / 800 W/s 30.0 s 35.0 s (= A, both copies)
|
|
# 20.0 s / 800 W/s 45.0 s 45.0 s
|
|
# 10.0 s / 400 W/s 30.0 s 35.0 s
|
|
# 20.0 s / 400 W/s 45.0 s 50.0 s
|
|
# 40.0 s / 200 W/s 90.0 s 75.0 s (4x A's lag)
|
|
#
|
|
# The LAG moves it roughly linearly; the RAMP barely does. "The ramp does
|
|
# not move it AT ALL" was too strong - halving it is worth 0 s at A's lag
|
|
# and +5 s at twice it - so the ramp is second-order, not absent. That is
|
|
# the sensitivity the margin has to carry until SPIKE-04 measures the real
|
|
# unit.
|
|
#
|
|
# UPPER, from the bill - 900 s, the quarter that sets the capacity tariff.
|
|
# A site running short of one unit's share must be reported well inside
|
|
# one.
|
|
#
|
|
# 185 s: thirty-seven control cycles. Against the TEST-21 re-run: 5.29x the
|
|
# worst legitimate episode this rig produces (35.0 s), 2.47x the worst a
|
|
# hypothetical second unit FOUR TIMES slower than A produces (75.0 s), 6.2x
|
|
# the firmware's own veto window (30 s), and 4.9x inside the quarter
|
|
# (900 s). ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
|
|
# is a different check on a different quantity, so a reader must not be
|
|
# able to think it was carried across - and not 150 or 200.
|
|
#
|
|
# ⚠️ 185 STANDS AND IS NOT CHANGED. The record claimed 6.2x the measured
|
|
# worst; the re-run makes it 5.29x. All four bounds still clear and none of
|
|
# them narrowly, and the ordering of the argument is unchanged. A shipped
|
|
# safety constant is not moved because a margin went from 6.2x to 5.3x while
|
|
# every bound holds - moving it would re-open SAFETY-41's four-attempt
|
|
# false-trip regime for nothing. What changed is the CLAIM, not the value.
|
|
#
|
|
# 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, this window
|
|
# buys its margin with reporting latency and with nothing else.
|
|
"delivery_hold_s": 185,
|
|
}
|
|
|
|
|
|
# ⚠️ WHY THERE IS NO `delivery_w` OPTION, AND WHY THAT IS A DERIVATION RATHER
|
|
# THAN A SAVING. The watts side of this check asks "how far short is far
|
|
# enough to be worth saying anything about", and the shipped control law
|
|
# already answers that question in one number: `Tuning.saturation_w`, 500 W,
|
|
# the gap at which `compute()` itself concludes THE INVERTER IS NOT FOLLOWING
|
|
# and freezes the integrator. Below it the loop calls the divergence ordinary
|
|
# tracking, and an alarm under a threshold the control law calls normal is an
|
|
# alarm about nothing.
|
|
#
|
|
# So `main.py` hands this check `self.tuning.saturation_w` and there is no
|
|
# fourth copy of a default to keep in step (CFG-03's complaint) and no way for
|
|
# the two to drift: an operator who raises `saturation_w` has told the loop
|
|
# that a bigger gap is ordinary tracking, and this check moves with them by
|
|
# construction rather than by somebody remembering.
|
|
#
|
|
# ⚠️ AND THAT SENTENCE IS A CHECKED CLAIM, NOT A DESCRIPTION OF AN INTENTION.
|
|
# It is only true while the call site passes the CONFIGURED value; a literal
|
|
# there that happens to equal 500 today keeps every fixture green and quietly
|
|
# removes the whole guarantee - `250.0` in place of `self.tuning.saturation_w`
|
|
# was a survivor of all 23 suites, because every fixture's gap was far above
|
|
# both numbers. test_delivery.py part 4b is what makes it checkable: it runs
|
|
# the same wedged unit at the shipped `saturation_w` and at a raised one inside
|
|
# the shipped schema, with a shortfall that falls BETWEEN them, and the two
|
|
# arms must answer differently. No constant passes both.
|
|
#
|
|
# ⚠️ IT IS ALSO WHY THIS CANNOT DOUBLE-REPORT THE SATURATION FREEZE'S OWN
|
|
# EVENT. `compute()` needs `saturation_cycles` (3) CONSECUTIVE cycles over
|
|
# that same gap before it freezes anything, so every cycle this module counts
|
|
# is a cycle the freeze has already seen. This is a reduction over the freeze's
|
|
# input, not a second opinion about it.
|
|
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeliveryConfig:
|
|
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
|
|
|
|
hold_s: float = float(DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UnitDelivery:
|
|
"""ONE unit's four facts for this cycle, and nothing else.
|
|
|
|
`name` the roster name. An alarm that cannot say which box to look at
|
|
is the whole reason SAFETY-30 split per-unit from site.
|
|
`share_w` what is STANDING on this unit's wire - `Inverter.standing_w()`,
|
|
the same operand SAFETY-30's per-unit comparison takes, after
|
|
the deadband, the slew limit, quantisation and the arbiter.
|
|
Not the optimizer's wish: the wire carries the emitted value.
|
|
`measured_w` this unit's OWN power, holding register 0x0518, + = discharge.
|
|
None when the reading did not arrive.
|
|
`accounted` why a shortfall on this unit is already explained, or None if
|
|
nothing this controller reads explains one. Built by
|
|
`account()`; it is a field rather than a method because the
|
|
facts behind it (liveness, the sign latch, SoC) belong to
|
|
main.py's `Inverter` and this module imports none of it.
|
|
"""
|
|
|
|
name: str
|
|
share_w: float | None
|
|
measured_w: float | None
|
|
accounted: str | None = None
|
|
|
|
|
|
def short_by(share_w, measured_w):
|
|
"""Watts of a DISCHARGE share that did not arrive, or None if unanswerable.
|
|
|
|
Positive means short. Zero and negative are folded into None rather than
|
|
reported, because "delivered its share" and "delivered more than its share"
|
|
are both not-this-fault and a caller comparing a float against a threshold
|
|
should not have to know which.
|
|
|
|
⚠️ CHARGE SHARES ANSWER None, AND THAT IS THE STATED LIMIT AT THE TOP OF
|
|
THIS FILE, not an oversight to be tidied away. There is no configured charge
|
|
ceiling in this add-on to explain a taper with.
|
|
|
|
⚠️ AND A MISSING READING ANSWERS None RATHER THAN "the whole share". Zero
|
|
would be this check inventing a fault from an absence, which is the shape of
|
|
the 2026-09-02 outage read the other way round; 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).
|
|
"""
|
|
if share_w is None or measured_w is None:
|
|
return None
|
|
share = float(share_w)
|
|
if share <= 0.0:
|
|
return None
|
|
gap = share - float(measured_w)
|
|
return gap if gap > 0.0 else None
|
|
|
|
|
|
def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
|
|
"""Why a shortfall on this unit is ALREADY EXPLAINED, or None if nothing is.
|
|
|
|
Every branch here is a state this add-on can read and already has an answer
|
|
for, and naming one of them a fault would be raising a second, louder alarm
|
|
about something already being handled - the SAFETY-15/SAFETY-19 failure that
|
|
`circulation.py` and `watchdog.py` both take their 30 s lower bound from.
|
|
|
|
`live` `Inverter.is_live()`. A unit whose link is stale or whose
|
|
writes are being rejected has ALREADY been dropped from the
|
|
split with a line in the action log saying so.
|
|
`parked` that unit's sign watchdog has latched. It is being commanded
|
|
0 W on purpose; not delivering is compliance, not a fault.
|
|
`soc` that pack's measured state of charge, percent. None is not a
|
|
full battery and is not permission - see below.
|
|
`bms_floor` that pack's OWN cut-out, `bms_soc_floor`. Required of every
|
|
roster entry since SAFETY-32, so at the only roster this
|
|
module is live at it is always configured.
|
|
|
|
⚠️ THE SoC BRANCH IS THE WHOLE DISCRIMINATOR AND IT IS ASKED THROUGH
|
|
`peakguard.discharge_permitted()`, WHICH IS PUBLIC FOR EXACTLY THIS REASON:
|
|
"a safety predicate transcribed into its own check is one copy plus a future
|
|
disagreement about what the check means". What is NOT reused is its wording,
|
|
which is peak-guard flavoured ("the peak guard's SoC floor") and would be a
|
|
false statement here - this module is not the peak guard and its floor is
|
|
the pack's own cut-out, not `peak_soc_floor`. One predicate, two sentences.
|
|
⚠️ AND 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, and
|
|
that direction is the opposite of the one `discharge_permitted()` was
|
|
written for. There it withholds a discharge; here it withholds an ALARM. An
|
|
unreadable SoC or an unstated floor means this module cannot say whether the
|
|
pack is empty, and an alarm nobody can justify is the crying-wolf failure
|
|
this whole file is written against. The cost is bounded: the same absence
|
|
stops the site through `inputs_ok` (SoC) or is refused at startup by the
|
|
roster schema (the floor).
|
|
"""
|
|
if not live:
|
|
return ("its link is stale or its writes are being rejected, and the "
|
|
"allocator has already dropped it from the split")
|
|
if parked:
|
|
return "it is latched at 0 W by a sign trip, so it is doing as it is told"
|
|
why = discharge_permitted(soc, bms_floor)
|
|
if why is not None:
|
|
if soc is None:
|
|
return "its state of charge did not read, so its pack cannot be graded"
|
|
if bms_floor is None:
|
|
return ("no bms_soc_floor is configured for it, so there is nothing "
|
|
"to say whether its pack is empty")
|
|
return (f"its pack is at {float(soc):.0f} %, at or below its own "
|
|
f"{float(bms_floor):.0f} % cut-out (bms_soc_floor)")
|
|
return None
|
|
|
|
|
|
def delinquent(units, min_w: float) -> 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`.
|
|
|
|
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
|
|
not delivering" and "B is not delivering while A is" are different facts and
|
|
only the second one is this module's conclusion.
|
|
|
|
⚠️ THE PROPER-SUBSET RULE IS THE CHECK, NOT A FILTER ON IT. A unit is named
|
|
only while the units in its state are FEWER than the units that were asked
|
|
to discharge. Everything asked and nothing explained is a SITE condition -
|
|
a derate, a curtailment, a grid-code limit, a plant that simply cannot -
|
|
and this module has no instrument that can tell that from every box being
|
|
wedged at once. Reporting it anyway would be the saturation-shaped alarm
|
|
`clamp.py` warns about, produced by the guard written to replace it.
|
|
|
|
⚠️ AND THAT IS WHY N=1 IS INERT WITHOUT A ROSTER GUARD. A `if n_units > 1`
|
|
in the caller would be a second place the same decision lives; the algebra
|
|
is the guard, the same way `circulation.circulating_w()` is identically zero
|
|
at N<=1.
|
|
|
|
⚠️ BUT THE ALGEBRA THAT DOES IT IS THE PROPER-SUBSET RULE, NOT THE
|
|
`len(asked) < 2` LINE - AND THIS PARAGRAPH USED TO SAY OTHERWISE. The
|
|
mutation battery found it: relaxing that line to `len(asked) < 1` is a
|
|
SURVIVOR at +0 B, and it is an EQUIVALENT MUTANT rather than a gap in the
|
|
checks. With one asked unit, `unaccounted` is a subset of a one-element set,
|
|
so it is either empty (`not unaccounted` returns `{}`) or equal to `asked`
|
|
(`len(unaccounted) == len(asked)` returns `{}`). There is no third case and
|
|
no input on which the two spellings differ. The line is a FAST PATH and a
|
|
signpost, not the mechanism - said here rather than left for the next
|
|
mutation sweep to rediscover, exactly as `circulation.circulating_w()` says
|
|
of its own `max(0.0, ...)`. Do not report its survival as a gap in the
|
|
checks, and do not delete it either: it is what tells a reader at a glance
|
|
that a one-inverter install never reaches any of this.
|
|
"""
|
|
asked = [u for u in units
|
|
if u.share_w is not None and float(u.share_w) > 0.0]
|
|
if len(asked) < 2:
|
|
return {}
|
|
|
|
gaps = {}
|
|
for u in asked:
|
|
gap = short_by(u.share_w, u.measured_w)
|
|
if gap is not None and gap > float(min_w):
|
|
gaps[u.name] = gap
|
|
|
|
unaccounted = {u.name: gaps[u.name] for u in asked
|
|
if u.name in gaps and u.accounted is None}
|
|
if not unaccounted or len(unaccounted) == len(asked):
|
|
return {}
|
|
|
|
peers = [u.name for u in asked if u.name not in unaccounted]
|
|
out = {}
|
|
for u in asked:
|
|
if u.name not in unaccounted:
|
|
continue
|
|
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"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
|
|
f"that state. Nothing this add-on reads explains it")
|
|
return out
|
|
|
|
|
|
class DeliveryWatchdog:
|
|
"""ONE PER INVERTER, evaluated once per control cycle.
|
|
|
|
Per unit rather than per site, and that is card requirement 2: the site
|
|
figure cannot say which box to look at, and naming the box is the whole
|
|
reason SAFETY-30 split per-unit from site in the first place. It sits beside
|
|
`Inverter.sign_wd` on the same object, under the same namespaced store keys,
|
|
for the same reason.
|
|
|
|
⚠️ NOT SHARED ACROSS CADENCES, for `SignWatchdog`'s reason: `since` is a
|
|
wall-clock start time, so two loops at two rates would each reset the
|
|
other's window.
|
|
"""
|
|
|
|
def __init__(self, cfg: DeliveryConfig | None = None, *,
|
|
tripped: bool = False, reason: str = ""):
|
|
self.cfg = cfg or DeliveryConfig()
|
|
self.since: datetime | None = None
|
|
# ⚠️ RESTORED FROM THE STORE, NOT DEFAULTED - SAFETY-34, and it is the
|
|
# requirement the card states in its own words. See the module note.
|
|
self.tripped = bool(tripped)
|
|
self.reason = reason if self.tripped else ""
|
|
|
|
def arm(self, cfg: DeliveryConfig) -> None:
|
|
"""Set the window. Nothing else.
|
|
|
|
⚠️ IT IS CONFIGURATION, NOT STATE, AND IT DELIBERATELY DOES NOT TOUCH
|
|
`since`, `tripped` OR `reason` - `SignWatchdog.arm()`'s argument,
|
|
which was found the expensive way: a detector that cleared its own
|
|
window when it was told what it was measuring would lose the episode it
|
|
is in the middle of, every cycle, and would never accumulate a hold.
|
|
"""
|
|
self.cfg = cfg
|
|
|
|
def evaluate(self, sentence, now: datetime) -> bool:
|
|
"""Advance one cycle. `sentence` is this unit's entry from `delinquent()`
|
|
(None when it has none). Returns True while tripped.
|
|
|
|
`now` is the caller's clock - `SignWatchdog.evaluate()`'s and
|
|
`CirculationWatchdog.evaluate()`'s discipline, so this is testable and
|
|
replayable rather than reading a clock of its own.
|
|
"""
|
|
if self.tripped:
|
|
# Latched. It does not re-evaluate, and unlike SAFETY-02 and
|
|
# SAFETY-31 that is NOT because the trip removed the evidence -
|
|
# nothing here changes what is commanded, so the evidence is still
|
|
# standing. It is because an intermittent unit that resumed must not
|
|
# erase its own record. See the module note.
|
|
return True
|
|
|
|
if sentence is None:
|
|
self.since = None
|
|
return False
|
|
|
|
if self.since is None:
|
|
self.since = now
|
|
return False
|
|
|
|
held = (now - self.since).total_seconds()
|
|
if held < self.cfg.hold_s:
|
|
return False
|
|
|
|
self.tripped = True
|
|
self.reason = (f"{sentence} - held for {held:.0f} s continuously "
|
|
f"(limit: over {self.cfg.hold_s:.0f} s)")
|
|
return True
|
|
|
|
def held_s(self, now: datetime) -> float:
|
|
"""How long the current shortfall has stood, for the status page.
|
|
|
|
Takes the clock rather than reading one, for `SignWatchdog.held_s()`'s
|
|
reason: an unclocked function is one nothing can test, which is exactly
|
|
what happened to that one.
|
|
"""
|
|
if self.since is None:
|
|
return 0.0
|
|
return (now - self.since).total_seconds()
|
|
|
|
def reset(self) -> None:
|
|
self.since = None
|
|
self.tripped = False
|
|
self.reason = ""
|