Compare commits

...
12 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 024a4f58dd OPT-09: the weights DO move on a wedged unit, and that narrows SAFETY-42
checks / unit (pull_request) Failing after 2s
B1, the blocking review finding. §5 of the decision said:

    "A wedged unit reads a healthy SoC, so no envelope closes and no weight
     moves - OPT-09 cannot see it at all."

The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. A weight is a function of a pack's ENERGY HEADROOM, not
of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's - the ordinary case this card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.

So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit. That is a DETECTION REGRESSION at
N=2. It is disclosed here, not fixed: making delinquent() relative to the ask
rather than absolute is SAFETY-42's code and a design change, filed as its own
card.

1. THE CORRECTION, in the three places that carried the claim

   * DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam
     accurately, quotes the sentence it is replacing, carries the measured
     rows and the window arithmetic, and names the residual in the same voice
     as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not
     exist". Also drops "they meet and do not overlap" from the opening.
   * app/packshare.py - a new section in the module docstring, immediately
     after "weights are a comfort choice for stability and not for delivery",
     which is where the over-read starts.
   * CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the
     operator-facing sentence: a wedged inverter with less energy headroom
     than its sibling can now go unreported at ordinary house loads.

   test_packshare.py part 8's closing check and its "what this file cannot
   see" list repeated the same false clause; both now say the ENVELOPE cannot
   reach a wedged unit and point at part 8b for what the WEIGHTS do to it.

2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks

   The reviewer's probe, reproduced: real compute(), real plan(), real
   packshare.weights(), real delivery.account(), real delivery.delinquent();
   the only model is the plant (A obeys within one cycle inside its rating,
   B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against
   its own 10 % floor, so envelope() does not close and account() explains
   nothing - a SAFETY-42 true positive by that card's own definition, which
   a PRECONDITION check asserts before anything is concluded from it.

       load 1500 W, B wedged at 15 %, equal weights  named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 1500 W, B wedged at 15 %, OPT-09 weights named on   0/200 cycles,
         site target +1630 W, shares a+1505, b+125,  grid -5 W, named=(nobody)
       load 6000 W, B wedged at 15 %, equal weights  named on 199/200 cycles
       load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles

       suppression window: equal weights, site target < 1000 W; OPT-09
       weights, site target < 6500 W of a 9600 W site

   THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that
   the weighted arm names NOBODY where the equal arm names the wedged unit.
   A check asserting the alarm still fires would fail, and papering over it
   would be worse than the original defect. The 6000 W row is there so the
   part is not pinning "the weighted arm never names anybody": past the
   healthy pack's own rating, _fill()'s last-resort pass hands the wedged
   unit 3000 W and both arms name it. The window is real and it has an edge.

   MUTATION - packshare.weights() keeps its shape and forces every weight to
   1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree
   with python3 -B, not reasoned about:

       load 1500 W, B wedged at 15 %, equal weights  named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
         site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b]
       suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W

     FAIL  AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...]
     FAIL  ...and the arithmetic behind it, stated as a window [...]
     FAIL  AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...]

   test_packshare.py exits 1. Restored: exits 0.

3. THREE PROC-01 PROSE DEFECTS

   * DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @
     464d398". The branch was rebased; git merge-base HEAD 3368231 is
     3368231. Corrected, and it says why the old number was there.
   * Same file:13 cited main.py:2272 for "resolution = resolve(...)".
     git show 3368231:goodwe_controller/app/main.py | grep -n says 2422.
     Corrected. The sibling citation arbiter.py:535 is right and is left.
   * DOCS.md:174 said "Since 0.7.x the split is driven by two separate
     things", which tells an operator the fix already shipped. config.yaml
     is 0.8.0 and the live install is 0.7.0, where the split is 50/50 - this
     card's own premise. Now "from 0.8.0 onwards", with an explicit warning
     that 0.7.0 and earlier are a fixed 50/50.

NOT TOUCHED, deliberately: delinquent()'s absolute threshold (SAFETY-42's
code, its own card), and app/main.py:2699's double-logged scope refusal (N4,
filed separately).

INVERTER B DOES NOT EXIST (expected ~2026-09-12). Every two-unit figure above
is TWO COPIES OF A through a plant modelled in test_packshare.py; B's 4600 W /
20.48 kWh / 10 % floor are from the datasheet and the serial. Nothing here was
measured on hardware, and the added disclaimers say so in each place.

