Compare commits

...
9 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 06661c5e17 PKG-07: bump 0.7.0 -> 0.8.0, because 0.7.0's own default is now a refused config
checks / unit (pull_request) Successful in 6m6s
Six merges landed behind an unbumped 0.7.0 - CI-06, SAFETY-38, SAFETY-39,
SAFETY-37, SAFETY-13, SAFETY-42. PKG-06 recorded this exact failure YESTERDAY
(bbc903b, 2026-09-05 19:23): the Supervisor keys the built image by `version:`,
so `ha apps update` against an unchanged version is a silent no-op and the box
keeps running the old tree. Catching it by hand a second time is the argument
for the automated check PKG-06's follow-up still owes.

MINOR rather than patch, and the reason is sharper than a semantic change:
SAFETY-39's validator rule 10 refuses an explicit `slew_w` above
SLEW_RAIL_FRAC x the site rail - 555.6 W on a 5000 W rail - and 0.7.0's own
SHIPPED DEFAULT was `slew_w: 1000` under schema `int(50,5000)`. An install that
never touched the key is refused on 0.8.0. Refusal is SystemExit(78); the
add-on does not start.

⚠️ AN EARLIER DRAFT OF THIS COMMIT JUSTIFIED THE BUMP BY CLAIMING `slew_w: 0`
USED TO MEAN "no limit" AND NOW MEANS "follow the rail". THAT WAS FALSE AND IS
REMOVED. The schema was `int(50,5000)` from the repo's first commit through
0.7.0, so 0 was never a legal value and never meant anything; and the old
`control.py` had no zero case - at slew_w=0 it pinned the command to its
previous value, freezing it, which is the opposite of "no limit". The widening
to `int(0,5000)` is a NEW capability, not a changed meaning. The bump is right;
the story told for it was invented - PROC-01's defect appearing in the very
commit that complains about process.

⚠️ AND THEN A SECOND ONE, IN THE SAME COMMIT: the text said PKG-06 was "three
days ago" when the two commits are about a day apart (2026-09-05 19:23 ->
2026-09-06 20:03). Caught in round 2 and corrected. Two invented facts in one
documentation-only change is the measurement PROC-01 wants, not an anecdote:
both were plausible, neither was load-bearing, and both would have shipped.

⚠️ THE PRE-DEPLOY CHECK THIS IMPLIES IS NOT DONE AND CANNOT BE DONE FROM HERE.
Whether the LIVE options survive rule 10 depends on the live `slew_w`, and the
options blob is only reachable through the Supervisor API, which needs a token
this session does not have; the HA REST API does not expose it and no entity
carries it. On record `slew_w` was set to 400 during the SAFETY-39 interim fix,
and 400 is below 555.6 - but that is a written record, not a reading, and the
value has been hand-edited since. Read the options and check them BEFORE
`ha apps update`. Exit 78 also takes the ingress panel down, because the add-on
serving it is not running.

The contents table now carries each merge subject's WHOLE claim. Two rows had
been trimmed to their flattering halves - SAFETY-39's "`gain` is the constant
that is still wrong" and SAFETY-42's "the discriminator the card asked for does
not exist" - and both are restored.

Separately, `## Unreleased` was a lie twice over: two headings by that name, one
spanning ~3000 lines across three releases, one from the 0.4.x era, and every
word under both is shipped and running. Retitled, not re-sorted; the split is
PKG-08.

Reviewed twice. Round 1 REJECTED for an empty PR - built in a clone a concurrent
agent was using, and "pushed" reported from the command's echo rather than from
the remote ref - and for the fabricated slew_w story. Round 2 APPROVED with the
date as a non-blocking finding, fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 20:16:39 +02:00
admin 464d398051 Merge pull request 'SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist' (#59) from SAFETY-42 into release/1.0
checks / unit (pull_request) Successful in 6m6s
SAFETY-42: alarm when the saturation freeze persists past the plant bound
2026-09-06 19:43:17 +02:00
Claude 090a8ecc35 SAFETY-42 round 2: pin the two thresholds at a config that is not the shipped one
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
2026-09-06 19:25:45 +02:00
glenn schrooyenandClaude Opus 5 c7eb8c6b97 SAFETY-42: close two mutation gaps, and correct what makes N=1 inert
The battery is 12 mutations, one freshly copied tree each, purged, `:ro`,
`python3 -B`, byte deltas measured. First round: 9 red, 3 survivors. All three
are now accounted for and the ledger is in DECISIONS §8.

M5 - `share <= 0` relaxed to `share == 0`, +0 B - WAS A REAL GAP AND THE TWO
OBVIOUS CHECKS DID NOT CLOSE IT. `short_by(-2000, 0) is None` and
`short_by(-2000, -100) is None` both stayed GREEN under the mutant: for a charge
share that is UNDER-delivered `share - measured` is negative and the `gap > 0`
test answers None anyway, so neither input can separate the two spellings. The
one that can is a unit moving MORE than its charge share - -2000 W told,
-3000 W measured, `share - measured` = +1000 W - which the mutant reports as a
shortfall. It is not one; it is something else driving that pack, which is
SAFETY-30's and SAFETY-31's question. That input is now the check, and M5 is RED.
MUTATION.md's third failure mode in miniature: the assertion ran, the sentence
was true, and the data could not reach the failure it named.

M11 - `inv.is_live(now)[0]` replaced by a literal `True`, -15 B - was a WIRING
gap the pure checks structurally could not see. `account(live=False, ...)` was
graded as a FUNCTION; nothing graded that `cycle()` asks the question, because no
fixture had a unit whose writes were being rejected. Such a unit still has its
last ACCEPTED command standing on the wire (`write_setpoint` leaves `last_write`
alone on a rejected write) and still measures nothing, so it looks exactly like a
wedged one to every number this check reads - and reporting it would be the
second, louder alarm about a state the allocator has already handled and logged.
`test_delivery.py` part 6 now builds that unit, with `rejected_since` back-dated
past LINK_STALE_S because the fixture runs in two seconds and the timer is
test_inverter.py's to grade. M11 is RED.

M3 - `len(asked) < 2` relaxed to `< 1`, +0 B - IS STILL A SURVIVOR, and it is an
EQUIVALENT MUTANT. 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 {}); with zero asked units both
spellings return {} as well. No input separates them, so no check can.

⚠️ AND FINDING IT CORRECTED THE MODULE'S OWN PROSE, WHICH IS THE MORE USEFUL
HALF. `delinquent()`'s docstring claimed `len(asked) < 2` was what makes N=1
inert. It is not - the PROPER-SUBSET rule is, and that line is a fast path and a
signpost. A false attribution of the mechanism in the file's own prose is exactly
what this repository keeps finding, and a mutation that survives is how it
surfaced. The line stays, with the equivalence written down beside it the way
circulation.circulating_w() records its own max(0.0, ...).

Also recorded, rather than left implicit: `account()`'s `parked` branch is graded
as a function but its WIRING is not mutated on its own; part 6's reversed-unit
fixture exercises it end to end, which is weaker, and the ledger says so.

Baseline 23/23 green before and after. app/main.py is byte-identical to the
previous commit - both closures are a test and a comment.

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
glenn schrooyenandClaude Opus 5 706e625d99 SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
A unit standing at a non-zero share and producing nothing is seen by NOTHING.
SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a
unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the
meter in direction. Circulating power is exactly zero when one unit does
nothing. The only trace left is `reason == "saturated-freeze"`, which
clamp.py:93-96 documents as the alarm an operator learns to ignore. It is
HW-01's shape, which ran 6 h 25 m with all three layers spectators.

⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE
MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while
|grid error| > X, both derived". Run on the two-master rig it fails in BOTH
directions, and no (N, X) fixes either:

  FALSE POSITIVE  `plantsim.Plant.limit()` returns 0 W for a discharge command
      into a pack at its own floor, and a wedged unit produces 0 W too. Load
      2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE
      IDENTICAL ON ALL 120 in grid, site target, every share and every measured
      watt. Both saturated-freeze on 116 of them, both hold |grid error| over
      300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The
      predicate's inputs do not differ, so no threshold on them can differ. And
      "a cold pack at its floor must not alarm" is the card's own AC 2.

  FALSE NEGATIVE  the standing grid error is what a HEALTHY SIBLING ERASES.
      Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop
      is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing
      at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run
      on the exact fault the card describes.

WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable
because of a reduction that only exists at N>=2:

    a unit is named while it is short by more than `saturation_w` with NO
    ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units
    asked to discharge.

