Compare commits

..
8 Commits
Author SHA1 Message Date
adminandClaude Opus 5 a17fcf5b86 SAFETY-32 round 4: the same family at the SIBLING ARGUMENT, and the family enumerated
checks / unit (pull_request) Successful in 4m53s
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 00:05:09 +02:00
glenn schrooyenandClaude Opus 5 59ab73559e 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-04 21:42:07 +02:00
glenn schrooyenandClaude Opus 5 619b218110 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-04 21:42:07 +02:00
adminandClaude Opus 5 041ec0a61b 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-04 21:42:07 +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
adminandClaude Opus 5 905b6fd2b3 MAINT-05 fix round 2: the refusal has to survive the NEXT start, and both marker guards have to be visible
checks / unit (pull_request) Successful in 5m14s
REJECTED because the fix round's own load-bearing sentence was FALSE, and the
whole suite was blind to that. `_verify_moves()` produced the red the reviewer
reproduced - that half stood - but DECISIONS §2 claimed "`ok` gates the marker,
so a misdirected migration is not stamped and the next start runs it again ...
a re-run is recoverable". THE REFUSAL LASTED EXACTLY ONE START.

MEASURED, Controller level, ONE persisted store, roster ["garage","shed"], flat
last_completed 2026-08-07, call site still handing `inverter-a` on both starts:

    construction 1: ok=False misdirected=4 moved=4 kept=0   marker=<absent>
    construction 2: ok=True  misdirected=0 moved=0 kept=4   marker=1
    history parked under unit/inverter-a/...; marker stamped after ONE restart.

WHY. `_verify_moves()` graded only `rep.moved`. On start 2 the same wrong call
site finds the four keys already present, so rule 1 routes every one of them to
`rep.kept`, `rep.moved` is EMPTY, the roster loop iterates over nothing,
`misdirected` is empty and `ok` is True. Start 3 then takes the
`from_version >= MAINT_SCHEMA_VERSION` early return FOREVER - including after
the call site is fixed. Loud-and-recoverable became silent-and-permanent after
one restart: the exact state store.py's header says must never happen, reached
by the fix written to prevent it.

WHY THE CHECK MISSED IT - the recurring defect, this time INSIDE the remedy.
Part 9b's recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` - the
CORRECTED name. It measures recovery along the one path where the operator has
ALREADY fixed the bug. Nothing in the 19 files called `migrate_maint_keys()`
twice with the SAME WRONG `unit`. The check was true and passed for the wrong
reason.

WHAT CHANGED

1. app/store.py `_verify_moves()` grades `set(rep.moved) | set(rep.kept)`.
   Sound by construction: every entry in both lists is a `unit_key(unit,
   legacy)` built from the SAME `unit` argument, so grading `kept` grades the
   same namespace decision `moved` does. A correct call site's `kept` entries
   sit under a roster name and pass; a wrong one cannot launder its namespace by
   having already written it. `migrate_unit_keys()` passes `roster=None` and
   returns before the loop, so it is untouched.

2. test_maintenance.py part 9c - THE CHECK THAT WAS MISSING. Two starts, SAME
   wrong unit, ONE store: the marker is still withheld on the second, the
   refusal names the same roster, the flat history is still there to recover
   from, and a third start with the CORRECTED name still gets a run (which a
   stamped marker would have made impossible). Its PRECONDITION asserts the
   SHAPE of the second report - `moved` empty, `kept` full - so it cannot
   quietly revert to grading 9b's path. Counterfactual beside it: the same two
   calls with the right name are accepted, then skipped as `already at schema`.

