Compare commits

..
15 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 22ca341459 ARCH-02: assert the invariant, because a fixture cannot enumerate arrangements
checks / unit (pull_request) Successful in 6m55s
Review approved and then attacked part 5b, the control that stands between B1's
defect and a green suite. It got through in three edits:

    A1  delete `END LEG`                        5b CATCHES it        2 FAIL
    A2  ...and reorder the legs                 5b BLIND             1 FAIL
    A3  ...and reword the closing summary       0 FAIL, "all checks passed"
                                                with a splitter that closed
                                                ZERO legs

⚠️ TWO OF THOSE THREE ARE ORDINARY REFACTORS - reordering report legs, rewording
a summary line - and neither reads as touching a safety check. 5b caught the
unclosed splitter only because the clean leg happened to be printed LAST, and
the backstop that saved A2 was a hardcoded transcription of the tool's own
sentence, so rewording that sentence blinded it too.

FIXED BY ASSERTING THE STRUCTURE RATHER THAN ADDING A FIXTURE: a leg may not
open while one is open, and every leg that opens must be explicitly closed.
Driven - deleting `END LEG` now fails on the FIRST edit, whatever the order or
the wording:

    AssertionError: leg 'SAME-STEP' opened while 'STAGED' was still open - the
    tool stopped closing its legs, and a splitter that never closes swallows the
    closing summary into the last leg.

All three attacks include that edit, so one assertion kills the class. Clean
tree unchanged: 28/28, all checks passed.

⭐ AND THE RULE IN MUTATION.md IS EXTENDED, because review's finding is the
general case of it. It said "a per-thing check that has never seen the things
DISAGREE is a total". Part 5b DOES make them disagree, every run, and was still
blind. So:

    ...AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
    OVER THAT ARRANGEMENT. When a fixture cannot enumerate the arrangements,
    assert the invariant they share.

Also recorded, NOT fixed, with the reason: the reference carries `entity_id
unique_id device` and grades all three, but never the discovery TOPIC. Review
measured `homeassistant/... -> ha/...` exiting 0 with no findings while every
announced entity dies. `discovery_payloads()` already returns the topic and
`announced_rows` discards it, so a fourth column closes it - but the prefix is
HA's fixed contract rather than a name this project chooses, so it is not what a
tidy-up rename reaches. Lower LIKELIHOOD than the N1 gap it resembles, not lower
consequence, and it stops being a note the day the topic becomes configurable.

Two stale figures corrected: the tool claimed five migration mutations where
there are nine, and pointed at a DOCS.md heading that does not exist (the real
one is "Before you commission inverter B", verified present at DOCS.md:1577).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 15:01:55 +02:00
Claude Opus 5 9c1a9e0f17 ARCH-02: M7's evidence was filtered out by its own finding kind
checks / unit (pull_request) Successful in 6m56s
The mutation battery prints the distinct findings each mutation caused, matched
on the finding's KIND. The pattern's character class was `[A-Z -]`, which has no
underscore - so `UNIQUE_ID DRIFT:` did not match, and M7, the mutation that
renames `mqtt.BASE`, printed its verdict with no evidence under it at all.

A filter that silently drops the very finding a mutation exists to produce is
the same defect as the rest of this card, at display scale. No behaviour change:
M7 was red before and is red now, and it now shows why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 14:23:35 +02:00
Claude Opus 5 638c213944 ARCH-02 round 2: the pin could not tell one red leg from two, and the tool went
checks / unit (pull_request) Successful in 6m57s
quiet when a flag was forgotten

Both blocking findings were reproduced before anything was changed, and both are
the class this card exists to guard against.

B1. THE PIN WAS A COUNT, AND A COUNT CANNOT SEPARATE TWO ROUTES. The rehearsal
reaches the disarmed latch by two independent paths and prints every finding
TWICE per red leg - once in the leg's block, once in the closing summary - so
ONE red leg satisfied `count(...) >= 2`. Measured: shipped code 4; with
`migrate_unit_keys()` handed the roster's own name (SAME-STEP fixed) 2, PASSES;
with a guard-side fix (both fixed) 0. The check named "NOT THE SAME-STEP PATH
ALONE" reported ok on the build whose only remaining failure IS the staged path.
The tool now prints `LEG <id>` / `END LEG <id>` and the pin requires the finding
in EACH leg by name.

⚠️ AND THE FIRST SPLITTER CARRIED THE SAME DEFECT ONE LEVEL DOWN - it opened on
the marker and never closed, so the last leg absorbed the closing summary and
read red whenever ANY leg was red. Caught by building the one fixture that
separates them. Part 5b is now that fixture, run every time: a tree in which
SAME-STEP is genuinely clean and STAGED is not. A per-thing check that has never
seen the things disagree is a total with extra steps.

⚠️ AND IT CORRECTS A CLAIM THIS CARD MADE. `main.py:250` is NOT the whole fix.
Measured on that exact change: LEG SAME-STEP clean, LEG STAGED still DISARMED -
the staged path stamps `schema_version` on the flat start and the roster start's
migration returns early before the name it is handed matters. DOCS.md now says
so, with the measurement.

B2. A FORGOTTEN FLAG WAS A CLEAN BILL OF HEALTH. Omitting `--live-entities`
printed "nothing orphaned" and exited 0, with `grep -ci` for the words returning
0 - while an UNREADABLE file exited 2. The asymmetry was backwards. Omitting it
now refuses (exit 2); `--no-live-entities` is the deliberate opt-out and carries
a standing banner in the header AND on the exit line, because a report is
skimmed from the bottom.

N1. THE REFERENCE DID NOT CARRY WHAT HA KEYS ON. It held entity_ids only, so
renaming `mqtt.BASE` (every unique_id moves) and changing `DEVICE["identifiers"]`
(HA sees a new device) both came back exit 0 - real orphanings with every
composed id byte-identical. The reference is now three columns
(`entity_id unique_id device`), is loaded on EVERY run including a degraded one,
and M7/M8 pin both. `object_id` is deliberately not a column and its rename is
required to SURVIVE, which is what bounds the four mqtt.py mutations.

Also found while fixing the above, and fixed with its own paired positive: the
VANISHED check did not carry the default/non-default rule the STRANDED check
does, so a pair of migrated DEFAULTS moving namespace read as two findings and
CONTROL-C went red on a CORRECT build. M9 drops non-default keys and must still
be a finding.

N2/N3. DOCS.md withdraws the unmeasured "not recoverable by advancing again" -
its own reasoning cut the other way and nothing here tested a re-advance; the
measured part (eight rows stop updating, history gaps, automations stop firing)
stands. And the byte-identical-input property is `test_rehearse.py`'s assertion,
not the tool's.

FRAMING. SAFETY-44 is ALREADY RECORDED on the merge base - `test_maintenance.py`
section 13, with the "guard is defeated by the very key the defect wrote"
sentence, in a file this card does not touch. DOCS.md now says this is
independent re-confirmation through a second instrument, reachable from an
owner-supplied state file rather than only from a fixture, and not a discovery.

app/ is still untouched. 59 checks in test_rehearse.py, 9 mutations red, 2
required survivors, 3 clean controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 14:21:34 +02:00
Claude Opus 5 1841416aa4 ARCH-02: put the fresh-install checks in the part they belong to
checks / unit (pull_request) Successful in 6m49s
They were printing under "6. THE REVERSE DIRECTION", which is not what they are
about, and part 2's heading still said two controls when there are three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:23:03 +02:00
Claude Opus 5 cb8f800180 ARCH-02: the harness was red on every fresh install, and a third control says so
A store the add-on has never written has no latch and no history. The DEV-02
migration still runs on it - `schema_version` is absent, so `from_version` is 0 -
and it writes `sign_tripped = False` and `sign_reason = ""` under `inverter-a`
before `build_roster()` exists. On a declared roster nothing reads either of
them, so the first draft called both STRANDED and exited 1. Every fresh install
that grows a second inverter would have been reported as a failed transition.

⚠️ THAT IS A DEFECT IN THE ALARM, NOT A CASE TO SUPPRESS, and store.py already
carries the distinction it needed: `MigrationReport.lost` reports a dropped
`sign_tripped=True` and NOT a dropped `sign_tripped=False`, because "absent" and
"never tripped" are the same statement - and because an alarm that fires on the
harmless case is one an operator learns to dismiss before the real one arrives.
The stranded check now makes the same split, on the VALUE and not on the key
name: a key stranded at its default is printed under its own heading, visibly,
and is not a finding; the same key stranded at a non-default still is. SAFETY-44
turns on `sign_tripped = True`, so it is untouched, and a check pins both
halves against each other so the softening cannot widen into a hole.

CONTROL-C is the fresh install, and it is a third clean control rather than a
line in a comment: the shipped code is right about it and the harness must say
nothing. It is the control that found this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:20:49 +02:00
Claude Opus 5 018da00b74 ARCH-02: show the mutation evidence, not the tool's own docstring
The mutation battery printed every line of the harness's output starting with a
warning sign, and the harness's header is four paragraphs of them - so each
mutation's evidence arrived buried under "THIS TOOL RUNS THE REAL Controller"
repeated six times, and each finding appeared once per transition leg plus once
in the summary. The filter now matches a finding's KIND and de-duplicates, so
what part 3 prints is the set of distinct findings the mutation caused.

No behaviour change: the same six mutations go red for the same reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:16:47 +02:00
Claude Opus 5 e06906d65f ARCH-02: rehearse the flat-to-roster switch, and it comes up disarmed
Nothing had ever run the real `Controller` through the transition inverter B's
commissioning performs on 2026-09-12. `tools/rehearse_roster.py` does, over a
COPY of a state file given as an input path, and reports the difference: every
store key that moved, appeared, vanished or was STRANDED under a name no
machine reads; every entity id before and after with orphans named; and whether
the maintenance history MAINT-05 moved once survived being moved again.

⭐ WHAT IT FOUND, AND IT IS SAFETY-44's ANSWER. A store that ran pre-DEV-02 flat
and declares a multi-inverter roster comes up with its SAFETY-02 latch DISARMED
unless the first roster entry happens to be called `inverter-a`.
`Controller.__init__` hands `migrate_unit_keys()` the hardcoded `UNIT_A`, so the
latch lands in `unit/inverter-a/sign_tripped`; a roster of ["garage","shed"]
reads `unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()`
- the guard written for exactly this - sees the migration's OWN misdirected key
satisfy its `namespaced_latched` test and returns without re-arming. Measured,
not argued: the file says `sign_tripped: true`, no inverter is latched, nothing
is held at 0 W. The same state file onto a roster whose first entry IS
`inverter-a` comes up latched, so the finding is attributable to the name.

The staged upgrade reaches it too: the flat start stamps `schema_version`, and
the roster start's migration then takes its early return.

⚠️ NO RUNTIME CODE IS CHANGED HERE. The fix to `migrate_unit_keys()` belongs to
SAFETY-44 reopened as a defect, shown red before it; this commit is the
instrument that shows it red.

THE CHECK CAN FAIL, AND WAS SEEN TO. `test_rehearse.py` establishes two CLEAN
CONTROLS first - two transitions the shipped code gets right, on which the
harness must say nothing - and then breaks the shipped migration and the
entity-id composition six ways in a private copy of the tree, requiring each to
turn its control from green to red WITH THE RIGHT FINDING NAMED. One mutation
is required to SURVIVE, with the reason written down. The synthetic state
carries maintenance history and a legacy-shaped latched `sign_tripped` with no
`schema_version` beside it, because state built by the happy path cannot detect
a migration that only mishandles legacy shapes.

⚠️ THE FIRST DRAFT OF THE HARNESS HAD HABIT 4 IN IT and its own battery caught
it - MUTATION.md's thirteenth instance, third in an instrument. The entity diff
composed both sides with the same build, so renaming the MQTT device moved every
id equally and it reported "no orphans" about a change that orphans everything.
`--live-entities` is the fix: a reference that does not move with the code.

THE RETREAT is supported and does not corrupt state - the flat rollback view
keeps the latch and the first pack's history live - but it orphans eight HA
entities, and the ROUND TRIP is not supported: a maintenance cycle completed
while rolled back is discarded on re-advance. Stated in DOCS.md as a decision.