THE ACCOUNT keeps the cold pack out and is the only thing that can: the two
cases are identical in the power domain, so the discriminator has to be a
non-power fact, and there is exactly one - the pack's own SoC against its own
`bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than
transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with
`peak_soc_floor`: that floor bounds a money-saving discharge only and using it
would explain away a wedged unit anywhere between the two.

THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see
hits every unit; one box that stopped listening does not - and it is what makes
this INERT AT N=1, by algebra rather than by a roster guard.

IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping
the wedged unit from `live` genuinely recovers a site the shipped path does not:
at load 1500 W, kept in the split the site settles at 800 W with a 700 W error
and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the
sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What
refuses it is that the only re-allocation this add-on can express is
all-or-nothing: a unit derated to 50 % is short by more than saturation_w with
nothing to explain it, so this detector names it too, and at load 3000 W
dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE.
A partial re-allocation is what would make acting safe and the allocator cannot
express it. That is a card.

control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it.

NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so
there is no fourth CFG-03 copy and it cannot drift from the number the control
law itself calls "not following". `delivery_hold_s: 185` comes off 2640
closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over
the whole schema range, across load reversals, a flapping load and ASYMMETRIC
SPLITS - a family SAFETY-30's campaign did not have and the one that dominates
here) plus 1890 runs at a roster of ONE as the control:

    threshold   reversals   flapping   weighted   N=1 control
     * 500 W      20.0 s      0.0 s      30.0 s      0.0 s

The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping
column - the family that defeated SAFETY-30's site window - is zero too, because
a common stimulus makes both units short at once and the subset stops being
proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W):
lowering the threshold can turn a proper subset into the whole roster and BREAK
the run.

⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY
EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter
figure above is two copies of A. Sweeping a different second unit: 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, 90.0 s at four times it.
185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x
LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is
SAFETY-02's site hold.

SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per
unit, re-raised on the first cycle of every process, cleared by the Start press.
No flat mirror: a rollback lands on a version that drives one actuator, where
this check is inert.

⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a
version bump before it ships. Additive and defaulted, so an existing options blob
stays valid.

STATED GAPS: charge shares are not graded (no configured charge ceiling exists,
so a constant-voltage taper cannot be explained and arming it would fire on every
maintenance charge); every unit wedged at once is not caught (the subset is not
proper and no instrument here separates that from a plant that cannot).

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
admin 0581648c73 Merge pull request 'SAFETY-13: the output freeze gets an exit, and only where it needs one' (#58) from SAFETY-13 into release/1.0
checks / unit (pull_request) Successful in 5m30s
2026-09-06 19:19:06 +02:00
glenn schrooyenandClaude Opus 5 f156fadac0 SAFETY-13: the bound came from the wrong half of a two-sided measurement
checks / unit (pull_request) Successful in 5m34s
NOTES.md 5.10 measured the 1.78 kW step BOTH ways - "Up: ~18 s from step to
deadband. Down: ~23 s" (NOTES.md:1550). SAFETY-39 takes 18 correctly: its
bound is a REFUSAL and the looser number refuses less. This branch reused
PLANT_SETTLE_S for the opposite job - how long the plant may still be the
EXPLANATION for a divergence - where the conservative half is the longer one.
ceil(23/2) = 12, not 9, and being short there does not refuse anything, it
accuses.

The false positive is real and is measured here. One inverter, one compute(),
one plant, no second writer anywhere in the harness: the command falls through
zero while the pack is still coasting the old way, unasked_power() calls our
own power "unasked", the counter reaches 9 while it is still coasting, and the
law emits "freeze-released". At the plant's own measured 18 s/23 s shape, over
272 load-step pairs, the 9-cycle bound fires in 62 and the 12-cycle bound in 2.

  PLANT_SETTLE_DOWN_S = 23.0 is added beside PLANT_SETTLE_S = 18.0, because
  the plant's settling time is a PAIR and each bound has to say which half it
  means and why.
  SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) = 12. It is no
  longer the reciprocal of SLEW_RAIL_FRAC; that was a coincidence of both
  reading the same half, and test_control.py now asserts the inequality.
  _SAT_COUNT_CAP moves with it, 10 -> 13. config.yaml accepts
  saturation_cycles as int(1,10), so the whole accepted range is now strictly
  BELOW the bound and the degenerate configuration this branch used to
  document no longer exists - pinned against config.yaml's own schema line.

Three shipped statements were falsified by that measurement and are rewritten,
not renumbered - at 12 the residual is smaller, not zero:

  unasked_power()'s docstring said anything outside the interval "was put
  there by something other than this loop". It was our own power: eight cycles
  after a +1990 W command the command is 0 and the pack still reads +522.3 W.
  control.py said "SO THE BOUND BELONGS TO (b) AND ONLY TO (b)". It fired in
  case (a).
  DOCS.md told an operator "if you see freeze-released, look for the second
  writer" - on a single-inverter install, hunting something that does not
  exist. Replaced by a section that gives the innocent causes first (a large
  load or PV swing; a plant slower than the one measured, 8/320 scenarios at
  23 s rising to 268/320 at 50 s) and the second writer only when the state
  STANDS.

Round 1's own results are kept and re-graded, not weakened. The release/1.0
sweep had to widen to reach the new bound (3780 cases): 3267 bit-identical,
432 releases, 27 command changes - all still charge starts from prev_w == 0 -
and 81 differing in the saturation counter alone, which nothing downstream can
read. The card's case releases on the twelfth saturated cycle instead of the
ninth and settles at the same -1190 W against the same frozen integrator.

Also corrected, because they were claims and did not reproduce: the coverage
table's "output freeze" row was 11, and the mutation form its own note
describes gives 6 on this tree, 6 on the first SAFETY-13 commit and 2 on
release/1.0; the detector row was left at 11 and is 15; the frozen = False
footnote was left at 10 and is 13. And section 6 reported 107 625 as "released
states" when it is the number of states SWEPT - 67 500 of them release, and
both are counted now.

Nothing here reached the inverter. 22/22 test files pass in goodwe-addon-ci,
tree rsynced to a plain path, mounted :ro, python3 -B, __pycache__ purged and
printed first (0 dirs / 0 .pyc; the worktree carries none).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 18:32:03 +02:00
adminandClaude Opus 5 6c4565659b SAFETY-13: the output freeze gets an exit, and only where it needs one
checks / unit (pull_request) Successful in 5m30s
While the loop is frozen the command's magnitude may fall but never rise. At
prev_w == 0 there is no magnitude left to fall, so a charge cannot START. That
refusal is deliberate and it is kept: the inverter reporting power we did not
command means another writer is on the actuator, which is the §1.5 non-goal.
What it did not have was an END - nothing cleared it but the other writer
leaving.

Reproduced on release/1.0 @ c8dcb38, at the live install's shape (max_w 5000,
slew_w 400, target_grid_w -10), with another writer holding the pack at +700 W.
The route is not the obvious one: entering from an export, the slew limit moves
the command off zero before the counter reaches 3. It is entered from the
DEADBAND - a site resting at its target counts saturated cycles while nothing is
wrong - and 0 W is then a fixed point: a 2 kW export arriving afterwards leaves
the command at exactly 0.0 W on every subsequent cycle.

And prev_w == 0 is not where it stops. The same tuning pins at -800 W against
its own integrator standing at -2388 W, with the same absence of an exit. So the
change is written against the output freeze, not against a special case of it.

THE DECISION IS WHICH FREEZES GET AN EXIT, NOT HOW LONG THE BLOCK LASTS.

  - A divergence a LIMIT could have produced - the inverter doing less than the
    command, in the command's own direction - clears itself, because the command
    is pinned at something the inverter can reach. No exit. release/1.0
    unchanged, and asserted so: a curtailed inverter is never released over 30
    cycles.
  - A divergence a limit could NOT have produced - overshooting the command, or
    the opposite sign - cannot clear itself, because clearing it needs the
    inverter to track OUR command. That one releases.

unasked_power() is that predicate and it is physical, not statistical: a limit
can only make the inverter do less, in the command's own direction, so
everything a limit can produce lies in the interval spanned by 0 and prev_w
widened by saturation_w. At prev_w == 0 it IS the saturation test, which is
exactly why the zero case is the one that strands.

The bound is not a new number. SETTLE_CYCLES = ceil(PLANT_SETTLE_S /
LOOP_TICK_S) = 9, SAFETY-39's two measured constants and the reciprocal of
SLEW_RAIL_FRAC: a divergence still standing after the plant's own settling time
is not this plant catching up. _SAT_COUNT_CAP is written as SETTLE_CYCLES + 1;
its value is unchanged at 10.

RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP, which is what makes this
bounded. The integrator freeze is untouched, so a released cycle can only move
the command toward an accumulator that stopped accumulating, by at most one slew
step, and never past it - swept over 107 625 released states. On the run above
the command starts on the ninth saturated cycle and settles at -1190 W against a
frozen integrator of -1194 W, not at the 5000 W rail.

Graded against a transcription of release/1.0 over the 3024-case sweep, which
was NOT taught the new rule: 2592 cases bit-identical, 432 releases, and 27 of
those change the command - every one a charge from prev_w == 0 that release/1.0
refused to start.

SAFETY-04's deliberate assertion is kept rather than inverted: its fixture sits
below the bound, so it still asserts the block, and the release is asserted
beside it on the other side of the bound.

Coverage audit re-measured, same mutation form, __pycache__ purged: output
freeze 2 -> 11 (2 reproduced on a pristine c8dcb38 first), the exit 4,
unasked_power 6.

saturation_cycles at 9 or 10 removes the block entirely, because the freeze
cannot engage before its own exit is due. Not refused at startup; asserted in
the tests and written in DOCS.md.

Nothing was measured on two inverters: inverter B does not physically exist
(NOTES.md §1.1b) and sim/plantsim.py:216 INVERTER_B is INVERTER_A copied with
measured=False. The N=2 escalation is stated from the code (actual_w is
site_batt_w, the SUM) and is labelled as such.

22/22 test files pass in goodwe-addon-ci, mounted :ro, bytecode purged first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 13:56:26 +02:00
admin c8dcb38a95 Merge pull request 'SAFETY-37: a stale reading is None, and arrival cannot be what says so' (#57) from SAFETY-37 into release/1.0 2026-09-06 13:06:27 +02:00
15 changed files with 4202 additions and 24 deletions
+200 -2
View File
@@ -1,5 +1,52 @@
# Changelog
## 0.8.0
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
THIS IS A MINOR BUMP.** [[SAFETY-39]] adds validator rule 10, which refuses an
explicit `slew_w` above `SLEW_RAIL_FRAC` x the site rail, where `SLEW_RAIL_FRAC`
is `LOOP_TICK_S / PLANT_SETTLE_S` = 2/18. On a 5000 W rail that ceiling is
**555.6 W**.
⚠️ **THE VALUE THAT BREAKS IS 0.7.0'S OWN SHIPPED DEFAULT.** `slew_w` defaulted
to **1000** and the schema was `int(50,5000)`; 1000 > 555.6, so an install that
never touched the key and simply took the default is refused on 0.8.0 until the
value is lowered. That is as backward-incompatible as it gets, and it is the
whole justification for MINOR over PATCH.
The schema also widens from `int(50,5000)` to `int(0,5000)`, and `slew_w: 0` -
**a value that was not legal before** - now means *follow the rail* and is the
shipped default. This is a new capability rather than a changed meaning.
⚠️ Refusal is `SystemExit(78)` (`EX_CONFIG`): the add-on does not start. **Read
the live options and check them against the ceiling BEFORE `ha apps update`.**
That check is NOT performed by this PR and cannot be - see the PR body.
CFG-04's rule and PKG-06's: the Supervisor keys the built image by `version:`,
so shipping a change without a bump means `ha apps update` is a silent no-op.
**Six merges accumulated behind an unbumped 0.7.0 before this was caught, for
the second time in two days.** The automated check that would catch it without
a human noticing is still unwritten, and that is PKG-06's standing follow-up.
Contents, in merge order since 0.7.0 (`bbc903b`). ⚠️ Each row carries the merge
subject's **whole** claim, including the half that is not flattering:
| | |
|---|---|
| `9e42cec` | CI-06 - `test_peakguard.py` answered two of its checks with the wall clock |
| `24c3d87` | SAFETY-38 - pin `site_standing` with shares that cannot answer for the site |
| `a4290d3` | SAFETY-39 - `slew_w` is a ratio to the rail, **and `gain` is the constant that is still wrong** |
| `c8dcb38` | SAFETY-37 - a stale reading is `None`, and arrival cannot be what says so |
| `0581648` | SAFETY-13 - the output freeze gets an exit, and only where it needs one |
| `464d398` | SAFETY-42 - a unit that stops delivering, **and the discriminator the card asked for does not exist** |
⚠️ **TWO DISCLOSED RESIDUALS SHIP IN THIS RELEASE.** SAFETY-13's bounded
freeze-exit shows 0 spurious releases on the sweep's measured-plant slice but
**2 of 272 on a finer grid**, and the docs carry the 2-of-272 figure rather than
the flattering one. SAFETY-42's card proposed a discriminator that **turned out
not to exist**, and the branch says so rather than shipping a weaker check under
the original name.
## 0.7.0
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
@@ -29,7 +76,153 @@ is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` an
the table above is what is new since it. Splitting it retroactively would mean
guessing a boundary; saying so is more useful than a guess.
## Unreleased
## Detail notes — accumulated, NOT a separate release
⚠️ **THIS HEADING USED TO SAY "Unreleased" AND IT WAS WRONG.** Everything below
has SHIPPED. The section accumulated across 0.6.0, 0.7.0 and 0.8.0 because each
release was cut without clearing it, so it names tickets from all three
([[SAFETY-18]] and [[SAFETY-30]] from the 0.6.0 era through [[SAFETY-42]] in
0.8.0). A reader who trusted the old heading would have concluded that shipped,
running behaviour was still pending — and there was a SECOND heading saying the
same thing further down, from the 0.4.x era.
Retitled rather than re-sorted, on purpose: splitting ~3000 lines by release is
real work that does not belong inside a version bump, where it would bury the
one line that changes what the Supervisor installs. Card PKG-08 carries it.
⚠️ **THE OUTPUT FREEZE NOW HAS AN EXIT, AND ONLY WHERE IT CANNOT END ON ITS OWN
([[SAFETY-13]]).** While the loop is frozen the command's magnitude may fall but
never rise; at `prev_w == 0` there is no magnitude left to fall, so a charge
cannot start. That refusal is deliberate - the inverter reporting power we did
not command means another writer is on the actuator, the §1.5 non-goal - and it
is kept. What it did not have was an END: nothing cleared it but the other
writer leaving.
The exit is now explicit, and *which* freezes get one is the decision rather
than *how long*:
- A divergence a **limit** could have produced - the inverter doing less than
the command, in the command's own direction - **clears itself**, because the
command is pinned at something the inverter can reach. It gets no exit, and
that is release/1.0's behaviour kept unchanged.
- A divergence a limit could **not** have produced - the inverter overshooting
the command, or producing the opposite sign - may not clear itself at all, so
it gets one. It releases once the divergence has outlasted the plant's own
measured settling time: `SETTLE_CYCLES` = **12** cycles at the measured tick.
⚠️ **THE BOUND COMES FROM THE SLOWER OF THE TWO DIRECTIONS THE PLANT WAS
MEASURED IN.** `NOTES.md` §5.10 timed the same 1.78 kW step both ways - ~18 s
up, ~23 s down - so `PLANT_SETTLE_DOWN_S` (23 s) joins `PLANT_SETTLE_S` (18 s)
as a named constant. SAFETY-39 uses 18 correctly, because its bound is a
*refusal* and the looser number refuses less; this bound asks how long the plant
may still be the *explanation*, and there the longer number is the safe one. A
bound of 9 derived from 18 fires the exit on a **single inverter with nothing
else on the bus** - measured in the container, 62 of 272 load-step pairs at the
plant's own measured shape, against 2 at 12.
⚠️ **`freeze-released` IS A PLACE TO START LOOKING, NOT A FINDING.** It says the
divergence outlasted the plant's measured settling time. It is **not** proof of
a second writer: a large load or PV swing can leave our own power coasting
outside our own command's interval, and a plant slower than the one that was
measured does the same. DOCS.md now gives the innocent causes first and the
second writer only when the state *stands*.
⚠️ **RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP.** The integrator
freeze is untouched, so the command can only walk, one slew step per cycle,
toward an accumulator that stopped accumulating when the freeze engaged.
Measured at the live install's shape (`max_w 5000`, `slew_w 400`,
`target_grid_w -10`) with another writer holding the pack at +700 W and 2 kW of
export standing: the command holds at 0 W, starts on the twelfth saturated
cycle and settles at **-1190 W** against a frozen integrator of -1194 W, not at
the 5000 W rail. On `release/1.0` @ `c8dcb38` the same run held **0.0 W on every
cycle**, for as long as the other writer stayed.
⚠️ **THIS IS NOT A REGRESSION FIX AND IT ESCALATES AT TWO INVERTERS.** The
unbounded block is `release/1.0` behaviour, confirmed cycle for cycle against
SAFETY-04's branch. At a roster of two, `actual_w` is the SITE SUM, so the
comparison is a site command against two units' power and one unit still moving
after the other has stopped can put the site into the block with no external
actor at all. ⚠️ That escalation is read from the code and from single-unit
runs; **it is not measured on two inverters.** Inverter B does not physically
exist yet and `sim/plantsim.py`'s `INVERTER_B` is `INVERTER_A` copied with
`measured=False`, so any two-inverter figure would be two copies of A.
Graded against `release/1.0` over the 3780-case sweep in `test_control.py`:
**3267 cases bit-identical, 432 releases, 27 of those change the command** -
every one a charge from `prev_w == 0` that `release/1.0` refused to start - and
**81 differ in the saturation counter alone**, because its ceiling moved with
the bound from 10 to 13. That ceiling change also removes a degenerate setting:
`config.yaml` accepts `saturation_cycles` up to 10, which is now strictly below
the release bound, so **no accepted configuration can stop the block engaging**.
At the old bound of 9 a setting of 9 or 10 deleted it silently.
⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.**
`delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The
Supervisor keys the built image by `version:`, so this needs a bump before it
ships - see the 0.7.0 heading above, which makes the same point about
`bms_soc_floor`. The key is ADDITIVE and defaulted, so an existing options blob
stays valid; what a missing bump costs is that nobody's Supervisor re-reads the
schema.
**Two or more inverters only, and at one inverter it is inert by construction.**
A unit standing at a discharge share it is not producing - short by more than
`saturation_w`, with nothing this add-on can read to explain it, while at least
one sibling is NOT in that state - is named after `delivery_hold_s`. That fault
was invisible to everything: the per-unit sign check needs OPPOSITION and a unit
at 0 W is not opposing but ABSENT, the site sum still agrees with the meter in
direction, circulating power is exactly zero when one unit does nothing, and the
only remaining trace is the saturation freeze - which `clamp.py` itself calls an
alarm operators learn to ignore. It is [[HW-01]]'s shape, which ran 6 h 25 m.
⚠️ **IT REPORTS. IT DOES NOT ACT.** Nothing is commanded differently, the loop is
untouched, `control.py` is not modified, and the site goes on running short by
that unit's share. Commanding an inverter that has stopped listening achieves
nothing (HW-01), and the only re-allocation this add-on can express is
all-or-nothing - measured on the rig, dropping a unit derated to 50 % from the
split leaves the site **400 W worse** than keeping it, and this detector cannot
tell that unit from a wedged one. The status row is a CAUTION, not a failure,
because the controller has not stopped.
⚠️ **THE CARD'S OWN DISCRIMINATOR WAS FALSIFIED, AND THAT IS THE MAIN CONTENT.**
SAFETY-42 asked for "saturation persisting past N while |grid error| > X, both
derived". Built and run on the two-master rig first, it fails in both
directions, and no (N, X) fixes either: a pack at its own floor and a wedged unit
are **identical on all 120 cycles** of a run in grid, site target, every share
and every measured watt - so the benign case fires - and with a sibling that has
headroom the standing grid error collapses to **30 W** while the wedged unit
stands at a +770 W share delivering nothing, so the real case does not. What
replaces it is a per-unit shortfall plus two reductions the card did not ask for:
an ACCOUNT (the pack's own `bms_soc_floor`, the only non-power fact that
separates an empty pack from a wedged one) and a PROPER SUBSET of the units asked
to discharge (which is what keeps a whole-site derate out, and what makes this
inert at N=1 - measured zero in all 1890 one-inverter runs).
The report **survives a Home Assistant restart** ([[SAFETY-34]]) under
`unit/<name>/delivery_tripped`, with one notification id per unit, re-raised on
the first cycle of every process and cleared by the same **Start** press as the
other latches. **Charge shares are deliberately not graded** - there is no
configured charge ceiling to explain a constant-voltage taper with, and arming
that direction would fire on every maintenance charge.
`DECISIONS/SAFETY-42-unit-delivery.md` carries the campaign (2640 healthy
two-inverter runs plus 1890 at one inverter as the control), the
inverter-B sensitivity sweep the margin is sized on, and the mutation ledger.
⚠️ **AND THE TWO THRESHOLDS IT READS ARE NOW GRADED AT A CONFIG THAT IS NOT THE
SHIPPED ONE.** `saturation_w` and each unit's `bms_soc_floor` are handed to this
check from the running config, and the promise that comes with that - raise
`saturation_w` and this check moves with you, there is no fourth copy of a
default to keep in step - is only true while the call site passes the
**configured** value. A literal equal to the shipped value keeps every fixture
green and removes the promise silently, and two such literals were survivors of
all 23 suites - measured, not argued - because no fixture that reaches this
check had ever run at a `saturation_w` other than the shipped 500 or at a
`bms_soc_floor` other than the shipped example's 10. Part 4b of
`test_delivery.py` now runs the same fault at a raised `saturation_w` and at two
cut-outs that bracket 10 from both sides, and each pair must answer two
different ways - so no constant passes. §9 of the decision record enumerates
every number this check reads and says which were already pinned this way and
which were not.
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
@@ -3247,7 +3440,12 @@ human to read rather than alarmed on - at a converged -10 W a 1 Wh register need
about six minutes to move, so any threshold would false-trip at the target
operating point.
## Unreleased
## Detail notes from the 0.4.x era — NOT unreleased
⚠️ Retitled with the same correction as the section above: [[TEL-05]] and the
entries under this heading shipped long ago. Two headings in one file both
claiming "Unreleased" is how a reader ends up believing running behaviour is
still pending.
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
@@ -0,0 +1,396 @@
# SAFETY-13 — the output freeze gets an exit, and only where it cannot end on its own
**Status:** decided and implemented on branch `SAFETY-13`, off `release/1.0` @ `c8dcb38`.
**Card:** SAFETY-13 — *"Bound the no-start-while-saturated output block"*
([Trello](https://trello.com/c/uZjmuF7B)).
**Nothing here reached the inverter.** Every figure below is from the pure control
law run in a container against a plant model, on this branch and on a pristine
`c8dcb38` for the before-figures. No rig, no ESP32, no Home Assistant.
---
## 0. The four things a reader should take away
1. **The card's headline case is real, and the route to it is not the obvious
one.** The block does not engage on the way down into an export; the slew
limit moves the command off zero before the counter reaches 3. It engages
from the **deadband** — a site resting at its target with another writer on
the bus, counting saturated cycles while nothing is wrong — and 0 W is then a
fixed point. §2.
2. **`prev_w == 0` is not a special case, so the fix is not one either.** The
same run pins at −800 W against its own integrator standing at −2388 W, at a
non-zero `prev_w`, with
the same absence of an exit. Zero is only the instance where the pinned
magnitude is the whole of what the battery could have done. §3.
3. **The decision is not "how long", it is "which".** A divergence a *limit*
could have produced clears itself and gets no exit — `release/1.0` unchanged.
A divergence a limit could not have produced may not, and gets one. The
predicate is physical, not statistical — and it is **not** a second-writer
detector; §4.1 and §8 say what it can and cannot tell apart.
4. **Releasing the output freeze does not unfreeze the loop**, and that is what
makes the release bounded rather than merely different. §6.
5. **The bound comes from the SLOWER half of a two-sided measurement, and the
first version of this branch took the faster half.** §8 is the correction and
the measurement behind it; it is the reason `SETTLE_CYCLES` is 12 and not 9.
---
## 1. What was there, in the terms the code uses
`control.py`, in `compute()`, after the clamp and the slew limit:
```python
if frozen:
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
reason = "saturated-freeze"
```
"Magnitude may fall, never rise." At `prev_w == 0` the magnitude is zero and
`max(target, 0)` forbids any negative command, so a charge cannot **start**.
The reason is written in the file and it is a good one: `frozen` means the
inverter's reported power has diverged from the command by more than
`saturation_w` for `saturation_cycles` consecutive cycles, and at a command of
zero that means the actuator is producing power nobody in this add-on asked for.
Starting to push the other way into it is the configuration §1.5 of the
specification rules out — *"any configuration in which two writers can reach one
actuator"*.
**The defect is that it has no end.** Not the error growing, not the integrator
moving, not time. `sat_count` stops rising at its cap and the state is a fixed
point.
---
## 2. Reproducing it — and correcting the route
Driven at the live install's shape: `max_w 5000`, `slew_w 400`,
`target_grid_w -10`, `saturation_w 500`, `saturation_cycles 3`, `gain 0.6`.
Another writer holds the pack at **+700 W** whatever is commanded.
**The route that does NOT reach it.** Start at `prev_w = 0` with 2 kW of export
already standing. On `c8dcb38` the loop moves off zero at the slew limit before
the counter reaches 3 — commands −400, −800 — so by the time `frozen` is true
`prev_w` is −800 and the block that gets applied is `max(target, −800)`. The
site charges at 800 W, not 0 W, for as long as the run lasts.
**The route that does.** The site is *resting*. `grid == target_grid_w`, so the
loop is in its deadband and the command is 0 — and `saturated_now` is computed
**before** the deadband branch, so the counter climbs anyway. Three cycles later
the loop is frozen at `prev_w == 0` with nothing wrong. Then the export arrives:
| cycle | grid | actual | prev | command | integrator | sat | reason |
|---|---|---|---|---|---|---|---|
| 0–1 | −10 | +700 | 0 | 0 | 0 | 1–2 | `deadband` |
| 2–5 | −10 | +700 | 0 | **0** | 0 | 3–6 | `saturated-freeze` |
| 6 | −2000 | +700 | 0 | **0** | −1194 | 7 | `saturated-freeze` |
| 7… | −2000 | +700 | 0 | **0** | −1194 | 8→10 | `saturated-freeze` |
On `c8dcb38` the command is `0.0` on **every** cycle from there on. The
integrator has moved to −1194 W and stopped (its own freeze holds it), the site
exports 2 kW, and the battery is idle until the other writer leaves.
That is the card's sentence — *"the site can sit exporting 2 kW with an idle
battery indefinitely"* — reproduced, with the route corrected: it is entered
from the **deadband**, not from a correction that ran out of room.
---
## 3. …and `prev_w == 0` is not where it stops
The same tuning, same writer, but the loop starts from zero with the export
already standing (the route from §2 that "does not reach it"):
| | `c8dcb38` |
|---|---|
| command after the freeze engages | **−800 W**, on every cycle |
| the loop's integrator, frozen | −2388 W |
| what ends it | nothing |
So the missing exit is a property of the **output freeze**, not of the zero
case. `prev_w == 0` is the instance where the pinned magnitude happens to be
zero. A rule written for zero alone would have left −800 W exactly as unbounded
as it found it — which is why the change below is written against the freeze and
not against a special case of it.
**And this is the half that escalates at two inverters.** `actual_w` is
`site_batt_w`, the SUM (ALLOC-01, DEV-02), so the comparison is a site command
against two units' power. One unit still moving after the other has stopped is
enough to put the site into the freeze with no external actor at all.
⚠️ **What that claim is and is not.** The escalation is stated from the code —
`prev_w` is `self.target`, the site total, and `actual_w` is the site sum — and
from the single-unit runs above. **It is not measured on two inverters.**
Inverter B does not physically exist (`NOTES.md` §1.1b: on paper, from datasheet
and serial), and `sim/plantsim.py:216` `INVERTER_B` is `INVERTER_A` copied with
`measured=False`. Any two-inverter number would be two copies of A.
---
## 4. The decision: which freezes get an exit, not how long the block lasts
Three answers were on the table, and the card named them: lift after N frozen
cycles, exempt above some export magnitude, or keep it unbounded and write down
the cost.
**A plain cycle bound was rejected**, because it bounds the wrong thing. The
freeze is right to be permanent in one case and cannot be permanent in the
other, and a single N treats them alike:
| the inverter is… | can the freeze clear itself? |
|---|---|
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap, the plant still ramping | **Yes.** The command is pinned at something the inverter can reach, so it catches up, `saturated_now` goes false, and the freeze ends. Asking for more would achieve nothing anyway. |
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **No.** Clearing the freeze requires the inverter to track *our* command, and the other writer is precisely what stops that. |
**An export-magnitude exemption was rejected** for a narrower reason: it makes
the block's end depend on how big the other actor's disturbance happens to be,
which is not a property of the thing the block is about.
So the rule implemented is:
> The output freeze applies as before, **unless** the inverter's reported power
> cannot be explained by the command not being met **and** the divergence has
> outlasted the plant that would otherwise have explained it.
### 4.1 The predicate is physical
`unasked_power(prev_w, actual_w, saturation_w)`. A limit can only make the
inverter do **less** than the command, in the command's own direction. It cannot
make it overshoot the command and it cannot make it produce the opposite sign.
So everything a limit can produce lies in the closed interval spanned by `0` and
`prev_w`, widened by `saturation_w` for noise, ramp lag and idle draw.
⚠️ **What it does NOT establish is that anything outside was put there by
something else.** The first version of this branch said exactly that, in the
docstring, in `control.py` and in DOCS.md, and it is false: the predicate
compares against **one cycle's** command while the pack carries every command
before it, so our own coast-down through zero lands outside our own interval.
§8 measures it. What the predicate guarantees is only its own first sentence —
*this cycle's command does not account for this cycle's reading* — and the time
bound below is what turns that into a reason to act.
| prev | actual | interval | verdict |
|---|---|---|---|
| +2000 | +700 | [0, 2000] | a limit |
| −5000 | −700 | [−5000, 0] | a limit |
| 0 | +700 | [−500, +500] | **unasked** |
| −800 | +700 | [−1300, +500] | **unasked** |
⚠️ **At `prev_w == 0` this predicate and the saturation detector are the same
test** — asserted in `test_control.py` over 801 readings. That is not a
coincidence to tidy away: with nothing commanded there is no interval left for a
limit to live in, which is exactly why the zero case is the one that strands.
⚠️ **It is not a detector and nothing alarms on it.** A stale, wrong-signed or
noisy reading lands outside the interval too. All it decides is whether the
freeze gets an exit; being wrong about it costs a bounded command (§6).
SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on sign, and it is
deliberately a different test with a different threshold.
### 4.2 The bound, and which half of the measurement it comes from
`SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)` = **12**.
⚠️ **`PLANT_SETTLE_DOWN_S` is new, and adding it is the point rather than a
cost.** `NOTES.md` §5.10 did not measure *a* settling time; it measured the same
1.78 kW step in **both directions** — "Up: ~18 s from step to deadband. Down:
~23 s from a −1784 W export excursion" (`NOTES.md:1550`). So the plant's
settling time is a **pair**, and which half is the safe one depends on which way
being wrong hurts:
| the bound | the question | the safe half | why |
|---|---|---|---|
| SAFETY-39's `SLEW_RAIL_FRAC` | may this configuration be **refused**? | **18 s**, the shorter | a looser bound refuses fewer working configurations |
| this one | may the plant still be the **explanation**? | **23 s**, the longer | a shorter bound stops believing the plant too early, and then it accuses |
The first version of this branch reused `PLANT_SETTLE_S` and got 9 — three
cycles short of the plant's own measured down-ramp, on the side that produces a
false positive. §8 is the measurement and the correction.
⚠️ **It is no longer the reciprocal of `SLEW_RAIL_FRAC`, and it never should
have been read as one.** The two were equal only while both were being derived
from the same half of the pair, and that coincidence read as a derivation. They
are 9 and 12 now; `test_control.py` asserts the inequality so they cannot be
quietly re-fused.
⚠️ **It inherits SAFETY-39's caveat about `LOOP_TICK_S`, in the opposite
direction.** A meter **slower** than 2 s makes 12 cycles cover more than 23 s, so
the freeze holds longer than the plant needs: that costs energy, never safety. A
meter **faster** makes them cover less, so the freeze can release while the
plant is still moving — bounded by §6 rather than by luck.
**`_SAT_COUNT_CAP` moved with it, from 10 to 13**, because it is written as
`SETTLE_CYCLES + 1` — a counter capped below the longest bound read from it
would silently delete the exit. That has a consequence worth stating on its own:
`config.yaml` accepts `saturation_cycles` as `int(1,10)`, so at the old bound of
9 a legal setting could sit **above** the exit and delete the block. At 12 the
whole accepted range is strictly below the bound, and the degenerate
configuration this branch used to document no longer exists. §7.
---
## 5. What actually changed, graded against `release/1.0`
`test_control.py`'s sweep still compares this law against a transcription of
`release/1.0` — the transcription was **not** taught the new rule, because an
oracle taught the change compares the law against itself. What changed is the
assertion: every case must be identical, or be a release with both of its
conditions true, or differ **in the saturation counter alone**.
⚠️ **The sweep had to be widened to keep grading anything.** Its `sat_count`
fixtures ran to 9; with the bound at 12 the counter never reached the exit and
every release silently vanished from the table, which is what the assertion on
the count caught. It runs to 12 now, at 3780 cases.
| | cases |
|---|---|
| total | 3780 |
| **bit-identical to `release/1.0`** | **3267** |
| releases | 432 |
| …of those, reason-only (the freeze was not binding) | 405 |
| …of those, **the command changes** | **27** |
| differ in the **saturation counter alone** | 81 |
All 27 command changes are at `prev_w == 0`, all with `release/1.0` commanding
exactly `0.0 W`, and every new command is **negative** — a charge start that was
refused.
⚠️ **The 81 are the counter's ceiling moving, and they reach nothing.**
`_SAT_COUNT_CAP` went from 10 to 13 with the bound, so above `release/1.0`'s own
ceiling the two counters differ. Nothing else may differ in those cases, and the
sweep asserts that: `frozen` is `sat_count >= saturation_cycles`, `config.yaml`
accepts that at most 10, and no other line in `compute()` reads the counter's
value — so a counter reading 13 instead of 10 cannot change what is commanded.
The 81 all sit at an `sc` above 10, which is a state `release/1.0` could never
hold at all.
The behaviour after the fix, on §2's run: the command holds at 0 W through
`sat_count` 3–11, releases on the **twelfth** saturated cycle, and walks
0 → −400 → −800 → **−1190 W**, where it stops — the same settling point as at
the old bound, three cycles later.
The genuine-limit case, driven for 30 cycles with the inverter curtailed to
±1000 W against a 4 kW import: `freeze-released` never appears and the command
stays pinned at +2400 W. Unchanged, deliberately.
---
## 6. Why the release cannot become windup (the card's fourth criterion)
**The integrator freeze is not lifted with the output freeze.** The command is
derived from `i_w`, then clamped, then slew-limited from `prev_w` — so a
released cycle can only move the command *toward* an accumulator that stopped
accumulating when the freeze engaged, by at most one slew step, and never past
it. `test_control.py` sweeps 107 625 states, of which **67 500 release**: no
released case leaves the interval between the previous command and the frozen
integrator, and none moves further than one slew step.
⚠️ **107 625 was previously reported as the number of released states. It is
the number of states swept**, and the assertion only ever graded the subset that
released. Both numbers are counted and printed now.
On §2's run that shows up as the settling point: **−1190 W against a frozen
integrator of −1194 W**, not the 5000 W rail. The tech lead's note on SAFETY-04
— that the carried `i_w` and `release/1.0`'s recomputed value land on the same
slew-limited command — is preserved: the `i_w=None` path is still graded against
the transcription, cycle for cycle, everywhere except the 432 releases.
⚠️ **The first form of this check was wrong and the sweep said so**, which is
left in the test file. It asserted `|target| <= |i_w|` on a released cycle and
produced `prev −5000, i_w −3000, target −4600`: the command was walking back
from −5000 toward the integrator and had not arrived. It was moving the right
way and the assertion still failed, because "never exceeds the integrator" is
not what no-windup means here.
---
## 7. What this does NOT buy, said plainly
**The site still does not reach its target while another writer is on the bus.**
It settles at the frozen integrator, not at the meter's target. What the exit
buys is that the battery stops being *idle* while the site exports; it does not
buy correct regulation against an actuator somebody else is also writing to, and
nothing in a rules engine can. A standing grid error with a second writer
present is the symptom of a configuration this add-on refuses to support.
**`freeze-released` is a place to start looking, not a finding.** It says the
divergence outlasted the plant's own measured settling time, and that is all it
says — §8. DOCS.md now tells the operator the innocent causes first (a large
load or PV swing, a plant slower than the one that was measured) and the second
writer only when the state **stands**. It is carried on `Decision.reason`, which
nothing after the freeze overwrites. No new alarm was added: SAFETY-41 removed
one that narrated normal anti-windup, and this state is not yet known to be
worth waking anybody for.
**No accepted configuration removes the block any more.** `config.yaml` accepts
`saturation_cycles` as `int(1,10)`; the bound is 12, so every accepted value is
strictly below it and the freeze always engages before its exit is due. This
paragraph used to say the opposite — at a bound of 9, settings of 9 and 10
deleted the block silently and the branch documented that as a known
consequence. Moving the bound closed it by construction. `test_control.py` pins
it against `config.yaml`'s own schema line rather than a transcription of it.
**Nothing was measured on two inverters.** See the warning in §3.
---
## 8. ⚠️ ROUND 2: the bound was derived from the wrong half, and it accused
**What was wrong.** `SETTLE_CYCLES` was `ceil(PLANT_SETTLE_S / LOOP_TICK_S)` = 9.
`PLANT_SETTLE_S` is 18 s, the **up**-ramp of `NOTES.md` §5.10's kettle step;
the same measurement recorded ~23 s coming back **down** (`NOTES.md:1550`).
SAFETY-39 takes 18 correctly — its bound is a refusal, and the looser number
refuses less. This bound asks the opposite question, and the looser number stops
believing the plant three cycles too early.
**Why that is not a tuning quibble.** The predicate has no memory. `prev_w` is
one cycle's command; the pack carries every command before it. A command that
falls **through zero** — the ordinary end of a load or PV step — leaves the pack
coasting in the old direction, so the reading has the sign the command *used* to
have and lands outside the interval spanned by 0 and the new `prev_w`.
`unasked_power()` calls that "unasked". With the bound three cycles short of the
plant's own down-ramp, the counter reaches it while the coast-down is still
going, and the law emits `freeze-released` on **one inverter with nothing else
on the bus.**
**Measured, in the container, N = 1.** One `compute()`, one plant model, one
command; no second writer anywhere in the harness. The plant is a first-order
lag whose two time constants come from the two measured settling times
(tau = t/3, since a first-order step is 95 % done at 3 tau) with
`sim/plantsim.py`'s measured 800 W/s ramp cap. It is a **model**, and it is
labelled one wherever it is used.
| | bound 9 | bound 12 |
|---|---|---|
| 1280 closed-loop scenarios (a grid of plant pairs, slews, `saturation_cycles`, load steps) | **113 fire** | **24 fire** |
| 272 load-step pairs at the measured 18 s / 23 s plant, `slew_w 400` | **62 fire** | **2 fire** |
The single case now pinned in `test_control.py`: a 4 kW export falling to
2.5 kW. At cycle 34 the command is −310 W, the pack is still at −1343.4 W
coasting down from −4860 W a few cycles earlier, `unasked_power(−310, −1343.4,
500)` is True, `sat_count` is 9, and the law says `freeze-released`. At 12 the
same run never releases.
**What the fix does not fix.** The bound makes the false positive rare on *the
plant that was measured*. It cannot make it impossible, because it is a
measurement and the plant can be slower than the measurement. Same sweep, bound
held at 12, lengthening only the plant's down-ramp: 8 of 320 scenarios at the
measured 23 s, 59 of 320 at 34 s, 268 of 320 at 50 s. That residual is why the
three statements below were corrected rather than merely re-numbered.
**Three shipped statements were falsified by this and are now rewritten:**
1. `unasked_power()`'s docstring — *"Anything outside that interval was put
there by something other than this loop."* It was our own power. The
docstring now says what the predicate does guarantee (this cycle's command
does not account for this cycle's reading) and states the coast-down as its
known blind spot.
2. `control.py`, in `compute()` — *"SO THE BOUND BELONGS TO (b) AND ONLY TO
(b)"*. It fired in case (a). The comment now says the bound is a reason to
stop believing (a), not a proof of (b), and that the release is written to be
survivable when the inference is wrong.
3. **DOCS.md — *"If you see `freeze-released` in the action log, look for the
second writer."*** This is the one that reaches a person, and on a
single-inverter install it sent them hunting something that does not exist.
Replaced by a section that gives the innocent causes first and the second
writer only when the state stands.
@@ -0,0 +1,592 @@
# SAFETY-42 — a unit that stops delivering its share, the discriminator the card asked for, and why that discriminator does not exist
**Status: DECIDED 2026-09-06.** A new per-inverter check, `app/delivery.py`,
names an inverter that is **standing at a discharge share it is not producing**
while at least one sibling is not in that state and nothing this add-on can read
explains it. It **reports and does not act**. At a roster of one it is inert by
construction.
⚠️ **This record also FALSIFIES the discriminator the card proposed.** SAFETY-42
asked for *"saturation persisting past N while |grid error| stays above X, with N
and X derived"*. That predicate was built and run on the rig before anything was
written, and it fails in **both** directions — not for want of better N and X,
but because of what the quantity is. §2 is the measurement. This is the
SAFETY-39 shape: saying the criterion is wrong was worth more than five
near-misses at satisfying it.
Filed with SAFETY-42, card `6a9d498a49082c0086e525c1`.
---
## 0. What is uncovered, and why it is structural rather than a threshold
A unit delivering **0 W against a non-zero share** is seen by nothing:
| guard | why it is silent |
|---|---|
| [[SAFETY-30]] per-unit sign | gates on **opposition ≥ 250 W on both sides**. A unit at 0 W never opens it — it is not opposing, it is **absent** |
| SAFETY-30 site sign | the sum still **agrees with the meter in direction**; only the magnitude is short |
| [[SAFETY-31]] circulation | `Σ\|Bᵢ\| − \|ΣBᵢ\|` is **zero** when one unit does nothing — circulation needs two units pulling *against* each other |
| 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 card therefore needed a
different signal, not a louder one.
It is [[HW-01]]'s shape. On 2026-09-02 the comms subsystem failed and the power
stage went on executing its last instruction for **6 h 25 m** with all three
layers spectators. At N=2 the same fault leaves a healthy sibling regulating
alone, the site short by up to one unit's share, and nothing saying so.
## 1. The rig
Everything in §2 and §3 was measured on `admin/goodwe`'s two-master rig
(TEST-19), used **read-only** and run container-free, from a harness in a
scratch tree. Real and imported, never transcribed: the control law
(`control.compute`, `Tuning`), the allocator (`arbiter.allocate_record` →
`allocator.plan`), the per-unit envelope the shipped `supervised` and `device`
claims build, the sign predicate (`watchdog.disagrees`), the circulating sensor
(`circulation.circulating_w`), the check under test (`delivery.delinquent`,
`short_by`, `account`), the wire (`frame_check.setpoint_atomic`, the firmware's
own encoder, in and back out through `plantsim.parse`/`decode`), and the plant
(`plantsim.Plant`). The per-unit reading is **holding register 0x0518**, the one
the live `batt_entity` binds.
Modelled, and therefore not evidence about the thing modelled: the master's
failsafe machine (absent), and the two fault injections —
* **wedged** — the write never reaches the plant, so its latched command stands.
Started at 0 W, which is *"delivering 0 W against a non-zero share"*.
* **at its floor** — the write arrives and `Plant.limit()` zeroes it, because
`soc <= soc_min`. This is the benign case the acceptance criterion names.
### ⚠️ Inverter B does not physically exist
`plantsim.INVERTER_B` is **inverter A copied**, `measured=False` (NOTES.md
§1.1b; the datasheet-and-serial figures 4600 W / 20.48 kWh / floor 10 % are
deliberately *not* in the model). **Every two-inverter figure in this document
is two copies of inverter A.** §3.3 is the sensitivity sweep that sizes the
margin that costs.
### Reproducing it
The harness is **not committed here**, for the reason SAFETY-30's and
SAFETY-31's were not: it imports `plantsim` and `frame_check`, which live in the
other repository and are not present in this one's CI image. What it does is
small enough to restate: build two `plantsim.Inverter`s from `INVERTER_A` /
`INVERTER_B`; close the loop with the add-on's own `compute()` on the SITE sum
(`Controller.site_batt_w`) and `arbiter.allocate_record()` over per-unit
envelopes of `±min(max_w, rating)`; put each share on its own wire through
`Inverter.command()`, honouring a configurable **write stagger** so B's number
can land up to four cycles after A's (the serialised write path); advance
`Site.settle()` one control cycle; and record, per unit, the command standing
*before* the write and that unit's 0x0518 power. Then grade each cycle with the
**shipped** `delivery.delinquent()` and take the **longest run of consecutive
cycles** on which it names anybody — not a count of naming samples, because the
window is cleared by any cycle that does not.
⚠️ MUTATION.md's note applies: this harness writes `__pycache__` into **both**
trees. Run it with `python3 -B` and purge.
## 2. The card's discriminator, measured — and it does not work
### 2.1 It fires on the benign case, and no (N, X) can stop it
`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. The two
are not merely similar:
> **Load 2000 W, equal weights, 2000 W per-unit cap, 120 control cycles.
> The two arms are IDENTICAL on all 120 cycles** in (grid, site target, each
> unit's share, each unit's measured power), to the last emitted watt. Both
> report `reason == "saturated-freeze"` on **116 of the 120** cycles (the other
> four are `slew-limited`, before it engages on cycle 4), both hold |grid error|
> over 300 W on **all 120** and settle at **1200 W**, and `circulating_w` is
> **0.0 W** in both.
A predicate whose inputs are *saturation duration* and *|grid error|* reads the
same number on both arms at every cycle. There is no N and no X. And *"a
legitimately saturated site — cold pack at its floor — does **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.2 It is silent on the headline case, because a healthy sibling erases it
The standing grid error is exactly the thing a working sibling removes.
> **Load 800 W, equal weights, 2000 W per-unit cap, B wedged, 200 cycles.**
> Over the last 30 cycles the loop is saturation-frozen on **30 of 30** — so
> "saturation persisting past N" is true for every 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. The card's own sentence — *"a healthy sibling
regulating alone, the site short by up to one unit's rating"* — describes the
case, and the grid error is the one number that cannot report it.
### 2.3 What that leaves
The derivation instruction stands and is honoured in §3. What could not be
derived was a threshold on the wrong quantity. Both failures are written into
`delivery.py`'s docstring as well, because the next person to read the card will
propose the same predicate.
## 3. The quantity that does work
Per unit, for a share that asks it to **discharge**:
```
short_by(i) = share_i − measured_i # watts that did not arrive
```
and the reduction that makes it readable:
> a unit is **delinquent** while it is short by more than `saturation_w` 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.
### 3.1 The account — the only thing that separates a wedged unit from a cold pack
§2.1 says there is nothing in the power domain. There is exactly one non-power
fact this add-on holds about a pack: **its own state of charge against its own
`bms_soc_floor`**, which SAFETY-32 made a required per-entry key. `account()`
asks it through **`peakguard.discharge_permitted()`** — public since SAFETY-32
precisely so that this question has one answer — and writes its own sentence,
because that function's wording names the *peak guard's* floor and would be a
false statement here.
Three things account for a shortfall, and each is a state already being handled:
| account | why it is not this fault |
|---|---|
| the unit is not live | `live_units()` has already dropped it from the split and the action log says so — firing inside that is the SAFETY-15/19 failure |
| the unit is parked by a sign trip | it is commanded 0 W on purpose; not delivering is compliance |
| `discharge_permitted(soc, bms_soc_floor)` | the pack is at or below its own cut-out and physically cannot |
⚠️ **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 —
the opposite direction to `discharge_permitted()`'s own caller, and the right
one here: there it withholds a discharge, here it withholds an **alarm**. The
cost is bounded, because the same absence stops the site through `inputs_ok`
(an unreadable SoC) or is refused by the roster schema (an unstated floor).
### 3.2 The proper subset — what only exists at N ≥ 2
A derate this add-on cannot see — thermal, grid-code, a DC-side restriction —
hits **every** unit that was asked. One box that has stopped listening does not.
So if every asked unit is short and unexplained, this module says **nothing**:
it has no instrument that tells that from a plant which simply cannot deliver,
and reporting it anyway would be the saturation-shaped alarm produced by the
guard written to replace it.
**At N = 1 the subset is never proper, for every input**, so the check is inert
there — not by a roster guard, but by the algebra, the same way
`circulation.circulating_w()` is identically zero at N ≤ 1. That is AC 5, and
§3.3's control arm measures it rather than asserting it.
### 3.3 The window — the one number this card chose
**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 × 5 slews, the
whole range `config.yaml` accepts for `gain` `float(0.05,1.0)` and `slew_w`
`int(0,5000)` (0 = follow the rail), across three families:
* **1050 load reversals** — 7 sizes (±300 W … ±5000 W) × 5 write staggers;
* **840 flapping loads** — 7 periods (10 s … 600 s) × 4 amplitudes;
* **750 weighted reversals** — 5 splits (0.5, 0.7, 0.9, 0.99, 0.1) × 5 staggers
on a ±2200 W reversal. ⚠️ **This family did not exist in SAFETY-30's campaign
and it is the one that dominates here**, for a mechanical reason: an
asymmetric split gives one unit a much larger step than its sibling, so the
big one is still ramping while the small one has settled — which is precisely
the *proper subset* this check keys on.
Every run is executed a second time at a **roster of one** on the same stimulus.
**The N=1 arm is the control**, and it is what separates *"a property of the
check"* from *"a property of the second inverter"*.
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
threshold:
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|---:|---:|---:|---:|---:|
| 100 W | 40.0 s (608) | 0.0 s (0) | 75.0 s (576) | **0.0 s (0)** |
| 250 W | 55.0 s (490) | 0.0 s (0) | 60.0 s (480) | **0.0 s (0)** |
| **500 W** | **20.0 s (206)** | **0.0 s (0)** | **30.0 s (368)** | **0.0 s (0)** |
| 700 W | 20.0 s (125) | 0.0 s (0) | 20.0 s (174) | **0.0 s (0)** |
| 1000 W | 15.0 s (70) | 0.0 s (0) | 15.0 s (111) | **0.0 s (0)** |
| 1500 W | 10.0 s (51) | 0.0 s (0) | 10.0 s (50) | **0.0 s (0)** |
| 2000 W | 10.0 s (24) | 0.0 s (0) | 5.0 s (23) | **0.0 s (0)** |
Three findings worth stating rather than leaving in the table:
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
threshold.** That is the compatibility promise measured, not argued.
2. **The flapping family is zero everywhere too** — and it is the family that
defeated SAFETY-30's site window (980 s at two inverters, 1000 s at one, no
window inside the billing quarter surviving it). It cannot touch this check,
because a common stimulus makes both units short at the same time and the
subset stops being proper. That is the proper-subset rule earning its place
on a case it was not designed for.
3. **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 rather than noise: lowering the
threshold puts MORE units into the short set, which can turn a proper subset
into the whole roster and **break** the run. Both the numerator and the
denominator move. Do not read this table as a sensitivity curve.
#### The watts side is not a new option — it is `Tuning.saturation_w`
`main.py` hands the check `self.tuning.saturation_w` (500 W) and there is no
`delivery_w` key anywhere. That is a derivation, not a saving:
* it is already **the gap at which `compute()` itself concludes the inverter is
not following** and freezes the integrator. Below it the shipped control law
calls the divergence ordinary tracking, and an alarm under a threshold the
control law calls normal is an alarm about nothing;
* `saturation_cycles` (3) consecutive cycles over that same gap are needed
before the freeze engages, so **every cycle this counts is a cycle the freeze
has already seen**. This is a reduction over the freeze's input, not a second
opinion about it;
* CFG-03 already tracks three places a default has to be kept in step. A fourth
copy could drift, and the drift that matters has a direction: an operator who
raises `saturation_w` has told the loop that a bigger gap is ordinary
tracking, and a check that stayed put would then alarm on it.
**Rejected:** a `delivery_w` option, for the three reasons above. A margin above
`saturation_w` (the way `circulation_w` sits 1.75× above its knee) was also
rejected: there is no knee here — the table above is not monotone — so a margin
would be a preference, and all the margin this check needs is in the window.
#### `delivery_hold_s: 185` — schema `int(30,900)`
Four bounds, and the value clears all four.
* **lower, from the rig — 30.0 s.** The worst continuous legitimate episode at
the shipped threshold across all 2640 runs (weighted family; 20.0 s on
reversals, 0.0 s on flapping).
* **lower, from the firmware — 30 s (`inverter.LINK_STALE_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. 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, not felt.** The
campaign's B is A copied, so 30.0 s is two identical machines. Sweeping a
*different* second unit through the weighted family (750 runs each):
| B lag | B ramp | worst legitimate | runs naming |
|---:|---:|---:|---:|
| 10.0 s (A) | 800 W/s (A) | **30.0 s** | 368/750 |
| 20.0 s | 800 W/s | 45.0 s | 469/750 |
| 10.0 s | 400 W/s | **30.0 s** | 370/750 |
| 20.0 s | 400 W/s | 45.0 s | 469/750 |
| 40.0 s | 200 W/s | **90.0 s** | 500/750 |
**The RAMP does not move it at all and the LAG moves it roughly linearly.**
The shortfall lasts as long as the unit's first-order lag holds it more than
`saturation_w` below its share; at these step sizes the ramp is never the
binding term. So the number to carry margin against is B's lag, and a unit
**four times slower than A** produces 90.0 s.
* **upper, from the bill — 900 s**, the quarter that sets the capacity tariff.
**185 s** is thirty-seven control cycles: **6.2×** the measured worst, **2.06×**
the four-times-slower case, **6.2×** the firmware veto and **4.9×** inside the
quarter. ⚠️ Deliberately **not 180** — that is SAFETY-02's site hold, and a
reader must not be able to think this 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**, the window
buys its margin with reporting latency and nothing else.
## 4. It reports. It does not act — and the strongest argument is for acting
Card requirement 3 asks which, and why. The answer is recorded with the
evidence **against** it first, because "alarm-only" is the weaker-looking answer.
**Command it harder — no.** HW-01 settles it: the power stage executed its last
instruction for 6 h 25 m through every command this add-on sent. A command is
not a remedy for a box that is not listening.
**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,
nothing is netting, and the sibling is the only thing still delivering. Parking
it takes the site from "short by one unit" to "short by all of them".
**Re-allocate its share to the sibling — no, and NOT because the loop already
does it.** That comfortable answer is false, and the rig says so. Dropping the
wedged unit 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*:
| B wedged, per-unit cap 2000 W | kept in the split | dropped from the split |
|---|---|---|
| load 800 W | settles inside 100 W from cycle 6 | from cycle 8 |
| load 1500 W | **800 W delivered, 700 W error, NEVER inside 100 W in 150 cycles** | 1500 W delivered, inside 100 W from cycle 14 |
| load 2000 W | **800 W delivered, 1200 W error, never settles** | 2000 W delivered, inside 100 W from cycle 8 |
The mechanism is the saturation freeze doing its job on a site comparison that a
non-delivering unit has poisoned: `|site target − site batt|` stays over
`saturation_w` forever, so the target cannot rise and the sibling's *share*
never grows. That is not three cycles of latency; it is the difference between a
site that recovers and one that does not.
**What refuses it anyway** is that the only re-allocation this add-on can
express is **all or nothing**. 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, and cannot tell a 50 %
derate from a wedged box, because no instrument this add-on has can:
| B derated to 50 %, load 3000 W, cap 2000 W | site delivers | B contributes | grid error |
|---|---:|---:|---:|
| kept in the split | 2400 W | 800 W | 600 W |
| **dropped** | **2000 W** | **0 W** | **1000 W — 400 W worse** |
**SAFETY-42 names B in that run.** So the remedy would make the site worse, in a
case the detector cannot rule out, on a live control law, driven by a brand-new
alarm. A **partial** re-allocation is what would make acting safe, and the
allocator has no way to express it today. That is a card, and this section is
its evidence.
`test_delivery.py` part 5 asserts the decision as a **counterfactual** rather
than by reading the code: the same fixture run with the report tripping and with
it never tripping produces byte-identical grid, site target, shares and measured
power on every cycle.
## 5. Requirement 4: the saturation freeze is untouched
`control.py` is not modified by this card, at all. The freeze still shapes the
loop; this reports on it. `test_delivery.py` part 6 asserts what each
neighbouring guard concludes on the same fixture — SAFETY-30 per-unit silent,
SAFETY-30 site silent, SAFETY-31 identically zero, the freeze firing at the
same counter on **both** arms — which is the honest version of "every existing
guard is silent", including the one that is not.
## 6. Surviving a restart (SAFETY-34)
`persistent_notification` is **in memory**. Home Assistant restarts, the card is
gone, and the fault runs on — precisely how the 2026-09-02 outage went unseen
for 6 h 25 m with the detector correct throughout. It matters more here than
anywhere else in this add-on, because SAFETY-02 and SAFETY-31 at least leave a
**stopped** site for somebody to notice; this one leaves a site that looks like
it is working.
* durable half: `unit/<name>/delivery_tripped` / `delivery_reason`, namespaced
per unit for `store.unit_key()`'s reason, written **reason first**.
* visible half: `_delivery_announced`, a per-**process** set of unit names,
deliberately not persisted. A restart empties the set and keeps the latch, so
the alarm is re-raised on the first cycle of every process — **one code path**
for a fresh trip and for a restart into an existing one, `check_circulating()`'s
argument.
* **no flat mirror**, where SAFETY-02 has one. `store.py`'s rule 2 keeps the flat
keys so a rollback still finds its latch, and the version being rolled back to
drives exactly one actuator — a roster at which this check is inert.
* one **notification id per unit**, built from the same `unit_slugs()` TEL-07
announces the MQTT entities under. Two wedged inverters must produce two cards,
not one replacing the other.
* it **latches**, and not for SAFETY-02's reason. Nothing here removes its own
evidence, because nothing here changes what is commanded. It latches so that a
unit which stopped delivering for minutes and then resumed cannot erase its own
record — the intermittent fault is the one an operator most needs told about.
Cleared by the same **Start** press as the others.
* the status row is `ok: False, warn: True` — this list's **caution**. Every
other latched row means the site is at 0 W and a red cross is the truth; this
one means the site is still running and quietly short, and a red cross would
say the controller had stopped.
## 7. What this does NOT catch, said plainly
* **Charge shares are not graded.** The account gate needs a bound this
controller reads in the commanded direction. 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 arming that direction would
fire on the taper of every §12 maintenance charge. The same fault in the other
direction wants the same treatment; what it needs first is a per-pack charge
ceiling this add-on does not have. **That is a card.**
* **Every unit wedged at once is not caught.** The subset is not proper, and
there is no instrument here that separates that from a plant which simply
cannot deliver. The site is short at the meter in that case, which is where the
existing guards can see it.
* **A pack that refuses above its configured floor is reported.** That is
deliberate: either the unit has stopped delivering, or `bms_soc_floor` does not
describe that battery. The notification says both, in those words.
* **A unit with a share of 0 W is not graded**, and neither is it a peer. With
only one unit asked to discharge there is nothing to compare against.
## 8. Mutation ledger
Every run on a **freshly copied tree of its own, `__pycache__` purged and the
count printed, mounted `:ro`, `python3 -B`** — MUTATION.md's rule, followed
because the first SAFETY-30 battery reused one directory, a container mounted it
mid-copy, read a missing `config.yaml` as a failing check, and the whole run was
discarded. **The baseline is green** (23/23) or nothing below is a result. Byte
deltas measured, not estimated; the `+0 B` ones are marked, since a
size-preserving edit is what TEST-15 turned into a false survivor once already.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| M1 | `delivery.py` | the SoC account removed — a cold pack at its floor alarms | −31 B | **RED** — `test_delivery.py`. This is the card |
| M2 | `delivery.py` | the proper-subset rule removed — a whole-site cause names every unit | −34 B | **RED** |
| **M3** | `delivery.py` | **the `len(asked) < 2` fast path relaxed to `< 1`** | **+0 B** | **SURVIVOR — and an EQUIVALENT MUTANT.** See below |
| M4 | `delivery.py` | the strict bound relaxed to `>=` — a gap the control law calls tracking is named | +1 B | **RED** |
| **M5** | `delivery.py` | `share <= 0` relaxed to `share == 0` — charge shares graded | **+0 B** | **SURVIVOR on the first battery — GAP CLOSED**, then **RED**. See below |
| M6 | `delivery.py` | `delivery_hold_s` 185 → a round 300 | **+0 B** | **RED** — the CFG-03 pin |
| M7 | `main.py` | the watts threshold no longer `Tuning.saturation_w` (×4) | +6 B | **RED** |
| M8 | `main.py` | the latch never persisted — nothing survives a restart | −35 B | **RED** |
| M9 | `main.py` | `_delivery_announced` never set — one notification per cycle | −34 B | **RED** |
| M10 | `main.py` | `peakguard.pack_floor_pct()`'s conjunction with `peak_soc_floor` used instead of the pack's own cut-out | +151 B | **RED** — the between-the-floors fixture |
| **M11** | `main.py` | **`inv.is_live(now)[0]` replaced by a literal `True`** | −15 B | **SURVIVOR at review — GAP CLOSED**, then **RED**. See below |
| M12 | `inverter.py` | the latch not restored from the store on start-up | −46 B | **RED** |
**11 of 12 red. One survivor, argued rather than left.**
### M5 was a real gap, and the two obvious checks did not close it
`test_delivery.py` already asserted `short_by(-2000, 0) is None` and
`short_by(-2000, -100) is None` — and **both stayed green under the mutant**.
For a charge share that is *under*-delivered, `share − measured` is negative and
the `gap > 0` test answers `None` anyway, so those two inputs cannot separate
`share <= 0` from `share == 0`. The one input that can is a unit moving **more**
than its charge share — `-2000 W` told, `-3000 W` measured, `share − measured` =
`+1000 W` — which the mutant reports as a shortfall. It is not one; it is
something else driving that pack, which is SAFETY-30's and SAFETY-31's question.
The check now uses that input and the mutant is red.
This is MUTATION.md's third failure mode in miniature: **the assertion ran, the
sentence was true, and the data could not reach the failure it named.**
### M11 was a wiring gap the pure checks structurally could not see
`account(live=False, …)` was already graded — as a **function**. What was not
graded was that `cycle()` actually *asks* `inv.is_live(now)`, and replacing that
call with a literal `True` left every check green, because **no fixture had a
unit whose writes were being rejected**. A dropped unit still has its last
*accepted* command standing on the wire (`write_setpoint` leaves `last_write`
alone on a rejected write) and still measures nothing, so it looks exactly like a
wedged one to every number this check reads — and reporting it would be the
second, louder alarm about a state already being handled that this module takes
its 30 s floor from. `test_delivery.py` part 6 now builds that unit (with
`rejected_since` back-dated past `LINK_STALE_S`, because the fixture runs in two
seconds and the timer is `test_inverter.py`'s to grade) and the mutant is red.
### M3 is recorded as a survivor because it is one — and it is EQUIVALENT
`len(asked) < 2` → `len(asked) < 1`. 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 with **zero** asked units both spellings return `{}`
as well. **No input separates them**, so no check can, and one written to try
would be a check about nothing.
⚠️ **And finding it corrected the module's own docstring, which is the more
useful half.** That paragraph claimed `len(asked) < 2` was *what makes N=1
inert*. It is not: the **proper-subset rule** is, and the line is a fast path and
a signpost. A false attribution of the mechanism in the file's own prose is
exactly what this repository keeps finding, and a mutation that survives is how
it surfaced. The line stays — it tells a reader at a glance that a one-inverter
install never reaches any of this — with the equivalence written down beside it,
the way `circulation.circulating_w()` records its own `max(0.0, …)`.
### Not separately mutated, and said rather than left implicit
`account()`'s `parked` branch is graded as a function (part 1) but its **wiring**
— `parked=inv.sign_wd.tripped` — is not mutated on its own. Part 6's reversed-unit
fixture exercises the path end to end and asserts the account it produces, which
is weaker than a mutation and is stated as such.
## 9. Round 2 — the thresholds this check reads, and which of them are pinned at a **changed** config
Everything in §8 mutated the *logic*. This section asks a narrower question that
§8 never asked, and it found two live survivors of the whole 23-file suite:
> for each number `main.py` hands this reduction, is the check still red when
> that number is replaced by a **literal equal to the value the fixtures use**?
That is not pedantry about style. Two of these numbers are **options**, and
`delivery.MIN_W_IS_SATURATION_W` and the `delivery_hold_s` block in `config.yaml`
both make a promise about them *in shipped, operator-readable prose*: there is no
fourth copy of a default to keep in step, and no way for the two to drift,
because an operator who raises `saturation_w` has told the loop that a bigger gap
is ordinary tracking. A literal at the call site keeps every fixture green and
removes exactly that promise — and the promise is why the design was chosen over
a fourth option key.
### The enumeration
| # | what the call site reads | mutated to | Δ | before round 2 | now |
|---|---|---|---|---|---|
| R5 | `self.tuning.saturation_w` | `250.0` | −19 B | **SURVIVED all 23 suites** | red — part 4b(a) |
| R6 | `bms_floor=inv.bms_soc_floor` | `10.0` | −13 B | **SURVIVED all 23 suites** | red — part 4b(b) |
| R7 | `arm(self.delivery_cfg)` | `arm(DeliveryConfig())` | −1 B | red | red — part 4 already ran at a hold of 1 s, not the shipped 185 s |
| R8 | `inv.is_live(now)[0]` | `inv.is_live(now, 3600.0)[0]` | +8 B | red | red — part 6's link-dead fixture |
| R9 | `bms_floor=inv.bms_soc_floor` | the **conjoined** floor, `max(pack cut-out, site peak_soc_floor)` | +105 B | red | red — and now red **twice**: the pre-existing between-floors check at shipped floors, and part 4b(b)'s shallow arm at a cut-out that is not shipped |
**R7 and R8 were already pinned across a changed setting, and R5 and R6 were
not.** The difference is entirely in the fixtures, not in the code:
`delivery_hold_s` had to be moved off 185 s for any of this file to run inside a
two-second test, and part 6 had to back-date `rejected_since` past
`LINK_STALE_S` to build a dropped unit at all. Nothing ever forced
`saturation_w` off 500 or a `bms_soc_floor` off 10 — so those two sat at the
shipped value in every fixture, and a literal equal to the shipped value is
invisible to a fixture that only ever uses the shipped value. **That is this
repository's recurring defect stated in one line: a fixture whose values cannot
reach the failure it names.**
`LINK_STALE_S` (R8) is the one number here with **no option behind it** — it is a
module constant in `inverter.py` and no test reassigns it — so "pinned across a
changed config" is not available for it, and is not claimed. What part 6 pins is
that *widening* the window changes the answer. Spelling `is_live(now, 30.0)` at
the call site would be an equivalent mutant today and would only drift if the
constant moved, which is a source change and not a config change.
### Why R5 and R6 are not equivalent mutants — the inputs that separate them
Both are separating inputs found by asking where the shipped answer and the
literal's answer can differ, and both are configs the shipped schema accepts.
**R5, watts.** Two copies of inverter A, B wedged, `saturation_w: 1800` (inside
its own schema `int(100,2000)`), load 1600 W. B's standing share and its
shortfall then land strictly **between** `250.0` and the configured 1800 W:
* head → `named = {}` — the operator raised the threshold and this check moved
with them, which is the whole promise;
* R5 → `named = {inverter-b}` — an alarm at a gap the control law has been told
is ordinary tracking.
At the shipped `saturation_w: 500` **both** name it, which is why every fixture
in part 4 is blind: their gaps are all above both thresholds.
**R6, percent.** The literal is invisible because `bms_soc_floor: 10` is what
`config.yaml`'s own `inverters:` example states and what every fixture in
`test_delivery.py` used. Two arms, on either side of it, both inside the schema
`int(0,50)` and both `validate.py` rule-8-valid:
* a pack at **15 %** against its own **25 %** cut-out is genuinely cold. Head
stays silent; R6 grades it against 10 %, finds it "permitted", and **names an
empty pack** — the crying-wolf failure this card exists to remove.
* a pack at **8 %** against its own **5 %** cut-out is genuinely wedged. Head
names it; R6 grades it against 10 %, calls it a cold pack, and **loses the
fault**.
One arm on each side is what leaves no constant at all: a single deeper arm
kills `10.0` but not `50.0`, a single shallower arm kills `10.0` but not `0.0`.
The two arms are byte-identical in the power domain on every cycle — the
configured cut-out is the only thing that differs, which is §3.1's claim made
again at cut-outs that are not the shipped one.
### What is still not pinned this way, said rather than left implicit
`parked=inv.sign_wd.tripped` and `standing[inv.name]`/`inv.batt` are wiring, not
thresholds: there is no "value the fixture happens to use" for a boolean or for a
measurement, so this question does not apply to them, and §8's last note already
records that the `parked` wiring is graded end-to-end rather than by mutation.
+154 -1
View File
@@ -473,7 +473,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
| `step_w` | 10 | Quantisation |
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1** |
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one meter update or `cycle_floor_s`, whichever comes first - see the note below. **Do not set to 1**. Every value this file accepts (1-10) is below the freeze's own release bound of 12 cycles, so no accepted setting can stop the block engaging - see SAFETY-13, below |
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
| `cycle_floor_s` | 15 | The longest the loop may go without evaluating. A deadline on the gap between cycles, reset by every cycle — on a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than ~10 s; it must stay under `meter_max_age_s`, past which there is no reading to evaluate anyway |
@@ -485,6 +485,7 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `unit_sign_disagree_s` | 85 | **Two or more inverters only.** How long one unit's command and its own power must stay opposed before that unit is parked |
| `site_sign_disagree_w` | 300 | **Two or more inverters only.** Threshold for the site-wide check - the sum of the commands against the meter. The same 300 W as `sign_disagree_w`, for the same reason: near zero the sign is noise |
| `site_sign_disagree_s` | 325 | **Two or more inverters only.** How long the site's total command and the meter must stay opposed before every inverter is parked. Longer than `sign_disagree_s` because the command has to cross zero over a rail that is the sum of the roster, at a `slew_w` that is not - measured at 110 s with one inverter and 215 s with two |
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
#### The sign-disagreement failsafe
@@ -584,6 +585,70 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
10.5 % and 0.0 %. There is no reason to move either value without a capture that
says so.
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
The two checks above both key on **direction**. A unit sitting at **0 W against
a non-zero share** has no direction: it is not pushing the wrong way, it is
**absent**. So the per-unit sign gate never opens (it needs opposition on both
sides of `unit_sign_disagree_w`), the site sum still agrees with the meter in
direction, and circulating power is exactly **zero** — that quantity needs two
units pulling *against* each other, and one of these is pulling nowhere.
The only thing that does fire is the **saturation freeze**, and this add-on's own
code calls that an alarm operators learn to ignore, because a loop can be legally
saturated all day: a cold pack, a curtailed inverter, an export limit.
So `delivery_hold_s` grades a different quantity — **how many watts of a
discharge share did not arrive** — and makes it readable with a reduction that
only exists once there is more than one inverter:
> a unit is named while it is short of its share by more than `saturation_w`,
> **with nothing this add-on can read to explain it**, and the units in that
> state are **fewer than the units that were asked to discharge**.
Both halves matter, and the second is why this cannot fire on a
one-inverter install at all:
- **"nothing explains it"** is what keeps a legitimately empty pack out. A pack
at or below its own `bms_soc_floor` cannot discharge, and that is the *only*
fact this add-on holds that distinguishes an empty pack from a wedged one —
the two are otherwise identical at the unit and at the meter, watt for watt.
A unit the add-on has already dropped from the split, or parked by a sign
trip, is likewise accounted for.
- **"fewer than were asked"** is what keeps a whole-site cause out. A derate this
add-on cannot see — thermal, grid-code, DC-side — hits every unit. One box that
has stopped listening does not. If *every* unit that was asked is short and
unexplained, nothing is reported: there is no instrument here that tells that
from a plant which simply cannot deliver.
**It reports. It does not act.** Commanding an inverter that has stopped
listening achieves nothing — that happened on this hardware once for 6 h 25 m —
and the only re-allocation this add-on can express is all-or-nothing, which
would throw away the output of a unit delivering *part* of its share. So the
loop is untouched, the sibling keeps regulating, and the site keeps running
**short by that unit's share** until somebody looks. The status page shows it as
a caution rather than a failure for exactly that reason: the controller has not
stopped.
The report **survives a Home Assistant restart** (`unit/<name>/delivery_tripped`
in the add-on's own state file) and the notification is re-raised on the next
start, because a `persistent_notification` is in memory and that is precisely how
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
other latches.
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure,
and it **displaces** `Running — holding grid at zero`. That last sentence is a
claim the site cannot make while a unit is absent, and a page that goes on
reading healthy while the fault runs is the whole shape this check exists to
remove.
**Charge shares are not graded**, and that is a stated gap rather than an
oversight: there is no configured charge ceiling anywhere in this add-on, so a
pack in constant-voltage taper — which legitimately takes hundreds of watts less
than it is told, for tens of minutes — could not be explained, and arming that
direction would fire on the taper of every maintenance charge.
#### Saturation is counted in cycles, not seconds
The specification states the saturation window as **"> 10 s"**. This add-on counts
@@ -628,6 +693,94 @@ correct toward that wrong value instead of holding a stale command. It is bounde
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
safety property.
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
While the loop is frozen the command's **magnitude may fall but never rise**. That is
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
nothing commanded there is no magnitude left to fall, so a charge cannot begin. That
refusal is deliberate. Commanding 0 W while the inverter reports hundreds of watts
means something is driving the actuator that this add-on did not ask, and starting to
push power the other way into it is how two controllers end up fighting over one
actuator — the configuration §1.5 of the specification rules out.
**What was missing was an end to it.** Nothing cleared the block: not the error growing,
not the integrator moving, not time. The exit is now explicit, and *which* freezes get
one is the decision:
| the inverter is… | can it clear itself? | what happens now |
|---|---|---|
| doing **less** than the command, in the command's own direction — a cold pack, a curtailment, an export cap | **yes.** The command is pinned at something the inverter can reach, so it catches up and the freeze clears | unchanged: frozen for as long as it lasts, with no exit |
| doing something **outside** what the command can explain — overshooting it, or the opposite sign | **not necessarily.** If another writer is on the actuator, clearing the freeze needs the inverter to track *our* command and the other writer is what stops it, so nothing ends it | the freeze releases once the divergence has outlasted the plant that could otherwise have explained it — see *what `freeze-released` actually tells you*, below, because this row is also where our own ramp lands |
The second row's threshold is the plant's own measured settling time — **the slower of
the two directions it was measured in.** The 2026-08-22 step test timed the same 1.78 kW
step both ways: about 18 s to settle going up, about 23 s coming back down. 23 s is the
one that belongs here, because the question this bound answers is *how long may the plant
still be the explanation*, and the safe answer is to keep believing the plant for as long
as the plant has ever been seen to take. At the measured 2 s meter tick that is
**12 cycles** (`control.py`, `SETTLE_CYCLES`).
⚠️ **The first version of this feature used 18 s, and 9 cycles was wrong in the direction
that accuses.** Nine cycles is three short of the plant's own down-ramp, so an ordinary
load step on a **single** inverter — the command falling through zero while the pack is
still coasting the old way — could produce `freeze-released` with nothing else on the bus
at all. Driven in the container against a model of the measured plant, one inverter and
no second writer anywhere: over 272 load-step pairs the 9-cycle bound produced a release
in 62 of them and the shipped 12-cycle bound in 2.
**Releasing the output freeze does not unfreeze the loop.** The *integrator* freeze is
untouched, so the command is free to move only as far as an accumulator that stopped
accumulating cycles ago, one slew step per cycle. Measured on the simulator at the live
install's shape (`max_w 5000`, `slew_w 400`, `target_grid_w -10`), with another writer
holding the pack at +700 W and 2 kW of export standing: the command holds at 0 W while
the counter climbs, starts on the **twelfth** saturated cycle, walks 0 → −400 → −800, and
settles at **−1190 W** against a frozen integrator of −1194 W — not at the 5000 W rail.
Before this change the same run held 0.0 W on every cycle, for as long as the other
writer stayed.
⚠️ **The site still does not reach its target in that state, and it is not supposed
to.** What the exit buys is that the battery stops being idle while the site exports;
what it does not buy is correct regulation against an actuator somebody else is also
writing to. A standing grid error with another writer on the bus is the symptom of a
configuration this add-on refuses to support, not a tuning problem.
#### What `freeze-released` in the action log actually tells you
**It says: the inverter's power did not match the command for longer than this plant has
ever been measured to take to settle.** That is all it says. It is a reason to go
looking; it is not a finding, and in particular it is **not** proof that a second writer
exists.
The test behind it compares the reading against **one cycle's** command, and the pack
carries more than one cycle of history. So our own power, arriving late, can look like
somebody else's:
- **A big, fast load step is the ordinary innocent cause.** The command falls through
zero while the pack is still coasting in the old direction, and for a few cycles the
reading has the sign the command *used* to have. Measured, one inverter, nothing else
on the bus: eight cycles after a +1990 W command the command was 0 and the pack still
read +522.3 W.
- **A plant slower than the one that was measured does it too.** The 12-cycle bound is
derived from a 23 s down-ramp. A colder or larger pack that takes longer than that will
produce releases on its own, and no constant read off the 2026-08-22 measurement can
prevent it. In the same container sweep, holding the bound at 12 and lengthening only
the plant's down-ramp, single-inverter releases went from 8 of 320 scenarios at the
measured 23 s to 59 of 320 at 34 s and 268 of 320 at 50 s.
**So, in order:** if you see `freeze-released` **once, during or just after a large load
or PV swing**, that is most likely the loop's own ramp and needs nothing. If you see it
**standing** — cycle after cycle, at a steady load, with a standing grid error the
battery is not correcting — then look for the second writer: another controller, a
scheduler in the inverter's own app, or an installer tool left connected. That is the
configuration §1.5 of the specification rules out, and it is the one this exit exists to
stop the loop stalling on forever.
⚠️ **No `saturation_cycles` you can set removes the block.** This file accepts 1-10, and
all of those are below the 12-cycle release bound, so the freeze always engages before
its own exit is due. (The first version of this feature had the bound at 9, where a
setting of 9 or 10 *did* silently delete the block; moving the bound to 12 closed that
by construction rather than by warning about it.)
#### Why `target_grid_w` is not zero
The deadband is a one-way ratchet: any resting point inside it holds until
+234 -3
View File
@@ -16,6 +16,7 @@ the short version is in the comments below. Do not "clean this up" - each rule
exists because its absence produced a specific, observed failure.
"""
import math
from dataclasses import dataclass
# ---------------------------------------------------------------------------
@@ -65,6 +66,17 @@ from dataclasses import dataclass
# validate.py refuse the impossible case only, never "close
# to the rail".
#
# ⚠️ AND THAT CHOICE IS LOCAL TO THIS BOUND. §5.10 MEASURED THE STEP IN BOTH
# DIRECTIONS, so "the plant's settling time" is a PAIR and not a number, and
# which half is the safe one depends on which way being wrong hurts. Taking
# 18 for a REFUSAL is right: the looser number refuses less. Taking 18 for a
# question of the form "how long does the plant remain a candidate
# explanation" is WRONG, because the looser number stops believing the plant
# too early - and there the answer is a false accusation. SAFETY-13 asks that
# second question and so reads PLANT_SETTLE_DOWN_S below, deliberately not
# this constant. Anything else added here has to say which of the two it
# means and why.
#
# LOOP_TICK_S is the METER, not this loop. `run_control()` runs a cycle on
# a changed meter value or on `cycle_floor_s`, whichever comes
# first, so the tick is the meter's own update rate. 2.0 s is
@@ -86,6 +98,99 @@ PLANT_SETTLE_S = 18.0
LOOP_TICK_S = 2.0
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
# The other half of the same measurement: the descent of the same 1.78 kW step,
# ~23 s from a -1784 W export excursion back into the deadband (goodwe/NOTES.md
# §5.10, the same run, the same day, the same plug). It is slower than the ramp
# up, which is the ordinary asymmetry of a pack that is pushed one way and
# coasts the other. Nothing above this line reads it; SAFETY-13 below does.
PLANT_SETTLE_DOWN_S = 23.0
# ---------------------------------------------------------------------------
# SAFETY-13. HOW LONG THE PLANT STAYS A CANDIDATE EXPLANATION.
#
# The output freeze below stops the command growing while the inverter is not
# following. Two completely different things produce that divergence and the
# freeze cannot tell them apart on magnitude alone:
#
# (a) THE PLANT HAS NOT CAUGHT UP YET, or it is at a limit - a cold pack, a
# curtailment, an export cap. Asking for more is pointless, and the
# freeze is right to hold. It also CLEARS ITSELF: the command is pinned
# at something the inverter can do, so the inverter catches up to it and
# `saturated_now` goes false.
#
# ⚠️ AND (a) INCLUDES THE CASE `unasked_power()` BELOW CANNOT SEE AS (a):
# the command has fallen THROUGH ZERO and the pack is still coasting in
# the old direction, so the reading has the sign the command used to have
# and not the one it has now. That is this loop's own power, arriving
# late. It is the reason the time bound is what does the work here and
# the predicate alone is not enough.
#
# (b) SOMETHING ELSE IS DRIVING THE ACTUATOR - the §1.5 non-goal, two writers
# on one actuator. The freeze CANNOT clear itself here, because clearing
# it needs the inverter to track OUR command and the other writer is what
# stops it. Nothing ends this but the other writer going away.
#
# ⚠️ SO THE BOUND IS ONLY EVER A REASON TO STOP BELIEVING (a). It does not
# establish (b) and nothing below claims it does: what it says is that the
# plant has run out of alibi, and (b) is then the only explanation this loop
# has left - not the only one there is. Everything downstream of the release is
# written to be safe when that inference is wrong, because sometimes it is.
#
# Case (a) is bounded by the plant, and the plant was measured:
#
# SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)
#
# ⚠️ THE **DOWN** NUMBER, AND THE DIRECTION IS THE WHOLE POINT. §5.10 measured
# the same step both ways - 18 s up, 23 s down - and SAFETY-39 takes 18 because
# 18 is the LOOSER bound for a REFUSAL. This is the opposite job. Here the
# question is "how long may the plant still be the explanation", and the
# conservative answer is the LONGER of the two: believe the plant for as long
# as the plant has ever been observed to take. Deriving this from 18 gives 9,
# which is three cycles SHORT of the plant's own measured down-ramp - and being
# short here does not refuse anything, it ACCUSES. Measured, single inverter,
# no second writer anywhere, 1280 closed-loop scenarios: at 9 the exit fires in
# 113 of them and at 12 in 24. The direction of that error is why the pair is
# now two named constants instead of one.
#
# ⚠️ AND IT IS NO LONGER THE RECIPROCAL OF SLEW_RAIL_FRAC. It was, at 9, and
# that coincidence read as a derivation - two different questions that happened
# to share an answer while both were being asked of the same half of a two-
# sided measurement. They are 9 and 12 now. If they ever coincide again it is
# arithmetic, not agreement.
#
# ⚠️ IT INHERITS SAFETY-39's CAVEAT ABOUT LOOP_TICK_S, in the opposite
# direction, which is why it is repeated rather than cross-referenced. A meter
# SLOWER than 2 s makes these 12 cycles cover MORE than 23 s, so the freeze
# holds longer than the plant needs: that costs energy - see the case in
# DOCS.md - and never safety. A meter FASTER than 2 s makes them cover LESS,
# so the freeze can release while the plant is genuinely still moving. That one
# is bounded twice over and by neither this constant nor luck: the INTEGRATOR
# freeze is not released with the output freeze, so the command can only reach
# the integrator's frozen value, and the output clamp bounds that in turn.
#
# ⚠️ AND A PLANT SLOWER THAN THE ONE THAT WAS MEASURED DOES THE SAME THING,
# which no constant derived from a measurement can fix. In the same 1280
# scenarios, holding the bound at 12 and lengthening only the plant's down-ramp
# past the measured 23 s, the exit starts firing again on a single inverter:
# 8/320 at 23 s, 59/320 at 34 s, 268/320 at 50 s. A re-measured plant means a
# re-derived constant here, exactly as for the two above it.
SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
# The saturation counter's ceiling, one above the longest bound read from it. A
# counter capped BELOW that bound would silently make the bound unreachable -
# i.e. delete the exit condition - so it is written against SETTLE_CYCLES and
# not as a literal, and it moves when SETTLE_CYCLES moves.
#
# ⚠️ IT WAS 10, WHICH IS ALSO config.yaml's MAXIMUM FOR `saturation_cycles`,
# AND THAT COLLISION IS GONE. At a bound of 9 the largest accepted
# `saturation_cycles` (10) sat ABOVE the exit, so a legal configuration could
# push the freeze's engagement past its own release and the no-start block
# stopped existing. At 12 every accepted value 1..10 is strictly below the
# bound, so the freeze always engages before its exit is due and no accepted
# configuration can delete the block. `test_control.py` pins that at the schema
# edge rather than in prose, because it is a property of two files agreeing.
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
@dataclass(frozen=True)
class Tuning:
@@ -185,6 +290,71 @@ def slew_limit_w(tuning: Tuning) -> float:
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
"""True when `actual_w` cannot be explained by THIS cycle's command not being met.
SAFETY-13. The one question the saturation magnitude test cannot answer:
is the inverter FAILING TO REACH what we asked, or is it doing something
the command in front of it does not account for?
⚠️ THE ANSWER IS PHYSICAL, NOT STATISTICAL, WHICH IS WHY IT IS A PREDICATE
AND NOT A THRESHOLD. A limit can only make the inverter do LESS than the
command, in the command's own direction. It cannot make it overshoot the
command, and it cannot make it produce power of the OPPOSITE sign. So the
whole set of readings a limit can produce is the closed interval spanned by
0 and `prev_w`, widened by `saturation_w` for measurement noise, ramp lag
and the inverter's own idle draw.
⚠️ WHAT IT DOES NOT SAY - AND AN EARLIER VERSION OF THIS DOCSTRING DID -
IS THAT ANYTHING OUTSIDE THE INTERVAL WAS PUT THERE BY SOMETHING OTHER THAN
THIS LOOP. THAT IS FALSE AND IT WAS MEASURED FALSE. This predicate has no
memory: `prev_w` is one cycle's command, while the plant carries every
command before it. Drive ONE inverter, with nothing else on the bus, from a
discharge down through zero, and the pack is still coasting in the old
direction after the command has crossed. Measured in the container, one
`compute()` against one plant: eight cycles after a +1990 W command the
command is 0 and the pack still reads +522.3 W, so
`unasked_power(0, 522.3, 500)` is True - and every watt of that 522.3 was
asked for by this loop. In 1280 single-inverter closed-loop scenarios with
no second writer in them at all, the exit built on this predicate fires in
24 of them.
So what it guarantees is exactly the sentence at the top: this cycle's
command does not account for this cycle's reading. What it CANNOT do is
tell "somebody else is writing" from "we are". That gap is the reason
SETTLE_CYCLES is derived from the plant's slower direction, why the caller
checks both, and why the reason string it produces is a place to start
looking rather than a finding.
Worked, at `saturation_w = 500`:
prev +2000, actual +700 -> inside [0, 2000] a limit. False.
prev -5000, actual -700 -> inside [-5000, 0] a limit. False.
prev 0, actual +700 -> outside [-500, 500] unasked. True.
prev -800, actual +700 -> outside [-1300, 500] unasked. True.
⚠️ AT `prev_w == 0` THIS IS EXACTLY `saturated_now`, and that is not a
coincidence to be tidied away - it is the reason the freeze's zero case is
the one the card was written about. With nothing commanded there is no
interval left for a LIMIT to live in, so at zero this test cannot be
answered by "the inverter is not reaching what we asked" at all. It can
still be answered by "the inverter has not finished leaving what we asked
for LAST time", which is the coast-down above and is why zero needs the
time bound as much as any other `prev_w` does.
⚠️ IT IS NOT A DETECTOR AND MUST NOT BE ALARMED ON. A saturated inverter
whose reading is stale, wrong-signed or simply noisy lands outside the
interval too, and so does our own ramp, above. All this predicate decides
is whether the output freeze gets an EXIT; being wrong about it costs at
most a bounded command that the frozen integrator and the output clamp both
still bound. SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on
sign, and it is deliberately a different test with a different threshold.
"""
lo = min(0.0, float(prev_w)) - abs(float(saturation_w))
hi = max(0.0, float(prev_w)) + abs(float(saturation_w))
return not (lo <= float(actual_w) <= hi)
def compute(
prev_w: float,
grid_w: float,
@@ -232,7 +402,7 @@ def compute(
# clockless; the remaining ceiling is that saturation_cycles expresses a
# bound in seconds rather than an exact number of them.
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
sat_count = min(sat_count + 1, 10) if saturated_now else 0
sat_count = min(sat_count + 1, _SAT_COUNT_CAP) if saturated_now else 0
frozen = sat_count >= tuning.saturation_cycles
# --- deadband ----------------------------------------------------------
@@ -346,8 +516,69 @@ def compute(
# implementation: commanding 0 while the inverter reports >500 W means
# something else is driving the bus, and that is not the moment to
# start pushing power the other way.
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
reason = "saturated-freeze"
#
# --- SAFETY-13: ...AND IT IS NOT THE MOMENT FOREVER EITHER ----------
# Everything above stands. What was missing is an END. Held at
# `prev_w == 0` this is a refusal to START, and nothing in this
# function or above it clears it - not the error growing, not the
# integrator moving, not time. Measured on release/1.0 @ c8dcb38, at
# the live install's shape (max_w 5000, slew_w 400, target_grid_w -10):
# a site resting in the deadband with another writer holding the pack
# at +700 W reaches sat_count 3 with the command at 0, and 0 W is then
# a FIXED POINT - a 2 kW export arriving afterwards leaves the command
# at exactly 0.0 W on every subsequent cycle, with the integrator
# standing at -1194 W the whole time.
#
# ⚠️ AND THE ZERO CASE IS NOT SPECIAL, WHICH IS WHY THE FIX IS NOT
# SPECIAL EITHER. The same run pinned at prev_w == -800 W, against its
# own integrator standing at -2388 W, for as long as it ran.
# `prev_w == 0` is only the instance where the pinned magnitude happens
# to be zero - i.e. where the standing cost is the whole of what the
# battery could have done.
# A rule written for the zero case alone would have left the other
# instances exactly as unbounded as it found them.
#
# ⚠️ WHAT DECIDES IS NOT HOW LONG, IT IS WHETHER THE FREEZE CAN EVER
# END ON ITS OWN. See SETTLE_CYCLES and `unasked_power()` at the top:
# a divergence a LIMIT could produce clears itself once the inverter
# catches up to the pinned command, so it needs no exit and gets none -
# unbounded, exactly as shipped. A divergence a limit CANNOT produce
# does not clear itself by being waited on, so it gets one.
#
# ⚠️ AND THAT IS NOT THE SAME SENTENCE AS "THE OTHER WRITER". An
# earlier version of this comment said the two conditions below fire
# only in case (b); they do not, and the sweep says so. Our own plant
# coasting through a command that has just crossed zero satisfies
# `unasked_power()` too, because that predicate compares against ONE
# cycle's command and the pack remembers more than one - measured, a
# single inverter with nothing else on the bus reaching this branch in
# 24 of 1280 closed-loop scenarios. `SETTLE_CYCLES` is what keeps that
# rare rather than routine (113 of the same 1280 at the old bound of
# 9), and it does it by believing the plant for as long as the plant
# has ever been measured to take. It is a bound on how long we wait,
# not a proof about what we found.
#
# ⚠️ SO THE RELEASE IS WRITTEN TO BE SURVIVABLE WHEN IT IS WRONG, and
# that is the paragraph below, not this one: what a false release costs
# is one slew step per cycle toward a frozen integrator. If it ever has
# to cost less than that, the thing to change is what happens after the
# release, not the confidence of the sentence before it.
#
# ⚠️ RELEASING IS NOT UNFREEZING. The INTEGRATOR freeze above is not
# lifted, and that is what makes this bounded rather than merely
# different: the command is free to move, but only as far as an
# integrator that stopped accumulating cycles ago, through the same
# slew limit as any other cycle. It buys back the standing error, not
# the rail.
#
# `reason` is authoritative here in a way it is not elsewhere in this
# function: nothing after this line writes it.
if unasked_power(prev_w, actual_w, tuning.saturation_w) \
and sat_count >= SETTLE_CYCLES:
reason = "freeze-released"
else:
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
reason = "saturated-freeze"
# --- quantise ----------------------------------------------------------
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
+634
View File
@@ -0,0 +1,634 @@
"""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 = ""
+33 -4
View File
@@ -122,6 +122,7 @@ import math
from datetime import datetime
from .clamp import ClampResult
from .delivery import DeliveryConfig, DeliveryWatchdog
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
@@ -166,6 +167,13 @@ UNIT_A = "inverter-a"
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
# reads this same mapping, so a key renamed here cannot silently stop being
# migrated. See store.py.
# ⚠️ SAFETY-42's PAIR IS DELIBERATELY NOT IN THIS TUPLE, AND THE ABSENCE IS THE
# STATEMENT. The values here are the PRE-DEV-02 FLAT key names that
# `store.migrate_unit_keys()` moves under a unit; `delivery_tripped` /
# `delivery_reason` never existed flat, because the check that writes them
# cannot fire at a roster of one (see delivery.py) and a one-inverter install is
# the only install that ever had flat keys. Adding them here would make the
# migration look for keys that cannot exist and would report them as absent.
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
@@ -184,7 +192,8 @@ class Inverter:
def __init__(self, name, *, setpoint_entity="", soc_entity="",
batt_entity="", batt_invert=False, max_w=2000.0,
bms_soc_floor=None, store=None, sign_cfg=None):
bms_soc_floor=None, store=None, sign_cfg=None,
delivery_cfg=None):
self.name = str(name)
self.setpoint_entity = str(setpoint_entity or "")
self.soc_entity = str(soc_entity or "")
@@ -274,6 +283,25 @@ class Inverter:
tripped=bool(data.get(self.key("sign_tripped"), False)),
reason=str(data.get(self.key("sign_reason"), "") or ""))
# --- SAFETY-42, one instance per unit, under NAMESPACED keys ---------
# ⚠️ PER UNIT BECAUSE THE CONCLUSION IS ABOUT A UNIT. "This box is not
# producing what it was told, while its sibling is" names a device, and
# naming the device is card requirement 2 - the site figure cannot say
# which one to look at, which is the whole reason SAFETY-30 split
# per-unit from site. The site-level REDUCTION that decides whether a
# unit qualifies lives in `delivery.delinquent()` and is computed once
# per cycle in `Controller.cycle()`; what is per-unit here is the
# WINDOW and the LATCH.
#
# ⚠️ AND IT IS RESTORED FROM THIS UNIT'S OWN KEYS, LIKE `sign_wd`. A
# latch held only in memory is not a latch: Home Assistant restarts, the
# `persistent_notification` goes with it, and the fault runs on unseen.
# That is SAFETY-34, and it is how the 2026-09-02 outage lasted 6 h 25 m.
self.delivery_wd = DeliveryWatchdog(
delivery_cfg or DeliveryConfig(),
tripped=bool(data.get(self.key("delivery_tripped"), False)),
reason=str(data.get(self.key("delivery_reason"), "") or ""))
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
@@ -436,7 +464,8 @@ def roster_names(opts: dict) -> list:
return names
def build_roster(opts: dict, store, sign_cfg=None) -> list:
def build_roster(opts: dict, store, sign_cfg=None,
delivery_cfg=None) -> list:
"""The site's inverters, in roster order.
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
@@ -490,7 +519,7 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
# mirror exactly as validate.py rule 8 defaults it, so an install
# that never set the key reads the same 10 % in both places.
bms_soc_floor=_flat_bms_floor(opts),
store=store, sign_cfg=sign_cfg)]
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
roster = []
names = roster_names(opts)
@@ -517,7 +546,7 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
# SAFETY-32's own defect written in three lines - the shallower pack
# graded against the deeper pack's BMS.
bms_soc_floor=_entry_bms_floor(entry),
store=store, sign_cfg=sign_cfg))
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
return roster
+323 -1
View File
@@ -38,6 +38,10 @@ from .arbiter import (
Claim, Resolution, actuator_for, resolve,
)
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .delivery import (
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
account as delivery_account, delinquent,
)
from .circulation import (
CIRC_OPTION_DEFAULTS, CirculationConfig, CirculationWatchdog,
circulating_w, opposing,
@@ -216,8 +220,17 @@ class Controller:
hold_s=float(opts.get("site_sign_disagree_s",
SIGN_OPTION_DEFAULTS["site_sign_disagree_s"])),
)
# SAFETY-42. THE WINDOW A UNIT MAY BE SHORT OF ITS SHARE BEFORE IT IS
# NAMED. There is no second key: the WATTS half of that check is
# `Tuning.saturation_w`, read at runtime rather than copied - see
# `delivery.MIN_W_IS_SATURATION_W`, and CFG-03 on why a fourth copy of a
# default is worth avoiding.
self.delivery_cfg = delivery_cfg = DeliveryConfig(
hold_s=float(opts.get("delivery_hold_s",
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
)
self.store_migration = migrate_unit_keys(store, UNIT_A)
self.inverters = build_roster(opts, store, sign_cfg)
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
# of `soc_entity`, `batt_entity`, `batt_invert` or `max_w`, and an
@@ -365,6 +378,32 @@ class Controller:
"opposed the meter.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
# ⚠️ A LATCH THAT DOES NOT ACT, WHICH IS THE ONE ASYMMETRY WITH EVERY
# OTHER RESTORED LATCH ON THIS OBJECT, AND IT IS DELIBERATE. SAFETY-02's
# and SAFETY-31's latches survive a restart because they command 0 W and
# an add-on that came back armed would drive straight into the fault
# again. This one commands nothing at all: it is restored so that the
# ALARM survives, which is the whole of SAFETY-34 and is exactly how the
# 2026-09-02 outage went unseen for 6 h 25 m with the detector correct
# the entire time. `delivery.py` carries the argument for reporting
# rather than acting, with the rig measurements on both sides of it.
#
# `_delivery_announced` is the per-PROCESS half - see `_circ_announced`,
# which is the same pattern for the same reason. The store says "this
# unit is tripped"; the flag says "this process has put that in front of
# a human". A restart clears the flag and keeps the latch.
self._delivery_announced = set()
for inv in self.inverters:
if inv.delivery_wd.tripped:
_LOG.error(
"SAFETY-42 was TRIPPED on %s before this restart and stays "
"tripped: %s. NOTHING IS BEING COMMANDED DIFFERENTLY - this "
"guard reports and does not act - so if that inverter is "
"still not delivering, the site is still short by its "
"share. Restarting does not clear this.",
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
# The per-unit watchdogs above ask "is THIS actuator doing what I told
@@ -1738,6 +1777,117 @@ class Controller:
"add-on restart AND a Home Assistant restart; restarting will "
"not clear it.", self.circ_wd.reason)
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
# not yet stale" hold below. A run of held cycles would otherwise leave
# each unit's `since` untouched across the gap and its window would
# resume from a start time that predates it - the ordering bug the sign
# watchdog's own note describes, in a third detector.
#
# ⚠️ THE OPERANDS ARE `standing` AND `inv.batt`, THE SAME PAIR SAFETY-30
# GRADES, AND THE CONCLUSION IS A DIFFERENT ONE. SAFETY-30 asks whether
# they OPPOSE; this asks whether the second one ARRIVED. A unit sitting
# at 0 W against a +2000 W share opposes nothing - it is not pushing the
# wrong way, it is absent - so the sign gate never opens, the site sum
# still agrees with the meter in direction, circulating power is
# identically zero, and the only trace left is `reason ==
# "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an
# operator learns to ignore. That is this card.
#
# ⚠️ AND `delinquent()` IS CALLED ONCE, OVER THE WHOLE ROSTER, BECAUSE
# THE CHECK IS A REDUCTION ACROSS UNITS AND NOT A PER-UNIT PREDICATE.
# What separates a wedged box from a legitimately saturated site is that
# the site's shortfall is a PROPER SUBSET of the units asked to deliver.
# Called per unit it would be a shortfall test, which is the saturation
# signal again. delivery.py carries the measurement: a pack at its own
# cut-out and a wedged unit produce IDENTICAL traces at the unit and at
# the meter, so nothing computed from one unit alone can tell them
# apart. At a roster of one the reduction is empty for every input.
#
# ⚠️ EVERY NUMBER BELOW IS THE CONFIGURED ONE, NEVER A LITERAL THAT
# HAPPENS TO EQUAL IT TODAY, AND THAT IS A SHIPPED PROMISE RATHER THAN
# A STYLE RULE. `delivery.MIN_W_IS_SATURATION_W` and config.yaml's
# `delivery_hold_s` block both tell an operator, in prose, that there is
# no fourth copy of a threshold to keep in step and no way for the two
# to drift - an operator who raises `saturation_w` has told the loop a
# bigger gap is ordinary tracking, and this check moves with them BY
# CONSTRUCTION. A literal here breaks exactly that promise, silently.
#
# Both of these were once literals that no check in this repository
# could see, because every fixture sat at the shipped value:
#
# self.tuning.saturation_w -> 250.0 (-19 B) - a survivor of all
# 23 suites, because part 4's
# gaps are far above BOTH
# bms_floor=inv.bms_soc_floor -> 10.0 (-13 B) - a survivor of all
# 23 suites, because every
# fixture's cut-out WAS 10 %
#
# test_delivery.py part 4b is what stands between this call and both of
# them: it grades this reduction at a `saturation_w` and at two
# `bms_soc_floor`s that are NOT the shipped ones, and its arms are
# answered opposite ways at two configured values, so no constant can
# pass. Change an argument here to a number and read that part before
# you decide the red is wrong.
for inv in self.inverters:
# ⚠️ `self.delivery_cfg`, NOT `DeliveryConfig()`. Same promise: the
# window is `delivery_hold_s`, and part 4b's neighbours already
# grade this one at a hold that is not the shipped 185 s.
inv.delivery_wd.arm(self.delivery_cfg)
_delinquent = delinquent(
[UnitDelivery(
inv.name, standing[inv.name], inv.batt,
delivery_account(
# ⚠️ `is_live()` AT ITS OWN DEFAULT WINDOW, which is
# `LINK_STALE_S` - the SAME call `live_units()` makes for
# the allocator. It has to be: what this branch means is
# "the allocator has already dropped this unit and said so",
# and a different window here would be this check answering
# a question about a roster the allocator did not build.
# It is the ONE number here with no option behind it, so
# "graded at a changed config" is not available for it;
# what part 6 grades instead is that WIDENING the window
# changes the answer.
live=inv.is_live(now)[0],
parked=inv.sign_wd.tripped,
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
# the site's `peak_soc_floor` either - that one bounds a
# money-saving discharge only, and grading a pack against it
# explains a wedged unit away in a band an ordinary install
# has ten points of.
soc=inv.soc, bms_floor=inv.bms_soc_floor))
for inv in self.inverters],
self.tuning.saturation_w)
for inv in self.inverters:
was_short = inv.delivery_wd.tripped
if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \
and not was_short:
# REASON FIRST, FLAG SECOND, and each `set` writes the file -
# the same atomicity ordering the three trips above use: a crash
# between the two must not persist a latch with no explanation
# for the operator who has to decide about clearing it.
#
# ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02
# HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback
# still finds its latch", and the version being rolled back TO
# drives exactly ONE actuator - a roster at which this check is
# inert by construction. A flat mirror would hand a downgraded
# add-on a latch taken on evidence about a roster it cannot have.
inv.store_set("delivery_reason", inv.delivery_wd.reason)
inv.store_set("delivery_tripped", True)
self._delivery_announced.discard(inv.name)
self.log_event(f"NOT DELIVERING {inv.name}: "
+ inv.delivery_wd.reason)
_LOG.error(
"SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED "
"DIFFERENTLY: this guard reports and does not act, because "
"commanding a unit that is not listening achieves nothing "
"(HW-01) and the only re-allocation this add-on can express "
"would throw away a partially-delivering unit's remaining "
"output. The site is short by that unit's share until "
"somebody looks at it. This survives an add-on AND a Home "
"Assistant restart.", inv.name, inv.delivery_wd.reason)
stale_after = float(self.o.get("stale_input_s", 15))
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
# does not become a fault; a LATCHED inverter must still reach the claim
@@ -2458,6 +2608,15 @@ class Controller:
# fresh trip instead of getting its own.
await self.check_circulating()
# --- SAFETY-42's alarm, and its boundary is the LATCH, like SAFETY-31's
# It grades `inv.delivery_wd.tripped`, which was advanced near the top
# of this method beside SAFETY-02 and SAFETY-31 and which nothing since
# has touched. Called on EVERY cycle rather than only on the cycle that
# trips, because its second job is re-raising the alarm after a restart
# into an existing trip - see its docstring on why that shares one code
# path with the fresh trip instead of getting its own.
await self.check_not_delivering()
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
# method assigned above and nothing since has touched.
@@ -2795,6 +2954,85 @@ class Controller:
f"know why two inverters were opposing."),
})
async def check_not_delivering(self) -> None:
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
⚠️ THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm.
`unit/<name>/delivery_tripped` in `/data/state.json` survives everything,
but nothing looks at a JSON file at 3 a.m. What the operator sees is a
`persistent_notification`, and THAT IS IN MEMORY: a Home Assistant
restart deletes it while the fault runs on. That is precisely how the
2026-09-02 outage went unseen for 6 h 25 m with the detector correct the
whole time (SAFETY-34), and it matters more here than anywhere else in
this file, because SAFETY-02 and SAFETY-31 at least leave a stopped site
for somebody to notice. This one leaves a site that looks like it is
working and is quietly delivering one unit less than it has.
So the durable half and the visible half are joined HERE, and the join
is `_delivery_announced`: a per-PROCESS set of unit names, deliberately
not persisted. The latch says "this unit is tripped"; membership says
"this process has put that in front of a human". A restart empties the
set and keeps the latch, so the notification is RE-RAISED on the first
cycle after every add-on start - which is also the first cycle after a
Home Assistant restart, because the add-on is restarted with it.
⚠️ ONE CODE PATH FOR BOTH, ON PURPOSE - `check_circulating()`'s
argument. A fresh trip and a restart into an existing trip raise the
alarm through these same lines; two paths would be two chances for the
restart one to rot unnoticed, and the restart one is the case nobody
exercises by hand.
⚠️ AND ONE NOTIFICATION ID PER UNIT. Every other alarm in this file has
a single fixed id because its subject is the site; this one's subject is
a named box, and two wedged inverters must produce two cards rather than
one replacing the other. The id is built from the same `unit_slugs()`
TEL-07 announces the MQTT entities under, so the card and the entity an
operator correlates it with carry the same token.
"""
names = self.units
slugs = unit_slugs(names)
for inv, slug in zip(self.inverters, slugs):
if not inv.delivery_wd.tripped or inv.name in self._delivery_announced:
continue
# Added BEFORE the await, for `check_circulating()`'s reason:
# `hass.call()` can raise or hang, and a retry every cycle for a
# broker that is down would be an alarm storm on the event bus. The
# latch is already durable and the log line already fired; this
# surface is best-effort by design.
self._delivery_announced.add(inv.name)
peers = ", ".join(n for n in names if n != inv.name) or "no sibling"
await self.hass.call("persistent_notification", "create", {
"notification_id": f"goodwe_not_delivering_{slug}",
"title": f"GoodWe: {inv.name} is not delivering its share",
"message": (
f"{inv.delivery_wd.reason}\n\nWHY NOTHING ELSE SAID SO. A "
f"unit sitting at 0 W against a non-zero share is not "
f"pushing the wrong way, it is ABSENT: the per-unit sign "
f"check needs opposition on both sides of "
f"{self.unit_sign_cfg.min_w:.0f} W and never opens, the "
f"site sum still agrees with the meter in "
f"direction, and circulating power is exactly zero when one "
f"unit does nothing. The only other trace is a saturation "
f"freeze, which this add-on's own code calls an alarm "
f"operators learn to ignore.\n\nNOTHING HAS BEEN COMMANDED "
f"DIFFERENTLY. This guard reports and does not act. "
f"Commanding an inverter that has stopped listening "
f"achieves nothing - that has happened on this hardware, "
f"once for 6 h 25 m - and the only re-allocation this "
f"add-on can express is all-or-nothing, which would throw "
f"away the output of a unit that is delivering PART of its "
f"share. So {peers} is still regulating and the site is "
f"short by up to {inv.rating_w():.0f} W until somebody "
f"looks at {inv.name}.\n\nWHAT TO CHECK, in order: is "
f"{inv.name} answering on RS485 at all; is its pack colder "
f"or emptier than its configured bms_soc_floor says (if it "
f"is, that setting does not describe this battery and this "
f"alarm is telling you so); and is anything else writing to "
f"it.\n\nThis alarm survives a Home Assistant restart. "
f"Restarting will not clear it; start control from the "
f"add-on UI once you know why."),
})
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
@@ -3557,6 +3795,49 @@ class Controller:
+ (f" - now {_circ:.0f} W" if _circ else "")
+ (f", opposed for {held:.0f} s" if held else "")})
# --- SAFETY-42 -------------------------------------------------------
# ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE ONE PLACE THE
# "reports, does not act" DECISION IS VISIBLE TO AN OPERATOR. Every
# other latched row in this list means the site is at 0 W and a red
# cross is the truth. This one means the site is STILL RUNNING and is
# quietly short by one unit's share; rendering it as a hard failure
# would say the controller had stopped, which it has not. It is not "ok"
# either - `ok: False` with `warn: True` is this list's caution, and a
# caution is what a fault nobody is acting on actually is.
if len(self.inverters) > 1:
for inv in self.inverters:
tag = f" ({inv.name})"
if inv.delivery_wd.tripped:
out.append({"ok": False, "warn": True,
"text": f"NOT DELIVERING{tag} - "
+ inv.delivery_wd.reason
+ ". Nothing has been commanded differently and "
"the rest of the roster is still regulating, "
"so the site is short by this unit's share. "
"Starting control clears this row; restarting "
"the add-on does NOT, and neither does "
"restarting Home Assistant."})
else:
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False,
"text": f"delivery watchdog{tag}: armed (reports after "
f"{inv.delivery_wd.cfg.hold_s:.0f} s short of "
f"its share by over "
f"{self.tuning.saturation_w:.0f} W, with a "
f"sibling that is not)"
+ (f" - short for {_dheld:.0f} s now"
if _dheld else "")})
else:
# AC 5, and the same honesty `circulating power: n/a` uses one block
# up. At one inverter the reduction this check is has no proper
# subset to find, for EVERY input, so "armed" would be a claim to be
# watching something that cannot happen.
out.append({"ok": True, "warn": False,
"text": "delivery watchdog: n/a at one inverter - a unit "
"short of its share is indistinguishable from a "
"plant that cannot deliver until there is a "
"sibling to compare it with"})
if not o.get("estop_fitted", False):
out.append({"ok": False, "warn": True,
"text": "no e-stop fitted: if this host dies the battery stays "
@@ -3616,6 +3897,26 @@ class Controller:
level, banner = "bad", f"NOT READY — {bad[0].get('banner') or bad[0]['text']}"
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
elif any(i.delivery_wd.tripped for i in self.inverters):
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
# Every `bad` banner above means the site is at 0 W; this one means
# the site is STILL RUNNING and quietly short by one unit's share,
# and a red headline would say the controller had stopped.
#
# ⚠️ BUT IT MUST DISPLACE "Running — holding grid at zero", AND THAT
# IS NOT COSMETIC. That sentence is a claim the site cannot make
# while a unit is absent - the loop is saturated and the meter is
# short - and "the page reads healthy while the fault runs" is
# precisely the shape this whole card exists to remove. It sits
# BELOW "Stopped" because a stopped site is the more urgent fact and
# the banner carries one sentence.
_nd = ", ".join(i.name for i in self.inverters
if i.delivery_wd.tripped)
level, banner = "warn", (
f"NOT DELIVERING — {_nd} is standing at a share it is not "
f"producing. The site is still running and short by it; "
f"nothing has been commanded differently.")
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
# of one this is the string it has always been, character for
@@ -3766,6 +4067,27 @@ class Controller:
self.store.set("circ_reason", "")
self.log_event("circulating-power trip cleared by operator")
self._circ_announced = False
# SAFETY-42, cleared by the SAME press, and for a reason that is
# NOT the one above. The other three latches are cleared here
# because their trip removed its own evidence - a site commanding
# 0 W cannot oppose anything - so only a human can say the fault
# is gone. This one commands nothing, so its evidence is still
# standing and it could in principle clear itself. It does not:
# a unit that stopped delivering for minutes and then resumed is
# an INTERMITTENT fault, and a detector that erased its own
# record of that would be the least useful thing in this file.
# ⚠️ AND `_delivery_announced` GOES WITH IT, like
# `_circ_announced`: leaving a name in it through a clear would
# mean the NEXT trip on that unit latched and raised no
# notification at all.
for inv in self.inverters:
if inv.delivery_wd.tripped:
self.log_event(f"not-delivering report on {inv.name} "
f"cleared by operator")
inv.delivery_wd.reset()
inv.store_set("delivery_tripped", False)
inv.store_set("delivery_reason", "")
self._delivery_announced = set()
if not self.auto:
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see command_site_zero().
# Zeroing `self.target` alone would leave each unit's last share
+57 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.7.0"
version: "0.8.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -511,6 +511,52 @@ options:
circulation_w: 700
circulation_hold_s: 75
# ⚠️ CFG-03. THIS LINE IS THE SOURCE OF TRUTH FOR THIS DEFAULT, and
# `app/delivery.py`'s `DELIVERY_OPTION_DEFAULTS` is the ONE in-code copy -
# the Dockerfile does not put this file in the image, so the running add-on
# cannot read it. test_delivery.py pins the copy by READING this line, so a
# drift goes red in CI rather than shipping. Two places - options:, schema: -
# plus that one dict, and no fourth.
#
# ⚠️ THERE IS NO `delivery_w` TO GO WITH IT, AND THAT IS DELIBERATE. The watts
# half of this check is `saturation_w`, the option two blocks down, read
# directly: it is already the gap at which the control law itself concludes
# the inverter is not following, and a second key would be a fourth place a
# threshold has to be kept in step and a way for the two to disagree. So
# RAISING `saturation_w` raises this check with it, and test_delivery.py part
# 4b holds that promise by running the same fault at two different
# `saturation_w` values and requiring two different answers - a check that
# only ever ran at 500 could not tell this wiring from a hard-coded number.
#
# How long ONE inverter may stand at a discharge share it is not producing -
# short by more than `saturation_w` - while at least one sibling is NOT in
# that state, before the add-on names it. It REPORTS: nothing is commanded
# differently, the loop is untouched, and the site goes on running short by
# that unit's share until somebody looks. app/delivery.py carries why, with
# the rig measurements on both sides of the decision.
#
# Ignored at one inverter, where the check is inert by construction: with no
# sibling, "this box cannot deliver" and "this plant cannot deliver" are the
# same observation.
# ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs
# at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the
# whole range this schema accepts - across load reversals, a flapping load and
# ASYMMETRIC SPLITS, plus 1890 runs at a roster of ONE as the control. At the
# shipped threshold the worst CONTINUOUS legitimate episode is 30.0 s (the
# weighted family; 20.0 s on reversals, 0.0 s on flapping, and 0.0 s at every
# threshold in all 1890 one-inverter runs). 185 s is 6.2x that.
#
# ⚠️ AND THE MARGIN IS SIZED ON A MEASUREMENT, NOT ON A FEELING, BECAUSE
# INVERTER B DOES NOT PHYSICALLY EXIST. The rig's B is A copied. Sweeping a
# DIFFERENT second unit through that campaign: the worst legitimate episode is
# insensitive to B's ramp (30.0 s at half A's ramp) and scales with its LAG -
# 45.0 s at twice A's measured 10 s lag, 90.0 s at four times it. 185 s is
# 2.06x even the four-times-slower case.
#
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
# and not 150 or 200. 37 control cycles.
delivery_hold_s: 185
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
@@ -772,6 +818,16 @@ schema:
# loop must be caught and acted on well inside one.
circulation_hold_s: int(30,900)
# SAFETY-42. The floor of 30 is `inverter.LINK_STALE_S`, for
# `circulation_hold_s`'s reason and the same one SAFETY-30's per-unit window
# takes it from: below the firmware's own veto window a unit not executing its
# share is a LINK fault the add-on already answers by dropping it from the
# split and saying so in the action log, and reporting inside that window
# would be a second, louder alarm about a state already being handled. The
# ceiling of 900 is the quarter that bills the capacity tariff - a site
# running short of one unit's share must be reported well inside one.
delivery_hold_s: int(30,900)
# ==========================================================================
# ADVANCED - the control law and diagnostics
# ==========================================================================
+477 -12
View File
@@ -17,8 +17,9 @@ Read MUTATION.md before you report a survivor.
import sys
from app.control import (LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC,
Tuning, compute, slew_limit_w,
from app.control import (LOOP_TICK_S, PLANT_SETTLE_DOWN_S, PLANT_SETTLE_S,
SETTLE_CYCLES, SLEW_RAIL_FRAC, Tuning, compute,
slew_limit_w, unasked_power,
maintenance_charge_floor, peak_at_risk)
# ⚠️ SAFETY-39: `slew_w` IS EXPLICIT HERE NOW, AND IT IS release/1.0's VALUE.
@@ -59,6 +60,34 @@ def check(name, cond):
# A comment cannot go stale-green, because it never claims to be running.
#
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
# ⚠️ THE SAFETY-13 ROWS WERE RE-MEASURED IN ROUND 2 AND THREE OF THEM WERE
# WRONG AS FIRST WRITTEN. Every figure below marked (S13) was taken by deleting
# the mechanism, purging __pycache__, running this file in the CI image and
# counting `FAIL` lines, on THIS tree; the release/1.0 comparison figures were
# taken the same way on a pristine c8dcb38 worktree.
#
# - `output freeze` was written as 11. The mutation form its own note
# describes - the whole `target = min(...)/max(...)` line removed, `reason`
# left alone - gives 6 on this tree AND 6 on the first SAFETY-13 commit,
# against 2 on release/1.0. 11 is not reproducible by that form, or by
# removing the line and its `reason` (4), or by swapping min/max (12). The
# row is 6.
# - `saturation detector, saturated_now = False` was left at 11 while the
# SAFETY-13 section added checks that grade it. It is 15.
# - the footnote's `frozen = False` figure was left at 10 for the same
# reason. It is 13.
#
# The rows below `output freeze` are this ticket's own mechanisms: removing the
# EXIT restores release/1.0's unbounded freeze; making `unasked_power()` return
# True gives an exit to the limit case as well, which is the half of the
# decision that has to stay unbounded; deriving SETTLE_CYCLES from
# PLANT_SETTLE_S (18 s, the UP ramp) instead of PLANT_SETTLE_DOWN_S (23 s) is
# the round-1 defect itself; and pinning `_SAT_COUNT_CAP` back at release/1.0's
# literal 10 caps the counter below the bound and deletes the exit silently.
#
# ⚠️ ONLY THE ROWS THIS BRANCH MOVES WERE RE-MEASURED. Everything not marked
# (S13) still carries 389d9ec's figures and was not re-run here.
#
# ⚠️ THE SLEW ROWS WERE RE-MEASURED AT SAFETY-39 AND ONE OF THEM MOVED: the
# slew limit was 4 and is now 5, because the SAFETY-39 section grades it too.
# The new row is the RAIL DEFAULT specifically - `slew_limit_w()` reduced to
@@ -75,10 +104,14 @@ def check(name, cond):
# output clamp 3
# slew limit 5
# slew follows the rail (SAFETY-39), `slew_w` read raw 3
# output freeze 2
# output freeze (S13) 6
# the output freeze's EXIT (S13), freeze made unconditional again 4
# `unasked_power()` discriminates, replaced by `return True` (S13) 6
# SETTLE_CYCLES from PLANT_SETTLE_S, not _DOWN_S (S13) 9
# `_SAT_COUNT_CAP` pinned back at release/1.0's 10 (S13) 6
# deadband 5
# quantisation 2
# saturation detector, `saturated_now = False` 11
# saturation detector, `saturated_now = False` (S13) 15
# saturation duration (AC 2), fires instantly 2
# sat counter reset on a good cycle 6
# target_grid_w bias 3
@@ -86,7 +119,8 @@ def check(name, cond):
#
# The detector figure is for the `saturated_now = False` form specifically;
# disabling it further down as `frozen = False` is a weaker mutation and gives
# 10. Reproduce the same form or the number will not match.
# 13 on this tree (10 on release/1.0). Reproduce the same form or the number
# will not match.
#
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
# for the mechanism under test. Deleting the integrator freeze once failed
@@ -282,8 +316,15 @@ check("frozen at i_w=0.0: an export push moves the integrator",
# hundreds of watts means something else is driving the bus. Asserted so that
# nobody "fixes" it by accident - the integrator moving is what this ticket
# owns, the command rule belongs to the output freeze.
#
# ⚠️ SAFETY-13 KEPT THIS ASSERTION RATHER THAN INVERTING IT, and the fixture is
# why it survived unchanged: `sat_count=3` becomes 4, which is below
# SETTLE_CYCLES, so this is still inside the window in which the block holds.
# SAFETY-13 gave the block an EXIT, not a different opinion about its first
# cycles - see the SAFETY-13 section below, where the same state is driven past
# the bound and the release is asserted on the other side of it.
check("frozen at i_w=0.0: the output freeze still blocks a charge from 0 W",
ze.target_w == 0.0)
ze.target_w == 0.0 and ze.sat_count < SETTLE_CYCLES)
# Where prev_w is already charging the output freeze does NOT block, and there
# the difference reaches the wire: held at 0.0 the integrator abandons the
# charge mid-export.
@@ -358,6 +399,348 @@ check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <=
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
loose > tight)
print("SAFETY-13: the output freeze has an exit, and only where it needs one")
# THE SHAPE OF THIS SECTION. release/1.0's output freeze holds the command's
# magnitude down while the inverter is not following, and nothing ends it. At
# `prev_w == 0` the held magnitude is ZERO, so it reads as "refuses to start a
# charge"; that is the case the card was written about, but it is one instance
# of a rule that is unbounded at every prev_w. SAFETY-13 bounds it exactly where
# it CANNOT end on its own - see `unasked_power()` - and nowhere else.
# --- 1. the predicate, which is the whole of the decision -------------------
# A limit can only make the inverter do LESS than the command, in the command's
# own direction, so everything a limit can produce lies in the interval spanned
# by 0 and prev_w widened by saturation_w. These are the four cases in the
# docstring, run rather than described.
check("limit: 700 W under a +2000 W command is explainable",
not unasked_power(2000.0, 700.0, 500.0))
check("limit: -700 W under a -5000 W command is explainable",
not unasked_power(-5000.0, -700.0, 500.0))
check("unasked: +700 W under a 0 W command is not",
unasked_power(0.0, 700.0, 500.0))
check("unasked: +700 W under a -800 W command is not",
unasked_power(-800.0, 700.0, 500.0))
# The band edges belong to the limit side, because saturation_w is the same
# tolerance the detector uses - a reading the detector calls "following" must
# never be called "another writer".
check("the saturation band is inclusive on both edges",
not unasked_power(0.0, 500.0, 500.0)
and not unasked_power(0.0, -500.0, 500.0)
and unasked_power(0.0, 500.01, 500.0))
# ⚠️ AT prev_w == 0 THE PREDICATE AND THE DETECTOR ARE THE SAME TEST, which is
# why the zero case is the one that strands: with nothing commanded there is no
# interval for a limit to live in.
same = all(unasked_power(0.0, a, 500.0) == (abs(a) > 500.0)
for a in [x * 5.0 for x in range(-400, 401)])
check("at prev_w == 0 unasked_power IS the saturation test, over 801 readings",
same)
# --- 2. the bound is derived, and from WHICH HALF of the measurement --------
# ⚠️ THE DIRECTION IS THE ASSERTION. goodwe/NOTES.md 5.10 measured the same
# 1.78 kW step both ways: ~18 s up, ~23 s down. SAFETY-39 takes the SHORTER
# one because its bound is a REFUSAL and the looser number refuses less. This
# bound asks the opposite question - how long the plant may still be the
# explanation for a divergence - and for that the conservative half is the
# LONGER one. The first version of this file derived it from 18, got 9, and
# that is three cycles short of the plant's own measured down-ramp, in the
# direction that ACCUSES rather than refuses (section 8 below drives it).
check(f"SETTLE_CYCLES is ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) ="
f" {SETTLE_CYCLES}",
SETTLE_CYCLES == 12
and SETTLE_CYCLES * LOOP_TICK_S >= PLANT_SETTLE_DOWN_S
and (SETTLE_CYCLES - 1) * LOOP_TICK_S < PLANT_SETTLE_DOWN_S)
check("...from the LONGER of the two measured settle times, so it is strictly"
" above what the shorter one would have given",
PLANT_SETTLE_DOWN_S > PLANT_SETTLE_S
and SETTLE_CYCLES > -(-PLANT_SETTLE_S // LOOP_TICK_S))
# ...and it is NOT SLEW_RAIL_FRAC's reciprocal any more. It was, while both
# were being derived from the same half of the pair, and that coincidence read
# as a derivation. Asserted as an INEQUALITY so the two cannot silently be
# re-fused into one number again.
check("the slew's rail crossing and this bound are now different numbers",
SETTLE_CYCLES > 1.0 / SLEW_RAIL_FRAC)
# The counter has to be able to REACH the bound or the exit does not exist.
# Driven, not read off the constant: run a saturated state and see how high the
# counter actually goes.
c, sc = 0, 0
while c < 50:
sc = compute(0.0, -2000.0, 900.0, T, sc).sat_count
c += 1
check(f"the saturation counter reaches {sc}, which is above the bound",
sc >= SETTLE_CYCLES)
# --- 3. prev_w == 0: blocked below the bound, released above it -------------
# The same state either side of the bound. This is the pair the card asks for.
blk = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
check("prev_w=0: below the bound the charge is still refused",
blk.target_w == 0.0 and blk.reason == "saturated-freeze"
and blk.sat_count == SETTLE_CYCLES - 1)
rel = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T,
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
check(f"prev_w=0: at the bound it is released to {rel.target_w:.0f} W",
rel.target_w < 0.0 and rel.reason == "freeze-released"
and rel.sat_count == SETTLE_CYCLES)
# ...and the released command is the SLEW step from 0, not the rail. A release
# that jumped to the integrator in one cycle would be the windup this ticket
# was told to avoid.
check(f"the released command is exactly one slew step"
f" ({slew_limit_w(T):.0f} W)",
abs(rel.target_w) == slew_limit_w(T))
# The mirror. Nothing here is sign-specific, so the discharge side must behave
# identically - every freeze rule in this file has a mirror, because the one
# that did not is the defect that got through review.
mblk = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
sat_count=SETTLE_CYCLES - 2, i_w=0.0)
mrel = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T,
sat_count=SETTLE_CYCLES - 1, i_w=0.0)
check("prev_w=0, discharging: same block, same release, mirrored",
mblk.reason == "saturated-freeze" and mrel.reason == "freeze-released"
and mrel.target_w == slew_limit_w(T))
# --- 4. non-zero prev_w, and the two halves are NOT the same --------------
# (a) A divergence a LIMIT could have produced is never released, however long
# it stands. This is release/1.0's behaviour and it is kept deliberately: the
# freeze clears itself here, because the command is pinned at something the
# inverter can do.
TL = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0)
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
for _ in range(30):
d = compute(prev, 4000.0, max(-1000.0, min(1000.0, prev)), TL, sat, i_w)
prev, i_w, sat, _r = d.target_w, d.i_w, d.sat_count, reasons.add(d.reason)
check("non-zero prev_w, a curtailed inverter: never released over 30 cycles",
"freeze-released" not in reasons and prev == 2400.0)
# (b) A divergence a limit could NOT have produced is released at the bound,
# at a non-zero prev_w exactly as at zero. release/1.0 pinned this run at
# -800 W, against its own integrator standing at -2388 W, for as long as it ran.
prev, i_w, sat, rows = 0.0, 0.0, 0, []
for _ in range(20):
d = compute(prev, -2000.0, 700.0, TL, sat, i_w)
rows.append((prev, d.target_w, d.i_w, d.reason))
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
pinned = [r for r in rows if r[3] == "saturated-freeze"]
check(f"non-zero prev_w, another writer: pinned at {pinned[0][1]:.0f} W...",
pinned and all(r[1] == -800.0 for r in pinned))
check(f"...then released, settling at {prev:.0f} W", prev == -2390.0)
# --- 5. THE CARD'S OWN CASE, DRIVEN END TO END -----------------------------
# The live install's shape (max_w 5000, slew_w 400, target_grid_w -10). The site
# rests in the deadband with another writer holding the pack at +700 W - so the
# saturation counter climbs while the command is 0 and nothing is wrong - and
# then 2 kW of export arrives. On release/1.0 @ c8dcb38 the command is 0.0 W on
# every cycle from there on: 0 W is a FIXED POINT, and the export is given away
# for as long as the other writer stays.
prev, i_w, sat, cmds = 0.0, 0.0, 0, []
for c in range(24):
d = compute(prev, -10.0 if c < 6 else -2000.0, 700.0, TL, sat, i_w)
cmds.append(d.target_w)
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
check("the card's case: the block does hold at first, at 0 W",
cmds[6] == 0.0 and cmds[7] == 0.0)
started = [i for i, c in enumerate(cmds) if c != 0.0]
check(f"the card's case: the charge starts at cycle {started[0]}"
f" instead of never", bool(started))
# ⚠️ AND IT STOPS AT THE FROZEN INTEGRATOR, NOT AT THE RAIL. This is AC 4: the
# integrator moved while the output was blocked, and the release must not turn
# that divergence into windup. It cannot, because releasing the OUTPUT freeze
# does not release the INTEGRATOR freeze - so the command can only walk, one
# slew step per cycle, to a value the integrator stopped accumulating at.
check(f"the card's case: it settles at {prev:.0f} W, at the frozen integrator "
f"({i_w:.0f} W) and not the {TL.max_w:.0f} W rail",
prev == -1190.0 and abs(prev) <= abs(i_w) and abs(prev) < TL.max_w)
steps = [abs(cmds[i] - cmds[i - 1]) for i in range(1, len(cmds))]
check(f"the card's case: no cycle moves more than the slew limit "
f"(worst {max(steps):.0f} W of {slew_limit_w(TL):.0f})",
max(steps) <= slew_limit_w(TL))
# --- 6. AC 4, AS A PROPERTY RATHER THAN A TRAJECTORY -----------------------
# ⚠️ THE FIRST FORM OF THIS CHECK WAS WRONG AND THE SWEEP SAID SO, which is
# worth leaving in the file. It asserted `|target| <= |i_w|` on a released
# cycle, and the sweep produced prev_w -5000, i_w -3000, target -4600: the
# command was walking BACK from -5000 toward the integrator and had not
# arrived. It was moving the right way and the assertion still failed, because
# "never exceeds the integrator" is not what no-windup means here.
#
# What it means: a released cycle can only move the command TOWARD the
# integrator, by at most one slew step, and never past it. So the divergence
# that built up while the output was blocked cannot arrive as a jump - it is
# spent one slew step at a time, against an integrator that stopped
# accumulating when the freeze engaged and is not released with the output.
# ⚠️ AND THE COUNT BELOW IS THE RELEASED STATES, NOT THE SWEPT ONES. The first
# version of this check named 41*25*21*5 - the size of the sweep - while
# grading only the subset that actually releases, so the number in the message
# was not the number the assertion was about. It is counted now.
step = float(T.step_w)
swept, past, jumped = 0, [], []
n_released = 0
for prev in [x * 250.0 for x in range(-20, 21)]:
for grid in [x * 500.0 for x in range(-12, 13)]:
for actual in [x * 500.0 for x in range(-10, 11)]:
for i0 in (-3000.0, -500.0, 0.0, 500.0, 3000.0):
swept += 1
d = compute(prev, grid, actual, TL, SETTLE_CYCLES, i0)
if d.reason != "freeze-released":
continue
n_released += 1
lo, hi = min(prev, d.i_w), max(prev, d.i_w)
if not (lo - step <= d.target_w <= hi + step):
past.append((prev, grid, actual, i0, d.target_w, d.i_w))
if abs(d.target_w - prev) > slew_limit_w(TL) + step:
jumped.append((prev, grid, actual, i0, d.target_w))
check(f"of {swept} states swept, the {n_released} that release keep the command"
f" between the previous command and the frozen integrator"
+ (f" (e.g. {past[0]})" if past else ""), not past and n_released > 0)
check("...and no released cycle moves further than one slew step"
+ (f" (e.g. {jumped[0]})" if jumped else ""), not jumped)
# --- 7. NO ACCEPTED CONFIGURATION CAN DELETE THE BLOCK ---------------------
# ⚠️ THIS CHECK REPLACES ITS OWN OPPOSITE, AND THE REASON IS WORTH KEEPING.
# While the bound was 9, `saturation_cycles` - which config.yaml accepts up to
# 10 - could be set AT OR ABOVE it, and then the freeze did not engage until
# its own exit was already due: the no-start block silently stopped existing,
# and this file asserted that as a known consequence. Moving the bound to 12
# put the whole accepted range strictly below it, so the degenerate
# configuration is gone rather than documented.
#
# It is pinned against config.yaml ITSELF and not a transcription, because the
# property is "two files agree" and a transcription is what lets them drift.
# Regex, not PyYAML: the add-on image ships no PyYAML (test_validate.py says
# the same thing at more length).
import os # noqa: E402
import re # noqa: E402
_CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
with open(_CFG, encoding="utf-8") as _fh:
_m = re.search(r"^ saturation_cycles: int\((\d+),(\d+)\)\s*$",
_fh.read(), re.M)
check("config.yaml's saturation_cycles range really was read", _m is not None)
_sc_lo, _sc_hi = int(_m.group(1)), int(_m.group(2))
check(f"every accepted saturation_cycles ({_sc_lo}..{_sc_hi}) is strictly"
f" below the bound ({SETTLE_CYCLES}), so no accepted configuration can"
f" push the freeze's engagement past its own exit",
_sc_hi < SETTLE_CYCLES)
# Driven at the schema's edge rather than argued: the block still engages, and
# it engages before the exit is due.
TN = Tuning(slew_w=1000.0, saturation_cycles=_sc_hi)
prev, i_w, sat, reasons = 0.0, 0.0, 0, set()
first_freeze = None
for _ in range(20):
d = compute(prev, -2000.0, 700.0, TN, sat, i_w)
reasons.add(d.reason)
if d.reason == "saturated-freeze" and first_freeze is None:
first_freeze = d.sat_count
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
check(f"at the largest accepted saturation_cycles ({_sc_hi}) the block still"
f" engages, at sat_count {first_freeze}, below the bound",
"saturated-freeze" in reasons and first_freeze is not None
and first_freeze < SETTLE_CYCLES)
# --- 8. THE BOUND'S DIRECTION, DRIVEN ON ONE INVERTER ----------------------
# ⚠️ WHY THIS SECTION EXISTS. The exit's two conditions are read as "another
# writer is on the actuator". They are not that, and the first shipped form of
# this file said they were. `unasked_power()` compares a reading against ONE
# cycle's command while the pack carries every command before it, so a command
# that has fallen THROUGH ZERO - or simply fallen faster than the pack coasts -
# leaves our own power outside our own interval. With the bound derived from
# the 18 s UP measurement that is not a corner case: it is what a normal load
# step does.
#
# The plant here is a MODEL and is labelled one. It is a first-order lag whose
# two time constants come from the two measured settling times (a first-order
# step is 95 % done at 3 tau, so tau = t / 3), with the ramp cap
# sim/plantsim.py measured on inverter A. It is not the rig and it is not the
# house; what it is for is that the two settling times ENTER the model in the
# same direction they enter the constant, so the check moves if either does.
#
# ⚠️ AND THERE IS NO SECOND WRITER IN THIS SECTION. One `compute()`, one
# plant, one command. Every `freeze-released` below is therefore false.
import math # noqa: E402
class _Plant:
"""One inverter, obeying our command and nothing else. A MODEL."""
RAMP_W_S = 800.0 # sim/plantsim.py INVERTER_A, measured=True
def __init__(self, up_s, down_s):
self.a_up = 1.0 - math.exp(-LOOP_TICK_S / (up_s / 3.0))
self.a_dn = 1.0 - math.exp(-LOOP_TICK_S / (down_s / 3.0))
self.w = 0.0
def tick(self, cmd):
a = self.a_up if abs(cmd) > abs(self.w) else self.a_dn
cap = self.RAMP_W_S * LOOP_TICK_S
self.w += max(-cap, min(cap, a * (cmd - self.w)))
from app import control as _control # noqa: E402
TS8 = Tuning(max_w=5000.0, slew_w=400.0, target_grid_w=-10.0,
saturation_w=500.0, saturation_cycles=3)
def _drive(bound, load1, load2, cycles=60, switch=25):
"""Run one closed loop at an arbitrary bound. -> (release cycles, rows)."""
was_b, was_c = _control.SETTLE_CYCLES, _control._SAT_COUNT_CAP
# ⚠️ BOTH, OR THE PROBE LIES: `_SAT_COUNT_CAP` is what `compute()` caps the
# counter with, so moving the bound without it caps the counter BELOW the
# bound and the exit quietly becomes unreachable - which reads as "no false
# positives". That is the hazard `_SAT_COUNT_CAP`'s own comment names.
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = bound, bound + 1
try:
p = _Plant(PLANT_SETTLE_S, PLANT_SETTLE_DOWN_S)
prev, i_w, sat, rows = 0.0, 0.0, 0, []
for c in range(cycles):
load = load1 if c < switch else load2
d = compute(prev, load - p.w, p.w, TS8, sat, i_w)
rows.append((c, prev, p.w, d.target_w, d.sat_count, d.reason))
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
p.tick(prev)
return [r for r in rows if r[5] == "freeze-released"], rows
finally:
_control.SETTLE_CYCLES, _control._SAT_COUNT_CAP = was_b, was_c
_SHORT = int(-(-PLANT_SETTLE_S // LOOP_TICK_S)) # what 18 s would give: 9
# A 4 kW export falling to 2.5 kW. Nothing exotic; no second writer.
_bad, _rows = _drive(_SHORT, -4000.0, -2500.0)
check(f"a bound derived from the SHORTER measurement ({_SHORT} cycles) fires"
f" the exit on ONE inverter with nothing else on the bus"
+ (f" - {len(_bad)} cycle(s), first at cycle {_bad[0][0]}" if _bad else ""),
bool(_bad))
if _bad:
_c, _prev, _act, _tgt, _sat, _ = _bad[0]
check(f"...and what it called unasked is our own coast-down: command"
f" {_prev:.0f} W, pack still at {_act:.1f} W, and the pack was"
f" commanded {min(r[1] for r in _rows[:_c]):.0f} W a few cycles"
f" earlier",
unasked_power(_prev, _act, TS8.saturation_w)
and abs(_act) > abs(_prev)
and min(r[1] for r in _rows[:_c]) < _act)
_good, _ = _drive(SETTLE_CYCLES, -4000.0, -2500.0)
check(f"the shipped bound ({SETTLE_CYCLES} cycles, the LONGER measurement)"
f" does not fire on the same run", not _good)
# ...and not on any of these, either. The whole grid of load steps the same
# plant can be given, at the tuning every headline figure in this ticket is
# quoted from.
_L = [x * 500.0 for x in range(-8, 9)]
_fired = {}
for _b in (_SHORT, SETTLE_CYCLES):
_fired[_b] = sum(1 for _a in _L for _z in _L
if _a != _z and _drive(_b, _a, _z)[0])
_pairs = len(_L) * (len(_L) - 1)
check(f"over {_pairs} load-step pairs on the measured plant, one inverter and"
f" no second writer: the short bound fires in {_fired[_SHORT]} of them,"
f" the shipped bound in {_fired[SETTLE_CYCLES]}",
_fired[SETTLE_CYCLES] < _fired[_SHORT])
# ⚠️ AND THE SHIPPED FIGURE IS NOT ZERO IN GENERAL. It is small here because
# the plant in this model is the one that was measured. A plant slower than
# that brings the false release back, and no constant derived from a
# measurement can fix that - it is why DOCS.md tells the operator what
# `freeze-released` is evidence OF, rather than what it proves.
print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
@@ -394,8 +777,37 @@ def legacy(prev, grid, actual, t, sat_count):
# "i-clamped", because the truncation happens on the integrator before the
# command is derived from it. Aliased here rather than papered over - if any
# OTHER reason ever diverges, this check goes red.
# ⚠️ SAFETY-13 IS A DELIBERATE DIVERGENCE FROM `legacy()`, AND THE SWEEP NOW
# GRADES THE DIVERGENCE INSTEAD OF FORBIDDING IT. `legacy()` above stays a
# transcription of release/1.0 - it is not edited to agree, for the reason the
# note on `slew_w` gives: an oracle taught the new rule compares the new law
# against itself. What changed is the assertion. Every case must either be
# IDENTICAL to release/1.0, or be a case where release/1.0 said
# "saturated-freeze" and this law says "freeze-released" with the two conditions
# that permit it both true. Anything else is a regression, and the counts below
# say how much of the sweep each answer covers.
ALIAS = {"i-clamped": "clamped"}
# ⚠️ SAFETY-13 ROUND 2 MOVED THE COUNTER'S CEILING, SO THE SWEEP NOW HAS A
# THIRD ANSWER. `_SAT_COUNT_CAP` is `SETTLE_CYCLES + 1`, which was 10 - the
# same literal release/1.0 used - and is now 13. Above release/1.0's own
# ceiling the two counters therefore differ, and NOTHING ELSE MAY: `frozen` is
# `sat_count >= saturation_cycles` and config.yaml accepts that at most 10, so
# a counter reading 13 instead of 10 cannot change it, and no other line in
# `compute()` reads the counter's value. That is asserted rather than asserted-
# by-omission, in `cap_diffs` below.
#
# ⚠️ AND THE SWEEP HAS TO REACH THE BOUND OR IT GRADES NOTHING. `sc` runs to 12
# now; at 9 the counter tops out one short of the exit and every release
# silently disappears from this table, which is exactly what happened when the
# bound moved and is why the count is asserted and not merely printed. 12 is a
# state release/1.0 could never hold - its counter stopped at 10 - so
# `legacy()` is being fed one input outside its own reachable domain, on
# purpose, and `cap_diffs` is where that shows up.
LEGACY_SAT_CAP = 10 # release/1.0's literal, transcribed in legacy()
diffs = []
released = []
value_diffs = []
cap_diffs = []
seen = set()
# ⚠️ `slew_w` IS WRITTEN OUT IN ALL THREE (SAFETY-39). `legacy()` below is a
# TRANSCRIPTION of release/1.0 and reads `t.slew_w` directly, as release/1.0
@@ -409,16 +821,69 @@ for tune in (Tuning(slew_w=1000.0), Tuning(slew_w=1000.0, target_grid_w=-10.0),
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
for grid in (-6000.0, -1000.0, -500.0, -14.0, 0.0, 14.0, 500.0, 1000.0, 6000.0):
for actual in (-2000.0, 0.0, 600.0, 2000.0):
for sc in (0, 2, 3, 9):
for sc in (0, 2, 3, 9, 12):
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
seen.add(d.reason)
got = (d.target_w, d.sat_count, d.frozen,
ALIAS.get(d.reason, d.reason))
if got != legacy(prev, grid, actual, tune, sc):
diffs.append((prev, grid, actual, sc, got,
legacy(prev, grid, actual, tune, sc)))
check(f"i_w=None reproduces release/1.0 over {3*7*9*4*4} cases, reason included"
+ (f" (first diff {diffs[0]})" if diffs else ""), not diffs)
was = legacy(prev, grid, actual, tune, sc)
if got == was:
continue
# everything that reaches the wire, without the counter
wire_same = (got[0], got[2], got[3]) == (was[0], was[2], was[3])
if wire_same:
# the ceiling moved and nothing else did. Only legal
# above release/1.0's own cap, and only with both
# counters standing at their own ceilings.
if (sc >= LEGACY_SAT_CAP
and was[1] == LEGACY_SAT_CAP
and got[1] == _control._SAT_COUNT_CAP):
cap_diffs.append((prev, grid, actual, sc, got[1], was[1]))
continue
diffs.append((prev, grid, actual, sc, got, was))
continue
explained = (
d.reason == "freeze-released"
and was[3] == "saturated-freeze"
and d.sat_count >= SETTLE_CYCLES
and unasked_power(prev, actual, tune.saturation_w))
if not explained:
diffs.append((prev, grid, actual, sc, got, was))
continue
released.append((prev, grid, actual, sc))
if got[0] != was[0]:
value_diffs.append((prev, got[0], was[0]))
TOTAL = 3 * 7 * 9 * 4 * 5
check(f"i_w=None reproduces release/1.0 over {TOTAL} cases except where"
f" SAFETY-13 releases the freeze, reason included"
+ (f" (first unexplained diff {diffs[0]})" if diffs else ""), not diffs)
# ⚠️ EXACT COUNTS, NOT INEQUALITIES. A weaker form of this line ("some
# releases happened") was written while the bound was moving, and the coverage
# audit caught it immediately: a `unasked_power()` that stops discriminating
# went from 6 checks to 5. The three numbers partition the sweep, so any one of
# them moving is a change somebody has to explain.
check(f"{TOTAL - len(released) - len(cap_diffs)} of {TOTAL} cases are"
f" bit-identical to release/1.0; {len(released)} are releases;"
f" {len(cap_diffs)} differ in the saturation counter alone",
len(released) == 432 and len(cap_diffs) == 81
and TOTAL - len(released) - len(cap_diffs) == 3267)
# ⚠️ THE COUNTER-ONLY DIFFERENCES ARE NOT A LOOPHOLE, so they are pinned twice:
# they may only occur at an `sc` above release/1.0's ceiling, and the largest
# `saturation_cycles` config.yaml accepts is below BOTH ceilings, so no
# accepted configuration can see the difference at all.
check(f"the {len(cap_diffs)} counter-only differences all sit above"
f" release/1.0's cap of {LEGACY_SAT_CAP}, where no accepted"
f" saturation_cycles can read them",
all(r[3] >= LEGACY_SAT_CAP for r in cap_diffs) and _sc_hi <= LEGACY_SAT_CAP)
# ⚠️ AND THE RELEASES ARE MOSTLY A LABEL. Only these change what reaches the
# wire; the rest report "freeze-released" on a command the freeze was not
# binding on anyway. Every one of them is a charge that release/1.0 refused to
# start from exactly 0 W, which is the ticket, stated as a count.
check(f"{len(value_diffs)} of those {len(released)} change the command,"
f" and every one is a charge start from prev_w == 0 that was refused",
len(value_diffs) == 27
and all(p == 0.0 and new_ < 0.0 and old_ == 0.0
for p, new_, old_ in value_diffs))
# ...and the rename is not a quiet deletion: the signal SAFETY-03 alarms on has
# to actually occur in that sweep, or its hook is dead.
File diff suppressed because it is too large Load Diff
+6
View File
@@ -1917,6 +1917,12 @@ _AUDITED = {
("test_freshness.py", "option_default"),
("test_price.py", "shipped"),
("test_tariff.py", "shipped_option"),
# SAFETY-42. `read_option()` there takes a key and reads config.yaml, so the
# signature puts it in this table whether anybody remembered it or not -
# which is what this table is for. It answers the SITE value because it is
# anchored at exactly two spaces; a loose-whitespace pattern would have
# answered a per-inverter `max_w`, which is the defect below.
("test_delivery.py", "read_option"),
}
# Helpers the audited readers call. Not called with a key themselves, so there
# is nothing to feed them; they are exec'd so their callers work.
+5
View File
@@ -130,6 +130,11 @@ DERIVED = {
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
"circulation_w": ("SAFETY-31", "924"),
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
# SAFETY-42. Two needles rather than one: the campaign it was derived from,
# and the word that says what it does NOT do. An operator who reads
# "not-delivering report" and assumes the add-on has handled it is the
# failure this alarm's whole wording is written against.
"delivery_hold_s": ("SAFETY-42", "DERIVED FROM", "REPORTS ONLY"),
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
"bms_soc_floor": ("0x0560", "MEASURED"),
"integrator_max_w": ("MEASURED", "Do not raise it"),
+3
View File
@@ -234,6 +234,9 @@ configuration:
circulation_hold_s:
name: "Safety · Circulating-power hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
delivery_hold_s:
name: "Safety · Not-delivering report after (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus 1890 runs at one inverter as the control, which report nothing at any threshold): the worst legitimate episode those runs produce is 30.0 s, the floor of 30 s is the firmware's own link veto below which a unit is already being dropped from the split, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 6.2 times that, and the margin is sized on a measurement rather than a feeling: the second inverter in those tests is a copy of the first, and a second unit four times slower produces 90.0 s. Do not change it without re-deriving it. A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
auto_start:
name: "Advanced · Start controlling automatically on boot"
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
+3
View File
@@ -214,6 +214,9 @@ configuration:
circulation_hold_s:
name: "Veiligheid · Wachttijd rondpompend vermogen (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
delivery_hold_s:
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus 1890 tests met een omvormer als controlegroep, die bij geen enkele drempel iets melden): de langste terechte episode in die tests duurt 30.0 s, de ondergrens van 30 s is het eigen veto van de firmware waaronder een omvormer al uit de verdeling wordt gehaald, en de bovengrens is het factuurkwartier van 900 s. 185 s is 6,2 keer zoveel, en die marge is op een meting gebaseerd en niet op een gevoel: de tweede omvormer in die tests is een kopie van de eerste, en een tweede eenheid die vier keer trager is levert 90,0 s op. Verander dit niet zonder de afleiding opnieuw te maken. Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
auto_start:
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."