checks / unit (pull_request) Successful in 5m30s
While the loop is frozen the command's magnitude may fall but never rise. At prev_w == 0 there is no magnitude left to fall, so a charge cannot START. That refusal is deliberate and it is kept: the inverter reporting power we did not command means another writer is on the actuator, which is the §1.5 non-goal. What it did not have was an END - nothing cleared it but the other writer leaving. Reproduced on release/1.0 @c8dcb38, at the live install's shape (max_w 5000, slew_w 400, target_grid_w -10), with another writer holding the pack at +700 W. The route is not the obvious one: entering from an export, the slew limit moves the command off zero before the counter reaches 3. It is entered from the DEADBAND - a site resting at its target counts saturated cycles while nothing is wrong - and 0 W is then a fixed point: a 2 kW export arriving afterwards leaves the command at exactly 0.0 W on every subsequent cycle. And prev_w == 0 is not where it stops. The same tuning pins at -800 W against its own integrator standing at -2388 W, with the same absence of an exit. So the change is written against the output freeze, not against a special case of it. THE DECISION IS WHICH FREEZES GET AN EXIT, NOT HOW LONG THE BLOCK LASTS. - A divergence a LIMIT could have produced - the inverter doing less than the command, in the command's own direction - clears itself, because the command is pinned at something the inverter can reach. No exit. release/1.0 unchanged, and asserted so: a curtailed inverter is never released over 30 cycles. - A divergence a limit could NOT have produced - overshooting the command, or the opposite sign - cannot clear itself, because clearing it needs the inverter to track OUR command. That one releases. unasked_power() is that predicate and it is physical, not statistical: a limit can only make the inverter do less, in the command's own direction, so everything a limit can produce lies in the interval spanned by 0 and prev_w widened by saturation_w. At prev_w == 0 it IS the saturation test, which is exactly why the zero case is the one that strands. The bound is not a new number. SETTLE_CYCLES = ceil(PLANT_SETTLE_S / LOOP_TICK_S) = 9, SAFETY-39's two measured constants and the reciprocal of SLEW_RAIL_FRAC: a divergence still standing after the plant's own settling time is not this plant catching up. _SAT_COUNT_CAP is written as SETTLE_CYCLES + 1; its value is unchanged at 10. RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP, which is what makes this bounded. The integrator freeze is untouched, so a released cycle can only move the command toward an accumulator that stopped accumulating, by at most one slew step, and never past it - swept over 107 625 released states. On the run above the command starts on the ninth saturated cycle and settles at -1190 W against a frozen integrator of -1194 W, not at the 5000 W rail. Graded against a transcription of release/1.0 over the 3024-case sweep, which was NOT taught the new rule: 2592 cases bit-identical, 432 releases, and 27 of those change the command - every one a charge from prev_w == 0 that release/1.0 refused to start. SAFETY-04's deliberate assertion is kept rather than inverted: its fixture sits below the bound, so it still asserts the block, and the release is asserted beside it on the other side of the bound. Coverage audit re-measured, same mutation form, __pycache__ purged: output freeze 2 -> 11 (2 reproduced on a pristinec8dcb38first), the exit 4, unasked_power 6. saturation_cycles at 9 or 10 removes the block entirely, because the freeze cannot engage before its own exit is due. Not refused at startup; asserted in the tests and written in DOCS.md. Nothing was measured on two inverters: inverter B does not physically exist (NOTES.md §1.1b) and sim/plantsim.py:216 INVERTER_B is INVERTER_A copied with measured=False. The N=2 escalation is stated from the code (actual_w is site_batt_w, the SUM) and is labelled as such. 22/22 test files pass in goodwe-addon-ci, mounted :ro, bytecode purged first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
3506 lines
231 KiB
Markdown
3506 lines
231 KiB
Markdown
# Changelog
|
||
|
||
## 0.7.0
|
||
|
||
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
|
||
[[SAFETY-32]] adds `bms_soc_floor: int(0,50)` as a **required** per-entry key on
|
||
the `inverters` roster - no trailing `?`. A config that declares a roster and
|
||
omits it on any entry is REFUSED at startup. An install running `inverters: []`,
|
||
which is every single-inverter install including the reference one, is
|
||
unaffected: the flat `bms_soc_floor` still describes the one pack.
|
||
|
||
CFG-04 recorded the rule this follows - the Supervisor keys the built image by
|
||
`version:`, so a schema change without a bump is a schema nobody reads.
|
||
|
||
Contents, in merge order since 0.6.0 (`2d025ee`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `3934ad7` | MAINT-05 - one recalibration per pack, migration graded against the roster |
|
||
| `67e649a` | SPIKE-04a - inverter B and pack B recorded, from datasheet and serial |
|
||
| `3993f9f` | SAFETY-32 - per-pack `bms_soc_floor`, rule 8 per unit, package-wide roster pin |
|
||
| `f72c48d` | UX-01 - add-on configuration in English and Dutch |
|
||
| `86eaef2` | TEL-07 - a second MQTT device for inverter B; A's identity byte-identical |
|
||
| `4482cd8` | SAFETY-41 - the clamp alarm stops narrating normal anti-windup |
|
||
|
||
⚠️ **0.6.0 WAS NEVER SECTIONED.** Its content sits below this heading rather than
|
||
under one of its own, because the bump shipped without cutting the section. That
|
||
is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` and
|
||
the table above is what is new since it. Splitting it retroactively would mean
|
||
guessing a boundary; saying so is more useful than a guess.
|
||
|
||
## Unreleased
|
||
|
||
⚠️ **THE OUTPUT FREEZE NOW HAS AN EXIT, AND ONLY WHERE IT CANNOT END ON ITS OWN
|
||
([[SAFETY-13]]).** While the loop is frozen the command's magnitude may fall but
|
||
never rise; at `prev_w == 0` there is no magnitude left to fall, so a charge
|
||
cannot start. That refusal is deliberate - the inverter reporting power we did
|
||
not command means another writer is on the actuator, the §1.5 non-goal - and it
|
||
is kept. What it did not have was an END: nothing cleared it but the other
|
||
writer leaving.
|
||
|
||
The exit is now explicit, and *which* freezes get one is the decision rather
|
||
than *how long*:
|
||
|
||
- A divergence a **limit** could have produced - the inverter doing less than
|
||
the command, in the command's own direction - **clears itself**, because the
|
||
command is pinned at something the inverter can reach. It gets no exit, and
|
||
that is release/1.0's behaviour kept unchanged.
|
||
- A divergence a limit could **not** have produced - the inverter overshooting
|
||
the command, or producing the opposite sign - cannot clear itself, because
|
||
clearing it needs the inverter to track *our* command. That one releases once
|
||
the divergence has outlasted `PLANT_SETTLE_S`, i.e. `SETTLE_CYCLES` = 9 cycles
|
||
at the measured tick. No new constant: it is the reciprocal of SAFETY-39's
|
||
`SLEW_RAIL_FRAC`.
|
||
|
||
⚠️ **RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP.** The integrator
|
||
freeze is untouched, so the command can only walk, one slew step per cycle,
|
||
toward an accumulator that stopped accumulating when the freeze engaged.
|
||
Measured at the live install's shape (`max_w 5000`, `slew_w 400`,
|
||
`target_grid_w -10`) with another writer holding the pack at +700 W and 2 kW of
|
||
export standing: the command holds at 0 W, starts on the ninth saturated cycle
|
||
and settles at **-1190 W** against a frozen integrator of -1194 W, not at the
|
||
5000 W rail. On `release/1.0` @ `c8dcb38` the same run held **0.0 W on every
|
||
cycle**, for as long as the other writer stayed.
|
||
|
||
⚠️ **THIS IS NOT A REGRESSION FIX AND IT ESCALATES AT TWO INVERTERS.** The
|
||
unbounded block is `release/1.0` behaviour, confirmed cycle for cycle against
|
||
SAFETY-04's branch. At a roster of two, `actual_w` is the SITE SUM, so the
|
||
comparison is a site command against two units' power and one unit still moving
|
||
after the other has stopped can put the site into the block with no external
|
||
actor at all.
|
||
|
||
Graded against `release/1.0` over the 3024-case sweep in `test_control.py`:
|
||
**2592 cases bit-identical, 432 releases, and 27 of those change the command** -
|
||
every one of them a charge from `prev_w == 0` that `release/1.0` refused to
|
||
start. `saturation_cycles` at 9 or 10 removes the block entirely; DOCS.md says
|
||
so.
|
||
|
||
⚠️ **A PER-INVERTER POWER READING THAT HAS STOPPED FOLLOWING ITS OWN COMMAND IS
|
||
NOW `None`, NOT A HELD NUMBER ([[SAFETY-37]]).** `Inverter.batt` (register
|
||
`0x0518`) goes through a freshness gate: unchanged for >= 35 s *while* the
|
||
standing command is over 250 W *and* has travelled over 250 W since the reading
|
||
last moved. A stale reading is a **missing input**, which §11.1 rule 1 already
|
||
answers with 0 W and keeps commanding.
|
||
|
||
**This is a behaviour change at the shipped roster of one, and it is deliberate.**
|
||
Before it, a frozen power register read as a healthy measurement for ever - which
|
||
is [[HW-01]], where a value held for 6 h 25 m while the pack drained to its floor
|
||
and every detector read healthy. A healthy install is byte-identical: sixty reads
|
||
of a dithering register under a reversing command all reach the consumers
|
||
unchanged, asserted rather than assumed. A false verdict fails toward 0 W, behind
|
||
the existing `stale_input_s` hold, and clears itself the moment the register
|
||
reports a different value.
|
||
|
||
At two or more inverters it also removes two false alarms that had no gate at
|
||
all: [[SAFETY-30]]'s per-unit sign check no longer trips on a frozen reading
|
||
(measured: it trips without the gate and NEVER with it - the tick index is
|
||
deliberately not quoted, because it is counted on the wall clock inside
|
||
`cycle()` and is therefore load-dependent; the checkable statement is the
|
||
presence or absence of the trip, which part 3 grades), and
|
||
[[SAFETY-31]]'s circulating-power sensor reports `None` instead of a loop that is
|
||
not happening.
|
||
|
||
**No new configuration key, and no schema change.** The bound is derived from
|
||
`circulation_hold_s` and `unit_sign_disagree_*`, which the checks read out of
|
||
`config.yaml` at both ends - so moving either default takes the gate red rather
|
||
than leaving it silently too slow. `DECISIONS/SAFETY-37-freshness-policy.md`
|
||
carries the derivation, what [[TEL-06]] and [[TEL-08]] still owe, and three
|
||
named survivors - including the largest: **this detects a value that STOPPED, not
|
||
a value that is WRONG.**
|
||
|
||
⚠️ **`slew_w` IS A RATIO TO THE RAIL NOW, AND ITS DEFAULT CHANGED FROM 1000 TO 0
|
||
([[SAFETY-39]]).** `0` does not mean "off" and does not mean "unset" - it means
|
||
**follow `max_w`**, exactly as `integrator_max_w: 0` already does, and resolves
|
||
to `max_w / 9`: 222 W at the shipped 2000 W cap, 556 W at the live install's
|
||
5000, 1067 W at a ~9600 W two-inverter site. The 9 is
|
||
`PLANT_SETTLE_S / LOOP_TICK_S` - an 18 s measured step response against a 2 s
|
||
meter tick - so the loop takes the plant's own settling time to cross its whole
|
||
output range at any rail.
|
||
|
||
**An install that has never set `slew_w` will therefore command smaller steps
|
||
than it did on 0.7.0, and will converge more slowly after a large disturbance.**
|
||
That is the point rather than a side effect: `slew_w: 1000` was derived against
|
||
a **1500 W** clamp in 2026-08 and the live install has run it against 5000 since,
|
||
which crosses the whole range in 10 s against an 18 s plant. Measured on the
|
||
owner's system 2026-09-05: a 3.4 kW export drove the setpoint 380 -> 5000 W in
|
||
five ticks, the battery to -5154 W past a 5000 VA rating, and the grid from
|
||
3.4 kW of **export** to 2.8 kW of **import**.
|
||
|
||
An explicit `slew_w` is still accepted and is never silently reduced. What is
|
||
new is that a value **faster than the rail allows** is now **REFUSED AT STARTUP**
|
||
(`validate.py` rule 10), graded against the ROSTER SUM - so `slew_w: 1000` with
|
||
`max_w: 5000` will not start. Set it to `0`, or to any value at or below
|
||
`max_w / 9`. Slower is always accepted, including the interim 400 the reference
|
||
install has run since 2026-09-05.
|
||
|
||
⚠️ **This does NOT fix the over-absorption, and the record says so at length.**
|
||
Re-deriving the slew removes the >= 900 W setpoint step; the loop still absorbs
|
||
~1 kW more than the site needs on a large export step, because that is a `gain`
|
||
term and `gain: 0.6` was measured and deliberately left alone. See
|
||
`DECISIONS/SAFETY-39-slew-against-the-rail.md` §6, and do not read this entry as
|
||
more than it says.
|
||
|
||
|
||
**`test_peakguard.py` answered two of its own checks with the wall clock
|
||
(CI-06).** The file sits on the required `checks / unit (pull_request)` context
|
||
and had a measured ~20 % flake rate. It cost a wrong merge diagnosis (parents
|
||
green, merge red — read as a semantic merge conflict, it was the flake), a false
|
||
red on two reviewers' CONTROL runs, and a false "mutation caught" that reached a
|
||
merged card in four places. **A flaky check does not waste a re-run; it
|
||
manufactures evidence for whichever hypothesis the reader already holds.**
|
||
|
||
Both causes are the control law's own gain `900 / remaining` applied to a
|
||
wall-clock gap the file never intended to have. Neither fix widens anything.
|
||
|
||
* **The cross-time projection.** The "one cycle of the intent form is exactly
|
||
the delivered form plus the command standing on the wire" check decided the
|
||
intent form from a **second** `PeakTracker.project(..., datetime.now())` taken
|
||
after the real cycle had returned, and compared it against a floor decided
|
||
from the **first** projection — three lines under a comment saying both run
|
||
"against the same frozen projection". `excess = 900 × (projected − ceiling) /
|
||
remaining` moves at `excess / remaining` W/s, so at a 1500 W floor the 1.5 W
|
||
tolerance bought exactly **`remaining / 1000` seconds**: 333 ms with 880 s
|
||
left in the quarter, **6 ms with 10 s left**. The gap between the two
|
||
statements measured **12.7–39.5 ms** over 26 instrumented runs. The check was
|
||
decided by *where in the quarter it ran*. It now keeps the `Projection`
|
||
**object** the real cycle graded and decides both forms from it — the clock is
|
||
out of the comparison and the tolerance is **exact**, not widened. Measured on
|
||
a real cycle: re-deciding the delivered form from that object reproduces the
|
||
controller's own floor to **0.000000000000 W**.
|
||
* **The fixture's own room.** `wired_over_limit()` laid telegrams with
|
||
`range(0, span + 1, 5)`, leaving **0–5 s of the open block uncovered by
|
||
construction**, and `excess` multiplies an uncovered second back up by
|
||
`900 / remaining`. Measured on the shipped file: at **12 s of remaining a
|
||
fixture that asked for a 1500 W floor delivered 613 W (−59 %)**, and at 2 s
|
||
the guard **did not engage at all** — which fails "the real cycle files the
|
||
peak claim", "…and the number written to the inverter is the guard's floor"
|
||
and "the STATUS PAGE row for a floor-limited guard" **together**. The
|
||
four-attempt retry could not help: four attempts take ~40 ms and all land in
|
||
the same dying quarter. Fixed with one telegram stamped at `now` (what a real
|
||
meter does, and what the docstring already claimed) plus `FIXTURE_ROOM_S`,
|
||
**derived** from the 1 % accuracy the helper promises rather than picked:
|
||
`net_w × lag / (remaining × want) ≤ 0.01 − lag/900` → **23.6 s → 24 s**, at
|
||
the measured 0.1 s worst-case lag.
|
||
|
||
**Nothing was skipped, disabled or marked xfail** — the file still carries all
|
||
of SAFETY-07 and SAFETY-25's forced-discharge SoC floor, and it gains a check:
|
||
the delivered form re-decided from the cycle's own projection must reproduce the
|
||
real controller's number to the bit, which pins the test's `decide()` kwargs as
|
||
main.py's actual call rather than an approximation of it. **One tolerance got
|
||
stricter** (1.5 W → exact), because removing the clock made exactness possible.
|
||
|
||
**`site_standing` — the one quantity BOTH site guards are asked about — is now
|
||
pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).**
|
||
`app/main.py`'s `site_standing = float(sum(standing.values()))` is the first
|
||
argument of [[SAFETY-30]]'s **latching, site-stopping** sign detector *and* the
|
||
`commanded_w` handed to [[SAFETY-18]]'s `looks_frozen()`. It was pinned by
|
||
**nothing**, and three mutations of it disarmed both guards with the whole suite
|
||
green. **This card changes coverage only: not one byte of `app/` moves — the
|
||
shipped `sum` is correct and must not be "fixed".**
|
||
|
||
* **⭐ THE FIXTURE THE SUITE COULD NOT BUILD: per-unit shares EACH BELOW the
|
||
site gate whose SUM clears it.** Two simulated units rated 220 W and 280 W,
|
||
both obeying exactly, inverted gain, house importing 2000 W — the site
|
||
commands **−500 W against a +2500 W meter** and no single share clears the
|
||
300 W gate. That is the case the site detector exists for, and it is the only
|
||
arrangement that can separate `sum` from the mean and from the largest share.
|
||
`test_watchdog.py` part 9a.
|
||
* **WHY THE EXISTING FIXTURE COULD NOT.** `_sign_site`, the suite's only N≥2
|
||
site fixture, runs shares of about −2500 W **each** — every one individually
|
||
over the gate — so sum, mean and largest-share all agree on it. *Its values
|
||
happen to be equal, and a check comparing two things that happen to be equal
|
||
cannot see the difference it names.* `MUTATION.md` habit 4, ninth instance,
|
||
inside the site half of the very card that names it.
|
||
* **FOUR CANDIDATE REDUCTIONS, FOUR DISTINCT ANSWERS, ONE OVER THE GATE** —
|
||
`sum` −500, mean −250, `max(key=abs)` −280, `[0]` −220 — and that is asserted
|
||
as a precondition, not described. The asymmetric ratings are load-bearing:
|
||
equal shares collapse three of the four onto one number, and the **smaller**
|
||
unit is first so `[0]` stays distinct from `max(key=abs)`. The **gate is the
|
||
shipped 300 W**; only the hold is shortened, because the hold is a wall clock.
|
||
* **⭐ THE SECOND CONSUMER IS COVERED, NOT SCOPED OUT.** The same value is
|
||
`looks_frozen()`'s `commanded_w` at `main.py:1926`. At the same roster the
|
||
site rail is 0.95 × 500 = **475 W**, so `sum` = 500 W rails it while 250, 280
|
||
and 220 do not. Shipped: the peak guard goes blind on SAFETY-18's conjunction
|
||
and files `floor_w = 0.0`. Under both surviving mutations it is **not blind at
|
||
all and files no floor**, so the battery may keep importing through a quarter
|
||
whose billed average is unobservable. `test_watchdog.py` part 9b. The open
|
||
quarter is deliberately backfilled with the stuck reading so the mutant cannot
|
||
reach the same action by the *coverage* route and mask the difference.
|
||
* **AT N=1 NOTHING CHANGES, AND IT IS ASSERTED.** On a real one-inverter
|
||
controller `standing` has one entry, so `sum`, the mean, `max(key=abs)` and
|
||
`[0]` are **the same number** — all three mutations are genuinely *equivalent*
|
||
on a shipped install and no N=1 fixture could ever separate them. Printed and
|
||
checked, beside the site detector still being absent there. Part 9c.
|
||
* **⚠️ A CORRECTION, AND IT IS THE MOST USEFUL LINE IN THIS CARD.** An earlier
|
||
revision of this entry said one of the three — `list(standing.values())[0]` —
|
||
was **already closed** by [[SAFETY-32]]'s package-wide structural pin, and
|
||
told future authors not to re-cover it. **That was false.** It had been
|
||
measured **once**, that run hit `test_peakguard.py`'s [[CI-06]] flake (a
|
||
measured **~20%** false-red rate), and the red was recorded as a mutation
|
||
**kill**. Re-measured on `release/1.0` @ `86eaef2` carrying only that edit:
|
||
the **full 21-file suite is green** and `test_peakguard.py` passes **five for
|
||
five** in isolation — **the mutation survives.** The reason SAFETY-32 cannot
|
||
see it is not the shape: part 12j taints names *bound* to roster values and
|
||
lists *accumulated* in a loop, and `standing` is a dict filled by **subscript
|
||
assignment**, so it never enters the taint set and `sum(standing.values())` is
|
||
not a roster site at all. 12j's own enumeration on the mutated tree lists
|
||
`main.py:1553`, `1555`, `1690` — **not `1599`**. **All three mutations needed
|
||
this card; all three are now covered by it.** *A single red from
|
||
`test_peakguard.py` proves nothing — re-run it five times in isolation and
|
||
name the failing check.*
|
||
* **⚠️ AND A ZERO-BYTE SURVIVOR ONE LINE BELOW THE ONE THIS CARD PINNED.**
|
||
Pinning the *value* `site_standing` said nothing about **where it goes**.
|
||
Swapping the detector's first two arguments —
|
||
`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`
|
||
— is **zero bytes** and left the tree fully green, because the check asserted
|
||
only that `"-500 W"` appeared **somewhere** in the banner. It still does:
|
||
**in the meter slot.** The swapped banner reads *"commanded +2500 W against a
|
||
meter reading −500 W"* — a site charging at −500 W into a +2500 W import, told
|
||
to the operator **exactly backwards**, on the one line somebody woken by a
|
||
latching site-stopping alarm reads first. The check now asserts the **ordered
|
||
pair** rather than a substring, and is red on it. **No `app/` change:** the
|
||
hole was in the test.
|
||
* **⭐ A SURVIVOR THIS CARD WENT LOOKING FOR AND CLOSED.**
|
||
`site_standing = float(self.target)` — the arbiter's site *target* instead of
|
||
the sum of what actually **stands on the wire** — is **−11 bytes** and
|
||
survived the full suite, because in every fixture that existed the two are the
|
||
same number. They are not when a device's reported range is narrower than its
|
||
`max_w`: the `device` claim is filed **per actuator**, so it bounds each
|
||
unit's share and never the site target. A site asking for −500 W while −250 W
|
||
stands would then take a **latching site-wide false trip** under its own
|
||
300 W gate. Closed by part 9a's device-clamped counterfactual.
|
||
* **⚠️ AND THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER — THE ONE A SHIPPED
|
||
INSTALL CAN ACTUALLY SEE.** The ordered pair went onto `main.py:1620`, the
|
||
**site** detector. That detector is armed only at N≥2, and part 9c asserts it
|
||
is **absent at a roster of one** — so on every install this add-on has, the
|
||
**per-unit** banner is the only sign-disagreement sentence that can ever be
|
||
printed, and its call at **`main.py:1560`** was still unpinned. The identical
|
||
swap there is **0 bytes** and left the tree **21/21 green**, and nothing
|
||
behavioural could ever have caught it: `disagrees()` is **symmetric** in those
|
||
two arguments (`commanded_w * against_w < 0`, `abs()` gate on both), so the
|
||
mutant takes **identical actions on every input**. What moves is the sentence
|
||
— measured, **9 emitted banner lines invert in 3 distinct sentences**,
|
||
including the one persisted to `sign_reason` and read back after a restart:
|
||
*"commanded +1000 W against its own measured power −1000 W"* becomes
|
||
*"commanded −1000 W against its own measured power +1000 W"*. Closed by the
|
||
ordered pair on **both** per-unit banners — the N≥2 `its own measured power`
|
||
one and the **N=1** `a meter reading` one — with both operands read off the
|
||
**observed cycle** rather than off the arguments the detector was handed, plus
|
||
a precondition on each that the two operands really are different and opposite
|
||
in sign. **Red on four named checks, green in the other twenty files. No
|
||
`app/` change.**
|
||
* **⭐ AND THE FAMILY IS NOW ENUMERATED BY MACHINE, because it had been
|
||
enumerated by hand twice and was short both times.**
|
||
`tools/symmetric_message_scan.py` finds every call whose argument order is
|
||
load-bearing for a **message** rather than for a decision: it renames each
|
||
parameter pair, erases every formatted string, canonicalises the commutative
|
||
operators and iterates to a **fixpoint** (the symmetry is usually one call
|
||
deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||
classifies each sentence by where it goes. A second census covers sentences
|
||
the **caller** builds. It found **one open survivor this card does not close
|
||
and now discloses** — `rail_unreachable()`'s `dev_min`/`dev_max` at
|
||
`main.py:3208`, **0 bytes, 21/21 green**, a status-page row — and **twelve**
|
||
unpinned caller-built sentences including SAFETY-31's `circ_reason`, which is
|
||
the same shape and is also persisted. The list, the pinned status of every
|
||
member and the instrument's own blind spots are in
|
||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7.
|
||
* **⚠️ AND A LEDGER SENTENCE THIS CARD ITSELF GOT WRONG, CORRECTED WITH THE
|
||
MEASUREMENT.** An earlier revision said *"before part 9 existed, **three** of
|
||
these left the whole suite green — M1, M2 and M3 — and the remaining seven
|
||
were caught by SAFETY-30's own N≥2 checks"*. **Eight** did. Re-run one at a
|
||
time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged,
|
||
byte delta and `main.py` fingerprint printed, **full 21-file suite** every
|
||
time: M1, M2, M3, M4, M7, M8, M9 and M10 are all **21/21 PASS**; only M5 and
|
||
M6 go red. It is the same species of error this round exists to fix — **a
|
||
survivor recorded as a kill** — and the file contradicted itself three times
|
||
while saying it. **And the two that ARE caught make the card's case:** M5
|
||
(`sum(abs …)`) and M6 (`-sum`) are the only two that reverse the **sign**.
|
||
Every other entry changes only the **magnitude**, and the old `_sign_site`
|
||
fixture's ~−2500 W shares put nine different magnitudes on the **same side of
|
||
the same 320 W gate**. The pre-existing coverage could see a sign and nothing
|
||
else.
|
||
* **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to
|
||
the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`,
|
||
bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the
|
||
byte delta printed. All 13 red.** Every one was **re-run independently** in
|
||
this round rather than carried forward from the previous round's report. The
|
||
claim-wiring pair (`sign_site` and `circulation` moved from site scope to unit
|
||
scope) closes two gaps a reviewer disclosed — though **`circulation`'s kill
|
||
belongs entirely to pre-existing checks and is not claimed by this card.**
|
||
⚠️ One **false red** from `test_peakguard.py` on M10 did not reproduce in four
|
||
subsequent runs; it is CI-06's known flakiness, no claim here rests on it, and
|
||
it is recorded rather than quietly dropped — unlike the one that *was*
|
||
believed, two bullets up. Ledger, and an **enumerated list of what was not
|
||
mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a.
|
||
* **⭐ ROUND 4: THE SCANNER COVERED ONE SYNTACTIC SINK AND CALLED IT THE
|
||
FAMILY, AND TWO LIVE MEMBERS WERE SITTING IN THE HOLE.** Every one of census
|
||
B's 16 members was a sentence that is **appended, assigned, or logged**. Not
|
||
one was **RETURNED** — and census A's return bucket only ever held a callee
|
||
formatting its own *parameters*, so a method formatting its own **attributes**
|
||
and handing the string back was invisible to both. That is the whole
|
||
`describe()` / `explain()` family, which is where this codebase keeps its
|
||
operator sentences. **Worse than the gap: it was in none of the five blind
|
||
spots the tool printed about itself.** The tool now scans the return sink
|
||
(census C), resolving it through the returning method's **call sites** so that
|
||
a returned *key* — `store.unit_key()`, `mqtt.unit_state_key()` — is not
|
||
mistaken for a returned *sentence*; it also scores a sentence handed to a
|
||
constructor as `reason=` or placed under a `"text"` dict key, which added
|
||
**six more caller-built members** it had been stepping past. Re-run, it
|
||
**rediscovered both of the members this round was sent to fix without being
|
||
told where to look**.
|
||
* **⭐ `ClampResult.describe()` — the clamp alarm the owner has been complaining
|
||
about all day — had a SECOND independent way to lie, and no `test_*.py`
|
||
mentioned `describe()` at all.** Transposing `requested_w` and `applied_w` in
|
||
`clamp.py:253` is a **zero-byte** edit that leaves the guard reporting a
|
||
setpoint clamped **UPWARD**, in the measured case to +9999 W by a bound whose
|
||
rating the same sentence gives as 2000 W. It is the §15 action-log row
|
||
(`main.py:1366`) **and** the ClampWatchdog notification reason
|
||
(`main.py:2452`). Measured **21/21 green** before; now **red on 4 checks** in
|
||
`test_clamp.py` part 6d, pinned on the **ordered pair** on the contiguous
|
||
literal at **both** consumers, with operands read off `ctl.target` and
|
||
`hass.writes[-1]` — what the loop asked and what the device was sent — rather
|
||
than off the `ClampResult` the swap lives in.
|
||
* **⭐ `PeakDecision.explain()` — the status-page banner — the same shape.**
|
||
`projected_w` ↔ `ceiling_w` at `peakguard.py:493`, **zero bytes**, and the
|
||
row then reads *"quarter projects 5013 W under a 2300 W ceiling"*: a quarter
|
||
**2713 W over** its ceiling, on a row whose own words say *idle* and whose own
|
||
flag says **ok**. `"peak guard idle"` appeared in no `test_*.py`. Measured
|
||
**21/21 green** before; now **red on 3 checks** in `test_peakguard.py` part 13,
|
||
pinned on the decision and on the shipped status row at `main.py:3313`.
|
||
⚠️ **A correction the ticket is owed:** the idle sentence does not reach
|
||
`main.py:3299/3302` — those are the *floor-limited* branch, where `explain()`
|
||
returns `self.reason` before the idle literal is built. It reaches
|
||
**`main.py:3313`** and, on the release edge, `main.py:1951`.
|
||
* **`test_arbiter.py`'s scope-violation check made a claim WIDER than its
|
||
assertion** — it said `scope_violations` *"names the source and the actuator
|
||
it does not belong on"* and asserted `"peak" in scope_errors[0]`, which is
|
||
still true after the two swap roles. Measured this round: **0 bytes, 21/21
|
||
green**; now red on 2 checks, pinned on the ordered pair.
|
||
* **14 more zero-byte mutations measured, and 10 SURVIVORS ARE DISCLOSED RATHER
|
||
THAN CLOSED**, each with its file fingerprint and its 21/21 verdict, in
|
||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7e–§7g: **two** in
|
||
`allocator.py`'s `UnitPlan.explain()`, three across `arbiter.py`'s two
|
||
`explain()`s, two in `tariff.py`, two in `arbitrage.py`'s trade reasons and
|
||
one in `main.py`'s SoC-floor notification. The other four of the fourteen are
|
||
kills: `allocator.py:162` was already caught by `test_alloc.py`, and
|
||
`clamp.py:253`, `peakguard.py:493` and `arbiter.py:264` are closed here. **An enumerated list with honest
|
||
gaps beats a fifth round finding a fifth instance.**
|
||
⚠️ **And the probe that grades those mutations was itself the defect, once.**
|
||
Its first cut numbered the operands **by order of appearance**, so after a
|
||
transposition the other leaf took the same number and all thirteen renders came
|
||
back **byte-identical** — which reads exactly like *the edit is inert*. Keying
|
||
the value to the **name** fixed it. A measurement comparing the swap against
|
||
itself, in the instrument this time.
|
||
* **The tool now states what it does NOT scan**, largest hole first:
|
||
`%s`-style lazy logging (`_LOG.error("… %s … %s", a, b)` puts the operands in
|
||
*arguments*, so no census here looks at them and the transposition is the same
|
||
zero-byte edit — **31 such calls across 8 modules, counted and listed by the
|
||
tool rather than asserted**), `.format()`/`%`/`+`/`.join()` sentences, a sentence that
|
||
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
|
||
sentences, message attributes outside the `reason`-shaped set, and everything
|
||
outside `app/`. Its five over/under-report gaps stay, and every census row now
|
||
prints whether the two interpolations **share a format spec** — the difference
|
||
between a transposition that is silent and one that merely looks broken.
|
||
|
||
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
|
||
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
|
||
one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere
|
||
to go. It has one now — **without touching a single entity that already
|
||
exists.**
|
||
|
||
* **⭐ A's discovery payloads are byte-identical to `release/1.0`.** Same topics,
|
||
same `unique_id`s, same payload bytes, same retain flags, in the same order —
|
||
verified by **diffing the announced payloads**, not by reading the code:
|
||
`9 topics, 0 missing, 0 changed, 0 added` at a roster of one, and
|
||
`0 missing, 0 changed, 8 added` at a roster of two. That is what protects the
|
||
live install's recorded history and the three watchdog automations that name
|
||
its entities.
|
||
* **⚠️ THE ASYMMETRY IS DELIBERATE — DO NOT "CLEAN IT UP".** The first roster
|
||
entry's per-unit rows go on **the device it already has**; every later entry
|
||
gets its **own** device (`via_device` the controller). "One device per
|
||
inverter" is tidier and is the change that renames every entity on the live
|
||
system — and because HA keeps the id it composed at first discovery, a rename
|
||
produces a **new entity beside a dead one**: the history stays behind and
|
||
every dashboard, template and automation goes on pointing at a sensor that
|
||
quietly stopped updating. The composed ids come out symmetric anyway
|
||
(`sensor.goodwe_inverter_a_battery_power` /
|
||
`sensor.goodwe_inverter_b_battery_power`).
|
||
* **Four per-unit rows** — setpoint, battery power, SoC and the SAFETY-02 sign
|
||
latch — announced **only from a roster of two**. At N=1 nothing is announced
|
||
and nothing is published: no second device, no renamed entity, no added topic,
|
||
asserted rather than assumed.
|
||
* **Site-scoped sensors stay site-scoped.** `grid` and `p1_sample_age_s` are
|
||
announced **once**, on A's device, at every roster length 1–6. There is one P1
|
||
and one grid connection, and `p1_sample_age_s` is a **watchdog input** — a
|
||
second copy is a second answer to "is the meter fresh?", one of which is
|
||
always the one nobody is looking at. Circulating power, controller status,
|
||
maintenance phase and the capacity-tariff peak are site for the same reason.
|
||
* **`inverter.roster_names()`** is now the one answer to "what are the units
|
||
called", read by `build_roster()` **and** by the publisher — which has to know
|
||
the roster **before** a `Controller` exists, because announcing happens on the
|
||
broker's CONNACK.
|
||
* **`test_mqtt.py`**, a new suite: the recorded `release/1.0` baseline
|
||
(generated by running that commit's module, not transcribed), the payload
|
||
diffs, the composed entity ids modelled **independently** of `app/mqtt.py` and
|
||
anchored on the one id verified against the live system
|
||
(`sensor.goodwe_p1_sample_age`), the announcement-vs-publication agreement on
|
||
a real `Controller` with **different** SoC, battery sign and latch state on
|
||
the two units, and **twelve mutations of the shipped module, all red, each on
|
||
a named property**.
|
||
* **MEASURED, NOT ASSERTED.** Three of those properties exist because the
|
||
mutation table found their absence: dropping the unit from the *state topic*,
|
||
moving `grid` into the per-unit set, and `units=()` in main()'s publisher call
|
||
were all **green** on the first run. So was a roster fallback that named two
|
||
nameless entries `inverter-a`. Sixteen mutations were run through
|
||
`tools/mutation_run.sh`; the survivors are enumerated **with their size** in
|
||
`test_mqtt.py` part 9.
|
||
* ⚠️ **Inverter B does not physically exist** (~2026-09-12). Every fixture here
|
||
is **simulated**, from the datasheet and serial in `NOTES.md` §1.1b. Nothing
|
||
in this card was measured on a second inverter.
|
||
|
||
**The add-on configuration screen now speaks English and Dutch instead of raw
|
||
option keys (UX-01).** There was no `translations/` directory at all, so Home
|
||
Assistant fell back to rendering the option KEYS — an operator opening
|
||
*Configuration* read `unit_sign_disagree_w`, `saturation_cycles`,
|
||
`integrator_max_w`, seventy of them, in file order, with no description and no
|
||
way to tell a constant derived from 1890 closed-loop test runs from their own
|
||
inverter's rating. **This card changes presentation only: not one default, not
|
||
one range and not one line of behaviour moved.**
|
||
|
||
* **`translations/en.yaml` and `translations/nl.yaml`**, with a `name` and a
|
||
`description` for **all 70 options** and for **all 7 per-entry keys** of the
|
||
`inverters:` repeatable section. The Dutch is written for a Flemish
|
||
homeowner rather than transliterated: *afname* and *injectie*, *kwartierpiek*
|
||
and *capaciteitstarief*, *laadniveau*, *uitschakelgrens* — the words that are
|
||
on the bill and on the meter.
|
||
* **⭐ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM AND WHETHER TO TOUCH IT.**
|
||
`unit_sign_disagree_s` now reads *"derived from 1890 closed-loop test runs
|
||
(SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs
|
||
produce… do not change it without re-deriving"*; `circulation_w` cites its 924
|
||
runs and the measured 400 W knee; `meter_freeze_s` cites the 2399 s capture;
|
||
`bms_soc_floor` says it is register `0x0560` read off the pack. The two
|
||
numbers that are genuinely the operator's — `max_w` and `peak_cap_w` — say
|
||
**"THIS IS YOUR NUMBER"** instead, and `cycle_cost_threshold_eur`, which is
|
||
neither measured nor the operator's, admits it is an open product question
|
||
(OPT-08) rather than borrowing the others' authority.
|
||
* **`config.yaml` reordered into task order** — hardware/entities → meter →
|
||
ratings and limits → tariff → maintenance → safety → advanced — in **both**
|
||
halves, with every comment carried with its key. ⚠️ The rendered order is the
|
||
**`schema:`** half's (`App.schema_ui` is `UiOptions(...)(data["schema"])`);
|
||
the `options:` half is reordered to match so the file reads the way the tab
|
||
renders. ⚠️ **`test_inverter.py` part 12's first-match trap is preserved**:
|
||
the nested per-entry `max_w:` still appears BEFORE the flat one under
|
||
`schema:`, and `inverters: []` still ships in the `options:` half. Moving the
|
||
nested block below the flat one is a **1-byte** edit and it was run: red in
|
||
`test_inverter.py` and in `test_translations.py`.
|
||
* **MEASURED, NOT ASSERTED.** `tools/supervisor_shape_probe.py` gains a section
|
||
F that loads both files with Supervisor 2026.08.0's *own*
|
||
`_read_app_translations()` out of the release image (a file that fails
|
||
`SCHEMA_APP_TRANSLATIONS` is **dropped with a log warning and nothing else**),
|
||
runs its own `UiOptions`, replays the frontend's label resolution transcribed
|
||
from the shipped bundle, and prints every rendered row in both languages.
|
||
* **⚠️ ONE MEASURED NEGATIVE RESULT, RECORDED RATHER THAN GLOSSED.** The
|
||
per-entry keys inside the `inverters:` repeatable section still render RAW.
|
||
`_convertSchemaElement` sends `type: "schema"` **with** `multiple` to
|
||
`ha-selector-object`, which labels its fields `fields[name].label || name`,
|
||
and the converter sets neither `label` nor `translation_key`. The `fields:`
|
||
translations are shipped and validated because that is the shape Supervisor's
|
||
own schema defines for exactly this, and a frontend that wires `label` through
|
||
would consume them — but on 20250925.1 they do not reach the screen, and this
|
||
card does not claim they do. The `inverters` label and description themselves
|
||
do.
|
||
* **A new check, `test_translations.py`, so this cannot rot.** A translation key
|
||
naming an option that does not exist, or an option with no translation, fails
|
||
CI — in both directions, for both languages, top-level and per-entry. It is
|
||
**mutation-tested both ways**: eleven mutations applied in-process, each
|
||
required to NAME the key it broke rather than merely to go red, plus a
|
||
negative control that the unmutated input is clean. Two of the eleven are the
|
||
ones that cost the WHOLE FILE rather than one label — an unrecognised
|
||
top-level block, and a `network:` value that is not a string. Supervisor
|
||
validates each translation file through `SCHEMA_APP_TRANSLATIONS` and, on
|
||
refusal, **logs a warning and drops all of it**, so those silently return the
|
||
entire tab to raw keys; neither is reachable through the `configuration:`
|
||
coverage, which is why they were hunted for and closed. Six more were run **on disk**, through the
|
||
purge-and-re-read protocol: deleting a translation (−65 B), renaming an option
|
||
in `config.yaml` (+4 B), adding an orphan key (+47 B), emptying a Dutch
|
||
description (−711 B), breaking one label's group prefix (−4 B) and deleting a
|
||
provenance citation (−19 B) each go red.
|
||
* **The parser has a licence.** There is no PyYAML in the add-on's CI image, so
|
||
`tools/translations_lint.py` reads these files by hand — and a hand parser
|
||
that quietly disagrees with the real loader is the defect this repository
|
||
keeps finding. The probe imports **that same module** and asserts, key by key
|
||
and string by string, that it returns what `_read_app_translations()` returns.
|
||
The parser is deliberately strict and **raises** on anything outside the
|
||
subset; nine malformed shapes are fed to it to prove it does.
|
||
* **⚠️ WHAT THIS CHECK CANNOT SEE, stated with its size.** It grades coverage
|
||
and provenance *citations*, not truth. Falsifying a figure inside a
|
||
description that is not one of the cited tokens survives (run: *"8.5 times"* →
|
||
*"3 times"*, −2 B, green), and swapping two options' descriptions survives
|
||
(0 B, green). Separately and out of this card's scope: changing a shipped
|
||
default that no suite pins survives — `step_w: 10` → `20` (0 B) leaves
|
||
`test_control.py`, `test_clamp.py`, `test_validate.py` and
|
||
`test_peakguard.py` green. That is the same family as CFG-04's DEFECT 2 and
|
||
the UX-01 rescope moved it to its own card on purpose.
|
||
|
||
**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
|
||
on ONE actuator** against **what the whole site** is exchanging with the network.
|
||
At a roster of one those are two views of the same thing - §6.2's identity - and
|
||
that is what makes the check sound and what earned it the best record in the
|
||
safety table. At two they are not, and it fails **in both directions**: inverter
|
||
A commanded −1500 W while the meter reads +1500 W is evidence of nothing when
|
||
inverter B is discharging 3000 W exactly as told, and - worse - a unit genuinely
|
||
driving the wrong way is **MASKED** by a sibling that covers for it, because the
|
||
site figure nets. No threshold recovers that; the summation destroys the
|
||
evidence.
|
||
|
||
* **PER UNIT: each inverter's own share against its OWN measured power**
|
||
(`Inverter.batt`, holding register 0x0518, per-instance since DEV-02).
|
||
Conclusion: *the actuator I am driving is not doing what I told it* - evidence
|
||
about one device, so the remedy stays the per-unit `failsafe` SET that
|
||
`arbiter.CLAIM_SCOPE` has classified `SCOPE_UNIT` since it was written. That
|
||
entry read *"the evidence is site-wide and the remedy is not"*; after this card
|
||
the first half is simply true. Verified by making one unit reverse while its
|
||
sibling covers it at the meter: the shipped comparison is false on **0 of 14**
|
||
cycles while the new one convicts that unit on every one, and only that unit is
|
||
parked.
|
||
* **SITE: the SUM of the standing commands against the meter**, as a new
|
||
`sign_site` SET at `P_SAFETY` on the site actuator, latched under flat
|
||
`site_sign_tripped` / `site_sign_reason`. This is the fault the 2026-08-20
|
||
capture contains and **no per-unit check can see it**, because every inverter
|
||
is faithfully executing its share - what is wrong is the number they were
|
||
given. It stops every inverter and the banner names the site rather than an
|
||
inverter, so nobody is sent to swap the wrong box.
|
||
* **⚠️ Different questions from SAFETY-31, and the PR and the decision record
|
||
both say which.** `circulation.py` compares the units **against each other**
|
||
and catches two inverters pushing *through* each other, which nets to zero at
|
||
the meter and which no single command is evidence about. This compares each
|
||
unit against **its own command**. They overlap in exactly one scenario - a unit
|
||
reversing while its sibling pulls the other way - and `test_watchdog.py`
|
||
asserts on that fixture that both fire and what each concludes. It is also not
|
||
the saturation freeze: that is a magnitude test (`|command − measured| >
|
||
500 W`) that shapes the loop and fires on a merely saturated unit; this is a
|
||
direction test that latches and parks one.
|
||
* **⚠️ All four thresholds RE-DERIVED at two inverters - one carried with a
|
||
justification, one refused.** 1890 closed-loop runs in which nothing is faulty,
|
||
over the whole range `config.yaml` accepts for `gain` and `slew_w`, across load
|
||
reversals with write staggers and flapping loads, **run at both rosters so the
|
||
N=1 arm is the control.** `unit_sign_disagree_w: 250` is `saturation_w / 2`, so
|
||
the smallest divergence this can see is exactly the gap at which the control
|
||
law itself already says the inverter is not following; `unit_sign_disagree_s:
|
||
85` is 8.5× the worst legitimate opposition those runs produce (10.0 s), 2.8×
|
||
the firmware's own 30 s link veto and under half the site hold.
|
||
`site_sign_disagree_w: 300` is **carried** - its argument is the meter's noise
|
||
floor and the deadband, and the roster touches neither. **`site_sign_disagree_s`
|
||
is NOT: 180 s would have shipped a false trip.** The worst legitimate crossing
|
||
at 300 W is 110 s with one inverter and **215 s with two**, because the command
|
||
must travel a rail that is the roster sum at a `slew_w` that is not - reachable
|
||
at `gain: 0.05`, `slew_w: 50` and a 2.2 kW load reversal, all schema-legal.
|
||
325 s is 1.51× that, inside the 900 s billing quarter and inside every episode
|
||
of the fault day. See `DECISIONS/SAFETY-30-per-unit-sign.md`.
|
||
* **⚠️ At ONE inverter this changes nothing, and "nothing" is asserted rather
|
||
than assumed.** The two questions are the same sentence at a roster of one, so
|
||
only the detector that shipped is armed: same comparison against the meter,
|
||
same 300 W / 180 s, same reason string byte for byte, same namespaced latch and
|
||
flat rollback mirror, same `failsafe` claim, same banner, same single status
|
||
row - and the site detector's window is never so much as opened.
|
||
`unit_sign_disagree_*` and `site_sign_disagree_*` do nothing on a
|
||
one-inverter install.
|
||
* **The threshold WIRING is pinned, not just the threshold values.** Review
|
||
found that `arm(self.sign_cfg if one else self.unit_sign_cfg, ...)` could be
|
||
collapsed to `arm(self.sign_cfg, ...)` — arming every unit with the
|
||
meter-facing pair instead of the re-derived per-unit one — with all 19 test
|
||
files still green, because nothing at a roster of two read `sign_wd.cfg` and
|
||
the trips in the fixture happen under either pair. `test_watchdog.py` part 3
|
||
now asserts the pair and the instrument on **every** detector against
|
||
literals, and the fixture's three threshold pairs were made pairwise distinct
|
||
in both coordinates so that a hold delivered from the wrong pair cannot
|
||
compare equal to the right one. M14–M16 in
|
||
`DECISIONS/SAFETY-30-per-unit-sign.md` §5.
|
||
* **Known and recorded rather than fixed:** the site comparison is defeated by a
|
||
square-wave load whose half period is about one control cycle, which holds it
|
||
opposed for ~1000 s at *both* rosters. The N=1 control arm is what says this is
|
||
a property of comparing a lagged command against a meter and has nothing to do
|
||
with the second inverter, so it is not acted on here - it wants an alternation
|
||
test, not a number, and a card of its own.
|
||
|
||
**An operator can declare a second inverter, and the shape was measured against
|
||
the Supervisor rather than assumed (CFG-04).** DEV-02 made rating, SoC,
|
||
watchdogs, heartbeat and store keys per-instance, so the code no longer assumed
|
||
one inverter — but the options schema still described exactly one, and
|
||
`build_roster()` returned a list of length one and said so in its own docstring.
|
||
The roster could only ever be a roster of one. **Version bumped to 0.6.0,
|
||
because the Supervisor keys the built image by `version:` and a schema change
|
||
without a bump is a schema nobody reads.**
|
||
|
||
* **`inverters:` — a list of objects, defaulting to `[]`.** Empty is not
|
||
"unconfigured", it is **the single-inverter configuration**: the flat
|
||
`setpoint_entity` / `soc_entity` / `batt_entity` / `batt_invert` / `max_w`
|
||
keys describe the one unit exactly as they always did. An install that
|
||
upgrades reads identically — same entities, same rating, same behaviour, no
|
||
operator action, and the whole existing check suite runs against it unchanged.
|
||
* **⚠️ The shape was ESTABLISHED, not assumed, and the empty default is the
|
||
load-bearing part.** There was no list-of-objects anywhere in `config.yaml`
|
||
and `static_profile` carries its structure as a JSON string in a `str?`
|
||
precisely because a nested schema was doubted. So Supervisor **2026.08.0**'s
|
||
own `AppOptions` validator — the version this site runs — was run against this
|
||
repository's `config.yaml` out of the release image
|
||
(`tools/supervisor_shape_probe.py`, committed; the transcript and the
|
||
reasoning are in `DECISIONS/CFG-04-inverter-roster.md`). It accepts the list,
|
||
enforces per-entry types, refuses a missing `name` / `setpoint_entity` /
|
||
`max_w` **by name**, and renders the option in the UI as a repeatable section.
|
||
And the shipped `options:` block validates as it stands while the **same
|
||
block with only `inverters` removed** is answered with **`Missing option
|
||
'inverters' in root`** — one key varied, so the refusal is about that key —
|
||
which is every options blob written before this version, since the key did not
|
||
exist when they were written. `App.options` is
|
||
the shipped `options:` block deep-merged with the operator's stored one, so
|
||
`inverters: []` in `config.yaml` is the difference between an upgrade nobody
|
||
notices and an add-on that cannot start with a schema pinned to the version
|
||
that would fix it. That is `docs/deploy-rollback.md` §4's trap in the forward
|
||
direction, found before it was sprung rather than after.
|
||
* **Nothing is inherited by an entry that omits a key, and that is DEV-02
|
||
restated as a config rule.** `name`, `setpoint_entity` and `max_w` are
|
||
required of every entry; a missing `soc_entity` means *this unit has no SoC
|
||
sensor*, never *use the other one's*. A three-line fallback to the flat keys
|
||
would have rebuilt the sibling-grading defect DEV-02 spent a card removing, in
|
||
YAML. Defaults still live in exactly one place (CFG-03): there is no
|
||
per-entry default anywhere, because there is no optional per-entry number.
|
||
* **SAFETY-28's `actuator_writers()` refuses two inverters naming one
|
||
`setpoint_entity` — the first configuration in this add-on's life in which
|
||
that was expressible.** The census was already structural and case-folding, so
|
||
it needed no change; what it needed was a config that could express the
|
||
hazard, and now there is one. Its sibling refusal is new: a flat
|
||
`setpoint_entity` left set beside a declared roster names an actuator nothing
|
||
will write, and is refused rather than ignored.
|
||
* **⚠️ Two PERMISSIVE READERS in shipped code, found and fixed.** Both failed in
|
||
the direction that hides a defect. (1) `validate.py` rules 5 and 6 compared
|
||
`sign_disagree_w` and `maintenance_charge_w` against the **flat** `max_w`; at
|
||
a roster of two 2000 W units with `max_w: 5000` left over from a
|
||
single-inverter install, `sign_disagree_w: 3000` passed rule 5 while
|
||
SAFETY-02's watchdog could fire on **neither** unit — rule 5's own failure
|
||
mode, reached through rule 5. They now read the roster's caps
|
||
(`unit_caps()`), which at one inverter is `[max_w]` exactly. (2)
|
||
`run_control()` re-ran the device-rating check behind `if
|
||
self.o.get("setpoint_entity")` — the flat key, which a roster config leaves
|
||
blank — so on a two-inverter site **no** inverter's `max_w` was ever graded
|
||
against the device it is written to. `validate_unit_ranges()` grades each unit
|
||
against its own ESPHome and runs unconditionally.
|
||
* **⚠️ Three config.yaml READERS IN THE CHECKS took the first match on a
|
||
stripped line, and one of them was already known.** `test_peakguard.py`'s
|
||
`read_option()` and `_schema_bound()` and `test_inverter.py`'s
|
||
`read_schema_max()` threw indentation away, so the nested per-inverter
|
||
`max_w:` began outranking the flat one — **silently, and correctly**, because
|
||
the two bounds are the same `int(100,5000)` today. A reader that is right by
|
||
coincidence is the failure mode this project is named after. All three are
|
||
anchored at exactly two spaces now, the halves of the file disambiguate
|
||
`peak_cap_w` instead of first-match, and an ambiguous key raises rather than
|
||
picking one. `test_tariff.py`'s copy of the idiom went with them.
|
||
* **The audit is a check, not a promise.** `test_inverter.py` part 11 finds
|
||
every `config.yaml`-reading function in the suite — transitively, so a reader
|
||
that never names the file because it delegates is still found — **executes**
|
||
each one against a synthetic two-inverter `config.yaml` whose per-inverter
|
||
numbers differ from its flat ones, and fails if any returns the per-inverter
|
||
value. The table is not maintained by hand: the signature decides, so the
|
||
eleventh reader is named on its first run. `config.yaml` declares `inverters:`
|
||
**before** `max_w:` on purpose, and part 11 asserts that ordering, so the trap
|
||
stays baited rather than sorted out by an ordering nobody would maintain.
|
||
* **Ten mutations run and observed red** (`tools/mutation_run.sh`), listed in
|
||
`test_inverter.py` part 12 with the check that bites each. **Two survived the
|
||
first run and the checks were changed, not the table:** the inheritance check
|
||
had blank flat keys, so "inherited" and "not inherited" were the same empty
|
||
string; and the `run_control()` check asserted a **source line** was present,
|
||
which re-indenting it does not remove. The second is now driven through the
|
||
shipped `run_control()` under a deadline, because that mutation's real symptom
|
||
is a hang rather than a red.
|
||
|
||
**Circulating power - two inverters pushing against each other - becomes a
|
||
measured quantity, a bounded one, and an alarm that survives a restart
|
||
(SAFETY-31).** Inverter A charging at 3 kW while inverter B discharges at 3 kW
|
||
nets ~0 W at P1: the site reads idle while the packs are cycled through two
|
||
round-trip conversion losses continuously. **Every guard in this add-on is a
|
||
consumer of that net figure**, so none of them can see it - SAFETY-02's sign
|
||
watchdog gates on `abs(grid_w) > 300` and the gate never opens, and both the
|
||
peak guard and the capacity tariff bill grid exchange.
|
||
|
||
* **A site sensor, `Σ|Bᵢ| − |ΣBᵢ|`.** Zero when the units pull the same way,
|
||
`2 × min(|B_a|,|B_b|)` when they oppose. Published as
|
||
`sensor.goodwe_circulating_power` beside the grid figure, because *"grid 0 W,
|
||
circulating 6000 W"* is the whole phenomenon in two dashboard rows and no
|
||
arithmetic on the grid figure alone can produce the second one.
|
||
* **⚠️ Derived from MEASURED per-inverter power, never from the allocation
|
||
record, and that is the whole card.** ALLOC-01's sign-coherence gate
|
||
(`REFUSE_SIGN`, its AC 7) already refuses any allocation whose shares carry
|
||
two signs, so a record-derived sensor reads zero **by construction** and could
|
||
only ever confirm that the allocator agrees with itself. What this detects is
|
||
circulation **nobody commanded**: a unit ignoring or lagging its share, a
|
||
stale share still being executed, a maintenance or SAFETY-25 pin holding one
|
||
unit while the loop drives the other, or HW-01's power stage running its last
|
||
instruction for **6 h 25 m** with the comms dead and the allocation record
|
||
impeccable throughout. Verified by making one unit ignore its share on the
|
||
TEST-19 rig: the record stays `ok` with both shares at −1250 W - a
|
||
record-derived sensor reads **0 W** - while the shipped sensor reads
|
||
**2000 W**. `Inverter.batt` became per-instance in DEV-02; before that this
|
||
sensor could not have been written.
|
||
* **The trip stops the site, and the decision record says why that is an action
|
||
rather than theatre.** Sustained opposition files `Claim.set("circulation",
|
||
P_SAFETY, 0.0)` on the **site** actuator - every inverter to 0 W, latched.
|
||
HW-01 established that a wedged inverter cannot be commanded at all, so the
|
||
remedy is not aimed at it: a circulating loop needs **two** participants and
|
||
**one of them is this controller**, faithfully driving the listening sibling
|
||
into the one that stopped listening. Stopping halves the loop, and - the more
|
||
important half - **makes the residue visible at the meter**, where every
|
||
existing guard can finally see it. What it cannot do is stop the wedged unit;
|
||
the notification says so, and names the isolator as the last actuator.
|
||
* **The threshold and the window were measured, not chosen (AC 3).** 924
|
||
closed-loop runs on the TEST-19 two-master rig in which *nothing* is faulty,
|
||
swept over the whole range the schema accepts for `gain` and `slew_w`. Worst
|
||
**continuous** legitimate opposition by threshold: **505.0 s at 100 W, 40.0 s
|
||
at 250 W, 10.0 s at 300 W, 5.0 s at 400 W and above**. The knee is 400 W and
|
||
it is the schema floor. `circulation_w: 700` is 1.75× it, the headroom bought
|
||
for inverter B's ramp and lag being **placeholders until SPIKE-04 measures
|
||
them**. `circulation_hold_s: 75` clears three bounds - 15× the worst
|
||
legitimate episode (5.0 s), 2.5× the firmware's own `LINK_STALE_S` veto, and
|
||
12× inside the 900 s capacity-tariff quarter. **A momentary opposition while
|
||
one inverter slews through zero is legitimate** - the serialised write path
|
||
lands B's number up to a control cycle after A's - and the guard does not fire
|
||
on it, in any of the 924 runs.
|
||
* **The alarm survives a Home Assistant restart (SAFETY-34).**
|
||
`persistent_notification` is in memory, which is exactly how the 2026-09-02
|
||
outage went unseen for 6 h 25 m with the detector correct throughout. The
|
||
latch is persisted to `/data` as flat, site-level keys - flat because the
|
||
quantity is defined over the roster and is identically zero for any one unit,
|
||
so there is no inverter to attribute it to - and the notification is
|
||
**re-raised on the first cycle after every start**, through the same code path
|
||
as a fresh trip.
|
||
* **At one inverter the sensor is identically zero and nothing changes (AC 5).**
|
||
`|B| − |B| == 0` for every reading, including one nobody took; no claim is
|
||
ever filed, the arbitration is byte-identical, and the status page says *n/a*
|
||
rather than claiming to watch something that cannot happen.
|
||
* **Verified by breaking it.** 18 mutations against the sensor, the window, the
|
||
latch, the claim, the store, the alarm, the routing table and both config
|
||
copies - **all red**, each on a named check; one equivalent mutant argued
|
||
rather than claimed; and one genuine **survivor found and closed** (`publish()`
|
||
turning an unknown into a published `0 W`). `arbiter.CLAIM_SCOPE` gains its
|
||
ninth source and `test_peakguard.py`'s 10f/10g tripwires were updated
|
||
deliberately, as their own comments require. Full ledger in
|
||
`DECISIONS/SAFETY-31-circulating-power.md`.
|
||
|
||
**Every per-device value stops being a Controller scalar: rating, SoC, battery
|
||
power, watchdogs, heartbeat, write bookkeeping and store keys are now
|
||
per-instance (DEV-02).** `app/inverter.py` introduces an `Inverter` object that
|
||
owns its entities, its rating, its device range, its `SignWatchdog`, its
|
||
`last_write` and its `last_beat`, under **store keys namespaced by unit name**.
|
||
The Controller holds a list of them and keeps only what is genuinely site-level:
|
||
the P1 reading, the site integrator, and the tariff quarter.
|
||
|
||
* **What the scalars were silently asserting.** One `dev_min`/`dev_max` pair was
|
||
read from one `setpoint_entity`, so a second inverter's rating was **never
|
||
read at all** and its clamp, its freeze rail and the peak guard's
|
||
`achievable_w` were the first one's numbers. One `soc` fed the maintenance
|
||
FSM, SAFETY-24's pack floor and arbitrage's bounds. One `SignWatchdog` - whose
|
||
own docstring says *"one instance per actuator"* - latched under **flat** store
|
||
keys, so a trip caused by B latched A across restarts and one operator press
|
||
cleared both. One `last_beat` and one serialised `hass.call()` meant a hung
|
||
write to A ate B's 30 s layer-1 budget.
|
||
* **`actual_w` became SITE level while the readings became per-instance, and
|
||
those are opposite directions on purpose.** `compute()`'s anti-windup test is
|
||
`abs(prev_w - actual_w) > saturation_w` with `prev_w` the **site** target, so
|
||
feeding it one inverter's power compares a site number against a unit number,
|
||
trips the saturation freeze permanently and leaves a standing grid error.
|
||
Measured on `admin/goodwe`'s two-master rig, 2 kW load: with one unit's power
|
||
the aggregate trace at the meter is split-dependent by **510 W at `gain: 0.2`,
|
||
250 W at 0.4 and 50 W at 0.6**; with the site sum **every split is
|
||
bit-identical at every gain the schema accepts**. That was ALLOC-01's
|
||
disclosed finding, graded in `test_alloc.py` part 8 and now closed there.
|
||
* **The store migration is graded, not merely performed.** `sign_tripped` /
|
||
`sign_reason` move to `unit/<name>/...` once, verified by **reading back**
|
||
what was written, and the version marker is only set when nothing that carried
|
||
state was lost. The flat keys are left in the file so a rollback still finds
|
||
its latch. And `Controller.__init__` compares the pre-migration flat latch
|
||
against what unit A actually came up with: if the latch did not survive it is
|
||
**re-armed from the legacy value and shouted about**, because a latch that
|
||
silently fails to migrate is a disarmed safety guard on an install whose state
|
||
file says it is protected.
|
||
* **Heartbeats are independent, and the budget is derived.** Each inverter owns
|
||
its own deadline, the refreshes are dispatched **concurrently**, and each write
|
||
is bounded by `WRITE_BUDGET_S` (4 s). The inequality that fixes that number is
|
||
graded rather than asserted: `4 + heartbeat_s`'s schema maximum of `25 < 30`,
|
||
layer 1's `FIRMWARE_INGEST_VETO_S`. Verified by injecting a transport that
|
||
accepts and never answers: B's write reaches the wire while A is still
|
||
hanging, and the serialised shape it replaces is run beside it.
|
||
* **`live_units()` answers honestly.** A unit whose every write has been rejected
|
||
for a whole layer-1 window is not commandable; the allocator drops it, the
|
||
survivors carry its share, the action log names it, and the dropped unit is
|
||
commanded **0 W** rather than left standing on its last share. A roster entry
|
||
with **no** `setpoint_entity` is deliberately *not* handled there - that is a
|
||
configuration that cannot be delivered, and it reaches main.py's refusal
|
||
branch, which commands 0 W and names the inverter.
|
||
* **At N=1 nothing moves.** The removed scalars survive as read/write properties
|
||
of the sole inverter, so every existing replay in this repository runs
|
||
unmodified - and they **refuse at N>=2** rather than answering for the first
|
||
inverter, which is the tripwire on a consumer that was never made
|
||
per-instance. `test_inverter.py` is the new file (77 checks); `test_alloc.py`
|
||
part 8, `test_peakguard.py` 10g/10h, `test_watchdog.py` and `test_clamp.py`
|
||
were re-aimed rather than relaxed.
|
||
* **Inverter B still does not exist.** `build_roster()` returns exactly one
|
||
inverter, from the flat options, and is the one function CFG-04 changes. Every
|
||
two-unit figure above is architecture, not a measurement of a second device;
|
||
the second rating in the checks is marked as a hypothesis.
|
||
|
||
**The write path is gated on the allocation record, not on how many config keys
|
||
exist (SAFETY-28).** specs §1.5 makes *"any configuration in which two writers
|
||
can reach one actuator"* a non-goal. Until now that non-goal was enforced by
|
||
`len(validate.ACTUATOR_WRITE_KEYS) == 1` - there is exactly one option key, so a
|
||
second writer is not expressible in the schema. True, and a statement about **the
|
||
shape of the options schema rather than about the writes**: add a per-inverter
|
||
config (CFG-04) and there are two real writers with that length still 1, so the
|
||
check stays green while the property it protects is gone. `sim/inject.py`'s
|
||
`two_writers` scenario asserted on the same tuple and went green with it.
|
||
|
||
* **The enforcement moved onto the writes.** `write_setpoint()` now refuses any
|
||
value that is not in **this tick's** allocation record from ALLOC-01:
|
||
`Controller.write_refusal()` asks `Allocation.authorises()` whether the single
|
||
allocator placed exactly this number, for this unit, on this tick. A second
|
||
writer with arithmetic of its own cannot satisfy that - its number appears in
|
||
no record - so it has to file a `Claim` and go through `resolve()`, which is
|
||
the one allocator. "Exactly one allocator per shared resource" stops being a
|
||
convention and becomes something the write path checks.
|
||
* **A refusal is a refusal, not a log line.** The unauthorised value is replaced
|
||
with **0 W and written**. Returning instead would leave the inverter latched
|
||
on its last command with the ESP32 heartbeat still being fed - holding an
|
||
unauthorised setpoint quietly, which is the outcome the gate exists to prevent.
|
||
* **A stale record cannot be replayed.** Every record now carries the `tick` it
|
||
was made for, handed *in* by the caller so `allocator.plan()` stays the pure
|
||
function `test_alloc.py` part 10 grades. The counter advances **once per
|
||
resolution, not once per loop iteration**: a new allocation makes the previous
|
||
record stale immediately, while the "inputs missing, not yet stale" hold -
|
||
which returns before the allocator runs, so one dropped meter reading does not
|
||
zero the inverter - leaves the standing authorisation intact for the heartbeat.
|
||
An **unstamped** record authorises nothing; "I cannot say when I was made"
|
||
fails closed.
|
||
* **⚠️ 0 W IS AUTHORISED UNCONDITIONALLY, SO THE FAILSAFE CANNOT DEADLOCK BEHIND
|
||
THE GATE.** `write_setpoint()` is also how layer 2 commands zero, and every
|
||
path that does so - `cycle()`'s two refusal branches, `shutdown()`, the
|
||
operator's stop button - runs while the allocator has *refused*. A gate that
|
||
asked the allocator's permission to command zero would block the safety write
|
||
behind the very component whose failure the safety write answers. The
|
||
exemption is on the **value**, not on a `failsafe=True` flag a caller asserts,
|
||
and the argument is that the hazard §1.5 names is N-times loop gain from
|
||
*broadcast*: at `S = 0` the broadcast and the correct allocation coincide -
|
||
`a_i = 1` for every unit gives every unit 0 W, which is exactly its share - so
|
||
there is no over-command to make at zero. Driven end to end through the real
|
||
refusal branch in `test_clamp.py` part 7c.
|
||
* **`unmet_w` is still not `ok`.** The gate reads `ok` and the unit's share and
|
||
never `unmet_w`, because an allocation that could not place every watt is
|
||
still safe to write; fusing them would let one saturated inverter park the
|
||
whole site. Graded on a record with 3800 W unmet.
|
||
* **`ACTUATOR_WRITE_KEYS` is replaced by `actuator_writers()`**, which counts
|
||
**writers per actuator** rather than option keys, and finds them by walking
|
||
the options tree rather than by looking for a container name CFG-04 has not
|
||
chosen yet. Two inverters with two entities are accepted - that is what the
|
||
per-inverter roster is *for*; two declarations aimed at one entity are refused,
|
||
naming the actuator and every writer pointed at it. The tuple survives only as
|
||
the enumeration a census needs, and nothing asserts on its length any more.
|
||
* **What neither check observes, said out loud.** The census sees *configured*
|
||
writers, so a second write path added in **code** is invisible to it - exactly
|
||
as it was invisible to the tuple. That half is closed at runtime by the record
|
||
gate, which does not care what the options look like or who called the method.
|
||
* Verified by breaking it: seven mutations on a purged tree via
|
||
`tools/mutation_run.sh` - removing the gate, making the zero exit an amnesty,
|
||
dropping the tick comparison, deleting the census refusal, fusing `unmet_w`
|
||
into `ok`, freezing the tick on the live path, and moving the gate below the
|
||
clamp - each turn `test_clamp.py` or `test_validate.py` red.
|
||
|
||
**The site grid target is now ALLOCATED across N inverters, sum-invariant, and
|
||
never broadcast (ALLOC-01).** `ARB-03` left `allocate()` as a documented seam
|
||
that passed through at N<=1 and DECLINED at N>=2; this fills it. The new
|
||
`app/allocator.py` turns one site target `S` into per-inverter shares with one
|
||
invariant - **`sum(shares) + unmet_w == S`, in integer arithmetic** - and each
|
||
inverter is then told a function of **its own share**, never of the shared
|
||
measurement.
|
||
|
||
* **Why a broadcast is a fault and not merely an inefficiency.** The control
|
||
primitive is meter emulation: the master reports a *fabricated grid reading*
|
||
and the inverter regulates itself to it, so one number means "the battery
|
||
moves X W" here and "the grid is at X W" there - an identity that holds only
|
||
at N=1. Hand both inverters the same target and each supplies it in full, so
|
||
the loop gain at the meter becomes N times the per-unit gain. Measured on
|
||
`admin/goodwe`'s two-master rig (TEST-19): **-400 W export overshoot at
|
||
`gain: 0.6`, and at `gain: 1.0` - which `config.yaml` accepts as its
|
||
documented maximum - a permanent 4000 W peak-to-peak limit cycle with not one
|
||
guard firing.** The sign watchdog is evaluated on every cycle and never trips,
|
||
because the disagreement flips on alternate ticks and never holds its 180 s
|
||
window.
|
||
* **The stability property is structural, not tuned.** With `sum(a_i) = 1` the
|
||
gain at the meter is `sum(a_i * g_i)`, a **convex combination** of the
|
||
per-unit gains, so it lies in `[min g_i, max g_i]` **for any split**. That is
|
||
what makes the weights a comfort choice (SoC, thermals, wear) rather than a
|
||
tuning parameter, and it means a bad split cannot destabilise the site.
|
||
Graded directly in `test_alloc.py` part 9, and measured end-to-end: a 90/10,
|
||
a 50/50 and a 100/0 split produce a **bit-identical aggregate trace at the
|
||
meter**, and a gain sweep to 1.0 at every split shows a flat tail at all 84
|
||
combinations.
|
||
* **Rounding is a safety property here.** The register is 1 W, and a per-unit
|
||
`round()` biases the sum - measured at **-125 W over 50 000 ticks at three
|
||
inverters**. At 1-2 Hz that is a permanent standing error the site integrator
|
||
chases and never reaches. So the target is rounded **once**, at the top, and
|
||
the fractional parts are settled by **largest remainder** with a deterministic
|
||
tie-break; every sum below that line is integer arithmetic and cannot drift.
|
||
Verified over 250 000 randomised ticks: **0 sum failures, 0 envelope escapes**,
|
||
and the one rounding that is left - the single site-level `quantisation_w`,
|
||
never distributed into a per-unit bias - totals **-39.8 W over 137 964
|
||
allocations, i.e. -0.0003 W per tick.**
|
||
* **Each share is clipped to that unit's interval, and THE TWO ENDS OF THE
|
||
INTERVAL ARE APPLIED INDEPENDENTLY.** `UnitBounds()` defaults to
|
||
`(-inf, +inf)`, so a one-sided envelope is the ordinary case at N>=2 rather
|
||
than an edge. The first cut of this work folded both ends into one
|
||
`min(max(v, lo), hi)` and substituted `v` itself for a missing bound, which
|
||
reads as "an absent bound cannot bind" but is not what it does: with
|
||
`hi == inf` the expression collapses to `min(max(v, lo), v) == v`, so **a
|
||
missing ceiling silently discarded the floor.** `_clamp_int(0, 2142, inf)`
|
||
returned 0. The worked failure it caused: a unit with a 2000 W floor and no
|
||
ceiling, beside a symmetric unit, against a +1000 W site target, was allocated
|
||
**+500 W - a quarter of its own floor** - and the sign-coherence refusal that
|
||
the same case with a finite ceiling correctly raises was **suppressed** with
|
||
it. Fixed by clamping each end on its own, and `UnitPlan.explain()` can no
|
||
longer print a share as being inside an interval that excludes it: that line
|
||
now reads `OUTSIDE ... - ENVELOPE VIOLATED, DO NOT WRITE`.
|
||
* **The envelope is read per tick, never cached.** `plan()` is a pure function
|
||
of that tick's intervals and holds no state, because a unit's range moves for
|
||
reasons unrelated to the allocator - a stale link, a pack at its floor, a
|
||
device rating that failed to read.
|
||
* **A dead link is announced, not absorbed.** A unit whose master is stale is
|
||
dropped from the envelope set; the survivors carry its share, and the
|
||
allocation record names the unit and the reason so `main.py` logs it. A site
|
||
running at half its inverters and reporting nothing reads as healthy on every
|
||
dashboard.
|
||
* **Opposite directions are refused, not averaged.** One pack discharging while
|
||
another charges nets to exactly the right number at the meter, so no existing
|
||
guard can see it. A sign-coherence gate makes it inexpressible.
|
||
* **`scope_violations()` is now consumed, and it is consumed as a refusal.**
|
||
ARB-03 left it logging without changing the resolution. A mis-routed claim
|
||
means the number about to be divided may itself be wrong, and dividing a
|
||
number that may be wrong is worse than not dividing it - the same answer
|
||
clause 3 already gives a contradiction. It is *not* promoted into an envelope:
|
||
a unit-scoped claim that landed on the site names no unit, so promoting it
|
||
would mean guessing one.
|
||
* **At N<=1 it is a pass-through, byte-identical.** The share is the site
|
||
target, as a float, unrounded and unclamped. Every one of the existing suites
|
||
replays unedited and green.
|
||
* **`main.py` still refuses to act at N>=2, for a new and narrower reason.**
|
||
There is one `setpoint_entity`, so this controller can command one inverter; a
|
||
valid two-way split names an inverter it has no writer for, and commanding a
|
||
partial site is a worse instruction than commanding nothing. The old
|
||
"no allocator" refusal and its test (`test_peakguard.py` part 10h) were
|
||
**re-aimed, not deleted** - `allocated=False` is now reachable on a mis-routed
|
||
claim, an empty envelope set, or sign incoherence, and all three are graded on
|
||
real cycles.
|
||
* **And the replay that missed it was widened, so the fix is graded rather than
|
||
merely applied.** The 250 000-tick replay drew every envelope as
|
||
`B(-uniform(0, rating), uniform(0, rating))` - **finite on both ends and
|
||
containing zero, every time** - while the check it fed claimed "no share ever
|
||
leaves the envelope it was clamped into". The data could only exercise a
|
||
symmetric closed interval around zero, so it reported 0 escapes against a live
|
||
defect. The generator now draws from **every shape `UnitBounds` can express**:
|
||
half-open above, half-open below, intervals that exclude zero, and bare
|
||
`UnitBounds()`. Measured A/B against the defective clamp on the same seed and
|
||
the same 250 000 ticks: **the old generator sees 0 escapes, the new one sees
|
||
39 991.** Reverting the clamp fix under the widened generator takes
|
||
`test_alloc.py` red on four checks; re-applying it takes it green. The
|
||
collapse is now a row in part 12's mutation table.
|
||
* **Known and not closed here:** at a roster of two the loop's `actual_w` is one
|
||
inverter's power used as a site measurement, because the add-on binds a single
|
||
`inverter_power` sensor. That is the *sensing* half of the same N=1 identity
|
||
this card removes from the *commanding* half, and it makes the aggregate trace
|
||
split-dependent below `gain: 1.0`. Measured, with the counterfactual, in
|
||
`test_alloc.py` part 8 and `tools/alloc_sim.py --splits`. It belongs to
|
||
`DEV-02`.
|
||
|
||
**`Claim` carries the `actuator` specs.md always said it did, and `resolve()`
|
||
is three-stage (ARB-03).** `specs.md` §6.1 declares `actuator: str` as the first
|
||
field of `Claim`; the shipped `app/arbiter.py` had no such field, so every claim
|
||
was implicitly about "the one and only actuator", `resolve()` returned one
|
||
scalar, and `main.py` assigned it to one `self.target`. **There was no seam an
|
||
allocator could occupy** - and `ARCH-01` decided the site grid target is
|
||
ALLOCATED across several inverters and never broadcast. This is a regression fix
|
||
against the written design, and it creates that seam. **At N=1 it is a pure
|
||
refactor: every one of the 17 suites replays unedited and green, on the same
|
||
numbers.**
|
||
|
||
* **The split, verified at each construction site rather than assumed.** Four
|
||
sources are about the **grid connection** and are SITE claims - `loop`
|
||
(regulates `grid_w`, the P1 measurement), `peak` (the quarter-hour average
|
||
offtake the capacity tariff bills), `arbitrage` (buys and sells at the grid
|
||
against a day-ahead price) and `safety` (`stopped` is the site's switch,
|
||
`inputs missing` is the site's own meter). Four are about **a pack, a device
|
||
or one write** and are PER-INVERTER - `device` (one inverter's own reported
|
||
rating), `supervised` (`max_w` caps what may be WRITTEN to one setpoint
|
||
entity), `failsafe` (**site-wide evidence, per-unit remedy**: SAFETY-02 grades
|
||
the command THIS controller put on THIS actuator, and at N>=2 that evidence
|
||
cannot be pinned on a sibling nobody here commands) and `maintenance` (§12's
|
||
recalibration is a pack procedure). The reasoning is recorded **at the line**,
|
||
in `arbiter.CLAIM_SCOPE` and again at each `claims.append` in `main.py`.
|
||
* **Getting it wrong is the parked-battery bug, so it is routed rather than
|
||
written.** A per-inverter reserve filed as a site `hi` against the peak guard's
|
||
site `lo` is `lo > hi`, arbiter clause 3, **0 W in the billed quarter** -
|
||
SAFETY-25 §0a's fatal case. `actuator_for()` is the one routing point and it
|
||
**raises `KeyError` on an unclassified source** rather than defaulting to the
|
||
site, because defaulting to the site is the failure itself.
|
||
* **Three stages, each a function a test can construct a case at.**
|
||
`pre_resolve()` turns one inverter's own claims into that unit's interval;
|
||
`site_resolve()` turns the site claims into one grid target and **is the
|
||
shipped arbiter, unchanged**; `allocate()` is **the seam, and deliberately not
|
||
an allocator** - `ALLOC-01` builds it. With at most one unit it passes the
|
||
target through (§6.2's N=1 identity); with two or more **it declines, no
|
||
shares, `allocated=False`**, because the obvious placeholder - hand the target
|
||
to each - IS the fault `ARCH-01` was filed about. **What `main.py` then does
|
||
is SUBSTITUTE, not abstain**: it replaces the resolution with a 0 W `failsafe`,
|
||
logs "refusing to broadcast the site target", and writes that 0 W through
|
||
`write_setpoint()` like any other cycle. The refusal is to BROADCAST; the
|
||
write still happens, in the fail-toward-inaction direction. Graded end to end
|
||
on a real two-inverter cycle by new **part 10h** - the branch is unreachable
|
||
on the shipped roster, and replacing it with `if False:` had left all 17
|
||
suites green.
|
||
* **`resolve()` HONOURS the roster it is handed, and that is now graded too.**
|
||
Left `None` the roster is derived from the claims, which is the documented
|
||
default; passed explicitly it is used. Deriving it *always* - ignoring the
|
||
argument - makes a unit that filed nothing this cycle cease to exist, so a
|
||
site of two reads as a site of one and the seam passes the whole grid target
|
||
through to the inverter that spoke: `ARCH-01`'s fault with its own evidence
|
||
removed. `test_arbiter.py` part 7 grades it on the one case that separates the
|
||
two, a **silent unit**.
|
||
* **The contradiction is no longer guarded against, it CANNOT BE WRITTEN DOWN.**
|
||
The partition is by `actuator` and it is a partition: a unit's `hi` and the
|
||
site's `lo` are never both terms of one `lo > hi` test. Constructed and swept -
|
||
40 filings (every per-inverter source x unit x bound, a unit contradicting
|
||
ITSELF included) against a live site floor, and not one moves the site target
|
||
or raises a contradiction. A unit's own impossible pair parks that unit and
|
||
says so on its own bounds; it does not park the site. That is SAFETY-25 §0c's
|
||
table made mechanical.
|
||
* **At N=1 nothing splits, and that is §6.2's identity rather than the
|
||
classification being ignored.** With one inverter the fabricated grid reading
|
||
and the battery setpoint are the same number, so the grid connection and the
|
||
only unit ARE one actuator and every source routes to the site. **Measured:
|
||
dropping that identity - splitting at N=1 - takes `test_peakguard.py`,
|
||
`test_freeze.py` and part 10g red**, because the `failsafe` and `maintenance`
|
||
SET claims would stop competing for the winner, `explain()` would name a
|
||
different source and `loop_is_following()` reads that name.
|
||
* **SAFETY-24's floor asymmetry survives, and now has a second half.** The
|
||
combination sweep is re-run and still holds - no negative floor, `hi` still
|
||
`+inf` on every claim the guard files - and it now also asserts every one of
|
||
them is on the **site** actuator, because a floor bound to one inverter's share
|
||
would leave the site, the thing the capacity tariff meters, with no floor.
|
||
* **The part 10f tripwire is kept and widened, not adapted around.** Its static
|
||
AST scan and its dynamic SoC sweep are untouched in substance - `source` is
|
||
still `Claim.set()`'s first positional argument precisely so the scan keeps
|
||
reading it, which is why `actuator` is the LAST field of `Claim` and not the
|
||
first. Its spy gained `**kw` so the roster passes through unread rather than
|
||
being silently dropped. New **part 10g** pins `CLAIM_SCOPE` to the same
|
||
eight-source set, so a ninth source now goes red twice. Verified by breaking
|
||
it: building SAFETY-25's Option B as a ninth source still takes 10f red on the
|
||
line it is written.
|
||
* **And the CALL SITES are pinned statically, at all eight of them.** 10g's
|
||
first cut graded routing only through a **real two-inverter cycle** and called
|
||
that "the line a mis-typed `actuator_of(...)` at a call site goes red on".
|
||
**It was not.** That cycle reaches `device`, `supervised` and `loop` and
|
||
nothing else - three of the eight direct appends - so a mis-type at the other
|
||
five survived every suite in this repository. Proved on the shipped tree by PR
|
||
#40's reviewer: `actuator_of("maintenance")` -> `actuator_of("peak")` at the
|
||
maintenance-shaping append left `test_arbiter.py`, `test_peakguard.py` and
|
||
`test_arbitrage.py` all green while, at a roster of two, it built a site loop
|
||
of -1500 W against the guard's site `lo` of 1500 W and the now-misrouted
|
||
charge-only `hi = 0` - `lo > hi`, arbiter clause 3, **0 W in the billed
|
||
quarter**, SAFETY-25 §0a's fatal case from a one-word typo. That is this
|
||
repository's named failure mode - **a check observing something other than
|
||
what it claimed** - reproduced inside the check written to prevent it, so it
|
||
is closed the way 10f closes it: over the **same AST scan**. All eight direct
|
||
appends expose their actuator as a literal `actuator=self.actuator_of('...')`,
|
||
so the literal is required to EQUAL the source the claim is filed under - all
|
||
eight, on the line the typo is written, in every configuration and at every
|
||
roster, whether or not any fixture reaches the state that files them.
|
||
**Measured: each of the eight mutated to a wrong actuator in turn, each one
|
||
red.** The two INDIRECT appends (`peak_claim`, `arb_claim`) carry no
|
||
`actuator=` at the call site to mis-type, and are pinned where their routing
|
||
is actually chosen - `peakguard.claim()` by the `SWEEP_SITE` sweep,
|
||
`arbitrage.claim()` by `test_arbitrage.py`. 10g's dynamic cycle is kept and
|
||
now says which three of the eight it sees instead of claiming the class.
|
||
|
||
**SAFETY-25's open question is ANSWERED: the BMS cut-out is the DELIBERATE and
|
||
only backstop for demand-driven discharge, and a loop-side reserve floor is
|
||
DEFERRED to `ALLOC-01` rather than rejected (SAFETY-25).** Decided by the
|
||
product owner on 2026-09-02. **Option A is the shipped behaviour, so `app/`
|
||
changed only where a comment claimed the question was still open** - no
|
||
setpoint, no claim, no option, no default moved, and the 17-suite run is green
|
||
on the same numbers as before.
|
||
|
||
* **Why deferring is the safe direction, and it is a property of the shape
|
||
rather than a preference.** Option B's proof - `bms_soc_floor <
|
||
loop_soc_floor <= peak_soc_floor`, verified through the real arbiter in
|
||
`test_peakguard.py` part 10d - has an unstated premise: the reserve predicate
|
||
and `discharge_permitted()` read the **same** state of charge. With two packs
|
||
they read different numbers. The reserve binds on the emptier pack and files
|
||
`hi = 0` while the peak guard, reading the other pack or an average, files
|
||
`lo = excess > 0`; `lo > hi` is arbiter clause 3 and the answer is **0 W, the
|
||
battery parked in the billed quarter** - the failure the memo itself marks
|
||
fatal. **No config-ordering validator can catch it**, because it is a runtime
|
||
divergence between two readings and not a config ordering, so CFG-01's shape
|
||
of check would pass on the exact tree that parks the battery. Option A's cost
|
||
is energy; Option B's cost built too early is a parked battery in the one
|
||
quarter of the month that sets the bill.
|
||
* **The revisit trigger is recorded explicitly, and it is `ALLOC-01`.** Under
|
||
`ARCH-01`'s host-side allocator the two claims stop sharing a list: the loop
|
||
floor becomes a **per-inverter `hi` consumed as an interval at stage A**,
|
||
while the peak floor stays a **site `lo` at stage B**. Different claim lists,
|
||
different stages, **structurally unable to cross** - which is a stronger
|
||
argument than the ordering rule, because it makes the crossing unreachable
|
||
rather than harmless. That is the shape in which Option B becomes safe to
|
||
build, and the memo says so at the point a future reader will look.
|
||
* **What will have to be RE-EARNED at N=2 is stated plainly rather than left
|
||
for someone to discover.** `SAFETY-27` now names **five floors across two
|
||
packs**, so "the SoC" stops being a scalar the config can bound. And
|
||
`test_peakguard.py` **part 10f - the pin that made SAFETY-25 mergeable** -
|
||
**cannot express two packs**: its dynamic half is a scalar 0-100 % SoC sweep
|
||
against a single-series capture, and a two-pack exposure lives in the
|
||
*difference* between two readings, which that sweep visits at no resolution.
|
||
Its static half (the AST scan pinning the eight claim sources) still bites at
|
||
N=2; the dynamic half goes silent. Merging a two-pack reserve on 10f's
|
||
current green would be merging on a check that cannot see the failure.
|
||
* **The options analysis is kept intact on purpose.** A memo that recorded the
|
||
verdict and deleted the reasoning is the failure mode this `DECISIONS/`
|
||
directory exists to prevent, so §1-§7 stand exactly as written - including
|
||
§5's *"(recommended)"* on Option B and §6's recommendation - with the new §0
|
||
saying what was decided and §6 marked as answered. Part 10e's check moved
|
||
with the memo: it used to pin the honest `Status: OPEN` line and now pins
|
||
that the memo names the option, the date and the trigger **and still carries
|
||
both options and the recommendation**.
|
||
* **The tripwire stays, and "deferred" is why it stays.** Part 10 and mutation
|
||
13 are unchanged in substance: a ninth claim source in `Controller.cycle()`
|
||
still takes 10f red on the line it is written. Deferred, not rejected, is
|
||
precisely the state in which someone builds Option B by accident.
|
||
|
||
Operator-facing surfaces follow the decision and nothing else changed on them:
|
||
`DOCS.md`'s SAFETY-25 section now says a choice was made and points at the
|
||
trigger, and `app/freeze.py`'s finding and `app/peakguard.py`'s point 2 say
|
||
"decided, on purpose" where they said "open".
|
||
|
||
**`PeakStore.record_block()` now has its POLICY ARGUMENT graded, and the
|
||
SAFETY-20 import check now looks at all three ways of writing an import
|
||
(TEST-13).** Two survivors from TARIFF-01's review, both closed in
|
||
`test_tariff.py` only - `app/` is untouched and no shipped behaviour changed.
|
||
|
||
* **The plumbing of the per-phase reduction was unobservable.** Mutating
|
||
`record_block()`'s `billed_w(block, policy)` to `billed_w(block, POLICY_NET)`
|
||
left all 17 suites green (+4 B; recorded by TARIFF-01's reviewer, re-run
|
||
through the purge harness for TEST-15, and re-run again here). The cause is
|
||
the third instance of the same shape as WITNESS-02's span normalisation and
|
||
TARIFF-01's own DST window: **a stimulus evaluated where the quantity it
|
||
grades is degenerate.** `billed_w()` short-circuits to `block.offtake_avg_w`
|
||
whenever `per_phase_offtake_avg_w` is absent, and every block that reached a
|
||
store anywhere in the suite was single-phase or built with
|
||
`per_phase_offtake_avg_w=None` - so NET, SUM and MAX collapsed onto one
|
||
number and the argument could not be seen. Section 7 already owns an
|
||
UNBALANCED fixture, `(3000, 500, -200) W`, whose three reductions are 3300 /
|
||
3500 / 3000 W; that block is now round-tripped **through the store** under
|
||
each policy and read back from both tables. NET is the smallest of the three
|
||
here, so a store that quietly billed NET while asked for MAX would
|
||
under-record the month's peak - maximum headroom for SAFETY-07, the same
|
||
under-guarding direction as the `getattr(..., 0.0)` default SAFETY-07 already
|
||
removed from `project()`.
|
||
* **The import half of the SAFETY-20 grader made a claim it did not check.** It
|
||
collected `ImportFrom.module` only, which is blind to two of Python's three
|
||
import spellings: `from . import arbiter as _ab` is an `ImportFrom` whose
|
||
`.module` is **None** (the module name is in `.names`), and `import
|
||
app.arbiter` is an `ast.Import` node the walk never visited. Both were added
|
||
to `app/tariff.py` and the shipped suite stayed **green**. Never a live
|
||
exposure - the `_names` half still catches any USE of either alias - but the
|
||
printed sentence "it imports nothing from arbiter, clamp or control" was
|
||
false for two forms out of three, which is worse than claiming less. The
|
||
grader now walks `Import` as well, joins `from . import X` onto its base, and
|
||
matches per dotted SEGMENT so `app.arbiter` cannot hide behind a package
|
||
prefix while `clamps` / `arbiters` / `controller` are not falsely flagged.
|
||
Both graders are also now shown non-vacuous against `main.py`, which really
|
||
does import all three modules.
|
||
* **Two items on the card were already closed and are struck, not
|
||
re-implemented.** `PeakReading.is_observed` is `self.state == OBSERVED`
|
||
(`app/tariff.py:266`), already false for `NO_RECORD`; and `project()`'s
|
||
`held` read is `max(averager.held_net_w, 0.0)` with **no** `getattr` default,
|
||
fixed by SAFETY-07 in 616b62d with a comment naming TEST-13 item 4. Restoring
|
||
a default there would re-open a money guard. Removing `clear_partial()` also
|
||
stays green and that remains a **PASS**: the `quarter_start` PRIMARY KEY plus
|
||
`ON CONFLICT ... WHERE excluded > existing` makes a replay a structural
|
||
no-op, which is TARIFF-01's claim rather than a hole in it.
|
||
|
||
Verified through `tools/mutation_run.sh` (purge, `python3 -B`, tree re-read from
|
||
disk, mutated line grepped off disk, byte delta recorded): 7 mutations, all red
|
||
on the named checks, plus green baselines before and after and a control run
|
||
showing the shipped suite passing every one of them.
|
||
|
||
**The two `arbitrage()` rungs SAFETY-26's AC4 sweep found pinned from ONE SIDE
|
||
ONLY are now pinned from both - and, the actual finding, are pinned so they stop
|
||
MASKING EACH OTHER (TEST-16).** `spread == 0.0` weakened to `<= 0.001` survived
|
||
the whole suite at +2 B, and `len(firm) < 2` weakened to `< 1` survived at
|
||
+0 B - the delta MUTATION.md names as the risky one. Neither was individually
|
||
live, and that was the problem: a single firm hour *also* has a spread of zero,
|
||
so each rung's failure was absorbed by the other and both could be weakened
|
||
together with nothing reporting it. That is invisible to single-mutation
|
||
testing, which is how the pair survived four rounds of review on TARIFF-03 and
|
||
FCST-03. `test_static_tariff.py` gains section 9, which grades WHICH RUNG
|
||
answered each stimulus rather than only whether it refused - the masked case
|
||
refuses correctly while reporting the false sentence "the tariff is flat" about
|
||
a series that is not flat, so a check asserting only "refused" passes on a
|
||
broken tree. The flat rung is swept with small-but-REAL spreads down to
|
||
0.000001 EUR/kWh at `min_spread_eur_kwh=0.0` (a value `arbitrage()`'s docstring
|
||
documents and its validator accepts on purpose), the firm-pair rung is probed
|
||
with a ONE-FIRM-HOUR series and graded on §8.3's sentence coming back rather
|
||
than the flat rung's, and a five-row table denies the masking on both sides.
|
||
Verified through TEST-15's `tools/mutation_run.sh` unchanged - recursive
|
||
`__pycache__` purge, `python3 -B`, tree re-copied from disk between rounds,
|
||
mutated line grepped off disk, byte delta printed: 7 mutations plus a green
|
||
baseline, 4 of them at +0 B, ALL RED, including both rungs weakened AT ONCE
|
||
(5 checks red). **No shipped behaviour changed - both rungs were correct all
|
||
along**; `app/` is untouched. `MUTATION.md` gains a section on masking as a
|
||
second way a survivor lies, and `test_arbitrage.py`'s SAFETY-26 AC4 note is
|
||
marked closed rather than left reading as an open finding.
|
||
|
||
**`check_output_clamp()` could be moved above `write_setpoint()` in `cycle()`
|
||
with test_clamp.py, test_freeze.py and test_loop.py all staying GREEN — the
|
||
guard's write-path evidence, `self.clamp_last`, had nothing watching it.**
|
||
Measured before the fix, on the move itself: all three suites green. The guard
|
||
has two evidence sources and only one of them was covered. `self.loop_clamped`
|
||
is set from the `Decision` well above the write, so it survives the move — and
|
||
on the §10.3 runaway replay it is the source of every engagement the alarm
|
||
reports (0 write-path engagements against 2 loop-side ones), which is precisely
|
||
what made the move nearly invisible. `self.clamp_last` is assigned by
|
||
`write_setpoint()` and by nothing else (`main.py`), is `None` until it has run,
|
||
and is what `engaged_write` reads; moved above the write, that consumer goes
|
||
quietly false and a DEVICE-bound clamp — `max_w` above the inverter's own
|
||
rating, SAFETY-20's install — stops being reported by anything.
|
||
test_clamp.py gains part 6, and it grades the CONSEQUENCE rather than the
|
||
position: a spy subclassed from the shipped `ClampWatchdog` records what
|
||
`self.clamp_last` held each time the guard consulted it, over a slew-limited
|
||
ramp where every cycle's command is a different number, and asserts the
|
||
evidence is always the write of THAT cycle. Hooking `clamp_wd.note()` rather
|
||
than the guard's name is what keeps a rename legitimate; the same assertions
|
||
are re-run through a controller whose guard is renamed and whose write lives in
|
||
a helper, and were verified against `main.py` itself — renaming the method
|
||
throughout and moving both calls into a new `_emit_and_guard()` helper keeps
|
||
part 6 green, while swapping the two calls turns it red on a line that names
|
||
`clamp_last`. AC 3 needed a stimulus of its own: through the real `cycle()` the
|
||
write-path half can never engage, because the arbiter files the device range
|
||
and `max_w` as P_SAFETY LIMIT claims and `resolution.target_w` is already
|
||
inside both bounds before `write_setpoint()` re-applies them — that duplication
|
||
is deliberate defence in depth, and part 6 now measures the 0 engagements
|
||
rather than leaving it as a claim in a comment. So the write path is driven
|
||
directly at `max_w` 5000 against a ±2500 W device: the wire carries 2500 W,
|
||
every loop-side flag stays clean, and the alarm fires on `clamp_last` alone —
|
||
with the two calls swapped, the same stimulus clamps the same value and the
|
||
alarm is silent. `main.py` gains one comment sentence pointing at the check,
|
||
mirroring the one SAFETY-18's block already carries for test_freeze.py part 5.
|
||
|
||
**`max_w` and the `LIVE_MAX_W` literal beside it were unpinned in test_freeze.py
|
||
and test_tariff.py, the same gap TEST-06 closed one key over for
|
||
`meter_freeze_s` (TEST-10).** Measured before the fix: `max_w: 2000 -> 4500`
|
||
in `config.yaml` left every check in both files GREEN, and `LIVE_MAX_W = 5000.0`
|
||
was a bare literal that silently disagreed with the shipped default (2000) with
|
||
nothing saying so — for contrast, `deadband_w: 15 -> 5` already went RED 2 + 1,
|
||
so the pinning technique itself was known to work, just not applied here.
|
||
Fixed three ways. First, `test_freeze.py` gained a check comparing `Tuning()`'s
|
||
code default (`control.py`, 2000.0) against `SHIPPED_MAX_W`, read from
|
||
`config.yaml` by extending the file's existing `_cfg_default()` regex reader
|
||
rather than reimplementing test_arbitrage.py's `_shipped`. Second, the literal
|
||
itself is renamed `LIVE_INSTALL_MAX_W` in both files, with a comment stating
|
||
plainly that 5000 is the live install's own override and not config.yaml's
|
||
shipped `max_w` — the defect was the silent literal, not the number. Third,
|
||
`test_freeze.py` now states `PINNED_KEYS = ("meter_freeze_s", "deadband_w",
|
||
"max_w")` in one place as the complete set of config keys this suite pins,
|
||
deliberately excluding `maintenance_*` (CFG-03 gave those a separate source of
|
||
truth in `app/maintenance.py`). Verified by breaking what the new check
|
||
watches: `max_w: 2000 -> 4500` in `config.yaml` now fails exactly
|
||
`"the code default and the shipped default agree on max_w too"` and nothing
|
||
else, and reverting returns the file to all-green.
|
||
|
||
**Maintenance option defaults lived in three places that had already disagreed,
|
||
and MAINT-04's tripwire was holding them apart rather than closing them
|
||
(CFG-03).** `config.yaml` shipped `maintenance_charge_w: 2000`, `main.py` carried
|
||
two `opts.get("maintenance_charge_w", 2500)` fallbacks, and `MaintConfig`'s
|
||
dataclass carried a third `2500.0`. An options dict with the key **absent** was
|
||
therefore accepted by `validate.py`'s rule 6 — which read 2000 — and then built
|
||
by `main.py` at 2500, against a 2000 W ceiling. Demonstrated through the shipped
|
||
code:
|
||
|
||
options dict with maintenance_charge_w ABSENT, max_w=2000
|
||
validate_startup_config -> [] (ACCEPTED, no problems)
|
||
MaintConfig().charge_w -> 2500.0
|
||
arbiter.resolve() -> 0.0 W, contradiction=True
|
||
|
||
That is the bug rule 6 exists to refuse, **passing rule 6**. Unreachable through
|
||
the Supervisor, which always supplies every key from `config.yaml`; fully
|
||
reachable from `sim/inject.py` and from the test suite, which is where anyone
|
||
debugging maintenance is.
|
||
|
||
**`maintenance_discharge_w` had the identical divergence** (`config.yaml` 2000,
|
||
`MaintConfig` 2500) and the card named only the charge side. Closing one key and
|
||
leaving its twin would have reproduced the defect one line down, so both are
|
||
closed.
|
||
|
||
**The single source of truth is `config.yaml`'s `options:` block** — the file the
|
||
Supervisor reads and the operator edits. There is now exactly **one** in-code
|
||
copy, `maintenance.MAINT_OPTION_DEFAULTS`, which is TARIFF-02's
|
||
`PRICE_OPTION_DEFAULTS` and SAFETY-24's `PEAK_OPTION_DEFAULTS` pattern applied to
|
||
the family that named the card. A mirror rather than a runtime read because the
|
||
`Dockerfile` copies `app/` and `run.sh` into the image and **not** `config.yaml`:
|
||
the running add-on cannot open the file it is configured by. `test_maintenance.py`
|
||
pins every entry by **reading** the shipped file, so a drift is a CI failure
|
||
rather than a comment. Every other declaration site now derives from it or has
|
||
ceased to exist:
|
||
|
||
- `MaintConfig`'s eight dataclass defaults are indexed out of the mirror — no
|
||
numeric literal survives in the class that held the third opinion;
|
||
- `MaintConfig.from_options()` is the **only** place in the add-on that reads a
|
||
`maintenance_*` option. `main.py`'s eight inline `opts.get(key, <literal>)`
|
||
calls are gone;
|
||
- `main.py`'s second fallback site (the cheap-window charge floor) reads
|
||
`self.maint.cfg.charge_w` instead of re-opening the options dict, so the rate
|
||
the shaping asks for and the rate the state machine drains at cannot be two
|
||
numbers from one install;
|
||
- `validate.py`'s rule 6 and its `maintenance_discharge_w` warning take their
|
||
fallbacks from the mirror.
|
||
|
||
**Where the RANGE lives is now stated and checked, so the next person does not
|
||
add a fifth site.** `maintenance_charge_w: int(500,5000)` in `config.yaml`'s
|
||
`schema:` block is a fourth declaration of a different kind — a bound, not a
|
||
default. Bounds live in the schema block **only**: the Supervisor enforces them
|
||
and reads only that file. `test_maintenance.py` asserts that every mirrored key
|
||
has a schema entry (or the Supervisor silently drops the option and the add-on
|
||
runs on the mirror instead of the operator's value) and that every shipped
|
||
default lies **inside** its own range. What the schema grammar cannot express is
|
||
a relation *between* keys — `maintenance_charge_w` against `max_w` — which is
|
||
why rule 6 is written in Python and does not belong in the mirror either.
|
||
|
||
**MAINT-04's tripwire in `test_validate.py` is DELETED, not left passing**, as it
|
||
and `validate.py`'s ⚠️ block both instructed each other to do. Its passing
|
||
condition *was* the bug. In its place, the same four consumers exercised the same
|
||
way and now agreeing: the validator accepts a dict with every `maintenance_*` key
|
||
missing, a **real `Controller`** built from that dict holds 2000 W, `MaintConfig()`
|
||
answers 2000 W with no dict at all, and the real arbiter emits `-2000 W, limited
|
||
by supervised(max_w)` instead of `0 W, contradiction=True`.
|
||
|
||
**Checked by breaking it, five ways, each observed red before being reverted:**
|
||
move `config.yaml` alone → the mirror pin fails; move `config.yaml` **and** the
|
||
mirror to 1500 → every consumer follows together (`MaintConfig()` 1500 W, the
|
||
`Controller` 1500 W, the arbiter `-1500 W, limited by maintenance(cheap-window
|
||
charge)`) and both suites go green again; retype `charge_w: float = 2500.0` into
|
||
the dataclass → red; restore `opts.get("maintenance_charge_w", 2500)` in
|
||
`main.py` → red; delete a key from `config.yaml`'s options block → red.
|
||
|
||
Stale prose that asserted the closed divergence as current is corrected in
|
||
`app/watchdog.py`, `test_watchdog.py` and `test_peakguard.py`.
|
||
|
||
**`peak_soc_floor` reads like a pack-wide reserve and is not one, and on a
|
||
sustained-load evening the grid-following loop discharges straight past it to
|
||
the BMS cut-out (SAFETY-25).** The gap has been written down four times without
|
||
being filed — SAFETY-18's finding (`freeze.py`), SAFETY-24's module note, its PR
|
||
body, and its honestly-uncaught 13th mutation — and never once measured. It is
|
||
measured now, on the committed 2026-08-20 capture, driving the shipped
|
||
`control.compute()`, `peakguard.decide()`, `peakguard.claim()` and the real
|
||
`arbiter.resolve()`:
|
||
|
||
19:00-22:00 UTC, 830 W mean, minimum 701 W, not one watt of export
|
||
reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
|
||
above peak_soc_floor
|
||
|
||
ends at 10.0 % - the bms_soc_floor cut-out
|
||
1525 Wh delivered by the LOOP over 587 cycles
|
||
549 cycles below peak_soc_floor, 343 of them still discharging
|
||
0 cycles on which the peak guard filed any floor at all
|
||
|
||
**And it is a behaviour, not a config value.** The same evening ends at
|
||
`9.9715 %` at `peak_soc_floor` = 5, 11, 20 **and** 60 — the shipped value and
|
||
both ends of the schema range — identical to four decimal places. The option an
|
||
operator would reach for cannot move the number by any amount. On the same day's
|
||
high-load block the two states coexist: 49 cycles reporting `PEAK GUARD
|
||
FLOOR-LIMITED` and, on **all 49**, the arbiter simultaneously granting the loop
|
||
up to 2000 W of discharge.
|
||
|
||
**Why SAFETY-24 could not close it.** The peak guard files a `lo` limit — a
|
||
*lower* bound. That asymmetry is what makes it provably unable to lower a target,
|
||
cap a discharge or contradict the arbiter, and it is therefore equally unable to
|
||
stop a discharge the loop itself wants. A floor on the guard cannot become a
|
||
floor on the pack by being raised.
|
||
|
||
**What changed is the honesty, on four operator-facing surfaces**, because that
|
||
half is required whichever way the open question is answered and it is the actual
|
||
misstatement on the live system today:
|
||
|
||
- `config.yaml` says next to the option that it **bounds the peak guard only** and
|
||
is **not a pack-wide reserve**, with the replayed evening and the sweep;
|
||
- `DOCS.md` says it in the options table and in a new section carrying the
|
||
figures above;
|
||
- the **status-page row** for a floor-limited guard carries the scope sentence —
|
||
it is the one line a human reads at the moment the guard stops, and read alone
|
||
it implies a reserve the replay shows does not exist;
|
||
- the **persistent notification** says what has stopped (the guard buying a lower
|
||
bill) and what has not (the pack being spent).
|
||
|
||
`peakguard.py` and `freeze.py` carry the measurement beside the findings they
|
||
already recorded.
|
||
|
||
**The product decision is NOT taken here.** Whether the loop gets a reserve floor
|
||
of its own moves real energy on this house and belongs to the owner.
|
||
`DECISIONS/SAFETY-25-loop-soc-floor.md` records the options, what each costs and
|
||
protects, weighed against MAINT-01 and against the fact that this would be the
|
||
**fifth** SoC key (OPT-04's `arbitrage_soc_floor` fired SAFETY-24's "revisit if a
|
||
third floor appears" trigger unremarked), and a recommendation. It also records a
|
||
trap found while writing it and now checked through the real arbiter: the obvious
|
||
shape — a `hi` limit on the loop — crosses the peak guard's `lo` and resolves to a
|
||
**CONTRADICTION at 0 W**, the battery parked in the quarter that sets the bill,
|
||
unless `bms_soc_floor < loop_soc_floor <= peak_soc_floor` is enforced at startup.
|
||
|
||
**`test_peakguard.py` part 10 is a tripwire, not a guarantee, and part 10f is
|
||
what makes it one.** The replay half records the defect; on its own it could not
|
||
see the defect being FIXED, because `replay_loop()` hand-builds main.py's claim
|
||
list and is therefore blind to any claim main.py adds — which is exactly where a
|
||
loop-side floor would be built. PR #31's reviewer proved that by building the
|
||
memo's own Option B in `main.py` and watching the whole suite stay green. **10f
|
||
pins the claim-source set:** every `claims.append(...)` in `Controller.cycle()`
|
||
is read off its AST and must be one of eight named sources, and real
|
||
`Controller.cycle()`s — spied at main.py's own `resolve`, swept over the whole
|
||
0–100 % state-of-charge range in both the idle and the floor-limited state — must
|
||
hand the arbiter exactly `{device, supervised, loop}` and
|
||
`{device, supervised, loop, peak}`. A ninth source, at any state of charge, in
|
||
any configuration, takes part 10 red on the line it is written. A floor built
|
||
inside `control.compute()` or the guard instead moves 10a–10c's replayed numbers,
|
||
which the replay drives directly. So the exposure cannot change size under a
|
||
green suite.
|
||
|
||
**Nine mutations, re-run against the fixed tree**, each against a freshly copied
|
||
tree with no `__pycache__` present at all, mounted `:ro` — the same structural
|
||
immunity `MUTATION.md` records for CI. Six are the AC 3 and behaviour mutations
|
||
already on this ticket's record (the status row's scope sentence, the
|
||
notification's, `config.yaml`'s, the memo deleted, the loop's gain zeroed, the
|
||
guard's claim given a `hi`); two are the reviewer's, and are the ones that
|
||
motivated 10f — the memo's own Option B at its recommended default, **now three
|
||
checks red where it was fully green**, and an absurd always-binding 95 % reserve,
|
||
**six red**; one is new, against the banner short form below. All nine are red,
|
||
no survivors, and no byte delta is `+0 B`.
|
||
|
||
**The gate has never once graded the content a pull request would land, and a
|
||
run that never started reported as a test failure (CI-02).** Both halves were
|
||
measured on a deliberately conflicted throwaway pull request rather than read
|
||
off the workflow, because the Actions log endpoints 404 on this Gitea and a
|
||
step's own conclusion is the only channel out of a job.
|
||
|
||
**Case 1, and the ticket's mechanism was wrong in a way that made it worse.**
|
||
CI-02 was filed saying a conflicted pull request loses its merge ref, so that
|
||
`git fetch $GITHUB_REF || git fetch $GITHUB_SHA` falls through and grades the
|
||
bare branch head. The symptom was real — PR #14 reported `checks / unit ->
|
||
success` while `mergeable: false` — but the mechanism is not. On this server
|
||
`GITHUB_REF` **is** `refs/pull/<n>/head`, `GITHUB_SHA` is the same commit, and
|
||
`git ls-remote origin 'refs/pull/*'` advertises no `/merge` refs at all, for
|
||
open and closed pull requests alike. The first fetch has always succeeded; **the
|
||
fallback has never fired once.** So the gate did not lose merged content on
|
||
conflicted pull requests — it has never had merged content, on any pull request
|
||
in this repository's history. Green has meant "this branch passed on its own",
|
||
which on a base that has moved is not what lands, and `block_on_outdated_branch`
|
||
is off on release/1.0 so the base usually has moved.
|
||
|
||
**The checkout now fetches the base tip and the pull request head, merges them,
|
||
and refuses if the merge does not apply** — as its own named step, so
|
||
`/actions/runs/<id>/jobs` distinguishes "nothing was tested" from "the tests
|
||
spoke". Verified by producing it: with the fix on a conflicted pull request the
|
||
required context went `failure` in 2 s with the two test steps `skipped`. The
|
||
`--depth 1` is gone (a merge needs a merge base; a full fetch of this repository
|
||
is 6.8 MB and 1.1 s), and the fallback survives only on the `workflow_dispatch`
|
||
path, which has no pull request to merge and now says so in its output.
|
||
|
||
**Case 2: `concurrency:` is gone, and `cancel-in-progress` was never the knob.**
|
||
Runs 531 and 533 on PR #14 were cancelled at 0 s each at the exact second the
|
||
next run was created, having never started; a cancelled run posts `failure` on
|
||
the required context, described `Canceled after 0s`, and branch protection
|
||
compares state and not description — so "nothing ran" and "your tests are
|
||
broken" arrive at the gate as the same red, wanting opposite responses. Three
|
||
settings were measured with one run holding the single runner slot and two
|
||
pushes behind it: with **no block at all** both queued runs survived and
|
||
executed; with the group kept and **`cancel-in-progress: false`** the queued run
|
||
was still cancelled at 0 s. A shared group cancels a run that has not started
|
||
regardless of that flag. Having no group is the only configuration measured to
|
||
leave a queued run alive, and it is what ships. The cost — a developer who
|
||
pushes four times occupies the shared runner four times — is CI-01's own trade
|
||
written the other way round: a delayed verdict, never a destroyed one.
|
||
|
||
**`test_ci_gate.py` grades the gate by running it, not by reading it.** It
|
||
extracts the checkout and merge steps out of `checks.yml` and executes the
|
||
shipped commands against two throwaway local repositories it builds — one whose
|
||
pull request merges, one whose does not — and pins that the old one-liner exits
|
||
0 on the unmergeable one while the new steps refuse. It also pins the two tokens
|
||
that assemble `checks / unit (pull_request)`: renaming `name:` or the `unit:`
|
||
job key would leave every open and future pull request waiting forever on a
|
||
context that never arrives, with no error anywhere in that sequence. Seven
|
||
mutations of `checks.yml` were each confirmed red. `.gitea/ci/Dockerfile` gains
|
||
`git` for it.
|
||
|
||
**What was NOT established, stated so nobody reads more into this.** The
|
||
original report that "no run was ever created for `0942228d`" was already
|
||
corrected on the card — run 533 was created and cancelled — and this work
|
||
confirms that from the runs list and the per-commit status history. A commit
|
||
that never had a run carries an empty `/statuses/<sha>` list; a queued one
|
||
carries `pending / Waiting to run` within seconds, and a concurrency-held one
|
||
`pending / Blocked by required conditions`. Those are distinguishable at the
|
||
API. `/actions/tasks`, which is what was polled originally, lists only tasks
|
||
that started, and cannot answer the question at all.
|
||
|
||
**The NOT READY banner no longer repeats the row underneath it.**
|
||
`status_payload()` renders the first hard-failing check as the page headline, so
|
||
the scope sentence above turned the loudest element on the page into a
|
||
550-character paragraph that the checks list then repeated verbatim. A row may
|
||
now carry an optional short `banner` form; the floor-limited row does, and its
|
||
headline is the guard's own line plus a 68-character scope tag. **The row itself
|
||
is unchanged** — the checks list is where an operator reads what the floor does
|
||
and does not bound, and shortening that would have been weakening AC 3. Rows
|
||
without the key are unaffected, and the page still renders with `textContent`.
|
||
|
||
**A setpoint device smaller than `max_w` silently disarmed the frozen-meter
|
||
detector, and the status page reported it as armed (SAFETY-20).** SAFETY-18's
|
||
conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the emitted command is
|
||
clipped to the setpoint device's own `[dev_min, dev_max]` range as well as to
|
||
`max_w` — in `clamp_to_rating()` and again as the arbiter's `device` P_SAFETY
|
||
limit claim. Measured on the shipped predicate at the ticket's numbers,
|
||
`max_w: 5000` on a ±4700 W inverter:
|
||
|
||
looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000) -> False
|
||
|
||
The command is pinned at the hardware's rail, the meter has been frozen for an
|
||
hour, and term 2 is unsatisfiable by construction because 4700 < 4750. Any
|
||
rating at or below `0.95 * max_w` disarms the conjunction outright. The page
|
||
then printed `armed (… command is over 4750 W)` — a guard that cannot fire,
|
||
advertising that it can, which is the failure SAFETY-15, SAFETY-19 and
|
||
validate.py rule 5 all exist to prevent.
|
||
|
||
**The rail is now measured against `min(max_w, |dev_min|, |dev_max|)`** —
|
||
`freeze.rail_ceiling_w()`, threaded through `looks_frozen()`,
|
||
`FreezeWatchdog.evaluate()` and both call sites in `main.py`. An unread rating
|
||
(`hass.limits()` returning `None` when ESPHome is not up) leaves the old
|
||
behaviour exactly, and correctly: on that path no device clamp is applied
|
||
either, so `max_w` really is the ceiling. A rating *larger* than `max_w` cannot
|
||
raise the rail — this is a `min()`, not a swap. The status row prints the rail
|
||
the predicate actually uses and names the device when the device is what capped
|
||
it; the log line and the HA notification do the same, since they outlive the
|
||
page.
|
||
|
||
**The smaller of the two device bounds, not a per-sign pair.** A per-sign rail
|
||
is tighter and is not wrong, but it is two rails needing two coverage verdicts
|
||
on a status row that carries one number, and the only configuration that
|
||
distinguishes them is refused at startup anyway. The symmetric ceiling can only
|
||
*lower* the rail, which widens term 2 rather than disarming it; term 1 carries
|
||
the no-false-positive argument and is untouched.
|
||
|
||
**A ceiling of 0 W is NOT COVERED, not "armed at 0 W".** A device that can
|
||
command nothing in one direction would make `abs(command) >= 0` true of every
|
||
command including a parked one — term 2 degenerately *true*, collapsing the
|
||
conjunction into the bare `unchanged_s > N` threshold TEL-05 refused for
|
||
false-tripping at this controller's own operating point. `rail_unreachable()`
|
||
returns the reason, `looks_frozen()` stays silent, and the page uses the NOT
|
||
COVERED row the unsupported-transport case already has — a second reason to use
|
||
that surface, not a new mechanism.
|
||
|
||
**AC4, decided rather than assumed: no new `validate.py` rule.** The
|
||
configuration is `max_w` above the device rating, which rule 2 (CFG-01) already
|
||
refuses on the identical predicate, re-run by `run_control()` once the real
|
||
rating is known. A rail-specific rule would fire on a strict subset of what rule
|
||
2 refuses (`0.95 * max_w > dev_max` implies `max_w > dev_max`) and could never
|
||
be the rule that stopped a start. The residue is the state where `hass.limits()`
|
||
answered `(None, None)` and the range checks were skipped — there is nothing to
|
||
refuse there, by construction, which is why the answer is a runtime row. Written
|
||
into validate.py's "deliberately not refused" list, and *pinned* by four checks
|
||
in `test_validate.py` so the decision's premise goes red if rule 2 is ever
|
||
softened back to a warning.
|
||
|
||
**And it was verified by breaking it.** Reverting `rail_w()` to bare `max_w`
|
||
fails nine checks; deleting the NOT COVERED handling fails five; and — the one
|
||
that mattered — shipping the corrected predicate while *not* passing the rating
|
||
from `check_meter_frozen()` originally left the whole suite green, because
|
||
nothing graded the caller. `test_freeze.py` part 5b now drives the real
|
||
`cycle()` on a ±4700 W device and asserts on the notification that leaves the
|
||
process; the unwiring mutation fails three checks there. The peak-guard call
|
||
site's wiring is still ungraded — it sits behind `p1_enabled` and needs a fed
|
||
averager to have any observable consequence — and that gap is named in the file
|
||
rather than implied.
|
||
**A mutation run could grade code that was not there, and the fix is a purge
|
||
rather than a flag (TEST-15).** FCST-03's developer reverted `self.provisional`
|
||
to `here.provisional`, re-ran, and reported a survivor; disassembly showed the
|
||
source read `here` while the running code loaded `self`. CPython validates a
|
||
`.pyc` on (source size, source mtime truncated to whole seconds), and a
|
||
size-preserving edit reverted inside one second moves neither number, so the
|
||
stale bytecode was reused. That does not make past results wrong - it made them
|
||
unverified, and the distinction is the whole card.
|
||
|
||
Reproduced rather than reasoned about. `test_bytecode.py` builds a real package
|
||
whose two return values are the same length, imports it in a real subprocess,
|
||
rewrites it inside the same second and reads back which value ran. Part 1 shows
|
||
the interpreter running `SELF` off a file that says `HERE`.
|
||
|
||
**And it shows the two obvious mitigations do not work.** `python3 -B` and
|
||
`PYTHONDONTWRITEBYTECODE=1` are write-side only: neither stops an EXISTING stale
|
||
`.pyc` being read, and a mutation run always has one because the previous run
|
||
wrote it. The advice already in `test_control.py`'s coverage audit led with `-B`
|
||
and is corrected here - the purge is the half that works, and `-B` earns its
|
||
place by protecting the NEXT edit rather than this one. No `sleep` anywhere:
|
||
one-second mtime resolution is the mechanism, and waiting it out would slow every
|
||
run down while still only covering the edit somebody remembered to slow.
|
||
|
||
`goodwe_controller/tools/mutation_run.sh` is the harness the house adopts: it
|
||
purges, prints a sha256/byte fingerprint of every `app/*.py` it is about to
|
||
grade - so a mutation report can say WHICH source produced it - runs each suite
|
||
under `python3 -B`, refuses a tree with no `test_*.py` (CI-01's rule), and fails
|
||
if a cache appears during the run. It takes the tree as an argument so the infra
|
||
repo's replay harnesses can call it.
|
||
|
||
`MUTATION.md` states the guarantee where a mutation gets written, and every one
|
||
of the 16 suites now points at it from its docstring - a list `test_bytecode.py`
|
||
derives from the tree, so a new suite joining without the pointer is red.
|
||
|
||
CI was already immune and is described that way rather than as a hole being
|
||
closed: `checks.yml` mounts the checkout `-v "$WS:/w:ro"`, and a read-only tree
|
||
cannot take a `__pycache__` at all - measured, and it fails silently, exit
|
||
status 0 - while each job `rm -rf`s its workspace and clones afresh.
|
||
`PYTHONDONTWRITEBYTECODE=1` is added as belt and braces, because the `:ro` is one
|
||
character wide. The shipped add-on was never affected: `Dockerfile` has set
|
||
`PYTHONDONTWRITEBYTECODE=1` since it was written.
|
||
|
||
**The survey of this week's mutation claims is in `MUTATION.md`, scoped by the
|
||
property rather than by re-running everything.** The defect is one-directional -
|
||
stale bytecode turns a red into a green survivor and never the reverse - so every
|
||
"all red, no survivors" result in the window is not at risk, and the exposure is
|
||
the recorded SURVIVORS from source-edit runs made before the purge discipline
|
||
landed on 2026-08-31. Two of them were re-run here under the harness and both
|
||
still stand (TARIFF-01's `record_block()` ignoring its `policy` argument;
|
||
SAFETY-03's `abs()` on the clamp limit). The rest are labelled still-unverified
|
||
rather than quietly assumed or noisily re-run.
|
||
|
||
Out of scope and recorded: the infra repo's `sim/` replay harnesses import the
|
||
add-on and write bytecode into both trees. Anyone mutating a module graded by a
|
||
replay must purge both by hand until that repo adopts the harness.
|
||
|
||
**`release/1.0` went red at midnight on a commit nobody had touched (TEST-17).**
|
||
`Controller.checks()` read the capacity-tariff peak with `self.peak.peak()` - no
|
||
argument - so `tariff.py` resolved the month from `datetime.now()`, while
|
||
`test_tariff.py` section 10 banks its quarter at the fixed fixture
|
||
`2026-08-30`. From 1 September the page was correctly asked for September,
|
||
correctly answered `NO_RECORD`, and the row read *"no completed quarter recorded
|
||
yet"* - a string containing no `"peak"`, which the section's filter required.
|
||
Four checks failed. The suite had been a latent time bomb since TARIFF-01
|
||
merged on 2026-08-30 and every PR that ran it ran in August.
|
||
|
||
Isolated by holding the commit and the image constant and moving only the clock
|
||
under `libfaketime`: green at 2026-08-30, four failures at 2026-09-01, identical
|
||
at `f4914a1`, `04292c4~1` and `04292c4`. The calendar, not a regression.
|
||
|
||
**The product was right and the test was wrong**, so the fix does not change what
|
||
the live page says. `checks()` takes `when: datetime | None = None` and threads
|
||
it into `peak()` and `warning_frac()` - the convention every other peak query in
|
||
`tariff.py` already follows. The single production caller passes nothing and
|
||
still means today. Section 10 now grades at its own fixture's month.
|
||
|
||
Three new checks make the defect detectable rather than seasonal: the same
|
||
controller read at two different reference months must produce two different
|
||
rows, which no clock can satisfy; the later month must read `NO_RECORD` and stay
|
||
distinguishable from both an observed peak and an unreadable store; and calling
|
||
`checks()` with no argument must still mean today. Verified by mutation - a
|
||
`checks()` that drops `when` is now red at every clock tried, where the old
|
||
section went green in August. Full suite green, 15 files, at the real clock and
|
||
at faked clocks in four other months.
|
||
|
||
One thing left alone deliberately: only the UNREADABLE branch prefixes its row
|
||
with `capacity-tariff peak:`. The `NO_RECORD` row carries neither that phrase
|
||
nor the word `peak`, which is what made the original filter brittle and what
|
||
broke the first draft of the new checks too. It is product text with other
|
||
suites reading it, so it is noted here rather than changed.
|
||
|
||
**Thirty days of the real meter, and the acceptance criterion three cards
|
||
deferred is now a number (DATA-01).** TARIFF-01 and SAFETY-07 both merged with
|
||
AC 3 - *"monthly peak held under the soft limit in >= 95 % of intervals over a
|
||
30-day simulation replay"* - honestly unmet, on the belief that only two days of
|
||
capture existed and nothing longer could be produced. That belief came from a
|
||
true statement about the past being read as one about the future: HA's recorder
|
||
still held the whole month. `scenarios/p1-net-30d-2026-08/` is 2026-08-01 to
|
||
2026-08-30, **485,841 samples, 30/30 days read, 0 failed, 4.13 MB gzipped**, in
|
||
the same schema as the two days that were already there.
|
||
|
||
`tools/export_p1_history.py` is the exporter, committed and runnable - the
|
||
2026-08-25 one-off was not, and that is exactly why the data was thought
|
||
unobtainable. It reads day-at-a-time, retries, and **never lets a failed read
|
||
look like an empty hour**: a day whose read fails writes no file, is recorded as
|
||
`"read": "FAILED"`, and the run exits non-zero; the entity is proved to exist
|
||
before any empty day is believed. Nothing is interpolated, so the month's 13
|
||
gaps (1291 s, 99.95 % covered) and its 3 `unavailable` samples stay visible.
|
||
`--verify` re-fetches the two committed days by the same route and reproduces
|
||
them **byte for byte** after decompression.
|
||
|
||
`tools/replay_ac3.py` runs the month. Over 30 days the criterion measures
|
||
**98.89 % uncontrolled and 98.99 % guarded**, so it is met on that reading - and
|
||
the uncontrolled figure is the finding: a month with no controller at all passes
|
||
it. On the other reading of the same sentence it is not met - the guard takes
|
||
August's peak from **5029.8 W to 4107.3 W**, still over the 3500 W soft limit,
|
||
because once a month peak is set `CEILING_MONTH_PEAK` defends that peak rather
|
||
than the cap. Neither number is hidden; both are in that directory's README.
|
||
|
||
The size decision is committed and measured, not absorbed: a `git bundle` of
|
||
the whole history goes from **1.58 MB to 5.28 MB** (+3.70 MB, x3.3), which a
|
||
Home Assistant supervisor clones on every install. Downsampling was not
|
||
available - `meter_max_age_s` is 30 s, so any coarser resample converts real
|
||
coverage into modelled meter-gap and moves the number being measured.
|
||
|
||
The thirty files sit in a SUBDIRECTORY on purpose: `test_tariff.py` part 9 and
|
||
`test_peakguard.py` part 6 glob `scenarios/p1-net-*.jsonl.gz` non-recursively,
|
||
so CI keeps replaying the two small days and does not grow ~50 s per PR. Both
|
||
files' honesty notes are corrected to point at the month.
|
||
|
||
**OPT-04's AC4 is met: twelve months of real EPEX BE day-ahead prices are now in
|
||
the repository, and the strategy has been replayed against them (DATA-02).**
|
||
AC4 asked for "net-positive across 12 months of replayed prices". OPT-04 shipped
|
||
it as NOT MET and replayed a synthetic year instead, because `scenarios/` held
|
||
only P1 meter captures. That was the right call at the time and the wrong
|
||
diagnosis of why: the criterion was being carried alongside a 30-day *meter*
|
||
criterion as though both needed waiting, and EPEX BE day-ahead is **public data,
|
||
not a capture**. It needed a fetch, not a month.
|
||
|
||
`scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json` — **8760 hourly points**,
|
||
2025-09-01 to 2026-09-01 Brussels-local, in §9.3's canonical three-field shape.
|
||
`scenarios/fetch_epex_be.py` produced it and `--verify` re-derives every claim it
|
||
makes about itself without touching the network.
|
||
|
||
**Licensing was checked before anything was committed.** The source is
|
||
Fraunhofer ISE's Energy-Charts API, which publishes the **BE** zone under
|
||
**CC BY 4.0 from Bundesnetzagentur | SMARD.de** and other zones under
|
||
private-use-only terms — so this zone is redistributable with attribution and the
|
||
data is committed rather than left as a script plus a checksum. The fetcher
|
||
**refuses to write a file** whose per-response `license_info` stops saying
|
||
CC BY 4.0. Cross-checked against the upstream: SMARD's own `4996/DE` series
|
||
matched this feed on all 96 quarter-hours of 2026-04-10, maximum difference
|
||
0.00 EUR/MWh.
|
||
|
||
**The figures are raw wholesale and the file says so.** `"all_in": false`, with
|
||
the markup/tax assumption carried in `provenance.all_in_assumption` as *numbers
|
||
to apply* rather than numbers already applied — TARIFF-02's double-counting trap
|
||
in data form. `test_arbitrage.py` section 7B applies it in one visible place and
|
||
**asserts arithmetically** that its two additive terms cancel out of every daily
|
||
spread, so the trade counts depend on the VAT multiplier alone and not on the
|
||
guessed markup. The same replay is run on raw wholesale as well, so how much of
|
||
the answer rests on the guess is reported (110 trades all-in, 88 raw).
|
||
|
||
**DST is handled by construction, not by a special case.** The fetcher iterates
|
||
in UTC and renders through `price.brussels()`, so **2025-10-26 comes out 25
|
||
points and 2026-03-29 comes out 23** — verified by counting per Brussels day
|
||
rather than asserting 24, and by checking that October's 02:00 appears twice at
|
||
`+02:00` and `+01:00` with two different prices while March's 02:00 does not
|
||
appear at all. The window is bounded at *local* midnight: bounding it at 00:00
|
||
UTC produced a 22-hour first day, and a 24-hour day silently missing from the
|
||
end, both of which the point-and-gap accounting caught.
|
||
|
||
**The result, at the shipped default, whatever it is.** At
|
||
`cycle_cost_threshold_eur` **0.10** widened by FCST-03's no-weather **1.5×** to a
|
||
required 0.1500 EUR/kWh, the real year completes **110 round trips** and is
|
||
**net-positive at +24.88 EUR** of benefit net of the cycle cost (2 kWh a trade).
|
||
Unwidened it completes 173. **Nothing was tuned:** the shipped default and the
|
||
2.5/1.5 factors are untouched, and every figure comes out of the same `decide()`.
|
||
|
||
**What the product owner's open decision now has under it.** The four
|
||
independent reports that a static day/night spread of ~0.0840 does not clear
|
||
0.10 **all stand** — a static-tariff site still never trades. What was missing
|
||
was the other half: a *dynamic* site's daily spread clears the widened
|
||
requirement on **121 of 365 days**, median daily all-in spread **0.1130**. The
|
||
threshold at which a median day would clear is **0.0754**, and at that value the
|
||
same year completes 156 trips for +35.42 EUR. Both numbers are reported; neither
|
||
is applied.
|
||
|
||
**And the synthetic year stays, because the real one cannot grade §17.** Not one
|
||
of the 365 real days has a negative *dearest* hour — 372 negative wholesale hours
|
||
and 0 days where the strategy would have sold into one — so "0 discharges into a
|
||
negative-price hour" over real prices is a count of something that could not
|
||
happen, and deleting the guard would not change it. Section 7B asserts that
|
||
degeneracy instead of quietly reporting the zero; section 7's generator is still
|
||
the only thing here that puts a negative price where the strategy would sell.
|
||
|
||
**Size, measured rather than assumed.** Plain JSON: 893 KB in the working tree
|
||
but **74.6 KB as a git object**, against 56.5 KB for a committed `.json.gz`. Git
|
||
already compresses blobs, so gzipping saves 18 KB and costs a reviewer the
|
||
ability to read the provenance block — including the licence claim they are
|
||
meant to check — in the diff. Plain, and `load()` still accepts `.gz` for a
|
||
future dataset that earns it.
|
||
|
||
**Nothing is wired into the add-on.** `generic_dayahead` remains the live fetch
|
||
path; nothing under `app/` imports the script, and the dataset is for replay.
|
||
|
||
**The capacity-peak guard's forced discharge now has an EMS-side energy floor
|
||
(SAFETY-24).** SAFETY-07 can command a discharge to hold a billed quarter down,
|
||
and nothing below it consulted the state of charge — `soc_floor` belongs to the
|
||
maintenance cycle, and freeze.py already recorded that for the grid-following
|
||
loop "the BMS is the sole energy backstop". On the 2026-08-20 replay the guard
|
||
asks for **878 Wh in a day**. Relying on the BMS is not absurd — it is a real
|
||
floor and it works — but it is the pack's protection rather than the operator's,
|
||
and a month of aggressive quarters leaves the battery repeatedly at that cut-out
|
||
with no EMS-side record of why.
|
||
|
||
`decide()` now takes `soc` and `soc_floor`, both **required** and neither
|
||
defaulted. Above the floor nothing changes. At or below it the guard files a
|
||
**0 W** floor instead of a sized one — one-directional, so charging stays blocked
|
||
and no discharge is capped — and reports `floor_limited` with the reason it
|
||
bound. It is not a trip: every stop in §11.2's table still outranks the claim,
|
||
and the floor can never be negative, so §9.1's "discharge and export are never
|
||
capped by the peak guard" survives as a property of the object (swept: 0 negative
|
||
floors across 1296 combinations of load, elapsed, battery, SoC and floor).
|
||
|
||
**Failing to shave is not silent.** A floor-limited cycle raises one notification
|
||
per episode (`goodwe_peak_soc_floor`), distinct from the saturation alarm because
|
||
the two ask the operator for opposite things, and renders its own status row.
|
||
An **unreadable SoC is floor-limited, not a full battery** — a full battery is
|
||
exactly the value that would disarm the floor, and this project has shipped that
|
||
defaulting once before (`| float(100)` in the stage-5 loop).
|
||
|
||
**Verified by replay, not by argument.** The 2026-08-20 capture is driven against
|
||
a modelled pack sized from the day's own measured appetite, so it genuinely
|
||
exhausts: without the floor it reaches the BMS cut-out and sits there for 1227
|
||
guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The
|
||
energy withheld equals the energy between the two floors to within one cycle at
|
||
the rail. The honest half is reported too — the worst quarter goes from 4338 W to
|
||
5030 W, because a floor-limited guard shaves less on purpose.
|
||
|
||
**Two new options, mirrored once.** `peak_soc_floor` (20) and `bms_soc_floor`
|
||
(10), whose defaults live in `peakguard.PEAK_OPTION_DEFAULTS` — TARIFF-02's
|
||
pattern — imported by `validate.py` and `main.py` rather than typed as literals,
|
||
and pinned to `config.yaml` by a check that reads the file. CFG-03 still tracks
|
||
three places. `validate.py` rule 8 refuses `peak_soc_floor <= bms_soc_floor` at
|
||
startup, in CFG-01/MAINT-04's manner. `maintenance_soc_floor` is deliberately
|
||
**not** reused and deliberately **not** renamed: it is a drain target, not a
|
||
spend limit, and the rename is recorded as a decision to revisit if a third
|
||
floor ever appears.
|
||
|
||
**Price arbitrage - the first strategy that trades on a price (OPT-04).**
|
||
Four cards built the price stack and nothing traded on it. `app/arbitrage.py`
|
||
is §10.1's priority 3: charge in the cheapest published hour, discharge in the
|
||
dearest, **and only when the spread covers `cycle_cost_threshold_eur`**. It is
|
||
off by default (`arbitrage_enabled: false`) - it is the first strategy in this
|
||
add-on that commands the battery against the meter on purpose.
|
||
|
||
**It consumes the four cards rather than re-deciding anything.** Whether a
|
||
trade is permitted at all, on which two hours, and against which widened
|
||
threshold is `TariffPlan.arbitrage()`'s answer (TARIFF-03), and that answer
|
||
cannot be obtained without first stating whether the peak guard is holding. So
|
||
§9.1's "give peak shaving priority over arbitrage" is honoured
|
||
**structurally**: there is no code path to a tradeable spread that skips the
|
||
question. And because the claim is filed at `P_ARBITRAGE` (30) while peakguard
|
||
files its floor at `P_PEAK` (50), the arbiter clamps an arbitrage charge even
|
||
if that interlock were bypassed. Two independent mechanisms, neither
|
||
re-implemented here.
|
||
|
||
**A guard that never ran is not a guard that is clear.** On `meter_source: off`
|
||
there is no telegram stream, so the peak guard is never evaluated. That third
|
||
state is carried as `None` and it **refuses**: charging at the rail creates the
|
||
billed quantity while nothing measures the quarter it lands in. `validate.py`
|
||
refuses that configuration at startup too, along with arbitrage armed on a site
|
||
with no price source at all, and a SoC floor at or above the ceiling.
|
||
|
||
**§17: never export into a negative price.** The guard is on the hour being
|
||
sold into, not on the spread - a day priced -0.30 ... -0.05 has a 0.25 EUR/kWh
|
||
spread that clears any shipped threshold and a dearest hour that is still
|
||
negative. Buying in a negative hour stays allowed; being paid to charge is the
|
||
right trade.
|
||
|
||
**Prices are fetched in the background, never on the tick.** `resolve_tariff()`
|
||
may open an HTTPS connection, and the control tick is what feeds the ESP32
|
||
heartbeat - a supplier's TLS timeout there is a supplier able to push the
|
||
add-on towards layer 1's 30 s veto. A separate task refreshes every 30 minutes;
|
||
the tick reads an attribute and an age bound, and a plan whose refresh has
|
||
stopped answering retires rather than standing as if it were current.
|
||
|
||
**What is honestly not measured.** The acceptance criterion asked for
|
||
net-positivity across 12 months of *replayed* prices. This repository holds no
|
||
price history - `scenarios/` is P1 meter data - so that criterion is **not met,
|
||
and is not restated**. What `test_arbitrage.py` section 7 does instead is
|
||
replay a synthetic year and assert the property that does not need real prices:
|
||
every executed round trip cleared its own widened requirement, on all of them,
|
||
across a year containing 522 hours priced below zero and zero discharges into
|
||
one. And at the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night
|
||
site **does not trade at all** - reported, not tuned. That number, and the
|
||
widening factors beside it, are one product decision and it is open.
|
||
|
||
**Provisional prices, and what may not rest on one (FCST-03).**
|
||
Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says
|
||
the unknown hours use yesterday's profile and are flagged `provisional: true` —
|
||
and **no irreversible action may be taken on a provisional price.**
|
||
`app/price_forecast.py` does the fill, and the whole module is shaped by one
|
||
defect: a carried-forward point and a published one are the same three fields
|
||
and a bool, so the natural line `PricePoint(..., provisional=y.provisional)`
|
||
copies yesterday's `False` onto today's guess and produces stale data presented
|
||
as current. That is the meter-side SAFETY-18 failure one layer up — the reading
|
||
arrives on time, looks fresh, and is wrong.
|
||
|
||
**So the flag is never passed through.** Carried points are built by a
|
||
constructor that has no `provisional` parameter at all (the literal `True` lives
|
||
in one place, and there is nothing for a caller to forget or copy); published
|
||
points are passed through **by identity**, so there is no copy that could lose a
|
||
flag; and an audit re-derives the expected flag for every emitted point from the
|
||
set of hours the feed actually published and *raises* rather than repairing one.
|
||
A published point may itself be provisional — `eneco_eddy`'s current-quarter
|
||
imbalance print is — so the audit's rule for a published hour is "whatever the
|
||
feed said", never "False".
|
||
|
||
**The fill keys on the Brussels wall clock, not on `t − 24 h`.** A 23-hour day
|
||
has no 02:00 and a 25-hour day has two, so a fixed 24-hour subtraction shifts
|
||
every carried hour by one across a transition and misprices a whole day with
|
||
nothing to show for it. Re-running the fill after a late publication replaces
|
||
the carried hours with the real print: the number and the flag move together
|
||
because the series is rebuilt, never patched.
|
||
|
||
**Arbitrage now drops provisional hours instead of refusing the whole day.**
|
||
The refusal that read "the plan is provisional" would have switched arbitrage off
|
||
every morning on every dynamic site once carry-forward filling landed — a
|
||
part-firm series is the *normal* state before the print. What §8.3 forbids is
|
||
acting **on** a provisional price, so the provisional hours are dropped and the
|
||
spread, the cheapest hour and the dearest hour are computed over the published
|
||
ones only; fewer than two firm hours is still a refusal, which is where the
|
||
§11.4 fallback (wholly provisional) lands. The permitting variant now refuses to
|
||
be *constructed* around a provisional point at all. The cheap-window flag became
|
||
per-hour for the same reason, so a site's maintenance charge floor does not go
|
||
dark every morning.
|
||
|
||
**Real-time / imbalance products get measurably wider thresholds.** One widening
|
||
path, driven by a set of active uncertainty sources: the product's own (§9.2 row
|
||
2, derived from the declared `PriceProduct` and never from a plugin id) and
|
||
§11.4's "no weather data" row, which the spec widens by a different trigger and
|
||
which is the same mechanism. The combination rule is `max`, not a product — two
|
||
independent doubts must not compound into a threshold nothing can clear. The
|
||
caller supplies the base round-trip cost threshold, so OPT-04 still owns what
|
||
the number *is*; this owns only how much wider it gets and why, and the allowed
|
||
verdict reports the threshold it cleared.
|
||
|
||
**An irreversible action is a value, not a judgement call at each call site.**
|
||
`Irreversible` carries the hours an action commits — the maintenance drain's
|
||
window is derived from `maintenance.DRAIN_TIMEOUT_MIN` rather than typed twice —
|
||
and the gate looks at exactly those hours, blocking on a provisional one, on an
|
||
hour the series does not cover, and on no price data at all: three different
|
||
reasons, none of them an answer. ⚠️ It is the entry point for a start being
|
||
*justified by a price*; the calendar path main.py runs today is untouched,
|
||
because a gate that blocked on `no_data` would stop the monthly cycle on every
|
||
default install — §12's opening sentence.
|
||
|
||
**Zero new options.** No `config.yaml` key, no new defaults dict, no `o.get(k,
|
||
X)` in main.py: the module reads no options at all, so CFG-03 did not widen.
|
||
|
||
**The grid-following loop is now capped against the month's billed capacity peak
|
||
(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the
|
||
consumer it was built for. Every cycle the guard projects the open quarter,
|
||
compares it to a ceiling and files ONE arbiter claim: a lower bound on the
|
||
setpoint at `P_PEAK`. That shape is both behaviours §10.1 asks for — a hard clamp
|
||
on charging and pre-emptive discharge — and it cannot cap discharge or export,
|
||
because a floor cannot. `excess = 900 × (projected − ceiling) ÷ remaining`, and
|
||
the floor is the MEASURED battery power plus that: a floor built on the standing
|
||
command winds itself to the rail whenever the inverter fails to follow, and one
|
||
built on delivered power cannot.
|
||
|
||
**It trips where SAFETY-18 only notifies, and the captures decided that.** On the
|
||
healthy reference day it engages on zero of 95 quarters; the action is
|
||
level-triggered, unlatched and re-derived every cycle, so a false engagement costs
|
||
one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the
|
||
billed quarter goes from **5029.8 W — above the inverter's own 5000 W rating — to
|
||
3485 W**, under the soft limit and without saturating. Quarters under the limit
|
||
across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is
|
||
still TARIFF-01's open item and this does not claim it.
|
||
|
||
**Blindness forbids importing, and it is checked before the projection is
|
||
graded.** A fresh averager projects a confident 0 W, which is under every ceiling
|
||
there is; a meter gap longer than `meter_max_age_s` makes the projection read LOW;
|
||
and a frozen meter reading export projects 0 W at full coverage. All three become
|
||
a floor of 0 W, which is one-directional and cannot cost pack energy. That is the
|
||
backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter
|
||
and commands nothing. The frozen term is `freeze.looks_frozen()` itself, evaluated
|
||
on this cycle's standing command, not a second copy of the conjunction.
|
||
|
||
**The ceiling is `peak_cap_w`, raised only by an OBSERVED reading.** `NO_RECORD`
|
||
and `NOT_OBSERVED` both leave it at the cap, so an unreadable peak store makes the
|
||
guard STRICTER. The gate is the reading's STATE, not whether a number happens to
|
||
be attached to it: today a `peak_w is not None` test behaves identically, and
|
||
depending on that coincidence is how a future last-known-peak field would silently
|
||
raise the ceiling off a read that failed. `headroom_w()` is deliberately not
|
||
consumed — `cap − peak` turns a zero peak into FULL freedom.
|
||
|
||
**Two defects found building it, both in the interaction rather than the
|
||
arithmetic.** A LIMIT does not out-vote another LIMIT — `arbiter.resolve()` applies
|
||
every limit at or above the winner's priority and takes the most restrictive — so
|
||
the peak floor (`lo ≥ 0`) and the maintenance charge-only shaping (`hi ≤ 0`)
|
||
resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it
|
||
should shave. Fixed by suppression rather than priority: main.py drops the
|
||
maintenance shaping while the guard is engaged, which is what that branch already
|
||
did for the optional `peak_forecast_entity` — a key that is empty on a default
|
||
install, so until now the suspension could not fire on the site that needed it.
|
||
And thresholding `excess > 0` released the claim the instant it started working,
|
||
producing seventeen engage/release cycles in one 900 s quarter (§10.2's
|
||
oscillation) and leaving that quarter 49 W over; the threshold is now the floor
|
||
itself, which is the same arithmetic read as a level rather than as a switch.
|
||
|
||
**`PeakTracker.project()` no longer reads a private name with a default.**
|
||
`getattr(averager, "_last_net", 0.0)` failed quiet AND in the disarming direction:
|
||
a rename credits 0 W held, the projection reads low, and the money guard does not
|
||
engage. `QuarterAverager.held_net_w` is now a documented public property, read
|
||
without a default, and `Projection` carries it. TEST-13 item 4.
|
||
|
||
**No new option, and no fourth defaults site.** The ceiling is `peak_cap_w`, the
|
||
coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`,
|
||
and the achievable discharge comes from `max_w` and the device rating.
|
||
`config.yaml` and `validate.py` are untouched; §14's `capacity_peak_limit_kw`
|
||
stays declined as the kW/W mismatch trap it is. On `meter_source: off` there are
|
||
no telegrams, so the guard does not run at all and the status page says NOT
|
||
COVERED — running it there would have blinded it permanently and stopped every
|
||
legacy install from charging, which is what the first cut did until
|
||
`test_clamp.py`'s export case caught it.
|
||
|
||
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
|
||
§9.2 puts a large share of the Belgian installed base on a static profile, and
|
||
an EMS that only works under dynamic pricing is an EMS for a minority. A new
|
||
`static_profile` option describes a two-period day/night tariff — or a single
|
||
fixed price — as JSON, and `app/static_tariff.py` turns it into the same
|
||
canonical `{start, eur_per_kwh, provisional}` series the fetched providers emit,
|
||
so nothing downstream learns that a tariff can be static.
|
||
|
||
**It is not a sixth provider plugin, and the reason is mechanical.**
|
||
`PriceProvider.capabilities()` is a classmethod, so a plugin's `PriceProduct` is
|
||
fixed when the class is written — but §9.2 makes STATIC and FIXED two different
|
||
products with two different required behaviours, and which one a site is on is a
|
||
property of the operator's data, not of any class. `StaticProfile.product`
|
||
derives it from the number of distinct prices instead. The second reason: the
|
||
plugin framework exists to stop a *fetched* figure being marked up twice, and a
|
||
number the operator typed as all-in has no publisher to disagree with.
|
||
|
||
**So the profile sits where §11.4 already says it does — under the providers, as
|
||
the fallback.** The configured-product path and the feed-is-down path are the
|
||
same generator differing in one flag: `provisional`. A static-tariff site runs
|
||
that path every day, so the degradation path for a dynamic site cannot rot
|
||
unused.
|
||
|
||
**"No arbitrage" is a type, not a boolean somebody has to remember to check.**
|
||
`TariffPlan.arbitrage()` returns a variant: the *allowed* arm carries the
|
||
spread and the cheapest and dearest hours, and the *refused* arm carries a
|
||
reason and nothing else — `.spread_eur_kwh` on a refusal raises rather than
|
||
answering 0.0. An optimiser cannot compute a trade from a refusal because it
|
||
cannot get the inputs out of one. Four refusals, all derived: the peak guard is
|
||
holding, there is no price data, the plan is provisional, or **the series is
|
||
flat**. That last one is AC2 — a fully-fixed tariff disables arbitrage by
|
||
arithmetic rather than by matching on an enum, and a dynamic feed that happens
|
||
to print one price all day is refused for the same true reason.
|
||
|
||
**Peak shaving outranks arbitrage, expressed so OPT-04 cannot skip it.**
|
||
`arbitrage()` takes `peak_guard_active` as a required keyword argument, so the
|
||
spread is not obtainable without first answering whether §10.1's priority 2 is
|
||
holding. §9.1's capacity tariff is a network cost charged whatever the energy
|
||
product is.
|
||
|
||
**Time-window shifting reaches the one shiftable load that exists.** main.py's
|
||
cheap-window flag — previously fed only by the `price_now_entity` /
|
||
`price_avg_entity` pair — now falls back to the static profile, and the Web UI
|
||
gained a **Tariff** row naming the window, the price, when it next changes and
|
||
why it is or is not cheap. The rule is *strictly* below the seven-day mean:
|
||
under a flat tariff `<=` would call every hour cheap and a fixed-tariff site
|
||
would top up from the grid at any hour, which is the opposite of
|
||
self-consumption only. Seven days rather than 48 hours because a 48 h mean taken
|
||
on a Friday is two-thirds weekend, and the same Tuesday 09:00 would come out
|
||
cheap or dear depending on when you asked.
|
||
|
||
**A profile must tile every day type exactly once, checked at startup.** A gap
|
||
is refused with the uncovered range named rather than filled in — an invented
|
||
price is one an optimiser would trade on — and an overlap is refused because
|
||
otherwise one of two prices wins silently on list order. The trap this catches
|
||
in practice: `22:00–07:00 mon-fri` does **not** cover Saturday morning, because
|
||
a window applies on the day the clock reads.
|
||
|
||
**validate.py rule 7's stated reason expired, exactly as TARIFF-02 predicted.**
|
||
That ticket left a tripwire in `test_price.py` section 12 asserting that nothing
|
||
in `app/` consumed `load_prices()`, with instructions to fix the refusal's
|
||
wording in the same commit when it went red. It went red naming
|
||
`static_tariff.py`. The message now says what is true instead — the series *is*
|
||
consumed, but main.py acts on the static side of it and not on a fetched one —
|
||
and the tripwire is re-aimed rather than removed: the consumer set is pinned, so
|
||
a second consumer (OPT-04) goes red again, and the expired phrase is asserted
|
||
gone so a bad merge cannot restore it. Rule 7 also grew a second refusal:
|
||
`static_profile` alongside the Home Assistant price pair is the same
|
||
silent-loser conflict, and a live one.
|
||
|
||
**No new place for an option default (CFG-03 is not widened).**
|
||
`static_profile` was added to `app/price.py`'s `PRICE_OPTION_DEFAULTS` — the one
|
||
in-code mirror TARIFF-02 created and pinned to `config.yaml` by a check — rather
|
||
than to a defaults dict in the new module, which has none. main.py grew no
|
||
`o.get("static_profile", …)` fallback. That is TARIFF-02's pattern, stated
|
||
here so a reviewer does not have to infer it.
|
||
|
||
**Measured, on the data that exists.** `test_static_tariff.py` replays the two
|
||
committed days of this house's real P1 meter (`scenarios/p1-net-2026-08-{20,23}`)
|
||
priced under a Belgian day/night profile. 2026-08-20 is a Thursday: 15.220 kWh
|
||
imported, 9.606 in the day window and 5.614 in the night one, EUR 4.7185 under
|
||
day/night against EUR 4.6725 flat, and shifting the day-window energy into the
|
||
night window saves EUR 0.8069 — which is 9.606 kWh x the 0.0840 spread, derived
|
||
twice and required to agree. 2026-08-23 is a Sunday: 0.728 kWh, **all** of it
|
||
night-priced, which is the weekend row proving itself on real data. §17 names
|
||
six Belgian scenarios and a static-profile case is not among them; this is the
|
||
seventh, and it is two captured days rather than a season — the file says so
|
||
rather than implying an annual figure.
|
||
|
||
Also worth knowing before OPT-04: on realistic Belgian day/night numbers the
|
||
whole-tariff spread is about 0.08 EUR/kWh, **below** the shipped
|
||
`cycle_cost_threshold_eur` of 0.10. A correct optimiser will decline to cycle
|
||
for the tariff alone on such a site. That is a product finding, not a defect.
|
||
|
||
**Price provider plugins, and one canonical all-in price format (TARIFF-02).**
|
||
`app/price.py` ships five providers - `generic_dayahead` (wholesale EPEX BE),
|
||
`engie_dynamic`, `totalenergies_dynamic`, `eneco_eddy` and the offline
|
||
`manual_csv` - all emitting exactly `{"prices": [{"start", "eur_per_kwh",
|
||
"provisional"}]}`, tz-aware at the Brussels offset, EUR/kWh, all-in. **Nothing
|
||
consumes it yet**: this is the seam, landed first, so that the arbitrage work
|
||
that follows never learns a supplier's name.
|
||
|
||
**Double-counting the markup is refused rather than discouraged, given a
|
||
correct declaration.** A plugin does not return a price; it returns the figure
|
||
its source published and declares which of {energy, markup, taxes} that figure
|
||
contains. The framework adds the complement, once. Engie and TotalEnergies
|
||
publish a retail figure with their margin already inside; adding the configured
|
||
3 c/kWh on top would make every future arbitrage decision wrong by that amount
|
||
with nothing reporting it.
|
||
|
||
Two mechanisms, because one was not enough. `PluginOptions` raises on the
|
||
framework-owned keys - and review of PR #16 then wrote a double-counting plugin
|
||
three more ways, one of them touching nothing but `PRICE_OPTION_DEFAULTS`, a
|
||
public dict in the module every plugin already imports. So `register()` now also
|
||
**refuses at import any plugin whose own code mentions a framework key**
|
||
(`code_leaks()`, docstrings stripped via `ast` so a plugin's mandated
|
||
`SOURCES:` line does not false-positive). Honest wholesale baseline 0.188
|
||
EUR/kWh; every attack landed 0.219.
|
||
|
||
**The guarantee's precise shape**, stated because an overclaim is worse than no
|
||
claim: the framework adds the complement exactly once *given a correct `sources`
|
||
declaration*. A plugin that declares `{energy}` while its feed is really all-in
|
||
still comes out one markup high while touching no framework name, and no code
|
||
can catch that - the declaration is the plugin's unverifiable claim about
|
||
somebody else's tariff. What is mechanised is reachability; what stays human is
|
||
truthfulness.
|
||
|
||
**The product type is declared, not inferred from the plugin's name.**
|
||
`eneco_eddy` is a real-time/imbalance product and gets widened arbitrage
|
||
thresholds; that reaches a consumer through `capabilities().product` and
|
||
`volatility_regime()`, never through a name comparison. `test_price.py` asserts
|
||
mechanically that no plugin id appears anywhere in the framework's own code,
|
||
and drives a sixth provider defined inside the test file to the correct regime
|
||
with `price.py` untouched.
|
||
|
||
**"No price data" is a type, not an empty list.** Following the same rework
|
||
`sim/riglock.py` took, a fetch returns `PriceRead.series(...)` or
|
||
`PriceRead.no_data(reason)`, and only the first carries fields. An empty series
|
||
is not constructible; `no_data` has no `.points` to iterate, no `.at()`, and
|
||
`.get("points", [])` raises rather than returning the default. **A day of free
|
||
electricity and a day with no prices are different instructions** - arbitrage is
|
||
pointless under the first and forbidden under the second - and an implementation
|
||
that returns `[]` on a failed fetch cannot tell them apart. Every site-level
|
||
failure returns `no_data`; nothing raises into the control tick.
|
||
|
||
**What is not verified, and cannot be by an offline suite:** every fixture is
|
||
synthetic, so the five feed URLs and JSON shapes are checked against a belief
|
||
about what those suppliers publish, not against the feeds. The parse layer is
|
||
exercised; the contract with each supplier is not. Expect the first live fetch
|
||
of each to need a shape fix, and note that a wrong shape degrades to
|
||
no-price-data rather than to a wrong price.
|
||
|
||
**`manual_csv` is the documented offline fallback** and needs no network, no
|
||
supplier account and no HTTP stack. See DOCS.md for the format. Its `start`
|
||
column must carry a UTC offset - a bare local timestamp is refused, because one
|
||
hour every October names two moments.
|
||
|
||
**Injection is priced separately** (`export_price_eur_kwh`), a scalar rather
|
||
than a second series, and negative values are accepted. Consumption plugins
|
||
cannot reach it.
|
||
|
||
**`price_provider` may not be combined with `price_now_entity` /
|
||
`price_avg_entity`** - the add-on refuses to start on that pair. The Home
|
||
Assistant sensors are the existing delegation of pricing to HA and still work
|
||
alone; with both configured they would silently keep deciding while the status
|
||
page named a supplier that decided nothing.
|
||
|
||
**No timezone database is used.** The shipped Alpine image has no `tzdata`, so
|
||
Brussels offsets are computed from the EU rule and every instant is carried as
|
||
UTC and rendered at the offset that applies to it. The checks drive both 2026
|
||
transitions - the 23-hour and 25-hour days - rather than a normal day, where a
|
||
fixed 24-slot implementation and a correct one agree.
|
||
|
||
**The capacity-tariff peak is now measured, banked and survives a restart
|
||
(TARIFF-01).** New `app/tariff.py`. The 15-minute block average itself was
|
||
already built and unwired - `p1.QuarterAverager` computes clock-aligned 900 s
|
||
blocks, time-weighted over irregular telegram spacing, offtake-only and
|
||
per-phase - and `P1Ingest.blocks` was a 96-entry list a restart erased. This
|
||
adds the SQLite behind it (`/data/tariff.db`): one row per completed quarter in
|
||
the indefinite 15-min tier, one row per calendar month in the permanent peak
|
||
tier, and no `DELETE` against either anywhere in the module.
|
||
|
||
**Blocks, not a sliding window.** The card says "rolling"; 9.1 bills "the
|
||
highest 15-minute average offtake", stated per QUARTER. "Rolling" here means
|
||
continuously updated, not a trailing-15-minutes window - a trailing window
|
||
reports a peak the meter never bills, and reports it HIGH, because a burst
|
||
straddling :15 fills it completely while contributing to two block averages at
|
||
partial weight.
|
||
|
||
**A restart mid-block resumes it, and a restart that outlives the block banks
|
||
it.** `QuarterAverager` grew `snapshot()`/`restore()`; the tracker checkpoints
|
||
the open block every 15 s (worst case 1.7 % of one quarter) and clears the
|
||
checkpoint in the same breath as a block close. The held sample keeps its
|
||
ORIGINAL timestamp across the restart, so the outage is integrated under the
|
||
same `max_hold_s` rule a running process would apply and uncovered seconds drag
|
||
the closed block DOWN rather than being held forward. `quarter_start` is the
|
||
primary key and a re-bank may only ever RAISE a figure, so a replay cannot
|
||
double-count and a partial re-run cannot lower a quarter already measured.
|
||
|
||
**An unreadable store is not a zero peak.** `peak()` answers `OBSERVED` /
|
||
`NO_RECORD` / `NOT_OBSERVED` and carries `peak_w = None` unless observed - the
|
||
`riglock.holder()` fix applied to something that spends money. A peak read as
|
||
0 W hands SAFETY-07 maximum headroom and silently disarms the guard at the
|
||
moment its own storage breaks, so there is no accessor that defaults it: a
|
||
caller who forgets gets a `TypeError`, and the status page prints a different
|
||
row for "no quarter yet" than for "the database did not answer".
|
||
|
||
**Calendar months are LOCAL, blocks are UTC.** A quarter starting 22:15 UTC on
|
||
31 August bills in September. Block alignment still needs no timezone.
|
||
|
||
**No new option, and no second peak entity.** The soft limit is the existing
|
||
`peak_cap_w`; `config.yaml` and `validate.py` are untouched, so CFG-03 is not
|
||
widened. `peak_forecast_entity` stays the input to `maintenance_charge_floor()`
|
||
- repointing that consumer changes what moves energy and belongs to SAFETY-07.
|
||
|
||
**It measures and nothing else.** No claim, no clamp, no command; graded on the
|
||
parsed syntax in `test_tariff.py`, which also proves the same grader lights up
|
||
on `main.py`. Per SAFETY-20, the quantity is DELIVERED power - `tariff.py` only
|
||
ever sees `P1Sample`, the meter's own reading, never a setpoint.
|
||
|
||
**AC 3 is not met and the check says so.** It asks for 30 days of replay; two
|
||
days of this house's real meter exist (2026-08-20, 2026-08-23), now committed as
|
||
`scenarios/p1-net-*.jsonl.gz`. `test_tariff.py` part 9 replays them, reports
|
||
the percentage under the shipped limit next to the sample size in days, and
|
||
asserts nothing about the 95 % figure.
|
||
|
||
**A computed setpoint over the rating now alarms instead of being clipped in
|
||
silence (SAFETY-03).** The clamp was never the missing half - specs.md 10.3
|
||
records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting
|
||
**14 768 W** while pinned at the 5 kW rail, and a silent clamp turns exactly that
|
||
into a flat, legal-looking 5000 W hold. `write_setpoint()` - the one path every
|
||
write takes, cycle, heartbeat, operator button and shutdown alike - now clamps to
|
||
the **validated** `max_w` and reports whether the raw value differed, and
|
||
`Controller.check_output_clamp()` raises `OUTPUT_CLAMP`: a persistent
|
||
notification (`goodwe_output_clamp`), an `ERROR`, an event row and a red status
|
||
banner. **Not a trip**: the clamped value is written and the loop keeps running,
|
||
per 11.2's "clamp *and alarm*".
|
||
|
||
**The bound is `max_w` and nothing is `min()`ed at runtime.** Section 14 requires
|
||
the loop clamp and the driver's `setpoint_limit_w` to agree, validated at startup
|
||
with a refusal on mismatch - which CFG-01 already ships. The device range is
|
||
still applied second, as defence in depth; after a successful validation it
|
||
cannot engage, so if it ever does, that is louder than the first stage and it
|
||
alarms too, naming the bound. No new config key: the guard's limit is the one
|
||
the operator already set, and its cadence is a module constant.
|
||
|
||
**`Decision` grew `i_clamped` / `clamped` booleans, and that is the load-bearing
|
||
part.** `reason` is one string that later stages overwrite - a cycle that clamps
|
||
the integrator and is then slew limited reports `slew-limited`, and one that
|
||
clamps while saturated reports `saturated-freeze`. Both *are* the winding case,
|
||
so an alarm keyed on the string is silent on exactly the cycles it exists for:
|
||
mutating main.py back onto `"clamp" in decision.reason` drops the 10.3 replay
|
||
from 2 engagements to **0**. The flags are set where the clamp happens and are
|
||
never overwritten. On a default install (`integrator_max_w` following `max_w`)
|
||
the integrator bound is the one that bites and the output clamp is unreachable,
|
||
so the integrator flag is the whole signal.
|
||
|
||
**Distinct from the saturation freeze, and checked in the negative.** A clamp is
|
||
a *software* bug signal; saturation is the *hardware* failing to follow a legal
|
||
command, and its action is to freeze the integrator, not to alarm. They share no
|
||
reason code, notification id, status row or counter. `test_clamp.py` drives a
|
||
loop that saturates for 10 consecutive cycles at 1.4 % of the rating and asserts
|
||
zero clamp notifications, zero engagements, no log row and no status row - after
|
||
first asserting that the saturation freeze *really did* engage, so the silence is
|
||
an observation rather than a scenario that did nothing.
|
||
|
||
**The action log is coalesced per episode, and that was a real bug.**
|
||
`log_event()` keeps 40 rows; one row per engagement flushed the entire ring
|
||
within 40 cycles of a winding loop, and the first thing it evicted was
|
||
SAFETY-18's own `METER FROZEN?` row - this guard silencing another guard's log.
|
||
Caught by `test_freeze.py` going red, not by inspection. One row per episode now,
|
||
carrying the exact engagement count read off the counter.
|
||
|
||
**A non-finite setpoint is refused, and it was worse than passing one through.**
|
||
`nan` is loss of input (11.1 rule 1: command zero, keep commanding), not an
|
||
out-of-range value. Measured: because every comparison against `nan` is False, a
|
||
naive `max(-limit, min(limit, nan))` keeps its *first* argument and turns a `nan`
|
||
into a command **at the full rating**, then reports it as a clamp engagement.
|
||
|
||
**Review fixes (PR #12).** Three, and the first was a behavioural regression
|
||
this PR introduced: `clamp_to_rating()` **returned early** from the `max_w`
|
||
stage, so the device bound - the "applied second, defence in depth" half - was
|
||
reached only when the rating bound had NOT fired. The bigger the overshoot, the
|
||
less the guard did. At `max_w` 5000 against a +-4000 device, +9999 emitted
|
||
**+5000 W, outside the device range**, while a mere +4500 was correctly held to
|
||
+4000. That undid the pre-PR guarantee "never send a value the device will
|
||
reject outright" and undercut this module's own case for clamping to `max_w`
|
||
rather than a runtime `min()`, which rests entirely on that stage catching a
|
||
validation that did not run. Both stages now always run, the result names both
|
||
bounds when both bite (`max_w+device`), and the property is asserted over a
|
||
492-point sweep rather than on witness values - the defect was invisible to any
|
||
single-value check that happened to sit inside one stage. No reachable
|
||
production path was found for it (two validation gates and the arbiter bound sit
|
||
in front), but latent is not harmless.
|
||
|
||
Two harness defects, both of the kind where a check cannot fail for the value it
|
||
forbids: the hysteresis check issued `range(CLEAR_CYCLES - 1)` clean cycles,
|
||
which at `CLEAR_CYCLES = 1` issues **zero** and passes while printing
|
||
"0 clean cycles do NOT clear it" - green for the chatter setting the constant
|
||
exists to prevent. And the re-assert check stimulated at `REASSERT_S + 9`, green
|
||
for **any** value including 3600, i.e. the hour of silence the module promises
|
||
not to produce. Both are now written in wall-clock terms that do not move when
|
||
the constant does: **one** clean cycle must not clear the alarm, and a loop
|
||
pinned across the 900 s capacity-tariff quarter must speak at least 5 times and
|
||
on no more than 20 % of cycles. `CLEAR_CYCLES = 1`, `REASSERT_S = 3600` and
|
||
`REASSERT_S = 0` each go red.
|
||
|
||
**`test_clamp.py`** (new, assert-based, no framework) covers the clamp
|
||
arithmetic and its boundaries - exactly +-`max_w` is *not* an engagement - the
|
||
alarm's edge/re-assert/hysteresis cadence, the 10.3 runaway replayed through the
|
||
real `Controller.cycle()` against an unclamped transcription, the saturation
|
||
negative, and the direct-write path. Six author mutations reproduced red,
|
||
including "clamp kept, alarm deleted" (the historical build) and "alarm keyed on
|
||
`reason`". Two of the check's own hypotheses about how the freeze and the clamp
|
||
interact were **refused by the sweep** and rewritten to what it measured: a
|
||
frozen cycle *can* clamp, and does so exactly when the integrator reverses.
|
||
|
||
`test_freeze.py`'s notification assertions now filter by `notification_id`
|
||
rather than by service - the add-on raises more than one kind of notification,
|
||
and its railing scenarios legitimately raise this one too.
|
||
|
||
**Cheap-window maintenance charging was unreachable at the shipped defaults, and
|
||
now the config that makes it unreachable is refused (MAINT-04).** `config.yaml`
|
||
shipped `max_w: 2000` against `maintenance_charge_w: 2500`. The cheap-window
|
||
shaping files `Claim.limit(hi = -maintenance_charge_w)` against the supervised
|
||
SAFETY limit's `lo = -max_w`, so `lo > hi` — the arbiter's clause 3, correctly
|
||
resolved to **0 W** and logged as a CONTRADICTION. **Maintenance silently never
|
||
charged in a cheap window on a default install**, and the only evidence read
|
||
like a bug in the arbiter rather than like the two numbers an operator set.
|
||
Measured against the real arbiter, loop asking +500 W:
|
||
|
||
```
|
||
max_w=2000 hi=-2500 -> +0 W contradiction=True
|
||
max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w)
|
||
max_w=3000 hi=-2500 -> -2500 W limited by maintenance(cheap-window charge)
|
||
```
|
||
|
||
`validate_startup_config()` now refuses `maintenance_charge_w > max_w` (rule 6),
|
||
the same shape as CFG-01's `sign_disagree_w >= max_w`: a number the operator set,
|
||
overridden by a layer they cannot see. Equality is reachable (`-max_w` is a
|
||
single valid point) and deliberately not refused. Verified by starting the
|
||
add-on on the pre-fix pair: exit 78, the refusal banner, no traceback.
|
||
|
||
**`maintenance_discharge_w` was checked, not assumed, and it is NOT the same
|
||
case.** The shaping is not symmetric. The drain phase does not shape the loop,
|
||
it OWNS the actuator — `Claim.set(P_MAINTENANCE, +discharge_w)` — and a SET is
|
||
*clamped* by the limits above it rather than contradicted by them. 2500 against
|
||
a 2000 W ceiling emits **+2000 W**, and the arbiter's own `explain()` prints
|
||
`maintenance -> 2000 W, limited by supervised(max_w)` every cycle. Nothing is
|
||
silent and energy still moves; the cycle completes slower (13.8 kWh usable at
|
||
2000 W is ~6.9 h against the 720 min drain timeout). **Warned about, not
|
||
refused.**
|
||
|
||
**The shipped defaults changed so the validator does not reject them.**
|
||
`maintenance_charge_w` and `maintenance_discharge_w` are now **2000**, at the
|
||
shipped `max_w`. `max_w` was deliberately NOT raised instead: it is the
|
||
supervised safety ceiling and raising a default upward ships more power to every
|
||
new install. Raising `max_w` (the reference install runs 5000) is what unlocks a
|
||
faster maintenance cycle — raise it first, then these. `test_validate.py` now
|
||
reads `config.yaml` itself and asserts the shipped numbers pass the rule, so a
|
||
default that the validator refuses cannot ship again.
|
||
|
||
### ⚠️ Upgrading an existing default install: the add-on may refuse to start
|
||
|
||
An install still carrying `maintenance_charge_w: 2500` against `max_w: 2000`
|
||
now meets rule 6 and **exits 78 with the refusal banner** instead of starting.
|
||
That is the intended behaviour — the configuration was already broken, it was
|
||
just silent about it — but an add-on that will not come back after an update
|
||
needs the fix stated here rather than only in the log.
|
||
|
||
**The fix, either way: set `maintenance_charge_w` to `2000` or lower, OR raise
|
||
`max_w` to `2500` or higher** (Settings → Add-ons → GoodWe RS485 Controller →
|
||
Configuration), then start it. The banner in the log says the same thing and
|
||
names both numbers.
|
||
|
||
Whether you hit this at all depends on whether the Supervisor kept the old
|
||
value, and **both cases are covered above because we did not verify which one
|
||
applies here.** The Supervisor merges an add-on's *stored* options over the
|
||
defaults in `config.yaml`, so:
|
||
|
||
- **A key the operator never edited** re-derives from the new `config.yaml` and
|
||
becomes 2000 on upgrade. Non-event, nothing to do.
|
||
- **A key the operator did set** persists at 2500 and the add-on refuses to
|
||
start until it is changed. Note this includes anyone who used the
|
||
Configuration page's YAML editor, which writes the whole options block back —
|
||
so "I never touched that field" is not a guarantee the old value is gone.
|
||
|
||
⚠️ **This is reasoned from the Supervisor's documented merge behaviour, not
|
||
measured on the live install.** The reference install's stored options could not
|
||
be read (`/api/hassio/` refuses a long-lived token) and probing it was out of
|
||
scope — it regulates a real battery. It runs `max_w: 5000` and is unaffected
|
||
either way.
|
||
|
||
⚠️ **Known, deliberately left, and worth a follow-up:** `main.py` and
|
||
`maintenance.py` still fall back to **2500** when the key is absent from the
|
||
options dict, while `config.yaml` and `validate.py` now say 2000. Unreachable on
|
||
a Supervisor install (every key is always supplied) and reachable only from
|
||
tests/`sim`. The real fix is one source of truth for option defaults, which is
|
||
bigger than this ticket and touches files another ticket is in.
|
||
|
||
## 0.5.2
|
||
|
||
**A meter frozen at a wrong-but-fresh value now raises an alarm. It still is not
|
||
stopped.** (SAFETY-18)
|
||
|
||
A meter that answers `200 OK` with a stale document forever is *arriving* on
|
||
every poll, so every freshness mechanism in the stack reads it as healthy:
|
||
`meter_max_age_s` is reset by each response, the firmware watchdog is fed by the
|
||
heartbeat and by a sample age that never climbs, and the arbiter contributes only
|
||
±`max_w`. The loop integrates a constant error and rails — replaying the real
|
||
`compute()` at 15 s cycles, in 150/60/30 s at `max_w: 2000` for a meter frozen at
|
||
350/1000/≥3000 W, and 360/135/75 s at `max_w: 5000`. **The live install runs
|
||
`max_w: 5000`**, which is both the schema maximum and the device rating.
|
||
|
||
The new detector (`app/freeze.py`) is a **conjunction**, not the bare
|
||
`unchanged_s` threshold TEL-05 measured and rejected:
|
||
|
||
unchanged_s ≥ meter_freeze_s AND |command| within 5 % of max_w
|
||
|
||
Both terms already existed and were already on the status page. They fail for
|
||
independent reasons: at the converged point the command is nowhere near the rail,
|
||
and at the rail against a real load the kWh registers move within seconds. At the
|
||
shipped 300 s there is also a structural relation, not just a margin — the
|
||
slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which
|
||
is inside the 15 W deadband, so the command there is parked at 0 W and the two
|
||
halves cannot both be true.
|
||
|
||
**Notification only, deliberately.** An HA persistent notification (fixed
|
||
`notification_id`, so a repeat replaces rather than stacks), an `ERROR` log line,
|
||
a UI event and a red status banner. No claim, no latch, no 0 W: a false trip
|
||
parks the battery and the operator's next move is to switch the guard off; a
|
||
false notification costs an alert. The trip is a separate decision that needs
|
||
data from a real install.
|
||
|
||
- New option `meter_freeze_s` (default 300, range 60–3600, no "off").
|
||
- **Only `meter_source: homewizard_local` is covered** — it is the only transport
|
||
that sees the meter's own document. Everywhere else the status page says
|
||
**NOT COVERED** rather than "armed".
|
||
- `DOCS.md` now states the **capacity-tariff exposure** this does *not* close:
|
||
`peak_cap_w` caps maintenance charging and nothing caps the loop, so a lying
|
||
meter can drive a 5 kW unattended import, and ≥15 minutes of that sets the
|
||
month's billed peak (measured: one 4998 W quarter moved a monthly peak from
|
||
4832 → 5013 W). That cap is SAFETY-07.
|
||
- `test_freeze.py` grades it in both directions: silent over 40 minutes of the
|
||
live house's real converged capture (committed as
|
||
`scenarios/p1-age-2026-08-26.jsonl.gz`), silent over an hour of TEL-05's
|
||
low-power case where a bare threshold is shown to fire, and alarming on a
|
||
genuinely frozen meter driven over real HTTP through the real
|
||
`HomeWizardLocalSource` — against the rig's own `hwsim` `freeze` fault when the
|
||
infra repo is reachable, an equivalent stub otherwise. Its negative twin is the
|
||
load-bearing one: the same railed command against a meter that is still moving
|
||
must stay silent at the same threshold.
|
||
|
||
## 0.5.1
|
||
|
||
**SAFETY-02's latch now survives an add-on restart, which is the difference
|
||
between a latch and a pause.** `auto` was persisted and `sign_wd.tripped` was
|
||
not, so reconstructing the `Controller` against the same store — exactly what a
|
||
container restart does — brought it back **armed, into the same fault, with no
|
||
operator involved**: `tripped=False`, banner back to `NOT READY`, and it resumed
|
||
commanding (measured: −1000 W, −2000 W) before re-tripping 180 s later. Under a
|
||
Supervisor that restarts a crashed add-on that is a loop of three-minute
|
||
wrong-direction bursts — and "restart the add-on" is the first thing an operator
|
||
looking at a `FAILSAFE` banner and a 0 W inverter will try.
|
||
|
||
`sign_tripped` and `sign_reason` are now `Store` keys beside the maintenance
|
||
state, written the moment the trip happens (reason first, flag second, so a crash
|
||
between the two can never persist a latch with no explanation) and restored in
|
||
`Controller.__init__`. Cleared only by the operator's start. `DOCS.md` said "it
|
||
does not clear itself"; that was false across a restart and now is not.
|
||
|
||
Also in this release, all of it review follow-up rather than new behaviour:
|
||
|
||
- **The arming gate is tested against the real arbiter.** It was the decision the
|
||
whole ticket turns on and it was asserted in three comment blocks and verified
|
||
by nothing — every case passed `following` in as a literal. `test_watchdog.py`
|
||
now derives it from `resolve()` across twelve claim configurations, including
|
||
the redirect case `winner == "loop"` alone would have got wrong and the `lo`
|
||
limit that raises magnitude without flipping sign.
|
||
- **The MQTT `status: "failsafe"` value is tested.** The check that claimed to
|
||
cover it restated the assertion three lines above it and never touched the
|
||
payload.
|
||
- **A forward hazard written down where it will be read, and made to bite.**
|
||
`winner == "loop"` disarms the watchdog under any higher-priority SET claim.
|
||
Today only maintenance and the safety stops emit one and neither follows the
|
||
meter; a peak-shaving strategy at `P_PEAK` **does**, and would blind the
|
||
watchdog for every peak event.
|
||
|
||
The gate is now `watchdog.loop_is_following()`, called by both `main.py` and
|
||
the test, for the same reason `disagrees()` was extracted: the test held a
|
||
*transcription* of the shipped expression, so editing the shipped gate alone to
|
||
`winner in ("loop", "peak")` left every suite green while the watchdog went
|
||
blind. Two copies, and the one that drifts is the one doing the grading.
|
||
|
||
⚠️ Extracting it was not sufficient on its own. The realistic P_PEAK row —
|
||
peak shaving asking for *more* than the loop — still could not detect the
|
||
widened gate, because the magnitude term rejects it regardless and masks the
|
||
winner term. A second row with peak asking for *less* isolates `winner` as the
|
||
only deciding term, and that is the one that turns red.
|
||
- **The worked example described a configuration that does not ship.** At the
|
||
defaults `maintenance_charge_w` (2500) exceeds `max_w` (2000), so that claim is
|
||
an arbiter *contradiction* resolving to 0 W, not a −2500 W charge. The example
|
||
is now written at 1500 W. (The contradiction is a real pre-existing bug —
|
||
a default cheap-window maintenance charge silently never happens — filed
|
||
separately, deliberately not fixed here.)
|
||
- **`disagrees()` is public**, so `sim/replay_sign.py` stops carrying its own copy
|
||
of the predicate it grades.
|
||
- `held_s` takes the clock instead of reading one, restoring the pure-function
|
||
property the module advertises.
|
||
- **The `sign_reason`-before-`sign_tripped` write ordering is enforced, not just
|
||
documented.** Swapping the two lines used to pass every check. A store that
|
||
fails the second write now proves the property the ordering exists for: what
|
||
survives a crash mid-trip is a reason with no latch, never a latch with no
|
||
explanation.
|
||
- Measured figures corrected throughout: 93.4 % / 4212 of 17280 (was 92.9 % /
|
||
4127 of 16349), per-cluster 0.3 / 0.9 / 10.5 / 0.0 %.
|
||
|
||
## 0.5.0
|
||
|
||
**SAFETY-02 — the sign-disagreement watchdog.** The add-on now watches whether
|
||
the setpoint it is emitting and the grid figure it is regulating on point the
|
||
same way. If they oppose each other, both over `sign_disagree_w` (300 W), for
|
||
`sign_disagree_s` (180 s) **continuously**, it files a failsafe claim at
|
||
`P_SAFETY`, commands **0 W**, and latches there until an operator starts control
|
||
again. New module `app/watchdog.py`; new options `sign_disagree_w` and
|
||
`sign_disagree_s`.
|
||
|
||
This is the gap the existing failsafes leave open. The stale-input watchdog asks
|
||
whether the meter is still talking, the saturation freeze asks whether the
|
||
inverter can follow, the output clamp asks whether the number is too big — a
|
||
loop can pass all three while charging the battery out of the grid, because the
|
||
only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly
|
||
that four times in one day, for up to 77 minutes at a stretch, and reached a
|
||
commanded −14 768 W against a ±5000 W inverter.
|
||
|
||
Validated against that day. `sim/replay_sign.py` in the infra repo drives this
|
||
module over the committed RS485 captures of both 2026-08-20 (the four episodes)
|
||
and 2026-08-23 (healthy): **18 trips on the fault day, every one inside a window
|
||
`NOTES.md` §4.7 accounts for, and 0 on the healthy day.** The four episodes do
|
||
share one signature rather than merely resembling each other — sign agreement
|
||
runs 0.3 / 0.9 / 10.5 / 0.0 % inside them against 93.4 % on the healthy day.
|
||
|
||
Three decisions worth knowing about:
|
||
|
||
- **It is disarmed whenever the grid-following loop is not what is driving.**
|
||
Sign agreement is a property of a loop that follows the meter. A maintenance
|
||
grid charge opposes the meter *by design* — 2500 W of charge into a house
|
||
drawing 500 W is +3000 W on the meter against a −2500 W command, for hours —
|
||
and armed unconditionally this would trip on every maintenance cycle. The gate
|
||
tests the emitted number, not the winning claim's name: a limit claim can let
|
||
the loop "win" and still replace its value with one pointing the other way.
|
||
- **The trip latches.** Commanding 0 W removes the command side of the
|
||
comparison, so the disagreement always clears the moment the trip works.
|
||
Un-latched, this would trip, clear, and hand the actuator back to the loop that
|
||
was driving the wrong way, every three minutes forever.
|
||
- **`sign_disagree_w` at or above `max_w` is refused at startup** (validator rule
|
||
5). The arbiter never lets a command exceed `max_w`, so such a threshold makes
|
||
the watchdog incapable of firing while the status page still reports it armed.
|
||
A disarmed guard that advertises itself as armed is the failure mode this
|
||
project keeps refusing to ship.
|
||
|
||
What it does **not** catch, stated so nobody assumes otherwise: an inverter that
|
||
acts opposite to what it was told moves the meter *further* the same way, so
|
||
command and meter agree while everything runs away. That is the saturation
|
||
freeze's job, which compares the command against the battery's actual power.
|
||
|
||
`test_validate.py`'s "the smallest sane max_w still starts" case now names a
|
||
reachable sign threshold, because a 1 W ceiling genuinely does disarm the 300 W
|
||
default and is now correctly refused.
|
||
|
||
## 0.4.0
|
||
|
||
**CFG-01.** The add-on now refuses to start on a configuration it cannot honour,
|
||
instead of logging a line and carrying on. `validate_startup_config()` runs in
|
||
`amain()` before the broker, the web server or any task exists, so a refusal
|
||
cannot leave half a controller running behind a UI.
|
||
|
||
It refuses only where continuing produces one of two outcomes: **(a)** a stated
|
||
limit is untrue - the operator configured a bound and a lower layer silently
|
||
enforces a different one - or **(b)** the add-on is inert and indistinguishable
|
||
from healthy. (b) is not a special case of (a): with no `setpoint_entity`, layer
|
||
1 pinning the inverter at 0 W is the failsafe *working*. The system does not
|
||
become unsafe, it becomes a convincing impression of a working controller.
|
||
|
||
Four things qualify:
|
||
|
||
- **`max_w` above (or below) the setpoint device's own rating.** This is the one
|
||
that changed behaviour. It used to warn `the device wins` and run anyway, with
|
||
`write_setpoint()` silently clamping, so the configured ceiling was never the
|
||
real one and the only evidence had scrolled off the log. The refusal names the
|
||
configured value, the device's value, and which wins.
|
||
- **No `setpoint_entity`.** A controller with no actuator: every write is a
|
||
no-op, the device rating is never read, and the ESP32 heartbeat that holds off
|
||
the layer-1 failsafe never happens. It ran, looked healthy, and moved nothing.
|
||
- **A `meter_source` that can never produce a reading** - missing `p1_host`,
|
||
missing `p1_net_entity`, a blank MQTT topic, a phase list disagreeing with
|
||
`meter_phases`, an unknown source name, or a `meter_poll_s` at or past
|
||
`meter_max_age_s`. These were already caught, but they only *disabled
|
||
ingestion*, which leaves the grid reading None forever: permanent "inputs
|
||
missing", 0 W, and a status page indistinguishable from a healthy meter nobody
|
||
has sent anything to yet.
|
||
- **`meter_max_age_s` above the firmware's ingest veto.** The schema accepts
|
||
`int(5,300)`; the ESP32's threshold is a compile-time `max_age_s: "30"` and it
|
||
trips layer 1 at `p1_age_s >= 30`. Set 120 and the add-on treats a 119 s
|
||
reading as fresh and keeps commanding, while the firmware has already vetoed
|
||
and is holding the inverter at 0 W - healthy meter, running controller,
|
||
nothing moving, and no line anywhere saying the number you set was overruled.
|
||
Structurally the same fault as the `max_w` case. Only *above* the veto is
|
||
refused: below it the add-on gives up first, which is fail-safe and visible on
|
||
its own status page. Mirrored as `validate.FIRMWARE_INGEST_VETO_S` - it cannot
|
||
be imported from a compile-time YAML substitution, so it carries a note about
|
||
the one direction in which it can go stale.
|
||
|
||
`max_w` at or below zero is refused too. Schema-blocked on a real install, but
|
||
`validate_startup_config()` is now also the simulation suite's entry point, and
|
||
a non-positive `max_w` inverts the arbiter's `supervised` bounds into a
|
||
contradiction that resolves to 0 W - i.e. it would read as a safety trip rather
|
||
than as the typo it is.
|
||
|
||
The predicates for the meter group still live in `p1.validate_meter_options()`,
|
||
which `build_source()` also calls - one definition, two callers, not two
|
||
competing validators. Two transports gained a check they never had: `ha_dsmr`
|
||
with only one of its two entities, and `mqtt_p1` with a blank topic, both of
|
||
which previously built a transport that could never read.
|
||
|
||
Deliberately **not** refused, because it is untidy rather than unsafe:
|
||
`integrator_max_w` above `max_w` (the arbiter still bounds the wire to ±max_w,
|
||
so the cost is unwind latency, not amperage - warned), a missing `soc_entity` /
|
||
`batt_entity` (fail-safe, and already a red NOT READY banner naming it), a
|
||
`setpoint_entity` that has not appeared on the Core API yet (ESPHome is routinely
|
||
late; refusing would make a slow boot need a human - re-checked once the real
|
||
rating arrives), and a thin-but-nonzero `meter_poll_s` margin.
|
||
|
||
A refusal is legible as a refusal: a banner that says in words that this is a
|
||
configuration problem and nothing is broken, the numbered problems, no traceback,
|
||
and exit status 78 (`EX_CONFIG`).
|
||
|
||
**SAFETY-12.** The control loop now evaluates on a clock as well as on a change.
|
||
`run_control()` ran `cycle()` only when the meter value differed from the
|
||
previous poll, so a meter that repeated a value ran **no control cycles at
|
||
all** - while the heartbeat went on re-emitting the last command and the
|
||
firmware went on putting it on the wire every 2 s. Layer 1 stayed fed, every
|
||
indicator read healthy, and a command computed from an old reading kept being
|
||
executed through whatever the load did next. Measured on the rig during TEL-05:
|
||
70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
|
||
|
||
**It was worse than a latency limit: it was a safety claim that could not
|
||
fire.** `stale_input_s` and the `inputs missing -> 0 W` claim both live *inside*
|
||
`cycle()`. Pre-fix, if the meter value happened to be constant, `cycle()` was
|
||
never called again - so that claim had no way to run. Constant meter, SoC entity
|
||
dying at t=4 s, same binary:
|
||
|
||
```
|
||
pre-fix writes: [(0.0, 220.0), (0.0, 220.0), (10.1, 220.0)]
|
||
final target: 220.0 W reason: 'loop -> 220 W'
|
||
post-fix writes: [(0.0, 220.0), (0.0, 220.0), (3.0, 430.0), (9.1, 0.0), (10.1, 0.0)]
|
||
final target: 0.0 W reason: 'safety -> 0 W'
|
||
```
|
||
|
||
The pre-fix controller held 220 W through the entire input outage with its
|
||
status line still reading `loop -> 220 W`, and layer 1 does not cover that case
|
||
on a `meter_source: off` install: no P1 age sensor is published, so the firmware
|
||
has nothing to veto on, while the heartbeat keeps its watchdog fed. So the old
|
||
behaviour was not "holds a stale command safely" - it was a failsafe that a
|
||
repeating meter switched off. `test_loop.py` asserts both halves.
|
||
|
||
`cycle_floor_s` (default **15 s**) is a deadline on the gap between cycles, not
|
||
a second scheduler: every cycle resets it, whichever trigger fired. On a healthy
|
||
meter it never elapses, so the cycle rate, the write sequence and therefore the
|
||
per-cycle gain the tuning was measured against are unchanged - asserted in
|
||
`test_loop.py` by driving the real loop twice on one binary (a huge
|
||
`cycle_floor_s` reproduces the old behaviour exactly) and comparing the
|
||
commanded values.
|
||
|
||
The reset is not the only thing holding that up, and the other argument is the
|
||
sturdier one: the combined cycle rate is `max(arrival rate, 1/floor)`, never the
|
||
sum. At 1/15 s the floor is strictly slower than the ~1/5 s arrival rate, so
|
||
even a hypothetical floor racing the arrivals could not exceed the nominal
|
||
healthy rate. The re-tune is unreachable by construction. ⚠️ That holds only
|
||
while `cycle_floor_s` stays above the meter's update interval, and the schema
|
||
minimum is 5 s - which is exactly the reference meter's interval. Nothing
|
||
enforces the relationship; `validate.py` is the obvious home for it and does not
|
||
have it yet.
|
||
|
||
Observed over six real minutes on the same rig, one binary, meter held at a
|
||
literally constant 350.0 W: **before, 1 cycle** (at startup) and then nothing,
|
||
the command stuck at 220 W against a 350 W import for the rest of the window.
|
||
**After, 24 cycles**, worst gap 15.14 s, and the ordinary PI ramp doing its job -
|
||
220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W, one step per cycle,
|
||
rail at t=136 s and flat after. Three minutes with the meter parked at the
|
||
target instead: **1 cycle before, 12 after, and 19 writes either way - the same
|
||
19**. Three minutes on a normally-changing meter: **36 cycles and 49 writes on
|
||
both sides, the same values in the same order.** The heartbeat write count never
|
||
moves; the only writes the fix adds are the ten the loop was supposed to be
|
||
making all along.
|
||
|
||
15 s is bracketed, not round.
|
||
|
||
**Below it, ~11 s, and it arises more routinely than a dropped poll.**
|
||
`HomeWizardLocalSource` polls at 5.0 s against a meter that updates every 4.97 s
|
||
(NOTES.md:640). Two near-equal periods beat, so roughly once every ~14 minutes
|
||
the poller steps over an update entirely - a ~10 s gap between changed values on
|
||
a perfectly healthy meter, plus up to 1 s for the 1 s tick to notice. A failed
|
||
HTTP poll (retried on the next tick, no backoff) adds another 5 s. The floor
|
||
sits above the beat-skip so it does not fire on that alone; what it takes to
|
||
reach 15 s is a beat-skip *and* a failed poll coinciding.
|
||
|
||
**Above it, 30 s.** `meter_max_age_s` is 30 s, past which there is no reading to
|
||
evaluate, and the firmware's ingest veto is also 30 s - so the loop is
|
||
guaranteed to have re-decided at least twice inside any window in which layer 1
|
||
might otherwise be the only thing deciding. ⚠️ The default is therefore not
|
||
independently justified at 15: if the firmware's veto ever moves, this moves
|
||
with it. Noted beside the constant in `main.py`.
|
||
|
||
It is also exactly `stale_input_s`, this system's existing answer to "how long
|
||
may we hold without re-deciding".
|
||
|
||
A floor cycle runs the **full** law on the same inputs, integrator included. In
|
||
P1 mode the freshness question is already answered upstream and answered harder
|
||
- past `meter_max_age_s` the reading is None, which is "inputs missing", which
|
||
is a `P_SAFETY` 0 W claim - so a floor cycle there can only ever run on a reading
|
||
this system already calls fresh. ⚠️ That is transport-conditional: with
|
||
`meter_source: off`, `read_inputs()` takes the grid figure straight from
|
||
`hass.number()` with **no age gate at all**, so a Home Assistant entity that has
|
||
gone quiet reads as current on that path, with or without this change. Running
|
||
the full law is still right there - the alternative is a degraded second law
|
||
reasoning about an age nobody measured - but the floor does not make that path
|
||
fresh. The integrator advances per *cycle*, not per second,
|
||
so bounding the gap bounds the accumulation; `integrator_max_w` bounds it after
|
||
that. A gentler second law for floor cycles would be a second set of dynamics
|
||
nobody can test on this hardware.
|
||
|
||
⚠️ **This is not a freeze detector**, and TEL-05 established one cannot be built
|
||
here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move
|
||
while the power figure legitimately repeats, so any threshold on `unchanged_s`
|
||
false-trips at the exact operating point we run at. Nothing in this change
|
||
decides whether a repeated reading is *true*. It stops "the number is the same"
|
||
from meaning "do not think". The consequence, stated plainly: against a meter
|
||
genuinely frozen at a wrong value the loop will now correct toward that wrong
|
||
value rather than hold a stale command, bounded by `max_w`, the arbiter and the
|
||
device rating. That was already what happened to a lying meter whose value
|
||
merely jittered; bit-exact repetition was never a safety property.
|
||
|
||
`DOCS.md`'s "Saturation is counted in cycles, not seconds" section documented
|
||
the old behaviour as an accepted deviation. It is **qualified, not deleted**:
|
||
the deviation survives (the window is still counted in cycles, and 3 x 15 s is
|
||
still not the specification's 10 s) but it is now bounded at 45 s instead of
|
||
open-ended, and the passage's second justification - "nothing accumulates while
|
||
it is stalled" - is called out as having covered integrator windup and not
|
||
staleness, which was the actual hazard. The same wrong argument in
|
||
`control.py`'s saturation comment is corrected too.
|
||
|
||
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
|
||
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
|
||
unsigned registers and refuse negatives, so neither fits the meter installed here.
|
||
|
||
**TEL-05.** `meter_source: homewizard_local` polls the meter's own local API
|
||
directly. This exists because of something the HA paths cannot do: Home Assistant
|
||
fires no state change for a repeated reading and exposes no arrival signal at
|
||
all, so `sensor.p1_sample_age_s` on those transports measures time since the
|
||
*value changed*, not since the meter *reported*. Real captured data has gaps of
|
||
42 s and 97 s between changes on a perfectly healthy meter - long enough for a
|
||
30 s staleness threshold to command 0 W for no reason. Every HTTP response to the
|
||
local API is a genuine arrival, so the age means what it says.
|
||
|
||
Verified on the rig against a literally constant 350.0 W for 70 s - the exact
|
||
state where the HA path recorded zero samples in 45 s - with 14 arrivals and the
|
||
age never exceeding the poll cadence.
|
||
|
||
Set `p1_host` to the meter's address and `meter_poll_s` (default 5 s, matching
|
||
the meter's own ~5 s update). Still defaults to `off`; nothing changes for an
|
||
install that does not opt in.
|
||
|
||
Freeze detection is separate and deliberately not thresholded: a meter answering
|
||
200 OK with a stale number *is* arriving, so no arrival stamp can flag it. The
|
||
energy registers carry that as `unchanged_s`, surfaced on the status line for a
|
||
human to read rather than alarmed on - at a converged -10 W a 1 Wh register needs
|
||
about six minutes to move, so any threshold would false-trip at the target
|
||
operating point.
|
||
|
||
## Unreleased
|
||
|
||
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
|
||
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
|
||
Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s`
|
||
(default 5 s, the meter's own update rate) sets the cadence.
|
||
|
||
✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when
|
||
the meter *reported*, and therefore the first one a firmware watchdog may
|
||
threshold.** Every HTTP response is an arrival: the meter answered, now, with
|
||
its current reading, and whether the *number* moved is not consulted. Home
|
||
Assistant cannot express that at all — a repeated reading emits no
|
||
`state_changed`, advances `last_reported` on neither serialiser, and
|
||
`state_reported` is not subscribable ("Event filter is required"). On
|
||
`ha_signed` that made a healthy meter under a flat load indistinguishable from
|
||
a dead one, and our own capture of this house's meter goes 42.2 s and 97.0 s
|
||
between changes — both past the default `meter_max_age_s` of 30, i.e. a false
|
||
trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this
|
||
mode.
|
||
|
||
Everything TEL-01 established is reused, not re-implemented: ingest
|
||
timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility
|
||
bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign
|
||
convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it
|
||
is a *missing* reading — never 0 W — and it does not reset the age.
|
||
|
||
Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault
|
||
freeze` injected. Still defaults to `off`.
|
||
|
||
⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival
|
||
detector can: a meter answering `200 OK` forever with a stale number is
|
||
arriving. The local API does expose what the HA path never had — the
|
||
`total_power_*_kwh` registers stop advancing — and the transport tracks it as
|
||
`unchanged_s`, but that is deliberately **not** folded into the age and not
|
||
thresholded: this controller regulates grid power toward ~0 W, and at a
|
||
converged −10 W the export register needs six minutes to move by its 1 Wh
|
||
resolution while the power figure legitimately repeats. Thresholding it at 30 s
|
||
would rebuild the false-trip limit cycle at the exact operating point we aim
|
||
for. Freeze detection needs the low-power case solved first, separately. It is
|
||
shown on the status page's P1 line instead — leaving it unthresholded only
|
||
holds up if a human can read it, so now they can.
|
||
|
||
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
|
||
Assistant entity: positive = import, negative = export. That is the shape a
|
||
HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter
|
||
actually fitted here - which neither TEL-01 transport can read, because
|
||
`ha_dsmr` needs two unsigned registers and refuses a negative one, i.e. every
|
||
exporting telegram. Set `p1_net_entity`, and `p1_phase_net_entities` for the
|
||
per-phase capacity-tariff figures on a three-phase connection.
|
||
|
||
Everything TEL-01 established is inherited rather than re-implemented - the
|
||
new transport is a subclass of the `ha_dsmr` one overriding only which
|
||
entities it wants and how they become a sample. So ingest timestamping,
|
||
`meter_max_age_s`, `sensor.p1_sample_age_s` recomputed against the clock and
|
||
republished once a second, the plausibility bounds, and `unavailable` /
|
||
`unknown` treated as a *missing reading and never 0 W* all behave identically
|
||
across the three sources.
|
||
|
||
Still defaults to `off`; an existing install is unaffected until it opts in.
|
||
|
||
⚠️ **`sensor.p1_sample_age_s` is published on `ha_signed`, but must not yet be
|
||
thresholded by the ESP32 stale-input watchdog.** On the HA WebSocket paths the
|
||
age is stamped from `state_changed`, so it measures time since the value
|
||
*changed*, not since the meter *reported* - and Home Assistant exposes no
|
||
arrival signal for a repeated reading (no `state_changed`, no `last_reported`
|
||
movement on either serialiser, and `state_reported` is not subscribable over
|
||
the WebSocket). Measured on the ENV-01 rig against the real HomeWizard
|
||
integration. `ha_dsmr` mostly escapes it because a telegram moves several
|
||
entities at once; `ha_signed` has one, so a healthy meter under a flat load is
|
||
indistinguishable from a dead one. Our own capture has the house meter going
|
||
42.2 s and 97.0 s between changes. Raising `meter_max_age_s` does not fix that,
|
||
it only chooses which error you get; the fix is an arrival stamp from the meter
|
||
itself and is a separate ticket. Full detail in DOCS.md.
|
||
|
||
## 0.3.0
|
||
|
||
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
||
bounded independently of the output clamp instead of inheriting whatever
|
||
headroom the clamp happened to leave. It also freezes while the inverter is
|
||
not tracking, rather than continuing to wind up against a command nothing is
|
||
acting on. `integrator_max_w` (default `0`) governs the bound; `0` means
|
||
"follow `max_w`", which is the existing behaviour.
|
||
|
||
Behaviour is unchanged at the defaults - a 4,928-case equivalence sweep
|
||
against the previous control law confirms it decides identically at
|
||
`integrator_max_w: 0`.
|
||
|
||
**TEL-01.** P1 meter ingestion, so a Belgian P1's two unsigned registers
|
||
(consumption, injection) no longer need a hand-written signed template
|
||
sensor: the subtraction moves into the add-on, done once and tested. Two
|
||
transports, chosen with the new `meter_source` option: `ha_dsmr` subscribes
|
||
to the DSMR integration over the HA WebSocket, `mqtt_p1` reads a topic.
|
||
Defaults to `off`, which keeps the existing `meter_entity` path untouched -
|
||
nothing changes for an install that does not opt in.
|
||
|
||
Enabling it publishes `sensor.p1_sample_age_s`: seconds since the newest
|
||
accepted telegram, recomputed against the clock and republished roughly once
|
||
a second rather than only when a telegram lands. That is deliberate - Home
|
||
Assistant only pushes a state on change, so a meter sitting at a genuinely
|
||
constant reading would otherwise look identical to a dead one. Watching the
|
||
age instead means a frozen meter shows a climbing age, not a flat line. The
|
||
firmware watchdog subscribes to this exact entity id.
|
||
|
||
Known limits, both already in DOCS.md: on `mqtt_p1`, a bridge stuck
|
||
republishing its last telegram still "arrives", so the age cannot detect
|
||
that particular failure - prefer `ha_dsmr` where both are available. And a
|
||
dead P1 meter takes 45 s to reach 0 W commanded (30 s for `meter_max_age_s`
|
||
to call the reading stale, then 15 s of `stale_input_s` on top), which is
|
||
`meter_max_age_s` and `stale_input_s` stacking, not either one alone.
|
||
|
||
## 0.2.1
|
||
|
||
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
|
||
holds any resting point inside it indefinitely, and the meter bills import and
|
||
export on separate registers, so resting at +14 W import costs 0.34 kWh/day
|
||
with the loop behaving perfectly. Biasing the target slightly negative moves
|
||
that residue onto the export register. Configurable in the Configuration tab;
|
||
see DOCS.md for the trade-off table.
|
||
|
||
Behaviour is unchanged at `target_grid_w: 0`.
|
||
|
||
## 0.2.0
|
||
|
||
Precedence between strategies is now a first-class object instead of an if/else
|
||
ladder, ahead of there being more than three of them.
|
||
|
||
Every strategy returns a CLAIM each cycle - `set` ("I want X") or `limit` ("the
|
||
result must stay within these bounds") - and `arbiter.py` resolves them by one
|
||
rule:
|
||
|
||
1. Highest-priority `set` wins; no claim at all means 0 W.
|
||
2. Then every `limit` whose priority is >= that set's priority applies, most
|
||
restrictive first.
|
||
3. Contradictory limits are a BUG: command 0 W and say so.
|
||
|
||
Clause 2 is why "money outranks maintenance" is now a consequence of the
|
||
priorities rather than a special case in a Jinja template: the maintenance
|
||
charge-only limit binds the loop, but will not bind a higher-priority peak
|
||
shaving claim when one exists.
|
||
|
||
- Maintenance shaping (charge-only, cheap-window floor) moved out of the control
|
||
law. `control.py` is once again only a controller that tracks the meter.
|
||
- The loop computes from the ARBITER's last output, not its own last wish. If
|
||
something outranked it, that is what the hardware actually did, and tracking
|
||
anything else makes it jump when it regains control.
|
||
- Every decision is explainable: "loop -> 0 W, limited by maintenance
|
||
(charge-only)" now appears in the UI and the log, instead of a bare number.
|
||
- Safety limits (device rating, supervised max_w) bind every strategy including
|
||
the highest, and are still enforced a second time at the point of writing.
|
||
|
||
## 0.1.6
|
||
|
||
Findings from installing this on a live system, replacing a working YAML
|
||
implementation. Every one of these was silent - the add-on looked healthy while
|
||
being completely unable to do its job.
|
||
|
||
- **`run.sh` must use `#!/usr/bin/with-contenv sh`.** The HA base images run
|
||
s6-overlay, which starts services with a SANITISED environment. With a plain
|
||
shebang, SUPERVISOR_TOKEN is simply absent and every Home Assistant call
|
||
returns 401 - while `homeassistant_api: true` makes the permissions look
|
||
correctly granted. Startup now logs the token length and probes the Core API,
|
||
so the next person sees it in one line.
|
||
- **Bump `version:` for every change.** Supervisor keys the built image by
|
||
version, so editing source and rebuilding silently reuses the old image. Two
|
||
fixes appeared not to work because of this.
|
||
- **Dependencies come from apk, not pip.** Alpine is musl and there are no musl
|
||
wheels for aiohttp; pip would compile it on the client's Pi.
|
||
- **paho-mqtt 1.x and 2.x are both supported.** Alpine ships 1.x, which has no
|
||
`CallbackAPIVersion`; that raised and took the whole add-on down with it.
|
||
- **MQTT can no longer take down control.** Publisher construction is wrapped -
|
||
observability must never stop the controller.
|
||
- **MQTT discovery is published from `on_connect`.** paho drops QoS-0 publishes
|
||
issued before the CONNACK, so announcing straight after `connect()` published
|
||
nothing at all while logging "MQTT connected".
|
||
- **Repeated failures log at most once a minute.** The control loop retries every
|
||
second; unthrottled warnings rolled the log buffer and destroyed the startup
|
||
diagnostics needed to debug the 401 above.
|
||
- **`auto_start` works.** The store's defaults supplied `auto: False`, so the
|
||
fallback to the option could never fire.
|
||
|
||
Known issue: after deleting the MQTT entities from the registry during
|
||
development, Home Assistant would not re-adopt them from retained discovery -
|
||
not even after clearing the retained topics and reconnecting. The add-on
|
||
publishes correct discovery and live state (verified on the broker); this is an
|
||
HA-side adoption problem and affects status entities only, never control.
|
||
|
||
## 0.1.0
|
||
|
||
First packaged release. Ports the control loop and the monthly maintenance
|
||
cycle from the reference Home Assistant implementation into an add-on.
|
||
|
||
- Grid-following control: gain/slew/clamp/deadband with anti-windup, all tuned
|
||
against measured hardware behaviour (see FIELD-GUIDE.md §14).
|
||
- Saturation freeze **with the duration term** — three consecutive diverging
|
||
cycles, not one. The instantaneous test fires on every large correction,
|
||
because the plant itself needs 3-6 s to settle.
|
||
- Monthly maintenance cycle as an ownership state machine: drain / charge /
|
||
hold, with exactly one writer of the setpoint at any moment.
|
||
- Capacity-tariff awareness: the maintenance charge is capped by quarter-hour
|
||
peak headroom, and peak shaving outranks the maintenance schedule.
|
||
- Failsafe behaviour: commands 0 W on missing inputs, on stop, and on shutdown.
|
||
Never replays a stale setpoint - the reference implementation did, and the
|
||
hardware watchdog cannot catch that.
|
||
- Ingress UI with a commissioning checklist that names problems in words.
|
||
- Optional MQTT discovery for status entities.
|
||
|
||
Known limits:
|
||
|
||
- Home Assistant OS / Supervised only (add-ons cannot run on Container/Core).
|
||
- The inverter protocol is reverse-engineered; no vendor contract.
|
||
- Without the optional RS485 e-stop, nothing covers the host machine dying.
|