Fetching /data/state.json, the options blob and the entity list are OWNER STEPS
and are written down. ⚠️ Nothing here was run against real state and nothing
here claims to have been.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 13:15:05 +02:00
admin 37dd0f00cc Merge pull request 'SAFETY-43: delinquency measured against the ask, not against a flat 500 W' (#69) from SAFETY-43 into release/1.0 2026-09-08 05:56:44 +02:00
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
admin a0d981731c Merge pull request 'SAFETY-31 AC 4: the alarm's attribution was graded by nothing, and a mutation proved it' (#68) from SAFETY-31-attribution into release/1.0 2026-09-08 04:27:16 +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 9099ceb1af SAFETY-31: the grid is 42, not 90, and the wrong number was in the failure text
checks / unit (pull_request) Successful in 6m27s
Review reproduced every figure in both PRs except one, and rejected on it.

⚠️ "0 of 90 GAINS x SPLITS combinations produce opposing shares" - the SUBSTANCE
is right and review re-measured it, but the DENOMINATOR is invented. sitesim.py
declares GAINS with 7 entries and SPLITS with 6: the grid is 42. 90 is not even
divisible by 7, so no reading of "GAINS x SPLITS" reaches it. It is almost
certainly TEST-20`s RUN count (84 + 6), which is a different quantity that
happened to be nearby.

⚠️ AND THE WORST COPY OF IT WAS INSIDE THE `PRECONDITION FAILED` TEXT, so the
next engineer to see this scenario go red would have been handed an
unreproducible measurement as their diagnosis - the exact defect class this
campaign exists to correct, in the failure path where it does the most harm.

Corrected in all seven places. At the failure-text site the denominator is now
`len(GAINS) * len(SPLITS)`, computed rather than typed, so it cannot drift from
the grid again - the same fix test_translations.py already took.

Also N3: a missing blank line was going to render the S3 and M19 paragraphs as
one in DECISIONS.

Re-run after the change: `inject.py two_master_circulation` PASS in 35 s, norig,
no rig lock taken. Add-on 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 20:13:01 +02:00
glenn schrooyenandClaude Opus 5 295a2aedb6 SAFETY-31 AC 4: the alarm's attribution was graded by nothing, and a mutation proved it
checks / unit (pull_request) Successful in 6m27s
`pos, neg = opposing(...)` -> `([], [])` in main.py's `check_circulating()`
(-102 B) deletes the "Right now X is discharging while Y charges" clause from
the operator notification, and SURVIVED ALL 26 FILES. The commit that shipped
this card claimed "18 mutations, all RED"; the ledger has no entry for the
attribution, and this is that entry.

It is not dead code. Ledger M18 - the whole `await check_circulating()` call
removed - is red on four checks, so the function runs. The three checks on the
notification asserted METER CANNOT SEE, CONTROL HAS BEEN STOPPED and `isolator`,
and never an inverter name. `circulation.py`'s own docstring calls that sentence
the first thing somebody woken by this alarm needs and "impossible to
reconstruct from the number alone".

test_watchdog.py section 6a closes it on a fixture that can tell three failures
apart:

  * the roster is ["garage", "shed", "cellar"] - operator names, none of them
    `UNIT_A`, for test_maintenance.py section 11's reason: on a fixture whose
    names are the module's own constants a check cannot tell the roster from
    the constant;
  * the site is run TWICE, MIRRORED, so the sentence is not symmetric and a
    pos/neg swap is a different red from a deletion;
  * a third pack rests at 5 W, so the floor that stops a pack in standby being
    named as a participant in a 6 kW loop is graded too.

Mutations, on a tree with no __pycache__ mounted :ro into the checks image:

  M20  pos, neg = opposing(...) -> ([], [])          -102 B  RED (2 checks)
  M21  pos and neg exchanged                          +0 B   RED (3 checks)
  M22  opposing(..., min_w=cfg.min_w/2.0) -> 0.0      -25 B  RED (3 checks)

26/26 files green with the block in place.

AND A DISCLOSURE, WHICH IS THE UNCOMFORTABLE HALF. DECISIONS section 3 records
that the 924-run campaign behind `circulation_w: 700` and `circulation_hold_s:
75` used a harness that is not committed in either repository. Two further
sweeps have now been run and no two of the three agree: peaks of 1700 W, 2750 W
and 1048 W, and worst-continuous figures above 700 W of 5.0 s, 15.0 s and 5.0 s.

That is NOT evidence of fabrication and is not reported as any. The committed
sweep measures why the spread is that wide: on one grid, changing only the
plant model and the write-stagger set, the worst legitimate peak is 1048 W, or
2250 W, or 0 W. The plant and the stagger are TERMS of the number. What the
spread does establish is that the claim cannot be adjudicated while the
instrument is missing.

The window argument survives and is now committed - all three sweeps trip zero
times, on 924 + 48 + 840 non-faulty runs. The threshold CURVE does not survive
as stated: `505.0 s at 100 W`, which is what the "knee at 400 W" argument rests
on, is 10.0 s on the committed grid. The curve still falls with the threshold,
so the knee is real in direction; its depth is established by nothing anybody
can run, and this commit says so rather than repeating it. The overclaiming
comment at test_watchdog.py's 1700 W fixture ("the worst single transient the
rig can produce") is replaced with what the fixture can support.

The committed, re-runnable half is admin/goodwe's `two_master_circulation`,
opened as a separate PR against that repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 19:08:33 +02:00
23 changed files with 3741 additions and 157 deletions
+63 -2
View File
@@ -46,8 +46,10 @@ 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. The
closure is card SAFETY-43.
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
@@ -97,6 +99,65 @@ 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
@@ -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
@@ -149,8 +149,70 @@ each swept across the **whole range `config.yaml` accepts** for the two settings
that decide how fast the site can turn around: `gain` `float(0.05,1.0)` and
`slew_w` `int(50,5000)`.
### ⚠️ THE HARNESS BEHIND EVERY FIGURE ABOVE IS NOT COMMITTED, AND UNTIL 2026-09-07 THAT MADE THEM UNCHECKABLE
**Read this before quoting any number in this section.** The paragraph below
said the campaign harness "is not committed here" and treated that as a
packaging detail. It is not: a figure produced by a script that exists in
nobody's tree cannot be re-run, cannot be reviewed, and cannot be defended when
it is contradicted. It has now been contradicted twice.
| sweep | worst peak | worst continuous | trips |
|---|---:|---:|---:|
| the 924-run campaign below — **harness uncommitted** | 1700 W | 505.0 s at 100 W; 5.0 s above 700 W | 0 / 924 |
| independent reviewer sweep, 2026-09-07, 48 honest reversals | **2750 W** | **15.0 s above 700 W** | 0 / 48 |
| `admin/goodwe` `two_master_circulation`, 840 runs — **committed** | 1048 W | 10.0 s at 100 W; 5.0 s above 700 W | **0 / 840** |
**This is not proof that anything was fabricated, and it should not be reported
as one.** The three swept different grids. What the third one adds is a
measurement of *why* the spread is that wide — on one grid, changing nothing but
the model:
| plant / stagger | worst legitimate peak |
|---|---:|
| modelled plant (lag 10 s, ramp 800 W/s), staggers up to a whole cycle | 1048 W |
| `INSTANT` (lag 0, unbounded ramp), staggers up to a whole cycle | 2250 W |
| `INSTANT`, staggers strictly under a whole cycle | **0 W** |
A factor of 2.1×, and a factor of infinity, between three defensible
descriptions of the same rig. **The plant model and the write-stagger set are
terms of the number**, and a worst case quoted without them is not a
measurement. That is enough to explain the whole spread above without anybody
having invented anything — and it is exactly what cannot be established when the
instrument is missing.
**What survives, and what does not.**
* **The window argument survives, and is now committed.** The claim that
decides `circulation_hold_s: 75` is *"the worst continuous legitimate episode
above 700 W is about one control cycle"*. All three sweeps agree on the shape
of that (5.0 s, 15.0 s, 5.0 s — one to three cycles against a fifteen-cycle
window), and **all three trip zero times**. `two_master_circulation` re-runs
it on every pull request in `admin/goodwe`.
* **The threshold curve does NOT survive as stated.** `505.0 s at 100 W` is the
number the "knee at 400 W" argument for `circulation_w: 700` rests on, and the
committed sweep measures **10.0 s** at 100 W on its grid — 50× smaller. The
curve still *falls* with the threshold (126 of 840 runs cross 100 W against 1
that crosses 700 W), so the knee is real in direction; its depth is not
established by anything anybody can run. **Do not quote 505.0 s.**
* **Nothing about the guard is compromised.** 0 trips in 924 + 48 + 840
non-faulty runs, from three independent grids.
**What would settle it:** the original harness, committed. Until then the
committed sweep is the only figure in this section anybody can check, and it is
labelled as its own grid's rather than as a reproduction.
### Reproducing it
⚠️ **THE COMMITTED VERSION IS `admin/goodwe`'s `sim/inject.py
two_master_circulation`** (`sim/sitesim.py:circulation_findings`, runnable on its
own as `python3 sim/sitesim.py --circulation`, `norig`, ~36 s, graded by
`checks.yml` on every pull request there). It produces the fault with
`plantsim.Inverter.wedge()` — a unit answering its bus and no longer steering its
power stage, which is HW-01 — and sweeps the legitimate family with the
serialised write path staggered up to a whole control cycle. The paragraph below
describes the ORIGINAL campaign, which is the one that is not committed.
The rig lives in `admin/goodwe` (`sim/`) and was used **read-only**; the harness
is not committed here because it imports that repo's `plantsim`/`frame_check`,
which are not present in this one or in CI. What it does is small enough to
@@ -206,7 +268,11 @@ margin above the knee is what absorbs that without re-opening this card.
**Revisit when SPIKE-04 lands, not before.**
The **schema floor is the knee itself**, 400 W, so a legally configured install
cannot land in the 445–505 s regime. What the threshold costs: a circulating
cannot land in the 445–505 s regime — ⚠️ and those two figures are from the
uncommitted campaign; the committed sweep measures 10.0 s at 100 W on its own
grid. See the disclosure at the head of this section. The floor is still the
right shape (fewer runs cross a higher threshold, measured 126/840 at 100 W
against 1/840 at 700 W); its DEPTH is not established by anything re-runnable. What the threshold costs: a circulating
loop smaller than 350 W per unit is not reported, which is deliberate — at that
scale the round-trip loss is a few watts.
@@ -289,6 +355,32 @@ SAFETY-25 — so no stale `.pyc` could exist to be read. Byte deltas recorded;
| M17 | `circulation.py` | default `700` → `500` | **+0 B** | **RED** — the CFG-03 pin and the not-a-round-number check |
| M18 | `main.py` | `await self.check_circulating()` removed | −39 B | **RED** — all four notification checks |
| M19 | `main.py` | publish `round(x or 0.0)` instead of `None` | −68 B | **survivor on the first battery — GAP CLOSED**, then RED |
| **M20** | `main.py` | **`pos, neg = opposing(...)` → `([], [])`** — the attribution clause deleted from the alarm | −102 B | **SURVIVED ALL 26 FILES on the shipped battery — GAP CLOSED**, then RED on §6a |
| M21 | `main.py` | `pos` and `neg` exchanged — the alarm names both packs, both the wrong way round | **+0 B** | **RED** — §6a ×3 (the mirrored run, and "only its own sentence") |
| M22 | `main.py` | `opposing(..., min_w=cfg.min_w / 2.0)` → `min_w=0.0` | −25 B | **RED** — §6a's resting-pack check |
**And four in `admin/goodwe`**, graded through `python3 sim/sitesim.py
--circulation` rather than through the injector — deliberately, because
`inject.py` is the file that takes the rig lock, and "run the injector to grade a
mutation" is not a sentence anybody should have to type. Byte deltas over
`plantsim.py` + `sitesim.py` together.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| S1 | `plantsim.py` | `wedge()` no longer stops the latch reaching the power stage | −20 B | **RED** — the opposing-signs precondition **and** "the watchdog did not trip" |
| S2 | `sitesim.py` | the sensor computed over the **allocation record** instead of register 0x0518 | **+0 B** | **RED** ×3 — this is M10 restated on the rig |
| S3 | `sitesim.py` | the campaign run on `plant=INSTANT` | −51 B | **RED** — the plant is a *term* of the figures; it was a SURVIVOR until that check was added, and finding out why produced §3's 1048/2250/0 W table |
| S4 | `sitesim.py` | the write stagger removed from the legitimate campaign | −19 B | **RED** — "the legitimate campaign produced no opposition at all" |
**S3 is recorded as a survivor because it was one, and it is the most useful
result in this table.** The scenario first shipped with a vacuity check whose
text claimed `plant=INSTANT` would make the sweep measure 0 W. S3 was run and
**passed**: `INSTANT` with a stagger of exactly one control cycle samples the
plant before the second unit has stepped, so it produces opposition anyway — and
a *larger* worst peak (2250 W) than the modelled plant does (1048 W). The claim
in the check's own text was wrong. It was corrected against the measurement, the
plant was pinned as an explicit term, and S3 is red now for a stated reason
rather than by accident. §3's spread table is that measurement.
**M19 is recorded as a survivor because it was one.** The first battery found no
check grading what `publish()` does when a per-unit reading is missing, so
@@ -297,6 +389,30 @@ watches went unnoticed — the sensor's own `None` rule, defeated one layer out.
A check was added and the mutation re-run; it is red now. The run that found it
was purged, so it is a result and not an artefact.
**M20 IS RECORDED AS A SURVIVOR BECAUSE IT WAS ONE, AND IT IS THE MORE
UNCOMFORTABLE OF THE TWO.** The commit message for this card claimed *"18
mutations, all RED"*, and M1–M19 above have no entry for the notification's
attribution clause. Verification on 2026-09-07 ran it and it **survived the
whole 26-file suite**: the alarm still fires, still stops the site, still says
the meter cannot see it — and no longer says *which two packs* or *which way
round*. `circulation.py`'s own docstring calls that sentence the first thing
somebody woken by this alarm needs and *"impossible to reconstruct from the
number alone"*.
It survived for one reason, and it is not reachability: **M18 (the whole call
removed) is red**, so `check_circulating()` is exercised. The three checks on
that notification asserted `METER CANNOT SEE`, `CONTROL HAS BEEN STOPPED` and
`isolator` — and **never an inverter name**. Nothing graded the attribution.
`test_watchdog.py` §6a closes it, and closes it on a fixture that can tell the
three failures apart. It runs a roster of **`["garage", "shed", "cellar"]`** —
operator names, none of them `UNIT_A`, for test_maintenance.py §11's reason:
on a fixture whose names are the module's own constants a check cannot tell the
roster from the constant. It runs the site **twice, mirrored**, so the sentence
is not symmetric and a swap (M21, `+0 B`) is a different red from a deletion.
And it carries a **third pack resting at 5 W**, so the floor that stops a pack
in standby being named as a participant in a 6 kW loop (M22) is graded too.
## 7. What this card does NOT close
* **Inverter B's ramp and lag are placeholders.** Every transient number in §3
@@ -305,7 +421,9 @@ was purged, so it is a result and not an artefact.
margin. **Do not present any figure here as a measurement of inverter B.**
* **A wedged inverter still cannot be stopped by this add-on.** §1 says so; the
notification says so; there is no software fix and this card does not pretend
to one.
to one. It *can* now be produced on demand: `plantsim.Inverter.wedge()`.
* **The depth of the threshold curve is not established by anything committed.**
§3's disclosure. The window is; the knee's magnitude is not.
* **A loop below ~350 W per unit is not reported.** Deliberate, §3.
* **The saturation freeze fires under a wedged unit and leaves a standing grid
error.** Observed on the rig while producing §2's numbers: with B wedged, the
@@ -142,6 +142,16 @@ take the **longest run of consecutive cycles** on which it names anybody — not
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
@@ -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**.
+179
View File
@@ -1574,6 +1574,185 @@ 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 commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
The day the `inverters:` roster is declared, two things change that are
**one-way on a live box and invisible until after the restart that applies
them**: the store migration renames the keys that hold the SAFETY-02 latch and
the maintenance history, and the entity ids Home Assistant composes change
shape. This tool runs the **real `Controller`** through that transition against
a **copy** of the state file and reports the difference.
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v <repo>:/w:ro \
goodwe-addon-checks:1 sh -c 'cd /w/goodwe_controller && \
python3 tools/rehearse_roster.py \
--state /w/state-copy.json \
--before /w/live-options.json \
--after /w/roster-options.json \
--live-entities /w/live-entities.txt \
--reverse --round-trip'
Exit `0` clean · `1` at least one finding · `2` could not run. ⚠️ **`2` is not a
pass.**
### The owner steps, because this session could not take them
⚠️ **The rehearsal is only as good as the state it is pointed at, and nothing in
this repository can fetch that.** SSH to the live box is blocked by policy and
that is correct — `/data/state.json` is the file the running add-on is writing.
So three things have to be exported by hand, onto the machine that runs the
check, **before** the roster is declared:
1. **The state file.** From the Supervisor host:
ha addons info local_goodwe_controller # confirm the slug first
docker cp addon_local_goodwe_controller:/data/state.json ./state-copy.json
or take it out of a full add-on backup, which is the same bytes. ⚠️ **Copy
it, never move it, and never write to the original.** The tool opens the path
it is given exactly once and does all of its writing on a scratch copy — but
the safe habit is to hand it a file the add-on has never heard of.
2. **The options blob**, as `--before`. It is not reachable through the Home
Assistant REST API and no entity carries it; export it from the Supervisor,
the same way `tools/predeploy_check.py` needs it. Write the roster you intend
to deploy into a second file and pass it as `--after`.
3. **The entity list**, as `--live-entities` — one entity id per line:
Developer tools -> Template:
{{ states | map(attribute='entity_id') | select('search','goodwe') | join('\n') }}
⚠️ **Omitting it is a REFUSAL (exit 2), not a quieter run.** The orphan check
is graded against the ids the install actually has, and without that file it
is not graded against reality at all — so the tool will not run rather than
hand back a clean bill of health for a half of the transition it did not
check. If you genuinely have to run without it, pass `--no-live-entities`:
the run then carries a standing banner in the report header **and** on the
exit line saying the orphan check was not graded. The asymmetry used to be
the other way round — an unreadable file refused, a missing flag passed with
exit 0 and no disclosure at all — and the owner runs this once, at
commissioning, under time pressure.
The install carries roughly 49 `goodwe*` entities and this add-on announces
eight of them. The tool separates the ids it owns from the ids it does not,
rather than claiming orphans it cannot cause.
### The reference, which is loaded on every run
`--reference` defaults to `tools/rehearsal/announced-reference.txt` and is read
**whether or not** you pass an entity list. It records what the build is
supposed to announce, three columns per row:
<entity_id> <unique_id> <device identifier>
⚠️ **All three, and the second and third are the ones an entity_id-only list
cannot see.** The tool's own before/after diff is composed twice by the same
build, so a change to the composition moves both sides equally and the diff
reports nothing. Worse, two changes orphan an install *without moving a single
composed entity_id*: renaming `mqtt.BASE` (every `unique_id` changes) and
changing `DEVICE["identifiers"]` (HA sees a new device). Home Assistant keys its
entity registry on `unique_id` and its device registry on the identifier, so
either one creates a **new** entity beside the old — and because discovery is
published `retain=True` with no removal payload, the old config stays on the
broker and the old entity stays in the registry, still named by every automation
that referenced it, never updating again. Both were measured coming back
**exit 0** before the reference carried three columns.
`object_id` is deliberately **not** a column: `app/mqtt.py` records that HA
2026.8 does not honour it in a discovery payload, so renaming one is a true
no-op, and `test_rehearse.py` requires that mutation to **survive** — otherwise
"the reference notices any edit to `mqtt.py`" would be indistinguishable from
"the reference notices what HA keys on".
⚠️ **No result in this repository was produced against real state.** Everything
recorded here and in `test_rehearse.py` was measured on the synthetic fixtures
in `tools/rehearsal/`.
### What it confirms on the synthetic fixtures
⚠️ **This is a SECOND INSTRUMENT reaching a conclusion this repository already
holds, not a discovery.** `test_maintenance.py` section 13 — *"RECORDED, NOT
FIXED: SAFETY-02's migration has the same wrong-namespace hole, and it is a
LATCH"* — already states it, already names `namespaced_latched`, and already
carries the sentence *"the guard is defeated by the very key the defect wrote"*.
That file is on `release/1.0` and this card does not touch it. What is new here
is that the finding is now reachable **from a state file the owner supplies**
rather than only from a fixture built in a test, and that it is reached by a
different route: a full `Controller` start diffed against its predecessor,
rather than assertions over a `MemStore`. Two instruments, one answer.
**The finding.** A pre-DEV-02 flat store that declares a multi-inverter roster
comes up with its SAFETY-02 latch **DISARMED**, unless the first roster entry
happens to be named `inverter-a`. `Controller.__init__` calls
`migrate_unit_keys(store, UNIT_A)` with a hardcoded constant, so the latch lands
in `unit/inverter-a/sign_tripped`; a roster of `["garage", "shed"]` reads
`unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` —
the guard written for exactly this — sees the migration's own misdirected key
satisfy its `namespaced_latched` test and returns without re-arming. The file
says `sign_tripped: true`, the banner is green, and nothing is held at 0 W.
**Both routes reach it, and they are NOT the same defect.** The rehearsal
reports them as separate legs because they need separate fixes:
| leg | how it gets there |
|---|---|
| `SAME-STEP` | the roster is declared in the same step as the upgrade. The migration runs, and is handed `UNIT_A`. |
| `STAGED` | the flat config runs once under 0.7.0+ first. That start stamps `schema_version = 1`, so the roster start's migration takes its `from_version >= SCHEMA_VERSION` early return and **the latch never moves at all**. |
⚠️ **So "hand `migrate_unit_keys()` the roster's own first name" is NOT the
whole fix, and the rehearsal measures that rather than asserting it.**
`test_rehearse.py` part 5b builds exactly that change and reports:
LEG STAGED: DISARMED
LEG SAME-STEP: clean
The staged path never reaches the line that was changed. A fix that stops there
closes the half an operator is least likely to take and leaves the half that
"upgrade first, declare the roster later" produces. The guard side —
`namespaced_latched` counting a namespace that is not on the roster — is what
covers both, and a change there turns every SAFETY-44 check in part 5 red, which
is what a fix should do to a pin that specifies a defect.
The fix belongs to its own card, shown red first. Part 5 pins the current
behaviour as a requirement so it cannot drift green by accident.
### The reverse direction: the retreat is supported, the round trip is not
If commissioning B goes wrong, **backing out to the flat single-inverter config
is supported and does not corrupt state.** Measured on the fixtures:
* the SAFETY-02 latch and the first pack's maintenance history both survive,
because `store.py` rule 2 keeps the flat spellings **live** rather than merely
present — `maintenance.UnitHistory.mirror_flat` writes them on every change,
so a single-FSM add-on finds a current history rather than one dated the day
the roster was declared;
* **it orphans eight Home Assistant entities** — the four per-unit rows for each
of the two units. **Measured:** the add-on stops announcing them and stops
publishing their state, so their history gaps and any automation whose
trigger names them stops firing, silently and without an error.
⚠️ **What is NOT measured, and was previously overstated here:** whether
advancing again restores them. An earlier draft of this section said the
orphaning was "not recoverable by advancing again: HA keeps the id it composed
at first discovery" — that reasoning cuts the other way, since keeping the
composed id is precisely what would let a re-advertised entity land back on
it, and discovery is published `retain=True` with no removal payload. Nothing
in this repository has tested a re-advance against a real Home Assistant, so
the claim is withdrawn rather than restated in the other direction. Plan the
retreat around the measured part: **the eight rows stop updating for as long
as you are retreated.**
⚠️ **What is NOT supported is the round trip.** A maintenance cycle that
completes *while the install is rolled back* is **discarded** when it
re-advances: `maint_schema_version` is already stamped, so `migrate_maint_keys()`
takes its early return and the pack reads the namespaced history from before the
retreat. The pack is then recalibrated on a schedule that does not know it
already was. **So: retreat freely, but treat a retreat as ending the current
maintenance bookkeeping — and if a cycle ran during it, correct
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
decision, not a discovery made at the inverter.
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
Two ways a deploy has gone wrong on this system, both of which the Supervisor
+72
View File
@@ -225,6 +225,78 @@ same place twice, the check is decoration.
forever, *including after the call site is fixed*. The check was true and
passed for the wrong reason. Habit **9** below is what falls out of it.
* ⚠️ **ARCH-02's OWN REHEARSAL HARNESS, CAUGHT BY ITS OWN MUTATION BATTERY —
the THIRTEENTH instance, and the third in an INSTRUMENT rather than in a
check.** `tools/rehearse_roster.py` reports orphaned Home Assistant entities
by composing the ids the add-on announces **before** the roster switch and
**after** it, and diffing. Both sides are composed by the same build. So the
two mutations that change the id composition itself — renaming the MQTT device
(`"GoodWe"` -> `"GoodWe Controller"`), renaming one site sensor (`"Grid power"`
-> `"Grid power W"`) — moved **every id on both sides equally**, and the
harness reported *"no orphans"* about a change that orphans the entire live
entity list. It read exactly like a clean transition. The first draft's
M5/M6 came back `exit 0` and `exit 2`, and neither was the failure they name.
The fix is habit 2 with a filename: **the reference has to be data that does
not move with the code.** `--live-entities` takes a list of the ids the
install actually has, `tools/rehearsal/live-entities-recorded.txt` is the
recorded stand-in that keeps it live in CI, and a build that announces an id
the list does not carry is now a `COMPOSITION DRIFT` finding rather than a
silence. ⚠️ **The anchor moved for the same reason**: it had been
`compose(mqtt.DEVICE["name"], p1["name"]) == "sensor.goodwe_p1_sample_age"`,
which any renaming of the device satisfies by moving both operands, and it is
now composed from two recorded **literals**.
Ask of an instrument what habit 4 asks of a check: *where did each side of
this comparison come from* — and if a mutation of the subject moves both
sides, the instrument is decoration.
⚠️ **AND REVIEW FOUND TWO MORE IN THE SAME CARD, ONE OF THEM INSIDE THE FIX
FOR THE OTHER — the FOURTEENTH and FIFTEENTH.** Both are the pin, not the
tool, and both are the same sentence in a new costume: *a check whose data
cannot reach the failure it names.*
* **Fourteenth — a COUNT cannot tell one red leg from two.** The rehearsal
reports two independent routes to the disarmed latch (`SAME-STEP` and
`STAGED`) and prints every finding **twice per red leg**: once in that
leg's own block and once in the closing summary. The check named *"NOT THE
SAME-STEP PATH ALONE"* was written `out.count("LATCH CAME UP DISARMED")
>= 2`, so **one** red leg always satisfied it. Measured on three builds:
shipped code (both legs red) → 4; `migrate_unit_keys()` handed the roster
name (SAME-STEP fixed) → 2, **passes**; a guard-side fix (both fixed) → 0.
The check reported `ok` on the build whose only remaining failure *is* the
path it says is not the only one. The fix is to parse the legs and require
the finding in **each**, which is habit 2 again: grade the thing, not a
total over the thing.
* **Fifteenth — and it is INSIDE THAT FIX, which is the third time this file
has had to write that sentence.** The first leg-splitter opened on a
`LEG <id>` marker and never closed, so every line after the *last* marker
was attributed to that leg — including the closing summary, which repeats
every finding from every leg. The last leg therefore read red whenever
**any** leg was red, and the new per-leg check was the old count with extra
steps. It was caught by building the one fixture that separates them: a
tree in which `SAME-STEP` is genuinely clean and `STAGED` is not. The tool
now prints `END LEG <id>` and the splitter closes on it.
The rule that falls out, and it is the cheap one: **a per-thing check that
has never seen the things DISAGREE is a total.** Part 5b exists to make
them disagree on every run.
⚠️ **AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
OVER THAT ARRANGEMENT.** Review found this by attacking the fix itself. Part
5b does make the legs disagree, on every run - and was still blind to a build
that merely REORDERS them, because it had only ever seen the clean leg
printed LAST. Three edits, two of them ordinary refactors (reorder the report
legs, reword a summary line), produced `all checks passed` from a splitter
that closed ZERO legs.
A fixture cannot enumerate the arrangements, so the STRUCTURE is asserted
instead: a leg may not open while one is open, and every leg that opens must
be explicitly closed. That fires on the FIRST edit of all three attacks,
whatever the order or the wording. **When a fixture cannot enumerate the
arrangements, assert the invariant they share.**
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
reaching outside the suite. The probe is the committed evidence that
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
+124 -6
View File
@@ -414,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."""
@@ -532,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
@@ -579,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
@@ -595,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
========================
+4 -2
View File
@@ -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.
+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:
+743
View File
@@ -0,0 +1,743 @@
"""Runnable check for the roster rehearsal. `python3 test_rehearse.py`
ARCH-02. `tools/rehearse_roster.py` is the instrument that decides whether the
flat -> roster switch on 2026-09-12 loses a safety latch, a month of maintenance
history, or a Home Assistant entity that an automation is watching. This file
grades the instrument.
⚠️ AN INSTRUMENT THAT HAS NEVER BEEN SEEN TO GO RED IS NOT EVIDENCE. This
repository's most-repeated defect, `MUTATION.md` habit 4 and now FIFTEEN
recorded instances, is "a check whose DATA cannot reach the failure it names" -
and a rehearsal that reports "migration OK" because it compared a structure to
itself is the purest example of it there is. So this file does two things in
order, and the first one is the one that makes the second mean anything:
Part 2 TWO CLEAN CONTROLS. Two (state file, roster) pairs on which the
shipped code is CORRECT and the harness must report NOTHING. A
harness that is red on every input has measured nothing, and every
red below it would be free.
Part 3 ⭐ SIX DELIBERATE MUTATIONS OF THE SHIPPED MIGRATION AND OF THE
ENTITY-ID COMPOSITION, each applied to a private copy of the tree,
each required to flip its control from green to red, AND each
required to produce the SPECIFIC finding it should - not merely a
non-zero exit. A mutation that goes red for the wrong reason is a
harness that would go red on the real defect for the wrong reason
too.
Part 4 TWO MUTATIONS THAT DELIBERATELY SURVIVE, with the reason written
down. A battery in which everything dies is a battery whose author
stopped as soon as the number looked good - and the `object_id` one
is what BOUNDS the four `app/mqtt.py` mutations above it: without a
change to that file that must NOT be caught, "the reference notices
any edit to mqtt.py" and "the reference notices what HA keys on" are
the same green.
Part 5b THE PER-LEG CHECK'S OWN POSITIVE CONTROL: a build in which exactly
ONE of the two legs is clean. ⚠️ A per-thing check that has never
seen the things DISAGREE is a total with extra steps, and this file
shipped that defect twice - see `legs()`.
Part 5 SAFETY-44's exact path, pinned as a finding, by name, in both
directions.
⚠️ THE MUTATIONS ARE SOURCE EDITS IN A COPIED TREE, NOT MONKEYPATCHES. A
monkeypatch grades the name a module was asked for; an edit grades the code that
runs. Each copy is made with `mkdtemp()`, so two runs of this file cannot
overwrite each other's work - habit 18 - and `__pycache__` is never copied, so
no mutant is ever graded through a stale `.pyc` - habit 1 / TEST-15.
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
FIX = HERE / "tools" / "rehearsal"
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# --------------------------------------------------------------------------- #
print("1. the harness's own preconditions")
# --------------------------------------------------------------------------- #
sys.path.insert(0, str(HERE / "tools"))
import rehearse_roster as RR # noqa: E402
check("the tool refuses rather than passing when it cannot run: its three exit "
"codes are distinct and 2 is not 0",
(RR.EXIT_OK, RR.EXIT_FINDING, RR.EXIT_COULD_NOT_RUN) == (0, 1, 2))
# ⚠️ THE ANCHOR. `app/mqtt.py` records `sensor.goodwe_p1_sample_age` as VERIFIED
# against the live system on 2026-08-26, and the ESP32's ingest guard subscribes
# to it. The tool's entity-id model is written independently of `mqtt._slug` on
# purpose (habit 4), so this is the one external fact that says the independent
# model is right. If it fails, every orphan claim the tool makes is worthless
# and the tool exits 2 rather than reporting.
try:
RR._check_anchor()
anchored = True
except RR.Refused:
anchored = False
check("the entity-id model reproduces the ONE id verified against the live "
f"system ({RR.ANCHOR_ID})", anchored)
check("...and it is NOT built out of `mqtt._slug`, so a mutation in the "
"shipped slugifier moves one side of the comparison and not both",
"_slug" not in RR.compose.__code__.co_names)
_opts = RR.load_options(str(FIX / "roster.yaml"))
check("the built-in YAML reader parses the roster fixture the check image "
"cannot parse with PyYAML (there is no `yaml` module in it)",
[e["name"] for e in _opts["inverters"]] == ["garage", "shed"]
and _opts["max_w"] == 5000 and _opts["batt_invert"] is False
and _opts["meter_source"] == "off")
try:
RR._mini_yaml("inverters: [a, b]\n")
_refused = False
except RR.Refused:
_refused = True
check("...and it REFUSES a construct it does not understand rather than "
"guessing, because a config silently mis-parsed here rehearses a "
"configuration nobody is going to deploy", _refused)
_flat = RR.load_options(str(FIX / "flat.yaml"))
check("⚠️ the two configs differ in the ONE way the transition is about: the "
"BEFORE roster is empty and the AFTER roster is declared - if they did "
"not, every diff below would be a structure compared to itself",
_flat.get("inverters") == [] and len(_opts["inverters"]) == 2)
check("...and the AFTER roster's first entry is NOT `inverter-a`, which is "
"habit 4's seventh instance in one line: with it, `UNIT_A` and "
"`inverters[0].name` are the same string and a misdirected namespace "
"cannot be seen",
_opts["inverters"][0]["name"] != RR.UNIT_A)
_legacy = json.loads((FIX / "state-legacy.json").read_text())
check("⚠️ the synthetic state carries MAINTENANCE HISTORY - state built by the "
"happy path cannot detect a migration that drops it",
_legacy.get("last_completed") and _legacy.get("phase"))
check("⚠️ ...and a LEGACY-SHAPED KEY WHOSE LOSS WOULD BE SILENT: a LATCHED "
"`sign_tripped` with no `schema_version` beside it, i.e. written by a "
"controller that had never heard of unit namespaces",
_legacy.get("sign_tripped") is True
and "schema_version" not in _legacy
and "maint_schema_version" not in _legacy)
# --------------------------------------------------------------------------- #
print()
print("2. THE CLEAN CONTROLS: three transitions on which the shipped code is "
"right and the harness must say nothing")
# --------------------------------------------------------------------------- #
# ⚠️ THIS PART IS WHAT MAKES PART 3 EVIDENCE. Six reds prove nothing about an
# instrument that is red on its own fixtures. Each control below is a genuine
# flat -> roster switch that the shipped migration handles correctly, and the
# required answer is EXIT 0. CONTROL-C earned its place by failing: it found the
# harness reporting a defect on every fresh install.
#
# CONTROL-A a PRE-DEV-02 flat store, latch tripped, onto a roster whose
# first entry IS `inverter-a`. This is the path SAFETY-44's
# documented reasoning describes, and on it that reasoning holds.
# CONTROL-B a store that has already run flat under 0.7.0 (schema stamped,
# no latch), onto a roster named `garage`/`shed`. This is the
# MAINT-05 half, and the names are deliberately not `inverter-a`.
# CONTROL-C a FRESH INSTALL - an empty state file - onto `garage`/`shed`.
# ⚠️ THIS ONE FOUND A DEFECT IN THE HARNESS. A fresh store's
# migration writes `sign_tripped = False` and `sign_reason = ""`
# under `inverter-a` before any roster exists, and the first draft
# reported both as STRANDED - red on every fresh install. Losing a
# DEFAULT loses nothing; that is `MigrationReport.lost`'s own
# distinction, and an alarm that fires on the harmless case is one
# an operator learns to dismiss before the real one arrives.
CONTROLS = {
"CONTROL-A": ("state-legacy.json", "roster-inverter-a.yaml"),
"CONTROL-B": ("state-flat-070.json", "roster.yaml"),
"CONTROL-C": ("state-fresh.json", "roster.yaml"),
}
def run(tree: Path, state: str, after: str, *extra) -> "tuple[int, str]":
"""The harness, as the owner runs it, over `tree`.
⚠️ ALWAYS WITH `--live-entities`. That file is the only part of the
entity-id comparison that does not move with the code, so a run without it
is a run that cannot see a renamed device.
"""
fixtures = tree / "tools" / "rehearsal"
proc = subprocess.run(
[sys.executable, str(tree / "tools" / "rehearse_roster.py"),
"--state", str(fixtures / state),
"--before", str(fixtures / "flat.yaml"),
"--after", str(fixtures / after),
"--live-entities", str(fixtures / "live-entities-recorded.txt"),
"--reference", str(fixtures / "announced-reference.txt"),
*extra],
capture_output=True, text=True,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
return proc.returncode, proc.stdout + proc.stderr
def mutant_tree(edits) -> str:
"""A private copy of the add-on with `edits` applied.
⚠️ EVERY EDIT IS ASSERTED TO HAVE MATCHED. Four defects in this session came
from `str.replace` calls whose pattern did not match: the call silently
returns the original string, the mutation never happens, and the harness
reports a SURVIVOR on code it never changed. That is a fabricated result.
"""
tmp = tempfile.mkdtemp(prefix="rehearse-mut-")
dest = Path(tmp) / "tree"
ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
(dest / "tools").mkdir(parents=True)
shutil.copytree(HERE / "app", dest / "app", ignore=ignore)
shutil.copytree(FIX, dest / "tools" / "rehearsal", ignore=ignore)
shutil.copyfile(HERE / "tools" / "rehearse_roster.py",
dest / "tools" / "rehearse_roster.py")
for rel, old, new in edits:
path = dest / rel
text = path.read_text(encoding="utf-8")
hits = text.count(old)
if hits != 1:
raise AssertionError(
f"mutation target appears {hits} times in {rel}, expected 1: "
f"{old!r} - the mutation did NOT happen and any result from it "
f"would be fabricated")
path.write_text(text.replace(old, new), encoding="utf-8")
return str(dest)
def legs(out: str) -> dict:
"""The report, split into its legs on the tool's `LEG <id>` markers.
⚠️ THIS FUNCTION IS THE FIX FOR A DEFECT IN THIS FILE, AND THE DEFECT IS
HABIT 4 ONE LEVEL UP. Part 5's check that the disarmed latch is "NOT THE
SAME-STEP PATH ALONE" was written as
`out.count("LATCH CAME UP DISARMED") >= 2`. The tool prints every finding
TWICE per red leg - once in that leg's own FINDINGS block and once in the
closing summary - so ONE red leg yields exactly 2 and the check passes.
Measured on three builds:
shipped code, both legs red count 4
fix at main.py:250 only -> STAGED alone red count 2 ok
partial guard fix on rep.ran -> SAME-STEP alone red count 2 ok
The check named "NOT THE SAME-STEP PATH ALONE" reported `ok` on the build
whose remaining failure IS the same-step path alone - and, worse, applying
the fix DOCS.md names as the cause left all five part-5 checks green. A
count over a whole report cannot distinguish one leg from two; only reading
the legs separately can.
⚠️ AND IT CLOSES ON `END LEG`, WHICH IS THE SAME DEFECT ONE LEVEL DOWN.
The first version of this splitter opened on `LEG <id>` and never closed,
so every line after the LAST marker went to that leg - including the
closing summary, which repeats every finding from every leg. The last leg
then read red whenever ANY leg was red. Measured on a build whose SAME-STEP
leg is genuinely clean: it still reported SAME-STEP disarmed. Part 5b is
the running control for exactly that.
"""
out_legs, current, closed = {}, None, set()
for line in out.splitlines():
if line.startswith(" LEG "):
# ⚠️ AND THIS ASSERTION IS THE ONE THAT KILLS THE CLASS. Review
# attacked part 5b directly and got through in three edits: delete
# `END LEG` (5b catches it), ALSO reorder the legs (5b goes blind -
# it only ever saw them disagree with the clean leg LAST), and ALSO
# reword the closing summary (the backstop is a transcription of the
# tool's own sentence, so rewording blinds that too). Result: "all
# checks passed" with a splitter that closed ZERO legs.
#
# Two of those three are ordinary refactors - reordering report legs
# and rewording a summary line - neither of which reads as touching
# a safety check. A fixture cannot cover every arrangement, so the
# STRUCTURE is asserted instead: a leg may not open while one is
# open, and every leg that opens must be explicitly closed. Under
# all three attacks nothing is ever closed, so this fires on the
# FIRST edit regardless of order or wording.
assert current is None, (
f"leg {line.strip()[4:].strip()!r} opened while {current!r} was "
"still open - the tool stopped closing its legs, and a splitter "
"that never closes swallows the closing summary into the last "
"leg. That is the defect this function exists to fix.")
current = line.strip()[4:].strip()
out_legs[current] = []
elif line.startswith(" END LEG "):
assert current is not None, "END LEG without an open leg"
closed.add(current)
current = None
elif current is not None:
out_legs[current].append(line)
assert current is None, f"leg {current!r} was never closed"
assert closed == set(out_legs), (
f"legs opened {sorted(out_legs)} but closed {sorted(closed)} - a leg "
"that is never closed absorbs everything after it")
return {k: "\n".join(v) for k, v in out_legs.items()}
for label, (state, after) in CONTROLS.items():
code, out = run(HERE, state, after)
check(f"{label}: the shipped code, {state} -> {after}, exits 0 with no "
f"findings", code == RR.EXIT_OK and "NO FINDINGS" in out)
# ⚠️ AND THE SOFTENING IS NOT A HOLE. The SAME key stranded with a NON-default
# value is still a finding - that is the whole of part 5 - so the two cases are
# separated by the VALUE and not by the key name.
_fresh_code, _fresh_out = run(HERE, "state-fresh.json", "roster.yaml")
# ⚠️ THE NAMESPACE IS READ OUT OF THE TOOL'S OWN REPORT, NOT SPELLED HERE. A
# hardcoded `unit/inverter-a/...` makes this check fail for the RIGHT reason on
# the shipped code and for the WRONG reason on a build that fixes the migration
# to use the roster's own name - it would look like the default-softening broke
# when what changed is the namespace. What this check is about is the VALUE.
_stranded_default = set(re.findall(
r"^\s+stranded\s+(unit/\S+/sign_tripped) = False$", _fresh_out, re.M))
check("a fresh install DOES strand a `.../sign_tripped` key - it is written "
"and nothing on the roster reads it - and the report SAYS SO under its "
f"own heading (found: {sorted(_stranded_default)})",
_fresh_code == RR.EXIT_OK and len(_stranded_default) == 1)
check("...and does NOT call it a finding, because the value is the DEFAULT; "
"the same key at True is the finding part 5 turns on, so the two cases "
"are separated by the VALUE and not by the key name",
not any(f"STRANDED STORE KEY: {k}" in _fresh_out
for k in _stranded_default)
and "STRANDED STORE KEY: unit/inverter-a/sign_tripped = True"
in run(HERE, "state-legacy.json", "roster.yaml")[1])
# --------------------------------------------------------------------------- #
print()
print("2b. ⚠️ OMITTING THE INSTALL'S ENTITY LIST IS A REFUSAL, NOT A QUIETER "
"RUN")
# --------------------------------------------------------------------------- #
# ⚠️ THIS PART EXISTS BECAUSE THE ASYMMETRY WAS BACKWARDS AND IT WAS MEASURED.
# A `--live-entities` file that could not be READ refused with exit 2; the flag
# simply not being PASSED produced "NO FINDINGS" and exit 0 - and
# `grep -ci "live-entities|recorded list|composition"` over that report returned
# ZERO, so nothing in the artifact an owner reads said half the check had not
# run. Measured on the two entity mutations at the time:
#
# M5 device rename with the flag: exit 1 without: exit 0
# M6 sensor rename with the flag: exit 1 without: exit 0
#
# The owner runs this ONCE, at commissioning, under time pressure. A forgotten
# flag must not hand back a clean bill of health for the half of the transition
# that orphans entities.
def raw(*args):
"""`(exit code, THE REPORT, the log)` - and the split matters.
⚠️ THE REPORT IS STDOUT AND THE CONTROLLER'S LOGGING IS STDERR. Grading
"is the degraded banner near the end of the report" over the two
CONCATENATED reads the last lines of the LOG - `SAFETY-02 was TRIPPED...`,
`unparseable timestamp...` - and reports a missing banner that is in fact
printed. That is not a hypothetical; it is how this check first failed.
"""
fx = HERE / "tools" / "rehearsal"
proc = subprocess.run(
[sys.executable, str(HERE / "tools" / "rehearse_roster.py"),
"--state", str(fx / "state-legacy.json"),
"--before", str(fx / "flat.yaml"),
"--after", str(fx / "roster-inverter-a.yaml"), *args],
capture_output=True, text=True,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"})
return proc.returncode, proc.stdout, proc.stderr
_no_code, _no_out, _no_err = raw()
check("omitting --live-entities REFUSES with exit 2 rather than reporting a "
"clean transition it did not check",
_no_code == RR.EXIT_COULD_NOT_RUN and "REFUSED" in _no_out + _no_err)
_deg_code, _deg_out, _ = raw("--no-live-entities")
check("...and the explicit opt-out RUNS, so the escape hatch exists",
_deg_code in (RR.EXIT_OK, RR.EXIT_FINDING))
check("⚠️ ...carrying a standing banner in the report HEADER and again in "
"the closing block, because a report is skimmed from the bottom",
_deg_out.count("DEGRADED RUN") >= 2
and "DEGRADED RUN" in "\n".join(
_deg_out.strip().splitlines()[-4:]))
check("...and the degraded report is not silent about what it did not do - "
"the words are in it, which is the grep that returned 0 before",
sum(w in _deg_out.lower() for w in
("live-entities", "orphan check", "not graded")) == 3)
check("contradictory flags are refused rather than one silently winning",
raw("--no-live-entities", "--live-entities",
str(FIX / "live-entities-recorded.txt"))[0]
== RR.EXIT_COULD_NOT_RUN)
check("...and the banner really is at the END of the report, which is the "
"half of that claim a concatenated stdout+stderr could not have graded",
"DEGRADED RUN" in "\n".join(_deg_out.strip().splitlines()[-4:]))
# ⚠️ AND THE COMPOSITION HALF IS NOT BEHIND THE FLAG AT ALL ANY MORE. The
# reference is loaded on EVERY run, so a degraded run still catches a renamed
# device - which is what makes `--no-live-entities` a genuinely bounded
# concession rather than "half the tool off".
_ref_missing = raw("--no-live-entities", "--reference", "/nonexistent.txt")
check("a missing or malformed reference is exit 2 on every run, because it is "
"the only reference in the tool that does not move with the code",
_ref_missing[0] == RR.EXIT_COULD_NOT_RUN)
# ⚠️ AND THE VANISHED CHECK CARRIES THE SAME DEFAULT/NON-DEFAULT RULE, BOUNDED
# THE SAME WAY. `M9` below drops a NON-default key and must still be a finding;
# without it, "vanished at the default is not a finding" could widen into
# "vanished is not a finding" and nobody would see it.
_m9 = mutant_tree([("app/store.py",
" value = data.get(legacy, DEFAULTS.get(legacy))\n"
" store.set(target, value)",
" value = data.get(legacy, DEFAULTS.get(legacy))\n"
" store.data.pop(legacy, None)\n"
" store.set(target, value)")])
_m9_code, _m9_out = run(Path(_m9), "state-legacy.json", "roster-inverter-a.yaml")
_m9_vanished = set(re.findall(r"VANISHED STORE KEY: (\S+) =", _m9_out))
print(f" deleting the flat key as it migrates -> exit {_m9_code}, "
f"vanished {sorted(_m9_vanished)}")
check("⭐ a NON-default key that vanishes is STILL a finding - a migration "
"made to DELETE the flat rollback view store.py rule 2 requires it to "
"leave in place is caught, so the default-softening above is bounded by "
"its own positive",
_m9_code == RR.EXIT_FINDING
and {"last_completed", "phase_started", "last_start_attempt"}
<= _m9_vanished)
check("...and `phase` is NOT among them, because its value 'idle' IS the "
"default - the same mutation, the same file, and the rule splitting the "
"two on the VALUE rather than on the key",
"phase" not in _m9_vanished)
shutil.rmtree(Path(_m9).parent, ignore_errors=True)
# ⚠️ AND THE INPUT FILE IS NEVER WRITTEN. The owner runs this against a copy of
# a live state file; a tool that edited it would be a tool that had to be
# trusted rather than one that cannot do harm.
_before_bytes = (FIX / "state-legacy.json").read_bytes()
run(HERE, "state-legacy.json", "roster.yaml", "--reverse", "--round-trip")
check("the state file the harness was pointed at is BYTE-IDENTICAL afterwards, "
"including after the reverse and round-trip legs, which write",
(FIX / "state-legacy.json").read_bytes() == _before_bytes)
# --------------------------------------------------------------------------- #
print()
print("3. ⭐ THE MUTATIONS: each one breaks the shipped migration or the "
"entity-id composition, and each must turn its control RED")
# --------------------------------------------------------------------------- #
MUTATIONS = [
# (id, control, what it breaks, edits, the finding it must produce)
("M1", "CONTROL-B",
"the maintenance migration is handed the hardcoded `UNIT_A` instead of "
"the roster's own first name - MAINT-05's exact shipped regression, which "
"left all 19 suites green",
[("app/main.py",
"store, self.inverters[0].name, [inv.name for inv in self.inverters])",
"store, UNIT_A, [inv.name for inv in self.inverters])")],
"MAINTENANCE HISTORY LOST"),
("M2", "CONTROL-B",
"the maintenance namespace is DROPPED: the migration moves no keys at all, "
"so a month of history stays under a flat spelling the new packs do not "
"read",
[("app/store.py",
"_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)",
"_move_flat_keys(store, unit, (), rep)")],
"MAINTENANCE HISTORY LOST"),
("M3", "CONTROL-A",
"the SAFETY-02 pair loses HALF of itself: `sign_reason` drops out of "
"`LEGACY_UNIT_KEYS`, so the latch migrates without the sentence that says "
"why it tripped",
[("app/store.py",
'LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")',
'LEGACY_UNIT_KEYS = ("sign_tripped",)')],
"LATCH KEY NOT UNDER A ROSTER NAME"),
("M4", "CONTROL-A",
"the READER's key composition changes while the migration's does not - "
"`Inverter.key()` upper-cases the unit - so the latch is written "
"perfectly, read back perfectly by the migration, and looked for under a "
"name that is not there",
[("app/inverter.py",
"return unit_key(self.name, name)",
"return unit_key(self.name.upper(), name)")],
"LATCH CAME UP DISARMED"),
("M5", "CONTROL-A",
"the entity-id composition changes on the DEVICE half: the MQTT device is "
"renamed, which is the 'tidy it up' change `app/mqtt.py` spends thirty "
"lines in capitals refusing. ⚠️ THE BEFORE/AFTER DIFF IS BLIND TO THIS - "
"both sides are composed by the mutant, so every id moves equally and "
"nothing looks orphaned. Only the recorded list catches it",
[("app/mqtt.py", '"name": "GoodWe",', '"name": "GoodWe Controller",')],
"COMPOSITION DRIFT"),
("M6", "CONTROL-A",
"the entity-id composition changes on the ENTITY half: one site sensor is "
"renamed, which orphans exactly one id and leaves the other seven alone",
[("app/mqtt.py",
'("grid", "goodwe_grid_power", "Grid power", "W", "power", "measurement", None),',
'("grid", "goodwe_grid_power", "Grid power W", "W", "power", "measurement", None),')],
"COMPOSITION DRIFT: this build announces sensor.goodwe_grid_power_w"),
# ⚠️ M7 AND M8 ARE THE TWO CHANGES THAT ORPHAN AN INSTALL WITHOUT MOVING A
# SINGLE COMPOSED entity_id, so an entity_id-only reference cannot see them.
# Both came back exit 0 before the reference carried three columns.
("M7", "CONTROL-A",
"the `unique_id` composition changes and NOT the entity_id: `mqtt.BASE` "
"is renamed, so every announced unique_id moves while every composed "
"entity_id stays byte-identical. HA's MQTT entity registry keys on "
"unique_id, so this is a NEW entity beside a dead one - and discovery is "
"retained with no removal payload, so the dead one stays",
[("app/mqtt.py", 'BASE = "goodwe_ctl"', 'BASE = "goodwe_controller"')],
"UNIQUE_ID DRIFT"),
("M8", "CONTROL-A",
"the DEVICE IDENTIFIER changes: HA's device registry keys on it, so the "
"whole install moves onto a new device and the old one keeps its dead "
"entities. Again no composed entity_id moves",
[("app/mqtt.py", '"identifiers": ["goodwe_rs485_controller"],',
'"identifiers": ["goodwe_rs485_ctl_v2"],')],
"DEVICE IDENTIFIER DRIFT"),
]
for mid, control, what, edits, must_say in MUTATIONS:
state, after = CONTROLS[control]
tree = mutant_tree(edits)
code, out = run(Path(tree), state, after)
red = code == RR.EXIT_FINDING
named = must_say in out
print()
print(f" --- {mid} ({control}) {what}")
print(f" exit {code} (0=clean 1=finding 2=could-not-run)")
# ⚠️ THE FINDINGS, DE-DUPLICATED AND IN ORDER. The tool prints each finding
# once per transition leg and once more in its summary, and its own header
# is full of ⚠️ paragraphs - printing every line that starts with one would
# bury the evidence this part exists to show under the tool's docstring.
seen = []
for line in out.splitlines():
text = line.strip()
# ⚠️ `_` IS IN THE CLASS. Without it `UNIQUE_ID DRIFT:` does not match
# its own finding kind, and M7 - the mutation that renames `mqtt.BASE`
# - printed its verdict with NO evidence under it. A filter that
# silently drops the very finding the mutation exists to produce is a
# small version of the same defect this file is about.
if text.startswith("⚠️ ") and re.match(r"⚠️ [A-Z][A-Z _-]+[A-Z()]:",
text):
if text not in seen:
seen.append(text)
for text in seen:
print(f" {text[:160]}")
check(f"{mid} turns {control} RED", red)
check(f"{mid} says WHY, and it is the right why: {must_say!r}", named)
shutil.rmtree(Path(tree).parent, ignore_errors=True)
# --------------------------------------------------------------------------- #
print()
print("4. THE MUTATION THAT SURVIVES, ON PURPOSE, WITH THE REASON WRITTEN DOWN")
# --------------------------------------------------------------------------- #
# ⚠️ A BATTERY IN WHICH EVERYTHING DIES IS A BATTERY SOMEBODY STOPPED WRITING
# AS SOON AS THE NUMBER LOOKED GOOD. `store.unit_key()` is the ONE spelling of
# the namespaced key, read by `Inverter.key()`, by `UnitHistory.key()` and by
# both migrations - that is store.py's stated design, "one spelling, in one
# place". Changing it therefore moves the writer AND every reader together, and
# the store comes out internally consistent under a different name.
#
# The harness is BLIND to it and cannot be otherwise: nothing inside one version
# of this add-on can see that the key shape stopped matching what an OLDER
# version reads. That is a ROLLBACK contract, and grading it needs two versions
# of the code, which is a different instrument (`tools/predeploy_check.py`'s
# territory). Stated here rather than left as an unexplained green.
_tree = mutant_tree([("app/store.py",
'return f"unit/{unit}/{key}"',
'return f"ns.{unit}.{key}"')])
_code, _out = run(Path(_tree), *CONTROLS["CONTROL-A"])
print(f" renaming the ONE key spelling -> exit {_code}")
check("renaming `unit_key()` survives, because writer and readers move "
"together - a fact about the design, recorded, not a gap being hidden",
_code == RR.EXIT_OK)
check("...and the store really did change shape underneath it, so this is a "
"survivor of a mutation that HAPPENED rather than of one that did not",
"ns.inverter-a.sign_tripped" in _out and "unit/inverter-a/" not in _out)
shutil.rmtree(Path(_tree).parent, ignore_errors=True)
# ⚠️ THE SECOND DECLARED SURVIVOR, AND IT IS THE ONE THAT BOUNDS M5-M8. Four
# mutations of `app/mqtt.py` are now required to go red; without a mutation of
# the same file that must NOT, "the reference notices any edit to mqtt.py"
# would be indistinguishable from "the reference notices what HA keys on".
# `object_id` is the case: `app/mqtt.py` lines 36-42 and 61-76 record that HA
# 2026.8 does NOT honour it in a discovery payload - the entity_id is composed
# from device name plus entity name - so renaming one changes nothing an
# install can see, and a tool that refused it would refuse a free change.
_tree2 = mutant_tree([("app/mqtt.py",
'("grid", "goodwe_grid_power", "Grid power",',
'("grid", "goodwe_grid_pwr", "Grid power",')])
_code2, _out2 = run(Path(_tree2), *CONTROLS["CONTROL-A"])
print(f" renaming an object_id (HA does not honour it) -> exit {_code2}")
check("renaming an `object_id` survives, because HA composes the entity_id "
"from the device and entity NAMES and ignores it - so the four mqtt.py "
"mutations above are red for what HA keys on, not for being edits to "
"mqtt.py",
_code2 == RR.EXIT_OK)
shutil.rmtree(Path(_tree2).parent, ignore_errors=True)
# --------------------------------------------------------------------------- #
print()
print("5. SAFETY-44: a pre-DEV-02 flat store meeting a DECLARED ROSTER, which "
"is the path nobody had established either way")
# --------------------------------------------------------------------------- #
# The question on the card: `migrate_unit_keys(store, UNIT_A)` takes a hardcoded
# constant and has no `misdirected` guard, while `migrate_maint_keys(store,
# unit, roster)` takes the roster and does. Is that asymmetry safe?
#
# ⚠️ MEASURED, NOT ARGUED. The two checks below are the answer, and they are
# written as REQUIREMENTS so that a fix to `migrate_unit_keys` turns them red
# and this file has to be updated deliberately rather than drifting.
code44, out44 = run(HERE, "state-legacy.json", "roster.yaml")
print(f" flat store + roster ['garage','shed'] -> exit {code44}")
check("⚠️ SAFETY-44 IS A DEFECT AGAIN: a store that ran pre-DEV-02 flat and "
"declares a multi-inverter roster comes up with its SAFETY-02 latch "
"DISARMED - the file says `sign_tripped: true` and not one inverter is "
"latched",
code44 == RR.EXIT_FINDING and "LATCH CAME UP DISARMED" in out44)
check("...and the harness names the two latch keys SAFETY-44 asks about, by "
"the namespace they were stranded in",
"unit/inverter-a/sign_tripped" in out44
and "unit/inverter-a/sign_reason" in out44)
check("...and the mechanism is visible in the report: the keys are stranded "
"under `inverter-a`, which is not on the roster, so no machine reads them",
"STRANDED STORE KEY" in out44 and "['garage', 'shed']" in out44)
# ⚠️ PER LEG, NOT A COUNT OVER THE WHOLE REPORT. See `legs()` for the three
# builds this distinction was measured on and for what the count missed.
_L = legs(out44)
print(f" legs in the report: {sorted(_L)}")
for _leg in ("STAGED", "SAME-STEP"):
print(f" LEG {_leg}: "
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _L.get(_leg, '') else 'clean'}")
check("the report is split into legs this check can grade SEPARATELY - the "
"tool prints a `LEG <id>` marker, because counting a finding's text over "
"the whole report cannot tell one red leg from two",
set(_L) == {"STAGED", "SAME-STEP"})
check("⚠️ SAME-STEP is disarmed: the roster is declared in the SAME step as "
"the upgrade, the migration runs and misdirects the latch to "
"`inverter-a`",
"LATCH CAME UP DISARMED" in _L.get("SAME-STEP", ""))
check("⚠️ AND IT IS NOT THE SAME-STEP PATH ALONE - the STAGED leg reaches the "
"same disarmed latch by a DIFFERENT route: the flat start stamps "
"`schema_version`, so the roster start's migration takes its early "
"return and the latch never moves at all",
"LATCH CAME UP DISARMED" in _L.get("STAGED", ""))
# ⚠️ AND THE CONTRAST, WHICH IS WHAT STOPS THE CHECK ABOVE BEING VACUOUS. The
# SAME state file onto a roster whose first entry IS `inverter-a` comes up
# LATCHED. The two runs differ in one thing - the first roster name - so the
# finding is attributable to it and to nothing else.
code_ok, out_ok = run(HERE, "state-legacy.json", "roster-inverter-a.yaml")
check("...and no leg's text has swallowed the CLOSING SUMMARY, which repeats "
"every finding from every leg - the failure that made the last leg read "
"red whenever any leg was",
not any("FINDING(S) - this transition is not clean" in v
for v in _L.values()))
# --------------------------------------------------------------------------- #
print()
print("5b. THE SPLITTER'S POSITIVE CONTROL: a build in which exactly ONE leg "
"is clean")
# --------------------------------------------------------------------------- #
# ⚠️ A PER-LEG CHECK THAT HAS NEVER SEEN THE LEGS DISAGREE IS A COUNT WITH
# EXTRA STEPS. Every fixture above has both legs red, so nothing so far
# separates "reads each leg" from "reads the whole report twice". This part
# builds the one case that does: `migrate_unit_keys()` handed the roster's own
# first name instead of the hardcoded `UNIT_A`.
#
# ⚠️ AND WHAT IT MEASURES IS ALSO THE ANSWER TO "IS THAT THE FIX". It is NOT.
# It repairs the SAME-STEP path only. The STAGED path is untouched, because
# there the flat start stamps `schema_version = 1` and the roster start's
# migration takes its `from_version >= SCHEMA_VERSION` early return before the
# name it would be handed matters at all. A fix that stops at this line would
# close the half an operator is least likely to take and leave the half that
# "upgrade first, declare the roster later" produces.
_one_leg = mutant_tree([(
"app/main.py",
" self.store_migration = migrate_unit_keys(store, UNIT_A)",
" self.store_migration = migrate_unit_keys(store, "
"roster_names(opts)[0])")])
_ol_code, _ol_out = run(Path(_one_leg), "state-legacy.json", "roster.yaml")
_OL = legs(_ol_out)
for _leg in ("STAGED", "SAME-STEP"):
print(f" LEG {_leg}: "
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _OL.get(_leg, '') else 'clean'}")
check("⭐ with `migrate_unit_keys()` handed the roster's own first name, the "
"SAME-STEP leg comes up CLEAN - so the splitter really does read the "
"legs separately, and a check that only counted would have missed this",
"LATCH CAME UP DISARMED" not in _OL["SAME-STEP"])
check("⚠️ ...while the STAGED leg is STILL DISARMED on the same build: that "
"line is not the whole fix, because the staged path never reaches it - "
"the first start stamps `schema_version` and the second start's "
"migration returns early",
"LATCH CAME UP DISARMED" in _OL["STAGED"])
check("...and the run as a whole is still a finding, so nobody reads this "
"partial fix as done",
_ol_code == RR.EXIT_FINDING)
shutil.rmtree(Path(_one_leg).parent, ignore_errors=True)
print()
print("5c. back to the shipped code")
check("the SAME state file onto a roster whose first entry IS `inverter-a` "
"comes up latched and clean - so the finding above is caused by the "
"roster NAME and not by the fixture being tripped",
code_ok == RR.EXIT_OK and "LATCH CAME UP DISARMED" not in out_ok)
# --------------------------------------------------------------------------- #
print()
print("6. THE REVERSE DIRECTION: the retreat, and the return from it")
# --------------------------------------------------------------------------- #
code_r, out_r = run(HERE, "state-flat-070.json", "roster.yaml",
"--reverse", "--round-trip")
_RL = legs(out_r)
print(f" legs in the reverse run: {sorted(_RL)}")
_orphaned = set(re.findall(r"ORPHANED ENTITY: (sensor\.\S+)",
_RL.get("RETREAT", "")))
print(f" the RETREAT leg orphans: {sorted(_orphaned)}")
check("the RETREAT is rehearsed as its own leg, and THAT LEG reports the "
"entities it orphans - the eight per-unit rows, which is the cost of "
"backing out",
{"RETREAT", "ROUND-TRIP"} <= set(_RL) and _orphaned == {
f"sensor.goodwe_{u}_{k}"
for u in ("garage", "shed")
for k in ("setpoint", "battery_power", "battery_soc", "sign_latch")})
check("...and NOT one site entity: the eight ids the install has today survive "
"the retreat, which is what makes the eight above attributable to the "
"per-unit rows and not to a blanket rename",
not any(o in _orphaned for o in
("sensor.goodwe_grid_power", "sensor.goodwe_setpoint",
"sensor.goodwe_battery_power", "sensor.goodwe_p1_sample_age")))
check("...and the first pack's maintenance history SURVIVES the retreat, "
"because `UnitHistory.mirror_flat` keeps the flat keys live - so the "
"retreat itself does not lose state. ⚠️ Read from the RETREAT LEG "
"alone, so the round trip's own loss cannot be mistaken for it",
"MAINTENANCE HISTORY LOST" not in _RL["RETREAT"])
check("⚠️ ...but the ROUND TRIP does not: a maintenance cycle completed while "
"the install is rolled back is DISCARDED when it re-advances, because "
"`maint_schema_version` is already stamped and the migration skips",
"RETREAT-WINDOW WORK DISCARDED" in _RL["ROUND-TRIP"])
check("...and that is a leg whose data can reach the failure it names: the "
"retreat window actually contains a completion, written through the "
"shipped `UnitHistory`, at a timestamp the starting fixture does not have",
RR.RETREAT_WINDOW_COMPLETION in out_r
and RR.RETREAT_WINDOW_COMPLETION
not in (FIX / "state-flat-070.json").read_text())
print()
if fails:
print(f"FAILED {len(fails)}:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+185 -10
View File
@@ -722,18 +722,41 @@ print(f" e.g. {_sustained.reason!r}")
print()
print(" ⚠️ AND NOT ON THE TRANSIENT, WHICH IS THE FALSE-ALARM CASE")
# ⚠️ THE SHAPES BELOW ARE THE RIG'S, NOT INVENTED. Measured on TEST-19 over 924
# closed-loop runs in which every unit executed the share it was given: the
# worst continuous opposition above 700 W is 5.0 s - ONE control cycle - at a
# peak of 1700 W. That is the serialised write path: on a reversing cycle B's
# new number lands up to a whole cycle after A's, both slew through zero at
# 800 W/s behind a 10 s lag, and between the two crossings they genuinely
# oppose. A guard that trips on that is a false-alarm generator, and this
# project's doctrine on crying wolf is explicit (SAFETY-15, SAFETY-19).
# THE SHAPE BELOW IS THE RIG'S: the serialised write path means that on a
# reversing cycle B's new number lands up to a whole control cycle after A's,
# both slew through zero behind a real ramp and a real lag, and between the two
# crossings they genuinely oppose. A guard that trips on that is a false-alarm
# generator, and this project's doctrine on crying wolf is explicit (SAFETY-15,
# SAFETY-19).
#
# ⚠️ THE MAGNITUDE IS A FIXTURE INPUT, NOT A MEASUREMENT, AND THIS COMMENT USED
# TO SAY OTHERWISE. It read "the worst single transient the rig can produce -
# 1700 W", sourced to a 924-run campaign whose harness IS NOT COMMITTED IN
# EITHER REPOSITORY (DECISIONS/SAFETY-31-circulating-power.md §3 says so in its
# own words). Three sweeps have now been run against that claim and no two agree:
#
# 924-run campaign, harness uncommitted peak 1700 W, 505.0 s at 100 W
# independent reviewer sweep, 2026-09-07 peak 2750 W, 15.0 s above 700 W
# goodwe `two_master_circulation`, 840 runs peak 1048 W, 5.0 s above 700 W
#
# ⚠️ THAT IS NOT EVIDENCE THAT ANY OF THEM IS WRONG. The three swept different
# grids, and the last one measured WHY the spread is so wide: on its own grid the
# worst legitimate peak is 1048 W on the modelled plant, 2250 W on `INSTANT`, and
# 0 W on `INSTANT` with no write stagger reaching a whole cycle. The plant model
# and the stagger set are TERMS of the number, and a worst case quoted without
# them is not a measurement. It is evidence that the claim cannot be adjudicated
# while the instrument is missing.
#
# So what this check now says is what it can support: 1700 W is LARGER than
# anything any of the three sweeps produced legitimately, and one control cycle
# of it does not trip. The committed, re-runnable version of the claim is
# `admin/goodwe`'s `sim/inject.py two_master_circulation`, which runs on every
# pull request there and requires ZERO of ~840 non-faulty runs to trip.
_one_cycle = [0.0] * 5 + [1700.0] + [0.0] * 30
_t1, _ = circ_drive(_one_cycle)
check("the worst single transient the rig can produce - 1700 W for ONE control "
"cycle - does not trip", not _t1.tripped)
check("a single transient of 1700 W - larger than the worst peak any committed "
"sweep of the legitimate family has produced - lasting ONE control cycle "
"does not trip", not _t1.tripped)
_repeated = ([0.0] * 3 + [1700.0]) * 20
_t2, _ = circ_drive(_repeated)
check("...and neither does it REPEATED every fourth cycle for 400 s, which is "
@@ -1091,6 +1114,158 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
"this' a different question from 'is this site tripped'",
len(_notes2) == 1)
print()
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
# ⚠️ THIS BLOCK EXISTS BECAUSE A MUTATION SURVIVED ALL 26 FILES. Replacing
#
# pos, neg = opposing([inv.batt for inv in self.inverters],
# min_w=self.circ_wd.cfg.min_w / 2.0)
#
# in `check_circulating()` with `pos, neg = ([], [])` (-102 B) silently deletes
# the "Right now X is discharging while Y charges" clause from the notification,
# and the whole suite stayed GREEN. The block above asserts three tokens -
# METER CANNOT SEE, CONTROL HAS BEEN STOPPED, isolator - and NEVER an inverter
# name, so the one sentence `circulation.py` calls "the first thing somebody
# woken by this alarm needs" and "impossible to reconstruct from the number
# alone" was graded by nothing at all.
#
# ⚠️ IT IS NOT DEAD CODE, WHICH IS THE PART THAT MAKES IT A CHECK GAP RATHER
# THAN A REACHABILITY ONE. Ledger M18 - the `await self.check_circulating()`
# call removed - goes red on four checks above, so this function runs. What
# nothing asserted was the ATTRIBUTION.
#
# ⚠️ AND THE ROSTER IS ["garage", "shed", "cellar"], WHICH IS THE OTHER HALF OF
# THE FIX. Every fixture above runs on `inverter-a`/`inverter-b`;
# test_maintenance.py section 11 records what that costs - on a fixture whose
# names are the module's own constants, a check cannot tell the roster from the
# constant, and `UNIT_A` is not even what a nameless entry falls back to. Here
# the names are an operator's, the two participants pull OPPOSITE ways so the
# sentence is not symmetric, and a third pack rests at 5 W. That makes three
# DIFFERENT mutations - the clause dropped, the two directions swapped, and the
# `min_w` floor dropped - into three DIFFERENT red checks. A fixture naming both
# units the same, or one in which the direction did not matter, would see the
# first and neither of the others.
_CIRC_ROSTER = [
dict(name="garage", setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
batt_entity="sensor.batt_a", max_w=5000),
# ⚠️ B_RATING_HYPOTHESIS, and part 4's note above says why it is not 4600:
# inverter B does not physically exist and a datasheet is not a measurement.
dict(name="shed", setpoint_entity="number.sp_b", soc_entity="sensor.soc_b",
batt_entity="sensor.batt_b", max_w=B_RATING_HYPOTHESIS),
# THE BYSTANDER. A third pack resting at standby draw, on the roster, read
# every cycle, and NOT a participant in anybody's loop.
dict(name="cellar", setpoint_entity="number.sp_c", soc_entity="sensor.soc_c",
batt_entity="sensor.batt_c", max_w=B_RATING_HYPOTHESIS),
]
_CIRC_NAMES = [e["name"] for e in _CIRC_ROSTER]
_REST_W = 5.0
class _NamedCircHass(_Hass):
"""Three named packs; the meter still reads exactly zero.
Same house as `_CircHass` - grid = load - sum(B) with two units cancelling -
with one extra pack idling at standby draw so that "who is named" and "who
is merely on the roster" are different questions.
"""
def __init__(self, a_w, b_w, c_w=_REST_W):
super().__init__()
self.vals = {"sensor.batt_a": a_w, "sensor.batt_b": b_w,
"sensor.batt_c": c_w}
async def number(self, entity, invert=False):
return {"sensor.grid": 0.0, "sensor.soc_a": 50.0,
"sensor.soc_b": 50.0, "sensor.soc_c": 50.0,
**self.vals}.get(entity)
def _named_trio(hass, store, *, hold_s=1, min_w=700):
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
"max_w": 5000, "gain": 0.6, "auto_start": True,
"circulation_w": min_w, "circulation_hold_s": hold_s,
"inverters": _CIRC_ROSTER}
c = Controller(opts, hass, store, _P(), stop=asyncio.Event())
c.auto = True
for inv in c.inverters:
inv.dev_min, inv.dev_max = -5000.0, 5000.0
return c
async def _circ_named(a_w, b_w):
hass = _NamedCircHass(a_w, b_w)
c = _named_trio(hass, _MemStore())
for _ in range(14):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.25)
notes = [x for x in hass.calls
if x[0] == "persistent_notification"
and x[2].get("notification_id") == "goodwe_circulating_power"]
return c, notes
_cg, _notes_g = asyncio.run(_circ_named(+3000.0, -3000.0))
_cs, _notes_s = asyncio.run(_circ_named(-3000.0, +3000.0))
_msg_g = _notes_g[0][2]["message"] if _notes_g else ""
_msg_s = _notes_s[0][2]["message"] if _notes_s else ""
print(f" roster : {_cg.units}")
print(f" garage discharging, measured: "
f"{dict(zip(_cg.units, [i.batt for i in _cg.inverters]))}")
print(f" -> {_msg_g.split(chr(10))[2] if _msg_g.count(chr(10)) > 2 else _msg_g!r}")
print(f" shed discharging, measured: "
f"{dict(zip(_cs.units, [i.batt for i in _cs.inverters]))}")
print(f" -> {_msg_s.split(chr(10))[2] if _msg_s.count(chr(10)) > 2 else _msg_s!r}")
# --- THE PRECONDITIONS, AS THEIR OWN CHECKS (MUTATION.md rule 1) ------------
check("PRECONDITION: the roster carries three DISTINCT operator-chosen names, "
f"none of them `UNIT_A` ({UNIT_A!r}) - so every claim below is a claim "
"about the roster and not about a constant that happens to equal it",
_cg.units == tuple(_CIRC_NAMES) and len(set(_CIRC_NAMES)) == 3
and UNIT_A not in _CIRC_NAMES)
check("PRECONDITION: the two runs are MIRROR IMAGES - same house, same meter at "
"0 W, same magnitudes, and the two participants swapped. Without this the "
"direction claim below could be satisfied by a sentence that ignores the "
"measurement entirely",
[i.batt for i in _cg.inverters] == [+3000.0, -3000.0, _REST_W]
and [i.batt for i in _cs.inverters] == [-3000.0, +3000.0, _REST_W]
and _cg.grid == 0.0 and _cs.grid == 0.0)
check("PRECONDITION: both runs actually tripped and actually raised the card, "
"so an absent sentence below is an absent sentence and not an absent alarm",
_cg.circ_wd.tripped and _cs.circ_wd.tripped
and len(_notes_g) == 1 and len(_notes_s) == 1)
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
_WHO_G = "Right now garage is discharging while shed charges."
_WHO_S = "Right now shed is discharging while garage charges."
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
"needs and impossible to reconstruct from the watts alone, and until this "
"line the sentence could be deleted with all 26 files green",
_WHO_G in _msg_g)
check("⚠️ AND WITH THE TWO SWAPPED, THE SENTENCE SWAPS. This is the half a "
"single fixture cannot have: `pos` and `neg` exchanged, or a hard-coded "
"sentence, satisfies the check above and fails this one. The operator is "
"being told which pack to walk to",
_WHO_S in _msg_s)
check("...and each run carries ONLY its own sentence - the mirrored alarm does "
"not also contain the other direction, so the clause is derived from this "
"cycle's measurement rather than assembled from the roster",
_WHO_S not in _msg_g and _WHO_G not in _msg_s)
check("...and the RESTING pack is not named. `cellar` is on the roster, is read "
"every cycle and is measured at 5 W of standby draw - `opposing()` is "
"called with a floor of half the trip threshold precisely so a pack idling "
"beside a 6 kW loop is not reported as one of its two participants",
"cellar" not in _msg_g and "cellar" not in _msg_s
and _cg.inverters[2].batt == _REST_W)
check("...and the sentence sits with the figure, not instead of it: the same "
"card still carries the watts, the window and the three things only this "
"alarm can say",
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
for m in (_msg_g, _msg_s)))
print()
print(" the operator's press is the only way out, and it clears BOTH halves")
@@ -0,0 +1,48 @@
# WHAT THIS BUILD IS SUPPOSED TO ANNOUNCE, recorded so that a change to the
# composition has something to disagree with. THREE COLUMNS:
#
# <entity_id> <unique_id> <device identifier>
#
# ⚠️ ALL THREE, AND THE SECOND AND THIRD ARE THE HALF THE FIRST DRAFT MISSED.
# A reference that carries only entity_ids cannot see the two changes that
# orphan an install without changing a single composed id:
#
# `BASE` renamed (`goodwe_ctl` -> anything) every unique_id changes
# `DEVICE["identifiers"]` changed HA sees a NEW device
#
# Home Assistant's MQTT integration keys its entity registry on `unique_id` and
# its device registry on the device identifier. Change either and HA creates a
# NEW entity beside the old one - and because discovery is published with
# `retain=True` and nothing publishes a removal payload, the OLD config stays on
# the broker and the dead entity stays in the registry, still named by every
# automation that referenced it, never updating again. The composed entity_id is
# identical on both, so an entity_id-only comparison reports a clean transition.
# That is the same shape as the habit-4 this file's own card already caught
# once: a reference that does not carry the thing that moved.
#
# ⚠️ AND `object_id` IS DELIBERATELY NOT A COLUMN. `app/mqtt.py` lines 36-42 and
# 61-76 record that HA 2026.8 does NOT honour `object_id` in a discovery
# payload - the id is composed from device name plus entity name - so renaming
# one is a true no-op and a column for it would make this file refuse a change
# that costs nothing. The check is graded on what HA actually keys on.
#
# PROVENANCE, stated exactly:
# - recorded 2026-09-08 from `release/1.0` at `meter_source: firmware`,
# roster of one, by reading `mqtt.discovery_payloads(units=[])`. It is what
# the code announces.
# - `sensor.goodwe_p1_sample_age` is additionally recorded in `app/mqtt.py` as
# VERIFIED AGAINST THE LIVE SYSTEM on 2026-08-26, and the ESP32's ingest
# guard subscribes to that exact string.
# - ⚠️ IT IS NOT A DUMP OF THE LIVE INSTALL'S ENTITY REGISTRY. That is what
# `--live-entities` is for. This file is about THE BUILD.
#
# Editing this file is how you say "the announcement changed on purpose". Do not
# edit it to make a check pass.
sensor.goodwe_setpoint goodwe_ctl_setpoint goodwe_rs485_controller
sensor.goodwe_grid_power goodwe_ctl_grid goodwe_rs485_controller
sensor.goodwe_battery_power goodwe_ctl_battery goodwe_rs485_controller
sensor.goodwe_battery_soc goodwe_ctl_soc goodwe_rs485_controller
sensor.goodwe_circulating_power goodwe_ctl_circulation goodwe_rs485_controller
sensor.goodwe_maintenance_phase goodwe_ctl_phase goodwe_rs485_controller
sensor.goodwe_controller_status goodwe_ctl_status goodwe_rs485_controller
sensor.goodwe_p1_sample_age goodwe_ctl_p1_age goodwe_rs485_controller
@@ -0,0 +1,22 @@
# ARCH-02 fixture: THE SHAPE THE LIVE BOX IS IN TODAY (a flat, single-inverter
# config). ⚠️ ILLUSTRATIVE ENTITY NAMES, NOT THE LIVE ONES - the owner runs the
# rehearsal against a copy of the real options blob, and this file exists so
# that the harness has a synthetic transition it can be graded on.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
inverters: []
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
@@ -0,0 +1,39 @@
# THE ENTITY IDS THE INSTALL HAS — a stand-in for the owner's real export.
#
# ⚠️ THIS FILE AND `announced-reference.txt` ANSWER DIFFERENT QUESTIONS AND ARE
# NOT INTERCHANGEABLE:
#
# announced-reference.txt what the BUILD announces. Loaded on EVERY run.
# Three columns, because HA keys on `unique_id` and
# on the device identifier as well as on the
# composed entity_id.
# this file (--live-entities)
# what the INSTALL has. One id per line. Orphans
# are graded against it: an id the install has and
# the AFTER shape does not announce is an orphan
# whatever the build's own history says.
#
# ⚠️ OMITTING `--live-entities` IS A REFUSAL (exit 2), NOT A QUIETER RUN. It
# used to be the other way round - an unreadable file refused, a missing flag
# passed with exit 0 and said nothing about the half it had not checked - and
# the owner runs this ONCE, at commissioning, under time pressure.
# `--no-live-entities` is the deliberate opt-out and it prints a standing
# banner in the report header and again on the exit line.
#
# PROVENANCE: these eight are what `release/1.0` announces at
# `meter_source: firmware`, roster of one, recorded 2026-09-08. They stand in
# for the export so the checks run in CI. ⚠️ THE REAL INSTALL CARRIES ROUGHLY 49
# `goodwe*` ENTITIES (card ARCH-02, read 2026-09-06); the other ~41 belong to
# the ESPHome firmware, to template sensors and to helpers, and this add-on
# announces none of them. The tool separates the ids it owns from the ids it
# does not rather than claiming orphans it cannot cause - which is the half of
# this file the eight-line stand-in cannot exercise, and the reason DOCS.md's
# owner step asks for the real export.
sensor.goodwe_battery_power
sensor.goodwe_battery_soc
sensor.goodwe_circulating_power
sensor.goodwe_controller_status
sensor.goodwe_grid_power
sensor.goodwe_maintenance_phase
sensor.goodwe_p1_sample_age
sensor.goodwe_setpoint
@@ -0,0 +1,42 @@
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
#
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
# every two-pack fixture in the suite named its first entry `inverter-a`, so
# `UNIT_A` and `inverters[0].name` were the same string in every check that
# existed and the defect was invisible. Names that are not `inverter-a` are what
# make a misdirected namespace something a rehearsal can SEE.
#
# `roster-inverter-a.yaml` beside this file is the same roster with the first
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
inverters:
- name: inverter-a
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
max_w: 5000
bms_soc_floor: 10
- name: inverter-b
setpoint_entity: number.goodwe_b_setpoint
soc_entity: sensor.goodwe_b_battery_soc_pct
batt_entity: sensor.goodwe_b_battery_power_w
max_w: 4600
bms_soc_floor: 10
@@ -0,0 +1,42 @@
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
#
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
# every two-pack fixture in the suite named its first entry `inverter-a`, so
# `UNIT_A` and `inverters[0].name` were the same string in every check that
# existed and the defect was invisible. Names that are not `inverter-a` are what
# make a misdirected namespace something a rehearsal can SEE.
#
# `roster-inverter-a.yaml` beside this file is the same roster with the first
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
batt_invert: false
max_w: 5000
bms_soc_floor: 10
meter_source: 'off'
meter_entity: sensor.p1_active_power
gain: 0.6
slew_w: 0
deadband_w: 15
target_grid_w: 0
step_w: 10
heartbeat_s: 10
stale_input_s: 15
maintenance_discharge_w: 2000
maintenance_charge_w: 2000
inverters:
- name: garage
setpoint_entity: number.goodwe_setpoint
soc_entity: sensor.goodwe_battery_soc_pct
batt_entity: sensor.goodwe_battery_power_w
max_w: 5000
bms_soc_floor: 10
- name: shed
setpoint_entity: number.goodwe_b_setpoint
soc_entity: sensor.goodwe_b_battery_soc_pct
batt_entity: sensor.goodwe_b_battery_power_w
max_w: 4600
bms_soc_floor: 10
@@ -0,0 +1,11 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a store that has ALREADY run the flat config under 0.7.0+, so DEV-02's `schema_version` is stamped and the SAFETY-02 latch is not tripped. NOT a copy of the live box's /data/state.json. This is the CLEAN CONTROL for the MAINT-05 half of the rehearsal: the flat->roster transition over it must come out with NO findings, which is what makes each deliberate mutation's RED mean something. A harness that is red on every input has not measured anything.",
"schema_version": 1,
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": false,
"sign_reason": "",
"auto": true
}
@@ -0,0 +1,3 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a FRESH INSTALL. No maintenance history, no latch, no schema markers: what /data/state.json looks like before the add-on has ever completed a cycle. It is a clean control, and it earned its place: the first draft of the harness reported this transition RED, because the DEV-02 migration writes both SAFETY-02 defaults under `inverter-a` before a roster exists and the tool called them stranded. Losing a default loses nothing."
}
@@ -0,0 +1,10 @@
{
"_comment": "ARCH-02 SYNTHETIC FIXTURE - a PRE-DEV-02 flat state file. NOT a copy of the live box's /data/state.json; this session cannot read that one and did not try. It is built to carry, deliberately, the two shapes that state built by the happy path does not have: a LATCHED SAFETY-02 failsafe under the flat spelling, and a real maintenance history under the flat spelling. Both are LEGACY-SHAPED: there is no schema_version and no maint_schema_version, so this file was last written by a controller that had never heard of unit namespaces. Losing either one is silent.",
"phase": "idle",
"phase_started": "2026-08-07T10:00:00+00:00",
"last_completed": "2026-08-07T18:42:00+00:00",
"last_start_attempt": "2026-08-07T10:00:00+00:00",
"sign_tripped": true,
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
"auto": true
}
File diff suppressed because it is too large Load Diff