Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19decf433f | ||
|
|
8beba0a3e7 | ||
|
|
a4c95f83ce | ||
|
|
ffbabea8ce | ||
|
|
f9e7fbf86d | ||
|
|
67e649ad6b | ||
|
|
a9993aeda2 | ||
|
|
3934ad7ea5 | ||
|
|
905b6fd2b3 | ||
|
|
65cddf35f1 | ||
|
|
1aef19e995 | ||
|
|
2d025eebf7 |
@@ -2,6 +2,461 @@
|
||||
|
||||
## 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
|
||||
one un-namespaced history, so at two packs **only the first was ever
|
||||
recalibrated** and the second's counter drifted forever — and nothing prevented
|
||||
the cycle draining pack A while the loop or arbitrage charged pack B, which is
|
||||
two power-moving phases on one grid connection pulling against each other.
|
||||
|
||||
* **ONE `Maintenance` PER ROSTER ENTRY**, each with its own schedule, phase and
|
||||
history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
|
||||
existing install's flat history into the first pack's namespace under
|
||||
`migrate_unit_keys()`'s three rules — shared in code, not restated — with the
|
||||
same read-back postcondition, and the flat keys are kept **live** as the
|
||||
rollback view so a downgrade finds a current history rather than one dated
|
||||
migration day.
|
||||
* **ONE MAINTENANCE SLOT PER SITE.** At most one pack may be in a power-moving
|
||||
phase (`drain`, `charge` **and** `hold` — `charge` is in the set because a
|
||||
pack pulled full from the grid beside a pack exporting to empty is 4 kW
|
||||
circulating with the meter at zero). The holder is **derived** from the packs'
|
||||
own stored phases, not persisted, so a restart mid-drain re-derives it. When
|
||||
several packs are due in the same hour the slot goes to the one that has gone
|
||||
**longest without a completed cycle**; roster order breaks a numeric tie and
|
||||
nothing else. Nothing preempts a cycle in flight.
|
||||
* **THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT.** A deferral does not
|
||||
stamp `last_start_attempt` (that is the 90 min post-abort cooldown, inside a
|
||||
60 min start hour), and does not move the phase, so neither the 12 h `drain`
|
||||
nor the 36 h `charge` timeout — *"the battery is not following commands"* —
|
||||
can be reached by waiting. It says WAITING, names the pack it waits for, and
|
||||
recalibrates at the next start hour; a test drives the winner's whole cycle
|
||||
and observes it start.
|
||||
* **THE SCHEDULE IS NOT STAGGERED BY CONSTRUCTION.** The interlock defers and
|
||||
the stagger is what comes out of it: one contested month separates two packs
|
||||
by one cycle length and they stay separated. `DECISIONS/MAINT-05-per-pack-maintenance.md`
|
||||
§3 carries the argument.
|
||||
* **`validate.py` rule 6 now grades `maintenance_charge_w` against the WEAKEST
|
||||
cap on the roster**, like rule 5. Its own comment said it would have to move
|
||||
the day `maint_unit` stopped being `inverters[0]`; that day is this card. It
|
||||
refuses configurations that used to start — a 3000 W charge beside a 2000 W
|
||||
pack that is now recalibrated too — which is the `lo > hi` contradiction rule
|
||||
6 exists to refuse, caught when the config is written. Identical at N=1.
|
||||
* **AT A ROSTER OF ONE, NOTHING CHANGES**, and it is asserted rather than
|
||||
assumed: the state file keeps the flat keys and gains none, the migration does
|
||||
not run, the four cycle events are the shipped strings character for
|
||||
character, and the status banner is the bare phase with no pack name.
|
||||
* **THE MIGRATION IS GRADED AGAINST THE ROSTER, NOT AGAINST THE NAME IT WAS
|
||||
HANDED** (fix round). `migrate_maint_keys()` takes the roster rather than a
|
||||
pack count and `_verify_moves()` checks the namespace it wrote against the
|
||||
names the machines actually read. A postcondition that reads back the
|
||||
namespace it was given cannot detect being given the wrong one: handed
|
||||
`inverter.UNIT_A` on a roster of `["garage", "shed"]`, the first version of
|
||||
this card wrote all four keys under `unit/inverter-a/...`, read all four back
|
||||
equal, reported **verified**, and stamped `maint_schema_version` — so both
|
||||
packs came up believing they had never been recalibrated and the next start
|
||||
skipped the migration too. That is now `misdirected`: not `ok`, the marker is
|
||||
**not** written, and the next start re-runs and recovers (rule 1 makes the
|
||||
re-run a no-op). `build_roster()` numbers a nameless roster entry
|
||||
`inverter-1`, so `inverter-a` is not even the fallback for a declared roster.
|
||||
Every two-pack fixture that could see this now names its first pack
|
||||
`garage`; with them all called `inverter-a`, `self.inverters[0].name` and
|
||||
`UNIT_A` were the same string and the swap left all 19 suites green.
|
||||
* **AND THE REFUSAL NOW SURVIVES THE NEXT START** (second fix round). The fix
|
||||
above graded only the keys it had just *written*, so it refused exactly once:
|
||||
measured at Controller level over one persisted store, roster
|
||||
`["garage", "shed"]`, with the call site still wrong on both starts —
|
||||
construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=<absent>`,
|
||||
construction 2 `ok=True misdirected=0 moved=0 kept=4` and the marker
|
||||
**stamped**. On the second start rule 1 finds the wrong keys already there and
|
||||
routes all four to `kept`, so `moved` is empty and the roster loop grades
|
||||
nothing — and start 3 skips the migration forever, *including after the call
|
||||
site is fixed*. Loud-and-recoverable became silent-and-permanent after one
|
||||
restart. `_verify_moves()` now grades `set(moved) | set(kept)`; both lists are
|
||||
built from the same `unit` argument, so grading `kept` grades the same
|
||||
namespace decision. `test_maintenance.py` part **9c** drives two starts with
|
||||
the same wrong unit over one store — the shape nothing in the suite had, since
|
||||
9b measured recovery along the path where the operator has *already* fixed the
|
||||
call site.
|
||||
* **AND THE MARKER GATE IS PINNED ON BOTH GUARDS.** `if rep.ok:` is
|
||||
`not lost and not misdirected`, and the mutation `if rep.ok or rep.kept:`
|
||||
stamped the marker on a migration that **lost a month of history** with all 19
|
||||
suites green. Part **9d** is the fixture that separates the two guards: two
|
||||
keys already namespaced (`kept` non-empty) and two whose write the store
|
||||
drops (`lost` non-empty), `misdirected` empty throughout.
|
||||
* ⚠️ **AND THE SAME MUTATION ONE FUNCTION UP WAS A SURVIVOR, FOUND HERE AND
|
||||
CLOSED.** `migrate_unit_keys()` — SAFETY-02's **latch** migration — gates its
|
||||
own marker on `rep.ok` too, and every check that reached that gate ran on a
|
||||
store where nothing was namespaced yet, so `rep.kept` was empty in all of
|
||||
them. `if rep.ok or rep.kept:` there stamps `schema_version` on a migration
|
||||
that **dropped the latch**: the guard is off, the file still says
|
||||
`sign_tripped: true`, and the next start skips the migration that would have
|
||||
re-armed it. Measured a survivor of all 19 files; now red on
|
||||
`test_inverter.py` part 10, which drops the `sign_tripped` write beside an
|
||||
already-namespaced `sign_reason`. No source change — the shipped gate was
|
||||
right; nothing could see it.
|
||||
* ⚠️ **FOUND, RECORDED, NOT FIXED (SECOND ONE).** `migrate_unit_keys()` —
|
||||
SAFETY-02's latch migration — has the identical hole and it costs a **guard**
|
||||
rather than a history. It runs before `build_roster()` (each `Inverter`
|
||||
restores its latch in its constructor), so it is handed the constant `UNIT_A`
|
||||
and there is no roster to grade against. On a declared roster a pre-DEV-02
|
||||
`sign_tripped: true` lands under `unit/inverter-a/sign_tripped`, which no unit
|
||||
reads — and `_grade_store_migration()`, whose job is to re-arm exactly this,
|
||||
is **defeated by the key the defect wrote**: its "is anything namespaced
|
||||
latched" test is satisfied by the misdirected value itself. Fixing it means
|
||||
naming the migration's target from the config before any `Inverter` exists,
|
||||
which is `inverter.py` and SAFETY-02's semantics, with its own battery.
|
||||
Measured and pinned as a running check (`test_maintenance.py` part 13), whose
|
||||
assertions state the WRONG behaviour on purpose.
|
||||
* ⚠️ **FOUND, RECORDED, NOT FIXED.** A maintenance drain **pins** its pack while
|
||||
the loop's target is a **site** claim, so when the site target is below the
|
||||
pin the only split asks the sibling to charge against the drain and
|
||||
`allocator.plan()` refuses it — the site parks at 0 W and the pack's 12 h
|
||||
timeout would eventually report *"drain stuck"* about a healthy battery.
|
||||
Pre-existing on `release/1.0` at `2d025ee` with the unit names swapped; fixing
|
||||
it means changing what the loop's target means while a unit is pinned, which
|
||||
is loop and allocator territory. Pinned as a running check
|
||||
(`test_maintenance.py` part 12) so it cannot be forgotten.
|
||||
|
||||
**`bms_soc_floor` describes a BATTERY, so at two packs it stops being one number
|
||||
(SAFETY-32).** The peak guard's energy backstop rests on two figures: the EMS
|
||||
floor `peak_soc_floor` (the depth a money-saving discharge may never reach) and
|
||||
`bms_soc_floor`, the percent **that pack's own BMS** cuts out at — register
|
||||
`0x0560`, read as 10 on the reference GW5000S-BP. Rule 8 refuses an EMS floor at
|
||||
or below the cut-out, because such a floor can never bind: the BMS wins and the
|
||||
status page reports a backstop that does not exist. With ONE cut-out for two
|
||||
packs of different models that rule graded the EMS floor against whichever
|
||||
battery happened to be written into the flat key, and a site with cut-outs of
|
||||
10 % and 30 % under `peak_soc_floor: 20` **started** — the guard would then
|
||||
command a forced discharge that pack B's BMS, not the operator's floor, is the
|
||||
thing that stops. Rule 8's own failure mode, reached through rule 8.
|
||||
|
||||
* **`bms_soc_floor` becomes a per-entry roster key, and it is REQUIRED there.**
|
||||
Nothing is inherited: an entry that omits it has no cut-out, does not borrow
|
||||
the flat one, and is refused by name at startup. A per-entry *default* was
|
||||
considered and declined — a default is one number for every pack, which is the
|
||||
defect this card removes; `DECISIONS/SAFETY-32-per-pack-bms-floor.md` records
|
||||
the decision and its migration cost. The flat key is unchanged and is now
|
||||
documented as the single-inverter shape's own cut-out.
|
||||
* **Rule 8 grades EVERY unit and NAMES the one it refuses**, through a new
|
||||
`unit_bms_floors_indexed()` that carries the **roster** index the value came
|
||||
off. CFG-04 had just fixed a rule 5/6 bug of exactly this shape — a caps-list
|
||||
index fed to a function that indexes the roster, so the message named the wrong
|
||||
inverter — and this reader skips nothing, so there is no second list for an
|
||||
index to desynchronise from. Graded on a roster whose offender is the SECOND
|
||||
entry *and* on the same two numbers in the other order.
|
||||
* **The permission is answered PER PACK.** `Controller.pack_floor_soc` — the
|
||||
emptiest measured pack — is untouched and is still what `decide()` grades;
|
||||
what changed is the floor. `peakguard.pack_permissions()` answers one
|
||||
`(name, reason)` per pack against that pack's own floor, and the floor-limited
|
||||
notification now names the pack that stopped the site rather than "the
|
||||
battery". At a roster of one the alarm is byte-for-byte what SAFETY-25 shipped.
|
||||
* **An unstated cut-out withholds rather than defaults.** `None`, not `0.0` —
|
||||
`discharge_permitted()` already treats an unknown floor as no permission, so
|
||||
the absence costs a shave and never a pack, including on `sim/inject.py`'s path
|
||||
which does not run the startup gate.
|
||||
* ⚠️ **Stated rather than hidden:** under any config rule 8 accepts, the per-pack
|
||||
*floor* reduces to `peak_soc_floor` for every pack — rule 8 makes it so. The
|
||||
reduction is written for `unit_caps_indexed()`'s reason ("unreachable" is a
|
||||
claim about rule ordering, not a property of a function) and is graded on both
|
||||
sides: 1775 rule-8-valid `(peak_soc_floor, bms_soc_floor)` pairs answer the
|
||||
option, and the pair rule 8 refuses answers the cut-out. `peak_soc_floor`
|
||||
itself is still one number for the site, so the "deeper pack needlessly
|
||||
withheld" half is filed as an open question, not closed.
|
||||
* **`tools/supervisor_shape_probe.py` re-run and repaired.** MUTATION.md habit 8:
|
||||
a committed transcript is a check and it decays. The required key broke §C and
|
||||
left §D printing PASS on a refusal about a *different* key. §D is now an
|
||||
isolated counterfactual over an accepted, merged baseline, one key varied per
|
||||
case, asserting the key **name** in the refusal — `all cases passed` on
|
||||
`amd64-hassio-supervisor:2026.08.0`.
|
||||
* ⚠️ **SECOND FIX ROUND — the round-2 fix carried the defect it was written to
|
||||
close, in its prose.** Two more rewrites survived a green 19-file suite on
|
||||
`8501e05`: `binding_soc_floor()`'s `max(floors)` → `max(floors[-2:])` (+5
|
||||
bytes) and the call site's `self.packs` → `self.packs[:2]` (+4 bytes). Both are
|
||||
checks whose TEXT claimed more than their FIXTURE could reach — 12c2's only
|
||||
counterfactual varied the **last** pack, which every window ending at the
|
||||
roster's end follows, and all three 12g runs drove a **two**-pack roster, where
|
||||
`[:2]` *is* the roster. **The general law, now stated in the file: a fixture of
|
||||
N packs cannot separate two reductions that agree on N packs**, and the regress
|
||||
does not terminate in a fixture — `[:1]` needed two packs, `[:2]` three, `[:3]`
|
||||
four. So the reduction is now graded by a **length sweep** (78 simulated
|
||||
rosters, lengths 1–12, deepest cut-out at every position, closing head and tail
|
||||
windows of width 1–11 at once) plus a **computed family table** of fourteen
|
||||
rewrites that goes red naming any member no roster kills; the call site is
|
||||
graded by **three-pack runs** with the deep cut-out at index 0, 1 and 2, plus
|
||||
an **exact structural pin** (the roster argument must unparse to `self.packs`)
|
||||
for the slice widths no bounded roster can reach. Round 3's own battery found
|
||||
`self.packs[:3]` / `[-3:]` surviving all six behavioural runs — the structural
|
||||
pin is what closes them — and one row is recorded **NOT CAUGHT**: a reduction
|
||||
over a window twelve packs wide, which needs a 13-pack roster to observe.
|
||||
No shipped behaviour changed in this round; only what observes it.
|
||||
* **Twelve source mutations, eleven red, one argued survivor**, through
|
||||
`tools/mutation_run.sh` (bytecode purged). The battery also found a real
|
||||
weakness of its own: `_flat_bms_floor()` had the default in two places and each
|
||||
covered for the other, so deleting either survived — masking, in three lines of
|
||||
option reading. One defaulting line now.
|
||||
* ⚠️ **FIX ROUND — that battery was INCOMPLETE, and the review found what it
|
||||
missed.** Neither of the two mutations below appears in the twelve rows above:
|
||||
the ledger graded "the reduction dropped" and "the bare option handed over"
|
||||
and never asked what happens when the reduction is simply the *wrong* one, or
|
||||
when the roster handed to it is *sliced*. No shipped behaviour changed here;
|
||||
two *checks* whose data could not reach the failure they named did. `max(floors)` → `min(floors)` in
|
||||
`binding_soc_floor()` (zero byte delta) and `self.packs` → `self.packs[:1]` at
|
||||
the `decide()` call site (+4 bytes) were each green across all 19 files. Both
|
||||
survived for the same reason: **under any rule-8-valid config every pack's
|
||||
`pack_floor_pct()` equals `peak_soc_floor`**, so `floors` is a list of
|
||||
identical numbers and `max`, `min`, first and last are four spellings of one
|
||||
answer — and the one check using a cut-out above the EMS floor ran on a roster
|
||||
of ONE, where every reduction agrees. The second also walked through part 12g,
|
||||
which asserted `"self.packs" in <the unparsed call site>`: a slice still
|
||||
contains that substring, so the pin passed on the mutation that grades the
|
||||
whole site against pack A. MUTATION.md habit 4 — a source-text test is a check
|
||||
on how code is spelled, not on what it does.
|
||||
* **New part 12c2** evaluates `binding_soc_floor()` on a roster whose per-pack
|
||||
floors **all differ** — four simulated cut-outs of 30/25/40/35 %, every one
|
||||
above `peak_soc_floor`, chosen so that `max` (40), `min` (25), first (30),
|
||||
last (35) and the mean (32.5) are five different numbers and the two extremes
|
||||
sit at *interior* roster positions. Two packs cannot separate those four;
|
||||
four is the smallest roster that can. The precondition is its own check
|
||||
(habit 1) and one pack — the last, neither deepest nor shallowest — is then
|
||||
raised past the old maximum and the answer follows it (habit 7).
|
||||
* **Part 12g is now behaviour, not source text.** Real `Controller.cycle()`s on
|
||||
a two-pack roster, both packs at the same SoC, with the cut-outs as the only
|
||||
thing varied: move the **non-first** pack's cut-out and the floor reaching
|
||||
`decide()` moves 20 % → 35 %, with the site's verdict flipping from a sized
|
||||
discharge to FLOOR-LIMITED. `self.packs[:1]` answers 20 % both times. Which
|
||||
option key the floor comes from stays pinned on the source where it already
|
||||
was, because that claim genuinely is about the code's shape.
|
||||
* ⚠️ **Two MORE survivors, found by this round's own battery and recorded
|
||||
rather than quietly repaired.** (a) The first cut of part 12g varied only
|
||||
pack B's cut-out, so `self.packs[-1:]` — grade the site against the **last**
|
||||
pack — agreed with the full roster on both runs and stayed green across all
|
||||
19 files (+5 bytes). A two-run fixture pins one slice and not the other.
|
||||
Part 12g now runs the **same pair of cut-outs in both orders**, so the
|
||||
deeper one is on the second pack in one run and on the first in another and
|
||||
the site floor is 35 % in both; no single-position slice passes all three
|
||||
runs. (b) `binding_soc_floor()`'s empty-roster branch rewritten to
|
||||
`return None` (−43 bytes) was also green — a defensive branch nothing in the
|
||||
suite reached. Both directions of it are pinned now, with the note that it
|
||||
cannot permit anything either way because `pack_floor_soc` is `None` at an
|
||||
empty roster.
|
||||
|
||||
* ⚠️ **THIRD FIX ROUND — the same family was alive at the SIBLING ARGUMENT of
|
||||
the same call.** Rounds 2 and 3 hardened the FLOOR axis; `decide()` is handed
|
||||
a second reduction over the same roster beside it, `soc=self.pack_floor_soc`,
|
||||
and `peakguard.py`'s own docstring names the two as a pair. Three rewrites of
|
||||
`Controller.pack_floor_soc` were green across all 19 files on `52380c0` and
|
||||
again after the rebase onto `3934ad7`: `min(float(v) for v in vals)` →
|
||||
`vals[:2]` (+4), `vals[-2:]` (+5) and `vals[:3]` (+4), while `[:1]`, `[-1:]`,
|
||||
`[1:]`, `[:-1]`, `max()` and the mean were all red. **The suite's separating
|
||||
power on the SoC axis stopped at exactly N=2** — the state the floor axis was
|
||||
in before round 3. It changes a real answer: a three-pack roster at
|
||||
50 / 60 / 11 % with the emptiest pack THIRD answers 11 % shipped and 50 %
|
||||
mutated, so the site is graded for discharge PERMISSION against a pack 39
|
||||
points fuller than the emptiest and spends a pack its own BMS stops. Round 3's
|
||||
machinery transfers directly — new **part 12i**: six real `cycle()`s with the
|
||||
emptiest pack at every position of a three-pack roster and both positions of a
|
||||
two-pack one (the observable read off `PeakDecision.soc`, cut-outs held flat
|
||||
so the floor axis cannot be what moved the answers); computed slice and
|
||||
non-slice tables that go red naming any member nothing kills; a **length
|
||||
sweep** of 78 real rosters, lengths 1–12, closing head and tail windows of
|
||||
width 1–11 and recording width 12 as honestly unreached; a **none-guard
|
||||
sweep** over the same 78; and **three exact structural pins** — the `vals`
|
||||
comprehension must iterate the bare `self.inverters`, and both `any()` and
|
||||
`min()` must reduce the whole `vals` — which close every slice width at every
|
||||
roster length. No shipped behaviour changed; only what observes it.
|
||||
* ⚠️ **SPIKE-04: production is DEGENERATE on the floor axis, so the
|
||||
differing-floor fixtures are the entire exposure.** Pack B is 4 × Dyness
|
||||
DL5.0C, DoD 90 → floor 10 % — **FROM THE DATASHEET AND THE SERIAL; inverter B
|
||||
does not exist and nothing about it is measured.** Pack A is DoD 90 → 10 % as
|
||||
well, and that one *is* measured. Both packs therefore floor at the same value
|
||||
in the field, `max(10, 10)` is 10 whatever it reduces, and a differing floor
|
||||
exists **only in the fixtures**. That is the argument for keeping them, not for
|
||||
simplifying them: anyone who collapses `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to
|
||||
one number deletes the only data this repository has that can see the
|
||||
reduction at all, and part 12c2's precondition check goes red if they do. The
|
||||
**SoC** axis has no such degeneracy — two batteries at the same state of
|
||||
charge is a coincidence, not a configuration — which is what makes
|
||||
`pack_floor_soc` the half of the pair that will be live from the day inverter
|
||||
B is wired up.
|
||||
* ⚠️ **The FAMILY enumerated rather than the member closed, and it found four
|
||||
more survivors in code this card never touched.** Every reduction over
|
||||
`self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||||
mutated to a slice against the full suite. Eighteen were already graded
|
||||
(`site_batt_w`, `site_rating_w`, `site_max_w`, `site_dev_bound`,
|
||||
`site_circulating_w`, `inputs_ok`, `units`, `Controller.packs`, `maint_unit`,
|
||||
the maintenance-due minimum, the store-migration roster, `validate_unit_ranges`
|
||||
and the round-2/3 sites). **Four were not, and three of them are SAFETY-30's,
|
||||
all `any(i.sign_wd.tripped for i in self.inverters)`** — `cycle()`'s
|
||||
stale-input hold, `publish()`'s status vocabulary and `status_payload()`'s
|
||||
banner — each +4 bytes and green across all 19 files, for the reason every
|
||||
blocker on this card was invisible: **the only fixture that reached them was a
|
||||
roster of ONE, where `self.inverters[:1]` IS `self.inverters`.** The first has
|
||||
teeth: a latched unit must take the roster past that hold or its `failsafe`
|
||||
SET stops being re-filed the moment a poll is missed, which is main.py's own
|
||||
sentence for it. New **test_watchdog.py part 8** grades all three with the
|
||||
latch at EACH position plus a no-latch counterfactual, and new **part 12j**
|
||||
pins all 26 roster comprehensions in `Controller` to iterating the roster
|
||||
whole — so a reduction added over a slice anywhere in the class is red on the
|
||||
line it is written, with no fixture required. Two rows are reported open
|
||||
rather than closed: `_grade_store_migration()`'s second disjunct (now red on
|
||||
12j, and argued to be an equivalent mutant because a unit tripped at startup
|
||||
implies its namespaced key is set) and `cycle()`'s undeliverable-unit **log
|
||||
line**, where `sorted(...)[:1]` (+4 bytes) is still green — it truncates names
|
||||
in one event message; the refusal itself is a different expression and is
|
||||
graded, so the site still stops.
|
||||
* ⚠️ **FOURTH FIX ROUND — the same family one MODULE over, and the pin widened
|
||||
until it discovers its own scope.** Round 4 enumerated the family and closed
|
||||
it inside the `Controller` class; the review found four more survivors
|
||||
immediately, in **validate.py**, on a 19/19 green suite: `min(caps)` →
|
||||
`min(caps[:1])` (+4) and `→ min(caps[-1:])` (+5) at `:779`, the caps
|
||||
comprehension over `caps_ix[:1]` (+4) at `:778`, and
|
||||
`min(caps_ix[-1:], key=…)` (+5) at `:893`. **None is an equivalent mutant.**
|
||||
With `max_w: 5000` on inverter-a beside `2000` on inverter-b and
|
||||
`sign_disagree_w: 3000`, the shipped validator refuses — *"the SAFETY-02
|
||||
sign-disagreement watchdog can never fire"* — and all four **accept**: the
|
||||
site starts with a watchdog reporting `armed` that cannot trip on pack B.
|
||||
Rule 5's own failure mode reached through rule 5; rule 6 loses its refusal the
|
||||
same way. **No shipped behaviour changed by the pin; what changed is that a
|
||||
hand-written scope stopped being trusted.**
|
||||
* **`test_peakguard.py` part 12j now parses every `.py` under `app/`** — no
|
||||
module is named in the check — and finds the roster by transitive closure
|
||||
from **one seed**, the `inverters` option key: through returns, through
|
||||
lists **accumulated in a loop over a roster** (which is what `caps`,
|
||||
`roster` and `problems` are, and what round 4's rule could not express),
|
||||
through attributes and properties, and **through call arguments**, which is
|
||||
how it walks into `peakguard`, `arbiter`, `allocator`, `circulation`,
|
||||
`maintenance` and `store` without being told they exist. A slice of a roster
|
||||
stays a roster so a mutation cannot cut the taint; an index is one unit and
|
||||
does not. **183 reductions across 9 modules** out of 23 files parsed — `main.py` 96,
|
||||
`allocator.py` 24, `validate.py` 19, `arbiter.py` 14, `circulation.py` 10,
|
||||
`maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, `store.py` 3.
|
||||
* **The hand-written module list would have been wrong in BOTH directions.**
|
||||
It named `watchdog.py`, which reduces over no roster at all — SAFETY-30's
|
||||
sign watchdogs are strictly per-unit — and omitted `maintenance.py` and
|
||||
`store.py`, which both do. A vacuous claim about one module and no claim
|
||||
about two others.
|
||||
* **The pin rediscovers all four blockers without being told where to look,**
|
||||
and finds a fifth beside them (`validate.py:783`, rule 5's message) that no
|
||||
review round named. Its counterfactual runs at two depths: every enumerated
|
||||
site's own expression mutated with each of the four cuts this card has been
|
||||
defeated by (732 spellings, all reported), and `[:1]` written into
|
||||
`app/validate.py` at each blocker position with the **whole** analysis
|
||||
re-run over the mutated source — a sliced site required at that exact line,
|
||||
where the clean package reports none.
|
||||
* **And the behavioural half, because a shape pin grades an identity and never
|
||||
a meaning.** `test_validate.py` grows a **roster SET** for the three rules
|
||||
that reduce over `unit_caps_indexed()` — widths 2, 3, 4, 5 with the one
|
||||
small cap at every position, 42 runs of the real validator — plus a
|
||||
**computed family table** of twelve candidate reductions each compared with
|
||||
what the shipped validator actually said on the same roster. Only `min`
|
||||
agrees everywhere; a member no roster kills is printed as a named survivor.
|
||||
* ⚠️ **A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's** —
|
||||
found by reading, **not** by the pin, which is the shape of what the pin
|
||||
cannot see. `unit_sign_disagree_w` was graded against
|
||||
`_number(opts, "max_w", 2000.0)` three lines under a branch that had already
|
||||
stopped doing that. **Both directions were measured:** two 2000 W units
|
||||
beside a leftover `max_w: 5000` and `unit_sign_disagree_w: 3000` produced
|
||||
**zero problems** — the site started with a per-unit watchdog that could not
|
||||
trip on either unit — and two 5000 W units with no flat key were **refused**
|
||||
against a 2000 W default the operator never wrote. It takes the weakest
|
||||
unit's own cap now and names that unit; at a roster of one it is the same
|
||||
comparison, boundary and sentence it always was.
|
||||
* ⚠️ **What the pin cannot see, with sizes**, in
|
||||
`DECISIONS/SAFETY-32-per-pack-bms-floor.md` §4d: semantics (`min` vs `max`
|
||||
is a zero-byte edit); direct index sites that are not reductions; a
|
||||
subscript by a *variable* (`weights[u]` is why); a slice through an
|
||||
intermediate call; anything outside `app/`, including `sim/inject.py`; and
|
||||
the taint's own seam at `*args` and unresolvable receivers, where everything
|
||||
downstream goes **unenumerated rather than reported unreachable**.
|
||||
`cycle()`'s undeliverable-unit log line is still green at +4 bytes and still
|
||||
filed open, unchanged from round 4.
|
||||
* **`tools/supervisor_shape_probe.py` re-run again (2026-09-05, 2026.08.0).**
|
||||
The rebase onto `67e649a` merged SPIKE-04a's `max_w: 4600` into the same
|
||||
§C/§D blocks this card had rewritten, and a transcript stitched by hand out
|
||||
of two runs is not a measurement. `all cases passed`, exit 0, with B's
|
||||
rating reading 4600 — its **datasheet** figure, still not a measurement of
|
||||
any unit.
|
||||
* ⚠️ **THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE.** All 183
|
||||
sites mutated to `[:1]`, one at a time, against the full 19-file suite in
|
||||
`goodwe-addon-ci` (`:ro`, `-B`, fresh rsync per mutant, 0 `.pyc` before and
|
||||
after): **183/183 RED** — which is tautological on its own, because the pin
|
||||
makes every slice red. The 51 whose first red was `test_peakguard.py` were
|
||||
re-run with the pin **neutralised**: **145 of 183 have a behavioural fixture
|
||||
that separates them from a slice of themselves; 38 have only the structural
|
||||
claim.** All 38 are listed by site, with what a `[:1]` there would cost, in
|
||||
§4d and in `test_peakguard.py` part 12j — 15 of them are rows and counts on
|
||||
the operator's page, 3 are `len(targets)` arithmetic beside a write path
|
||||
that IS graded, 6 are allocator internals ALLOC-01's fixtures reach but not
|
||||
with enough units to separate. **None of the 38 is closed behaviourally by
|
||||
this card, and none is counted as covered.**
|
||||
|
||||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||||
exist yet. Every second-pack figure — in fixtures, in the probe, in
|
||||
config.yaml's commented example — is **simulated and labelled**; only inverter
|
||||
A's 10 % is read off hardware.
|
||||
|
||||
**At two inverters the sign watchdog stops comparing one unit against the whole
|
||||
site meter, and becomes two checks instead of one (SAFETY-30).**
|
||||
`disagrees(commanded_w, grid_w, ...)` compared the command **this controller put
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
# MAINT-05 — one recalibration per pack, one at a time, and what the card left open
|
||||
|
||||
**Status: DECIDED 2026-09-04.** There is one `Maintenance` per roster entry,
|
||||
each with its own schedule, phase and history. The site holds exactly **one
|
||||
maintenance slot**: at most one pack may be in a power-moving phase, the slot is
|
||||
derived from the packs' own stored phases rather than persisted separately, and
|
||||
when several packs are due in the same hour it goes to the one that has gone
|
||||
**longest without a completed cycle**. The schedule is **not** staggered by
|
||||
construction — the interlock defers, and the stagger emerges from it.
|
||||
|
||||
**Fix round 2026-09-04.** The store migration is now graded against the
|
||||
**roster** rather than against the unit name it was handed — see §2's
|
||||
*"The read-back postcondition is graded against the ROSTER"* — and the same
|
||||
hole in SAFETY-02's latch migration is recorded in §7.
|
||||
|
||||
Filed with MAINT-05. Card `6a9a9e8503872905f298d80a`.
|
||||
|
||||
---
|
||||
|
||||
## 0. What the card asked to be decided, and the answers
|
||||
|
||||
| the card's open question | the decision |
|
||||
|---|---|
|
||||
| §2 the arbitration | **one pack at a time; the other stays under the loop.** Nothing preempts a cycle in flight. |
|
||||
| §2 who wins when several are due | **the longest-waiting pack**, `last_completed` oldest first, `inf` for never-recalibrated. Roster order breaks a numeric tie and nothing else. |
|
||||
| §4 staggering | **not by construction.** The interlock defers the second pack; the stagger is what comes out of it. §3 below. |
|
||||
| AC 7 rule 6's `maint_unit` comment | **no, the first entry's cap is no longer right.** It grades against the weakest cap on the roster now, like rule 5. §4 below. |
|
||||
| the store layout at N=1 | **flat, unchanged.** The namespace appears the day the roster grows. §2 below. |
|
||||
|
||||
## 1. Why the slot is derived and not persisted
|
||||
|
||||
The holder is *"whichever pack's stored phase is not `idle`"* — a fact already on
|
||||
disk, written by the packs themselves in the per-pack history this card
|
||||
introduces. A `maint_slot_holder` key would be a **second opinion about the same
|
||||
thing**, and the two would disagree the first time the add-on was killed between
|
||||
writing the phase and writing the holder. Derived, a controller that comes back
|
||||
up mid-drain reads the drain and re-derives the holder from it; there is no
|
||||
recovery path to get wrong because there is no second fact.
|
||||
|
||||
`CHARGE` is in `POWER_MOVING` even though the control **loop** owns the setpoint
|
||||
in that phase, and that is the decision most likely to be "simplified" later.
|
||||
Pack A charging from the grid at 2000 W while pack B drains 2000 W to export is
|
||||
4 kW going round in a circle with the meter reading zero and both packs
|
||||
believing they are behaving — the circulating power [[SAFETY-31]] exists to
|
||||
detect, arriving as something this module can simply not do. `HOLD` is in the
|
||||
tuple for the other half of the sentence: it moves nothing, and it **owns the
|
||||
actuator**, so a second cycle starting under it would still be two
|
||||
recalibrations at once.
|
||||
|
||||
## 2. Why the store layout at a roster of one does not change
|
||||
|
||||
The maintenance keys are flat — `phase`, `phase_started`, `last_completed`,
|
||||
`last_start_attempt` — on every install since the add-on shipped. At one pack
|
||||
there is nothing for a namespace to distinguish that pack **from**: §6.2's
|
||||
identity, the same one `arbiter.actuator_for()` leans on when it routes every
|
||||
claim to the site actuator at N=1. So `Maintenance` is constructed with
|
||||
`unit=None` there, reads and writes the flat keys directly, and
|
||||
`migrate_maint_keys()` **returns without writing anything**. A one-inverter state
|
||||
file comes out of this card byte-identical, which is AC 6 and which
|
||||
`test_maintenance.py` part 10 asserts by driving a whole cycle and comparing the
|
||||
key set.
|
||||
|
||||
### The migration has its own marker, and that is not tidiness
|
||||
|
||||
`SCHEMA_VERSION` / `schema_version` is DEV-02's, and it is **monotone**: every
|
||||
install runs that migration exactly once. This one runs **only where the roster
|
||||
has more than one pack**, so a single counter could not express *"migrated on
|
||||
this site, deliberately not migrated on that one"* without lying to the next
|
||||
version that reads it — a one-inverter store would have to be pinned at the old
|
||||
number forever, and a genuine future v2 migration could then never be told apart
|
||||
from a site that simply never grew. `maint_schema_version` answers one question
|
||||
and answers it honestly.
|
||||
|
||||
The **three rules are shared in code**, not restated: `_move_flat_keys()` is
|
||||
rule 1 (never overwrite an existing namespaced value) and rule 2 (the flat keys
|
||||
stay in the file), `_verify_moves()` is the read-back postcondition, and the
|
||||
marker is written last. A second transcription of a migration is a second
|
||||
opinion about what "it landed" means.
|
||||
|
||||
### The read-back postcondition is graded against the ROSTER (fix round)
|
||||
|
||||
**A postcondition that reads back the namespace it was handed cannot detect
|
||||
being handed the wrong one.** That sentence is the whole finding, and this card
|
||||
shipped it broken.
|
||||
|
||||
`_verify_moves()` iterated `rep.moved` — the keys it had just written — and
|
||||
asked whether each read back equal. On a store where the write lands, it always
|
||||
does. So a migration handed a `unit` that is on no roster wrote four keys, read
|
||||
four keys back, set `verified = True`, and the caller stamped
|
||||
`maint_schema_version`. Every symptom of a clean migration, and an install's
|
||||
whole maintenance history parked under a name nothing will ever look up. And
|
||||
because the marker was stamped, **the next start skipped the migration too** —
|
||||
which is precisely the state `store.py`'s header says must never happen: a
|
||||
failed migration a later version treats as done.
|
||||
|
||||
Reproduced on the reviewer's roster, a house with a garage pack and a shed pack,
|
||||
carrying a flat `last_completed` of 2026-08-07:
|
||||
|
||||
| | `unit` the migration was handed | key written | pack 0 reads back | due now? |
|
||||
|---|---|---|---|---|
|
||||
| correct | `self.inverters[0].name` = `garage` | `unit/garage/last_completed` | `2026-08-07` | **False** |
|
||||
| wrong | `inverter.UNIT_A` = `inverter-a` | `unit/inverter-a/last_completed` | `None` | **True** |
|
||||
|
||||
`build_roster()` names a declared roster's entries **from the config**, and its
|
||||
fallback for a nameless entry is a **positional** `inverter-1` — so `inverter-a`
|
||||
is not the name of anything on a declared roster, not even by default.
|
||||
|
||||
**The remedy, and why this one.** `migrate_maint_keys()` now takes the **roster**
|
||||
in place of an `n_units` count (the count was `len(self.inverters)` at the only
|
||||
call site, so the roster carries it, and a count passed beside the names is a
|
||||
second opinion about how many packs there are). `_verify_moves()` grades the
|
||||
namespace it wrote against those names, and a unit that is not among them is
|
||||
reported as `misdirected` — a **different** failure from `lost`:
|
||||
|
||||
* `lost` is *the store took the write and dropped it* — a storage fault, and it
|
||||
has a harmless-default case (a write of `last_completed: None` loses nothing,
|
||||
because "absent" and "never recalibrated" are the same statement).
|
||||
* `misdirected` is *the store did exactly as it was told, by a caller that was
|
||||
wrong*. There is no harmless-default version of it: a write nobody reads is
|
||||
not information, whatever its value. So anything in it fails `ok` on its own.
|
||||
|
||||
And **`ok` gates the marker**, so a misdirected migration is not stamped and the
|
||||
next start runs it again. The argument for it is asymmetric: a re-run is
|
||||
recoverable — rule 1 makes it a no-op where keys did land, and rule 2 left the
|
||||
flat keys in the file to recover *from* — while a silent skip is not recoverable
|
||||
at all, because the store now claims the migration happened.
|
||||
|
||||
### ⚠️ THE PARAGRAPH ABOVE USED TO END HERE, AND AS IT STOOD IT WAS FALSE
|
||||
|
||||
**The refusal lasted exactly one start. The second start stamped the marker.**
|
||||
That is a correction to what the first fix round wrote down, not a new
|
||||
qualification of it, and it is recorded here because a decision document that
|
||||
states a guarantee the code does not provide is worse than one that says
|
||||
nothing.
|
||||
|
||||
`_verify_moves()` graded only `rep.moved`. Measured at Controller level over
|
||||
**one persisted store**, roster `["garage", "shed"]`, flat `last_completed`
|
||||
2026-08-07, with the call site still handing `inverter-a` on **both** starts:
|
||||
|
||||
```
|
||||
construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent> (withheld)
|
||||
construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1 (STAMPED)
|
||||
history parked under unit/inverter-a/…; marker stamped after ONE restart.
|
||||
```
|
||||
|
||||
On start 2 the same wrong call site finds the four keys it wrote already
|
||||
present, so **rule 1** — never overwrite a namespaced value — routes every one
|
||||
of them to `rep.kept`. `rep.moved` is empty, the roster-grading loop iterates
|
||||
over nothing, `misdirected` is empty, `ok` is True, the marker is stamped, and
|
||||
start 3 takes the `from_version >= MAINT_SCHEMA_VERSION` early return **forever
|
||||
— including after the call site is fixed**. Loud-and-recoverable became
|
||||
silent-and-permanent after one restart: exactly the state `store.py`'s header
|
||||
says must never happen, reached by the fix written to prevent it.
|
||||
|
||||
**The remedy is one line**, and it is sound by construction rather than by
|
||||
argument: grade `set(rep.moved) | set(rep.kept)`. Every entry in *both* lists is
|
||||
a `unit_key(unit, legacy)` built from the **same** `unit` argument, so grading
|
||||
`kept` grades the same namespace decision `moved` does. A correct call site's
|
||||
`kept` entries are under a roster name and pass; a wrong one cannot launder its
|
||||
namespace by having already written it. `migrate_unit_keys()` passes
|
||||
`roster=None` and returns before the loop, so it is unaffected.
|
||||
|
||||
**Why the check missed it, which is the part worth keeping.** Part 9b's recovery
|
||||
check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` — the **corrected** name.
|
||||
It measures recovery along the one path where the operator has *already fixed
|
||||
the bug*. The real failure is a call site that is still wrong on the next start,
|
||||
and nothing in the 19 files called `migrate_maint_keys()` twice with the **same
|
||||
wrong** `unit`. The check was true, passed, and could not reach the failure its
|
||||
sentence named — MUTATION.md's third defect, this time sitting inside the fix
|
||||
written to close it. Part **9c** now drives two starts with the same wrong unit
|
||||
over one store, asserts the marker is still withheld on the second, and asserts
|
||||
the *shape* of the second report (`moved` empty, `kept` full) so it cannot
|
||||
quietly revert to 9b's path.
|
||||
|
||||
### The marker gate is `rep.ok` and nothing weaker — both guards, each pinned
|
||||
|
||||
`if rep.ok:` is `not lost and not misdirected`, and until this round only the
|
||||
`misdirected` half was reachable by a check that also had a non-empty `kept`.
|
||||
The mutation
|
||||
|
||||
```
|
||||
if rep.ok: -> if rep.ok or rep.kept:
|
||||
```
|
||||
|
||||
stamps the marker on a migration that **lost a month of history** — defeating
|
||||
the older, settled `lost` guard as well as the new one — and **all 19 suites
|
||||
were green on it**. Part **9d** is the fixture that separates them: two keys
|
||||
already namespaced (`kept` non-empty, so the mutant's `or` fires) and two keys
|
||||
whose write the store drops (`lost` non-empty, so `ok` is False), with
|
||||
`misdirected` empty throughout so the section cannot pass on the other guard by
|
||||
accident. Measured differential on it: shipped → marker absent; mutant → marker
|
||||
`1`, both reporting `ok=False lost=2 kept=2`.
|
||||
|
||||
**Why the fixtures had to change, and this is the part that got the card
|
||||
rejected.** Nothing in the repository could see whether any of the above was
|
||||
true, because `inverter.UNIT_A` **is** the string `"inverter-a"` and every
|
||||
two-pack fixture in `test_maintenance.py` named its first roster entry
|
||||
`inverter-a`. `self.inverters[0].name` and `UNIT_A` were therefore the same
|
||||
value in every check that existed, and swapping one for the other at the call
|
||||
site left **all 19 suites green**. MUTATION.md's third defect exactly — a check
|
||||
whose *data* cannot reach the failure it names — and the fifth consecutive card
|
||||
to carry it. Parts 9 and 11 now run on `["garage", "shed"]`, each behind a
|
||||
**precondition check** asserting that the first name is not `UNIT_A`, so an edit
|
||||
that makes them equal again goes red instead of making the claims vacuous.
|
||||
|
||||
### And the flat keys are kept LIVE, which is the half DEV-02 had to add later
|
||||
|
||||
Rule 2 originally said the flat keys are left in place and go stale. DEV-02
|
||||
amended it, for the sign latch, to *"left in place is not enough"* — and the
|
||||
same sentence holds for history. A fossil dated the day the second inverter was
|
||||
declared would hand a rolled-back single-FSM add-on a `last_completed` months
|
||||
old (it recalibrates a pack that does not need it) or a stale `phase` with a
|
||||
`phase_started` old enough to abort on the 12 h timeout the moment it comes up.
|
||||
So `UnitHistory` **mirrors the first pack's writes back onto the flat keys** for
|
||||
as long as the roster has more than one entry. Only the first pack: the flat
|
||||
spelling means `inverters[0]` on every install that has one, and a mirror from
|
||||
pack B would overwrite pack A's rollback view with pack B's phase.
|
||||
|
||||
## 3. Staggering: the interlock defers, and the stagger is the residue
|
||||
|
||||
Two packs on one monthly schedule are due in the same hour every month. The
|
||||
options were a stagger **by construction** — offset each pack's first cycle by
|
||||
`interval_days / N` — or **deferral**. Deferral, for three reasons:
|
||||
|
||||
1. **A constructed stagger cannot remove the need for the interlock.** A forced
|
||||
start, a commissioning cycle, an abort-and-retry, or a pack added mid-month
|
||||
desynchronises any static offset immediately, so the interlock has to be
|
||||
there anyway — and then the stagger is a second mechanism that must also be
|
||||
correct, for a case the first one already handles.
|
||||
2. **A deferral is observable and a stagger is not.** The deferred pack says, in
|
||||
the action log and on the status page, that it is waiting, which pack it is
|
||||
waiting for, and that it will run at the next start hour. A silent offset
|
||||
moves an operator's configured start day and explains nothing.
|
||||
3. **The stagger comes out of the deferral for free and then persists.** Pack A
|
||||
completes on day 1 and is next due on day 29; pack B, deferred, completes on
|
||||
day 2 and is next due on day 30. One contested month separates them by one
|
||||
cycle length and they stay separated.
|
||||
|
||||
**Starvation was the risk and it is closed at two points.** A deferral does
|
||||
**not** stamp `last_start_attempt` — that key is the post-abort cooldown, and at
|
||||
90 minutes inside a 60-minute start hour, stamping it would lock the deferred
|
||||
pack out of exactly the hour it is waiting for. And the winner is the
|
||||
longest-waiting pack rather than the first in the roster, so a pack that keeps
|
||||
aborting cannot monopolise the slot month after month while its sibling drifts —
|
||||
which is this card's own defect wearing a different costume.
|
||||
|
||||
**The deferral must not read as a fault.** §12's 12 h and 36 h phase timeouts
|
||||
mean *"the battery is not following commands"*. A deferred pack stays `IDLE`
|
||||
with `phase_started` untouched, so no timeout is reachable by waiting, and the
|
||||
line it logs says WAITING, names the pack it waits for and says it will run
|
||||
next — none of which any failure path in `maintenance.py` says.
|
||||
|
||||
## 4. Rule 6 (the card calls it rule 8): the first entry's cap is no longer right
|
||||
|
||||
`validate.py`'s rule 6 comment said, in as many words, that
|
||||
`maintenance_charge_w` was graded against `inverters[0]`'s cap because there was
|
||||
exactly one `Maintenance` and it could only ever recalibrate the first pack —
|
||||
and that taking `min()` would be an over-refusal, because it would refuse *"a
|
||||
5000 W pack being recalibrated beside a 2000 W sibling the cycle never
|
||||
touches"*. It closed: **"when `maint_unit` stops being `inverters[0]`, this line
|
||||
moves with it."**
|
||||
|
||||
It has stopped. Every pack is recalibrated on its own schedule, so **the sibling
|
||||
the cycle never touches does not exist** and the over-refusal argument has no
|
||||
subject. The shaping claim is filed on whichever pack is charging, so the cap
|
||||
that can contradict it is any cap on the roster and the one that contradicts it
|
||||
first is the smallest.
|
||||
|
||||
**This refuses configurations that used to start**, and that is the point rather
|
||||
than a side effect: `max_w` 5000 on pack A beside 2000 on pack B with
|
||||
`maintenance_charge_w` 3000 was correct while only pack A was ever recalibrated,
|
||||
and is now a cheap-window charge asking the arbiter for `hi = -3000` against a
|
||||
supervised `lo = -2000` — `lo > hi`, arbiter clause 3, 0 W, on the pack nobody
|
||||
was watching. Refused when it is **written**, which is rule 6's whole job. At a
|
||||
roster of one `min()` over one cap is that cap: same comparison, same message,
|
||||
same boundary.
|
||||
|
||||
## 5. The claim stays per-unit, and nothing here files a site claim
|
||||
|
||||
`arbiter.CLAIM_SCOPE` has classified `maintenance` as `SCOPE_UNIT` since it was
|
||||
written. Two FSMs mean two claims on two actuators, which the allocator consumes
|
||||
as per-unit intervals. [[ARCH-01]] is explicit that this must never become a
|
||||
site claim, because per-inverter constraints filed site-wide are the
|
||||
cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
|
||||
card changed there is **which** roster entry the claim is filed on: the one whose
|
||||
state machine produced the result, rather than `inverters[0]`.
|
||||
|
||||
## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
|
||||
|
||||
Measured while wiring this card, and **pre-existing on `release/1.0` at
|
||||
`2d025ee`** — the same drain filed on `inverters[0]` produces the identical
|
||||
refusal with the two unit names swapped.
|
||||
|
||||
The maintenance SET **pins** its pack. The loop's target is a **site** claim.
|
||||
When the site target is *below* the pin — a 2000 W drain while the house imports
|
||||
less than that, which is most of a night — the only split that sums to the site
|
||||
target asks the sibling to **charge** while the maintained pack **discharges**,
|
||||
and `allocator.plan()` refuses it by name as circulating power. The refusal is
|
||||
correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED`
|
||||
line in the action log.
|
||||
|
||||
**The consequence an operator would see is a misdiagnosis.** The pack is held at
|
||||
0 W while its state machine believes it is draining, so after 12 h the DRAIN
|
||||
timeout fires and reports *"drain stuck"* — §12's words for a battery that is not
|
||||
following commands. The battery is fine; the site refused to allocate.
|
||||
|
||||
**Why it is not fixed here.** The fix is to change what the site loop's target
|
||||
*means* while a unit is pinned — regulate the meter using only the unpinned
|
||||
packs — which is loop and allocator territory and changes the integrator's
|
||||
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
|
||||
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
|
||||
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
|
||||
its own.
|
||||
|
||||
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
|
||||
|
||||
Found while fixing §2's instance. It is the same defect one module over and it
|
||||
costs a **disarmed safety guard** rather than a lost history.
|
||||
|
||||
`Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` — a hardcoded
|
||||
constant where the maintenance migration now passes the roster's own first name.
|
||||
It **has** to run before `build_roster()`, because each `Inverter` restores its
|
||||
sign latch in its constructor, so there is no roster to name yet and
|
||||
`_verify_moves()` is passed `roster=None`. On a **declared** roster a pre-DEV-02
|
||||
`sign_tripped: true` therefore lands under `unit/inverter-a/sign_tripped`, which
|
||||
no unit on that site reads.
|
||||
|
||||
**And the guard that exists for exactly this is defeated by the key the defect
|
||||
wrote.** `_grade_store_migration()` re-arms the latch when the flat key says
|
||||
tripped and *nothing namespaced is latched*. The misdirected write **is**
|
||||
namespaced and **is** latched, so `namespaced_latched` is `True`, the guard
|
||||
returns, and no inverter on the roster comes up tripped — on an install whose
|
||||
`/data/state.json` still reads `sign_tripped: true`. That is DEV-02's own
|
||||
sentence for the worst case: the state file says protected, the banner is green,
|
||||
and the controller resumes commanding into the fault that tripped it.
|
||||
|
||||
Measured, on the same `["garage", "shed"]` roster:
|
||||
|
||||
```
|
||||
pre-DEV-02 flat latch + declared roster ['garage', 'shed'] ->
|
||||
migrated 2 flat key(s): unit/inverter-a/sign_reason, unit/inverter-a/sign_tripped
|
||||
latch keys in the store: ['sign_tripped', 'site_sign_tripped', 'unit/inverter-a/sign_tripped']
|
||||
roster came up tripped: [('garage', False), ('shed', False)]
|
||||
the same store on the FLAT config -> [('inverter-a', True)]
|
||||
```
|
||||
|
||||
The last line is the counterfactual: on the flat shape, where `UNIT_A` really is
|
||||
the roster's only name, the same store migrates and comes up latched. So this is
|
||||
a defect of the **declared roster**, not of the migration as such.
|
||||
|
||||
**Why it is not fixed here.** The remedy is to name the migration's target from
|
||||
the config before any `Inverter` exists, which means factoring `build_roster()`'s
|
||||
naming rule out of `build_roster()` so there is still only **one** spelling of
|
||||
it — `inverter.py` and SAFETY-02's latch semantics, with its own mutation battery
|
||||
over `test_inverter.py` and `test_watchdog.py`. MAINT-05 moves the maintenance
|
||||
history and nothing about the sign latch, and a silent expansion into a safety
|
||||
latch is how a rejected PR becomes a broken one.
|
||||
|
||||
It is pinned as a running check (`test_maintenance.py` part 13) rather than left
|
||||
as a comment. ⚠️ **Its assertions state the WRONG behaviour on purpose** — read
|
||||
them as the specification of a defect; the card that fixes it should expect them
|
||||
to go red and should replace them.
|
||||
|
||||
### And its MARKER GATE had the round-3 hole too — found here, closed here
|
||||
|
||||
Separate from the namespace hole above, and **fixed** rather than recorded,
|
||||
because closing it cost one test fixture and no source change.
|
||||
`migrate_unit_keys()` gates `schema_version` on `rep.ok` exactly as
|
||||
`migrate_maint_keys()` gates its own marker, and every check that reached that
|
||||
gate ran on a store where **nothing** was namespaced yet — so `rep.kept` was
|
||||
empty in all of them and
|
||||
|
||||
```
|
||||
if rep.ok: -> if rep.ok or rep.kept:
|
||||
```
|
||||
|
||||
**survived all 19 files**. What it does is stamp the schema marker on a
|
||||
migration that DROPPED THE LATCH: the guard is off, the state file still reads
|
||||
`sign_tripped: true`, and because the store now says the migration finished, the
|
||||
next start skips the one thing that would have re-armed it. DEV-02's own worst
|
||||
case, filed under *done*.
|
||||
|
||||
`test_inverter.py` part 10 now drops the `sign_tripped` write **beside an
|
||||
already-namespaced `sign_reason`** — `lost` non-empty *and* `kept` non-empty,
|
||||
which is the one shape none of the existing fixtures had — and asserts the
|
||||
marker is withheld, with the accepted-baseline counterfactual beside it (habit
|
||||
7). The shipped gate was correct throughout; what changed is that a check can
|
||||
now see it.
|
||||
@@ -0,0 +1,816 @@
|
||||
# SAFETY-32 — `bms_soc_floor` per pack, and the two judgement calls it left open
|
||||
|
||||
**Status:** decided and implemented on branch `SAFETY-32`.
|
||||
**Card:** SAFETY-32 — *"One `bms_soc_floor` for two packs grades the shallower
|
||||
pack against the deeper one's cut-out"*, scoped by the card's own PM PROOFREAD
|
||||
to its **third** bullet only. Bullets 1 (aggregate anti-windup) and 2 (per-instance
|
||||
`achievable_w`) were verified as already shipped by DEV-02 and CFG-04 and were
|
||||
**not** touched — `main.py`'s `actual_w=self.site_batt_w` comment is the record
|
||||
of why the first of those must stay site-level, and it still reads as written.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, stated in the terms the code uses
|
||||
|
||||
`bms_soc_floor` **describes a battery**: it is the percent that pack's own BMS
|
||||
cuts out at, register `0x0560` on the reference GW5000S-BP, read as 10. It was a
|
||||
flat option because there was one pack.
|
||||
|
||||
`peak_soc_floor` **describes the site's policy**: the depth a money-saving
|
||||
discharge may never reach. Rule 8 refuses `peak_soc_floor <= bms_soc_floor`,
|
||||
because such a floor can never bind — the BMS wins and the status page reports a
|
||||
backstop that does not exist.
|
||||
|
||||
At two packs of different models the two cut-outs differ, and one number for both
|
||||
is wrong **in the direction that empties a pack**: with cut-outs of 10 % and 30 %
|
||||
and `peak_soc_floor: 20`, the add-on **started**, and the guard would command a
|
||||
forced discharge that pack B's *BMS* — not the operator's floor — is the thing
|
||||
that stops. That is rule 8's own failure mode, reached through rule 8.
|
||||
|
||||
---
|
||||
|
||||
## 2. DECISION 1 — required per entry, not defaulted from `PEAK_OPTION_DEFAULTS`
|
||||
|
||||
The card left this open and asked for the reason to be recorded.
|
||||
|
||||
**Decided: REQUIRED of every roster entry**, in `config.yaml`'s schema
|
||||
(`bms_soc_floor: int(0,50)`, no trailing `?`) and in `validate.py` rule 8, which
|
||||
refuses a missing one **by name**.
|
||||
|
||||
The reason is that a default here is not a convenience, it is **this card's own
|
||||
defect wearing a different hat**. A default is one number applied to every pack,
|
||||
which is exactly what the flat key was. An add-on that answers "10 %" for a pack
|
||||
nobody has told it about has invented a property of somebody's battery, and the
|
||||
failure mode of the invention is the one this card exists to remove — the
|
||||
shallower pack protected by a floor that belongs to the deeper one. CFG-03's
|
||||
rule ("defaults live in exactly one place") points the same way: the one mirror
|
||||
`PEAK_OPTION_DEFAULTS["bms_soc_floor"]` is the default for the **flat** key, i.e.
|
||||
for the single-inverter shape, and reusing it per entry would make one dict the
|
||||
default in two shapes with two meanings.
|
||||
|
||||
It is also the treatment `max_w` already gets, for the same reason and by the
|
||||
same argument (`inverter.py`'s `build_roster()` docstring): the per-unit numbers
|
||||
that describe hardware are required, and the per-unit *sensors* are optional
|
||||
because an absent sensor is a real state.
|
||||
|
||||
**The cost is stated rather than hidden.** This is a schema change: a roster
|
||||
config written before this branch is refused by the Supervisor until each entry
|
||||
gains the key, and every roster fixture in the suite needed it. Nothing in
|
||||
production carries a roster today — `inverters:` ships `[]` and the second
|
||||
inverter is ~8 days out — so the migration is paid by fixtures and by whoever
|
||||
writes the first real roster, at a startup refusal that names the entry.
|
||||
|
||||
**What is NOT inherited.** An entry that omits the key does not borrow the flat
|
||||
one. `Inverter.bms_soc_floor` is then `None`, `peakguard.pack_floor_pct()`
|
||||
answers `None`, and `discharge_permitted()` already treats an unknown floor as
|
||||
**no permission** — so even in the window before rule 8 runs (e.g. `sim/inject.py`,
|
||||
which does not run the startup gate) the absence costs a shave and never a pack.
|
||||
`0.0` was rejected explicitly: it is the most permissive value the key has.
|
||||
|
||||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||||
|
||||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||||
floors, each of them `max(peak_soc_floor, that pack's own cut-out)`, with a
|
||||
`max()`. The two reductions pair: a minimum SoC against a maximum floor is the
|
||||
conservative combination, and both move the verdict towards refusing a shave and
|
||||
away from spending a pack. `min(soc) <= max(floor)` is implied by "some pack is
|
||||
at or below its own floor", so the site verdict is never more permissive than
|
||||
grading each pack separately would be. The converse is not true and is not
|
||||
claimed — a site can be refused while every pack individually has headroom, which
|
||||
costs a shave.
|
||||
|
||||
### 3a. ⚠️ FIX ROUND — the reduction was written and argued, and NOT GRADED
|
||||
|
||||
PR #48 was rejected because the argument in §4 below justified *writing* the
|
||||
reduction and was then not carried into a check. Neither of the two mutations
|
||||
below appears anywhere in §6's twelve-row battery: it graded *"the reduction
|
||||
dropped"* and *"the bare option handed over"*, and never asked what happens when
|
||||
the reduction is the **wrong one**, or when the roster handed to it is
|
||||
**sliced**. Both were applied to `app/` by the reviewer and **all 19 files
|
||||
stayed green**:
|
||||
|
||||
| mutation | Δ | why it survived |
|
||||
|---|---|---|
|
||||
| `binding_soc_floor()`: `max(floors)` → `min(floors)` | **0 bytes** | every roster in the suite was rule-8-valid or one pack long, so `floors` held **identical numbers** and `max`/`min`/first/last were four spellings of one answer |
|
||||
| the `decide()` call site: `self.packs` → `self.packs[:1]` | **+4 bytes** | part 12g asserted `"self.packs" in <the unparsed expression>` — and `self.packs[:1]` **contains that substring** |
|
||||
|
||||
The zero-byte one was proved to have actually run rather than assumed:
|
||||
`dis.get_instructions` showed the `LOAD_GLOBAL`s as `[float, pack_floor_pct,
|
||||
any, min]`, and `binding_soc_floor([Pack("a",50,10), Pack("b",50,35)], 20.0)`
|
||||
returned `20.0` where the shipped code returns `35.0`.
|
||||
|
||||
**The common cause is this repository's recurring defect** — a check whose DATA
|
||||
cannot reach the failure it names. The one check that used a cut-out **above**
|
||||
the EMS floor (12c's `_n1_deep`) ran on a roster of **ONE**, where every possible
|
||||
reduction agrees. So no check in the suite ever evaluated
|
||||
`binding_soc_floor()` on two packs whose floors **differ**.
|
||||
|
||||
**And the rule-8 degeneracy is not a defence, by §4's own words.** If
|
||||
"unreachable is a claim about today's rule ordering, not a property of this
|
||||
function, and `validate_startup_config()` is also `sim/inject.py`'s entry point"
|
||||
is enough to *write* the reduction, it is enough to *grade* it. Through
|
||||
`sim/inject.py`, which does not run rule 8 at all, `min(floors)` lets the guard
|
||||
command a forced discharge to 20 % on a pack whose BMS cuts out at 35 %.
|
||||
|
||||
**What was added — no shipped behaviour changed, only what observes it:**
|
||||
|
||||
* **`test_peakguard.py` part 12c2** evaluates `binding_soc_floor()` on a roster
|
||||
whose per-pack floors **all differ**. Four **simulated** cut-outs —
|
||||
`30, 25, 40, 35` %, every one strictly above `peak_soc_floor: 20`, so
|
||||
`pack_floor_pct()` selects the cut-out for each and `floors` *is* that tuple in
|
||||
that order. The four candidate reductions are then four different numbers:
|
||||
|
||||
max = 40 <- the shipped answer
|
||||
min = 25
|
||||
first = 30 <- "grade the site against pack A", inside the function
|
||||
last = 35
|
||||
mean = 32.5
|
||||
|
||||
The extremes sit at **interior** roster positions (index 2 is the max, index 1
|
||||
the min). A two-pack roster structurally cannot separate these — there `max`
|
||||
and `last` coincide and so do `min` and `first` — and neither can three. Four
|
||||
is the smallest roster that separates all four. The precondition is its own
|
||||
check (habit 1), one pack is then raised past the old maximum (habit 7: the
|
||||
**last** pack, which was neither extreme, so the line can only move if the
|
||||
reduction read it), and `peak_soc_floor` is lifted above every cut-out to grade
|
||||
the other side of the conjunction.
|
||||
|
||||
* **Part 12g is behaviour now, not source text** (MUTATION.md habit 4). Real
|
||||
`Controller.cycle()`s on a two-pack roster, both packs held at the same SoC and
|
||||
everything else identical, with the packs' own cut-outs as the only thing
|
||||
varied:
|
||||
|
||||
cut-outs A 10 / B 18 % -> decide() got soc_floor=20 %, floor_limited=False
|
||||
cut-outs A 10 / B 35 % -> decide() got soc_floor=35 %, floor_limited=True
|
||||
|
||||
The observable is `PeakDecision.soc_floor` — the argument, echoed back by the
|
||||
shipped `decide()` — so the whole path from `Controller.inverters` to the
|
||||
guard's argument is under test, and the site's **verdict flips** as well as the
|
||||
number. `self.packs[:1]` answers 20 % both times. **Which** option key the floor
|
||||
comes from stays pinned on the source in part 4, because that claim genuinely
|
||||
is about the code's shape; that the option still governs is now graded
|
||||
behaviourally too.
|
||||
|
||||
* ⚠️ **A THIRD SURVIVOR, FOUND WHILE FIXING THE FIRST TWO, AND RECORDED RATHER
|
||||
THAN QUIETLY REPAIRED.** The first cut of part 12g varied only **pack B's**
|
||||
cut-out — so `self.packs[-1:]`, grade the site against the **last** pack,
|
||||
agreed with the full roster on both runs and stayed green across all 19 files
|
||||
(+5 bytes). A two-run fixture that only ever moves the second pack pins `[:1]`
|
||||
and not `[-1:]`; it is the same defect one step along, and it was in the fix
|
||||
for this defect. Part 12g now runs the **same pair of cut-outs in both
|
||||
orders**:
|
||||
|
||||
cut-outs A 10 / B 18 % -> soc_floor=20 %, floor_limited=False
|
||||
cut-outs A 10 / B 35 % -> soc_floor=35 %, floor_limited=True
|
||||
...the SAME PAIR SWAPPED, A 35 / B 18 % -> soc_floor=35 %, floor_limited=True
|
||||
|
||||
`[:1]` answers 20 % on the second run, `[-1:]` answers 20 % on the third, and
|
||||
the roster answers 35 % on both. No single-position slice passes all three.
|
||||
This is the mirror `12h` already ran on the SoC axis, which is where the shape
|
||||
was borrowed from.
|
||||
|
||||
* ⚠️ **AND A FOURTH, from the same battery: the EMPTY-roster branch.** Rewriting
|
||||
`binding_soc_floor([])`'s `float(soc_floor)` to `return None` (−43 bytes) was
|
||||
green across all 19 files. It is defensive code — `Controller.inverters` is
|
||||
never empty on a started controller, and `inputs_ok` refuses the cycle before
|
||||
the guard is reached if it were — so nothing in the suite went near it. It is
|
||||
**pinned rather than argued**, in both directions (the option when there is
|
||||
one, `None` when there is not), because a branch with no check is a branch
|
||||
that can be rewritten under a green suite. The check also records why it
|
||||
cannot permit anything either way: `pack_floor_soc` is `None` at an empty
|
||||
roster, and `discharge_permitted()` refuses on a `None` SoC whatever the floor
|
||||
is.
|
||||
|
||||
* **`wired_over_limit()` gained a `prepare(ctl)` hook**, inside its retry loop so
|
||||
every attempt grades the same site, which is the only window in which a second
|
||||
inverter can join the roster before the telegrams are fed.
|
||||
|
||||
* **In-process mutation rows 22, 22b and 23** record all four survivors, so a
|
||||
later edit that re-degenerates any of these fixtures goes red on a row rather
|
||||
than on nothing.
|
||||
|
||||
### 3b. ⚠️ SECOND FIX ROUND — the fix carried the defect it was written to close
|
||||
|
||||
Round 2 closed both named survivors, and both of round 3's blockers are the
|
||||
**same defect sitting inside that fix**: a check whose TEXT claims more than its
|
||||
FIXTURE can observe. Applied to `app/` on `8501e05`, **all 19 files stayed
|
||||
green** for both:
|
||||
|
||||
| mutation | Δ | the sentence that was too broad | why the fixture could not see it |
|
||||
|---|---|---|---|
|
||||
| `binding_soc_floor()`: `max(floors)` → `max(floors[-2:])` | **+5 bytes** | *"So the reduction reads **every entry**"* | 12c2's only counterfactual raised the **last** pack (`_deeper[-1]`), and **every window that ends at the roster's end follows the last pack**. On `(30,25,40,35)` both answer 40; on `_deeper` both answer 45. |
|
||||
| the `decide()` call site: `self.packs` → `self.packs[:2]` | **+4 bytes** | *"what is reduced is the **ROSTER** and not a POSITION in it"* | all three `_floor_reaching_decide` runs drove a **two-pack** roster, where `self.packs[:2]` **is** `self.packs` — by exactly the argument 12c2 itself makes about two packs being unable to separate max/min/first/last. |
|
||||
|
||||
Round 2's *report* stated the narrow, true claim ("no **single-position** slice
|
||||
passes all three runs"). The *check in the file* stated the broad one. That gap
|
||||
is the whole of round 3.
|
||||
|
||||
**THE GENERAL LAW, stated once so the next card can use it:**
|
||||
|
||||
> **A fixture of N packs cannot separate two reductions that agree on N packs.**
|
||||
> Two cannot separate max from min from first from last. Three cannot separate
|
||||
> max from max-of-the-last-two. A single counterfactual that varies one position
|
||||
> cannot separate the reduction from any window containing that position.
|
||||
|
||||
Its corollary is the reason this section is long: **the regress does not
|
||||
terminate in a fixture.** Closing `[:1]` took two packs; `[:2]` took three;
|
||||
`[:3]` takes four — and a four-pack run hands `[:4]` the same free pass. Round 3
|
||||
therefore does two different things rather than adding one more pack.
|
||||
|
||||
**What was added — no shipped behaviour changed, only what observes it:**
|
||||
|
||||
* **Part 12c2 gained a ROSTER SET and a computed FAMILY TABLE.** The same four
|
||||
simulated cut-outs with the **deepest moved to each of the four positions**,
|
||||
and fourteen enumerated rewrites of `max(floors)` each checked against every
|
||||
roster. The table is **computed and printed**, and a member with an empty kill
|
||||
list takes the check **red naming it** — so a family member can no longer be
|
||||
added without a fixture that reaches it. `max(floors[-2:])` is killed only by
|
||||
the rosters whose deepest sits at index 0 or 1, which is precisely the data the
|
||||
single last-pack counterfactual could not supply.
|
||||
|
||||
* **Part 12c2 gained a LENGTH SWEEP.** `binding_soc_floor()` is a pure function
|
||||
over a list, which is the one place a sweep is cheap: **78 simulated rosters,
|
||||
lengths 1 through 12, the deepest cut-out at every position of each.** That
|
||||
closes *every* head window `max(floors[:w])` and *every* tail window
|
||||
`max(floors[-w:])` of width **1 to 11** in one property rather than one
|
||||
mutation at a time — and the check asserts that width **12 is NOT reached**, so
|
||||
the limit is a red line rather than a silence.
|
||||
|
||||
* **Part 12g drives THREE-pack rosters**, with the deep cut-out at index 0, 1
|
||||
**and** 2. Every positional slice of a three-pack roster is a proper subset of
|
||||
`{0,1,2}` and so drops a position; the run whose deep pack sits there answers
|
||||
the option, 20 %, where the roster answers 35 %. `self.packs[:2]` goes red on
|
||||
the index-2 run. `_floor_reaching_decide()` now takes `*floors`, and the extra
|
||||
units join through the same `second_inverter()` hook the two-pack runs use.
|
||||
|
||||
* **Part 12g gained a POSITIONAL-SLICE TABLE**, computed against the **real**
|
||||
`Controller.packs` each of the six cycles actually ran and the **real**
|
||||
`PeakDecision.soc_floor` it produced — so it measures the shipped call site's
|
||||
own answer, not a re-derivation, and a survivor takes the check red.
|
||||
|
||||
* ⚠️ **AND A FIFTH AND SIXTH SURVIVOR, FOUND BY ROUND 3'S OWN BATTERY AND
|
||||
CLOSED STRUCTURALLY RATHER THAN BY ONE MORE PACK.** `self.packs[:3]` (+4) and
|
||||
`self.packs[-3:]` (+5) survive **all six** behavioural runs, because the
|
||||
longest roster those runs drive is three packs long. Part 12g therefore also
|
||||
pins the roster **argument's identity** on the AST: it must unparse to exactly
|
||||
`self.packs`, which is false of every slice at every length. ⚠️ This is **not**
|
||||
the substring test round 2 removed — `"self.packs" in expr` is *true* of every
|
||||
slice; `ast.unparse(arg) == "self.packs"` is *false* of all of them. The two
|
||||
halves are separated and each says what it reaches: the **runs** grade the
|
||||
VALUE and reach every slice up to N=3; the **pin** grades the ARGUMENT'S
|
||||
IDENTITY at any N and is blind to two things the runs are not — a rewrite of
|
||||
what `Controller.packs` *returns* (the runs' `len(ctl.packs) == 3` catches
|
||||
that) and the reduction *inside* `binding_soc_floor()` (12c2's).
|
||||
|
||||
* ⚠️ **AND ONE ROW IS RECORDED AS NOT CAUGHT.** A reduction over a window
|
||||
**twelve packs wide** is indistinguishable from `max(floors)` on every roster
|
||||
in the file, because the sweep stops at twelve. Unlike the call site there is
|
||||
no identity claim to pin — the claim is semantic, "the maximum", and a shape
|
||||
pin would refuse honest refactors of the expression. It is reported **with its
|
||||
size** (it needs a 13-pack roster, on a site whose roster is ONE and whose
|
||||
second unit is ~8 days out) rather than hidden.
|
||||
|
||||
* **Two rewrites are EQUIVALENT, not uncaught.** `max(set(floors))` and
|
||||
`list(reversed(self.packs))` cannot change any answer — `max` is order- and
|
||||
duplicate-invariant. The second is nonetheless red on the structural pin.
|
||||
|
||||
* **In-process mutation rows 24 – 27** record all four of round 3's findings,
|
||||
including the not-caught one, so a later edit that re-degenerates any of these
|
||||
fixtures goes red on a row rather than on nothing.
|
||||
|
||||
## 4. ⚠️ THE HONEST LIMITATION, and it is the reason this record exists
|
||||
|
||||
**Under any configuration rule 8 accepts, `binding_soc_floor()` answers
|
||||
`peak_soc_floor` for every pack.** Rule 8 refuses `peak_soc_floor <= bms_soc_floor`
|
||||
on *every* unit, so `max(peak_soc_floor, bms_i) == peak_soc_floor` everywhere the
|
||||
add-on will actually start. The per-pack **floor** is therefore non-degenerate
|
||||
only against a config rule 8 refuses.
|
||||
|
||||
This is not a defect and it is not hidden. It is the same argument
|
||||
`validate.unit_caps_indexed()` makes about itself, in that function's own words:
|
||||
|
||||
> *"Rule 9(d) already refuses such a config so the two lists cannot desynchronise
|
||||
> in shipped use — but 'unreachable' is a claim about today's rule ordering, not
|
||||
> a property of this function, and `validate_startup_config()` is also
|
||||
> `sim/inject.py`'s entry point."*
|
||||
|
||||
CFG-04's index desynchronisation is what happened the last time that reasoning
|
||||
was left implicit. So the reduction is written, and it is graded on both sides:
|
||||
`test_peakguard.py` part 12c sweeps **1775** rule-8-valid `(peak_soc_floor,
|
||||
bms_soc_floor)` pairs and asserts the answer is the option in every one of them
|
||||
(that is AC 5, measured over the whole option space rather than at the shipped
|
||||
point), and then asserts that on the pair rule 8 *refuses* the answer is the
|
||||
**cut-out** — because without that second line the sweep would be satisfied by
|
||||
`soc_floor` typed straight through, and the mutation that deletes the `max()`
|
||||
could never bite. It does bite: M2 on the PR.
|
||||
|
||||
**What IS live at a rule-8-valid roster** is the rest of the card: rule 8 now
|
||||
refuses the two-model config that used to start; the permission is answered per
|
||||
pack, so the alarm names the pack that stopped the site rather than "the
|
||||
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
|
||||
|
||||
## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis
|
||||
|
||||
Rounds 2 and 3 hardened the **floor** axis: the `max` inside
|
||||
`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
|
||||
`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the
|
||||
**sibling argument of the same call**:
|
||||
|
||||
```python
|
||||
self.peak_guard = peakguard.decide(
|
||||
...,
|
||||
soc=self.pack_floor_soc, # MINIMUM <- round 4
|
||||
soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3
|
||||
```
|
||||
|
||||
`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with
|
||||
the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as
|
||||
its weaker half. On `52380c0`, with all 19 files green and verified again after
|
||||
the rebase onto `3934ad7`:
|
||||
|
||||
| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now |
|
||||
|---|---|---|---|
|
||||
| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED |
|
||||
| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED |
|
||||
| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED |
|
||||
| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED |
|
||||
|
||||
The suite's separating power on the SoC axis stopped at exactly **N = 2** —
|
||||
precisely the state the floor axis was in before round 3, and which round 3
|
||||
itself named as its blocker 2. A fixture of N packs cannot separate reductions
|
||||
that agree on N packs.
|
||||
|
||||
**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest
|
||||
pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. The
|
||||
site is then graded for discharge *permission* against a pack 39 points fuller
|
||||
than the emptiest, and spends a pack its own BMS stops — through
|
||||
`sim/inject.py`, which runs no validator rule at all. The verdict flips with it:
|
||||
11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes
|
||||
no forced discharge, where the mutant is permitted and sizes a positive one.
|
||||
|
||||
### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure
|
||||
|
||||
SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE
|
||||
DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and
|
||||
nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that
|
||||
one is measured*, off register `0x0560` on the reference GW5000S-BP.
|
||||
|
||||
So **production is degenerate on the floor axis**: both packs floor at the same
|
||||
value, `max(10, 10) == 10`, and a differing floor exists **only in the
|
||||
fixtures**. That is not an argument for simplifying the fixtures to matching
|
||||
values — it is the argument for keeping them. `binding_soc_floor()`'s reduction
|
||||
is observable *nowhere else*, and the moment a third pack, a replacement pack or
|
||||
a different chemistry arrives it is observable in the field. **Anyone who
|
||||
"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only
|
||||
data this repository has that can see the reduction at all**; part 12c2's
|
||||
precondition check goes red if they do, and this paragraph is why.
|
||||
|
||||
The **SoC axis has no such degeneracy**. Two batteries at the same state of
|
||||
charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of
|
||||
the pair that will be non-degenerate in the field from the day inverter B is
|
||||
wired up.
|
||||
|
||||
### The treatment, transferred from round 3
|
||||
|
||||
* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at
|
||||
every position of a three-pack roster and both positions of a two-pack one,
|
||||
the observable read off `PeakDecision.soc` (the argument, echoed) rather than
|
||||
off a spy or a grep. Cut-outs held flat across all of them, so the floor axis
|
||||
cannot be what moved the answers.
|
||||
* **the positional-slice table and the non-slice table** — computed against the
|
||||
real `Controller.inverters` each cycle carried and the real decision it
|
||||
produced, printed, with a member that nothing kills taking the check red.
|
||||
* **the length sweep** — the real property on real rosters of 1 to 12 inverters,
|
||||
the empty pack at every position. Closes head and tail windows of width 1-11
|
||||
at once. Width 12 and wider is **honestly not reached**, and asserted to be
|
||||
unreached so a later sweep that lengthens the rosters must rewrite the
|
||||
sentence.
|
||||
* **the exact structural pin** — three of them, because there are three
|
||||
reductions over roster length in this one property: `vals` is built by one
|
||||
comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces
|
||||
the whole list; `min(... for v in vals)` reduces the whole list. Every slice
|
||||
of either is red at **every** roster length, including the `[:3]` and `[:12]`
|
||||
the runs and the sweep cannot see.
|
||||
|
||||
Unlike the floor axis, **there is no uncaught row here**: the claim at
|
||||
`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right
|
||||
instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum")
|
||||
and a shape pin there would refuse honest refactors. That asymmetry is why one
|
||||
carries an open row and the other does not.
|
||||
|
||||
## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded
|
||||
|
||||
This card has now been rejected three times for the same shape one step over, so
|
||||
round 4 stopped closing members and enumerated the **family**. Every reduction
|
||||
over `self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||||
mutated to a slice, one at a time, against the full 19-file suite.
|
||||
|
||||
| # | site | reduction | consumer | mutation | result |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) |
|
||||
| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) |
|
||||
| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) |
|
||||
| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) |
|
||||
| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) |
|
||||
| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) |
|
||||
| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) |
|
||||
| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) |
|
||||
| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) |
|
||||
| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) |
|
||||
| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) |
|
||||
| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) |
|
||||
| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) |
|
||||
| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) |
|
||||
| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||||
| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||||
| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below |
|
||||
| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** |
|
||||
|
||||
**Rows 18-20 are the finding, and they are not this card's code.** All three are
|
||||
`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and
|
||||
all three survived a green 19-file suite for exactly the reason every blocker on
|
||||
this card survived: *the only fixture that reached them was a roster of ONE,
|
||||
where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has
|
||||
built a two-inverter roster with only inverter B latched since SAFETY-30
|
||||
shipped — the precise data all three needed — and none of them read it.
|
||||
|
||||
Row 18 is the one with teeth. A latched unit must take the whole roster past the
|
||||
stale-input hold, because that unit's `failsafe` SET is the thing parking it;
|
||||
main.py's own comment says *"the trip would stop acting the moment a poll was
|
||||
missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's
|
||||
SoC returns early, and B's last share stands for the heartbeat to re-emit.
|
||||
test_watchdog.py part 8 now drives that with the latch at **each** position, plus
|
||||
the no-latch counterfactual that proves the hold really fires.
|
||||
|
||||
**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there
|
||||
is now red on 12j's enumeration, but I could not construct a *reachable* state in
|
||||
which it changes an answer: `_grade_store_migration()` runs at startup, before
|
||||
any cycle, so a unit is tripped in memory only if its own `unit/<name>/sign_tripped`
|
||||
key was truthy — which makes `namespaced_latched` True and short-circuits the
|
||||
disjunct. I believe it is an equivalent mutant at that call site rather than a
|
||||
live hole, and I am recording the argument instead of asserting the conclusion.
|
||||
|
||||
**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u
|
||||
in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in
|
||||
one event-log line**. It is +4 bytes and no check bites. The refusal itself —
|
||||
`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different
|
||||
expression and *is* graded (test_peakguard.py part 10h-1b), so the site still
|
||||
stops; what a `[:1]` costs is an operator being told one name when two units have
|
||||
no writer. It is not a roster comprehension (it iterates `resolution.shares`), so
|
||||
12j does not cover it and does not pretend to. Closing it needs a three-inverter
|
||||
fixture with two writer-less entries, which is ALLOC-01's fixture and not this
|
||||
card's. **Filed as open, with its size and its consequence, rather than closed
|
||||
quietly or left unmentioned.**
|
||||
|
||||
### And the family closed rather than the member
|
||||
|
||||
`test_peakguard.py` part 12j enumerates the family from the AST and requires
|
||||
every member to take the roster **whole**, so a new reduction over a slice is red
|
||||
**on the line it is written**, at every roster length, with no fixture required.
|
||||
That check is what would have caught rounds 2, 3 and 4's blockers on the round
|
||||
they were written instead of one review round each; it is also what found rows
|
||||
18-21.
|
||||
|
||||
⚠️ **AND IT WAS STILL TOO NARROW — see §4d.** Round 4's version enumerated the
|
||||
26 roster comprehensions inside the `Controller` class, because that is where
|
||||
the four known sites were. Round 5's blockers were four reductions in
|
||||
`validate.py`, one module over, over a list *derived* from the roster three
|
||||
calls away from it. The pin now parses every `.py` under `app/` and finds the
|
||||
roster by transitive closure from one option key: **183 reductions across 9
|
||||
modules.**
|
||||
|
||||
## 4d. ⚠️ ROUND 5 — the same family one MODULE over, and an instrument that discovers its own scope
|
||||
|
||||
Round 4 enumerated the family and closed it **inside the `Controller` class**,
|
||||
because that is where the four known sites lived. The review found four more
|
||||
survivors immediately, in `validate.py`, on a 19/19 green suite:
|
||||
|
||||
| site | mutation | delta | suite |
|
||||
|---|---|---|---|
|
||||
| `validate.py:779` | `min(caps)` → `min(caps[:1])` | +4 B | **GREEN** |
|
||||
| `validate.py:779` | `min(caps)` → `min(caps[-1:])` | +5 B | **GREEN** |
|
||||
| `validate.py:778` | `caps = [cap for _, cap in caps_ix[:1]]` | +4 B | **GREEN** |
|
||||
| `validate.py:893` | `min(caps_ix[-1:], key=lambda p: p[1])` | +5 B | **GREEN** |
|
||||
|
||||
**None of them is an equivalent mutant — a safety refusal disappears.** With
|
||||
`max_w: 5000` on inverter-a beside `2000` on inverter-b and
|
||||
`sign_disagree_w: 3000`, the shipped validator answers one problem —
|
||||
*"sign_disagree_w is 3000 W but max_w (inverter-b) is 2000 W, so the SAFETY-02
|
||||
sign-disagreement watchdog can never fire"* — and every one of the four answers
|
||||
**zero**. The site starts, with a watchdog reporting `armed` on the status page
|
||||
that cannot trip on pack B. That is rule 5's own failure mode reached **through**
|
||||
rule 5, which is the sentence `test_inverter.py:1657` says the rule exists to
|
||||
prevent. Rule 6 loses its refusal the same way.
|
||||
|
||||
### Why round 4's pin could not see them, and why the fix is not four more checks
|
||||
|
||||
`caps` and `caps_ix` are not comprehensions over `self.inverters`; they are lists
|
||||
**derived** from the roster, three function calls away from it, in a module the
|
||||
enumeration never opened. Closing them by hand would have produced a fifth
|
||||
rejection with the same shape one step over — that is the whole record of this
|
||||
card. **A hand-written scope is the same defect as a hand-written fixture**
|
||||
(MUTATION.md habit 12): a check whose data cannot reach the failure it names.
|
||||
|
||||
### The instrument
|
||||
|
||||
`test_peakguard.py` part 12j now parses **every `.py` under `app/`** off disk —
|
||||
no module is named anywhere in the check — and finds the roster by transitive
|
||||
closure from **one seed**, the `inverters` option key:
|
||||
|
||||
* a function whose returns are roster-carrying is **roster-producing**
|
||||
(`roster_entries` → `build_roster` → `unit_caps_indexed` → `unit_caps`);
|
||||
* a name assigned one carries it, **and so does a list accumulated in a loop
|
||||
over one** — which is exactly what `caps`, `roster` and `problems` are, and
|
||||
what round 4's rule could not express;
|
||||
* an attribute or property answering one carries it (`self.inverters`,
|
||||
`Controller.packs`, `Controller.units`, `live_units()`);
|
||||
* **an argument at a call site taints that parameter in the callee**, which is
|
||||
how the pin walks into `peakguard.binding_soc_floor`, `arbiter.resolve`,
|
||||
`allocator.plan`, `circulation.circulating_w`, `maintenance.arbitrate` and
|
||||
`store.migrate_maint_keys` without being told they exist;
|
||||
* and **a slice of a roster is still a roster**, so a mutation cannot cut the
|
||||
taint and hide every reduction downstream of itself. An **index** is one unit
|
||||
and is not.
|
||||
|
||||
It then requires, at every place a roster-carrying value is reduced or iterated,
|
||||
that the whole of it arrives: no `[:1]`, `[-1:]`, `[:2]` or `[0]` between the
|
||||
roster and the reduction.
|
||||
|
||||
### What it found — and the hand-written list was wrong in BOTH directions
|
||||
|
||||
**183 reductions across 9 modules**, out of 23 `.py` files parsed:
|
||||
`main.py` 96, `allocator.py` 24, `validate.py` 19, `arbiter.py` 14,
|
||||
`circulation.py` 10, `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3,
|
||||
`store.py` 3 — and 14 modules with no roster reduction at all.
|
||||
|
||||
The card's own suggested module list named `watchdog.py`, which reduces over no
|
||||
roster at all (SAFETY-30's sign watchdogs are strictly per-unit — there is
|
||||
nothing to slice), and did **not** name `maintenance.py` or `store.py`, which
|
||||
both do. A pin built from that list would have made a vacuous claim about one
|
||||
module and no claim at all about two others. That is the argument for computing
|
||||
the scope, made with data rather than with an assertion.
|
||||
|
||||
The pin **independently rediscovers all four blockers** — it is told one option
|
||||
key and nothing else — and finds a **fifth** beside them at
|
||||
`validate.py:783`, the `min(caps_ix, key=...)` inside rule 5's message, which no
|
||||
review round named.
|
||||
|
||||
### The counterfactual, at two depths
|
||||
|
||||
* **Per site, all of them.** Every enumerated site's own expression, in its own
|
||||
scope, mutated with each of the four cuts this card has actually been defeated
|
||||
by. **183 sites × 4 cuts = 732** mutated spellings; every one reported. The same run asserts
|
||||
the predicate **accepts** the legitimate wrapped forms the package really uses
|
||||
(`enumerate`, `zip`, `sorted`, `pack_permissions(packs, floor)`) and does not
|
||||
treat a keyed lookup (`weights[u]` in `allocator._fill`) as a slice — which is
|
||||
what four false SLICED reports cost before `shortens()` was written.
|
||||
* **End to end, on the real files.** `[:1]` written into `app/validate.py` at
|
||||
each blocker position, the **whole** analysis re-run over the mutated source,
|
||||
and a sliced site required at that exact line — where the clean package
|
||||
reports none. This is the check that would have taken PR #48 red four rounds
|
||||
ago, with no fixture reaching it.
|
||||
|
||||
### The sweep, and the number that must not be quoted alone
|
||||
|
||||
All 183 sites were mutated to `[:1]`, one at a time, +4 bytes each, and each
|
||||
mutant run against the full 19-file suite in `goodwe-addon-ci` (mounted `:ro`,
|
||||
`python3 -B`, a fresh rsync per mutant; 0 `.pyc` in the tree before and after —
|
||||
a read-only mount cannot write one, so TEST-15 is closed by construction here
|
||||
rather than by discipline). **183 of 183 RED.**
|
||||
|
||||
⚠️ **On its own that is tautological**: with the pin in the tree every slice is
|
||||
red *by the pin*. So all 51 mutants whose first red was `test_peakguard.py` were
|
||||
re-run with the pin **neutralised** (`_SLICED` blanked), leaving only the
|
||||
behavioural checks:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **145 of 183** | a behavioural fixture in this repository separates the site from a slice of itself — red with the pin turned off |
|
||||
| **38 of 183** | **nothing but this pin sees it.** Structurally closed by this card; behaviourally **UNGRADED**, and listed rather than counted as covered |
|
||||
|
||||
The 38: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1,
|
||||
`circulation.py` 1, `maintenance.py` 1. Grouped by what a `[:1]` there costs:
|
||||
|
||||
* **The operator's page — 15, the largest group.** `Controller.checks`
|
||||
(3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, 3400, 3458) and
|
||||
`status_payload` (3556, 3566, 3578). A slice reports one unit's row where two
|
||||
exist, or "1 of 2 live" reading 1 of 1. Nothing commands differently; what is
|
||||
lost is the evidence a human uses to notice that it should.
|
||||
* **The write path's own counts.** `write_setpoint` 1267/1272/1280 — three
|
||||
`len(targets)` guards. The write itself iterates `targets` at 1287 and *is*
|
||||
graded (test_inverter.py); these three are its log arithmetic and refusal
|
||||
count.
|
||||
* **The allocator's internals.** `_fill`'s zero-weight last-resort pool (365),
|
||||
`plan`'s `dropped` (526, 653), `free` (584), `frac_order` (591). ALLOC-01
|
||||
drives two and three units through the *weighted* pass; the last-resort pass
|
||||
and the largest-remainder tie-break are reached, but not with enough units for
|
||||
a slice to change the answer.
|
||||
* **Startup and FSM plumbing.** `__init__` 235/351, `_only` 831,
|
||||
`_maint_button` 805, `cycle`'s maintenance zip 1505, `send_heartbeats` 3015,
|
||||
`check_output_clamp` 2445/2452, `check_circulating` 2701,
|
||||
`maintenance.arbitrate` 547, `arbiter.site_resolve` 517,
|
||||
`circulation.opposing` 242.
|
||||
* **Two refusal-TEXT sites.** `validate._roster_problems`'s `len(entries)` (554)
|
||||
and `refuse()`'s numbering loop (1263) — both shape the words of a refusal
|
||||
that still happens.
|
||||
* **`_grade_store_migration` (698)**, already row 21 of §4c, argued there to be
|
||||
an equivalent mutant at that call site. The argument is unchanged and is still
|
||||
an argument, not a proof.
|
||||
|
||||
⚠️ **None of the 38 is closed behaviourally by this card, and saying so is the
|
||||
point.** Closing sites one at a time is what cost this card four review rounds.
|
||||
The pin closes the **family** — a slice at any of them is red on the line it is
|
||||
written — and this list is what a shape pin can never promise.
|
||||
|
||||
### And the behavioural half, because a shape pin cannot grade a meaning
|
||||
|
||||
The pin grades an **identity** ("the whole roster arrives"). `min` → `max` is a
|
||||
zero-byte edit and invisible to it, forever. So `test_validate.py` grows a
|
||||
**roster SET** for the three rules that reduce over `unit_caps_indexed()`:
|
||||
rosters of width 2, 3, 4 and 5 with the one small cap at **every position** —
|
||||
14 rosters × 3 rules = 42 runs of the real validator — plus a **computed family
|
||||
table** of twelve candidate reductions (`max`, `first`, `last`, the mean, and
|
||||
`[:1]`, `[-1:]`, `[:2]`, `[:3]`, `[1:]`, `[:-1]`, `[::2]`) each compared against
|
||||
what the shipped validator actually said on the same roster. Only `min` agrees
|
||||
everywhere; any member no roster kills is **printed as a named survivor** and
|
||||
takes the check red.
|
||||
|
||||
### ⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
|
||||
|
||||
Found by reading the three branches side by side — **not** by the pin, and said
|
||||
that way because it is the shape of what the pin cannot see. Rule 5's sibling
|
||||
branch, `unit_sign_disagree_w` (SAFETY-30's per-inverter sign watchdog), was
|
||||
graded against `_number(opts, "max_w", 2000.0)` — the **flat** option — three
|
||||
lines under a branch that had already stopped doing that. Both directions were
|
||||
measured before the fix:
|
||||
|
||||
* a roster of two 2000 W units beside a leftover `max_w: 5000` from a
|
||||
single-inverter install, with `unit_sign_disagree_w: 3000`, produced **zero
|
||||
problems**. The site started, the status page said `armed`, and the per-unit
|
||||
watchdog could not trip on **either** unit. Identical sentence to the blocker,
|
||||
reached through the branch beside it;
|
||||
* and a roster of two 5000 W units with no flat key was **refused** at 3000 W
|
||||
against a 2000 W *default the operator never wrote*, for a threshold both
|
||||
units can reach.
|
||||
|
||||
It takes the same `weakest` the branch above takes, from the same list, and
|
||||
names the same unit. At a roster of one `unit_caps()` is `[max_w]`, so the
|
||||
shipped single-inverter refusal is the same comparison, the same boundary and
|
||||
the same sentence. Both directions are asserted in `test_validate.py`.
|
||||
|
||||
### ⚠️ WHAT THE PIN CANNOT SEE — open rows, with sizes
|
||||
|
||||
1. **Semantics.** `min` vs `max` vs the mean: zero-byte edits, invisible to any
|
||||
shape pin. Only the behavioural sets above can see them, and they only see
|
||||
the rules they drive.
|
||||
2. **Direct index sites that are not reductions.** `self.inverters[0]` in
|
||||
`_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in
|
||||
`maint_unit`, `entries[index]` in `_unit_label()`. Single-unit lookups, meant
|
||||
to be, graded where they live.
|
||||
3. **A subscript by a NAME.** `weights[u]`, `cap[u]`, `add[u]` in
|
||||
`allocator._fill` are keyed lookups into a roster-derived mapping and are
|
||||
accepted deliberately, so `caps[i]` with a variable `i` would pass. Not in
|
||||
the family this card has been defeated by; buying it costs four false reports.
|
||||
4. **A slice through an intermediate call.** `sorted(roster)[:1]` subscripts a
|
||||
call result, not a roster reference. Nothing in `app/` is written that way
|
||||
today; if something is, it would not be seen.
|
||||
5. **Anything outside `app/`.** `tools/`, `sim/` and the checks themselves are
|
||||
not parsed — and `sim/inject.py` reaches `validate_startup_config()`.
|
||||
6. **The taint's own seam.** It crosses a call boundary only where the callee is
|
||||
a plain function this package defines and the argument is positional or
|
||||
keyword. A roster handed through `*args`, stored in a dict and read back, or
|
||||
passed to a method on an unresolvable object, is lost at that hop — and
|
||||
everything downstream goes **unenumerated** rather than reported unreachable.
|
||||
That is the shape of the remaining exposure: not zero, and not measurable
|
||||
from inside the check.
|
||||
7. **`cycle()`'s undeliverable-unit log line** (row 22 of §4c) is still green at
|
||||
+4 bytes, unchanged from round 4 and still filed open.
|
||||
|
||||
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
|
||||
|
||||
`peak_soc_floor` is still **one number for the site**, so the card's own
|
||||
"or the deeper pack is needlessly withheld" half is not closed and this record
|
||||
does not pretend it is. With cut-outs of 10 % and 18 %, rule 8 obliges the
|
||||
operator to set the EMS floor above 18, and the 10 % pack is then held above its
|
||||
own need. Closing that means `peak_soc_floor` becoming a per-entry key too, which
|
||||
is a second option migration and a product decision about what the site-wide
|
||||
number then means. The card scoped itself to `bms_soc_floor` and explicitly told
|
||||
this developer not to widen it, so it is filed here as the open question it is
|
||||
rather than smuggled in.
|
||||
|
||||
**Also still not in this card, and named by the shipped code already:** the
|
||||
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
|
||||
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
|
||||
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
|
||||
per-pack bound. Nothing here touched it.
|
||||
|
||||
## 6. What was changed, and one thing that was found on the way
|
||||
|
||||
* `config.yaml` — `bms_soc_floor: int(0,50)` required per `inverters:` entry;
|
||||
the flat key and its default are untouched and are documented as the
|
||||
single-inverter shape's own cut-out.
|
||||
* `app/inverter.py` — `Inverter.bms_soc_floor`; `_entry_bms_floor()` (`None`, not
|
||||
a borrowed number and not `0.0`); `_flat_bms_floor()`.
|
||||
* `app/peakguard.py` — `Pack`, `pack_floor_pct()`, `pack_permissions()`,
|
||||
`binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
|
||||
one place the question is answered.
|
||||
* `app/validate.py` — `unit_bms_floors_indexed()`; rule 8 grades every unit and
|
||||
names it via the **roster** index the value came off.
|
||||
* `app/main.py` — `Controller.packs`; the `decide(soc_floor=…)` reduction; the
|
||||
floor-limited notification names the pack(s) at or below their own floor, at a
|
||||
roster of two or more only.
|
||||
* `tools/supervisor_shape_probe.py` — **re-run, and repaired.** MUTATION.md
|
||||
habit 8: a committed transcript is a check and it decays. Making the key
|
||||
required broke §C outright and, worse, left §D printing **PASS** on a refusal
|
||||
about the *wrong key* — its "no `max_w`" case was answered `Missing option
|
||||
'bms_soc_floor'`. §D is now an isolated counterfactual over a **merged,
|
||||
accepted** baseline with exactly one key varied per case, asserting the key's
|
||||
**name** in the refusal, and it carries a `bms_soc_floor` case in both
|
||||
directions. Re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`:
|
||||
`all cases passed`.
|
||||
* `app/inverter.py`, found by the mutation battery — `_flat_bms_floor()` was
|
||||
written with the default in **two** places (the `.get()` fallback *and* the
|
||||
blank test). Deleting either one survived, because each covered for the other:
|
||||
MUTATION.md's masking section in three lines of option reading. It has one
|
||||
defaulting line now, and M12 takes six checks red.
|
||||
|
||||
* **Round 3 changed no `app/` file at all.** `test_peakguard.py` only:
|
||||
parts 12c2 and 12g. Its battery was **46 mutations across
|
||||
`binding_soc_floor()`'s reduction, the `decide()` call site, `Controller.packs`
|
||||
and `pack_floor_pct()`'s conjunction** — bytecode purged and counted before and
|
||||
after each (23 → 0). **43 red, 3 survivors, all three recorded above and in the
|
||||
in-process rows**: `max(floors[:12])` and `max(floors[-12:])`, which need a
|
||||
13-pack roster to observe and are reported with that size rather than hidden;
|
||||
and `max(set(floors))`, which is **equivalent** — `max` is duplicate-invariant,
|
||||
so it cannot change an answer. `list(reversed(self.packs))` is equivalent too
|
||||
(`max` is order-invariant) and is nonetheless red on the structural pin.
|
||||
|
||||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||||
physically exist. Every second-pack number in a fixture, in the probe and in
|
||||
config.yaml's commented example is **simulated and labelled as such**; only the
|
||||
10 % on inverter A is read off hardware. The three-pack runs added in round 3 are simulated
|
||||
twice over: there is no inverter C at all, and the shallow slots carry inverter
|
||||
A's measured 10 % while the 35 % cut-out that moves along the roster is invented.
|
||||
|
||||
---
|
||||
|
||||
## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0
|
||||
|
||||
Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring):
|
||||
|
||||
```
|
||||
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" \
|
||||
--entrypoint python3 \
|
||||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||||
/probe/supervisor_shape_probe.py
|
||||
```
|
||||
|
||||
It reads `config.yaml` and runs Supervisor's own `AppOptions` validator in a
|
||||
throwaway container. It touches no live Supervisor, POSTs nothing and installs
|
||||
nothing.
|
||||
|
||||
⚠️ **RE-RUN AGAIN ON 2026-09-05**, because the rebase onto `67e649a` merged
|
||||
SPIKE-04a's `max_w: 4600` into the same §C/§D blocks this card had rewritten,
|
||||
and MUTATION.md habit 8 says a committed transcript that a document quotes is a
|
||||
check. A hand-merged transcript is not a measurement of anything. The block
|
||||
below is the output of that run, not of the 2026-09-04 one: B's rating reads
|
||||
**4600** — its datasheet figure, still not a measurement of any unit — and every
|
||||
case still passes.
|
||||
|
||||
```
|
||||
== C. AC2: a two-inverter blob an operator would POST ==
|
||||
PASS two inverters
|
||||
[{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000, "bms_soc_floor": 10}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_so
|
||||
|
||||
== D. what the schema itself refuses, per entry ==
|
||||
PASS the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere else to come from
|
||||
[{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600, "bms_soc_floor": 10}]
|
||||
PASS an entry with no name is refused, and the refusal NAMES it
|
||||
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no setpoint_entity is refused, and the refusal NAMES it
|
||||
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no max_w is refused, and the refusal NAMES it
|
||||
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with no bms_soc_floor (SAFETY-32) is refused, and the refusal NAMES it
|
||||
Missing option 'bms_soc_floor' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
PASS an entry with max_w over the range is refused, and the refusal NAMES it
|
||||
value must be at most 5000.0
|
||||
PASS an entry with bms_soc_floor over the range (SAFETY-32) is refused, and the refusal NAMES it
|
||||
value must be at most 50.0
|
||||
PASS a JSON STRING where the list belongs is refused
|
||||
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
|
||||
|
||||
== E. the ROLLBACK direction, and the operator's UI ==
|
||||
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
|
||||
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
|
||||
PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
|
||||
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
|
||||
|
||||
== all cases passed ==
|
||||
```
|
||||
@@ -78,9 +78,20 @@ inverters:
|
||||
setpoint_entity: number.goodwe_b_setpoint_w
|
||||
soc_entity: sensor.goodwe_b_soc
|
||||
batt_entity: sensor.goodwe_b_battery_power
|
||||
max_w: 3000
|
||||
max_w: 4600
|
||||
```
|
||||
|
||||
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
|
||||
GoodWe GW5048D-ES (serial `55048ESU22AW0992`, identified 2026-09-04) whose
|
||||
datasheet gives max charging and discharging power as 4600 W; with nothing
|
||||
connected to its PV inputs the battery path is the ceiling, not the 5000 VA AC
|
||||
nameplate. The datasheet prints that number under *Battery Input Data*, so it
|
||||
may be a DC figure, while `max_w` is graded against register `0x0518` — the
|
||||
inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
|
||||
`max_w: 4600` is still the right thing to configure, because the freeze rail is
|
||||
`min(max_w, dev_max)` and the device range read off the unit is what binds.
|
||||
`goodwe/NOTES.md` §1.1b records both readings and picks neither.
|
||||
|
||||
| per-entry key | required | meaning |
|
||||
|---|---|---|
|
||||
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
|
||||
@@ -554,7 +565,7 @@ help - see "Why the tuning is what it is".
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||
| `peak_cap_w` | The site's capacity-tariff target |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start. ⚠️ **At a declared `inverters:` roster this key moves INTO each entry and is REQUIRED there** (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused **by name** at startup, and the flat key below is then not read at all |
|
||||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||||
|
||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||
|
||||
@@ -135,11 +135,19 @@ manufactures them from a **fixture**: the check runs, the assertion is
|
||||
evaluated, the sentence above it is true — and the numbers it compares are
|
||||
arranged so that the failure it describes could not have changed the answer.
|
||||
|
||||
It is not hypothetical and it is not rare. Six instances in two weeks; three
|
||||
It is not hypothetical and it is not rare. EIGHT instances in two weeks; four
|
||||
were found by review, two - CFG-04's fixture pair - by an author's own mutation
|
||||
battery, which is the cheaper place, and the sixth by review again, in the one
|
||||
battery, which is the cheaper place, one by review again, in the one
|
||||
place nobody had thought to point the rule at: not a check in the suite, but a
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite.
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX
|
||||
WRITTEN TO CLOSE THE SEVENTH.
|
||||
|
||||
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
|
||||
MAINT-05's, is the one that says why: the defect is not carelessness about
|
||||
fixtures, it is that **a check and the thing it checks were built from the same
|
||||
constant**. Ask of every difference-claim not "is this assertion true" but
|
||||
"where did each side of this comparison come from" - and if the answer is the
|
||||
same place twice, the check is decoration.
|
||||
|
||||
* **ARB-03** shipped a call-site scan asserting that every `claims.append` in
|
||||
`cycle()` routes through `actuator_of()`. Its own parse **saw 3 of the 8
|
||||
@@ -167,6 +175,44 @@ COMMITTED EVIDENCE ARTIFACT four documents cite.
|
||||
than against its behaviour. It now drives the shipped `run_control()` on a
|
||||
two-inverter config, **under a deadline**, because the mutation's real symptom
|
||||
is that the controller enters its loop and never returns.
|
||||
* **MAINT-05** shipped `Controller.__init__` calling
|
||||
`migrate_maint_keys(store, self.inverters[0].name, ...)` under a comment
|
||||
saying *"it targets `self.inverters[0].name` and NOT `UNIT_A` ... writing
|
||||
`UNIT_A` here would deposit an install's whole maintenance history under a
|
||||
name that is not on the roster at all"*. `inverter.UNIT_A` **is** the string
|
||||
`"inverter-a"`, and every two-pack fixture in `test_maintenance.py` named its
|
||||
first roster entry `inverter-a` — so the two expressions were the same value
|
||||
in every check that existed, and swapping them left **all 19 files green**
|
||||
while losing a real install's whole maintenance history. Two-pack fixtures are
|
||||
now `["garage", "shed"]`, each behind a precondition check (habit 1) that the
|
||||
first name is not `UNIT_A`.
|
||||
|
||||
⚠️ **AND THE POSTCONDITION HAD THE SAME SHAPE AS THE FIXTURE.**
|
||||
`store._verify_moves()` read back the keys it had just written, under the
|
||||
namespace it had just been handed — so it verified clean, and the caller
|
||||
stamped the schema marker, on a migration nothing would ever read. *A check
|
||||
that reads back the namespace it was handed cannot detect being handed the
|
||||
wrong one*: it compared a thing against itself, exactly as DEV-02's fixture
|
||||
compared `(-5000, 5000)` against `(-5000, 5000)`. The habit-2 fix is the same
|
||||
one — grade against something the thing under test did not supply, here the
|
||||
ROSTER the machines read.
|
||||
|
||||
⚠️ **AND THE FIX ITSELF CARRIED THE DEFECT, WHICH IS THE SIXTH CARD IN A ROW
|
||||
AND THE FIRST TIME IT HAS APPEARED *INSIDE* A REMEDY.** The fix round's
|
||||
recovery check was `migrate_maint_keys(_m5, ROSTER[0], ROSTER)` — the
|
||||
**corrected** name. It measures recovery along the one path where the operator
|
||||
has *already fixed the bug*, and nothing in the 19 files called
|
||||
`migrate_maint_keys()` twice with the **same wrong** `unit`. So the refusal
|
||||
the card was rejected twice to obtain lasted **exactly one start**: measured
|
||||
at Controller level over one persisted store, roster `["garage", "shed"]` —
|
||||
construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=<absent>`,
|
||||
construction 2 `ok=True misdirected=0 moved=0 kept=4 marker=1`. On start 2
|
||||
rule 1 routes the wrongly-written keys to `rep.kept`, `rep.moved` is empty,
|
||||
the roster loop iterates over nothing, and the marker is stamped on a
|
||||
migration that reached no readable key — after which the early return skips it
|
||||
forever, *including after the call site is fixed*. The check was true and
|
||||
passed for the wrong reason. Habit **9** below is what falls out of it.
|
||||
|
||||
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
|
||||
reaching outside the suite. The probe is the committed evidence that
|
||||
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
||||
@@ -241,6 +287,81 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
red. `inverters: []` was deleted from `config.yaml` and the probe re-run
|
||||
against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` for
|
||||
exactly that reason.
|
||||
9. **Exercise a refusal a SECOND time with the SAME wrong input — not the
|
||||
corrected one.** A guard that refuses once and then admits the same bad
|
||||
caller is not a guard, it is a delay, and the second call is a *different
|
||||
code path* whenever the first call left state behind. MAINT-05's roster
|
||||
grading iterated the keys it had just **written**; on the second start the
|
||||
same wrong caller found them already present, they moved to the
|
||||
`already namespaced` list, and the loop graded an empty set. Re-running with
|
||||
the **fixed** argument — which is what the check did — measures the operator's
|
||||
recovery, never the persistence of the refusal. Ask of every refusal: *what
|
||||
does this do when the caller has not been fixed and simply runs again?* And
|
||||
assert the **shape** of that second report, not only its verdict, so the
|
||||
check cannot silently revert to grading the first path.
|
||||
|
||||
10. **A fixture of N members cannot separate two reductions that agree on N
|
||||
members — so grade the FAMILY, and grade it from the SOURCE.** This is the
|
||||
only lesson SAFETY-32 cost four review rounds to learn, and it generalises
|
||||
past packs to every roster, list and window in this repository. Two packs
|
||||
cannot tell `max` from `min` from first from last. Three cannot tell `max`
|
||||
from `max(x[-2:])`. **The regress does not terminate in a fixture**: `[:1]`
|
||||
needs two members, `[:2]` three, `[:3]` four, and adding one more member
|
||||
moves the hole rather than closing it. Three things follow, and all three
|
||||
are now in `test_peakguard.py` parts 12c2, 12g, 12i and 12j:
|
||||
* the fixture is a **SET** — the extreme at every position of the roster,
|
||||
not one roster with the extreme somewhere;
|
||||
* the family table is **COMPUTED and PRINTED**, so a member no fixture kills
|
||||
shows up as a named survivor and takes the check red, instead of being
|
||||
absent from a list nobody re-derived;
|
||||
* where the claim is an **IDENTITY** ("this roster", "this list"), pin it
|
||||
**exactly on the AST** — `ast.unparse(arg) == "self.packs"` is false of
|
||||
every slice, where `"self.packs" in src` is true of all of them. Where the
|
||||
claim is **SEMANTIC** ("the maximum"), a shape pin would refuse honest
|
||||
refactors, so report the unreached width **with its size** instead.
|
||||
|
||||
11. **When a check of this shape is found, mutate its SIBLINGS before shipping
|
||||
the fix.** SAFETY-32 was rejected three times for the same defect one step
|
||||
over: the reduction, then the roster handed to it, then the reduction handed
|
||||
to the *other argument of the same call*. Enumerating the family from the
|
||||
AST and mutating each member took one afternoon and found **four more
|
||||
survivors in code the card never touched** — three of SAFETY-30's
|
||||
`any(i.sign_wd.tripped for i in self.inverters)` reductions, invisible
|
||||
because the only fixture that reached them was a roster of ONE, where
|
||||
`self.inverters[:1]` **is** `self.inverters`. Ask of every fix: *what is the
|
||||
sibling of the thing I just hardened, and does any fixture separate it from
|
||||
a slice of itself?*
|
||||
|
||||
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
|
||||
@@ -297,6 +418,8 @@ commit and PR bodies.
|
||||
| **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. **Now CLOSED by TEST-13** (`test_tariff.py` section 7), which round-trips section 7's own unbalanced `(3000, 500, -200) W` block THROUGH the store under each policy - three distinct banked figures, so a hardcoded policy takes at least two of them red. Third instance of "a stimulus evaluated where the quantity it grades is degenerate", after WITNESS-02's span normalisation and TARIFF-01's own DST window. |
|
||||
| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, then 9 more after review, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All are **reds**, which is the direction this defect cannot fabricate. ⚠️ The second round exists because the first round's mutations were all aimed at DISARMING the guard, and the claim part 10 actually made was about the guard being FIXED - the reviewer built the fix and the suite stayed green. Bytecode was never the issue there; an unaimed battery was. |
|
||||
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
|
||||
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
|
||||
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
|
||||
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
|
||||
|
||||
**Nothing here says a past result is wrong.** The two survivors re-run above
|
||||
|
||||
@@ -15,6 +15,11 @@ The scalars, and what each one was silently asserting:
|
||||
SAFETY-24's pack floor and arbitrage's bounds. An
|
||||
average lets the emptier pack be driven below its own
|
||||
floor; a maximum parks the fuller one.
|
||||
`bms_soc_floor` SAFETY-32. ONE cut-out percent for every pack, and it
|
||||
describes A BATTERY. Two packs of different models
|
||||
have different cut-outs, so the flat key graded the
|
||||
shallower pack against the deeper one's BMS - wrong
|
||||
in the direction that empties a pack.
|
||||
`batt` ONE battery power into the anti-windup saturation
|
||||
detector - see the SITE/UNIT note at the bottom of
|
||||
this docstring, because this one goes the OTHER WAY.
|
||||
@@ -117,6 +122,12 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
# shipped default and a literal here would be the fourth defaults site that
|
||||
# ticket exists to prevent. peakguard imports arbiter/control/tariff and none
|
||||
# of those imports this module, so the direction is one-way.
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .store import unit_key
|
||||
from .watchdog import SignWatchdog, SignWatchdogConfig
|
||||
|
||||
@@ -170,7 +181,7 @@ class Inverter:
|
||||
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
store=None, sign_cfg=None):
|
||||
bms_soc_floor=None, store=None, sign_cfg=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
@@ -179,6 +190,19 @@ class Inverter:
|
||||
# The supervised cap on what may be written to THIS setpoint entity.
|
||||
# See the module docstring on why this is a number and not a `Tuning`.
|
||||
self.max_w = float(max_w)
|
||||
# SAFETY-32. THIS PACK'S OWN BMS CUT-OUT, in percent, or None when the
|
||||
# roster entry did not state one.
|
||||
#
|
||||
# ⚠️ NOT DEFAULTED HERE, AND None RATHER THAN A NUMBER. A cut-out is a
|
||||
# property of the battery in the cupboard; inventing one for a pack that
|
||||
# did not state it is precisely this card's defect - a pack graded
|
||||
# against a floor that belongs to a different battery - rebuilt in the
|
||||
# constructor. `peakguard.pack_floor_pct()` treats None as an unknown
|
||||
# floor and `discharge_permitted()` treats an unknown floor as NO
|
||||
# permission, so the absence costs a shave; validate.py rule 8 refuses
|
||||
# the config by name long before that, at startup.
|
||||
self.bms_soc_floor = (None if bms_soc_floor is None
|
||||
else float(bms_soc_floor))
|
||||
|
||||
# --- the device's own reported range, read from its own entity -------
|
||||
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
||||
@@ -339,8 +363,9 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
||||
`inverters:` empty?
|
||||
|
||||
EMPTY the five flat keys - `setpoint_entity`, `soc_entity`,
|
||||
`batt_entity`, `batt_invert`, `max_w` - describe the ONE inverter,
|
||||
EMPTY the six flat keys - `setpoint_entity`, `soc_entity`,
|
||||
`batt_entity`, `batt_invert`, `max_w`, `bms_soc_floor` (SAFETY-32)
|
||||
- describe the ONE inverter,
|
||||
exactly as the shipped scalars did. This is what every install
|
||||
that upgrades into this version reads, without an operator
|
||||
touching anything, and it is byte-identical to what DEV-02
|
||||
@@ -353,8 +378,8 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
|
||||
because "this unit's number came from somewhere else" is the
|
||||
defect DEV-02 spent a card removing and a fallback here would
|
||||
rebuild it in three lines. `name`, `setpoint_entity` and `max_w`
|
||||
are required of every entry - the schema enforces it on the
|
||||
rebuild it in three lines. `name`, `setpoint_entity`, `max_w` and
|
||||
`bms_soc_floor` (SAFETY-32) are required of every entry - the schema enforces it on the
|
||||
Supervisor path, `validate.py` rule 9 enforces it for sim/inject.py
|
||||
and the checks, which reach this function with any dict at all.
|
||||
|
||||
@@ -380,6 +405,12 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
batt_entity=opts.get("batt_entity", ""),
|
||||
batt_invert=bool(opts.get("batt_invert")),
|
||||
max_w=float(opts.get("max_w", 2000)),
|
||||
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
|
||||
# - the one pack the flat options describe is the one pack whose
|
||||
# cut-out `bms_soc_floor` states. Defaulted from CFG-03's single
|
||||
# mirror exactly as validate.py rule 8 defaults it, so an install
|
||||
# that never set the key reads the same 10 % in both places.
|
||||
bms_soc_floor=_flat_bms_floor(opts),
|
||||
store=store, sign_cfg=sign_cfg)]
|
||||
|
||||
roster = []
|
||||
@@ -401,10 +432,54 @@ def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
||||
# ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did
|
||||
# not state is not the flat one, it is a rating rule 9 refuses.
|
||||
max_w=_entry_max_w(entry),
|
||||
# ⚠️ NOT `opts["bms_soc_floor"]`, FOR `max_w`'S REASON AND THIS
|
||||
# CARD'S. An entry that omits its cut-out has NOT got the flat one;
|
||||
# it has none, and rule 8 refuses it by name. Borrowing here would be
|
||||
# SAFETY-32's own defect written in three lines - the shallower pack
|
||||
# graded against the deeper pack's BMS.
|
||||
bms_soc_floor=_entry_bms_floor(entry),
|
||||
store=store, sign_cfg=sign_cfg))
|
||||
return roster
|
||||
|
||||
|
||||
def _flat_bms_floor(opts: dict):
|
||||
"""The single-inverter shape's own cut-out, or None if it is unparseable.
|
||||
|
||||
Defaulted from `PEAK_OPTION_DEFAULTS` when the key is absent or blank, which
|
||||
is `validate._number()`'s behaviour on the same key - the two have to agree
|
||||
or the validator would grade a number the roster does not build.
|
||||
"""
|
||||
# ⚠️ ONE defaulting line, not two. The first cut wrote the fallback into the
|
||||
# `.get()` AND into the blank test below it, and a mutation that deleted
|
||||
# either one survived - each was covering for the other, which is
|
||||
# MUTATION.md's masking section in three lines of configuration reading.
|
||||
raw = opts.get("bms_soc_floor")
|
||||
if raw is None or raw == "":
|
||||
return float(PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _entry_bms_floor(entry: dict):
|
||||
"""One entry's OWN cut-out, or None when it did not state a usable one.
|
||||
|
||||
None rather than a borrowed number and rather than 0.0 - `_entry_max_w()`
|
||||
can answer 0.0 because a zero rating commands nothing, but a zero FLOOR is
|
||||
the most permissive value the key has and would read as "this pack may be
|
||||
emptied". Rule 8 refuses the missing key by name; until it does, None
|
||||
withholds.
|
||||
"""
|
||||
raw = entry.get("bms_soc_floor")
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _entry_max_w(entry: dict) -> float:
|
||||
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
||||
|
||||
|
||||
+368
-110
@@ -54,14 +54,16 @@ from .hass import HomeAssistant
|
||||
# window an operator would look up here is the one the roster actually uses.
|
||||
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||
roster_entries)
|
||||
from .maintenance import IDLE, MaintConfig, Maintenance
|
||||
from .maintenance import (
|
||||
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
|
||||
)
|
||||
from .mqtt import MqttPublisher
|
||||
from . import peakguard
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
from .store import migrate_unit_keys
|
||||
from .store import migrate_maint_keys, migrate_unit_keys
|
||||
from .validate import (ConfigRefused, refuse, validate_startup_config,
|
||||
validate_unit_ranges)
|
||||
from .watchdog import (
|
||||
@@ -265,7 +267,66 @@ class Controller:
|
||||
# maintenance.MAINT_OPTION_DEFAULTS before adding a ninth key: it goes
|
||||
# in config.yaml's `options:` and `schema:` blocks and in that dict, and
|
||||
# nowhere else.
|
||||
self.maint = Maintenance(MaintConfig.from_options(opts), store)
|
||||
# --- MAINT-05: ONE `Maintenance` PER ROSTER ENTRY --------------------
|
||||
# ⚠️ AND THE MIGRATION RUNS FIRST, FOR THE REASON THE SAFETY-02 ONE
|
||||
# DOES. These objects READ their history in their constructors' wake -
|
||||
# `phase`, `last_completed` - so a history still sitting under the flat
|
||||
# spelling when they are built is a pack that comes up believing it has
|
||||
# never been recalibrated, on a site whose state file says otherwise.
|
||||
# It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys
|
||||
# belonged to whichever pack the one FSM was driving, which is the first
|
||||
# roster entry, and a declared `inverters:` roster names its own entries
|
||||
# (`inverter.build_roster()` says so, and its fallback for a nameless
|
||||
# entry is a POSITIONAL `inverter-1`, so `inverter-a` is not even the
|
||||
# default there). Writing `UNIT_A` here would deposit an install's whole
|
||||
# maintenance history under a name that is not on the roster at all.
|
||||
#
|
||||
# ⚠️ AND THAT SENTENCE USED TO BE A COMMENT NOTHING COULD GRADE, WHICH
|
||||
# IS WHY THE ROSTER IS PASSED RATHER THAN A COUNT. Every two-pack
|
||||
# fixture in the suite named its first entry `inverter-a`, so
|
||||
# `self.inverters[0].name` and `UNIT_A` were the same string in every
|
||||
# check that existed and swapping them left all 19 files green - while
|
||||
# on a roster of `["garage", "shed"]` it silently loses the history and
|
||||
# stamps the marker, so the next start skips the migration too.
|
||||
# `migrate_maint_keys()` now grades the namespace it wrote against the
|
||||
# names the machines read; test_maintenance.py sections 9 and 11 drive
|
||||
# a `["garage", "shed"]` roster, which is what makes the mutation red
|
||||
# instead of invisible.
|
||||
self.maint_migration = migrate_maint_keys(
|
||||
store, self.inverters[0].name, [inv.name for inv in self.inverters])
|
||||
_LOG.info("maintenance history: %s", self.maint_migration.explain())
|
||||
if not self.maint_migration.ok:
|
||||
# Not a disarmed guard - history, not a latch - so it does not
|
||||
# re-arm anything. It is still the difference between a pack that is
|
||||
# recalibrated on schedule and one that silently is not, which is
|
||||
# §12's opening sentence, so it is said where an operator looks.
|
||||
self.log_event("maintenance history did NOT migrate cleanly: "
|
||||
+ self.maint_migration.explain())
|
||||
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
|
||||
# PROCEDURE - how hard to drain, how full to charge, how long to hold -
|
||||
# and the procedure is the same on every pack in the roster; what is per
|
||||
# pack is the SCHEDULE and the HISTORY, which live in the store. A
|
||||
# per-entry config block would be a second shape for CFG-04's roster to
|
||||
# carry and a fifth place for a `maintenance_charge_w` to disagree with
|
||||
# config.yaml. If a site ever needs two different procedures, that is a
|
||||
# roster-schema card and it starts here.
|
||||
_maint_cfg = MaintConfig.from_options(opts)
|
||||
_one_pack = len(self.inverters) <= 1
|
||||
self.maints = [
|
||||
Maintenance(
|
||||
_maint_cfg, store,
|
||||
# ⚠️ `None` AT A ROSTER OF ONE IS THE FLAT LAYOUT, NOT A MISSING
|
||||
# NAME. §6.2's identity: one pack, one history, and the keys it
|
||||
# has been written under since the add-on shipped. See
|
||||
# `maintenance.UnitHistory` and `store.migrate_maint_keys()` -
|
||||
# this is the line that keeps a one-inverter state file
|
||||
# byte-identical.
|
||||
unit=None if _one_pack else inv.name,
|
||||
name=inv.name,
|
||||
# The rollback view, and only ever the first pack's: the flat
|
||||
# keys mean `inverters[0]` on every install that has them.
|
||||
mirror_flat=(not _one_pack and i == 0))
|
||||
for i, inv in enumerate(self.inverters)]
|
||||
|
||||
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
|
||||
# docstring has always said and what this Controller did not do. Each
|
||||
@@ -453,6 +514,9 @@ class Controller:
|
||||
self._peak_saturated = False
|
||||
self._peak_floor_limited = False
|
||||
self._maint_shape_suspended = False
|
||||
# [(pack name, why)] for packs that are due and waiting their turn.
|
||||
# Empty at a roster of one, where nothing can defer anything.
|
||||
self._maint_deferred = []
|
||||
# Set by amain() once the transport is built, so the status page can show
|
||||
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||
# None when P1 is off, or under a transport that has no such counter.
|
||||
@@ -667,18 +731,79 @@ class Controller:
|
||||
return tuple(inv.name for inv in self.inverters)
|
||||
|
||||
@property
|
||||
def maint_unit(self):
|
||||
"""The pack the ONE maintenance state machine is recalibrating.
|
||||
def maint_index(self) -> int:
|
||||
"""Which roster slot holds the site's ONE maintenance slot right now.
|
||||
|
||||
⚠️ NAMED RATHER THAN ASSUMED. This add-on holds exactly one
|
||||
`Maintenance`, whose history lives under the store's un-namespaced
|
||||
`phase` / `last_completed` keys, so the pack it drives is the first in
|
||||
the roster. Two packs recalibrating on two schedules needs a second FSM
|
||||
and a namespaced history of its own; what THIS card fixes is only that
|
||||
the claim is filed on ONE actuator instead of on whichever one happened
|
||||
to be asked. Read store.LEGACY_UNIT_KEYS on why those keys did not move.
|
||||
The pack that is mid-cycle if there is one, otherwise the first - which
|
||||
at a roster of one is always 0, always was, and is the whole of this
|
||||
property's N=1 behaviour.
|
||||
"""
|
||||
return self.inverters[0]
|
||||
for i, m in enumerate(self.maints):
|
||||
if m.phase in POWER_MOVING:
|
||||
return i
|
||||
return 0
|
||||
|
||||
@property
|
||||
def maint(self):
|
||||
"""The `Maintenance` the site's one maintenance slot belongs to.
|
||||
|
||||
⚠️ MAINT-05 TURNED THIS FROM AN ATTRIBUTE INTO A VIEW, AND THE THING IT
|
||||
IS A VIEW OF CHANGED WITH IT. There are now `self.maints` - one FSM per
|
||||
roster entry, each with its own schedule, phase and history - and every
|
||||
SITE-level surface that used to read `self.maint` (the `phase` MQTT
|
||||
value, the status banner, the operator's start/abort buttons) wants THE
|
||||
ONE THAT IS RECALIBRATING, not "the first one". `maintenance.arbitrate()`
|
||||
guarantees at most one pack is ever out of IDLE, so "the one that is
|
||||
recalibrating" is well defined; when none is, this is `maints[0]` and
|
||||
the surfaces read exactly what they read before.
|
||||
|
||||
⚠️ AT A ROSTER OF ONE IT IS `maints[0]` ON EVERY TICK, in both branches,
|
||||
which is the object that used to be assigned here. Nothing downstream
|
||||
can tell the difference - that is AC 6, and test_maintenance.py part 9
|
||||
asserts it rather than trusting this paragraph.
|
||||
"""
|
||||
return self.maints[self.maint_index]
|
||||
|
||||
@property
|
||||
def maint_unit(self):
|
||||
"""The pack the site's one maintenance slot is currently on.
|
||||
|
||||
⚠️ THIS USED TO BE `self.inverters[0]`, FLATLY, AND THE COMMENT THAT
|
||||
STOOD HERE EXPLAINED WHY: there was exactly one `Maintenance`, its
|
||||
history lived under the store's un-namespaced `phase` / `last_completed`
|
||||
keys, so the pack it drove could only be the first in the roster. That
|
||||
sentence is what MAINT-05 removed. There is now one FSM and one
|
||||
namespaced history PER ROSTER ENTRY, so the pack being recalibrated is
|
||||
whichever one holds the site's maintenance slot, and `maint_index` -
|
||||
not the roster's order - is what answers it.
|
||||
|
||||
⚠️ AND `validate.py` RULE 6 MOVED WITH IT, exactly as its own comment
|
||||
said it must ("when `maint_unit` stops being `inverters[0]`, this line
|
||||
moves with it"). It graded `maintenance_charge_w` against the FIRST
|
||||
entry's cap on the argument that the cycle never touches the others;
|
||||
that argument is now false, so it grades against the weakest cap on the
|
||||
roster, like rule 5. See the ⚠️ block there.
|
||||
"""
|
||||
return self.inverters[self.maint_index]
|
||||
|
||||
def _maint_button(self, now):
|
||||
"""The pack an operator's one maintenance button acts on.
|
||||
|
||||
The pack holding the site's maintenance slot when one holds it, so
|
||||
"abort" stops the cycle that is running and "start" restarts it -
|
||||
exactly what both buttons meant while there was one pack, and the reason
|
||||
this is not simply "the longest-waiting one". When every pack is idle it
|
||||
IS the longest-waiting one, which is `arbitrate()`'s own clause 2, so a
|
||||
forced start and a scheduled start choose the same pack rather than two
|
||||
different ones.
|
||||
|
||||
⚠️ AT A ROSTER OF ONE BOTH BRANCHES ARE `maints[0]`. There is no third
|
||||
case: `max()` over a one-element list is that element.
|
||||
"""
|
||||
if self.maints[self.maint_index].phase in POWER_MOVING:
|
||||
return self.maints[self.maint_index]
|
||||
return max(enumerate(self.maints),
|
||||
key=lambda p: (p[1].waited_days(now), -p[0]))[1]
|
||||
|
||||
def inverter(self, name: str):
|
||||
"""The roster entry called `name`, or None."""
|
||||
@@ -940,6 +1065,26 @@ class Controller:
|
||||
return None
|
||||
return float(min(float(v) for v in vals))
|
||||
|
||||
@property
|
||||
def packs(self) -> list:
|
||||
"""SAFETY-32. Each pack as its own THREE numbers, in roster order.
|
||||
|
||||
`pack_floor_soc` above is the site's ONE verdict and stays exactly what
|
||||
DEV-02 made it - the emptiest measured pack. This is the thing that
|
||||
reduction cannot produce: by the time `min()` has answered, WHICH pack
|
||||
the number came from and how far that pack was from ITS OWN cut-out have
|
||||
both been thrown away, and two packs at 15 % against cut-outs of 10 % and
|
||||
20 % have opposite answers that are indistinguishable afterwards.
|
||||
|
||||
⚠️ EVERY unit, live or not. Same reason as `inputs_ok`: a pack whose SoC
|
||||
did not answer is a pack nothing can grade, and dropping it from this
|
||||
list would let the site spend the packs that DID answer while one sat
|
||||
unmeasured. `peakguard.discharge_permitted()` treats a None SoC as no
|
||||
permission, which is where that must be decided.
|
||||
"""
|
||||
return [peakguard.Pack(inv.name, inv.soc, inv.bms_soc_floor)
|
||||
for inv in self.inverters]
|
||||
|
||||
@property
|
||||
def inputs_ok(self) -> bool:
|
||||
"""The site meter, and EVERY pack's own two readings.
|
||||
@@ -1329,14 +1474,36 @@ class Controller:
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# ⚠️ THE PACK IT IS MAINTAINING, NOT "the SoC". §12's recalibration
|
||||
# drains and refills ONE pack and grades ONE coulomb counter, so it is
|
||||
# graded on that pack's own reading - see `maint_unit`. A minimum across
|
||||
# packs would abort the cycle on a sibling's state, and an average would
|
||||
# declare a pack full that is not.
|
||||
result = self.maint.tick(now, self.maint_unit.soc)
|
||||
for msg in result.events:
|
||||
self.log_event(msg)
|
||||
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
|
||||
# drains and refills ONE pack and grades ONE coulomb counter, so each
|
||||
# state machine is ticked with the SoC of the inverter it belongs to. A
|
||||
# minimum across packs would abort a cycle on a sibling's state, and an
|
||||
# average would declare a pack full that is not. The pairing is
|
||||
# positional and both lists are built from the roster in `__init__`, so
|
||||
# a pack cannot be graded on its neighbour's meter.
|
||||
#
|
||||
# ⚠️ AND `arbitrate()` RUNS FIRST, ONCE, OVER ALL OF THEM. It is the
|
||||
# site's one maintenance slot - see maintenance.py - and a pack that
|
||||
# does not hold it is told so by name rather than left to discover it.
|
||||
# Calling `tick()` without the answer this produced is the
|
||||
# two-packs-draining-at-once defect with the interlock present but not
|
||||
# wired, which is why the plan is built here and not inside the loop.
|
||||
maint_plan = maint_arbitrate(self.maints, now)
|
||||
maint_results = []
|
||||
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
|
||||
res = machine.tick(now, inv.soc, blocked_by=blocked)
|
||||
for msg in res.events:
|
||||
self.log_event(msg)
|
||||
maint_results.append(res)
|
||||
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
|
||||
# deferral is edge-logged (it lasts as long as the other pack's cycle),
|
||||
# so by the time an operator asks "why has pack B not recalibrated" the
|
||||
# line that said so has scrolled off. §12's whole failure mode is a
|
||||
# schedule that silently stops firing; a pack that is due, idle and
|
||||
# WAITING has to be able to say so at the moment it is looked at.
|
||||
self._maint_deferred = [(m.name, r.deferred)
|
||||
for m, r in zip(self.maints, maint_results)
|
||||
if r.deferred]
|
||||
|
||||
# --- SAFETY-02, and it runs BEFORE the early return below -------------
|
||||
# ⚠️ ORDER MATTERS HERE. If this were evaluated after the "inputs missing
|
||||
@@ -1734,9 +1901,21 @@ class Controller:
|
||||
# protect. None stays None: `decide()` treats it as floor-limited,
|
||||
# which costs a shave rather than a pack.
|
||||
soc=self.pack_floor_soc,
|
||||
soc_floor=float(self.o.get(
|
||||
# ⚠️ SAFETY-32. THE SAME OPTION, REDUCED OVER THE PACKS' OWN
|
||||
# CUT-OUTS. `peak_soc_floor` is still one site-wide EMS floor and is
|
||||
# still read from that one key with CFG-03's one mirror behind it;
|
||||
# what `binding_soc_floor()` adds is the conjunction with each pack's
|
||||
# OWN `bms_soc_floor`, so the floor this site is graded against can
|
||||
# never be below the deepest cut-out on the roster. MAXIMUM here
|
||||
# pairs with the MINIMUM in `pack_floor_soc`: both reductions move
|
||||
# the verdict towards refusing a shave and away from spending a pack.
|
||||
# Under any config rule 8 accepts it IS the option, for every pack -
|
||||
# asserted over the whole schema range in test_peakguard.py part 12
|
||||
# rather than assumed, because "another rule refuses that" is a claim
|
||||
# about rule ordering and sim/inject.py does not run rule 8 at all.
|
||||
soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get(
|
||||
"peak_soc_floor",
|
||||
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])),
|
||||
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"]))),
|
||||
max_hold_s=self.p1.averager.max_hold_s,
|
||||
# ⚠️ SAFETY-20. The device range goes in TOO, and this call site is
|
||||
# the one that needed it most: `standing` is `last_write`, i.e. the
|
||||
@@ -1838,88 +2017,114 @@ class Controller:
|
||||
self.log_event(self.arb.explain())
|
||||
|
||||
# --- maintenance -----------------------------------------------------
|
||||
if result.owns_setpoint:
|
||||
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
|
||||
# drain THIS pack to its floor, charge THIS pack full, recalibrate
|
||||
# THIS pack's coulomb counter. Two packs recalibrate on two
|
||||
# schedules. See arbiter.CLAIM_SCOPE.
|
||||
# ⚠️ FILED ON THE UNIT BEING MAINTAINED, WHICH IS THE FIRST ONE.
|
||||
# §12's monthly recalibration is a PACK procedure and this add-on
|
||||
# holds exactly ONE `Maintenance` state machine, so the pack it is
|
||||
# maintaining is `self.maint_unit`. Two packs recalibrating on two
|
||||
# schedules is a second FSM with its own namespaced history, which
|
||||
# is a card of its own - what this card must not do is file one
|
||||
# pack's procedure on every actuator.
|
||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||
float(result.setpoint_w or 0.0), result.phase,
|
||||
actuator=self.actuator_of("maintenance",
|
||||
self.maint_unit)))
|
||||
self.sat_count = 0
|
||||
elif result.charge_only:
|
||||
# Money outranks the maintenance schedule: while the quarter-hour
|
||||
# projection is over the cap, the charge-only shaping is simply not
|
||||
# claimed, so the loop can discharge and shave the peak. When a
|
||||
# dedicated peak-shaving strategy arrives it will claim SET at
|
||||
# P_PEAK and outrank this limit without any code here changing.
|
||||
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
|
||||
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's claim
|
||||
# is `lo >= 0`. Both are limits, arbiter.py applies EVERY limit at or
|
||||
# above the winner's priority, and most restrictive wins - so a peak
|
||||
# event during a maintenance charge window files `lo > hi`, which is
|
||||
# a CONTRADICTION and resolves to 0 W. The battery would park in the
|
||||
# exact quarter it should be shaving. Priority does not save it:
|
||||
# clause 2 has no notion of a higher-priority limit overriding a
|
||||
# lower one, and it should not - "most restrictive" is what makes the
|
||||
# safety bounds unconditional.
|
||||
#
|
||||
# So the higher-priority claimant SUPPRESSES the lower one rather
|
||||
# than out-voting it, which is what this branch already did for
|
||||
# `peak_forecast_entity`. That entity is optional and empty on a
|
||||
# default install, so until now the suspension could not fire on the
|
||||
# site that needed it. `self.peak_guard` is measured from the meter
|
||||
# and is always there.
|
||||
#
|
||||
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is not
|
||||
# tidiness. It was inline, and a reviewer mutated it back to its
|
||||
# historical broken form - re-gated on `peak_forecast_entity` alone -
|
||||
# and every suite in this repository stayed green while a default
|
||||
# install parked at 0 W in the quarter that sets the month's bill.
|
||||
# Read shaping_suspended() before touching this: both terms are
|
||||
# there, and why neither is sufficient alone.
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc,
|
||||
float(self.o.get("peak_cap_w", 3500))):
|
||||
# Edge-logged: level-logging here would write one line per cycle
|
||||
# for as long as the event lasts and flush the 40-line action log
|
||||
# of the very context that explains it.
|
||||
if not self._maint_shape_suspended:
|
||||
self._maint_shape_suspended = True
|
||||
self.log_event("peak at risk - maintenance charge shaping "
|
||||
"suspended so the peak claim is not "
|
||||
"contradicted")
|
||||
else:
|
||||
self._maint_shape_suspended = False
|
||||
hi = 0.0
|
||||
reason = "charge-only"
|
||||
if self.cheap:
|
||||
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
|
||||
# OF `self.o`. This line used to re-open the options dict
|
||||
# with its own literal fallback, so the rate the shaping
|
||||
# asked for and the rate the state machine drained at could
|
||||
# be different numbers from the same install - and, with the
|
||||
# key absent, they were. The Controller already parsed this
|
||||
# once in __init__; asking the object that owns it cannot
|
||||
# drift from it.
|
||||
floor = maintenance_charge_floor(
|
||||
self.maint.cfg.charge_w,
|
||||
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
# PER-INVERTER, like the SET above and for the same reason -
|
||||
# the shaping is about the pack being charged.
|
||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi, reason=reason,
|
||||
actuator=self.actuator_of("maintenance",
|
||||
self.maint_unit)))
|
||||
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
|
||||
# At most one machine is out of IDLE on any tick, so at most one of
|
||||
# these branches fires and the claim list is the shape it has always
|
||||
# been. The loop is not a widening of what maintenance may ask for; it
|
||||
# is what makes the ONE claim it does file land on the pack that is
|
||||
# actually being recalibrated, whichever roster slot that is.
|
||||
for inv, result in zip(self.inverters, maint_results):
|
||||
if result.owns_setpoint:
|
||||
# PER-INVERTER. §12's monthly recalibration is a PACK procedure -
|
||||
# drain THIS pack to its floor, charge THIS pack full,
|
||||
# recalibrate THIS pack's coulomb counter. Two packs recalibrate
|
||||
# on two schedules. See arbiter.CLAIM_SCOPE.
|
||||
#
|
||||
# ⚠️ FILED ON THE PACK WHOSE STATE MACHINE PRODUCED IT - `inv`,
|
||||
# the roster entry this `result` was ticked from - and that is
|
||||
# the whole of MAINT-05 at this line. It used to be
|
||||
# `self.maint_unit`, which was `inverters[0]` flatly, because
|
||||
# there was one FSM and its history had nowhere else to live.
|
||||
# Pack B's drain filed on pack A is one pack being commanded for
|
||||
# another's procedure while the pack that is actually draining
|
||||
# stays under the loop: the site would move the right total and
|
||||
# recalibrate nothing.
|
||||
#
|
||||
# ⚠️ AND IT STAYS `SCOPE_UNIT`. Two FSMs mean two claims on two
|
||||
# actuators, which the allocator consumes as per-unit intervals;
|
||||
# ARCH-01 is explicit that this must never become a site claim,
|
||||
# because per-inverter constraints filed site-wide are the
|
||||
# cross-inverter `lo > hi` contradiction at arbiter.py:114.
|
||||
claims.append(Claim.set("maintenance", P_MAINTENANCE,
|
||||
float(result.setpoint_w or 0.0), result.phase,
|
||||
actuator=self.actuator_of("maintenance", inv)))
|
||||
self.sat_count = 0
|
||||
elif result.charge_only:
|
||||
# Money outranks the maintenance schedule: while the quarter-hour
|
||||
# projection is over the cap, the charge-only shaping is simply not
|
||||
# claimed, so the loop can discharge and shave the peak. When a
|
||||
# dedicated peak-shaving strategy arrives it will claim SET at
|
||||
# P_PEAK and outrank this limit without any code here changing.
|
||||
# ⚠️ AND SAFETY-07 IS THE OTHER HALF OF THIS CONDITION, NOT A
|
||||
# SECOND MECHANISM. The shaping is `hi <= 0`; the peak guard's
|
||||
# claim is `lo >= 0`. Both are limits, arbiter.py applies EVERY
|
||||
# limit at or above the winner's priority, and most restrictive
|
||||
# wins - so a peak event during a maintenance charge window files
|
||||
# `lo > hi`, which is a CONTRADICTION and resolves to 0 W. The
|
||||
# battery would park in the exact quarter it should be shaving.
|
||||
# Priority does not save it: clause 2 has no notion of a
|
||||
# higher-priority limit overriding a lower one, and it should not -
|
||||
# "most restrictive" is what makes the safety bounds unconditional.
|
||||
#
|
||||
# So the higher-priority claimant SUPPRESSES the lower one rather
|
||||
# than out-voting it, which is what this branch already did for
|
||||
# `peak_forecast_entity`. That entity is optional and empty on a
|
||||
# default install, so until now the suspension could not fire on the
|
||||
# site that needed it. `self.peak_guard` is measured from the meter
|
||||
# and is always there.
|
||||
#
|
||||
# ⚠️ THE CONJUNCTION LIVES IN peakguard.py, NOT HERE, and that is
|
||||
# not tidiness. It was inline, and a reviewer mutated it back to its
|
||||
# historical broken form - re-gated on `peak_forecast_entity` alone
|
||||
# - and every suite in this repository stayed green while a default
|
||||
# install parked at 0 W in the quarter that sets the month's bill.
|
||||
# Read shaping_suspended() before touching this: both terms are
|
||||
# there, and why neither is sufficient alone.
|
||||
#
|
||||
# ⚠️ ONE SUSPENSION FLAG FOR THE SITE, NOT ONE PER PACK, AND THE
|
||||
# INTERLOCK IS WHAT MAKES THAT TRUE. The quantity it edge-logs is
|
||||
# a peak event at the grid connection, and `arbitrate()` allows
|
||||
# at most one pack to be in CHARGE at a time, so this branch can
|
||||
# run at most once per tick. Were two packs ever allowed to
|
||||
# charge together, this flag would have to become per-pack - and
|
||||
# so would the reasoning above it.
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc,
|
||||
float(self.o.get("peak_cap_w", 3500))):
|
||||
# Edge-logged: level-logging here would write one line per
|
||||
# cycle for as long as the event lasts and flush the 40-line
|
||||
# action log of the very context that explains it.
|
||||
if not self._maint_shape_suspended:
|
||||
self._maint_shape_suspended = True
|
||||
self.log_event("peak at risk - maintenance charge shaping "
|
||||
"suspended so the peak claim is not "
|
||||
"contradicted")
|
||||
else:
|
||||
self._maint_shape_suspended = False
|
||||
hi = 0.0
|
||||
reason = "charge-only"
|
||||
if self.cheap:
|
||||
# ⚠️ CFG-03. `self.maint.cfg.charge_w`, NOT A SECOND READ
|
||||
# OF `self.o`. This line used to re-open the options dict
|
||||
# with its own literal fallback, so the rate the shaping
|
||||
# asked for and the rate the state machine drained at
|
||||
# could be different numbers from the same install - and,
|
||||
# with the key absent, they were. The Controller already
|
||||
# parsed this once in __init__; asking the object that
|
||||
# owns it cannot drift from it. Every pack's machine
|
||||
# holds the same `MaintConfig` instance, so this is the
|
||||
# rate the pack in front of us is charging at.
|
||||
floor = maintenance_charge_floor(
|
||||
self.maint.cfg.charge_w,
|
||||
self.peak_fc, float(self.o.get("peak_cap_w", 3500)))
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
# PER-INVERTER, like the SET above and for the same reason -
|
||||
# the shaping is about the pack being charged, which is `inv`.
|
||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
|
||||
reason=reason,
|
||||
actuator=self.actuator_of("maintenance",
|
||||
inv)))
|
||||
|
||||
# --- the grid-following loop -----------------------------------------
|
||||
if self.auto and self.inputs_ok:
|
||||
@@ -2423,6 +2628,22 @@ class Controller:
|
||||
# notification that matters into a traceback.
|
||||
_at_floor = ("at the floor" if g.soc_floor is None
|
||||
else f"at {float(g.soc_floor):.0f} %")
|
||||
# ⚠️ SAFETY-32. WHICH PACK STOPPED THE SITE. `g.soc_floor` is the floor
|
||||
# the decision was actually taken with, so this re-grades nothing and
|
||||
# cannot disagree with the alarm it is printed inside. At a roster of one
|
||||
# the sentence is omitted entirely and this notification is byte-for-byte
|
||||
# what SAFETY-25 shipped - there is exactly one pack and naming it would
|
||||
# be noise on every existing install.
|
||||
_blocked = [name for name, why
|
||||
in peakguard.pack_permissions(self.packs, g.soc_floor)
|
||||
if why is not None]
|
||||
_which = ("" if len(self.inverters) < 2 else
|
||||
f"\n\nSAFETY-32 - WHICH PACK: this is graded per pack against "
|
||||
f"that pack's own BMS cut-out (bms_soc_floor), and the "
|
||||
f"pack(s) at or below their own floor right now are "
|
||||
f"{', '.join(_blocked) if _blocked else '(none - see the reason above)'}"
|
||||
f". The other packs on the roster are not the reason the guard "
|
||||
f"stopped and raising their cut-out will not change this.")
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_peak_soc_floor",
|
||||
"title": "GoodWe: the capacity-peak guard has run out of battery",
|
||||
@@ -2442,7 +2663,7 @@ class Controller:
|
||||
f"{_at_floor} - ordinary self-consumption has no SoC "
|
||||
f"floor at all and will go on discharging past it, down to the "
|
||||
f"pack's own BMS cut-out. What has stopped is the guard buying "
|
||||
f"you a lower bill, not the pack being spent."),
|
||||
f"you a lower bill, not the pack being spent." + _which),
|
||||
})
|
||||
|
||||
async def check_circulating(self) -> None:
|
||||
@@ -3310,14 +3531,29 @@ class Controller:
|
||||
elif not self.auto:
|
||||
level, banner = "warn", "Stopped — inverter commanded to 0 W"
|
||||
elif self.maint.phase != IDLE:
|
||||
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE. At a roster
|
||||
# of one this is the string it has always been, character for
|
||||
# character; at two, "Maintenance: drain" without a name is a
|
||||
# banner an operator cannot act on, because the remedy is at one
|
||||
# device and the site has two.
|
||||
level, banner = "warn", f"Maintenance: {self.maint.phase}"
|
||||
if len(self.inverters) > 1:
|
||||
banner += f" ({self.maint_unit.name})"
|
||||
else:
|
||||
level, banner = "ok", "Running — holding grid at zero"
|
||||
|
||||
def w(v):
|
||||
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
|
||||
|
||||
next_due = self.maint.next_due(datetime.now(timezone.utc))
|
||||
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. One date on a
|
||||
# site with two schedules has to be the one that comes first, or the row
|
||||
# says "2026-10-01" while a pack recalibrates tomorrow. `None` means
|
||||
# "never run, due now" and has to win over any date, so it is tested for
|
||||
# rather than sorted with. At a roster of one this is that pack's own
|
||||
# date, unchanged.
|
||||
_now = datetime.now(timezone.utc)
|
||||
_dues = [m.next_due(_now) for m in self.maints]
|
||||
next_due = None if any(d is None for d in _dues) else min(_dues)
|
||||
rows = [
|
||||
("Grid", w(self.grid)),
|
||||
("Battery", w(self.site_batt_w)),
|
||||
@@ -3333,7 +3569,14 @@ class Controller:
|
||||
else f"{self.pack_floor_soc:g} %"),
|
||||
("Commanded", w(self.target)),
|
||||
("Why", self.reason),
|
||||
("Maintenance phase", self.maint.phase),
|
||||
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
|
||||
else f"{self.maint.phase} ({self.maint_unit.name})"),
|
||||
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
|
||||
# page - where `_maint_deferred` is empty on every tick - carries
|
||||
# exactly the rows it carried before.
|
||||
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
|
||||
for n, why in self._maint_deferred)),)
|
||||
if self._maint_deferred else ()),
|
||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||
("Cheap window", "yes" if self.cheap else "no"),
|
||||
("Tariff", self.tariff_words),
|
||||
@@ -3445,11 +3688,26 @@ class Controller:
|
||||
await self.command_site_zero()
|
||||
return {"auto": self.auto}
|
||||
if what == "maint_start":
|
||||
for msg in self.maint.force_start(now):
|
||||
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
|
||||
# ONE LABEL. `_maint_button()` answers it: the pack that holds the
|
||||
# site's maintenance slot if one does - which is what "start"
|
||||
# already meant at a roster of one, where pressing it mid-drain
|
||||
# re-entered DRAIN - and otherwise the pack that has gone longest
|
||||
# without a cycle, which is the same pack `arbitrate()` would have
|
||||
# picked on its own. At N=1 both branches are `maints[0]` and this
|
||||
# is the shipped call, byte for byte.
|
||||
#
|
||||
# A PER-PACK BUTTON IS A UI CARD, NOT THIS ONE. `web.py` posts a
|
||||
# bare `maint_start` with no unit, and inventing a unit parameter
|
||||
# here would ship half a surface: the page would still have one
|
||||
# button and the API would have two meanings.
|
||||
for msg in self._maint_button(now).force_start(now):
|
||||
self.log_event(msg)
|
||||
return {"phase": self.maint.phase}
|
||||
if what == "maint_abort":
|
||||
for msg in self.maint.abort(now, "operator"):
|
||||
# Aborts the pack that is actually cycling - `_maint_button()` again
|
||||
# - and then zeroes the SITE, which is what it always did.
|
||||
for msg in self._maint_button(now).abort(now, "operator"):
|
||||
self.log_event(msg)
|
||||
await self.command_site_zero()
|
||||
return {"phase": self.maint.phase}
|
||||
|
||||
@@ -18,16 +18,47 @@ OWNERSHIP, not priority. Exactly one thing writes the setpoint at any moment:
|
||||
The loop is never "disabled" - it yields. The heartbeat to the inverter keeps
|
||||
running in every phase, so the hardware watchdog stays fed and a crash here
|
||||
still ends with the inverter at 0 W rather than latched.
|
||||
|
||||
ONE PACK AT A TIME (MAINT-05). There is now one `Maintenance` per roster entry,
|
||||
each with its own schedule, its own phase and its own history under its own
|
||||
Store keys - and therefore, for the first time, two of them able to move power
|
||||
on ONE grid connection at once. Read `arbitrate()` before touching any of it:
|
||||
the site holds exactly ONE maintenance slot, a pack that is not IDLE holds it,
|
||||
and every other pack is held at IDLE under the loop until it is released. That
|
||||
is not a scheduling nicety. A drain of pack A against a charge of pack B is two
|
||||
power-moving phases pulling opposite ways across the meter - the circulating
|
||||
power SAFETY-31 exists to DETECT, arriving here as something this module can
|
||||
simply not do.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .store import MAINT_UNIT_KEYS, unit_key
|
||||
|
||||
_LOG = logging.getLogger("goodwe.maint")
|
||||
|
||||
IDLE, DRAIN, CHARGE, HOLD = "idle", "drain", "charge", "hold"
|
||||
|
||||
# ⚠️ THE PHASES THAT MOVE POWER, WHICH IS NOT THE SAME LIST AS "owns the
|
||||
# setpoint". The table above is about WHO WRITES the number; this is about
|
||||
# whether the pack is being pushed. DRAIN and HOLD own the setpoint. CHARGE does
|
||||
# NOT - the loop owns it - and CHARGE is in here anyway, because the charge-only
|
||||
# clamp plus the cheap-window floor is what pulls a second pack full while the
|
||||
# first is being emptied. Leaving CHARGE out is precisely the interlock hole
|
||||
# this card is about: pack A charging from the grid at 2000 W while pack B
|
||||
# drains 2000 W to export is 4 kW going round in a circle with the meter at
|
||||
# zero, and both packs believing they are behaving.
|
||||
#
|
||||
# HOLD commands 0 W and moves nothing, and it is in here for the other half of
|
||||
# the sentence: it OWNS the actuator - the pack is parked at the top for the BMS
|
||||
# and is deliberately not available to the loop - so a second cycle starting
|
||||
# under it would still be two recalibrations at once, which is the thing whose
|
||||
# value is a clean uninterrupted coulomb count.
|
||||
POWER_MOVING = (DRAIN, CHARGE, HOLD)
|
||||
|
||||
# A drain of a 13.8 kWh usable pack (300 Ah x 51.2 V, DoD 90) at the shipped
|
||||
# 2000 W is ~6.9 h even with no house load helping - and less in practice, since
|
||||
# house load drains alongside the export and soc_floor stops short of empty.
|
||||
@@ -153,17 +184,102 @@ class MaintResult:
|
||||
setpoint_w: float | None = None
|
||||
charge_only: bool = False
|
||||
events: list = field(default_factory=list)
|
||||
# ⚠️ WHY A DEFERRAL IS A FIELD AND NOT JUST A LOG LINE. A pack that is due
|
||||
# and is not starting looks, from every surface an operator has, exactly
|
||||
# like a pack whose schedule has quietly stopped firing - §12's opening
|
||||
# sentence, the failure this whole module exists because of. It is carried
|
||||
# so the status page can SAY "waiting for the other pack" rather than
|
||||
# showing an idle machine and a due date in the past. Empty on every path
|
||||
# that is not deferring, so a roster of one never sees it.
|
||||
deferred: str = ""
|
||||
|
||||
|
||||
class UnitHistory:
|
||||
"""One pack's maintenance history in the Store, under one spelling.
|
||||
|
||||
⚠️ THE NAMESPACE IS A CONSTRUCTOR ARGUMENT AND `None` IS A REAL VALUE, NOT
|
||||
AN OMISSION. `unit=None` means the FLAT keys - `phase`, `phase_started`,
|
||||
`last_completed`, `last_start_attempt` - which is what every install has
|
||||
written since the add-on shipped and what a one-inverter site keeps writing
|
||||
after MAINT-05. §6.2's identity: with one pack there is nothing for a
|
||||
namespace to distinguish it FROM, and adding one would change the layout of
|
||||
a live state file to say something it already said. `arbiter.actuator_for()`
|
||||
makes the same move for the same reason and its docstring argues it.
|
||||
|
||||
⚠️ `mirror_flat` IS THE ROLLBACK VIEW AND IT IS ONLY EVER THE FIRST PACK'S.
|
||||
At N>=2 pack A's history has been migrated into its namespace and is written
|
||||
there; the flat keys are ALSO kept in step, so an operator who rolls the
|
||||
add-on back to a single-FSM version finds a CURRENT history rather than one
|
||||
dated the day the second inverter was declared. store.migrate_maint_keys()
|
||||
carries the argument, and it is DEV-02's rule 2 applied to history instead
|
||||
of to a latch: "left in the file" is not the same promise as "still true".
|
||||
Pack B has no flat spelling to mirror to - the flat keys mean pack A on
|
||||
every install that has them - so it is constructed with mirroring off, and
|
||||
a mirror from B would OVERWRITE A's rollback view with B's phase.
|
||||
"""
|
||||
|
||||
def __init__(self, store, unit: str | None = None, mirror_flat: bool = False):
|
||||
self.store = store
|
||||
self.unit = unit
|
||||
self.mirror_flat = bool(mirror_flat)
|
||||
|
||||
def key(self, name: str) -> str:
|
||||
"""The Store key this pack's `name` lives under."""
|
||||
return name if self.unit is None else unit_key(self.unit, name)
|
||||
|
||||
def get(self, name: str, default=None):
|
||||
return getattr(self.store, "data", {}).get(self.key(name), default)
|
||||
|
||||
def set(self, name: str, value) -> None:
|
||||
self.store.set(self.key(name), value)
|
||||
if self.mirror_flat:
|
||||
self.store.set(name, value)
|
||||
|
||||
def get_time(self, name: str):
|
||||
return self.store.get_time(self.key(name))
|
||||
|
||||
def set_time(self, name: str, when) -> None:
|
||||
self.store.set_time(self.key(name), when)
|
||||
if self.mirror_flat:
|
||||
self.store.set_time(name, when)
|
||||
|
||||
|
||||
class Maintenance:
|
||||
def __init__(self, cfg: MaintConfig, store):
|
||||
"""ONE pack's recalibration cycle: its schedule, its phase, its history.
|
||||
|
||||
⚠️ ONE PER ROSTER ENTRY SINCE MAINT-05, AND THE SLOT IS NOT ITS OWN. This
|
||||
object decides WHEN ITS PACK would like to start; `arbitrate()` decides
|
||||
which of them may, because the constraint is a property of the grid
|
||||
connection and no single FSM can see it. Never call `tick()` in a loop
|
||||
without passing the `blocked_by` that `arbitrate()` returned for it: that is
|
||||
the two-packs-at-once bug with the interlock present but not wired.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
|
||||
name: str = "", mirror_flat: bool = False):
|
||||
self.cfg = cfg
|
||||
self.store = store
|
||||
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
|
||||
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
|
||||
# the one place that turns it into the key this pack owns. A raw
|
||||
# `store.set("phase", ...)` anywhere below would write pack A's history
|
||||
# from pack B's state machine, which is the shared-state bug this card
|
||||
# is about, and there would be nothing to see at a roster of one.
|
||||
self.store = UnitHistory(store, unit, mirror_flat)
|
||||
self.unit = unit
|
||||
# What an event or a refusal calls this pack. At a roster of one it is
|
||||
# never rendered - a single machine cannot be deferred - so the shipped
|
||||
# one-inverter reason strings are untouched.
|
||||
self.name = str(name or unit or "the pack")
|
||||
# Edge-memo for the deferral note. The action log holds 40 lines and a
|
||||
# deferral lasts as long as the other pack's cycle - hours - so a line
|
||||
# per cycle would flush the very context that explains it. Same pattern
|
||||
# as `Controller._arb_action` and `_maint_shape_suspended`.
|
||||
self._deferred = ""
|
||||
|
||||
# -- state ---------------------------------------------------------------
|
||||
@property
|
||||
def phase(self) -> str:
|
||||
return self.store.data.get("phase", IDLE)
|
||||
return self.store.get("phase", IDLE)
|
||||
|
||||
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
|
||||
self.store.set("phase", phase)
|
||||
@@ -185,14 +301,60 @@ class Maintenance:
|
||||
last = self.store.get_time("last_completed")
|
||||
return None if last is None else last + timedelta(days=self.cfg.interval_days)
|
||||
|
||||
def waited_days(self, now: datetime) -> float:
|
||||
"""Days since this pack last COMPLETED a cycle; `inf` if it never has.
|
||||
|
||||
⚠️ THE ARBITRATION KEY, AND IT IS DELIBERATELY NOT `due()`. `due()` is a
|
||||
yes/no and two packs that are both due are indistinguishable by it -
|
||||
which is how a roster-order tie-break gets written and how the first
|
||||
entry then wins every contested month forever while the second drifts.
|
||||
This is an ORDERING: the pack whose coulomb counter has gone longest
|
||||
without a recalibration goes first. `inf` for never-recalibrated is the
|
||||
same statement `due()` makes about an absent `last_completed`, kept as
|
||||
one so a fresh pack cannot lose to one that ran last month.
|
||||
"""
|
||||
last = self.store.get_time("last_completed")
|
||||
return math.inf if last is None else (now - last).total_seconds() / 86400.0
|
||||
|
||||
def _cooled(self, now: datetime) -> bool:
|
||||
"""Is the post-abort cooldown over?"""
|
||||
last_try = self.store.get_time("last_start_attempt")
|
||||
return (last_try is None
|
||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
|
||||
|
||||
def wants_start(self, now: datetime) -> bool:
|
||||
"""Would this pack begin a cycle on this tick if the site let it?
|
||||
|
||||
⚠️ THE SAME PREDICATE `tick()` USES, NOT A COPY OF IT. `arbitrate()` has
|
||||
to know who is asking before anybody moves, and a second transcription
|
||||
of "enabled, in the start hour, due and cooled down" is a second opinion
|
||||
about who was asking - so the IDLE branch of `tick()` calls this one.
|
||||
"""
|
||||
return (self.phase == IDLE and self.cfg.enabled
|
||||
and now.hour == self.cfg.start_hour and self.due(now)
|
||||
and self._cooled(now))
|
||||
|
||||
# -- operations ----------------------------------------------------------
|
||||
def force_start(self, now: datetime) -> list:
|
||||
def force_start(self, now: datetime, blocked_by: str = "") -> list:
|
||||
"""Start a cycle regardless of the calendar.
|
||||
|
||||
Commissioning uses this: a cycle must be proven end to end before a site
|
||||
is signed off, and nobody waits a month to find out the schedule never
|
||||
fires.
|
||||
|
||||
⚠️ REGARDLESS OF THE CALENDAR, NOT REGARDLESS OF THE INTERLOCK. The
|
||||
calendar is a preference and an operator may overrule it; "one pack
|
||||
moves power at a time" is a property of the grid connection and an
|
||||
operator pressing a button does not add a second one. A forced start
|
||||
against a busy site is REFUSED, by name, and the phase machine does not
|
||||
move - the refusal is returned as an event so the operator reads it in
|
||||
the action log rather than watching nothing happen.
|
||||
"""
|
||||
if blocked_by:
|
||||
msg = (f"maintenance: forced start REFUSED on {self.name} - "
|
||||
f"{blocked_by}")
|
||||
_LOG.warning("%s", msg)
|
||||
return [msg]
|
||||
events = []
|
||||
self.store.set_time("last_start_attempt", now)
|
||||
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
||||
@@ -203,23 +365,80 @@ class Maintenance:
|
||||
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
||||
return events
|
||||
|
||||
def _defer_note(self, why: str, events: list) -> list:
|
||||
"""Say once, not every second, that this pack is waiting its turn.
|
||||
|
||||
⚠️ THE WORDING IS DELIBERATELY NOT AN ABORT AND NOT A TIMEOUT. §12's
|
||||
notes are explicit that a 12 h phase timeout means "the battery is not
|
||||
following commands - a FAULT, not a slow day", and the one thing that
|
||||
must not happen here is an operator reading a deferral as that. This
|
||||
line says the pack is WAITING, names the pack it is waiting for, and
|
||||
says it will run next - none of which any failure path in this module
|
||||
says.
|
||||
"""
|
||||
if self._deferred != why:
|
||||
self._deferred = why
|
||||
msg = (f"maintenance: {self.name} is due and is WAITING its turn - "
|
||||
f"{why}. It is idle under the control loop and will "
|
||||
f"recalibrate at the next start hour once the slot is free; "
|
||||
f"this is not a stalled phase.")
|
||||
_LOG.info("%s", msg)
|
||||
events.append(msg)
|
||||
return events
|
||||
|
||||
# -- the machine ---------------------------------------------------------
|
||||
def tick(self, now: datetime, soc: float | None) -> MaintResult:
|
||||
def tick(self, now: datetime, soc: float | None,
|
||||
blocked_by: str = "") -> MaintResult:
|
||||
"""One cycle of this pack's state machine.
|
||||
|
||||
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
|
||||
site's one maintenance slot is available to it, otherwise the reason it
|
||||
is not. It gates two things and nothing else - a start, and a phase that
|
||||
is already moving power on a pack that does not hold the slot.
|
||||
"""
|
||||
events: list = []
|
||||
phase = self.phase
|
||||
|
||||
# ⚠️ THE SLOT IS CHECKED BEFORE THE PHASE, AND THIS BRANCH IS A RECOVERY
|
||||
# RATHER THAN AN EXPECTED PATH. `arbitrate()` grants the slot to the
|
||||
# pack that is already mid-cycle, so a pack that is BOTH moving power
|
||||
# and blocked means two histories on disk say "drain" - a hand-edited
|
||||
# state file, or a store carried across a version that had no interlock.
|
||||
# The fail-safe direction is unambiguous: two packs pulling against each
|
||||
# other on one connection is the fault SAFETY-31 latches the whole site
|
||||
# for. Stand this one down to 0 W and say why; the holder keeps its
|
||||
# cycle, which is the outcome that loses no coulomb count at all.
|
||||
if blocked_by and phase in POWER_MOVING:
|
||||
events += self.abort(now, f"interlock - {blocked_by}")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
|
||||
if phase == IDLE:
|
||||
if self.cfg.enabled and now.hour == self.cfg.start_hour and self.due(now):
|
||||
last_try = self.store.get_time("last_start_attempt")
|
||||
cooled = (
|
||||
last_try is None
|
||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN)
|
||||
)
|
||||
if cooled:
|
||||
self.store.set_time("last_start_attempt", now)
|
||||
self._enter(DRAIN, now, events,
|
||||
"maintenance: due -> drain (exports the surplus)")
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
if self.wants_start(now):
|
||||
if blocked_by:
|
||||
# ⚠️ `last_start_attempt` IS NOT STAMPED HERE, AND THAT IS
|
||||
# THE STARVATION FIX RATHER THAN AN OMISSION. That key is
|
||||
# the post-ABORT cooldown: it exists so a cycle that failed
|
||||
# does not re-trigger inside the same start hour. A deferral
|
||||
# is not an attempt - nothing was tried and nothing failed -
|
||||
# and stamping it would put this pack under a 90 minute
|
||||
# cooldown for a start hour that is 60 minutes long, so the
|
||||
# pack that lost the arbitration would still be locked out
|
||||
# at the moment the winner released the slot.
|
||||
#
|
||||
# ⚠️ AND THE PHASE DOES NOT MOVE, WHICH IS THE OTHER HALF.
|
||||
# `phase_started` is untouched, so the 12 h DRAIN and 36 h
|
||||
# CHARGE timeouts - which mean "the battery is not following
|
||||
# commands, a fault" - cannot be reached by waiting. A
|
||||
# deferred pack is IDLE under the loop, not a stuck one.
|
||||
return MaintResult(IDLE, False, None, False,
|
||||
self._defer_note(blocked_by, events),
|
||||
blocked_by)
|
||||
self._deferred = ""
|
||||
self.store.set_time("last_start_attempt", now)
|
||||
self._enter(DRAIN, now, events,
|
||||
"maintenance: due -> drain (exports the surplus)")
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
self._deferred = ""
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
|
||||
# ⚠️ Fail toward inaction. Without a state-of-charge reading none of the
|
||||
@@ -273,3 +492,77 @@ class Maintenance:
|
||||
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
|
||||
events += self.abort(now, f"unknown phase {phase!r}")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
|
||||
|
||||
def _days(n: float) -> str:
|
||||
return "never recalibrated" if n == math.inf else f"{n:.0f} d since its last cycle"
|
||||
|
||||
|
||||
def arbitrate(machines, now: datetime) -> list:
|
||||
"""Grant the site's ONE maintenance slot. Returns a list parallel to
|
||||
`machines`: "" for the pack that may move power, otherwise the reason the
|
||||
pack may not.
|
||||
|
||||
⚠️ THE SLOT IS A PROPERTY OF THE GRID CONNECTION, WHICH IS WHY IT IS NOT ON
|
||||
`Maintenance`. Each FSM can see its own pack's schedule and nothing else; the
|
||||
quantity being rationed - "how many phases are moving power across one
|
||||
meter" - is a sum over the roster, exactly like `sum(|B_i|) - |sum(B_i)|` in
|
||||
SAFETY-31 and for the same reason. A per-pack answer to a site-level
|
||||
question is the shape of defect this repository keeps finding.
|
||||
|
||||
⚠️ IT IS DERIVED, NOT PERSISTED, AND THAT IS DELIBERATE. The holder is
|
||||
"whichever pack's stored phase is not IDLE" - a fact already on disk, in the
|
||||
per-pack history the packs themselves write. A separate `maint_slot_holder`
|
||||
key would be a second opinion about the same thing, and the two would
|
||||
disagree the first time the add-on was killed between writing the phase and
|
||||
writing the holder. Restart-safe by construction: a controller that comes
|
||||
back up mid-drain reads the drain and re-derives the holder from it.
|
||||
|
||||
TWO CLAUSES, IN ORDER:
|
||||
|
||||
1. A PACK ALREADY MOVING POWER KEEPS THE SLOT. Nothing preempts a cycle in
|
||||
flight, because a recalibration interrupted halfway is worth less than
|
||||
nothing - the coulomb counter is graded on ONE uninterrupted drain and
|
||||
refill, so a cycle stopped at 40 % has spent the energy and bought no
|
||||
calibration. If more than one pack somehow comes up moving power, the
|
||||
FIRST in roster order holds and `tick()` stands the others down; see the
|
||||
recovery branch there.
|
||||
|
||||
2. OTHERWISE THE LONGEST-WAITING CANDIDATE STARTS. `wants_start()` is asked
|
||||
of every pack, and among those that say yes the slot goes to the one with
|
||||
the largest `waited_days()`. NOT roster order: two packs on one monthly
|
||||
schedule are due in the same hour every month, and a roster-order tie
|
||||
would hand the slot to `inverters[0]` every single time while the second
|
||||
pack's coulomb counter drifted forever - which is, in a different
|
||||
costume, the exact defect this card was opened for. Roster order breaks a
|
||||
genuine numeric tie and nothing else.
|
||||
|
||||
⚠️ AT A ROSTER OF ONE THIS IS THE IDENTITY FUNCTION. One machine is never
|
||||
another machine's blocker: clause 1 finds at most itself and clause 2 has at
|
||||
most one candidate, so it returns `[""]` on every tick and no reason string
|
||||
it can build is ever rendered. That is what makes the one-inverter behaviour
|
||||
byte-identical rather than merely similar.
|
||||
"""
|
||||
blocks = [""] * len(machines)
|
||||
busy = [(i, m) for i, m in enumerate(machines) if m.phase in POWER_MOVING]
|
||||
if busy:
|
||||
held, holder = busy[0]
|
||||
for i, m in enumerate(machines):
|
||||
if i != held:
|
||||
blocks[i] = (f"{holder.name} is mid-cycle ({holder.phase}) and "
|
||||
f"holds the site's one maintenance slot")
|
||||
return blocks
|
||||
|
||||
wants = [(i, m) for i, m in enumerate(machines) if m.wants_start(now)]
|
||||
if len(wants) <= 1:
|
||||
return blocks
|
||||
# Largest waited_days wins; `-index` makes a numeric tie fall to roster
|
||||
# order rather than to whatever `max()` happens to see first.
|
||||
won, winner = max(wants, key=lambda p: (p[1].waited_days(now), -p[0]))
|
||||
for i, m in wants:
|
||||
if i != won:
|
||||
blocks[i] = (f"{winner.name} has waited longer "
|
||||
f"({_days(winner.waited_days(now))} against "
|
||||
f"{_days(m.waited_days(now))}) and takes the site's "
|
||||
f"one maintenance slot first")
|
||||
return blocks
|
||||
|
||||
@@ -360,6 +360,29 @@ distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
|
||||
a loop-side guard would be the third - at which point the migration buys enough
|
||||
to be worth its cost.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
SAFETY-32: ONE `bms_soc_floor` FOR TWO PACKS GRADES ONE AGAINST THE OTHER'S BMS
|
||||
|
||||
`bms_soc_floor` above describes A PACK, not a site, and it was a flat option
|
||||
because there was one pack. At two, two batteries of different models have
|
||||
different cut-outs, and one number for both is wrong in the direction that
|
||||
empties a battery: the SHALLOWER pack (the higher cut-out) is protected by a
|
||||
floor that belongs to the DEEPER one. So it became a roster key - `Pack` carries
|
||||
each unit's own value, `pack_floor_pct()` conjoins it with the site's EMS floor,
|
||||
and validate.py rule 8 grades EVERY unit and NAMES the one it refuses.
|
||||
|
||||
⚠️ `peak_soc_floor` DID NOT MOVE, AND THE RESIDUAL IS RECORDED RATHER THAN
|
||||
HIDDEN. It is one number for the site, so with cut-outs of 10 % and 18 % the
|
||||
operator must set it above 18 and the deeper pack is then withheld above its own
|
||||
need. That half - a per-pack EMS floor - is NOT this card and must not be
|
||||
smuggled in with it; DECISIONS/SAFETY-32-per-pack-bms-floor.md states it as the
|
||||
open question it is.
|
||||
|
||||
⚠️ AND THE MIN() OVER MEASURED SoC IS STILL RIGHT. `Controller.pack_floor_soc`
|
||||
reduces to the emptiest pack and this card does not touch it. What changed is
|
||||
the FLOOR it is graded against, and the addition of a per-pack answer beside the
|
||||
site's one verdict - see `pack_permissions()`.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
|
||||
|
||||
@@ -572,6 +595,110 @@ def discharge_permitted(soc, soc_floor) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pack:
|
||||
"""SAFETY-32. ONE pack's three numbers for the floor question, NAMED.
|
||||
|
||||
`name` the roster name, because a refusal that does not say WHICH pack
|
||||
stopped the site sends an operator to the wrong device - the
|
||||
same defect CFG-04's `unit_caps_indexed()` was written for.
|
||||
`soc` THIS pack's measured state of charge. None when its sensor did
|
||||
not answer, and None is not a full battery.
|
||||
`bms_floor` THIS pack's OWN cut-out percent (`bms_soc_floor`), which is a
|
||||
property of the hardware in the cupboard and NOT of the site.
|
||||
None when the roster entry did not state one - see
|
||||
`pack_floor_pct()` for why that is withheld and not 0 %.
|
||||
|
||||
⚠️ IT CARRIES THE CUT-OUT, NOT THE EMS FLOOR. `peak_soc_floor` is one number
|
||||
for the whole site (the depth a money-saving discharge may never reach) and
|
||||
stays a flat option; `bms_soc_floor` describes a battery and became a roster
|
||||
key here, because two packs of different models have different cut-outs and
|
||||
grading both against one of them is wrong in the direction that empties the
|
||||
shallower one.
|
||||
"""
|
||||
|
||||
name: str
|
||||
soc: float | None
|
||||
bms_floor: float | None
|
||||
|
||||
|
||||
def pack_floor_pct(pack, soc_floor):
|
||||
"""The percent THIS pack may not be spent below, or None if that is unknown.
|
||||
|
||||
`max(the site's EMS floor, this pack's own cut-out)` - the CONJUNCTION, and
|
||||
the direction matters: the EMS floor is what the operator asked for and the
|
||||
cut-out is what the hardware will do anyway, so a pack is protected by
|
||||
whichever is higher. Taking the site floor alone is the shipped defect this
|
||||
card removes (the shallower pack graded against the deeper one's cut-out);
|
||||
taking the cut-out alone would throw SAFETY-24's margin away.
|
||||
|
||||
⚠️ AN UNSTATED CUT-OUT IS None, NOT ZERO, AND NOT THE FLAT OPTION. 0 % would
|
||||
be a floor that permits everything - the most permissive value there is,
|
||||
invented from an absence - and borrowing the flat `bms_soc_floor` is CFG-04's
|
||||
"nothing is inherited" broken in one line, which is this card's whole subject
|
||||
restated. `discharge_permitted()` already treats a None floor as NO
|
||||
permission, so an entry that forgot the key costs a shave rather than a pack.
|
||||
|
||||
⚠️ AND UNDER A rule-8-VALID CONFIG THIS IS `soc_floor`, FOR EVERY PACK.
|
||||
validate.py rule 8 refuses `peak_soc_floor <= bms_soc_floor` on EVERY unit,
|
||||
so `max()` selects the EMS floor everywhere the add-on will actually start -
|
||||
which is exactly why the reduction is written here rather than assumed at the
|
||||
call site. "Another rule refuses that config" is a claim about today's rule
|
||||
ordering, not a property of this function, and `validate_startup_config()` is
|
||||
also `sim/inject.py`'s entry point. `unit_caps_indexed()`'s docstring records
|
||||
the same argument, and CFG-04's index desynchronisation is what happened the
|
||||
last time it was left implicit.
|
||||
"""
|
||||
if soc_floor is None or pack.bms_floor is None:
|
||||
return None
|
||||
return max(float(soc_floor), float(pack.bms_floor))
|
||||
|
||||
|
||||
def pack_permissions(packs, soc_floor) -> list[tuple[str, str | None]]:
|
||||
"""SAFETY-32. Per pack: None if it may be spent, else the reason it may not.
|
||||
|
||||
ONE `(name, reason)` pair per pack, in roster order, from the SAME
|
||||
`discharge_permitted()` the site verdict uses - a second transcription of
|
||||
"may this pack be spent" is a second answer to it, which is the argument
|
||||
that made that predicate public in the first place.
|
||||
|
||||
⚠️ THIS IS THE OBSERVABLE THE MIN() CANNOT PRODUCE. `Controller.pack_floor_soc`
|
||||
reduces the measured SoC to the emptiest pack BEFORE anything is graded, and
|
||||
that reduction is right for the site's one verdict - but it has thrown away
|
||||
which pack it came from and how far that pack was from ITS OWN floor by the
|
||||
time the answer exists. Two packs at 15 % against floors of 10 % and 20 %
|
||||
have opposite answers and are indistinguishable after the `min()`.
|
||||
"""
|
||||
return [(p.name, discharge_permitted(p.soc, pack_floor_pct(p, soc_floor)))
|
||||
for p in packs]
|
||||
|
||||
|
||||
def binding_soc_floor(packs, soc_floor):
|
||||
"""The ONE floor `decide()` may grade `pack_floor_soc` against, at a roster.
|
||||
|
||||
MAXIMUM over the packs' own floors, and it is the conservative reduction that
|
||||
pairs with the MINIMUM `pack_floor_soc` reduction: `min(soc) <= max(floor)`
|
||||
is implied by "some pack is at or below its own floor", so this verdict is
|
||||
never more permissive than grading each pack separately would be. The other
|
||||
direction is not true and is not claimed - a site can be refused here while
|
||||
every pack individually has headroom, which costs a shave and never a pack.
|
||||
|
||||
None when ANY pack's floor is unknown, for `Controller.site_dev_bound()`'s
|
||||
reason and in the same direction: `discharge_permitted()` treats a None floor
|
||||
as no permission, so an unknown floor withholds rather than defaults.
|
||||
|
||||
⚠️ AT N=1 THIS IS `soc_floor` ITSELF under any config rule 8 accepts, and
|
||||
test_peakguard.py part 12 asserts that over the whole schema range rather
|
||||
than assuming it.
|
||||
"""
|
||||
if not packs:
|
||||
return None if soc_floor is None else float(soc_floor)
|
||||
floors = [pack_floor_pct(p, soc_floor) for p in packs]
|
||||
if any(f is None for f in floors):
|
||||
return None
|
||||
return max(floors)
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
|
||||
+281
-26
@@ -126,16 +126,53 @@ SCHEMA_VERSION = 1
|
||||
SCHEMA_KEY = "schema_version"
|
||||
|
||||
# Which flat keys belong to ONE inverter, and therefore move into its namespace.
|
||||
# ⚠️ THE MAINTENANCE KEYS ARE NOT IN HERE AND THAT IS DELIBERATE. `phase`,
|
||||
# `phase_started`, `last_completed` and `last_start_attempt` belong to the
|
||||
# maintenance state machine, of which this add-on still holds exactly ONE - a
|
||||
# monthly recalibration is a pack procedure and per-pack SCHEDULING is a card of
|
||||
# its own, not something to infer from a key rename. Moving them here would
|
||||
# quietly re-home an install's whole maintenance history under a unit name; they
|
||||
# stay where they are, untouched, which is the other half of "migrates without
|
||||
# losing its latch OR ITS HISTORY".
|
||||
# ⚠️ THE MAINTENANCE KEYS ARE STILL NOT IN HERE, AND SINCE MAINT-05 THAT IS A
|
||||
# DIFFERENT DECISION RATHER THAN THE SAME ONE. They DO move now - one
|
||||
# `Maintenance` per roster entry needs one history per roster entry - but they
|
||||
# move on their OWN marker, `MAINT_SCHEMA_KEY`, and only on a site that actually
|
||||
# has more than one pack. Two conditions the SAFETY-02 keys do not share, so
|
||||
# folding them into this tuple would either drag them across on a
|
||||
# single-inverter install (which is the byte-identical store layout MAINT-05
|
||||
# must not change) or hold the sign latch hostage to a roster count it has
|
||||
# nothing to do with. `MAINT_UNIT_KEYS` below carries them.
|
||||
LEGACY_UNIT_KEYS = ("sign_tripped", "sign_reason")
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MAINT-05: THE SECOND MIGRATION, AND WHY IT IS A SECOND ONE
|
||||
#
|
||||
# 0 = the flat, one-history layout every install has had since the add-on
|
||||
# shipped: `phase` / `phase_started` / `last_completed` /
|
||||
# `last_start_attempt` describe THE pack, because there was one FSM.
|
||||
# 1 = one history per roster entry, under `unit/<name>/...`.
|
||||
#
|
||||
# ⚠️ ITS OWN MARKER, NOT A BUMP OF `SCHEMA_VERSION`, AND THE REASON IS THAT THE
|
||||
# TWO MIGRATIONS DO NOT HAVE THE SAME PRECONDITION. DEV-02's runs on every
|
||||
# install exactly once and is monotone in the version number. This one runs ONLY
|
||||
# WHERE THE ROSTER HAS MORE THAN ONE PACK, because at N=1 §6.2's identity holds
|
||||
# and the flat keys ARE that pack's namespace - moving them would add four keys
|
||||
# and a version marker to a store MAINT-05's own acceptance criterion requires
|
||||
# to be byte-identical. A single monotone counter cannot express "migrated on
|
||||
# this site, deliberately not migrated on that one" without lying to the next
|
||||
# version that reads it: a one-inverter store would have to be left at the old
|
||||
# number forever, and then a genuine v2 migration could never be told apart from
|
||||
# a site that simply never grew.
|
||||
#
|
||||
# ⚠️ AND IT IS TWO MARKERS, NOT TWO COPIES OF THE THREE RULES.
|
||||
# `_move_flat_keys()` and `_verify_moves()` below are shared verbatim by both
|
||||
# migrations, so rules 1 and 3 and the READ-BACK POSTCONDITION are one
|
||||
# implementation. A second transcription of a migration is a second opinion
|
||||
# about what "it landed" means.
|
||||
MAINT_SCHEMA_VERSION = 1
|
||||
MAINT_SCHEMA_KEY = "maint_schema_version"
|
||||
|
||||
# The flat maintenance-history keys, in the order they are moved.
|
||||
# ⚠️ THE VALUES ARE THE FLAT SPELLINGS AND THE MODULE THAT READS THEM INDEXES
|
||||
# THIS SAME TUPLE (`maintenance.UnitHistory`), so a key renamed here cannot
|
||||
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
|
||||
# SAFETY-02 pair.
|
||||
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
|
||||
"last_start_attempt")
|
||||
|
||||
|
||||
def unit_key(unit: str, key: str) -> str:
|
||||
"""The namespaced Store key for `key` on inverter `unit`.
|
||||
@@ -174,16 +211,35 @@ class MigrationReport:
|
||||
# to shout: an alarm that fires on the harmless case is an alarm an
|
||||
# operator learns to dismiss before the real one arrives.
|
||||
self.lost = []
|
||||
# ⚠️ A DIFFERENT FAILURE FROM `lost`, AND IT HAS TO BE, BECAUSE THE
|
||||
# READ-BACK CANNOT SEE IT. `lost` is "the store took the write and
|
||||
# dropped it". This is "the store took the write, kept it perfectly, and
|
||||
# NOTHING WILL EVER READ IT" - the migration was handed a unit name that
|
||||
# is not on the roster, so every machine looks under a different key and
|
||||
# finds nothing. There is no harmless-default version of this the way
|
||||
# there is for `lost`: a write nobody reads is not information, whatever
|
||||
# its value, so anything in here fails `ok` on its own.
|
||||
self.misdirected = []
|
||||
# Why nothing ran, when nothing ran. Empty means "already at schema".
|
||||
self.skipped = ""
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Nothing that CARRIED STATE failed to land."""
|
||||
return not self.lost
|
||||
"""Nothing that CARRIED STATE failed to land, and it landed WHERE THE
|
||||
MACHINES LOOK."""
|
||||
return not self.lost and not self.misdirected
|
||||
|
||||
def explain(self) -> str:
|
||||
if not self.ran:
|
||||
return (f"store already at schema {self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
# ⚠️ `skipped` RATHER THAN A SECOND SENTENCE ABOUT VERSIONS. Since
|
||||
# MAINT-05 there are two reasons a migration does not run - the
|
||||
# store is already at this schema, or the roster has one pack and
|
||||
# the flat keys ARE its namespace - and an operator reading the
|
||||
# startup log needs to be told which, because only one of them
|
||||
# changes when a second inverter is declared.
|
||||
return self.skipped or (f"store already at schema "
|
||||
f"{self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
||||
f"namespaces: " + ", ".join(sorted(self.moved)))
|
||||
if self.kept:
|
||||
@@ -192,6 +248,8 @@ class MigrationReport:
|
||||
out += " - PROBLEMS: " + "; ".join(self.problems)
|
||||
if self.lost:
|
||||
out += " - STATE LOST: " + "; ".join(self.lost)
|
||||
if self.misdirected:
|
||||
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
|
||||
return out
|
||||
|
||||
|
||||
@@ -243,7 +301,30 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
for legacy in LEGACY_UNIT_KEYS:
|
||||
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
|
||||
_verify_moves(store, rep)
|
||||
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
else:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
def _move_flat_keys(store, unit: str, keys, rep: MigrationReport) -> None:
|
||||
"""Rule 1, once, for both migrations.
|
||||
|
||||
Copies each flat `key` to `unit/<unit>/<key>` UNLESS the namespaced spelling
|
||||
is already present - a store that has both has been written by a version
|
||||
that knew about the namespace, and that value is the live one. Rule 2 is the
|
||||
absence of a delete: the flat keys are left exactly where they were.
|
||||
"""
|
||||
data = getattr(store, "data", {})
|
||||
for legacy in keys:
|
||||
target = unit_key(unit, legacy)
|
||||
if target in data:
|
||||
rep.kept.append(target)
|
||||
@@ -252,17 +333,63 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
# tripped has no `sign_tripped` in its file at all, and the value it
|
||||
# then lands on has to be the one `Store.load()` would have supplied -
|
||||
# otherwise "absent" and "never tripped" stop being the same statement,
|
||||
# which is the distinction store.py's own header is about.
|
||||
# which is the distinction store.py's own header is about. The same
|
||||
# sentence holds for `last_completed`: absent means "never recalibrated"
|
||||
# and a literal here would be a second opinion about that.
|
||||
value = data.get(legacy, DEFAULTS.get(legacy))
|
||||
store.set(target, value)
|
||||
rep.moved[target] = value
|
||||
|
||||
# THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION. This
|
||||
# is the difference between this function and a two-line copy: the caller
|
||||
# can ask "is the latch that was in the file before now under the key the
|
||||
# watchdog reads", through the same `unit_key()` the watchdog's constructor
|
||||
# reads through, and a `False` there is a guard that is off on an install
|
||||
# whose state file says it is on.
|
||||
|
||||
def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||
"""THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION.
|
||||
|
||||
This is the difference between these functions and a two-line copy: the
|
||||
caller can ask "is the latch that was in the file before now under the key
|
||||
the watchdog reads" - and, since MAINT-05, "is the history that was in the
|
||||
file before now under the key THIS PACK's state machine reads" - through the
|
||||
same `unit_key()` those readers read through. A `False` here is a guard that
|
||||
is off, or a month of coulomb-count history gone, on an install whose state
|
||||
file says otherwise.
|
||||
|
||||
⚠️ AND `roster` IS THE OTHER HALF, WHICH THE READ-BACK ON ITS OWN CANNOT BE.
|
||||
A POSTCONDITION THAT READS BACK THE NAMESPACE IT WAS HANDED CANNOT DETECT
|
||||
BEING HANDED THE WRONG ONE. That is not a hypothetical: MAINT-05 shipped
|
||||
with `migrate_maint_keys()` taking a bare `unit` string, and a call site that
|
||||
passed `inverter.UNIT_A` instead of the first roster entry's name migrated an
|
||||
install's whole maintenance history into `unit/inverter-a/...` on a roster of
|
||||
`["garage", "shed"]`, read all four keys back under that same name, was
|
||||
SATISFIED, and stamped the schema marker - so the next start skipped the
|
||||
migration too and both packs came up believing they had never been
|
||||
recalibrated. Every symptom of a clean migration, and the history gone.
|
||||
`build_roster()` names a declared roster's entries from the config and falls
|
||||
back to a POSITIONAL `inverter-N`, so `inverter-a` is not even the default
|
||||
there; there is no roster on which that write could have been read.
|
||||
|
||||
So the namespace is graded against the one the MACHINES read - the roster -
|
||||
rather than against the argument this function was handed, which is the
|
||||
thing under test. A unit that is not on the roster is `misdirected`, which
|
||||
fails `ok`, which means the caller does NOT stamp the version marker and the
|
||||
next start runs the migration again. A re-run is recoverable, by rule 1; a
|
||||
silent skip is the state store.py's header says must never happen.
|
||||
|
||||
⚠️ AND THE GRADED SET IS `moved` UNION `kept`, WHICH IS WHAT MAKES THAT
|
||||
PARAGRAPH TRUE ON THE SECOND START AS WELL AS THE FIRST. Graded over `moved`
|
||||
alone the refusal lasted exactly one start: the wrong call site is still
|
||||
wrong next time, finds the keys it wrote already present, and rule 1 routes
|
||||
every one of them to `kept` - so `moved` is empty, this loop sees nothing,
|
||||
`ok` comes back True and the marker is stamped on a migration that has
|
||||
reached no readable key. The measurement is in the comment on the loop
|
||||
itself. `test_maintenance.py` section 9c calls this twice with the SAME
|
||||
wrong `unit` over one store, which is the shape nothing in the suite had.
|
||||
|
||||
`roster=None` means "not graded", and the one caller that passes it is
|
||||
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
|
||||
own docstring gives - the `Inverter` objects restore their latch in their
|
||||
constructors, so the keys have to be in place before there is a roster to
|
||||
check against. That gap is real and is recorded as a running check rather
|
||||
than left to a comment: see test_maintenance.py section 13.
|
||||
"""
|
||||
for target, value in rep.moved.items():
|
||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||
if got == value:
|
||||
@@ -274,14 +401,142 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
if value != DEFAULTS.get(legacy):
|
||||
rep.lost.append(note)
|
||||
|
||||
if roster is None:
|
||||
return
|
||||
names = [str(n) for n in roster]
|
||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
|
||||
# through the same spelling the writer used rather than re-deriving it.
|
||||
#
|
||||
# ⚠️ `moved` UNION `kept`, AND THE UNION IS THE WHOLE OF THE SECOND FIX
|
||||
# ROUND. Grading only `moved` made the refusal last EXACTLY ONE START.
|
||||
# Measured at Controller level over one persisted store, roster
|
||||
# ["garage", "shed"], flat `last_completed` 2026-08-07, with the call site
|
||||
# still handing `inverter-a` on both starts:
|
||||
#
|
||||
# construction 1: ok=False misdirected=4 moved=4 kept=0 marker=<absent>
|
||||
# construction 2: ok=True misdirected=0 moved=0 kept=4 marker=1
|
||||
#
|
||||
# On start 2 the four keys the WRONG start 1 wrote are already present, so
|
||||
# rule 1 routes every one of them to `rep.kept`, `rep.moved` is EMPTY, this
|
||||
# loop iterates over nothing, `misdirected` is empty and `ok` is True - so
|
||||
# the caller stamps the marker and start 3 takes the `from_version >=
|
||||
# SCHEMA` early return FOREVER, including after the call site is fixed.
|
||||
# Loud-and-recoverable became silent-and-permanent after one restart: the
|
||||
# exact state this module's header says must never happen, reached by the
|
||||
# fix that was written to prevent it.
|
||||
#
|
||||
# The union is sound by construction rather than by hope: every entry in
|
||||
# both `rep.moved` and `rep.kept` is a `unit_key(unit, legacy)` built from
|
||||
# the SAME `unit` argument, so grading `kept` grades the same namespace
|
||||
# decision `moved` does. A correct call site puts its `kept` entries under a
|
||||
# roster name and they pass; a wrong one cannot launder its namespace by
|
||||
# having already written it.
|
||||
for target in sorted(set(rep.moved) | set(rep.kept)):
|
||||
parts = target.split("/")
|
||||
unit = parts[1] if len(parts) > 2 else ""
|
||||
if unit in names:
|
||||
continue
|
||||
rep.verified = False
|
||||
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
|
||||
f"on the roster {names!r} - nothing reads that key")
|
||||
rep.problems.append(note)
|
||||
rep.misdirected.append(note)
|
||||
|
||||
|
||||
def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
||||
"""Move the flat maintenance history into the FIRST pack's namespace.
|
||||
|
||||
MAINT-05. Until this card there was one `Maintenance` and one history, and
|
||||
`store.py`'s own header argued the keys should stay flat because per-pack
|
||||
scheduling was a card of its own. This is that card: there is now one FSM
|
||||
per roster entry, so there is one history per roster entry, and the flat one
|
||||
belongs to the pack that was being recalibrated - `inverters[0]`, which is
|
||||
what `Controller.maint_unit` named and what `validate.py` rule 6 graded
|
||||
against.
|
||||
|
||||
⚠️ `len(roster) <= 1` IS A RETURN, NOT A SPECIAL CASE. At one inverter the
|
||||
flat keys ARE that pack's namespace (§6.2's identity, the same one
|
||||
`arbiter.actuator_for()` leans on), `Maintenance` is constructed to read
|
||||
them directly, and a migration would add four keys plus a marker to a store
|
||||
that must come out byte-identical. The migration happens on the day the
|
||||
roster grows, which is the day the flat spelling stops being able to answer
|
||||
"which pack".
|
||||
|
||||
⚠️ `roster` IS REQUIRED, AND IT REPLACED AN `n_units: int`. Two reasons, and
|
||||
the second is the one MAINT-05 was rejected over.
|
||||
|
||||
The small one: `n_units` was `len(self.inverters)` at the only call site, so
|
||||
the roster carries it - `len(roster)` - and a count passed beside the names
|
||||
is a second opinion about how many packs there are.
|
||||
|
||||
The load-bearing one: THE NAMES ARE THE POSTCONDITION. `unit` says which
|
||||
pack the flat keys belonged to and that is genuinely a decision - the first
|
||||
roster entry, because that is the pack the one FSM was driving - but a
|
||||
decision the caller can get WRONG, and until this signature existed nothing
|
||||
could tell. `_verify_moves()` read back the namespace it was handed, so a
|
||||
migration into a name no machine reads verified clean and stamped the
|
||||
marker. Handing the roster in means the read-back is graded against the
|
||||
keys the MACHINES read, and a `unit` that is not among these names comes
|
||||
back `misdirected`: not `ok`, marker NOT written, migration runs again next
|
||||
start - and again the start after that, because `_verify_moves()` grades
|
||||
`kept` as well as `moved`. See `_verify_moves()` for the install this was
|
||||
measured on, and for what grading `moved` alone cost on start 2.
|
||||
|
||||
THE THREE RULES ARE `migrate_unit_keys()`'s, SHARED IN CODE rather than
|
||||
restated: `_move_flat_keys()` is rule 1 (never overwrite a namespaced value)
|
||||
and rule 2 (the flat keys are left in the file), `_verify_moves()` is the
|
||||
read-back postcondition, and the marker is written LAST so a crash before it
|
||||
leaves the migration to run again - which rule 1 makes a no-op.
|
||||
|
||||
⚠️ AND RULE 2 IS KEPT LIVE HERE TOO, WHICH IS THE HALF DEV-02 HAD TO ADD
|
||||
LATER. "Left in place" alone makes the flat history a fossil dated the day
|
||||
the second inverter was declared: an operator who rolls back to a
|
||||
single-FSM add-on would find a `last_completed` months old and recalibrate a
|
||||
pack that does not need it, or - worse - a stale `phase` and a
|
||||
`phase_started` old enough to abort on the 12 h timeout the moment it comes
|
||||
up. So `maintenance.UnitHistory` MIRRORS the first pack's writes back onto
|
||||
the flat keys for as long as the roster has more than one entry. The flat
|
||||
pair is the ROLLBACK VIEW of the history, maintained, exactly as
|
||||
`sign_tripped` is the rollback view of the latch.
|
||||
"""
|
||||
rep = MigrationReport()
|
||||
data = getattr(store, "data", {})
|
||||
names = [str(n) for n in roster]
|
||||
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
|
||||
if len(names) <= 1:
|
||||
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
|
||||
"history and are read in place, so there is nothing to "
|
||||
"move and the store layout does not change")
|
||||
return rep
|
||||
if rep.from_version >= MAINT_SCHEMA_VERSION:
|
||||
rep.skipped = (f"maintenance history already at schema "
|
||||
f"{rep.from_version}; written by a multi-inverter "
|
||||
f"version and not touched")
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)
|
||||
_verify_moves(store, rep, roster=names)
|
||||
|
||||
# ⚠️ `rep.ok` AND NOTHING ELSE - NOT `ok or kept`, NOT `not lost`, NOT
|
||||
# `not misdirected`. BOTH guards gate this line and each one is pinned by a
|
||||
# check that the other cannot satisfy: section 9c refuses a store whose
|
||||
# failure is `misdirected` with `lost` empty, and section 9d refuses one
|
||||
# whose failure is `lost` with `misdirected` empty AND `kept` non-empty -
|
||||
# the mutation `if rep.ok or rep.kept:` stamps the marker on a migration
|
||||
# that lost a month of history, and until section 9d existed all 19 suites
|
||||
# were green on it.
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
|
||||
else:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
# ⚠️ NOT MARKED DONE, FOR EITHER FAILURE. The same sentence
|
||||
# `migrate_unit_keys()` carries: the one thing worse than a failed
|
||||
# migration is a failed migration a later version skips. Rule 1 makes
|
||||
# the re-run a no-op where the keys did land, so refusing the marker
|
||||
# costs one repeated pass and buys the difference between a migration
|
||||
# that can still be fixed and one the store says already happened.
|
||||
_LOG.error("per-pack maintenance-history migration did NOT land and "
|
||||
"has not been marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
installed base is on a fixed or static tariff. Refusing to start without a
|
||||
price feed would lock those sites out of the add-on entirely.
|
||||
|
||||
8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)]
|
||||
8. `peak_soc_floor` at or below `bms_soc_floor`, ON ANY UNIT. [(a)]
|
||||
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
|
||||
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
|
||||
is the EMS-side bound on how far down it may take the pack. The BMS has its
|
||||
@@ -142,6 +142,12 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
of binding while its status page reports one - rule 5's "a disarmed guard
|
||||
that advertises itself as armed", on the guard whose failure mode is a pack
|
||||
that is quietly never available when it is wanted.
|
||||
⚠️ SAFETY-32: `bms_soc_floor` DESCRIBES A BATTERY, so at a roster it is a
|
||||
per-entry key and this rule grades EVERY unit and NAMES the one it refuses.
|
||||
One number for two packs of different models refuses the wrong config and
|
||||
accepts the wrong one - cut-outs of 10 % and 30 % under `peak_soc_floor: 20`
|
||||
used to start. A missing per-entry cut-out is refused here rather than
|
||||
defaulted: a cut-out invented for a pack IS this defect.
|
||||
|
||||
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
|
||||
equal, the EMS floor and the pack's cut-out act at the same percent, so the
|
||||
@@ -386,6 +392,49 @@ def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
|
||||
return caps
|
||||
|
||||
|
||||
def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
|
||||
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
|
||||
|
||||
`(roster index, the raw value as configured, the parsed float or None)`, one
|
||||
triple per unit. The raw value travels too because the refusal quotes what
|
||||
the operator actually wrote, and `!r` of a re-read `opts` key is the wrong
|
||||
string the moment the value came off a roster entry.
|
||||
|
||||
⚠️ THE INDEX IS THE ROSTER'S, NOT THIS LIST'S, AND THAT IS CFG-04'S BUG
|
||||
WRITTEN DOWN. Rules 5 and 6 fed a CAPS-list index into `_unit_label()`,
|
||||
which indexes the ROSTER, so on a roster whose first entry was malformed the
|
||||
refusal named the WRONG INVERTER - an operator sent to the wrong device by
|
||||
the very message that exists to stop that. `unit_caps_indexed()` exists
|
||||
because of it. This function goes one step further and SKIPS NOTHING: every
|
||||
entry produces a triple, including the ones with no usable floor, because a
|
||||
missing cut-out is itself what rule 8 has to refuse BY NAME. There is
|
||||
therefore no second list for an index to desynchronise from.
|
||||
|
||||
⚠️ AND NOTHING IS INHERITED. At a declared roster the FLAT `bms_soc_floor` is
|
||||
not read at all - CFG-04's rule, and this card's subject: a pack that borrows
|
||||
a sibling's cut-out is graded against a battery it is not. At the flat shape
|
||||
the flat key IS this one pack's cut-out, defaulted from CFG-03's single
|
||||
mirror exactly as `inverter._flat_bms_floor()` defaults it.
|
||||
"""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [(0, opts.get("bms_soc_floor"),
|
||||
_number(opts, "bms_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["bms_soc_floor"]))]
|
||||
out: list[tuple[int, object, object]] = []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
# Rule 9(a)/(b) already refuses a non-dict entry by index, and it
|
||||
# runs FIRST. Naming it twice would hand the operator two sentences
|
||||
# about one line of YAML.
|
||||
continue
|
||||
raw = entry.get("bms_soc_floor")
|
||||
out.append((index, raw,
|
||||
None if raw is None or raw == ""
|
||||
else _number(entry, "bms_soc_floor", None)))
|
||||
return out
|
||||
|
||||
|
||||
def _unit_label(opts: dict, index: int) -> str:
|
||||
""""" at a single-inverter config, " (inverter-b)" at a roster.
|
||||
|
||||
@@ -757,21 +806,48 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# second inverter is added - which is the day nobody is watching the startup
|
||||
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
|
||||
# prevents is silent.
|
||||
#
|
||||
# ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32
|
||||
# ROUND 5. This branch used to compare against `max_w = _number(opts,
|
||||
# "max_w", 2000.0)`, the FLAT option, which on a roster config is either
|
||||
# ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the
|
||||
# site) or a leftover from a single-inverter install (so it compared against
|
||||
# a number no unit has). Both directions were live and both were measured:
|
||||
#
|
||||
# a roster of two 2000 W units beside a leftover `max_w: 5000` and
|
||||
# `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED,
|
||||
# with a per-inverter watchdog reporting `armed` on the status page and
|
||||
# incapable of tripping on EITHER unit. That is the identical sentence the
|
||||
# branch above exists to refuse, reached through the branch beside it;
|
||||
#
|
||||
# and a roster of two 5000 W units with no flat key was REFUSED at
|
||||
# `unit_sign_disagree_w: 3000` against a 2000 W default the operator never
|
||||
# wrote, for a threshold both units can reach.
|
||||
#
|
||||
# `unit_caps()`'s own docstring already said why: there is no such thing as
|
||||
# a site rating on the wire, and this watchdog grades ONE UNIT's standing
|
||||
# command against THAT unit's `supervised` bound. So it takes the same
|
||||
# `weakest` the branch above takes, from the same list, and names the same
|
||||
# unit. At a roster of one `unit_caps()` is `[max_w]` and this is the
|
||||
# identical comparison it always was - which is what keeps the shipped
|
||||
# single-inverter refusal byte-for-byte what it was.
|
||||
unit_sign_w = _number(opts, "unit_sign_disagree_w",
|
||||
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
|
||||
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||
if caps_ix else "")
|
||||
if unit_sign_w is None:
|
||||
problems.append(f"unit_sign_disagree_w "
|
||||
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
|
||||
elif max_w is not None and max_w > 0 and unit_sign_w >= max_w:
|
||||
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
||||
problems.append(
|
||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is "
|
||||
f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
|
||||
f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
||||
"watchdog can never fire: no inverter is ever commanded past max_w, "
|
||||
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
|
||||
"inverters that is the guard which catches a unit doing the "
|
||||
"OPPOSITE of what it was told while its sibling covers for it at "
|
||||
"the meter - the case the site-level watchdog is structurally "
|
||||
f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W "
|
||||
f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W "
|
||||
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
|
||||
"measured default), or raise max_w.")
|
||||
|
||||
@@ -802,24 +878,47 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
|
||||
# test_maintenance.py pins it by reading the shipped file.
|
||||
#
|
||||
# ⚠️ AND IT IS GRADED AGAINST THE FIRST UNIT'S CAP, NOT THE SMALLEST AND NOT
|
||||
# THE FLAT ONE. Rule 5 takes the weakest unit because there is a watchdog on
|
||||
# every one of them; there is exactly ONE `Maintenance` in this add-on and
|
||||
# `Controller.maint_unit` names the pack it recalibrates - the first in the
|
||||
# roster, because the cycle's history lives under un-namespaced Store keys.
|
||||
# The shaping claim is filed on THAT actuator and on no other, so the cap
|
||||
# that can contradict it is THAT unit's. Taking `min()` here would refuse a
|
||||
# perfectly workable site - a 5000 W pack being recalibrated beside a 2000 W
|
||||
# sibling the cycle never touches - which is the over-refusal this file's
|
||||
# docstring says gets a validator switched off. When `maint_unit` stops
|
||||
# being `inverters[0]`, this line moves with it.
|
||||
# ⚠️ MAINT-05: IT IS GRADED AGAINST THE WEAKEST UNIT'S CAP NOW, AND THE
|
||||
# COMMENT THIS REPLACES SAID SO WOULD HAPPEN. It read: "there is exactly ONE
|
||||
# `Maintenance` in this add-on and `Controller.maint_unit` names the pack it
|
||||
# recalibrates - the first in the roster, because the cycle's history lives
|
||||
# under un-namespaced Store keys. The shaping claim is filed on THAT
|
||||
# actuator and on no other, so the cap that can contradict it is THAT
|
||||
# unit's. Taking `min()` here would refuse a perfectly workable site - a
|
||||
# 5000 W pack being recalibrated beside a 2000 W sibling the cycle never
|
||||
# touches ... When `maint_unit` stops being `inverters[0]`, this line moves
|
||||
# with it."
|
||||
#
|
||||
# `maint_unit` has stopped being `inverters[0]`. There is one `Maintenance`
|
||||
# per roster entry, each with its own namespaced history, and EVERY pack is
|
||||
# recalibrated on its own schedule - so the sibling "the cycle never
|
||||
# touches" does not exist any more and the over-refusal argument has no
|
||||
# subject. The shaping claim is filed on whichever pack is charging, so the
|
||||
# cap that can contradict it is any cap on the roster, and the one that
|
||||
# contradicts it FIRST is the smallest. This is rule 5's conjunction for
|
||||
# rule 5's reason: a threshold that is fine against the biggest unit says
|
||||
# nothing about the smallest, and the smallest is the one that goes to 0 W.
|
||||
#
|
||||
# ⚠️ THIS REFUSES CONFIGS THAT USED TO START, AND THAT IS THE POINT RATHER
|
||||
# THAN A SIDE EFFECT. `max_w` 5000 on pack A beside 2000 on pack B with
|
||||
# `maintenance_charge_w` 3000 was accepted before this card and was correct
|
||||
# while only pack A was ever recalibrated. Pack B now recalibrates too, and
|
||||
# its cheap-window charge asks the arbiter for `hi = -3000` against a
|
||||
# supervised `lo = -2000`: `lo > hi`, arbiter clause 3, 0 W, contradiction -
|
||||
# a maintenance charge that silently never happens on that pack, which is
|
||||
# exactly what rule 6 exists to refuse at the moment it is WRITTEN. At a
|
||||
# roster of one `min()` over one cap is that cap and the comparison, the
|
||||
# message and the boundary are identical.
|
||||
charge_w = _number(opts, "maintenance_charge_w",
|
||||
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
|
||||
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON. `caps[0]` is the first
|
||||
# unit WITH A USABLE CAP, which is `inverters[0]` only while every earlier
|
||||
# entry has one; `_unit_label(opts, 0)` was hard-wired to `inverters[0]`
|
||||
# regardless. Both come off the one pair now.
|
||||
maint_index, maint_cap = caps_ix[0] if caps_ix else (0, None)
|
||||
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON: `unit_caps_indexed()`
|
||||
# SKIPS entries with no usable `max_w`, so a caps-list position is not a
|
||||
# roster position and `_unit_label()` indexes the roster. The index travels
|
||||
# with the number, so the unit the message names is the unit the number came
|
||||
# off - which on a roster whose first entry has a junk `max_w` is not the
|
||||
# same thing at all.
|
||||
maint_index, maint_cap = (min(caps_ix, key=lambda p: p[1]) if caps_ix
|
||||
else (0, None))
|
||||
maint_where = _unit_label(opts, maint_index)
|
||||
if charge_w is None:
|
||||
problems.append(
|
||||
@@ -848,28 +947,39 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# in-code mirror, pinned to config.yaml by test_peakguard.py, and main.py
|
||||
# reads the same one - exactly as maintenance.MAINT_OPTION_DEFAULTS now is
|
||||
# for the `maintenance_*` family.
|
||||
#
|
||||
# ⚠️ SAFETY-32 - GRADED ONCE PER UNIT, AND THE REFUSAL NAMES THE UNIT. One
|
||||
# `bms_soc_floor` for two packs compares the EMS floor against whichever
|
||||
# battery happened to be written into the flat key: a site with cut-outs of
|
||||
# 10 % and 30 % and `peak_soc_floor: 20` STARTED, and the guard would then
|
||||
# command a forced discharge that the second pack's BMS - not the operator's
|
||||
# floor - is the thing that stops. That is the disarmed-backstop state this
|
||||
# rule exists to refuse, reached through the rule itself. So the loop is over
|
||||
# units, and `_unit_label()` is fed the ROSTER index the value came off (see
|
||||
# `unit_bms_floors_indexed()`), because CFG-04 has just fixed the version of
|
||||
# this that named the wrong inverter.
|
||||
soc_floor = _number(opts, "peak_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
|
||||
bms_floor = _number(opts, "bms_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
||||
if soc_floor is None or bms_floor is None:
|
||||
problems.append(
|
||||
"peak_soc_floor and bms_soc_floor must both be numbers (got "
|
||||
f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})")
|
||||
elif soc_floor <= bms_floor:
|
||||
problems.append(
|
||||
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
||||
f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
||||
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
||||
"the pack's protection rather than yours. The guard would report a "
|
||||
"SoC floor on its status page while being incapable of ever being "
|
||||
"the thing that stops the discharge, and the symptom of that is a "
|
||||
"battery repeatedly sitting at the BMS limit with no record of why "
|
||||
f"- which looks exactly like the SoC drift the maintenance cycle "
|
||||
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
||||
f"(the shipped default is "
|
||||
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
||||
"bms_soc_floor if it does not match this pack.")
|
||||
for _ix, _raw, bms_floor in unit_bms_floors_indexed(opts):
|
||||
where = _unit_label(opts, _ix)
|
||||
if soc_floor is None or bms_floor is None:
|
||||
problems.append(
|
||||
f"peak_soc_floor and bms_soc_floor{where} must both be numbers "
|
||||
f"(got {opts.get('peak_soc_floor')!r} / {_raw!r})")
|
||||
elif soc_floor <= bms_floor:
|
||||
problems.append(
|
||||
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
||||
f"(bms_soc_floor{where}) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
||||
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
||||
"the pack's protection rather than yours. The guard would report a "
|
||||
"SoC floor on its status page while being incapable of ever being "
|
||||
"the thing that stops the discharge, and the symptom of that is a "
|
||||
"battery repeatedly sitting at the BMS limit with no record of why "
|
||||
f"- which looks exactly like the SoC drift the maintenance cycle "
|
||||
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
||||
f"(the shipped default is "
|
||||
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
||||
f"bms_soc_floor{where} if it does not match this pack.")
|
||||
|
||||
# --- warnings: real, but not worth refusing over -------------------------
|
||||
integ = opts.get("integrator_max_w")
|
||||
|
||||
@@ -56,23 +56,58 @@ options:
|
||||
# soc_entity: sensor.goodwe_a_soc
|
||||
# batt_entity: sensor.goodwe_a_battery_power
|
||||
# max_w: 5000
|
||||
# bms_soc_floor: 10
|
||||
# - name: inverter-b
|
||||
# setpoint_entity: number.goodwe_b_setpoint_w
|
||||
# soc_entity: sensor.goodwe_b_soc
|
||||
# batt_entity: sensor.goodwe_b_battery_power
|
||||
# max_w: 3000
|
||||
# max_w: 4600
|
||||
# bms_soc_floor: 10
|
||||
#
|
||||
# ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A
|
||||
# PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES,
|
||||
# serial `55048ESU22AW0992`, whose datasheet gives Max. Charging / Discharging
|
||||
# Power as 4600 W; nothing will be connected to its MPPTs, so the battery path
|
||||
# is the ceiling and the 5000 VA AC nameplate is not. The 3000 W this example
|
||||
# used to carry was a guess made before the unit was known. ⚠️ It is STILL not
|
||||
# measured: the datasheet prints 4600 W under BATTERY INPUT DATA, so it may be
|
||||
# DC, and `max_w` is graded against 0x0518 - the inverter's own AC power. If it
|
||||
# is DC the AC ceiling is nearer 4324 W. SAFETY-20's `min(max_w, dev_max)` is
|
||||
# what settles that once the device range is read; see goodwe/NOTES.md §1.1b,
|
||||
# which records both readings and picks neither.
|
||||
#
|
||||
# ⚠️ AND THE `bms_soc_floor: 10` BESIDE IT IS FROM THE SAME PLACE - THE
|
||||
# DATASHEET AND THE SERIAL, NOT A PACK ANYONE HAS READ. No second inverter
|
||||
# exists on this site (SPIKE-04, ≈ 2026-09-12 for B); the cut-out must be read
|
||||
# off the pack that is actually installed (register 0x0560 on the reference
|
||||
# unit) before that pack is commanded. It reads 10 here because pack A reads
|
||||
# 10, which makes this example DEGENERATE ON THE FLOOR AXIS: a site whose two
|
||||
# packs floor at the SAME percent cannot separate `min`, `max`, first or last
|
||||
# over the roster, and every SAFETY-32 fixture that DIFFERS the floors differs
|
||||
# it for exactly that reason. Do not "simplify" those fixtures to match this
|
||||
# example.
|
||||
#
|
||||
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
|
||||
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
|
||||
# rating became per-instance precisely so that no inverter is ever graded
|
||||
# against a sibling's numbers; an entry silently inheriting `soc_entity` would
|
||||
# rebuild that defect in YAML. `name`, `setpoint_entity` and `max_w` are
|
||||
# therefore REQUIRED of every entry (the schema below enforces it, and
|
||||
# rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and
|
||||
# `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and
|
||||
# validate.py rule 9 enforces it again for the callers the Supervisor never
|
||||
# sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH
|
||||
# SENSOR - `inputs_ok` already stops the site for that - and never means
|
||||
# "use the other one's".
|
||||
#
|
||||
# ⚠️ SAFETY-32: `bms_soc_floor` IS PER ENTRY AND IS REQUIRED, NOT DEFAULTED.
|
||||
# It is the percent THAT pack's own BMS cuts out at, and two batteries of
|
||||
# different models do not share one. A default would be this add-on inventing
|
||||
# a cut-out for a battery it has not been told about, which is the same defect
|
||||
# as inheriting one: the shallower pack gets protected by a floor that belongs
|
||||
# to the deeper pack, and validate.py rule 8 - which refuses a peak_soc_floor
|
||||
# the BMS would win against - would be grading the wrong number. Rule 8 grades
|
||||
# EVERY entry and names the one it refuses. The flat key below is unchanged and
|
||||
# is the single-inverter shape's own cut-out.
|
||||
#
|
||||
# ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a
|
||||
# roster is declared is two config sites aimed at actuators, one of which
|
||||
# nothing writes; if it names an entity an entry also names, SAFETY-28's
|
||||
@@ -355,6 +390,13 @@ options:
|
||||
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
|
||||
# it: such a floor can never bind, the BMS wins, and the status page would
|
||||
# report an energy backstop that does not exist.
|
||||
#
|
||||
# ⚠️ SAFETY-32 - THIS KEY DESCRIBES ONE PACK, so it is the SINGLE-INVERTER
|
||||
# shape's number and nothing more. Declare an `inverters:` roster and each
|
||||
# entry states its own `bms_soc_floor`; this one is not read at all and is
|
||||
# not inherited by any entry. Rule 8 then grades every unit separately, which
|
||||
# is the whole of that card: one cut-out for two packs of different models
|
||||
# protects the shallower pack with the deeper pack's number.
|
||||
bms_soc_floor: 10
|
||||
|
||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||
@@ -500,6 +542,7 @@ schema:
|
||||
- name: str
|
||||
setpoint_entity: str
|
||||
max_w: int(100,5000)
|
||||
bms_soc_floor: int(0,50)
|
||||
soc_entity: str?
|
||||
batt_entity: str?
|
||||
batt_invert: bool?
|
||||
|
||||
@@ -192,10 +192,27 @@ class Hass:
|
||||
|
||||
A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
|
||||
"batt_entity": "sensor.batt_a"}
|
||||
# ⚠️ A HYPOTHESIS, NOT A MEASUREMENT. Inverter B does not exist; 3000 W is
|
||||
# chosen only because it DIFFERS from A's 5000 W, which is what lets a check
|
||||
# tell B's number from A's. SPIKE-04 measures the real one.
|
||||
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
|
||||
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
|
||||
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
|
||||
# kept because the only property these checks consume is that B's number is
|
||||
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
|
||||
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
|
||||
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
|
||||
# onto the real values would cost these checks their separating power to buy
|
||||
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
|
||||
# placeholder for it either - the placeholder era ended on 2026-09-04.
|
||||
B_RATING_HYPOTHESIS = 3000.0
|
||||
# SAFETY-32. Each pack's OWN BMS cut-out percent, now a REQUIRED roster key.
|
||||
# ⚠️ 10 IS MEASURED (register 0x0560 on the reference GW5000S-BP and the shipped
|
||||
# `bms_soc_floor` default); 15 IS SIMULATED - there is no second pack. They
|
||||
# DIFFER on purpose: a fixture whose two per-unit numbers are equal cannot show
|
||||
# a check that read the wrong unit's, which is MUTATION.md habit 1 and the defect
|
||||
# that has now bitten four consecutive cards. Both sit below the shipped
|
||||
# `peak_soc_floor` of 20, so rule 8 accepts this roster.
|
||||
A_BMS_FLOOR = 10
|
||||
B_BMS_FLOOR_SIM = 15
|
||||
|
||||
A_RATING = 5000.0
|
||||
|
||||
BASE_OPTS = dict(A_ENT, meter_source="off", meter_entity="sensor.grid",
|
||||
@@ -572,6 +589,58 @@ check("...and a store that only fails to persist a DEFAULT is not reported as "
|
||||
"dismiss before the real one arrives",
|
||||
migrate_unit_keys(LosesTheLatch(), UNIT_A).ok is True)
|
||||
|
||||
# ⚠️ AND THE MARKER GATE, ON A STORE WHERE SOMETHING WAS ALREADY NAMESPACED.
|
||||
# FOUND BY MAINT-05's ROUND-3 BATTERY, AND IT WAS A SURVIVOR OF ALL 19 FILES.
|
||||
# `migrate_unit_keys()` gates `SCHEMA_KEY` on `rep.ok`, and every check above
|
||||
# reaches that gate on a store where NOTHING was already under `unit/...` - so
|
||||
# `rep.kept` is empty in all of them, and the mutation
|
||||
#
|
||||
# if rep.ok: -> if rep.ok or rep.kept:
|
||||
#
|
||||
# stamped the marker on a migration that DROPPED THE LATCH, with the whole suite
|
||||
# green. That is a disarmed safety guard plus a store that says the migration
|
||||
# finished, so the next start skips the one thing that would have re-armed it -
|
||||
# DEV-02's own worst case with the paper trail filed under "done".
|
||||
#
|
||||
# The fixture is the one shape that separates them: `sign_reason` ALREADY
|
||||
# namespaced (so `kept` is non-empty and the mutant's `or` fires) and the
|
||||
# `sign_tripped: true` write dropped on the floor (so `lost` is non-empty and
|
||||
# `ok` is False). `migrate_unit_keys()` is passed no roster, so `misdirected`
|
||||
# cannot be what refuses this - it is the `lost` guard or nothing.
|
||||
_kept_reason = "SIGN_DISAGREEMENT: already namespaced by a later version"
|
||||
_broken_kept = LosesTheLatch({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured",
|
||||
unit_key(UNIT_A, "sign_reason"): _kept_reason})
|
||||
_rep_kept = migrate_unit_keys(_broken_kept, UNIT_A)
|
||||
print(f" a dropped latch write beside an already-namespaced reason: "
|
||||
f"{_rep_kept.explain()}")
|
||||
print(f" lost={len(_rep_kept.lost)} kept={len(_rep_kept.kept)} "
|
||||
f"ok={_rep_kept.ok}; marker "
|
||||
f"{_broken_kept.data.get(SCHEMA_KEY, '<<absent>>')!r}")
|
||||
check("PRECONDITION: this fixture reaches the marker gate through the `lost` "
|
||||
"guard with a NON-EMPTY `kept`, which is the combination none of the "
|
||||
"checks above has. One key dropped, one key left alone, `ok` False. If a "
|
||||
"later edit empties `kept` here the mutation goes invisible again, so the "
|
||||
"shape is asserted rather than assumed",
|
||||
_rep_kept.ran and len(_rep_kept.lost) == 1 and len(_rep_kept.kept) == 1
|
||||
and not _rep_kept.ok
|
||||
and _broken_kept.data[unit_key(UNIT_A, "sign_reason")] == _kept_reason)
|
||||
check("⚠️ AND THE STORE IS NOT MARKED MIGRATED. The key that was ALREADY there "
|
||||
"does not buy a marker for the latch that was not: stamping here would "
|
||||
"tell the next start that the migration which dropped `sign_tripped` had "
|
||||
"finished, so it would never run again and the guard would stay off on an "
|
||||
"install whose file still reads `sign_tripped: true`",
|
||||
SCHEMA_KEY not in _broken_kept.data)
|
||||
check("...and the counterfactual, one property different: the SAME store shape "
|
||||
"on a store that does not drop writes IS marked migrated. So the refusal "
|
||||
"above is about the latch that was lost, not about `kept` being non-empty",
|
||||
migrate_unit_keys(
|
||||
MemStore({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured",
|
||||
unit_key(UNIT_A, "sign_reason"): _kept_reason}),
|
||||
UNIT_A).ok is True)
|
||||
|
||||
_broken2 = LosesTheLatch({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured"})
|
||||
_c2d, _ = built(store=_broken2)
|
||||
@@ -1359,13 +1428,16 @@ _TWO_OPTS.pop("batt_entity")
|
||||
_TWO_OPTS["inverters"] = [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
max_w=A_RATING),
|
||||
max_w=A_RATING, bms_soc_floor=A_BMS_FLOOR),
|
||||
# ⚠️ 3000 W IS A HYPOTHESIS AND IS MARKED AS ONE WHEREVER IT APPEARS. It is
|
||||
# here because it DIFFERS from A's 5000 W, which is the only property any
|
||||
# check below uses. SPIKE-04 measures the real device.
|
||||
# ⚠️ AND SO IS ITS 15 % CUT-OUT (SAFETY-32). There is no second pack to read
|
||||
# register 0x0560 off; the value is SIMULATED, and it differs from A's 10 %
|
||||
# for MUTATION.md habit 1's reason and no other.
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS),
|
||||
max_w=B_RATING_HYPOTHESIS, bms_soc_floor=B_BMS_FLOOR_SIM),
|
||||
]
|
||||
_c9, _hass9 = built(_TWO_OPTS)
|
||||
print(f" the roster built from `inverters:` -> {[repr(i) for i in _c9.inverters]}")
|
||||
@@ -1441,14 +1513,19 @@ _decl9 = dict(ln.strip().lstrip("- ").split(": ", 1)
|
||||
for ln in _entry9.splitlines()[1:] if ": " in ln)
|
||||
print(f" the shipped per-entry schema: {_decl9}")
|
||||
check("...and the schema half declares it as a LIST OF OBJECTS whose `name`, "
|
||||
"`setpoint_entity` and `max_w` are REQUIRED - no trailing `?` - so the "
|
||||
"Supervisor refuses a half-written entry before the add-on sees it, and "
|
||||
"an entry can never fall back on a sibling's rating because there is no "
|
||||
"entry without one",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "soc_entity",
|
||||
"batt_entity", "batt_invert"}
|
||||
"`setpoint_entity`, `max_w` and `bms_soc_floor` are REQUIRED - no "
|
||||
"trailing `?` - so the Supervisor refuses a half-written entry before the "
|
||||
"add-on sees it, and an entry can never fall back on a sibling's rating "
|
||||
"or on a sibling's BMS cut-out because there is no entry without one. "
|
||||
"⚠️ SAFETY-32 ADDED THE FOURTH ONE AS REQUIRED RATHER THAN AS "
|
||||
"`int(0,50)?`, and the difference is the whole of that card: an optional "
|
||||
"per-pack cut-out is a DEFAULTED one, and a default is one number for "
|
||||
"two batteries - the shallower pack protected by the deeper pack's BMS",
|
||||
set(_decl9) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
||||
"soc_entity", "batt_entity", "batt_invert"}
|
||||
and not any(_decl9[k].endswith("?")
|
||||
for k in ("name", "setpoint_entity", "max_w"))
|
||||
for k in ("name", "setpoint_entity", "max_w",
|
||||
"bms_soc_floor"))
|
||||
and all(_decl9[k].endswith("?")
|
||||
for k in ("soc_entity", "batt_entity", "batt_invert")))
|
||||
|
||||
@@ -1530,9 +1607,11 @@ check("...and two inverters on two DIFFERENT entities are accepted, because "
|
||||
"that is the configuration this card exists for and a validator that "
|
||||
"fires on the legitimate case gets switched off",
|
||||
validate_startup_config(_opts(setpoint_entity="", inverters=[
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING),
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a", max_w=A_RATING,
|
||||
bms_soc_floor=A_BMS_FLOOR),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
max_w=B_RATING_HYPOTHESIS)])) == [])
|
||||
max_w=B_RATING_HYPOTHESIS,
|
||||
bms_soc_floor=B_BMS_FLOOR_SIM)])) == [])
|
||||
|
||||
# --- the rest of rule 9 -------------------------------------------------------
|
||||
_p_flat = validate_startup_config(_opts(inverters=[
|
||||
@@ -1629,11 +1708,28 @@ check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
|
||||
"is inverter-B. The refusal says inverter-c",
|
||||
_why(_p_skew, "max_w (inverter-c) is 1000 W")
|
||||
and not _why(_p_skew, "max_w (inverter-b) is 1000 W"))
|
||||
check("⚠️ RULE 6 TOO. `caps[0]` is the first entry WITH a usable cap - "
|
||||
"inverter-b - while `_unit_label(opts, 0)` was hard-wired to inverter-a, "
|
||||
"so the maintenance refusal quoted B's rating under A's name",
|
||||
_why(_p_skew, "max_w (inverter-b) is 2000 W")
|
||||
and not _why(_p_skew, "max_w (inverter-a) is 2000 W"))
|
||||
# ⚠️ MAINT-05 MOVED RULE 6's SUBJECT, AND THIS CHECK MOVED WITH IT. It used to
|
||||
# assert the refusal named inverter-b, because rule 6 graded the FIRST entry
|
||||
# with a usable cap - `Controller.maint_unit` was `inverters[0]` and the shaping
|
||||
# claim could be filed nowhere else. There is now one `Maintenance` per roster
|
||||
# entry, so the shaping claim can be filed on ANY pack and the cap that
|
||||
# contradicts it first is the SMALLEST - inverter-c's 1000 W. The property this
|
||||
# check exists for is unchanged and is the reason it is still here: the index
|
||||
# has to travel with the cap, or the message quotes one unit's rating under
|
||||
# another unit's name.
|
||||
check("⚠️ RULE 6 TOO, AND SINCE MAINT-05 IT IS THE WEAKEST PACK IT NAMES. "
|
||||
"inverter-c's 1000 W sits at caps index 1, and `_unit_label(opts, 1)` is "
|
||||
"inverter-B - so a caps index leaking into a roster index would say "
|
||||
"inverter-b here. The refusal says inverter-c, with inverter-c's number",
|
||||
_why(_p_skew, "maintenance_charge_w is 3000 W but max_w (inverter-c) is "
|
||||
"1000 W")
|
||||
and not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
|
||||
"(inverter-b)"))
|
||||
check("...and it is no longer graded against the FIRST usable cap at all, which "
|
||||
"is the behaviour change MAINT-05 makes here: inverter-b's 2000 W was the "
|
||||
"pre-card answer and appears nowhere in the maintenance refusal now",
|
||||
not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
|
||||
"(inverter-b) is 2000 W"))
|
||||
check("...and the counterfactual, so this is not a check that would pass on a "
|
||||
"validator that named nothing: the OLD expressions still evaluate, on "
|
||||
"this same config, to the wrong names",
|
||||
@@ -2132,6 +2228,16 @@ MUTATIONS = [
|
||||
("validate.py allowing two roster entries with the SAME name",
|
||||
"part 10: 'two entries with the SAME name are refused' - one Store "
|
||||
"namespace for two units is DEV-02's defect rebuilt in YAML"),
|
||||
("`migrate_unit_keys()`'s marker gate `if rep.ok:` -> `if rep.ok or "
|
||||
"rep.kept:` - the schema marker stamped on a migration that DROPPED THE "
|
||||
"LATCH, because one key happened to be namespaced already",
|
||||
"part 10: 'AND THE STORE IS NOT MARKED MIGRATED'. ⚠️ THIS ONE SURVIVED "
|
||||
"ALL 19 FILES and was found by MAINT-05's round-3 battery, not by this "
|
||||
"card: every migration check here reached the gate on a store where "
|
||||
"NOTHING was namespaced yet, so `rep.kept` was empty in all of them and "
|
||||
"the `or` never fired. Stamping there disarms the latch AND tells the "
|
||||
"next start the migration finished, so it never re-arms - DEV-02's own "
|
||||
"worst case filed under 'done'"),
|
||||
]
|
||||
# ⚠️ FOUR OF THESE SURVIVED THE FIRST TIME AND THE CHECKS WERE CHANGED, NOT THE
|
||||
# TABLE - two in DEV-02, two more in CFG-04, and the CFG-04 pair are recorded in
|
||||
@@ -2150,7 +2256,7 @@ for what, caught in MUTATIONS:
|
||||
check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check "
|
||||
f"above. A guard whose checks survive its own defeat is not a guard, and "
|
||||
f"this repository has three separate records of exactly that",
|
||||
len(MUTATIONS) == 34)
|
||||
len(MUTATIONS) == 35)
|
||||
|
||||
|
||||
print()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -204,12 +204,17 @@ check("...and the refusal names the actuator and every writer aimed at it, so "
|
||||
_PER_INVERTER_OK = {k: v for k, v in base().items() if k != "setpoint_entity"}
|
||||
_PER_INVERTER_OK["inverters"] = [
|
||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_setpoint",
|
||||
"max_w": 2000},
|
||||
"max_w": 2000, "bms_soc_floor": 10},
|
||||
# ⚠️ INVERTER B DOES NOT EXIST. 2000 W here is the SHIPPED default repeated,
|
||||
# not a rating measured off a second device - SPIKE-04 measures that when
|
||||
# there is one to measure. Nothing in this file depends on the number.
|
||||
# ⚠️ SAFETY-32 made `bms_soc_floor` a REQUIRED per-entry key, and the two
|
||||
# values here DIFFER (10 / 15) rather than being copied. 15 % is SIMULATED -
|
||||
# there is no second pack to read a cut-out off - and the reason it is not
|
||||
# simply 10 is MUTATION.md habit 1: a fixture whose two per-unit numbers are
|
||||
# equal cannot show a check reading the wrong unit's.
|
||||
{"name": "inverter-b", "setpoint_entity": "number.goodwe_setpoint_b",
|
||||
"max_w": 2000}]
|
||||
"max_w": 2000, "bms_soc_floor": 15}]
|
||||
check("two inverters with their own entities are ACCEPTED - one writer each, "
|
||||
"no shared actuator, and that is CFG-04's whole purpose",
|
||||
validate_startup_config(_PER_INVERTER_OK, -5000, 5000) == [])
|
||||
@@ -353,6 +358,64 @@ check("...and it is refused with maintenance_enabled off, as shipped",
|
||||
base(max_w=2000, maintenance_charge_w=2500,
|
||||
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
|
||||
|
||||
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
|
||||
# The comment this replaces in validate.py said, in as many words, that it took
|
||||
# the FIRST unit's cap because there was exactly one `Maintenance` and it could
|
||||
# only ever recalibrate `inverters[0]` - and that "when `maint_unit` stops being
|
||||
# `inverters[0]`, this line moves with it". It has. Every pack is now
|
||||
# recalibrated on its own schedule, so the shaping claim can be filed on any of
|
||||
# them and the cap that contradicts it first is the smallest.
|
||||
#
|
||||
# ⚠️ THE FIXTURE'S TWO CAPS ARE DELIBERATELY DIFFERENT AND THE RATE SITS
|
||||
# BETWEEN THEM. Two equal caps could not tell "grades the first" from "grades
|
||||
# the smallest", and a rate above both would be refused by either rule - so
|
||||
# 3000 W against caps of 5000 W and 2000 W is the one arrangement where the old
|
||||
# answer and the new one disagree. ⚠️ 2000 W for the second pack is a
|
||||
# HYPOTHESIS: inverter B does not physically exist and nothing here is measured
|
||||
# from one.
|
||||
def _roster(a_max, b_max, **over):
|
||||
o = base(**over)
|
||||
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
|
||||
o.pop(k, None)
|
||||
o["inverters"] = [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
max_w=a_max),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=b_max)]
|
||||
return o
|
||||
|
||||
|
||||
_two = _roster(5000, 2000, maintenance_charge_w=3000, maintenance_discharge_w=1000,
|
||||
sign_disagree_w=300, unit_sign_disagree_w=300)
|
||||
_p_two = [p for p in validate_startup_config(_two, -5000, 5000)
|
||||
if "maintenance_charge_w" in p]
|
||||
print(f" 5000 W and 2000 W packs, charge 3000 W -> {_p_two}")
|
||||
check("⚠️ A CHARGE RATE THE SMALLEST PACK CANNOT TAKE IS REFUSED, even though "
|
||||
"the FIRST pack could. Before MAINT-05 the second pack was never "
|
||||
"recalibrated, so its cap could not contradict anything; it is now, and "
|
||||
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
|
||||
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
|
||||
len(_p_two) == 1 and why(_p_two, "max_w (inverter-b) is 2000 W"))
|
||||
check("...and the message names the pack the number came off, not the first "
|
||||
"entry - an operator told to lower a number on the wrong device is being "
|
||||
"sent to the wrong device by the message that exists to stop that",
|
||||
not why(_p_two, "max_w (inverter-a)"))
|
||||
check("...while the same rate against two packs that can both take it starts, "
|
||||
"so this is a comparison and not a blanket refusal of rosters",
|
||||
[p for p in validate_startup_config(
|
||||
_roster(5000, 4000, maintenance_charge_w=3000,
|
||||
maintenance_discharge_w=1000, sign_disagree_w=300,
|
||||
unit_sign_disagree_w=300), -5000, 5000)
|
||||
if "maintenance_charge_w" in p] == [])
|
||||
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
|
||||
"message, the same boundary, the same refusal the flat config always got",
|
||||
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
||||
-5000, 5000), "THE CEILING WINS")
|
||||
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
||||
-5000, 5000) == [])
|
||||
|
||||
# ⚠️ THE SIBLING, CHECKED RATHER THAN ASSUMED. The shaping is NOT symmetric:
|
||||
# drain files Claim.set at P_MAINTENANCE and owns the actuator, so the ceiling
|
||||
# CLAMPS it (+2000 W) instead of contradicting it, and the arbiter's explain()
|
||||
@@ -770,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
|
||||
|
||||
|
||||
@@ -1809,6 +1818,181 @@ check("...and no `sign_site` claim can be filed at a roster of one, so the "
|
||||
"arbitration is byte-identical to what shipped",
|
||||
"sign_site" not in _c1s.reason)
|
||||
|
||||
|
||||
print()
|
||||
print("8. SAFETY-32 ROUND 4's ENUMERATION: the two `any(...)` reductions over")
|
||||
print(" the roster whose only evidence is a latched NON-FIRST unit")
|
||||
# ⚠️ THIS SECTION IS NOT SAFETY-32's SUBJECT, AND IT IS HERE BECAUSE OF IT.
|
||||
# SAFETY-32 was rejected three times for the same shape one step over: a
|
||||
# reduction over roster length whose fixture could not separate it from a slice
|
||||
# of the roster. Round 4 therefore ENUMERATED every such reduction in
|
||||
# `Controller` - min, max, sum, any, all, sorted, [0], [-1] over
|
||||
# `self.inverters` / `self.packs` - and mutated each to `[:1]` to find out which
|
||||
# were GRADED. Two survived a green 19-file suite, both `any(i.sign_wd.tripped
|
||||
# for i in self.inverters)`, both SAFETY-30's, and this block is what closes
|
||||
# them. No shipped behaviour changes; the reductions were already right.
|
||||
#
|
||||
# main.py `cycle()`, the stale-input hold - +4 bytes, 19/19 GREEN
|
||||
# main.py `publish()`, the status vocabulary - +4 bytes, 19/19 GREEN
|
||||
#
|
||||
# ⚠️ WHY THE SUITE COULD NOT SEE THEM, and it is the same sentence every time:
|
||||
# the only fixture that reached either was a roster of ONE, where
|
||||
# `self.inverters[:1]` IS `self.inverters`. Part 3 above builds a two-inverter
|
||||
# roster in which ONLY inverter B is latched - the exact data both reductions
|
||||
# needed - and neither of them was read on it. A fixture of N units cannot
|
||||
# separate reductions that agree on N units, one card over.
|
||||
#
|
||||
# ⚠️ AND BOTH ORDERS ARE RUN, for 12g's reason: a single "only B is latched"
|
||||
# fixture kills `[:1]` and leaves `[-1:]` - grade the roster by its LAST unit -
|
||||
# answering identically. The pair is what leaves no single-position slice.
|
||||
|
||||
|
||||
async def _latched_then_input_gap(reversed_idx):
|
||||
"""Latch ONE unit's sign watchdog, then take a SIBLING's reading away.
|
||||
|
||||
⚠️ A REAL LATCH FROM A REAL EPISODE, not `sign_wd.tripped = True`: the same
|
||||
`_SignHass` plant and the same `_drive()` part 3 uses, so the detector
|
||||
reaches its verdict through the shipped path. Then one pack's SoC goes
|
||||
missing for LESS than `stale_input_s`, which is the state the hold exists
|
||||
for - a single missed poll must not become a fault.
|
||||
|
||||
THE FAULT THIS GRADES: a LATCHED unit must take the whole roster past that
|
||||
hold, because the unit's `failsafe` SET is the thing parking it. Return
|
||||
early and the unit's last share stays standing for the heartbeat to
|
||||
re-emit - main.py's own comment: "the trip would stop acting the moment a
|
||||
poll was missed". A reduction that looked at one roster POSITION would do
|
||||
exactly that whenever the latched unit was not the position it looked at.
|
||||
"""
|
||||
hass = _SignHass(load_w=2000.0, reversed_units={reversed_idx})
|
||||
c = _sign_site(hass)
|
||||
await _drive(c)
|
||||
sibling = c.inverters[1 - reversed_idx]
|
||||
sibling.soc = None
|
||||
c.inputs_bad_since = datetime.now(timezone.utc)
|
||||
await c.cycle()
|
||||
return c
|
||||
|
||||
|
||||
_gap_b = asyncio.run(_latched_then_input_gap(1))
|
||||
_gap_a = asyncio.run(_latched_then_input_gap(0))
|
||||
for _tag, _c, _i in (("B latched", _gap_b, 1), ("A latched", _gap_a, 0)):
|
||||
_pl = [p for p in (_c.allocation.plans if _c.allocation else ())
|
||||
if p.unit == _c.inverters[_i].name]
|
||||
print(f" {_tag}, sibling's SoC missing 0 s: inputs_ok="
|
||||
f"{_c.inputs_ok}, reason={_c.reason!r}, latched unit target="
|
||||
f"{_c.inverters[_i].target}, why={_pl[0].why if _pl else '(no plan)'}")
|
||||
check("PRECONDITION (habit 1): on BOTH runs the site really is in the state "
|
||||
"the hold is for - one pack's reading is missing, `inputs_ok` is False, "
|
||||
"the gap is younger than `stale_input_s`, and the SITE latch is NOT "
|
||||
"tripped, so the only thing that can carry the roster past the hold is "
|
||||
"the per-unit `any()`. And exactly ONE unit is latched on each run, at "
|
||||
"the OTHER position",
|
||||
_gap_b.inputs_ok is False and _gap_a.inputs_ok is False
|
||||
and _gap_b.site_sign_wd.tripped is False
|
||||
and _gap_a.site_sign_wd.tripped is False
|
||||
and [i.sign_wd.tripped for i in _gap_b.inverters] == [False, True]
|
||||
and [i.sign_wd.tripped for i in _gap_a.inverters] == [True, False])
|
||||
check("⚠️ A LATCHED UNIT TAKES THE ROSTER PAST THE STALE-INPUT HOLD FROM EITHER "
|
||||
"POSITION. Neither cycle parks on `inputs missing`; both go on to file "
|
||||
"claims, and the latched unit is commanded 0 W by its OWN `failsafe` "
|
||||
"claim, named in the allocation record. Grade this with "
|
||||
"`self.inverters[:1]` and the B-latched run returns early - the +4-byte "
|
||||
"mutation that was GREEN across all 19 files - and with "
|
||||
"`self.inverters[-1:]` the A-latched run does. The remedy stops acting "
|
||||
"the moment a poll is missed, which is main.py's own sentence for it",
|
||||
not (_gap_b.reason or "").startswith("inputs missing")
|
||||
and not (_gap_a.reason or "").startswith("inputs missing")
|
||||
and _gap_b.inverters[1].target == 0.0
|
||||
and _gap_a.inverters[0].target == 0.0
|
||||
and all(any(p.unit == c.inverters[i].name and "failsafe" in p.why
|
||||
for p in (c.allocation.plans if c.allocation else ()))
|
||||
for c, i in ((_gap_b, 1), (_gap_a, 0))))
|
||||
# ...and the counterfactual, which is what makes the two lines above a
|
||||
# DIFFERENCE rather than a description: with NOTHING latched, the very same
|
||||
# input gap DOES park the site on the hold. Without this the checks above would
|
||||
# be satisfied by a `cycle()` that never had a hold at all.
|
||||
async def _gap_no_latch():
|
||||
c = _sign_site(_SignHass(load_w=2000.0))
|
||||
await _drive(c, n=2)
|
||||
c.inverters[0].soc = None
|
||||
c.inputs_bad_since = datetime.now(timezone.utc)
|
||||
await c.cycle()
|
||||
return c
|
||||
|
||||
|
||||
_gap_none = asyncio.run(_gap_no_latch())
|
||||
print(f" nothing latched, same gap: reason={_gap_none.reason!r}")
|
||||
check("...and the counterfactual (habit 7): with NO unit latched the SAME gap "
|
||||
"DOES hold the site - `inputs missing 0s` - so the two runs above show "
|
||||
"the latch carrying the roster past a hold that is really there, rather "
|
||||
"than a hold that never fires",
|
||||
(_gap_none.reason or "").startswith("inputs missing")
|
||||
and not any(i.sign_wd.tripped for i in _gap_none.inverters))
|
||||
|
||||
# --- and the SECOND survivor: the status vocabulary publish() hands the broker
|
||||
# ⚠️ THE EXISTING CHECK IS AT A ROSTER OF ONE, which is why this one exists.
|
||||
# "MQTT's controller-status sensor reports 'failsafe'" above drives a
|
||||
# single-inverter Controller, where `self.inverters[:1]` is the whole roster.
|
||||
# A site at 0 W reporting `running` is the failure main.py's comment names, and
|
||||
# at two inverters it is reachable whenever the latched unit is not the one a
|
||||
# narrowed reduction happens to read.
|
||||
_pub = {}
|
||||
|
||||
|
||||
def _published(ctl):
|
||||
_pub.clear()
|
||||
ctl.mqtt = type("P", (), {"enabled": True,
|
||||
"publish": lambda _s, v: _pub.update(v),
|
||||
"close": lambda _s: None})()
|
||||
ctl.publish()
|
||||
return dict(_pub)
|
||||
|
||||
|
||||
_pb, _pa = _published(_gap_b), _published(_gap_a)
|
||||
_pn = _published(_gap_none)
|
||||
print(f" published status: B latched -> {_pb.get('status')!r}, "
|
||||
f"A latched -> {_pa.get('status')!r}, none latched -> "
|
||||
f"{_pn.get('status')!r}")
|
||||
check("⚠️ AND `publish()` SAYS `failsafe` WHICHEVER UNIT IS LATCHED. The status "
|
||||
"sensor is the vocabulary every dashboard already reads, and a site with "
|
||||
"one inverter parked by its own failsafe claim reporting `running` is "
|
||||
"the exact failure main.py's comment names. `self.inverters[:1]` "
|
||||
"publishes `running` on the B-latched site - +4 bytes, GREEN across all "
|
||||
"19 files - and `self.inverters[-1:]` does it on the A-latched one",
|
||||
_pb.get("status") == "failsafe" and _pa.get("status") == "failsafe")
|
||||
check("...and the counterfactual: the same two-inverter site with NOTHING "
|
||||
"latched publishes `running`, so the two above are a difference the "
|
||||
"reduction produced and not a constant",
|
||||
_pn.get("status") == "running" and _gap_none.auto is True)
|
||||
|
||||
# --- and the THIRD one the same enumeration found: the OPERATOR'S BANNER -----
|
||||
# ⚠️ `status_payload()`'s failsafe branch is a fourth `any(... for i in
|
||||
# self.inverters)`, and `self.inverters[:1]` there was +4 bytes and GREEN across
|
||||
# all 19 files too - for the third time, because the only fixture that reached
|
||||
# it was a roster of ONE. The banner is the ONE sentence at the top of the page;
|
||||
# a site holding an inverter at 0 W under a "Stopped" or "NOT READY" headline
|
||||
# sends the operator to the wrong question.
|
||||
_bn_b = _gap_b.status_payload()
|
||||
_bn_a = _gap_a.status_payload()
|
||||
_bn_n = _gap_none.status_payload()
|
||||
print(f" banner: B latched -> {_bn_b['banner']!r}")
|
||||
print(f" A latched -> {_bn_a['banner']!r}")
|
||||
print(f" none -> {_bn_n['banner']!r}")
|
||||
check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED, at "
|
||||
"level `bad`, with the N>=2 sentence that names the unit's OWN measured "
|
||||
"power rather than the meter. `self.inverters[:1]` drops the headline on "
|
||||
"the B-latched site and `self.inverters[-1:]` on the A-latched one, and "
|
||||
"the page then leads with whatever the next branch says",
|
||||
_bn_b["level"] == _bn_a["level"] == "bad"
|
||||
and _bn_b["banner"] == _bn_a["banner"]
|
||||
and _bn_b["banner"].startswith("FAILSAFE")
|
||||
and "its own measured power" in _bn_b["banner"])
|
||||
check("...and the counterfactual again: the same site with nothing latched "
|
||||
"carries neither that level nor that sentence, so the banner really is "
|
||||
"being produced by the reduction under test",
|
||||
not _bn_n["banner"].startswith("FAILSAFE")
|
||||
and _bn_n["banner"] != _bn_b["banner"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -40,8 +40,24 @@ DECISIONS/CFG-04-inverter-roster.md:
|
||||
reconstruction happens to lack FIRST, which is an artefact of the
|
||||
reconstruction and NOT evidence about `inverters`. B is that evidence.
|
||||
C. A two-inverter blob validates, per-entry types enforced.
|
||||
D. An entry missing `name`, `setpoint_entity` or `max_w` is refused BY NAME,
|
||||
and a JSON string where the list belongs is refused outright.
|
||||
D. An entry missing `name`, `setpoint_entity`, `max_w` or `bms_soc_floor` is
|
||||
refused BY NAME - one key varied from an ACCEPTED baseline, and the key's
|
||||
own name asserted in the refusal - and a JSON string where the list belongs
|
||||
is refused outright.
|
||||
|
||||
⚠️ RE-RUN 2026-09-04 FOR SAFETY-32, which made `bms_soc_floor` a REQUIRED
|
||||
per-entry key. That change broke §C and, worse, left §D printing PASS on a
|
||||
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
|
||||
now carries the key and §D is an isolated counterfactual asserting the key name.
|
||||
|
||||
⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's
|
||||
rebase onto SPIKE-04a merged two independent edits into §C and §D - the required
|
||||
`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched
|
||||
together by hand from two runs is not a measurement, so this one was measured:
|
||||
`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a
|
||||
reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it
|
||||
differs from A's 10 so that the per-entry number is exercised at all, while
|
||||
production is expected to have BOTH packs at 10 %.
|
||||
"""
|
||||
import sys, json, copy
|
||||
sys.path.insert(0, "/usr/src/supervisor")
|
||||
@@ -145,13 +161,47 @@ show("the same blob MERGED with these defaults - what App.options actually is",
|
||||
f"max_w={out.get('max_w')!r}" if ok else out)
|
||||
|
||||
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
|
||||
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
|
||||
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
|
||||
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
|
||||
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
|
||||
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
|
||||
# nameplate is wrong for this purpose too, because nothing will be connected to
|
||||
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
|
||||
#
|
||||
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
|
||||
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
|
||||
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
|
||||
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
|
||||
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
|
||||
# read off the unit. So this blob is what an operator would POST TODAY, not a
|
||||
# claim about what inverter B will turn out to do.
|
||||
#
|
||||
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
|
||||
# This file's own §B is the record of what an instrument that cannot distinguish
|
||||
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
|
||||
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
|
||||
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
|
||||
# pair instead of the real one.
|
||||
#
|
||||
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
|
||||
# blob is also what proves an operator can still POST a two-inverter config
|
||||
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
|
||||
# not exercise a per-entry number at all, for the same reason the two ratings
|
||||
# must stay different.
|
||||
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
|
||||
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
|
||||
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
|
||||
# - there is no second pack on this site, and in production BOTH packs are
|
||||
# expected to floor at 10 %, which is why the differing pair lives here and in
|
||||
# the fixtures rather than in config.yaml's example.
|
||||
TWO = dict(LIVE, setpoint_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
|
||||
"max_w": 5000},
|
||||
"max_w": 5000, "bms_soc_floor": 10},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
|
||||
"max_w": 3000}])
|
||||
"max_w": 4600, "bms_soc_floor": 15}])
|
||||
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
|
||||
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
|
||||
# The validated blob, for anyone who wants to POST it. Optional: the probe's
|
||||
@@ -162,14 +212,38 @@ except OSError:
|
||||
pass
|
||||
|
||||
print("\n== D. what the schema itself refuses, per entry ==")
|
||||
for title, entry in (
|
||||
("no name", {"setpoint_entity": "number.b", "max_w": 3000}),
|
||||
("no setpoint_entity", {"name": "inverter-b", "max_w": 3000}),
|
||||
("no max_w", {"name": "inverter-b", "setpoint_entity": "number.b"}),
|
||||
("max_w over the range", {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 9999})):
|
||||
ok, out = validate(dict(TWO, inverters=[entry]))
|
||||
show(f"an entry with {title} is refused", not ok, out)
|
||||
ok, out = validate(dict(TWO, inverters="[]"))
|
||||
# ⚠️ ONE KEY VARIED FROM AN ACCEPTED BASELINE, AND THE REFUSAL IS ASSERTED BY
|
||||
# NAME - §B's two lessons, applied here because SAFETY-32 is what broke this
|
||||
# section. These entries used to omit several keys at once and assert a bare
|
||||
# `not ok`; the moment `bms_soc_floor` became required, the "no max_w" case went
|
||||
# on printing PASS while the refusal it printed said `Missing option
|
||||
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
|
||||
# anything, so the baseline below is COMPLETE and each case deletes exactly one
|
||||
# key - or, for the range case, moves exactly one value out of range.
|
||||
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600,
|
||||
"bms_soc_floor": 10}
|
||||
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
|
||||
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_
|
||||
# start_hour` on the run that caught it), and a baseline refused for one of THOSE
|
||||
# is the provenance problem §B2 is kept as a warning about.
|
||||
D_BASE = MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO))
|
||||
ok, out = validate(dict(D_BASE, inverters=[D_FULL]))
|
||||
show("the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere "
|
||||
"else to come from", ok, json.dumps(out.get("inverters")) if ok else out)
|
||||
for title, entry, expect in (
|
||||
("no name", {k: v for k, v in D_FULL.items() if k != "name"}, "name"),
|
||||
("no setpoint_entity",
|
||||
{k: v for k, v in D_FULL.items() if k != "setpoint_entity"}, "setpoint_entity"),
|
||||
("no max_w", {k: v for k, v in D_FULL.items() if k != "max_w"}, "max_w"),
|
||||
("no bms_soc_floor (SAFETY-32)",
|
||||
{k: v for k, v in D_FULL.items() if k != "bms_soc_floor"}, "bms_soc_floor"),
|
||||
("max_w over the range", dict(D_FULL, max_w=9999), "at most 5000"),
|
||||
("bms_soc_floor over the range (SAFETY-32)",
|
||||
dict(D_FULL, bms_soc_floor=99), "at most 50")):
|
||||
ok, out = validate(dict(D_BASE, inverters=[entry]))
|
||||
show(f"an entry with {title} is refused, and the refusal NAMES it",
|
||||
(not ok) and expect in str(out), out)
|
||||
ok, out = validate(dict(D_BASE, inverters="[]"))
|
||||
show("a JSON STRING where the list belongs is refused", not ok, out)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user