At N>=2 a battery at or under its own peak reserve is held: it gives nothing for the peak (weight 0 while the site gives) and the guard's need is placed on the live, non-refusing batteries above theirs. The guard is floor-limited, and alarms, only when those cannot hold the limit at full power for ALARM_HOLD_S. The peak alarms no longer read "Not ready" or hold the Home page at 0 W, because control keeps running. N=1 is byte-identical. DECISIONS/SAFETY-87-guard-uses-able-batteries.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
819 lines
50 KiB
Markdown
819 lines
50 KiB
Markdown
# SAFETY-32 — `bms_soc_floor` per pack, and the two judgement calls it left open
|
||
|
||
**Status:** decided and implemented on branch `SAFETY-32`.
|
||
**Card:** SAFETY-32 — *"One `bms_soc_floor` for two packs grades the shallower
|
||
pack against the deeper one's cut-out"*, scoped by the card's own PM PROOFREAD
|
||
to its **third** bullet only. Bullets 1 (aggregate anti-windup) and 2 (per-instance
|
||
`achievable_w`) were verified as already shipped by DEV-02 and CFG-04 and were
|
||
**not** touched — `main.py`'s `actual_w=self.site_batt_w` comment is the record
|
||
of why the first of those must stay site-level, and it still reads as written.
|
||
|
||
---
|
||
|
||
## 1. The defect, stated in the terms the code uses
|
||
|
||
`bms_soc_floor` **describes a battery**: it is the percent that pack's own BMS
|
||
cuts out at, register `0x0560` on the reference GW5000S-BP, read as 10. It was a
|
||
flat option because there was one pack.
|
||
|
||
`peak_soc_floor` **describes the site's policy**: the depth a money-saving
|
||
discharge may never reach. Rule 8 refuses `peak_soc_floor <= bms_soc_floor`,
|
||
because such a floor can never bind — the BMS wins and the status page reports a
|
||
backstop that does not exist.
|
||
|
||
At two packs of different models the two cut-outs differ, and one number for both
|
||
is wrong **in the direction that empties a pack**: with cut-outs of 10 % and 30 %
|
||
and `peak_soc_floor: 20`, the add-on **started**, and the guard would command a
|
||
forced discharge that pack B's *BMS* — not the operator's floor — is the thing
|
||
that stops. That is rule 8's own failure mode, reached through rule 8.
|
||
|
||
---
|
||
|
||
## 2. DECISION 1 — required per entry, not defaulted from `PEAK_OPTION_DEFAULTS`
|
||
|
||
The card left this open and asked for the reason to be recorded.
|
||
|
||
**Decided: REQUIRED of every roster entry**, in `config.yaml`'s schema
|
||
(`bms_soc_floor: int(0,50)`, no trailing `?`) and in `validate.py` rule 8, which
|
||
refuses a missing one **by name**.
|
||
|
||
The reason is that a default here is not a convenience, it is **this card's own
|
||
defect wearing a different hat**. A default is one number applied to every pack,
|
||
which is exactly what the flat key was. An add-on that answers "10 %" for a pack
|
||
nobody has told it about has invented a property of somebody's battery, and the
|
||
failure mode of the invention is the one this card exists to remove — the
|
||
shallower pack protected by a floor that belongs to the deeper one. CFG-03's
|
||
rule ("defaults live in exactly one place") points the same way: the one mirror
|
||
`PEAK_OPTION_DEFAULTS["bms_soc_floor"]` is the default for the **flat** key, i.e.
|
||
for the single-inverter shape, and reusing it per entry would make one dict the
|
||
default in two shapes with two meanings.
|
||
|
||
It is also the treatment `max_w` already gets, for the same reason and by the
|
||
same argument (`inverter.py`'s `build_roster()` docstring): the per-unit numbers
|
||
that describe hardware are required, and the per-unit *sensors* are optional
|
||
because an absent sensor is a real state.
|
||
|
||
**The cost is stated rather than hidden.** This is a schema change: a roster
|
||
config written before this branch is refused by the Supervisor until each entry
|
||
gains the key, and every roster fixture in the suite needed it. Nothing in
|
||
production carries a roster today — `inverters:` ships `[]` and the second
|
||
inverter is ~8 days out — so the migration is paid by fixtures and by whoever
|
||
writes the first real roster, at a startup refusal that names the entry.
|
||
|
||
**What is NOT inherited.** An entry that omits the key does not borrow the flat
|
||
one. `Inverter.bms_soc_floor` is then `None`, `peakguard.pack_floor_pct()`
|
||
answers `None`, and `discharge_permitted()` already treats an unknown floor as
|
||
**no permission** — so even in the window before rule 8 runs (e.g. `sim/inject.py`,
|
||
which does not run the startup gate) the absence costs a shave and never a pack.
|
||
`0.0` was rejected explicitly: it is the most permissive value the key has.
|
||
|
||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||
|
||
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
|
||
|
||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||
floors, each of them `max(peak_soc_floor, that pack's own cut-out)`, with a
|
||
`max()`. The two reductions pair: a minimum SoC against a maximum floor is the
|
||
conservative combination, and both move the verdict towards refusing a shave and
|
||
away from spending a pack. `min(soc) <= max(floor)` is implied by "some pack is
|
||
at or below its own floor", so the site verdict is never more permissive than
|
||
grading each pack separately would be. The converse is not true and is not
|
||
claimed — a site can be refused while every pack individually has headroom, which
|
||
costs a shave.
|
||
|
||
### 3a. ⚠️ FIX ROUND — the reduction was written and argued, and NOT GRADED
|
||
|
||
PR #48 was rejected because the argument in §4 below justified *writing* the
|
||
reduction and was then not carried into a check. Neither of the two mutations
|
||
below appears anywhere in §6's twelve-row battery: it graded *"the reduction
|
||
dropped"* and *"the bare option handed over"*, and never asked what happens when
|
||
the reduction is the **wrong one**, or when the roster handed to it is
|
||
**sliced**. Both were applied to `app/` by the reviewer and **all 19 files
|
||
stayed green**:
|
||
|
||
| mutation | Δ | why it survived |
|
||
|---|---|---|
|
||
| `binding_soc_floor()`: `max(floors)` → `min(floors)` | **0 bytes** | every roster in the suite was rule-8-valid or one pack long, so `floors` held **identical numbers** and `max`/`min`/first/last were four spellings of one answer |
|
||
| the `decide()` call site: `self.packs` → `self.packs[:1]` | **+4 bytes** | part 12g asserted `"self.packs" in <the unparsed expression>` — and `self.packs[:1]` **contains that substring** |
|
||
|
||
The zero-byte one was proved to have actually run rather than assumed:
|
||
`dis.get_instructions` showed the `LOAD_GLOBAL`s as `[float, pack_floor_pct,
|
||
any, min]`, and `binding_soc_floor([Pack("a",50,10), Pack("b",50,35)], 20.0)`
|
||
returned `20.0` where the shipped code returns `35.0`.
|
||
|
||
**The common cause is this repository's recurring defect** — a check whose DATA
|
||
cannot reach the failure it names. The one check that used a cut-out **above**
|
||
the EMS floor (12c's `_n1_deep`) ran on a roster of **ONE**, where every possible
|
||
reduction agrees. So no check in the suite ever evaluated
|
||
`binding_soc_floor()` on two packs whose floors **differ**.
|
||
|
||
**And the rule-8 degeneracy is not a defence, by §4's own words.** If
|
||
"unreachable is a claim about today's rule ordering, not a property of this
|
||
function, and `validate_startup_config()` is also `sim/inject.py`'s entry point"
|
||
is enough to *write* the reduction, it is enough to *grade* it. Through
|
||
`sim/inject.py`, which does not run rule 8 at all, `min(floors)` lets the guard
|
||
command a forced discharge to 20 % on a pack whose BMS cuts out at 35 %.
|
||
|
||
**What was added — no shipped behaviour changed, only what observes it:**
|
||
|
||
* **`test_peakguard.py` part 12c2** evaluates `binding_soc_floor()` on a roster
|
||
whose per-pack floors **all differ**. Four **simulated** cut-outs —
|
||
`30, 25, 40, 35` %, every one strictly above `peak_soc_floor: 20`, so
|
||
`pack_floor_pct()` selects the cut-out for each and `floors` *is* that tuple in
|
||
that order. The four candidate reductions are then four different numbers:
|
||
|
||
max = 40 <- the shipped answer
|
||
min = 25
|
||
first = 30 <- "grade the site against pack A", inside the function
|
||
last = 35
|
||
mean = 32.5
|
||
|
||
The extremes sit at **interior** roster positions (index 2 is the max, index 1
|
||
the min). A two-pack roster structurally cannot separate these — there `max`
|
||
and `last` coincide and so do `min` and `first` — and neither can three. Four
|
||
is the smallest roster that separates all four. The precondition is its own
|
||
check (habit 1), one pack is then raised past the old maximum (habit 7: the
|
||
**last** pack, which was neither extreme, so the line can only move if the
|
||
reduction read it), and `peak_soc_floor` is lifted above every cut-out to grade
|
||
the other side of the conjunction.
|
||
|
||
* **Part 12g is behaviour now, not source text** (MUTATION.md habit 4). Real
|
||
`Controller.cycle()`s on a two-pack roster, both packs held at the same SoC and
|
||
everything else identical, with the packs' own cut-outs as the only thing
|
||
varied:
|
||
|
||
cut-outs A 10 / B 18 % -> decide() got soc_floor=20 %, floor_limited=False
|
||
cut-outs A 10 / B 35 % -> decide() got soc_floor=35 %, floor_limited=True
|
||
|
||
The observable is `PeakDecision.soc_floor` — the argument, echoed back by the
|
||
shipped `decide()` — so the whole path from `Controller.inverters` to the
|
||
guard's argument is under test, and the site's **verdict flips** as well as the
|
||
number. `self.packs[:1]` answers 20 % both times. **Which** option key the floor
|
||
comes from stays pinned on the source in part 4, because that claim genuinely
|
||
is about the code's shape; that the option still governs is now graded
|
||
behaviourally too.
|
||
|
||
* ⚠️ **A THIRD SURVIVOR, FOUND WHILE FIXING THE FIRST TWO, AND RECORDED RATHER
|
||
THAN QUIETLY REPAIRED.** The first cut of part 12g varied only **pack B's**
|
||
cut-out — so `self.packs[-1:]`, grade the site against the **last** pack,
|
||
agreed with the full roster on both runs and stayed green across all 19 files
|
||
(+5 bytes). A two-run fixture that only ever moves the second pack pins `[:1]`
|
||
and not `[-1:]`; it is the same defect one step along, and it was in the fix
|
||
for this defect. Part 12g now runs the **same pair of cut-outs in both
|
||
orders**:
|
||
|
||
cut-outs A 10 / B 18 % -> soc_floor=20 %, floor_limited=False
|
||
cut-outs A 10 / B 35 % -> soc_floor=35 %, floor_limited=True
|
||
...the SAME PAIR SWAPPED, A 35 / B 18 % -> soc_floor=35 %, floor_limited=True
|
||
|
||
`[:1]` answers 20 % on the second run, `[-1:]` answers 20 % on the third, and
|
||
the roster answers 35 % on both. No single-position slice passes all three.
|
||
This is the mirror `12h` already ran on the SoC axis, which is where the shape
|
||
was borrowed from.
|
||
|
||
* ⚠️ **AND A FOURTH, from the same battery: the EMPTY-roster branch.** Rewriting
|
||
`binding_soc_floor([])`'s `float(soc_floor)` to `return None` (−43 bytes) was
|
||
green across all 19 files. It is defensive code — `Controller.inverters` is
|
||
never empty on a started controller, and `inputs_ok` refuses the cycle before
|
||
the guard is reached if it were — so nothing in the suite went near it. It is
|
||
**pinned rather than argued**, in both directions (the option when there is
|
||
one, `None` when there is not), because a branch with no check is a branch
|
||
that can be rewritten under a green suite. The check also records why it
|
||
cannot permit anything either way: `pack_floor_soc` is `None` at an empty
|
||
roster, and `discharge_permitted()` refuses on a `None` SoC whatever the floor
|
||
is.
|
||
|
||
* **`wired_over_limit()` gained a `prepare(ctl)` hook**, inside its retry loop so
|
||
every attempt grades the same site, which is the only window in which a second
|
||
inverter can join the roster before the telegrams are fed.
|
||
|
||
* **In-process mutation rows 22, 22b and 23** record all four survivors, so a
|
||
later edit that re-degenerates any of these fixtures goes red on a row rather
|
||
than on nothing.
|
||
|
||
### 3b. ⚠️ SECOND FIX ROUND — the fix carried the defect it was written to close
|
||
|
||
Round 2 closed both named survivors, and both of round 3's blockers are the
|
||
**same defect sitting inside that fix**: a check whose TEXT claims more than its
|
||
FIXTURE can observe. Applied to `app/` on `8501e05`, **all 19 files stayed
|
||
green** for both:
|
||
|
||
| mutation | Δ | the sentence that was too broad | why the fixture could not see it |
|
||
|---|---|---|---|
|
||
| `binding_soc_floor()`: `max(floors)` → `max(floors[-2:])` | **+5 bytes** | *"So the reduction reads **every entry**"* | 12c2's only counterfactual raised the **last** pack (`_deeper[-1]`), and **every window that ends at the roster's end follows the last pack**. On `(30,25,40,35)` both answer 40; on `_deeper` both answer 45. |
|
||
| the `decide()` call site: `self.packs` → `self.packs[:2]` | **+4 bytes** | *"what is reduced is the **ROSTER** and not a POSITION in it"* | all three `_floor_reaching_decide` runs drove a **two-pack** roster, where `self.packs[:2]` **is** `self.packs` — by exactly the argument 12c2 itself makes about two packs being unable to separate max/min/first/last. |
|
||
|
||
Round 2's *report* stated the narrow, true claim ("no **single-position** slice
|
||
passes all three runs"). The *check in the file* stated the broad one. That gap
|
||
is the whole of round 3.
|
||
|
||
**THE GENERAL LAW, stated once so the next card can use it:**
|
||
|
||
> **A fixture of N packs cannot separate two reductions that agree on N packs.**
|
||
> Two cannot separate max from min from first from last. Three cannot separate
|
||
> max from max-of-the-last-two. A single counterfactual that varies one position
|
||
> cannot separate the reduction from any window containing that position.
|
||
|
||
Its corollary is the reason this section is long: **the regress does not
|
||
terminate in a fixture.** Closing `[:1]` took two packs; `[:2]` took three;
|
||
`[:3]` takes four — and a four-pack run hands `[:4]` the same free pass. Round 3
|
||
therefore does two different things rather than adding one more pack.
|
||
|
||
**What was added — no shipped behaviour changed, only what observes it:**
|
||
|
||
* **Part 12c2 gained a ROSTER SET and a computed FAMILY TABLE.** The same four
|
||
simulated cut-outs with the **deepest moved to each of the four positions**,
|
||
and fourteen enumerated rewrites of `max(floors)` each checked against every
|
||
roster. The table is **computed and printed**, and a member with an empty kill
|
||
list takes the check **red naming it** — so a family member can no longer be
|
||
added without a fixture that reaches it. `max(floors[-2:])` is killed only by
|
||
the rosters whose deepest sits at index 0 or 1, which is precisely the data the
|
||
single last-pack counterfactual could not supply.
|
||
|
||
* **Part 12c2 gained a LENGTH SWEEP.** `binding_soc_floor()` is a pure function
|
||
over a list, which is the one place a sweep is cheap: **78 simulated rosters,
|
||
lengths 1 through 12, the deepest cut-out at every position of each.** That
|
||
closes *every* head window `max(floors[:w])` and *every* tail window
|
||
`max(floors[-w:])` of width **1 to 11** in one property rather than one
|
||
mutation at a time — and the check asserts that width **12 is NOT reached**, so
|
||
the limit is a red line rather than a silence.
|
||
|
||
* **Part 12g drives THREE-pack rosters**, with the deep cut-out at index 0, 1
|
||
**and** 2. Every positional slice of a three-pack roster is a proper subset of
|
||
`{0,1,2}` and so drops a position; the run whose deep pack sits there answers
|
||
the option, 20 %, where the roster answers 35 %. `self.packs[:2]` goes red on
|
||
the index-2 run. `_floor_reaching_decide()` now takes `*floors`, and the extra
|
||
units join through the same `second_inverter()` hook the two-pack runs use.
|
||
|
||
* **Part 12g gained a POSITIONAL-SLICE TABLE**, computed against the **real**
|
||
`Controller.packs` each of the six cycles actually ran and the **real**
|
||
`PeakDecision.soc_floor` it produced — so it measures the shipped call site's
|
||
own answer, not a re-derivation, and a survivor takes the check red.
|
||
|
||
* ⚠️ **AND A FIFTH AND SIXTH SURVIVOR, FOUND BY ROUND 3'S OWN BATTERY AND
|
||
CLOSED STRUCTURALLY RATHER THAN BY ONE MORE PACK.** `self.packs[:3]` (+4) and
|
||
`self.packs[-3:]` (+5) survive **all six** behavioural runs, because the
|
||
longest roster those runs drive is three packs long. Part 12g therefore also
|
||
pins the roster **argument's identity** on the AST: it must unparse to exactly
|
||
`self.packs`, which is false of every slice at every length. ⚠️ This is **not**
|
||
the substring test round 2 removed — `"self.packs" in expr` is *true* of every
|
||
slice; `ast.unparse(arg) == "self.packs"` is *false* of all of them. The two
|
||
halves are separated and each says what it reaches: the **runs** grade the
|
||
VALUE and reach every slice up to N=3; the **pin** grades the ARGUMENT'S
|
||
IDENTITY at any N and is blind to two things the runs are not — a rewrite of
|
||
what `Controller.packs` *returns* (the runs' `len(ctl.packs) == 3` catches
|
||
that) and the reduction *inside* `binding_soc_floor()` (12c2's).
|
||
|
||
* ⚠️ **AND ONE ROW IS RECORDED AS NOT CAUGHT.** A reduction over a window
|
||
**twelve packs wide** is indistinguishable from `max(floors)` on every roster
|
||
in the file, because the sweep stops at twelve. Unlike the call site there is
|
||
no identity claim to pin — the claim is semantic, "the maximum", and a shape
|
||
pin would refuse honest refactors of the expression. It is reported **with its
|
||
size** (it needs a 13-pack roster, on a site whose roster is ONE and whose
|
||
second unit is ~8 days out) rather than hidden.
|
||
|
||
* **Two rewrites are EQUIVALENT, not uncaught.** `max(set(floors))` and
|
||
`list(reversed(self.packs))` cannot change any answer — `max` is order- and
|
||
duplicate-invariant. The second is nonetheless red on the structural pin.
|
||
|
||
* **In-process mutation rows 24 – 27** record all four of round 3's findings,
|
||
including the not-caught one, so a later edit that re-degenerates any of these
|
||
fixtures goes red on a row rather than on nothing.
|
||
|
||
## 4. ⚠️ THE HONEST LIMITATION, and it is the reason this record exists
|
||
|
||
**Under any configuration rule 8 accepts, `binding_soc_floor()` answers
|
||
`peak_soc_floor` for every pack.** Rule 8 refuses `peak_soc_floor <= bms_soc_floor`
|
||
on *every* unit, so `max(peak_soc_floor, bms_i) == peak_soc_floor` everywhere the
|
||
add-on will actually start. The per-pack **floor** is therefore non-degenerate
|
||
only against a config rule 8 refuses.
|
||
|
||
This is not a defect and it is not hidden. It is the same argument
|
||
`validate.unit_caps_indexed()` makes about itself, in that function's own words:
|
||
|
||
> *"Rule 9(d) already refuses such a config so the two lists cannot desynchronise
|
||
> in shipped use — but 'unreachable' is a claim about today's rule ordering, not
|
||
> a property of this function, and `validate_startup_config()` is also
|
||
> `sim/inject.py`'s entry point."*
|
||
|
||
CFG-04's index desynchronisation is what happened the last time that reasoning
|
||
was left implicit. So the reduction is written, and it is graded on both sides:
|
||
`test_peakguard.py` part 12c sweeps **1775** rule-8-valid `(peak_soc_floor,
|
||
bms_soc_floor)` pairs and asserts the answer is the option in every one of them
|
||
(that is AC 5, measured over the whole option space rather than at the shipped
|
||
point), and then asserts that on the pair rule 8 *refuses* the answer is the
|
||
**cut-out** — because without that second line the sweep would be satisfied by
|
||
`soc_floor` typed straight through, and the mutation that deletes the `max()`
|
||
could never bite. It does bite: M2 on the PR.
|
||
|
||
**What IS live at a rule-8-valid roster** is the rest of the card: rule 8 now
|
||
refuses the two-model config that used to start; the permission is answered per
|
||
pack, so the alarm names the pack that stopped the site rather than "the
|
||
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
|
||
|
||
## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis
|
||
|
||
Rounds 2 and 3 hardened the **floor** axis: the `max` inside
|
||
`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
|
||
`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the
|
||
**sibling argument of the same call**:
|
||
|
||
```python
|
||
self.peak_guard = peakguard.decide(
|
||
...,
|
||
soc=self.pack_floor_soc, # MINIMUM <- round 4
|
||
soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3
|
||
```
|
||
|
||
`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with
|
||
the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as
|
||
its weaker half. On `52380c0`, with all 19 files green and verified again after
|
||
the rebase onto `3934ad7`:
|
||
|
||
| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now |
|
||
|---|---|---|---|
|
||
| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED |
|
||
| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED |
|
||
| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED |
|
||
| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED |
|
||
|
||
The suite's separating power on the SoC axis stopped at exactly **N = 2** —
|
||
precisely the state the floor axis was in before round 3, and which round 3
|
||
itself named as its blocker 2. A fixture of N packs cannot separate reductions
|
||
that agree on N packs.
|
||
|
||
**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest
|
||
pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. The
|
||
site is then graded for discharge *permission* against a pack 39 points fuller
|
||
than the emptiest, and spends a pack its own BMS stops — through
|
||
`sim/inject.py`, which runs no validator rule at all. The verdict flips with it:
|
||
11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes
|
||
no forced discharge, where the mutant is permitted and sizes a positive one.
|
||
|
||
### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure
|
||
|
||
SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE
|
||
DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and
|
||
nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that
|
||
one is measured*, off register `0x0560` on the reference GW5000S-BP.
|
||
|
||
So **production is degenerate on the floor axis**: both packs floor at the same
|
||
value, `max(10, 10) == 10`, and a differing floor exists **only in the
|
||
fixtures**. That is not an argument for simplifying the fixtures to matching
|
||
values — it is the argument for keeping them. `binding_soc_floor()`'s reduction
|
||
is observable *nowhere else*, and the moment a third pack, a replacement pack or
|
||
a different chemistry arrives it is observable in the field. **Anyone who
|
||
"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only
|
||
data this repository has that can see the reduction at all**; part 12c2's
|
||
precondition check goes red if they do, and this paragraph is why.
|
||
|
||
The **SoC axis has no such degeneracy**. Two batteries at the same state of
|
||
charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of
|
||
the pair that will be non-degenerate in the field from the day inverter B is
|
||
wired up.
|
||
|
||
### The treatment, transferred from round 3
|
||
|
||
* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at
|
||
every position of a three-pack roster and both positions of a two-pack one,
|
||
the observable read off `PeakDecision.soc` (the argument, echoed) rather than
|
||
off a spy or a grep. Cut-outs held flat across all of them, so the floor axis
|
||
cannot be what moved the answers.
|
||
* **the positional-slice table and the non-slice table** — computed against the
|
||
real `Controller.inverters` each cycle carried and the real decision it
|
||
produced, printed, with a member that nothing kills taking the check red.
|
||
* **the length sweep** — the real property on real rosters of 1 to 12 inverters,
|
||
the empty pack at every position. Closes head and tail windows of width 1-11
|
||
at once. Width 12 and wider is **honestly not reached**, and asserted to be
|
||
unreached so a later sweep that lengthens the rosters must rewrite the
|
||
sentence.
|
||
* **the exact structural pin** — three of them, because there are three
|
||
reductions over roster length in this one property: `vals` is built by one
|
||
comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces
|
||
the whole list; `min(... for v in vals)` reduces the whole list. Every slice
|
||
of either is red at **every** roster length, including the `[:3]` and `[:12]`
|
||
the runs and the sweep cannot see.
|
||
|
||
Unlike the floor axis, **there is no uncaught row here**: the claim at
|
||
`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right
|
||
instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum")
|
||
and a shape pin there would refuse honest refactors. That asymmetry is why one
|
||
carries an open row and the other does not.
|
||
|
||
## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded
|
||
|
||
This card has now been rejected three times for the same shape one step over, so
|
||
round 4 stopped closing members and enumerated the **family**. Every reduction
|
||
over `self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||
mutated to a slice, one at a time, against the full 19-file suite.
|
||
|
||
| # | site | reduction | consumer | mutation | result |
|
||
|---|---|---|---|---|---|
|
||
| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) |
|
||
| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) |
|
||
| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) |
|
||
| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) |
|
||
| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) |
|
||
| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) |
|
||
| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) |
|
||
| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) |
|
||
| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) |
|
||
| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) |
|
||
| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) |
|
||
| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) |
|
||
| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) |
|
||
| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||
| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||
| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below |
|
||
| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** |
|
||
|
||
**Rows 18-20 are the finding, and they are not this card's code.** All three are
|
||
`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and
|
||
all three survived a green 19-file suite for exactly the reason every blocker on
|
||
this card survived: *the only fixture that reached them was a roster of ONE,
|
||
where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has
|
||
built a two-inverter roster with only inverter B latched since SAFETY-30
|
||
shipped — the precise data all three needed — and none of them read it.
|
||
|
||
Row 18 is the one with teeth. A latched unit must take the whole roster past the
|
||
stale-input hold, because that unit's `failsafe` SET is the thing parking it;
|
||
main.py's own comment says *"the trip would stop acting the moment a poll was
|
||
missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's
|
||
SoC returns early, and B's last share stands for the heartbeat to re-emit.
|
||
test_watchdog.py part 8 now drives that with the latch at **each** position, plus
|
||
the no-latch counterfactual that proves the hold really fires.
|
||
|
||
**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there
|
||
is now red on 12j's enumeration, but I could not construct a *reachable* state in
|
||
which it changes an answer: `_grade_store_migration()` runs at startup, before
|
||
any cycle, so a unit is tripped in memory only if its own `unit/<name>/sign_tripped`
|
||
key was truthy — which makes `namespaced_latched` True and short-circuits the
|
||
disjunct. I believe it is an equivalent mutant at that call site rather than a
|
||
live hole, and I am recording the argument instead of asserting the conclusion.
|
||
|
||
**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u
|
||
in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in
|
||
one event-log line**. It is +4 bytes and no check bites. The refusal itself —
|
||
`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different
|
||
expression and *is* graded (test_peakguard.py part 10h-1b), so the site still
|
||
stops; what a `[:1]` costs is an operator being told one name when two units have
|
||
no writer. It is not a roster comprehension (it iterates `resolution.shares`), so
|
||
12j does not cover it and does not pretend to. Closing it needs a three-inverter
|
||
fixture with two writer-less entries, which is ALLOC-01's fixture and not this
|
||
card's. **Filed as open, with its size and its consequence, rather than closed
|
||
quietly or left unmentioned.**
|
||
|
||
### And the family closed rather than the member
|
||
|
||
`test_peakguard.py` part 12j enumerates the family from the AST and requires
|
||
every member to take the roster **whole**, so a new reduction over a slice is red
|
||
**on the line it is written**, at every roster length, with no fixture required.
|
||
That check is what would have caught rounds 2, 3 and 4's blockers on the round
|
||
they were written instead of one review round each; it is also what found rows
|
||
18-21.
|
||
|
||
⚠️ **AND IT WAS STILL TOO NARROW — see §4d.** Round 4's version enumerated the
|
||
26 roster comprehensions inside the `Controller` class, because that is where
|
||
the four known sites were. Round 5's blockers were four reductions in
|
||
`validate.py`, one module over, over a list *derived* from the roster three
|
||
calls away from it. The pin now parses every `.py` under `app/` and finds the
|
||
roster by transitive closure from one option key: **183 reductions across 9
|
||
modules.**
|
||
|
||
## 4d. ⚠️ ROUND 5 — the same family one MODULE over, and an instrument that discovers its own scope
|
||
|
||
Round 4 enumerated the family and closed it **inside the `Controller` class**,
|
||
because that is where the four known sites lived. The review found four more
|
||
survivors immediately, in `validate.py`, on a 19/19 green suite:
|
||
|
||
| site | mutation | delta | suite |
|
||
|---|---|---|---|
|
||
| `validate.py:779` | `min(caps)` → `min(caps[:1])` | +4 B | **GREEN** |
|
||
| `validate.py:779` | `min(caps)` → `min(caps[-1:])` | +5 B | **GREEN** |
|
||
| `validate.py:778` | `caps = [cap for _, cap in caps_ix[:1]]` | +4 B | **GREEN** |
|
||
| `validate.py:893` | `min(caps_ix[-1:], key=lambda p: p[1])` | +5 B | **GREEN** |
|
||
|
||
**None of them is an equivalent mutant — a safety refusal disappears.** With
|
||
`max_w: 5000` on inverter-a beside `2000` on inverter-b and
|
||
`sign_disagree_w: 3000`, the shipped validator answers one problem —
|
||
*"sign_disagree_w is 3000 W but max_w (inverter-b) is 2000 W, so the SAFETY-02
|
||
sign-disagreement watchdog can never fire"* — and every one of the four answers
|
||
**zero**. The site starts, with a watchdog reporting `armed` on the status page
|
||
that cannot trip on pack B. That is rule 5's own failure mode reached **through**
|
||
rule 5, which is the sentence `test_inverter.py:1657` says the rule exists to
|
||
prevent. Rule 6 loses its refusal the same way.
|
||
|
||
### Why round 4's pin could not see them, and why the fix is not four more checks
|
||
|
||
`caps` and `caps_ix` are not comprehensions over `self.inverters`; they are lists
|
||
**derived** from the roster, three function calls away from it, in a module the
|
||
enumeration never opened. Closing them by hand would have produced a fifth
|
||
rejection with the same shape one step over — that is the whole record of this
|
||
card. **A hand-written scope is the same defect as a hand-written fixture**
|
||
(MUTATION.md habit 12): a check whose data cannot reach the failure it names.
|
||
|
||
### The instrument
|
||
|
||
`test_peakguard.py` part 12j now parses **every `.py` under `app/`** off disk —
|
||
no module is named anywhere in the check — and finds the roster by transitive
|
||
closure from **one seed**, the `inverters` option key:
|
||
|
||
* a function whose returns are roster-carrying is **roster-producing**
|
||
(`roster_entries` → `build_roster` → `unit_caps_indexed` → `unit_caps`);
|
||
* a name assigned one carries it, **and so does a list accumulated in a loop
|
||
over one** — which is exactly what `caps`, `roster` and `problems` are, and
|
||
what round 4's rule could not express;
|
||
* an attribute or property answering one carries it (`self.inverters`,
|
||
`Controller.packs`, `Controller.units`, `live_units()`);
|
||
* **an argument at a call site taints that parameter in the callee**, which is
|
||
how the pin walks into `peakguard.binding_soc_floor`, `arbiter.resolve`,
|
||
`allocator.plan`, `circulation.circulating_w`, `maintenance.arbitrate` and
|
||
`store.migrate_maint_keys` without being told they exist;
|
||
* and **a slice of a roster is still a roster**, so a mutation cannot cut the
|
||
taint and hide every reduction downstream of itself. An **index** is one unit
|
||
and is not.
|
||
|
||
It then requires, at every place a roster-carrying value is reduced or iterated,
|
||
that the whole of it arrives: no `[:1]`, `[-1:]`, `[:2]` or `[0]` between the
|
||
roster and the reduction.
|
||
|
||
### What it found — and the hand-written list was wrong in BOTH directions
|
||
|
||
**183 reductions across 9 modules**, out of 23 `.py` files parsed:
|
||
`main.py` 96, `allocator.py` 24, `validate.py` 19, `arbiter.py` 14,
|
||
`circulation.py` 10, `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3,
|
||
`store.py` 3 — and 14 modules with no roster reduction at all.
|
||
|
||
The card's own suggested module list named `watchdog.py`, which reduces over no
|
||
roster at all (SAFETY-30's sign watchdogs are strictly per-unit — there is
|
||
nothing to slice), and did **not** name `maintenance.py` or `store.py`, which
|
||
both do. A pin built from that list would have made a vacuous claim about one
|
||
module and no claim at all about two others. That is the argument for computing
|
||
the scope, made with data rather than with an assertion.
|
||
|
||
The pin **independently rediscovers all four blockers** — it is told one option
|
||
key and nothing else — and finds a **fifth** beside them at
|
||
`validate.py:783`, the `min(caps_ix, key=...)` inside rule 5's message, which no
|
||
review round named.
|
||
|
||
### The counterfactual, at two depths
|
||
|
||
* **Per site, all of them.** Every enumerated site's own expression, in its own
|
||
scope, mutated with each of the four cuts this card has actually been defeated
|
||
by. **183 sites × 4 cuts = 732** mutated spellings; every one reported. The same run asserts
|
||
the predicate **accepts** the legitimate wrapped forms the package really uses
|
||
(`enumerate`, `zip`, `sorted`, `pack_permissions(packs, floor)`) and does not
|
||
treat a keyed lookup (`weights[u]` in `allocator._fill`) as a slice — which is
|
||
what four false SLICED reports cost before `shortens()` was written.
|
||
* **End to end, on the real files.** `[:1]` written into `app/validate.py` at
|
||
each blocker position, the **whole** analysis re-run over the mutated source,
|
||
and a sliced site required at that exact line — where the clean package
|
||
reports none. This is the check that would have taken PR #48 red four rounds
|
||
ago, with no fixture reaching it.
|
||
|
||
### The sweep, and the number that must not be quoted alone
|
||
|
||
All 183 sites were mutated to `[:1]`, one at a time, +4 bytes each, and each
|
||
mutant run against the full 19-file suite in `goodwe-addon-ci` (mounted `:ro`,
|
||
`python3 -B`, a fresh rsync per mutant; 0 `.pyc` in the tree before and after —
|
||
a read-only mount cannot write one, so TEST-15 is closed by construction here
|
||
rather than by discipline). **183 of 183 RED.**
|
||
|
||
⚠️ **On its own that is tautological**: with the pin in the tree every slice is
|
||
red *by the pin*. So all 51 mutants whose first red was `test_peakguard.py` were
|
||
re-run with the pin **neutralised** (`_SLICED` blanked), leaving only the
|
||
behavioural checks:
|
||
|
||
| | |
|
||
|---|---|
|
||
| **145 of 183** | a behavioural fixture in this repository separates the site from a slice of itself — red with the pin turned off |
|
||
| **38 of 183** | **nothing but this pin sees it.** Structurally closed by this card; behaviourally **UNGRADED**, and listed rather than counted as covered |
|
||
|
||
The 38: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1,
|
||
`circulation.py` 1, `maintenance.py` 1. Grouped by what a `[:1]` there costs:
|
||
|
||
* **The operator's page — 15, the largest group.** `Controller.checks`
|
||
(3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, 3400, 3458) and
|
||
`status_payload` (3556, 3566, 3578). A slice reports one unit's row where two
|
||
exist, or "1 of 2 live" reading 1 of 1. Nothing commands differently; what is
|
||
lost is the evidence a human uses to notice that it should.
|
||
* **The write path's own counts.** `write_setpoint` 1267/1272/1280 — three
|
||
`len(targets)` guards. The write itself iterates `targets` at 1287 and *is*
|
||
graded (test_inverter.py); these three are its log arithmetic and refusal
|
||
count.
|
||
* **The allocator's internals.** `_fill`'s zero-weight last-resort pool (365),
|
||
`plan`'s `dropped` (526, 653), `free` (584), `frac_order` (591). ALLOC-01
|
||
drives two and three units through the *weighted* pass; the last-resort pass
|
||
and the largest-remainder tie-break are reached, but not with enough units for
|
||
a slice to change the answer.
|
||
* **Startup and FSM plumbing.** `__init__` 235/351, `_only` 831,
|
||
`_maint_button` 805, `cycle`'s maintenance zip 1505, `send_heartbeats` 3015,
|
||
`check_output_clamp` 2445/2452, `check_circulating` 2701,
|
||
`maintenance.arbitrate` 547, `arbiter.site_resolve` 517,
|
||
`circulation.opposing` 242.
|
||
* **Two refusal-TEXT sites.** `validate._roster_problems`'s `len(entries)` (554)
|
||
and `refuse()`'s numbering loop (1263) — both shape the words of a refusal
|
||
that still happens.
|
||
* **`_grade_store_migration` (698)**, already row 21 of §4c, argued there to be
|
||
an equivalent mutant at that call site. The argument is unchanged and is still
|
||
an argument, not a proof.
|
||
|
||
⚠️ **None of the 38 is closed behaviourally by this card, and saying so is the
|
||
point.** Closing sites one at a time is what cost this card four review rounds.
|
||
The pin closes the **family** — a slice at any of them is red on the line it is
|
||
written — and this list is what a shape pin can never promise.
|
||
|
||
### And the behavioural half, because a shape pin cannot grade a meaning
|
||
|
||
The pin grades an **identity** ("the whole roster arrives"). `min` → `max` is a
|
||
zero-byte edit and invisible to it, forever. So `test_validate.py` grows a
|
||
**roster SET** for the three rules that reduce over `unit_caps_indexed()`:
|
||
rosters of width 2, 3, 4 and 5 with the one small cap at **every position** —
|
||
14 rosters × 3 rules = 42 runs of the real validator — plus a **computed family
|
||
table** of twelve candidate reductions (`max`, `first`, `last`, the mean, and
|
||
`[:1]`, `[-1:]`, `[:2]`, `[:3]`, `[1:]`, `[:-1]`, `[::2]`) each compared against
|
||
what the shipped validator actually said on the same roster. Only `min` agrees
|
||
everywhere; any member no roster kills is **printed as a named survivor** and
|
||
takes the check red.
|
||
|
||
### ⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
|
||
|
||
Found by reading the three branches side by side — **not** by the pin, and said
|
||
that way because it is the shape of what the pin cannot see. Rule 5's sibling
|
||
branch, `unit_sign_disagree_w` (SAFETY-30's per-inverter sign watchdog), was
|
||
graded against `_number(opts, "max_w", 2000.0)` — the **flat** option — three
|
||
lines under a branch that had already stopped doing that. Both directions were
|
||
measured before the fix:
|
||
|
||
* a roster of two 2000 W units beside a leftover `max_w: 5000` from a
|
||
single-inverter install, with `unit_sign_disagree_w: 3000`, produced **zero
|
||
problems**. The site started, the status page said `armed`, and the per-unit
|
||
watchdog could not trip on **either** unit. Identical sentence to the blocker,
|
||
reached through the branch beside it;
|
||
* and a roster of two 5000 W units with no flat key was **refused** at 3000 W
|
||
against a 2000 W *default the operator never wrote*, for a threshold both
|
||
units can reach.
|
||
|
||
It takes the same `weakest` the branch above takes, from the same list, and
|
||
names the same unit. At a roster of one `unit_caps()` is `[max_w]`, so the
|
||
shipped single-inverter refusal is the same comparison, the same boundary and
|
||
the same sentence. Both directions are asserted in `test_validate.py`.
|
||
|
||
### ⚠️ WHAT THE PIN CANNOT SEE — open rows, with sizes
|
||
|
||
1. **Semantics.** `min` vs `max` vs the mean: zero-byte edits, invisible to any
|
||
shape pin. Only the behavioural sets above can see them, and they only see
|
||
the rules they drive.
|
||
2. **Direct index sites that are not reductions.** `self.inverters[0]` in
|
||
`_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in
|
||
`maint_unit`, `entries[index]` in `_unit_label()`. Single-unit lookups, meant
|
||
to be, graded where they live.
|
||
3. **A subscript by a NAME.** `weights[u]`, `cap[u]`, `add[u]` in
|
||
`allocator._fill` are keyed lookups into a roster-derived mapping and are
|
||
accepted deliberately, so `caps[i]` with a variable `i` would pass. Not in
|
||
the family this card has been defeated by; buying it costs four false reports.
|
||
4. **A slice through an intermediate call.** `sorted(roster)[:1]` subscripts a
|
||
call result, not a roster reference. Nothing in `app/` is written that way
|
||
today; if something is, it would not be seen.
|
||
5. **Anything outside `app/`.** `tools/`, `sim/` and the checks themselves are
|
||
not parsed — and `sim/inject.py` reaches `validate_startup_config()`.
|
||
6. **The taint's own seam.** It crosses a call boundary only where the callee is
|
||
a plain function this package defines and the argument is positional or
|
||
keyword. A roster handed through `*args`, stored in a dict and read back, or
|
||
passed to a method on an unresolvable object, is lost at that hop — and
|
||
everything downstream goes **unenumerated** rather than reported unreachable.
|
||
That is the shape of the remaining exposure: not zero, and not measurable
|
||
from inside the check.
|
||
7. **`cycle()`'s undeliverable-unit log line** (row 22 of §4c) is still green at
|
||
+4 bytes, unchanged from round 4 and still filed open.
|
||
|
||
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
|
||
|
||
`peak_soc_floor` is still **one number for the site**, so the card's own
|
||
"or the deeper pack is needlessly withheld" half is not closed and this record
|
||
does not pretend it is. With cut-outs of 10 % and 18 %, rule 8 obliges the
|
||
operator to set the EMS floor above 18, and the 10 % pack is then held above its
|
||
own need. Closing that means `peak_soc_floor` becoming a per-entry key too, which
|
||
is a second option migration and a product decision about what the site-wide
|
||
number then means. The card scoped itself to `bms_soc_floor` and explicitly told
|
||
this developer not to widen it, so it is filed here as the open question it is
|
||
rather than smuggled in.
|
||
|
||
**Also still not in this card, and named by the shipped code already:** the
|
||
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
|
||
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
|
||
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
|
||
per-pack bound. Nothing here touched it.
|
||
|
||
## 6. What was changed, and one thing that was found on the way
|
||
|
||
* `config.yaml` — `bms_soc_floor: int(0,50)` required per `inverters:` entry;
|
||
the flat key and its default are untouched and are documented as the
|
||
single-inverter shape's own cut-out.
|
||
* `app/inverter.py` — `Inverter.bms_soc_floor`; `_entry_bms_floor()` (`None`, not
|
||
a borrowed number and not `0.0`); `_flat_bms_floor()`.
|
||
* `app/peakguard.py` — `Pack`, `pack_floor_pct()`, `pack_permissions()`,
|
||
`binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
|
||
one place the question is answered.
|
||
* `app/validate.py` — `unit_bms_floors_indexed()`; rule 8 grades every unit and
|
||
names it via the **roster** index the value came off.
|
||
* `app/main.py` — `Controller.packs`; the `decide(soc_floor=…)` reduction; the
|
||
floor-limited notification names the pack(s) at or below their own floor, at a
|
||
roster of two or more only.
|
||
* `tools/supervisor_shape_probe.py` — **re-run, and repaired.** MUTATION.md
|
||
habit 8: a committed transcript is a check and it decays. Making the key
|
||
required broke §C outright and, worse, left §D printing **PASS** on a refusal
|
||
about the *wrong key* — its "no `max_w`" case was answered `Missing option
|
||
'bms_soc_floor'`. §D is now an isolated counterfactual over a **merged,
|
||
accepted** baseline with exactly one key varied per case, asserting the key's
|
||
**name** in the refusal, and it carries a `bms_soc_floor` case in both
|
||
directions. Re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`:
|
||
`all cases passed`.
|
||
* `app/inverter.py`, found by the mutation battery — `_flat_bms_floor()` was
|
||
written with the default in **two** places (the `.get()` fallback *and* the
|
||
blank test). Deleting either one survived, because each covered for the other:
|
||
MUTATION.md's masking section in three lines of option reading. It has one
|
||
defaulting line now, and M12 takes six checks red.
|
||
|
||
* **Round 3 changed no `app/` file at all.** `test_peakguard.py` only:
|
||
parts 12c2 and 12g. Its battery was **46 mutations across
|
||
`binding_soc_floor()`'s reduction, the `decide()` call site, `Controller.packs`
|
||
and `pack_floor_pct()`'s conjunction** — bytecode purged and counted before and
|
||
after each (23 → 0). **43 red, 3 survivors, all three recorded above and in the
|
||
in-process rows**: `max(floors[:12])` and `max(floors[-12:])`, which need a
|
||
13-pack roster to observe and are reported with that size rather than hidden;
|
||
and `max(set(floors))`, which is **equivalent** — `max` is duplicate-invariant,
|
||
so it cannot change an answer. `list(reversed(self.packs))` is equivalent too
|
||
(`max` is order-invariant) and is nonetheless red on the structural pin.
|
||
|
||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||
physically exist. Every second-pack number in a fixture, in the probe and in
|
||
config.yaml's commented example is **simulated and labelled as such**; only the
|
||
10 % on inverter A is read off hardware. The three-pack runs added in round 3 are simulated
|
||
twice over: there is no inverter C at all, and the shallow slots carry inverter
|
||
A's measured 10 % while the 35 % cut-out that moves along the roster is invented.
|
||
|
||
---
|
||
|
||
## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0
|
||
|
||
Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring):
|
||
|
||
```
|
||
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" \
|
||
--entrypoint python3 \
|
||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||
/probe/supervisor_shape_probe.py
|
||
```
|
||
|
||
It reads `config.yaml` and runs Supervisor's own `AppOptions` validator in a
|
||
throwaway container. It touches no live Supervisor, POSTs nothing and installs
|
||
nothing.
|
||
|
||
⚠️ **RE-RUN AGAIN ON 2026-09-05**, because the rebase onto `67e649a` merged
|
||
SPIKE-04a's `max_w: 4600` into the same §C/§D blocks this card had rewritten,
|
||
and MUTATION.md habit 8 says a committed transcript that a document quotes is a
|
||
check. A hand-merged transcript is not a measurement of anything. The block
|
||
below is the output of that run, not of the 2026-09-04 one: B's rating reads
|
||
**4600** — its datasheet figure, still not a measurement of any unit — and every
|
||
case still passes.
|
||
|
||
```
|
||
== C. AC2: a two-inverter blob an operator would POST ==
|
||
PASS two inverters
|
||
[{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000, "bms_soc_floor": 10}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_so
|
||
|
||
== D. what the schema itself refuses, per entry ==
|
||
PASS the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere else to come from
|
||
[{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600, "bms_soc_floor": 10}]
|
||
PASS an entry with no name is refused, and the refusal NAMES it
|
||
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||
PASS an entry with no setpoint_entity is refused, and the refusal NAMES it
|
||
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||
PASS an entry with no max_w is refused, and the refusal NAMES it
|
||
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||
PASS an entry with no bms_soc_floor (SAFETY-32) is refused, and the refusal NAMES it
|
||
Missing option 'bms_soc_floor' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||
PASS an entry with max_w over the range is refused, and the refusal NAMES it
|
||
value must be at most 5000.0
|
||
PASS an entry with bms_soc_floor over the range (SAFETY-32) is refused, and the refusal NAMES it
|
||
value must be at most 50.0
|
||
PASS a JSON STRING where the list belongs is refused
|
||
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
|
||
|
||
== E. the ROLLBACK direction, and the operator's UI ==
|
||
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
|
||
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
|
||
PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
|
||
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
|
||
|
||
== all cases passed ==
|
||
```
|