Compare commits

...
18 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 1448bd909b SAFETY-43: the no_account switch was graded by nothing
checks / unit (pull_request) Successful in 6m29s
Review's remaining non-blocking finding, and it is B1's shape one level down -
in the fix for B1.

`wedged_loop`'s `no_account=True` was used in exactly ONE place: the cut-out
fixture, where it PROVABLY cannot change the answer, because a pack at its own
cut-out is never asked and the gate is never reached. Review neutered the switch
so it removed nothing, and the file stayed GREEN while printing

    ...named=(nobody); with account() DELETED: 0/200

with `account()` not deleted. A line asserting a mechanism that did not happen.

⭐ THE SWITCH DOES BITE, ON A FIXTURE THIS HELPER COULD ALREADY REACH. At the
same cut-out with EQUAL weights, `plan()` hands B a 50/50 share, so the account
gate is the only thing keeping it silent. Measured:

    cut-out, OPT-09 weights   gate on   0/200   gate DELETED     0/200
    cut-out, EQUAL weights    gate on   0/200   gate DELETED   200/200

Asserted. Driven: neutering the switch now goes RED on exactly that check, and
nothing else.

Two things this buys beyond closing the finding. It proves the switch removes
the gate, so the printed "with account() DELETED" line is about a run in which
something was. And it gives part 8b the end-to-end account-gate control ON A
POSITIVE SHARE that the block previously had to outsource to test_delivery.py
part 1 - the arm is no longer only true, it is locally demonstrated.

Suite: 27 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 04:38:18 +02:00
Claude Opus 5 b21e229adf SAFETY-43 round 2: the cut-out "positive control" was vacuous, and the record
checks / unit (pull_request) Successful in 6m29s
stated the wrong mechanism

⚠️ REVIEW FINDING, BLOCKING, AND IT IS THE EVIDENCE THAT WAS WRONG RATHER THAN
THE BEHAVIOUR. Part 8b's cut-out arm claimed an "identical power trace" to the
wedged arm and that "what separates them is `account()`". EVERY PART OF THAT WAS
FALSE, and the check survived deleting the gate it named. Measured:

    cut-out arm, account() live        named 0/200, distinct B shares: [0]
    cut-out arm, account() FORCED None named 0/200, distinct B shares: [0]
    wedged  arm, account() live        named 200/200, shares 77/125/133/154 W

At its own cut-out the pack's usable energy ABOVE that cut-out is zero, so
`weights()` answers 0.0, `_fill()`'s preferred pass gives it nothing, the healthy
sibling covers the load alone so the last-resort pass never runs, and its share
is 0 W ON ALL 200 CYCLES. A unit with a 0 W share is not in `asked`, so
`len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()` is
never consulted. Nothing was identical, "100 % of the ask" was 0/0, and the arm
never reached the comparison it was written to grade.

WHAT REPLACES IT IS A BETTER FACT, NOT A WEAKER ONE: at its own cut-out OPT-09
STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached. That
is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the
allocator declines to ask before the detector has to decline to alarm. So the arm
is kept and re-pointed, not deleted:

  * the 0 W share is now ASSERTED (`_sat_last[1][Bee] == 0`), not inferred from
    the silence - a control that cannot say WHY it is quiet cannot fail;
  * the arm runs a SECOND time with `account()` DELETED (`no_account=True`) and
    must still be silent, which is what proves which mechanism does the work;
  * the account gate's claim is re-pointed at test_delivery.py part 1, where it
    is graded on a REAL positive 125 W share.

⚠️ AND IT WAS INVISIBLE FOR ONE REASON WORTH KEEPING: that was the ONLY line in
part 8b that did not print its shares. `inverter-b+0` would have been on screen
from the first run. Every line in the part prints them now. The derated control's
vacuity guard also now asserts ITS OWN unit is asked, rather than re-asserting a
different run's 200/200 - which is exactly why the old guard could not see this.

