Compare commits

..
8 Commits
Author SHA1 Message Date
adminandClaude Opus 5 19decf433f SAFETY-32 round 5: stop closing sites, build an instrument that finds its own scope
checks / unit (pull_request) Successful in 4m59s
Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.

⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:

  roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
    clean                        -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
                                    SAFETY-02 sign-disagreement watchdog can never fire"
    min(caps[:1])          +4 B  -> 0 problems. The site STARTS.
    caps_ix[:1]            +4 B  -> 0 problems.
  the SAME roster with the small cap FIRST (2000, 5000)
    clean                        -> 1 problem, naming inverter-a
    min(caps[-1:])         +5 B  -> 0 problems.

⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.

THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)

Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
what `caps`, `roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and THROUGH CALL ARGUMENTS, which is how it walks
into peakguard, arbiter, allocator, circulation, maintenance and store without being
told they exist. A slice of a roster stays a roster so a mutation cannot cut the taint;
an index is one unit and does not.

  183 roster reductions across 9 modules, out of 23 files parsed:
  main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
  peakguard 4, inverter 3, store 3.  14 modules reduce over no roster at all.

⚠️ THE HAND-WRITTEN MODULE LIST WOULD HAVE BEEN WRONG IN BOTH DIRECTIONS. It named
watchdog.py, which reduces over no roster at all (SAFETY-30's sign watchdogs are
strictly per-unit), and omitted maintenance.py and store.py, which both do. A vacuous
claim about one module and no claim at all about two others - which is the argument for
computing the scope, made with data.

The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 732 spellings, all
reported), and `[:1]` written into app/validate.py at each blocker position with the
WHOLE analysis re-run over the mutated source.

THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE

All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:

  145 of 183   a behavioural fixture separates the site from a slice of itself
   38 of 183   ONLY the structural claim. Behaviourally UNGRADED.

All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 are rows and counts on the operator's page, 3 are
`len(targets)` arithmetic beside a write path that IS graded, 6 are allocator internals
ALLOC-01 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.

THE BEHAVIOURAL HALF, because a shape pin grades an identity and never a meaning

test_validate.py grows a ROSTER SET for the three rules that reduce over
`unit_caps_indexed()`: widths 2, 3, 4, 5 with the one small cap at EVERY position,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered on the same roster.
Only `min` agrees everywhere; a member no roster kills is printed as a NAMED SURVIVOR.

⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's

Found by reading the three branches side by side, NOT by the pin - and said that way
because it is the shape of what the pin cannot see. `unit_sign_disagree_w` was graded
against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already
stopped doing that. Both directions were measured before the fix:

  two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
  The site started, the status page said `armed`, and the per-inverter watchdog could
  not trip on EITHER unit. Identical sentence to the blocker, one branch over.
  two 5000 W units with NO flat key -> REFUSED against a 2000 W default the operator
  never wrote, for a threshold both units can reach.

It takes the same `weakest` the branch above takes and names the same unit. At a roster
of one `unit_caps()` is `[max_w]`, so the shipped single-inverter refusal is the same
comparison, the same boundary and the same sentence.

MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)

  B1  validate min(caps)     -> min(caps[:1])        +4 B  RED test_validate.py
  B2  validate min(caps)     -> min(caps[-1:])       +5 B  RED test_validate.py
  B3  validate caps_ix       -> caps_ix[:1]          +4 B  RED test_validate.py
  B4  validate rule 6 min    -> min(caps_ix[-1:])    +5 B  RED test_validate.py
  B5  validate rule 5 msg    -> min(caps_ix[:1])     +4 B  RED test_validate.py
  B6  validate rule 5b msg   -> min(caps_ix[-1:])    +5 B  RED test_validate.py
  B7  CONTROL: weakest -> max(0.0, weakest)         +10 B  GREEN, as intended
  R3a peakguard max(floors)  -> min(floors)           0 B  RED test_peakguard.py
  R3b peakguard max(floors)  -> max(floors[-2:])     +5 B  RED test_peakguard.py
  R3c main call site packs   -> self.packs[:2]       +4 B  RED test_peakguard.py
  R4a main pack_floor_soc    -> vals[:2]             +4 B  RED test_peakguard.py
  R4b main pack_floor_soc    -> vals[-2:]            +5 B  RED test_peakguard.py
  R4c main pack_floor_soc    -> vals[:3]             +4 B  RED test_peakguard.py
  R4d main cycle() any(...)  -> self.inverters[:1]   +4 B  RED test_watchdog.py

B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.

R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
  packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
  binding_soc_floor(...) = 30.0   the 0-byte mutant would answer 15.0   (+15.0 pp)
  -> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.

⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.

⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.

REBASED onto 67e649a. Two conflicts with SPIKE-04a, both in the blocks this card had
rewritten (config.yaml's roster example and the probe's C/D sections). Resolved to carry
BOTH claims, and the probe was RE-RUN rather than hand-merged: `all cases passed` on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, transcript updated with the
date. MUTATION.md habit 8 - a committed transcript stitched together out of two runs is
not a measurement.

Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0
before and after. CI-06: test_peakguard.py run four times in isolation, green each time
(288 ok lines); test_validate.py three times, green each time.

MUTATION.md gains habits 12 and 13: build the instrument once the same shape has been
found twice AND make it discover its own scope, and say what the instrument cannot see
with sizes in the same breath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 05:36:16 +02:00
adminandClaude Opus 5 8beba0a3e7 SAFETY-32 round 4: the same family at the SIBLING ARGUMENT, and the family enumerated
Rounds 2 and 3 hardened the FLOOR axis - the max inside binding_soc_floor(),
the roster handed to it, Controller.packs, pack_floor_pct()'s conjunction.
peakguard.decide() is handed a SECOND reduction over the same roster beside it,
soc=self.pack_floor_soc, and peakguard.py's own docstring names the two as a
pair. Three rewrites of Controller.pack_floor_soc were green across all 19 files
on 52380c0 and again after the rebase onto 3934ad7:

    min(float(v) for v in vals)  ->  ... vals[:2]   +4 bytes
                                 ->  ... vals[-2:]  +5 bytes
                                 ->  ... vals[:3]   +4 bytes

while [:1], [-1:], [1:], [:-1], max() and the mean were all red. The suite's
separating power on the SoC axis stopped at exactly N=2 - the state the floor
axis was in before round 3.

It changes a real answer: three packs at 50/60/11 %, emptiest THIRD, answers
11.0 shipped and 50.0 mutated. The site is then graded for discharge PERMISSION
against a pack 39 points fuller than the emptiest and spends a pack its own BMS
stops, through sim/inject.py which runs no validator rule.

Round 3's machinery transfers directly. New test_peakguard.py part 12i:
- six REAL Controller.cycle()s, emptiest pack at every position of a three-pack
  roster and both positions of a two-pack one, observable read off
  PeakDecision.soc; cut-outs held flat so the floor axis cannot move the answers
- computed slice and non-slice tables, printed, red naming any live survivor
- a LENGTH SWEEP over the real property, 78 rosters of 1..12 real inverters,
  closing head/tail widths 1-11 and asserting width 12 is unreached
- a NONE-GUARD sweep over the same 78
- THREE exact structural pins: the vals comprehension iterates the bare
  self.inverters, and both any() and min() reduce the whole vals - closing every
  slice width at every roster length, which no bounded fixture can

SPIKE-04: pack B is 4 x Dyness DL5.0C, DoD 90 -> floor 10 % [FROM DATASHEET AND
SERIAL - inverter B does not exist and nothing about it is measured]. Pack A is
DoD 90 -> 10 % and that one IS measured. So production is DEGENERATE on the
floor axis and a differing floor exists ONLY in the fixtures - which is the
argument for keeping them, not for simplifying them. Recorded in CHANGELOG and
DECISIONS so nobody later collapses them to one number. The SoC axis has no such
degeneracy.

AND THE FAMILY, not the member. Every reduction over self.inverters/self.packs
in Controller was found from the AST and mutated to a slice against the full
suite. Eighteen were already graded. FOUR were not, and three of them are
SAFETY-30's, all any(i.sign_wd.tripped for i in self.inverters), each +4 bytes
and green across 19 files - because the only fixture that reached them was a
roster of ONE, where self.inverters[:1] IS self.inverters:

- cycle()'s stale-input hold: a LATCHED unit must take the roster past it or its
  failsafe SET stops being re-filed the moment a poll is missed
- publish()'s status vocabulary: a site holding an inverter at 0 W published
  "running"
- status_payload()'s banner: the one headline on the page

New test_watchdog.py part 8 grades all three with the latch at EACH position
plus a no-latch counterfactual. New part 12j pins all 26 roster comprehensions
in Controller to iterating the roster WHOLE, so a reduction added over a slice
anywhere in the class is red on the line it is written, with no fixture needed.

Two rows reported OPEN rather than closed: _grade_store_migration()'s second
disjunct (now red on 12j, argued equivalent because a unit tripped at startup
implies its namespaced key is set) and cycle()'s undeliverable-unit LOG LINE,
where sorted(...)[:1] is still green - it truncates names in one event message;
the refusal itself is a different expression and is graded.

MUTATION.md gains habits 10 and 11: a fixture of N members cannot separate two
reductions that agree on N, and mutate the SIBLINGS of anything you harden.

No shipped behaviour changed - tests and documentation only. 19/19 green;
all three named survivors and all round-3 mutations verified RED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
glenn schrooyenandClaude Opus 5 a4c95f83ce SAFETY-32 round 3: a fixture of N packs cannot separate reductions that agree on N
Both blockers were the recurring defect sitting inside the fix written to close
it: a check whose TEXT claims more than its FIXTURE can observe. Both survived a
green 19-file suite on 8501e05.

  max(floors) -> max(floors[-2:])   +5 bytes.  12c2's only counterfactual raised
  the LAST pack, and every window ending at the roster's end follows the last
  pack. The check said "the reduction reads every entry"; the data reached "it
  reads the last entry".

  self.packs -> self.packs[:2]      +4 bytes.  All three 12g runs drove a TWO-pack
  roster, where [:2] IS the roster. The check said "what is reduced is the ROSTER
  and not a POSITION in it"; the true claim was the narrower "no SINGLE-POSITION
  slice passes all three runs".

The prose is cut back to what the old data reaches, and new data added for the
rest. No app/ file is touched - app/peakguard.py and app/main.py are byte-identical
to 8501e05.

12c2 - the pure function:
  * a ROSTER SET: the same four simulated cut-outs with the deepest moved to each
    of the four positions.
  * a computed FAMILY TABLE of fourteen rewrites, printed, with the roster that
    kills each. A member with an empty kill list takes the check RED naming it.
  * a LENGTH SWEEP: 78 rosters, lengths 1-12, deepest at every position of each.
    Closes every head window max(floors[:w]) and tail window max(floors[-w:]) of
    width 1-11 in one property.

12g - the call site:
  * _floor_reaching_decide() takes *floors; extra units join through the same
    second_inverter() hook.
  * THREE-pack runs with the deep cut-out at index 0, 1 and 2. Every positional
    slice of a three-pack roster drops a position, and the run whose deep pack
    sits there answers the option where the roster answers the cut-out.
  * a POSITIONAL-SLICE TABLE computed against the REAL Controller.packs each of
    the six cycles ran and the REAL PeakDecision.soc_floor it produced.
  * an EXACT STRUCTURAL PIN on the roster argument's identity. Not the substring
    test round 2 removed: "self.packs" in expr is TRUE of every slice;
    ast.unparse(arg) == "self.packs" is FALSE of all of them.

THE GENERAL LAW, and why the pin is there: a fixture of N packs cannot separate
two reductions that agree on N packs, and the regress does not terminate in a
fixture. [:1] needed two packs, [:2] three, [:3] four. This round's own battery
found self.packs[:3] (+4) and self.packs[-3:] (+5) surviving all six behavioural
runs; the structural pin is what closes them, and one more pack would only have
moved the hole.

Battery: 46 mutations over the reduction, the call site, Controller.packs and
pack_floor_pct()'s conjunction. Bytecode purged and counted 23 -> 0 for each.
43 red, 3 survivors, all three recorded:
  max(floors[:12]) / max(floors[-12:])  NOT CAUGHT - needs a 13-pack roster,
                                        reported with its size, row 27.
  max(set(floors))                      EQUIVALENT - max is duplicate-invariant.
(list(reversed(self.packs)) is equivalent too and is red on the structural pin.)

In-process mutation rows 24-27 record all of it, including the not-caught one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
glenn schrooyenandClaude Opus 5 ffbabea8ce SAFETY-32 fix round: grade the reduction on packs whose floors DIFFER
AC 6 was not met for the reduction main.py actually hands decide(). The
reviewer removed the per-pack grading twice and the 19-file suite stayed green
both times. No shipped behaviour changes here - app/ is untouched. What changes
is what observes it.

WHY BOTH SURVIVED, and it is this repository's recurring defect landing on this
card's own Decision 2: under any rule-8-valid config every pack's
pack_floor_pct() equals peak_soc_floor, so `floors` is a list of IDENTICAL
numbers and max/min/first/last are four spellings of one answer. The only check
using a cut-out ABOVE the EMS floor - 12c's `_n1_deep` - ran on a roster of ONE,
where every reduction agrees. So no check ever evaluated binding_soc_floor() on
two packs whose floors differ. And the degeneracy is not a defence: this PR's own
argument ("unreachable is a claim about today's rule ordering, and
validate_startup_config() is also sim/inject.py's entry point") justifies writing
the reduction and equally obliges grading it.

  * test_peakguard.py part 12c2 - NEW. binding_soc_floor() on a roster whose
    per-pack floors ALL DIFFER: four SIMULATED cut-outs 30/25/40/35 %, each
    strictly above peak_soc_floor, so max (40), min (25), first (30), last (35)
    and the mean (32.5) are five different numbers and the extremes sit at
    INTERIOR roster positions. Two packs structurally cannot separate those -
    there max==last and min==first - and neither can three; four is the smallest
    roster that can. Precondition is its own check (habit 1); the counterfactual
    raises ONE pack - the last, neither extreme - past the old maximum and the
    answer follows (habit 7); peak_soc_floor is then lifted above every cut-out
    to grade the other side of the conjunction.
  * test_peakguard.py part 12g - REWRITTEN as behaviour (habit 4). The old
    assertion was `"self.packs" in <the unparsed call site>`, which `self.packs[:1]`
    still satisfies. It now drives REAL Controller.cycle()s on a two-pack roster,
    both packs at the same SoC, and reads PeakDecision.soc_floor - the argument,
    echoed back by the shipped decide(). The cut-outs are the only thing varied,
    and the SAME PAIR is carried in BOTH ORDERS.
  * ⚠️ TWO MORE SURVIVORS, found by this round's own battery and recorded rather
    than quietly repaired. (a) The first cut of 12g varied only pack B's cut-out,
    so `self.packs[-1:]` (+5 bytes) agreed with the roster on both runs and stayed
    green across all 19 files; the swapped pair closes it, and between the two
    directions no single-position slice passes all three runs. (b)
    binding_soc_floor()'s EMPTY-roster branch rewritten to `return None`
    (-43 bytes) was green too - a defensive branch nothing in the suite reached.
    Both directions of it are pinned now.
  * wired_over_limit() gained a prepare(ctl) hook, inside its retry loop so every
    attempt grades the same site.
  * In-process mutation rows 22, 22b and 23 record all four survivors.

Nine further source mutations went through the harness on the fixed tree
(binding_soc_floor -> floors[0] / floors[-1], pack_floor_pct conjoining with
min, pack_permissions grading against packs[0], `any` -> `all` on the unknown
floor, Controller.packs reversed, decide(soc=) taking inverters[0].soc, and the
two survivors above). Seven were red on first run; the two survivors are now red
as well. Three of the nine have a ZERO byte delta and still went red, which is
the direction stale bytecode cannot fabricate - and the harness purged anyway.

MANDATORY PROOF, all in the CI image over a tree rsynced to a plain path and
bind-mounted :ro, bytecode entry count printed before and after the purge:
each of the four mutations now takes test_peakguard.py RED, and the unmutated
tree is 19/19 green (twice, plus test_peakguard.py five times in isolation -
card CI-06's flake did not reappear). The zero-byte one was proved to have RUN rather than
assumed: dis.get_instructions LOAD_GLOBALs were [float, pack_floor_pct, any,
min] and binding_soc_floor() returned 25.0 on the 12c2 roster where the shipped
code returns 40.0.

No measurement of inverter B appears anywhere. It does not physically exist.
Every second-pack and fourth-pack figure added here is SIMULATED and labelled at
its definition; only inverter A's 10 % is read off hardware. No hardware was
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
adminandClaude Opus 5 f9e7fbf86d SAFETY-32: bms_soc_floor per pack, rule 8 per unit
`bms_soc_floor` describes A BATTERY, not a site. At two packs of different
models the cut-outs differ, and one number for both graded the shallower pack
against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under
`peak_soc_floor: 20` STARTED, and the guard would command a forced discharge
that pack B's BMS, not the operator's floor, is the thing that stops. That is
rule 8's own failure mode, reached through rule 8.

Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate
anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as
already shipped and are untouched.

* config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry.
  Nothing is inherited; the flat key stays the single-inverter shape's own
  cut-out. A per-entry DEFAULT was declined - a default is one number for every
  pack, which is this card's defect - and the decision is recorded.
* inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None,
  not a borrowed number and not 0.0 (the most permissive value the key has).
* peakguard.py: `Pack`, `pack_floor_pct()`, `pack_permissions()`,
  `binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
  one place the question is answered.
* validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8
  grades EVERY unit and NAMES the one it refuses. Graded with the offender
  SECOND and with the same two numbers in the other order.
* main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack
  reduction of the same option; the floor-limited notification names the pack
  that stopped the site. `pack_floor_soc` - the emptiest pack, and its
  None-on-missing behaviour - is untouched.
