Files
goodwe-addon/goodwe_controller/app/delivery.py
T
Claude 090a8ecc35
checks / unit (pull_request) Successful in 6m7s
SAFETY-42 round 2: pin the two thresholds at a config that is not the shipped one
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
2026-09-06 19:25:45 +02:00

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 = ""