ALSO, from the same review:

  N1  app/delivery.py said 0.05 was "twelve times more sensitive" than 0.85.
      0.85/0.05 = 17 and no reading of the pair gives 12. An unsupported number
      in shipped source; replaced with what was actually measured.
  N2  M14's recorded count was 4 named checks in test_delivery.py and it is 5 -
      part 1's pre-existing "a shortfall AT the threshold is not one" also goes
      red. It under-claimed, so it never inflated the evidence, but a recorded
      figure that does not reproduce is still one.
  N3  test_delivery.py part 1 printed the alarm sentence with a bare
      `delinquent(...)["b"]`, which raises KeyError under M12/M13 and ABORTED
      THE FILE AT PART 1, leaving parts 2-8 ungraded. The direction is safe (an
      abort is a red; MUTATION.md's defect fabricates only greens) but the
      recorded coverage read smaller than the truth. It is a `.get` now, and
      M12/M13's counts are re-run: 7 and 7 named checks, not 5 and 6.

CFG-07: the assessment is unchanged (worse by one control cycle, 35.0 -> 40.0 s,
deliberately not fixed here) and now says WHEN it has to close - BEFORE INVERTER
B IS COMMISSIONED (~2026-09-12), because the band is unreachable at N=1 and
commissioning B is when it first becomes reachable - and that CFG-07 HAS NO
DECISION DOCUMENT IN THIS TREE, so whoever takes it writes one first.

No behaviour change. `delinquent()`'s two arms, the fraction, `min_w` and the
N=1 path are all untouched by this commit. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 04:15:07 +02:00
Claude Opus 5 2271e147c6 SAFETY-43: delinquency measured against the ask, not against a flat 500 W
checks / unit (pull_request) Successful in 6m20s
`delivery.delinquent()` named a unit whose `short_by()` gap exceeded an
ABSOLUTE `min_w = Tuning.saturation_w = 500 W`. OPT-09's SoC-aware weights
shrink a low-headroom unit's share, and shrinking the share shrinks the gap, so
a unit standing at +125 W and delivering 0 W - 100 % delinquent - was never
named. Measured end to end on the real loop (test_packshare.py part 8b):

    load 1500 W, B wedged at 15 %, equal weights    named on 199/200 cycles
    load 1500 W, B wedged at 15 %, OPT-09 weights   named on   0/200 cycles

⚠️ The suppressed regime is the quiet one: at a load the healthy sibling covers,
the meter is satisfied, `unmet_w` is 0 and no action-log line fires, so this
alarm is the ONLY instrument that would have told the operator.

WHAT CHANGED, in one line:

    named if  short_by() > min_w  OR  short_by() > frac * share

⚠️ `min_w` WAS NOT LOWERED. It exists to stop normal saturation being reported
as delinquency, and lowering it trades a missed alarm for a false one on every
unit at once - SAFETY-41's four-attempt regime. This is a SECOND way to be
named, not a looser first one: all seven of test_delivery.py part 1's existing
fixtures answer identically under both rules, and the absolute arm's suppression
window is still exactly 1000 W / 6500 W, still asserted in part 8b.

THE FRACTION IS DERIVED, 0.85. admin/goodwe:sim/delivery_window.py gained a
`--fractions` axis and grades the constant over the SAME 2640 no-fault runs that
produced `delivery_hold_s`'s 35.0 s floor. Worst continuous legitimate episode,
worst of three families, twenty fractions:

    frac   0.05  0.10  0.15  0.20  0.25  0.30  0.35  0.40  0.45  0.50
    worst  90.0  90.0  70.0  70.0  60.0  55.0  55.0  60.0  45.0  45.0  s
    frac   0.55  0.60  0.65  0.70  0.75  0.80 *0.85  0.90  0.95  0.99
    worst  40.0  40.0  45.0  45.0  45.0  45.0  40.0  40.0  40.0  40.0  s
    absolute arm alone: 35.0 s (SAFETY-42 §3.3, reproduced in the same runs)

⚠️ NO CLIFF ANYWHERE: 0.45-0.99 is flat within one control cycle, and it is not
monotone - §3.3's own mechanism (c). ⚠️ "Zero false namings" is NOT achievable
at any fraction and the record says so; what is zero is false ALARMS - 90.0 s
worst against a 185 s hold, so nothing comes within 95 s of notifying anybody.
N=1 control 0.0 s at every fraction in all 2640 one-inverter runs; flapping
0.0 s in all 840.

DRIVEN. Part 8b pinned the SUPPRESSED behaviour and said "if it ever goes red
because the alarm fired, read the card that made it fire". It now grades BOTH
rules on one run - `share_frac=None` is SAFETY-42's rule exactly:

    OPT-09 weights, absolute arm alone   named on   0/200
    OPT-09 weights, the shipped rule     named on 200/200

POSITIVE CONTROL. Same fixture, same weights, same 125 W share, one legitimate
fact changed: a pack at its own 10 % cut-out is named on 0/200 (account() still
gates it), and a unit delivering HALF its share is named on 0/200.

N=1 BYTE-IDENTICAL, asserted: `plan()` handed a `weights` mapping that raises on
any read still answers identically at a roster of one, and the same mapping
raises at a roster of two; `delinquent()` answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.

MUTATION, run on fresh :ro trees: M12 (drop the relative arm), M13 (frac -> 1.0)
and M14 (frac -> 0.05) are all red on named checks. The constant cannot move in
either direction unseen.

⚠️ CFG-07 gets ONE CONTROL CYCLE WORSE and is deliberately not fixed here: the
worst legitimate episode goes 35.0 -> 40.0 s, so the schema's `int(30,900)`
false-tripping band widens from [30,35] to [30,40]. CFG-07 should be re-derived
against 40.0 s.

No option value, no schema bound and no default changed. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 03:14:16 +02:00
admin 6719bd882f Merge pull request 'TEST-21: delivery_hold_s's floor re-derived — 30.0 s did not reproduce, it is 35.0 s, and 185 still stands' (#67) from TEST-21 into release/1.0 2026-09-07 22:41:35 +02:00
glenn schrooyenandClaude Opus 5 f76639c7b2 TEST-21: two of the argmax's four axes are on an edge, and the doc did not say so
checks / unit (pull_request) Successful in 6m22s
§3.3 already set the standard - "a worst case sitting on an edge of the grid
would be a number about the grid" - and then did not check its own argmax
against it. Review did. Reproduced here independently before writing it down:

    stagger  0 -> 1 -> 2 -> 3 -> 4      0.0  15.0  25.0  30.0  35.0 s
    weights (A) at stagger 4            0.5 -> 20.0   0.7/0.9/0.99 -> 0.0
                                        0.10 -> 35.0 s

Strictly increasing in stagger, maximal at the largest modelled stagger, and
maximal at the most asymmetric split. Interior only in `gain` and `slew_w`.

⚠️ SO THE HEADLINE IS 0.0 s AT stagger=0. The 35.0 s is ENTIRELY a product of
the modelled serialised-write stagger, which is declared MODELLED in the harness
and is a TERM of the figure rather than a detail of it. A site whose two writes
land in the same cycle does not produce this episode at all.

It does not move `delivery_hold_s`: 185 s must cover the worst the modelled site
can produce, and four cycles of stagger is inside what a real two-inverter write
path can do. What changes is that a reader asking "is 35.0 s about the plant or
about the grid?" now gets the answer instead of the invitation.

⚠️ THE PR BODY HAD CLAIMED THE ARGMAX WAS "interior to the grid, not on an edge
of an axis I had to choose". That was wrong, and it is the claim the doc's own
standard exists to test. The grid is the record's own, fixed by §3.3 before this
rebuild ran, which is the mitigating half and is stated too.

Also: `nl.yaml` wrote "35.0 s" beside "5,29 keer" and "75,0 s" in one sentence.
Dutch takes the comma.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 21:47:08 +02:00
adminandClaude Opus 5 57775d4e06 TEST-21: delivery_hold_s's floor re-derived, and 30.0 s did not reproduce
No code and no option changes. What changes is a number this repository claimed
about a shipped safety constant.

`delivery_hold_s: 185` shipped resting on "30.0 s, the worst continuous
legitimate episode at the shipped threshold across all 2640 runs", plus a lag
sweep giving 45.0 s and 90.0 s - from a harness `DECISIONS/SAFETY-42` §1
deliberately did NOT commit, because it imports `plantsim` and `frame_check` out
of admin/goodwe. So the floor under a safety constant could not be re-run. That
is PROC-01's shape, with eleven instances in three days.

The harness is committed now, in admin/goodwe as `sim/delivery_window.py`, and
NOT here. `plantsim` and `frame_check` are that repo's; restating them in this
tree is CI-08's coupling, which has broken three times, and the alternative
fate - skipped for want of its imports - is a green that establishes nothing.
There the dependency runs one way: THIS repo's `control`, `arbiter` and the
shipped `delivery.delinquent()` are imported live out of whichever checkout is
under test. Its `--selftest` is now a step in that repo's `checks.yml`, so a
change here that moves `saturation_w`, `max_w`, `delivery_hold_s` or the shape
of `delinquent()` goes red there instead of leaving a stale number here.

    cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
        python3 -B delivery_window.py --selftest --campaign --blag

⚠️ THE HEADLINE DID NOT REPRODUCE: 35.0 s, not 30.0 s. Recorded as measured. The
harness was not adjusted until it agreed - the one correction made during the
rebuild was made AGAINST the doc's number (§1 records it) and made every figure
LARGER.

WHAT REPRODUCED - and it is the part the compatibility promise rests on:
  * the N=1 control arm is 0.0 s at EVERY threshold in EVERY run. §3.2's algebra
    measured, not argued.
  * the flapping family is 0.0 s everywhere in all 840 runs - the family that
    defeated SAFETY-30's site window still cannot touch this check.
  * 45.0 s at twice A's lag, to the cycle.

WHAT DID NOT:
  * the headline, by one control cycle (30.0 -> 35.0 s); the reversal family by
    two (20.0 -> 30.0 s).
  * the NON-MONOTONE curve. §3.3 finding 3 read 55.0 s at 250 W against 40.0 s
    at 100 W as a property of the reduction. This rebuild's curve is monotone.
    The mechanism is real and is still the reason not to read either table as a
    sensitivity curve, but it is an ARGUMENT now, not a measurement, and it is
    written down as weaker.
  * the low-threshold rows, badly (40.0 -> 120.0 s at 100 W). Nothing ships
    there, but that gap is the honest measure of how much of the original table
    was a property of axes nobody wrote down. §3.3 fixes every axis COUNT and
    RANGE and no individual value; the rebuild prints its choices above its own
    table so the caveat travels with the number.
  * the four-times-slower row moved the OTHER way: 75.0 s, not 90.0 s.

185 s IS NOT CHANGED. It is 5.29x the re-measured worst (the record claimed
6.2x), 2.47x the four-times-slower case, 6.2x the firmware's LINK_STALE_S veto
and 4.9x inside the billing quarter. All four bounds clear and none narrowly,
and the ordering of the argument is unchanged. Moving a shipped safety constant
because a margin went from 6.2x to 5.3x while every bound holds would re-open
SAFETY-41's four-attempt false-trip regime for nothing.

⚠️ ONE CONSEQUENCE AN OPERATOR CAN HIT. At 30.0 s the rig's floor coincided with
the firmware's veto and a reader could not tell which was binding. At 35.0 s the
rig's is strictly the binding one - and it is ABOVE the schema's own
`int(30,900)` minimum. `delivery_hold_s: 30` is therefore below a MEASURED
legitimate episode and would report on ordinary saturation. Both translations
now say so. Raising the schema floor is a change to a shipped option's contract
and is left to its own card rather than smuggled in here.

⚠️ THE B-LAG ROWS ARE RELABELLED THROUGHOUT. Inverter B does not physically
exist (~2026-09-12); `plantsim.INVERTER_B` is A COPIED, `measured=False`. Every
two-inverter figure in this record is two copies of A, and the lag sweep is a
SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, not a measurement of it. The old
wording - "MEASURED, not felt" - overstated exactly that, and "the RAMP does not
move it at all" was too strong: halving it is worth 0 s at A's lag and +5 s at
twice it.

Touched: DECISIONS/SAFETY-42 §1 and §3.3, app/delivery.py's provenance comment,
config.yaml's `delivery_hold_s` block, both translations, CHANGELOG Unreleased.
26/26 suites pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 21:47:08 +02:00
admin bb469163a6 Merge pull request 'UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02' (#66) from UX-04 into release/1.0 2026-09-07 19:03:35 +02:00
glenn schrooyenandClaude Opus 5 477911afa1 UX-04: the 59 was computed and then graded against "> 15"
checks / unit (pull_request) Successful in 6m22s
Review approved and found four things worth fixing. Three are cosmetic; the
fourth is the same defect this card is about, one level up.

⚠️ THE FLOOR WAS PRINTED AS 59 AND ASSERTED AS `> 15`. So a manifest change
dropping the required count to 20 would have passed while the sentence beside it
still said 59 - a number displayed but not graded, which is exactly what let
UX-01's AC 3 survive review in the first place, and exactly the shape
`test_translations.py` was already fixed for.

Now DERIVED and graded in two separate assertions, because they are two
different claims:

  1. `_EXPECT_REQ = len(schema) - len(_OPTIONAL)`, where optional is Supervisor's
     own rule - a `?` suffix, plus `inverters` because `UiOptions` marks a list
     optional - asserted EQUAL to Supervisor's required count. The printed
     number is computed, so it cannot go stale.
  2. ⚠️ AND THE WITHDRAWAL'S PREMISE, SEPARATELY. The equality alone is not
     enough and I checked why: both sides read the same `?` convention, so
     marking a key optional moves BOTH and it stays green - measured, by making
     `slew_w` optional and watching it pass. That grades Supervisor agreeing
     with us, NOT the thing AC 3 was withdrawn for. The second assertion grades
     the premise: the floor is still far above the ~15 AC 3 asked for, and if it
     ever is not, the withdrawal needs re-arguing rather than re-asserting.

Driven, both directions, against the real 2026.08.0 image:

    clean                         PASS  "floor is DERIVED: 71 schema keys minus
                                         12 optional ones is 59, and
                                         Supervisor's own required flag agrees"
    premise threshold 15 -> 40    FAIL  "the floor of 59 is still far above
                                         the ~40 AC 3 asked for"

Also: a dangling reference to `DECISIONS/UX-04-hiding-and-the-deepmerge.md`,
which was never the filename shipped, and `DOCS.md` still saying section F
"prints" what it now grades.

Not changed, and review agreed both are disclosed rather than defects: the
pair-keyed ledger cannot see a SECOND site for a pair it already holds (the
site count can grow while the pair set cannot), and the probe's assertions run
by hand rather than in CI because they need the Supervisor image - the same
status as before this card, not a regression.

Suite: 27 files, all pass. Probe: all cases passed on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, --network none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:56:55 +02:00
Claude Opus 5 21f22b393a UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02
checks / unit (pull_request) Successful in 6m20s
UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is
LIVE, so all three describe the running install. No behaviour, no schema and no
default moves here: config.yaml, translations/ and every module under app/ are
byte-identical to 6e7b233.

⚠️ AC 3 - "the visible count drops to ~15" - IS WITHDRAWN AS UNSATISFIABLE, and
the arithmetic is the reason rather than the effort. options: and schema: both
declare all 71 keys, so the set HA's toggle can hide is EMPTY - it hides a key
only when it is ?-optional AND absent from options:, and nothing is absent. But
the deeper fact is that no manifest could have met it: Supervisor's own
UiOptions marks 59 of the 71 REQUIRED, and the frontend's own filter -
`schema.filter(e => e.name in opts || e.required)`, transcribed out of the
bundle in the release image - shows a required field unconditionally. So 59 is
the floor with options: EMPTIED ALTOGETHER. The target was ~4x below anything
reachable.

⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH.
A key absent from options: has no default for the Supervisor to deep-merge into
an existing box's stored blob. That is exactly the delivery_hold_s mechanism
this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to
its options: default of 185 instead of becoming exit 78. Hiding a key is
precisely the edit that removes that. It would also promote the 27 unpinned
in-code literal fallbacks the new check ledgers into the ACTUAL source of the
defaults on a live install - the CFG-03 defect the same card's AC 4 exists to
police, made load-bearing by its AC 3.

⚠️ THE COUNT WAS PRINTED AND NEVER GRADED, WHICH IS HOW THIS PASSED REVIEW.
supervisor_shape_probe.py section F has printed "the Configuration tab renders
71 fields" since the day it was written and exited 0 through UX-01's whole
review while displaying the number that contradicted the criterion it was
evidence for. Three assertions replace the bare print, every number computed
from the manifest and from Supervisor's own `required` flag rather than typed,
including an ISOLATED COUNTERFACTUAL that empties options: and nothing else.
MEASURED RED: delete one key (price_feed_url) from options: and two of them
fail - `Hideable: ['price_feed_url']`, `70 of 71 fields render` - exit 1.

--- AC 4: the AST check, built --------------------------------------------

AC 4's first clause is VACUOUS over an empty set (see above) and its second -
"the table changed goes red" - already works six times over. The third, the one
with the content, was never written: the only literal scan in the repository is
test_validate.py's regex over eight maintenance_* keys, which is a grep and
predates the card.

test_defaults_pin.py walks ast.Call nodes over app/*.py. It:

  - DISCOVERS the six *_OPTION_DEFAULTS mirrors from the tree rather than
    naming them. Every existing pin names its own table by hand, so a SEVENTH
    table added tomorrow is pinned by nobody and nothing says so;
  - grades all 30 mirrored keys against config.yaml, and requires that no
    module carries a literal fallback for any of them;
  - FREEZES the CFG-03 backlog at exactly today's 27 (module, key) pairs, by
    pair and not by count - a count goes green again the moment one site is
    deleted and another added. Line numbers are deliberately out of the key: a
    ledger that reddens on an unrelated reformat is one people re-bless without
    reading. It can only shrink;
  - grades every remaining literal AGAINST config.yaml, which is the half that
    catches CFG-04's actual failure - two copies of one number, one moves.

⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for:
main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10.
It cannot reach an operator - the deep-merge guarantees the key is present - and
the only paths that hand over a partial dict are sim/inject.py and this repo's
fixtures. Recorded as the single permitted divergence with its reason rather
than corrected; changing it alters the control loop's target and belongs to a
card that grades the loop. A second divergence goes red.

⭐ THE RED DIRECTION WAS RUN, TWICE OVER. Part 5 feeds the scanner a synthetic
module containing a deliberate violation of each rule and REQUIRES it to be
caught, in-process, through the same function the graded parts call - so a
scanner that goes blind stops working there too - and the site total is graded
against a floor. And a literal was planted in the REAL tree:

    main.py:290  float(opts.get("maintenance_charge_w", 2000))
    -> FAIL no literal fallback survives for any of the 30 keys a mirror
       already answers for                                          exit 1

    main.py:290  opts.get("p1_host", "x"), opts.get("slew_w", 999)
    -> FAIL no NEW module/key pair has started carrying a literal fallback
            [('main.py', 'p1_host')]
       FAIL every remaining literal still AGREES with config.yaml
            main.py  slew_w: code 999 vs config.yaml 0               exit 1

Both plants removed.

--- AC 5: CFG-04 is closed, and not by this card's predecessor -------------

fef6ef2 touched config.yaml, translations/, test_translations.py and the probe -
no defaults code at all - and on 0.7.0 the max_w 2000 -> 2500 triple mutation
survives 21 files, 0 RED. CONFIRMED CLOSED on release/1.0 by re-running the same
triple mutation on a throwaway copy of 6e7b233: 26 files, RED at
test_selfconfig.py, exit 1 -

  1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches
  the rail collapsing to the 2000 default under an explicit 400 W slew

git log -L 310,325:test_selfconfig.py attributes that check to bcee235,
"UX-02: pin both halves of the max_w fix, because neither was graded". The AC is
met BY UX-02 and ships with the pending 0.9.0 deploy. Nothing was re-done.

--- the 8 per-entry translations: they stay --------------------------------

Measured negative and not a defect in them: UiOptions gives inverters type
schema WITH multiple, the frontend sends that case to ha-selector-object, which
labels fields `fields[name].label || name`, and the converter sets neither label
nor translation_key. The Supervisor tab CANNOT be made to render them. They stay
because UX-02's ingress panel is the surface that can, because they cost
nothing, and because deleting them removes the only thing that would catch a
per-entry key added without a translation - which is how SAFETY-32's REQUIRED
bms_soc_floor would have joined the roster unlabelled. A written decision, not a
change; translations/ is untouched.

tools/supervisor_selfapi_probe.py stays red on its hardcoded len(_sub) == 7.
That is TOOL-04, already filed, and it is not re-filed or fixed here.

--- evidence ---------------------------------------------------------------

  positive control  test_defaults_pin.py's mirror floor moved 6 -> 7 inside the
                    harness container: "### RED: test_defaults_pin.py", exit 1
  suite             27 files, ALL GREEN, exit 0 (goodwe-addon-checks:1, the
                    checkout mounted read-only)
  shape probe       all cases passed, exit 0, on
                    ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0

Nothing here touched hardware, the live Home Assistant at 192.168.2.158, or
sim/. Both probe runs were throwaway containers over a read-only mount.

DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md carries all four
decisions, the frontend transcription, and the two counterfactual transcripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:29:48 +02:00
admin 6e7b23388c Merge pull request 'PKG-13: the no-op half of the pre-deploy check has never run as documented' (#65) from PKG-13 into release/1.0 2026-09-07 17:34:18 +02:00
glenn schrooyenandClaude Opus 5 1ba35f9c99 PKG-13: one safe.directory flag, not two, and the tighter of the pair
checks / unit (pull_request) Successful in 6m19s
Review found `-c safe.directory={where}` and `-c safe.directory=*` set on the
same invocation, the specific value redundant once the wildcard is present.

Dropped the WILDCARD rather than the specific path, which is the opposite of
the suggestion and the tighter end of it: `_git()` always runs `-C where`, so
the only repository it ever reads is the one it was pointed at, and naming that
one path grants exactly the trust the tool needs. `*` granted trust to every
repository on the machine for the lifetime of each subprocess - harmless for
read-only `log`/`show` with no tty, as review measured, but broader than the
job.

Verified after the change: the documented container invocation still runs BOTH
halves and reports `no runtime change since the last bump` / `CLEARED for
deploy`, so the fix this branch exists for is intact.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 17:27:36 +02:00
glenn schrooyenandClaude Opus 5 0b653c35a6 PKG-13: the no-op half of the pre-deploy check has never run as documented
checks / unit (pull_request) Successful in 6m19s
⚠️ THE INVOCATION ON PKG-09's CARD AND IN THE TOOL'S OWN DOCSTRING RUNS ONLY
HALF THE CHECK. The container runs as uid 0 against a checkout owned by the
operator, and git refuses that:

    fatal: detected dubious ownership in repository at '/w/goodwe-addon'

`_git` returned non-zero, `last_version_change()` returned None, and the
silent-no-op check - THE REASON PKG-09 EXISTS - reported SKIPPED. Measured, on
the documented command against release/1.0:

    ⚠️  SKIPPED: could not ask git what changed since the last bump;
                 the no-op check is INCONCLUSIVE, not clean
    NOT CLEARED

and with `--user $(id -u):$(id -g)`, unchanged otherwise:

    no runtime change since the last bump - nothing to deploy
    CLEARED for deploy.

⚠️ THE TWO HALVES WERE MUTUALLY EXCLUSIVE ACROSS THE TWO DOCUMENTED
ENVIRONMENTS. In the container the validator ran and git skipped; on a bare host
git ran and the validator skipped on aiohttp. NEITHER DOCUMENTED COMMAND RAN
BOTH. The pre-0.9.0 clearance was sound only because it happened to be driven as
two separate invocations.

⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults the
git result only when `shipped == deployed`; every real run so far compared 0.7.0
against 0.9.0 and printed its verdict without ever needing git.

FIXED IN `_git()` WITH `safe.directory`, NOT BY DOCUMENTING `--user`. The tool
should be correct however it is invoked rather than requiring every future
caller to remember a flag; the read is read-only, on a repository the operator
is about to deploy from.

⚠️ WHY THE TESTS COULD NOT SEE IT, WHICH IS THE FINDING BEHIND THE FINDING.
`test_version_gate.py` invokes the gate with `sys.executable` against fixtures
it creates as its own uid, so no fixture could ever meet an ownership mismatch.
MUTATION.md habit 4 in a fourth shape, inside the file written to prevent it -
after the pickaxe bug, after the one-bump history, after the filter-only
survivor. Two fixtures added:

  - a directory that is NOT a git repository, so the lookup genuinely fails:
    the gate must exit 2 saying INCONCLUSIVE and must NOT say CLEARED.
  - ⚠️ THE EXIT CODES PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0` is
    satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
    earlier draft could not tell apart. 1 says "this deploy is wrong"; 2 says
    "nobody checked". Driven: collapsing 2 into 1 now goes red on exactly that
    check.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 17:02:01 +02:00
admin d0633665c9 Merge pull request 'PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship' (#64) from PKG-12 into release/1.0
PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship
2026-09-07 00:53:44 +02:00
glenn schrooyenandClaude Opus 5 8e3734f5ea PKG-12: the live options WERE checked, and the changelog said they were not
checks / unit (pull_request) Successful in 6m17s
Review approved the bump and, in doing so, dated one of its own paragraphs.
When this section was written the pre-deploy check had not been run and the
changelog said so, correctly. The owner then exported the reference install's
options and the check cleared, which makes the shipped sentence false in the
release notes an operator reads at the hardware.

Replaced with what was measured, twice:

  stored blob as-is                          exit 0, CLEARED
  effective post-upgrade config              exit 0, CLEARED
    (the 70 stored keys merged OVER 0.9.0's
     71 config.yaml defaults)

  max_w 5000, slew_w 400, rule-10 ceiling 555.6 W.

⚠️ AND THE SECOND CHECK IS NOT CEREMONY. The stored blob has 70 keys, not 71:
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so a box running 0.7.0 has
never stored it, and it is REQUIRED in the schema with no trailing `?` - the
exact shape that becomes exit 78. It resolves to its `options:` default of 185
because the Supervisor sets `App.options = deepmerge(defaults, stored)`, which
`tools/supervisor_selfapi_probe.py` §D pins against a real Supervisor image.
Review re-derived that independently rather than taking the reasoning on trust.

"Your saved settings are valid" and "what the box will actually run is valid"
are different questions, and only the second one is the one being asked before
a deploy.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 00:47:11 +02:00
glenn schrooyenandClaude Opus 5 86bdf49332 PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship
checks / unit (pull_request) Successful in 6m14s
⚠️ MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK, AND THAT DISTINCTION IS
CHECKED RATHER THAN ASSERTED. PKG-07 was minor because a config that started on
0.7.0 could be REFUSED on 0.8.0. Nothing of that kind is true here:

  - `options:` and `schema:` hold 71 top-level keys on both 06661c5 and HEAD.
    The one schema addition is a PER-ENTRY key, `capacity_kwh: float(0.1,200)?`,
    and the trailing `?` makes it OPTIONAL.
  - An unmodified 0.8.0-era config validates clean on 0.9.0. Measured through
    the shipped validator on both the flat single-inverter shape the reference
    install runs and an N=2 roster with no `capacity_kwh` on any entry: 0
    problems each.
  - OPT-09's two new refusals fire only on configs that USE the new key - an
    unparseable value, or the key stated on one entry and left empty on another.
    Neither can reach a config written before the key existed.

PKG-07's first draft justified its bump with an invented compatibility break and
review caught it. This one states what was measured, and the measurement says
existing installs upgrade untouched.

Contents since 0.8.0 (`06661c5`): UX-02 (`3d29993`), OPT-09 (`fdb3b69`),
PKG-09 (`fcaa724`).

⚠️ THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS - PKG-06 six
merges, PKG-07 six, this one five - and this time PKG-09's own tool caught it
during PKG-09's review rather than a person. The changelog carries OPT-09's
disclosed N=2 detection regression and points at the pre-deploy check.

⚠️ STILL NOT DONE, AND STILL NOT DOABLE FROM HERE: the live options have not
been checked against this version's validator. The gate reports exit 2 - NOT
CLEARED - rather than clearing, which is correct. Export the options from the
Supervisor and re-run before `ha apps update`.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 00:18:36 +02:00
admin fcaa72418d Merge pull request 'PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject' (#63) from PKG-09 into release/1.0
PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject
2026-09-07 00:04:32 +02:00
glenn schrooyenandClaude Opus 5 04b3330def PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject
checks / unit (pull_request) Successful in 6m16s
Two ways a deploy of this add-on has gone wrong, and the Supervisor reports
both as success.

THE SILENT NO-OP. The Supervisor keys the built image by `version:`, so
`ha apps update` against an unchanged version installs nothing and says it
worked. PKG-06 recorded this on 2026-09-05 with six merges behind an unbumped
version; PKG-07 recorded it again on 2026-09-06 with six. Both times a human
noticed. Nothing else was going to.

THE REFUSED CONFIG. A config the shipped validator rejects is SystemExit(78) -
the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES WITH IT, because the
add-on serving it is not running, so recovery means the Supervisor UI.

⚠️ IT CAUGHT A LIVE ONE ON ITS OWN PULL REQUEST. Run against release/1.0 while
this branch was in review: version 0.8.0 on both sides with UX-02's and
OPT-09's runtime commits already merged behind it. The class this exists to
catch had recurred, the same day, unnoticed - which is the argument for PKG-10,
a non-required post-merge check, since this script only helps someone who
remembers to run it.

⚠️ THE FIRST VERSION WAS BROKEN ON EVERY REAL REPOSITORY AND ITS TESTS COULD NOT
SEE IT. The "when did the version last change" lookup was `git log -S`, the
pickaxe, which finds commits that change the COUNT of a literal string. A bump
edits the line IN PLACE, so the count never moves and it returned only the
commit that first INTRODUCED the line. Measured here:

    -S  ->  0a1e61d  2026-08-23  Packaged as a Home Assistant add-on
    -G  ->  06661c5  2026-09-06  PKG-07: bump 0.7.0 -> 0.8.0

so a correctly bumped tree was reported as 87 COMMITS BEHIND AN UNBUMPED
VERSION, PKG-06's and PKG-07's own bump commits among the offenders. A tool
whose whole purpose is to be trusted cried wolf on every run.

⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER - it matches a reformat,
a quote change, a comment moved onto the line. `-G` narrows the candidates and
the VALUE is then compared against the parent's.

⚠️ THREE DISTINCT SHAPES OF HABIT 4 IN ONE FILE, each found by a different
means, which is the honest record of how this landed:
  1. Fixtures built fresh repositories whose version line is introduced once and
     never edited - the one shape in which `-S` looks correct. Found by review.
     Answered with a three-state history; the old lookup goes red on 4 checks.
  2. That three-state history still could not tell "value-compared" from
     "filtered-only": review swapped in `-G`'s newest raw hit and THE WHOLE
     SUITE STAYED GREEN, so the docstring's central claim was pinned by nothing.
     Answered with a cosmetic touch of the version line - `-G` matches it, the
     value comparison does not. That mutant now goes red on 3 checks.
  3. The counts below were claimed fixed and two of them had not landed at all.

Two git shapes it still gets wrong are recorded in the docstring rather than
fixed - a bump resolved inside a merge commit (false refusal) and a bump
reverted while the work is kept (false clearance, the dangerous direction).
Neither has ever occurred here; both were constructed in review. Card PKG-11.

⚠️ AND THE PROSE COUNTS, WHICH I REPORTED AS FIXED WHEN THEY WERE NOT. Round 1
found "PKG-06 ... four merges" (its own commit message says six) and "recorded
eleven times" (MUTATION.md:550 says TWELFTH). I replaced them without asserting
the replacement matched, said "all applied", and round 2 found both still in the
tree - one fixed in DOCS.md but not in the script, one not fixed anywhere. A
claim that a correction landed is itself a claim that needs checking, and this
is PROC-01's tenth instance.

WHAT IT DOES NOT DO. It does not read the live box: the options blob needs the
Supervisor API. Without --options the validator half SKIPS - loudly, exit 2 -
and never pretends. On a bare host `app/validate.py` needs aiohttp, so that half
skips too and names the container to re-run in. A missing or malformed --options
file is a REFUSAL with a sentence, not a traceback.

EXIT CODES: 0 cleared, 1 REFUSED, 2 NOT CLEARED. ⚠️ 2 IS NOT A PASS.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 23:57:51 +02:00
admin fdb3b69397 Merge pull request 'OPT-09: the weights parameter nothing ever passed, and what it cannot do' (#62) from OPT-09 into release/1.0
OPT-09: the weights parameter nothing ever passed, and what it costs SAFETY-42
2026-09-06 23:33:17 +02:00
17 changed files with 3130 additions and 229 deletions
+225
View File
@@ -1,5 +1,88 @@
# Changelog
## 0.9.0
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
POINT.** 0.8.0 was minor because a config that started on 0.7.0 could be
REFUSED on it (rule 10 against 0.7.0's own default `slew_w: 1000`). Nothing of
that kind is true here, and it was checked rather than assumed:
- `options:` and `schema:` both hold **71 top-level keys**, the same 71 as
0.8.0. The one schema addition is a per-entry key, `capacity_kwh:
float(0.1,200)?` - **note the trailing `?`, it is OPTIONAL**.
- An unmodified 0.8.0-era config validates clean on 0.9.0. Measured on both the
flat single-inverter shape the reference install runs and an N=2 roster with
no `capacity_kwh` on any entry: **0 problems each**.
- The two refusals [[OPT-09]] adds fire only on configs that USE the new key -
an unparseable value, or the key stated on one roster entry and left empty on
another. Neither can reach a config written before the key existed.
So: existing installs upgrade untouched, and the bump is minor because the
option surface and the behaviour grew.
Contents, in merge order since 0.8.0 (`06661c5`):
| | |
|---|---|
| `3d29993` | UX-02 - the validator's refusals, next to the field, before the save |
| `fdb3b69` | OPT-09 - the weights parameter nothing ever passed, and what it cannot do |
| `fcaa724` | PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject |
**UX-02** builds the real configuration surface in the ingress panel, beside the
Supervisor Configuration tab that cannot be removed. The prize is not styling:
`validate.py` writes long cross-field refusals the Supervisor UI *structurally
cannot show*, and until now an operator met them as **exit 78 in a log**, after
saving and restarting. ⚠️ Two config surfaces now exist and the DECISIONS record
names the **stored options blob** as authoritative, neither UI.
**OPT-09** makes the architecture's one free parameter real. `main.py` called
`resolve()` without weights, so the split was a fixed 50/50 regardless of what
either pack could deliver; it now passes SoC- and capacity-aware weights.
⚠️ **OPT-09 SHIPS A DISCLOSED DETECTION REGRESSION AT N=2.** `delivery.
delinquent()` reads an ABSOLUTE 500 W gap, so weighting a low-headroom unit down
to a small share puts a unit delivering **none of what it was asked** under the
threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to
**0 of 200** at a load its sibling can carry alone. The alarm returns once the
healthy pack saturates - swept, that is a 5000 W load on the fixture, **below**
the proportional-regime formula's 6500 W and not above it. ⚠️ **N=1 IS
UNAFFECTED**: `allocator.plan()` returns before weights are read at all.
⚠️ **CLOSED BY SAFETY-43 ON THIS BRANCH** - see its section below; the regression
above is history, and the record of it is kept because the measurement that
found it is what the fix is graded against.
**PKG-09** adds `tools/predeploy_check.py`. ⚠️ **RUN IT BEFORE DEPLOYING THIS
RELEASE** - it is what caught the fact that 0.8.0 had five runtime commits
behind it, which is why this section exists:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.8.0 --options /w/live-options.json
Exit `0` cleared, `1` REFUSED, `2` NOT CLEARED. ⚠️ **2 is not a pass.**
✅ **RUN AGAINST THIS RELEASE, 2026-09-07: CLEARED, exit 0.** The reference
install's options were exported from the Supervisor (70 keys) and checked twice
- the stored blob as-is, and the **effective post-upgrade config**, being those
70 keys merged over 0.9.0's 71 defaults. Both pass. `max_w` 5000, `slew_w` 400
against a rule-10 ceiling of 555.6 W.
⚠️ **THE STORED BLOB HAS 70 KEYS, NOT 71, AND THAT IS THE INTERESTING PART.**
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running 0.7.0
has never stored it - and it is REQUIRED in the schema, no trailing `?`, which
is the shape that becomes exit 78. It resolves to its `options:` default of
**185**, because the Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`; `tools/supervisor_selfapi_probe.py` §D pins that
against a real Supervisor image rather than assuming it. The second check above
exists because "your saved settings are valid" and "what the box will actually
run is valid" are different questions, and only the second one matters.
⚠️ **THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS** - PKG-06 six
merges, PKG-07 six, this one five - and this time it was caught by PKG-09's own
tool during PKG-09's review rather than by a person. `ha apps update` against an
unchanged version installs nothing and reports success. PKG-10 carries the
post-merge alarm so that noticing stops being a human's job.
## Unreleased
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
@@ -14,6 +97,107 @@ what happens when nobody does.
| | |
|---|---|
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
| _this branch_ | SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression |
⚠️ **SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A
TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL.** `delivery.delinquent()` named a unit
whose shortfall exceeded an **absolute** `min_w = saturation_w = 500 W`. OPT-09's
weights shrink a low-headroom unit's share, and shrinking the share shrinks the
gap - so a unit standing at +125 W and delivering **0 W**, 100 % delinquent, was
never named. It now names a unit whose gap exceeds `min_w` **OR** exceeds
`DELIVERY_SHARE_FRACTION` (0.85) of that unit's **own share**. Measured on the
same closed-loop fixture, same weights, same detector:
load 1500 W OPT-09 weights the old rule named on 0/200 cycles
load 1500 W OPT-09 weights the shipped one named on 200/200 cycles
⚠️ **`saturation_w` WAS NOT LOWERED, AND THAT IS THE POINT.** Lowering it would
report normal saturation as delinquency on every unit at once - SAFETY-41's
four-attempt false-trip regime, *"i'm getting really annoyed by the notifications
in my HA"*. This ADDS a second way to be named: all seven of `test_delivery.py`
part 1's existing fixtures answer identically under the old rule and the new one,
and the absolute arm's suppression window is still exactly 1000 W / 6500 W.
**0.85 is derived, not chosen.** `admin/goodwe:sim/delivery_window.py --campaign
--fractions` grades it over the SAME 2640 no-fault runs that produced
`delivery_hold_s`'s floor. The worst continuous legitimate episode is **35.0 s**
under the absolute arm alone (SAFETY-42 §3.3, reproduced in the same run) and
**40.0 s** at 0.85. ⚠️ **THE CURVE HAS NO CLIFF AND THAT IS THE FINDING**: swept
at twenty fractions from 0.05 to 0.99, everything from **0.45 to 0.99** is 40.0
or 45.0 s - within one control cycle of every other - and below 0.45 it rises
gradually to 90.0 s at 0.05. ⚠️ **"Zero false namings" is not achievable at any
fraction and the record says so**; what is zero is false ALARMS - the longest
legitimate episode in the whole sweep is 90.0 s against a 185 s hold, so nothing
comes within 95 s of notifying anybody at any fraction measured, and 145 s at the
shipped one. The N=1 control arm is 0.0 s at every fraction in all 2640
one-inverter runs, and the flapping family is 0.0 s in all 840.
⚠️ **[[CFG-07]] GETS ONE CONTROL CYCLE WORSE AND IS NOT FIXED HERE.**
`delivery_hold_s` is declared `int(30,900)` while the measured legitimate worst
was 35.0 s; it is now 40.0 s, so the band of schema-legal false-tripping values
widens from `[30, 35]` to `[30, 40]`. CFG-07 should be re-derived against 40.0 s.
⚠️ **IT MUST CLOSE BEFORE INVERTER B IS COMMISSIONED (~2026-09-12)**, not before
the next deploy: the band is UNREACHABLE at N=1 - `delinquent()` answers `{}` for
every input at a roster of one, so no `delivery_hold_s` can false-trip on the
install running today - and commissioning B is the moment it becomes reachable.
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE** - it exists as a card
reference here and in SAFETY-43's record and nowhere else, so whoever takes it
writes `DECISIONS/CFG-07-*.md` first.
**SAFETY-43 CHANGES NO OPTION VALUE, NO SCHEMA BOUND AND NO DEFAULT** - the
fraction is a derived constant with a committed harness behind it, deliberately
not a knob, for the reason there is no `delivery_w`. The one edit to
`config.yaml` is a COMMENT: the `delivery_hold_s` block described the check as
"short by more than `saturation_w`", which is now only half of it.
**No behaviour change at one inverter, asserted rather than argued**: `plan()` is
handed a `weights` mapping that raises if anything reads it and still answers
identically at a roster of one (and the same mapping raises at a roster of two,
so the tripwire is real), and `delinquent()` answers `{}` on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` carries the curve, the
positive controls and the CFG-07 assessment.
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
CLAIMED.** `delivery_hold_s: 185` shipped resting on *"30.0 s, the worst
continuous legitimate episode at the shipped threshold across all 2640 runs"* -
from a harness `DECISIONS/SAFETY-42-unit-delivery.md` §1 deliberately did **not
commit**, because it imports `plantsim` and `frame_check` out of `admin/goodwe`.
A shipped safety constant the next reviewer cannot re-run is PROC-01's shape.
The harness is committed now, **in `admin/goodwe` as `sim/delivery_window.py`**,
beside `sitesim.py`. It is there and not here on purpose: those two modules are
that repo's, and restating them in this tree is the CI-08 coupling that has
broken three times. The dependency runs one way instead - this repo's `control`,
`arbiter` and the shipped `delivery.delinquent()` are imported live out of
whichever checkout is under test. Re-run it with:
```
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --blag
```
⚠️ **AND 30.0 s DID NOT REPRODUCE - IT IS 35.0 s.** Reported as measured rather
than adjusted to agree. What DID reproduce is the part the compatibility promise
rests on: the **N=1 control arm is 0.0 s at every threshold in every run**, and
the **flapping family is 0.0 s everywhere** in all 840 runs. What did not: the
headline (one control cycle worse), the reversal family (20.0 → 30.0 s), the
non-monotone curve (this rebuild's is monotone, so that finding is now an
argument and not a measurement), and the low-threshold rows, badly. The B-lag
rows moved the other way - a hypothetical unit four times slower than A gives
**75.0 s**, not 90.0 s - and they are relabelled throughout as a **sensitivity
study on a hypothesis about inverter B, which does not physically exist yet**,
rather than as a measurement of it.
**`delivery_hold_s` is NOT changed.** 185 s is 5.29× the re-measured worst
(was claimed 6.2×), 2.47× the four-times-slower case, 6.2× the firmware's
`LINK_STALE_S` veto and 4.9× inside the billing quarter: all four bounds still
clear and none narrowly. ⚠️ One consequence is worth an operator's attention:
at 35.0 s the rig's floor is now **above** the schema's own `int(30,900)`
minimum, so `delivery_hold_s: 30` is below a measured legitimate episode and
would report on ordinary saturation. The option is marked *derived, do not
change*; raising the schema floor is left to its own card.
⚠️ **UX-02 ADDS A SECOND PLACE TO CONFIGURE THIS ADD-ON, AND NO NEW WAY TO
COMMAND THE INVERTER.** The ingress panel gains a Configuration page beside
@@ -50,6 +234,47 @@ already declared, and the panel was already live.
the Configuration tab **can** be removed - at the cost of all 71 options, which
is why it stays. `DECISIONS/UX-02-two-config-surfaces.md` carries both.
⚠️ **UX-04 CHANGES NO BEHAVIOUR, NO SCHEMA AND NO DEFAULT.** It adds one check
file, three assertions to a probe, and a decisions record; `config.yaml`,
`translations/` and every module under `app/` are byte-identical to `6e7b233`.
It closes out [[UX-01]], which shipped in **0.7.0 and is live**, with three
criteria that were never met:
* **AC 3 - "the visible count drops to ~15" - is WITHDRAWN as unsatisfiable.**
`options:` and `schema:` declare the same 71 keys, so the set HA's *show
unused optional configuration options* toggle can hide is **empty**, and
Supervisor's own `UiOptions` marks **59 of the 71 required** - the frontend
shows those unconditionally, so 59 is the floor even with `options:` emptied
altogether. The target was ~4x below what any manifest can reach. ⚠️ And the
12 hides that *are* expressible would be bought with the deep-merge: a key
absent from `options:` has no default for the Supervisor to merge into a
0.7.0 box's stored blob, which is exactly the `delivery_hold_s` mechanism
that makes this release's upgrade safe rather than exit 78. The count was
**printed** by `tools/supervisor_shape_probe.py` §F from the day it was
written and never graded, which is how this passed review; it is now three
assertions, and removing one key from `options:` turns two of them red.
* **AC 4's third clause - the AST check for stray in-code literal fallbacks -
is BUILT**, as `test_defaults_pin.py`. It discovers the six
`*_OPTION_DEFAULTS` mirrors from the tree rather than naming them, grades all
30 mirrored keys against `config.yaml`, and freezes the CFG-03 backlog at
exactly today's **27 (module, key) pairs** so it can only shrink. It found
one previously unreported divergence: `main.py` answers a missing
`target_grid_w` with **0** where `config.yaml` ships **-10** - unreachable on
a Supervisor install, recorded rather than corrected. Its red direction runs
on every invocation, and a literal planted in the real tree was shown red.
* **AC 5 / the CFG-04 double-literal `max_w` hole is closed, by
[[UX-02]]'s `bcee235` and not by UX-01's `fef6ef2`.** Re-measured on this
branch: the `2000 -> 2500` triple mutation is **RED at `test_selfconfig.py`**,
where it survived 21/21 green on 0.7.0. The attribution is corrected in
`DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md`; nothing was
re-done.
The 8 per-entry `inverters:` translations **stay**. They are shipped, validated
and measured *not* to reach the Supervisor tab - `ha-selector-object` labels
those fields `fields[name].label || name` and the converter sets no `label` -
and UX-02's ingress panel is the surface that can render them. A written
decision, not a change.
## 0.8.0
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
@@ -161,17 +161,18 @@ 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
⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWED SAFETY-42's DETECTION WINDOW.
THAT WAS A DETECTION REGRESSION AT N=2, IT WAS NOT FIXED HERE, AND SAFETY-43
CLOSED IT - see the block at the end of this section.** 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
exists to exploit. And `delivery.delinquent()`
named a unit only while its `short_by()` gap exceeded `min_w =
Tuning.saturation_w`, and that was **500 W in absolute watts** with nothing
else beside it. 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.
@@ -200,20 +201,47 @@ 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. ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
`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. The
closure - `delinquent()` measured against the ask rather than a flat 500 W - is
card **SAFETY-43**.
`test_packshare.py` part 8b graded all of it and **pinned the unflattering
behaviour**, so that the day it changed a named check would move. It has moved.
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.
### ⚠️ CLOSED BY SAFETY-43 - THIS IS NO LONGER A DISCLOSED RESIDUAL
This section used to end *"until then this is a disclosed residual [...] at two
inverters, a wedged unit with materially less energy headroom than its sibling
can go unreported at ordinary house loads"*. That sentence is **retired, not
softened**, and what replaces it is a measurement rather than an intention.
`delivery.delinquent()` is now relative to the ask **as well as** absolute:
named if short_by() > min_w OR short_by() > frac * share
with `frac = delivery.DELIVERY_SHARE_FRACTION`. A unit delivering 0 of 125 W is
100 % delinquent and is named; a unit delivering 0 of 1500 W was already named
and still is. On the **same fixture, under the same weights**, part 8b now
grades both rules in one run - `share_frac=None` is SAFETY-42's absolute arm,
exactly:
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
⚠️ **`min_w` WAS NOT LOWERED, AND THAT IS THE HALF THAT MATTERS HERE.** The
absolute arm's suppression window is still exactly the arithmetic above - 1000 W
at equal weights, 6500 W at these - and part 8b still asserts both numbers.
Lowering `saturation_w` would have traded a missed alarm for a false one on
every unit at once, which is SAFETY-41's four-attempt regime. SAFETY-43 added a
**second** way to be named and left the first where it was.
⚠️ **AND WHAT OPT-09 STILL DOES NOT DO IS UNCHANGED.** This card stops the site
ASKING a pack whose own SoC says it cannot deliver; it does not re-allocate a
wedged unit's watts, and SAFETY-43 did not make it. The alarm got louder; the
loop did not move. The derivation of `frac` - swept over the same 2640 no-fault
runs that produced `delivery_hold_s`'s floor - is in
`DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md`.
So the seam, stated accurately **as of SAFETY-43**: OPT-09 stops the ask, and it
still narrows the ABSOLUTE arm's window for a low-headroom unit exactly as
described above - but it no longer narrows the DETECTOR, because the detector no
longer reads only that arm.
⚠️ **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
@@ -68,22 +68,102 @@ 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
### Reproducing it — ⚠️ THE HARNESS IS COMMITTED NOW (TEST-21, 2026-09-07)
⚠️ **THIS SECTION USED TO SAY THE HARNESS WAS NOT COMMITTED, AND THAT WAS THE
DEFECT TEST-21 FOUND.** A shipped safety constant whose floor the next reviewer
cannot re-run is PROC-01's shape, and PROC-01 had eleven instances in three
days. The paragraph this replaces was true — what the harness does *is* small
enough to restate — and it was not enough: a restatement in prose leaves every
axis it does not name to the next person's judgement, and §3.3 below now records
how far that moves the number. **It moved. See §3.3's second table.**
It is committed in the **other** repository, `admin/goodwe`, as
`sim/delivery_window.py`, beside `sitesim.py` — the container-free two-master
harness it is built on. From clean checkouts of both repos:
```
cd goodwe/sim
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --selftest
GOODWE_ADDON_REPO=/path/to/goodwe-addon python3 -B delivery_window.py --campaign --blag
```
stdlib only. No container, no HA, no esphome, **no rig and no rig lock** — it is
a `plantsim` campaign, not a rig job. About fifteen minutes for the whole thing.
⚠️ **AND IT IS IN `admin/goodwe` RATHER THAN HERE, WHICH IS CI-08's RULE
APPLIED RATHER THAN IGNORED.** It imports `plantsim` (the plant, the per-unit
wire, `Site`) and `frame_check` (the firmware's own encoder), both **owned by
that repo** and neither present in this one's CI image. Committed here it had
two possible fates and both are the coupling CI-08 has been broken by three
times: skipped for want of its imports — a green that establishes nothing — or
the plant and the encoder **restated in this tree**, which is a second copy of
something the other repo owns, free to drift, and drift in a fixture is
invisible until the number it defends is wrong. There the dependency runs one
way and stays one-directional: **this** repo's `control`, `arbiter` and
`delivery` are imported live out of whichever checkout is under test
(`$GOODWE_ADDON_REPO`), never copied — so a change to `delinquent()` moves the
number on the next run instead of silently disagreeing with a fixture. That is
the seam `sitesim.py` already stands on, which is the other reason the campaign
is its neighbour and not a third arrangement.
⚠️ **THE SELFTEST IS CI-GATED; THE CAMPAIGN IS NOT, AND THE SPLIT IS
DELIBERATE.** `admin/goodwe`'s `.gitea/workflows/checks.yml` gained one step —
`python3 -B delivery_window.py --selftest`, seconds, beside the existing
`norig, no lock` TEST-19 and TEST-20 steps, in the workflow that **already**
checks out this repo for `two_writers` and accepts that boundary in writing. So
a change **here** that moved `saturation_w`, `max_w`, `delivery_hold_s` or the
shape of `delinquent()` turns that step red rather than leaving a stale number
in this record. The **campaign** (`--campaign --blag`, ~15 minutes) is not
gated: it is a campaign, not a gate, and `failure-injection.yml`'s jobs run on
the **rig** runner, which a container-free simulation has no business holding.
What TEST-21 asked for is a command a reviewer can run from a clean checkout —
that is the block above.
⚠️ **`--selftest` IS A FALSIFIABILITY ARM, NOT A SMOKE TEST, AND IT RUNS
FIRST.** A campaign whose N=1 column and whose flapping column are 0.0 s
everywhere is indistinguishable from a campaign whose detector was never wired
up — `sitesim.broadcast_onset` exists for the same reason. So the selftest
drives a genuinely **wedged** unit through the same loop and refuses to proceed
unless the shipped `delinquent()` names it for longer than the shipped hold
(it names it for 275.0 s). Every 0.0 s in §3.3 is only worth reading because
that arm passes.
What it does, unchanged from the restatement it replaces: 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.
⚠️ **SINCE [[SAFETY-43]] THE ROWS OF §3.3's TABLE ARE GRADED WITH
`share_frac=None`** — `delinquent()`'s ABSOLUTE arm alone, which is this card's
rule exactly. Said here because it is the difference between §3.3 still meaning
what it is labelled as and quietly becoming a table about a different predicate:
SAFETY-43 added a SECOND way to be named, the harness grades it in its own rows
(`--fractions`), and the numbers below are unchanged and were re-measured to
confirm it. `DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md` §3 carries
those rows and what they cost — the worst legitimate episode goes from 35.0 s to
40.0 s at the shipped fraction, which does not move `delivery_hold_s`.
⚠️ **ONE THING THE RESTATEMENT DID NOT SAY, AND IT IS LOAD-BEARING.** "The
command standing before the write" is the **controller's** standing command —
`Inverter.standing_w()`, i.e. `last_write` — and **not** the far end of the
wire. A write that has been issued but has not landed yet is standing as far as
this add-on is concerned, which is the whole of the write-stagger axis. Grading
against the plant's latch instead reads a number no add-on has, and it makes
both the stagger *and a wedged unit* invisible: a unit whose writes never arrive
shows a standing command of 0 W, is never `asked`, and is never named. That is
not a conservative harness, it is a harness that cannot fail — and the rebuild
did exactly this on its first run, which is how the falsifiability arm earned
its place above.
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.
@@ -213,7 +293,9 @@ Every run is executed a second time at a **roster of one** on the same stimulus.
check"* from *"a property of the second inverter"*.
Worst **CONTINUOUS** legitimate episode, as a function of the shortfall
threshold:
threshold. **ORIGINAL CAMPAIGN, 2026-09-06, from the harness that was not
committed — SUPERSEDED, and kept here because a record that quietly replaced its
own numbers would be worth less than one that shows both:**
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (1890 runs)** |
|---:|---:|---:|---:|---:|
@@ -225,7 +307,100 @@ threshold:
| 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:
#### ⚠️ TEST-21, 2026-09-07 — THE RE-RUN, AND 30.0 s DID NOT REPRODUCE
`admin/goodwe:sim/delivery_window.py --campaign`, same three families, same
counts, same thresholds, the shipped `delinquent()`:
| threshold | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640 runs)** |
|---:|---:|---:|---:|---:|
| 100 W | 120.0 s (770) | 0.0 s (0) | 165.0 s (685) | **0.0 s (0)** |
| 250 W | 65.0 s (687) | 0.0 s (0) | 100.0 s (590) | **0.0 s (0)** |
| **500 W** | **30.0 s (460)** | **0.0 s (0)** | **35.0 s (403)** | **0.0 s (0)** |
| 700 W | 25.0 s (342) | 0.0 s (0) | 30.0 s (318) | **0.0 s (0)** |
| 1000 W | 15.0 s (4) | 0.0 s (0) | 25.0 s (131) | **0.0 s (0)** |
| 1500 W | 0.0 s (0) | 0.0 s (0) | 20.0 s (43) | **0.0 s (0)** |
| 2000 W | 0.0 s (0) | 0.0 s (0) | 0.0 s (0) | **0.0 s (0)** |
**The floor is 35.0 s, not 30.0 s** — weighted family, and the worst single run
is `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W reversal. The
argmax is printed with the table on purpose (see below): a worst case sitting on
an edge of the grid would be a number about the grid.
⚠️ **AND BY THAT STANDARD, TWO OF ITS FOUR AXES ARE ON AN EDGE. Measured, at the
argmax's other axes held fixed:**
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
0.10 -> 35.0 s
Strictly increasing in stagger and maximal at 4, the largest modelled stagger;
and maximal at the most asymmetric split. It is interior only in `gain` (2nd of
6) and `slew_w` (3rd of 5).
⚠️ **SO THE HEADLINE IS 0.0 s AT stagger=0, AND 35.0 s IS ENTIRELY A PRODUCT OF
THE MODELLED SERIALISED-WRITE STAGGER.** That stagger is declared MODELLED at
`sim/delivery_window.py:60-64` — it is a term of this figure, not a detail of
it. A site whose two writes land in the same cycle does not produce this episode
at all.
This does not move `delivery_hold_s`: 185 s must cover the worst case the
modelled site can produce, and a stagger of four cycles is inside what a real
two-inverter write path can do. But a reader entitled to ask "is 35.0 s about
the plant or about the grid?" deserves the answer, and the answer is: **partly
about the grid, and the grid's shape is the record's own, fixed by §3.3 before
this rebuild ran.** Found in review of TEST-21; the PR body had claimed the
argmax was interior, and it is not.
**WHAT REPRODUCED, EXACTLY:**
* the **N=1 control column is 0.0 s at every threshold**, in every run. That is
§3.2's algebra measured, and it is the claim the compatibility promise rests
on. (This rebuild ran the control arm over all three families — 2640 runs —
where the original ran 1890, i.e. the two families whose stimulus is not
weighted. The weighted stimulus at a roster of one is the same stimulus with
inert weights, and it too reports nothing at any threshold.)
* the **flapping family is 0.0 s at every threshold**, in all 840 runs. The
proper-subset rule really does defeat the family that defeated SAFETY-30's
site window.
* **45.0 s at twice A's lag**, to the cycle. See the B-lag table below.
**WHAT DID NOT:**
* the headline. **35.0 s against a recorded 30.0 s** — one control cycle worse,
and 20.0 s → 30.0 s on the reversal family, two cycles worse.
* **the non-monotonicity.** §3.3 finding 3 read the original 55.0 s at 250 W
against 40.0 s at 100 W as a property of the reduction. **This rebuild's
curve is monotone decreasing on both N=2 families.** The *mechanism* finding 3
describes is real and is still the reason not to read either table as a
sensitivity curve — lowering the threshold really can turn a proper subset
into the whole roster and break the run — but **this grid does not exhibit
it**, so the claim now rests on the mechanism alone and no longer on a
measurement. That is a weaker claim than the one this record made, and it is
written down as weaker.
* the low-threshold rows, badly: 120.0 s / 165.0 s at 100 W against 40.0 s /
75.0 s. Nothing ships at 100 W, but the size of that gap is the honest measure
of how much of the original table was a property of axes nobody wrote down.
⚠️ **WHY THE NUMBERS MOVED, AS FAR AS IT CAN BE ESTABLISHED.** §3.3 fixes the
*counts* of every axis (6 gains × 5 slews; 7 reversal sizes; 7 flap periods × 4
amplitudes; 5 staggers; and the 5 splits, which it lists) and the *ranges*
(`config.yaml`'s `gain` `float(0.05,1.0)` and `slew_w` `int(0,5000)`). It does
**not** fix the individual values, the run length, or the per-unit cap. The
rebuild spread them over the admissible ranges and **prints every one of them
above its own table**, so the caveat travels with the number instead of living
in somebody's memory. The residual disagreement — one to two control cycles at
the shipped threshold — is inside the sensitivity to that choice; the
low-threshold rows are not, and are reported as not.
⚠️ **AND THE HARNESS WAS NOT ADJUSTED UNTIL IT AGREED.** The one substantive
correction made during the rebuild was made *against* the doc's number, not
towards it, and it is recorded in §1: grading on the plant's latch rather than
the controller's standing command produced a harness that could not name even a
wedged unit. Fixing that made the numbers **larger**. Nothing else was tuned.
Findings 1-3 below are the original campaign's, kept with finding 3 now
qualified by the re-run above:
1. **The N=1 column is zero everywhere, in all 1890 runs and at every
threshold.** That is the compatibility promise measured, not argued.
@@ -268,37 +443,77 @@ would be a preference, and all the margin this check needs is in the window.
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 rig — 35.0 s** (TEST-21, 2026-09-07; **the original record
said 30.0 s and it did not reproduce**). The worst continuous legitimate
episode at the shipped threshold across all 2640 runs — weighted family, worst
single run `gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90` on the ±2200 W
reversal; 30.0 s on reversals, 0.0 s on flapping. Re-run it with
`admin/goodwe:sim/delivery_window.py --campaign` (§1).
⚠️ **THIS IS NOW STRICTLY THE BINDING LOWER BOUND, WHICH IT WAS NOT BEFORE.**
At 30.0 s it coincided with the firmware's veto below and a reader could not
tell which of the two was doing the work. At 35.0 s the rig's number is on its
own, above the firmware's — so **the rig is what stops `delivery_hold_s` going
low**, and the schema's `int(30,900)` floor no longer sits above the worst
legitimate episode. An operator who sets it to the schema minimum of 30 s is
now **below** a measured legitimate episode and will get false reports; the
option is marked *derived, do not change* and this is what that marking is
for. Raising the schema floor to 35 is a change to a shipped option's
contract and is left to its own card rather than smuggled in here.
* **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):
* **margin, from inverter B not existing.** ⚠️ **THIS ROW IS A SENSITIVITY
STUDY ON A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, AND THE ORIGINAL
WORDING ("MEASURED, not felt") OVERSTATED IT.** Inverter B is not installed
(~2026-09-12; NOTES.md §1.1b). The campaign's B is **A copied**, `measured=
False`, so the 35.0 s above is **two identical machines** and every row below
is a *what-if*: what a slower second unit would cost this window. **No row
here is B's lag.** What it is genuinely evidence for is the SHAPE of the
margin — which term the window has to be sized against — and that is what it
is used for. Sweeping a *different* second unit through the weighted family
(750 runs each). Second column is the original record, third is TEST-21's
re-run with the committed harness (`--blag`):
| 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 |
| B lag | B ramp | 2026-09-06 (uncommitted) | **TEST-21 re-run** | runs naming |
|---:|---:|---:|---:|---:|
| 10.0 s (A) | 800 W/s (A) | 30.0 s | **35.0 s** | 403/750 |
| 20.0 s | 800 W/s | 45.0 s | **45.0 s** | 454/750 |
| 10.0 s | 400 W/s | 30.0 s | **35.0 s** | 411/750 |
| 20.0 s | 400 W/s | 45.0 s | **50.0 s** | 454/750 |
| 40.0 s | 200 W/s | 90.0 s | **75.0 s** | 478/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.
**The LAG moves it roughly linearly and the RAMP barely moves it**, which is
the original finding and it survives — with one correction: *"the RAMP does
not move it at all"* is too strong. Halving the ramp is worth 0 s at A's lag
and **+5 s at twice it** (45.0 → 50.0), so the ramp is a second-order term
rather than no term. 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 rarely the binding one. So the number to carry margin against is still
B's lag — and a hypothetical unit **four times slower than A** produces
**75.0 s**, where the original record said 90.0 s. ⚠️ Note the direction: this
row moved **in the margin's favour**, while the floor above moved against it.
Neither was adjusted to suit the other.
* **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.
**185 s** is thirty-seven control cycles. Against the TEST-21 re-run it is
**5.29×** the measured worst legitimate episode (35.0 s), **2.47×** the
hypothetical four-times-slower second unit (75.0 s), **6.2×** the firmware veto
(30 s) and **4.9×** inside the quarter (900 s). ⚠️ 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.
⚠️ **185 STANDS, AND HERE IS THE ARITHMETIC RATHER THAN THE ASSURANCE.** The
record claimed 6.2× the measured worst; the re-run makes that **5.29×**. All
four bounds still clear, none of them narrowly, and the *ordering* of the
argument is unchanged: the rig's floor is the binding one, the quarter is the
ceiling, and 185 sits between them with margin at both ends. So the value is
**not changed** — a shipped safety constant is not moved because its margin
went from 6.2× to 5.3× while every bound still holds, and moving it would
invalidate SAFETY-41's four-attempt false-trip regime for nothing. What IS
changed is the claim: this record no longer says 6.2×, and no longer says 30.0
s, because neither is what the harness produces.
**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
@@ -0,0 +1,381 @@
# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W
**Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap
exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction
is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3.
⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md
§1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and
`test_packshare.py`'s plant is modelled in that file. **Every two-inverter
figure in this document is two copies of A.** Nothing here is a measurement of
inverter B.
## 0. The defect, and why it is the quiet regime that matters
[[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is
a function of a pack's **energy headroom**, not of what the pack delivers, so a
**wedged** unit — one that accepts its share and produces nothing — is weighted
down exactly like a legitimately low-headroom one. `delinquent()` read an
**absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share
shrinks the gap. Measured end to end on the real loop (`test_packshare.py`
part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real
`delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its
own 10 % cut-out, so no envelope closes and `account()` explains nothing — a
SAFETY-42 **true positive** by that card's own definition):
load 1500 W equal weights shares {a:1500, b:1500} named on 199/200
load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200
A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the
absolute threshold disagreed.
⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling
covers, the meter is satisfied (grid within 5 W of zero on that fixture),
`unmet_w` is 0 because both units *accepted* their shares, no envelope closes and
no action-log line fires. The delinquency alarm is the **only** instrument that
would have told the operator the site was quietly running one inverter.
## 1. What was NOT done, and it is the larger half of this card
⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which
the control law itself concludes the inverter is not following — and it exists to
stop **normal saturation** being reported as delinquency. Lowering it trades a
missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four
attempts** on notification cadence in this system and the owner's words were
*"i'm getting really annoyed by the notifications in my HA"*.
So the change is an **`or`**, never a looser first arm:
named if short_by() > min_w OR short_by() > frac * share
Everything `min_w` named before is still named. Nothing `min_w` refused becomes
noisy **unless the unit is delivering almost none of its ask**, which is not
saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its
existing `delinquent()` fixtures under **both** rules — `share_frac=None` is
SAFETY-42's rule exactly — and requires identical answers on every one.
The absolute arm's suppression window is therefore **unchanged**: still
`site target < 2·min_w` = 1000 W at equal weights, still
`site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's
fixture. Part 8b still asserts both numbers, now as evidence that the first arm
did not move.
## 2. The evidence is a diff between two rules on ONE run
`delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**.
That parameter exists for one reason: so the red and the green come out of one
run over one tree instead of out of two commits that a reader has to trust.
`test_packshare.py` part 8b grades every cycle twice:
load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles
load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles
load 1500 W equal weights absolute arm alone named on 199/200 cycles
load 1500 W equal weights the shipped rule named on 200/200 cycles
The equal-weights arm gains **exactly one cycle** — the first, where the slew
limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold
(the absolute bound is strict) and the relative arm names a box delivering none
of its 500 W. That single cycle is the whole of what this card costs an arm that
was already firing.
⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the
**suppressed** behaviour, and its comment said: *"if it ever goes red because the
alarm fired, read the card that made it fire before 'fixing' this line."* This is
that card. The pin now asserts the opposite answer and keeps the old one beside
it, measured rather than remembered.
## 3. ⭐ Deriving the fraction
⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen.
`admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed —
gained a second grading axis: the same **2640 no-fault runs at N=2** (three
families × 6 gains × 5 slews × write staggers; every unit executes the share the
allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped
`min_w = 500 W` for each candidate fraction, on every cycle of every run — so a
pass grades every fraction it is given, and the absolute arm beside them, over
one tree and one set of runs. Every row in a pass is comparable to every other
row in it, run for run.
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --fractions
⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL
TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten
`(0.25 … 0.99)` and then the ten that resolve the low end
`(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm
control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax),
which is what says the two passes are one campaign. A table a reviewer cannot
reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in
miniature, so the committed `FRACTIONS` was widened to all twenty in review: the
command above now reproduces every row below.
### 3.1 The curve
Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually
sees — its window is cleared by any cycle that does not name), runs naming in
brackets:
| `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** |
|---:|---:|---:|---:|---:|
| absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** |
| 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** |
| 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** |
| 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** |
| 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** |
| 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** |
| 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** |
| 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** |
| 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** |
| 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** |
| 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** |
| 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** |
| 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** |
| 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** |
| **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** |
| 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
| 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
| 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** |
Worst of the three families, which is what `delivery_hold_s` has to cover:
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** —
30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4
split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every
row below it, which is what makes those rows a comparison rather than a claim.
### 3.2 What the curve says
* ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From
**0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle
(5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at
0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not
0.90?" is asking about a difference this campaign cannot resolve, and the
honest answer is that it cannot.
* ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and
0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is
SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more
sensitive rule puts MORE units into the short set, which can turn a proper
subset into the WHOLE roster and END the episode.** The reduction is not
monotone in its own threshold. Do not read this column as a sensitivity curve
in the ordinary sense.
* **The shoulder is at about 0.45**, and it is a shoulder rather than an edge:
the reversal family's naming count saturates at 840 of 1050 runs from 0.30
upward, so below the shoulder what grows is the LENGTH of an episode, not the
number of runs that have one.
* **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.**
The compatibility promise is measured for the new arm, not argued.
* **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common
stimulus makes both units short at once and the subset stops being proper —
the property that defeated SAFETY-30's site window still holds.
### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction
The card asked for *the largest fraction that yields zero false namings across
the whole no-fault campaign*. **There is none.** Every fraction adds naming
cycles: the reversal family goes from 460 runs naming to 840, the weighted family
from 403 to 635. A relative arm that never fired on a legitimate transient would
be a relative arm that never fired at all — the write stagger lands B's number up
to four cycles late, and a unit whose new command has not arrived is, for those
cycles, delivering a small fraction of what is standing on its wire.
What **is** zero at every fraction is **false alarms**:
> the longest continuous legitimate episode anywhere in the 20-point sweep is
> **90.0 s** — at 0.05, the most sensitive fraction measured — against a
> `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within
> **95 s** of notifying an operator, **at any fraction measured**. At the
> shipped 0.85 it is 40.0 s, i.e. 145 s of slack.
That is the quantity SAFETY-41 cares about and it is the one this card had to
protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst
legitimate episode, where the absolute arm alone gave 5.29×.
### 3.4 The bounds 0.85 answers to
* **LOWER, from the campaign** — the shoulder at about **0.45**, below which the
added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40
above it.
* **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm
can only fire on a unit that is *importing* while asked to discharge, which is
SAFETY-30's fault and not this one, so a unit delivering **none** of its share
(gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
wedged box reporting a small standby figure instead of a clean zero.
* **UPPER, from the hold** — every fraction in the flat range leaves
`delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst
legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it.
**`delivery_hold_s` IS NOT CHANGED BY THIS CARD.**
* ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The
campaign cannot separate them. 0.85 is the midpoint of the argument above —
0.40 of margin above the shoulder, 0.15 below the ceiling — and a
re-derivation that moved it to 0.90 would be moving a number this campaign
never resolved. It is not moved for that reason.
## 4. The positive control — what the relative arm must still NOT say
A card that adds a way to raise an alarm has to show what it did **not** add.
`test_packshare.py` part 8b runs the same fixture, the same weights and the same
125 W share with the one fact that makes the shortfall legitimate changed:
| fixture | B's share | named | why it is silent |
|---|---|---|---|
| B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault |
| B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** |
| B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms |
### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did
**Corrected in review.** The first draft of this section claimed the wedged and
cut-out arms had *"identical power traces — 0 W against a positive share, 100 %
of the ask missing"* and that *"what separates them is `account()`"*. **Every
part of that was false**, and the check that carried it was **vacuous**:
* at its own cut-out the pack's usable energy **above that cut-out is zero**, so
`packshare.weights()` answers **0.0** for it, `_fill()`'s preferred pass gives
it nothing, the healthy sibling covers the load alone so the last-resort pass
never runs, and its share is **0 W on all 200 cycles**;
* a unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns `{}`
**before either arm is evaluated** and `account()` is **never consulted**;
* the traces are not identical — the cut-out arm's share is **0 W on every
cycle** (asserted in part 8b and printed on its line) while the wedged arm's
is **125 W** at the last cycle (asserted in the GREEN check) and takes four
distinct values 77/125/133/154 W across the 200, measured by re-running the
same loop with per-cycle recording during review — and "100 % of the ask" is
0/0.
Measured, by deleting the gate the claim rested on:
cut-out arm, account() live named 0/200, distinct B shares: [0]
cut-out arm, account() DELETED named 0/200, distinct B shares: [0]
wedged arm, account() live named 200/200, shares [77,125,133,154]
⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out
**OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never
reached — a **second, independent protection sitting upstream of `account()`**:
the allocator declines to ask before the detector has to decline to alarm. Part
8b now asserts the 0 W share and prints it, and runs the arm a second time with
`account()` deleted to show which mechanism is doing the work.
⚠️ **It was invisible for one reason worth keeping**: that line was the only one
in part 8b that did not print its shares. `inverter-b+0` would have been on
screen from the first run. Every line in that part prints them now, and the
derated control's vacuity guard now asserts **its own** unit is asked, rather
than re-asserting a different run's 200/200 — which is exactly why the old guard
could not see this.
**The account gate is graded where it is real**, on a positive 125 W share, in
`test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same
0 of 125 W with the shortfall explained names nobody). That part also carries
that the relative bound is **strict** (a gap of exactly `frac · share` is not
over it) and that the proper-subset rule still refuses a site-wide cause (both
units delivering none of a 125 W share names nobody).
## 5. N=1 is byte-identical, and it is asserted rather than argued
The live install is **N=1**. Two independent statements, each graded against the
thing that would break it (`test_packshare.py` part 8b):
1. **`allocator.plan()` returns before `weights` is read at all.** Graded by
handing it a `weights` mapping that **raises on any read**: at a roster of one
it answers identically to the same call with no weights; at a roster of two
the same mapping is read and raises, so the tripwire is a real one.
2. **`delinquent()` answers `{}` at a roster of one for every input** — 84
combinations of (share, measured, rule), including **0 of 125 W**, the exact
input this card added an arm for. `len(asked) < 2` returns before either
threshold is compared, so the fraction is never even multiplied.
Nothing in this card is reachable at one inverter. It must land before inverter B
is commissioned (~2026-09-12), not before the next deploy.
## 5b. Mutation — the fraction is pinned from BOTH directions
⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied
fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc`
could go stale — MUTATION.md's defect, and all three of these are **reds**, the
direction that defect cannot fabricate. Recorded in `test_packshare.py` part 11
as M12–M14.
| mutation | named checks | where |
|---|---:|---|
| **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. |
| **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. |
| **M14** `DELIVERY_SHARE_FRACTION` → **0.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. |
M13 and M14 are the pair that matters: the constant cannot be moved in **either**
direction without a named check moving with it.
### ⚠️ Two of these counts were wrong, and the reason is worth keeping
**Corrected in review.**
* **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed
the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any
tree where the relative arm is broken or absent that raises `KeyError` and
**aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded
coverage of a mutation run read *smaller* than the truth. The direction is safe
(an abort is a red, and MUTATION.md's defect can only fabricate a green), but a
figure that does not reproduce is a figure that does not reproduce. It is a
`.get` now, with the reason written beside it, and the counts above are from
re-runs with the guard in place.
* **M14 was recorded as 4 named checks in `test_delivery.py` and it is 5** — part
1's pre-existing *"a shortfall AT the threshold is not one"* also goes red. That
one under-claimed, so it never inflated the evidence; it is still a recorded
number that did not reproduce.
## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here
CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s
schema while the measured worst legitimate episode at the shipped threshold is
**35.0 s** — so the schema offers an operator a value (30) that is **below a
measured legitimate episode** and would report on ordinary saturation.
**This card makes it worse, and by exactly one control cycle.** The relative arm
takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped
fraction, so the band of schema-legal but false-tripping values widens from
`[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not
35 s. It is not fixed here: raising a schema floor is an option-surface change
with its own compatibility argument (an existing config carrying `35` would start
being refused), and folding it into a safety fix would hide it. **CFG-07 should
be re-derived against 40.0 s, not 35.0 s.**
⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).**
Not before the next deploy. The band is **unreachable at N=1** — `delinquent()`
returns `{}` for every input at a roster of one, so no value of
`delivery_hold_s` can false-trip on the install that is running today — and the
live install is N=1 until B exists. Commissioning B is the moment the band
becomes reachable, and that is the deadline this card hands it.
⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a
`[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else —
so there is no file for the 40.0 s to be recorded in, and a reader who goes
looking will find a card reference with no record behind it. Whoever takes
CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is
**40.0 s**, measured here, and the harness that produces it is
`admin/goodwe:sim/delivery_window.py --campaign --fractions`.
## 7. Provenance of every number in this document
| number | where it came from |
|---|---|
| 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run |
| 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 |
| every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them |
| 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there |
| 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card |
| 4.62× | 185 / 40.0, printed by the campaign |
| 0/200 on both positive controls, and B's 0 W share at the cut-out | part 8b, run and asserted |
| 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed |
| 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only |
| M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded |
| 84 single-unit rosters | part 8b, run |
⚠️ Every two-inverter figure above is **two copies of inverter A**.
@@ -0,0 +1,379 @@
# UX-04 — what the Configuration tab renders, and the pin that was never built
**Status: DECIDED 2026-09-07.** Three decisions and one confirmation, in the
order [[UX-01]]'s unmet acceptance criteria are numbered:
1. **UX-01 AC 3 (hide the optional fields behind HA's toggle) is WITHDRAWN as
unsatisfiable**, not deferred. The target was arithmetically unreachable, and
the part of it that *was* reachable would be bought with the upgrade path.
The measurement is now **graded** in `tools/supervisor_shape_probe.py` §F
rather than printed, so the withdrawal cannot be quietly reversed.
2. **UX-01 AC 4's third clause is now BUILT**: `test_defaults_pin.py`, an AST
check, with its red direction executed on every run.
3. **UX-01 AC 5 / the CFG-04 double-literal hole is CONFIRMED CLOSED on
`release/1.0`, by [[UX-02]]'s `bcee235`, not by UX-01's `fef6ef2`.** The
attribution is corrected here; no work was re-done.
4. **The 8 per-entry `inverters:` translations STAY.** They do not reach the
Supervisor tab and cannot be made to; [[UX-02]]'s ingress panel is where they
become visible.
Filed with UX-04. Card `6a9ec94617e9c48ed74ca5dd`. Every number below was
measured on `release/1.0` at `6e7b233` on 2026-09-07, against
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` — the version
`ha info` reports on the live install — and against the 26-file check suite in
`goodwe-addon-checks:1`. Nothing here contacted the live Supervisor at
192.168.2.158, posted options, or restarted anything.
---
## 1. Hiding: withdrawn, and why that is the *safe* answer
### 1.1 What AC 3 claimed
> "The visible count drops to ~15, with the rest behind HA's *show unused
> optional configuration options* toggle."
### 1.2 What the tab actually renders
`config.yaml` declares **71 keys under `options:` and the same 71 under
`schema:`**. The set that HA's toggle is *able* to hide — schema-only keys — is
therefore **empty**:
```
hideable = set(schema) - set(options) -> [] (0 of 71)
```
On the deployed 0.7.0 tree the same measurement is 70/70. Nothing was hidden
before UX-01, nothing is hidden after it, and no line of `fef6ef2` moved a key
either way. AC 3 is not partially met; it is unstarted.
### 1.3 The rule that decides it, replayed rather than paraphrased
From the frontend bundle shipped **in this image**,
`api/panel/frontend_latest/5680.*.js` (frontend 20250925.1):
```js
_filteredSchema = (opts, schema) => schema.filter(e => e.name in opts || e.required)
```
`opts` is the add-on's options **as Supervisor serves them** — the operator's
stored blob after `options:` has been deep-merged into it. So a field is hidden
only when it is **both** absent from `options:` **and** not `required`. The
probe now runs that filter on this manifest rather than quoting it.
### 1.4 Reason one: the target was never reachable
Supervisor's own `UiOptions` marks `required` on **59** of the 71 fields; 12 are
optional. The filter above shows a required field unconditionally. So the
**floor** — what the tab renders with `options:` emptied *altogether*, which is
the most hiding that is even expressible — is 59 fields.
| `options:` block | fields rendered |
|---|---|
| as shipped (71 keys) | **71** |
| emptied entirely (0 keys) | **59** |
| AC 3's target | ~15 |
AC 3 asked for a number roughly four times below the floor. No arrangement of
`options:` reaches it. The criterion could not have been met by any
implementation; it was mis-specified, and the honest disposal of a
mis-specified criterion is withdrawal, not a partial gesture toward it.
### 1.5 Reason two: the 12 hides that *are* expressible cost the deep-merge
Moving a key out of `options:` is not free. `App.options` is
`deepmerge(config.yaml defaults, the stored user options)`, so **a key absent
from `options:` has no default for the Supervisor to merge**.
That is exactly the mechanism the 0.9.0 upgrade rests on. From `CHANGELOG.md`,
the pre-deploy check run against this release on 2026-09-07:
> The reference install's stored blob has **70 keys, not 71**.
> `delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running
> 0.7.0 has never stored it — and it is REQUIRED in the schema, no trailing
> `?`, which is the shape that becomes **exit 78**. It resolves to its
> `options:` default of **185** because the Supervisor deep-merges.
Hiding a key is done by taking it *out* of `options:` and marking it `?`. That
is, precisely, the edit that removes a key's merge default — the thing standing
between a 0.7.0 box and exit 78 on upgrade. It would be traded for at most 12
fewer rows on a form, on a site where the add-on is holding a real house's grid
exchange at zero.
### 1.6 Reason three: it would promote 27 unpinned literals to load-bearing
And the cost compounds with §2 of this document. `test_defaults_pin.py` part 4
ledgers **27 (module, key) pairs** in `app/` that still answer a missing option
with an in-code literal. Today those literals are unreachable on a Supervisor
install, because the deep-merge guarantees every `options:` key is present in
the served blob. Remove keys from `options:` to hide them and those literals
stop being dead code: they become **the actual source of the defaults on a live
install** — the CFG-03 defect the same card's AC 4 exists to police, made
load-bearing by the card's own AC 3.
### 1.7 What now grades this
`tools/supervisor_shape_probe.py` §F. The count was printed there from the day
it was written — *"the Configuration tab renders 71 fields"* — and the probe
exited 0 through UX-01's whole review while displaying the number that
contradicted the criterion it was evidence for. **A measurement that is
displayed but not asserted is how this passed review.** Three assertions
replace the bare print:
```
PASS every one of the 71 `schema:` keys is ALSO in `options:`, so schema-only
- the only set HA's toggle can hide - is EMPTY. Hideable: []
PASS ...so replaying the frontend's own _filteredSchema on the served options
hides NOTHING: 71 of 71 fields render
PASS ...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is
expressible - the tab still renders 59 required fields. THAT is the
floor, and AC 3 asked for ~15.
```
Every number in them is computed from the manifest and from Supervisor's own
`required` flag; none is typed. **The red direction was run**: deleting one key
(`price_feed_url`) from `options:` and nothing else turns the first two to FAIL
— `Hideable: ['price_feed_url']`, `70 of 71 fields render` — and the probe
exits 1. The counterfactual is isolated: one key varied, nothing else.
So if a future editor empties `options:` to shorten the form, this goes red and
somebody has to read §1.5 before merging.
---
## 2. The defaults pin: `test_defaults_pin.py`
### 2.1 What AC 4 asked for and what shipped
AC 4 has three clauses.
| clause | state before UX-04 |
|---|---|
| "every `schema:` key absent from `options:` has exactly one defaults-table entry" | **vacuous** — that set is empty (§1.2), so the clause is true of any tree, including one with no defaults at all |
| "the table changed → red" | **works**, six times over, once per mirror in its own suite |
| "no module carries its own literal fallback for a schema default — an AST check, not a grep" | **never built** |
The only literal scan in the repository before this card is
`test_validate.py:1025`: a `re.findall` restricted to eight `maintenance_*`
keys across three named files. It is a grep, it predates UX-01, and it was not
written for this.
### 2.2 What an "in-code literal fallback" is, structurally
A call in `app/` taking a string literal that names a `schema:` key, with a
constant in the next positional slot:
```python
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
```
Found by walking `ast.Call` nodes — so a multi-line call and a renamed `opts`
are found, and the same text inside a comment or docstring is not. Two
exclusions, both structural and both argued at the site:
* **`getattr(obj, "max_w", 0.0)`** reads an attribute off an object, not an
option off a blob. Excluded by callee name.
* **the empty string.** `opts.get("static_profile", "")` is Python's "not
stated", and all six such sites in `validate.py` normalise it immediately —
`str(opts.get(k, "") or "").strip()`. It cannot be a stale copy of a *number*,
which is the whole of the CFG-04 defect class. Excluded by value. **`0` is
not excluded**: `slew_w: 0` and `integrator_max_w: 0` are real shipped
defaults with real meanings.
### 2.3 What it grades, measured on `release/1.0` @ `6e7b233`
```
2. the mirrors, DISCOVERED from the tree rather than listed here
6 table(s): circulation.py:CIRC_OPTION_DEFAULTS, delivery.py:DELIVERY_OPTION_DEFAULTS,
maintenance.py:MAINT_OPTION_DEFAULTS, peakguard.py:PEAK_OPTION_DEFAULTS,
price.py:PRICE_OPTION_DEFAULTS, watchdog.py:SIGN_OPTION_DEFAULTS
30 mirrored key(s) of 71 in `schema:`
3. the AST scan
scanned 29 modules, 34 literal fallback site(s): 0 over mirrored keys,
34 over unmirrored ones
4. the CFG-03 ledger
27 (module, key) pair(s) carry a literal for a key no mirror answers for
```
Four properties, none of them vacuous:
1. **The mirrors are discovered, not listed.** Six suites already pin their own
table to `config.yaml` — and every one of those pins names its table by
hand, so a *seventh* table added tomorrow would be pinned by nobody and
nothing would say so. This walks `app/` for module-level `*_OPTION_DEFAULTS`
assignments and grades whatever it finds against `config.yaml`. That is the
property the six hand-written pins cannot have.
2. **No literal survives for a key a mirror already answers for.** 30 keys,
0 violations.
3. **The CFG-03 ledger is FROZEN, by (module, key) pair, not by count.** A
count goes green again the moment somebody deletes one site and adds
another. Line numbers are deliberately *not* in the key: a ledger that goes
red on an unrelated reformat is a ledger people learn to re-bless without
reading. The 27 pairs are the open CFG-03 backlog; they are **not** removed
here, because that is behaviour change across `main.py`, `p1.py`,
`inverter.py` and `validate.py` that no single card should make in passing.
The set can now only shrink.
4. **Every remaining literal is graded against `config.yaml`** — the half that
catches CFG-04's actual failure mode, two copies of one number and one moves.
### 2.4 One divergence found, and it is new
```
main.py target_grid_w: code 0 vs config.yaml -10
```
`main.py:290` reads `target_grid_w=float(opts.get("target_grid_w", 0))` while
`config.yaml` ships **-10**. A dict handed to `Controller` without the key holds
net exchange at 0 W instead of the shipped -10 W bias into import. **It cannot
reach an operator**: on a Supervisor install the deep-merge guarantees the key
is present, and the only paths that hand over a partial dict are
`sim/inject.py` and this repository's own fixtures. It is recorded in the check
as the single permitted divergence, with the reason, rather than corrected —
changing it alters the control loop's target and belongs to a card that grades
the loop. A *second* divergence goes red.
### 2.5 ⭐ The red direction, shown rather than claimed
A check that has never been shown failing is not a check — which is exactly
what clause one of this AC was. Two things follow from that:
**(a) The red direction runs on every invocation.** Part 5 feeds the scanner a
synthetic module containing a deliberate violation of each rule and *requires*
it to be flagged, in-process, through the same `literal_fallbacks()` the graded
parts call. A scanner that goes blind — a renamed helper, an `ast` change, a
`SCHEMA` that comes back empty — stops working here too, and the total site
count is additionally graded against a floor.
**(b) A literal was planted in the real tree and the check went red.** Planted
at `main.py:290`, `float(opts.get("maintenance_charge_w", 2000))` — a mirrored
key:
```
3. the AST scan: no module carries its own literal fallback for a MIRRORED schema default
scanned 29 modules, 35 literal fallback site(s): 1 over mirrored keys, 34 over unmirrored ones
ok the scanner is not blind: it found 35 sites (floor 20)
main.py:290 get('maintenance_charge_w', 2000)
FAIL no literal fallback survives for any of the 30 keys a mirror already
answers for - those sites must index the mirror
1 FAILED (exit 1)
```
And part 4's two directions, planted the same way with
`opts.get("p1_host", "x")` and `opts.get("slew_w", 999)`:
```
4. the CFG-03 ledger
28 (module, key) pair(s) carry a literal for a key no mirror answers for
FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')]
main.py p1_host: code 'x' vs config.yaml ''
main.py slew_w: code 999 vs config.yaml 0
main.py target_grid_w: code 0 vs config.yaml -10
FAIL every remaining literal still AGREES with config.yaml, except the 1
divergence(s) recorded above it
(exit 1)
```
The second of those is the CFG-04 defect class itself: a literal that has
drifted away from `config.yaml` while the file still says something else. Both
plants were removed; the tree is byte-identical to `6e7b233` apart from this
card's own files.
---
## 3. CFG-04: closed on `release/1.0`, by UX-02, not by UX-01
UX-01's AC 5 claimed the double-literal `max_w` hole. It did not close it.
`fef6ef2` touched `config.yaml`, `translations/`, `test_translations.py` and the
probe — **no defaults code at all** — and on the 0.7.0 tree the triple mutation
survives: 21 files, 0 RED.
**Confirmed on current `release/1.0` @ `6e7b233`, 2026-09-07.** The same triple
mutation, `2000 -> 2500` at the three descendants of 0.7.0's
`inverter.py:438` / `validate.py:383` / `validate.py:684`:
| 0.7.0 | `release/1.0` @ `6e7b233` |
|---|---|
| `inverter.py:438` `max_w=float(opts.get("max_w", 2000))` | `inverter.py:640` `raw = opts.get("max_w", 2000)` |
| `validate.py:383` `cap = _number(opts, "max_w", 2000.0)` | `validate.py:466`, same line |
| `validate.py:684` `max_w = _number(opts, "max_w", 2000.0)` | `validate.py:836`, same line |
Result — 26 files, **RED at `test_selfconfig.py`**, exit 1:
```
1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the
rail collapsing to the 2000 default under an explicit 400 W slew, so the box
cannot be emptied into a quietly halved rail
```
`git log -L 310,325:goodwe_controller/test_selfconfig.py` attributes that check
to **`bcee235`, "UX-02: pin both halves of the max_w fix, because neither was
graded", glenn schrooyen, 2026-09-06** — not to UX-01. The hole ships closed
with the pending 0.9.0 deploy. **The AC is met by UX-02.** No work was re-done
here; the mutation was run once, on a throwaway copy, to confirm it.
---
## 4. The 8 per-entry translations: they stay
### 4.1 The measurement, and it is negative
`translations/{en,nl}.yaml` carry a name and a description for all 8 per-entry
keys of the repeatable `inverters:` section. They are loaded by Supervisor's own
loader, they pass `SCHEMA_APP_TRANSLATIONS`, and `test_translations.py` grades
their coverage. **They do not reach the screen**, and this is not a defect in
them. `UiOptions` gives `inverters` `type: "schema"` with `multiple: True`, and
the frontend's `_convertSchemaElement` sends exactly that case to a selector
rather than to a nested `ha-form`:
```js
if ("schema" === t.type && !t.multiple) return {...type:"expandable"...} // not us
if ("schema" === t.type) { const o = {}; for (const a of t.schema)
o[a.name] = {required: a.required, selector: ...}; return {object:{multiple:t.multiple, fields:o}} }
```
and `ha-selector-object` labels those fields `fields[name].label || name`, while
`_convertSchemaElementToSelector` sets **neither `label` nor `translation_key`**.
So an operator sees raw `name`, `setpoint_entity`, `max_w`, `bms_soc_floor`. The
probe already asserts this, negatively and by name.
### 4.2 Decision: keep them. Four reasons.
1. **The Supervisor tab cannot be made to render them.** The gap is in
somebody else's converter, not in this repository. There is no change here
that fixes it; deleting the translations does not fix it either.
2. **[[UX-02]]'s ingress panel is where they become visible**, and it renders
the roster itself rather than handing it to `ha-selector-object`. The
`fields:` block is the shape Supervisor's own schema defines for exactly
this, and it is what a surface that wires `label` through consumes.
3. **They cost nothing.** No runtime path reads them; they are two YAML blocks
in files the Supervisor already parses, and the parse is graded green.
4. **Deleting them would remove the only thing that catches a per-entry key
added without a translation.** `test_translations.py` grades top-level and
per-entry coverage symmetrically; drop the per-entry half and SAFETY-32's
`bms_soc_floor` — a *required* per-entry key added after UX-01 — would have
joined the roster with no label and nothing to say so.
This is a written decision, not a change. The tree is unmodified in
`translations/`.
### 4.3 Not re-filed
`tools/supervisor_selfapi_probe.py` has been known red since OPT-09 — it
hardcodes `len(_sub) == 7` against 8 per-entry keys. That is **TOOL-04**,
already filed, and it is not re-filed or fixed here. It is named only so a
reader of §4.1 knows why the *other* probe is the one quoted.
---
## 5. What this card did not do
* It did not remove a key from `options:`. See §1.5 — the deep-merge default is
what makes the pending 0.9.0 upgrade safe on a 0.7.0 box.
* It did not pay down the 27-pair CFG-03 ledger. See §2.3.
* It did not correct `main.py`'s `target_grid_w` fallback. See §2.4.
* It did not touch `translations/`. See §4.2.
* It did not re-do CFG-04. See §3.
* It did not touch hardware, the live Home Assistant at 192.168.2.158, or
`sim/`. Both probe runs were throwaway containers over a read-only mount.
+45 -1
View File
@@ -110,7 +110,8 @@ so Home Assistant's *Configuration* tab renders human labels and a description
for every option instead of the raw option keys. What that does and does not buy
you is worth stating plainly, because it was **measured against the Supervisor
image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`)
rather than assumed — `tools/supervisor_shape_probe.py` section F prints the
rather than assumed — `tools/supervisor_shape_probe.py` section F GRADES (it
used only to print) the
whole rendered list, in both languages.
| | |
@@ -1572,3 +1573,46 @@ If an MQTT broker is available the add-on publishes setpoint, grid power,
battery power, circulating power, state of charge, maintenance phase and
controller status by MQTT discovery. This is observability only — the controller works fine without a
broker, and MQTT problems can never affect control.
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
Two ways a deploy has gone wrong on this system, both of which the Supervisor
reports as success:
**The silent no-op.** The Supervisor keys the built image by `version:` in
`config.yaml`. `ha apps update` against an unchanged version installs nothing
and says it worked. This happened twice: PKG-06 (six merges behind an unbumped
version) and PKG-07 (six again). Both times a human noticed. Nothing else would have.
**The refused config.** A config the shipped validator rejects is
`SystemExit(78)` — the add-on does not start. ⚠️ **And the ingress panel goes
with it**, because the add-on serving it is not running, so recovery means the
Supervisor UI. SAFETY-39's rule 10 made this reachable from a config that
started fine on the previous version: 0.7.0's own default `slew_w: 1000`
exceeds the 555.6 W ceiling on a 5000 W rail.
Run it inside the container, so the validator half actually runs:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.7.0 --options /w/live-options.json
Export `live-options.json` from the Supervisor first — the options blob is not
reachable through the Home Assistant REST API, and no entity carries it.
**Exit codes.** `0` cleared · `1` REFUSED, do not deploy · `2` NOT CLEARED,
meaning a check did not run. ⚠️ **2 is not a pass.** On a bare host without the
add-on's dependencies the validator half skips and returns 2 rather than
pretending; `test_version_gate.py` pins that a skipped check can never exit 0,
because the first draft of that test reported four silent skips as passes.
⚠️ **Why this is not a CI check.** The obvious gate — "a pull request touching
runtime code must bump the version" — is the wrong shape here. This project
deliberately batches many merges into one deploy, so a per-PR bump would put
every branch in conflict over one line and still not prevent the failure, which
happens at deploy and not at merge. The check belongs where the mistake is made.
⚠️ This file's own change does **not** need a version bump, and the gate agrees:
`tools/` and `test_*.py` are not in its runtime set, because demanding a bump
for a typo fix trains people to bump without reading — the habit the check
exists to prevent.
+208 -39
View File
@@ -264,36 +264,66 @@ DELIVERY_OPTION_DEFAULTS = {
# 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:
# stimulus, as the control.
#
# ⚠️ RE-RUN BY TEST-21 (2026-09-07) AND THE HEADLINE DID NOT REPRODUCE. The
# harness that produced the original table was never committed, which is
# PROC-01's shape; it is committed now, in the OTHER repository, as
# `admin/goodwe:sim/delivery_window.py` (it imports `plantsim` and
# `frame_check`, which that repo owns - restating them here would be CI-08's
# coupling, and CI-08 has broken three times). Re-derive it with:
#
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
# python3 -B delivery_window.py --selftest --campaign --blag
#
# Worst CONTINUOUS legitimate episode. Second block is the re-run:
#
# threshold reversals flapping weighted N=1 control (1890 runs)
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s
# 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
# 100 W 40.0 s 0.0 s 75.0 s 0.0 s } 2026-09-06
# 250 W 55.0 s 0.0 s 60.0 s 0.0 s } harness
# * 500 W 20.0 s 0.0 s 30.0 s 0.0 s } NOT
# 700 W 20.0 s 0.0 s 20.0 s 0.0 s } COMMITTED
# 1000 W 15.0 s 0.0 s 15.0 s 0.0 s } - see
# 2000 W 10.0 s 0.0 s 5.0 s 0.0 s } DECISIONS
#
# threshold reversals flapping weighted N=1 control (2640 runs)
# 100 W 120.0 s 0.0 s 165.0 s 0.0 s } TEST-21
# 250 W 65.0 s 0.0 s 100.0 s 0.0 s } 2026-09-07
# * 500 W 30.0 s 0.0 s 35.0 s 0.0 s } committed
# 700 W 25.0 s 0.0 s 30.0 s 0.0 s } harness,
# 1000 W 15.0 s 0.0 s 25.0 s 0.0 s } re-runnable
# 1500 W 0.0 s 0.0 s 20.0 s 0.0 s }
# 2000 W 0.0 s 0.0 s 0.0 s 0.0 s }
#
# * is the shipped threshold, `Tuning.saturation_w`.
#
# ⚠️ THREE THINGS IN THAT TABLE ARE WORTH MORE THAN THE ROW THEY SIT ON.
# (a) The N=1 column is ZERO EVERYWHERE, 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.
# (a) The N=1 column is ZERO EVERYWHERE, at every threshold, IN BOTH
# CAMPAIGNS. The compatibility promise is measured, not argued, and it
# is the part that reproduced most cleanly.
# (b) The FLAPPING column is zero everywhere too, in both - and that is the
# family that defeated SAFETY-30's site window (980 s at N=2, 1000 s at
# N=1, no window inside the billing quarter surviving it). It cannot
# touch this check, because a common stimulus makes BOTH units short at
# once and the subset stops being proper.
# (c) THE ORIGINAL CURVE WAS NOT MONOTONE - 55.0 s at 250 W against 40.0 s
# at 100 W - and that was read as a property of the reduction: a lower
# threshold puts MORE units into the short set, which can turn a proper
# subset into the whole roster and BREAK the run. ⚠️ THE RE-RUN'S CURVE
# IS MONOTONE. The mechanism is real and is still the reason not to read
# either table as a sensitivity curve, but it is no longer something
# these numbers demonstrate - it is an argument now, not a measurement.
#
# THE BOUNDS THE VALUE ANSWERS TO:
#
# LOWER, from the rig - 30.0 s, the worst continuous legitimate episode at
# the shipped threshold across all 2640 runs.
# LOWER, from the rig - 35.0 s (TEST-21; the original record said 30.0 s
# and it did not reproduce), the worst continuous legitimate episode at
# the shipped threshold across all 2640 runs. ⚠️ THIS IS NOW STRICTLY THE
# BINDING LOWER BOUND: at 30.0 s it coincided with the firmware's veto
# below and a reader could not tell which was doing the work; at 35.0 s it
# is ABOVE it, and it is above the schema's own `int(30,900)` floor too -
# so an operator who sets `delivery_hold_s` to 30 is below a MEASURED
# legitimate episode. That is what "derived, do not change" is for.
#
# LOWER, from the firmware - `inverter.LINK_STALE_S`, 30 s. A unit not
# executing its share for less than the firmware's own veto window is a
@@ -302,26 +332,47 @@ DELIVERY_OPTION_DEFAULTS = {
# 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.
# MARGIN, from INVERTER B NOT EXISTING. ⚠️ THIS IS A SENSITIVITY STUDY ON
# A HYPOTHESIS ABOUT B, NOT A MEASUREMENT OF B, and the wording here used
# to say "MEASURED rather than GUESSED", which overstated it. B is not
# installed (~2026-09-12). The campaign's B is A COPIED
# (`plantsim.INVERTER_B`, `measured=False`), so the 35.0 s above is TWO
# IDENTICAL MACHINES and every row below is a what-if. NO ROW HERE IS B's
# LAG. Sweeping a DIFFERENT second unit through the weighted family
# (2026-09-06 -> TEST-21 re-run):
#
# B lag / ramp original re-run
# 10.0 s / 800 W/s 30.0 s 35.0 s (= A, both copies)
# 20.0 s / 800 W/s 45.0 s 45.0 s
# 10.0 s / 400 W/s 30.0 s 35.0 s
# 20.0 s / 400 W/s 45.0 s 50.0 s
# 40.0 s / 200 W/s 90.0 s 75.0 s (4x A's lag)
#
# The LAG moves it roughly linearly; the RAMP barely does. "The ramp does
# not move it AT ALL" was too strong - halving it is worth 0 s at A's lag
# and +5 s at twice it - so the ramp is second-order, not absent. That is
# the sensitivity the margin has to carry until SPIKE-04 measures the real
# unit.
#
# UPPER, from the bill - 900 s, the quarter that sets the capacity tariff.
# A site running short of one unit's share must be reported well inside
# one.
#
# 185 s: thirty-seven control cycles. 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
# 185 s: thirty-seven control cycles. Against the TEST-21 re-run: 5.29x the
# worst legitimate episode this rig produces (35.0 s), 2.47x the worst a
# hypothetical second unit FOUR TIMES slower than A produces (75.0 s), 6.2x
# the firmware's own veto window (30 s), and 4.9x inside the quarter
# (900 s). ⚠️ DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this
# is a different check on a different quantity, so a reader must not be
# able to think it was carried across - and not 150 or 200.
#
# ⚠️ 185 STANDS AND IS NOT CHANGED. The record claimed 6.2x the measured
# worst; the re-run makes it 5.29x. All four bounds still clear and none of
# them narrowly, and the ordering of the argument is unchanged. A shipped
# safety constant is not moved because a margin went from 6.2x to 5.3x while
# every bound holds - moving it would re-open SAFETY-41's four-attempt
# false-trip regime for nothing. What changed is the CLAIM, not the value.
#
# WHAT IT COSTS, stated rather than hidden: up to 185 s of a site running
# short by one unit's share before anybody is told. Against a fault that ran
# 6 h 25 m unnoticed, and against a guard that TAKES NO ACTION, this window
@@ -363,6 +414,94 @@ DELIVERY_OPTION_DEFAULTS = {
MIN_W_IS_SATURATION_W = "control.Tuning.saturation_w"
# ---------------------------------------------------------------------------
# SAFETY-43. THE SECOND WAY TO BE NAMED, AND WHY IT IS A FRACTION OF THE ASK.
#
# `min_w` above is an ABSOLUTE gap, and OPT-09 made that a hole. `weights()`
# shrinks a low-headroom unit's share, and shrinking the share shrinks the gap:
# a unit standing at +125 W and delivering 0 W is 100 % delinquent and 125 W
# short, so an absolute 500 W never names it. Measured end to end
# (`test_packshare.py` part 8b, real `compute()`, real `plan()`, real
# `delinquent()`, B wedged at a healthy 15 % against its own 10 % cut-out):
#
# load 1500 W, equal weights the wedged unit named on 199/200 cycles
# load 1500 W, OPT-09 weights named on 0/200 cycles
#
# ⚠️ AND THE SUPPRESSED REGIME IS THE QUIET ONE. At a load the healthy sibling
# covers, the meter is satisfied, `unmet_w` is 0 and no action-log line fires,
# so this alarm is the ONLY instrument that would have told the operator.
#
# ⚠️ THE FIX IS NOT A SMALLER `min_w`, AND THAT IS THE WHOLE SHAPE OF THIS
# CARD. `min_w` is `Tuning.saturation_w` and it exists to stop NORMAL
# SATURATION being reported as delinquency - the crying-wolf failure the whole
# module is written against, and the one SAFETY-41 spent four attempts on
# ("i'm getting really annoyed by the notifications in my HA"). Lowering it
# trades a missed alarm for a false one on every unit at once. A fraction of
# the unit's OWN SHARE is a SECOND way to be named that leaves the first
# exactly where it was: `gap > min_w OR gap > frac * share`. Nothing that was
# silent before this card becomes noisy unless the unit is delivering almost
# none of what it was asked for, which is not saturation-shaped at all.
#
# WHERE 0.85 COMES FROM - MEASURED ON THE SAME CAMPAIGN AS `delivery_hold_s`.
# `admin/goodwe:sim/delivery_window.py --campaign --fractions` grades this
# constant over the SAME 2640 no-fault runs (three families x 6 gains x 5 slews
# x the write staggers, every unit executing the share it was given) that
# produced the 35.0 s worst legitimate episode, at the shipped `min_w`, and
# reports what the relative arm ADDS. Worst continuous legitimate episode,
# worst of the three families:
#
# frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
# worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
#
# frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
# worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
#
# absolute arm alone: 35.0 s - SAFETY-42 §3.3's figure, re-measured in the
# SAME runs, which is what makes every row above a comparison and not a claim.
# N=1 control: 0.0 s at EVERY fraction, in all 2640 one-inverter runs.
# Flapping family: 0.0 s at EVERY fraction, in all 840 runs.
#
# ⚠️ READ THE CURVE, NOT THE ROW. THERE IS NO CLIFF ANYWHERE IN IT. From 0.45
# to 0.99 every figure is 40.0 or 45.0 s - within ONE CONTROL CYCLE (5.0 s) of
# every other - and below 0.45 it rises gradually to 90.0 s at 0.05. It is not
# monotone either (0.40 reports 60.0 s where 0.35 reports 55.0), which is
# §3.3's own mechanism (c) rather than noise in the plant: a more sensitive rule
# puts MORE units into the short set, which can turn a proper subset into the
# WHOLE roster and END the episode. So this constant is INSENSITIVE over the
# whole usable range, and that is the finding rather than a caveat on it. 0.85
# sits in the flat part, 0.40 above the shoulder at ~0.45 and 0.15 below 1.0.
#
# ⚠️ AND "ZERO FALSE NAMINGS" IS NOT AVAILABLE AT ANY FRACTION - said here
# because the card asked for exactly that. Every fraction adds naming CYCLES to
# the no-fault campaign; a relative arm that never fired on a legitimate
# transient would be one that never fired at all, because the modelled write
# stagger lands B's number up to four cycles late and a unit whose new command
# has not arrived IS delivering a small fraction of what is standing on its
# wire. What is zero at every fraction is false ALARMS: the longest legitimate
# episode anywhere in the 20-point sweep is 90.0 s - at 0.05, the most sensitive
# fraction measured and far below anything this card would ship - against a
# `delivery_hold_s` of 185 s, so NOTHING in 2640 no-fault runs comes within 95 s
# of notifying an operator at any fraction measured. At 0.85 the hold is 4.62x
# the worst legitimate episode where the absolute arm alone gave 5.29x. That is
# the price, in full.
#
# ⚠️ THE DIRECTION IS THE OPPOSITE OF `min_w`'s AND IT IS EASY TO GET BACKWARDS.
# A SMALLER fraction names MORE units - it is the more sensitive setting and the
# more dangerous one - so the campaign puts a LOWER bound on this number, not an
# upper one. It is bounded ABOVE by the card's own case: at 1.0 the arm can only
# fire on a unit that is IMPORTING while asked to discharge, which is
# SAFETY-30's fault and not this one, so a unit delivering NONE of its share
# (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a
# wedged box reporting a small standby figure rather than a clean zero.
#
# ⚠️ AND IT IS NOT AN OPTION, FOR `delivery_w`'s REASON ONE PARAGRAPH UP. An
# operator has no instrument to derive it with, a knob here is a knob on how
# loud this add-on is, and SAFETY-41 is the record of what that costs. It is a
# derived constant with a committed harness behind it, and the way to move it
# is to re-run the campaign.
DELIVERY_SHARE_FRACTION = 0.85
@dataclass(frozen=True)
class DeliveryConfig:
"""Defaults, not constants - config.yaml carries both. See CFG-03 above."""
@@ -481,12 +620,30 @@ def account(*, live: bool, parked: bool, soc, bms_floor) -> str | None:
return None
def delinquent(units, min_w: float) -> dict:
def delinquent(units, min_w: float,
share_frac: float = DELIVERY_SHARE_FRACTION) -> dict:
"""Which units are short with NO ACCOUNT, when the roster disagrees with them.
`units` one `UnitDelivery` per inverter, in roster order.
`min_w` the shortfall that counts - `control.Tuning.saturation_w`. See
`MIN_W_IS_SATURATION_W`.
`units` one `UnitDelivery` per inverter, in roster order.
`min_w` the ABSOLUTE shortfall that counts - `control.Tuning.
saturation_w`. See `MIN_W_IS_SATURATION_W`.
`share_frac` SAFETY-43. The RELATIVE shortfall that counts, as a fraction
of that unit's own share. See `DELIVERY_SHARE_FRACTION`; it
is a parameter rather than a read of the constant so the
campaign that derived it can sweep it over the shipped code.
⚠️ `None` MEANS THE ABSOLUTE ARM ALONE - SAFETY-42's rule,
exactly - and it exists for ONE caller: the campaign in
`admin/goodwe:sim/delivery_window.py`, which has to be able
to measure what this card ADDS against what it replaced, run
for run, over ONE tree. Nothing in the add-on passes it, and
`main.py` passes nothing at all.
⚠️ THE TWO ARMS ARE AN `or` AND THE ORDER OF THAT SENTENCE MATTERS. The
absolute arm is untouched by SAFETY-43: everything `min_w` named before it
is still named, and nothing `min_w` refused becomes noisy unless the unit
is delivering almost NONE of its ask. Lowering `min_w` would have been the
other change - the one that reports normal saturation on every unit at once
- and `DELIVERY_SHARE_FRACTION` says at length why it was refused.
Returns `{name: sentence}`, empty when there is nothing to say. The sentence
is the operator's first line and it carries the peer clause, because "B is
@@ -528,7 +685,13 @@ def delinquent(units, min_w: float) -> dict:
gaps = {}
for u in asked:
gap = short_by(u.share_w, u.measured_w)
if gap is not None and gap > float(min_w):
if gap is None:
continue
# SAFETY-43: absolute OR relative. `share_w` is > 0 for every member
# of `asked`, so the second comparison is always well formed.
if gap > float(min_w) or (share_frac is not None
and gap > float(share_frac)
* float(u.share_w)):
gaps[u.name] = gap
unaccounted = {u.name: gaps[u.name] for u in asked
@@ -544,7 +707,13 @@ def delinquent(units, min_w: float) -> dict:
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"{unaccounted[u.name]:.0f} W of its share "
# SAFETY-43. The PROPORTION is in the sentence because the watts
# alone stopped being the whole story the moment a unit could be
# named on the relative arm: "125 W is not arriving" reads like a
# rounding error, and "125 W (100 % of its share)" is the fault.
f"({100.0 * unaccounted[u.name] / float(u.share_w):.0f} % of it) "
f"is not arriving - while "
f"{', '.join(peers)} {'is' if len(peers) == 1 else 'are'} not in "
f"that state. Nothing this add-on reads explains it")
return out
+30 -17
View File
@@ -57,8 +57,8 @@ deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on.
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
⚠️ AND IT WAS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROWED SAFETY-42's
DETECTION WINDOW - A DETECTION REGRESSION AT N=2, NOW CLOSED BY SAFETY-43
=======================================================================
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
@@ -68,27 +68,40 @@ 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
`delivery.delinquent()` named a unit only while its `short_by()` gap exceeded
`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
own 10 % cut-out, so `envelope()` does not close and `account()` explains
nothing): at a 1500 W load the healthy pack can carry alone, equal weights named
the wedged unit on 199 of 200 cycles and these weights named it on NONE. The
suppression window widened 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 had
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.
hands the wedged unit a big share again and the absolute arm names it.
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE - KEPT HERE BECAUSE IT IS A MEASUREMENT, EVEN THOUGH SAFETY-43 HAS SINCE MADE THE WINDOW IRRELEVANT TO THE DETECTOR. Swept on this fixture with the ABSOLUTE arm alone, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returned BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm named the wedged unit 199/200 at every load swept, 1000 W included.
⚠️ 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.
⚠️ THAT IS NO LONGER A RESIDUAL. SAFETY-43 made `delinquent()` relative to
the ask AS WELL AS absolute - `gap > min_w OR gap > frac * share`, and a
unit delivering 0 of 125 W is as delinquent as one delivering 0 of 1500 W.
On the SAME fixture, under the SAME weights, the wedged unit is now named
on 200 of 200 cycles. Part 8b grades both rules on one run:
`share_frac=None` is SAFETY-42's absolute arm alone and still says nothing,
which is what keeps the paragraph above honest rather than nostalgic.
⚠️ AND `min_w` WAS NOT LOWERED. The absolute arm's suppression window is
still exactly the arithmetic above - 1000 W at equal weights, 6500 W at
these - because a lower `saturation_w` would report NORMAL SATURATION as
delinquency on every unit at once, which is the false-trip regime SAFETY-41
spent four attempts in. What changed is that there is a SECOND way to be
named; nothing that was quiet before became noisy.
⚠️ WHAT THIS MODULE STILL CANNOT DO IS UNCHANGED. OPT-09 stops the site
ASKING a pack whose own SoC says it cannot deliver; it does not, and will
not, re-allocate a wedged unit's watts to its sibling - `delivery.py`'s
"IT REPORTS. IT DOES NOT ACT" block is the measurement behind that, and
SAFETY-43 did not touch it. The alarm is louder; the loop is identical.
WHY ENERGY AND NOT POWER
========================
+29 -13
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.8.0"
version: "0.9.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -548,8 +548,10 @@ options:
# 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
# short by more than `saturation_w`, OR short by more than a fixed FRACTION
# of its own share (SAFETY-43; the fraction is derived and is not an option,
# see app/delivery.py) - 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.
@@ -560,17 +562,31 @@ options:
# ⚠️ 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.
# ASYMMETRIC SPLITS, plus a roster-of-ONE arm as the control. At the shipped
# threshold the worst CONTINUOUS legitimate episode is 35.0 s (the weighted
# family; 30.0 s on reversals, 0.0 s on flapping, and 0.0 s at every threshold
# in every one-inverter run). 185 s is 5.29x 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.
# ⚠️ AND THE CAMPAIGN IS RE-RUNNABLE NOW, WHICH IT WAS NOT WHEN THIS NUMBER
# SHIPPED. TEST-21 (2026-09-07) committed the harness to the OTHER repository,
# `admin/goodwe:sim/delivery_window.py` - it imports plantsim and frame_check,
# which that repo owns. From clean checkouts of both, side by side:
#
# cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
# python3 -B delivery_window.py --selftest --campaign --blag
#
# ⚠️ THE ORIGINAL RECORD SAID 30.0 s AND IT DID NOT REPRODUCE - the re-run
# says 35.0 s. 185 s still clears all four bounds and is unchanged; what
# changed is the claim. DECISIONS/SAFETY-42-unit-delivery.md §3.3 carries both
# tables and what did and did not reproduce.
#
# ⚠️ AND THE MARGIN IS A SENSITIVITY STUDY ON A HYPOTHESIS, NOT A MEASUREMENT,
# BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). The rig's B is A
# COPIED. Sweeping a DIFFERENT second unit through that campaign: the worst
# legitimate episode barely moves with B's ramp (35.0 s at half A's ramp) and
# scales with its LAG - 45.0 s at twice A's measured 10 s lag, 75.0 s at four
# times it. 185 s is 2.47x even the four-times-slower case. NO NUMBER HERE IS
# A MEASUREMENT OF INVERTER B.
#
# DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it -
# and not 150 or 200. 37 control cycles.
+372
View File
@@ -0,0 +1,372 @@
"""UX-04 - the defaults pin UX-01 AC 4 promised and never built.
`python3 -B test_defaults_pin.py`
WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
`options:` has exactly one defaults-table entry ... the table changed goes red
... and NO MODULE CARRIES ITS OWN LITERAL FALLBACK FOR A SCHEMA DEFAULT - an AST
check, not a grep."
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
71 keys, so "every schema key absent from options" quantifies over the empty set
and is true of any tree, including one with no defaults at all. Clause two -
"the table changed goes red" - does work, six times over, once per mirror in its
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
literal scan in this repository before this file is
`test_validate.py`'s `re.findall(r'"(maintenance_[a-z_]+)"...')`, which is a
grep, is restricted to eight `maintenance_*` keys and three named files, and
predates UX-01.
⚠️ AND A CHECK THAT CANNOT GO RED IS NOT A CHECK, so this file does not repeat
clause one's mistake. Every part below states the size of the set it quantifies
over and grades that size against a floor derived from the tree, and part 5
plants a literal in a synthetic module and REQUIRES the scanner to catch it, so
the red direction is exercised on every run rather than attested in a commit
message. MUTATION.md habit 4.
--------------------------------------------------------------------------- #
WHAT AN "IN-CODE LITERAL FALLBACK" IS, STRUCTURALLY, so a reader can argue with
the definition rather than guess it. It is a call in `app/` that takes a STRING
LITERAL naming a `schema:` key and, in the next positional slot, a CONSTANT:
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
Two shapes, one rule, found by walking `ast.Call` nodes - not by matching text,
so `opts.get(\n "max_w",\n 2000,\n)` and a renamed `opts` are found too and
a `max_w` inside a comment or a docstring is not.
TWO EXCLUSIONS, both structural and both narrow:
* `getattr(obj, "max_w", 0.0)` reads an ATTRIBUTE off an object, not an option
off the operator's blob. Its third argument is an absent-attribute guard and
has no relationship to config.yaml. Excluded by function name.
* THE EMPTY STRING. `opts.get("static_profile", "")` is Python's "this key is
not stated", and every one of the six sites in `validate.py` immediately
normalises it - `str(opts.get(k, "") or "").strip()`. It cannot be a stale
copy of a NUMBER, which is the whole of the CFG-04 defect class, and
rewriting those six to index a mirror would replace a sentence that reads
"absent" with one that reads "the shipped default" at sites where absent is
what is meant. Excluded by value. `0` is NOT excluded: `slew_w: 0` and
`integrator_max_w: 0` are real shipped defaults with real meanings.
--------------------------------------------------------------------------- #
WHAT THIS FILE DOES *NOT* DO, said before the parts rather than after them.
It does not remove the 27 (module, key) pairs in part 4's ledger. That is
CFG-03's open ticket - option defaults living in four places - and it is a body
of behaviour change across `main.py`, `p1.py`, `inverter.py` and `validate.py`
that no single card should make in passing. What this file does instead is
FREEZE the ledger: the set is graded EXACTLY, so the twenty-eighth pair is red
on the commit that introduces it and the ledger can only ever shrink. A backlog
that cannot grow is a backlog being paid down; one that is merely written in a
comment is the state UX-01 left.
MUTATION.md is the method for the red-direction evidence in part 5, and
`tools/mutation_run.sh` is the harness.
"""
import ast
import io
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
APP = HERE / "app"
FAILED = 0
def check(what, ok):
global FAILED
print((" ok " if ok else " FAIL ") + what)
if not ok:
FAILED += 1
# --------------------------------------------------------------------------- #
# config.yaml, parsed by hand.
#
# ⚠️ NO PyYAML, AND NOT AS A PREFERENCE. The add-on image installs py3-aiohttp
# and py3-paho-mqtt only; `import yaml` raises in the checks container and a
# check that skips on ImportError is not a check. `test_maintenance.py` and
# `test_price.py` each carry their own two-line reader for the same reason, and
# this is the third - which is itself an instance of the defect this file
# grades, one level up. It is written down rather than tidied away because
# tidying it into a shared helper is a change to two suites that are green.
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
_SCHEMA_AT = CFG_TEXT.index("\nschema:")
_OPTS_BLOCK, _SCHEMA_BLOCK = CFG_TEXT[:_SCHEMA_AT], CFG_TEXT[_SCHEMA_AT:]
def _scalar(raw):
"""One `options:` value, as the yaml module this image lacks would read it."""
raw = raw.split("#", 1)[0].strip().strip('"').strip("'")
if raw in ("true", "false"):
return raw == "true"
if raw == "[]":
return []
for cast in (int, float):
try:
return cast(raw)
except ValueError:
pass
return raw
def _block_keys(block):
return [m.group(1) for m in re.finditer(r"^ ([a-z0-9_]+):", block, re.M)]
SHIPPED = {m.group(1): _scalar(m.group(2))
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", _OPTS_BLOCK, re.M)}
SCHEMA = set(_block_keys(_SCHEMA_BLOCK))
print("UX-04 - the defaults pin\n")
print("1. the surface this file quantifies over")
print(f" config.yaml: {len(SHIPPED)} keys under `options:`, {len(SCHEMA)} "
f"under `schema:`")
# The floor, for part 1's own reason: a reader that returned {} would make every
# check below vacuously true. 60 is the count of REQUIRED schema keys and is the
# number `tools/supervisor_shape_probe.py` section F now grades; any floor well
# inside the real size does the job, and this one cannot be reached by accident.
check(f"the hand reader found a real options block ({len(SHIPPED)} keys, floor 60)",
len(SHIPPED) >= 60)
check(f"...and a real schema block ({len(SCHEMA)} keys, floor 60)",
len(SCHEMA) >= 60)
check("every shipped default names a schema key, so a mirror pinned to one is "
"pinned to the other",
sorted(set(SHIPPED) - SCHEMA) == [])
# --------------------------------------------------------------------------- #
print()
print("2. the mirrors, DISCOVERED from the tree rather than listed here")
# ⚠️ WHY DISCOVERED. Six suites already pin their own mirror to config.yaml -
# test_maintenance, test_price, test_watchdog (twice), test_peakguard,
# test_delivery. Every one of those pins names its table by hand, so a SEVENTH
# table added tomorrow is pinned by nobody and nothing says so. This walks
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
# grades whatever it finds, so the seventh is covered on the commit that
# introduces it. That is the property the six hand-written pins cannot have.
MODULES = sorted(p.name for p in APP.glob("*.py"))
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
for name in MODULES}
MIRROR = {} # schema key -> (module, table name, value)
TABLES = []
for name in MODULES:
for node in TREES[name].body:
if not (isinstance(node, ast.Assign) and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id.endswith("OPTION_DEFAULTS")):
continue
table = node.targets[0].id
TABLES.append((name, table))
for k, v in zip(node.value.keys, node.value.values):
# A literal key with a literal value is the only shape a mirror
# has; anything else is not a table this file can grade, and
# saying so out loud beats skipping it silently.
MIRROR[k.value] = (name, table, ast.literal_eval(v))
print(f" {len(TABLES)} table(s): " + ", ".join(f"{m}:{t}" for m, t in TABLES))
print(f" {len(MIRROR)} mirrored key(s) of {len(SCHEMA)} in `schema:`")
check(f"the walk found the mirrors that exist ({len(TABLES)} tables, floor 6)",
len(TABLES) >= 6)
_unschemad = sorted(k for k in MIRROR if k not in SCHEMA)
check(f"every mirrored key is a real schema key {_unschemad or ''}",
_unschemad == [])
_unshipped = sorted(k for k in MIRROR if k not in SHIPPED)
check(f"...and is shipped in the options block {_unshipped or ''}",
_unshipped == [])
_drift = sorted(k for k, (m, t, v) in MIRROR.items()
if k in SHIPPED and v != SHIPPED[k])
check(f"...and reproduces config.yaml exactly, so ANY mirror - including one no "
f"suite names by hand - goes red on drift {_drift or ''}",
_drift == [])
# --------------------------------------------------------------------------- #
print()
print("3. the AST scan: no module carries its own literal fallback for a "
"MIRRORED schema default")
def literal_fallbacks(tree, name):
"""Every `f(..., "<schema key>", <constant>, ...)` in one module.
Yields (module, lineno, callee, key, value). See the header for the two
exclusions and the argument for each.
"""
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn = node.func
callee = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", "")
if callee == "getattr":
continue
for i, arg in enumerate(node.args[:-1]):
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)
and arg.value in SCHEMA):
continue
default = node.args[i + 1]
if not isinstance(default, ast.Constant):
continue
if default.value is None or default.value == "":
continue
yield (name, node.lineno, callee, arg.value, default.value)
ALL_SITES = [s for name in MODULES for s in literal_fallbacks(TREES[name], name)]
MIRRORED_SITES = [s for s in ALL_SITES if s[3] in MIRROR]
UNMIRRORED_SITES = [s for s in ALL_SITES if s[3] not in MIRROR]
print(f" scanned {len(MODULES)} modules, {len(ALL_SITES)} literal fallback "
f"site(s): {len(MIRRORED_SITES)} over mirrored keys, "
f"{len(UNMIRRORED_SITES)} over unmirrored ones")
# ⚠️ THE SCANNER'S OWN FLOOR, and it is the line that stops this whole file
# rotting into clause one. A scanner that silently stopped matching - a renamed
# helper, an ast API change, a `SCHEMA` that came back empty - would report ZERO
# sites and every check below would pass. So the total is graded against a floor
# the tree already exceeds by a wide margin, and part 5 additionally feeds the
# scanner a source it MUST flag.
check(f"the scanner is not blind: it found {len(ALL_SITES)} sites (floor 20)",
len(ALL_SITES) >= 20)
for site in sorted(MIRRORED_SITES):
print(f" {site[0]}:{site[1]} {site[2]}({site[3]!r}, {site[4]!r})")
check(f"no literal fallback survives for any of the {len(MIRROR)} keys a mirror "
f"already answers for - those sites must index the mirror",
MIRRORED_SITES == [])
# --------------------------------------------------------------------------- #
print()
print("4. the CFG-03 ledger: the unmirrored keys that still carry a literal, "
"frozen at exactly today's set")
# ⚠️ WHY A FROZEN SET AND NOT A COUNT. A count goes green again the moment
# somebody deletes one site and adds another, which is the same-size drift this
# repository keeps finding. The pair (module, key) is stable under every edit
# that is not the one being guarded against: reformatting, moving the call
# within the file, renaming the local `opts`. It changes only when a module
# starts or stops carrying a literal for a key - which is exactly the event.
#
# LINE NUMBERS ARE DELIBERATELY NOT IN THE KEY. A ledger that goes red when an
# unrelated edit shifts a line is a ledger people learn to re-bless without
# reading, and a ledger re-blessed without reading grades nothing.
LEDGER = {
("inverter.py", "max_w"),
("main.py", "auto_start"),
("main.py", "cycle_floor_s"),
("main.py", "deadband_w"),
("main.py", "estop_fitted"),
("main.py", "gain"),
("main.py", "heartbeat_s"),
("main.py", "log_level"),
("main.py", "meter_freeze_s"),
("main.py", "meter_max_age_s"),
("main.py", "meter_phases"),
("main.py", "peak_cap_w"),
("main.py", "saturation_cycles"),
("main.py", "saturation_w"),
("main.py", "sign_disagree_s"),
("main.py", "sign_disagree_w"),
("main.py", "slew_w"),
("main.py", "stale_input_s"),
("main.py", "step_w"),
("main.py", "target_grid_w"),
("p1.py", "meter_poll_s"),
("p1.py", "meter_source"),
("validate.py", "max_w"),
("validate.py", "meter_max_age_s"),
("validate.py", "meter_phases"),
("validate.py", "sign_disagree_w"),
("validate.py", "slew_w"),
}
_found = {(s[0], s[3]) for s in UNMIRRORED_SITES}
_new = sorted(_found - LEDGER)
_gone = sorted(LEDGER - _found)
print(f" {len(_found)} (module, key) pair(s) carry a literal for a key no "
f"mirror answers for")
check(f"no NEW module/key pair has started carrying a literal fallback {_new or ''}",
_new == [])
check(f"...and none has been paid off without the ledger being shortened - "
f"shrink LEDGER when it has {_gone or ''}",
_gone == [])
# --- and every one of them is graded AGAINST config.yaml, which is the half
# that catches CFG-04's actual failure: two copies of one number, one moves.
DIVERGENT = {
# main.py:290 `target_grid_w=float(opts.get("target_grid_w", 0))`.
# config.yaml ships -10 W. MEASURED here for the first time, 2026-09-07,
# and it is a real divergence, not a sentinel: a dict handed to
# `Controller` WITHOUT the key holds net exchange at 0 W instead of the
# shipped -10 W bias into import. On a Supervisor install the key is always
# present - the deep-merge of `options:` guarantees it - so this cannot
# reach an operator; the paths that CAN hand over a partial dict are
# `sim/inject.py` and this repository's own fixtures. It is recorded rather
# than corrected because changing it is a behaviour change to the control
# loop's target, which belongs to a card that grades the loop.
("main.py", "target_grid_w"): (0, -10),
}
_diverge = {}
for mod, line, callee, key, value in UNMIRRORED_SITES:
want = SHIPPED.get(key)
same = (isinstance(value, bool) == isinstance(want, bool)) and value == want
if not same:
_diverge[(mod, key)] = (value, want)
for pair, (got, want) in sorted(_diverge.items()):
print(f" {pair[0]} {pair[1]}: code {got!r} vs config.yaml {want!r}")
check(f"every remaining literal still AGREES with config.yaml, except the "
f"{len(DIVERGENT)} divergence(s) recorded above it",
_diverge == DIVERGENT)
# --------------------------------------------------------------------------- #
print()
print("5. the red direction, RUN on every invocation")
# ⚠️ A CHECK THAT HAS NEVER BEEN SHOWN FAILING IS NOT A CHECK. Parts 3 and 4
# both pass on a clean tree, which is exactly what UX-01 AC 4's clause one did
# while grading nothing. So the scanner is fed a source that is a deliberate
# violation of each rule and is REQUIRED to flag it - in-process, against the
# same `literal_fallbacks()` the parts above call, so a scanner that stops
# working stops working here too.
_PLANTED = '''
def build(opts):
a = opts.get("maintenance_charge_w", 2000) # mirrored key -> part 3
b = _number(opts, "cycle_floor_s", 15) # unmirrored, main.py's key
c = opts.get("static_profile", "") # the "" sentinel: NOT a hit
d = getattr(unit, "max_w", 0.0) # attribute guard: NOT a hit
e = opts.get("peak_soc_floor", 20) # mirrored key -> part 3
return a, b, c, d, e
'''
_hits = list(literal_fallbacks(ast.parse(_PLANTED, "<planted>"), "<planted>"))
_keys = sorted(h[3] for h in _hits)
for h in _hits:
print(f" caught {h[2]}({h[3]!r}, {h[4]!r}) at line {h[1]}")
check("a planted literal for a MIRRORED key is caught - this is part 3's red "
"direction, exercised",
"maintenance_charge_w" in _keys and "peak_soc_floor" in _keys)
check("a planted literal for an unmirrored key is caught too - part 4's",
"cycle_floor_s" in _keys)
check('...and the two documented exclusions still hold: the "" sentinel and '
"getattr are not reported",
"static_profile" not in _keys and _keys.count("max_w") == 0)
check(f"exactly the three intended sites, no more {_keys}",
_keys == ["cycle_floor_s", "maintenance_charge_w", "peak_soc_floor"])
# ...and the mirror comparison's red direction, likewise executed rather than
# claimed: part 2 goes red when a table and config.yaml disagree by one.
_drifted = dict(MIRROR)
_pick = sorted(MIRROR)[0]
_mod, _tab, _val = _drifted[_pick]
_drifted[_pick] = (_mod, _tab, "###drifted###")
check(f"part 2's comparison rejects a mirror that has drifted (simulated on "
f"{_pick!r})",
any(v != SHIPPED.get(k) for k, (_, _, v) in _drifted.items()))
print()
if FAILED:
print(f"{FAILED} FAILED")
sys.exit(1)
print("all checks passed")
+109 -6
View File
@@ -33,8 +33,9 @@ from datetime import datetime, timedelta, timezone
from app.control import Tuning
from app.delivery import (
DELIVERY_OPTION_DEFAULTS, REASON_CODE, DeliveryConfig, DeliveryWatchdog,
UnitDelivery, account, delinquent, short_by,
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
DeliveryConfig, DeliveryWatchdog, UnitDelivery, account, delinquent,
short_by,
)
from app.inverter import LINK_STALE_S
from app.main import Controller
@@ -151,6 +152,77 @@ check("a unit given no share is not asked and cannot be named, and it is not "
"a peer either - with only one unit asked there is nothing to compare",
delinquent([U("a", 0, 0), U("b", 2000, 0)], 500) == {})
print()
print(" SAFETY-43: THE RELATIVE ARM - short OF ITS OWN ASK, not just short")
# ⚠️ `share_frac=None` IS SAFETY-42's RULE EXACTLY, and every check in this part
# above this line is run under BOTH rules below to show the card took nothing
# away. What it ADDS is one thing: a unit can now be named for delivering almost
# none of a SMALL share. OPT-09's weights make small shares ordinary - a
# low-headroom unit is weighted down to 125 W of a 1630 W site target - so
# before this card a wedged unit could be 100 % delinquent and 125 W short, and
# 125 W is under a 500 W threshold. test_packshare.py part 8b measures that end
# to end on the real loop; what is graded here is the predicate.
_ABS = None # SAFETY-42's rule
_tiny = [U("a", 1505, 1505), U("b", 125, 0)]
check("A UNIT DELIVERING 0 OF 125 W IS NAMED, AND UNDER THE OLD RULE IT WAS "
"NOT. Same three units, same `min_w`, one input changed: the absolute arm "
"alone answers {} because 125 W < 500 W, and the shipped rule names it "
"because 100 % of its ask is not arriving",
delinquent(_tiny, 500, _ABS) == {}
and list(delinquent(_tiny, 500)) == ["b"])
# ⚠️ `.get`, NOT `["b"]`, AND THAT IS NOT STYLE. A bare subscript here raises
# KeyError on any tree where the relative arm is broken or absent, which ABORTS
# THE FILE at part 1 and leaves parts 2-8 UNGRADED. The direction is safe - an
# abort is a red, and MUTATION.md's defect can only fabricate a green - but the
# recorded coverage of a mutation run then reads larger than it was, which is
# the shape M11's entry in test_packshare.py part 11 already discloses. Found
# in review of this card, on M12 and M13.
_tiny_said = delinquent(_tiny, 500).get("b", "(not named - see the check above)")
print(f" {_tiny_said}")
check("...and the sentence carries the PROPORTION as well as the watts, because "
"'125 W is not arriving' reads like a rounding error and '125 W (100 % of "
"it)' is the fault",
"125 W of its share (100 % of it) is not arriving" in _tiny_said)
check("THE ACCOUNT GATE IS UNTOUCHED BY IT - the same 0 of 125 W with the "
"shortfall EXPLAINED names nobody. A relative criterion that could talk "
"its way past `account()` would report every pack sitting at its own "
"cut-out on a small share, which is the crying-wolf failure this module "
"exists to avoid",
delinquent([U("a", 1505, 1505), U("b", 125, 0, "its pack is empty")],
500) == {})
check("...and so is the PROPER-SUBSET rule: both units delivering none of a "
"small share is a SITE condition and is still nobody's fault to name",
delinquent([U("a", 125, 0), U("b", 125, 0)], 500) == {})
check("...and the relative bound is STRICT, like the absolute one. A gap of "
"EXACTLY the fraction of the share is not over it; one watt more is",
delinquent([U("a", 2000, 2000),
U("b", 1000, 1000 * (1.0 - DELIVERY_SHARE_FRACTION))],
5000) == {}
and list(delinquent([U("a", 2000, 2000),
U("b", 1000,
1000 * (1.0 - DELIVERY_SHARE_FRACTION) - 1)],
5000)) == ["b"])
_both_rules = [(delinquent(u, w, _ABS), delinquent(u, w)) for u, w in (
([U("a", 2000, 2000), U("b", 2000, 0)], 500),
([U("a", 2000, 2000), U("b", 2000, 0, "its pack is empty")], 500),
([U("a", 2000, 0), U("b", 2000, 0)], 500),
([U("a", 2000, 2000), U("b", 2000, 0), U("c", 2000, 0)], 500),
([U("a", 2000, 0, "at its floor"), U("b", 2000, 0)], 500),
([U("a", 2000, 2000), U("b", 2000, 1500)], 500),
([U("a", 0, 0), U("b", 2000, 0)], 500),
)]
check(f"⚠️ AND IT TOOK NOTHING AWAY: all {len(_both_rules)} fixtures above this "
f"block answer IDENTICALLY under SAFETY-42's rule and the shipped one. "
f"The relative arm is an `or`, so it can only ever ADD a naming - and on "
f"every case this part already graded it adds none. That is the "
f"difference between adding a second way to be named and lowering "
f"`min_w`, which would have moved all seven",
all(a == b for a, b in _both_rules))
print()
print(" AC 5: AT ONE INVERTER IT IS INERT, AND BY ALGEBRA RATHER THAN BY A")
print(" ROSTER GUARD - `len(asked) < 2` is unreachable-by-construction there")
@@ -323,9 +395,14 @@ class _DeliveryHass:
same in both.
"""
def __init__(self, load_w=_LOAD_W, b_soc=50.0):
def __init__(self, load_w=_LOAD_W, b_soc=50.0, b_frac=0.0):
self.load_w = float(load_w)
self.b_soc = float(b_soc)
# SAFETY-43. What fraction of its command B actually delivers. 0.0 is a
# WEDGED unit and is every arm in this file but part 4b(a) - see there
# for why that one arm needs a unit that is short WITHOUT being 100 %
# short.
self.b_frac = float(b_frac)
self.writes = []
self.calls = []
self.ctrl = None
@@ -343,8 +420,11 @@ class _DeliveryHass:
# A obeys. B produces NOTHING, whatever it is told - and whether that is
# a wedged power stage or an empty pack is not a question its power
# register can answer.
b = self.ctrl.inverters[1]
b_cmd = float((b.last_write if b.last_write is not None else b.target)
or 0.0)
return [float((a.last_write if a.last_write is not None else a.target)
or 0.0), 0.0]
or 0.0), self.b_frac * b_cmd]
async def number(self, entity, invert=False):
m = self.measured()
@@ -630,15 +710,27 @@ print(" schema. Same load, same wedged unit, two configured thresholds.")
# load is what puts B's shortfall BETWEEN the two: above the shipped threshold,
# below the raised one. The gap itself is printed by the run that produces it
# and is never written down here; what is asserted is the ORDERING.
#
# ⚠️ AND B IS DERATED HERE RATHER THAN WEDGED, WHICH SAFETY-43 MADE NECESSARY.
# `delinquent()` now names a unit whose gap exceeds `min_w` OR a fraction of its
# own share, so a unit delivering NONE of its ask is named at every `min_w`
# there is - which is the whole point of that card, and which would make the
# raised arm below fire for a reason that has nothing to do with the watts. A
# unit delivering HALF of what it is asked for is short in absolute watts and
# comfortably inside the fraction, so it is the fixture that still isolates
# `saturation_w`. `_SAT_B_FRAC` is asserted below to be inside the relative
# arm's silence, so a future change to the fraction turns this red rather than
# quietly turning the pin vacuous.
_SAT_RAISED = 1800
_SAT_LOAD_W = 1600.0
_SAT_B_FRAC = 0.5
_SAT_SHIPPED = int(read_option("saturation_w"))
_sat_lo, _sat_hi = (int(x) for x in re.match(
r"int\((\d+),(\d+)\)", read_option("saturation_w", "schema")).groups())
async def _at_saturation_w(sat):
hass = _DeliveryHass(load_w=_SAT_LOAD_W)
async def _at_saturation_w(sat, b_frac=_SAT_B_FRAC):
hass = _DeliveryHass(load_w=_SAT_LOAD_W, b_frac=b_frac)
c = _ctrl(hass, saturation_w=sat)
return c, await _drive(c, watch=_sample)
@@ -674,6 +766,17 @@ check("the raised arm REACHED the comparison: B was asked for a discharge "
and not _b_up.sign_wd.tripped
and account(live=True, parked=False, soc=_b_up.soc,
bms_floor=_b_up.bms_soc_floor) is None)
check("...and SAFETY-43's RELATIVE arm is silent on both arms of this fixture, "
"which is what leaves the watts as the only moving part. B delivers half "
"of what it is asked for, so its gap is half its share and that is "
"inside `DELIVERY_SHARE_FRACTION`. ⚠️ A FIXTURE WHERE THE RELATIVE ARM "
"FIRED WOULD NAME B AT EVERY `saturation_w` AND THIS PIN WOULD BE "
"VACUOUS, so it is asserted rather than assumed",
_gap_up / float(_b_up.standing_w()) <= DELIVERY_SHARE_FRACTION
and _gap_ship / float(_b_ship.standing_w()) <= DELIVERY_SHARE_FRACTION
and delinquent([U("a", 1000, 1000),
U("b", float(_b_up.standing_w()), _b_up.batt)],
_SAT_RAISED) == {})
check("...and its shortfall lands STRICTLY BETWEEN the two configured "
"thresholds, which is the one place the two answers can differ. Part 4's "
"fixtures are all above both, which is exactly why they cannot see this",
+353 -93
View File
@@ -41,7 +41,9 @@ from app.arbiter import (
ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve,
)
from app.control import Tuning, compute
from app.delivery import UnitDelivery, account, delinquent
from app.delivery import (
DELIVERY_SHARE_FRACTION, UnitDelivery, account, delinquent,
)
from app.peakguard import discharge_permitted
fails = []
@@ -605,67 +607,87 @@ check("...and SAFETY-42 is where the case this card CANNOT reach is handled. A "
"site ASKING a pack whose own SoC says it cannot deliver; SAFETY-42 "
"alarms when a pack that said it could does not. ⚠️ THE WEIGHT, HOWEVER, "
"DOES MOVE - this check used to say it did not - and part 8b grades what "
"that costs the alarm",
"that cost the alarm, and what SAFETY-43 did about it",
not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding)
# ==============================================================================
print()
print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH IS "
"A REGRESSION AND IS PINNED HERE AS ONE")
print("8b. ⚠️ WHAT THE WEIGHTS DID TO SAFETY-42's DETECTION WINDOW, AND WHAT "
"SAFETY-43 DID ABOUT IT - GRADED UNDER BOTH RULES, ON ONE FIXTURE")
# ⚠️ READ THIS BEFORE READING THE CHECKS: THEY ASSERT THE UNFLATTERING ANSWER ON
# PURPOSE. An earlier draft of §5 of the decision said a wedged unit's weight
# does not move, so OPT-09 "cannot see it at all". The first clause is about the
# ENVELOPE and is true; the second is FALSE. A wedged unit's weight moves
# whenever its energy headroom differs from its sibling's - it is a pack fact,
# not a delivery fact - and `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. Shrink it below 500 W and the
# alarm stops firing on a unit that is still delivering nothing.
# ⚠️ THIS PART USED TO PIN THE UNFLATTERING ANSWER ON PURPOSE, AND SAFETY-43 IS
# THE CARD THAT INVERTED IT. What it said - and it was true when it was written
# - was: a wedged unit's weight MOVES whenever its energy headroom differs from
# its sibling's (it is a pack fact, not a delivery fact), `delivery.delinquent()`
# named a unit only while its `short_by()` gap exceeded `min_w`
# (`Tuning.saturation_w`, 500 W) IN ABSOLUTE WATTS, and shrinking a unit's share
# shrinks its gap. Shrink it below 500 W and the alarm stopped firing on a unit
# that was still delivering nothing. The old text ended "if it ever goes red
# because the alarm fired, read the card that made it fire before 'fixing' this
# line". SAFETY-43 is that card, this is that red, and the checks below are the
# same three measurements with their answers the other way round.
#
# So the seam is not "OPT-09 cannot see it". It is: OPT-09 stops the ask AND
# narrows SAFETY-42's detection window on the same unit. At equal weights the
# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is
# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W.
# ⚠️ AND THE EVIDENCE IS THE DIFF BETWEEN TWO RULES ON ONE RUN, NOT A NUMBER
# THAT CHANGED IN A COMMIT. `delinquent()` takes `share_frac`, and
# `share_frac=None` is SAFETY-42's rule EXACTLY - the absolute arm alone. So
# `wedged_loop` grades every cycle twice, once under each rule, and the red and
# the green sit side by side in one output:
#
# ⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT `_fill()`'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once `_fill()` pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
# load 1500 W, OPT-09 weights, absolute arm only named on 0/200 cycles
# load 1500 W, OPT-09 weights, the shipped rule named on 200/200 cycles
#
# ⚠️ THIS IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE. Making
# `delinquent()` relative to the ask rather than absolute is SAFETY-42's code and
# a design change; it is filed as card SAFETY-43. What this part does is GRADE THE
# CURRENT BEHAVIOUR so that the day it changes, a named check moves.
# A future reader who wants to know what this card did runs this file.
#
# ⚠️ WHAT SAFETY-43 DID NOT DO IS LOWER `min_w`, and the check on the window
# arithmetic below is what holds that open: the ABSOLUTE arm's suppression
# window is still 500 W wide and still widens to 6500 W under these weights.
# Nothing that was quiet became noisy; a SECOND way to be named was added, and
# it is a fraction of the unit's OWN ask, so it fires on a unit delivering none
# of 125 W exactly as it fires on one delivering none of 1500 W.
#
# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION,
# checked below before anything is concluded from it: B is WEDGED - it accepts
# its share and produces nothing - at a HEALTHY 15 % against its own 10 % floor,
# so `envelope()` does not close and `account()` explains nothing. This is
# exactly the case §5 says the envelope cannot reach, which is why it is the
# case where the weights' side effect matters.
# case where the weights' side effect mattered.
#
# ⚠️ TWO COPIES OF A. Inverter B does not exist; the plant below is modelled in
# this file the way part 8's is, and B's ratings are datasheet.
WEDGED_A_SOC, WEDGED_B_SOC = 90.0, 15.0 # both healthy, both above their floor
MIN_W = Tuning().saturation_w # what `delinquent()` is handed
ABSOLUTE_ONLY = None # `share_frac=None` - SAFETY-42's rule
def wedged_loop(load_w, weighted, cycles=200):
def wedged_loop(load_w, weighted, cycles=200, share_frac=DELIVERY_SHARE_FRACTION,
b_delivers=0.0, b_soc=WEDGED_B_SOC, no_account=False):
"""The closed loop with unit B WEDGED at a healthy SoC. Real everything.
Real `compute()`, real `plan()`, real `packshare.weights()`, real
`delivery.account()` and real `delivery.delinquent()`. The only model is the
plant: A obeys within one cycle inside its rating, B accepts whatever it is
told and delivers 0 W - which is the wedged box, and is what makes this a
plant: A obeys within one cycle inside its rating, B delivers `b_delivers`
of whatever it is told - 0.0 is the wedged box, and is what makes this a
SAFETY-42 case rather than an OPT-09 one.
`weighted=False` is equal weights - what shipped. Envelopes are left OPEN in
both arms deliberately: neither pack's SoC closes one here, so filing them
would change nothing and would blur which half of the card is being graded.
`share_frac` is SAFETY-43's relative arm. `ABSOLUTE_ONLY` grades the same
cycles under SAFETY-42's rule, which is how the red and the green below come
out of ONE run over ONE tree instead of out of two commits.
`no_account=True` DELETES the account gate - every unit is handed
`accounted=None` - and it exists to answer "would this arm still be silent
if `account()` did nothing at all?". A control that answers the same way
with the gate removed is not grading the gate, and the cut-out arm below is
exactly that case. See it there; it is the point of that block.
Returns `(cycles on which delinquent() named somebody, last cycle)`.
"""
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, WEDGED_B_SOC, B_KWH)]
packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, b_soc, B_KWH)]
tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W)
prev, i_w, sat = 0.0, None, 0
power = [0.0, 0.0]
@@ -678,13 +700,15 @@ def wedged_loop(load_w, weighted, cycles=200):
r = plan(d.target_w, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=w)
assert r.ok, r.refused
# THE MODELLED PLANT: A within one cycle inside its rating; B WEDGED.
power = [max(-A_W, min(A_W, float(r.shares[A]))), 0.0]
power = [max(-A_W, min(A_W, float(r.shares[A]))),
b_delivers * max(-B_W, min(B_W, float(r.shares[Bee])))]
grid = load_w - sum(power)
who = delinquent(
[UnitDelivery(p.name, r.shares[p.name], m,
None if no_account else
account(live=True, parked=False, soc=p.soc,
bms_floor=p.bms_floor))
for p, m in zip(packs, power)], MIN_W)
for p, m in zip(packs, power)], MIN_W, share_frac)
named += bool(who)
last = (d.target_w, dict(r.shares), grid, who)
return named, last
@@ -698,75 +722,271 @@ check("PRECONDITION: the wedged unit is a SAFETY-42 TRUE POSITIVE and NOT an "
not packshare.envelope(_WEDGED_PACK).binding
and account(live=True, parked=False, soc=WEDGED_B_SOC,
bms_floor=FLOOR) is None)
check("PRECONDITION: the shipped fraction is a fraction, and it is STRICTLY "
"below 1.0 - at 1.0 the relative arm could only fire on a unit IMPORTING "
"while asked to discharge, which is SAFETY-30's fault and not this one, "
"and this card's own case (a unit delivering NONE of its share, gap over "
"share exactly 1.0) would be missed",
0.0 < DELIVERY_SHARE_FRACTION < 1.0)
_wedge = {}
for _load in (1500.0, 6000.0):
for _weighted in (False, True):
_n, _t = wedged_loop(_load, _weighted)
_wedge[(_load, _weighted)] = (_n, _t)
print(f" load {_load:.0f} W, B wedged at 15 %, "
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
f"named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W, shares "
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
+ f", grid {_t[2]:+.0f} W, named="
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
for _frac in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION):
_n, _t = wedged_loop(_load, _weighted, share_frac=_frac)
_wedge[(_load, _weighted, _frac)] = (_n, _t)
print(f" load {_load:.0f} W, B wedged at 15 %, "
f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} "
+ ("absolute arm only" if _frac is ABSOLUTE_ONLY
else f"frac {_frac:.2f} ")
+ f", named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W,"
+ " shares "
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items()))
+ f", grid {_t[2]:+.0f} W, named="
+ (str(sorted(_t[3])) if _t[3] else "(nobody)"))
check("AT EQUAL WEIGHTS - WHAT SHIPPED - the wedged unit is named on every "
"cycle but the first, at a load its sibling can carry on its own. The "
"loop winds the site target to twice the load, the 50/50 split stands "
"+1500 W on a box producing nothing, and 1500 W > 500 W is what "
"`delinquent()` reads",
_wedge[(1500.0, False)][0] == 199
and sorted(_wedge[(1500.0, False)][1][3]) == [Bee]
and _wedge[(1500.0, False)][1][1] == {A: 1500, Bee: 1500})
check("⚠️ THE CARD, RED: UNDER SAFETY-42's RULE AND OPT-09's WEIGHTS THE WEDGED "
"UNIT IS NAMED ON NONE OF THE 200. Same wedged box, same healthy SoC, "
"same load, same detector: the split moves to 1505/125, the gap on the "
"wedged unit falls to 125 W, and 125 W < 500 W so the absolute arm "
"returns {}. This is the defect SAFETY-43 was filed for, and it is "
"measured here rather than remembered",
_wedge[(1500.0, True, ABSOLUTE_ONLY)][0] == 0
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][3] == {}
and _wedge[(1500.0, True, ABSOLUTE_ONLY)][1][1] == {A: 1505, Bee: 125})
check("⚠️ AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200. Same wedged "
"box, same healthy SoC, same load, same detector: the split moves to "
"1505/125, the gap on the wedged unit falls to 125 W, and 125 W < 500 W "
"so `delinquent()` returns {}. THIS CHECK PINS A REGRESSION - it asserts "
"what the code DOES, not what anyone wants, and if it ever goes red "
"because the alarm fired, read the card that made it fire before "
"'fixing' this line",
_wedge[(1500.0, True)][0] == 0
and _wedge[(1500.0, True)][1][3] == {}
and _wedge[(1500.0, True)][1][1] == {A: 1505, Bee: 125})
check("⚠️ THE CARD, GREEN: THE SHIPPED RULE NAMES IT ON ALL 200, ON THE SAME "
"RUN. The unit is standing at +125 W and delivering 0 W - 100 % of its "
"ask is not arriving - and `gap > frac * share` does not care that 125 W "
"is a small number. The ONLY input that moved between this check and the "
"one above is `share_frac`",
_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
and sorted(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee]
and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][1] \
== {A: 1505, Bee: 125})
check("AT EQUAL WEIGHTS - WHAT SHIPPED BEFORE OPT-09 - the wedged unit was "
"already named under the absolute rule on every cycle but the first, at "
"a load its sibling can carry on its own. The loop winds the site target "
"to twice the load, the 50/50 split stands +1500 W on a box producing "
"nothing, and 1500 W > 500 W is what the absolute arm reads",
_wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 199
and sorted(_wedge[(1500.0, False, ABSOLUTE_ONLY)][1][3]) == [Bee]
and _wedge[(1500.0, False, ABSOLUTE_ONLY)][1][1] == {A: 1500, Bee: 1500})
check("...and the shipped rule adds exactly the FIRST cycle to that arm and "
"nothing else - 199 becomes 200. On cycle one the slew limit holds the "
"split at 500/500, a 500 W gap is NOT over a 500 W threshold (the "
"absolute bound is strict), and the relative arm names a box delivering "
"none of its 500 W. That one cycle is the whole of what this card costs "
"an arm that was already firing",
_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
and _wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][0]
- _wedge[(1500.0, False, ABSOLUTE_ONLY)][0] == 1)
check("...and this is the regime where NOTHING ELSE TELLS THE OPERATOR "
"ANYTHING: the meter is satisfied (grid within 5 W of zero), `unmet_w` is "
"0 because both units accepted their shares, no envelope closed and no "
"action-log line fires. The site is quietly running one inverter and the "
"one instrument built to say so has gone quiet with it",
abs(_wedge[(1500.0, True)][1][2]) <= 5.0
and abs(_wedge[(1500.0, False)][1][2]) <= 5.0)
"action-log line fires. The site is quietly running one inverter, and "
"before this card the one instrument built to say so had gone quiet with "
"it",
abs(_wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0
and abs(_wedge[(1500.0, False, DELIVERY_SHARE_FRACTION)][1][2]) <= 5.0)
_w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0)
_window_equal = MIN_W * 2.0
_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee]
print(f" suppression window on this fixture: equal weights, site target "
f"< {_window_equal:.0f} W; OPT-09 weights, site target "
f"< {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
check("...and the arithmetic behind it, stated as a window rather than as one "
"fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a "
"fraction f of the site target is invisible while `f * target < min_w`. "
"Equal weights suppress below 1000 W; these weights suppress below "
"6500 W of a 9600 W site. ⚠️ BOTH ARE PROPORTIONAL-REGIME BOUNDS AND "
"NEITHER IS REACHABLE: the healthy pack's own rating binds first, so the "
"alarm returns BELOW 6500 W (swept: 0/200 to a 3000 W load, 41/200 at "
"4900 W, 195/200 at 5000 W) and the equal arm never suppresses at all. "
"That is the regression, and it is a property "
"of the threshold being absolute rather than of any number chosen here",
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0)
print(f" the ABSOLUTE arm's suppression window is UNCHANGED by SAFETY-43: "
f"equal weights, site target < {_window_equal:.0f} W; OPT-09 weights, "
f"site target < {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site")
check("⚠️ AND `min_w` WAS NOT LOWERED, WHICH IS THE HALF OF THIS CARD THAT IS "
"EASIEST TO GET WRONG. The absolute arm still suppresses below 1000 W at "
"equal weights and below 6500 W at these ones - the same arithmetic this "
"part has always printed - because SAFETY-43 ADDED a second way to be "
"named rather than loosening the first. Lowering `saturation_w` would "
"have reported normal saturation on every unit at once, which is the "
"false-trip regime SAFETY-41 spent four attempts in",
abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0
and MIN_W == Tuning().saturation_w == 500.0)
check("⚠️ AND THE ABSOLUTE ARM WAS NEVER SILENT EVERYWHERE - which is what a "
"check that only ever ran the quiet load would be pinning. Push the load "
"past the healthy pack's OWN RATING and `_fill()`'s last-resort pass "
"hands the wedged unit 3000 W anyway, the gap goes back over 500 W, and "
"both arms named it under SAFETY-42's rule too. The window was real and "
"it had an upper edge; the hole was the quiet regime below that edge, "
"and the shipped rule now names the unit on every cycle of all four runs",
_wedge[(6000.0, True, ABSOLUTE_ONLY)][0] == 195
and _wedge[(6000.0, False, ABSOLUTE_ONLY)][0] == 199
and _wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][0] == 200
and _wedge[(6000.0, False, DELIVERY_SHARE_FRACTION)][0] == 200
and sorted(_wedge[(6000.0, True, DELIVERY_SHARE_FRACTION)][1][3]) == [Bee])
print()
print(" THE POSITIVE CONTROL: what the relative arm must STILL NOT say")
# ⚠️ A CARD THAT ADDS A WAY TO RAISE AN ALARM HAS TO SHOW WHAT IT DID NOT ADD,
# or it has traded a missed alarm for a false one - which in this system is not
# a rhetorical risk. SAFETY-41 took four attempts on notification cadence and
# the owner's words were "i'm getting really annoyed by the notifications in my
# HA". So: the same fixture, the same weights, the same tiny 125 W share, with
# the ONE fact that makes the shortfall legitimate changed.
# ⚠️ AND THE FIRST DRAFT OF THIS BLOCK WAS VACUOUS, WHICH IS WHY IT NOW PRINTS
# ITS SHARES. It ran the cut-out arm, saw 0/200, and concluded "`account()`
# explains it" and "identical trace to the wedged arm". BOTH WERE FALSE. At its
# own cut-out the pack's usable energy ABOVE that cut-out is zero, so
# `weights()` gives it 0.0, `_fill()`'s preferred pass gives it nothing, the
# healthy sibling covers the load alone so the LAST-RESORT pass never runs, and
# its share is 0 W on ALL 200 CYCLES. A unit with a 0 W share is not in `asked`,
# so `len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()`
# is never consulted - measured below by deleting the gate and getting the same
# answer. The wedged arm's share is the 125 W printed on its own line above;
# this one's is 0, printed and asserted below. Nothing was identical and nothing
# reached the comparison.
#
# ⚠️ THE FACT THAT REPLACES IT IS BETTER, NOT WEAKER: at its own cut-out OPT-09
# STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached.
# That is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the
# allocator declines to ask before the detector has to decline to alarm. The
# account gate itself is graded where it is REAL, on a positive 125 W share, in
# `test_delivery.py` part 1 ("THE ACCOUNT GATE IS UNTOUCHED BY IT").
#
# ⚠️ AND IT WAS INVISIBLE FOR ONE REASON: this was the only line in part 8b that
# did not print its shares. `inverter-b+0` would have been on screen from the
# first run. Every line in this part prints them now.
_sat_named, _sat_last = wedged_loop(1500.0, True, b_soc=FLOOR)
_sat_nogate, _sat_nogate_last = wedged_loop(1500.0, True, b_soc=FLOOR,
no_account=True)
print(f" load 1500 W, B AT ITS OWN {FLOOR:.0f} % CUT-OUT, OPT-09 weights, "
f"frac {DELIVERY_SHARE_FRACTION:.2f}: named on {_sat_named:3d}/200 "
f"cycles, shares "
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_sat_last[1].items()))
+ ", named="
+ (str(sorted(_sat_last[3])) if _sat_last[3] else "(nobody)")
+ f"; with account() DELETED: {_sat_nogate:3d}/200")
# ⚠️ AND THE `no_account=True` SWITCH ITSELF WAS GRADED BY NOTHING. It is used
# in exactly one place - the fixture above - where it PROVABLY cannot change the
# answer, because a cut-out pack is never asked and the gate is never reached.
# Review neutered the switch so it removed nothing: the file stayed GREEN and
# printed "with account() DELETED: 0/200" while `account()` had not been
# deleted. A line asserting a mechanism that did not happen - B1's shape, one
# level down, in the fix for B1.
#
# The switch DOES bite, on a fixture already reachable from this helper: at the
# cut-out with EQUAL weights, `plan()` hands B a 50/50 share, so the gate is the
# only thing keeping it silent. Measured: gate on 0/200, gate deleted 200/200.
# That proves the switch removes the gate, AND gives this part the end-to-end
# account-gate control on a POSITIVE share that it otherwise has to outsource to
# test_delivery.py part 1.
_eq_gate, _eq_last = wedged_loop(1500.0, False, b_soc=FLOOR)
_eq_nogate, _ = wedged_loop(1500.0, False, b_soc=FLOOR, no_account=True)
print(f" ...and the SWITCH is real: same cut-out, EQUAL weights so B IS "
f"asked ({_eq_last[1][Bee]:+d} W last cycle) - gate on {_eq_gate:3d}/200, "
f"gate DELETED {_eq_nogate:3d}/200")
check("⭐ THE `no_account` SWITCH ACTUALLY REMOVES THE GATE, on a fixture where "
"the gate is the only thing keeping the pack silent. Without this the "
"line above prints 'with account() DELETED' about a run in which nothing "
"was deleted - which is exactly the defect this block was rewritten for",
_eq_gate == 0 and _eq_nogate == 200 and _eq_last[1][Bee] > 0)
check("A PACK AT ITS OWN CUT-OUT IS NEVER ASKED, SO THE RELATIVE ARM IS NEVER "
"REACHED. Its usable energy above its own cut-out is zero, `weights()` "
"answers 0.0 for it, the healthy sibling covers the load so `_fill()`'s "
"last-resort pass never runs, and its share is 0 W on every one of the "
"200 cycles - so it is not in `asked` and `len(asked) < 2` returns {} "
"before either arm is compared. ⚠️ THE SHARE IS ASSERTED, not inferred "
"from the silence: a control that cannot say WHY it is quiet is a control "
"that cannot fail",
_sat_named == 0 and _sat_last[3] == {} and _sat_last[1][Bee] == 0)
check("⚠️ AND THAT IS A SECOND, INDEPENDENT PROTECTION - NOT THE ACCOUNT GATE, "
"WHICH THIS ARM DOES NOT TOUCH. Delete `account()` entirely, hand every "
"unit `accounted=None`, and this arm is STILL silent on all 200 cycles, "
"because the protection is UPSTREAM of it: the allocator declines to ask "
"before the detector has to decline to alarm. ⚠️ SO DO NOT READ THIS "
"BLOCK AS EVIDENCE ABOUT `account()` - it is evidence about the ask. The "
"account gate on a POSITIVE share is graded in test_delivery.py part 1",
_sat_nogate == 0 and _sat_nogate_last[3] == {})
_derate_named, _derate_last = wedged_loop(1500.0, True, b_delivers=0.5)
print(f" load 1500 W, B delivering HALF its share, OPT-09 weights, frac "
f"{DELIVERY_SHARE_FRACTION:.2f}: named on {_derate_named:3d}/200 cycles, "
f"shares "
+ ", ".join(f"{u}{v:+d}" for u, v in sorted(_derate_last[1].items()))
+ ", named="
+ (str(sorted(_derate_last[3])) if _derate_last[3] else "(nobody)"))
check("...and ORDINARY TRACKING ON A SMALL SHARE IS NOT DELINQUENCY EITHER. "
"The same unit at the same weights delivering HALF of what it is asked "
"for is short by 50 % - well inside the shipped fraction - and its gap "
"in watts is far under `min_w`, so NEITHER arm names it. A relative "
"criterion that fired here would be the saturation-shaped alarm this "
"whole module is written against",
_derate_named == 0 and _derate_last[3] == {})
check("...and the DERATED control is not vacuous, which is asserted on the ONE "
"thing that could make it so: unlike the cut-out arm above, its unit IS "
"asked - a positive share on every cycle - so it really does reach both "
"arms and is turned away by the size of its shortfall. ⚠️ THIS GUARD USED "
"TO RE-ASSERT THE WEDGED ARM's 200/200 INSTEAD, WHICH IS WHY IT DID NOT "
"CATCH THE CUT-OUT ARM BEING VACUOUS: a check on a DIFFERENT run cannot "
"say anything about whether THIS one reached the comparison",
_derate_last[1][Bee] > 0
and _wedge[(1500.0, True, DELIVERY_SHARE_FRACTION)][0] == 200)
print()
print(" N=1 IS BYTE-IDENTICAL, AND THAT IS ASSERTED RATHER THAN ARGUED")
# ⚠️ THE LIVE INSTALL IS N=1 AND THIS CARD MUST NOT REACH IT. Two independent
# statements, and each is checked against the thing that would break it:
# 1. `plan()` RETURNS BEFORE `weights` IS READ AT ALL, so no weighting - and
# no change to how one is derived - can move a one-inverter share. Graded
# with a `weights` object that RAISES if anything reads it.
# 2. `delinquent()` returns {} at a roster of one for every input, including
# the input the RELATIVE arm was built for. The proper-subset algebra runs
# before either arm, so the fraction is never even multiplied.
class _Poison(dict):
"""A `weights` that cannot be read without saying so. Not a mock: a tripwire."""
def get(self, *a, **k):
raise AssertionError("plan() read weights at a roster of one")
def __getitem__(self, k):
raise AssertionError("plan() read weights at a roster of one")
_n1_plain = plan(1234.5, {A: _B(-A_W, A_W)})
_n1_poison = plan(1234.5, {A: _B(-A_W, A_W)}, weights=_Poison())
check("`plan()` at a roster of ONE returns the site target unread, and it "
"returns it WITHOUT TOUCHING `weights` - handed a mapping that raises on "
"any read, it answers identically to the same call with no weights at "
"all. The N=1 pass-through is above the weighting, so OPT-09 could not "
"move it and SAFETY-43 does not either",
_n1_poison.shares == _n1_plain.shares == {A: 1234.5}
and _n1_poison.unmet_w == _n1_plain.unmet_w)
try:
plan(1234.5, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=_Poison())
except AssertionError:
_poison_bites = True
else:
_poison_bites = False
check("...and the tripwire is a real one: the SAME poisoned mapping at a roster "
"of TWO is read and raises. A check that could not fail would be "
"measuring nothing",
_poison_bites)
_n1_rel = [delinquent([UnitDelivery(A, s, m)], MIN_W, f)
for s in (0, 125, 500, 2000, 5000, -2000, None)
for m in (0, 60, 125, 2000, -2000, None)
for f in (ABSOLUTE_ONLY, DELIVERY_SHARE_FRACTION)]
check(f"...and `delinquent()` answers {{}} at a roster of one on all "
f"{len(_n1_rel)} of (share, measured, rule) - INCLUDING 0 of 125 W, the "
f"exact input this card added an arm for. `len(asked) < 2` returns "
f"before either threshold is compared, so the one-inverter install this "
f"add-on is running today cannot tell that SAFETY-43 happened",
all(r == {} for r in _n1_rel))
check("⚠️ AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY - which is "
"what a check that only ever ran the quiet load would be pinning. Push "
"the load past the healthy pack's OWN RATING and `_fill()`'s last-resort "
"pass hands the wedged unit 3000 W anyway, the gap goes back over 500 W, "
"and BOTH arms name it. The window is real and it has an upper edge; the "
"hole is the quiet regime below that edge",
_wedge[(6000.0, True)][0] == 195
and sorted(_wedge[(6000.0, True)][1][3]) == [Bee]
and _wedge[(6000.0, False)][0] == 199
and sorted(_wedge[(6000.0, False)][1][3]) == [Bee])
# ==============================================================================
print()
@@ -978,9 +1198,12 @@ print(" a preference and are honest about being one.")
print(" * A WEDGED UNIT IS NOT REACHABLE BY THE ENVELOPE. Its SoC reads")
print(" healthy, so nothing closes, and SAFETY-42 owns the detection.")
print(" ⚠️ BUT IT IS REACHABLE BY THE WEIGHTS, WHICH MOVE ON ENERGY")
print(" HEADROOM AND NOT ON DELIVERY - part 8b grades what that does")
print(" to SAFETY-42's window, and the answer is a REGRESSION that is")
print(" pinned rather than fixed.")
print(" HEADROOM AND NOT ON DELIVERY. That narrowed SAFETY-42's")
print(" detection window - a regression this file PINNED rather than")
print(" fixed - and SAFETY-43 closed it by making delinquency relative")
print(" to the ask as well as absolute. Part 8b now grades both rules")
print(" on one fixture: the absolute arm still says nothing, and the")
print(" shipped one names the unit on all 200 cycles.")
# ==============================================================================
print()
@@ -1023,13 +1246,41 @@ MUT = [
"mutation took it down with a TypeError three lines into part 1"),
("M11 weights(): keep the shape, force every weight to 1.0 <- THE PROOF "
"THAT PART 8b CAN GO RED",
"test_packshare 8b, three named checks: the wedged unit goes back to being "
"named on 199/200 at 1500 W, the split reverts to 1500/1500 and the "
"window collapses from 6500 W to 1000 W. ⚠️ RUN, NOT REASONED ABOUT - a "
"check that pins an UNFLATTERING behaviour has to be shown capable of "
"failing, or it is indistinguishable from a check that pins nothing. "
"test_packshare 8b, FIVE named checks - re-run for SAFETY-43 rather than "
"carried across, because the part it grades was rewritten: the split "
"reverts to 1500/1500 so the absolute arm names the unit on 199/200 "
"(taking the card's RED check down), the shipped rule's 200/200 comes "
"from the wrong shares (taking the GREEN one down), the OPT-09 window "
"collapses from 6500 W to 1000 W, the 6000 W arm's 195 becomes 199, and "
"the derated positive control moves from 0/200 to 3/200. ⚠️ RUN, NOT "
"REASONED ABOUT - a check that pins a behaviour has to be shown capable "
"of failing, or it is indistinguishable from a check that pins nothing. "
"(It also takes parts 1, 3, 7 and 8 down, which is M3's territory; 8b is "
"the part that names the SAFETY-42 interaction)"),
# --- SAFETY-43's three, and they pin the new arm from BOTH sides --------
("M12 delinquent(): drop the relative arm, keep `gap > min_w` <- THE "
"CARD ITSELF REVERTED",
"SEVEN named checks: test_delivery 1 (THREE - the 0-of-125 W naming, the "
"proportion in the sentence, and the strict relative bound) and "
"test_packshare 8b (FOUR - the GREEN check goes back to 0/200 under "
"OPT-09's weights, the equal arm's 200 back to 199, the 6000 W arm back "
"to 195/199, and the derated control's vacuity guard falls with them). "
"test_alloc and test_control stay green, which is the point - nothing "
"outside this check moves"),
("M13 DELIVERY_SHARE_FRACTION 0.85 -> 1.0 <- THE TOO-LOOSE DIRECTION",
"SEVEN named checks: test_delivery 1 (TWO) and test_packshare 8b (FIVE), "
"including the PRECONDITION that the fraction is strictly below 1.0. At "
"1.0 the arm can only fire on a unit IMPORTING while asked to discharge, "
"so the card's own case - 0 of 125 W, gap over share exactly 1.0 - is "
"missed and the whole card is a no-op"),
("M14 DELIVERY_SHARE_FRACTION 0.85 -> 0.05 <- THE TOO-SENSITIVE ONE",
"SIX named checks: test_delivery 1 (TWO - the strict bound, and 'IT TOOK "
"NOTHING AWAY' stops being true) and 4b (THREE - the `saturation_w` pin "
"goes VACUOUS, because the relative arm names B at every threshold), plus "
"test_packshare 8b's DERATED positive control, where a unit delivering "
"half its share starts being named. ⚠️ THIS IS THE ROW THAT MATTERS - the "
"fraction is pinned from BOTH directions, so it is a derived number with "
"checks around it rather than a literal nobody can move"),
]
for _n, _w in MUT:
print(f" caught {_n}")
@@ -1039,8 +1290,17 @@ check("every mutation in the table above was RUN and every one of them is "
"M6: the whole defect this card fixes - a parameter nothing passes - is "
"invisible to every behavioural fixture in this repository, because they "
"all build their own claim list and call `resolve()` themselves. That is "
"why the call site is pinned statically as well",
len(MUT) == 11)
"why the call site is pinned statically as well. ⚠️ M12-M14 ARE "
"SAFETY-43's and were run the same way, on trees copied fresh and "
"mounted :ro so no `.pyc` could go stale - MUTATION.md's defect. All "
"three are REDS, which is the direction that defect cannot fabricate. "
"⚠️ AND THEIR COUNTS WERE RE-RUN IN REVIEW AND TWO OF THEM MOVED: "
"test_delivery.py part 1 used to subscript `delinquent(...)['b']` for a "
"print, which raised KeyError under M12 and M13 and ABORTED THE FILE at "
"part 1, leaving parts 2-8 ungraded and the recorded coverage smaller "
"than the truth. It is a `.get` now. M14's count was simply wrong: five "
"named checks in test_delivery, not four",
len(MUT) == 14)
print()
if fails:
+282
View File
@@ -0,0 +1,282 @@
"""PKG-09 - grade the check that refuses a no-op deploy. `python3 test_version_gate.py`
⚠️ THE POINT OF THIS FILE IS THE REFUSALS, NOT THE CLEARANCES. A pre-deploy
check that only ever says "CLEARED" is the defect `MUTATION.md` habit 4 names -
"a check whose DATA cannot reach the failure it names" - which this repository
has recorded twelve times. So every refusal below is DRIVEN: a repository is
built in which the failure is really present, and the check is required to say
so. A clearance is only meaningful next to a refusal from the same harness.
The third property is the one that is easy to lose: a SKIPPED check must not
look like a PASSED one. Omitting `--options` cannot exit 0.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
GATE = os.path.join(HERE, "tools", "predeploy_check.py")
sys.path.insert(0, os.path.join(HERE, "tools"))
fails = []
tmpdirs = []
def check(name, ok):
print(f" {'ok ' if ok else 'FAIL'} {name}")
if not ok:
fails.append(name)
def git(repo, *args):
return subprocess.run(
["git", "-C", repo, "-c", "user.email=t@t", "-c", "user.name=t", *args],
capture_output=True, text=True)
def make_repo(version="0.7.0"):
"""A repo shaped like this one: <root>/goodwe_controller/{config.yaml,app,tools}."""
root = tempfile.mkdtemp(prefix="test_version_gate.")
tmpdirs.append(root)
addon = os.path.join(root, "goodwe_controller")
os.makedirs(os.path.join(addon, "app"))
write(addon, "config.yaml", f'name: t\nversion: "{version}"\n')
write(addon, "app/control.py", "X = 1\n")
write(addon, "CHANGELOG.md", "# Changelog\n")
git(root, "init", "-q", ".")
git(root, "add", "-A")
git(root, "commit", "-q", "-m", f"initial at {version}")
return root, addon
def write(addon, rel, text):
p = os.path.join(addon, rel)
with open(p, "w", encoding="utf-8") as fh:
fh.write(text)
def run_gate(addon, root, deployed, options=None):
"""Run the REAL gate against a throwaway tree.
⚠️ The script is invoked where it SHIPS, not copied into the fixture. It
imports the validator from its own sibling `app/`, which is the behaviour
that matters: the gate must check a config against the validator it ships
beside, not against whatever happens to sit next to a copy. Copying it into
the fixture made every validator assertion silently SKIP - the first draft
of this file did exactly that and reported four green skips as passes.
"""
cmd = [sys.executable, GATE,
"--deployed-version", deployed, "--tree", addon, "--repo", root]
if options:
cmd += ["--options", options]
return subprocess.run(cmd, capture_output=True, text=True)
VALID = {"max_w": 2000, "slew_w": 0, "target_grid_w": -10, "gain": 0.6,
"inverters": [], "setpoint_entity": "number.goodwe_setpoint_w"}
def opts_file(d):
fd, p = tempfile.mkstemp(suffix=".json")
with os.fdopen(fd, "w") as fh:
json.dump(d, fh)
return p
print("classification - what forces a rebuild and what does not")
from predeploy_check import is_runtime, shipped_version # noqa: E402
for p in ("goodwe_controller/app/control.py", "goodwe_controller/config.yaml",
"goodwe_controller/translations/en.yaml", "goodwe_controller/run.sh"):
check(f"runtime: {p}", is_runtime(p))
for p in ("goodwe_controller/CHANGELOG.md", "goodwe_controller/DOCS.md",
"goodwe_controller/test_control.py", "goodwe_controller/tools/x.py",
"goodwe_controller/DECISIONS/a.md", "README.md"):
check(f"NOT runtime: {p}", not is_runtime(p))
print("\nTHE REFUSAL - a runtime change behind an unchanged version")
root, addon = make_repo("0.7.0")
write(addon, "app/control.py", "X = 2\n")
git(root, "commit", "-qam", "SAFETY-99: change behaviour, bump nothing")
r = run_gate(addon, root, "0.7.0", opts_file(VALID))
check("refuses (exit 1)", r.returncode == 1)
check("...and says the deploy would be a NO-OP", "NO-OP" in r.stdout)
check("...and names the offending commit", "SAFETY-99" in r.stdout)
check("...and does NOT say cleared", "CLEARED for deploy" not in r.stdout)
print("\nPOSITIVE CONTROL - the same check must NOT fire on a docs-only change")
root2, addon2 = make_repo("0.7.0")
write(addon2, "CHANGELOG.md", "# Changelog\n\n## notes\n")
git(root2, "commit", "-qam", "PKG-99: documentation only")
r2 = run_gate(addon2, root2, "0.7.0", opts_file(VALID))
check("does not refuse a docs-only tree", r2.returncode == 0)
check("...and says there is nothing to deploy",
"nothing to deploy" in r2.stdout)
print("\nTHE CLEARANCE - a bumped version with a valid config")
root3, addon3 = make_repo("0.7.0")
write(addon3, "app/control.py", "X = 3\n")
write(addon3, "config.yaml", 'name: t\nversion: "0.8.0"\n')
git(root3, "commit", "-qam", "PKG-99: change and bump together")
r3 = run_gate(addon3, root3, "0.7.0", opts_file(VALID))
check("clears (exit 0)", r3.returncode == 0)
check("...and says so", "CLEARED for deploy" in r3.stdout)
print("\n⭐ A SKIPPED CHECK MUST NOT LOOK LIKE A PASSED ONE")
r4 = run_gate(addon3, root3, "0.7.0") # no --options
check("omitting --options cannot exit 0", r4.returncode != 0)
check("...says SKIPPED", "SKIPPED" in r4.stdout)
check("...says the config was NEVER CHECKED", "NEVER CHECKED" in r4.stdout)
check("...and does NOT claim clearance", "CLEARED for deploy" not in r4.stdout)
print("\nTHE OTHER REFUSAL - a config this version's validator rejects")
bad = dict(VALID, slew_w=1000) # rule 10: > 2/18 x 2000 = 222 W
r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
check("refuses a config the validator rejects", r5.returncode == 1)
check("...and warns it is exit 78", "78" in r5.stdout)
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
print()
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
# ⚠️ EVERY FIXTURE ABOVE BUILDS A FRESH REPOSITORY WHOSE `version:` LINE IS
# INTRODUCED ONCE AND NEVER EDITED. That is precisely the shape in which the
# original `git log -S 'version: "'` looked correct - the pickaxe finds commits
# that change a string's COUNT, and introducing the line changes it from 0 to 1.
# A real repository edits the line IN PLACE, the count never moves again, and
# the lookup silently returns the commit that first added it.
#
# MEASURED on admin/goodwe-addon before the fix: `-S` named `0a1e61d`
# (2026-08-23, the original packaging commit) as "the last bump", so a correctly
# bumped tree was reported as 87 commits behind an unbumped version - with
# PKG-06's and PKG-07's own bump commits listed among the offenders. The tool
# cried wolf on every run, and none of the fixtures above could see it, because
# a test that builds its own one-bump history is a test asserting against a
# structure it built itself. MUTATION.md habit 4, in the file written to
# prevent a recurrence.
from predeploy_check import last_version_change # noqa: E402
root4 = tempfile.mkdtemp(prefix="test_version_gate.hist.")
tmpdirs.append(root4)
addon4 = os.path.join(root4, "goodwe_controller")
os.makedirs(os.path.join(addon4, "app"))
write(addon4, "config.yaml", 'name: t\nversion: "0.5.0"\n')
write(addon4, "app/control.py", "X = 1\n")
git(root4, "init", "-q", ".")
git(root4, "add", "-A")
git(root4, "commit", "-q", "-m", "initial at 0.5.0")
_first = git(root4, "rev-parse", "HEAD").stdout.strip()
write(addon4, "app/control.py", "X = 2\n")
git(root4, "commit", "-qam", "runtime change between bumps")
write(addon4, "config.yaml", 'name: t\nversion: "0.6.0"\n')
git(root4, "commit", "-qam", "bump to 0.6.0")
write(addon4, "app/control.py", "X = 3\n")
git(root4, "commit", "-qam", "another runtime change")
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0"\n')
git(root4, "commit", "-qam", "bump to 0.7.0")
_last_bump = git(root4, "rev-parse", "HEAD").stdout.strip()
check("⭐ THE LAST BUMP IS FOUND, NOT THE FIRST. An in-place edit of the "
"version line does not change how many times the string occurs, so a "
"pickaxe search returns the commit that introduced it and every bump "
"after that is invisible",
last_version_change(root4) == _last_bump)
check("...and it is emphatically not the commit that introduced the line",
last_version_change(root4) != _first)
r6 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("a tree bumped with nothing after it clears, even with bumps in history",
r6.returncode == 0 and "CLEARED for deploy" in r6.stdout)
write(addon4, "app/control.py", "X = 4\n")
git(root4, "commit", "-qam", "SAFETY-98: runtime change after the last bump")
r7 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("and one runtime commit after that last bump is refused", r7.returncode == 1)
check("...naming only the commit since the LAST bump, not since the first",
"SAFETY-98" in r7.stdout and "runtime change between bumps" not in r7.stdout)
# ⚠️ AND THE FIXTURE ABOVE STILL COULD NOT TELL TWO IMPLEMENTATIONS APART.
# Review swapped `last_version_change` for "-G's newest raw hit, skip the value
# comparison entirely" and THE WHOLE SUITE STAYED GREEN, including the section
# above - because a linear history of real bumps never produces a commit that
# touches the version LINE without changing the version VALUE. So the docstring's
# central claim ("the VALUE is compared, and that is the only thing that answers
# it") was not pinned by anything, and `-G` alone would have shipped as if it
# were the fix. That is the third distinct shape of habit 4 in this one file.
#
# A cosmetic touch of the version line is the discriminator: `-G '^version: "'`
# matches it, and the value comparison does not.
write(addon4, "config.yaml", 'name: t\nversion: "0.7.0" # pinned deliberately\n')
git(root4, "commit", "-qam", "COSMETIC: annotate the version line, same value")
_cosmetic = git(root4, "rev-parse", "HEAD").stdout.strip()
check("⭐ A COMMIT THAT TOUCHES THE VERSION LINE WITHOUT CHANGING THE VERSION "
"IS NOT A BUMP. `-G` matches it and a filter-only lookup would name it; "
"only comparing the VALUE against the parent's answers the question the "
"tool is actually asking",
last_version_change(root4) == _last_bump)
check("...and it is emphatically not the cosmetic commit",
last_version_change(root4) != _cosmetic)
r8 = run_gate(addon4, root4, "0.7.0", opts_file(VALID))
check("...and because config.yaml IS a runtime path, that cosmetic commit is "
"still counted as something unshipped - conservative, and correct: the "
"Supervisor rebuilds on the version string alone, so a changed file "
"behind an unchanged version is a no-op whatever changed in it",
r8.returncode == 1 and "COSMETIC" in r8.stdout)
print()
print("⭐ A GIT LOOKUP THAT GENUINELY FAILS - THE SHAPE NO FIXTURE COULD REACH")
# ⚠️ EVERY FIXTURE ABOVE CREATES ITS OWN REPOSITORY AS ITS OWN UID, so none of
# them could ever meet the failure that actually happened: the documented
# invocation runs the container as uid 0 against a checkout owned by the
# operator, git refuses with "detected dubious ownership", `_git` returns
# non-zero, and the no-op half reports SKIPPED. It degraded honestly - exit 2,
# never a false clearance - but the check this file exists for had never once
# run in its documented form, and nothing here could see that.
#
# `_git` now sets `safe.directory`, so ownership no longer breaks it. This
# section grades the REMAINING failure mode: git answering not at all.
root5 = tempfile.mkdtemp(prefix="test_version_gate.nogit.")
tmpdirs.append(root5)
addon5 = os.path.join(root5, "goodwe_controller")
os.makedirs(addon5)
write(addon5, "config.yaml", 'name: t\nversion: "0.7.0"\n') # NOT a git repo
check("a repository git cannot answer for reports no last-bump commit",
last_version_change(root5) is None)
r9 = run_gate(addon5, root5, "0.7.0", opts_file(VALID))
check("⭐ AND THE GATE DOES NOT CLEAR ON IT. An unanswerable git lookup is "
"INCONCLUSIVE, not clean - the one thing it must never do is call a "
"tree it could not inspect deployable",
r9.returncode != 0 and "CLEARED for deploy" not in r9.stdout)
check("...it says INCONCLUSIVE in those words", "INCONCLUSIVE" in r9.stdout)
# ⚠️ AND THE EXIT CODES ARE PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0`
# is satisfied by collapsing NOT-CLEARED into REFUSED, which review found the
# earlier draft could not tell apart. 1 and 2 mean different things to an
# operator: 1 says "this deploy is wrong", 2 says "nobody checked".
check("NOT CLEARED is exactly 2, not merely non-zero", r9.returncode == 2)
r10 = run_gate(addon3, root3, "0.7.0", opts_file(dict(VALID, slew_w=1000)))
check("REFUSED is exactly 1, not merely non-zero",
r10.returncode == 1 and "REFUSED" in r10.stdout)
print("\nthe shipped tree's own version is readable")
check("shipped_version() finds a version", bool(shipped_version(HERE)))
for d in tmpdirs:
shutil.rmtree(d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
+306
View File
@@ -0,0 +1,306 @@
"""PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject.
python3 tools/predeploy_check.py --deployed-version 0.7.0 --options live-options.json
⚠️ RUN IT WHERE THE ADD-ON'S DEPENDENCIES EXIST. `app/validate.py` imports its
siblings and those need aiohttp, so on a bare host the validator half SKIPS -
loudly, and exit 2, never a silent pass. To get both halves:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.7.0 --options /w/live-options.json
EXIT CODES. 0 cleared; 1 REFUSED, do not deploy; 2 NOT CLEARED - a check did not
run, and that is not the same as passing.
TWO FAILURES THIS ANSWERS, BOTH OF WHICH HAVE HAPPENED.
1. THE SILENT NO-OP. The Supervisor keys the built image by `version:` in
config.yaml. `ha apps update` against an unchanged version does nothing and
REPORTS SUCCESS. PKG-06 recorded this on 2026-09-05 with SIX merges behind
an unbumped version - its own commit message says so, and this line said
"four" through two review rounds; PKG-07 recorded it again on 2026-09-06 with six. Both
times a human noticed. Nothing else was ever going to.
2. THE REFUSED CONFIG. A config the shipped validator rejects is
`SystemExit(78)` - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES
WITH IT, because the add-on serving it is not running, so recovery means the
Supervisor UI. SAFETY-39's rule 10 made this reachable by a config that
started fine on the previous version: it refuses an explicit `slew_w` above
SLEW_RAIL_FRAC x the site rail, and 0.7.0's own shipped default of 1000
exceeds the 555.6 W ceiling on a 5000 W rail.
⚠️ WHY THIS IS NOT A CI CHECK. The obvious version gate - "a pull request that
touches runtime code must bump the version" - is the WRONG shape for this
project, which deliberately batches many merges into one deploy. It would demand
a bump per pull request, put every branch in conflict over one line of
config.yaml, and still not prevent the actual failure, which happens at DEPLOY
and not at merge. The check belongs where the mistake is made.
⚠️ WHAT THIS DOES NOT DO. It does not read the live box. The options blob is
reachable only through the Supervisor API, which needs a supervisor token, so
the operator exports it and passes `--options`. Without that flag the validator
half is SKIPPED and says so - a skipped check must never look like a passed one.
"""
import argparse
import json
import os
import re
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ADDON = os.path.dirname(HERE)
sys.path.insert(0, ADDON)
# Files that end up in the built image and can change what it DOES. A change to
# any of these needs a new `version:` or the Supervisor will not rebuild.
# CHANGELOG.md, DOCS.md, MUTATION.md, DECISIONS/, test_*.py, tools/ and
# scenarios/ are deliberately absent: they ship in the repo but cannot change
# runtime behaviour, and demanding a bump for a typo fix trains people to bump
# without reading, which is the habit this file exists to prevent.
RUNTIME = (
"goodwe_controller/app/",
"goodwe_controller/translations/",
"goodwe_controller/config.yaml",
"goodwe_controller/Dockerfile",
"goodwe_controller/run.sh",
"goodwe_controller/requirements.txt",
"goodwe_controller/build.yaml",
)
CONFIG_PATH = "goodwe_controller/config.yaml"
_VER = re.compile(r'^version:\s*"([^"]+)"', re.M)
def shipped_version(tree=ADDON):
with open(os.path.join(tree, "config.yaml"), encoding="utf-8") as fh:
m = _VER.search(fh.read())
return m.group(1) if m else None
def is_runtime(path):
return any(path == p or path.startswith(p) for p in RUNTIME)
def _git(*args, repo=None):
"""git, run so it answers regardless of who owns the checkout.
⚠️ WITHOUT `safe.directory` THIS TOOL SILENTLY DID HALF ITS JOB. The
invocation this file's own docstring documents runs the container as uid 0
against a checkout owned by the operator, and git refuses that:
fatal: detected dubious ownership in repository at '/w/goodwe-addon'
`_git` then returned non-zero, `last_version_change()` returned None, and
the no-op check reported SKIPPED - so **the silent-no-op half, which is the
reason this file exists, had never once run in its documented form.**
It degraded honestly: exit 2, INCONCLUSIVE, never a false clearance. But a
check that cannot fire is not a check, and the two documented environments
were mutually exclusive - in the container the validator ran and git
skipped; on a bare host git ran and the validator skipped on aiohttp.
Neither documented command ran both halves.
⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults
the git result only when `shipped == deployed`; every real run so far
compared 0.7.0 against 0.9.0 and printed its verdict without needing git.
`test_version_gate.py` could not see it either: it invokes the gate with
`sys.executable` against fixtures it creates as its own uid, so no fixture
ever met the ownership mismatch. MUTATION.md habit 4, in a fourth shape,
inside the file written to prevent it.
`safe.directory` is the right fix rather than `--user`: it makes the tool
correct however it is invoked, instead of making every future caller
remember a flag. The read is read-only, on a repository the operator is
about to deploy from.
"""
where = repo or os.path.dirname(ADDON)
return subprocess.run(
["git", "-c", f"safe.directory={where}", "-C", where, *args],
capture_output=True, text=True)
def _version_at(sha, repo=None, path=CONFIG_PATH):
"""`version:` as of one commit, or None if the file or the key is absent."""
r = _git("show", f"{sha}:{path}", repo=repo)
if r.returncode != 0:
return None
m = _VER.search(r.stdout)
return m.group(1) if m else None
def last_version_change(repo=None):
"""The newest commit whose `version:` VALUE differs from its parent's.
⚠️ THIS USED TO BE `git log -S 'version: "'` AND IT WAS WRONG ON EVERY REAL
REPOSITORY. `-S` is the pickaxe: it finds commits that change the COUNT of
a literal string. A bump edits the line in place - `version: "0.7.0"` ->
`version: "0.8.0"` - so the substring still occurs exactly once before and
after, and `-S` never sees it. It found only the commit that first
INTRODUCED the line.
Measured on this repository: `-S` returned `0a1e61d` (2026-08-23, the
original packaging commit) as "the last bump", so the script reported the
correctly-bumped tree as 87 commits behind an unbumped version - listing
PKG-06's and PKG-07's own bump commits among the offenders. A tool whose
entire purpose is to be trusted before a deploy cried wolf on every run.
⚠️ AND `-G` IS NOT THE FIX EITHER, ONLY A BETTER FILTER. `-G'^version: "'`
matches a commit whose diff touches a line with that shape - which includes
a reformat, a quote change, or a comment moved onto the line, none of which
are bumps. So `-G` narrows the candidates and then the VALUE is compared
against the parent's. The question is "did the version change", and that is
the only thing that answers it.
⚠️ TWO GIT SHAPES THIS STILL GETS WRONG, NEITHER OF WHICH HAS EVER OCCURRED
IN THIS REPOSITORY - every bump here has been a clean, linear, forward-only
edit. Both were constructed in review rather than found:
1. A BUMP RESOLVED INSIDE A MERGE COMMIT IS INVISIBLE. `git log -G -- path`
does not diff merge commits at all without `-m`, so a version line whose
final value is decided by a conflict resolution is never a candidate.
The lookup then names the last ordinary bump, and the merge itself counts
as a runtime change since it - a FALSE REFUSAL of a tree that is in fact
correctly bumped.
2. REVERTING A BUMP WHILE KEEPING THE WORK MASKS THE WORK. Bump, change
`app/`, then revert only `config.yaml`: the version is back where it
started, the runtime change is still in the tree, and this reports
"nothing to deploy" - a FALSE CLEARANCE, which is the dangerous
direction.
Card PKG-11 carries both. They are recorded here rather than fixed because
a lookup that handles them costs more than the failure they represent, and
a caveat a reader can check beats a guarantee that is not true.
"""
r = _git("log", "--format=%H", "-G", r'^version: "', "--", CONFIG_PATH,
repo=repo)
if r.returncode != 0:
return None
shas = r.stdout.split()
for sha in shas: # newest first
here = _version_at(sha, repo)
there = _version_at(sha + "^", repo)
if here is not None and here != there:
return sha
return shas[-1] if shas else None
def runtime_commits_since_version_change(repo=None):
"""Commits touching RUNTIME since `version:` last changed. ([], None) if unknown.
Returns (commits, last_bump_sha). An empty list with a sha means the tree is
clean; an empty list with None means git could not answer and the caller
must NOT read that as a pass.
"""
last = last_version_change(repo)
if not last:
return [], None
r = _git("log", "--format=%H %s", f"{last}..HEAD", repo=repo)
if r.returncode != 0:
return [], None
out = []
for line in r.stdout.splitlines():
sha, _, subject = line.partition(" ")
f = _git("show", "--name-only", "--format=", sha, repo=repo)
if any(is_runtime(p) for p in f.stdout.split()):
out.append((sha[:10], subject))
return out, last[:10]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--deployed-version", required=True,
help="the version currently running ON THE BOX")
ap.add_argument("--options", help="path to the live options blob, exported "
"from the Supervisor. Omitted = that half is SKIPPED.")
ap.add_argument("--tree", default=ADDON)
ap.add_argument("--repo", default=None)
a = ap.parse_args()
problems, skipped = [], []
shipped = shipped_version(a.tree)
print(f" deployed on the box : {a.deployed_version}")
print(f" shipped in this tree: {shipped}")
commits, last = runtime_commits_since_version_change(a.repo)
if shipped is None:
problems.append("could not read `version:` out of config.yaml")
elif shipped == a.deployed_version:
if last is None:
skipped.append("could not ask git what changed since the last bump; "
"the no-op check is INCONCLUSIVE, not clean")
if commits:
problems.append(
f"THE DEPLOY WOULD BE A NO-OP. Version is {shipped} on both sides, and "
f"{len(commits)} commit(s) touching runtime files have landed since the "
f"version last changed ({last}). `ha apps update` will report success "
f"and install NOTHING. Bump `version:` first.")
for sha, subj in commits[:12]:
print(f" {sha} {subj[:76]}")
elif last is not None:
print(" no runtime change since the last bump - nothing to deploy")
if a.options:
# ⚠️ A TOOL RUN IN A HURRY BEFORE A DEPLOY MUST NOT ANSWER WITH A
# TRACEBACK. A missing or malformed export is the likeliest operator
# error here, and it is a REFUSAL, not a skip - the config was not
# checked and the script must not be re-run past it.
opts = None
try:
with open(a.options, encoding="utf-8") as fh:
opts = json.load(fh)
except FileNotFoundError:
problems.append(f"--options file not found: {a.options}")
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
problems.append(f"--options file could not be read as JSON "
f"({type(exc).__name__}: {exc}); the config was NOT checked")
if a.options and opts is not None:
try:
from app.validate import validate_startup_config
except Exception as exc: # pragma: no cover
skipped.append(
f"could not import the shipped validator ({exc}); the config check "
f"did NOT run. `app/validate.py` imports its siblings, and those need "
f"the add-on's runtime dependencies - so THIS SCRIPT ONLY CHECKS A "
f"CONFIG WHERE THOSE DEPENDENCIES EXIST: inside the add-on container, "
f"or in the CI image. Re-run it as:\n"
f" docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \\\n"
f" python3 /w/goodwe_controller/tools/predeploy_check.py \\\n"
f" --deployed-version <ver> --options /w/<options.json>")
else:
found = validate_startup_config(opts)
if found:
problems.append(
f"THE LIVE CONFIG IS REFUSED BY THIS VERSION'S VALIDATOR "
f"({len(found)} problem(s)). Deploying it is SystemExit(78): the "
f"add-on does not start, and the ingress panel goes with it because "
f"the add-on serving it is not running. Recovery is the Supervisor UI.")
for p in found:
print(f" - {p}")
else:
print(" live options pass this version's validator")
else:
skipped.append("--options was not given, so the live config was NEVER CHECKED "
"against this version's validator. A config refused at startup is "
"exit 78. Export the options and re-run before deploying.")
print()
for s in skipped:
print(f" ⚠️ SKIPPED: {s}")
if problems:
for p in problems:
print(f" ❌ {p}")
print("\n REFUSED - do not deploy.")
return 1
if skipped:
print(" NOT CLEARED - checks above did not run. Deploying now is a decision "
"made without them.")
return 2
print(" CLEARED for deploy.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -315,6 +315,114 @@ print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
"SCHEMA BLOCK'S and not the options block's)")
# --------------------------------------------------------------------------- #
# ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN.
#
# UX-01 AC 3 said the visible count would drop to ~15, with the rest behind
# HA's "show unused optional configuration options" toggle. The line above has
# printed the refutation since the day it was written - 71, not 15 - and PRINTED
# IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while
# displaying the number that contradicted the criterion it was evidence for.
# That is the defect, not the count.
#
# THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of
# this section and replayed rather than paraphrased:
#
# _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts
# || e.required)
#
# `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's
# stored blob after `options:` has been deep-merged into it - so a key is
# hideable only when it is BOTH absent from `options:` AND not required.
#
# WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements:
#
# 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks
# `required` on every non-`?` key, and the filter above shows a required
# key UNCONDITIONALLY. So the floor - what the tab renders with `options:`
# EMPTIED ALTOGETHER, which is the most hiding that is even expressible -
# is the required count below. It is nowhere near 15. No arrangement of
# `options:` can reach the target; the criterion was never satisfiable.
#
# 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key
# absent from `options:` has nothing for the Supervisor to merge into an
# existing box's stored blob. That is exactly the `delivery_hold_s`
# mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob
# cannot contain resolves to its `options:` default instead of failing
# validation, and moving it out of `options:` is precisely how you get the
# exit 78 that mechanism exists to prevent. Worse, it would promote the 27
# unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4
# ledgers into the LOAD-BEARING source of those defaults on a live install
# - which is the CFG-03 defect the same card's AC 4 exists to police.
#
# So: four fewer clicks, bought with the upgrade path and with the defaults
# pin. The decision is written up in
# DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
# and the three assertions below are what keeps it a decision rather than an
# omission - if anyone empties `options:` to shorten the form, this goes red.
_hideable = sorted(set(schema) - set(defaults))
_required = [n["name"] for n in ui if n.get("required")]
def _filtered(opts):
"""The frontend's own `_filteredSchema`, replayed on this manifest."""
return [n for n in ui if n["name"] in opts or n.get("required")]
show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so "
f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 "
f"is not partially met, it is unstarted. Hideable: {_hideable}",
not _hideable and len(schema) == len(defaults))
show(f"...so replaying the frontend's own _filteredSchema on the served options "
f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render",
len(_filtered(defaults)) == len(ui))
# ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read
# `len(_required) > 15` while the prose said 59 - so a manifest change dropping
# the required count to 20 would have passed while the sentence beside it stayed
# wrong. That is the same shape as the count this repo already fixed in
# `test_translations.py`: a number displayed but not graded.
#
# So the expected value is computed from config.yaml by Supervisor's OWN rule -
# a key is optional iff its schema string ends in `?`, plus `inverters`, which
# `UiOptions` marks optional because it is a list - and the assertion is an
# EQUALITY against that. Typing 59 here would rot the first time a key is added.
_OPTIONAL = {k for k, v in schema.items()
if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"}
_EXPECT_REQ = len(schema) - len(_OPTIONAL)
show(f"...and that floor is DERIVED: {len(schema)} schema keys minus "
f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is "
f"{_EXPECT_REQ}, and Supervisor's own required flag agrees",
_EXPECT_REQ == len(_required))
# ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is
# deliberately NOT enough on its own: both sides read the same `?` convention,
# so marking a key optional moves BOTH and it stays green. That is correct - it
# makes the printed number impossible to leave stale - but it grades Supervisor
# agreeing with us, not the thing AC 3 was withdrawn for.
#
# AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If
# enough keys ever became optional that the floor approached 15, the withdrawal
# stops being true and this file should say so rather than keep printing a
# sentence that has quietly become wrong.
_AC3_ASKED = 15
show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} "
f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever "
f"goes red the withdrawal needs re-arguing, not re-asserting",
_EXPECT_REQ > 2 * _AC3_ASKED)
# ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required`
# flag rather than a count typed here. `options:` is emptied and NOTHING else
# varies, so the answer cannot be about anything but the merge.
show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is "
f"expressible - the tab still renders {len(_filtered({}))} required "
f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is "
f"withdrawn as unsatisfiable, not deferred.",
len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ)
def _entry(lang, name, path=()):
node = TRANSLATIONS.get(lang, {}).get("configuration")
+1 -1
View File
@@ -239,7 +239,7 @@ configuration:
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."
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 a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). 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."
+1 -1
View File
@@ -219,7 +219,7 @@ configuration:
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."
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 een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). 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."