* tools/supervisor_shape_probe.py: RE-RUN and repaired. The required key broke
  §C and left §D printing PASS on a refusal about a different key. §D is now an
  isolated counterfactual over an accepted merged baseline, one key varied,
  asserting the key name. `all cases passed` on 2026.08.0.

Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR
reduces to `peak_soc_floor` for every pack. The reduction is written for
`unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid
pairs answer the option, the pair rule 8 refuses answers the cut-out.
`peak_soc_floor` is still one number for the site, so the "deeper pack
needlessly withheld" half is filed as an open question, not closed.

Full 19-file suite green. Twelve source mutations through
tools/mutation_run.sh: eleven red, one argued survivor. The battery also found
`_flat_bms_floor()` defaulting in two places, each covering for the other.

No measurement of inverter B appears anywhere: it does not exist yet. Every
second-pack figure is simulated and labelled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
admin 67e649ad6b Merge pull request 'SPIKE-04a: inverter B is 4600 W FROM ITS DATASHEET, and the three documented copies are pinned' (#49) from SPIKE-04a into release/1.0 2026-09-05 02:55:24 +02:00
glenn schrooyenandClaude Opus 5 a9993aeda2 SPIKE-04a: inverter B is 4600 W FROM ITS DATASHEET, and the copies are pinned
checks / unit (pull_request) Successful in 4m51s
The desk half of SPIKE-04. No control behaviour changes; this is documentation
and the check that stops it drifting again.

Three places stated inverter B's rating - config.yaml's commented roster
example, DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator
would POST" - and all three said 3000 W, a number nobody had measured or read
off anything. The owner supplied B's model and serial on 2026-09-04: a GoodWe
GW5048D-ES, serial `55048ESU22AW0992`, max charging and discharging power
4600 W. Serial characters 6-8 are `ESU`, platform 105 / AA55, so SPIKE-01 does
not gate this site. All three copies now read 4600.

⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and the one ambiguity
is recorded rather than guessed. The datasheet prints it under BATTERY INPUT
DATA, so it is probably DC, while max_w is graded against 0x0518 - the
inverter's own AC power. If it is DC the AC ceiling is nearer 4324 W. Neither
reading is written in as fact; SAFETY-20's min(max_w, dev_max) already bounds
it correctly under either, once dev_max is read off the unit, so `max_w: 4600`
is what an operator should configure today. goodwe/NOTES.md §1.1b carries the
full record with the date and the method on every figure.

A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.
test_validate.py now pins all three documented rosters: findable, in agreement,
and equal to 4600 - with an explicit len(...) == 3 guard so that a regex which
stopped matching cannot leave "they all agree" green on the two sites still
read. It also grades the PROVENANCE beside the number (the datasheet, the
0x0518 it is graded against, and the 4324 W the DC reading would make it)
WITHIN 2500 characters of the roster rather than file-wide.

⚠️ The first version of that provenance check searched whole files and was
ALREADY VACUOUS on two of them: 0x0518 appeared once in config.yaml and once in
DOCS.md before this card, for unrelated reasons, so `"0x0518" in src` was green
no matter what happened next to B's rating. That is a check whose data cannot
reach the failure it names, in the file that exists to complain about them. It
is fixed, and the window is what fixes it - deleting the caveat beside the
number now goes red even where the same token survives elsewhere in the file.
The windowed version immediately found that the DOCS.md caveat did not name
0x0518 at all; DOCS.md now says it.

Four mutations, bytecode purged and counted at 0 before and after each, all
RED (two of them 0-byte deltas that change a printed answer):
  M1  probe's operator blob 4600 -> 3000        0 B   RED (value + agreement)
  M2  DOCS.md's DC/AC caveat deleted         -157 B   RED (provenance)
  M3  config.yaml alone 4600 -> 4324            0 B   RED (agreement)
  M4  probe entity renamed, regex finds nothing       RED (the vacuity guard)
Restored, green again. The probe itself was re-run against Supervisor
2026.08.0's own AppOptions validator with the 4600: all cases passed, exit 0.

⚠️ THE TEST FIXTURES KEEP 3000 W ON PURPOSE AND NOW SAY WHY AT THE LINE.
test_inverter.py, test_maintenance.py, test_peakguard.py and test_watchdog.py
build a second inverter at 3000 W against A's 5000 W. The only property those
checks consume is that B's number is unmistakably not A's, and the real pair is
a 400 W gap - narrower than slew_w, inside the range where an off-by-one-unit
bug and ordinary control arithmetic produce indistinguishable numbers.
Collapsing them onto the real values would buy realism those checks do not use
and cost them their separating power, which is exactly what test_peakguard.py's
own §10g PRECONDITION exists to prevent. Their comments no longer call 3000 W a
hypothesis about B's rating; they call it illustrative, and not a placeholder
either - the placeholder era ended on 2026-09-04.

Also recorded in the changelog for the cards that own them: 94 % battery-to-AC
=> ~88 % round trip => ~360 W burned continuously to circulate 3 kW, which is
the number SAFETY-31 argues its threshold from without having one; < 13 W night
self-consumption per unit, which nothing accounts for; and the A/B asymmetry in
opposite directions (~33 % more energy, ~8 % less power), which means MAINT-05's
12 h drain timeout was derived on A and should be re-checked against B.

⚠️ Honest survivors:
- goodwe/NOTES.md §1.1b, where all of this is actually recorded, is graded by
  nothing here. Rewriting its 16 occurrences of 4600 to 3000 and turning every
  FROM DATASHEET label into MEASURED ON THE UNIT leaves all 19 files GREEN.
  This CI cannot fetch admin/goodwe - private, and GITHUB_TOKEN is minted for
  this repository only, as .gitea/workflows/checks.yml already states - so the
  cross-repo check is not buildable here.
- No check can see whether 4600 is the RIGHT number. Everything above pins a
  datasheet reading against itself. Only the bench half of SPIKE-04 can say
  whether the unit agrees, and the DC-vs-AC ambiguity is the live example.
- The constant is still named B_RATING_HYPOTHESIS in four test files, which
  reads as a hypothesis about B's rating when it is now an illustrative
  fixture. It is left alone deliberately: renaming ~20 sites across
  test_peakguard.py and test_inverter.py would collide hard with the open
  SAFETY-32 pull request, which rewrites 1255 lines of the first. The comment
  directly above each definition says what the value is and is not.

Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and
verified 0 before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 22:19:31 +02:00
admin 3934ad7ea5 Merge pull request 'MAINT-05: one recalibration per pack, and only one pack moves power at a time' (#47) from MAINT-05 into release/1.0 2026-09-04 21:39:25 +02:00
15 changed files with 4673 additions and 76 deletions
+339
View File
@@ -2,6 +2,67 @@
## Unreleased
**Inverter B's rating is 4600 W, FROM ITS DATASHEET, and every documented copy
of it now says so (SPIKE-04a).** Three places stated B's rating — `config.yaml`'s
commented roster example, `DOCS.md`'s, and `tools/supervisor_shape_probe.py`'s
"a blob an operator would POST" — and all three said **3000 W**, a number that
was never measured, never read off a datasheet, and simply wrong. The owner
supplied B's model and serial on 2026-09-04: a **GoodWe GW5048D-ES**, serial
`55048ESU22AW0992`, max charging and discharging power **4600 W**. No control
behaviour changes here; this is documentation and its pin.
* **Serial characters 6-8 read `ESU`** → platform 105, AA55, the supported set.
Not `ESN`/`ESC` (ES G2, platform 745). SPIKE-01 does not gate this site.
⚠️ A passed platform gate is **not** a proven control surface: `specs.md`
already calls other platform-105 serials *"same protocol family; untested
silicon"*, and this repo's `0x056E`/`0x056F` map was reverse-engineered from a
passive tap on **one BPS unit**. That check is the bench half of SPIKE-04.
* **⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and one ambiguity is
recorded rather than guessed.** The datasheet prints it under *Battery Input
Data*, so it is probably **DC**, while `max_w` is graded against `0x0518` —
the inverter's own **AC** power. If it is DC the AC ceiling is nearer
4600 × 0.94 ≈ **4324 W**, and the charge direction moves the other way
(≈ 4894 W drawn AC-side). **Neither reading is written in as fact.**
SAFETY-20's `min(max_w, dev_max)` already bounds it correctly under either,
once `dev_max` is read off the unit — so `max_w: 4600` is what to configure
today. `goodwe/NOTES.md` §1.1b carries the full record, with the date and the
method on every figure.
* **A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.**
`test_validate.py` now pins all three documented rosters: they must be
findable, they must agree, and they must say **4600**. It also grades the
**provenance beside the number** — the datasheet it came from, the `0x0518`
it is graded against, and the 4324 W the DC reading would make it — **within
2500 characters of the roster, not file-wide.** The first version searched
whole files and was already vacuous on two of them, because `0x0518` appeared
in `config.yaml` and `DOCS.md` for unrelated reasons before this card: a
check whose data could not reach the failure it named, in the file that
exists to complain about those. Four mutations were run against the fixed
version — the probe back to 3000 (0-byte delta), the `DOCS.md` caveat
deleted, `config.yaml` alone "corrected" to 4324 (0-byte delta), and the
probe's entity renamed so the regex finds nothing — and all four are red.
* **THE TEST FIXTURES KEEP 3000 W ON PURPOSE, AND NOW SAY WHY AT THE LINE.**
`test_inverter.py`, `test_maintenance.py`, `test_peakguard.py` and
`test_watchdog.py` build a second inverter at 3000 W against A's 5000 W. The
only property those checks consume is that B's number is **unmistakably** not
A's, and the real pair is a **400 W** gap — narrower than `slew_w`, inside the
range where an off-by-one-unit bug and ordinary control arithmetic produce
indistinguishable numbers. Collapsing them onto the real values would buy
realism those checks do not use and cost them their separating power, which
is the defect `test_peakguard.py`'s own §10g precondition exists to prevent.
They are **illustrative, not B's rating, and no longer a placeholder for it**.
* **Two consequences with numbers, recorded in `goodwe/NOTES.md` §1.1b for the
cards that own them.** At 94 % battery-to-AC a round trip is ≈ 88 %, so A
charging 3 kW while B discharges 3 kW burns ≈ **360 W continuously** with the
meter at zero — SAFETY-31 argues its threshold from *"a few watts"* and has no
figure. And **< 13 W night self-consumption per unit** (≈ 26 W for two,
≈ 0.6 kWh/day), which nothing in this add-on accounts for anywhere.
* **The site is asymmetric in opposite directions:** A = 5000 W / 15.36 kWh
nominal, B = 4600 W / 20.48 kWh — ~33 % more energy, ~8 % less power. No
stability implication (ARCH-01's ḡ = Σαᵢgᵢ is a convex combination), but a
full recalibration of B moves 33 % more energy and **MAINT-05's 12 h `drain`
phase timeout was derived on A**; it should be re-checked against B rather
than assumed to carry.
**Every pack is recalibrated on its own schedule, and only one moves power at a
time (MAINT-05).** §12's monthly cycle drains a pack to empty and refills it to
true its coulomb counter. There was exactly one `Maintenance` state machine and
@@ -118,6 +179,284 @@ two power-moving phases on one grid connection pulling against each other.
is loop and allocator territory. Pinned as a running check
(`test_maintenance.py` part 12) so it cannot be forgotten.
**`bms_soc_floor` describes a BATTERY, so at two packs it stops being one number
(SAFETY-32).** The peak guard's energy backstop rests on two figures: the EMS
floor `peak_soc_floor` (the depth a money-saving discharge may never reach) and
`bms_soc_floor`, the percent **that pack's own BMS** cuts out at — register
`0x0560`, read as 10 on the reference GW5000S-BP. Rule 8 refuses an EMS floor at
or below the cut-out, because such a floor can never bind: the BMS wins and the
status page reports a backstop that does not exist. With ONE cut-out for two
packs of different models that rule graded the EMS floor against whichever
battery happened to be written into the flat key, and a site with cut-outs of
10 % and 30 % under `peak_soc_floor: 20` **started** — the guard would then
command a forced discharge that pack B's BMS, not the operator's floor, is the
thing that stops. Rule 8's own failure mode, reached through rule 8.
* **`bms_soc_floor` becomes a per-entry roster key, and it is REQUIRED there.**
Nothing is inherited: an entry that omits it has no cut-out, does not borrow
the flat one, and is refused by name at startup. A per-entry *default* was
considered and declined — a default is one number for every pack, which is the
defect this card removes; `DECISIONS/SAFETY-32-per-pack-bms-floor.md` records
the decision and its migration cost. The flat key is unchanged and is now
documented as the single-inverter shape's own cut-out.
* **Rule 8 grades EVERY unit and NAMES the one it refuses**, through a new
`unit_bms_floors_indexed()` that carries the **roster** index the value came
off. CFG-04 had just fixed a rule 5/6 bug of exactly this shape — a caps-list
index fed to a function that indexes the roster, so the message named the wrong
inverter — and this reader skips nothing, so there is no second list for an
index to desynchronise from. Graded on a roster whose offender is the SECOND
entry *and* on the same two numbers in the other order.
* **The permission is answered PER PACK.** `Controller.pack_floor_soc` — the
emptiest measured pack — is untouched and is still what `decide()` grades;
what changed is the floor. `peakguard.pack_permissions()` answers one
`(name, reason)` per pack against that pack's own floor, and the floor-limited
notification now names the pack that stopped the site rather than "the
battery". At a roster of one the alarm is byte-for-byte what SAFETY-25 shipped.
* **An unstated cut-out withholds rather than defaults.** `None`, not `0.0` —
`discharge_permitted()` already treats an unknown floor as no permission, so
the absence costs a shave and never a pack, including on `sim/inject.py`'s path
which does not run the startup gate.
* ⚠️ **Stated rather than hidden:** under any config rule 8 accepts, the per-pack
*floor* reduces to `peak_soc_floor` for every pack — rule 8 makes it so. The
reduction is written for `unit_caps_indexed()`'s reason ("unreachable" is a
claim about rule ordering, not a property of a function) and is graded on both
sides: 1775 rule-8-valid `(peak_soc_floor, bms_soc_floor)` pairs answer the
option, and the pair rule 8 refuses answers the cut-out. `peak_soc_floor`
itself is still one number for the site, so the "deeper pack needlessly
withheld" half is filed as an open question, not closed.
* **`tools/supervisor_shape_probe.py` re-run and repaired.** MUTATION.md habit 8:
a committed transcript is a check and it decays. The required key broke §C and
left §D printing PASS on a refusal about a *different* key. §D is now an
isolated counterfactual over an accepted, merged baseline, one key varied per
case, asserting the key **name** in the refusal — `all cases passed` on
`amd64-hassio-supervisor:2026.08.0`.
* ⚠️ **SECOND FIX ROUND — the round-2 fix carried the defect it was written to
close, in its prose.** Two more rewrites survived a green 19-file suite on
`8501e05`: `binding_soc_floor()`'s `max(floors)` → `max(floors[-2:])` (+5
bytes) and the call site's `self.packs` → `self.packs[:2]` (+4 bytes). Both are
checks whose TEXT claimed more than their FIXTURE could reach — 12c2's only
counterfactual varied the **last** pack, which every window ending at the
roster's end follows, and all three 12g runs drove a **two**-pack roster, where
`[:2]` *is* the roster. **The general law, now stated in the file: a fixture of
N packs cannot separate two reductions that agree on N packs**, and the regress
does not terminate in a fixture — `[:1]` needed two packs, `[:2]` three, `[:3]`
four. So the reduction is now graded by a **length sweep** (78 simulated
rosters, lengths 1–12, deepest cut-out at every position, closing head and tail
windows of width 1–11 at once) plus a **computed family table** of fourteen
rewrites that goes red naming any member no roster kills; the call site is
graded by **three-pack runs** with the deep cut-out at index 0, 1 and 2, plus
an **exact structural pin** (the roster argument must unparse to `self.packs`)
for the slice widths no bounded roster can reach. Round 3's own battery found
`self.packs[:3]` / `[-3:]` surviving all six behavioural runs — the structural
pin is what closes them — and one row is recorded **NOT CAUGHT**: a reduction
over a window twelve packs wide, which needs a 13-pack roster to observe.
No shipped behaviour changed in this round; only what observes it.
* **Twelve source mutations, eleven red, one argued survivor**, through
`tools/mutation_run.sh` (bytecode purged). The battery also found a real
weakness of its own: `_flat_bms_floor()` had the default in two places and each
covered for the other, so deleting either survived — masking, in three lines of
option reading. One defaulting line now.
* ⚠️ **FIX ROUND — that battery was INCOMPLETE, and the review found what it
missed.** Neither of the two mutations below appears in the twelve rows above:
the ledger graded "the reduction dropped" and "the bare option handed over"
and never asked what happens when the reduction is simply the *wrong* one, or
when the roster handed to it is *sliced*. No shipped behaviour changed here;
two *checks* whose data could not reach the failure they named did. `max(floors)` → `min(floors)` in
`binding_soc_floor()` (zero byte delta) and `self.packs` → `self.packs[:1]` at
the `decide()` call site (+4 bytes) were each green across all 19 files. Both
survived for the same reason: **under any rule-8-valid config every pack's
`pack_floor_pct()` equals `peak_soc_floor`**, so `floors` is a list of
identical numbers and `max`, `min`, first and last are four spellings of one
answer — and the one check using a cut-out above the EMS floor ran on a roster
of ONE, where every reduction agrees. The second also walked through part 12g,
which asserted `"self.packs" in <the unparsed call site>`: a slice still
contains that substring, so the pin passed on the mutation that grades the
whole site against pack A. MUTATION.md habit 4 — a source-text test is a check
on how code is spelled, not on what it does.
* **New part 12c2** evaluates `binding_soc_floor()` on a roster whose per-pack
floors **all differ** — four simulated cut-outs of 30/25/40/35 %, every one
above `peak_soc_floor`, chosen so that `max` (40), `min` (25), first (30),
last (35) and the mean (32.5) are five different numbers and the two extremes
sit at *interior* roster positions. Two packs cannot separate those four;
four is the smallest roster that can. The precondition is its own check
(habit 1) and one pack — the last, neither deepest nor shallowest — is then
raised past the old maximum and the answer follows it (habit 7).
* **Part 12g is now behaviour, not source text.** Real `Controller.cycle()`s on
a two-pack roster, both packs at the same SoC, with the cut-outs as the only
thing varied: move the **non-first** pack's cut-out and the floor reaching
`decide()` moves 20 % → 35 %, with the site's verdict flipping from a sized
discharge to FLOOR-LIMITED. `self.packs[:1]` answers 20 % both times. Which
option key the floor comes from stays pinned on the source where it already
was, because that claim genuinely is about the code's shape.
* ⚠️ **Two MORE survivors, found by this round's own battery and recorded
rather than quietly repaired.** (a) The first cut of part 12g varied only
pack B's cut-out, so `self.packs[-1:]` — grade the site against the **last**
pack — agreed with the full roster on both runs and stayed green across all
19 files (+5 bytes). A two-run fixture pins one slice and not the other.
Part 12g now runs the **same pair of cut-outs in both orders**, so the
deeper one is on the second pack in one run and on the first in another and
the site floor is 35 % in both; no single-position slice passes all three
runs. (b) `binding_soc_floor()`'s empty-roster branch rewritten to
`return None` (−43 bytes) was also green — a defensive branch nothing in the
suite reached. Both directions of it are pinned now, with the note that it
cannot permit anything either way because `pack_floor_soc` is `None` at an
empty roster.
* ⚠️ **THIRD FIX ROUND — the same family was alive at the SIBLING ARGUMENT of
the same call.** Rounds 2 and 3 hardened the FLOOR axis; `decide()` is handed
a second reduction over the same roster beside it, `soc=self.pack_floor_soc`,
and `peakguard.py`'s own docstring names the two as a pair. Three rewrites of
`Controller.pack_floor_soc` were green across all 19 files on `52380c0` and
again after the rebase onto `3934ad7`: `min(float(v) for v in vals)` →
`vals[:2]` (+4), `vals[-2:]` (+5) and `vals[:3]` (+4), while `[:1]`, `[-1:]`,
`[1:]`, `[:-1]`, `max()` and the mean were all red. **The suite's separating
power on the SoC axis stopped at exactly N=2** — the state the floor axis was
in before round 3. It changes a real answer: a three-pack roster at
50 / 60 / 11 % with the emptiest pack THIRD answers 11 % shipped and 50 %
mutated, so the site is graded for discharge PERMISSION against a pack 39
points fuller than the emptiest and spends a pack its own BMS stops. Round 3's
machinery transfers directly — new **part 12i**: six real `cycle()`s with the
emptiest pack at every position of a three-pack roster and both positions of a
two-pack one (the observable read off `PeakDecision.soc`, cut-outs held flat
so the floor axis cannot be what moved the answers); computed slice and
non-slice tables that go red naming any member nothing kills; a **length
sweep** of 78 real rosters, lengths 1–12, closing head and tail windows of
width 1–11 and recording width 12 as honestly unreached; a **none-guard
sweep** over the same 78; and **three exact structural pins** — the `vals`
comprehension must iterate the bare `self.inverters`, and both `any()` and
`min()` must reduce the whole `vals` — which close every slice width at every
roster length. No shipped behaviour changed; only what observes it.
* ⚠️ **SPIKE-04: production is DEGENERATE on the floor axis, so the
differing-floor fixtures are the entire exposure.** Pack B is 4 × Dyness
DL5.0C, DoD 90 → floor 10 % — **FROM THE DATASHEET AND THE SERIAL; inverter B
does not exist and nothing about it is measured.** Pack A is DoD 90 → 10 % as
well, and that one *is* measured. Both packs therefore floor at the same value
in the field, `max(10, 10)` is 10 whatever it reduces, and a differing floor
exists **only in the fixtures**. That is the argument for keeping them, not for
simplifying them: anyone who collapses `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to
one number deletes the only data this repository has that can see the
reduction at all, and part 12c2's precondition check goes red if they do. The
**SoC** axis has no such degeneracy — two batteries at the same state of
charge is a coincidence, not a configuration — which is what makes
`pack_floor_soc` the half of the pair that will be live from the day inverter
B is wired up.
* ⚠️ **The FAMILY enumerated rather than the member closed, and it found four
more survivors in code this card never touched.** Every reduction over
`self.inverters` / `self.packs` in `Controller` was found from the AST and
mutated to a slice against the full suite. Eighteen were already graded
(`site_batt_w`, `site_rating_w`, `site_max_w`, `site_dev_bound`,
`site_circulating_w`, `inputs_ok`, `units`, `Controller.packs`, `maint_unit`,
the maintenance-due minimum, the store-migration roster, `validate_unit_ranges`
and the round-2/3 sites). **Four were not, and three of them are SAFETY-30's,
all `any(i.sign_wd.tripped for i in self.inverters)`** — `cycle()`'s
stale-input hold, `publish()`'s status vocabulary and `status_payload()`'s
banner — each +4 bytes and green across all 19 files, for the reason every
blocker on this card was invisible: **the only fixture that reached them was a
roster of ONE, where `self.inverters[:1]` IS `self.inverters`.** The first has
teeth: a latched unit must take the roster past that hold or its `failsafe`
SET stops being re-filed the moment a poll is missed, which is main.py's own
sentence for it. New **test_watchdog.py part 8** grades all three with the
latch at EACH position plus a no-latch counterfactual, and new **part 12j**
pins all 26 roster comprehensions in `Controller` to iterating the roster
whole — so a reduction added over a slice anywhere in the class is red on the
line it is written, with no fixture required. Two rows are reported open
rather than closed: `_grade_store_migration()`'s second disjunct (now red on
12j, and argued to be an equivalent mutant because a unit tripped at startup
implies its namespaced key is set) and `cycle()`'s undeliverable-unit **log
line**, where `sorted(...)[:1]` (+4 bytes) is still green — it truncates names
in one event message; the refusal itself is a different expression and is
graded, so the site still stops.
* ⚠️ **FOURTH FIX ROUND — the same family one MODULE over, and the pin widened
until it discovers its own scope.** Round 4 enumerated the family and closed
it inside the `Controller` class; the review found four more survivors
immediately, in **validate.py**, on a 19/19 green suite: `min(caps)` →
`min(caps[:1])` (+4) and `→ min(caps[-1:])` (+5) at `:779`, the caps
comprehension over `caps_ix[:1]` (+4) at `:778`, and
`min(caps_ix[-1:], key=…)` (+5) at `:893`. **None is an equivalent mutant.**
With `max_w: 5000` on inverter-a beside `2000` on inverter-b and
`sign_disagree_w: 3000`, the shipped validator refuses — *"the SAFETY-02
sign-disagreement watchdog can never fire"* — and all four **accept**: the
site starts with a watchdog reporting `armed` that cannot trip on pack B.
Rule 5's own failure mode reached through rule 5; rule 6 loses its refusal the
same way. **No shipped behaviour changed by the pin; what changed is that a
hand-written scope stopped being trusted.**
* **`test_peakguard.py` part 12j now parses every `.py` under `app/`** — no
module is named in the check — and finds the roster by transitive closure
from **one seed**, the `inverters` option key: through returns, through
lists **accumulated in a loop over a roster** (which is what `caps`,
`roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and **through call arguments**, which is
how it walks into `peakguard`, `arbiter`, `allocator`, `circulation`,
`maintenance` and `store` without being told they exist. A slice of a roster
stays a roster so a mutation cannot cut the taint; an index is one unit and
does not. **183 reductions across 9 modules** out of 23 files parsed — `main.py` 96,
`allocator.py` 24, `validate.py` 19, `arbiter.py` 14, `circulation.py` 10,
`maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, `store.py` 3.
* **The hand-written module list would have been wrong in BOTH directions.**
It named `watchdog.py`, which reduces over no roster at all — SAFETY-30's
sign watchdogs are strictly per-unit — and omitted `maintenance.py` and
`store.py`, which both do. A vacuous claim about one module and no claim
about two others.
* **The pin rediscovers all four blockers without being told where to look,**
and finds a fifth beside them (`validate.py:783`, rule 5's message) that no
review round named. Its counterfactual runs at two depths: every enumerated
site's own expression mutated with each of the four cuts this card has been
defeated by (732 spellings, all reported), and `[:1]` written into
`app/validate.py` at each blocker position with the **whole** analysis
re-run over the mutated source — a sliced site required at that exact line,
where the clean package reports none.
* **And the behavioural half, because a shape pin grades an identity and never
a meaning.** `test_validate.py` grows a **roster SET** for the three rules
that reduce over `unit_caps_indexed()` — widths 2, 3, 4, 5 with the one
small cap at every position, 42 runs of the real validator — plus a
**computed family table** of twelve candidate reductions each compared with
what the shipped validator actually said on the same roster. Only `min`
agrees everywhere; a member no roster kills is printed as a named survivor.
* ⚠️ **A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's** —
found by reading, **not** by the pin, which is the shape of what the pin
cannot see. `unit_sign_disagree_w` was graded against
`_number(opts, "max_w", 2000.0)` three lines under a branch that had already
stopped doing that. **Both directions were measured:** two 2000 W units
beside a leftover `max_w: 5000` and `unit_sign_disagree_w: 3000` produced
**zero problems** — the site started with a per-unit watchdog that could not
trip on either unit — and two 5000 W units with no flat key were **refused**
against a 2000 W default the operator never wrote. It takes the weakest
unit's own cap now and names that unit; at a roster of one it is the same
comparison, boundary and sentence it always was.
* ⚠️ **What the pin cannot see, with sizes**, in
`DECISIONS/SAFETY-32-per-pack-bms-floor.md` §4d: semantics (`min` vs `max`
is a zero-byte edit); direct index sites that are not reductions; a
subscript by a *variable* (`weights[u]` is why); a slice through an
intermediate call; anything outside `app/`, including `sim/inject.py`; and
the taint's own seam at `*args` and unresolvable receivers, where everything
downstream goes **unenumerated rather than reported unreachable**.
`cycle()`'s undeliverable-unit log line is still green at +4 bytes and still
filed open, unchanged from round 4.
* **`tools/supervisor_shape_probe.py` re-run again (2026-09-05, 2026.08.0).**
The rebase onto `67e649a` merged SPIKE-04a's `max_w: 4600` into the same
§C/§D blocks this card had rewritten, and a transcript stitched by hand out
of two runs is not a measurement. `all cases passed`, exit 0, with B's
rating reading 4600 — its **datasheet** figure, still not a measurement of
any unit.
* ⚠️ **THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE.** All 183
sites mutated to `[:1]`, one at a time, against the full 19-file suite in
`goodwe-addon-ci` (`:ro`, `-B`, fresh rsync per mutant, 0 `.pyc` before and
after): **183/183 RED** — which is tautological on its own, because the pin
makes every slice red. The 51 whose first red was `test_peakguard.py` were
re-run with the pin **neutralised**: **145 of 183 have a behavioural fixture
that separates them from a slice of themselves; 38 have only the structural
claim.** All 38 are listed by site, with what a `[:1]` there would cost, in
§4d and in `test_peakguard.py` part 12j — 15 of them are rows and counts on
the operator's page, 3 are `len(targets)` arithmetic beside a write path
that IS graded, 6 are allocator internals ALLOC-01's fixtures reach but not
with enough units to separate. **None of the 38 is closed behaviourally by
this card, and none is counted as covered.**
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
exist yet. Every second-pack figure — in fixtures, in the probe, in
config.yaml's commented example — is **simulated and labelled**; only inverter
A's 10 % is read off hardware.
**At two inverters the sign watchdog stops comparing one unit against the whole
site meter, and becomes two checks instead of one (SAFETY-30).**
`disagrees(commanded_w, grid_w, ...)` compared the command **this controller put
@@ -0,0 +1,816 @@
# SAFETY-32 — `bms_soc_floor` per pack, and the two judgement calls it left open
**Status:** decided and implemented on branch `SAFETY-32`.
**Card:** SAFETY-32 — *"One `bms_soc_floor` for two packs grades the shallower
pack against the deeper one's cut-out"*, scoped by the card's own PM PROOFREAD
to its **third** bullet only. Bullets 1 (aggregate anti-windup) and 2 (per-instance
`achievable_w`) were verified as already shipped by DEV-02 and CFG-04 and were
**not** touched — `main.py`'s `actual_w=self.site_batt_w` comment is the record
of why the first of those must stay site-level, and it still reads as written.
---
## 1. The defect, stated in the terms the code uses
`bms_soc_floor` **describes a battery**: it is the percent that pack's own BMS
cuts out at, register `0x0560` on the reference GW5000S-BP, read as 10. It was a
flat option because there was one pack.
`peak_soc_floor` **describes the site's policy**: the depth a money-saving
discharge may never reach. Rule 8 refuses `peak_soc_floor <= bms_soc_floor`,
because such a floor can never bind — the BMS wins and the status page reports a
backstop that does not exist.
At two packs of different models the two cut-outs differ, and one number for both
is wrong **in the direction that empties a pack**: with cut-outs of 10 % and 30 %
and `peak_soc_floor: 20`, the add-on **started**, and the guard would command a
forced discharge that pack B's *BMS* — not the operator's floor — is the thing
that stops. That is rule 8's own failure mode, reached through rule 8.
---
## 2. DECISION 1 — required per entry, not defaulted from `PEAK_OPTION_DEFAULTS`
The card left this open and asked for the reason to be recorded.
**Decided: REQUIRED of every roster entry**, in `config.yaml`'s schema
(`bms_soc_floor: int(0,50)`, no trailing `?`) and in `validate.py` rule 8, which
refuses a missing one **by name**.
The reason is that a default here is not a convenience, it is **this card's own
defect wearing a different hat**. A default is one number applied to every pack,
which is exactly what the flat key was. An add-on that answers "10 %" for a pack
nobody has told it about has invented a property of somebody's battery, and the
failure mode of the invention is the one this card exists to remove — the
shallower pack protected by a floor that belongs to the deeper one. CFG-03's
rule ("defaults live in exactly one place") points the same way: the one mirror
`PEAK_OPTION_DEFAULTS["bms_soc_floor"]` is the default for the **flat** key, i.e.
for the single-inverter shape, and reusing it per entry would make one dict the
default in two shapes with two meanings.
It is also the treatment `max_w` already gets, for the same reason and by the
same argument (`inverter.py`'s `build_roster()` docstring): the per-unit numbers
that describe hardware are required, and the per-unit *sensors* are optional
because an absent sensor is a real state.
**The cost is stated rather than hidden.** This is a schema change: a roster
config written before this branch is refused by the Supervisor until each entry
gains the key, and every roster fixture in the suite needed it. Nothing in
production carries a roster today — `inverters:` ships `[]` and the second
inverter is ~8 days out — so the migration is paid by fixtures and by whoever
writes the first real roster, at a startup refusal that names the entry.
**What is NOT inherited.** An entry that omits the key does not borrow the flat
one. `Inverter.bms_soc_floor` is then `None`, `peakguard.pack_floor_pct()`
answers `None`, and `discharge_permitted()` already treats an unknown floor as
**no permission** — so even in the window before rule 8 runs (e.g. `sim/inject.py`,
which does not run the startup gate) the absence costs a shave and never a pack.
`0.0` was rejected explicitly: it is the most permissive value the key has.
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
**is unchanged**, exactly as the card instructed. What changed is the floor it is
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
floors, each of them `max(peak_soc_floor, that pack's own cut-out)`, with a
`max()`. The two reductions pair: a minimum SoC against a maximum floor is the
conservative combination, and both move the verdict towards refusing a shave and
away from spending a pack. `min(soc) <= max(floor)` is implied by "some pack is
at or below its own floor", so the site verdict is never more permissive than
grading each pack separately would be. The converse is not true and is not
claimed — a site can be refused while every pack individually has headroom, which
costs a shave.
### 3a. ⚠️ FIX ROUND — the reduction was written and argued, and NOT GRADED
PR #48 was rejected because the argument in §4 below justified *writing* the
reduction and was then not carried into a check. Neither of the two mutations
below appears anywhere in §6's twelve-row battery: it graded *"the reduction
dropped"* and *"the bare option handed over"*, and never asked what happens when
the reduction is the **wrong one**, or when the roster handed to it is
**sliced**. Both were applied to `app/` by the reviewer and **all 19 files
stayed green**:
| mutation | Δ | why it survived |
|---|---|---|
| `binding_soc_floor()`: `max(floors)` → `min(floors)` | **0 bytes** | every roster in the suite was rule-8-valid or one pack long, so `floors` held **identical numbers** and `max`/`min`/first/last were four spellings of one answer |
| the `decide()` call site: `self.packs` → `self.packs[:1]` | **+4 bytes** | part 12g asserted `"self.packs" in <the unparsed expression>` — and `self.packs[:1]` **contains that substring** |
The zero-byte one was proved to have actually run rather than assumed:
`dis.get_instructions` showed the `LOAD_GLOBAL`s as `[float, pack_floor_pct,
any, min]`, and `binding_soc_floor([Pack("a",50,10), Pack("b",50,35)], 20.0)`
returned `20.0` where the shipped code returns `35.0`.
**The common cause is this repository's recurring defect** — a check whose DATA
cannot reach the failure it names. The one check that used a cut-out **above**
the EMS floor (12c's `_n1_deep`) ran on a roster of **ONE**, where every possible
reduction agrees. So no check in the suite ever evaluated
`binding_soc_floor()` on two packs whose floors **differ**.
**And the rule-8 degeneracy is not a defence, by §4's own words.** If
"unreachable is a claim about today's rule ordering, not a property of this
function, and `validate_startup_config()` is also `sim/inject.py`'s entry point"
is enough to *write* the reduction, it is enough to *grade* it. Through
`sim/inject.py`, which does not run rule 8 at all, `min(floors)` lets the guard
command a forced discharge to 20 % on a pack whose BMS cuts out at 35 %.
**What was added — no shipped behaviour changed, only what observes it:**
* **`test_peakguard.py` part 12c2** evaluates `binding_soc_floor()` on a roster
whose per-pack floors **all differ**. Four **simulated** cut-outs —
`30, 25, 40, 35` %, every one strictly above `peak_soc_floor: 20`, so
`pack_floor_pct()` selects the cut-out for each and `floors` *is* that tuple in
that order. The four candidate reductions are then four different numbers:
max = 40 <- the shipped answer
min = 25
first = 30 <- "grade the site against pack A", inside the function
last = 35
mean = 32.5
The extremes sit at **interior** roster positions (index 2 is the max, index 1
the min). A two-pack roster structurally cannot separate these — there `max`
and `last` coincide and so do `min` and `first` — and neither can three. Four
is the smallest roster that separates all four. The precondition is its own
check (habit 1), one pack is then raised past the old maximum (habit 7: the
**last** pack, which was neither extreme, so the line can only move if the
reduction read it), and `peak_soc_floor` is lifted above every cut-out to grade
the other side of the conjunction.
* **Part 12g is behaviour now, not source text** (MUTATION.md habit 4). Real
`Controller.cycle()`s on a two-pack roster, both packs held at the same SoC and
everything else identical, with the packs' own cut-outs as the only thing
varied:
cut-outs A 10 / B 18 % -> decide() got soc_floor=20 %, floor_limited=False
cut-outs A 10 / B 35 % -> decide() got soc_floor=35 %, floor_limited=True
The observable is `PeakDecision.soc_floor` — the argument, echoed back by the
shipped `decide()` — so the whole path from `Controller.inverters` to the
guard's argument is under test, and the site's **verdict flips** as well as the
number. `self.packs[:1]` answers 20 % both times. **Which** option key the floor
comes from stays pinned on the source in part 4, because that claim genuinely
is about the code's shape; that the option still governs is now graded
behaviourally too.
* ⚠️ **A THIRD SURVIVOR, FOUND WHILE FIXING THE FIRST TWO, AND RECORDED RATHER
THAN QUIETLY REPAIRED.** The first cut of part 12g varied only **pack B's**
cut-out — so `self.packs[-1:]`, grade the site against the **last** pack,
agreed with the full roster on both runs and stayed green across all 19 files
(+5 bytes). A two-run fixture that only ever moves the second pack pins `[:1]`
and not `[-1:]`; it is the same defect one step along, and it was in the fix
for this defect. Part 12g now runs the **same pair of cut-outs in both
orders**:
cut-outs A 10 / B 18 % -> soc_floor=20 %, floor_limited=False
cut-outs A 10 / B 35 % -> soc_floor=35 %, floor_limited=True
...the SAME PAIR SWAPPED, A 35 / B 18 % -> soc_floor=35 %, floor_limited=True
`[:1]` answers 20 % on the second run, `[-1:]` answers 20 % on the third, and
the roster answers 35 % on both. No single-position slice passes all three.
This is the mirror `12h` already ran on the SoC axis, which is where the shape
was borrowed from.
* ⚠️ **AND A FOURTH, from the same battery: the EMPTY-roster branch.** Rewriting
`binding_soc_floor([])`'s `float(soc_floor)` to `return None` (−43 bytes) was
green across all 19 files. It is defensive code — `Controller.inverters` is
never empty on a started controller, and `inputs_ok` refuses the cycle before
the guard is reached if it were — so nothing in the suite went near it. It is
**pinned rather than argued**, in both directions (the option when there is
one, `None` when there is not), because a branch with no check is a branch
that can be rewritten under a green suite. The check also records why it
cannot permit anything either way: `pack_floor_soc` is `None` at an empty
roster, and `discharge_permitted()` refuses on a `None` SoC whatever the floor
is.
* **`wired_over_limit()` gained a `prepare(ctl)` hook**, inside its retry loop so
every attempt grades the same site, which is the only window in which a second
inverter can join the roster before the telegrams are fed.
* **In-process mutation rows 22, 22b and 23** record all four survivors, so a
later edit that re-degenerates any of these fixtures goes red on a row rather
than on nothing.
### 3b. ⚠️ SECOND FIX ROUND — the fix carried the defect it was written to close
Round 2 closed both named survivors, and both of round 3's blockers are the
**same defect sitting inside that fix**: a check whose TEXT claims more than its
FIXTURE can observe. Applied to `app/` on `8501e05`, **all 19 files stayed
green** for both:
| mutation | Δ | the sentence that was too broad | why the fixture could not see it |
|---|---|---|---|
| `binding_soc_floor()`: `max(floors)` → `max(floors[-2:])` | **+5 bytes** | *"So the reduction reads **every entry**"* | 12c2's only counterfactual raised the **last** pack (`_deeper[-1]`), and **every window that ends at the roster's end follows the last pack**. On `(30,25,40,35)` both answer 40; on `_deeper` both answer 45. |
| the `decide()` call site: `self.packs` → `self.packs[:2]` | **+4 bytes** | *"what is reduced is the **ROSTER** and not a POSITION in it"* | all three `_floor_reaching_decide` runs drove a **two-pack** roster, where `self.packs[:2]` **is** `self.packs` — by exactly the argument 12c2 itself makes about two packs being unable to separate max/min/first/last. |
Round 2's *report* stated the narrow, true claim ("no **single-position** slice
passes all three runs"). The *check in the file* stated the broad one. That gap
is the whole of round 3.
**THE GENERAL LAW, stated once so the next card can use it:**
> **A fixture of N packs cannot separate two reductions that agree on N packs.**
> Two cannot separate max from min from first from last. Three cannot separate
> max from max-of-the-last-two. A single counterfactual that varies one position
> cannot separate the reduction from any window containing that position.
Its corollary is the reason this section is long: **the regress does not
terminate in a fixture.** Closing `[:1]` took two packs; `[:2]` took three;
`[:3]` takes four — and a four-pack run hands `[:4]` the same free pass. Round 3
therefore does two different things rather than adding one more pack.
**What was added — no shipped behaviour changed, only what observes it:**
* **Part 12c2 gained a ROSTER SET and a computed FAMILY TABLE.** The same four
simulated cut-outs with the **deepest moved to each of the four positions**,
and fourteen enumerated rewrites of `max(floors)` each checked against every
roster. The table is **computed and printed**, and a member with an empty kill
list takes the check **red naming it** — so a family member can no longer be
added without a fixture that reaches it. `max(floors[-2:])` is killed only by
the rosters whose deepest sits at index 0 or 1, which is precisely the data the
single last-pack counterfactual could not supply.
* **Part 12c2 gained a LENGTH SWEEP.** `binding_soc_floor()` is a pure function
over a list, which is the one place a sweep is cheap: **78 simulated rosters,
lengths 1 through 12, the deepest cut-out at every position of each.** That
closes *every* head window `max(floors[:w])` and *every* tail window
`max(floors[-w:])` of width **1 to 11** in one property rather than one
mutation at a time — and the check asserts that width **12 is NOT reached**, so
the limit is a red line rather than a silence.
* **Part 12g drives THREE-pack rosters**, with the deep cut-out at index 0, 1
**and** 2. Every positional slice of a three-pack roster is a proper subset of
`{0,1,2}` and so drops a position; the run whose deep pack sits there answers
the option, 20 %, where the roster answers 35 %. `self.packs[:2]` goes red on
the index-2 run. `_floor_reaching_decide()` now takes `*floors`, and the extra
units join through the same `second_inverter()` hook the two-pack runs use.
* **Part 12g gained a POSITIONAL-SLICE TABLE**, computed against the **real**
`Controller.packs` each of the six cycles actually ran and the **real**
`PeakDecision.soc_floor` it produced — so it measures the shipped call site's
own answer, not a re-derivation, and a survivor takes the check red.
* ⚠️ **AND A FIFTH AND SIXTH SURVIVOR, FOUND BY ROUND 3'S OWN BATTERY AND
CLOSED STRUCTURALLY RATHER THAN BY ONE MORE PACK.** `self.packs[:3]` (+4) and
`self.packs[-3:]` (+5) survive **all six** behavioural runs, because the
longest roster those runs drive is three packs long. Part 12g therefore also
pins the roster **argument's identity** on the AST: it must unparse to exactly
`self.packs`, which is false of every slice at every length. ⚠️ This is **not**
the substring test round 2 removed — `"self.packs" in expr` is *true* of every
slice; `ast.unparse(arg) == "self.packs"` is *false* of all of them. The two
halves are separated and each says what it reaches: the **runs** grade the
VALUE and reach every slice up to N=3; the **pin** grades the ARGUMENT'S
IDENTITY at any N and is blind to two things the runs are not — a rewrite of
what `Controller.packs` *returns* (the runs' `len(ctl.packs) == 3` catches
that) and the reduction *inside* `binding_soc_floor()` (12c2's).
* ⚠️ **AND ONE ROW IS RECORDED AS NOT CAUGHT.** A reduction over a window
**twelve packs wide** is indistinguishable from `max(floors)` on every roster
in the file, because the sweep stops at twelve. Unlike the call site there is
no identity claim to pin — the claim is semantic, "the maximum", and a shape
pin would refuse honest refactors of the expression. It is reported **with its
size** (it needs a 13-pack roster, on a site whose roster is ONE and whose
second unit is ~8 days out) rather than hidden.
* **Two rewrites are EQUIVALENT, not uncaught.** `max(set(floors))` and
`list(reversed(self.packs))` cannot change any answer — `max` is order- and
duplicate-invariant. The second is nonetheless red on the structural pin.
* **In-process mutation rows 24 – 27** record all four of round 3's findings,
including the not-caught one, so a later edit that re-degenerates any of these
fixtures goes red on a row rather than on nothing.
## 4. ⚠️ THE HONEST LIMITATION, and it is the reason this record exists
**Under any configuration rule 8 accepts, `binding_soc_floor()` answers
`peak_soc_floor` for every pack.** Rule 8 refuses `peak_soc_floor <= bms_soc_floor`
on *every* unit, so `max(peak_soc_floor, bms_i) == peak_soc_floor` everywhere the
add-on will actually start. The per-pack **floor** is therefore non-degenerate
only against a config rule 8 refuses.
This is not a defect and it is not hidden. It is the same argument
`validate.unit_caps_indexed()` makes about itself, in that function's own words:
> *"Rule 9(d) already refuses such a config so the two lists cannot desynchronise
> in shipped use — but 'unreachable' is a claim about today's rule ordering, not
> a property of this function, and `validate_startup_config()` is also
> `sim/inject.py`'s entry point."*
CFG-04's index desynchronisation is what happened the last time that reasoning
was left implicit. So the reduction is written, and it is graded on both sides:
`test_peakguard.py` part 12c sweeps **1775** rule-8-valid `(peak_soc_floor,
bms_soc_floor)` pairs and asserts the answer is the option in every one of them
(that is AC 5, measured over the whole option space rather than at the shipped
point), and then asserts that on the pair rule 8 *refuses* the answer is the
**cut-out** — because without that second line the sweep would be satisfied by
`soc_floor` typed straight through, and the mutation that deletes the `max()`
could never bite. It does bite: M2 on the PR.
**What IS live at a rule-8-valid roster** is the rest of the card: rule 8 now
refuses the two-model config that used to start; the permission is answered per
pack, so the alarm names the pack that stopped the site rather than "the
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis
Rounds 2 and 3 hardened the **floor** axis: the `max` inside
`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the
**sibling argument of the same call**:
```python
self.peak_guard = peakguard.decide(
...,
soc=self.pack_floor_soc, # MINIMUM <- round 4
soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3
```
`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with
the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as
its weaker half. On `52380c0`, with all 19 files green and verified again after
the rebase onto `3934ad7`:
| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now |
|---|---|---|---|
| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED |
| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED |
| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED |
| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED |
The suite's separating power on the SoC axis stopped at exactly **N = 2** —
precisely the state the floor axis was in before round 3, and which round 3
itself named as its blocker 2. A fixture of N packs cannot separate reductions
that agree on N packs.
**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest
pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. The
site is then graded for discharge *permission* against a pack 39 points fuller
than the emptiest, and spends a pack its own BMS stops — through
`sim/inject.py`, which runs no validator rule at all. The verdict flips with it:
11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes
no forced discharge, where the mutant is permitted and sizes a positive one.
### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure
SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE
DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and
nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that
one is measured*, off register `0x0560` on the reference GW5000S-BP.
So **production is degenerate on the floor axis**: both packs floor at the same
value, `max(10, 10) == 10`, and a differing floor exists **only in the
fixtures**. That is not an argument for simplifying the fixtures to matching
values — it is the argument for keeping them. `binding_soc_floor()`'s reduction
is observable *nowhere else*, and the moment a third pack, a replacement pack or
a different chemistry arrives it is observable in the field. **Anyone who
"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only
data this repository has that can see the reduction at all**; part 12c2's
precondition check goes red if they do, and this paragraph is why.
The **SoC axis has no such degeneracy**. Two batteries at the same state of
charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of
the pair that will be non-degenerate in the field from the day inverter B is
wired up.
### The treatment, transferred from round 3
* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at
every position of a three-pack roster and both positions of a two-pack one,
the observable read off `PeakDecision.soc` (the argument, echoed) rather than
off a spy or a grep. Cut-outs held flat across all of them, so the floor axis
cannot be what moved the answers.
* **the positional-slice table and the non-slice table** — computed against the
real `Controller.inverters` each cycle carried and the real decision it
produced, printed, with a member that nothing kills taking the check red.
* **the length sweep** — the real property on real rosters of 1 to 12 inverters,
the empty pack at every position. Closes head and tail windows of width 1-11
at once. Width 12 and wider is **honestly not reached**, and asserted to be
unreached so a later sweep that lengthens the rosters must rewrite the
sentence.
* **the exact structural pin** — three of them, because there are three
reductions over roster length in this one property: `vals` is built by one
comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces
the whole list; `min(... for v in vals)` reduces the whole list. Every slice
of either is red at **every** roster length, including the `[:3]` and `[:12]`
the runs and the sweep cannot see.
Unlike the floor axis, **there is no uncaught row here**: the claim at
`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right
instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum")
and a shape pin there would refuse honest refactors. That asymmetry is why one
carries an open row and the other does not.
## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded
This card has now been rejected three times for the same shape one step over, so
round 4 stopped closing members and enumerated the **family**. Every reduction
over `self.inverters` / `self.packs` in `Controller` was found from the AST and
mutated to a slice, one at a time, against the full 19-file suite.
| # | site | reduction | consumer | mutation | result |
|---|---|---|---|---|---|
| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) |
| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) |
| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) |
| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) |
| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) |
| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) |
| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) |
| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) |
| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) |
| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) |
| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) |
| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) |
| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) |
| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) |
| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) |
| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) |
| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) |
| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) |
| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) |
| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) |
| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below |
| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** |
**Rows 18-20 are the finding, and they are not this card's code.** All three are
`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and
all three survived a green 19-file suite for exactly the reason every blocker on
this card survived: *the only fixture that reached them was a roster of ONE,
where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has
built a two-inverter roster with only inverter B latched since SAFETY-30
shipped — the precise data all three needed — and none of them read it.
Row 18 is the one with teeth. A latched unit must take the whole roster past the
stale-input hold, because that unit's `failsafe` SET is the thing parking it;
main.py's own comment says *"the trip would stop acting the moment a poll was
missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's
SoC returns early, and B's last share stands for the heartbeat to re-emit.
test_watchdog.py part 8 now drives that with the latch at **each** position, plus
the no-latch counterfactual that proves the hold really fires.
**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there
is now red on 12j's enumeration, but I could not construct a *reachable* state in
which it changes an answer: `_grade_store_migration()` runs at startup, before
any cycle, so a unit is tripped in memory only if its own `unit/<name>/sign_tripped`
key was truthy — which makes `namespaced_latched` True and short-circuits the
disjunct. I believe it is an equivalent mutant at that call site rather than a
live hole, and I am recording the argument instead of asserting the conclusion.
**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u
in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in
one event-log line**. It is +4 bytes and no check bites. The refusal itself —
`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different
expression and *is* graded (test_peakguard.py part 10h-1b), so the site still
stops; what a `[:1]` costs is an operator being told one name when two units have
no writer. It is not a roster comprehension (it iterates `resolution.shares`), so
12j does not cover it and does not pretend to. Closing it needs a three-inverter
fixture with two writer-less entries, which is ALLOC-01's fixture and not this
card's. **Filed as open, with its size and its consequence, rather than closed
quietly or left unmentioned.**
### And the family closed rather than the member
`test_peakguard.py` part 12j enumerates the family from the AST and requires
every member to take the roster **whole**, so a new reduction over a slice is red
**on the line it is written**, at every roster length, with no fixture required.
That check is what would have caught rounds 2, 3 and 4's blockers on the round
they were written instead of one review round each; it is also what found rows
18-21.
⚠️ **AND IT WAS STILL TOO NARROW — see §4d.** Round 4's version enumerated the
26 roster comprehensions inside the `Controller` class, because that is where
the four known sites were. Round 5's blockers were four reductions in
`validate.py`, one module over, over a list *derived* from the roster three
calls away from it. The pin now parses every `.py` under `app/` and finds the
roster by transitive closure from one option key: **183 reductions across 9
modules.**
## 4d. ⚠️ ROUND 5 — the same family one MODULE over, and an instrument that discovers its own scope
Round 4 enumerated the family and closed it **inside the `Controller` class**,
because that is where the four known sites lived. The review found four more
survivors immediately, in `validate.py`, on a 19/19 green suite:
| site | mutation | delta | suite |
|---|---|---|---|
| `validate.py:779` | `min(caps)` → `min(caps[:1])` | +4 B | **GREEN** |
| `validate.py:779` | `min(caps)` → `min(caps[-1:])` | +5 B | **GREEN** |
| `validate.py:778` | `caps = [cap for _, cap in caps_ix[:1]]` | +4 B | **GREEN** |
| `validate.py:893` | `min(caps_ix[-1:], key=lambda p: p[1])` | +5 B | **GREEN** |
**None of them is an equivalent mutant — a safety refusal disappears.** With
`max_w: 5000` on inverter-a beside `2000` on inverter-b and
`sign_disagree_w: 3000`, the shipped validator answers one problem —
*"sign_disagree_w is 3000 W but max_w (inverter-b) is 2000 W, so the SAFETY-02
sign-disagreement watchdog can never fire"* — and every one of the four answers
**zero**. The site starts, with a watchdog reporting `armed` on the status page
that cannot trip on pack B. That is rule 5's own failure mode reached **through**
rule 5, which is the sentence `test_inverter.py:1657` says the rule exists to
prevent. Rule 6 loses its refusal the same way.
### Why round 4's pin could not see them, and why the fix is not four more checks
`caps` and `caps_ix` are not comprehensions over `self.inverters`; they are lists
**derived** from the roster, three function calls away from it, in a module the
enumeration never opened. Closing them by hand would have produced a fifth
rejection with the same shape one step over — that is the whole record of this
card. **A hand-written scope is the same defect as a hand-written fixture**
(MUTATION.md habit 12): a check whose data cannot reach the failure it names.
### The instrument
`test_peakguard.py` part 12j now parses **every `.py` under `app/`** off disk —
no module is named anywhere in the check — and finds the roster by transitive
closure from **one seed**, the `inverters` option key:
* a function whose returns are roster-carrying is **roster-producing**
(`roster_entries` → `build_roster` → `unit_caps_indexed` → `unit_caps`);
* a name assigned one carries it, **and so does a list accumulated in a loop
over one** — which is exactly what `caps`, `roster` and `problems` are, and
what round 4's rule could not express;
* an attribute or property answering one carries it (`self.inverters`,
`Controller.packs`, `Controller.units`, `live_units()`);
* **an argument at a call site taints that parameter in the callee**, which is
how the pin walks into `peakguard.binding_soc_floor`, `arbiter.resolve`,
`allocator.plan`, `circulation.circulating_w`, `maintenance.arbitrate` and
`store.migrate_maint_keys` without being told they exist;
* and **a slice of a roster is still a roster**, so a mutation cannot cut the
taint and hide every reduction downstream of itself. An **index** is one unit
and is not.
It then requires, at every place a roster-carrying value is reduced or iterated,
that the whole of it arrives: no `[:1]`, `[-1:]`, `[:2]` or `[0]` between the
roster and the reduction.
### What it found — and the hand-written list was wrong in BOTH directions
**183 reductions across 9 modules**, out of 23 `.py` files parsed:
`main.py` 96, `allocator.py` 24, `validate.py` 19, `arbiter.py` 14,
`circulation.py` 10, `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3,
`store.py` 3 — and 14 modules with no roster reduction at all.
The card's own suggested module list named `watchdog.py`, which reduces over no
roster at all (SAFETY-30's sign watchdogs are strictly per-unit — there is
nothing to slice), and did **not** name `maintenance.py` or `store.py`, which
both do. A pin built from that list would have made a vacuous claim about one
module and no claim at all about two others. That is the argument for computing
the scope, made with data rather than with an assertion.
The pin **independently rediscovers all four blockers** — it is told one option
key and nothing else — and finds a **fifth** beside them at
`validate.py:783`, the `min(caps_ix, key=...)` inside rule 5's message, which no
review round named.
### The counterfactual, at two depths
* **Per site, all of them.** Every enumerated site's own expression, in its own
scope, mutated with each of the four cuts this card has actually been defeated
by. **183 sites × 4 cuts = 732** mutated spellings; every one reported. The same run asserts
the predicate **accepts** the legitimate wrapped forms the package really uses
(`enumerate`, `zip`, `sorted`, `pack_permissions(packs, floor)`) and does not
treat a keyed lookup (`weights[u]` in `allocator._fill`) as a slice — which is
what four false SLICED reports cost before `shortens()` was written.
* **End to end, on the real files.** `[:1]` written into `app/validate.py` at
each blocker position, the **whole** analysis re-run over the mutated source,
and a sliced site required at that exact line — where the clean package
reports none. This is the check that would have taken PR #48 red four rounds
ago, with no fixture reaching it.
### The sweep, and the number that must not be quoted alone
All 183 sites were mutated to `[:1]`, one at a time, +4 bytes each, and each
mutant run against the full 19-file suite in `goodwe-addon-ci` (mounted `:ro`,
`python3 -B`, a fresh rsync per mutant; 0 `.pyc` in the tree before and after —
a read-only mount cannot write one, so TEST-15 is closed by construction here
rather than by discipline). **183 of 183 RED.**
⚠️ **On its own that is tautological**: with the pin in the tree every slice is
red *by the pin*. So all 51 mutants whose first red was `test_peakguard.py` were
re-run with the pin **neutralised** (`_SLICED` blanked), leaving only the
behavioural checks:
| | |
|---|---|
| **145 of 183** | a behavioural fixture in this repository separates the site from a slice of itself — red with the pin turned off |
| **38 of 183** | **nothing but this pin sees it.** Structurally closed by this card; behaviourally **UNGRADED**, and listed rather than counted as covered |
The 38: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1,
`circulation.py` 1, `maintenance.py` 1. Grouped by what a `[:1]` there costs:
* **The operator's page — 15, the largest group.** `Controller.checks`
(3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, 3400, 3458) and
`status_payload` (3556, 3566, 3578). A slice reports one unit's row where two
exist, or "1 of 2 live" reading 1 of 1. Nothing commands differently; what is
lost is the evidence a human uses to notice that it should.
* **The write path's own counts.** `write_setpoint` 1267/1272/1280 — three
`len(targets)` guards. The write itself iterates `targets` at 1287 and *is*
graded (test_inverter.py); these three are its log arithmetic and refusal
count.
* **The allocator's internals.** `_fill`'s zero-weight last-resort pool (365),
`plan`'s `dropped` (526, 653), `free` (584), `frac_order` (591). ALLOC-01
drives two and three units through the *weighted* pass; the last-resort pass
and the largest-remainder tie-break are reached, but not with enough units for
a slice to change the answer.
* **Startup and FSM plumbing.** `__init__` 235/351, `_only` 831,
`_maint_button` 805, `cycle`'s maintenance zip 1505, `send_heartbeats` 3015,
`check_output_clamp` 2445/2452, `check_circulating` 2701,
`maintenance.arbitrate` 547, `arbiter.site_resolve` 517,
`circulation.opposing` 242.
* **Two refusal-TEXT sites.** `validate._roster_problems`'s `len(entries)` (554)
and `refuse()`'s numbering loop (1263) — both shape the words of a refusal
that still happens.
* **`_grade_store_migration` (698)**, already row 21 of §4c, argued there to be
an equivalent mutant at that call site. The argument is unchanged and is still
an argument, not a proof.
⚠️ **None of the 38 is closed behaviourally by this card, and saying so is the
point.** Closing sites one at a time is what cost this card four review rounds.
The pin closes the **family** — a slice at any of them is red on the line it is
written — and this list is what a shape pin can never promise.
### And the behavioural half, because a shape pin cannot grade a meaning
The pin grades an **identity** ("the whole roster arrives"). `min` → `max` is a
zero-byte edit and invisible to it, forever. So `test_validate.py` grows a
**roster SET** for the three rules that reduce over `unit_caps_indexed()`:
rosters of width 2, 3, 4 and 5 with the one small cap at **every position** —
14 rosters × 3 rules = 42 runs of the real validator — plus a **computed family
table** of twelve candidate reductions (`max`, `first`, `last`, the mean, and
`[:1]`, `[-1:]`, `[:2]`, `[:3]`, `[1:]`, `[:-1]`, `[::2]`) each compared against
what the shipped validator actually said on the same roster. Only `min` agrees
everywhere; any member no roster kills is **printed as a named survivor** and
takes the check red.
### ⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
Found by reading the three branches side by side — **not** by the pin, and said
that way because it is the shape of what the pin cannot see. Rule 5's sibling
branch, `unit_sign_disagree_w` (SAFETY-30's per-inverter sign watchdog), was
graded against `_number(opts, "max_w", 2000.0)` — the **flat** option — three
lines under a branch that had already stopped doing that. Both directions were
measured before the fix:
* a roster of two 2000 W units beside a leftover `max_w: 5000` from a
single-inverter install, with `unit_sign_disagree_w: 3000`, produced **zero
problems**. The site started, the status page said `armed`, and the per-unit
watchdog could not trip on **either** unit. Identical sentence to the blocker,
reached through the branch beside it;
* and a roster of two 5000 W units with no flat key was **refused** at 3000 W
against a 2000 W *default the operator never wrote*, for a threshold both
units can reach.
It takes the same `weakest` the branch above takes, from the same list, and
names the same unit. At a roster of one `unit_caps()` is `[max_w]`, so the
shipped single-inverter refusal is the same comparison, the same boundary and
the same sentence. Both directions are asserted in `test_validate.py`.
### ⚠️ WHAT THE PIN CANNOT SEE — open rows, with sizes
1. **Semantics.** `min` vs `max` vs the mean: zero-byte edits, invisible to any
shape pin. Only the behavioural sets above can see them, and they only see
the rules they drive.
2. **Direct index sites that are not reductions.** `self.inverters[0]` in
`_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in
`maint_unit`, `entries[index]` in `_unit_label()`. Single-unit lookups, meant
to be, graded where they live.
3. **A subscript by a NAME.** `weights[u]`, `cap[u]`, `add[u]` in
`allocator._fill` are keyed lookups into a roster-derived mapping and are
accepted deliberately, so `caps[i]` with a variable `i` would pass. Not in
the family this card has been defeated by; buying it costs four false reports.
4. **A slice through an intermediate call.** `sorted(roster)[:1]` subscripts a
call result, not a roster reference. Nothing in `app/` is written that way
today; if something is, it would not be seen.
5. **Anything outside `app/`.** `tools/`, `sim/` and the checks themselves are
not parsed — and `sim/inject.py` reaches `validate_startup_config()`.
6. **The taint's own seam.** It crosses a call boundary only where the callee is
a plain function this package defines and the argument is positional or
keyword. A roster handed through `*args`, stored in a dict and read back, or
passed to a method on an unresolvable object, is lost at that hop — and
everything downstream goes **unenumerated** rather than reported unreachable.
That is the shape of the remaining exposure: not zero, and not measurable
from inside the check.
7. **`cycle()`'s undeliverable-unit log line** (row 22 of §4c) is still green at
+4 bytes, unchanged from round 4 and still filed open.
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
`peak_soc_floor` is still **one number for the site**, so the card's own
"or the deeper pack is needlessly withheld" half is not closed and this record
does not pretend it is. With cut-outs of 10 % and 18 %, rule 8 obliges the
operator to set the EMS floor above 18, and the 10 % pack is then held above its
own need. Closing that means `peak_soc_floor` becoming a per-entry key too, which
is a second option migration and a product decision about what the site-wide
number then means. The card scoped itself to `bms_soc_floor` and explicitly told
this developer not to widen it, so it is filed here as the open question it is
rather than smuggled in.
**Also still not in this card, and named by the shipped code already:** the
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
per-pack bound. Nothing here touched it.
## 6. What was changed, and one thing that was found on the way
* `config.yaml` — `bms_soc_floor: int(0,50)` required per `inverters:` entry;
the flat key and its default are untouched and are documented as the
single-inverter shape's own cut-out.
* `app/inverter.py` — `Inverter.bms_soc_floor`; `_entry_bms_floor()` (`None`, not
a borrowed number and not `0.0`); `_flat_bms_floor()`.
* `app/peakguard.py` — `Pack`, `pack_floor_pct()`, `pack_permissions()`,
`binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
one place the question is answered.
* `app/validate.py` — `unit_bms_floors_indexed()`; rule 8 grades every unit and
names it via the **roster** index the value came off.
* `app/main.py` — `Controller.packs`; the `decide(soc_floor=…)` reduction; the
floor-limited notification names the pack(s) at or below their own floor, at a
roster of two or more only.
* `tools/supervisor_shape_probe.py` — **re-run, and repaired.** MUTATION.md
habit 8: a committed transcript is a check and it decays. Making the key
required broke §C outright and, worse, left §D printing **PASS** on a refusal
about the *wrong key* — its "no `max_w`" case was answered `Missing option
'bms_soc_floor'`. §D is now an isolated counterfactual over a **merged,
accepted** baseline with exactly one key varied per case, asserting the key's
**name** in the refusal, and it carries a `bms_soc_floor` case in both
directions. Re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`:
`all cases passed`.
* `app/inverter.py`, found by the mutation battery — `_flat_bms_floor()` was
written with the default in **two** places (the `.get()` fallback *and* the
blank test). Deleting either one survived, because each covered for the other:
MUTATION.md's masking section in three lines of option reading. It has one
defaulting line now, and M12 takes six checks red.
* **Round 3 changed no `app/` file at all.** `test_peakguard.py` only:
parts 12c2 and 12g. Its battery was **46 mutations across
`binding_soc_floor()`'s reduction, the `decide()` call site, `Controller.packs`
and `pack_floor_pct()`'s conjunction** — bytecode purged and counted before and
after each (23 → 0). **43 red, 3 survivors, all three recorded above and in the
in-process rows**: `max(floors[:12])` and `max(floors[-12:])`, which need a
13-pack roster to observe and are reported with that size rather than hidden;
and `max(set(floors))`, which is **equivalent** — `max` is duplicate-invariant,
so it cannot change an answer. `list(reversed(self.packs))` is equivalent too
(`max` is order-invariant) and is nonetheless red on the structural pin.
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
physically exist. Every second-pack number in a fixture, in the probe and in
config.yaml's commented example is **simulated and labelled as such**; only the
10 % on inverter A is read off hardware. The three-pack runs added in round 3 are simulated
twice over: there is no inverter C at all, and the shallow slots carry inverter
A's measured 10 % while the 35 % cut-out that moves along the roster is invented.
---
## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0
Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring):
```
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" \
--entrypoint python3 \
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
/probe/supervisor_shape_probe.py
```
It reads `config.yaml` and runs Supervisor's own `AppOptions` validator in a
throwaway container. It touches no live Supervisor, POSTs nothing and installs
nothing.
⚠️ **RE-RUN AGAIN ON 2026-09-05**, because the rebase onto `67e649a` merged
SPIKE-04a's `max_w: 4600` into the same §C/§D blocks this card had rewritten,
and MUTATION.md habit 8 says a committed transcript that a document quotes is a
check. A hand-merged transcript is not a measurement of anything. The block
below is the output of that run, not of the 2026-09-04 one: B's rating reads
**4600** — its datasheet figure, still not a measurement of any unit — and every
case still passes.
```
== C. AC2: a two-inverter blob an operator would POST ==
PASS two inverters
[{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000, "bms_soc_floor": 10}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_so
== D. what the schema itself refuses, per entry ==
PASS the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere else to come from
[{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600, "bms_soc_floor": 10}]
PASS an entry with no name is refused, and the refusal NAMES it
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no setpoint_entity is refused, and the refusal NAMES it
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no max_w is refused, and the refusal NAMES it
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no bms_soc_floor (SAFETY-32) is refused, and the refusal NAMES it
Missing option 'bms_soc_floor' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with max_w over the range is refused, and the refusal NAMES it
value must be at most 5000.0
PASS an entry with bms_soc_floor over the range (SAFETY-32) is refused, and the refusal NAMES it
value must be at most 50.0
PASS a JSON STRING where the list belongs is refused
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
== E. the ROLLBACK direction, and the operator's UI ==
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
== all cases passed ==
```
+13 -2
View File
@@ -78,9 +78,20 @@ inverters:
setpoint_entity: number.goodwe_b_setpoint_w
soc_entity: sensor.goodwe_b_soc
batt_entity: sensor.goodwe_b_battery_power
max_w: 3000
max_w: 4600
```
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
GoodWe GW5048D-ES (serial `55048ESU22AW0992`, identified 2026-09-04) whose
datasheet gives max charging and discharging power as 4600 W; with nothing
connected to its PV inputs the battery path is the ceiling, not the 5000 VA AC
nameplate. The datasheet prints that number under *Battery Input Data*, so it
may be a DC figure, while `max_w` is graded against register `0x0518` — the
inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
`max_w: 4600` is still the right thing to configure, because the freeze rail is
`min(max_w, dev_max)` and the device range read off the unit is what binds.
`goodwe/NOTES.md` §1.1b records both readings and picks neither.
| per-entry key | required | meaning |
|---|---|---|
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
@@ -554,7 +565,7 @@ help - see "Why the tuning is what it is".
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
| `peak_cap_w` | The site's capacity-tariff target |
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start. ⚠️ **At a declared `inverters:` roster this key moves INTO each entry and is REQUIRED there** (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused **by name** at startup, and the flat key below is then not read at all |
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
On a capacity-tariff site the maintenance charge is capped by the headroom left
+63
View File
@@ -300,6 +300,69 @@ Cheap habits fall out of it, and all of them are now used in this repository:
assert the **shape** of that second report, not only its verdict, so the
check cannot silently revert to grading the first path.
10. **A fixture of N members cannot separate two reductions that agree on N
members — so grade the FAMILY, and grade it from the SOURCE.** This is the
only lesson SAFETY-32 cost four review rounds to learn, and it generalises
past packs to every roster, list and window in this repository. Two packs
cannot tell `max` from `min` from first from last. Three cannot tell `max`
from `max(x[-2:])`. **The regress does not terminate in a fixture**: `[:1]`
needs two members, `[:2]` three, `[:3]` four, and adding one more member
moves the hole rather than closing it. Three things follow, and all three
are now in `test_peakguard.py` parts 12c2, 12g, 12i and 12j:
* the fixture is a **SET** — the extreme at every position of the roster,
not one roster with the extreme somewhere;
* the family table is **COMPUTED and PRINTED**, so a member no fixture kills
shows up as a named survivor and takes the check red, instead of being
absent from a list nobody re-derived;
* where the claim is an **IDENTITY** ("this roster", "this list"), pin it
**exactly on the AST** — `ast.unparse(arg) == "self.packs"` is false of
every slice, where `"self.packs" in src` is true of all of them. Where the
claim is **SEMANTIC** ("the maximum"), a shape pin would refuse honest
refactors, so report the unreached width **with its size** instead.
11. **When a check of this shape is found, mutate its SIBLINGS before shipping
the fix.** SAFETY-32 was rejected three times for the same defect one step
over: the reduction, then the roster handed to it, then the reduction handed
to the *other argument of the same call*. Enumerating the family from the
AST and mutating each member took one afternoon and found **four more
survivors in code the card never touched** — three of SAFETY-30's
`any(i.sign_wd.tripped for i in self.inverters)` reductions, invisible
because the only fixture that reached them was a roster of ONE, where
`self.inverters[:1]` **is** `self.inverters`. Ask of every fix: *what is the
sibling of the thing I just hardened, and does any fixture separate it from
a slice of itself?*
12. **When the same shape has been found twice, STOP CLOSING SITES AND BUILD
THE INSTRUMENT — and make it DISCOVER its own scope.** SAFETY-32 was
rejected four times, and each round closed exactly the site it was handed:
the reduction, then the roster handed to it, then the *sibling argument* of
the same call, then the same family one *module* over, in validate.py.
Round 4 did enumerate — and enumerated inside the `Controller` class,
because that is where the four known sites lived. **A hand-written scope is
the same defect as a hand-written fixture**: it is a check whose data cannot
reach the failure it names, and it goes on passing while the thing it was
supposed to cover sits in the file nobody added to the list. Round 5's pin
parses every `.py` under `app/`, seeds itself from ONE option key, and
propagates through returns, accumulator loops, attributes and *call
arguments* to find the roster wherever it went — 183 reductions across 9
modules. It rediscovered all four blockers without being told where to look,
and it named two modules the hand-written list would not have contained
(`maintenance.py`, `store.py`) while *omitting* one the list did contain
(`watchdog.py`, which is strictly per-unit and reduces over no roster at
all). **The list was wrong in both directions**, which is exactly what a
computed scope is for.
13. **Say what the instrument CANNOT see, with sizes, in the same breath.** A
structural pin grades an **identity** ("this whole roster arrives"); it can
never grade a **meaning** (`min` vs `max` is a zero-byte edit and invisible
to it). Ship the pin AND the behavioural set, name the gap, and give every
unreached case its byte delta — `cycle()`'s undeliverable-unit log line
(+4 B, still green), a subscript by a *variable* rather than a constant, a
slice taken through an intermediate call, anything outside `app/`. **An
enumerated list with an honest gap is worth more than a claim of
completeness a reviewer falsifies in ten minutes**, and every round of this
card that claimed the latter was rejected for it.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
+80 -5
View File
@@ -15,6 +15,11 @@ The scalars, and what each one was silently asserting:
SAFETY-24's pack floor and arbitrage's bounds. An
average lets the emptier pack be driven below its own
floor; a maximum parks the fuller one.
`bms_soc_floor` SAFETY-32. ONE cut-out percent for every pack, and it
describes A BATTERY. Two packs of different models
have different cut-outs, so the flat key graded the
shallower pack against the deeper one's BMS - wrong
in the direction that empties a pack.
`batt` ONE battery power into the anti-windup saturation
detector - see the SITE/UNIT note at the bottom of
this docstring, because this one goes the OTHER WAY.
@@ -117,6 +122,12 @@ import math
from datetime import datetime
from .clamp import ClampResult
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
# shipped default and a literal here would be the fourth defaults site that
# ticket exists to prevent. peakguard imports arbiter/control/tariff and none
# of those imports this module, so the direction is one-way.
from .peakguard import PEAK_OPTION_DEFAULTS
from .store import unit_key
from .watchdog import SignWatchdog, SignWatchdogConfig
@@ -170,7 +181,7 @@ class Inverter:
def __init__(self, name, *, setpoint_entity="", soc_entity="",
batt_entity="", batt_invert=False, max_w=2000.0,
store=None, sign_cfg=None):
bms_soc_floor=None, store=None, sign_cfg=None):
self.name = str(name)
self.setpoint_entity = str(setpoint_entity or "")
self.soc_entity = str(soc_entity or "")
@@ -179,6 +190,19 @@ class Inverter:
# The supervised cap on what may be written to THIS setpoint entity.
# See the module docstring on why this is a number and not a `Tuning`.
self.max_w = float(max_w)
# SAFETY-32. THIS PACK'S OWN BMS CUT-OUT, in percent, or None when the
# roster entry did not state one.
#
# ⚠️ NOT DEFAULTED HERE, AND None RATHER THAN A NUMBER. A cut-out is a
# property of the battery in the cupboard; inventing one for a pack that
# did not state it is precisely this card's defect - a pack graded
# against a floor that belongs to a different battery - rebuilt in the
# constructor. `peakguard.pack_floor_pct()` treats None as an unknown
# floor and `discharge_permitted()` treats an unknown floor as NO
# permission, so the absence costs a shave; validate.py rule 8 refuses
# the config by name long before that, at startup.
self.bms_soc_floor = (None if bms_soc_floor is None
else float(bms_soc_floor))
# --- the device's own reported range, read from its own entity -------
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
@@ -339,8 +363,9 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
`inverters:` empty?
EMPTY the five flat keys - `setpoint_entity`, `soc_entity`,
`batt_entity`, `batt_invert`, `max_w` - describe the ONE inverter,
EMPTY the six flat keys - `setpoint_entity`, `soc_entity`,
`batt_entity`, `batt_invert`, `max_w`, `bms_soc_floor` (SAFETY-32)
- describe the ONE inverter,
exactly as the shipped scalars did. This is what every install
that upgrades into this version reads, without an operator
touching anything, and it is byte-identical to what DEV-02
@@ -353,8 +378,8 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
because "this unit's number came from somewhere else" is the
defect DEV-02 spent a card removing and a fallback here would
rebuild it in three lines. `name`, `setpoint_entity` and `max_w`
are required of every entry - the schema enforces it on the
rebuild it in three lines. `name`, `setpoint_entity`, `max_w` and
`bms_soc_floor` (SAFETY-32) are required of every entry - the schema enforces it on the
Supervisor path, `validate.py` rule 9 enforces it for sim/inject.py
and the checks, which reach this function with any dict at all.
@@ -380,6 +405,12 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
batt_entity=opts.get("batt_entity", ""),
batt_invert=bool(opts.get("batt_invert")),
max_w=float(opts.get("max_w", 2000)),
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
# - the one pack the flat options describe is the one pack whose
# cut-out `bms_soc_floor` states. Defaulted from CFG-03's single
# mirror exactly as validate.py rule 8 defaults it, so an install
# that never set the key reads the same 10 % in both places.
bms_soc_floor=_flat_bms_floor(opts),
store=store, sign_cfg=sign_cfg)]
roster = []
@@ -401,10 +432,54 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
# ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did
# not state is not the flat one, it is a rating rule 9 refuses.
max_w=_entry_max_w(entry),
# ⚠️ NOT `opts["bms_soc_floor"]`, FOR `max_w`'S REASON AND THIS
# CARD'S. An entry that omits its cut-out has NOT got the flat one;
# it has none, and rule 8 refuses it by name. Borrowing here would be
# SAFETY-32's own defect written in three lines - the shallower pack
# graded against the deeper pack's BMS.
bms_soc_floor=_entry_bms_floor(entry),
store=store, sign_cfg=sign_cfg))
return roster
def _flat_bms_floor(opts: dict):
"""The single-inverter shape's own cut-out, or None if it is unparseable.
Defaulted from `PEAK_OPTION_DEFAULTS` when the key is absent or blank, which
is `validate._number()`'s behaviour on the same key - the two have to agree
or the validator would grade a number the roster does not build.
"""
# ⚠️ ONE defaulting line, not two. The first cut wrote the fallback into the
# `.get()` AND into the blank test below it, and a mutation that deleted
# either one survived - each was covering for the other, which is
# MUTATION.md's masking section in three lines of configuration reading.
raw = opts.get("bms_soc_floor")
if raw is None or raw == "":
return float(PEAK_OPTION_DEFAULTS["bms_soc_floor"])
try:
return float(raw)
except (TypeError, ValueError):
return None
def _entry_bms_floor(entry: dict):
"""One entry's OWN cut-out, or None when it did not state a usable one.
None rather than a borrowed number and rather than 0.0 - `_entry_max_w()`
can answer 0.0 because a zero rating commands nothing, but a zero FLOOR is
the most permissive value the key has and would read as "this pack may be
emptied". Rule 8 refuses the missing key by name; until it does, None
withholds.
"""
raw = entry.get("bms_soc_floor")
if raw is None or raw == "":
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
def _entry_max_w(entry: dict) -> float:
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
+51 -3
View File
@@ -1065,6 +1065,26 @@ class Controller:
return None
return float(min(float(v) for v in vals))
@property
def packs(self) -> list:
"""SAFETY-32. Each pack as its own THREE numbers, in roster order.
`pack_floor_soc` above is the site's ONE verdict and stays exactly what
DEV-02 made it - the emptiest measured pack. This is the thing that
reduction cannot produce: by the time `min()` has answered, WHICH pack
the number came from and how far that pack was from ITS OWN cut-out have
both been thrown away, and two packs at 15 % against cut-outs of 10 % and
20 % have opposite answers that are indistinguishable afterwards.
⚠️ EVERY unit, live or not. Same reason as `inputs_ok`: a pack whose SoC
did not answer is a pack nothing can grade, and dropping it from this
list would let the site spend the packs that DID answer while one sat
unmeasured. `peakguard.discharge_permitted()` treats a None SoC as no
permission, which is where that must be decided.
"""
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
for inv in self.inverters]
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings.
@@ -1881,9 +1901,21 @@ class Controller:
# protect. None stays None: `decide()` treats it as floor-limited,
# which costs a shave rather than a pack.
soc=self.pack_floor_soc,
soc_floor=float(self.o.get(
# ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN
# CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is
# still read from that one key with CFG-03's one mirror behind it;
# what `binding_soc_floor()` adds is the conjunction with each pack's
# OWN `bms_soc_floor`, so the floor this site is graded against can
# never be below the deepest cut-out on the roster. MAXIMUM here
# pairs with the MINIMUM in `pack_floor_soc`: both reductions move
# the verdict towards refusing a shave and away from spending a pack.
# Under any config rule 8 accepts it IS the option, for every pack -
# asserted over the whole schema range in test_peakguard.py part 12
# rather than assumed, because "another rule refuses that" is a claim
# about rule ordering and sim/inject.py does not run rule 8 at all.
soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get(
"peak_soc_floor",
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])),
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))),
max_hold_s=self.p1.averager.max_hold_s,
# ⚠️ SAFETY-20. The device range goes in TOO, and this call site is
# the one that needed it most: `standing` is `last_write`, i.e. the
@@ -2596,6 +2628,22 @@ class Controller:
# notification that matters into a traceback.
_at_floor = ("at the floor" if g.soc_floor is None
else f"at {float(g.soc_floor):.0f} %")
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
# the decision was actually taken with, so this re-grades nothing and
# cannot disagree with the alarm it is printed inside. At a roster of one
# the sentence is omitted entirely and this notification is byte-for-byte
# what SAFETY-25 shipped - there is exactly one pack and naming it would
# be noise on every existing install.
_blocked = [name for name, why
in peakguard.pack_permissions(self.packs, g.soc_floor)
if why is not None]
_which = ("" if len(self.inverters) < 2 else
f"\n\nSAFETY-32 - WHICH PACK: this is graded per pack against "
f"that pack's own BMS cut-out (bms_soc_floor), and the "
f"pack(s) at or below their own floor right now are "
f"{', '.join(_blocked) if _blocked else '(none - see the reason above)'}"
f". The other packs on the roster are not the reason the guard "
f"stopped and raising their cut-out will not change this.")
await self.hass.call("persistent_notification", "create", {
"notification_id": "goodwe_peak_soc_floor",
"title": "GoodWe: the capacity-peak guard has run out of battery",
@@ -2615,7 +2663,7 @@ class Controller:
f"{_at_floor} - ordinary self-consumption has no SoC "
f"floor at all and will go on discharging past it, down to the "
f"pack's own BMS cut-out. What has stopped is the guard buying "
f"you a lower bill, not the pack being spent."),
f"you a lower bill, not the pack being spent." + _which),
})
async def check_circulating(self) -> None:
+127
View File
@@ -360,6 +360,29 @@ distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
a loop-side guard would be the third - at which point the migration buys enough
to be worth its cost.
------------------------------------------------------------------------------
SAFETY-32: ONE `bms_soc_floor` FOR TWO PACKS GRADES ONE AGAINST THE OTHER'S BMS
`bms_soc_floor` above describes A PACK, not a site, and it was a flat option
because there was one pack. At two, two batteries of different models have
different cut-outs, and one number for both is wrong in the direction that
empties a battery: the SHALLOWER pack (the higher cut-out) is protected by a
floor that belongs to the DEEPER one. So it became a roster key - `Pack` carries
each unit's own value, `pack_floor_pct()` conjoins it with the site's EMS floor,
and validate.py rule 8 grades EVERY unit and NAMES the one it refuses.
⚠️ `peak_soc_floor` DID NOT MOVE, AND THE RESIDUAL IS RECORDED RATHER THAN
HIDDEN. It is one number for the site, so with cut-outs of 10 % and 18 % the
operator must set it above 18 and the deeper pack is then withheld above its own
need. That half - a per-pack EMS floor - is NOT this card and must not be
smuggled in with it; DECISIONS/SAFETY-32-per-pack-bms-floor.md states it as the
open question it is.
⚠️ AND THE MIN() OVER MEASURED SoC IS STILL RIGHT. `Controller.pack_floor_soc`
reduces to the emptiest pack and this card does not touch it. What changed is
the FLOOR it is graded against, and the addition of a per-pack answer beside the
site's one verdict - see `pack_permissions()`.
------------------------------------------------------------------------------
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
@@ -572,6 +595,110 @@ def discharge_permitted(soc, soc_floor) -> str | None:
return None
@dataclass(frozen=True)
class Pack:
"""SAFETY-32. ONE pack's three numbers for the floor question, NAMED.
`name` the roster name, because a refusal that does not say WHICH pack
stopped the site sends an operator to the wrong device - the
same defect CFG-04's `unit_caps_indexed()` was written for.
`soc` THIS pack's measured state of charge. None when its sensor did
not answer, and None is not a full battery.
`bms_floor` THIS pack's OWN cut-out percent (`bms_soc_floor`), which is a
property of the hardware in the cupboard and NOT of the site.
None when the roster entry did not state one - see
`pack_floor_pct()` for why that is withheld and not 0 %.
⚠️ IT CARRIES THE CUT-OUT, NOT THE EMS FLOOR. `peak_soc_floor` is one number
for the whole site (the depth a money-saving discharge may never reach) and
stays a flat option; `bms_soc_floor` describes a battery and became a roster
key here, because two packs of different models have different cut-outs and
grading both against one of them is wrong in the direction that empties the
shallower one.
"""
name: str
soc: float | None
bms_floor: float | None
def pack_floor_pct(pack, soc_floor):
"""The percent THIS pack may not be spent below, or None if that is unknown.
`max(the site's EMS floor, this pack's own cut-out)` - the CONJUNCTION, and
the direction matters: the EMS floor is what the operator asked for and the
cut-out is what the hardware will do anyway, so a pack is protected by
whichever is higher. Taking the site floor alone is the shipped defect this
card removes (the shallower pack graded against the deeper one's cut-out);
taking the cut-out alone would throw SAFETY-24's margin away.
⚠️ AN UNSTATED CUT-OUT IS None, NOT ZERO, AND NOT THE FLAT OPTION. 0 % would
be a floor that permits everything - the most permissive value there is,
invented from an absence - and borrowing the flat `bms_soc_floor` is CFG-04's
"nothing is inherited" broken in one line, which is this card's whole subject
restated. `discharge_permitted()` already treats a None floor as NO
permission, so an entry that forgot the key costs a shave rather than a pack.
⚠️ AND UNDER A rule-8-VALID CONFIG THIS IS `soc_floor`, FOR EVERY PACK.
validate.py rule 8 refuses `peak_soc_floor <= bms_soc_floor` on EVERY unit,
so `max()` selects the EMS floor everywhere the add-on will actually start -
which is exactly why the reduction is written here rather than assumed at the
call site. "Another rule refuses that config" is a claim about today's rule
ordering, not a property of this function, and `validate_startup_config()` is
also `sim/inject.py`'s entry point. `unit_caps_indexed()`'s docstring records
the same argument, and CFG-04's index desynchronisation is what happened the
last time it was left implicit.
"""
if soc_floor is None or pack.bms_floor is None:
return None
return max(float(soc_floor), float(pack.bms_floor))
def pack_permissions(packs, soc_floor) -> list[tuple[str, str | None]]:
"""SAFETY-32. Per pack: None if it may be spent, else the reason it may not.
ONE `(name, reason)` pair per pack, in roster order, from the SAME
`discharge_permitted()` the site verdict uses - a second transcription of
"may this pack be spent" is a second answer to it, which is the argument
that made that predicate public in the first place.
⚠️ THIS IS THE OBSERVABLE THE MIN() CANNOT PRODUCE. `Controller.pack_floor_soc`
reduces the measured SoC to the emptiest pack BEFORE anything is graded, and
that reduction is right for the site's one verdict - but it has thrown away
which pack it came from and how far that pack was from ITS OWN floor by the
time the answer exists. Two packs at 15 % against floors of 10 % and 20 %
have opposite answers and are indistinguishable after the `min()`.
"""
return [(p.name, discharge_permitted(p.soc, pack_floor_pct(p, soc_floor)))
for p in packs]
def binding_soc_floor(packs, soc_floor):
"""The ONE floor `decide()` may grade `pack_floor_soc` against, at a roster.
MAXIMUM over the packs' own floors, and it is the conservative reduction that
pairs with the MINIMUM `pack_floor_soc` reduction: `min(soc) <= max(floor)`
is implied by "some pack is at or below its own floor", so this verdict is
never more permissive than grading each pack separately would be. The other
direction is not true and is not claimed - a site can be refused here while
every pack individually has headroom, which costs a shave and never a pack.
None when ANY pack's floor is unknown, for `Controller.site_dev_bound()`'s
reason and in the same direction: `discharge_permitted()` treats a None floor
as no permission, so an unknown floor withholds rather than defaults.
⚠️ AT N=1 THIS IS `soc_floor` ITSELF under any config rule 8 accepts, and
test_peakguard.py part 12 asserts that over the whole schema range rather
than assuming it.
"""
if not packs:
return None if soc_floor is None else float(soc_floor)
floors = [pack_floor_pct(p, soc_floor) for p in packs]
if any(f is None for f in floors):
return None
return max(floors)
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
max_hold_s=None, frozen: bool = False) -> PeakDecision:
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
+112 -25
View File
@@ -132,7 +132,7 @@ NOT READY banner already names the problem, (b) does not apply.
installed base is on a fixed or static tariff. Refusing to start without a
price feed would lock those sites out of the add-on entirely.
8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)]
8. `peak_soc_floor` at or below `bms_soc_floor`, ON ANY UNIT. [(a)]
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
is the EMS-side bound on how far down it may take the pack. The BMS has its
@@ -142,6 +142,12 @@ NOT READY banner already names the problem, (b) does not apply.
of binding while its status page reports one - rule 5's "a disarmed guard
that advertises itself as armed", on the guard whose failure mode is a pack
that is quietly never available when it is wanted.
⚠️ SAFETY-32: `bms_soc_floor` DESCRIBES A BATTERY, so at a roster it is a
per-entry key and this rule grades EVERY unit and NAMES the one it refuses.
One number for two packs of different models refuses the wrong config and
accepts the wrong one - cut-outs of 10 % and 30 % under `peak_soc_floor: 20`
used to start. A missing per-entry cut-out is refused here rather than
defaulted: a cut-out invented for a pack IS this defect.
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
equal, the EMS floor and the pack's cut-out act at the same percent, so the
@@ -386,6 +392,49 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
return caps
def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
`(roster index, the raw value as configured, the parsed float or None)`, one
triple per unit. The raw value travels too because the refusal quotes what
the operator actually wrote, and `!r` of a re-read `opts` key is the wrong
string the moment the value came off a roster entry.
⚠️ THE INDEX IS THE ROSTER'S, NOT THIS LIST'S, AND THAT IS CFG-04'S BUG
WRITTEN DOWN. Rules 5 and 6 fed a CAPS-list index into `_unit_label()`,
which indexes the ROSTER, so on a roster whose first entry was malformed the
refusal named the WRONG INVERTER - an operator sent to the wrong device by
the very message that exists to stop that. `unit_caps_indexed()` exists
because of it. This function goes one step further and SKIPS NOTHING: every
entry produces a triple, including the ones with no usable floor, because a
missing cut-out is itself what rule 8 has to refuse BY NAME. There is
therefore no second list for an index to desynchronise from.
⚠️ AND NOTHING IS INHERITED. At a declared roster the FLAT `bms_soc_floor` is
not read at all - CFG-04's rule, and this card's subject: a pack that borrows
a sibling's cut-out is graded against a battery it is not. At the flat shape
the flat key IS this one pack's cut-out, defaulted from CFG-03's single
mirror exactly as `inverter._flat_bms_floor()` defaults it.
"""
entries = roster_entries(opts)
if not entries:
return [(0, opts.get("bms_soc_floor"),
_number(opts, "bms_soc_floor",
PEAK_OPTION_DEFAULTS["bms_soc_floor"]))]
out: list[tuple[int, object, object]] = []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
# Rule 9(a)/(b) already refuses a non-dict entry by index, and it
# runs FIRST. Naming it twice would hand the operator two sentences
# about one line of YAML.
continue
raw = entry.get("bms_soc_floor")
out.append((index, raw,
None if raw is None or raw == ""
else _number(entry, "bms_soc_floor", None)))
return out
def _unit_label(opts: dict, index: int) -> str:
""""" at a single-inverter config, " (inverter-b)" at a roster.
@@ -757,21 +806,48 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# second inverter is added - which is the day nobody is watching the startup
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
# prevents is silent.
#
# ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32
# ROUND 5. This branch used to compare against `max_w = _number(opts,
# "max_w", 2000.0)`, the FLAT option, which on a roster config is either
# ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the
# site) or a leftover from a single-inverter install (so it compared against
# a number no unit has). Both directions were live and both were measured:
#
# a roster of two 2000 W units beside a leftover `max_w: 5000` and
# `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED,
# with a per-inverter watchdog reporting `armed` on the status page and
# incapable of tripping on EITHER unit. That is the identical sentence the
# branch above exists to refuse, reached through the branch beside it;
#
# and a roster of two 5000 W units with no flat key was REFUSED at
# `unit_sign_disagree_w: 3000` against a 2000 W default the operator never
# wrote, for a threshold both units can reach.
#
# `unit_caps()`'s own docstring already said why: there is no such thing as
# a site rating on the wire, and this watchdog grades ONE UNIT's standing
# command against THAT unit's `supervised` bound. So it takes the same
# `weakest` the branch above takes, from the same list, and names the same
# unit. At a roster of one `unit_caps()` is `[max_w]` and this is the
# identical comparison it always was - which is what keeps the shipped
# single-inverter refusal byte-for-byte what it was.
unit_sign_w = _number(opts, "unit_sign_disagree_w",
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
if caps_ix else "")
if unit_sign_w is None:
problems.append(f"unit_sign_disagree_w "
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
elif max_w is not None and max_w > 0 and unit_sign_w >= max_w:
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
problems.append(
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is "
f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
"watchdog can never fire: no inverter is ever commanded past max_w, "
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
"inverters that is the guard which catches a unit doing the "
"OPPOSITE of what it was told while its sibling covers for it at "
"the meter - the case the site-level watchdog is structurally "
f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W "
f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W "
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
"measured default), or raise max_w.")
@@ -871,28 +947,39 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# in-code mirror, pinned to config.yaml by test_peakguard.py, and main.py
# reads the same one - exactly as maintenance.MAINT_OPTION_DEFAULTS now is
# for the `maintenance_*` family.
#
# ⚠️ SAFETY-32 - GRADED ONCE PER UNIT, AND THE REFUSAL NAMES THE UNIT. One
# `bms_soc_floor` for two packs compares the EMS floor against whichever
# battery happened to be written into the flat key: a site with cut-outs of
# 10 % and 30 % and `peak_soc_floor: 20` STARTED, and the guard would then
# command a forced discharge that the second pack's BMS - not the operator's
# floor - is the thing that stops. That is the disarmed-backstop state this
# rule exists to refuse, reached through the rule itself. So the loop is over
# units, and `_unit_label()` is fed the ROSTER index the value came off (see
# `unit_bms_floors_indexed()`), because CFG-04 has just fixed the version of
# this that named the wrong inverter.
soc_floor = _number(opts, "peak_soc_floor",
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
bms_floor = _number(opts, "bms_soc_floor",
PEAK_OPTION_DEFAULTS["bms_soc_floor"])
if soc_floor is None or bms_floor is None:
problems.append(
"peak_soc_floor and bms_soc_floor must both be numbers (got "
f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})")
elif soc_floor <= bms_floor:
problems.append(
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced "
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
"the pack's protection rather than yours. The guard would report a "
"SoC floor on its status page while being incapable of ever being "
"the thing that stops the discharge, and the symptom of that is a "
"battery repeatedly sitting at the BMS limit with no record of why "
f"- which looks exactly like the SoC drift the maintenance cycle "
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
f"(the shipped default is "
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
"bms_soc_floor if it does not match this pack.")
for _ix, _raw, bms_floor in unit_bms_floors_indexed(opts):
where = _unit_label(opts, _ix)
if soc_floor is None or bms_floor is None:
problems.append(
f"peak_soc_floor and bms_soc_floor{where} must both be numbers "
f"(got {opts.get('peak_soc_floor')!r} / {_raw!r})")
elif soc_floor <= bms_floor:
problems.append(
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
f"(bms_soc_floor{where}) is {bms_floor:.0f} %, so SAFETY-07's forced "
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
"the pack's protection rather than yours. The guard would report a "
"SoC floor on its status page while being incapable of ever being "
"the thing that stops the discharge, and the symptom of that is a "
"battery repeatedly sitting at the BMS limit with no record of why "
f"- which looks exactly like the SoC drift the maintenance cycle "
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
f"(the shipped default is "
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
f"bms_soc_floor{where} if it does not match this pack.")
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
+46 -3
View File
@@ -56,23 +56,58 @@ options:
# soc_entity: sensor.goodwe_a_soc
# batt_entity: sensor.goodwe_a_battery_power
# max_w: 5000
# bms_soc_floor: 10
# - name: inverter-b
# setpoint_entity: number.goodwe_b_setpoint_w
# soc_entity: sensor.goodwe_b_soc
# batt_entity: sensor.goodwe_b_battery_power
# max_w: 3000
# max_w: 4600
# bms_soc_floor: 10
#
# ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A
# PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES,
# serial `55048ESU22AW0992`, whose datasheet gives Max. Charging / Discharging
# Power as 4600 W; nothing will be connected to its MPPTs, so the battery path
# is the ceiling and the 5000 VA AC nameplate is not. The 3000 W this example
# used to carry was a guess made before the unit was known. ⚠️ It is STILL not
# measured: the datasheet prints 4600 W under BATTERY INPUT DATA, so it may be
# DC, and `max_w` is graded against 0x0518 - the inverter's own AC power. If it
# is DC the AC ceiling is nearer 4324 W. SAFETY-20's `min(max_w, dev_max)` is
# what settles that once the device range is read; see goodwe/NOTES.md §1.1b,
# which records both readings and picks neither.
#
# ⚠️ AND THE `bms_soc_floor: 10` BESIDE IT IS FROM THE SAME PLACE - THE
# DATASHEET AND THE SERIAL, NOT A PACK ANYONE HAS READ. No second inverter
# exists on this site (SPIKE-04, ≈ 2026-09-12 for B); the cut-out must be read
# off the pack that is actually installed (register 0x0560 on the reference
# unit) before that pack is commanded. It reads 10 here because pack A reads
# 10, which makes this example DEGENERATE ON THE FLOOR AXIS: a site whose two
# packs floor at the SAME percent cannot separate `min`, `max`, first or last
# over the roster, and every SAFETY-32 fixture that DIFFERS the floors differs
# it for exactly that reason. Do not "simplify" those fixtures to match this
# example.
#
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
# rating became per-instance precisely so that no inverter is ever graded
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
# rebuild that defect in YAML. `name`, `setpoint_entity` and `max_w` are
# therefore REQUIRED of every entry (the schema below enforces it, and
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
# validate.py rule 9 enforces it again for the callers the Supervisor never
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
# SENSOR - `inputs_ok` already stops the site for that - and never means
# "use the other one's".
#
# ⚠️ SAFETY-32: `bms_soc_floor` IS PER ENTRY AND IS REQUIRED, NOT DEFAULTED.
# It is the percent THAT pack's own BMS cuts out at, and two batteries of
# different models do not share one. A default would be this add-on inventing
# a cut-out for a battery it has not been told about, which is the same defect
# as inheriting one: the shallower pack gets protected by a floor that belongs
# to the deeper pack, and validate.py rule 8 - which refuses a peak_soc_floor
# the BMS would win against - would be grading the wrong number. Rule 8 grades
# EVERY entry and names the one it refuses. The flat key below is unchanged and
# is the single-inverter shape's own cut-out.
#
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
# roster is declared is two config sites aimed at actuators, one of which
# nothing writes; if it names an entity an entry also names, SAFETY-28's
@@ -355,6 +390,13 @@ options:
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
# it: such a floor can never bind, the BMS wins, and the status page would
# report an energy backstop that does not exist.
#
# ⚠️ SAFETY-32 - THIS KEY DESCRIBES ONE PACK, so it is the SINGLE-INVERTER
# shape's number and nothing more. Declare an `inverters:` roster and each
# entry states its own `bms_soc_floor`; this one is not read at all and is
# not inherited by any entry. Rule 8 then grades every unit separately, which
# is the whole of that card: one cut-out for two packs of different models
# protects the shallower pack with the deeper pack's number.
bms_soc_floor: 10
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
@@ -500,6 +542,7 @@ schema:
- name: str
setpoint_entity: str
max_w: int(100,5000)
bms_soc_floor: int(0,50)
soc_entity: str?
batt_entity: str?
batt_invert: bool?
+41 -14
View File
@@ -192,10 +192,27 @@ class Hass:
A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
"batt_entity": "sensor.batt_a"}
# ⚠️ A HYPOTHESIS, NOT A MEASUREMENT. Inverter B does not exist; 3000 W is
# chosen only because it DIFFERS from A's 5000 W, which is what lets a check
# tell B's number from A's. SPIKE-04 measures the real one.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
# SAFETY-32. Each pack's OWN BMS cut-out percent, now a REQUIRED roster key.
# ⚠️ 10 IS MEASURED (register 0x0560 on the reference GW5000S-BP and the shipped
# `bms_soc_floor` default); 15 IS SIMULATED - there is no second pack. They
# DIFFER on purpose: a fixture whose two per-unit numbers are equal cannot show
# a check that read the wrong unit's, which is MUTATION.md habit 1 and the defect
# that has now bitten four consecutive cards. Both sit below the shipped
# `peak_soc_floor` of 20, so rule 8 accepts this roster.
A_BMS_FLOOR = 10
B_BMS_FLOOR_SIM = 15
A_RATING = 5000.0
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
@@ -1411,13 +1428,16 @@ _TWO_OPTS.pop("batt_entity")
_TWO_OPTS["inverters"] = [
dict(name="inverter-a", setpoint_entity="number.sp_a",
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
max_w=A_RATING),
max_w=A_RATING, bms_soc_floor=A_BMS_FLOOR),
# ⚠️ 3000 W IS A HYPOTHESIS AND IS MARKED AS ONE WHEREVER IT APPEARS. It is
# here because it DIFFERS from A's 5000 W, which is the only property any
# check below uses. SPIKE-04 measures the real device.
# ⚠️ AND SO IS ITS 15 % CUT-OUT (SAFETY-32). There is no second pack to read
# register 0x0560 off; the value is SIMULATED, and it differs from A's 10 %
# for MUTATION.md habit 1's reason and no other.
dict(name="inverter-b", setpoint_entity="number.sp_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
max_w=B_RATING_HYPOTHESIS),
max_w=B_RATING_HYPOTHESIS, bms_soc_floor=B_BMS_FLOOR_SIM),
]
_c9, _hass9 = built(_TWO_OPTS)
print(f" the roster built from `inverters:` -> {[repr(i) for i in _c9.inverters]}")
@@ -1493,14 +1513,19 @@ _decl9 = dict(ln.strip().lstrip("- ").split(": ", 1)
for ln in _entry9.splitlines()[1:] if ": " in ln)
print(f" the shipped per-entry schema: {_decl9}")
check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
"`setpoint_entity` and `max_w` are REQUIRED - no trailing `?` - so the "
"Supervisor refuses a half-written entry before the add-on sees it, and "
"an entry can never fall back on a sibling's rating because there is no "
"entry without one",
set(_decl9) == {"name", "setpoint_entity", "max_w", "soc_entity",
"batt_entity", "batt_invert"}
"`setpoint_entity`, `max_w` and `bms_soc_floor` are REQUIRED - no "
"trailing `?` - so the Supervisor refuses a half-written entry before the "
"add-on sees it, and an entry can never fall back on a sibling's rating "
"or on a sibling's BMS cut-out because there is no entry without one. "
"⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS "
"`int(0,50)?`, and the difference is the whole of that card: an optional "
"per-pack cut-out is a DEFAULTED one, and a default is one number for "
"two batteries - the shallower pack protected by the deeper pack's BMS",
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
"soc_entity", "batt_entity", "batt_invert"}
and not any(_decl9[k].endswith("?")
for k in ("name", "setpoint_entity", "max_w"))
for k in ("name", "setpoint_entity", "max_w",
"bms_soc_floor"))
and all(_decl9[k].endswith("?")
for k in ("soc_entity", "batt_entity", "batt_invert")))
@@ -1582,9 +1607,11 @@ check("...and two inverters on two DIFFERENT entities are accepted, because "
"that is the configuration this card exists for and a validator that "
"fires on the legitimate case gets switched off",
validate_startup_config(_opts(setpoint_entity="", inverters=[
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING),
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING,
bms_soc_floor=A_BMS_FLOOR),
dict(name="inverter-b", setpoint_entity="number.sp_b",
max_w=B_RATING_HYPOTHESIS)])) == [])
max_w=B_RATING_HYPOTHESIS,
bms_soc_floor=B_BMS_FLOOR_SIM)])) == [])
# --- the rest of rule 9 -------------------------------------------------------
_p_flat = validate_startup_config(_opts(inverters=[
+10 -3
View File
@@ -1059,9 +1059,16 @@ class Pub:
pass
# ⚠️ 3000 W FOR PACK B IS A HYPOTHESIS AND IS MARKED AS ONE EVERYWHERE IT
# APPEARS. Inverter B does not physically exist; the number is here only because
# it DIFFERS from pack A's 5000 W, which is the only property these checks use.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
A_RATING = 5000.0
FLAT_OPTS = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
File diff suppressed because it is too large Load Diff
+340 -2
View File
@@ -204,12 +204,17 @@ check("...and the refusal names the actuator and every writer aimed at it, so "
_PER_INVERTER_OK = {k: v for k, v in base().items() if k != "setpoint_entity"}
_PER_INVERTER_OK["inverters"] = [
{"name": "inverter-a", "setpoint_entity": "number.goodwe_setpoint",
"max_w": 2000},
"max_w": 2000, "bms_soc_floor": 10},
# ⚠️ INVERTER B DOES NOT EXIST. 2000 W here is the SHIPPED default repeated,
# not a rating measured off a second device - SPIKE-04 measures that when
# there is one to measure. Nothing in this file depends on the number.
# ⚠️ SAFETY-32 made `bms_soc_floor` a REQUIRED per-entry key, and the two
# values here DIFFER (10 / 15) rather than being copied. 15 % is SIMULATED -
# there is no second pack to read a cut-out off - and the reason it is not
# simply 10 is MUTATION.md habit 1: a fixture whose two per-unit numbers are
# equal cannot show a check reading the wrong unit's.
{"name": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b",
"max_w": 2000}]
"max_w": 2000, "bms_soc_floor": 15}]
check("two inverters with their own entities are ACCEPTED - one writer each, "
"no shared actuator, and that is CFG-04's whole purpose",
validate_startup_config(_PER_INVERTER_OK, -5000, 5000) == [])
@@ -828,6 +833,339 @@ check("...and MaintConfig's dataclass defaults carry no numeric literal either "
not re.search(r"^\s*(charge_w|discharge_w):\s*float\s*=\s*-?\d",
_srcs["maintenance.py"], re.M))
# --------------------------------------------------------------------------- #
print("SPIKE-04a: inverter B's documented rating, and the provenance beside it")
# ⚠️ WHY A DOCUMENTATION CARD LEFT A CHECK BEHIND. Inverter B's rating was
# written down in THREE places - config.yaml's commented roster example,
# DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator would
# POST" - as 3000 W, a figure nobody had measured, read off a datasheet, or
# sourced at all. It was wrong: SPIKE-04a (2026-09-04) identified inverter B
# from the owner's model number and serial as a GoodWe GW5048D-ES rated 4600 W
# on the battery path. Three transcriptions of one number drifting away from
# the truth together is this repo's recurring defect in its plainest form, and
# correcting them once WITHOUT PINNING THEM buys nothing: the next editor
# touches one of the three and the other two stay wrong, silently.
#
# ⚠️ AND THE PROVENANCE IS GRADED, NOT ONLY THE VALUE, WHICH IS THE HALF THAT
# MATTERS. 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT. The datasheet
# prints it under BATTERY INPUT DATA, so it is probably DC, while `max_w` is
# graded against 0x0518 - the inverter's own AC power - which would put the AC
# ceiling nearer 4324 W. An editor who tidies that caveat away leaves a paper
# number reading as an observation of hardware that does not exist yet, and
# THAT is the failure this card exists to prevent. So the caveat is what these
# checks reach for; a check that graded 4600 alone would watch the value and
# miss the lie.
#
# ⚠️ WHAT THIS DELIBERATELY DOES NOT GRADE: the test fixtures. Several files
# build a second inverter at 3000 W on purpose, because the property those
# checks consume is that B's rating is UNMISTAKABLY not A's, and the real pair
# (5000/4600) is a 400 W gap - narrower than `slew_w`. Those are illustrative,
# they say so at the line, and they are not statements of B's rating. The three
# sites below are the ones that ARE such statements.
_HERE = os.path.dirname(os.path.abspath(__file__))
_docs_txt = open(os.path.join(_HERE, "DOCS.md"), encoding="utf-8").read()
_probe_txt = open(os.path.join(_HERE, "tools", "supervisor_shape_probe.py"),
encoding="utf-8").read()
# One regex per site, each anchored on something only THAT site has, so a match
# cannot silently wander into a neighbouring blob. The probe's anchor is the
# operator blob's real entity name: its section D refusal entries use
# `number.b` and one of them carries a deliberate 9999, so an anchor loose
# enough to reach them would grade the wrong dict and still look green.
_b_rating = {
"config.yaml": re.search(
r"#\s+- name: inverter-b\b(?:[^\n]*\n)*?\s*#\s+max_w:\s*(\d+)", _txt),
"DOCS.md": re.search(
r"^ - name: inverter-b\b(?:[^\n]*\n)*?^ max_w:\s*(\d+)",
_docs_txt, re.M),
"supervisor_shape_probe.py": re.search(
r'"name": "inverter-b",\s*"setpoint_entity": "number\.goodwe_b_setpoint_w"'
r'(?:[^}]*?)"max_w":\s*(\d+)', _probe_txt),
}
check(f"all three documented rosters were FOUND "
f"{ {k: bool(v) for k, v in _b_rating.items()} } - a regex that matches "
"nothing is a green check that read nothing",
all(_b_rating.values()))
# ⚠️ THE `len(...) == 3` IS NOT DECORATION. Without it a regex that stopped
# matching would shrink this dict, and "all the values agree" would go GREEN on
# the two sites that were still read - a check whose data no longer reaches the
# failure it names, which is the exact fault this whole section is about.
_b_vals = {k: int(m.group(1)) for k, m in _b_rating.items() if m}
check(f"...and all three state the SAME rating for inverter B {_b_vals}",
len(_b_vals) == 3 and len(set(_b_vals.values())) == 1)
check(f"...and it is 4600 W - the GW5048D-ES datasheet figure, not the 3000 W "
f"hypothesis that predated the unit being identified {_b_vals}",
len(_b_vals) == 3 and set(_b_vals.values()) == {4600})
# The provenance triple. Each site must still say WHERE 4600 came from, that it
# is graded against an AC register, and what the DC-vs-AC ambiguity does to it -
# the last carried by the 4324 W figure, which is the one marker that cannot
# survive a careless "it's 4600 now, done" rewrite.
#
# ⚠️ SEARCHED IN A WINDOW AROUND THE ROSTER, NOT OVER THE WHOLE FILE, AND THE
# FIRST VERSION OF THIS CHECK DID THE LATTER. `0x0518` already appeared once in
# config.yaml and once in DOCS.md before this card, for unrelated reasons - so a
# whole-file `"0x0518" in src` was GREEN on those two files no matter what
# happened next to inverter B's rating. That is a check whose data cannot reach
# the failure it names, in the same file that exists to complain about them.
# The window is the text either side of the roster match, so deleting the caveat
# beside the number goes red even where the same word survives elsewhere.
_sites = {"config.yaml": _txt, "DOCS.md": _docs_txt,
"supervisor_shape_probe.py": _probe_txt}
_MARKERS = ("datasheet", "0x0518", "4324")
_prov = {}
for _name, _src in _sites.items():
_m = _b_rating[_name]
_win = _src[max(0, _m.start() - 2500):_m.end() + 2500].lower() if _m else ""
_prov[_name] = {k: k in _win for k in _MARKERS}
for _name, _got in sorted(_prov.items()):
check(f"{_name} still carries B's provenance WITHIN 2500 chars of the "
f"roster - the datasheet it came from, the 0x0518 it is graded "
f"against, and the 4324 W the DC reading would make it {_got}",
all(_got.values()))
# --------------------------------------------------------------------------- #
print("rules 5, 5b and 6 on a ROSTER SET - the smallest cap at EVERY position")
# ⚠️ SAFETY-32 ROUND 5, AND THE REASON THIS SECTION EXISTS AT ALL. PR #48 was
# rejected on FOUR MUTATIONS THAT SURVIVED A 19/19 GREEN SUITE, all of them in
# the three lines above:
#
# validate.py:779 min(caps) -> min(caps[:1]) +4 B GREEN
# validate.py:779 min(caps) -> min(caps[-1:]) +5 B GREEN
# validate.py:778 caps = [cap for _, cap in caps_ix[:1]] +4 B GREEN
# validate.py:893 min(caps_ix[-1:], key=lambda p: p[1]) +5 B GREEN
#
# NONE of them is an equivalent mutant: each one deletes a safety refusal.
# `max_w: 5000` on inverter-a beside `2000` on inverter-b with
# `sign_disagree_w: 3000` is refused by the shipped validator with "the
# SAFETY-02 sign-disagreement watchdog can never fire" and ACCEPTED by every one
# of the four - the site starts, with a watchdog that reports `armed` on the
# status page and cannot trip on pack B. That is rule 5's own failure mode
# reached THROUGH rule 5.
#
# WHY THE OLD FIXTURE COULD NOT SEE IT, which is the whole lesson and is now
# MUTATION.md habit 10: the fixture was ONE roster, `[5000, 2000]`. On that
# roster `min` and `last` and `[-1:]` and `[:2]` all answer 2000. A fixture of N
# members cannot separate two reductions that agree on N members, and adding a
# member moves the hole instead of closing it.
#
# SO THE FIXTURE IS A SET, NOT A ROSTER: the smallest cap at EVERY POSITION of
# rosters of width 2, 3, 4 and 5. `[:1]` needs width 2 to be caught, `[:2]`
# needs 3, `[:3]` needs 4 - the sweep is what terminates the regress that a
# single fixture cannot. And the FAMILY TABLE below is COMPUTED AND PRINTED, so
# a member no fixture kills shows up as a NAMED SURVIVOR rather than as an
# absence from a list nobody re-derived.
#
# ⚠️ THE DIFFERING CAPS ARE A FIXTURE PROPERTY AND MUST STAY ONE. In production
# both packs are expected to floor at 10 % and SPIKE-04a puts inverter B at
# 4600 W against A's 5000 W - a 400 W gap, narrower than slew_w. A fixture built
# on the real pair would be degenerate on exactly the axis these checks measure.
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (SPIKE-04, ~2026-09-12). Nothing
# in this section is measured off a second unit; the rosters are simulated and
# are labelled so.
_BIG, _SMALL = 5000.0, 2000.0
_RATE = 3000.0 # strictly between them, which is the point
def _roster_n(caps, **over):
"""A roster of len(caps) units, unit-0..unit-N, each with its own cap."""
o = base(**over)
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
o.pop(k, None)
o["inverters"] = [
dict(name=f"unit-{i}", setpoint_entity=f"number.sp{i}",
soc_entity=f"sensor.soc{i}", batt_entity=f"sensor.batt{i}",
max_w=c, bms_soc_floor=10)
for i, c in enumerate(caps)]
o.update(over)
return o
# The three rules that reduce over `unit_caps_indexed()`, each as
# (option name, the token its refusal is recognised by).
_CAP_RULES = (
("sign_disagree_w", "SAFETY-02 sign-disagreement"),
("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"),
("maintenance_charge_w", "maintenance charge in a cheap window"),
)
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
# THE SET: width x position of the one small cap.
_SET = []
for _n in (2, 3, 4, 5):
for _pos in range(_n):
_caps = [_BIG] * _n
_caps[_pos] = _SMALL
_SET.append((_n, _pos, tuple(_caps)))
# habit 1: the PRECONDITION as its own check. If a later edit makes the caps
# equal, this goes red rather than the claims below going quietly vacuous.
check("PRECONDITION: every fixture in the set has a UNIQUE smallest cap, that "
"cap is at the stated position, and the rate under test sits STRICTLY "
"between it and the others - two equal caps could not tell `min` from "
"`first` from `last` and the whole set would be measuring nothing",
len(_SET) == 14
and all(c[p] == _SMALL and c.count(_SMALL) == 1 and _SMALL < _RATE < _BIG
for _n2, p, c in _SET)
and {p for _n2, p, _c in _SET} == {0, 1, 2, 3, 4})
def _verdict(opts, token):
"""(refused?, the unit the message NAMES, the cap it QUOTES)."""
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
if not ps:
return (False, None, None)
m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0])
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
# --- the shipped answer, on every member of the set -------------------------
_rows = []
for _opt, _token in _CAP_RULES:
for _n, _pos, _caps in _SET:
_o = _roster_n(list(_caps), **dict(_QUIET, **{_opt: _RATE}))
_rows.append((_opt, _n, _pos, _caps, _verdict(_o, _token)))
print(f" {len(_CAP_RULES)} rules x {len(_SET)} rosters = {len(_rows)} runs "
f"of the REAL validator (rate {_RATE:.0f} W, caps {_SMALL:.0f}/"
f"{_BIG:.0f} W, simulated rosters - inverter B does not exist):")
for _opt in sorted({r[0] for r in _rows}):
_got = [(r[1], r[2], r[4]) for r in _rows if r[0] == _opt]
print(f" {_opt:<22} " + " ".join(
f"n{n}p{p}:{'REFUSE ' + str(u) if ok else 'starts'}"
for n, p, (ok, u, _c) in _got))
check("⚠️ EVERY ONE OF THE THREE CAP RULES REFUSES ON EVERY MEMBER OF THE SET, "
"and NAMES THE UNIT THE NUMBER CAME OFF - at width 2, 3, 4 and 5, with "
"the small cap at every position in turn. A reduction that answered "
"`first`, `last`, `[:1]`, `[-1:]`, `[:2]` or `[:-1]` instead of `min` "
"misses at least one member of this set, which is what four hand-run "
"mutations against a 19/19 green suite proved the single `[5000, 2000]` "
"fixture could not do",
all(ok and unit == f"unit-{p}" and cap == _SMALL
for _o2, _n2, p, _c2, (ok, unit, cap) in _rows))
check("...and it is a COMPARISON, not a blanket refusal of rosters: the same "
"three rates against a roster whose caps can ALL take them start "
"cleanly, at every width. Without this the line above is satisfied by a "
"validator that refuses everything (habit 2 in the negative direction)",
all(_verdict(_roster_n([_BIG] * n, **dict(_QUIET, **{opt: _RATE})),
tok) == (False, None, None)
for opt, tok in _CAP_RULES for n in (1, 2, 3, 4, 5)))
# --- the POSITIONAL-SLICE FAMILY TABLE, computed and printed ----------------
# ⚠️ COMPUTED, NOT LISTED. Each candidate is applied to the SAME caps the
# validator saw, and its (unit, cap) answer is compared against what the shipped
# validator actually said on that roster. A candidate that agrees everywhere is
# a reduction this fixture set CANNOT distinguish from the shipped one, and it
# is printed as a SURVIVOR rather than left out of a table nobody re-derived.
def _pick(caps, fn):
ix = list(enumerate(caps))
try:
i, c = fn(ix)
except (ValueError, IndexError):
return None
return (f"unit-{i}", c)
_FAMILY = {
"min (SHIPPED)": lambda ix: min(ix, key=lambda p: p[1]),
"max": lambda ix: max(ix, key=lambda p: p[1]),
"first": lambda ix: ix[0],
"last": lambda ix: ix[-1],
"min(caps[:1])": lambda ix: min(ix[:1], key=lambda p: p[1]),
"min(caps[-1:])": lambda ix: min(ix[-1:], key=lambda p: p[1]),
"min(caps[:2])": lambda ix: min(ix[:2], key=lambda p: p[1]),
"min(caps[:3])": lambda ix: min(ix[:3], key=lambda p: p[1]),
"min(caps[1:])": lambda ix: min(ix[1:], key=lambda p: p[1]),
"min(caps[:-1])": lambda ix: min(ix[:-1], key=lambda p: p[1]),
"min(caps[::2])": lambda ix: min(ix[::2], key=lambda p: p[1]),
"mean": lambda ix: (0, sum(c for _i, c in ix) / len(ix)),
}
_survivors = []
print(" the family table - each candidate against what the SHIPPED "
"validator answered on the same roster:")
for _name, _fn in _FAMILY.items():
_misses = []
for _opt, _n, _pos, _caps, (_ok, _unit, _cap) in _rows:
_pred = _pick(list(_caps), _fn)
_pred_refuses = _pred is not None and _pred[1] > 0 and _RATE >= _pred[1]
if _opt == "maintenance_charge_w":
_pred_refuses = (_pred is not None and _pred[1] > 0
and _RATE > _pred[1])
_said = ((True, _pred[0], _pred[1]) if _pred_refuses
else (False, None, None))
if _said != (_ok, _unit, _cap):
_misses.append(f"{_opt[:4]}/n{_n}p{_pos}")
if not _misses and _name != "min (SHIPPED)":
_survivors.append(_name)
print(f" {_name:<16} {'AGREES EVERYWHERE (survivor)' if not _misses else 'differs on ' + str(len(_misses)) + ' run(s), first: ' + _misses[0]}")
check("⚠️ AND THE FAMILY TABLE HAS NO SURVIVOR BUT THE SHIPPED REDUCTION. "
"Eleven candidates - max, first, last, the mean, and every positional "
"slice this card has actually been defeated by - are each computed "
"against what the real validator answered on the same roster, and every "
"one of them differs somewhere in the set. This is the check that would "
"have taken PR #48 red four rounds ago: a member no fixture kills is "
"PRINTED AS A NAMED SURVIVOR here instead of being absent from a list "
f"nobody re-derived. survivors: {_survivors}",
_survivors == [])
# --- the ROSTER OF ONE, where every one of them is the same thing -----------
_at_one = {n: _pick([_SMALL], f) for n, f in _FAMILY.items()}
print(f" at a ROSTER OF ONE the twelve candidates answer: "
f"{sorted({str(v) for v in _at_one.values()})}")
check("...and at a ROSTER OF ONE every candidate that still has a member to "
"reduce answers THE SAME THING - which is exactly why no "
"single-inverter fixture in this repository could ever have caught any "
"of this, and why the shipped single-inverter refusals are what they "
"always were. The only two that do NOT coincide are `caps[1:]` and "
"`caps[:-1]`, which EMPTY the list and would raise: at N=1 they are "
"crashes rather than silent wrong answers, so they are not survivors "
"there either",
{v for v in _at_one.values() if v is not None} == {("unit-0", _SMALL)}
and {n for n, v in _at_one.items() if v is None}
== {"min(caps[1:])", "min(caps[:-1])"}
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
"SAFETY-02 sign-disagreement")[0])
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
# PIN - and said that way on purpose, because it is the shape of what the pin
# cannot see. `unit_sign_disagree_w` is SAFETY-30's, not this card's. It was
# graded against `_number(opts, "max_w", 2000.0)`, the FLAT option, three lines
# under a branch that had already stopped doing that. Both directions were
# measured before the fix and both are asserted here.
_leftover = _roster_n([_SMALL, _SMALL],
**dict(_QUIET, unit_sign_disagree_w=_RATE))
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement")
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
"WATCHDOG SILENTLY. Two 2000 W units beside a `max_w: 5000` left over "
"from a single-inverter install and `unit_sign_disagree_w: 3000` used to "
"produce ZERO problems - the site started, the status page said `armed`, "
"and the watchdog could not trip on EITHER unit. It is refused now, "
"against the weakest unit's own cap, and the message names that unit",
_lv == (True, "unit-0", _SMALL))
check("...and the over-refusal in the OTHER direction is gone too: a roster of "
"two 5000 W units with NO flat `max_w` was refused at 3000 W against a "
"2000 W DEFAULT the operator never wrote, for a threshold both units can "
"reach. It starts",
_verdict(_roster_n([_BIG, _BIG],
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
"SAFETY-30 per-inverter sign-disagreement")
== (False, None, None))
check("...and the flat single-inverter config it always refused is refused "
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
"the same comparison, the same boundary and the same sentence",
why(validate_startup_config(base(max_w=2000, unit_sign_disagree_w=2000),
-5000, 5000), "unit_sign_disagree_w")
and validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
-5000, 5000) == [])
# --------------------------------------------------------------------------- #
print()
if fails:
+187 -3
View File
@@ -798,9 +798,18 @@ print(" circulation the allocator NEVER COMMANDED")
from app.inverter import Inverter # noqa: E402
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~10 days out and SPIKE-04
# measures it. Its rating here is a HYPOTHESIS, named as one, exactly as
# test_peakguard.py's helper names its own.
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~8 days out (2026-09-04) and
# the bench half of SPIKE-04 measures it.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
@@ -1809,6 +1818,181 @@ check("...and no `sign_site` claim can be filed at a roster of one, so the "
"arbitration is byte-identical to what shipped",
"sign_site" not in _c1s.reason)
print()
print("8. SAFETY-32 ROUND 4's ENUMERATION: the two `any(...)` reductions over")
print(" the roster whose only evidence is a latched NON-FIRST unit")
# ⚠️ THIS SECTION IS NOT SAFETY-32's SUBJECT, AND IT IS HERE BECAUSE OF IT.
# SAFETY-32 was rejected three times for the same shape one step over: a
# reduction over roster length whose fixture could not separate it from a slice
# of the roster. Round 4 therefore ENUMERATED every such reduction in
# `Controller` - min, max, sum, any, all, sorted, [0], [-1] over
# `self.inverters` / `self.packs` - and mutated each to `[:1]` to find out which
# were GRADED. Two survived a green 19-file suite, both `any(i.sign_wd.tripped
# for i in self.inverters)`, both SAFETY-30's, and this block is what closes
# them. No shipped behaviour changes; the reductions were already right.
#
# main.py `cycle()`, the stale-input hold - +4 bytes, 19/19 GREEN
# main.py `publish()`, the status vocabulary - +4 bytes, 19/19 GREEN
#
# ⚠️ WHY THE SUITE COULD NOT SEE THEM, and it is the same sentence every time:
# the only fixture that reached either was a roster of ONE, where
# `self.inverters[:1]` IS `self.inverters`. Part 3 above builds a two-inverter
# roster in which ONLY inverter B is latched - the exact data both reductions
# needed - and neither of them was read on it. A fixture of N units cannot
# separate reductions that agree on N units, one card over.
#
# ⚠️ AND BOTH ORDERS ARE RUN, for 12g's reason: a single "only B is latched"
# fixture kills `[:1]` and leaves `[-1:]` - grade the roster by its LAST unit -
# answering identically. The pair is what leaves no single-position slice.
async def _latched_then_input_gap(reversed_idx):
"""Latch ONE unit's sign watchdog, then take a SIBLING's reading away.
⚠️ A REAL LATCH FROM A REAL EPISODE, not `sign_wd.tripped = True`: the same
`_SignHass` plant and the same `_drive()` part 3 uses, so the detector
reaches its verdict through the shipped path. Then one pack's SoC goes
missing for LESS than `stale_input_s`, which is the state the hold exists
for - a single missed poll must not become a fault.
THE FAULT THIS GRADES: a LATCHED unit must take the whole roster past that
hold, because the unit's `failsafe` SET is the thing parking it. Return
early and the unit's last share stays standing for the heartbeat to
re-emit - main.py's own comment: "the trip would stop acting the moment a
poll was missed". A reduction that looked at one roster POSITION would do
exactly that whenever the latched unit was not the position it looked at.
"""
hass = _SignHass(load_w=2000.0, reversed_units={reversed_idx})
c = _sign_site(hass)
await _drive(c)
sibling = c.inverters[1 - reversed_idx]
sibling.soc = None
c.inputs_bad_since = datetime.now(timezone.utc)
await c.cycle()
return c
_gap_b = asyncio.run(_latched_then_input_gap(1))
_gap_a = asyncio.run(_latched_then_input_gap(0))
for _tag, _c, _i in (("B latched", _gap_b, 1), ("A latched", _gap_a, 0)):
_pl = [p for p in (_c.allocation.plans if _c.allocation else ())
if p.unit == _c.inverters[_i].name]
print(f" {_tag}, sibling's SoC missing 0 s: inputs_ok="
f"{_c.inputs_ok}, reason={_c.reason!r}, latched unit target="
f"{_c.inverters[_i].target}, why={_pl[0].why if _pl else '(no plan)'}")
check("PRECONDITION (habit 1): on BOTH runs the site really is in the state "
"the hold is for - one pack's reading is missing, `inputs_ok` is False, "
"the gap is younger than `stale_input_s`, and the SITE latch is NOT "
"tripped, so the only thing that can carry the roster past the hold is "
"the per-unit `any()`. And exactly ONE unit is latched on each run, at "
"the OTHER position",
_gap_b.inputs_ok is False and _gap_a.inputs_ok is False
and _gap_b.site_sign_wd.tripped is False
and _gap_a.site_sign_wd.tripped is False
and [i.sign_wd.tripped for i in _gap_b.inverters] == [False, True]
and [i.sign_wd.tripped for i in _gap_a.inverters] == [True, False])
check("⚠️ A LATCHED UNIT TAKES THE ROSTER PAST THE STALE-INPUT HOLD FROM EITHER "
"POSITION. Neither cycle parks on `inputs missing`; both go on to file "
"claims, and the latched unit is commanded 0 W by its OWN `failsafe` "
"claim, named in the allocation record. Grade this with "
"`self.inverters[:1]` and the B-latched run returns early - the +4-byte "
"mutation that was GREEN across all 19 files - and with "
"`self.inverters[-1:]` the A-latched run does. The remedy stops acting "
"the moment a poll is missed, which is main.py's own sentence for it",
not (_gap_b.reason or "").startswith("inputs missing")
and not (_gap_a.reason or "").startswith("inputs missing")
and _gap_b.inverters[1].target == 0.0
and _gap_a.inverters[0].target == 0.0
and all(any(p.unit == c.inverters[i].name and "failsafe" in p.why
for p in (c.allocation.plans if c.allocation else ()))
for c, i in ((_gap_b, 1), (_gap_a, 0))))
# ...and the counterfactual, which is what makes the two lines above a
# DIFFERENCE rather than a description: with NOTHING latched, the very same
# input gap DOES park the site on the hold. Without this the checks above would
# be satisfied by a `cycle()` that never had a hold at all.
async def _gap_no_latch():
c = _sign_site(_SignHass(load_w=2000.0))
await _drive(c, n=2)
c.inverters[0].soc = None
c.inputs_bad_since = datetime.now(timezone.utc)
await c.cycle()
return c
_gap_none = asyncio.run(_gap_no_latch())
print(f" nothing latched, same gap: reason={_gap_none.reason!r}")
check("...and the counterfactual (habit 7): with NO unit latched the SAME gap "
"DOES hold the site - `inputs missing 0s` - so the two runs above show "
"the latch carrying the roster past a hold that is really there, rather "
"than a hold that never fires",
(_gap_none.reason or "").startswith("inputs missing")
and not any(i.sign_wd.tripped for i in _gap_none.inverters))
# --- and the SECOND survivor: the status vocabulary publish() hands the broker
# ⚠️ THE EXISTING CHECK IS AT A ROSTER OF ONE, which is why this one exists.
# "MQTT's controller-status sensor reports 'failsafe'" above drives a
# single-inverter Controller, where `self.inverters[:1]` is the whole roster.
# A site at 0 W reporting `running` is the failure main.py's comment names, and
# at two inverters it is reachable whenever the latched unit is not the one a
# narrowed reduction happens to read.
_pub = {}
def _published(ctl):
_pub.clear()
ctl.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v),
"close": lambda _s: None})()
ctl.publish()
return dict(_pub)
_pb, _pa = _published(_gap_b), _published(_gap_a)
_pn = _published(_gap_none)
print(f" published status: B latched -> {_pb.get('status')!r}, "
f"A latched -> {_pa.get('status')!r}, none latched -> "
f"{_pn.get('status')!r}")
check("⚠️ AND `publish()` SAYS `failsafe` WHICHEVER UNIT IS LATCHED. The status "
"sensor is the vocabulary every dashboard already reads, and a site with "
"one inverter parked by its own failsafe claim reporting `running` is "
"the exact failure main.py's comment names. `self.inverters[:1]` "
"publishes `running` on the B-latched site - +4 bytes, GREEN across all "
"19 files - and `self.inverters[-1:]` does it on the A-latched one",
_pb.get("status") == "failsafe" and _pa.get("status") == "failsafe")
check("...and the counterfactual: the same two-inverter site with NOTHING "
"latched publishes `running`, so the two above are a difference the "
"reduction produced and not a constant",
_pn.get("status") == "running" and _gap_none.auto is True)
# --- and the THIRD one the same enumeration found: the OPERATOR'S BANNER -----
# ⚠️ `status_payload()`'s failsafe branch is a fourth `any(... for i in
# self.inverters)`, and `self.inverters[:1]` there was +4 bytes and GREEN across
# all 19 files too - for the third time, because the only fixture that reached
# it was a roster of ONE. The banner is the ONE sentence at the top of the page;
# a site holding an inverter at 0 W under a "Stopped" or "NOT READY" headline
# sends the operator to the wrong question.
_bn_b = _gap_b.status_payload()
_bn_a = _gap_a.status_payload()
_bn_n = _gap_none.status_payload()
print(f" banner: B latched -> {_bn_b['banner']!r}")
print(f" A latched -> {_bn_a['banner']!r}")
print(f" none -> {_bn_n['banner']!r}")
check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED, at "
"level `bad`, with the N>=2 sentence that names the unit's OWN measured "
"power rather than the meter. `self.inverters[:1]` drops the headline on "
"the B-latched site and `self.inverters[-1:]` on the A-latched one, and "
"the page then leads with whatever the next branch says",
_bn_b["level"] == _bn_a["level"] == "bad"
and _bn_b["banner"] == _bn_a["banner"]
and _bn_b["banner"].startswith("FAILSAFE")
and "its own measured power" in _bn_b["banner"])
check("...and the counterfactual again: the same site with nothing latched "
"carries neither that level nor that sentence, so the banner really is "
"being produced by the reduction under test",
not _bn_n["banner"].startswith("FAILSAFE")
and _bn_n["banner"] != _bn_b["banner"])
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
@@ -40,8 +40,24 @@ DECISIONS/CFG-04-inverter-roster.md:
reconstruction happens to lack FIRST, which is an artefact of the
reconstruction and NOT evidence about `inverters`. B is that evidence.
C. A two-inverter blob validates, per-entry types enforced.
D. An entry missing `name`, `setpoint_entity` or `max_w` is refused BY NAME,
and a JSON string where the list belongs is refused outright.
D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is
refused BY NAME - one key varied from an ACCEPTED baseline, and the key's
own name asserted in the refusal - and a JSON string where the list belongs
is refused outright.
⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED
per-entry key. That change broke §C and, worse, left §D printing PASS on a
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
now carries the key and §D is an isolated counterfactual asserting the key name.
⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's
rebase onto SPIKE-04a merged two independent edits into §C and §D - the required
`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched
together by hand from two runs is not a measurement, so this one was measured:
`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a
reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it
differs from A's 10 so that the per-entry number is exercised at all, while
production is expected to have BOTH packs at 10 %.
"""
import sys, json, copy
sys.path.insert(0, "/usr/src/supervisor")
@@ -145,13 +161,47 @@ show("the same blob MERGED with these defaults - what App.options actually is",
f"max_w={out.get('max_w')!r}" if ok else out)
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
# nameplate is wrong for this purpose too, because nothing will be connected to
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
#
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
# read off the unit. So this blob is what an operator would POST TODAY, not a
# claim about what inverter B will turn out to do.
#
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
# This file's own §B is the record of what an instrument that cannot distinguish
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
# pair instead of the real one.
#
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
# blob is also what proves an operator can still POST a two-inverter config
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
# not exercise a per-entry number at all, for the same reason the two ratings
# must stay different.
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
# - there is no second pack on this site, and in production BOTH packs are
# expected to floor at 10 %, which is why the differing pair lives here and in
# the fixtures rather than in config.yaml's example.
TWO = dict(LIVE, setpoint_entity="", inverters=[
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
"max_w": 5000},
"max_w": 5000, "bms_soc_floor": 10},
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
"max_w": 3000}])
"max_w": 4600, "bms_soc_floor": 15}])
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
# The validated blob, for anyone who wants to POST it. Optional: the probe's
@@ -162,14 +212,38 @@ except OSError:
pass
print("\n== D. what the schema itself refuses, per entry ==")
for title, entry in (
("no name", {"setpoint_entity": "number.b", "max_w": 3000}),
("no setpoint_entity", {"name": "inverter-b", "max_w": 3000}),
("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}),
("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})):
ok, out = validate(dict(TWO, inverters=[entry]))
show(f"an entry with {title} is refused", not ok, out)
ok, out = validate(dict(TWO, inverters="[]"))
# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY
# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this
# section. These entries used to omit several keys at once and assert a bare
# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went
# on printing PASS while the refusal it printed said `Missing option
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
# anything, so the baseline below is COMPLETE and each case deletes exactly one
# key - or, for the range case, moves exactly one value out of range.
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600,
"bms_soc_floor": 10}
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
# start_hour` on the run that caught it), and a baseline refused for one of THOSE
# is the provenance problem §B2 is kept as a warning about.
D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere "
"else to come from", ok, json.dumps(out.get("inverters")) if ok else out)
for title, entry, expect in (
("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"),
("no setpoint_entity",
{k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"),
("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"),
("no bms_soc_floor (SAFETY-32)",
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"),
("bms_soc_floor over the range (SAFETY-32)",
dict(D_FULL, bms_soc_floor=99), "at most 50")):
ok, out = validate(dict(D_BASE, inverters=[entry]))
show(f"an entry with {title} is refused, and the refusal NAMES it",
(not ok) and expect in str(out), out)
ok, out = validate(dict(D_BASE, inverters="[]"))
show("a JSON STRING where the list belongs is refused", not ok, out)