checks / unit (pull_request) Successful in 6m29s
stated the wrong mechanism ⚠️ REVIEW FINDING, BLOCKING, AND IT IS THE EVIDENCE THAT WAS WRONG RATHER THAN THE BEHAVIOUR. Part 8b's cut-out arm claimed an "identical power trace" to the wedged arm and that "what separates them is `account()`". EVERY PART OF THAT WAS FALSE, and the check survived deleting the gate it named. Measured: cut-out arm, account() live named 0/200, distinct B shares: [0] cut-out arm, account() FORCED None named 0/200, distinct B shares: [0] wedged arm, account() live named 200/200, shares 77/125/133/154 W At its own cut-out the pack's usable energy ABOVE that cut-out is zero, so `weights()` answers 0.0, `_fill()`'s preferred pass gives it nothing, the healthy sibling covers the load alone so the last-resort pass never runs, and its share is 0 W ON ALL 200 CYCLES. A unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()` is never consulted. Nothing was identical, "100 % of the ask" was 0/0, and the arm never reached the comparison it was written to grade. WHAT REPLACES IT IS A BETTER FACT, NOT A WEAKER ONE: at its own cut-out OPT-09 STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached. That is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the allocator declines to ask before the detector has to decline to alarm. So the arm is kept and re-pointed, not deleted: * the 0 W share is now ASSERTED (`_sat_last[1][Bee] == 0`), not inferred from the silence - a control that cannot say WHY it is quiet cannot fail; * the arm runs a SECOND time with `account()` DELETED (`no_account=True`) and must still be silent, which is what proves which mechanism does the work; * the account gate's claim is re-pointed at test_delivery.py part 1, where it is graded on a REAL positive 125 W share. ⚠️ AND IT WAS INVISIBLE FOR ONE REASON WORTH KEEPING: that was the ONLY line in part 8b that did not print its shares. `inverter-b+0` would have been on screen from the first run. Every line in the part prints them now. The derated control's vacuity guard also now asserts ITS OWN unit is asked, rather than re-asserting a different run's 200/200 - which is exactly why the old guard could not see this. ALSO, from the same review: N1 app/delivery.py said 0.05 was "twelve times more sensitive" than 0.85. 0.85/0.05 = 17 and no reading of the pair gives 12. An unsupported number in shipped source; replaced with what was actually measured. N2 M14's recorded count was 4 named checks in test_delivery.py and it is 5 - part 1's pre-existing "a shortfall AT the threshold is not one" also goes red. It under-claimed, so it never inflated the evidence, but a recorded figure that does not reproduce is still one. N3 test_delivery.py part 1 printed the alarm sentence with a bare `delinquent(...)["b"]`, which raises KeyError under M12/M13 and ABORTED THE FILE AT PART 1, leaving parts 2-8 ungraded. The direction is safe (an abort is a red; MUTATION.md's defect fabricates only greens) but the recorded coverage read smaller than the truth. It is a `.get` now, and M12/M13's counts are re-run: 7 and 7 named checks, not 5 and 6. CFG-07: the assessment is unchanged (worse by one control cycle, 35.0 -> 40.0 s, deliberately not fixed here) and now says WHEN it has to close - BEFORE INVERTER B IS COMMISSIONED (~2026-09-12), because the band is unreachable at N=1 and commissioning B is when it first becomes reachable - and that CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE, so whoever takes it writes one first. No behaviour change. `delinquent()`'s two arms, the fraction, `min_w` and the N=1 path are all untouched by this commit. 27/27 suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
804 lines
45 KiB
Python
804 lines
45 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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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, the most sensitive
|
|
# fraction measured and far below anything this card would ship - 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."""
|
|
|
|
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,
|
|
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 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
|
|
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 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
|
|
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 "
|
|
# 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
|
|
|
|
|
|
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 = ""
|