checks / unit (pull_request) Successful in 6m7s
THE BLOCKER. At the `delinquent()` call site, `self.tuning.saturation_w` -> `250.0` (-19 B) was a survivor of all 23 suites. It is not an equivalent mutant: two copies of inverter A, B wedged, `saturation_w: 1800` (inside its own schema int(100,2000)) and a load of 1600 W put B's shortfall strictly between the literal and the configured value - head names nobody, the mutant names B. At the shipped 500 both name it, which is exactly why every fixture in part 4 was blind: their gaps are all above both thresholds. It matters beyond a mutation score. `delivery.py`'s MIN_W_IS_SATURATION_W note and the `delivery_hold_s` block in config.yaml both promise an operator, in shipped prose, that there is no fourth copy of a threshold to keep in step and no way for the two to drift - raise `saturation_w` and this check moves with you. A literal at the call site removes exactly that promise, silently, and the promise is why the design was chosen over a `delivery_w` key. ASKING THE SAME QUESTION OF EVERY OTHER NUMBER THIS CHECK READS FOUND A SECOND SURVIVOR OF ALL 23 SUITES: `bms_floor=inv.bms_soc_floor` -> `10.0` (-13 B), invisible because config.yaml's own example states 10 % and every fixture in this file used it. Two arms bracket it: a pack at 15 % against its own 25 % cut-out is cold and must stay unnamed (the literal names an empty pack), and a pack at 8 % against its own 5 % cut-out is wedged and must be named (the literal explains the fault away). One arm on each side leaves no constant at all. `delivery_hold_s` and `is_live()`'s window were ALREADY pinned - the fixtures had to move them to run - and `LINK_STALE_S` has no option behind it, so "pinned across a changed config" is not available for it and is not claimed. DECISIONS section 9 enumerates all of it with the deltas and which check kills which. Verified in the CI image, tree mounted :ro, `python3 -B`, `__pycache__` purged and counted before every run (TEST-15): R5 saturation_w -> 250.0 -19 B killed by part 4b(a) R6 bms_soc_floor -> 10.0 -13 B killed by part 4b(b), both arms R7 arm(self.delivery_cfg) -> DeliveryConfig() -1 B killed (already) R8 is_live(now) -> is_live(now, 3600.0) +8 B killed (already) R9 pack cut-out -> conjoined site floor +105 B killed twice head: 23/23 test files PASS, no bytecode written during the run. No shipped behaviour changes: the only edits to app/ are comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
635 lines
34 KiB
Python
635 lines
34 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. Worst CONTINUOUS legitimate episode:
|
|
#
|
|
# threshold reversals flapping weighted N=1 control (1890 runs)
|
|
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s
|
|
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s
|
|
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s
|
|
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s
|
|
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s
|
|
# 2000 W 10.0 s 0.0 s 5.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, in all 1890 runs at every
|
|
# threshold. The compatibility promise is measured, not argued.
|
|
# (b) The FLAPPING column is zero everywhere too - 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 CURVE IS NOT MONOTONE - 55.0 s at 250 W against 40.0 s at 100 W -
|
|
# and that is a property of the reduction, not noise: a lower threshold
|
|
# puts MORE units into the short set, which can turn a proper subset
|
|
# into the whole roster and BREAK the run. Do not read it as a
|
|
# sensitivity curve.
|
|
#
|
|
# THE BOUNDS THE VALUE ANSWERS TO:
|
|
#
|
|
# LOWER, from the rig - 30.0 s, the worst continuous legitimate episode at
|
|
# the shipped threshold across all 2640 runs.
|
|
#
|
|
# 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, AND IT IS MEASURED RATHER THAN
|
|
# GUESSED. The campaign's B is A COPIED (`plantsim.INVERTER_B`,
|
|
# `measured=False`), so the 30.0 s above is two identical machines.
|
|
# Sweeping a DIFFERENT second unit through the weighted family: the worst
|
|
# legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and
|
|
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 90.0 s at
|
|
# four times it. 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. 6.2x the worst legitimate episode this
|
|
# rig produces, 2.06x the worst a second unit FOUR TIMES slower than A
|
|
# produces, 6.2x the firmware's own veto window, and 4.9x inside the
|
|
# quarter. ⚠️ 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.
|
|
#
|
|
# 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 = ""
|