3. test_maintenance.py part 9d - THE OTHER GUARD ON THE SAME LINE, and a
   survivor nothing in the repository could see. `if rep.ok:` is
   `not lost and not misdirected`; every check that reached the marker did so on
   a store whose `kept` was empty, so

       if rep.ok:        ->      if rep.ok or rep.kept:        (+12 B)

   stamped the marker on a migration that LOST A MONTH OF HISTORY - defeating
   the older, settled `lost` guard as well as the new one - and ALL 19 SUITES
   WERE GREEN on it (measured at 65cddf3, purged, :ro). 9d's fixture is the one
   shape that separates the guards: two keys already namespaced (`kept`
   non-empty, the mutant's `or` fires) and two whose write the store drops
   (`lost` non-empty), `misdirected` empty throughout.

4. FOUND HERE AND CLOSED: THE SAME MUTATION ONE FUNCTION UP. `migrate_unit_keys()`
   - SAFETY-02's LATCH migration - gates its own marker the same way, and every
   check that reached it ran on a store with an empty `kept`. `if rep.ok or
   rep.kept:` at store.py:307 SURVIVED ALL 19 FILES. It stamps `schema_version`
   on a migration that DROPPED THE LATCH: the guard is off, the file still reads
   `sign_tripped: true`, and the store now says the migration finished, so the
   next start skips the one thing that would re-arm it. test_inverter.py part 10
   now drops the `sign_tripped` write BESIDE an already-namespaced
   `sign_reason`, with the accepted-baseline counterfactual. No source change -
   the shipped gate was right; nothing could see it. Mutation table 34 -> 35.

5. DECISIONS/MAINT-05 §2 CORRECTED. The paragraph that stated the false
   guarantee now states what was measured, why grading `moved` alone could not
   hold, and why the union is sound. §7 records the marker-gate hole found in
   `migrate_unit_keys()` and that it was closed. MUTATION.md gains habit 9 -
   "exercise a refusal a SECOND time with the SAME wrong input, not the
   corrected one" - and two survey rows.

MUTATION LEDGER - 11 mutations, ALL RED on the final tree, every run on a fresh
copy with the bytecode cache purged (entry count printed before and after) and
the tree mounted :ro so none could be written. Two were survivors BEFORE this
commit and both are recorded as such. Full transcript on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 19:40:51 +02:00
glenn schrooyenandClaude Opus 5 65cddf35f1 MAINT-05 fix round: grade the migration against the ROSTER, not against the name it was handed
checks / unit (pull_request) Successful in 4m47s
REJECTED because a survivor undoes AC 5, and it survived for the recurring
reason: two fixture values that must be distinguishable were EQUAL.

THE SURVIVOR. app/main.py:282 mutated from `self.inverters[0].name` to `UNIT_A`
left the full 19-file suite green. `inverter.UNIT_A == "inverter-a"`, and every
two-pack fixture in test_maintenance.py named its first roster entry
"inverter-a" - so the two expressions were the same string in every check that
existed. main.py's own comment made the distinction load-bearing and nothing in
the repository could see whether that comment was true.

THE FAILURE IS REAL. `build_roster()` names a declared roster's entries from the
config and numbers a nameless one POSITIONALLY (`inverter-1`), so "inverter-a"
is not even the fallback. On ["garage","shed"] with a flat last_completed of
2026-08-07 the mutant migrates into unit/inverter-a/, pack 0 reads back None,
and both packs come up believing they have never been recalibrated.

AND IT FAILED SILENTLY, which was the worse half: `_verify_moves()` read back
the keys it had just WRITTEN, under the namespace it had just been HANDED, was
satisfied, and MAINT_SCHEMA_KEY was stamped - so the next start skipped the
migration too. A postcondition that reads back the namespace it was handed
cannot detect being handed the wrong one.

WHAT CHANGED

1. THE POSTCONDITION IS GRADED AGAINST THE ROSTER. `_verify_moves()` takes the
   names the MACHINES read and reports a namespace that is not among them as
   `misdirected` - a new, differently-named failure from `lost`:
     * `lost` is "the store took the write and dropped it" - a storage fault,
       with a harmless-default case (a dropped `last_completed: None` loses
       nothing, because absent and never-recalibrated are the same statement);
     * `misdirected` is "the store did exactly as it was told, by a caller that
       was wrong". There is no harmless-default version of it - a write nobody
       reads is not information whatever its value - so it fails `ok` alone.
   `ok` gates the marker, so a misdirected migration is NOT stamped and the next
   start re-runs it. Asymmetric on purpose: a re-run is recoverable (rule 1
   makes it a no-op, rule 2 left the flat keys to recover from), a silent skip
   is not. Part 9b MEASURES the recovery rather than asserting it.

2. `migrate_maint_keys()` TAKES THE ROSTER IN PLACE OF AN `n_units` COUNT. The
   count was `len(self.inverters)` at the only call site, so the roster carries
   it, and a count beside the names is a second opinion about how many packs
   there are.

3. THE FIXTURES CAN NOW REACH THE FAILURE THEY NAME. Parts 9 and 11 run on
   ["garage","shed"], each behind a PRECONDITION check that the first name is
   not UNIT_A - so an edit that makes them equal again goes red instead of
   making the claims vacuous. Part 11's fixture date is relative to `now`, not
   a literal, so "history preserved" and "history lost" cannot converge as the
   file ages past `interval_days`.

MUTATION LEDGER - all red, purged tree, none at +0 B, full detail on the PR:
   M1  main.py  inverters[0].name -> UNIT_A            -16 B  4 checks red
   M2  store.py drop `roster=names` from _verify_moves -14 B  5 checks red
   M3  store.py drop the misdirected.append            -37 B  5 checks red
   M4  store.py ok: drop `and not self.misdirected`    -25 B  3 checks red
   M5  fixture  garage -> inverter-a (the trap itself)  +4 B  5 checks red
   M6  store.py parts[1] -> parts[-1]                   +1 B  1 check red

FOUND AND NOT FIXED - `migrate_unit_keys()` HAS THE SAME HOLE AND IT IS A LATCH.
It runs BEFORE `build_roster()` (each Inverter restores its latch in its
constructor), so it is handed the constant UNIT_A with no roster to grade
against. On a declared roster a pre-DEV-02 `sign_tripped: true` lands under
unit/inverter-a/sign_tripped, which no unit reads - and
`_grade_store_migration()`, whose whole job is to re-arm exactly this, is
DEFEATED BY THE KEY THE DEFECT WROTE: its "is anything namespaced latched" test
is satisfied by the misdirected value itself. Measured, with the flat-config
counterfactual, and pinned as a running check (test_maintenance.py part 13)
whose assertions state the WRONG behaviour on purpose. It wants its own card:
the remedy is in inverter.py and SAFETY-02's semantics, and a silent expansion
into a safety latch is how a rejected PR becomes a broken one.

Suite: 19/19 PASS, purged tree, bind-mounted :ro in goodwe-addon-ci.
No hardware was touched; every fixture here is SIMULATED and pack B's 3000 W
remains a marked hypothesis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 16:18:34 +02:00
adminandClaude Opus 5 1aef19e995 MAINT-05: one recalibration per pack, and only one pack moves power at a time
checks / unit (pull_request) Successful in 4m48s
§12's monthly cycle drains a pack to empty and refills it to true its coulomb
counter. There was exactly one `Maintenance` and one un-namespaced history, so
at two packs only the first was ever recalibrated - and nothing prevented the
cycle draining pack A while the loop charged pack B, which is two power-moving
phases on one grid connection pulling against each other.

* ONE `Maintenance` PER ROSTER ENTRY, each with its own schedule, phase and
  history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
  install's flat history into the first pack's namespace under
  `migrate_unit_keys()`'s three rules - shared in code via `_move_flat_keys()`
  and `_verify_moves()`, not restated - on its own marker, because unlike
  DEV-02's it runs only where the roster has more than one pack. The flat keys
  are kept LIVE as the rollback view.
* ONE MAINTENANCE SLOT PER SITE (`maintenance.arbitrate()`). At most one pack
  in a power-moving phase - drain, charge AND hold. The holder is DERIVED from
  the packs' own stored phases, never persisted, so a restart mid-drain
  re-derives it. Several packs due in the same hour: the slot goes to the one
  that has gone longest without a completed cycle; roster order breaks a
  numeric tie and nothing else. Nothing preempts a cycle in flight.
* THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT. A deferral does not
  stamp `last_start_attempt` and does not move the phase, so neither the 12 h
  drain nor the 36 h charge timeout is reachable by waiting.
* The claim is filed on the pack whose state machine produced it, and stays
  SCOPE_UNIT - ARCH-01's rule.
* `validate.py` rule 6 grades `maintenance_charge_w` against the WEAKEST cap on
  the roster now, as its own comment said it must once `maint_unit` stopped
  being `inverters[0]`.
* At a roster of one nothing changes, and it is asserted: no new store key, the
  migration does not run, the four cycle events are the shipped strings, the
  banner is the bare phase.
* RECORDED NOT FIXED: a drain pins its pack while the loop's target is a site
  claim, so below the pin the allocator refuses the split and the site parks at
  0 W. Pre-existing at 2d025ee. Pinned as a running check.

Verified: 19/19 suites green in the CI image; 17 mutations run through
tools/mutation_run.sh (bytecode purged), every one observed red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 13:38:29 +02:00
13 changed files with 4025 additions and 176 deletions
+181
View File
@@ -2,6 +2,122 @@
## Unreleased
**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
one un-namespaced history, so at two packs **only the first was ever
recalibrated** and the second's counter drifted forever — and nothing prevented
the cycle draining pack A while the loop or arbitrage charged pack B, which is
two power-moving phases on one grid connection pulling against each other.
* **ONE `Maintenance` PER ROSTER ENTRY**, each with its own schedule, phase and
history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
existing install's flat history into the first pack's namespace under
`migrate_unit_keys()`'s three rules — shared in code, not restated — with the
same read-back postcondition, and the flat keys are kept **live** as the
rollback view so a downgrade finds a current history rather than one dated
migration day.
* **ONE MAINTENANCE SLOT PER SITE.** At most one pack may be in a power-moving
phase (`drain`, `charge` **and** `hold` — `charge` is in the set because a
pack pulled full from the grid beside a pack exporting to empty is 4 kW
circulating with the meter at zero). The holder is **derived** from the packs'
own stored phases, not persisted, so a restart mid-drain re-derives it. When
several packs are due in the same hour the slot goes to the one that has gone
**longest without a completed cycle**; roster order breaks a numeric tie and
nothing else. Nothing preempts a cycle in flight.
* **THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT.** A deferral does not
stamp `last_start_attempt` (that is the 90 min post-abort cooldown, inside a
60 min start hour), and does not move the phase, so neither the 12 h `drain`
nor the 36 h `charge` timeout — *"the battery is not following commands"* —
can be reached by waiting. It says WAITING, names the pack it waits for, and
recalibrates at the next start hour; a test drives the winner's whole cycle
and observes it start.
* **THE SCHEDULE IS NOT STAGGERED BY CONSTRUCTION.** The interlock defers and
the stagger is what comes out of it: one contested month separates two packs
by one cycle length and they stay separated. `DECISIONS/MAINT-05-per-pack-maintenance.md`
§3 carries the argument.
* **`validate.py` rule 6 now grades `maintenance_charge_w` against the WEAKEST
cap on the roster**, like rule 5. Its own comment said it would have to move
the day `maint_unit` stopped being `inverters[0]`; that day is this card. It
refuses configurations that used to start — a 3000 W charge beside a 2000 W
pack that is now recalibrated too — which is the `lo > hi` contradiction rule
6 exists to refuse, caught when the config is written. Identical at N=1.
* **AT A ROSTER OF ONE, NOTHING CHANGES**, and it is asserted rather than
assumed: the state file keeps the flat keys and gains none, the migration does
not run, the four cycle events are the shipped strings character for
character, and the status banner is the bare phase with no pack name.
* **THE MIGRATION IS GRADED AGAINST THE ROSTER, NOT AGAINST THE NAME IT WAS
HANDED** (fix round). `migrate_maint_keys()` takes the roster rather than a
pack count and `_verify_moves()` checks the namespace it wrote against the
names the machines actually read. A postcondition that reads back the
namespace it was given cannot detect being given the wrong one: handed
`inverter.UNIT_A` on a roster of `["garage", "shed"]`, the first version of
this card wrote all four keys under `unit/inverter-a/...`, read all four back
equal, reported **verified**, and stamped `maint_schema_version` — so both
packs came up believing they had never been recalibrated and the next start
skipped the migration too. That is now `misdirected`: not `ok`, the marker is
**not** written, and the next start re-runs and recovers (rule 1 makes the
re-run a no-op). `build_roster()` numbers a nameless roster entry
`inverter-1`, so `inverter-a` is not even the fallback for a declared roster.
Every two-pack fixture that could see this now names its first pack
`garage`; with them all called `inverter-a`, `self.inverters[0].name` and
`UNIT_A` were the same string and the swap left all 19 suites green.
* **AND THE REFUSAL NOW SURVIVES THE NEXT START** (second fix round). The fix
above graded only the keys it had just *written*, so it refused exactly once:
measured at Controller level over one persisted store, roster
`["garage", "shed"]`, with the call site still wrong on both starts —
construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=<absent>`,
construction 2 `ok=True misdirected=0 moved=0 kept=4` and the marker
**stamped**. On the second start rule 1 finds the wrong keys already there and
routes all four to `kept`, so `moved` is empty and the roster loop grades
nothing — and start 3 skips the migration forever, *including after the call
site is fixed*. Loud-and-recoverable became silent-and-permanent after one
restart. `_verify_moves()` now grades `set(moved) | set(kept)`; both lists are
built from the same `unit` argument, so grading `kept` grades the same
namespace decision. `test_maintenance.py` part **9c** drives two starts with
the same wrong unit over one store — the shape nothing in the suite had, since
9b measured recovery along the path where the operator has *already* fixed the
call site.
* **AND THE MARKER GATE IS PINNED ON BOTH GUARDS.** `if rep.ok:` is
`not lost and not misdirected`, and the mutation `if rep.ok or rep.kept:`
stamped the marker on a migration that **lost a month of history** with all 19
suites green. Part **9d** is the fixture that separates the two guards: two
keys already namespaced (`kept` non-empty) and two whose write the store
drops (`lost` non-empty), `misdirected` empty throughout.
* ⚠️ **AND THE SAME MUTATION ONE FUNCTION UP WAS A SURVIVOR, FOUND HERE AND
CLOSED.** `migrate_unit_keys()` — SAFETY-02's **latch** migration — gates its
own marker on `rep.ok` too, and every check that reached that gate ran on a
store where nothing was namespaced yet, so `rep.kept` was empty in all of
them. `if rep.ok or rep.kept:` there stamps `schema_version` on a migration
that **dropped the latch**: the guard is off, the file still says
`sign_tripped: true`, and the next start skips the migration that would have
re-armed it. Measured a survivor of all 19 files; now red on
`test_inverter.py` part 10, which drops the `sign_tripped` write beside an
already-namespaced `sign_reason`. No source change — the shipped gate was
right; nothing could see it.
* ⚠️ **FOUND, RECORDED, NOT FIXED (SECOND ONE).** `migrate_unit_keys()` —
SAFETY-02's latch migration — has the identical hole and it costs a **guard**
rather than a history. It runs before `build_roster()` (each `Inverter`
restores its latch in its constructor), so it is handed the constant `UNIT_A`
and there is no roster to grade against. On a declared roster a pre-DEV-02
`sign_tripped: true` lands under `unit/inverter-a/sign_tripped`, which no unit
reads — and `_grade_store_migration()`, whose job is to re-arm exactly this,
is **defeated by the key the defect wrote**: its "is anything namespaced
latched" test is satisfied by the misdirected value itself. Fixing it means
naming the migration's target from the config before any `Inverter` exists,
which is `inverter.py` and SAFETY-02's semantics, with its own battery.
Measured and pinned as a running check (`test_maintenance.py` part 13), whose
assertions state the WRONG behaviour on purpose.
* ⚠️ **FOUND, RECORDED, NOT FIXED.** A maintenance drain **pins** its pack while
the loop's target is a **site** claim, so when the site target is below the
pin the only split asks the sibling to charge against the drain and
`allocator.plan()` refuses it — the site parks at 0 W and the pack's 12 h
timeout would eventually report *"drain stuck"* about a healthy battery.
Pre-existing on `release/1.0` at `2d025ee` with the unit names swapped; fixing
it means changing what the loop's target means while a unit is pinned, which
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
@@ -125,6 +241,71 @@ thing that stops. Rule 8's own failure mode, reached through rule 8.
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.
⚠️ **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
@@ -0,0 +1,388 @@
# MAINT-05 — one recalibration per pack, one at a time, and what the card left open
**Status: DECIDED 2026-09-04.** There is one `Maintenance` per roster entry,
each with its own schedule, phase and history. The site holds exactly **one
maintenance slot**: at most one pack may be in a power-moving phase, the slot is
derived from the packs' own stored phases rather than persisted separately, and
when several packs are due in the same hour it goes to the one that has gone
**longest without a completed cycle**. The schedule is **not** staggered by
construction — the interlock defers, and the stagger emerges from it.
**Fix round 2026-09-04.** The store migration is now graded against the
**roster** rather than against the unit name it was handed — see §2's
*"The read-back postcondition is graded against the ROSTER"* — and the same
hole in SAFETY-02's latch migration is recorded in §7.
Filed with MAINT-05. Card `6a9a9e8503872905f298d80a`.
---
## 0. What the card asked to be decided, and the answers
| the card's open question | the decision |
|---|---|
| §2 the arbitration | **one pack at a time; the other stays under the loop.** Nothing preempts a cycle in flight. |
| §2 who wins when several are due | **the longest-waiting pack**, `last_completed` oldest first, `inf` for never-recalibrated. Roster order breaks a numeric tie and nothing else. |
| §4 staggering | **not by construction.** The interlock defers the second pack; the stagger is what comes out of it. §3 below. |
| AC 7 rule 6's `maint_unit` comment | **no, the first entry's cap is no longer right.** It grades against the weakest cap on the roster now, like rule 5. §4 below. |
| the store layout at N=1 | **flat, unchanged.** The namespace appears the day the roster grows. §2 below. |
## 1. Why the slot is derived and not persisted
The holder is *"whichever pack's stored phase is not `idle`"* — a fact already on
disk, written by the packs themselves in the per-pack history this card
introduces. A `maint_slot_holder` key would be a **second opinion about the same
thing**, and the two would disagree the first time the add-on was killed between
writing the phase and writing the holder. Derived, a controller that comes back
up mid-drain reads the drain and re-derives the holder from it; there is no
recovery path to get wrong because there is no second fact.
`CHARGE` is in `POWER_MOVING` even though the control **loop** owns the setpoint
in that phase, and that is the decision most likely to be "simplified" later.
Pack A charging from the grid at 2000 W while pack B drains 2000 W to export is
4 kW going round in a circle with the meter reading zero and both packs
believing they are behaving — the circulating power [[SAFETY-31]] exists to
detect, arriving as something this module can simply not do. `HOLD` is in the
tuple for the other half of the sentence: it moves nothing, and it **owns the
actuator**, so a second cycle starting under it would still be two
recalibrations at once.
## 2. Why the store layout at a roster of one does not change
The maintenance keys are flat — `phase`, `phase_started`, `last_completed`,
`last_start_attempt` — on every install since the add-on shipped. At one pack
there is nothing for a namespace to distinguish that pack **from**: §6.2's
identity, the same one `arbiter.actuator_for()` leans on when it routes every
claim to the site actuator at N=1. So `Maintenance` is constructed with
`unit=None` there, reads and writes the flat keys directly, and
`migrate_maint_keys()` **returns without writing anything**. A one-inverter state
file comes out of this card byte-identical, which is AC 6 and which
`test_maintenance.py` part 10 asserts by driving a whole cycle and comparing the
key set.
### The migration has its own marker, and that is not tidiness
`SCHEMA_VERSION` / `schema_version` is DEV-02's, and it is **monotone**: every
install runs that migration exactly once. This one runs **only where the roster
has more than one pack**, so a single counter could not express *"migrated on
this site, deliberately not migrated on that one"* without lying to the next
version that reads it — a one-inverter store would have to be pinned at the old
number forever, and a genuine future v2 migration could then never be told apart
from a site that simply never grew. `maint_schema_version` answers one question
and answers it honestly.
The **three rules are shared in code**, not restated: `_move_flat_keys()` is
rule 1 (never overwrite an existing namespaced value) and rule 2 (the flat keys
stay in the file), `_verify_moves()` is the read-back postcondition, and the
marker is written last. A second transcription of a migration is a second
opinion about what "it landed" means.
### The read-back postcondition is graded against the ROSTER (fix round)
**A postcondition that reads back the namespace it was handed cannot detect
being handed the wrong one.** That sentence is the whole finding, and this card
shipped it broken.
`_verify_moves()` iterated `rep.moved` — the keys it had just written — and
asked whether each read back equal. On a store where the write lands, it always
does. So a migration handed a `unit` that is on no roster wrote four keys, read
four keys back, set `verified = True`, and the caller stamped
`maint_schema_version`. Every symptom of a clean migration, and an install's
whole maintenance history parked under a name nothing will ever look up. And
because the marker was stamped, **the next start skipped the migration too** —
which is precisely the state `store.py`'s header says must never happen: a
failed migration a later version treats as done.
Reproduced on the reviewer's roster, a house with a garage pack and a shed pack,
carrying a flat `last_completed` of 2026-08-07:
| | `unit` the migration was handed | key written | pack 0 reads back | due now? |
|---|---|---|---|---|
| correct | `self.inverters[0].name` = `garage` | `unit/garage/last_completed` | `2026-08-07` | **False** |
| wrong | `inverter.UNIT_A` = `inverter-a` | `unit/inverter-a/last_completed` | `None` | **True** |
`build_roster()` names a declared roster's entries **from the config**, and its
fallback for a nameless entry is a **positional** `inverter-1` — so `inverter-a`
is not the name of anything on a declared roster, not even by default.
**The remedy, and why this one.** `migrate_maint_keys()` now takes the **roster**
in place of an `n_units` count (the count was `len(self.inverters)` at the only
call site, so the roster carries it, and a count passed beside the names is a
second opinion about how many packs there are). `_verify_moves()` grades the
namespace it wrote against those names, and a unit that is not among them is
reported as `misdirected` — a **different** failure from `lost`:
* `lost` is *the store took the write and dropped it* — a storage fault, and it
has a harmless-default case (a write of `last_completed: None` loses nothing,
because "absent" and "never recalibrated" are the same statement).
* `misdirected` is *the store did exactly as it was told, by a caller that was
wrong*. There is no harmless-default version of it: a write nobody reads is
not information, whatever its value. So anything in it fails `ok` on its own.
And **`ok` gates the marker**, so a misdirected migration is not stamped and the
next start runs it again. The argument for it is asymmetric: a re-run is
recoverable — rule 1 makes it a no-op where keys did land, and rule 2 left the
flat keys in the file to recover *from* — while a silent skip is not recoverable
at all, because the store now claims the migration happened.
### ⚠️ THE PARAGRAPH ABOVE USED TO END HERE, AND AS IT STOOD IT WAS FALSE
**The refusal lasted exactly one start. The second start stamped the marker.**
That is a correction to what the first fix round wrote down, not a new
qualification of it, and it is recorded here because a decision document that
states a guarantee the code does not provide is worse than one that says
nothing.
`_verify_moves()` graded only `rep.moved`. Measured at Controller level over
**one persisted store**, roster `["garage", "shed"]`, flat `last_completed`
2026-08-07, with the call site still handing `inverter-a` on **both** starts:
```
construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent> (withheld)
construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1 (STAMPED)
history parked under unit/inverter-a/…; marker stamped after ONE restart.
```
On start 2 the same wrong call site finds the four keys it wrote already
present, so **rule 1** — never overwrite a namespaced value — routes every one
of them to `rep.kept`. `rep.moved` is empty, the roster-grading loop iterates
over nothing, `misdirected` is empty, `ok` is True, the marker is stamped, and
start 3 takes the `from_version >= MAINT_SCHEMA_VERSION` early return **forever
— including after the call site is fixed**. Loud-and-recoverable became
silent-and-permanent after one restart: exactly the state `store.py`'s header
says must never happen, reached by the fix written to prevent it.
**The remedy is one line**, and it is sound by construction rather than by
argument: grade `set(rep.moved) | set(rep.kept)`. Every entry in *both* lists is
a `unit_key(unit, legacy)` built from the **same** `unit` argument, so grading
`kept` grades the same namespace decision `moved` does. A correct call site's
`kept` entries are under a roster name and pass; a wrong one cannot launder its
namespace by having already written it. `migrate_unit_keys()` passes
`roster=None` and returns before the loop, so it is unaffected.
**Why the check missed it, which is the part worth keeping.** Part 9b's recovery
check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` — the **corrected** name.
It measures recovery along the one path where the operator has *already fixed
the bug*. The real failure is a call site that is still wrong on the next start,
and nothing in the 19 files called `migrate_maint_keys()` twice with the **same
wrong** `unit`. The check was true, passed, and could not reach the failure its
sentence named — MUTATION.md's third defect, this time sitting inside the fix
written to close it. Part **9c** now drives two starts with the same wrong unit
over one store, asserts the marker is still withheld on the second, and asserts
the *shape* of the second report (`moved` empty, `kept` full) so it cannot
quietly revert to 9b's path.
### The marker gate is `rep.ok` and nothing weaker — both guards, each pinned
`if rep.ok:` is `not lost and not misdirected`, and until this round only the
`misdirected` half was reachable by a check that also had a non-empty `kept`.
The mutation
```
if rep.ok: -> if rep.ok or rep.kept:
```
stamps the marker on a migration that **lost a month of history** — defeating
the older, settled `lost` guard as well as the new one — and **all 19 suites
were green on it**. Part **9d** is the fixture that separates them: two keys
already namespaced (`kept` non-empty, so the mutant's `or` fires) and two keys
whose write the store drops (`lost` non-empty, so `ok` is False), with
`misdirected` empty throughout so the section cannot pass on the other guard by
accident. Measured differential on it: shipped → marker absent; mutant → marker
`1`, both reporting `ok=False lost=2 kept=2`.
**Why the fixtures had to change, and this is the part that got the card
rejected.** Nothing in the repository could see whether any of the above was
true, because `inverter.UNIT_A` **is** the string `"inverter-a"` and every
two-pack fixture in `test_maintenance.py` named its first roster entry
`inverter-a`. `self.inverters[0].name` and `UNIT_A` were therefore the same
value in every check that existed, and swapping one for the other at the call
site left **all 19 suites green**. MUTATION.md's third defect exactly — a check
whose *data* cannot reach the failure it names — and the fifth consecutive card
to carry it. Parts 9 and 11 now run on `["garage", "shed"]`, each behind a
**precondition check** asserting that the first name is not `UNIT_A`, so an edit
that makes them equal again goes red instead of making the claims vacuous.
### And the flat keys are kept LIVE, which is the half DEV-02 had to add later
Rule 2 originally said the flat keys are left in place and go stale. DEV-02
amended it, for the sign latch, to *"left in place is not enough"* — and the
same sentence holds for history. A fossil dated the day the second inverter was
declared would hand a rolled-back single-FSM add-on a `last_completed` months
old (it recalibrates a pack that does not need it) or a stale `phase` with a
`phase_started` old enough to abort on the 12 h timeout the moment it comes up.
So `UnitHistory` **mirrors the first pack's writes back onto the flat keys** for
as long as the roster has more than one entry. Only the first pack: the flat
spelling means `inverters[0]` on every install that has one, and a mirror from
pack B would overwrite pack A's rollback view with pack B's phase.
## 3. Staggering: the interlock defers, and the stagger is the residue
Two packs on one monthly schedule are due in the same hour every month. The
options were a stagger **by construction** — offset each pack's first cycle by
`interval_days / N` — or **deferral**. Deferral, for three reasons:
1. **A constructed stagger cannot remove the need for the interlock.** A forced
start, a commissioning cycle, an abort-and-retry, or a pack added mid-month
desynchronises any static offset immediately, so the interlock has to be
there anyway — and then the stagger is a second mechanism that must also be
correct, for a case the first one already handles.
2. **A deferral is observable and a stagger is not.** The deferred pack says, in
the action log and on the status page, that it is waiting, which pack it is
waiting for, and that it will run at the next start hour. A silent offset
moves an operator's configured start day and explains nothing.
3. **The stagger comes out of the deferral for free and then persists.** Pack A
completes on day 1 and is next due on day 29; pack B, deferred, completes on
day 2 and is next due on day 30. One contested month separates them by one
cycle length and they stay separated.
**Starvation was the risk and it is closed at two points.** A deferral does
**not** stamp `last_start_attempt` — that key is the post-abort cooldown, and at
90 minutes inside a 60-minute start hour, stamping it would lock the deferred
pack out of exactly the hour it is waiting for. And the winner is the
longest-waiting pack rather than the first in the roster, so a pack that keeps
aborting cannot monopolise the slot month after month while its sibling drifts —
which is this card's own defect wearing a different costume.
**The deferral must not read as a fault.** §12's 12 h and 36 h phase timeouts
mean *"the battery is not following commands"*. A deferred pack stays `IDLE`
with `phase_started` untouched, so no timeout is reachable by waiting, and the
line it logs says WAITING, names the pack it waits for and says it will run
next — none of which any failure path in `maintenance.py` says.
## 4. Rule 6 (the card calls it rule 8): the first entry's cap is no longer right
`validate.py`'s rule 6 comment said, in as many words, that
`maintenance_charge_w` was graded against `inverters[0]`'s cap because there was
exactly one `Maintenance` and it could only ever recalibrate the first pack —
and that taking `min()` would be an over-refusal, because it would refuse *"a
5000 W pack being recalibrated beside a 2000 W sibling the cycle never
touches"*. It closed: **"when `maint_unit` stops being `inverters[0]`, this line
moves with it."**
It has stopped. Every pack is recalibrated on its own schedule, so **the sibling
the cycle never touches does not exist** and the over-refusal argument has no
subject. The shaping claim is filed on whichever pack is charging, so the cap
that can contradict it is any cap on the roster and the one that contradicts it
first is the smallest.
**This refuses configurations that used to start**, and that is the point rather
than a side effect: `max_w` 5000 on pack A beside 2000 on pack B with
`maintenance_charge_w` 3000 was correct while only pack A was ever recalibrated,
and is now a cheap-window charge asking the arbiter for `hi = -3000` against a
supervised `lo = -2000` — `lo > hi`, arbiter clause 3, 0 W, on the pack nobody
was watching. Refused when it is **written**, which is rule 6's whole job. At a
roster of one `min()` over one cap is that cap: same comparison, same message,
same boundary.
## 5. The claim stays per-unit, and nothing here files a site claim
`arbiter.CLAIM_SCOPE` has classified `maintenance` as `SCOPE_UNIT` since it was
written. Two FSMs mean two claims on two actuators, which the allocator consumes
as per-unit intervals. [[ARCH-01]] is explicit that this must never become a
site claim, because per-inverter constraints filed site-wide are the
cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
card changed there is **which** roster entry the claim is filed on: the one whose
state machine produced the result, rather than `inverters[0]`.
## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
Measured while wiring this card, and **pre-existing on `release/1.0` at
`2d025ee`** — the same drain filed on `inverters[0]` produces the identical
refusal with the two unit names swapped.
The maintenance SET **pins** its pack. The loop's target is a **site** claim.
When the site target is *below* the pin — a 2000 W drain while the house imports
less than that, which is most of a night — the only split that sums to the site
target asks the sibling to **charge** while the maintained pack **discharges**,
and `allocator.plan()` refuses it by name as circulating power. The refusal is
correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED`
line in the action log.
**The consequence an operator would see is a misdiagnosis.** The pack is held at
0 W while its state machine believes it is draining, so after 12 h the DRAIN
timeout fires and reports *"drain stuck"* — §12's words for a battery that is not
following commands. The battery is fine; the site refused to allocate.
**Why it is not fixed here.** The fix is to change what the site loop's target
*means* while a unit is pinned — regulate the meter using only the unpinned
packs — which is loop and allocator territory and changes the integrator's
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
its own.
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
Found while fixing §2's instance. It is the same defect one module over and it
costs a **disarmed safety guard** rather than a lost history.
`Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` — a hardcoded
constant where the maintenance migration now passes the roster's own first name.
It **has** to run before `build_roster()`, because each `Inverter` restores its
sign latch in its constructor, so there is no roster to name yet and
`_verify_moves()` is passed `roster=None`. On a **declared** roster a pre-DEV-02
`sign_tripped: true` therefore lands under `unit/inverter-a/sign_tripped`, which
no unit on that site reads.
**And the guard that exists for exactly this is defeated by the key the defect
wrote.** `_grade_store_migration()` re-arms the latch when the flat key says
tripped and *nothing namespaced is latched*. The misdirected write **is**
namespaced and **is** latched, so `namespaced_latched` is `True`, the guard
returns, and no inverter on the roster comes up tripped — on an install whose
`/data/state.json` still reads `sign_tripped: true`. That is DEV-02's own
sentence for the worst case: the state file says protected, the banner is green,
and the controller resumes commanding into the fault that tripped it.
Measured, on the same `["garage", "shed"]` roster:
```
pre-DEV-02 flat latch + declared roster ['garage', 'shed'] ->
migrated 2 flat key(s): unit/inverter-a/sign_reason, unit/inverter-a/sign_tripped
latch keys in the store: ['sign_tripped', 'site_sign_tripped', 'unit/inverter-a/sign_tripped']
roster came up tripped: [('garage', False), ('shed', False)]
the same store on the FLAT config -> [('inverter-a', True)]
```
The last line is the counterfactual: on the flat shape, where `UNIT_A` really is
the roster's only name, the same store migrates and comes up latched. So this is
a defect of the **declared roster**, not of the migration as such.
**Why it is not fixed here.** The remedy is to name the migration's target from
the config before any `Inverter` exists, which means factoring `build_roster()`'s
naming rule out of `build_roster()` so there is still only **one** spelling of
it — `inverter.py` and SAFETY-02's latch semantics, with its own mutation battery
over `test_inverter.py` and `test_watchdog.py`. MAINT-05 moves the maintenance
history and nothing about the sign latch, and a silent expansion into a safety
latch is how a rejected PR becomes a broken one.
It is pinned as a running check (`test_maintenance.py` part 13) rather than left
as a comment. ⚠️ **Its assertions state the WRONG behaviour on purpose** — read
them as the specification of a defect; the card that fixes it should expect them
to go red and should replace them.
### And its MARKER GATE had the round-3 hole too — found here, closed here
Separate from the namespace hole above, and **fixed** rather than recorded,
because closing it cost one test fixture and no source change.
`migrate_unit_keys()` gates `schema_version` on `rep.ok` exactly as
`migrate_maint_keys()` gates its own marker, and every check that reached that
gate ran on a store where **nothing** was namespaced yet — so `rep.kept` was
empty in all of them and
```
if rep.ok: -> if rep.ok or rep.kept:
```
**survived all 19 files**. What it does is stamp the schema marker on a
migration that DROPPED THE LATCH: the guard is off, the state file still reads
`sign_tripped: true`, and because the store now says the migration finished, the
next start skips the one thing that would have re-armed it. DEV-02's own worst
case, filed under *done*.
`test_inverter.py` part 10 now drops the `sign_tripped` write **beside an
already-namespaced `sign_reason`** — `lost` non-empty *and* `kept` non-empty,
which is the one shape none of the existing fixtures had — and asserts the
marker is withheld, with the accepted-baseline counterfactual beside it (habit
7). The shipped gate was correct throughout; what changed is that a check can
now see it.
@@ -311,6 +311,173 @@ 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 **every** comprehension in `Controller`
that iterates the roster — 26 of them across 23 methods, computed from main.py's
AST, printed, and required to iterate the roster **whole**. A new reduction added
over `self.inverters[:1]` anywhere in the class 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.
## 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
+95 -3
View File
@@ -135,11 +135,19 @@ manufactures them from a **fixture**: the check runs, the assertion is
evaluated, the sentence above it is true — and the numbers it compares are
arranged so that the failure it describes could not have changed the answer.
It is not hypothetical and it is not rare. Six instances in two weeks; three
It is not hypothetical and it is not rare. EIGHT instances in two weeks; four
were found by review, two - CFG-04's fixture pair - by an author's own mutation
battery, which is the cheaper place, and the sixth by review again, in the one
battery, which is the cheaper place, one by review again, in the one
place nobody had thought to point the rule at: not a check in the suite, but a
COMMITTED EVIDENCE ARTIFACT four documents cite.
COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX
WRITTEN TO CLOSE THE SEVENTH.
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
MAINT-05's, is the one that says why: the defect is not carelessness about
fixtures, it is that **a check and the thing it checks were built from the same
constant**. Ask of every difference-claim not "is this assertion true" but
"where did each side of this comparison come from" - and if the answer is the
same place twice, the check is decoration.
* **ARB-03** shipped a call-site scan asserting that every `claims.append` in
`cycle()` routes through `actuator_of()`. Its own parse **saw 3 of the 8
@@ -167,6 +175,44 @@ COMMITTED EVIDENCE ARTIFACT four documents cite.
than against its behaviour. It now drives the shipped `run_control()` on a
two-inverter config, **under a deadline**, because the mutation's real symptom
is that the controller enters its loop and never returns.
* **MAINT-05** shipped `Controller.__init__` calling
`migrate_maint_keys(store, self.inverters[0].name, ...)` under a comment
saying *"it targets `self.inverters[0].name` and NOT `UNIT_A` ... writing
`UNIT_A` here would deposit an install's whole maintenance history under a
name that is not on the roster at all"*. `inverter.UNIT_A` **is** the string
`"inverter-a"`, and every two-pack fixture in `test_maintenance.py` named its
first roster entry `inverter-a` — so the two expressions were the same value
in every check that existed, and swapping them left **all 19 files green**
while losing a real install's whole maintenance history. Two-pack fixtures are
now `["garage", "shed"]`, each behind a precondition check (habit 1) that the
first name is not `UNIT_A`.
⚠️ **AND THE POSTCONDITION HAD THE SAME SHAPE AS THE FIXTURE.**
`store._verify_moves()` read back the keys it had just written, under the
namespace it had just been handed — so it verified clean, and the caller
stamped the schema marker, on a migration nothing would ever read. *A check
that reads back the namespace it was handed cannot detect being handed the
wrong one*: it compared a thing against itself, exactly as DEV-02's fixture
compared `(-5000, 5000)` against `(-5000, 5000)`. The habit-2 fix is the same
one — grade against something the thing under test did not supply, here the
ROSTER the machines read.
⚠️ **AND THE FIX ITSELF CARRIED THE DEFECT, WHICH IS THE SIXTH CARD IN A ROW
AND THE FIRST TIME IT HAS APPEARED *INSIDE* A REMEDY.** The fix round's
recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` — the
**corrected** name. It measures recovery along the one path where the operator
has *already fixed the bug*, and nothing in the 19 files called
`migrate_maint_keys()` twice with the **same wrong** `unit`. So the refusal
the card was rejected twice to obtain lasted **exactly one start**: measured
at Controller level over one persisted store, roster `["garage", "shed"]` —
construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=<absent>`,
construction 2 `ok=True misdirected=0 moved=0 kept=4 marker=1`. On start 2
rule 1 routes the wrongly-written keys to `rep.kept`, `rep.moved` is empty,
the roster loop iterates over nothing, and the marker is stamped on a
migration that reached no readable key — after which the early return skips it
forever, *including after the call site is fixed*. The check was true and
passed for the wrong reason. Habit **9** below is what falls out of it.
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
reaching outside the suite. The probe is the committed evidence that
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
@@ -241,6 +287,50 @@ Cheap habits fall out of it, and all of them are now used in this repository:
red. `inverters: []` was deleted from `config.yaml` and the probe re-run
against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` for
exactly that reason.
9. **Exercise a refusal a SECOND time with the SAME wrong input — not the
corrected one.** A guard that refuses once and then admits the same bad
caller is not a guard, it is a delay, and the second call is a *different
code path* whenever the first call left state behind. MAINT-05's roster
grading iterated the keys it had just **written**; on the second start the
same wrong caller found them already present, they moved to the
`already namespaced` list, and the loop graded an empty set. Re-running with
the **fixed** argument — which is what the check did — measures the operator's
recovery, never the persistence of the refusal. Ask of every refusal: *what
does this do when the caller has not been fixed and simply runs again?* And
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?*
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
@@ -297,6 +387,8 @@ commit and PR bodies.
| **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. **Now CLOSED by TEST-13** (`test_tariff.py` section 7), which round-trips section 7's own unbalanced `(3000, 500, -200) W` block THROUGH the store under each policy - three distinct banked figures, so a hardcoded policy takes at least two of them red. Third instance of "a stimulus evaluated where the quantity it grades is degenerate", after WITNESS-02's span normalisation and TARIFF-01's own DST window. |
| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, then 9 more after review, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All are **reds**, which is the direction this defect cannot fabricate. ⚠️ The second round exists because the first round's mutations were all aimed at DISARMING the guard, and the claim part 10 actually made was about the guard being FIXED - the reviewer built the fix and the suite stayed green. Bytecode was never the issue there; an unaimed battery was. |
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
**Nothing here says a past result is wrong.** The two survivors re-run above
+317 -107
View File
@@ -54,14 +54,16 @@ from .hass import HomeAssistant
# window an operator would look up here is the one the roster actually uses.
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries)
from .maintenance import IDLE, MaintConfig, Maintenance
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
)
from .mqtt import MqttPublisher
from . import peakguard
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_unit_keys
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (ConfigRefused, refuse, validate_startup_config,
validate_unit_ranges)
from .watchdog import (
@@ -265,7 +267,66 @@ class Controller:
# maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes
# in config.yaml's `options:` and `schema:` blocks and in that dict, and
# nowhere else.
self.maint = Maintenance(MaintConfig.from_options(opts), store)
# --- MAINT-05: ONE `Maintenance` PER ROSTER ENTRY --------------------
# ⚠️ AND THE MIGRATION RUNS FIRST, FOR THE REASON THE SAFETY-02 ONE
# DOES. These objects READ their history in their constructors' wake -
# `phase`, `last_completed` - so a history still sitting under the flat
# spelling when they are built is a pack that comes up believing it has
# never been recalibrated, on a site whose state file says otherwise.
# It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys
# belonged to whichever pack the one FSM was driving, which is the first
# roster entry, and a declared `inverters:` roster names its own entries
# (`inverter.build_roster()` says so, and its fallback for a nameless
# entry is a POSITIONAL `inverter-1`, so `inverter-a` is not even the
# default there). Writing `UNIT_A` here would deposit an install's whole
# maintenance history under a name that is not on the roster at all.
#
# ⚠️ AND THAT SENTENCE USED TO BE A COMMENT NOTHING COULD GRADE, WHICH
# IS WHY THE ROSTER IS PASSED RATHER THAN A COUNT. Every two-pack
# fixture in the suite named its first entry `inverter-a`, so
# `self.inverters[0].name` and `UNIT_A` were the same string in every
# check that existed and swapping them left all 19 files green - while
# on a roster of `["garage", "shed"]` it silently loses the history and
# stamps the marker, so the next start skips the migration too.
# `migrate_maint_keys()` now grades the namespace it wrote against the
# names the machines read; test_maintenance.py sections 9 and 11 drive
# a `["garage", "shed"]` roster, which is what makes the mutation red
# instead of invisible.
self.maint_migration = migrate_maint_keys(
store, self.inverters[0].name, [inv.name for inv in self.inverters])
_LOG.info("maintenance history: %s", self.maint_migration.explain())
if not self.maint_migration.ok:
# Not a disarmed guard - history, not a latch - so it does not
# re-arm anything. It is still the difference between a pack that is
# recalibrated on schedule and one that silently is not, which is
# §12's opening sentence, so it is said where an operator looks.
self.log_event("maintenance history did NOT migrate cleanly: "
+ self.maint_migration.explain())
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
# PROCEDURE - how hard to drain, how full to charge, how long to hold -
# and the procedure is the same on every pack in the roster; what is per
# pack is the SCHEDULE and the HISTORY, which live in the store. A
# per-entry config block would be a second shape for CFG-04's roster to
# carry and a fifth place for a `maintenance_charge_w` to disagree with
# config.yaml. If a site ever needs two different procedures, that is a
# roster-schema card and it starts here.
_maint_cfg = MaintConfig.from_options(opts)
_one_pack = len(self.inverters) <= 1
self.maints = [
Maintenance(
_maint_cfg, store,
# ⚠️ `None` AT A ROSTER OF ONE IS THE FLAT LAYOUT, NOT A MISSING
# NAME. §6.2's identity: one pack, one history, and the keys it
# has been written under since the add-on shipped. See
# `maintenance.UnitHistory` and `store.migrate_maint_keys()` -
# this is the line that keeps a one-inverter state file
# byte-identical.
unit=None if _one_pack else inv.name,
name=inv.name,
# The rollback view, and only ever the first pack's: the flat
# keys mean `inverters[0]` on every install that has them.
mirror_flat=(not _one_pack and i == 0))
for i, inv in enumerate(self.inverters)]
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
# docstring has always said and what this Controller did not do. Each
@@ -453,6 +514,9 @@ class Controller:
self._peak_saturated = False
self._peak_floor_limited = False
self._maint_shape_suspended = False
# [(pack name, why)] for packs that are due and waiting their turn.
# Empty at a roster of one, where nothing can defer anything.
self._maint_deferred = []
# Set by amain() once the transport is built, so the status page can show
# what only the transport knows (homewizard_local's unchanged_s). Stays
# None when P1 is off, or under a transport that has no such counter.
@@ -667,18 +731,79 @@ class Controller:
return tuple(inv.name for inv in self.inverters)
@property
def maint_unit(self):
"""The pack the ONE maintenance state machine is recalibrating.
def maint_index(self) -> int:
"""Which roster slot holds the site's ONE maintenance slot right now.
⚠️ NAMED RATHER THAN ASSUMED. This add-on holds exactly one
`Maintenance`, whose history lives under the store's un-namespaced
`phase` / `last_completed` keys, so the pack it drives is the first in
the roster. Two packs recalibrating on two schedules needs a second FSM
and a namespaced history of its own; what THIS card fixes is only that
the claim is filed on ONE actuator instead of on whichever one happened
to be asked. Read store.LEGACY_UNIT_KEYS on why those keys did not move.
The pack that is mid-cycle if there is one, otherwise the first - which
at a roster of one is always 0, always was, and is the whole of this
property's N=1 behaviour.
"""
return self.inverters[0]
for i, m in enumerate(self.maints):
if m.phase in POWER_MOVING:
return i
return 0
@property
def maint(self):
"""The `Maintenance` the site's one maintenance slot belongs to.
⚠️ MAINT-05 TURNED THIS FROM AN ATTRIBUTE INTO A VIEW, AND THE THING IT
IS A VIEW OF CHANGED WITH IT. There are now `self.maints` - one FSM per
roster entry, each with its own schedule, phase and history - and every
SITE-level surface that used to read `self.maint` (the `phase` MQTT
value, the status banner, the operator's start/abort buttons) wants THE
ONE THAT IS RECALIBRATING, not "the first one". `maintenance.arbitrate()`
guarantees at most one pack is ever out of IDLE, so "the one that is
recalibrating" is well defined; when none is, this is `maints[0]` and
the surfaces read exactly what they read before.
⚠️ AT A ROSTER OF ONE IT IS `maints[0]` ON EVERY TICK, in both branches,
which is the object that used to be assigned here. Nothing downstream
can tell the difference - that is AC 6, and test_maintenance.py part 9
asserts it rather than trusting this paragraph.
"""
return self.maints[self.maint_index]
@property
def maint_unit(self):
"""The pack the site's one maintenance slot is currently on.
⚠️ THIS USED TO BE `self.inverters[0]`, FLATLY, AND THE COMMENT THAT
STOOD HERE EXPLAINED WHY: there was exactly one `Maintenance`, its
history lived under the store's un-namespaced `phase` / `last_completed`
keys, so the pack it drove could only be the first in the roster. That
sentence is what MAINT-05 removed. There is now one FSM and one
namespaced history PER ROSTER ENTRY, so the pack being recalibrated is
whichever one holds the site's maintenance slot, and `maint_index` -
not the roster's order - is what answers it.
⚠️ AND `validate.py` RULE 6 MOVED WITH IT, exactly as its own comment
said it must ("when `maint_unit` stops being `inverters[0]`, this line
moves with it"). It graded `maintenance_charge_w` against the FIRST
entry's cap on the argument that the cycle never touches the others;
that argument is now false, so it grades against the weakest cap on the
roster, like rule 5. See the ⚠️ block there.
"""
return self.inverters[self.maint_index]
def _maint_button(self, now):
"""The pack an operator's one maintenance button acts on.
The pack holding the site's maintenance slot when one holds it, so
"abort" stops the cycle that is running and "start" restarts it -
exactly what both buttons meant while there was one pack, and the reason
this is not simply "the longest-waiting one". When every pack is idle it
IS the longest-waiting one, which is `arbitrate()`'s own clause 2, so a
forced start and a scheduled start choose the same pack rather than two
different ones.
⚠️ AT A ROSTER OF ONE BOTH BRANCHES ARE `maints[0]`. There is no third
case: `max()` over a one-element list is that element.
"""
if self.maints[self.maint_index].phase in POWER_MOVING:
return self.maints[self.maint_index]
return max(enumerate(self.maints),
key=lambda p: (p[1].waited_days(now), -p[0]))[1]
def inverter(self, name: str):
"""The roster entry called `name`, or None."""
@@ -1349,14 +1474,36 @@ class Controller:
"""
now = datetime.now(timezone.utc)
# ⚠️ THE PACK IT IS MAINTAINING, NOT "the SoC". §12's recalibration
# drains and refills ONE pack and grades ONE coulomb counter, so it is
# graded on that pack's own reading - see `maint_unit`. A minimum across
# packs would abort the cycle on a sibling's state, and an average would
# declare a pack full that is not.
result = self.maint.tick(now, self.maint_unit.soc)
for msg in result.events:
self.log_event(msg)
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
# drains and refills ONE pack and grades ONE coulomb counter, so each
# state machine is ticked with the SoC of the inverter it belongs to. A
# minimum across packs would abort a cycle on a sibling's state, and an
# average would declare a pack full that is not. The pairing is
# positional and both lists are built from the roster in `__init__`, so
# a pack cannot be graded on its neighbour's meter.
#
# ⚠️ AND `arbitrate()` RUNS FIRST, ONCE, OVER ALL OF THEM. It is the
# site's one maintenance slot - see maintenance.py - and a pack that
# does not hold it is told so by name rather than left to discover it.
# Calling `tick()` without the answer this produced is the
# two-packs-draining-at-once defect with the interlock present but not
# wired, which is why the plan is built here and not inside the loop.
maint_plan = maint_arbitrate(self.maints, now)
maint_results = []
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
res = machine.tick(now, inv.soc, blocked_by=blocked)
for msg in res.events:
self.log_event(msg)
maint_results.append(res)
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
# deferral is edge-logged (it lasts as long as the other pack's cycle),
# so by the time an operator asks "why has pack B not recalibrated" the
# line that said so has scrolled off. §12's whole failure mode is a
# schedule that silently stops firing; a pack that is due, idle and
# WAITING has to be able to say so at the moment it is looked at.
self._maint_deferred = [(m.name, r.deferred)
for m, r in zip(self.maints, maint_results)
if r.deferred]
# --- SAFETY-02, and it runs BEFORE the early return below -------------
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
@@ -1870,88 +2017,114 @@ class Controller:
self.log_event(self.arb.explain())
# --- maintenance -----------------------------------------------------
if result.owns_setpoint:
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
# drain THIS pack to its floor, charge THIS pack full, recalibrate
# THIS pack's coulomb counter. Two packs recalibrate on two
# schedules. See arbiter.CLAIM_SCOPE.
# ⚠️ FILED ON THE UNIT BEING MAINTAINED, WHICH IS THE FIRST ONE.
# §12's monthly recalibration is a PACK procedure and this add-on
# holds exactly ONE `Maintenance` state machine, so the pack it is
# maintaining is `self.maint_unit`. Two packs recalibrating on two
# schedules is a second FSM with its own namespaced history, which
# is a card of its own - what this card must not do is file one
# pack's procedure on every actuator.
claims.append(Claim.set("maintenance", P_MAINTENANCE,
float(result.setpoint_w or 0.0), result.phase,
actuator=self.actuator_of("maintenance",
self.maint_unit)))
self.sat_count = 0
elif result.charge_only:
# Money outranks the maintenance schedule: while the quarter-hour
# projection is over the cap, the charge-only shaping is simply not
# claimed, so the loop can discharge and shave the peak. When a
# dedicated peak-shaving strategy arrives it will claim SET at
# P_PEAK and outrank this limit without any code here changing.
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim
# is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or
# above the winner's priority, and most restrictive wins - so a peak
# event during a maintenance charge window files `lo > hi`, which is
# a CONTRADICTION and resolves to 0 W. The battery would park in the
# exact quarter it should be shaving. Priority does not save it:
# clause 2 has no notion of a higher-priority limit overriding a
# lower one, and it should not - "most restrictive" is what makes the
# safety bounds unconditional.
#
# So the higher-priority claimant SUPPRESSES the lower one rather
# than out-voting it, which is what this branch already did for
# `peak_forecast_entity`. That entity is optional and empty on a
# default install, so until now the suspension could not fire on the
# site that needed it. `self.peak_guard` is measured from the meter
# and is always there.
#
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not
# tidiness. It was inline, and a reviewer mutated it back to its
# historical broken form - re-gated on `peak_forecast_entity` alone -
# and every suite in this repository stayed green while a default
# install parked at 0 W in the quarter that sets the month's bill.
# Read shaping_suspended() before touching this: both terms are
# there, and why neither is sufficient alone.
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc,
float(self.o.get("peak_cap_w", 3500))):
# Edge-logged: level-logging here would write one line per cycle
# for as long as the event lasts and flush the 40-line action log
# of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
else:
self._maint_shape_suspended = False
hi = 0.0
reason = "charge-only"
if self.cheap:
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
# OF `self.o`. This line used to re-open the options dict
# with its own literal fallback, so the rate the shaping
# asked for and the rate the state machine drained at could
# be different numbers from the same install - and, with the
# key absent, they were. The Controller already parsed this
# once in __init__; asking the object that owns it cannot
# drift from it.
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason,
actuator=self.actuator_of("maintenance",
self.maint_unit)))
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
# At most one machine is out of IDLE on any tick, so at most one of
# these branches fires and the claim list is the shape it has always
# been. The loop is not a widening of what maintenance may ask for; it
# is what makes the ONE claim it does file land on the pack that is
# actually being recalibrated, whichever roster slot that is.
for inv, result in zip(self.inverters, maint_results):
if result.owns_setpoint:
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
# drain THIS pack to its floor, charge THIS pack full,
# recalibrate THIS pack's coulomb counter. Two packs recalibrate
# on two schedules. See arbiter.CLAIM_SCOPE.
#
# ⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`,
# the roster entry this `result` was ticked from - and that is
# the whole of MAINT-05 at this line. It used to be
# `self.maint_unit`, which was `inverters[0]` flatly, because
# there was one FSM and its history had nowhere else to live.
# Pack B's drain filed on pack A is one pack being commanded for
# another's procedure while the pack that is actually draining
# stays under the loop: the site would move the right total and
# recalibrate nothing.
#
# ⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two
# actuators, which the allocator consumes as per-unit intervals;
# ARCH-01 is explicit that this must never become a site claim,
# because per-inverter constraints filed site-wide are the
# cross-inverter `lo > hi` contradiction at arbiter.py:114.
claims.append(Claim.set("maintenance", P_MAINTENANCE,
float(result.setpoint_w or 0.0), result.phase,
actuator=self.actuator_of("maintenance", inv)))
self.sat_count = 0
elif result.charge_only:
# Money outranks the maintenance schedule: while the quarter-hour
# projection is over the cap, the charge-only shaping is simply not
# claimed, so the loop can discharge and shave the peak. When a
# dedicated peak-shaving strategy arrives it will claim SET at
# P_PEAK and outrank this limit without any code here changing.
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's
# claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY
# limit at or above the winner's priority, and most restrictive
# wins - so a peak event during a maintenance charge window files
# `lo > hi`, which is a CONTRADICTION and resolves to 0 W. The
# battery would park in the exact quarter it should be shaving.
# Priority does not save it: clause 2 has no notion of a
# higher-priority limit overriding a lower one, and it should not -
# "most restrictive" is what makes the safety bounds unconditional.
#
# So the higher-priority claimant SUPPRESSES the lower one rather
# than out-voting it, which is what this branch already did for
# `peak_forecast_entity`. That entity is optional and empty on a
# default install, so until now the suspension could not fire on the
# site that needed it. `self.peak_guard` is measured from the meter
# and is always there.
#
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is
# not tidiness. It was inline, and a reviewer mutated it back to its
# historical broken form - re-gated on `peak_forecast_entity` alone
# - and every suite in this repository stayed green while a default
# install parked at 0 W in the quarter that sets the month's bill.
# Read shaping_suspended() before touching this: both terms are
# there, and why neither is sufficient alone.
#
# ⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE
# INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is
# a peak event at the grid connection, and `arbitrate()` allows
# at most one pack to be in CHARGE at a time, so this branch can
# run at most once per tick. Were two packs ever allowed to
# charge together, this flag would have to become per-pack - and
# so would the reasoning above it.
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc,
float(self.o.get("peak_cap_w", 3500))):
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
else:
self._maint_shape_suspended = False
hi = 0.0
reason = "charge-only"
if self.cheap:
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
# OF `self.o`. This line used to re-open the options dict
# with its own literal fallback, so the rate the shaping
# asked for and the rate the state machine drained at
# could be different numbers from the same install - and,
# with the key absent, they were. The Controller already
# parsed this once in __init__; asking the object that
# owns it cannot drift from it. Every pack's machine
# holds the same `MaintConfig` instance, so this is the
# rate the pack in front of us is charging at.
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged, which is `inv`.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
reason=reason,
actuator=self.actuator_of("maintenance",
inv)))
# --- the grid-following loop -----------------------------------------
if self.auto and self.inputs_ok:
@@ -3358,14 +3531,29 @@ class Controller:
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
elif self.maint.phase != IDLE:
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
# of one this is the string it has always been, character for
# character; at two, "Maintenance: drain" without a name is a
# banner an operator cannot act on, because the remedy is at one
# device and the site has two.
level, banner = "warn", f"Maintenance: {self.maint.phase}"
if len(self.inverters) > 1:
banner += f" ({self.maint_unit.name})"
else:
level, banner = "ok", "Running — holding grid at zero"
def w(v):
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
next_due = self.maint.next_due(datetime.now(timezone.utc))
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a
# site with two schedules has to be the one that comes first, or the row
# says "2026-10-01" while a pack recalibrates tomorrow. `None` means
# "never run, due now" and has to win over any date, so it is tested for
# rather than sorted with. At a roster of one this is that pack's own
# date, unchanged.
_now = datetime.now(timezone.utc)
_dues = [m.next_due(_now) for m in self.maints]
next_due = None if any(d is None for d in _dues) else min(_dues)
rows = [
("Grid", w(self.grid)),
("Battery", w(self.site_batt_w)),
@@ -3381,7 +3569,14 @@ class Controller:
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
("Why", self.reason),
("Maintenance phase", self.maint.phase),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
# page - where `_maint_deferred` is empty on every tick - carries
# exactly the rows it carried before.
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
for n, why in self._maint_deferred)),)
if self._maint_deferred else ()),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
@@ -3493,11 +3688,26 @@ class Controller:
await self.command_site_zero()
return {"auto": self.auto}
if what == "maint_start":
for msg in self.maint.force_start(now):
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
# ONE LABEL. `_maint_button()` answers it: the pack that holds the
# site's maintenance slot if one does - which is what "start"
# already meant at a roster of one, where pressing it mid-drain
# re-entered DRAIN - and otherwise the pack that has gone longest
# without a cycle, which is the same pack `arbitrate()` would have
# picked on its own. At N=1 both branches are `maints[0]` and this
# is the shipped call, byte for byte.
#
# A PER-PACK BUTTON IS A UI CARD, NOT THIS ONE. `web.py` posts a
# bare `maint_start` with no unit, and inventing a unit parameter
# here would ship half a surface: the page would still have one
# button and the API would have two meanings.
for msg in self._maint_button(now).force_start(now):
self.log_event(msg)
return {"phase": self.maint.phase}
if what == "maint_abort":
for msg in self.maint.abort(now, "operator"):
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
for msg in self._maint_button(now).abort(now, "operator"):
self.log_event(msg)
await self.command_site_zero()
return {"phase": self.maint.phase}
+309 -16
View File
@@ -18,16 +18,47 @@ OWNERSHIP, not priority. Exactly one thing writes the setpoint at any moment:
The loop is never "disabled" - it yields. The heartbeat to the inverter keeps
running in every phase, so the hardware watchdog stays fed and a crash here
still ends with the inverter at 0 W rather than latched.
ONE PACK AT A TIME (MAINT-05). There is now one `Maintenance` per roster entry,
each with its own schedule, its own phase and its own history under its own
Store keys - and therefore, for the first time, two of them able to move power
on ONE grid connection at once. Read `arbitrate()` before touching any of it:
the site holds exactly ONE maintenance slot, a pack that is not IDLE holds it,
and every other pack is held at IDLE under the loop until it is released. That
is not a scheduling nicety. A drain of pack A against a charge of pack B is two
power-moving phases pulling opposite ways across the meter - the circulating
power SAFETY-31 exists to DETECT, arriving here as something this module can
simply not do.
"""
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from .store import MAINT_UNIT_KEYS, unit_key
_LOG = logging.getLogger("goodwe.maint")
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
# ⚠️ THE PHASES THAT MOVE POWER, WHICH IS NOT THE SAME LIST AS "owns the
# setpoint". The table above is about WHO WRITES the number; this is about
# whether the pack is being pushed. DRAIN and HOLD own the setpoint. CHARGE does
# NOT - the loop owns it - and CHARGE is in here anyway, because the charge-only
# clamp plus the cheap-window floor is what pulls a second pack full while the
# first is being emptied. Leaving CHARGE out is precisely the interlock hole
# this card is about: pack A charging from the grid at 2000 W while pack B
# drains 2000 W to export is 4 kW going round in a circle with the meter at
# zero, and both packs believing they are behaving.
#
# HOLD commands 0 W and moves nothing, and it is in here for the other half of
# the sentence: it OWNS the actuator - the pack is parked at the top for the BMS
# and is deliberately not available to the loop - so a second cycle starting
# under it would still be two recalibrations at once, which is the thing whose
# value is a clean uninterrupted coulomb count.
POWER_MOVING = (DRAIN, CHARGE, HOLD)
# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped
# 2000 W is ~6.9 h even with no house load helping - and less in practice, since
# house load drains alongside the export and soc_floor stops short of empty.
@@ -153,17 +184,102 @@ class MaintResult:
setpoint_w: float | None = None
charge_only: bool = False
events: list = field(default_factory=list)
# ⚠️ WHY A DEFERRAL IS A FIELD AND NOT JUST A LOG LINE. A pack that is due
# and is not starting looks, from every surface an operator has, exactly
# like a pack whose schedule has quietly stopped firing - §12's opening
# sentence, the failure this whole module exists because of. It is carried
# so the status page can SAY "waiting for the other pack" rather than
# showing an idle machine and a due date in the past. Empty on every path
# that is not deferring, so a roster of one never sees it.
deferred: str = ""
class UnitHistory:
"""One pack's maintenance history in the Store, under one spelling.
⚠️ THE NAMESPACE IS A CONSTRUCTOR ARGUMENT AND `None` IS A REAL VALUE, NOT
AN OMISSION. `unit=None` means the FLAT keys - `phase`, `phase_started`,
`last_completed`, `last_start_attempt` - which is what every install has
written since the add-on shipped and what a one-inverter site keeps writing
after MAINT-05. §6.2's identity: with one pack there is nothing for a
namespace to distinguish it FROM, and adding one would change the layout of
a live state file to say something it already said. `arbiter.actuator_for()`
makes the same move for the same reason and its docstring argues it.
⚠️ `mirror_flat` IS THE ROLLBACK VIEW AND IT IS ONLY EVER THE FIRST PACK'S.
At N>=2 pack A's history has been migrated into its namespace and is written
there; the flat keys are ALSO kept in step, so an operator who rolls the
add-on back to a single-FSM version finds a CURRENT history rather than one
dated the day the second inverter was declared. store.migrate_maint_keys()
carries the argument, and it is DEV-02's rule 2 applied to history instead
of to a latch: "left in the file" is not the same promise as "still true".
Pack B has no flat spelling to mirror to - the flat keys mean pack A on
every install that has them - so it is constructed with mirroring off, and
a mirror from B would OVERWRITE A's rollback view with B's phase.
"""
def __init__(self, store, unit: str | None = None, mirror_flat: bool = False):
self.store = store
self.unit = unit
self.mirror_flat = bool(mirror_flat)
def key(self, name: str) -> str:
"""The Store key this pack's `name` lives under."""
return name if self.unit is None else unit_key(self.unit, name)
def get(self, name: str, default=None):
return getattr(self.store, "data", {}).get(self.key(name), default)
def set(self, name: str, value) -> None:
self.store.set(self.key(name), value)
if self.mirror_flat:
self.store.set(name, value)
def get_time(self, name: str):
return self.store.get_time(self.key(name))
def set_time(self, name: str, when) -> None:
self.store.set_time(self.key(name), when)
if self.mirror_flat:
self.store.set_time(name, when)
class Maintenance:
def __init__(self, cfg: MaintConfig, store):
"""ONE pack's recalibration cycle: its schedule, its phase, its history.
⚠️ ONE PER ROSTER ENTRY SINCE MAINT-05, AND THE SLOT IS NOT ITS OWN. This
object decides WHEN ITS PACK would like to start; `arbitrate()` decides
which of them may, because the constraint is a property of the grid
connection and no single FSM can see it. Never call `tick()` in a loop
without passing the `blocked_by` that `arbitrate()` returned for it: that is
the two-packs-at-once bug with the interlock present but not wired.
"""
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
name: str = "", mirror_flat: bool = False):
self.cfg = cfg
self.store = store
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
# the one place that turns it into the key this pack owns. A raw
# `store.set("phase", ...)` anywhere below would write pack A's history
# from pack B's state machine, which is the shared-state bug this card
# is about, and there would be nothing to see at a roster of one.
self.store = UnitHistory(store, unit, mirror_flat)
self.unit = unit
# What an event or a refusal calls this pack. At a roster of one it is
# never rendered - a single machine cannot be deferred - so the shipped
# one-inverter reason strings are untouched.
self.name = str(name or unit or "the pack")
# Edge-memo for the deferral note. The action log holds 40 lines and a
# deferral lasts as long as the other pack's cycle - hours - so a line
# per cycle would flush the very context that explains it. Same pattern
# as `Controller._arb_action` and `_maint_shape_suspended`.
self._deferred = ""
# -- state ---------------------------------------------------------------
@property
def phase(self) -> str:
return self.store.data.get("phase", IDLE)
return self.store.get("phase", IDLE)
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
self.store.set("phase", phase)
@@ -185,14 +301,60 @@ class Maintenance:
last = self.store.get_time("last_completed")
return None if last is None else last + timedelta(days=self.cfg.interval_days)
def waited_days(self, now: datetime) -> float:
"""Days since this pack last COMPLETED a cycle; `inf` if it never has.
⚠️ THE ARBITRATION KEY, AND IT IS DELIBERATELY NOT `due()`. `due()` is a
yes/no and two packs that are both due are indistinguishable by it -
which is how a roster-order tie-break gets written and how the first
entry then wins every contested month forever while the second drifts.
This is an ORDERING: the pack whose coulomb counter has gone longest
without a recalibration goes first. `inf` for never-recalibrated is the
same statement `due()` makes about an absent `last_completed`, kept as
one so a fresh pack cannot lose to one that ran last month.
"""
last = self.store.get_time("last_completed")
return math.inf if last is None else (now - last).total_seconds() / 86400.0
def _cooled(self, now: datetime) -> bool:
"""Is the post-abort cooldown over?"""
last_try = self.store.get_time("last_start_attempt")
return (last_try is None
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
def wants_start(self, now: datetime) -> bool:
"""Would this pack begin a cycle on this tick if the site let it?
⚠️ THE SAME PREDICATE `tick()` USES, NOT A COPY OF IT. `arbitrate()` has
to know who is asking before anybody moves, and a second transcription
of "enabled, in the start hour, due and cooled down" is a second opinion
about who was asking - so the IDLE branch of `tick()` calls this one.
"""
return (self.phase == IDLE and self.cfg.enabled
and now.hour == self.cfg.start_hour and self.due(now)
and self._cooled(now))
# -- operations ----------------------------------------------------------
def force_start(self, now: datetime) -> list:
def force_start(self, now: datetime, blocked_by: str = "") -> list:
"""Start a cycle regardless of the calendar.
Commissioning uses this: a cycle must be proven end to end before a site
is signed off, and nobody waits a month to find out the schedule never
fires.
⚠️ REGARDLESS OF THE CALENDAR, NOT REGARDLESS OF THE INTERLOCK. The
calendar is a preference and an operator may overrule it; "one pack
moves power at a time" is a property of the grid connection and an
operator pressing a button does not add a second one. A forced start
against a busy site is REFUSED, by name, and the phase machine does not
move - the refusal is returned as an event so the operator reads it in
the action log rather than watching nothing happen.
"""
if blocked_by:
msg = (f"maintenance: forced start REFUSED on {self.name} - "
f"{blocked_by}")
_LOG.warning("%s", msg)
return [msg]
events = []
self.store.set_time("last_start_attempt", now)
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
@@ -203,23 +365,80 @@ class Maintenance:
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
return events
def _defer_note(self, why: str, events: list) -> list:
"""Say once, not every second, that this pack is waiting its turn.
⚠️ THE WORDING IS DELIBERATELY NOT AN ABORT AND NOT A TIMEOUT. §12's
notes are explicit that a 12 h phase timeout means "the battery is not
following commands - a FAULT, not a slow day", and the one thing that
must not happen here is an operator reading a deferral as that. This
line says the pack is WAITING, names the pack it is waiting for, and
says it will run next - none of which any failure path in this module
says.
"""
if self._deferred != why:
self._deferred = why
msg = (f"maintenance: {self.name} is due and is WAITING its turn - "
f"{why}. It is idle under the control loop and will "
f"recalibrate at the next start hour once the slot is free; "
f"this is not a stalled phase.")
_LOG.info("%s", msg)
events.append(msg)
return events
# -- the machine ---------------------------------------------------------
def tick(self, now: datetime, soc: float | None) -> MaintResult:
def tick(self, now: datetime, soc: float | None,
blocked_by: str = "") -> MaintResult:
"""One cycle of this pack's state machine.
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
site's one maintenance slot is available to it, otherwise the reason it
is not. It gates two things and nothing else - a start, and a phase that
is already moving power on a pack that does not hold the slot.
"""
events: list = []
phase = self.phase
# ⚠️ THE SLOT IS CHECKED BEFORE THE PHASE, AND THIS BRANCH IS A RECOVERY
# RATHER THAN AN EXPECTED PATH. `arbitrate()` grants the slot to the
# pack that is already mid-cycle, so a pack that is BOTH moving power
# and blocked means two histories on disk say "drain" - a hand-edited
# state file, or a store carried across a version that had no interlock.
# The fail-safe direction is unambiguous: two packs pulling against each
# other on one connection is the fault SAFETY-31 latches the whole site
# for. Stand this one down to 0 W and say why; the holder keeps its
# cycle, which is the outcome that loses no coulomb count at all.
if blocked_by and phase in POWER_MOVING:
events += self.abort(now, f"interlock - {blocked_by}")
return MaintResult(IDLE, True, 0.0, False, events)
if phase == IDLE:
if self.cfg.enabled and now.hour == self.cfg.start_hour and self.due(now):
last_try = self.store.get_time("last_start_attempt")
cooled = (
last_try is None
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN)
)
if cooled:
self.store.set_time("last_start_attempt", now)
self._enter(DRAIN, now, events,
"maintenance: due -> drain (exports the surplus)")
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
if self.wants_start(now):
if blocked_by:
# ⚠️ `last_start_attempt` IS NOT STAMPED HERE, AND THAT IS
# THE STARVATION FIX RATHER THAN AN OMISSION. That key is
# the post-ABORT cooldown: it exists so a cycle that failed
# does not re-trigger inside the same start hour. A deferral
# is not an attempt - nothing was tried and nothing failed -
# and stamping it would put this pack under a 90 minute
# cooldown for a start hour that is 60 minutes long, so the
# pack that lost the arbitration would still be locked out
# at the moment the winner released the slot.
#
# ⚠️ AND THE PHASE DOES NOT MOVE, WHICH IS THE OTHER HALF.
# `phase_started` is untouched, so the 12 h DRAIN and 36 h
# CHARGE timeouts - which mean "the battery is not following
# commands, a fault" - cannot be reached by waiting. A
# deferred pack is IDLE under the loop, not a stuck one.
return MaintResult(IDLE, False, None, False,
self._defer_note(blocked_by, events),
blocked_by)
self._deferred = ""
self.store.set_time("last_start_attempt", now)
self._enter(DRAIN, now, events,
"maintenance: due -> drain (exports the surplus)")
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
self._deferred = ""
return MaintResult(IDLE, False, None, False, events)
# ⚠️ Fail toward inaction. Without a state-of-charge reading none of the
@@ -273,3 +492,77 @@ class Maintenance:
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
events += self.abort(now, f"unknown phase {phase!r}")
return MaintResult(IDLE, True, 0.0, False, events)
def _days(n: float) -> str:
return "never recalibrated" if n == math.inf else f"{n:.0f} d since its last cycle"
def arbitrate(machines, now: datetime) -> list:
"""Grant the site's ONE maintenance slot. Returns a list parallel to
`machines`: "" for the pack that may move power, otherwise the reason the
pack may not.
⚠️ THE SLOT IS A PROPERTY OF THE GRID CONNECTION, WHICH IS WHY IT IS NOT ON
`Maintenance`. Each FSM can see its own pack's schedule and nothing else; the
quantity being rationed - "how many phases are moving power across one
meter" - is a sum over the roster, exactly like `sum(|B_i|) - |sum(B_i)|` in
SAFETY-31 and for the same reason. A per-pack answer to a site-level
question is the shape of defect this repository keeps finding.
⚠️ IT IS DERIVED, NOT PERSISTED, AND THAT IS DELIBERATE. The holder is
"whichever pack's stored phase is not IDLE" - a fact already on disk, in the
per-pack history the packs themselves write. A separate `maint_slot_holder`
key would be a second opinion about the same thing, and the two would
disagree the first time the add-on was killed between writing the phase and
writing the holder. Restart-safe by construction: a controller that comes
back up mid-drain reads the drain and re-derives the holder from it.
TWO CLAUSES, IN ORDER:
1. A PACK ALREADY MOVING POWER KEEPS THE SLOT. Nothing preempts a cycle in
flight, because a recalibration interrupted halfway is worth less than
nothing - the coulomb counter is graded on ONE uninterrupted drain and
refill, so a cycle stopped at 40 % has spent the energy and bought no
calibration. If more than one pack somehow comes up moving power, the
FIRST in roster order holds and `tick()` stands the others down; see the
recovery branch there.
2. OTHERWISE THE LONGEST-WAITING CANDIDATE STARTS. `wants_start()` is asked
of every pack, and among those that say yes the slot goes to the one with
the largest `waited_days()`. NOT roster order: two packs on one monthly
schedule are due in the same hour every month, and a roster-order tie
would hand the slot to `inverters[0]` every single time while the second
pack's coulomb counter drifted forever - which is, in a different
costume, the exact defect this card was opened for. Roster order breaks a
genuine numeric tie and nothing else.
⚠️ AT A ROSTER OF ONE THIS IS THE IDENTITY FUNCTION. One machine is never
another machine's blocker: clause 1 finds at most itself and clause 2 has at
most one candidate, so it returns `[""]` on every tick and no reason string
it can build is ever rendered. That is what makes the one-inverter behaviour
byte-identical rather than merely similar.
"""
blocks = [""] * len(machines)
busy = [(i, m) for i, m in enumerate(machines) if m.phase in POWER_MOVING]
if busy:
held, holder = busy[0]
for i, m in enumerate(machines):
if i != held:
blocks[i] = (f"{holder.name} is mid-cycle ({holder.phase}) and "
f"holds the site's one maintenance slot")
return blocks
wants = [(i, m) for i, m in enumerate(machines) if m.wants_start(now)]
if len(wants) <= 1:
return blocks
# Largest waited_days wins; `-index` makes a numeric tie fall to roster
# order rather than to whatever `max()` happens to see first.
won, winner = max(wants, key=lambda p: (p[1].waited_days(now), -p[0]))
for i, m in wants:
if i != won:
blocks[i] = (f"{winner.name} has waited longer "
f"({_days(winner.waited_days(now))} against "
f"{_days(m.waited_days(now))}) and takes the site's "
f"one maintenance slot first")
return blocks
+281 -26
View File
@@ -126,16 +126,53 @@ SCHEMA_VERSION = 1
SCHEMA_KEY = "schema_version"
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
# ⚠️ THE MAINTENANCE KEYS ARE NOT IN HERE AND THAT IS DELIBERATE. `phase`,
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
# maintenance state machine, of which this add-on still holds exactly ONE - a
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
# its own, not something to infer from a key rename. Moving them here would
# quietly re-home an install's whole maintenance history under a unit name; they
# stay where they are, untouched, which is the other half of "migrates without
# losing its latch OR ITS HISTORY".
# ⚠️ THE MAINTENANCE KEYS ARE STILL NOT IN HERE, AND SINCE MAINT-05 THAT IS A
# DIFFERENT DECISION RATHER THAN THE SAME ONE. They DO move now - one
# `Maintenance` per roster entry needs one history per roster entry - but they
# move on their OWN marker, `MAINT_SCHEMA_KEY`, and only on a site that actually
# has more than one pack. Two conditions the SAFETY-02 keys do not share, so
# folding them into this tuple would either drag them across on a
# single-inverter install (which is the byte-identical store layout MAINT-05
# must not change) or hold the sign latch hostage to a roster count it has
# nothing to do with. `MAINT_UNIT_KEYS` below carries them.
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
# --------------------------------------------------------------------------- #
# MAINT-05: THE SECOND MIGRATION, AND WHY IT IS A SECOND ONE
#
# 0 = the flat, one-history layout every install has had since the add-on
# shipped: `phase` / `phase_started` / `last_completed` /
# `last_start_attempt` describe THE pack, because there was one FSM.
# 1 = one history per roster entry, under `unit/<name>/...`.
#
# ⚠️ ITS OWN MARKER, NOT A BUMP OF `SCHEMA_VERSION`, AND THE REASON IS THAT THE
# TWO MIGRATIONS DO NOT HAVE THE SAME PRECONDITION. DEV-02's runs on every
# install exactly once and is monotone in the version number. This one runs ONLY
# WHERE THE ROSTER HAS MORE THAN ONE PACK, because at N=1 §6.2's identity holds
# and the flat keys ARE that pack's namespace - moving them would add four keys
# and a version marker to a store MAINT-05's own acceptance criterion requires
# to be byte-identical. A single monotone counter cannot express "migrated on
# this site, deliberately not migrated on that one" without lying to the next
# version that reads it: a one-inverter store would have to be left at the old
# number forever, and then a genuine v2 migration could never be told apart from
# a site that simply never grew.
#
# ⚠️ AND IT IS TWO MARKERS, NOT TWO COPIES OF THE THREE RULES.
# `_move_flat_keys()` and `_verify_moves()` below are shared verbatim by both
# migrations, so rules 1 and 3 and the READ-BACK POSTCONDITION are one
# implementation. A second transcription of a migration is a second opinion
# about what "it landed" means.
MAINT_SCHEMA_VERSION = 1
MAINT_SCHEMA_KEY = "maint_schema_version"
# The flat maintenance-history keys, in the order they are moved.
# ⚠️ THE VALUES ARE THE FLAT SPELLINGS AND THE MODULE THAT READS THEM INDEXES
# THIS SAME TUPLE (`maintenance.UnitHistory`), so a key renamed here cannot
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
# SAFETY-02 pair.
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
"last_start_attempt")
def unit_key(unit: str, key: str) -> str:
"""The namespaced Store key for `key` on inverter `unit`.
@@ -174,16 +211,35 @@ class MigrationReport:
# to shout: an alarm that fires on the harmless case is an alarm an
# operator learns to dismiss before the real one arrives.
self.lost = []
# ⚠️ A DIFFERENT FAILURE FROM `lost`, AND IT HAS TO BE, BECAUSE THE
# READ-BACK CANNOT SEE IT. `lost` is "the store took the write and
# dropped it". This is "the store took the write, kept it perfectly, and
# NOTHING WILL EVER READ IT" - the migration was handed a unit name that
# is not on the roster, so every machine looks under a different key and
# finds nothing. There is no harmless-default version of this the way
# there is for `lost`: a write nobody reads is not information, whatever
# its value, so anything in here fails `ok` on its own.
self.misdirected = []
# Why nothing ran, when nothing ran. Empty means "already at schema".
self.skipped = ""
@property
def ok(self) -> bool:
"""Nothing that CARRIED STATE failed to land."""
return not self.lost
"""Nothing that CARRIED STATE failed to land, and it landed WHERE THE
MACHINES LOOK."""
return not self.lost and not self.misdirected
def explain(self) -> str:
if not self.ran:
return (f"store already at schema {self.from_version}; no "
f"per-inverter migration needed")
# ⚠️ `skipped` RATHER THAN A SECOND SENTENCE ABOUT VERSIONS. Since
# MAINT-05 there are two reasons a migration does not run - the
# store is already at this schema, or the roster has one pack and
# the flat keys ARE its namespace - and an operator reading the
# startup log needs to be told which, because only one of them
# changes when a second inverter is declared.
return self.skipped or (f"store already at schema "
f"{self.from_version}; no "
f"per-inverter migration needed")
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
f"namespaces: " + ", ".join(sorted(self.moved)))
if self.kept:
@@ -192,6 +248,8 @@ class MigrationReport:
out += " - PROBLEMS: " + "; ".join(self.problems)
if self.lost:
out += " - STATE LOST: " + "; ".join(self.lost)
if self.misdirected:
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
return out
@@ -243,7 +301,30 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
return rep
rep.ran = True
for legacy in LEGACY_UNIT_KEYS:
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
_verify_moves(store, rep)
if rep.ok:
store.set(SCHEMA_KEY, SCHEMA_VERSION)
else:
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
# next start rather than be recorded as finished - the one thing worse
# than a failed migration is a failed migration a later version skips.
_LOG.error("per-inverter store migration LOST STATE and has not been "
"marked done: %s", rep.explain())
return rep
def _move_flat_keys(store, unit: str, keys, rep: MigrationReport) -> None:
"""Rule 1, once, for both migrations.
Copies each flat `key` to `unit/<unit>/<key>` UNLESS the namespaced spelling
is already present - a store that has both has been written by a version
that knew about the namespace, and that value is the live one. Rule 2 is the
absence of a delete: the flat keys are left exactly where they were.
"""
data = getattr(store, "data", {})
for legacy in keys:
target = unit_key(unit, legacy)
if target in data:
rep.kept.append(target)
@@ -252,17 +333,63 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
# tripped has no `sign_tripped` in its file at all, and the value it
# then lands on has to be the one `Store.load()` would have supplied -
# otherwise "absent" and "never tripped" stop being the same statement,
# which is the distinction store.py's own header is about.
# which is the distinction store.py's own header is about. The same
# sentence holds for `last_completed`: absent means "never recalibrated"
# and a literal here would be a second opinion about that.
value = data.get(legacy, DEFAULTS.get(legacy))
store.set(target, value)
rep.moved[target] = value
# THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION. This
# is the difference between this function and a two-line copy: the caller
# can ask "is the latch that was in the file before now under the key the
# watchdog reads", through the same `unit_key()` the watchdog's constructor
# reads through, and a `False` there is a guard that is off on an install
# whose state file says it is on.
def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
"""THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION.
This is the difference between these functions and a two-line copy: the
caller can ask "is the latch that was in the file before now under the key
the watchdog reads" - and, since MAINT-05, "is the history that was in the
file before now under the key THIS PACK's state machine reads" - through the
same `unit_key()` those readers read through. A `False` here is a guard that
is off, or a month of coulomb-count history gone, on an install whose state
file says otherwise.
⚠️ AND `roster` IS THE OTHER HALF, WHICH THE READ-BACK ON ITS OWN CANNOT BE.
A POSTCONDITION THAT READS BACK THE NAMESPACE IT WAS HANDED CANNOT DETECT
BEING HANDED THE WRONG ONE. That is not a hypothetical: MAINT-05 shipped
with `migrate_maint_keys()` taking a bare `unit` string, and a call site that
passed `inverter.UNIT_A` instead of the first roster entry's name migrated an
install's whole maintenance history into `unit/inverter-a/...` on a roster of
`["garage", "shed"]`, read all four keys back under that same name, was
SATISFIED, and stamped the schema marker - so the next start skipped the
migration too and both packs came up believing they had never been
recalibrated. Every symptom of a clean migration, and the history gone.
`build_roster()` names a declared roster's entries from the config and falls
back to a POSITIONAL `inverter-N`, so `inverter-a` is not even the default
there; there is no roster on which that write could have been read.
So the namespace is graded against the one the MACHINES read - the roster -
rather than against the argument this function was handed, which is the
thing under test. A unit that is not on the roster is `misdirected`, which
fails `ok`, which means the caller does NOT stamp the version marker and the
next start runs the migration again. A re-run is recoverable, by rule 1; a
silent skip is the state store.py's header says must never happen.
⚠️ AND THE GRADED SET IS `moved` UNION `kept`, WHICH IS WHAT MAKES THAT
PARAGRAPH TRUE ON THE SECOND START AS WELL AS THE FIRST. Graded over `moved`
alone the refusal lasted exactly one start: the wrong call site is still
wrong next time, finds the keys it wrote already present, and rule 1 routes
every one of them to `kept` - so `moved` is empty, this loop sees nothing,
`ok` comes back True and the marker is stamped on a migration that has
reached no readable key. The measurement is in the comment on the loop
itself. `test_maintenance.py` section 9c calls this twice with the SAME
wrong `unit` over one store, which is the shape nothing in the suite had.
`roster=None` means "not graded", and the one caller that passes it is
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
own docstring gives - the `Inverter` objects restore their latch in their
constructors, so the keys have to be in place before there is a roster to
check against. That gap is real and is recorded as a running check rather
than left to a comment: see test_maintenance.py section 13.
"""
for target, value in rep.moved.items():
got = getattr(store, "data", {}).get(target, "<<missing>>")
if got == value:
@@ -274,14 +401,142 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
if value != DEFAULTS.get(legacy):
rep.lost.append(note)
if roster is None:
return
names = [str(n) for n in roster]
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
# through the same spelling the writer used rather than re-deriving it.
#
# ⚠️ `moved` UNION `kept`, AND THE UNION IS THE WHOLE OF THE SECOND FIX
# ROUND. Grading only `moved` made the refusal last EXACTLY ONE START.
# Measured at Controller level over one persisted store, roster
# ["garage", "shed"], flat `last_completed` 2026-08-07, with the call site
# still handing `inverter-a` on both starts:
#
# construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
# construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1
#
# On start 2 the four keys the WRONG start 1 wrote are already present, so
# rule 1 routes every one of them to `rep.kept`, `rep.moved` is EMPTY, this
# loop iterates over nothing, `misdirected` is empty and `ok` is True - so
# the caller stamps the marker and start 3 takes the `from_version >=
# SCHEMA` early return FOREVER, including after the call site is fixed.
# Loud-and-recoverable became silent-and-permanent after one restart: the
# exact state this module's header says must never happen, reached by the
# fix that was written to prevent it.
#
# The union is sound by construction rather than by hope: every entry in
# both `rep.moved` and `rep.kept` is a `unit_key(unit, legacy)` built from
# the SAME `unit` argument, so grading `kept` grades the same namespace
# decision `moved` does. A correct call site puts its `kept` entries under a
# roster name and they pass; a wrong one cannot launder its namespace by
# having already written it.
for target in sorted(set(rep.moved) | set(rep.kept)):
parts = target.split("/")
unit = parts[1] if len(parts) > 2 else ""
if unit in names:
continue
rep.verified = False
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
f"on the roster {names!r} - nothing reads that key")
rep.problems.append(note)
rep.misdirected.append(note)
def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
"""Move the flat maintenance history into the FIRST pack's namespace.
MAINT-05. Until this card there was one `Maintenance` and one history, and
`store.py`'s own header argued the keys should stay flat because per-pack
scheduling was a card of its own. This is that card: there is now one FSM
per roster entry, so there is one history per roster entry, and the flat one
belongs to the pack that was being recalibrated - `inverters[0]`, which is
what `Controller.maint_unit` named and what `validate.py` rule 6 graded
against.
⚠️ `len(roster) <= 1` IS A RETURN, NOT A SPECIAL CASE. At one inverter the
flat keys ARE that pack's namespace (§6.2's identity, the same one
`arbiter.actuator_for()` leans on), `Maintenance` is constructed to read
them directly, and a migration would add four keys plus a marker to a store
that must come out byte-identical. The migration happens on the day the
roster grows, which is the day the flat spelling stops being able to answer
"which pack".
⚠️ `roster` IS REQUIRED, AND IT REPLACED AN `n_units: int`. Two reasons, and
the second is the one MAINT-05 was rejected over.
The small one: `n_units` was `len(self.inverters)` at the only call site, so
the roster carries it - `len(roster)` - and a count passed beside the names
is a second opinion about how many packs there are.
The load-bearing one: THE NAMES ARE THE POSTCONDITION. `unit` says which
pack the flat keys belonged to and that is genuinely a decision - the first
roster entry, because that is the pack the one FSM was driving - but a
decision the caller can get WRONG, and until this signature existed nothing
could tell. `_verify_moves()` read back the namespace it was handed, so a
migration into a name no machine reads verified clean and stamped the
marker. Handing the roster in means the read-back is graded against the
keys the MACHINES read, and a `unit` that is not among these names comes
back `misdirected`: not `ok`, marker NOT written, migration runs again next
start - and again the start after that, because `_verify_moves()` grades
`kept` as well as `moved`. See `_verify_moves()` for the install this was
measured on, and for what grading `moved` alone cost on start 2.
THE THREE RULES ARE `migrate_unit_keys()`'s, SHARED IN CODE rather than
restated: `_move_flat_keys()` is rule 1 (never overwrite a namespaced value)
and rule 2 (the flat keys are left in the file), `_verify_moves()` is the
read-back postcondition, and the marker is written LAST so a crash before it
leaves the migration to run again - which rule 1 makes a no-op.
⚠️ AND RULE 2 IS KEPT LIVE HERE TOO, WHICH IS THE HALF DEV-02 HAD TO ADD
LATER. "Left in place" alone makes the flat history a fossil dated the day
the second inverter was declared: an operator who rolls back to a
single-FSM add-on would find a `last_completed` months old and recalibrate a
pack that does not need it, or - worse - a stale `phase` and a
`phase_started` old enough to abort on the 12 h timeout the moment it comes
up. So `maintenance.UnitHistory` MIRRORS the first pack's writes back onto
the flat keys for as long as the roster has more than one entry. The flat
pair is the ROLLBACK VIEW of the history, maintained, exactly as
`sign_tripped` is the rollback view of the latch.
"""
rep = MigrationReport()
data = getattr(store, "data", {})
names = [str(n) for n in roster]
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
if len(names) <= 1:
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
"history and are read in place, so there is nothing to "
"move and the store layout does not change")
return rep
if rep.from_version >= MAINT_SCHEMA_VERSION:
rep.skipped = (f"maintenance history already at schema "
f"{rep.from_version}; written by a multi-inverter "
f"version and not touched")
return rep
rep.ran = True
_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)
_verify_moves(store, rep, roster=names)
# ⚠️ `rep.ok` AND NOTHING ELSE - NOT `ok or kept`, NOT `not lost`, NOT
# `not misdirected`. BOTH guards gate this line and each one is pinned by a
# check that the other cannot satisfy: section 9c refuses a store whose
# failure is `misdirected` with `lost` empty, and section 9d refuses one
# whose failure is `lost` with `misdirected` empty AND `kept` non-empty -
# the mutation `if rep.ok or rep.kept:` stamps the marker on a migration
# that lost a month of history, and until section 9d existed all 19 suites
# were green on it.
if rep.ok:
store.set(SCHEMA_KEY, SCHEMA_VERSION)
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
else:
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
# next start rather than be recorded as finished - the one thing worse
# than a failed migration is a failed migration a later version skips.
_LOG.error("per-inverter store migration LOST STATE and has not been "
"marked done: %s", rep.explain())
# ⚠️ NOT MARKED DONE, FOR EITHER FAILURE. The same sentence
# `migrate_unit_keys()` carries: the one thing worse than a failed
# migration is a failed migration a later version skips. Rule 1 makes
# the re-run a no-op where the keys did land, so refusing the marker
# costs one repeated pass and buys the difference between a migration
# that can still be fixed and one the store says already happened.
_LOG.error("per-pack maintenance-history migration did NOT land and "
"has not been marked done: %s", rep.explain())
return rep
+39 -16
View File
@@ -851,24 +851,47 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
# test_maintenance.py pins it by reading the shipped file.
#
# ⚠️ AND IT IS GRADED AGAINST THE FIRST UNIT'S CAP, NOT THE SMALLEST AND NOT
# THE FLAT ONE. Rule 5 takes the weakest unit because there is a watchdog on
# every one of them; there is exactly ONE `Maintenance` in this add-on and
# `Controller.maint_unit` names the pack it recalibrates - the first in the
# roster, because the cycle's history lives under un-namespaced Store keys.
# The shaping claim is filed on THAT actuator and on no other, so the cap
# that can contradict it is THAT unit's. Taking `min()` here would refuse a
# perfectly workable site - a 5000 W pack being recalibrated beside a 2000 W
# sibling the cycle never touches - which is the over-refusal this file's
# docstring says gets a validator switched off. When `maint_unit` stops
# being `inverters[0]`, this line moves with it.
# ⚠️ MAINT-05: IT IS GRADED AGAINST THE WEAKEST UNIT'S CAP NOW, AND THE
# COMMENT THIS REPLACES SAID SO WOULD HAPPEN. It read: "there is exactly ONE
# `Maintenance` in this add-on and `Controller.maint_unit` names the pack it
# recalibrates - the first in the roster, because the cycle's history lives
# under un-namespaced Store keys. The shaping claim is filed on THAT
# actuator and on no other, so the cap that can contradict it is THAT
# unit's. Taking `min()` here would refuse a perfectly workable site - a
# 5000 W pack being recalibrated beside a 2000 W sibling the cycle never
# touches ... When `maint_unit` stops being `inverters[0]`, this line moves
# with it."
#
# `maint_unit` has stopped being `inverters[0]`. There is one `Maintenance`
# per roster entry, each with its own namespaced history, and EVERY pack is
# recalibrated on its own schedule - so the sibling "the cycle never
# touches" does not exist any more and the over-refusal argument has no
# subject. The shaping claim is filed on whichever pack is charging, so the
# cap that can contradict it is any cap on the roster, and the one that
# contradicts it FIRST is the smallest. This is rule 5's conjunction for
# rule 5's reason: a threshold that is fine against the biggest unit says
# nothing about the smallest, and the smallest is the one that goes to 0 W.
#
# ⚠️ THIS REFUSES CONFIGS THAT USED TO START, AND THAT IS THE POINT RATHER
# THAN A SIDE EFFECT. `max_w` 5000 on pack A beside 2000 on pack B with
# `maintenance_charge_w` 3000 was accepted before this card and was correct
# while only pack A was ever recalibrated. Pack B now recalibrates too, and
# its cheap-window charge asks the arbiter for `hi = -3000` against a
# supervised `lo = -2000`: `lo > hi`, arbiter clause 3, 0 W, contradiction -
# a maintenance charge that silently never happens on that pack, which is
# exactly what rule 6 exists to refuse at the moment it is WRITTEN. At a
# roster of one `min()` over one cap is that cap and the comparison, the
# message and the boundary are identical.
charge_w = _number(opts, "maintenance_charge_w",
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON. `caps[0]` is the first
# unit WITH A USABLE CAP, which is `inverters[0]` only while every earlier
# entry has one; `_unit_label(opts, 0)` was hard-wired to `inverters[0]`
# regardless. Both come off the one pair now.
maint_index, maint_cap = caps_ix[0] if caps_ix else (0, None)
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON: `unit_caps_indexed()`
# SKIPS entries with no usable `max_w`, so a caps-list position is not a
# roster position and `_unit_label()` indexes the roster. The index travels
# with the number, so the unit the message names is the unit the number came
# off - which on a roster whose first entry has a junk `max_w` is not the
# same thing at all.
maint_index, maint_cap = (min(caps_ix, key=lambda p: p[1]) if caps_ix
else (0, None))
maint_where = _unit_label(opts, maint_index)
if charge_w is None:
problems.append(
+85 -6
View File
@@ -582,6 +582,58 @@ check("...and a store that only fails to persist a DEFAULT is not reported as "
"dismiss before the real one arrives",
migrate_unit_keys(LosesTheLatch(), UNIT_A).ok is True)
# ⚠️ AND THE MARKER GATE, ON A STORE WHERE SOMETHING WAS ALREADY NAMESPACED.
# FOUND BY MAINT-05's ROUND-3 BATTERY, AND IT WAS A SURVIVOR OF ALL 19 FILES.
# `migrate_unit_keys()` gates `SCHEMA_KEY` on `rep.ok`, and every check above
# reaches that gate on a store where NOTHING was already under `unit/...` - so
# `rep.kept` is empty in all of them, and the mutation
#
# if rep.ok: -> if rep.ok or rep.kept:
#
# stamped the marker on a migration that DROPPED THE LATCH, with the whole suite
# green. That is a disarmed safety guard plus a store that says the migration
# finished, so the next start skips the one thing that would have re-armed it -
# DEV-02's own worst case with the paper trail filed under "done".
#
# The fixture is the one shape that separates them: `sign_reason` ALREADY
# namespaced (so `kept` is non-empty and the mutant's `or` fires) and the
# `sign_tripped: true` write dropped on the floor (so `lost` is non-empty and
# `ok` is False). `migrate_unit_keys()` is passed no roster, so `misdirected`
# cannot be what refuses this - it is the `lost` guard or nothing.
_kept_reason = "SIGN_DISAGREEMENT: already namespaced by a later version"
_broken_kept = LosesTheLatch({
"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured",
unit_key(UNIT_A, "sign_reason"): _kept_reason})
_rep_kept = migrate_unit_keys(_broken_kept, UNIT_A)
print(f" a dropped latch write beside an already-namespaced reason: "
f"{_rep_kept.explain()}")
print(f" lost={len(_rep_kept.lost)} kept={len(_rep_kept.kept)} "
f"ok={_rep_kept.ok}; marker "
f"{_broken_kept.data.get(SCHEMA_KEY, '<<absent>>')!r}")
check("PRECONDITION: this fixture reaches the marker gate through the `lost` "
"guard with a NON-EMPTY `kept`, which is the combination none of the "
"checks above has. One key dropped, one key left alone, `ok` False. If a "
"later edit empties `kept` here the mutation goes invisible again, so the "
"shape is asserted rather than assumed",
_rep_kept.ran and len(_rep_kept.lost) == 1 and len(_rep_kept.kept) == 1
and not _rep_kept.ok
and _broken_kept.data[unit_key(UNIT_A, "sign_reason")] == _kept_reason)
check("⚠️ AND THE STORE IS NOT MARKED MIGRATED. The key that was ALREADY there "
"does not buy a marker for the latch that was not: stamping here would "
"tell the next start that the migration which dropped `sign_tripped` had "
"finished, so it would never run again and the guard would stay off on an "
"install whose file still reads `sign_tripped: true`",
SCHEMA_KEY not in _broken_kept.data)
check("...and the counterfactual, one property different: the SAME store shape "
"on a store that does not drop writes IS marked migrated. So the refusal "
"above is about the latch that was lost, not about `kept` being non-empty",
migrate_unit_keys(
MemStore({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured",
unit_key(UNIT_A, "sign_reason"): _kept_reason}),
UNIT_A).ok is True)
_broken2 = LosesTheLatch({"sign_tripped": True,
"sign_reason": "SIGN_DISAGREEMENT: measured"})
_c2d, _ = built(store=_broken2)
@@ -1649,11 +1701,28 @@ check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
"is inverter-B. The refusal says inverter-c",
_why(_p_skew, "max_w (inverter-c) is 1000 W")
and not _why(_p_skew, "max_w (inverter-b) is 1000 W"))
check("⚠️ RULE 6 TOO. `caps[0]` is the first entry WITH a usable cap - "
"inverter-b - while `_unit_label(opts, 0)` was hard-wired to inverter-a, "
"so the maintenance refusal quoted B's rating under A's name",
_why(_p_skew, "max_w (inverter-b) is 2000 W")
and not _why(_p_skew, "max_w (inverter-a) is 2000 W"))
# ⚠️ MAINT-05 MOVED RULE 6's SUBJECT, AND THIS CHECK MOVED WITH IT. It used to
# assert the refusal named inverter-b, because rule 6 graded the FIRST entry
# with a usable cap - `Controller.maint_unit` was `inverters[0]` and the shaping
# claim could be filed nowhere else. There is now one `Maintenance` per roster
# entry, so the shaping claim can be filed on ANY pack and the cap that
# contradicts it first is the SMALLEST - inverter-c's 1000 W. The property this
# check exists for is unchanged and is the reason it is still here: the index
# has to travel with the cap, or the message quotes one unit's rating under
# another unit's name.
check("⚠️ RULE 6 TOO, AND SINCE MAINT-05 IT IS THE WEAKEST PACK IT NAMES. "
"inverter-c's 1000 W sits at caps index 1, and `_unit_label(opts, 1)` is "
"inverter-B - so a caps index leaking into a roster index would say "
"inverter-b here. The refusal says inverter-c, with inverter-c's number",
_why(_p_skew, "maintenance_charge_w is 3000 W but max_w (inverter-c) is "
"1000 W")
and not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
"(inverter-b)"))
check("...and it is no longer graded against the FIRST usable cap at all, which "
"is the behaviour change MAINT-05 makes here: inverter-b's 2000 W was the "
"pre-card answer and appears nowhere in the maintenance refusal now",
not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
"(inverter-b) is 2000 W"))
check("...and the counterfactual, so this is not a check that would pass on a "
"validator that named nothing: the OLD expressions still evaluate, on "
"this same config, to the wrong names",
@@ -2152,6 +2221,16 @@ MUTATIONS = [
("validate.py allowing two roster entries with the SAME name",
"part 10: 'two entries with the SAME name are refused' - one Store "
"namespace for two units is DEV-02's defect rebuilt in YAML"),
("`migrate_unit_keys()`'s marker gate `if rep.ok:` -> `if rep.ok or "
"rep.kept:` - the schema marker stamped on a migration that DROPPED THE "
"LATCH, because one key happened to be namespaced already",
"part 10: 'AND THE STORE IS NOT MARKED MIGRATED'. ⚠️ THIS ONE SURVIVED "
"ALL 19 FILES and was found by MAINT-05's round-3 battery, not by this "
"card: every migration check here reached the gate on a store where "
"NOTHING was namespaced yet, so `rep.kept` was empty in all of them and "
"the `or` never fired. Stamping there disarms the latch AND tells the "
"next start the migration finished, so it never re-arms - DEV-02's own "
"worst case filed under 'done'"),
]
# ⚠️ FOUR OF THESE SURVIVED THE FIRST TIME AND THE CHECKS WERE CHANGED, NOT THE
# TABLE - two in DEV-02, two more in CFG-04, and the CFG-04 pair are recorded in
@@ -2170,7 +2249,7 @@ for what, caught in MUTATIONS:
check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check "
f"above. A guard whose checks survive its own defeat is not a guard, and "
f"this repository has three separate records of exactly that",
len(MUTATIONS) == 34)
len(MUTATIONS) == 35)
print()
File diff suppressed because it is too large Load Diff
+667 -1
View File
@@ -4149,6 +4149,590 @@ check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR --
# ⚠️ THIS IS ROUND 4's BLOCKER, AND IT IS THE SAME FAMILY ONE ARGUMENT OVER.
# Everything from 12c2 to 12h grades reductions on the FLOOR axis: the max
# inside `binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
# `pack_floor_pct()`'s conjunction. `Controller.pack_floor_soc` is the MINIMUM
# over the same roster, handed to the SAME `peakguard.decide()` call as `soc=`,
# and until this block nothing graded its reduction past N=2.
#
# ⚠️ THE PAIRING IS DOCUMENTED, WHICH IS WHY HARDENING ONE HALF IS NOT ENOUGH.
# main.py's call site says "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". A conjunction whose two halves have different
# amounts of evidence behind them is exactly as strong as its weaker half.
#
# THE SURVIVORS THIS BLOCK EXISTS FOR, all confirmed GREEN across all 19 files
# at 52380c0 and again on the rebase onto 3934ad7:
#
# min(float(v) for v in vals) -> ... for v in vals[:2] +4 bytes
# -> ... for v in vals[-2:] +5 bytes
# -> ... for v in vals[:3] +4 bytes
#
# while `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)` and the
# mean were all killed. The suite's separating power on the SoC axis stopped at
# exactly N=2 - which is the state the FLOOR axis was in before round 3, and
# which round 3 named as its own blocker 2. The evidence is a fixture of two
# packs, and A FIXTURE OF N PACKS CANNOT SEPARATE REDUCTIONS THAT AGREE ON N.
#
# IT CHANGES A REAL ANSWER, which is why it is a defect and not a tidiness
# point: a three-pack roster at 50 / 60 / 11 % with the 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 one, and spends a pack its own BMS stops - through `sim/inject.py`,
# which runs no validator rule at all.
#
# ⚠️ AND PRODUCTION IS DEGENERATE ON THE FLOOR AXIS, WHICH MAKES THIS AXIS THE
# LIVE ONE. SPIKE-04 reads pack B as 4 x Dyness DL5.0C at DoD 90 [FROM THE
# DATASHEET AND THE SERIAL - inverter B does not exist and nothing about it has
# been measured], so its cut-out is 10 %, the same as pack A's MEASURED 10 %.
# Two packs that floor at the same value make `binding_soc_floor()` answer the
# option whatever it reduces. The SoC axis has no such degeneracy: two batteries
# at the same state of charge is a coincidence, not a configuration.
#
# THE TREATMENT IS ROUND 3's, TRANSFERRED: a roster SET with the extreme at
# every position, a LENGTH SWEEP, and an EXACT STRUCTURAL PIN on the reduction's
# argument. Nothing below is a measurement of a second or third pack.
_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM = 11.0, 50.0, 60.0
_S_TRIO_SIM = (_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM)
def _soc_reaching_decide(tag, *socs):
"""Drive ONE REAL `Controller.cycle()` on an N-pack roster and report the
SoC `peakguard.decide()` was actually handed, read back off the decision
main.py itself filed (`PeakDecision.soc` is the argument, echoed).
⚠️ THE EXACT MIRROR OF `_floor_reaching_decide` ABOVE, on the other axis.
Not a spy and not a grep: the shipped `cycle()` runs, the shipped
`pack_floor_soc` runs, and the observable is a field of the decision the
controller stored - so the whole path from `Controller.inverters` to the
guard's argument is under test. Every pack carries the SAME cut-out
(inverter A's measured 10 %, which is below `peak_soc_floor`, so
`binding_soc_floor()` answers the option for every roster here); the only
thing that varies between calls is the packs' states of charge. That is
habit 1: with the FLOOR axis held flat, nothing but the SoC reduction can
move these answers.
"""
def _prep(ctl):
for _i in range(len(socs) - 1):
_sfx = chr(ord("b") + _i)
second_inverter(ctl, name=f"inverter-{_sfx}",
setpoint=f"number.sp_{_sfx}")
for inv, so in zip(ctl.inverters, socs):
inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR
ctl, _hs, _net, _rem = wired_over_limit(
TMP / f"s32s_{tag}.db", LIVE_MAX_W * 0.3, prepare=_prep)
return ctl, ctl.peak_guard
# BASELINE: three packs, all of them ABOVE `peak_soc_floor`, so the guard is
# permitted and sizes a real forced discharge. Every run below varies one thing
# from this baseline (habit 7) - which pack is the empty one, and nothing else.
_s_ctl_ok, _s_ok = _soc_reaching_decide("ok3", _S_FULL_SIM, _S_MID_SIM,
SOC_BETWEEN)
# THE SET: the SAME THREE readings, the emptiest at EACH position in turn. A
# reduction that reads a proper subset of the positions misses the empty pack on
# the run where it sits at the position that subset drops, and answers a fuller
# pack instead.
_s3_runs = []
for _pos in range(3):
_sc3 = [_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM]
_sc3.remove(_S_EMPTY_SIM)
_sc3.insert(_pos, _S_EMPTY_SIM)
_c3s, _d3s = _soc_reaching_decide(f"s3p{_pos}", *_sc3)
_s3_runs.append((f"3-pack, emptiest at index {_pos}", _sc3, _c3s, _d3s))
# ...and the two-pack mirror, which is the shape production reaches first.
_s2_runs = []
for _pos in range(2):
_sc2 = [_S_FULL_SIM, _S_EMPTY_SIM] if _pos else [_S_EMPTY_SIM, _S_FULL_SIM]
_c2s, _d2s = _soc_reaching_decide(f"s2p{_pos}", *_sc2)
_s2_runs.append((f"2-pack, emptiest at index {_pos}", _sc2, _c2s, _d2s))
print(f" REAL cycles, every cut-out held at inverter-a's measured "
f"{A_PACK_FLOOR:.0f} %, peak_soc_floor {SOC_FLOOR:.0f} % (every pack past "
f"the first is SIMULATED):")
print(f" 3-pack baseline, none empty: SoCs "
f"{[f'{s:g}' for s in (_S_FULL_SIM, _S_MID_SIM, SOC_BETWEEN)]} % -> "
f"decide() got soc={_s_ok.soc:g} %, floor_limited={_s_ok.floor_limited}")
for _rn, _sc, _ctl_s, _dec_s in _s3_runs + _s2_runs:
print(f" {_rn}: SoCs {[f'{s:g}' for s in _sc]} % -> decide() got "
f"soc={_dec_s.soc:g} %, floor_limited={_dec_s.floor_limited}")
check("⚠️ THE POSITIONAL PIN ON THE SoC AXIS, AND IT NEEDS THREE PACKS TO "
"EXIST. On a roster of THREE the SoC that reaches `decide()` is the "
"EMPTIEST pack wherever on the roster that pack sits - index 0, index 1 "
"and index 2 all answer 11 %, and all three rosters carry the SAME "
"multiset of readings. A property that reduced any PROPER SUBSET of the "
"positions would answer a fuller pack on the run whose empty one sits at "
"the position it dropped. `vals[:2]` and `vals[-2:]` - the +4- and "
"+5-byte mutations that walked through every fixture in this file up to "
"round 3, because at N=2 they ARE the roster - go red here",
[r[3].soc for r in _s3_runs] == [_S_EMPTY_SIM] * 3
and all(len(r[2].inverters) == 3 for r in _s3_runs)
and all(sorted(inv.soc for inv in r[2].inverters)
== sorted(_S_TRIO_SIM) for r in _s3_runs)
and [[inv.soc for inv in r[2].inverters] for r in _s3_runs]
== [r[1] for r in _s3_runs])
check("...and the TWO-pack mirror, which is what production reaches the moment "
"inverter B is installed: swap the same two readings between the packs "
"and the answer is the emptier one both times. `vals[:1]` and `vals[-1:]` "
"- grade the site against pack A alone, or against the last pack alone - "
"each agree with the roster on one of these two runs and disagree on the "
"other, so neither survives the pair",
[r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2
and [[inv.soc for inv in r[2].inverters] for r in _s2_runs]
== [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]])
check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a "
"defect rather than a reporting difference. With the emptiest pack at "
"11 % - below the 20 % EMS floor - every one of those five sites is "
"FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline "
"with its emptiest pack at 25 % is permitted and sizes a positive one. A "
"reduction that dropped the empty pack's position would grade this site "
"for PERMISSION against a pack 39 points fuller and spend, through "
"`sim/inject.py`, a pack its own BMS stops",
all(r[3].floor_limited is True and r[3].floor_w == 0.0
for r in _s3_runs + _s2_runs)
and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0
and _s_ok.soc == SOC_BETWEEN)
check("...and the precondition that makes the six runs comparable (habit 1): "
"every pack in every roster carries the SAME cut-out, so "
"`binding_soc_floor()` answers the SAME site floor on all of them and the "
"FLOOR axis cannot be what separated the answers; the three readings "
"really are three different numbers; and the empty one is really below "
"the EMS floor while the other two are really above it - two readings on "
"the same side of it would make every line here vacuous",
len({r[3].soc_floor for r in _s3_runs + _s2_runs}) == 1
and _s_ok.soc_floor == _s3_runs[0][3].soc_floor == float(SOC_FLOOR)
and all(inv.bms_soc_floor == A_PACK_FLOOR
for r in _s3_runs + _s2_runs for inv in r[2].inverters)
and len({_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM}) == 3
and _S_EMPTY_SIM < SOC_FLOOR < SOC_BETWEEN < _S_MID_SIM < _S_FULL_SIM)
# ⚠️ AND THE ENUMERATION ITSELF, COMPUTED RATHER THAN CLAIMED - 12g's table on
# the other axis. Every positional slice of `vals`, applied to the REAL
# `Controller.inverters` each cycle above actually carried, compared against the
# SoC that REALLY reached `decide()` on that run. A slice with an empty kill
# list is a live survivor and this line goes red naming it.
#
# ⚠️ WHAT THIS DOES NOT CLAIM. It grades the members listed and nothing wider -
# a rewrite that is not a positional slice (a different reduction, a filter on
# pack NAME, a sort before the slice) is not covered here. `max` and the mean
# are graded on the same runs immediately below. The table is PRINTED so a
# reader can see exactly how far the claim reaches.
_S_RUNS = ([("3-pack baseline, none empty", _s_ctl_ok, _s_ok)]
+ [(rn, ctl, dec) for rn, _sc, ctl, dec in _s3_runs + _s2_runs])
def _soc_reduced(vals, fn):
"""`pack_floor_soc`'s body with ONE thing changed: what `min()` iterates.
The None guard still runs over the WHOLE list, because these mutations are
inside the `min()` generator and not in the guard - the guard is graded
separately below. An empty slice makes the real `min()` raise, which is a
crashing cycle and therefore a kill; it is reported as a distinct answer
rather than swallowed.
"""
if not vals or any(v is None for v in vals):
return None
sl = fn(vals)
return float(min(float(v) for v in sl)) if sl else "min() of an empty slice"
_SOC_SLICE_FAMILY = {
"vals[:1]": lambda vs: vs[:1],
"vals[-1:]": lambda vs: vs[-1:],
"vals[:2]": lambda vs: vs[:2],
"vals[-2:]": lambda vs: vs[-2:],
"vals[1:]": lambda vs: vs[1:],
"vals[:-1]": lambda vs: vs[:-1],
"vals[1:-1]": lambda vs: vs[1:-1],
"vals[::2]": lambda vs: vs[::2],
}
_SOC_REDUCTION_FAMILY = {
"max(vals)": lambda vs: max(vs),
"mean(vals)": lambda vs: sum(vs) / len(vs),
"median(vals), upper": lambda vs: sorted(vs)[len(vs) // 2],
"sorted(vals)[1] (second emptiest)": lambda vs: sorted(vs)[min(1, len(vs) - 1)],
}
_soc_slice_kills = {
_nm: [rn for rn, ctl, dec in _S_RUNS
if _soc_reduced([inv.soc for inv in ctl.inverters], _fn) != dec.soc]
for _nm, _fn in _SOC_SLICE_FAMILY.items()}
_soc_red_kills = {
_nm: [rn for rn, ctl, dec in _S_RUNS
if _fn([inv.soc for inv in ctl.inverters]) != dec.soc]
for _nm, _fn in _SOC_REDUCTION_FAMILY.items()}
print(" THE POSITIONAL-SLICE FAMILY inside `pack_floor_soc`, and which run "
"separates each from the whole roster:")
for _nm, _k in _soc_slice_kills.items():
print(f" {_nm:<12} killed by: "
+ ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***"))
print(" ...and the NON-slice rewrites of the reduction itself:")
for _nm, _k in _soc_red_kills.items():
print(f" {_nm:<34} killed by: "
+ ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***"))
check("⚠️ EVERY positional slice in the enumerated family answers something "
"OTHER than the SoC that really reached `decide()` on at least one of "
"the six runs above - and it is the THREE-pack runs that kill `[:2]` and "
"`[-2:]`, which the two-pack runs structurally could not, by exactly the "
"argument 12c2 makes about N packs and reductions that agree on N. The "
"kill list is computed against the REAL `Controller.inverters` each cycle "
"carried and the REAL `PeakDecision.soc` it produced, so a member with an "
"empty list is a live survivor and takes this line red naming it. This "
"grades the LISTED members only; the printed table is its extent",
all(_soc_slice_kills.values()))
check("...and the same for the rewrites that are not slices at all: `max` - "
"the FULLEST pack, which is `binding_soc_floor()`'s reduction pasted onto "
"the wrong axis and the single most likely wrong edit here - the mean, "
"the median and the second-emptiest each disagree with the shipped answer "
"on at least one run. `max(vals)` on the index-2 run answers 60 % where "
"the site is graded at 11 %",
all(_soc_red_kills.values()))
check("...and the baseline the tables are measured against is the shipped "
"property's OWN answer, not a re-derivation: on every run, the SoC that "
"reached `decide()` equals `Controller.pack_floor_soc` on the roster that "
"cycle carried. If it did not, every kill list above would be measuring "
"the wrong thing",
all(dec.soc == ctl.pack_floor_soc for _rn, ctl, dec in _S_RUNS)
and len(_S_RUNS) == 6
and sorted(len(ctl.inverters) for _rn, ctl, dec in _S_RUNS)
== [2, 2, 3, 3, 3, 3])
# ⚠️ AND THE HONEST LIMIT OF EVERY RUN ABOVE, FOUND BY THIS ROUND'S OWN BATTERY
# AND NOT ARGUED FROM: `vals[:3]` and `vals[-3:]` SURVIVE all six behavioural
# runs, for the same reason `[:2]` survived the two-pack ones - THE LONGEST
# ROSTER HERE IS THREE, and a slice that keeps three consecutive positions IS
# the roster at N=3. Adding a four-pack cycle would close those two and hand
# `[:4]` and `[-4:]` the same free pass. THE REGRESS DOES NOT TERMINATE IN A
# FIXTURE - round 3's lesson, arriving unchanged on this axis.
#
# So the same two halves, each saying what it reaches. First the SWEEP, which is
# cheap here because `pack_floor_soc` is a PROPERTY and needs no cycle to
# evaluate: rosters of 1 to 12 real `Inverter`s, the empty pack at EVERY
# position of each, and the property must answer the emptiest every time. That
# kills every fixed-width head or tail window narrower than the longest roster
# at once, rather than one mutation at a time.
_S_SWEEP_N = 12
def _roster_of(socs):
"""A real Controller carrying one real `Inverter` per reading given."""
ctl, _hs = built(TMP / f"s32sw{len(socs)}.db")
for _i in range(len(socs) - 1):
second_inverter(ctl, name=f"sweep-{_i}", setpoint=f"number.sw_{_i}")
for inv, so in zip(ctl.inverters, socs):
inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR
return ctl
_s_sweep_rows = []
for _n in range(1, _S_SWEEP_N + 1):
for _pos in range(_n):
_sc = [_S_FULL_SIM] * _n
_sc[_pos] = _S_EMPTY_SIM
_s_sweep_rows.append((_n, _pos, tuple(_sc),
_roster_of(_sc).pack_floor_soc))
_s_sweep_bad = [(n, p, sc, got) for n, p, sc, got in _s_sweep_rows
if got != min(sc)]
_s_head_kill = {w: any(min(sc[:w]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows)
for w in range(1, _S_SWEEP_N + 1)}
_s_tail_kill = {w: any(min(sc[-w:]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows)
for w in range(1, _S_SWEEP_N + 1)}
print(f" LENGTH SWEEP on the real property (all SIMULATED past pack A): "
f"{len(_s_sweep_rows)} rosters, lengths 1..{_S_SWEEP_N}, the "
f"{_S_EMPTY_SIM:g} % pack at EVERY position of each among "
f"{_S_FULL_SIM:g} % siblings -> "
f"{len(_s_sweep_rows) - len(_s_sweep_bad)} answer the emptiest, "
f"{len(_s_sweep_bad)} do not")
print(" fixed-width windows killed by the sweep: head widths "
+ ",".join(str(w) for w in sorted(_s_head_kill) if _s_head_kill[w])
+ "; tail widths "
+ ",".join(str(w) for w in sorted(_s_tail_kill) if _s_tail_kill[w])
+ f"; NOT reached: head/tail width {_S_SWEEP_N} and wider")
check("⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`. Over rosters "
"of 1 to 12 real inverters with the empty pack at every position of each, "
"the property answers the EMPTIEST every time - so every head window "
"`min(vals[:w])` and every tail window `min(vals[-w:])` of width 1 to 11 "
"disagrees with it on at least one roster here, and that is the whole "
"positional family below width 12 closed in one property rather than one "
"mutation at a time. ⚠️ THIS IS THE REAL PROPERTY ON A REAL ROSTER, not a "
"re-implementation: `_roster_of()` appends real `Inverter` objects and "
"reads `ctl.pack_floor_soc`, so it also sees a rewrite of the "
"comprehension that builds `vals`",
not _s_sweep_bad
and len(_s_sweep_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2
and all(_s_head_kill[w] and _s_tail_kill[w]
for w in range(1, _S_SWEEP_N)))
check("⚠️ ...AND WIDTH 12 AND WIDER IS HONESTLY NOT REACHED, recorded rather "
"than papered over - the same ceiling 12c2's sweep records on the floor "
"axis. No roster in this file is longer than 12, so `min(vals[:12])` IS "
"`min(vals)` on every one of them and no check here can tell them apart. "
"A 13-pack roster would close width 12 and hand width 13 the same free "
"pass. That is why the STRUCTURAL PIN below exists alongside these runs, "
"and this line asserts the unreachedness itself so a later sweep that "
"lengthens the rosters takes it red and forces the sentence rewritten",
not _s_head_kill[_S_SWEEP_N] and not _s_tail_kill[_S_SWEEP_N]
and max(len(sc) for _n, _p, sc, _g in _s_sweep_rows) == _S_SWEEP_N)
# ...and the NONE GUARD is a reduction over roster length TOO, and it had
# exactly the same fixture problem: 12d sets the SECOND of two packs to None, so
# `any(v is None for v in vals[:1])` - grade only pack A for silence - answers
# "all present" there and hands a list containing None to `min()`. The sweep
# below moves the silent pack along the roster at every length, which is the
# data that reaches it. THE DIRECTION MATTERS: None must WIN, because a silent
# pack is a pack nothing can grade and `decide()` treats None as floor-limited.
def _pfs_answer(ctl):
"""`pack_floor_soc`, with a raise reported as an ANSWER rather than
ending the file. A guard narrowed to a slice does not return a wrong
number - it lets a None reach `min(float(v) ...)` and raises TypeError -
and a check that dies is a check that names nothing."""
try:
return ctl.pack_floor_soc
except Exception as exc: # noqa: BLE001 - reported
return f"raised {type(exc).__name__}"
_s_none_rows = []
for _n in range(1, _S_SWEEP_N + 1):
for _pos in range(_n):
_ctl_n = _roster_of([_S_FULL_SIM] * _n)
_ctl_n.inverters[_pos].soc = None
_s_none_rows.append((_n, _pos, _pfs_answer(_ctl_n)))
print(f" NONE-GUARD SWEEP: {len(_s_none_rows)} rosters, ONE silent pack at "
f"every position of every length 1..{_S_SWEEP_N} -> "
f"{sum(1 for _n, _p, g in _s_none_rows if g is None)} answer None")
check("⚠️ THE NONE GUARD IS THE SAME FAMILY. One silent pack at ANY position of "
"ANY roster length makes `pack_floor_soc` None - never a number, and "
"never the minimum of the packs that did answer. A guard narrowed to a "
"positional slice would let the site spend the packs that answered while "
"one sat unmeasured; 12d's two-pack fixture, with the SECOND pack silent, "
"cannot see a guard narrowed to the first",
all(g is None for _n, _p, g in _s_none_rows)
and len(_s_none_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2)
# ⚠️ THE STRUCTURAL HALF: the reduction's ARGUMENT, pinned EXACTLY, at any N.
# This is 12g's treatment applied inside a property rather than at a call site,
# and it is NOT a substring test - `"vals" in src` is true of every slice of
# `vals`, whereas `ast.unparse(iter) == "vals"` is false of all of them.
# `vals[:3]` unparses to `vals[:3]`.
#
# THREE expressions are pinned, because there are three reductions over roster
# length in this one property and the runs above reach them unevenly:
# the LIST `[inv.soc for inv in self.inverters]` - the roster itself
# the GUARD `any(v is None for v in vals)`
# the REDUCTION `min(float(v) for v in vals)`
#
# ⚠️ AND THE PIN IS BLIND TO WHAT THE RUNS SEE, which is why both halves are
# here. It cannot see `min` become `max` (identical shape, different name -
# killed by the roster set), it cannot see `inv.soc` become `inv.bms_soc_floor`
# (killed by the runs' held-flat cut-outs), and it cannot see a change inside
# `Inverter.soc` at all.
_pfs_fn = next(n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name == "pack_floor_soc")
_pfs_vals = next(n.value for n in _pfs_fn.body
if isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == ["vals"])
def _gen_iter_of(fn_node, name):
"""The iterable of the comprehension inside the single `name(...)` call."""
call = next(n for n in ast.walk(fn_node)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == name)
comp = call.args[0]
return comp.generators[0].iter if len(comp.generators) == 1 else None
_pfs_min_iter = _gen_iter_of(_pfs_fn, "min")
_pfs_any_iter = _gen_iter_of(_pfs_fn, "any")
print(f" pack_floor_soc reduces: list over `{ast.unparse(_pfs_vals.generators[0].iter)}`"
f", any() over `{ast.unparse(_pfs_any_iter)}`, min() over "
f"`{ast.unparse(_pfs_min_iter)}`")
check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduces "
"the WHOLE list `vals` - a bare Name, not a Subscript, not a call, not a "
"second comprehension - and `vals` is built by ONE comprehension over the "
"WHOLE property `self.inverters`, an attribute of self and not a slice of "
"one. So EVERY slice of either is red here at EVERY roster length, "
"including `vals[:3]` and `vals[-3:]`, which survive all six runs above "
"because the longest roster they drive is three packs long, and including "
"`self.inverters[:2]` in the comprehension, which the sweep sees only up "
"to length 12. This grades the code's SHAPE and nothing about the values; "
"what the expression MEANS is graded by the runs",
isinstance(_pfs_min_iter, ast.Name) and _pfs_min_iter.id == "vals"
and ast.unparse(_pfs_min_iter) == "vals"
and isinstance(_pfs_vals, ast.ListComp)
and len(_pfs_vals.generators) == 1
and not _pfs_vals.generators[0].ifs
and isinstance(_pfs_vals.generators[0].iter, ast.Attribute)
and isinstance(_pfs_vals.generators[0].iter.value, ast.Name)
and _pfs_vals.generators[0].iter.value.id == "self"
and _pfs_vals.generators[0].iter.attr == "inverters"
and ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters")
check("...and the NONE GUARD reduces that same whole list, pinned the same way. "
"`any(v is None for v in vals[:1])` is +4 bytes and leaves every other "
"line in this file identical; the sweep above reaches it up to length 12 "
"and this reaches it at every length",
isinstance(_pfs_any_iter, ast.Name) and _pfs_any_iter.id == "vals"
and ast.unparse(_pfs_any_iter) == "vals")
check("...and the counterfactual that shows these pins are not vacuous (habit "
"7): the very slices they exist for really do unparse to something else, "
"so the equalities above are doing work rather than being trivially true "
"of anything shaped like an argument",
all(ast.unparse(ast.parse(f"vals{sl}", mode="eval").body) != "vals"
and ast.unparse(ast.parse(f"self.inverters{sl}",
mode="eval").body) != "self.inverters"
for sl in ("[:1]", "[-1:]", "[:2]", "[-2:]", "[:3]", "[-3:]",
"[:12]", "[-12:]", "[1:]", "[:-1]", "[1:-1]", "[::2]"))
and ast.unparse(ast.parse("list(reversed(vals))",
mode="eval").body) != "vals")
# ...and BOTH consumers of this property are pinned to it by name, so a reduction
# hardened here cannot be bypassed by a call site that stops using it. The peak
# guard's `soc=` is already pinned in part 4; the OTHER one is arbitrage's, and
# it is a safety consumer too - `soc_floor_pct` is what stops a sell.
_arb_calls = [n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr == "decide"
and isinstance(n.func.value, ast.Name)
and n.func.value.id == "arbitrage"]
_arb_soc = ast.unparse(next(k.value for k in _arb_calls[0].keywords
if k.arg == "soc"))
print(f" main.py's arbitrage.decide(soc=...) is `{_arb_soc}` "
f"({len(_arb_calls)} call site)")
check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()` "
"is handed the SAME `self.pack_floor_soc` and grades it against "
"`soc_floor_pct` to decide whether to SELL - so the mutation this block "
"exists for spends a pack on the arbitrage path as well as the peak-guard "
"path, and hardening the property covers both. A call site that reverted "
"to a per-unit reading, or defaulted it, would take this line red",
len(_arb_calls) == 1 and _arb_soc == "self.pack_floor_soc")
# --- 12j. THE ENUMERATION ITSELF, AS A RUNNING CHECK -------------------------
# ⚠️ THIS CARD HAS NOW BEEN 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 2 hardened `binding_soc_floor()`; round 3 found
# the call site's argument; round 4's blocker was `pack_floor_soc`, the SIBLING
# ARGUMENT of the same `decide()` call. Closing them one at a time has cost four
# rounds, so this block stops enumerating them by hand.
#
# EVERY comprehension in `Controller` that iterates the roster is listed here,
# COMPUTED from main.py's AST, PRINTED, and required to iterate the roster
# WHOLE. That is the family: `min`, `max`, `sum`, `any`, `all`, `sorted` and the
# list builders all take their roster through one of these, so a `[:1]`, a
# `[-1:]` or a `[:2]` anywhere in the class takes this line red on the line it
# was written - at every roster length, in every configuration, whether or not
# any fixture in this repository reaches it.
#
# ⚠️ WHAT IT DOES NOT COVER, said plainly. It grades the ITERABLE and nothing
# else: not which attribute is read off each unit, not which reduction is
# applied, and not the direct index sites (`self.inverters[0]` in `_only()`,
# `actuator_of()` and the store migration, `self.inverters[self.maint_index]` in
# `maint_unit`), which are not comprehensions and are graded where they live.
# The behavioural halves above are what say what these reductions MEAN.
_ctrl_cls = next(n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, ast.ClassDef) and n.name == "Controller")
_ROSTER_ATTRS = ("inverters", "packs")
def _is_roster(node):
return (isinstance(node, ast.Attribute) and node.attr in _ROSTER_ATTRS
and isinstance(node.value, ast.Name) and node.value.id == "self")
def _mentions_roster(node):
return any(_is_roster(n) for n in ast.walk(node))
_roster_comps = []
for _fn in ast.walk(_ctrl_cls):
if not isinstance(_fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for _n in ast.walk(_fn):
if isinstance(_n, (ast.ListComp, ast.SetComp, ast.DictComp,
ast.GeneratorExp)):
for _g in _n.generators:
if _mentions_roster(_g.iter):
_roster_comps.append((_fn.name, _g.iter.lineno,
ast.unparse(_g.iter)))
_roster_comps.sort(key=lambda r: (r[1], r[2]))
def _slices_roster(node):
"""Is the roster SUBSCRIPTED anywhere inside this expression?
⚠️ THE TEST IS ON THE SUBSCRIPT, NOT ON THE SPELLING. `enumerate(...)`,
`zip(...)` and `pack_permissions(roster, floor)` all hand the WHOLE roster
on and are legitimate; `self.inverters[:1]` is the family this card has been
rejected over three times. Requiring the iterable to unparse to exactly
`self.inverters` would refuse the first three and grade the spelling instead
of the thing.
"""
return any(isinstance(n, ast.Subscript) and _is_roster(n.value)
for n in ast.walk(node))
_sliced = [r for r in _roster_comps
if _slices_roster(ast.parse(r[2], mode="eval").body)]
print(f" EVERY comprehension in Controller that iterates the roster "
f"({len(_roster_comps)} of them, in "
f"{len({m for m, _l, _s in _roster_comps})} methods), and what each "
f"iterates:")
for _mname, _ln, _src in _roster_comps:
print(f" main.py:{_ln:<5} {_mname:<26} for ... in {_src}"
+ ("" if _src in ("self.inverters", "self.packs")
else " [whole roster, wrapped]"))
check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY. Every "
"comprehension in `Controller` that iterates the roster iterates it "
"WHOLE - `self.inverters` or `self.packs`, bare. This is the check that "
"would have caught rounds 2, 3 and 4's blockers on the round they were "
"written instead of one review each: `min(... for v in vals[:2])` is not "
"here (it reduces a derived list, which 12i pins directly), but every "
"`for inv in self.inverters[:1]` in the class is. A new reduction added "
"over a slice of the roster is red on the line it is written, at every "
"roster length and with no fixture required. ⚠️ THE ITERABLE ONLY - what "
"is read off each unit and what reduction is applied are graded by the "
"runs, and the direct index sites are not comprehensions and not here",
len(_roster_comps) > 0 and not _sliced)
check("...and the enumeration is not vacuously empty and its predicate is not "
"trivially true (habit 7): it found more than twenty roster "
"comprehensions across more than fifteen methods, it REJECTS the same "
"expressions with a slice on them - bare and wrapped in `enumerate()` "
"alike - and it ACCEPTS the three legitimate wrapped forms this class "
"actually uses. A predicate that answered the same thing for both would "
"make the line above a description rather than a check",
len(_roster_comps) >= 20
and len({m for m, _l, _s in _roster_comps}) >= 15
and all(_slices_roster(ast.parse(f"self.{a}{sl}", mode="eval").body)
and _slices_roster(ast.parse(f"enumerate(self.{a}{sl})",
mode="eval").body)
for a in _ROSTER_ATTRS
for sl in ("[:1]", "[-1:]", "[:2]", "[1:]", "[:-1]", "[::2]"))
and not any(_slices_roster(ast.parse(e, mode="eval").body)
for e in ("self.inverters", "self.packs",
"enumerate(self.inverters)",
"peakguard.pack_permissions(self.packs, f)")))
# ⚠️ AND THE TWO THIS ENUMERATION ACTUALLY FOUND, recorded here because the
# lesson is the finding and not the fix. Mutating every roster reduction in
# `Controller` to `[:1]` one at a time turned up TWO that survived a green
# 19-file suite, both `any(i.sign_wd.tripped for i in self.inverters)` and both
# SAFETY-30's rather than this card's:
#
# `cycle()`'s stale-input hold +4 bytes, 19/19 green - a LATCHED unit must
# take the roster past the hold, or its failsafe SET stops being re-filed
# the moment a poll is missed. Graded now in test_watchdog.py part 8, on
# a two-inverter roster with the latch at EACH position in turn.
# `publish()`'s status vocabulary +4 bytes, 19/19 green - a site with one
# inverter parked by its own failsafe claim published `running`. Same
# part, same two positions.
#
# Both were invisible for the same 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`.
# ==============================================================================
print()
print("11. mutation: what defeats these guards, and which check bites")
@@ -4502,6 +5086,74 @@ MUT.append(("`binding_soc_floor()` reduced over a window 12 packs wide or "
"max is order- and duplicate-invariant; the second is nonetheless "
"red on 12g's structural pin"))
# --- ROUND 4: THE SIBLING ARGUMENT, AND THE ENUMERATION THAT FOUND THE REST --
# 28. `pack_floor_soc` reduced over a WINDOW of roster positions. THIS ROUND'S
# BLOCKER, and the same family as rows 25-26 one ARGUMENT over: the MINIMUM
# handed to the same `decide()` call as `soc=`. Green across all 19 files at
# 52380c0 and again on the rebase onto 3934ad7.
MUT.append(("`pack_floor_soc` reduced over a WINDOW - `vals[:2]` (+4 bytes) or "
"`vals[-2:]` (+5) - ROUND 4's BLOCKER, the SoC axis of the pair",
all(_soc_slice_kills[k] for k in ("vals[:2]", "vals[-2:]",
"vals[:1]", "vals[-1:]")),
"part 12i's THREE-pack runs and their POSITIONAL-SLICE TABLE: the "
"same three readings with the emptiest at index 0, 1 and 2, so "
"every proper subset of the positions drops the empty pack on some "
"run. ⚠️ At N=2 `[:2]` IS the roster - the two-pack mirror cannot "
"see it, which is why the suite's separating power on this axis "
"stopped at exactly N=2 for four rounds"))
# 29. ...and the three-wide window, invisible to every behavioural run here.
MUT.append(("`pack_floor_soc` reduced over a THREE-wide window - `vals[:3]` / "
"`vals[-3:]` - invisible to every cycle in this file",
_s_head_kill[3] and _s_tail_kill[3]
and ast.unparse(_pfs_min_iter) == "vals",
"part 12i's LENGTH SWEEP (widths 1-11, on the REAL property over "
"rosters of 1 to 12 real inverters) and its STRUCTURAL PIN. The "
"six real cycles cannot see it - three packs is the longest roster "
"they drive - and the sweep and the pin are what do"))
# 30. the reduction itself, on the wrong axis. Zero byte delta.
MUT.append(("`pack_floor_soc` reduced with `max()` - the FULLEST pack, which is "
"`binding_soc_floor()`'s own reduction pasted onto the SoC axis - "
"or with the mean, the median or the second-emptiest",
all(_soc_red_kills.values()),
"part 12i's NON-SLICE table, on the same six real cycles: the "
"emptiest pack is 11 % where the fullest is 60 %, so every one of "
"the four disagrees on every run. ⚠️ Zero byte delta, and the most "
"likely wrong edit here precisely because the sibling reduction one "
"argument over IS a max"))
# 31. the None guard, which is a reduction over roster length too.
MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
"`any(v is None for v in vals[:1])`, +4 bytes",
all(g is None for _n, _p, g in _s_none_rows)
and ast.unparse(_pfs_any_iter) == "vals",
"part 12i's NONE-GUARD SWEEP - one silent pack at every position of "
"every length 1..12 - and the same structural pin. ⚠️ 12d's "
"two-pack fixture silences the SECOND pack only, so a guard "
"narrowed to the FIRST answers 'all present' there and hands a None "
"to `min()`"))
# 32. and the roster the list itself is built from.
MUT.append(("`pack_floor_soc` building `vals` from a SLICE of the roster - "
"`[inv.soc for inv in self.inverters[:2]]`, +4 bytes",
ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters"
and _s_head_kill[2] and not _sliced,
"part 12i's sweep reads the REAL property on real rosters so it "
"sees this too, its pin is on the comprehension's own iterable, "
"and 12j's ENUMERATION covers every other comprehension in the "
"class at once"))
# 33. THE ENUMERATION'S OWN FINDING, and the reason this row is worth more than
# the four above: it is not this card's code.
MUT.append(("a reduction over the roster added ANYWHERE ELSE in `Controller` "
"over a slice - which is how rounds 2, 3 and 4 each began",
bool(_roster_comps) and not _sliced,
"part 12j: every roster comprehension in the class, enumerated "
"from main.py's AST and required to iterate the roster whole. ⚠️ "
"Mutating each of them to `[:1]` found FOUR that survived a green "
"19-file suite, none of them this card's: three `sign_wd.tripped` "
"reductions (SAFETY-30's stale-input hold, `publish()`'s status "
"vocabulary and `status_payload()`'s banner - now graded in "
"test_watchdog.py part 8) and the store-migration guard's redundant "
"second disjunct. THIS row is the one that closes the family "
"rather than the member"))
for label, caught, where in MUT:
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
print(f" {where}")
@@ -4536,7 +5188,7 @@ check("...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUIT
"consecutive cards and, twice running, inside the fix written to close "
"it. The last five were found by a battery rather than by argument, "
"which is the only thing that has ever found any of them",
all(m[1] for m in MUT[21:27]) and len(MUT) == 28)
all(m[1] for m in MUT[21:27]) and len(MUT) == 34)
check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row "
"this round could write for it: a reduction over a window TWELVE packs "
"wide is indistinguishable from `max(floors)` on every roster in this "
@@ -4549,6 +5201,20 @@ check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row
"with, and it exists to be argued with",
not MUT[27][1]
and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N)))
check("...⚠️ AND ROUND 4's SIX, WHICH ARE THE SAME FAMILY AT THE SIBLING "
"ARGUMENT OF THE SAME CALL. `pack_floor_soc` is the MINIMUM over the "
"roster handed to `decide(soc=...)`; `binding_soc_floor()` is the MAXIMUM "
"handed to `decide(soc_floor=...)` beside it, and peakguard.py's own "
"docstring names them as a pair. Rounds 2 and 3 hardened one half; "
"`vals[:2]`, `vals[-2:]` and `vals[:3]` were still green on 52380c0 with "
"the other, and a three-pack roster at 50 / 60 / 11 % answers 11 % clean "
"and 50 % mutated - a discharge PERMISSION graded against a pack 39 "
"points fuller than the emptiest. The last row is the one that matters "
"most: closing the MEMBER four times has cost four review rounds, so the "
"FAMILY is now enumerated from the source, and that enumeration found "
"four more survivors in code this card never touched",
all(m[1] for m in MUT[28:34]) and len(MUT) == 34
and _S_EMPTY_SIM < SOC_FLOOR)
check("...and so is the last one, which is not a defeat at all but the EDGE OF "
"THIS CARD: the grid-following loop can still take the pack past this "
"floor, because the floor bounds the peak guard's own forced discharge "
+58
View File
@@ -358,6 +358,64 @@ check("...and it is refused with maintenance_enabled off, as shipped",
base(max_w=2000, maintenance_charge_w=2500,
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
# The comment this replaces in validate.py said, in as many words, that it took
# the FIRST unit's cap because there was exactly one `Maintenance` and it could
# only ever recalibrate `inverters[0]` - and that "when `maint_unit` stops being
# `inverters[0]`, this line moves with it". It has. Every pack is now
# recalibrated on its own schedule, so the shaping claim can be filed on any of
# them and the cap that contradicts it first is the smallest.
#
# ⚠️ THE FIXTURE'S TWO CAPS ARE DELIBERATELY DIFFERENT AND THE RATE SITS
# BETWEEN THEM. Two equal caps could not tell "grades the first" from "grades
# the smallest", and a rate above both would be refused by either rule - so
# 3000 W against caps of 5000 W and 2000 W is the one arrangement where the old
# answer and the new one disagree. ⚠️ 2000 W for the second pack is a
# HYPOTHESIS: inverter B does not physically exist and nothing here is measured
# from one.
def _roster(a_max, b_max, **over):
o = base(**over)
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
o.pop(k, None)
o["inverters"] = [
dict(name="inverter-a", setpoint_entity="number.sp_a",
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
max_w=a_max),
dict(name="inverter-b", setpoint_entity="number.sp_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
max_w=b_max)]
return o
_two = _roster(5000, 2000, maintenance_charge_w=3000, maintenance_discharge_w=1000,
sign_disagree_w=300, unit_sign_disagree_w=300)
_p_two = [p for p in validate_startup_config(_two, -5000, 5000)
if "maintenance_charge_w" in p]
print(f" 5000 W and 2000 W packs, charge 3000 W -> {_p_two}")
check("⚠️ A CHARGE RATE THE SMALLEST PACK CANNOT TAKE IS REFUSED, even though "
"the FIRST pack could. Before MAINT-05 the second pack was never "
"recalibrated, so its cap could not contradict anything; it is now, and "
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
len(_p_two) == 1 and why(_p_two, "max_w (inverter-b) is 2000 W"))
check("...and the message names the pack the number came off, not the first "
"entry - an operator told to lower a number on the wrong device is being "
"sent to the wrong device by the message that exists to stop that",
not why(_p_two, "max_w (inverter-a)"))
check("...while the same rate against two packs that can both take it starts, "
"so this is a comparison and not a blanket refusal of rosters",
[p for p in validate_startup_config(
_roster(5000, 4000, maintenance_charge_w=3000,
maintenance_discharge_w=1000, sign_disagree_w=300,
unit_sign_disagree_w=300), -5000, 5000)
if "maintenance_charge_w" in p] == [])
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
"message, the same boundary, the same refusal the flat config always got",
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
-5000, 5000), "THE CEILING WINS")
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
-5000, 5000) == [])
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
+175
View File
@@ -1809,6 +1809,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)}")