Verified with the real harness per .gitea/workflows/checks.yml
(goodwe-addon-checks:1): COLLECTED 24 files, ALLGREEN. Harness positive
control run first - test_validate.py deliberately broken, harness reported
"FAILED: test_validate.py" and exited 1; restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:53:03 +02:00
glenn schrooyenandClaude Opus 5 8123e261ce OPT-09: the weights parameter nothing ever passed, and what it cannot do
checks / unit (pull_request) Successful in 6m19s
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:

  * a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
    `bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
    pack is not asked to discharge and its share goes to the sibling;
  * SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
    `_fill()`'s last-resort pass will override the moment the sibling saturates.

Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.

Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.

SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.

Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.

⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 21:38:40 +02:00
admin 3368231048 Merge pull request 'PKG-07: bump 0.7.0 -> 0.8.0, and stop the changelog calling shipped work unreleased' (#60) from PKG-07 into release/1.0
PKG-07: bump 0.7.0 -> 0.8.0, because 0.7.0's own default is now a refused config
2026-09-06 20:23:23 +02:00
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
25 changed files with 6761 additions and 67 deletions
+290 -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,243 @@ 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.
⚠️ **THE SITE TARGET IS NO LONGER SPLIT 50/50 BETWEEN TWO PACKS ([[OPT-09]]) -
AND THIS ADDS A PER-ENTRY OPTION, SO IT IS A SCHEMA CHANGE.**
`capacity_kwh: float(0.1,200)?` joins the `inverters` roster as an **optional**
per-entry key. Optional, where SAFETY-32 made `bms_soc_floor` required, and for
that card's own reason: a missing cut-out would have to be *defaulted* and one
default for two batteries protects the shallower pack with the deeper pack's
number, whereas a missing capacity has an honest answer - no opinion about the
split, every unit weighs 1.0, which is exactly what shipped. It is **all or
nothing across the roster** and rule 9 refuses a partial one. There is no flat
`capacity_kwh`: at one inverter there is no split to weight.
The allocator has had a `weights` argument since [[ALLOC-01]] and **nothing had
ever passed one** - `main.py` called `resolve()` without it and `arbiter.py`
defaulted it to 1.0 per unit - so the split was fixed 50/50 regardless of what
either pack could do. Two things now feed it, and they are not the same kind of
thing:
- **an ENVELOPE, which is a permission.** A new `pack` UNIT claim at `P_SAFETY`
closes a unit's `hi` at 0 W when its own SoC is at or under its own
`bms_soc_floor`, and its `lo` at 0 W at 100 %. The allocator obeys it
absolutely, so an empty pack is not asked to discharge and its share moves to
the sibling. ⚠️ **Not filed at all at a roster of one** - every source routes
to the site there, so it would bound the SITE TARGET, and [[SAFETY-25]]'s
Option A (no loop-side SoC floor) stands untouched on the shipped install.
- **WEIGHTS, which are a preference.** Between those limits each pack is asked
for the fraction matching its **energy** headroom in kWh - not its power
rating. The two packs are asymmetric in opposite directions (5000 W /
15.36 kWh against 4600 W / 20.48 kWh **from datasheet**), so the two ways of
dividing hand the work to different packs.
⚠️ **AND WEIGHTS ARE A COMFORT CHOICE FOR STABILITY, NOT FOR DELIVERY.**
[[ARCH-01]]'s convex combination guarantees a bad split cannot destabilise the
site; it says nothing about whether the site can regulate. Measured (two 2000 W
units, one at its floor, modelled plant, real `compute()`): at a 3000 W load the
weighted arm and the shipped arm command the **identical** +2000 W into the empty
pack and deliver the **identical** 2000 W, because `_fill()`'s last-resort pass
hands the remainder to a zero-weighted unit rather than leave watts unplaced.
Closing the envelope does not recover those watts either - it reports them:
`unmet_w` becomes +2000 W and the action log says so, where before the record
read impeccable. A unit that cannot deliver still parks the site short by its
share, and that is now visible instead of silent.
Also: `arbitrage.decide()` is sized per pack rather than at the site rating on
both sides, so a two-pack site cannot ask to buy 9600 W with one pack able to
absorb it; and a 0 W sizing is refused by name, because a 0 W arbitrage claim is
a park order that outranks the loop. ⚠️ The sell side is a **no-op on every
roster today** - the sell gate reads the emptiest pack, so it refuses outright in
exactly the states where the sum could be smaller - and moving that gate is
deliberately not this card's; `DECISIONS/OPT-09-soc-aware-weights.md` §6 says
why.
⚠️ **[[SAFETY-42]]'s end-to-end fixture moved, and its finding did not.** That
card's evidence was that a wedged unit and a pack at its floor are byte-identical
in the power domain. After this card the cold pack is no longer *asked*, so the
shares differ - but the two quantities SAFETY-42's rejected discriminator reads
(saturation persistence and |grid error|) are still inseparable on both arms, so
the conclusion is unchanged and is now stated on the predicate's own inputs.
`test_delivery.py` part 4 carries the whole argument. The two cards meet at one
hazard from opposite sides: **OPT-09 stops the site ASKING a pack whose SoC says
it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.**
⚠️ **AND THIS CARD NARROWS SAFETY-42's DETECTION WINDOW. IT IS A DETECTION
REGRESSION AT TWO INVERTERS AND IT IS DISCLOSED RATHER THAN FIXED.** An earlier
draft of this entry and of `DECISIONS/OPT-09-soc-aware-weights.md` §5 said a
wedged unit's weight does not move, so OPT-09 could not see the case at all. The
ENVELOPE half is true - a wedged box reads a healthy SoC and nothing closes. The
WEIGHT half was **false**: a weight is a function of a pack's ENERGY HEADROOM,
not of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's. And `delivery.delinquent()` names a unit
only while its shortfall exceeds `saturation_w` - **500 W in absolute watts** -
so shrinking the share shrinks the gap below the threshold and the alarm stops
firing. Measured end to end with the real loop, the real allocator and the real
detector, on a unit wedged at a healthy 15 % against its own 10 % floor (a
SAFETY-42 true positive by that card's own definition): at a 1500 W load the
healthy pack can carry alone, equal weights name it on **199 of 200** cycles and
these weights name it on **none of the 200** - shares 1505/125, gap 125 W, and
125 W is under the threshold. The suppression window widens from a site target
below 1000 W to a site target below `saturation_w * (w_A + w_B) / w_B`, which on
that fixture is **6500 W of a 9600 W site**; above it the healthy pack hits its
own rating, the allocator's last-resort pass hands the wedged unit a big share
again and the alarm returns. So in the quiet regime - where `unmet_w` is 0, the
meter is satisfied and no action-log line fires - **a wedged inverter with less
energy headroom than its sibling can now go unreported at ordinary house
loads.** `test_packshare.py` part 8b grades it and pins the CURRENT behaviour, so
the day it changes a named check moves. Closing it means making `delinquent()`
relative to the ask rather than absolute, which is SAFETY-42's code and a design
change; that is filed as its own card and is deliberately not done here.
⚠️ Every two-unit figure above is **two copies of inverter A** through a modelled
plant - inverter B does not exist yet - and B's ratings are datasheet and serial.
⚠️ **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 +3530,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,302 @@
# OPT-09 - the weights parameter, and what it is honestly for
**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3368231`
(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name
the pre-rebase base, `464d398`).
## 0. What was actually wrong
`allocator.plan()` has taken a `weights` argument since ALLOC-01, `_fill()`
consumes it in two passes, and **nothing had ever passed one**. Confirmed on the
base commit:
arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...)
main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...)
So `weights` was None on every tick of every install, `plan()` turned that into
1.0 per unit, and the site target was split **50/50 regardless of what either
pack could do**. The card was filed as "per-pack arbitrage sizing"; the architect
pass found the sharper content, and it is the sharper content that this branch is
mostly about.
## 1. Two instruments, and they are not interchangeable
`allocator.py` states the rule this card is built on, and it is mechanical rather
than advisory:
> WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
> "please do not use this unit", and it is honoured while any other unit has
> headroom - but it is NOT a constraint.
`_fill()` runs a deliberate **last-resort pass** over the zero-weight units when
the preferred ones saturate, so that a preference can never manufacture a
standing error at the meter. A pack at its own cut-out that is only *weighted*
to zero is therefore still commanded to discharge the moment its sibling fills
up. So this branch ships both:
| | question | mechanism |
|---|---|---|
| `packshare.envelope()` | MAY this pack be asked, at all, in this direction? | a `pack` UNIT claim at `P_SAFETY`, `hi = 0` at the cut-out, `lo = 0` at 100 % |
| `packshare.weights()` | GIVEN that both may, which should carry more? | energy headroom in kWh, handed to `resolve()` |
## 2. ⚠️ What the weights CANNOT do
ARCH-01's convex-combination argument - the gain at the meter is
`sum(a_i * g_i)`, so it lies in `[min g_i, max g_i]` for **any** split -
guarantees that a bad split cannot **destabilise** the site. It says nothing
about whether the site can **regulate**. A unit at its energy limit has
`g_i = 0`; the combination is still inside the interval and the load it was
asked to carry simply does not move.
Measured, `test_packshare.py` part 8, two units rated 2000 W each, B sitting on
its own floor and therefore answering 0 W to any discharge command. The plant is
modelled in that file (`lag_s = 0`, obeys within one cycle, bounded by its
rating) and the loop is the real `compute()`:
| load | arm | site target | shares | grid | unmet |
|---|---|---|---|---|---|
| 1500 W | shipped | +3000 W | A +1500, B +1500 | 0 W | 0 W |
| 1500 W | weights | +1500 W | A +1500, B +0 | 0 W | 0 W |
| 1500 W | envelope | +1500 W | A +1500, B +0 | 0 W | 0 W |
| 3000 W | shipped | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
| 3000 W | weights | +4000 W | A +2000, B +2000 | +1000 W | 0 W |
| 3000 W | envelope | +4000 W | A +2000, B **+0** | +1000 W | **+2000 W** |
Three things are worth reading off that table, and the second and third are the
uncomfortable ones:
1. At a load the healthy pack can cover, the weights alone deliver the whole
improvement. The shipped arm reaches the same meter reading only by winding
the site target to **twice the load** and standing a +1500 W command on a
battery that answers nothing, permanently saturation-frozen. That is not a
working site, it is a site with no headroom left for the next load step.
2. At a load it cannot cover, **the weights stop working entirely**. The
weighted arm and the shipped arm command the identical +2000 W into the empty
pack, because `_fill()`'s last-resort pass hands the remainder to the
zero-weighted unit. Same shares, same watts, same error.
3. **And the envelope does not rescue the delivery either - it names the
shortfall.** Same 2000 W delivered, same 1000 W standing at the meter. What
changes is that the empty pack is commanded 0 W instead of 2000 W, and 2000 W
of site target is *reported* as unplaceable instead of appearing to have been
placed.
> **Weights are a comfort choice for STABILITY and NOT for DELIVERY.** Nothing in
> this add-on shifts a unit's load to its sibling once the sibling is at its
> rating, and weighting a failing unit down is not a remedy for it.
## 2b. ⚠️ The card's own premise is only half true
The card opens with *"a unit that stops delivering its share (empty, full,
curtailed, wedged) parks the site with a standing grid error and no alarm."*
After this branch: the **ask** stops, and while the sibling has headroom the site
regulates to zero error instead of winding to twice the load. The **standing grid
error does not stop** once the sibling is at its rating - §2's table measures
1000 W standing in all three arms - and there is **still no alarm** for it, only
a report. SAFETY-42 will not alarm on a pack at its own floor; that is one of its
acceptance criteria and its evidence is that the benign and the wedged case are
indistinguishable in the power domain.
So the two cards together mean: an empty pack limiting the site is now REPORTED
(`unmet_w`, an action-log line) and still, deliberately, NOT ALARMED. Whether it
should be alarmed is a decision - and the alarm it would need is about a site's
stored ENERGY, not about an inverter's delivery, so it is not this card's and not
SAFETY-42's. Recorded rather than quietly closed against a premise this card only
half meets.
## 3. Which floor, and which ceiling - the two places this could have become policy
**The floor is `bms_soc_floor` alone, never `peakguard.pack_floor_pct()`.** That
function's conjunction with `peak_soc_floor` is right for the peak guard and
wrong here: SAFETY-25 decided (2026-09-02, Option A) that `peak_soc_floor` bounds
the peak guard **only**, ordinary self-consumption discharges straight past it,
and closing an envelope at it would turn an operator preference into a hard stop
on the grid-following loop - the memo's **deferred Option B**, built by accident.
`delivery.py`'s `account()` made the same choice one step out, for the same
reason. Graded: a pack at 15 % with a 10 % cut-out is not closed, though a 20 %
EMS floor would have closed it.
**The ceiling is 100 %, and the coarseness is the honest answer.** There is no
per-pack charge ceiling anywhere in this add-on - `delivery.py` says so at length
and calls it a card, and it is why SAFETY-42 is armed on discharge shares only.
The only other number available is `arbitrage_soc_ceiling`, which is one
site-wide **trade policy**; filing it as a `P_SAFETY` envelope on the loop would
be the mistake above in the charge direction. So the envelope closes only where
the ask is physically impossible, and everything between is left to the weights -
which is the right instrument for a graded preference and is honest about being
only a preference.
## 4. N=1, and the tripwire this had to argue past
`actuator_for()` routes **every** source to the site at one inverter, so a `pack`
interval filed there would bound the SITE TARGET rather than a share. `main.py`
therefore does not file it at all below a roster of two - the same guard, for the
same reason, that SAFETY-30's `sign_site` carries. AC 4 ("at N=1 allocation is
byte-identical") is that `if`.
`test_peakguard.py` part 10f pins the claim-source set against `main.py`'s own
AST precisely so that a state-of-charge-conditioned claim cannot arrive quietly.
`pack` is state-of-charge-conditioned, so the argument is written at that set
rather than the count edited: the number is not an option, the claim is
unit-scoped so it cannot bound the site target, it is not filed at N=1, and part
10f's own note names this as the revisit trigger - *"under ALLOC-01 the reserve
becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a ninth claim
here"*. Stage 1 is how an interval reaches the allocator and stage 1's input is
claims, so a unit-scoped claim is the shape that sentence asks for.
**What N=1 still does not get, said plainly:** nothing stops the loop discharging
into the BMS cut-out at one inverter. That is unchanged, it is SAFETY-25's
deferred option, and the BMS is what stops it.
## 5. SAFETY-42 - the same hazard from the opposite side
The seam is **the state of charge** - and they overlap in one direction, which
is what the rest of this section is about:
* **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver.
* **SAFETY-42 alarms** when a pack that said it could does not.
A wedged unit reads a healthy SoC, so **no envelope closes** - that half is
absolute, and it is why this card cannot stop the ask on a wedged box. A pack at
its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after
this card that ask does not arrive in the first place, which removes the benign
arm from the detector's input rather than teaching it a new discriminator.
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWS SAFETY-42's DETECTION WINDOW. THIS
IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** An earlier draft of
this section said "no envelope closes and no weight moves - OPT-09 cannot see it
at all". The second clause was **false**, and it was load-bearing for the claim
that the two cards do not overlap. A weight is a function of the pack's ENERGY
HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever
its headroom differs from its sibling's - which is the ordinary case this card
exists to exploit. And `delivery.delinquent()` (`app/delivery.py:484`)
names a unit only while its `short_by()` gap exceeds `min_w =
Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is
**500 W in absolute watts**. Shrinking a unit's share shrinks its gap. Shrink the
share far enough and the gap drops below 500 W while the unit is still delivering
nothing, and the alarm stops firing.
Measured on the closed loop - real `compute()`, real `plan()`, real
`delinquent()`, modelled plant, B wedged at 15 % against its own 10 % floor so
nothing closes and `account()` explains nothing, i.e. a SAFETY-42 **true
positive** by that card's own definition:
load 1500 W equal weights (shipped) named on 199/200 cycles
shares {a:1500, b:1500} grid +0 W named=['inverter-b']
load 1500 W OPT-09 weights named on 0/200 cycles
shares {a:1505, b: 125} grid -5 W named=(nobody)
⚠️ That plant is modelled in `test_packshare.py`, and **inverter B does not
exist** (expected ~2026-09-12): it is two copies of A driven with different
numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the
serial. The claim is about the arithmetic of the split and the threshold, which
needs no hardware, and not about a GW5000S-BP.
At a load the healthy pack can cover - the regime where nothing else tells the
operator anything, `unmet_w` is 0, the meter is satisfied and no action-log line
fires - a wedged inverter goes from named on essentially every cycle to **never
named**. The suppression window widens from `site target < 2 * min_w` = 1000 W to
`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of
a 9600 W site; above it - once the healthy pack is at its own rating and
`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm
returns. `test_packshare.py` part 8b grades all of it and **pins the current,
unflattering behaviour**, so that the day it changes a named check moves.
So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit.** The two cards do not merely meet from
opposite sides; this one takes something away from the other one. Closing the gap
means making `delinquent()` relative to the ask rather than absolute, which is
SAFETY-42's code and a design change - it is filed as its own card, deliberately
not done here, and this paragraph is its input. Until then this is a **disclosed
residual**, in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's own "the
discriminator does not exist": at two inverters, a wedged unit with materially
less energy headroom than its sibling can go unreported at ordinary house loads.
⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without
connecting it.** That module's rejection of re-allocation rests on *"the only
re-allocation this add-on can express is ALL OR NOTHING [...] a partial
re-allocation is what would make acting safe, and the allocator has no way to
express it today"*. Passing weights **is** that expression. Wiring delinquency
into them is deliberately **not** done here: it would be acting on the alarm,
which SAFETY-42 refused with rig evidence (a unit derated to 50 % is 400 W worse
dropped than kept, and no instrument this add-on has can tell it from a wedged
one), and - see §2 - a weight cannot stop a unit being commanded anyway. That is
a card, and this paragraph is its input.
## 6. Per-pack arbitrage sizing, and the half that is deliberately not moved
`arbitrage.decide()` was handed `site_max_w` on **both** sides, so a two-pack
site would buy at the sum of the ratings whatever either pack's state of charge
was. It is now sized to the packs whose own SoC permits that direction
(`Controller.trade_power_w()`), using **arbitrage's own** bounds rather than the
BMS cut-out - sizing a trade is the trade's business; permitting a command is the
pack's.
Two honest limits:
* **The sell side is a no-op on every roster today.** `decide()` is handed
`pack_floor_soc` - the emptiest pack - so a sell is refused outright whenever
*any* pack is at or under the floor, i.e. in exactly the states where the sum
could be smaller than the site rating. The arithmetic is graded directly
(`test_packshare.py` part 9); it becomes reachable the day that gate is
per-pack.
* **Moving that gate is not this card's.** Sizing a sell off the *fullest* pack
would let the trade proceed while the emptiest pack was below
`arbitrage_soc_floor`, and the only per-pack instrument that could keep it out
of that pack is the weights - which are a preference, and `_fill()` will hand
it watts anyway. Doing it safely needs an envelope that depends on WHICH CLAIM
WON the tick, which the arbiter's partition does not express. §2 is why that is
a real objection and not a hedge.
`arbitrage.decide()` also now refuses a **0 W sizing** by name (`IDLE_NO_SIZE`),
because a `Claim.set(..., 0.0)` at `P_ARBITRAGE` is a park order that outranks
the loop - the failure the note on `claim()` is already written against. The
sizing can never be zero on a tick that trades today; that is a coincidence of
the existing gate, and this makes it structural instead.
## 7. The fixture, which is the hard part
Two packs that differ in only ONE of SoC and capacity cannot separate a
SoC-aware weighting from a capacity-aware one; two identical packs cannot
separate ANY weighting from any other. `test_packshare.py` part 1 grades the
fixture itself before anything is asserted with it - four candidate schemes
(equal, power rating, SoC only, energy), four distinct splits of the same
1000 W:
equal A +500, B +500
power A +521, B +479
soc only A +125, B +875
energy A +97, B +903
and the counterfactuals: on two identical packs all four collapse to one answer;
at the same SoC, `soc only` collapses onto `equal`; at the same capacity,
`energy` collapses onto `soc only`.
## 8. Provenance of every number
* A: **5000 W, 15.36 kWh, 10 % floor** - measured (GW5000S-BP, register 0x0560).
* B: **4600 W, 20.48 kWh, 10 % floor** - **FROM DATASHEET AND SERIAL**
(`goodwe/NOTES.md` §1.1b, GW5048D-ES `55048ESU22AW0992`). ⚠️ **Inverter B does
not exist** (expected ~2026-09-12).
* Every two-unit run in `test_packshare.py` is arithmetic over intervals and
weights, or a plant modelled in that file. `sim/plantsim.py:216`'s `INVERTER_B`
is **inverter A copied** with `measured=False`, so any two-inverter simulation
result anywhere in this project is **two copies of A**.
* No rig was taken, no hardware was touched, and nothing here is evidence about
a second GoodWe inverter.
## 9. Config
`capacity_kwh` joins the `inverters` roster as an **optional** per-entry key
(`float(0.1,200)?`) - the opposite choice to SAFETY-32's required
`bms_soc_floor`, and for that card's own reason: a missing cut-out would have to
be *defaulted*, and one default for two batteries protects the shallower pack
with the deeper pack's number, whereas a missing capacity has an honest answer -
no opinion about the split, every unit weighs 1.0, which is exactly what shipped.
It is **all or nothing across the roster** and rule 9 refuses a partial one: a
pack measured in kWh beside a sibling weighing a dimensionless 1.0 is not a
comparison, `weights()` answers "no opinion" for the whole roster in that case,
and a key that silently changes nothing is worse than one that refuses to load.
There is deliberately **no flat `capacity_kwh`**: at one inverter there is no
split to weight, so it would be an option an operator can set and nothing reads.
@@ -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.
+205 -1
View File
@@ -73,6 +73,7 @@ either direction, and the check is mutation-tested both ways.
| `soc_entity` | yes | Battery state of charge — use the **ESP32's own read** |
| `batt_entity` | yes | Battery power — again the ESP32's read, `+` = discharging |
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
@@ -98,11 +99,13 @@ inverters:
soc_entity: sensor.goodwe_a_soc
batt_entity: sensor.goodwe_a_battery_power
max_w: 5000
capacity_kwh: 15.36
- name: inverter-b
setpoint_entity: number.goodwe_b_setpoint_w
soc_entity: sensor.goodwe_b_soc
batt_entity: sensor.goodwe_b_battery_power
max_w: 4600
capacity_kwh: 20.48
```
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
@@ -166,6 +169,54 @@ in the log rather than silent, but it is an outage until someone puts the
single-inverter entity back in that field. **Before downgrading, write down the
`setpoint_entity` of the unit you want to keep running.**
#### How the site target is divided between the packs (OPT-09)
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
version before it the split is a fixed 50/50 regardless of what either pack can
do** — that is the defect this section describes the fix for, so if the add-on
you are running reports 0.7.0, none of what follows is happening yet. From 0.8.0
the split is driven by two separate things, and it is worth knowing which is
which, because only one of them is a rule:
* **The envelope — a permission.** A pack whose own state of charge is at or
under its own `bms_soc_floor` is **not asked to discharge**; a pack at 100 %
is **not asked to charge**. Its share goes to the sibling, inside that
sibling's own rating. This is absolute: the allocator cannot exceed it.
* **The weights — a preference.** Between those limits, each pack is asked for
the fraction of the site target that matches its **energy headroom**: the kWh
it has above its own cut-out when the site is discharging, the kWh it has
below full when the site is charging. That is what `capacity_kwh` is for.
⚠️ **`capacity_kwh` is optional, and it is all-or-nothing.** Leave it out
everywhere and the add-on has no opinion about the split — the target is divided
evenly, exactly as it was before. Fill it in on **every** entry or on none: a
pack measured in kWh beside one that is not is not a comparison, and the add-on
refuses to start rather than ignore half of what you typed.
⚠️ **Why energy and not the rating.** The two packs here pull in opposite
directions — A is 5000 W with 15.36 kWh, B is 4600 W with 20.48 kWh, so B has
about a third more energy and about 8 % less power. Dividing by rating hands the
work to A; dividing by energy hands it to B. The question a split is answering is
"which pack can sustain its share for the hour", so it is the energy one. Each
inverter's own rating still binds its own share, separately, through `max_w`.
⚠️ **What the weights will NOT do for you, stated plainly.** They are a comfort
choice about which pack does the work. They are **not** a way to make the site
deliver. A unit that is at its energy limit — or that has simply stopped
responding — does not hand its load to its sibling: the loop sees the shortfall,
freezes, and the site runs short by that unit's share. Weighting a failing unit
down does not fix that, because when the healthy unit reaches its own rating the
allocator hands the remainder to the down-weighted one anyway rather than leave
watts unplaced. What you get instead is honesty: the action log reports the watts
that could not be placed (`ALLOCATOR: +2000 W of the site target could not be
placed on any inverter`), and a unit that accepts its share and does not produce
it is named by the delivery alarm (SAFETY-42, above).
⚠️ **At one inverter none of this applies and nothing changed.** There is no
split to weight, `capacity_kwh` is not read, and no envelope is closed —
self-consumption still discharges down to the battery's own cut-out exactly as
it always has.
### What appears in Home Assistant at two inverters
**Nothing you already have changes.** The eight entities this add-on has always
@@ -473,7 +524,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 +536,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 +636,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 +744,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
+8
View File
@@ -394,6 +394,14 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
`.pinned_by` and `.contradiction` so that this module does not
import the arbiter and cannot grow a dependency on claims.
`weights` unit -> non-negative comfort weight. Missing units weigh 1.0.
⚠️ A MAPPING, ALWAYS - never a function. OPT-09 lets `resolve()`
accept a callable of the site target, because an energy-headroom
weighting is direction-dependent and the direction is stage 2's
answer; `resolve()` calls it and hands the RESULT here. Keeping
that out of this module is what keeps `plan()` a pure function of
numbers and intervals: a callback invoked inside the fill would
put a caller's code, and whatever it reads, back into the module
whose whole discipline is that it reads nothing.
`live` the units whose master has reported recently enough to be
commanded, or None for "all of them". A unit not listed is
DROPPED and the record says so; the survivors then carry the
+46 -1
View File
@@ -205,6 +205,27 @@ CLAIM_SCOPE = {
# At N=1 the two are the same comparison and this claim is the one filed,
# exactly as it shipped.
"failsafe": SCOPE_UNIT,
# main.py: `Claim.limit("pack", P_SAFETY, lo, hi, ...)`, OPT-09. THIS PACK'S
# OWN STATE OF CHARGE against THIS PACK'S OWN `bms_soc_floor`, filed only
# where it BINDS and only at a roster of two or more. It says "this battery
# cannot do that", which is a fact about one cupboard in the same way
# `device`'s rating is a fact about one box, so it is scoped like `device`
# and filed at the same priority.
#
# ⚠️ AND IT IS NOT SAFETY-25's DEFERRED LOOP-SIDE RESERVE, WHICH LOOKS
# EXACTLY LIKE IT AND IS THE REASON test_peakguard.py PART 10f EXISTS. That
# one was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
# `loop_soc_floor` option - an EMS preference, chosen by an operator,
# stopping the grid-following loop at a depth somebody picked. This is the
# pack's own BMS cut-out, which is measured off register 0x0560 and required
# of every roster entry since SAFETY-32; no option is added, `peak_soc_floor`
# is deliberately NOT consulted, and the SITE target is not touched - only
# which unit carries it. Part 10f's own note names the revisit trigger:
# "under ALLOC-01 the reserve becomes a PER-INVERTER INTERVAL AT THE
# ALLOCATOR rather than a ninth claim here", and stage 1 is how an interval
# reaches the allocator, so a unit-scoped claim is the shape that sentence
# asks for rather than an evasion of it.
"pack": SCOPE_UNIT,
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
@@ -544,7 +565,10 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
wear - and cannot destabilise the site whatever it says. `live` is the set
wear - and cannot destabilise the site whatever it says. ⚠️ OPT-09 LET IT BE
A CALLABLE as well as a mapping: called with the resolved site target once
stage 2 has run, because an energy-headroom weighting is direction-dependent
and the direction is stage 2's answer. See the call site below. `live` is the set
of units whose master has reported recently enough to be commanded; a unit
left out of it is DROPPED from the envelope set and the record says so,
because the survivor carrying a dead unit's share silently is the failure
@@ -600,6 +624,27 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
# left as a log line nothing grades.
out.scope_errors = scope_violations(claims)
out.unit_bounds = bounds
# OPT-09. THE WEIGHTS MAY ARRIVE AS A FUNCTION OF THE SITE TARGET, and this
# is the one line that makes SoC-aware weighting expressible at all.
#
# ⚠️ THE REASON IS THAT THE DIRECTION IS NOT KNOWN ANY EARLIER. A weighting
# by ENERGY HEADROOM is direction-dependent - room above the floor when the
# site is discharging, room below full when it is charging - and the two
# give opposite answers on the same roster. The site target is chosen by
# stage 2, three lines up, so a caller computing weights before `resolve()`
# would have to guess which way the site is about to go, and would be wrong
# in one of the two directions on every tick.
#
# ⚠️ IT IS CALLED ONCE, HERE, BETWEEN STAGE 2 AND STAGE 3, AND NEVER FROM
# INSIDE THE ALLOCATOR. `allocator.plan()` goes on taking a plain mapping,
# which is what keeps it a pure function of numbers and intervals: a
# callback invoked inside the fill would put a caller's code - and whatever
# it reads - back into the module whose whole discipline is that it reads
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
# own direction can differ from the target's sign under a pin, in which case
# the weighting is a worse preference and still cannot be unsafe).
if callable(weights):
weights = weights(out.target_w)
out.shares, out.allocated, out.allocation = allocate_record(
out.target_w, bounds, weights=weights, live=live,
scope_errors=out.scope_errors, site_priority=out.winner_priority,
+15
View File
@@ -173,6 +173,17 @@ IDLE_NO_SOC = (
"state of charge is not being read, and a battery whose fill nobody knows "
"cannot be told to empty or to fill")
IDLE_OFF_PEAK_HOUR = "this hour is neither the buy hour nor the sell hour"
# OPT-09. A TRADE NOTHING CAN TAKE IS NOT A TRADE, AND THE ALTERNATIVE IS WORSE
# THAN A MISSED HOUR. `charge_w`/`discharge_w` used to be the site's whole rating
# and are now SIZED to the packs whose state of charge permits that direction, so
# a caller can hand this function a zero. A zero must not reach `claim()`: a
# `Claim.set(..., 0.0)` at P_ARBITRAGE is a PARK ORDER that outranks the loop,
# which is the failure the note on `claim()` is written against - it would
# silently disable self-consumption for the whole hour. So the sizing is checked
# where the decision is made rather than trusted to arrive positive.
IDLE_NO_SIZE = (
"no pack's state of charge permits this side of the trade, so it is sized "
"at 0 W - and a 0 W arbitrage claim is a park order, not a trade")
NEGATIVE_PRICE = (
"§17: the sell hour is priced BELOW ZERO, so discharging into it would give "
"energy away and pay for the privilege. Arbitrage must not export at a "
@@ -372,6 +383,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
f"the sell hour is here at {price:.4f} EUR/kWh but the pack is "
f"at {soc:g} %, at or under the {floor_pct:g} % reserve floor "
f"arbitrage may not spend (§10.1 priority 4)", **common)
if not abs(float(discharge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **common)
return ArbitrageDecision(
action=DISCHARGE, power_w=abs(float(discharge_w)),
reason=(f"{DISCHARGE}: this is the dearest firm hour at "
@@ -388,6 +401,8 @@ def _act(plan, now, v, soc, charge_w, discharge_w, floor_pct, ceiling_pct):
f"the buy hour is here at {price:.4f} EUR/kWh but the pack is at "
f"{soc:g} %, at or over the {ceiling_pct:g} % ceiling - there is "
f"nowhere to put the cheap energy", **common)
if not abs(float(charge_w)) > 0.0:
return _idle(IDLE_NO_SIZE, **common)
return ArbitrageDecision(
action=CHARGE, power_w=-abs(float(charge_w)),
reason=(f"{CHARGE}: this is the cheapest firm hour at "
+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 = ""
+87 -5
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, capacity_kwh=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 "")
@@ -206,6 +215,20 @@ class Inverter:
# the config by name long before that, at startup.
self.bms_soc_floor = (None if bms_soc_floor is None
else float(bms_soc_floor))
# OPT-09. THIS PACK'S USABLE CAPACITY IN kWh, or None when the roster
# entry did not state one.
#
# ⚠️ OPTIONAL WHERE `bms_soc_floor` IS REQUIRED, AND THE ASYMMETRY IS
# THE POINT RATHER THAN AN INCONSISTENCY. A missing cut-out would have
# to be defaulted, and one default for two batteries protects the
# shallower pack with the deeper pack's number - SAFETY-32's whole card.
# A missing capacity has an honest answer that costs nothing: this pack
# states no energy headroom, so `packshare.weights()` has NO OPINION
# about the split and every unit weighs 1.0, which is exactly the
# behaviour that shipped. None, never 0.0: a zero-capacity pack is one
# that may never be used, and nobody decides that by omitting a key.
self.capacity_kwh = (None if capacity_kwh is None
else float(capacity_kwh))
# --- the device's own reported range, read from its own entity -------
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
@@ -274,6 +297,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 +478,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
@@ -453,7 +496,9 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
it - see the options-block note on `inverters:`.
NON-EMPTY each entry describes one inverter and the flat keys describe
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
none of them. OPT-09's `capacity_kwh` is an entry key too, and an
OPTIONAL one - see `_entry_capacity_kwh()` and `Inverter.__init__`
on why that absence is answerable where a missing cut-out is not. ⚠️ NOTHING IS INHERITED. An entry that omits
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
because "this unit's number came from somewhere else" is the
defect DEV-02 spent a card removing and a fallback here would
@@ -490,7 +535,14 @@ 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)]
# ⚠️ OPT-09 ADDS NO FLAT `capacity_kwh`, AND THE ABSENCE IS THE
# DECISION. Capacity is read for ONE purpose - weighting a split
# between two packs - and at the single-inverter shape there is no
# split: `allocator.plan()` is a pass-through at N<=1 and a weight
# is arithmetic nobody reads. A flat key would be an option an
# operator can set, that the add-on reads, and that changes nothing,
# which is the worst of the three.
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg)]
roster = []
names = roster_names(opts)
@@ -517,7 +569,12 @@ 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))
# OPT-09, and NOT inherited either, for the reason above it: a
# capacity borrowed from a sibling would weight a 15.36 kWh pack as
# though it were a 20.48 kWh one, which is the split this card
# exists to stop being wrong.
capacity_kwh=_entry_capacity_kwh(entry),
store=store, sign_cfg=sign_cfg, delivery_cfg=delivery_cfg))
return roster
@@ -559,6 +616,31 @@ def _entry_bms_floor(entry: dict):
return None
def _entry_capacity_kwh(entry: dict):
"""One entry's OWN usable capacity in kWh, or None when it stated none.
None rather than 0.0 and rather than a borrowed number: `_entry_max_w()` can
answer 0.0 because a zero rating commands nothing and rule 9 refuses it by
name, but a zero CAPACITY is a pack with no energy headroom in either
direction, i.e. a pack the weighting would silently stop preferring. "Not
stated" and "empty" must not be the same answer - `packshare.weights()`
turns the first into "no opinion at all" and the second into a weight of
zero, and those are different splits.
A NON-POSITIVE or unparseable value also answers None. Rule 9 refuses it at
startup with a message; until it does, withholding is the direction that
cannot invent a preference.
"""
raw = entry.get("capacity_kwh")
if raw is None or raw == "":
return None
try:
val = float(raw)
except (TypeError, ValueError):
return None
return val if val > 0.0 else None
def _entry_max_w(entry: dict) -> float:
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
+520 -3
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,
@@ -61,6 +65,7 @@ from .maintenance import (
from .mqtt import (MqttPublisher, announced_units, unit_slugs,
unit_state_key)
from . import peakguard
from . import packshare
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .static_tariff import resolve_tariff, static_status
@@ -141,6 +146,23 @@ def load_options() -> dict:
return {}
def pack_energy_of(inv):
"""OPT-09. ONE roster entry as the view `packshare` consumes.
⚠️ ONE BUILDER, TWO CALLERS, AND THAT IS THE POINT. `Controller.pack_energy`
needs the whole roster (for the weights and the trade sizing) and
`Controller.cycle()` needs one entry at a time (for that unit's `pack`
claim). Written out twice, the two would drift - and the direction they
would drift in is a unit whose envelope is computed from a different set of
numbers than its weight, which is precisely the class of defect DEV-02 spent
a card removing. It is a module function rather than a method on `Inverter`
so that `inverter.py` does not have to import `packshare`.
"""
return packshare.PackEnergy(inv.name, inv.soc, inv.bms_soc_floor,
inv.capacity_kwh, inv.max_w)
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db"):
@@ -216,8 +238,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 +396,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
@@ -589,6 +646,12 @@ class Controller:
# edge-triggered. Starts as the empty tuple - nothing has been dropped
# yet and nothing has been said about it.
self._dropped_last = ()
# OPT-09. The last cycle's `unmet_w`, so the line that reports it is
# edge-triggered for `_dropped_last`'s reason: a site that cannot place
# every watt stays that way for as long as the pack is empty, and a line
# a second would flush the 40-row action log of the context explaining
# it. 0 is "every watt was placed", which is the state at boot.
self._unmet_last = 0
# ⚠️ THE SITE MEASUREMENT, AND THE ONLY ONE LEFT ON THIS OBJECT. `soc`
# and `batt` were per-device scalars and now live on each `Inverter`;
# `grid` is P1, one meter, one connection, and belongs here.
@@ -976,6 +1039,55 @@ class Controller:
"""
return float(sum(inv.max_w for inv in self.inverters))
def trade_power_w(self, floor_pct, ceiling_pct) -> tuple:
"""OPT-09. `(charge_w, discharge_w)` - how hard a trade may be SIZED.
The site rating is what the site can push; this is what the site can
push AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` used to be
handed `site_max_w` on both sides, so on a roster of two the trade was
sized at the sum of the ratings whatever either pack's state of charge
was - the site would buy at 9600 W with only one pack able to absorb it,
and the watts that could not be placed would come back as `unmet_w` for
the site integrator to chase for the rest of the hour.
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, because SIZING
A TRADE IS THE TRADE'S BUSINESS. This method answers "how much may this
strategy ask for"; the ENVELOPE - `packshare.envelope()`, filed as a
`pack` claim - answers "what may this pack be commanded at all", and
that one is the pack's own physical cut-out for the reason SAFETY-25
gives. Sizing with the physical floor would let the strategy ask for
watts its own reserve forbids; bounding the envelope with the trade's
reserve would let a trade preference stop the grid-following loop.
THEY ARE DIFFERENT QUESTIONS AND THEY GET DIFFERENT NUMBERS.
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack ends up
carrying the trade is the allocator's answer, and the only per-pack
instrument it has for a TRADE reserve is the weights - which are a
preference, not a permission (`allocator._fill()` will hand watts to a
zero-weighted unit rather than leave them unmet). So a pack between its
arbitrage floor and its BMS cut-out can still take part of a sell. That
is stated rather than fixed: fixing it needs a per-pack envelope that
depends on WHICH claim won the tick, which the arbiter's partition does
not express, and is a card of its own.
A pack whose SoC did not read is counted on NEITHER side. It is not
reachable with control running - `inputs_ok` stops the site when any
pack's SoC is missing - and withholding is the direction that cannot
over-size.
⚠️ NEITHER SIDE CAN BE ZERO ON A TICK THAT ACTUALLY TRADES, and it is
not left to that coincidence: `arbitrage.decide()` refuses a 0 W sizing
by name (`IDLE_NO_SIZE`), because a 0 W arbitrage claim is a park order
that outranks the loop.
"""
# ⚠️ THE REDUCTION ITSELF LIVES IN `packshare`, NOT HERE, and this
# method is the roster view it is applied to. A transcription of the
# arithmetic into a check is one copy plus a future disagreement about
# what it means, and the copy that drifts is always the grading one -
# so `test_packshare.py` part 9 calls the SAME function this does.
return packshare.trade_power_w(self.pack_energy, floor_pct, ceiling_pct)
@property
def site_rating_w(self) -> float:
"""The largest magnitude the SITE can actually be commanded, in W.
@@ -1093,6 +1205,44 @@ class Controller:
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
for inv in self.inverters]
@property
def pack_energy(self) -> list:
"""OPT-09. Each pack as the FOUR numbers a split needs, in roster order.
The fourth is `capacity_kwh`, and it is why this is not `packs` above.
That list answers the FLOOR question - may this pack be spent - and its
consumers (`pack_permissions`, `binding_soc_floor`) have no use for a
capacity and would have to default one. This list answers the SPLIT
question - which pack should carry more of the site target - and cannot
be asked without one.
⚠️ EVERY unit, live or not, for `packs`'s reason. The allocator drops
what `live_units()` does not list and `plan()` ignores a weight for a
unit it dropped, so filtering here would only move the same decision to
a worse place - and `packshare.weights()` needs to see a pack that did
not answer in order to withhold its opinion about the whole roster.
"""
return [pack_energy_of(inv) for inv in self.inverters]
def pack_weights(self, target_w):
"""OPT-09. The comfort weights for a site target of `target_w`.
⚠️ IT IS A METHOD HANDED TO `resolve()`, NOT A DICT COMPUTED BEFORE IT,
and that is not indirection for its own sake. An energy-headroom
weighting is direction-dependent - room above the floor when the site is
discharging, room below full when it is charging - and the two give
opposite answers on the same roster. The direction is the SIGN OF THE
SITE TARGET, which is stage 2's output, so nothing here can know it
until `resolve()` has run stage 2. `arbiter.resolve()` calls this once,
between stage 2 and stage 3, with the number it just chose.
⚠️ AND IT IS READ FRESH ON EVERY CALL, NEVER CACHED, for the reason the
allocator refuses to cache an envelope: a pack's SoC moves for reasons
that have nothing to do with the allocator, and a weighting held from
construction prefers a pack that has since emptied.
"""
return packshare.weights(self.pack_energy, target_w)
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings.
@@ -1738,6 +1888,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
@@ -1780,6 +2041,39 @@ class Controller:
# setpoint entity (§11.3), not a bound on the grid connection.
claims.append(Claim.limit("supervised", P_SAFETY, -inv.max_w, inv.max_w, "max_w",
actuator=self.actuator_of("supervised", inv)))
# --- OPT-09: WHAT THIS PACK'S STATE OF CHARGE FORBIDS -------------
# ⚠️ THE POINT OF THE CARD, AND IT IS AN ENVELOPE RATHER THAN A
# WEIGHT. Weighting an empty pack down does NOT stop it being
# commanded: `allocator._fill()` runs a deliberate last-resort pass
# over the zero-weight units when the preferred ones saturate, so
# that a preference can never manufacture a standing error at the
# meter. Only a closed envelope stops the ask, and `cap` is obeyed
# absolutely. So a pack at or under its own BMS cut-out gets
# `hi = 0` and a pack at 100 % gets `lo = 0`, and the site target
# goes to the sibling instead of being asked of a battery that
# cannot answer it.
#
# ⚠️ AT A ROSTER OF ONE THIS IS NEVER FILED, AND THAT IS THE SAME
# IDENTITY `sign_site` IS GUARDED BY RATHER THAN A SPECIAL CASE.
# `actuator_for()` routes every source to the SITE at N=1 - with one
# inverter the fabricated grid reading and the battery setpoint are
# the same number - so this interval would bound THE SITE TARGET
# there, not a share. That is a behaviour change on the live
# single-inverter install and it is the shape SAFETY-25 decided
# against on 2026-09-02 (Option A): ordinary self-consumption is not
# given an SoC floor by this card. There is also nothing for it to
# do at N=1 - no sibling to move the share to - so the whole of AC 4
# ("at N=1 allocation is byte-identical to today") is this `if`.
#
# WHAT IT COSTS, SAID PLAINLY: at N=1 nothing here stops the loop
# discharging into the BMS cut-out. That is unchanged, it is
# SAFETY-25's deferred Option B, and the BMS itself is what stops it.
if n_units > 1:
_pe = packshare.envelope(pack_energy_of(inv))
if _pe.binding:
claims.append(Claim.limit("pack", P_SAFETY, _pe.lo, _pe.hi,
_pe.why,
actuator=self.actuator_of("pack", inv)))
# The SITE bound, for the site-level consumers below. It is the roster
# sum: what this site can deliver, not what one of its inverters can.
max_w = self.site_max_w
@@ -2052,6 +2346,7 @@ class Controller:
self.arb = None
if arbitrage.enabled(self.o):
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
self.arb = arbitrage.decide(
self.fresh_tariff_plan(now), now,
peak_guard_active=arbitrage.guard_state(self.peak_guard),
@@ -2064,7 +2359,22 @@ class Controller:
soc=self.pack_floor_soc,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
charge_w=max_w, discharge_w=max_w,
# OPT-09. PER-PACK SIZING. Not `max_w` - the site rating - on
# both sides any more: the trade is sized to the packs whose own
# state of charge permits that direction, so it can never ask
# for more than the roster can take. `trade_power_w()` carries
# the argument, including which of the two SoC bounds belongs
# here and which belongs on the envelope. ⚠️ AT N=1 BOTH SIDES
# ARE `max_w` EXACTLY, whenever the trade is permitted at all:
# the one pack that passed the gate is the whole roster.
# ⚠️ AND ON THE SELL SIDE THIS IS A NO-OP TODAY on any roster,
# because `soc=` below is the EMPTIEST pack, so a sell is
# refused outright whenever any pack is at or under the floor -
# i.e. whenever this sum could be smaller than the site rating.
# It becomes reachable the day that gate becomes per-pack;
# `test_packshare.py` part 9 grades it directly rather than
# letting an unreachable branch go ungraded.
charge_w=charge_w, discharge_w=discharge_w,
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
# SITE, and arbitrage.claim() says so on the object it builds. It
# buys and sells at the grid against a day-ahead price: the price is
@@ -2269,8 +2579,24 @@ class Controller:
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
self.alloc_tick += 1
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD. ARCH-01 left the
# allocator one free parameter and ALLOC-01 built the machinery that
# consumes it (`allocator._fill`); this line is the first thing that has
# ever passed one. Without it every unit weighed 1.0 and the split was
# a fixed 50/50 regardless of what either pack could do.
#
# ⚠️ A METHOD, NOT A DICT. The weighting is by ENERGY HEADROOM and is
# therefore direction-dependent, and the direction is the sign of the
# site target - which stage 2 chooses inside `resolve()`. See
# `pack_weights()` and `packshare.weights()`; the arbiter calls it once,
# between stage 2 and stage 3.
#
# ⚠️ AND IT IS A PREFERENCE, NOT A PERMISSION. Nothing about the site's
# ability to DELIVER rests on this argument - a weight cannot stop a
# unit being commanded and cannot move a failing unit's load to its
# sibling. The `pack` claim filed above is the half that constrains.
resolution = resolve(claims, units=self.units, live=self.live_units(),
tick=self.alloc_tick)
weights=self.pack_weights, tick=self.alloc_tick)
self.allocation = resolution.allocation
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
@@ -2364,6 +2690,25 @@ class Controller:
if self._dropped_last and not _dropped:
self.log_event("ALLOCATOR: every inverter is back in the split")
self._dropped_last = _dropped
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
# `unmet_w` is the site target the envelopes could not absorb. It is
# NOT a broken allocation - `Allocation`'s own docstring says why
# fusing it with `ok` would let one saturated inverter park the whole
# site - but it IS the site failing to deliver what the loop asked
# for, and before this card nothing anywhere reported it. It is the
# state a closed envelope produces when BOTH packs are at their
# limits: the ask has nowhere left to go, and an operator watching a
# standing grid error needs the reason rather than the symptom.
# Edge-triggered on the VALUE, for `_dropped` reason.
_unmet = int(self.allocation.unmet_w if self.allocation else 0)
if _unmet != self._unmet_last:
if _unmet:
self.log_event(
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
f"placed on any inverter - {self.allocation.explain()}")
else:
self.log_event("ALLOCATOR: the whole site target fits again")
self._unmet_last = _unmet
if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope does
# not permit. It is a BUG in the filing site, not a state to survive:
@@ -2458,6 +2803,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 +3149,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 +3990,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 +4092,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 +4262,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
+449
View File
@@ -0,0 +1,449 @@
"""OPT-09 - what each pack can still do, in the two forms stage 3 accepts.
ARCH-01 left the allocator ONE free parameter - `weights` - and ALLOC-01 built
the machinery that consumes it (`allocator._fill`). Nothing ever passed one.
`main.py` called `resolve()` without `weights=`, `arbiter.resolve()` defaulted
it to None and `allocator.plan()` turned that into 1.0 for every unit, so the
split was a fixed 50/50 REGARDLESS OF WHAT EITHER PACK COULD DO. This module is
what fills that argument, and it fills the other one too - the per-unit
interval - because on their own the weights cannot do the job. Read the two
sections below in order; the second is the important one.
⚠️ THE TWO ARE NOT INTERCHANGEABLE, AND THE ALLOCATOR SAYS SO FIRST
===================================================================
`allocator.py`'s docstring states the rule this module is built on:
WEIGHTS EXPRESS PREFERENCE. ENVELOPES EXPRESS PERMISSION. A weight of 0 is
"please do not use this unit", and it is honoured while any other unit has
headroom - but it is NOT a constraint [...] To make a unit genuinely
unusable, close its envelope or leave it out of `live`.
That is mechanical, not advisory: `_fill()` runs a SECOND, last-resort pass over
the zero-weight units when the preferred ones fill up, deliberately, so that a
preference can never manufacture a standing error at the meter. So a pack at its
own cut-out that is only WEIGHTED to zero is still commanded to discharge the
moment its sibling saturates. Closing its `hi` is what actually stops the ask.
Hence this module produces two things and they answer two different questions:
envelope(pack) MAY this pack be asked, at all, in this direction?
-> a `hi`/`lo` that main.py files as a UNIT claim, and the
allocator obeys absolutely (`cap` is never overridden).
weights(packs,S) GIVEN that both may, which should carry more of it?
-> a comfort choice, in the sense ARCH-01 means: the gain
seen at the meter is a CONVEX COMBINATION of the
per-unit gains for ANY split, so this cannot destabilise
the site whatever it says.
⚠️ AND WHAT THE WEIGHTS CANNOT DO, BECAUSE THE CONVEX-COMBINATION ARGUMENT IS
EASY TO OVER-READ
============================================================================
ARCH-01 guarantees that a bad split cannot DESTABILISE the site. It says nothing
whatever about whether the site can REGULATE. A unit sitting at its energy limit
has g_i = 0 - it answers no watts to any command - and the convex combination
then contains a zero: `gbar = sum(a_i * g_i)` is still inside [min g_i, max g_i]
and the site is still stable, while the load it was asked to carry simply does
not move. `compute()` sees the resulting standing error, trips its saturation
freeze and holds the site target where it is, so the share of a unit that cannot
deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
⚠️ SO: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY.
Weighting a failing unit down is not a remedy for it. The remedies this
add-on has are the ENVELOPE (here, when the reason is the pack's own state
of charge - a fact this controller reads) and `live_units()` (when the unit
cannot be commanded at all). SAFETY-42 measured what the third case costs:
with one unit wedged and equal weights, the site settles 700 W short and
never recovers, because the freeze pins the target and the sibling's share
never rises. That module DETECTS it; this one cannot fix it, and saying so
is worth more than a weighting scheme that looks like it might.
⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's
DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2
=======================================================================
This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said
with it - that a wedged unit's weight does not move, so OPT-09 cannot see the
case at all. THE ENVELOPE HALF IS TRUE: a wedged box reads a healthy SoC and
nothing here closes. THE WEIGHT HALF WAS FALSE. `weights()` is a function of a
pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight
moves whenever its headroom differs from its sibling's - the ordinary case this
module exists to exploit.
`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds
`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's
share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real
`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its
own 10 % floor, so `envelope()` does not close and `account()` explains
nothing): at a 1500 W load the healthy pack can carry alone, equal weights name
the wedged unit on 199 of 200 cycles and these weights name it on NONE. The
suppression window widens from `site target < 2 * min_w` = 1000 W to
`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has
an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass
hands the wedged unit a big share again and the alarm returns.
⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK
**AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That
is a disclosed residual, not a fixed thing. Closing it means making
`delinquent()` relative to the ask rather than absolute - SAFETY-42's code
and a design change, filed as its own card and deliberately NOT done here.
Do not "fix" part 8b by making it assert the alarm still fires; it does not,
and a green check that says otherwise would be worse than the gap.
WHY ENERGY AND NOT POWER
========================
The two packs at this site are asymmetric IN OPPOSITE DIRECTIONS. From the
datasheet and the serials (NOTES.md 1.1b): A is 5000 W / 15.36 kWh, B is 4600 W
/ 20.48 kWh - B has about 33 % more ENERGY and about 8 % less POWER. A split by
power rating hands the trade to A; a split by energy headroom hands it to B. The
question the split is answering is "which pack can sustain its share for the
hour", so it is the energy one, and the ratings are already applied - as the
`supervised` and `device` claims, per unit, which is where a POWER bound belongs.
⚠️ INVERTER B DOES NOT EXIST YET (expected ~2026-09-12) AND NOTHING HERE IS
MEASURED ON IT. `sim/plantsim.py`'s INVERTER_B is inverter A copied, with
`measured=False` on it. Every figure above is FROM DATASHEET AND SERIAL, and
every two-unit result this card reports is TWO COPIES OF A driven with different
numbers, which is a statement about the arithmetic and not about the hardware.
WHICH FLOOR, AND WHICH CEILING - THE TWO CHOICES THAT ARE POLICY IF YOU GET
THEM WRONG
==========================================================================
⚠️ THE FLOOR IS `bms_soc_floor` ALONE, NOT `peakguard.pack_floor_pct()`'s
conjunction with `peak_soc_floor`. This is the same choice `delivery.py`'s
`account()` had to make and it is made for the same reason, one direction
further out: `peak_soc_floor` bounds the PEAK GUARD ONLY (SAFETY-25), ordinary
self-consumption discharges straight past it, and closing a unit's envelope at
it would silently turn an EMS preference into a hard stop on the grid-following
loop - which is the memo's DEFERRED Option B, decided against on 2026-09-02, and
not this card's to build. `bms_soc_floor` is a property of the battery in the
cupboard: below it the pack's own BMS stops, so a discharge command sent there
is a command that cannot be executed by anything.
⚠️ AND THE CEILING IS 100 %, WHICH IS COARSE, AND THE COARSENESS IS THE HONEST
ANSWER RATHER THAN A GAP TO BE PAPERED OVER. There is no per-pack charge ceiling
anywhere in this add-on - `delivery.py` says so at length and calls it a card,
and it is why SAFETY-42 is armed on discharge shares only. The numbers that
exist are `arbitrage_soc_ceiling` (one site-wide number, and a TRADE policy) and
nothing else. Filing a trade policy as a P_SAFETY envelope on the loop would be
exactly the mistake the paragraph above refuses, in the charge direction. So the
envelope closes only where the ask is physically impossible - a pack reporting
100 % being told to take more - and everything between is left to the WEIGHTS,
which is the right instrument for a graded preference and is honest about being
only a preference.
CAPACITY IS OPTIONAL AND "UNKNOWN" MEANS "NO OPINION"
=====================================================
`capacity_kwh` is a new, OPTIONAL per-entry key. Optional, where SAFETY-32 made
`bms_soc_floor` required, because the two absences mean different things: a
missing cut-out would have to be DEFAULTED and a default protects the shallower
pack with the deeper pack's number, whereas a missing capacity can be answered
with "then this module has no opinion about the split" - which is exactly
today's behaviour, 1.0 for every unit. `weights()` therefore answers None unless
EVERY pack in the roster states both a capacity and a state of charge: a
half-known roster would weight the packs that answered against a sibling
weighing 1.0, which is a comparison between a kWh and a dimensionless one.
"""
from dataclasses import dataclass
from math import inf, isfinite
from .peakguard import discharge_permitted
# The only charge ceiling that is a property of a PACK rather than of a policy.
# See the module docstring: `arbitrage_soc_ceiling` is a trade preference and
# must not become a P_SAFETY bound on the grid-following loop.
FULL_PCT = 100.0
# The claim source name main.py files these intervals under.
#
# ⚠️ MAIN.PY STILL WRITES THE STRING LITERALLY AND MUST GO ON DOING SO, so this
# constant is a NAME FOR THE CHECKS rather than something to import at the
# filing site. test_peakguard.py part 10f reads `Claim.limit(...)`'s FIRST
# POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set, and part
# 10g reads `actuator_of('<literal>')` the same way; both go blind on a name
# they would have to resolve. So the two spellings are tied by a check instead -
# `CLAIM_SCOPE[packshare.SOURCE] == SCOPE_UNIT` in test_packshare.py part 10 -
# which is the one direction that cannot make the scanners vacuous.
SOURCE = "pack"
@dataclass(frozen=True)
class PackEnergy:
"""ONE pack's four numbers for the two questions this module answers.
`name` the roster name. An envelope that does not say WHICH pack it
closed sends an operator to the wrong cupboard - the defect
`peakguard.Pack` and CFG-04's `unit_caps_indexed()` were
both written for.
`soc` THIS pack's measured state of charge, in percent. None when
its sensor did not answer, and ⚠️ None IS NOT A FULL
BATTERY - `discharge_permitted()` says why at length.
`bms_floor` THIS pack's OWN `bms_soc_floor`, the percent at which its
BMS cuts out. Required of every roster entry since
SAFETY-32, so at a declared roster this is never None.
`capacity_kwh` THIS pack's usable capacity. None when the entry did not
state one, which means "no opinion" and never 0 - a zero
capacity is a pack that may never be used, which is a
decision nobody made by omitting a key.
`max_w` THIS unit's supervised rating. ⚠️ IT IS NOT AN INPUT TO THE
SPLIT AND MUST NOT BECOME ONE - the whole of deliverable 2
is that the split is by ENERGY and the rating is applied
separately, per unit, as the `supervised` and `device`
claims. It is here for `trade_power_w()` alone, which
answers a different question: how many watts the packs that
are ALLOWED to take a trade can take between them.
⚠️ IT IS A SEPARATE TYPE FROM `peakguard.Pack` RATHER THAN AN EXTENSION OF
IT, AND THAT IS DELIBERATE. `Pack` carries the three numbers the FLOOR
question needs and is consumed by `pack_permissions()` and
`binding_soc_floor()`, whose answers are site verdicts about spending a pack
for money. This one carries a fourth number that is meaningless to those
reductions and would have to be defaulted into them. Two small types with
one purpose each beat one type with a field half its callers must ignore.
"""
name: str
soc: float | None
bms_floor: float | None
capacity_kwh: float | None
max_w: float = 0.0
@dataclass(frozen=True)
class PackEnvelope:
"""The interval OPT-09 asks for on one pack, and the sentence that says why.
`(-inf, +inf)` is "no opinion" and is the common answer; `binding` is how a
caller asks whether there is anything to file. A claim filed for every pack
on every cycle regardless would put an open limit into the arbiter's
considered list on every tick, which costs nothing arithmetically and buries
the one cycle where it means something.
"""
lo: float = -inf
hi: float = inf
why: str = ""
@property
def binding(self) -> bool:
return self.lo != -inf or self.hi != inf
def usable_kwh(pack) -> float | None:
"""Energy this pack can still DISCHARGE before its own BMS cuts out.
None when it cannot be computed - no capacity, no reading, or no floor - and
never 0.0 for those, because "no opinion" and "nothing left" are the two
answers a weighting must not confuse: the first weighs 1.0 and the second
weighs nothing at all.
Clamped at 0.0 from below. A pack measured BELOW its own cut-out is at
-0.4 kWh of usable energy by arithmetic and at zero in fact.
"""
if (pack.capacity_kwh is None or pack.soc is None
or pack.bms_floor is None):
return None
return max(0.0, float(pack.capacity_kwh)
* (float(pack.soc) - float(pack.bms_floor)) / 100.0)
def spare_kwh(pack) -> float | None:
"""Energy this pack can still ACCEPT before it is full.
`FULL_PCT`, not `arbitrage_soc_ceiling`: the module docstring carries the
argument, and it is the same one that keeps `peak_soc_floor` out of
`usable_kwh()`. A pack's own fullness is a fact; a trade's reserve is not.
⚠️ AND IT IS A WEIGHTING INPUT, NOT A PERMISSION. It says nothing about the
constant-voltage taper, in which a pack legitimately accepts far less than
it is told for tens of minutes - there is no configured ceiling in this
add-on that could describe that, which is `delivery.py`'s stated reason for
being armed on discharge shares only.
"""
if pack.capacity_kwh is None or pack.soc is None:
return None
return max(0.0, float(pack.capacity_kwh)
* (FULL_PCT - float(pack.soc)) / 100.0)
def envelope(pack) -> PackEnvelope:
"""The interval this pack may be commanded in, from its state of charge.
Positive is DISCHARGE throughout this add-on, so:
* a pack at or below its own cut-out gets `hi = 0` - it may still be
charged, it may not be asked to discharge;
* a pack at 100 % gets `lo = 0` - it may still be discharged, it may not
be asked to charge.
⚠️ THE DISCHARGE HALF ASKS `peakguard.discharge_permitted()`, THE PREDICATE
SAFETY-32 MADE PUBLIC, rather than writing `soc <= floor` a third time. That
function is already the one place this question is answered - for the peak
guard's own verdict and for `delivery.account()`'s discriminator - and a
safety predicate transcribed into a third copy is two future disagreements
about what it means. What is NOT reused is its wording, which is peak-guard
flavoured; the floor handed in is the pack's own cut-out, exactly as
`delivery.account()` hands it.
⚠️ AND A MISSING READING OR A MISSING FLOOR WITHHOLDS THE CLOSURE, NOT THE
DISCHARGE - WHICH IS THE OPPOSITE DIRECTION FROM `discharge_permitted()`'s
OWN, ON PURPOSE. That predicate answers "may this site SPEND this pack for
money", where an unread SoC must not authorise the spend and a full battery
is exactly the value that would disarm the floor. The question here is
different: whether to take a unit's share away from it and hand it to a
sibling. An absent measurement is not evidence that a battery is flat, and
closing on it would park a healthy unit's discharge on every tick for as
long as its sensor was missing - moving load onto its sibling on the
strength of a reading nobody took.
Both absences already have a stronger answer arriving first, which is why
this one costs nothing: a missing SoC stops the WHOLE SITE through
`inputs_ok` (a site-level P_SAFETY set of 0 W), and a roster entry with no
`bms_soc_floor` is refused at startup by validate.py rule 8 and by
config.yaml's own schema, which requires the key of every entry. So the
conjunction below is "positive evidence only": both numbers present, and the
predicate says the pack is at or under its own cut-out.
⚠️ `discharge_permitted()` IS STILL THE PREDICATE, and it is still the only
place the comparison is written - what this module decides for itself is the
two None cases, which it decides differently and says why. The BOUNDARY
(`<=`, so a pack sitting exactly on its floor is closed) comes from there
and is graded against it over the whole percent range in test_packshare.py
part 2, so a future change to that boundary moves both.
⚠️ AT P_SAFETY IT BINDS THE MAINTENANCE DRAIN TOO, AND THAT IS RIGHT RATHER
THAN AN OVERSIGHT. main.py files this as a `P_SAFETY` limit, so clause 2 of
the arbiter's rule applies it to the §12 recalibration drain (a SET at
P_MAINTENANCE): a drain into a pack already at its BMS cut-out is clamped to
0 W. On the shipped numbers it cannot even bite - `maintenance_soc_floor`
defaults to 11 % against a 10 % cut-out, so the drain finishes above this
interval. On a config that sets the drain target BELOW the cut-out
(`int(5,30)` against `int(0,50)`, so the schema permits it) the drain stalls
and the FSM's 12 h DRAIN timeout eventually reports "drain stuck" - which is
what ALREADY happens without this card, because the pack's own BMS refuses
the command whatever the allocator sends. The failure is the config's, the
remedy is the same, and this envelope does not make it worse; it only makes
the refusal visible one layer earlier, in the allocation record.
⚠️ BOTH ENDS CANNOT CLOSE AT ONCE ON ANY CONFIGURATION THE SCHEMA ACCEPTS.
It would take a pack reporting at least 100 % while sitting at or under its
own cut-out, and `config.yaml` bounds `bms_soc_floor` at `int(0,50)` on both
the flat key and every roster entry - so the two conditions cannot both hold.
If they somehow did, the result is `[0, 0]`, which is an interval containing
exactly one integer rather than a contradiction, and the allocator commands
that unit 0 W. Nothing is special-cased for it here: inventing a second
opinion about an unreachable state is how unreachable states get opinions
that are wrong.
"""
hi, lo, why = inf, -inf, ""
if (pack.soc is not None and pack.bms_floor is not None
and discharge_permitted(pack.soc, pack.bms_floor) is not None):
hi = 0.0
why = (f"{pack.name} is at {float(pack.soc):g} %, at or under its own "
f"{float(pack.bms_floor):g} % BMS cut-out - it is not asked to "
f"discharge")
if pack.soc is not None and float(pack.soc) >= FULL_PCT:
lo = 0.0
why = (f"{pack.name} is at {float(pack.soc):g} % and cannot take a "
f"charge - it is not asked to")
return PackEnvelope(lo=lo, hi=hi, why=why)
def weights(packs, target_w) -> dict | None:
"""Per-unit comfort weights for THIS tick's site target, or None for none.
The weight of a pack is the energy it has left IN THE DIRECTION THE SITE IS
ABOUT TO TRAVEL - usable energy above its own cut-out when the site is
discharging, room below full when it is charging. `allocator._fill()` uses
only the RATIOS, so the unit is free and kWh is the honest one to leave it
in: it is what a reader of the action log can check against the datasheet.
⚠️ THE DIRECTION IS AN ARGUMENT BECAUSE IT IS NOT KNOWABLE ANY EARLIER. The
two directions give OPPOSITE answers on the same roster - A at 20 % of
15.36 kWh and B at 80 % of 20.48 kWh weigh 1.5 : 14.3 to discharge and
12.3 : 4.1 to charge - so a direction-free weighting is not a compromise
between them, it is wrong in one of the two on every tick. The site target
is chosen by `arbiter.site_resolve()`, i.e. by stage 2, so the earliest
honest moment to ask this is between stage 2 and stage 3. That is why
`resolve()` accepts a CALLABLE here and calls it with the resolved target,
and why main.py hands it a method rather than a dict.
⚠️ `target_w >= 0` IS THE DISCHARGE SIDE, AND IT IS THE SITE TARGET'S SIGN
RATHER THAN THE FILL'S. `allocator.plan()` decides its own direction from
`need = remainder - placed`, which differs from `sign(target_w)` only when a
PIN or an envelope that excludes zero has already moved the base - a
maintenance drain, a failsafe park. On those ticks the weighting is computed
for the other direction and is therefore merely a worse preference; it
cannot be unsafe, for ARCH-01's reason, and it cannot break the sum, which
is integer arithmetic in the allocator. Chasing the exact direction would
mean handing this function into `plan()` and calling it from inside the
fill, which would make the allocator's purity depend on a caller's callback.
None means "no opinion, weigh everything 1.0", and there are three ways to
get it, all of which are today's behaviour rather than a new one:
* fewer than two packs. At N<=1 `plan()` is a pass-through and a weighting
would be arithmetic nobody reads;
* any pack that cannot state its headroom - see the module docstring on
why a half-known roster is worse than an unweighted one;
* every pack at zero headroom in this direction. All-zero weights would
put the whole roster into `_fill()`'s LAST-RESORT pass, where the shares
come out equal anyway and every `UnitPlan.why` reads "last resort: the
preferred units were full" - a true sentence about the wrong thing.
"""
packs = list(packs)
if len(packs) < 2:
return None
room = (usable_kwh if float(target_w) >= 0.0 else spare_kwh)
out = {}
for p in packs:
kwh = room(p)
if kwh is None or not isfinite(kwh):
return None
out[p.name] = float(kwh)
if not any(v > 0.0 for v in out.values()):
return None
return out
def trade_power_w(packs, floor_pct, ceiling_pct) -> tuple:
"""`(charge_w, discharge_w)` - how hard an arbitrage trade may be SIZED.
The site rating is what the site can push; this is what the site can push
AND STILL HAVE SOMEWHERE TO PUT IT. `arbitrage.decide()` was handed
`site_max_w` on BOTH sides, so a two-pack site would ask to buy at the sum of
the ratings whatever either pack's state of charge was - and the watts that
could not be placed would come back as `unmet_w` for the site integrator to
chase for the rest of the hour.
⚠️ THE BOUNDS ARE ARBITRAGE'S OWN (`arbitrage_soc_floor` /
`arbitrage_soc_ceiling`) AND NOT THE PACK'S BMS CUT-OUT, BECAUSE SIZING A
TRADE IS THE TRADE'S BUSINESS. This answers "how much may this strategy ask
for"; `envelope()` above answers "what may this pack be commanded at all",
and that one is the pack's own physical cut-out for SAFETY-25's reason.
Sizing with the physical floor would let the strategy ask for watts its own
reserve forbids; bounding the envelope with the trade's reserve would let a
trade preference stop the grid-following loop. TWO QUESTIONS, TWO NUMBERS.
⚠️ AND IT SIZES THE ASK, IT DOES NOT ROUTE IT. Which pack carries the trade
is the allocator's answer, and the only per-pack instrument it has for a
TRADE reserve is the weights - which are a preference, not a permission. So
a pack between its arbitrage floor and its BMS cut-out can still take part
of a sell. That is stated rather than fixed: fixing it needs an envelope
that depends on WHICH CLAIM WON the tick, which the arbiter's partition does
not express, and it is a card of its own.
A pack whose SoC did not read is counted on NEITHER side: withholding is the
direction that cannot over-size, and `inputs_ok` has stopped the site by
then anyway.
⚠️ NEITHER SIDE MAY REACH `arbitrage.claim()` AS A ZERO - a 0 W arbitrage
claim is a PARK ORDER that outranks the loop. It cannot be zero on a tick
that trades today, because the sell gate refuses on the emptiest pack and
the buy gate refuses when every pack is at the ceiling; `arbitrage.decide()`
refuses a 0 W sizing by name anyway, so that is structural rather than a
coincidence of the gates.
"""
return (float(sum(p.max_w for p in packs
if p.soc is not None and float(p.soc) < ceiling_pct)),
float(sum(p.max_w for p in packs
if p.soc is not None and float(p.soc) > floor_pct)))
+49 -1
View File
@@ -570,7 +570,7 @@ def _roster_problems(opts: dict) -> list[str]:
rules up. A rule the Supervisor duplicates costs nothing and covers the
callers it does not.
Five things are refused, and each is a config that would otherwise RUN:
Seven things are refused, and each is a config that would otherwise RUN:
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
can name it; without the rule the roster would be one inverter built
@@ -593,6 +593,18 @@ def _roster_problems(opts: dict) -> list[str]:
config. Refused rather than ignored, for §1.5's reason and for this
card's: a config that silently becomes a different config is worse than
one that refuses to load.
f. OPT-09. An entry whose `capacity_kwh` is stated but is not a usable
positive number of kWh. It is read to weight the SPLIT by energy
headroom; a zero, a negative or a word is a pack the split would
quietly stop preferring while the operator can see a value in the box.
g. OPT-09. `capacity_kwh` stated on SOME entries and not others. The
weighting is a COMPARISON between packs, so a pack measured in kWh
against a sibling weighing a dimensionless 1.0 is not one -
`packshare.weights()` answers "no opinion" for the WHOLE roster in that
case, which means every key the operator filled in does nothing at all
and nothing says so. All or none, and this is clause (e)'s argument
restated: a config that silently becomes a different config is worse
than one that refuses to load.
"""
problems: list[str] = []
raw = opts.get("inverters")
@@ -651,6 +663,42 @@ def _roster_problems(opts: dict) -> list[str]:
"sibling is exactly what DEV-02 removed. State this unit's own "
"rating in watts.")
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
stated, blank = [], []
for i, entry in enumerate(entries):
if not isinstance(entry, dict):
continue # already refused above, by name
raw = entry.get("capacity_kwh")
nm = str(entry.get("name") or "").strip()
who = f"inverters[{i}] ({nm})" if nm else f"inverters[{i}]"
if raw is None or raw == "":
blank.append(who)
continue
try:
val = float(raw)
except (TypeError, ValueError):
val = None
if val is None or not (val > 0.0):
problems.append(
f"{who} has capacity_kwh {raw!r}, which is not a usable number "
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
"read to weight the split of the site target by ENERGY headroom "
"rather than by power rating; a zero or an unparseable value is "
"a pack the split would quietly stop preferring while the box "
"still shows a value. State it in kWh, or leave it empty on "
"every entry.")
else:
stated.append(who)
if stated and blank:
problems.append(
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
"split weights one pack's energy headroom against another's, so a "
"pack measured in kWh beside a sibling that weighs a dimensionless "
"1.0 is not a comparison - the add-on answers 'no opinion' for the "
"whole roster and the value you filled in changes nothing. State a "
"capacity for every inverter, or for none of them.")
flat_sp = str(opts.get("setpoint_entity") or "").strip()
if entries and flat_sp:
problems.append(
+83 -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
@@ -108,6 +108,25 @@ options:
# EVERY entry and names the one it refuses. The flat key below is unchanged and
# is the single-inverter shape's own cut-out.
#
# ⚠️ OPT-09: `capacity_kwh` IS PER ENTRY AND IS OPTIONAL, WHICH IS THE
# OPPOSITE CHOICE TO `bms_soc_floor` ABOVE AND IS MADE FOR THE SAME REASON.
# It is THAT pack's usable capacity in kWh, and it is read for exactly one
# purpose: weighting the SPLIT of the site target between packs by ENERGY
# headroom rather than by power rating. The two packs here are asymmetric in
# OPPOSITE directions - 5000 W / 15.36 kWh against 4600 W / 20.48 kWh, from
# the datasheet and the serials - so a split by rating and a split by energy
# hand the work to DIFFERENT packs, and the energy one is the question ("which
# pack can sustain its share for the hour"). A missing cut-out would have to be
# DEFAULTED, and one default for two batteries protects the shallower pack with
# the deeper pack's number; a missing capacity has an honest answer instead -
# this add-on then has NO OPINION about the split and every unit weighs 1.0,
# which is exactly what shipped before this card. ⚠️ IT IS ALL OR NOTHING
# ACROSS THE ROSTER: state it on every entry or on none, because a pack
# weighted in kWh against a sibling weighing a dimensionless 1.0 is not a
# comparison. There is deliberately NO flat `capacity_kwh` - at one inverter
# there is no split to weight, so the key would be one an operator can set and
# nothing reads.
#
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
# roster is declared is two config sites aimed at actuators, one of which
# nothing writes; if it names an entity an entry also names, SAFETY-28's
@@ -511,6 +530,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
# ==========================================================================
@@ -575,6 +640,12 @@ schema:
# enforced per entry, a missing required key names the key, a scalar where the
# list belongs is refused, and the UI renders it as a repeatable section.
#
# ⚠️ AND OPT-09's `capacity_kwh` IS DECLARED OPTIONAL (`?`), UNLIKE
# `bms_soc_floor`, WHICH IS THE ARGUMENT IN THE OPTIONS BLOCK ABOVE: an
# optional cut-out is a defaulted one and a default is one number for two
# batteries, whereas an absent capacity has an honest answer - no opinion
# about the split, every unit weighs 1.0, which is what shipped.
#
# ⚠️ THREE KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with
# no `name` has no Store namespace and would share another unit's SAFETY-02
# latch; one with no `setpoint_entity` is a unit nothing can command; one with
@@ -596,6 +667,7 @@ schema:
soc_entity: str?
batt_entity: str?
batt_invert: bool?
capacity_kwh: float(0.1,200)?
estop_fitted: bool
@@ -772,6 +844,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
# ==========================================================================
+13 -4
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print()
print("ARB-03 2. the split: eight sources, each classified where it is BUILT")
print("ARB-03 2. the split: eleven sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -180,16 +180,25 @@ UNIT_SOURCES = {
"measured power (0x0518), so what it learns is about one "
"device. The site-wide half moved to `sign_site`",
"maintenance": "the monthly recalibration is a pack procedure",
# OPT-09, and it is the entry a reader will want to check against
# SAFETY-25's deferred loop-side reserve, because it has the same shape.
"pack": "THIS pack's own state of charge against THIS pack's own "
"`bms_soc_floor` - a fact about one battery in one cupboard, "
"exactly as `device`'s rating is a fact about one box. It is "
"not SAFETY-25's deferred `loop_soc_floor` reserve: no option "
"is added, `peak_soc_floor` is not consulted, the SITE target "
"is untouched, and main.py does not file it at all at a "
"roster of one",
}
for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all TEN claim sources are classified, and only those ten",
check("all ELEVEN claim sources are classified, and only those eleven",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the six that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the four that are about a PACK, a DEVICE or one write are per-inverter",
check("the five that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
check("a source nobody classified cannot be routed at all - actuator_for() "
"raises rather than defaulting to the site, because defaulting to the "
@@ -207,7 +216,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the four "
check("with TWO the identity breaks and the split becomes real: the five "
"per-inverter sources route to the unit this controller drives, the six "
"site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
+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
+18 -3
View File
@@ -1529,14 +1529,23 @@ check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
"⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS "
"`int(0,50)?`, and the difference is the whole of that card: an optional "
"per-pack cut-out is a DEFAULTED one, and a default is one number for "
"two batteries - the shallower pack protected by the deeper pack's BMS",
"two batteries - the shallower pack protected by the deeper pack's BMS. "
"⚠️ OPT-09 ADDED `capacity_kwh` AND MADE THE OPPOSITE CHOICE ON PURPOSE: "
"it is OPTIONAL (`float(0.1,200)?`) because an absent capacity has an "
"honest answer - the add-on then has no opinion about the SPLIT and every "
"unit weighs 1.0, which is exactly what shipped - where an absent cut-out "
"would have to be invented. If you are here because you added a REQUIRED "
"key, read `Inverter.__init__` before changing this line: a required key "
"refuses every options blob written before it existed",
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
"soc_entity", "batt_entity", "batt_invert"}
"soc_entity", "batt_entity", "batt_invert",
"capacity_kwh"}
and not any(_decl9[k].endswith("?")
for k in ("name", "setpoint_entity", "max_w",
"bms_soc_floor"))
and all(_decl9[k].endswith("?")
for k in ("soc_entity", "batt_entity", "batt_invert")))
for k in ("soc_entity", "batt_entity", "batt_invert",
"capacity_kwh")))
# ⚠️ NOTHING IS INHERITED, AND THIS IS DEV-02 RESTATED AS A CONFIG RULE.
# ⚠️ THE FLAT KEYS ARE SET TO REAL ENTITIES HERE, AND THAT IS THE WHOLE CHECK.
@@ -1917,6 +1926,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.
File diff suppressed because it is too large Load Diff
+78 -27
View File
@@ -2576,9 +2576,42 @@ check("...and so does the persistent notification, which outlives the page and "
# observe exactly {device, supervised, loop} at every SoC, because the shipped
# roster is one inverter and this claim cannot be filed there at all. See
# DECISIONS/SAFETY-30-per-unit-sign.md.
# ⚠️ OPT-09 ADDED THE ELEVENTH, AND IT IS THE ONE THIS TRIPWIRE WAS BUILT FOR
# THE SHAPE OF, SO IT NEEDS THE LONGEST PARAGRAPH. `pack` is a
# `Claim.limit(... P_SAFETY ...)` on ONE INVERTER'S actuator, filed only where it
# BINDS and only at a roster of TWO OR MORE, carrying that pack's own state of
# charge against that pack's own `bms_soc_floor`: `hi = 0` for a battery at or
# under its cut-out, `lo = 0` for one at 100 %.
#
# IT IS CONDITIONED ON A STATE OF CHARGE, WHICH IS EXACTLY WHAT THIS SET WATCHES
# FOR, so the argument has to be made rather than asserted. SAFETY-25's deferred
# Option B was `Claim.limit("reserve", P_LOOP, hi=0.0)` under a NEW
# `loop_soc_floor` option: an EMS PREFERENCE, chosen by an operator, stopping the
# grid-following loop at a depth somebody picked, ON THE SITE. Four things
# separate this from it, and all four are graded elsewhere in this repository:
#
# 1. THE NUMBER IS NOT AN OPTION. It is `bms_soc_floor` - measured off
# register 0x0560 and required of every roster entry since SAFETY-32. No
# new option is added and `peak_soc_floor` is deliberately NOT consulted
# (test_packshare.py part 2 grades that a pack at 15 % with a 10 % cut-out
# is NOT closed, though a 20 % EMS floor would have closed it).
# 2. IT IS A UNIT CLAIM, SO IT CANNOT BOUND THE SITE TARGET. It changes WHICH
# INVERTER carries the target, never how big the target is.
# 3. IT IS NOT FILED AT ALL AT A ROSTER OF ONE, which is why the sweeps below
# still observe exactly {device, supervised, loop} at every state of charge
# from 0 to 100 % on the shipped install. Option A stands untouched there:
# ordinary self-consumption still discharges to the BMS cut-out.
# 4. AND THE MEMO ITSELF NAMES THIS AS THE REVISIT: "under ALLOC-01 the
# reserve becomes a PER-INVERTER INTERVAL AT THE ALLOCATOR rather than a
# ninth claim here". Stage 1 is how an interval reaches the allocator, and
# stage 1's input is claims - so a unit-scoped claim is the shape that
# sentence asks for.
#
# If you are here because you added a TWELFTH, that argument is what this line
# wants from you, not an edit.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation",
"sign_site"}
"sign_site", "pack"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan.
@@ -2619,10 +2652,11 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these nine. THIS is "
"what makes part 10 a tripwire: replay_loop() hand-builds the claim list, "
"so it cannot see a claim main.py adds - and a loop-side SoC reserve is a "
"NINTH source. Filing one takes this line red on the line it is written",
check("...and the sources that cycle can file are EXACTLY these ELEVEN. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it "
"is written, and OPT-09's `pack` had to argue its way past it above",
set(_filed) == CLAIM_SOURCES)
@@ -2749,8 +2783,8 @@ check("...and replay_loop() folding main.py's TWO P_SAFETY rating limits into "
# structurally impossible. The structure is `CLAIM_SCOPE`, so `CLAIM_SCOPE` is
# pinned to the AST-scanned set rather than trusted to agree with it.
check("every source main.py can file is CLASSIFIED site or per-inverter, and "
"nothing else is. A TENTH source now goes red twice - once here for "
"having no classification, once above for being a tenth - and it takes an "
"nothing else is. A TWELFTH source now goes red twice - once here for "
"having no classification, once above for being a twelfth - and it takes an "
"argument about what the claim is ABOUT to make either line green",
set(CLAIM_SCOPE) == CLAIM_SOURCES)
print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
@@ -2758,8 +2792,8 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
+ " | per-inverter: "
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
"SAFETY-30's additions: the six about the GRID CONNECTION are site "
"claims and the four about a pack, a device or one write are "
"SAFETY-30's and OPT-09's additions: the six about the GRID CONNECTION "
"are site claims and the five about a pack, a device or one write are "
"per-inverter. `circulation` is a site claim because its quantity is "
"defined over the roster and is identically zero for any one unit; "
"`sign_site` is one because the number that tripped it is the SITE "
@@ -2769,7 +2803,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance"})
== {"device", "supervised", "failsafe", "maintenance", "pack"})
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
# real cycle files is a site claim. Observed on the claims main.py's own resolve
@@ -2853,8 +2887,9 @@ _restore_b()
print(" with a second inverter on the roster, main.py routes: "
+ ", ".join(f"{s}->{a}" for s, a in sorted(_routed.items())))
check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"inverter and its OWN routing method splits the ten: `device`, "
"`supervised`, `failsafe` and `maintenance` bind the unit this controller "
"inverter and its OWN routing method splits the eleven: `device`, "
"`supervised`, `failsafe`, `maintenance` and OPT-09's `pack` bind the "
"unit this controller "
"drives, `loop`, `peak`, `arbitrage`, `safety`, SAFETY-31's `circulation` "
"and SAFETY-30's `sign_site` stay on the grid connection. Swap any one of "
"them - route `peak` to the unit and the site keeps no floor; route "
@@ -2865,6 +2900,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"rest of the roster goes on pushing the same way - and this line goes red",
_routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a",
"pack": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
@@ -2948,16 +2984,16 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all TEN of `Controller.cycle()`'s DIRECT appends route through "
check("all ELEVEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 10
len(_direct) == 11
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE TEN THE LITERAL IT ROUTES THROUGH IS THE "
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
"SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed "
"`actuator_of(...)` at a call site goes red on - all nine of them, on "
"the line the typo is written, at every roster, INCLUDING the six "
@@ -2971,14 +3007,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong "
"share. Five of the ten appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe` and maintenance's two - and all five pass one. "
"share. Six of the eleven appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two and OPT-09's `pack` - and "
"all six pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
"the site, beside `circulation`, because the number that tripped it is "
"the site target",
_unit_sources_unrouted == []
and len([r for r in _routing
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 5)
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 6)
check("...and the only appends this pin does NOT reach are the two that file a "
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
"`actuator=` at the call site to mis-type. Their routing is pinned where "
@@ -2987,7 +3024,7 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the eleven appends account for exactly the nine "
check("...and between them the twelve appends account for exactly the eleven "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3027,10 +3064,19 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
"connection",
sorted((c.source, c.actuator) for c in _cl2)
== [("device", "inverter-a"), ("device", "inverter-b"),
("loop", ACTUATOR_SITE), ("supervised", "inverter-a"),
("loop", ACTUATOR_SITE),
# ⚠️ OPT-09, AND IT IS OBSERVED HERE RATHER THAN ONLY PINNED
# STATICALLY. This fixture leaves both packs at 100 % - the last value
# of the SoC sweep above - so each one's `pack` claim closes its `lo`
# at 0 W: a full battery is not asked to charge. It is on ITS OWN
# actuator, which is the half a `pack` limit filed on the site would
# fail, and it appears ONLY here: the one-inverter sweep above files
# no `pack` claim at any state of charge, which is AC 4.
("pack", "inverter-a"), ("pack", "inverter-b"),
("supervised", "inverter-a"),
("supervised", "inverter-b")])
check("...and naming those three is not an accident of this fixture but the "
"reason the static pin exists - SEVEN of the ten sources need a state "
"reason the static pin exists - SEVEN of the eleven sources need a state "
"no check in this file reaches. SAFETY-31's `circulation` is one of the "
"seven: it is filed only while the site is MEASURED to be circulating, "
"which no fixture here is. SAFETY-30's `sign_site` is another, and it "
@@ -3079,16 +3125,21 @@ check("...and so is each unit's `device` bound, which is the other number the "
"inverter's reported range bound nothing at all",
_dev2 == {"inverter-a": (-LIVE_MAX_W, LIVE_MAX_W),
"inverter-b": (-B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS)})
_n2_plans = {p.unit: (p.lo, p.hi) for p in (_n2_alloc.plans if _n2_alloc else ())}
print(f" the envelopes the ARBITER RESOLVED: {_n2_plans}")
check("...and the bounds the ARBITER RESOLVED for inverter B - not just the "
"claims filed at it - are B's own, because a claim filed correctly and "
"then resolved against the wrong envelope would be the same defect one "
"layer down",
"layer down. ⚠️ THE `hi` IS B'S OWN RATING AND THE `lo` IS 0 W, and the "
"second half is OPT-09 rather than a defect: both packs in this fixture "
"sit at 100 % - the last value of the SoC sweep above - so each one's "
"`pack` claim closes its charge end. The two units' envelopes are still "
"DIFFERENT NUMBERS, which is what this check exists to observe: a bound "
"copied from the first unit would read +5000 on B",
_n2_alloc is not None
and next((p for p in _n2_alloc.plans if p.unit == "inverter-b"), None)
is not None
and (lambda p: p.lo == -B_RATING_HYPOTHESIS
and p.hi == B_RATING_HYPOTHESIS)(
next(p for p in _n2_alloc.plans if p.unit == "inverter-b")))
and _n2_plans.get("inverter-b") == (0.0, B_RATING_HYPOTHESIS)
and _n2_plans.get("inverter-a") == (0.0, LIVE_MAX_W)
and _n2_plans["inverter-a"] != _n2_plans["inverter-b"])
# --- 10h. ALLOC-01: WHAT MAIN.PY DOES WITH AN ALLOCATION IT CANNOT DELIVER ---
+10 -4
View File
@@ -74,7 +74,7 @@ check(f"the option surface was actually read - {len(ORDER)} options, not zero",
check("...and the repeatable `inverters:` section's per-entry keys were read "
"too, which is the half a top-level-only reader would miss",
NESTED.get("inverters", [])[:2] == ["name", "setpoint_entity"]
and len(NESTED["inverters"]) == 7)
and len(NESTED["inverters"]) == 8)
print("\n2. AC: every option and every per-entry key is translated, in BOTH "
"languages")
@@ -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"),
@@ -243,10 +248,11 @@ for what, text in BAD:
check(f"every shape outside the accepted subset RAISES rather than being "
f"skipped. Silently accepted: {_accepted}", not _accepted)
check("...and the accepted subset really does round-trip the shipped files - "
"70 options and 7 per-entry keys came back out of each, which is what "
"says the strictness above is not just refusing everything",
"70 options and 8 per-entry keys came back out of each (OPT-09 added the "
"eighth, `capacity_kwh`), which is what says the strictness above is not "
"just refusing everything",
all(len(TR[lg]["configuration"]) == len(ORDER)
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 7
and len(TR[lg]["configuration"]["inverters"]["fields"]) == 8
for lg in LANGS))
print(" ⚠️ the licence for this hand parser is NOT this file. "
"tools/supervisor_shape_probe.py section F loads the same two files with "
+47
View File
@@ -264,6 +264,53 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the "
"setpoint_entity": " "}]),
-5000, 5000), "has no setpoint_entity"))
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
# RUN, which is the bar every clause of this rule is held to. A capacity that is
# not a usable number is a pack the split would quietly stop preferring while
# the operator can see a value in the box; a capacity on SOME entries is worse,
# because `packshare.weights()` answers "no opinion" for the WHOLE roster in
# that case - so every key the operator filled in does exactly nothing and
# nothing says so.
def _with_cap(*caps):
o = {k: v for k, v in _PER_INVERTER_OK.items()}
o["inverters"] = [dict(e) for e in _PER_INVERTER_OK["inverters"]]
for entry, cap in zip(o["inverters"], caps):
if cap is not None:
entry["capacity_kwh"] = cap
return o
check("a roster stating a capacity on EVERY entry is accepted - and the two "
"differ (15.36 / 20.48, FROM DATASHEET for B), because a fixture whose "
"two per-unit numbers are equal cannot show a check reading the wrong "
"unit's",
validate_startup_config(_with_cap(15.36, 20.48), -5000, 5000) == [])
check("...and a roster stating NONE is accepted too, which is what OPTIONAL "
"means: the add-on has no opinion about the split and every unit weighs "
"1.0, exactly as it did before this card",
validate_startup_config(_with_cap(None, None), -5000, 5000) == [])
check("a capacity that is not a usable number of kWh is REFUSED, and the "
"refusal names the entry - zero, negative and a word alike",
all(why(validate_startup_config(_with_cap(15.36, bad), -5000, 5000),
"capacity_kwh") for bad in (0, -3, "twenty", 0.0)))
check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left empty "
"on the other. It is the clause that costs an operator something, and it "
"is the one worth having: a pack measured in kWh beside a sibling "
"weighing a dimensionless 1.0 is not a comparison, so the key would "
"silently change nothing at all",
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
"not PARTIAL")
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
"not PARTIAL"))
check("...and the refusal names WHICH entry is which, because an operator with "
"three inverters told 'one of them is wrong' is sent to the wrong "
"cupboard",
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
"inverter-a")
and why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
"inverter-b"))
# --------------------------------------------------------------------------- #
print("3. a meter that can never read")
# ⚠️ These predicates belong to p1.validate_meter_options(), which build_source()
+6
View File
@@ -78,6 +78,9 @@ configuration:
batt_invert:
name: "Battery power sign is reversed (optional)"
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
capacity_kwh:
name: "Usable battery capacity (kWh) (optional)"
description: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or its label. It is read for one purpose: dividing the site's target between the inverters by how much ENERGY each pack has left rather than by how many watts it is rated for. The two packs at the reference site are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so those two ways of dividing the work hand it to different batteries, and the energy one is the question worth asking. Optional: leave it empty and the add-on has no opinion, dividing the target evenly exactly as it did before. State it on EVERY inverter or on none of them - a pack measured in kWh next to one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you filled in."
estop_fitted:
name: "Hardware · RS485 emergency stop is fitted"
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
@@ -234,6 +237,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."
+6
View File
@@ -58,6 +58,9 @@ configuration:
batt_invert:
name: "Teken van het batterijvermogen omkeren (optioneel)"
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
capacity_kwh:
name: "Bruikbare capaciteit van de batterij (kWh) (optioneel)"
description: "De bruikbare capaciteit van DEZE batterij in kilowattuur, van het datablad of het typeplaatje. Ze wordt voor één ding gelezen: het verdelen van de doelwaarde van de installatie over de omvormers volgens hoeveel ENERGIE elke batterij nog heeft, in plaats van volgens het vermogen waarvoor ze gebouwd is. De twee batterijen op de referentiesite zijn 5000 W met 15,36 kWh en 4600 W met 20,48 kWh, dus die twee manieren van verdelen wijzen het werk aan verschillende batterijen toe, en de energieverdeling is de juiste vraag. Optioneel: laat leeg en de add-on heeft geen voorkeur en verdeelt gelijk, precies zoals voordien. Vul ze in bij ELKE omvormer of bij geen enkele - een batterij in kWh naast een batterij zonder waarde is geen vergelijking, en de add-on start liever niet op dan de helft van wat u invulde te negeren."
estop_fitted:
name: "Hardware · RS485-noodstop is geplaatst"
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
@@ -214,6 +217,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."