Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19decf433f | ||
|
|
8beba0a3e7 | ||
|
|
a4c95f83ce | ||
|
|
ffbabea8ce | ||
|
|
f9e7fbf86d | ||
|
|
67e649ad6b | ||
|
|
a9993aeda2 |
@@ -2,6 +2,67 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Inverter B's rating is 4600 W, FROM ITS DATASHEET, and every documented copy
|
||||
of it now says so (SPIKE-04a).** Three places stated B's rating — `config.yaml`'s
|
||||
commented roster example, `DOCS.md`'s, and `tools/supervisor_shape_probe.py`'s
|
||||
"a blob an operator would POST" — and all three said **3000 W**, a number that
|
||||
was never measured, never read off a datasheet, and simply wrong. The owner
|
||||
supplied B's model and serial on 2026-09-04: a **GoodWe GW5048D-ES**, serial
|
||||
`55048ESU22AW0992`, max charging and discharging power **4600 W**. No control
|
||||
behaviour changes here; this is documentation and its pin.
|
||||
|
||||
* **Serial characters 6-8 read `ESU`** → platform 105, AA55, the supported set.
|
||||
Not `ESN`/`ESC` (ES G2, platform 745). SPIKE-01 does not gate this site.
|
||||
⚠️ A passed platform gate is **not** a proven control surface: `specs.md`
|
||||
already calls other platform-105 serials *"same protocol family; untested
|
||||
silicon"*, and this repo's `0x056E`/`0x056F` map was reverse-engineered from a
|
||||
passive tap on **one BPS unit**. That check is the bench half of SPIKE-04.
|
||||
* **⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and one ambiguity is
|
||||
recorded rather than guessed.** The datasheet prints it under *Battery Input
|
||||
Data*, so it is probably **DC**, while `max_w` is graded against `0x0518` —
|
||||
the inverter's own **AC** power. If it is DC the AC ceiling is nearer
|
||||
4600 × 0.94 ≈ **4324 W**, and the charge direction moves the other way
|
||||
(≈ 4894 W drawn AC-side). **Neither reading is written in as fact.**
|
||||
SAFETY-20's `min(max_w, dev_max)` already bounds it correctly under either,
|
||||
once `dev_max` is read off the unit — so `max_w: 4600` is what to configure
|
||||
today. `goodwe/NOTES.md` §1.1b carries the full record, with the date and the
|
||||
method on every figure.
|
||||
* **A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.**
|
||||
`test_validate.py` now pins all three documented rosters: they must be
|
||||
findable, they must agree, and they must say **4600**. It also grades the
|
||||
**provenance beside the number** — the datasheet it came from, the `0x0518`
|
||||
it is graded against, and the 4324 W the DC reading would make it — **within
|
||||
2500 characters of the roster, not file-wide.** The first version searched
|
||||
whole files and was already vacuous on two of them, because `0x0518` appeared
|
||||
in `config.yaml` and `DOCS.md` for unrelated reasons before this card: a
|
||||
check whose data could not reach the failure it named, in the file that
|
||||
exists to complain about those. Four mutations were run against the fixed
|
||||
version — the probe back to 3000 (0-byte delta), the `DOCS.md` caveat
|
||||
deleted, `config.yaml` alone "corrected" to 4324 (0-byte delta), and the
|
||||
probe's entity renamed so the regex finds nothing — and all four are red.
|
||||
* **THE TEST FIXTURES KEEP 3000 W ON PURPOSE, AND NOW SAY WHY AT THE LINE.**
|
||||
`test_inverter.py`, `test_maintenance.py`, `test_peakguard.py` and
|
||||
`test_watchdog.py` build a second inverter at 3000 W against A's 5000 W. The
|
||||
only property those checks consume is that B's number is **unmistakably** not
|
||||
A's, and the real pair is a **400 W** gap — narrower than `slew_w`, inside the
|
||||
range where an off-by-one-unit bug and ordinary control arithmetic produce
|
||||
indistinguishable numbers. Collapsing them onto the real values would buy
|
||||
realism those checks do not use and cost them their separating power, which
|
||||
is the defect `test_peakguard.py`'s own §10g precondition exists to prevent.
|
||||
They are **illustrative, not B's rating, and no longer a placeholder for it**.
|
||||
* **Two consequences with numbers, recorded in `goodwe/NOTES.md` §1.1b for the
|
||||
cards that own them.** At 94 % battery-to-AC a round trip is ≈ 88 %, so A
|
||||
charging 3 kW while B discharges 3 kW burns ≈ **360 W continuously** with the
|
||||
meter at zero — SAFETY-31 argues its threshold from *"a few watts"* and has no
|
||||
figure. And **< 13 W night self-consumption per unit** (≈ 26 W for two,
|
||||
≈ 0.6 kWh/day), which nothing in this add-on accounts for anywhere.
|
||||
* **The site is asymmetric in opposite directions:** A = 5000 W / 15.36 kWh
|
||||
nominal, B = 4600 W / 20.48 kWh — ~33 % more energy, ~8 % less power. No
|
||||
stability implication (ARCH-01's ḡ = Σαᵢgᵢ is a convex combination), but a
|
||||
full recalibration of B moves 33 % more energy and **MAINT-05's 12 h `drain`
|
||||
phase timeout was derived on A**; it should be re-checked against B rather
|
||||
than assumed to carry.
|
||||
|
||||
**Every pack is recalibrated on its own schedule, and only one moves power at a
|
||||
time (MAINT-05).** §12's monthly cycle drains a pack to empty and refills it to
|
||||
true its coulomb counter. There was exactly one `Maintenance` state machine and
|
||||
@@ -305,6 +366,91 @@ thing that stops. Rule 8's own failure mode, reached through rule 8.
|
||||
line**, where `sorted(...)[:1]` (+4 bytes) is still green — it truncates names
|
||||
in one event message; the refusal itself is a different expression and is
|
||||
graded, so the site still stops.
|
||||
* ⚠️ **FOURTH FIX ROUND — the same family one MODULE over, and the pin widened
|
||||
until it discovers its own scope.** Round 4 enumerated the family and closed
|
||||
it inside the `Controller` class; the review found four more survivors
|
||||
immediately, in **validate.py**, on a 19/19 green suite: `min(caps)` →
|
||||
`min(caps[:1])` (+4) and `→ min(caps[-1:])` (+5) at `:779`, the caps
|
||||
comprehension over `caps_ix[:1]` (+4) at `:778`, and
|
||||
`min(caps_ix[-1:], key=…)` (+5) at `:893`. **None is an equivalent mutant.**
|
||||
With `max_w: 5000` on inverter-a beside `2000` on inverter-b and
|
||||
`sign_disagree_w: 3000`, the shipped validator refuses — *"the SAFETY-02
|
||||
sign-disagreement watchdog can never fire"* — and all four **accept**: the
|
||||
site starts with a watchdog reporting `armed` that cannot trip on pack B.
|
||||
Rule 5's own failure mode reached through rule 5; rule 6 loses its refusal the
|
||||
same way. **No shipped behaviour changed by the pin; what changed is that a
|
||||
hand-written scope stopped being trusted.**
|
||||
* **`test_peakguard.py` part 12j now parses every `.py` under `app/`** — no
|
||||
module is named in the check — and finds the roster by transitive closure
|
||||
from **one seed**, the `inverters` option key: through returns, through
|
||||
lists **accumulated in a loop over a roster** (which is what `caps`,
|
||||
`roster` and `problems` are, and what round 4's rule could not express),
|
||||
through attributes and properties, and **through call arguments**, which is
|
||||
how it walks into `peakguard`, `arbiter`, `allocator`, `circulation`,
|
||||
`maintenance` and `store` without being told they exist. A slice of a roster
|
||||
stays a roster so a mutation cannot cut the taint; an index is one unit and
|
||||
does not. **183 reductions across 9 modules** out of 23 files parsed — `main.py` 96,
|
||||
`allocator.py` 24, `validate.py` 19, `arbiter.py` 14, `circulation.py` 10,
|
||||
`maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, `store.py` 3.
|
||||
* **The hand-written module list would have been wrong in BOTH directions.**
|
||||
It named `watchdog.py`, which reduces over no roster at all — SAFETY-30's
|
||||
sign watchdogs are strictly per-unit — and omitted `maintenance.py` and
|
||||
`store.py`, which both do. A vacuous claim about one module and no claim
|
||||
about two others.
|
||||
* **The pin rediscovers all four blockers without being told where to look,**
|
||||
and finds a fifth beside them (`validate.py:783`, rule 5's message) that no
|
||||
review round named. Its counterfactual runs at two depths: every enumerated
|
||||
site's own expression mutated with each of the four cuts this card has been
|
||||
defeated by (732 spellings, all reported), and `[:1]` written into
|
||||
`app/validate.py` at each blocker position with the **whole** analysis
|
||||
re-run over the mutated source — a sliced site required at that exact line,
|
||||
where the clean package reports none.
|
||||
* **And the behavioural half, because a shape pin grades an identity and never
|
||||
a meaning.** `test_validate.py` grows a **roster SET** for the three rules
|
||||
that reduce over `unit_caps_indexed()` — widths 2, 3, 4, 5 with the one
|
||||
small cap at every position, 42 runs of the real validator — plus a
|
||||
**computed family table** of twelve candidate reductions each compared with
|
||||
what the shipped validator actually said on the same roster. Only `min`
|
||||
agrees everywhere; a member no roster kills is printed as a named survivor.
|
||||
* ⚠️ **A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's** —
|
||||
found by reading, **not** by the pin, which is the shape of what the pin
|
||||
cannot see. `unit_sign_disagree_w` was graded against
|
||||
`_number(opts, "max_w", 2000.0)` three lines under a branch that had already
|
||||
stopped doing that. **Both directions were measured:** two 2000 W units
|
||||
beside a leftover `max_w: 5000` and `unit_sign_disagree_w: 3000` produced
|
||||
**zero problems** — the site started with a per-unit watchdog that could not
|
||||
trip on either unit — and two 5000 W units with no flat key were **refused**
|
||||
against a 2000 W default the operator never wrote. It takes the weakest
|
||||
unit's own cap now and names that unit; at a roster of one it is the same
|
||||
comparison, boundary and sentence it always was.
|
||||
* ⚠️ **What the pin cannot see, with sizes**, in
|
||||
`DECISIONS/SAFETY-32-per-pack-bms-floor.md` §4d: semantics (`min` vs `max`
|
||||
is a zero-byte edit); direct index sites that are not reductions; a
|
||||
subscript by a *variable* (`weights[u]` is why); a slice through an
|
||||
intermediate call; anything outside `app/`, including `sim/inject.py`; and
|
||||
the taint's own seam at `*args` and unresolvable receivers, where everything
|
||||
downstream goes **unenumerated rather than reported unreachable**.
|
||||
`cycle()`'s undeliverable-unit log line is still green at +4 bytes and still
|
||||
filed open, unchanged from round 4.
|
||||
* **`tools/supervisor_shape_probe.py` re-run again (2026-09-05, 2026.08.0).**
|
||||
The rebase onto `67e649a` merged SPIKE-04a's `max_w: 4600` into the same
|
||||
§C/§D blocks this card had rewritten, and a transcript stitched by hand out
|
||||
of two runs is not a measurement. `all cases passed`, exit 0, with B's
|
||||
rating reading 4600 — its **datasheet** figure, still not a measurement of
|
||||
any unit.
|
||||
* ⚠️ **THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE.** All 183
|
||||
sites mutated to `[:1]`, one at a time, against the full 19-file suite in
|
||||
`goodwe-addon-ci` (`:ro`, `-B`, fresh rsync per mutant, 0 `.pyc` before and
|
||||
after): **183/183 RED** — which is tautological on its own, because the pin
|
||||
makes every slice red. The 51 whose first red was `test_peakguard.py` were
|
||||
re-run with the pin **neutralised**: **145 of 183 have a behavioural fixture
|
||||
that separates them from a slice of themselves; 38 have only the structural
|
||||
claim.** All 38 are listed by site, with what a `[:1]` there would cost, in
|
||||
§4d and in `test_peakguard.py` part 12j — 15 of them are rows and counts on
|
||||
the operator's page, 3 are `len(targets)` arithmetic beside a write path
|
||||
that IS graded, 6 are allocator internals ALLOC-01's fixtures reach but not
|
||||
with enough units to separate. **None of the 38 is closed behaviourally by
|
||||
this card, and none is counted as covered.**
|
||||
|
||||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||||
exist yet. Every second-pack figure — in fixtures, in the probe, in
|
||||
|
||||
@@ -470,13 +470,227 @@ 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.
|
||||
`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
|
||||
|
||||
@@ -546,7 +760,7 @@ A's measured 10 % while the 35 % cut-out that moves along the roster is invented
|
||||
|
||||
---
|
||||
|
||||
## 7. The probe transcript, re-run 2026-09-04 on 2026.08.0
|
||||
## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0
|
||||
|
||||
Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring):
|
||||
|
||||
@@ -561,6 +775,14 @@ 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
|
||||
@@ -568,7 +790,7 @@ nothing.
|
||||
|
||||
== 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": 3000, "bms_soc_floor": 10}]
|
||||
[{"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
|
||||
|
||||
@@ -78,9 +78,20 @@ inverters:
|
||||
setpoint_entity: number.goodwe_b_setpoint_w
|
||||
soc_entity: sensor.goodwe_b_soc
|
||||
batt_entity: sensor.goodwe_b_battery_power
|
||||
max_w: 3000
|
||||
max_w: 4600
|
||||
```
|
||||
|
||||
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
|
||||
GoodWe GW5048D-ES (serial `55048ESU22AW0992`, identified 2026-09-04) whose
|
||||
datasheet gives max charging and discharging power as 4600 W; with nothing
|
||||
connected to its PV inputs the battery path is the ceiling, not the 5000 VA AC
|
||||
nameplate. The datasheet prints that number under *Battery Input Data*, so it
|
||||
may be a DC figure, while `max_w` is graded against register `0x0518` — the
|
||||
inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
|
||||
`max_w: 4600` is still the right thing to configure, because the freeze rail is
|
||||
`min(max_w, dev_max)` and the device range read off the unit is what binds.
|
||||
`goodwe/NOTES.md` §1.1b records both readings and picks neither.
|
||||
|
||||
| per-entry key | required | meaning |
|
||||
|---|---|---|
|
||||
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
|
||||
|
||||
@@ -332,6 +332,37 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
sibling of the thing I just hardened, and does any fixture separate it from
|
||||
a slice of itself?*
|
||||
|
||||
12. **When the same shape has been found twice, STOP CLOSING SITES AND BUILD
|
||||
THE INSTRUMENT — and make it DISCOVER its own scope.** SAFETY-32 was
|
||||
rejected four times, and each round closed exactly the site it was handed:
|
||||
the reduction, then the roster handed to it, then the *sibling argument* of
|
||||
the same call, then the same family one *module* over, in validate.py.
|
||||
Round 4 did enumerate — and enumerated inside the `Controller` class,
|
||||
because that is where the four known sites lived. **A hand-written scope is
|
||||
the same defect as a hand-written fixture**: it is a check whose data cannot
|
||||
reach the failure it names, and it goes on passing while the thing it was
|
||||
supposed to cover sits in the file nobody added to the list. Round 5's pin
|
||||
parses every `.py` under `app/`, seeds itself from ONE option key, and
|
||||
propagates through returns, accumulator loops, attributes and *call
|
||||
arguments* to find the roster wherever it went — 183 reductions across 9
|
||||
modules. It rediscovered all four blockers without being told where to look,
|
||||
and it named two modules the hand-written list would not have contained
|
||||
(`maintenance.py`, `store.py`) while *omitting* one the list did contain
|
||||
(`watchdog.py`, which is strictly per-unit and reduces over no roster at
|
||||
all). **The list was wrong in both directions**, which is exactly what a
|
||||
computed scope is for.
|
||||
|
||||
13. **Say what the instrument CANNOT see, with sizes, in the same breath.** A
|
||||
structural pin grades an **identity** ("this whole roster arrives"); it can
|
||||
never grade a **meaning** (`min` vs `max` is a zero-byte edit and invisible
|
||||
to it). Ship the pin AND the behavioural set, name the gap, and give every
|
||||
unreached case its byte delta — `cycle()`'s undeliverable-unit log line
|
||||
(+4 B, still green), a subscript by a *variable* rather than a constant, a
|
||||
slice taken through an intermediate call, anything outside `app/`. **An
|
||||
enumerated list with an honest gap is worth more than a claim of
|
||||
completeness a reviewer falsifies in ten minutes**, and every round of this
|
||||
card that claimed the latter was rejected for it.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
|
||||
@@ -806,21 +806,48 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# second inverter is added - which is the day nobody is watching the startup
|
||||
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
|
||||
# prevents is silent.
|
||||
#
|
||||
# ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32
|
||||
# ROUND 5. This branch used to compare against `max_w = _number(opts,
|
||||
# "max_w", 2000.0)`, the FLAT option, which on a roster config is either
|
||||
# ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the
|
||||
# site) or a leftover from a single-inverter install (so it compared against
|
||||
# a number no unit has). Both directions were live and both were measured:
|
||||
#
|
||||
# a roster of two 2000 W units beside a leftover `max_w: 5000` and
|
||||
# `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED,
|
||||
# with a per-inverter watchdog reporting `armed` on the status page and
|
||||
# incapable of tripping on EITHER unit. That is the identical sentence the
|
||||
# branch above exists to refuse, reached through the branch beside it;
|
||||
#
|
||||
# and a roster of two 5000 W units with no flat key was REFUSED at
|
||||
# `unit_sign_disagree_w: 3000` against a 2000 W default the operator never
|
||||
# wrote, for a threshold both units can reach.
|
||||
#
|
||||
# `unit_caps()`'s own docstring already said why: there is no such thing as
|
||||
# a site rating on the wire, and this watchdog grades ONE UNIT's standing
|
||||
# command against THAT unit's `supervised` bound. So it takes the same
|
||||
# `weakest` the branch above takes, from the same list, and names the same
|
||||
# unit. At a roster of one `unit_caps()` is `[max_w]` and this is the
|
||||
# identical comparison it always was - which is what keeps the shipped
|
||||
# single-inverter refusal byte-for-byte what it was.
|
||||
unit_sign_w = _number(opts, "unit_sign_disagree_w",
|
||||
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
|
||||
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||
if caps_ix else "")
|
||||
if unit_sign_w is None:
|
||||
problems.append(f"unit_sign_disagree_w "
|
||||
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
|
||||
elif max_w is not None and max_w > 0 and unit_sign_w >= max_w:
|
||||
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
||||
problems.append(
|
||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is "
|
||||
f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
|
||||
f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
||||
"watchdog can never fire: no inverter is ever commanded past max_w, "
|
||||
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
|
||||
"inverters that is the guard which catches a unit doing the "
|
||||
"OPPOSITE of what it was told while its sibling covers for it at "
|
||||
"the meter - the case the site-level watchdog is structurally "
|
||||
f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W "
|
||||
f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W "
|
||||
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
|
||||
"measured default), or raise max_w.")
|
||||
|
||||
|
||||
@@ -61,14 +61,31 @@ options:
|
||||
# setpoint_entity: number.goodwe_b_setpoint_w
|
||||
# soc_entity: sensor.goodwe_b_soc
|
||||
# batt_entity: sensor.goodwe_b_battery_power
|
||||
# max_w: 3000
|
||||
# max_w: 4600
|
||||
# bms_soc_floor: 10
|
||||
#
|
||||
# ⚠️ THE SECOND ENTRY ABOVE IS AN EXAMPLE, NOT A MEASUREMENT. No second
|
||||
# inverter exists on this site yet; its 3000 W rating and its 10 % cut-out are
|
||||
# placeholders in a comment, and the cut-out in particular must be read off the
|
||||
# pack that is actually installed (register 0x0560 on the reference unit)
|
||||
# before that pack is commanded.
|
||||
# ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A
|
||||
# PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES,
|
||||
# serial `55048ESU22AW0992`, whose datasheet gives Max. Charging / Discharging
|
||||
# Power as 4600 W; nothing will be connected to its MPPTs, so the battery path
|
||||
# is the ceiling and the 5000 VA AC nameplate is not. The 3000 W this example
|
||||
# used to carry was a guess made before the unit was known. ⚠️ It is STILL not
|
||||
# measured: the datasheet prints 4600 W under BATTERY INPUT DATA, so it may be
|
||||
# DC, and `max_w` is graded against 0x0518 - the inverter's own AC power. If it
|
||||
# is DC the AC ceiling is nearer 4324 W. SAFETY-20's `min(max_w, dev_max)` is
|
||||
# what settles that once the device range is read; see goodwe/NOTES.md §1.1b,
|
||||
# which records both readings and picks neither.
|
||||
#
|
||||
# ⚠️ AND THE `bms_soc_floor: 10` BESIDE IT IS FROM THE SAME PLACE - THE
|
||||
# DATASHEET AND THE SERIAL, NOT A PACK ANYONE HAS READ. No second inverter
|
||||
# exists on this site (SPIKE-04, ≈ 2026-09-12 for B); the cut-out must be read
|
||||
# off the pack that is actually installed (register 0x0560 on the reference
|
||||
# unit) before that pack is commanded. It reads 10 here because pack A reads
|
||||
# 10, which makes this example DEGENERATE ON THE FLOOR AXIS: a site whose two
|
||||
# packs floor at the SAME percent cannot separate `min`, `max`, first or last
|
||||
# over the roster, and every SAFETY-32 fixture that DIFFERS the floors differs
|
||||
# it for exactly that reason. Do not "simplify" those fixtures to match this
|
||||
# example.
|
||||
#
|
||||
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
|
||||
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
|
||||
|
||||
@@ -192,9 +192,16 @@ class Hass:
|
||||
|
||||
A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
|
||||
"batt_entity": "sensor.batt_a"}
|
||||
# ⚠️ A HYPOTHESIS, NOT A MEASUREMENT. Inverter B does not exist; 3000 W is
|
||||
# chosen only because it DIFFERS from A's 5000 W, which is what lets a check
|
||||
# tell B's number from A's. SPIKE-04 measures the real one.
|
||||
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
|
||||
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
|
||||
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
|
||||
# kept because the only property these checks consume is that B's number is
|
||||
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
|
||||
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
|
||||
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
|
||||
# onto the real values would cost these checks their separating power to buy
|
||||
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
|
||||
# placeholder for it either - the placeholder era ended on 2026-09-04.
|
||||
B_RATING_HYPOTHESIS = 3000.0
|
||||
# SAFETY-32. Each pack's OWN BMS cut-out percent, now a REQUIRED roster key.
|
||||
# ⚠️ 10 IS MEASURED (register 0x0560 on the reference GW5000S-BP and the shipped
|
||||
|
||||
@@ -1059,9 +1059,16 @@ class Pub:
|
||||
pass
|
||||
|
||||
|
||||
# ⚠️ 3000 W FOR PACK B IS A HYPOTHESIS AND IS MARKED AS ONE EVERYWHERE IT
|
||||
# APPEARS. Inverter B does not physically exist; the number is here only because
|
||||
# it DIFFERS from pack A's 5000 W, which is the only property these checks use.
|
||||
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
|
||||
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
|
||||
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
|
||||
# kept because the only property these checks consume is that B's number is
|
||||
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
|
||||
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
|
||||
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
|
||||
# onto the real values would cost these checks their separating power to buy
|
||||
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
|
||||
# placeholder for it either - the placeholder era ended on 2026-09-04.
|
||||
B_RATING_HYPOTHESIS = 3000.0
|
||||
A_RATING = 5000.0
|
||||
FLAT_OPTS = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
|
||||
+569
-124
@@ -2668,6 +2668,16 @@ check("...and the arbiter agrees the routing is legal, on the list main.py "
|
||||
# DATA could not reach the failure it named (ARB-03's call-site scan saw 3 of 8
|
||||
# sites; ALLOC-01's replay generator saw 0 escapes where a widened one saw
|
||||
# 39,991), so the number is different HERE, once, for every fixture in the file.
|
||||
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
|
||||
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
|
||||
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
|
||||
# kept because the only property these checks consume is that B's number is
|
||||
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
|
||||
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
|
||||
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
|
||||
# onto the real values would cost these checks their separating power to buy
|
||||
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
|
||||
# placeholder for it either - the placeholder era ended on 2026-09-04.
|
||||
B_RATING_HYPOTHESIS = LIVE_MAX_W - 2000.0
|
||||
|
||||
|
||||
@@ -4608,127 +4618,563 @@ check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()
|
||||
"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
|
||||
# --- 12j. THE ENUMERATION ITSELF, AS A RUNNING CHECK, ACROSS THE PACKAGE ------
|
||||
# ⚠️ THIS CARD HAS NOW BEEN REJECTED FOUR 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.
|
||||
# the call site's argument; round 4 found `pack_floor_soc`, the SIBLING ARGUMENT
|
||||
# of the same `decide()` call, and wrote the first version of this block - which
|
||||
# enumerated the family inside `Controller` AND STOPPED AT THE CLASS BOUNDARY.
|
||||
# Round 5's blocker was four survivors in validate.py, one module over, where
|
||||
# rules 5 and 6 reduce over `unit_caps_indexed()`. Closing them one at a time
|
||||
# has cost five rounds. This block does not do that any more.
|
||||
#
|
||||
# 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.
|
||||
# THE INSTRUMENT IS NOW PACKAGE-WIDE AND THE MODULE LIST IS COMPUTED, NOT
|
||||
# WRITTEN. Every `.py` under `app/` is read off disk and parsed; nothing here
|
||||
# names a module, so a module added tomorrow is graded on the day it is added.
|
||||
# ⚠️ A HARDCODED MODULE LIST WOULD ITSELF BE "A CHECK WHOSE DATA CANNOT REACH
|
||||
# THE FAILURE IT NAMES" (MUTATION.md), which is the defect this file exists to
|
||||
# complain about: the list would go on passing while the reduction it was
|
||||
# supposed to cover lived in the module nobody added to 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")
|
||||
# WHAT COUNTS AS A ROSTER, DERIVED RATHER THAN DECLARED. The seed is ONE
|
||||
# expression - reading the `inverters` option - and everything else is transitive
|
||||
# closure over the AST:
|
||||
# * a function all of whose returns are roster-carrying is roster-producing
|
||||
# (`roster_entries` -> `build_roster` -> `unit_caps_indexed` -> `unit_caps`);
|
||||
# * a name assigned a roster-carrying value carries it, and so does a list
|
||||
# ACCUMULATED inside a loop over one (`caps`, `roster`, `problems`);
|
||||
# * an attribute or property that answers one carries it (`self.inverters`,
|
||||
# `Controller.packs`, `Controller.units`, `Controller.live_units()`);
|
||||
# * an argument at a call site that is handed one taints THAT PARAMETER in the
|
||||
# callee, which is how the pin crosses 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 the reductions downstream of itself.
|
||||
# An INDEX of a roster is NOT a roster: `self.inverters[0]` is one unit, and
|
||||
# treating it as a roster would spread this analysis over every unit attribute
|
||||
# in the package.
|
||||
#
|
||||
# THE CLAIM, in one line: at every place in `app/` where a roster-carrying value
|
||||
# is REDUCED or ITERATED, the whole of it arrives. A `[:1]`, a `[-1:]`, a `[:2]`
|
||||
# or a `[0]` between the roster and the reduction is red on the line it is
|
||||
# written, at every roster length, in every configuration, and with no fixture
|
||||
# required.
|
||||
_APP_SRC = {p.stem: p.read_text(encoding="utf-8")
|
||||
for p in sorted((HERE / "app").glob("*.py"))}
|
||||
_ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure.
|
||||
_WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed",
|
||||
"filter", "iter", "set", "dict"}
|
||||
_REDUCERS = {"min", "max", "sum", "any", "all", "len", "sorted", "next", "list",
|
||||
"tuple", "set", "reversed", "enumerate", "zip", "map", "filter"}
|
||||
|
||||
|
||||
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 _roster_sites(sources):
|
||||
"""Every roster-carrying expression in reduction/iteration position.
|
||||
|
||||
|
||||
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.
|
||||
Returns `(rows, meta)`; each row is
|
||||
`(module, lineno, col, end_lineno, end_col, qualname, kind, source, sliced)`.
|
||||
"""
|
||||
return any(isinstance(n, ast.Subscript) and _is_roster(n.value)
|
||||
for n in ast.walk(node))
|
||||
trees = {m: ast.parse(s) for m, s in sources.items()}
|
||||
funcs, importmap = {}, {}
|
||||
|
||||
for m, tree in trees.items():
|
||||
def walk(node, qual):
|
||||
for ch in ast.iter_child_nodes(node):
|
||||
if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
funcs[(m, qual + ch.name)] = ch
|
||||
walk(ch, qual + ch.name + ".")
|
||||
elif isinstance(ch, ast.ClassDef):
|
||||
walk(ch, qual + ch.name + ".")
|
||||
else:
|
||||
walk(ch, qual)
|
||||
walk(tree, "")
|
||||
for n in ast.walk(tree):
|
||||
if isinstance(n, ast.ImportFrom):
|
||||
if n.module:
|
||||
base = n.module.split(".")[-1]
|
||||
if base in trees:
|
||||
for a in n.names:
|
||||
importmap[(m, a.asname or a.name)] = (base, a.name)
|
||||
else:
|
||||
# `from . import peakguard` - a MODULE, not a name inside
|
||||
# one. This branch is the only reason the pin can see
|
||||
# peakguard.py, and it was missing from the first draft.
|
||||
for a in n.names:
|
||||
if a.name in trees:
|
||||
importmap[(m, a.asname or a.name)] = (a.name, None)
|
||||
elif isinstance(n, ast.Import):
|
||||
for a in n.names:
|
||||
base = a.name.split(".")[-1]
|
||||
if base in trees:
|
||||
importmap[(m, a.asname or base)] = (base, None)
|
||||
|
||||
t_funcs, t_params, t_attrs, t_locals = set(), set(), set(), {}
|
||||
bodies = {}
|
||||
|
||||
def body_nodes(fn):
|
||||
if id(fn) not in bodies:
|
||||
out = []
|
||||
|
||||
def rec(n):
|
||||
for ch in ast.iter_child_nodes(n):
|
||||
if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||
ast.Lambda)):
|
||||
continue
|
||||
out.append(ch)
|
||||
rec(ch)
|
||||
rec(fn)
|
||||
bodies[id(fn)] = out
|
||||
return bodies[id(fn)]
|
||||
|
||||
def callee(m, qual, node):
|
||||
f = node.func
|
||||
if isinstance(f, ast.Name):
|
||||
if (m, f.id) in importmap:
|
||||
om, oname = importmap[(m, f.id)]
|
||||
if oname and (om, oname) in funcs:
|
||||
return (om, oname)
|
||||
if (m, f.id) in funcs:
|
||||
return (m, f.id)
|
||||
elif isinstance(f, ast.Attribute):
|
||||
if isinstance(f.value, ast.Name):
|
||||
if f.value.id == "self" and "." in qual:
|
||||
cls = qual.rsplit(".", 1)[0]
|
||||
if (m, cls + "." + f.attr) in funcs:
|
||||
return (m, cls + "." + f.attr)
|
||||
if (m, f.value.id) in importmap:
|
||||
om, _ = importmap[(m, f.value.id)]
|
||||
if (om, f.attr) in funcs:
|
||||
return (om, f.attr)
|
||||
return None
|
||||
|
||||
def derived(node, m, qual):
|
||||
if node is None:
|
||||
return False
|
||||
if isinstance(node, ast.Subscript):
|
||||
# A SLICE of a roster is still a roster - a defective one - so the
|
||||
# taint survives the mutation and every reduction downstream of it
|
||||
# reports too. An INDEX is one unit and is not a roster.
|
||||
return (isinstance(node.slice, ast.Slice)
|
||||
and derived(node.value, m, qual))
|
||||
if isinstance(node, ast.Call):
|
||||
f = node.func
|
||||
if (isinstance(f, ast.Attribute) and f.attr in ("get", "pop")
|
||||
and node.args and isinstance(node.args[0], ast.Constant)
|
||||
and node.args[0].value == _ROSTER_OPTION):
|
||||
return True # THE SEED
|
||||
if isinstance(f, ast.Name) and f.id in _WRAPPERS:
|
||||
return any(derived(a, m, qual) for a in node.args)
|
||||
tgt = callee(m, qual, node)
|
||||
return bool(tgt and tgt in t_funcs)
|
||||
if isinstance(node, ast.Name):
|
||||
return (node.id in t_locals.get((m, qual), ())
|
||||
or (m, qual, node.id) in t_params)
|
||||
if isinstance(node, ast.Attribute):
|
||||
if isinstance(node.value, ast.Name) and node.value.id == "self":
|
||||
if node.attr in t_attrs:
|
||||
return True
|
||||
cls = qual.rsplit(".", 1)[0] if "." in qual else None
|
||||
return bool(cls and (m, cls + "." + node.attr) in t_funcs)
|
||||
return False
|
||||
if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp,
|
||||
ast.GeneratorExp)):
|
||||
return any(derived(g.iter, m, qual) for g in node.generators)
|
||||
if isinstance(node, ast.IfExp):
|
||||
return (derived(node.body, m, qual)
|
||||
or derived(node.orelse, m, qual))
|
||||
if isinstance(node, ast.BoolOp):
|
||||
return any(derived(v, m, qual) for v in node.values)
|
||||
if isinstance(node, (ast.List, ast.Tuple)):
|
||||
return any(isinstance(e, ast.Starred) and derived(e.value, m, qual)
|
||||
for e in node.elts)
|
||||
return False
|
||||
|
||||
for _ in range(40): # transitive closure, to fixpoint
|
||||
before = (len(t_funcs), len(t_params), len(t_attrs),
|
||||
sum(len(v) for v in t_locals.values()))
|
||||
for (m, qual), fn in funcs.items():
|
||||
loc = t_locals.setdefault((m, qual), set())
|
||||
for n in body_nodes(fn):
|
||||
if (isinstance(n, (ast.Assign, ast.AnnAssign))
|
||||
and getattr(n, "value", None) is not None
|
||||
and derived(n.value, m, qual)):
|
||||
for t in (n.targets if isinstance(n, ast.Assign)
|
||||
else [n.target]):
|
||||
if isinstance(t, ast.Name):
|
||||
loc.add(t.id)
|
||||
elif (isinstance(t, ast.Attribute)
|
||||
and isinstance(t.value, ast.Name)
|
||||
and t.value.id == "self"):
|
||||
t_attrs.add(t.attr)
|
||||
if (isinstance(n, (ast.For, ast.AsyncFor))
|
||||
and derived(n.iter, m, qual)):
|
||||
# A list ACCUMULATED one-per-unit is a roster too: `caps`,
|
||||
# `roster`, `problems` are all built this way, and round 5's
|
||||
# blocker was two reductions over one of them.
|
||||
for c in ast.walk(n):
|
||||
if (isinstance(c, ast.Call)
|
||||
and isinstance(c.func, ast.Attribute)
|
||||
and c.func.attr in ("append", "extend")
|
||||
and isinstance(c.func.value, ast.Name)):
|
||||
loc.add(c.func.value.id)
|
||||
if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
|
||||
and n.func.attr == "extend"
|
||||
and isinstance(n.func.value, ast.Name)
|
||||
and n.args and derived(n.args[0], m, qual)):
|
||||
loc.add(n.func.value.id)
|
||||
if isinstance(n, ast.Return) and derived(n.value, m, qual):
|
||||
t_funcs.add((m, qual))
|
||||
if isinstance(n, ast.Call):
|
||||
tgt = callee(m, qual, n)
|
||||
if tgt:
|
||||
cfn = funcs[tgt]
|
||||
pos = [a.arg for a in
|
||||
cfn.args.posonlyargs + cfn.args.args]
|
||||
off = 1 if (pos and pos[0] == "self") else 0
|
||||
for j, a in enumerate(n.args):
|
||||
if j + off < len(pos) and derived(a, m, qual):
|
||||
t_params.add((tgt[0], tgt[1], pos[j + off]))
|
||||
for kw in n.keywords:
|
||||
if kw.arg and derived(kw.value, m, qual):
|
||||
t_params.add((tgt[0], tgt[1], kw.arg))
|
||||
after = (len(t_funcs), len(t_params), len(t_attrs),
|
||||
sum(len(v) for v in t_locals.values()))
|
||||
if after == before:
|
||||
break
|
||||
|
||||
def shortens(sub):
|
||||
"""Does this subscript change HOW MANY members come out?
|
||||
|
||||
A SLICE does, and so does a CONSTANT index (N in, one out). A subscript
|
||||
by a NAME does not: `weights[u]`, `cap[u]` and `add[u]` in
|
||||
`allocator._fill` are keyed lookups into a roster-derived MAPPING, which
|
||||
is what iterating it looks like. Flagging those would make this check cry
|
||||
wolf on every dict the roster builds - it did, on four sites, before this
|
||||
predicate was written.
|
||||
"""
|
||||
sl = sub.slice
|
||||
if isinstance(sl, ast.Slice):
|
||||
return True
|
||||
if isinstance(sl, ast.Constant) and isinstance(sl.value, int):
|
||||
return True
|
||||
return (isinstance(sl, ast.UnaryOp) and isinstance(sl.op, ast.USub)
|
||||
and isinstance(sl.operand, ast.Constant))
|
||||
|
||||
def sliced(node, m, qual):
|
||||
return any(isinstance(n, ast.Subscript) and shortens(n)
|
||||
and derived(n.value, m, qual) for n in ast.walk(node))
|
||||
|
||||
def leaves(node, m, qual):
|
||||
"""The roster REFERENCES inside a reduced expression.
|
||||
|
||||
The place a slice goes is the roster reference, not the whole argument:
|
||||
`sum(i.max_w for i in self.inverters)` is defeated by slicing
|
||||
`self.inverters`, and slicing the generator would be a TypeError rather
|
||||
than a wrong answer - a mutation that crashes proves nothing.
|
||||
"""
|
||||
out = []
|
||||
|
||||
def rec(n):
|
||||
if (isinstance(n, (ast.Name, ast.Attribute, ast.Call,
|
||||
ast.Subscript)) and derived(n, m, qual)):
|
||||
out.append(n)
|
||||
if isinstance(n, ast.Call):
|
||||
for a in n.args:
|
||||
rec(a)
|
||||
return
|
||||
for c in ast.iter_child_nodes(n):
|
||||
rec(c)
|
||||
rec(node)
|
||||
return out
|
||||
|
||||
seen = {}
|
||||
for (m, qual), fn in funcs.items():
|
||||
def note(kind, expr):
|
||||
bad = sliced(expr, m, qual)
|
||||
for lf in leaves(expr, m, qual):
|
||||
key = (m, lf.lineno, lf.col_offset, lf.end_lineno,
|
||||
lf.end_col_offset)
|
||||
seen.setdefault(key, (qual, kind, ast.unparse(lf), bad))
|
||||
for n in body_nodes(fn):
|
||||
if isinstance(n, (ast.ListComp, ast.SetComp, ast.DictComp,
|
||||
ast.GeneratorExp)):
|
||||
for g in n.generators:
|
||||
if derived(g.iter, m, qual) or sliced(g.iter, m, qual):
|
||||
note("for..in", g.iter)
|
||||
elif isinstance(n, (ast.For, ast.AsyncFor)):
|
||||
if derived(n.iter, m, qual) or sliced(n.iter, m, qual):
|
||||
note("for stmt", n.iter)
|
||||
elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id in _REDUCERS):
|
||||
for a in n.args:
|
||||
if derived(a, m, qual) or sliced(a, m, qual):
|
||||
note(n.func.id + "()", a)
|
||||
def probe(m, qual, expr_src):
|
||||
"""Would this expression, IN THAT SCOPE, be reported as sliced?
|
||||
|
||||
The per-site counterfactual runs through here: it is the SAME predicate
|
||||
the enumeration above is graded with, asked about a mutated spelling of
|
||||
the site's own expression, so a predicate that stopped answering would
|
||||
take both to the same place.
|
||||
"""
|
||||
return sliced(ast.parse(expr_src, mode="eval").body, m, qual)
|
||||
|
||||
rows = sorted((k + v) for k, v in seen.items())
|
||||
return rows, (sorted(t_funcs), sorted(t_params), sorted(t_attrs), probe)
|
||||
|
||||
|
||||
_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:
|
||||
_SITES, (_TFUNCS, _TPARAMS, _TATTRS, _PROBE) = _roster_sites(_APP_SRC)
|
||||
_SLICED = [r for r in _SITES if r[8]]
|
||||
_BY_MOD = {}
|
||||
for _r in _SITES:
|
||||
_BY_MOD.setdefault(_r[0], []).append(_r)
|
||||
|
||||
|
||||
def _slice_at(row, cut="[:1]"):
|
||||
"""The package source with ONE roster reference sliced, for a counterfactual."""
|
||||
m, _ln, _c, eln, ec = row[:5]
|
||||
src = dict(_APP_SRC)
|
||||
lines = src[m].split("\n")
|
||||
lines[eln - 1] = lines[eln - 1][:ec] + cut + lines[eln - 1][ec:]
|
||||
src[m] = "\n".join(lines)
|
||||
return src
|
||||
|
||||
|
||||
print(f" {len(_APP_SRC)} modules parsed from app/ (NOT a written list); "
|
||||
f"{len(_BY_MOD)} of them reduce over a roster or a roster-derived list, "
|
||||
f"and every such reduction is enumerated below "
|
||||
f"({len(_SITES)} of them):")
|
||||
for _m in sorted(_BY_MOD):
|
||||
print(f" {_m}.py: {len(_BY_MOD[_m])}")
|
||||
for _m in sorted(_BY_MOD):
|
||||
for _row in _BY_MOD[_m]:
|
||||
print(f" {'SLICED ' if _row[8] else ' '}{_m}.py:{_row[1]:<5}"
|
||||
f" {_row[5]:<38} {_row[6]:<10} {_row[7][:56]}")
|
||||
print(f" modules with NO roster reduction at all: "
|
||||
f"{sorted(set(_APP_SRC) - set(_BY_MOD))}")
|
||||
print(f" ...and what made each of them a roster, computed from ONE seed "
|
||||
f"(`opts[{_ROSTER_OPTION!r}]`): {len(_TFUNCS)} roster-PRODUCING functions, "
|
||||
f"{len(_TPARAMS)} roster-carrying PARAMETERS across module boundaries, "
|
||||
f"and the attributes {_TATTRS}:")
|
||||
print(f" returns a roster: "
|
||||
+ ", ".join(f"{m}.{q}" for m, q in _TFUNCS))
|
||||
print(f" is HANDED a roster: "
|
||||
+ ", ".join(f"{m}.{q}({a})" for m, q, a in _TPARAMS))
|
||||
|
||||
# ⚠️ THE PR #48 ROUND-5 BLOCKERS, REDISCOVERED BY THE INSTRUMENT RATHER THAN
|
||||
# COPIED FROM THE REVIEW. If this pin did not independently name all of them,
|
||||
# the pin would be too narrow and THAT would be the finding.
|
||||
#
|
||||
# `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.
|
||||
# ⚠️ MATCHED BY FUNCTION AND EXPRESSION, NOT BY LINE NUMBER. The blockers were
|
||||
# reported as validate.py:778 `caps_ix`, :779 `caps`, :783 and :893 `caps_ix`;
|
||||
# editing three lines into validate.py moves all four, and a check pinned to
|
||||
# 778 would then be green because it found NOTHING - a check whose data cannot
|
||||
# reach the failure it names, in the block that exists to complain about them.
|
||||
# The lines the pin actually reports are PRINTED so a reviewer can match them
|
||||
# against the review by eye.
|
||||
_BLOCK_FN = "validate_startup_config"
|
||||
_blockers = [r for r in _SITES
|
||||
if r[0] == "validate" and r[5] == _BLOCK_FN
|
||||
and r[7] in ("caps", "caps_ix")]
|
||||
print(f" PR #48's round-5 blockers, as the pin names them "
|
||||
f"(validate.{_BLOCK_FN}, reductions over the caps list): "
|
||||
+ ", ".join(f"validate.py:{r[1]} {r[6]} {r[7]}" for r in _blockers))
|
||||
|
||||
check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY, AND "
|
||||
"NOW ACROSS THE WHOLE PACKAGE INSTEAD OF ONE CLASS. Every place in "
|
||||
"`app/` where a roster or a roster-derived list is REDUCED or ITERATED "
|
||||
"takes the whole of it: no `[:1]`, no `[-1:]`, no `[:2]`, no `[0]` "
|
||||
"between the roster and the reduction. This is the check that would have "
|
||||
"caught rounds 2, 3, 4 AND 5's blockers on the round they were written "
|
||||
"instead of one review each - round 4's version stopped at `Controller` "
|
||||
"and round 5's four survivors were one module over, in validate.py. A "
|
||||
"new reduction added over a slice of a roster, in ANY module under "
|
||||
"`app/`, is red on the line it is written, at every roster length and "
|
||||
"with no fixture required",
|
||||
len(_SITES) > 0 and not _SLICED)
|
||||
|
||||
check("...and it INDEPENDENTLY REDISCOVERS EVERY ONE OF PR #48's ROUND-5 "
|
||||
"BLOCKERS - the `for ... in caps_ix` the caps list is built from, the "
|
||||
"`min(caps)` rule 5 reduces it with, and the `min(caps_ix, key=...)` "
|
||||
"rule 5's message and rule 6's comparison each take - and it found a "
|
||||
"FURTHER ONE beside them that no review round named. A pin that had to "
|
||||
"be told where to look would be a transcription of the review rather "
|
||||
"than an instrument, so it is told ONE option key and nothing else",
|
||||
len([r for r in _blockers if r[7] == "caps_ix"]) >= 3
|
||||
and len([r for r in _blockers if r[7] == "caps"]) >= 1)
|
||||
|
||||
check("...and it reaches the sites of EVERY PREVIOUS ROUND'S BLOCKER TOO: "
|
||||
"round 2's `peakguard.binding_soc_floor`, round 4's "
|
||||
"`cycle()`/`publish()` `any(... for i in self.inverters)`, and "
|
||||
"SAFETY-30's `_grade_store_migration`. The whole point of a family is "
|
||||
"that its members are found by one rule",
|
||||
any(r[0] == "peakguard" and r[5] == "binding_soc_floor" for r in _SITES)
|
||||
and any(r[0] == "main" and r[5] == "Controller.cycle"
|
||||
and r[6] == "any()" for r in _SITES)
|
||||
and any(r[0] == "main" and r[5] == "Controller.publish"
|
||||
and r[6] == "any()" for r in _SITES)
|
||||
and any(r[0] == "main" and r[5] == "Controller._grade_store_migration"
|
||||
for r in _SITES))
|
||||
|
||||
# --- non-vacuity, habit 7: vary ONE thing from an ACCEPTED baseline ----------
|
||||
# ⚠️ EVERY ONE of the enumerated sites, mutated. Not a sample: the counterfactual
|
||||
# asks the SAME predicate the enumeration is graded with whether a `[:1]` on
|
||||
# THAT site's own expression, in THAT site's own scope, would be reported. A
|
||||
# predicate that had quietly stopped answering would take the line above green
|
||||
# and this one red.
|
||||
_probe_fail = []
|
||||
for _r in _SITES:
|
||||
for _cut in ("[:1]", "[-1:]", "[:2]", "[0]"):
|
||||
if not _PROBE(_r[0], _r[5], _r[7] + _cut):
|
||||
_probe_fail.append((_r[0], _r[1], _r[7] + _cut))
|
||||
print(f" per-site counterfactual: {len(_SITES)} sites x 4 cuts "
|
||||
f"([:1], [-1:], [:2], [0]) = {len(_SITES) * 4} mutated spellings, "
|
||||
f"{len(_probe_fail)} of them NOT reported")
|
||||
check("...and the predicate is not trivially true: sliced at EVERY ONE of the "
|
||||
"enumerated sites, in that site's own scope, with each of the four cuts "
|
||||
"this card has actually been defeated by, it reports the slice. It also "
|
||||
"ACCEPTS the legitimate wrapped forms the package really uses "
|
||||
"(`enumerate(...)`, `zip(...)`, `sorted(...)`, "
|
||||
"`pack_permissions(packs, floor)`) and REJECTS a keyed lookup being "
|
||||
"treated as a slice (`weights[u]` in allocator._fill, which is what four "
|
||||
"false SLICED reports cost before `shortens()` existed)",
|
||||
not _probe_fail
|
||||
and not _PROBE("main", "Controller.cycle", "self.inverters")
|
||||
and not _PROBE("main", "Controller.cycle", "enumerate(self.inverters)")
|
||||
and not _PROBE("main", "Controller.cycle", "zip(self.inverters, self.maints)")
|
||||
and not _PROBE("main", "Controller.check_peak_floor_limited",
|
||||
"peakguard.pack_permissions(self.packs, 10.0)")
|
||||
and not _PROBE("allocator", "_fill", "[u for u in order if weights[u] > 0.0]")
|
||||
and _PROBE("allocator", "_fill", "[u for u in order[:1] if weights[u] > 0.0]"))
|
||||
|
||||
# --- and the END-TO-END counterfactual, on the REAL FILES --------------------
|
||||
# ⚠️ The probe above grades the predicate. This grades THE WHOLE INSTRUMENT: the
|
||||
# text of a shipped module is mutated at a real byte offset, the entire analysis
|
||||
# is re-run from scratch on it, and the site has to come back SLICED at exactly
|
||||
# the line the mutation was written on. That is the difference between "the
|
||||
# rule is right" and "the rule is wired to the files".
|
||||
_e2e = []
|
||||
for _r in _blockers:
|
||||
_mut, _ = _roster_sites(_slice_at(_r))
|
||||
_hit = [x for x in _mut if x[8] and x[0] == _r[0] and x[1] == _r[1]]
|
||||
_e2e.append((_r[0], _r[1], _r[7], len(_hit), len([x for x in _mut if x[8]])))
|
||||
for _m, _ln, _s, _hit, _tot in _e2e:
|
||||
print(f" {_m}.py:{_ln} `{_s}` -> `{_s}[:1]`: reported at that line "
|
||||
f"{'YES' if _hit else 'NO'} ({_tot} sliced site(s) in the package)")
|
||||
check("...and the INSTRUMENT is wired to the files, not only to itself: "
|
||||
"writing `[:1]` into app/validate.py at each of the four round-5 blocker "
|
||||
"positions - every reduction over the caps list - and re-running the WHOLE "
|
||||
"analysis over the mutated source "
|
||||
"reports a sliced site at that exact line every time, where the clean "
|
||||
"package reports none. ⚠️ THIS IS THE ONE THAT WOULD HAVE CAUGHT PR #48: "
|
||||
"the four mutations the reviewer ran by hand against a 19/19 green suite "
|
||||
"are red HERE without any fixture reaching them",
|
||||
len(_e2e) >= 4 and all(h for _m, _l, _s, h, _t in _e2e))
|
||||
|
||||
|
||||
# ⚠️ WHAT THIS INSTRUMENT CANNOT SEE, SAID PLAINLY, BECAUSE AN ENUMERATED LIST
|
||||
# WITH AN HONEST GAP IS WORTH MORE THAN A CLAIM OF COMPLETENESS A REVIEWER CAN
|
||||
# FALSIFY IN TEN MINUTES. Every item below is an OPEN ROW, not a closed one.
|
||||
#
|
||||
# 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`.
|
||||
# IT GRADES AN IDENTITY, NOT A MEANING. The claim it makes is "the whole roster
|
||||
# arrives". It says nothing about WHICH attribute is read off each unit, WHICH
|
||||
# reduction is applied, or whether that reduction is the right one:
|
||||
# `min(caps)` -> `max(caps)` is invisible here, and always will be, because a
|
||||
# shape pin on a semantic claim would refuse honest refactors (MUTATION.md
|
||||
# habit 10). The behavioural halves - 12a-12i above, test_validate.py's rule 5
|
||||
# and rule 6 roster SETS, test_watchdog.py part 8 - are what say what these
|
||||
# reductions MEAN, and they are the only thing that can.
|
||||
#
|
||||
# AND SPECIFICALLY, THE FOUR THINGS IT WILL NOT REPORT:
|
||||
# 1. 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()`. These are single-unit
|
||||
# lookups, they are meant to be, and treating an index as a roster would
|
||||
# spread this analysis over every unit attribute in the package. They are
|
||||
# graded where they live.
|
||||
# 2. 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 - `shortens()` above. So a mutation that wrote
|
||||
# `caps[i]` with `i` a variable would pass. It is not in the family this
|
||||
# card has been defeated by four times (`[:1]`, `[-1:]`, `[:2]`, `[0]`),
|
||||
# and buying it would cost four false SLICED reports in allocator.py.
|
||||
# 3. A SLICE TAKEN THROUGH AN INTERMEDIATE REDUCTION. `sorted(roster)[:1]` is
|
||||
# a subscript on a CALL RESULT rather than on a roster reference, and the
|
||||
# pin does not flag it. Nothing in `app/` is written that way today; if
|
||||
# something is, this line is the record that it would not be seen.
|
||||
# 4. ANYTHING OUTSIDE `app/`. `tools/`, `sim/` and the checks themselves are
|
||||
# not parsed. `sim/inject.py` reaches `validate_startup_config()` and is
|
||||
# graded by nothing here.
|
||||
#
|
||||
# AND THE DISCOVERY ITSELF HAS A SEAM: the taint crosses a call boundary only
|
||||
# where the callee is a plain function this package defines and the argument is
|
||||
# passed positionally or by keyword. A roster handed through `*args`, stored in a
|
||||
# dict and read back out, or passed to a method on an object the analysis cannot
|
||||
# resolve, is lost at that hop - and everything downstream of the hop goes
|
||||
# UNENUMERATED rather than reported as unreachable. That is the shape of the
|
||||
# remaining exposure: not zero, and not measurable from inside this check.
|
||||
# ⚠️ AND WHAT THE ENUMERATION ACTUALLY FOUND WHEN EVERY SITE WAS MUTATED - THE
|
||||
# FINDING, RECORDED, BECAUSE THE LESSON IS THE MEASUREMENT AND NOT THE FIX.
|
||||
#
|
||||
# All 183 sites above were mutated to `[:1]` one at a time, +4 bytes each, and
|
||||
# each mutant was 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, which is TEST-15 closed
|
||||
# by construction rather than by discipline). **183 of 183 RED.**
|
||||
#
|
||||
# ⚠️ THAT NUMBER IS TAUTOLOGICAL ON ITS OWN AND MUST NOT BE QUOTED WITHOUT THE
|
||||
# SECOND RUN. With the check above in the tree, every slice is red BY THIS
|
||||
# CHECK. So all 51 mutants whose first red was `test_peakguard.py` were re-run
|
||||
# with this pin NEUTRALISED (`_SLICED` blanked), leaving only the behavioural
|
||||
# checks. The answer:
|
||||
#
|
||||
# 145 of 183 a BEHAVIOURAL fixture in this repository separates the site
|
||||
# from a slice of itself - it goes red with the pin turned off
|
||||
# 38 of 183 NOTHING but this pin sees it. Structurally closed as of this
|
||||
# card; behaviourally UNGRADED, and listed below rather than
|
||||
# counted as covered
|
||||
#
|
||||
# THE 38, BY MODULE: main.py 27, allocator.py 6, validate.py 2, arbiter.py 1,
|
||||
# circulation.py 1, maintenance.py 1. Grouped by what a `[:1]` there would cost:
|
||||
#
|
||||
# THE OPERATOR'S PAGE - 15 of them, and the largest group by far.
|
||||
# `Controller.checks` (3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370,
|
||||
# 3400, 3458) and `status_payload` (3556, 3566, 3578) build the rows and
|
||||
# the counts an operator reads. A slice reports ONE unit's row where two
|
||||
# exist, or a "1 of 2 live" that says 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 around the one place this add-on writes. The
|
||||
# write itself iterates `targets` at 1287 and IS graded (test_inverter);
|
||||
# these three are the arithmetic in its log line and its refusal count.
|
||||
# THE ALLOCATOR'S INTERNALS - `_fill`'s zero-weight last-resort pool (365),
|
||||
# `plan`'s `dropped` (526, 653), `free` (584) and `frac_order` (591).
|
||||
# ALLOC-01's fixtures drive 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 at 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.
|
||||
# AND `_grade_store_migration` (698), which round 4 already recorded as row 21
|
||||
# of the DECISIONS table and argued to be an equivalent mutant at that call
|
||||
# site. The argument is unchanged and is still an argument, not a proof.
|
||||
#
|
||||
# ⚠️ NONE OF THESE 38 IS CLOSED BEHAVIOURALLY BY THIS CARD, AND SAYING SO IS THE
|
||||
# POINT. Closing them one at a time is exactly 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 the list above is what a shape pin can never promise. The
|
||||
# honest summary is: 145 sites have a fixture that can see them, 38 have only a
|
||||
# structural claim, and every one of the 38 is named here with what it would
|
||||
# cost rather than left out of a count.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5134,25 +5580,24 @@ MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
|
||||
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,
|
||||
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"))
|
||||
"and 12j's ENUMERATION covers every other roster reduction in the "
|
||||
"PACKAGE 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"))
|
||||
MUT.append(("a reduction over a roster added ANYWHERE ELSE IN THE PACKAGE over "
|
||||
"a slice - which is how rounds 2, 3, 4 AND 5 each began",
|
||||
bool(_SITES) and not _SLICED,
|
||||
"part 12j: every roster reduction in EVERY module under `app/`, "
|
||||
"enumerated from the AST off one seed and required to take the "
|
||||
"roster whole. ⚠️ Round 4's version stopped at the `Controller` "
|
||||
"class and round 5's four blockers were one module over, in "
|
||||
"validate.py; this one enumerates "
|
||||
f"{len(_SITES)} sites across {len(_BY_MOD)} modules and "
|
||||
"rediscovers all four without being told where to look. 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}")
|
||||
|
||||
@@ -833,6 +833,339 @@ check("...and MaintConfig's dataclass defaults carry no numeric literal either "
|
||||
not re.search(r"^\s*(charge_w|discharge_w):\s*float\s*=\s*-?\d",
|
||||
_srcs["maintenance.py"], re.M))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("SPIKE-04a: inverter B's documented rating, and the provenance beside it")
|
||||
# ⚠️ WHY A DOCUMENTATION CARD LEFT A CHECK BEHIND. Inverter B's rating was
|
||||
# written down in THREE places - config.yaml's commented roster example,
|
||||
# DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator would
|
||||
# POST" - as 3000 W, a figure nobody had measured, read off a datasheet, or
|
||||
# sourced at all. It was wrong: SPIKE-04a (2026-09-04) identified inverter B
|
||||
# from the owner's model number and serial as a GoodWe GW5048D-ES rated 4600 W
|
||||
# on the battery path. Three transcriptions of one number drifting away from
|
||||
# the truth together is this repo's recurring defect in its plainest form, and
|
||||
# correcting them once WITHOUT PINNING THEM buys nothing: the next editor
|
||||
# touches one of the three and the other two stay wrong, silently.
|
||||
#
|
||||
# ⚠️ AND THE PROVENANCE IS GRADED, NOT ONLY THE VALUE, WHICH IS THE HALF THAT
|
||||
# MATTERS. 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT. The datasheet
|
||||
# prints it under BATTERY INPUT DATA, so it is probably DC, while `max_w` is
|
||||
# graded against 0x0518 - the inverter's own AC power - which would put the AC
|
||||
# ceiling nearer 4324 W. An editor who tidies that caveat away leaves a paper
|
||||
# number reading as an observation of hardware that does not exist yet, and
|
||||
# THAT is the failure this card exists to prevent. So the caveat is what these
|
||||
# checks reach for; a check that graded 4600 alone would watch the value and
|
||||
# miss the lie.
|
||||
#
|
||||
# ⚠️ WHAT THIS DELIBERATELY DOES NOT GRADE: the test fixtures. Several files
|
||||
# build a second inverter at 3000 W on purpose, because the property those
|
||||
# checks consume is that B's rating is UNMISTAKABLY not A's, and the real pair
|
||||
# (5000/4600) is a 400 W gap - narrower than `slew_w`. Those are illustrative,
|
||||
# they say so at the line, and they are not statements of B's rating. The three
|
||||
# sites below are the ones that ARE such statements.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_docs_txt = open(os.path.join(_HERE, "DOCS.md"), encoding="utf-8").read()
|
||||
_probe_txt = open(os.path.join(_HERE, "tools", "supervisor_shape_probe.py"),
|
||||
encoding="utf-8").read()
|
||||
|
||||
# One regex per site, each anchored on something only THAT site has, so a match
|
||||
# cannot silently wander into a neighbouring blob. The probe's anchor is the
|
||||
# operator blob's real entity name: its section D refusal entries use
|
||||
# `number.b` and one of them carries a deliberate 9999, so an anchor loose
|
||||
# enough to reach them would grade the wrong dict and still look green.
|
||||
_b_rating = {
|
||||
"config.yaml": re.search(
|
||||
r"#\s+- name: inverter-b\b(?:[^\n]*\n)*?\s*#\s+max_w:\s*(\d+)", _txt),
|
||||
"DOCS.md": re.search(
|
||||
r"^ - name: inverter-b\b(?:[^\n]*\n)*?^ max_w:\s*(\d+)",
|
||||
_docs_txt, re.M),
|
||||
"supervisor_shape_probe.py": re.search(
|
||||
r'"name": "inverter-b",\s*"setpoint_entity": "number\.goodwe_b_setpoint_w"'
|
||||
r'(?:[^}]*?)"max_w":\s*(\d+)', _probe_txt),
|
||||
}
|
||||
check(f"all three documented rosters were FOUND "
|
||||
f"{ {k: bool(v) for k, v in _b_rating.items()} } - a regex that matches "
|
||||
"nothing is a green check that read nothing",
|
||||
all(_b_rating.values()))
|
||||
# ⚠️ THE `len(...) == 3` IS NOT DECORATION. Without it a regex that stopped
|
||||
# matching would shrink this dict, and "all the values agree" would go GREEN on
|
||||
# the two sites that were still read - a check whose data no longer reaches the
|
||||
# failure it names, which is the exact fault this whole section is about.
|
||||
_b_vals = {k: int(m.group(1)) for k, m in _b_rating.items() if m}
|
||||
check(f"...and all three state the SAME rating for inverter B {_b_vals}",
|
||||
len(_b_vals) == 3 and len(set(_b_vals.values())) == 1)
|
||||
check(f"...and it is 4600 W - the GW5048D-ES datasheet figure, not the 3000 W "
|
||||
f"hypothesis that predated the unit being identified {_b_vals}",
|
||||
len(_b_vals) == 3 and set(_b_vals.values()) == {4600})
|
||||
|
||||
# The provenance triple. Each site must still say WHERE 4600 came from, that it
|
||||
# is graded against an AC register, and what the DC-vs-AC ambiguity does to it -
|
||||
# the last carried by the 4324 W figure, which is the one marker that cannot
|
||||
# survive a careless "it's 4600 now, done" rewrite.
|
||||
#
|
||||
# ⚠️ SEARCHED IN A WINDOW AROUND THE ROSTER, NOT OVER THE WHOLE FILE, AND THE
|
||||
# FIRST VERSION OF THIS CHECK DID THE LATTER. `0x0518` already appeared once in
|
||||
# config.yaml and once in DOCS.md before this card, for unrelated reasons - so a
|
||||
# whole-file `"0x0518" in src` was GREEN on those two files no matter what
|
||||
# happened next to inverter B's rating. That is a check whose data cannot reach
|
||||
# the failure it names, in the same file that exists to complain about them.
|
||||
# The window is the text either side of the roster match, so deleting the caveat
|
||||
# beside the number goes red even where the same word survives elsewhere.
|
||||
_sites = {"config.yaml": _txt, "DOCS.md": _docs_txt,
|
||||
"supervisor_shape_probe.py": _probe_txt}
|
||||
_MARKERS = ("datasheet", "0x0518", "4324")
|
||||
_prov = {}
|
||||
for _name, _src in _sites.items():
|
||||
_m = _b_rating[_name]
|
||||
_win = _src[max(0, _m.start() - 2500):_m.end() + 2500].lower() if _m else ""
|
||||
_prov[_name] = {k: k in _win for k in _MARKERS}
|
||||
for _name, _got in sorted(_prov.items()):
|
||||
check(f"{_name} still carries B's provenance WITHIN 2500 chars of the "
|
||||
f"roster - the datasheet it came from, the 0x0518 it is graded "
|
||||
f"against, and the 4324 W the DC reading would make it {_got}",
|
||||
all(_got.values()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("rules 5, 5b and 6 on a ROSTER SET - the smallest cap at EVERY position")
|
||||
# ⚠️ SAFETY-32 ROUND 5, AND THE REASON THIS SECTION EXISTS AT ALL. PR #48 was
|
||||
# rejected on FOUR MUTATIONS THAT SURVIVED A 19/19 GREEN SUITE, all of them in
|
||||
# the three lines above:
|
||||
#
|
||||
# validate.py:779 min(caps) -> min(caps[:1]) +4 B GREEN
|
||||
# validate.py:779 min(caps) -> min(caps[-1:]) +5 B GREEN
|
||||
# validate.py:778 caps = [cap for _, cap in caps_ix[:1]] +4 B GREEN
|
||||
# validate.py:893 min(caps_ix[-1:], key=lambda p: p[1]) +5 B GREEN
|
||||
#
|
||||
# NONE of them is an equivalent mutant: each one deletes a safety refusal.
|
||||
# `max_w: 5000` on inverter-a beside `2000` on inverter-b with
|
||||
# `sign_disagree_w: 3000` is refused by the shipped validator with "the
|
||||
# SAFETY-02 sign-disagreement watchdog can never fire" and ACCEPTED by every one
|
||||
# of the four - the site starts, with a watchdog that reports `armed` on the
|
||||
# status page and cannot trip on pack B. That is rule 5's own failure mode
|
||||
# reached THROUGH rule 5.
|
||||
#
|
||||
# WHY THE OLD FIXTURE COULD NOT SEE IT, which is the whole lesson and is now
|
||||
# MUTATION.md habit 10: the fixture was ONE roster, `[5000, 2000]`. On that
|
||||
# roster `min` and `last` and `[-1:]` and `[:2]` all answer 2000. A fixture of N
|
||||
# members cannot separate two reductions that agree on N members, and adding a
|
||||
# member moves the hole instead of closing it.
|
||||
#
|
||||
# SO THE FIXTURE IS A SET, NOT A ROSTER: the smallest cap at EVERY POSITION of
|
||||
# rosters of width 2, 3, 4 and 5. `[:1]` needs width 2 to be caught, `[:2]`
|
||||
# needs 3, `[:3]` needs 4 - the sweep is what terminates the regress that a
|
||||
# single fixture cannot. And the FAMILY TABLE below is COMPUTED AND PRINTED, so
|
||||
# a member no fixture kills shows up as a NAMED SURVIVOR rather than as an
|
||||
# absence from a list nobody re-derived.
|
||||
#
|
||||
# ⚠️ THE DIFFERING CAPS ARE A FIXTURE PROPERTY AND MUST STAY ONE. In production
|
||||
# both packs are expected to floor at 10 % and SPIKE-04a puts inverter B at
|
||||
# 4600 W against A's 5000 W - a 400 W gap, narrower than slew_w. A fixture built
|
||||
# on the real pair would be degenerate on exactly the axis these checks measure.
|
||||
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (SPIKE-04, ~2026-09-12). Nothing
|
||||
# in this section is measured off a second unit; the rosters are simulated and
|
||||
# are labelled so.
|
||||
_BIG, _SMALL = 5000.0, 2000.0
|
||||
_RATE = 3000.0 # strictly between them, which is the point
|
||||
|
||||
|
||||
def _roster_n(caps, **over):
|
||||
"""A roster of len(caps) units, unit-0..unit-N, each with its own cap."""
|
||||
o = base(**over)
|
||||
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
|
||||
o.pop(k, None)
|
||||
o["inverters"] = [
|
||||
dict(name=f"unit-{i}", setpoint_entity=f"number.sp{i}",
|
||||
soc_entity=f"sensor.soc{i}", batt_entity=f"sensor.batt{i}",
|
||||
max_w=c, bms_soc_floor=10)
|
||||
for i, c in enumerate(caps)]
|
||||
o.update(over)
|
||||
return o
|
||||
|
||||
|
||||
# The three rules that reduce over `unit_caps_indexed()`, each as
|
||||
# (option name, the token its refusal is recognised by).
|
||||
_CAP_RULES = (
|
||||
("sign_disagree_w", "SAFETY-02 sign-disagreement"),
|
||||
("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"),
|
||||
("maintenance_charge_w", "maintenance charge in a cheap window"),
|
||||
)
|
||||
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
|
||||
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
|
||||
|
||||
# THE SET: width x position of the one small cap.
|
||||
_SET = []
|
||||
for _n in (2, 3, 4, 5):
|
||||
for _pos in range(_n):
|
||||
_caps = [_BIG] * _n
|
||||
_caps[_pos] = _SMALL
|
||||
_SET.append((_n, _pos, tuple(_caps)))
|
||||
|
||||
# habit 1: the PRECONDITION as its own check. If a later edit makes the caps
|
||||
# equal, this goes red rather than the claims below going quietly vacuous.
|
||||
check("PRECONDITION: every fixture in the set has a UNIQUE smallest cap, that "
|
||||
"cap is at the stated position, and the rate under test sits STRICTLY "
|
||||
"between it and the others - two equal caps could not tell `min` from "
|
||||
"`first` from `last` and the whole set would be measuring nothing",
|
||||
len(_SET) == 14
|
||||
and all(c[p] == _SMALL and c.count(_SMALL) == 1 and _SMALL < _RATE < _BIG
|
||||
for _n2, p, c in _SET)
|
||||
and {p for _n2, p, _c in _SET} == {0, 1, 2, 3, 4})
|
||||
|
||||
|
||||
def _verdict(opts, token):
|
||||
"""(refused?, the unit the message NAMES, the cap it QUOTES)."""
|
||||
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
|
||||
if not ps:
|
||||
return (False, None, None)
|
||||
m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0])
|
||||
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
|
||||
|
||||
|
||||
# --- the shipped answer, on every member of the set -------------------------
|
||||
_rows = []
|
||||
for _opt, _token in _CAP_RULES:
|
||||
for _n, _pos, _caps in _SET:
|
||||
_o = _roster_n(list(_caps), **dict(_QUIET, **{_opt: _RATE}))
|
||||
_rows.append((_opt, _n, _pos, _caps, _verdict(_o, _token)))
|
||||
print(f" {len(_CAP_RULES)} rules x {len(_SET)} rosters = {len(_rows)} runs "
|
||||
f"of the REAL validator (rate {_RATE:.0f} W, caps {_SMALL:.0f}/"
|
||||
f"{_BIG:.0f} W, simulated rosters - inverter B does not exist):")
|
||||
for _opt in sorted({r[0] for r in _rows}):
|
||||
_got = [(r[1], r[2], r[4]) for r in _rows if r[0] == _opt]
|
||||
print(f" {_opt:<22} " + " ".join(
|
||||
f"n{n}p{p}:{'REFUSE ' + str(u) if ok else 'starts'}"
|
||||
for n, p, (ok, u, _c) in _got))
|
||||
|
||||
check("⚠️ EVERY ONE OF THE THREE CAP RULES REFUSES ON EVERY MEMBER OF THE SET, "
|
||||
"and NAMES THE UNIT THE NUMBER CAME OFF - at width 2, 3, 4 and 5, with "
|
||||
"the small cap at every position in turn. A reduction that answered "
|
||||
"`first`, `last`, `[:1]`, `[-1:]`, `[:2]` or `[:-1]` instead of `min` "
|
||||
"misses at least one member of this set, which is what four hand-run "
|
||||
"mutations against a 19/19 green suite proved the single `[5000, 2000]` "
|
||||
"fixture could not do",
|
||||
all(ok and unit == f"unit-{p}" and cap == _SMALL
|
||||
for _o2, _n2, p, _c2, (ok, unit, cap) in _rows))
|
||||
|
||||
check("...and it is a COMPARISON, not a blanket refusal of rosters: the same "
|
||||
"three rates against a roster whose caps can ALL take them start "
|
||||
"cleanly, at every width. Without this the line above is satisfied by a "
|
||||
"validator that refuses everything (habit 2 in the negative direction)",
|
||||
all(_verdict(_roster_n([_BIG] * n, **dict(_QUIET, **{opt: _RATE})),
|
||||
tok) == (False, None, None)
|
||||
for opt, tok in _CAP_RULES for n in (1, 2, 3, 4, 5)))
|
||||
|
||||
# --- the POSITIONAL-SLICE FAMILY TABLE, computed and printed ----------------
|
||||
# ⚠️ COMPUTED, NOT LISTED. Each candidate is applied to the SAME caps the
|
||||
# validator saw, and its (unit, cap) answer is compared against what the shipped
|
||||
# validator actually said on that roster. A candidate that agrees everywhere is
|
||||
# a reduction this fixture set CANNOT distinguish from the shipped one, and it
|
||||
# is printed as a SURVIVOR rather than left out of a table nobody re-derived.
|
||||
def _pick(caps, fn):
|
||||
ix = list(enumerate(caps))
|
||||
try:
|
||||
i, c = fn(ix)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return (f"unit-{i}", c)
|
||||
|
||||
|
||||
_FAMILY = {
|
||||
"min (SHIPPED)": lambda ix: min(ix, key=lambda p: p[1]),
|
||||
"max": lambda ix: max(ix, key=lambda p: p[1]),
|
||||
"first": lambda ix: ix[0],
|
||||
"last": lambda ix: ix[-1],
|
||||
"min(caps[:1])": lambda ix: min(ix[:1], key=lambda p: p[1]),
|
||||
"min(caps[-1:])": lambda ix: min(ix[-1:], key=lambda p: p[1]),
|
||||
"min(caps[:2])": lambda ix: min(ix[:2], key=lambda p: p[1]),
|
||||
"min(caps[:3])": lambda ix: min(ix[:3], key=lambda p: p[1]),
|
||||
"min(caps[1:])": lambda ix: min(ix[1:], key=lambda p: p[1]),
|
||||
"min(caps[:-1])": lambda ix: min(ix[:-1], key=lambda p: p[1]),
|
||||
"min(caps[::2])": lambda ix: min(ix[::2], key=lambda p: p[1]),
|
||||
"mean": lambda ix: (0, sum(c for _i, c in ix) / len(ix)),
|
||||
}
|
||||
_survivors = []
|
||||
print(" the family table - each candidate against what the SHIPPED "
|
||||
"validator answered on the same roster:")
|
||||
for _name, _fn in _FAMILY.items():
|
||||
_misses = []
|
||||
for _opt, _n, _pos, _caps, (_ok, _unit, _cap) in _rows:
|
||||
_pred = _pick(list(_caps), _fn)
|
||||
_pred_refuses = _pred is not None and _pred[1] > 0 and _RATE >= _pred[1]
|
||||
if _opt == "maintenance_charge_w":
|
||||
_pred_refuses = (_pred is not None and _pred[1] > 0
|
||||
and _RATE > _pred[1])
|
||||
_said = ((True, _pred[0], _pred[1]) if _pred_refuses
|
||||
else (False, None, None))
|
||||
if _said != (_ok, _unit, _cap):
|
||||
_misses.append(f"{_opt[:4]}/n{_n}p{_pos}")
|
||||
if not _misses and _name != "min (SHIPPED)":
|
||||
_survivors.append(_name)
|
||||
print(f" {_name:<16} {'AGREES EVERYWHERE (survivor)' if not _misses else 'differs on ' + str(len(_misses)) + ' run(s), first: ' + _misses[0]}")
|
||||
check("⚠️ AND THE FAMILY TABLE HAS NO SURVIVOR BUT THE SHIPPED REDUCTION. "
|
||||
"Eleven candidates - max, first, last, the mean, and every positional "
|
||||
"slice this card has actually been defeated by - are each computed "
|
||||
"against what the real validator answered on the same roster, and every "
|
||||
"one of them differs somewhere in the set. This is the check that would "
|
||||
"have taken PR #48 red four rounds ago: a member no fixture kills is "
|
||||
"PRINTED AS A NAMED SURVIVOR here instead of being absent from a list "
|
||||
f"nobody re-derived. survivors: {_survivors}",
|
||||
_survivors == [])
|
||||
|
||||
# --- the ROSTER OF ONE, where every one of them is the same thing -----------
|
||||
_at_one = {n: _pick([_SMALL], f) for n, f in _FAMILY.items()}
|
||||
print(f" at a ROSTER OF ONE the twelve candidates answer: "
|
||||
f"{sorted({str(v) for v in _at_one.values()})}")
|
||||
check("...and at a ROSTER OF ONE every candidate that still has a member to "
|
||||
"reduce answers THE SAME THING - which is exactly why no "
|
||||
"single-inverter fixture in this repository could ever have caught any "
|
||||
"of this, and why the shipped single-inverter refusals are what they "
|
||||
"always were. The only two that do NOT coincide are `caps[1:]` and "
|
||||
"`caps[:-1]`, which EMPTY the list and would raise: at N=1 they are "
|
||||
"crashes rather than silent wrong answers, so they are not survivors "
|
||||
"there either",
|
||||
{v for v in _at_one.values() if v is not None} == {("unit-0", _SMALL)}
|
||||
and {n for n, v in _at_one.items() if v is None}
|
||||
== {"min(caps[1:])", "min(caps[:-1])"}
|
||||
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
|
||||
"SAFETY-02 sign-disagreement")[0])
|
||||
|
||||
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
|
||||
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
|
||||
# PIN - and said that way on purpose, because it is the shape of what the pin
|
||||
# cannot see. `unit_sign_disagree_w` is SAFETY-30's, not this card's. It was
|
||||
# graded against `_number(opts, "max_w", 2000.0)`, the FLAT option, three lines
|
||||
# under a branch that had already stopped doing that. Both directions were
|
||||
# measured before the fix and both are asserted here.
|
||||
_leftover = _roster_n([_SMALL, _SMALL],
|
||||
**dict(_QUIET, unit_sign_disagree_w=_RATE))
|
||||
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
|
||||
_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement")
|
||||
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
|
||||
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
|
||||
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
|
||||
"WATCHDOG SILENTLY. Two 2000 W units beside a `max_w: 5000` left over "
|
||||
"from a single-inverter install and `unit_sign_disagree_w: 3000` used to "
|
||||
"produce ZERO problems - the site started, the status page said `armed`, "
|
||||
"and the watchdog could not trip on EITHER unit. It is refused now, "
|
||||
"against the weakest unit's own cap, and the message names that unit",
|
||||
_lv == (True, "unit-0", _SMALL))
|
||||
check("...and the over-refusal in the OTHER direction is gone too: a roster of "
|
||||
"two 5000 W units with NO flat `max_w` was refused at 3000 W against a "
|
||||
"2000 W DEFAULT the operator never wrote, for a threshold both units can "
|
||||
"reach. It starts",
|
||||
_verdict(_roster_n([_BIG, _BIG],
|
||||
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
|
||||
"SAFETY-30 per-inverter sign-disagreement")
|
||||
== (False, None, None))
|
||||
check("...and the flat single-inverter config it always refused is refused "
|
||||
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
|
||||
"the same comparison, the same boundary and the same sentence",
|
||||
why(validate_startup_config(base(max_w=2000, unit_sign_disagree_w=2000),
|
||||
-5000, 5000), "unit_sign_disagree_w")
|
||||
and validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
|
||||
-5000, 5000) == [])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -798,9 +798,18 @@ print(" circulation the allocator NEVER COMMANDED")
|
||||
|
||||
from app.inverter import Inverter # noqa: E402
|
||||
|
||||
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~10 days out and SPIKE-04
|
||||
# measures it. Its rating here is a HYPOTHESIS, named as one, exactly as
|
||||
# test_peakguard.py's helper names its own.
|
||||
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~8 days out (2026-09-04) and
|
||||
# the bench half of SPIKE-04 measures it.
|
||||
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
|
||||
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
|
||||
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
|
||||
# kept because the only property these checks consume is that B's number is
|
||||
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
|
||||
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
|
||||
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
|
||||
# onto the real values would cost these checks their separating power to buy
|
||||
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
|
||||
# placeholder for it either - the placeholder era ended on 2026-09-04.
|
||||
B_RATING_HYPOTHESIS = 3000.0
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,15 @@ DECISIONS/CFG-04-inverter-roster.md:
|
||||
per-entry key. That change broke §C and, worse, left §D printing PASS on a
|
||||
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
|
||||
now carries the key and §D is an isolated counterfactual asserting the key name.
|
||||
|
||||
⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's
|
||||
rebase onto SPIKE-04a merged two independent edits into §C and §D - the required
|
||||
`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched
|
||||
together by hand from two runs is not a measurement, so this one was measured:
|
||||
`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a
|
||||
reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it
|
||||
differs from A's 10 so that the per-entry number is exercised at all, while
|
||||
production is expected to have BOTH packs at 10 %.
|
||||
"""
|
||||
import sys, json, copy
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
@@ -152,22 +161,47 @@ show("the same blob MERGED with these defaults - what App.options actually is",
|
||||
f"max_w={out.get('max_w')!r}" if ok else out)
|
||||
|
||||
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
|
||||
# ⚠️ SAFETY-32 ADDED `bms_soc_floor` AS A REQUIRED PER-ENTRY KEY, and this blob
|
||||
# is what proves the operator can still POST a two-inverter config. The TWO
|
||||
# VALUES DIFFER ON PURPOSE (10 and 15): equal values would not exercise a
|
||||
# per-entry number at all, and this file's own §B is the record of what an
|
||||
# instrument that cannot distinguish its two cases is worth.
|
||||
# ⚠️ NEITHER IS A MEASUREMENT OF INVERTER B. Only inverter A's 10 % is read off
|
||||
# hardware (register 0x0560 on the reference GW5000S-BP); there is no second
|
||||
# pack on this site, so B's 3000 W and its 15 % are BOTH placeholders in a
|
||||
# schema-shape probe and must not be quoted as anything else.
|
||||
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
|
||||
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
|
||||
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
|
||||
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
|
||||
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
|
||||
# nameplate is wrong for this purpose too, because nothing will be connected to
|
||||
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
|
||||
#
|
||||
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
|
||||
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
|
||||
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
|
||||
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
|
||||
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
|
||||
# read off the unit. So this blob is what an operator would POST TODAY, not a
|
||||
# claim about what inverter B will turn out to do.
|
||||
#
|
||||
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
|
||||
# This file's own §B is the record of what an instrument that cannot distinguish
|
||||
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
|
||||
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
|
||||
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
|
||||
# pair instead of the real one.
|
||||
#
|
||||
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
|
||||
# blob is also what proves an operator can still POST a two-inverter config
|
||||
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
|
||||
# not exercise a per-entry number at all, for the same reason the two ratings
|
||||
# must stay different.
|
||||
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
|
||||
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
|
||||
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
|
||||
# - there is no second pack on this site, and in production BOTH packs are
|
||||
# expected to floor at 10 %, which is why the differing pair lives here and in
|
||||
# the fixtures rather than in config.yaml's example.
|
||||
TWO = dict(LIVE, setpoint_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
||||
"max_w": 5000, "bms_soc_floor": 10},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
|
||||
"max_w": 3000, "bms_soc_floor": 15}])
|
||||
"max_w": 4600, "bms_soc_floor": 15}])
|
||||
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
||||
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
|
||||
# The validated blob, for anyone who wants to POST it. Optional: the probe's
|
||||
@@ -186,7 +220,7 @@ print("\n== D. what the schema itself refuses, per entry ==")
|
||||
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
|
||||
# anything, so the baseline below is COMPLETE and each case deletes exactly one
|
||||
# key - or, for the range case, moves exactly one value out of range.
|
||||
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 3000,
|
||||
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600,
|
||||
"bms_soc_floor": 10}
|
||||
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
|
||||
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
|
||||
|
||||
Reference in New Issue
Block a user