`config.yaml` 0.9.0 -> 0.10.0, and the top `## Unreleased` block is closed
into `## 0.10.0` with a preamble that names the backward-incompatible
changes and who they reach.
MINOR, and NOT because eighteen tickets landed. A stored options blob the
previous version started on is REFUSED on this one:
* TARIFF-07 retires three `price_provider` ids; a site set to one is
refused BY NAME at startup. Measured on the reference blob, with a
control on an accepted id so the refusals are about the ID.
* TARIFF-18 moves `price_taxes_eur_kwh` 0.06 -> 0.11736. A site that
STORED 0.06 is untouched, and - the non-obvious half - a taxes period
table beside that stored 0.06 does NOT become a startup refusal on
upgrade, because `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH` reads it as
untouched. Measured, with the counterfactual that the rung is not
weakened (ORES's 0.1198 is still refused).
* SAFETY-10 adds `shadow_mode`, shipped `auto`. An existing CONTROLLING
install does not change by one watt and a FRESH one comes up observing.
Measured cross-revision, 8f82dd4 -> e6da478, same stimulus through the
shipped `Controller.cycle()`: byte-identical write streams, 13/13/25
writes. Positive control: the same driver at `shadow_mode: on` gets 13
writes to 5000 W on the older tree and one 0 W write on the newer.
And the one a reader of the newest section alone would miss: `ha addons
update` does not walk the versions, so a 0.7.0 install meets 0.8.0's and
0.9.0's rules at the same moment as 0.10.0's. Of those, SAFETY-39's rule 10
refuses 0.7.0's OWN shipped default `slew_w: 1000` against a 555.6 W
ceiling on a 5000 W rail. Measured red on this tree; the reference
install's `slew_w: 400` is measured green.
SCOPE, and it is drawn honestly rather than tidily. The top block's 18
entries all merged after the 0.9.0 bump `86bdf49`, so the block is closed
as-is and its `_this branch_` placeholders are resolved to merge shas.
⚠️ 0.10.0 SHIPS THREE MORE RUNTIME TICKETS THAT ARE NOT IN THAT TABLE -
SAFETY-43, TEST-21 and SAFETY-44 - and they are NAMED in the preamble
rather than moved, because re-filing this file's misplaced entries is
PKG-08. SAFETY-44 (`app/main.py`, `app/store.py`) is in the changelog
nowhere at all; that is said in both places. The second `## Unreleased`
block below `## 0.9.0` is ANNOTATED, not rewritten or emptied - UX-02 in it
shipped in 0.9.0, the other three in 0.10.0. No released section is
rewritten.
`test_version_gate.py` gains part 11: the bump and the closed section are
one act. PKG-09 refuses a deploy whose version did not move; nothing graded
the mirror failure, in which the version moves and the block is never
closed. Two sides from two files, a slicer with a synthetic positive
control run on every invocation, the retired ids read off `app/price.py` by
AST rather than transcribed, and the count of still-misfiled blocks frozen
so it can only shrink.
Mutations, all purged, tree bind-mounted :ro, backups at mktemp paths and
the revert fingerprinted:
M1 `## 0.10.0` -> `## Unreleased` +4 B RED 2
M2 version 0.10.0 -> 0.10.1 +0 B RED 1 (ZERO-BYTE)
M3 an id stripped from the NEWEST +10 B RED 1
section only, left below
M4 a 4th retired provider in price.py +62 B RED 1 here, 4 in
test_price.py
M5 `## 0.9.0` heading lost -3 B RED 1
M6 a 2nd `## Unreleased` left standing +27 B RED 1
M7 the slicer never closes -32 B RED 3
M8 SUPERSEDED_TAXES... emptied -5 B RED 2 on the evidence
instrument, plus
test_validate/test_price
⚠️ M5 WAS A SURVIVOR ON THE FIRST DRAFT. The slicer control was a sentinel
(`## 0.8.0`) that only a slicer running to EOF could reach, and deleting one
heading never gets there - ARCH-02's fifteenth instance, in a fresh costume.
11a now asserts the invariant instead: exactly one contents table per
section, which is what a lost heading breaks.
29/29 test files green. `tools/predeploy_check.py --deployed-version 0.7.0
--options <live blob>` CLEARED, exit 0, before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
368 KiB
Changelog
0.10.0
⚠️ A CONFIG THAT IS LEGAL ON 0.9.0 IS REFUSED ON 0.10.0, AND THAT - NOT THE
NUMBER OF TICKETS - IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH. Same
shape as 0.8.0's slew_w rule and the same test: is there a stored options
blob the previous version started on and this one will not? There is. A refusal
is SystemExit(78) (EX_CONFIG) - the add-on does not start, ⚠️ and the
ingress panel goes with it, because the add-on serving it is not running, so
recovery means the Supervisor UI.
Measured on the reference install's own exported blob (70 keys), merged over this version's 75 defaults the way the Supervisor merges it, inside the CI image. The three below are what a reader who checks only one thing should check.
-
⚠️ TARIFF-07 - THREE
price_providerIDS ARE RETIRED, AND A SITE SET TO ONE OF THEM IS REFUSED BY NAME AT STARTUP.engie_dynamic,totalenergies_dynamicandeneco_eddyare gone fromPROVIDERS. Each was accepted, silently, at every startup on 0.9.0 and earlier; on 0.10.0 each is a refusal that names the id, the endpoint, what that endpoint actually answers - a 404 from a gateway with no route, a TLS handshake against a hostname the certificate does not cover, a 404 with an empty body - which tariff the site has really been running since the id was entered, and what to set instead. The refusal IS the migration. Every fetch any of the three ever made ended inSourceUnavailable, and §11.4 turns that into a SILENT fall back to the static profile, so there is no working behaviour to preserve and nothing on the status page ever differed from a provider that simply never found a cheap hour. ⚠️ All three ids stay inconfig.yaml'sprice_providerenum on purpose: drop one and the Supervisor rejects the options file before the add-on runs, the operator gets an opaqueexpected one of ..., andvalidate.py's message is never seen - on exactly the sites that need it.PROVIDERSis three; the enum is seven. ⚠️ The reference install is onprice_provider: offand is unaffected. -
TARIFF-18 -
price_taxes_eur_kwhmoves from0.06to0.11736, and the non-obvious half is the refusal that deliberately does NOT happen. A site that stored0.06is unaffected in both directions. It keeps0.06, becauseApp.optionsisdeepmerge(config.yaml defaults, stored options)and a moved default reaches only keys the blob does not carry - so the new figure arrives at a fresh install, and at an existing one only when its options are re-saved. ⚠️ AND IT DOES NOT BECOME A STARTUP REFUSAL EITHER, WHICH TOOK A MECHANISM. TARIFF-17 made aprice_taxes_profileperiod table beside a non-defaultprice_taxes_eur_kwha refusal, comparing against the shipped default precisely because the Supervisor writes the key whether the operator typed it or not. Moving the default turns every stored0.06into "non-default" with nobody editing anything, so a working Walloon or Brussels site with a period table would have stopped starting on this upgrade, over an inert number the table overrides anyway.price.pycarriesSUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)andvalidate.pyreads a superseded default as untouched. The rung is not weakened: the same table beside a figure that is neither the shipped nor a superseded default - ORES's0.1198- is still refused, by name, and the refusal now says which two figures it will accept. -
SAFETY-10 - a new
shadow_modeoption, shippedauto, and BOTH halves of what that does matter. ⚠️ AN EXISTING INSTALL THAT IS CONTROLLING DOES NOT CHANGE BY ONE WATT.autois not a constant the Supervisor merges; it is a value the add-on resolves, and it resolves tonot control_startedshadow.engaged()readsController.auto, the persisted start/stop state. An install withauto_start: true, or whose operator has pressed Start / stop control, is started, soautoresolves to live. An install that is stopped keeps its actuator at 0 W exactly as before, from the same writer on the same heartbeat; what changes is what the status page says and that the decision path now runs and is recorded. ⚠️ A FRESH INSTALL COMES UP OBSERVING: it shipsauto_start: false, has no storedautokey, and therefore runs its whole decision path in shadow before it is ever allowed to command anything. That is the "default-on" the card asked for, delivered where it is safe and withheld where it is not - there is no mechanism in the add-on options system by which one key can default differently for a new install and an existing one, so a literalshadow_mode: truedefault would have stopped the battery at the reference site on the next update. Measured cross-revision - the same stimulus through the shippedController.cycle()on8f82dd4(the merge before SAFETY-10) and one6da478(SAFETY-10 itself), start button ON: the write streams are byte-identical, 13 / 13 / 25 writes on a 6000 W import, a 4000 W export and a 300 W trickle, same values in the same order. An install that is stopped is byte-identical across the same pair too, at one 0 W write. ⚠️ The positive control is what makes that readable: the same driver withshadow_mode: onand the button ON gets 13 writes up to 5000 W on the older tree and one 0 W write on the newer, so the driver does reach shadow mode and an identical pair is a result rather than a silence. The shadow case is also graded intest_clamp.pypart 8 on what reachedset_number(), against a precondition run that puts 30 non-zero writes on the wire. ⚠️ An unrecognisedshadow_modefails toward inaction and is refused by name (rule 11), so the reinterpretation is never silent; no blob written before 0.10.0 can carry one.
⚠️ AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL IS -
THE REFUSAL THAT CAN STOP YOU IS IN 0.8.0'S SECTION, NOT IN THIS ONE. ha addons update does not walk the versions; it installs 0.10.0 in one step, so
every validator rule added in 0.8.0 and 0.9.0 lands at the same moment as
0.10.0's own, and a config that has not been looked at since 0.7.0 meets all
of them at once. Of those, exactly one can refuse a config nobody has edited:
SAFETY-39's rule 10, added in 0.8.0, refuses an explicit
slew_waboveSLEW_RAIL_FRAC xthe site rail, whereSLEW_RAIL_FRACisLOOP_TICK_S / PLANT_SETTLE_S= 2/18. On a 5000 W rail that ceiling is 555.6 W - and ⚠️ 0.7.0's own shipped default wasslew_w: 1000, so an install that never touched the key and simply took the default does not start on 0.10.0 until the value is lowered.
Measured on this tree: the reference blob with slew_w: 1000 is refused, by
name, and the same blob at its actual slew_w: 400 is accepted. 0.9.0's own
additions cannot reach a pre-0.9.0 config at all - OPT-09's two refusals
fire only on a roster entry that USES capacity_kwh, a key that did not exist
- so 0.8.0's is the whole of the crossing. Read the live options and check
slew_wagainst the ceiling andprice_provideragainst the withdrawn list BEFOREha addons update, withtools/predeploy_check.py, which does both.
✅ RUN AGAINST THIS RELEASE: CLEARED, exit 0.
docker run --rm -v <repo>:/w:ro -v <dir>:/opt:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.7.0 --options /opt/live-options.json
deployed on the box : 0.7.0
shipped in this tree: 0.10.0
live options pass this version's validator
CLEARED for deploy.
The option surface: 71 keys -> 75, options: and schema: in step. Four
new keys, and none of them can refuse a blob written before it existed:
price_formula, price_export_formula and price_taxes_profile are str? -
note the trailing ?, they are OPTIONAL - defaulting to "";
shadow_mode is list(auto|on|off) with no ?, which is the shape that
becomes exit 78, and it is safe for the same reason 0.9.0's delivery_hold_s
was: it carries an options: default, so the Supervisor's merge supplies it to
every stored blob that lacks it. Nothing was removed. One default moved
(price_taxes_eur_kwh, above) and one enum widened (price_provider gains
custom_formula).
⚠️ WHY 0.10.0 AND NOT 0.9.0, SINCE 0.9.0'S IMAGE NEVER REACHED THE BOX.
The block this section closes used to say the choice depended on whether
0.9.0 had been built and pulled, and that if it had not, this work belonged
inside 0.9.0's section. That was written before 0.9.0's section was cut. It
has been cut since 86bdf49, on 2026-09-07, and 28 runtime commits have
landed behind it; folding them in would make the string 0.9.0 name two
different trees, and the Supervisor keys the built image on exactly that
string. Reusing it is the PKG-06/PKG-07 no-op in its worst form - a cached
image the Supervisor is entitled to serve while reporting success. The version
a tree has already shipped under is not available to be re-cut.
⚠️ AND THIS SECTION IS NOT THE WHOLE OF WHAT 0.10.0 SHIPS. THREE TICKETS
WITH RUNTIME CHANGES LANDED AFTER 86bdf49 AND ARE NOT IN THE TABLE BELOW.
Saying so is cheaper than moving them, and moving them is PKG-08's card,
not this one:
| ticket | runtime files | where it is written down |
|---|---|---|
| SAFETY-43 | app/delivery.py, app/packshare.py, config.yaml |
the ## Unreleased block below ## 0.9.0, which is misfiled |
| TEST-21 | app/delivery.py, config.yaml, translations/ |
the same block |
| SAFETY-44 | app/main.py, app/store.py |
⚠️ nowhere in this file |
Five more landed that touch no runtime file and so cannot change what the
image does - PKG-13, UX-04, SAFETY-31, ARCH-02, INSTR-01. The count that
matters for the no-op check is tools/predeploy_check.py's own: 28 runtime
commits since 86bdf49, of which the table below accounts for 21.
Contents of this release, in merge order since 0.9.0 (86bdf49):
c2787da |
FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
5059e28 |
FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
958f5cd |
FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
4257e38 |
TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
88c59a9 |
TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
559f0eb |
TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
a6e64fb |
TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
8d09b30 |
TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
aade2c7 |
TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so ⚠️ BACKWARD-INCOMPATIBLE |
068b83f |
TARIFF-15 - custom_formula: your own tariff card's two coefficients, graded against the card's own printed price |
b4a3801 |
TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving |
c518007 |
TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface |
7796f16 |
TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
4dcd9b0 |
TARIFF-18 - price_taxes_eur_kwh stops shipping at half the real Flemish figure ⚠️ CHANGED DEFAULT |
478ba93 |
TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero |
8f82dd4 |
SAFETY-45 - a price-driven charge is SIZED by the capacity-tariff guard instead of being switched on and off by it ⚠️ CONTROL STABILITY |
e6da478 |
SAFETY-10 - shadow mode: the whole decision path runs and nothing reaches the inverter ⚠️ NEW OPTION, DEFAULT-ON FOR A NEW INSTALL |
587495e |
SAFETY-47 - what a shadow run can show you, and the watts on its status row ⚠️ CORRECTS THREE CLAIMS SAFETY-10 SHIPPED |
SAFETY-47 — what a shadow run can show you, and the watts on its status row
⚠️ Nothing about the write path changed, and nothing needed to. Shadow mode still holds every inverter at 0 W through the one writer. What was wrong is what three merged documents said a shadow run can show you, and a number on the status page that could be 6.61× what a live controller would command.
A shadow run cannot show you a closed-loop defect. Not partially — not at all. The battery is held at 0 W, so every measured input — the meter, the billed-quarter accumulator behind it, the battery power, the state of charge — describes a plant that did not respond, and a closed-loop defect is one whose stimulus is the plant's response. That is the same property that makes shadow mode safe, so it is not a gap to be closed later: the closed-loop answer comes from the closed-loop rig or from a live run.
SAFETY-10 promised otherwise, because of a sentence that was true when it was written and false one commit later:
the strategies are unaffected — they ask for a fixed power from inputs that do not depend on what the battery did — so a shadow run does show you the trades, the peak-guard engagements and the SoC-limit behaviour.
SAFETY-45 (above) sizes the buy leg to the billed quarter's charging headroom,
and that headroom is (900 / seconds remaining) × (ceiling − projected) with
the projection read off the open quarter's meter. The strategy became
closed-loop the day that landed. Measured on the rig, one hour at peak_cap_w
1500 under a 1200 W house:
| live | shadow | |
|---|---|---|
| charge onsets | 28 | 1 |
| grid | −455.5 … +3200.0 W | 1200.0 W flat — the house |
| capacity guard engaged | 410 / 720 cycles | 0 / 720 |
| state of charge | 50.000019 → 51.856789 % | 50.000000 → 50.000000 % |
What the documents now say. The exemption narrows to exactly what is still true — which hour a strategy picks and why are unaffected, because the clock and the price series are not functions of the battery; how hard it trades is not. Of the three defects shadow mode was written for, one — TARIFF-14's wrong quarter-hour — is genuinely visible from a shadow run, and it is graded that way: identical live and shadow on 720 of 720 charging cycles at a binding cap.
What you will see on the status page. The Shadow mode row and the action
log now introduce the watts as what the controller would have commanded on
this cycle — in every state, because no number a shadow run records is a
forecast of a live run. And when a measured billed quarter sized those
watts, both surfaces carry:
⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same trade against a meter the battery is moving, so this is an UPPER BOUND and not a prediction; how far under it a live run lands is a closed-loop question and a shadow run cannot answer it.
⚠️ The number is caveated, not re-computed, and that was the decision.
Re-computing it to "look right" would mean the recorded decision was no longer
the decision the shipped path reached — while the arbiter, the clamp, the
write-authorisation gate, the action log and MQTT all ran on the real one. A
shadow run's whole worth is that what it records is what the controller
actually decided. DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md §3
records the three alternatives that were rejected and why.
⚠️ On a site whose cap does not bind, nothing changes by one watt. At
peak_cap_w 15000 against a 350 W house the recorded figure is the live one
exactly, and no caveat is a lie there either — the row still says the size came
off a meter with no battery in it, which is true and is the point.
And SAFETY-10's own committed evidence could never have failed. Its
comparison table ran at a cap the guard never reaches, and read only the
first charging sample — the one instant at which the two runs agree even at a
cap that does bind. Both had to be closed; closing either alone closes nothing.
The table is re-taken across the whole run at a binding cap, and
test_arbitrage.py part 3d is the CI-side grader so this class cannot recur.
14 mutations, no survivors; all 29 check files green.
SAFETY-10 — shadow mode, and what its default does to YOUR install
A new option, shadow_mode, with three values and a shipped default of
auto. In shadow mode the add-on runs its entire decision path — strategies,
arbiter, clamps, guards — and sends nothing to the inverter: every inverter
is held at 0 W, refreshed on the ordinary heartbeat by the ordinary single
writer, and the status page says plainly that the battery is not being
controlled. What it would have commanded, and why, is on the status page and
in the action log.
⚠️ WHAT HAPPENS TO AN EXISTING INSTALL: NOTHING. auto resolves to shadow
mode only while control is stopped. An install that is controlling —
auto_start: true, or Start / stop control pressed — is started, so auto
resolves to ordinary control and not one watt changes. An install that is
stopped keeps its actuator at 0 W exactly as it did before, from the same
writer on the same heartbeat; what changes for it is what the status page and
the action log say, and that the decision path now runs and is recorded.
⚠️ AND THAT IS WHY IT IS NOT A BOOLEAN. The card is titled "default-on".
Taken literally — shadow_mode: true in config.yaml — it would be the worst
change in this repository's history: the Supervisor composes an add-on's
options as its defaults, overlaid with the stored user options, so a new key
with a new default reaches every existing install on the next update,
including the one regulating a real battery at the reference site. It would
have silently stopped it, and the only symptom would have been a battery that
stopped doing anything. There is no mechanism in the add-on options system that
lets one key default differently for a new install and an existing one, so
"default-on for new" is expressed as a value the add-on resolves rather
than a constant the Supervisor merges.
A new install ships auto_start: false, so it comes up in shadow mode and
observes a day of decisions before it is allowed to command anything. That is
the default-on the card asked for, delivered where it is safe and withheld
where it is not.
⚠️ A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE. TARIFF-07 withdraws engie_dynamic, totalenergies_dynamic and
eneco_eddy. price_provider set to any of those three is now refused at
startup — SystemExit(78) (EX_CONFIG), the add-on does not start — where
it was accepted before. That is as backward-incompatible as it gets, and it is
the point of the change rather than a side effect of it: see the next
paragraph for why starting was the worse outcome. Same shape as 0.8.0's
slew_w rule, and the same instruction follows — read the live options and
check price_provider against the withdrawn list BEFORE ha addons update.
That check is NOT performed by this branch and cannot be.
⚠️ THE REFUSAL IS THE MIGRATION, BECAUSE THERE WAS NOTHING TO MIGRATE. All
three pointed at endpoints that do not exist and never did — verified by
hand on 2026-09-13: a 404 from a gateway with no route, a TLS hostname the
certificate does not cover, and a 404 with an empty body. Every fetch any of
them ever made failed, and §11.4 turns a failed fetch into a silent fall
back to the static profile. So a site configured on one of these has been
running on static_profile — or on nothing at all, where none was set —
since the day the id was entered, with nothing on its status page ever
differing from a working provider that never found a cheap hour. The refusal
names the id, the endpoint, what that endpoint actually answers, which tariff
has really been in force, and what to set instead. Starting quietly is what
hid this; it is not an option that was on the table.
⚠️ THERE IS NO ALIAS AND THERE WILL NOT BE ONE. These were retail tariffs
of the form B × spot + A with per-contract coefficients that live behind
the customer's supplier login; CREG's tariff-card pages 404 and VREG was
unreachable, so there is no public index to read them from. An alias to
generic_dayahead — which reads wholesale — would have to invent the numbers
a battery is priced against. A formula provider is the planned answer and is a
separate card. (Annotated 2026-09-13: that card is TARIFF-15, below, and it
shipped on this branch as custom_formula. It is still not an alias — the
operator types their own coefficients; nothing is invented on their behalf.)
SAFETY-45 - the trade is sized by the capacity guard, not switched by it
A price-driven charge now asks the peak guard HOW HARD it may pull, instead of
only whether it may pull at all. On any site where load + max_w reaches
peak_cap_w - at the shipped peak_cap_w: 3500 and max_w: 2000, any house
drawing over 1500 W in the cheapest quarter - arbitrage and the capacity-tariff
guard were driving each other in a sustained limit cycle. Measured on TEST-23
stage 3's closed rig at peak_cap_w: 1500 against a 1200 W house: 28 charge
onsets in one hour, mean period 131 s, the grid swinging −456 to +3200 W, and
every billed quarter in the three affected configurations finishing over the
cap it was oscillating about. §10.2: "an EMS that oscillates is worse than no
EMS, both for hardware wear and - per §9.1 - for the peak it is trying to
avoid."
What was happening. The guard computes wanted = batt_w + excess - the
battery discharge that would land the quarter exactly on the ceiling - and files
a claim only while that number is positive. While a trade charges at max_w
the number is negative for the first minute or so of every cycle, so the guard
files nothing and the trade is not constrained at all. wanted then crosses
zero, the guard engages on a floor of a few watts, and that flips
arbitrage.guard_state() - which cancels the whole trade. The
grid-following loop takes the actuator, drives the meter to zero and past it,
the projection collapses, the guard releases, and the trade comes back at
max_w. 115 s later, again.
The fix, and it is a shape this add-on already had.
control.maintenance_charge_floor() has always capped a MAINTENANCE charge at
"the headroom left under the site's cap", because "a maintenance charge is the
only thing this system does that is big enough and long enough to set that
peak". ⚠️ That stopped being true when OPT-04 landed: a trade commands max_w
for a whole tariff interval, deliberately, against the meter.
peakguard.charge_headroom_w() publishes the same quantity off the measured
guard rather than off the optional peak_forecast_entity, and
arbitrage.decide() sizes the buy leg to it.
No new arithmetic and no new constant. The number is wanted_w read in the
other direction: a negative wanted is not "nothing to say", it is a permitted
charge of −wanted. peakguard.decide() is unchanged - byte-identical to
release/1.0, sha256 = 200090d0835a3a55 - so 2026-08-20's anti-bang-bang rung
is exactly where it was, and tools/replay_ac3.py reproduces the same 190/190
guarded quarters, 110 engaged cycles and 878 Wh on this branch as on the tip.
After, on the same rig, same command:
peak_cap_w / house |
onsets per hour | grid swing | energy bought | billed quarters |
|---|---|---|---|---|
| 1500 / 1200 W | 28 → 1 | 3656 → 282 W | 180 → 285 Wh | 1490/1497/1509 → 1500/1500/1500 |
| 3500 / 1800 W | 8 → 1 | 3673 → 1688 W | 1653 → 1616 Wh | 3511/3536/3559 → 3500/3500/3500 |
| 3500 / 3000 W | 32 → 1 | 3798 → 475 W | 483 → 475 Wh | 3519/3542/3557 → 3500/3500/3500 |
| 5000 / 1200 W | 1 → 1 | identical | identical | identical |
One onset means the trade starts once and runs for the whole window - the
battery is now trading 100 % of the cheap hour where it traded 43 %, 92 % and
54 %. Every billed quarter now lands on the cap and none over it. At the
aggressive cap the site buys 58 % MORE energy, because a continuous 300 W trade
beats a 2000 W trade that is cancelled for well over half the hour; at the two
shipped-default rows it buys about 2 % less and the worst quarter drops from
59 W over the cap to zero. The last row is the negative control - load + max_w
never reaches the cap there, and the two runs are bit-identical. This costs
nothing on the installs it is not about.
⚠️ NOT A CONFIG CHANGE. No option is added, removed or re-defaulted, and
arbitrage remains off in the shipped configuration. A site that has not armed
arbitrage_enabled is unaffected in every respect.
⚠️ WHAT IS NOT FIXED, AND IT IS NAMED RATHER THAN IMPLIED. TEST-23 finding 1
- the export overshoot at the END of any trade, 356-938 W, about 0.37 ×
max_w - is untouched and is a different card. A house whose load ALONE exceeds
peak_cap_wstill has the guard engage and the trade refused outright, which is correct, and the grid-following loop then drives to grid = 0 rather than to the ceiling. SeeDECISIONS/SAFETY-45-sizing-the-trade.md§5, and §2 there for the negative-lodesign that was rejected because it creates a newlo > hicontradiction against the maintenance charge shaping.
TARIFF-16 - the injection leg stops being one number
price_export_formula: what a site is PAID for export, as factor × index + offset with its own coefficients, alongside the flat export_price_eur_kwh.
⚠️ THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT.
arbitrage.py does not read export_price_eur_kwh at all — its own comment
says so, and gives §9.3's near-zero Belgian injection price as the reason the
economically sound trade on this market is charge cheap to displace expensive
import. Both hours it compares are all-in consumption prices. So no trade
this add-on takes today is decided by an injection price, and nothing here
changes one. What it changes is what an operator is told, and what any future
export-aware strategy rests on: OPT-06 and any export logic would otherwise
inherit a scalar that cannot represent the market, and the 6% VAT asymmetry
between the two legs would be invisible in the arithmetic. test_price.py 17h
parses every module under app/ and goes red the day something calls an
injection reader, so that claim is graded rather than attested.
The market publishes a line, not a number. Verbatim off the cards: Eneco
Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c€/kWh — "Het injectietarief is
dynamisch en kan per uur wijzigen", identical on the January and the September
2026 cards; ENGIE Dynamic (B x eSpot_15) - A; Aspiravi 0,07 x Belpex - 2.
Four properties a scalar cannot hold:
- Its own factor. Eneco
0,102on consumption against0,100on injection; Aspiravi0,07against about0,10on its consumption leg. Not the consumptionBwith a sign flip. - A negative offset —
− 1,188,− 2.price_markup_eur_kwh: float(0,1)cannot express a negative markup at all, so it has no home on the consumption side either. - VAT-exempt — "Injectietarieven zijn vrijgesteld van btw" — while
consumption is at 6%.
vat_pctis now per leg, so the six-point asymmetry is expressible; a scalar carries no VAT field at all. - It goes negative with no floor, below
11,88 EUR/MWhof BELPEX-H — the midday hours a PV household exports in. You can be charged to export.
⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED, and that needed a
different band rather than a lower floor. implausible_all_in() floors the
consumption leg at +0.02 EUR/kWh of energy, which is right there and fatal
here — measured, not assumed: test_price.py 17c puts one spec through both
bands and prints that the consumption band refuses −0.03008 EUR/kWh while the
injection band accepts it. The floor was not decoration, though: it is what
catches a card read a hundred times too small, the one unit error the card
self-check cannot see because it cancels out of a comparison between the two
sides. So the bound moves off the price and onto the coefficients, where it
is index-free and sign-free — a pass-through band (factor as a share of
the wholesale price; Eneco's injection leg is 1.00×, Aspiravi's 0.70×, the band
is 0.10–10×) and an offset magnitude bound (0.20 EUR/kWh against published
values of 0.01188 and 0.02). Two rungs that can both answer a hundredfold
error, so each is graded on its reason, each gets a stimulus the other
cannot reach, and they are mutated together (TEST-16).
⚠️ THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT
THAT IS STATED RATHER THAN WORKED AROUND. price_formula can require a card
block because a Belgian card prints the consumption formula, the reference index
and the resulting price. Eneco's injection formula is printed with no price
beside it. Requiring a block would make the market's own published line
inexpressible; inventing the estimate would grade the coefficients against
arithmetic done with the same coefficients. So the block is optional on this
leg and unchanged on the other, the self-check runs at the same tolerance
wherever a printed estimate exists, and validate.py says at startup —
"its coefficients are UNGRADED" — when it does not, naming the two bounds that
ran instead. This is TARIFF-15 demonstrating two tariffs end to end rather than
three, one card later.
ONE PARSER, NOT TWO. custom_formula.py already shipped the units table,
the NaN rule, the closed enumerations, the unknown-key refusal, the card
comparison and to_resolution()'s DST handling, so parse_export_formula() is
that parser with its option name as an argument — TARIFF-17's move on
parse_static_profile(), exactly. Everything that differs between the two legs
is a string in one _LEGS table. test_price.py 17b pins it two ways: by
parsing every module under app/ and asserting all 32 grammar names are defined
in exactly one of them, and by deriving twelve refusal sentences for the new
option from the old option's own sentence rather than transcribing them. The
three sentences that deliberately differ are named, with what differs, and
asserted to differ.
NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and
export_price_eur_kwh()'s body is byte-identical — pinned on the AST, not on
the source text — with export_price_at() falling through to it for every index
value. test_price.py 17f compares seven option dicts × five index values
against the pre-TARIFF-16 expression with ==.
export_price_eur_kwh and the new key are both in FRAMEWORK_KEYS;
BANNED_IN_PLUGIN_CODE is derived from that set, so code_leaks() refuses a
plugin that names either with nothing else edited.
Also corrected here: config.yaml's comment beside export_price_eur_kwh
still read "no shipped supplier publishes a dynamic injection curve".
TARIFF-07 struck that claim from app/price.py and DOCS.md and did not reach
config.yaml, where it has been shipping since.
20 mutations, every one red on a named check, ledger in test_price.py
below section 17 with byte deltas — including a zero-byte one (the two legs
transposed in the VAT-asymmetry banner, which changes no decision and only
moves the sentence) and a one-byte one (card_required=True on the
injection leg). Three were run over all 29 files and are red on test_price.py
alone; the rest over the five suites that can reach this card. Each row gets a
fresh copy of the tree under a mktemp path, mounted :ro — no revert to fail
and no shared backup path, which is MUTATION.md habit 18 closed structurally.
29/29 green.
TARIFF-17 - a price component that varies by hour has somewhere to live
price_taxes_profile: the taxes component as a period table, alongside the
flat price_taxes_eur_kwh. ⚠️ The flat scalar was not a wrong number, and
this is not a correction of one. The optimiser reads exactly two things off a
series — the spread max − min over the firm points, and which hour is
cheapest — and an additive constant identical in every hour cancels out of the
first and leaves the second where it was. It is provably inert. What was
missing was anywhere for an hour-varying non-energy component to live.
Belgium has one. Fluvius distribution is a single rate, and its row on the
tariff card reads Normaal | Uitsl. nacht — exclusive night being a separate
physical circuit, not a night register — so for the reference site in Flanders
nothing here changes. The Walloon DSOs and Sibelga are on the same card with
four columns, Enkelvoudig | Dag | Nacht | Uitsl. nacht, and the gap is real
money (c€/kWh): ORES 13,27 / 7,39, AIESH 15,17 / 8,21, Tecteo RESA
12,20 / 7,01, Sibelga 9,39 / 7,10, ORES IMPACT 2026 16,578 / 5,090.
⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO. The cheap network hours are the cheap spot hours, so flattening them does not shift the curve — it understates the spread and declines cycles that would have paid. Measured on one ordinary Belgian winter weekday, same feed, same markup, the only difference being the taxes argument:
flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500
ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788
Five different flat figures spanning and exceeding the table's own range give
the same hour and the same spread to the last digit; the table moves the
hour a battery would buy in from 13:00 to 03:00. That is the regression
test_price.py section 16 grades, and it grades the hour, not the spread:
an implementation that reads the table once and adds one number to every point
produces the right magnitude and the wrong shape, and a spread assertion passes
on it. DOCS.md now names the three buckets — LEVEL, SPREAD, ORDER — so the
next person adding a component knows which one it is in.
The change is contained. static_tariff.py already shipped the grammar —
periods tiling the day, gaps and overlaps refused, day types, DST — so it is
the same parser, taking the option name and the "what this number means"
clause as arguments. There is no second tiling rule, because a second one would
be a second set of 23- and 25-hour-day bugs; test_static_tariff.py section 10
pins that by parsing every module under app/ and asserting the grammar is
defined in exactly one of them, and by deriving each of thirteen refusal
sentences for the new option from the old option's own sentence.
Component.TAXES semantics, code_leaks() and the double-counting guarantee
are untouched: the new key joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is
derived from that set, and a plugin naming it is refused at register().
⚠️ A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH. It is
read on the ~30 s planning path through assemble(), and a table that first
fails at 03:00 is a site that stops pricing at 03:00 — which §11.4 turns into a
quiet fallback indistinguishable from a healthy fixed-tariff day. validate.py
parses it at startup, and refuses three things: a table that does not tile the
day (the same sentence static_profile gives, naming this key); a table set
beside a non-default price_taxes_eur_kwh, because the table wins and the
scalar would then be a number the operator typed that decides nothing; and a
whole card typed in cents, which is how these figures are printed. That
last rung is not a restatement of the parser's ±10 EUR/kWh bound — ORES's
13,27 trips the parser, but Sibelga's entire card, 9,39 and 7,10, is
under it, and would otherwise have started as a 9.39 EUR/kWh levy.
Nothing changes for a site that does not set it. The option ships "", the
scalar path is unchanged arithmetically — test_price.py 16f reproduces the
pre-TARIFF-17 expression and compares 12 capability/scalar combinations point
for point with == — and the reference site is in Flanders, where the flat
figure is correct. This is a product concern rather than a site one: the add-on
is sold to clients, and a Walloon or Brussels client could not express their
own tariff.
TARIFF-18 - price_taxes_eur_kwh stops shipping at half the real figure
⚠️ A SHIPPED DEFAULT CHANGED: price_taxes_eur_kwh 0.06 -> 0.11736 EUR/kWh.
Same shape as 0.8.0's slew_w rule, and the same instruction follows — read
the live options before ha addons update, this time to see whether
price_taxes_profile is set, which is the one shape the move could have
refused. The "who is affected" section below is the part that matters, and it
is shorter on consequences than a changed default usually is.
The old 0.06 had no recorded provenance anywhere: not a card, not a date,
not a component list. It is roughly half the Flemish figure. The new default
is the Eneco residential card for Flanders, September 2026, incl. 6 % VAT, and
its rows are now in the code (app/price.py's
FLEMISH_TAXES_COMPONENTS_EUR_KWH, re-added by test_price.py section 1b):
| component | c€/kWh |
|---|---|
| Fluvius distribution (Antwerpen) | 5,35 |
| Bijzondere accijns, "Alle verbruik" — no volume bands | 4,876 |
| Bijdrage groene stroom en WKK Vlaanderen | 1,51 |
| Energiebijdrage — 0 EUR/MWh since 2026-08-01, merged into the accijns | 0 |
| total | 11,736 c€/kWh = 0.11736 |
⚠️ IT IS PER-DSO, AND THE DEFAULT IS NOT AN AVERAGE. Only the distribution
row moves: across the eight Fluvius DSOs it runs 5,28 c€/kWh
(Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about 0.117 to
0.131. The default is a real DSO's real row, near the low end, and DOCS
tells an operator to look up their own. A Walloon or Brussels site should
not use this scalar at all — its distribution tariff has a day and a night band
and belongs in TARIFF-17's price_taxes_profile.
⚠️ THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE AND CANNOT.
This scalar is a LEVEL component: an additive constant cancels exactly in
max − min and leaves the argmin where it was. TARIFF-17 shipped the machinery
and the proof — six flat scalars spanning and exceeding the whole per-DSO range
give the identical cheapest hour and the identical spread to the last
digit (test_price.py 16b, printed on every run), and none of that changes
here. No trade happens that did not happen before because of the level. What
does change is two things, and nothing else:
- every number an operator reads — the all-in prices on the status page,
custom_formula's startup preview and the recorded price history. At half the true non-energy component they made the add-on look considerably better than it is; and - §17's negative-price guard (
arbitrage.py), the one rung that tests an absolute all-in price rather than a difference. It fires whenspot < −(markup + taxes), so its boundary moves −0.09000 -> −0.14736 EUR/kWh of spot. A −0.11868 EUR/kWh spot hour used to read −0.02868 all-in and the discharge was refused as paid-for disposal; at the real tax level it reads +0.02868 and discharges, because that hour genuinely paid.test_arbitrage.pysection 2c drives both sides through the shippeddecide(), and pins the boundary from below as well.
⚠️ AND THAT ASYMMETRY IS WHY THE DEFAULT SITS AT THE BOTTOM OF THE BAND. Set too high, the figure carries a genuinely negative hour above zero and the add-on exports into paid-for disposal — §17's own case. Set too low, it only declines a discharge that would have paid. One default standing in for eight DSOs belongs at the bottom.
Who is affected
- A site that set
price_taxes_eur_kwhitself: nothing changes. Its value is kept and is never silently replaced. - A site whose stored options blob already carries the key: nothing changes
either, and this is most existing installs. The Supervisor sets
App.options = deepmerge(config.yaml defaults, stored options)(tools/supervisor_selfapi_probe.py§D), so a moved default reaches only keys the blob does not carry — and the reference install's export carries 70 of the 71, the one absentee being a key added after its last save. Such a site goes on running at0.06and reading numbers that are about half its real all-in price until somebody opens the Configuration tab. ⚠️ That is a real limit of this card and it is not fixed here: the change reaches a fresh install, and an existing one only when its options are re-saved. ⚠️ NOT re-measured on this branch — the 70-of-71 figure is 0.9.0's, and PKG-09'stools/predeploy_check.py --options <live blob>against the live options is what would confirm it before deploying. - ⚠️ A site with
price_taxes_profileSET would have been refused at startup, and is deliberately not. TARIFF-17 made a taxes period table beside a non-defaultprice_taxes_eur_kwhaSystemExit(78)refusal, comparing against the shipped default precisely because the Supervisor writes the key whether it was typed or not. Moving the default turns every stored0.06into "non-default" without anybody editing anything, so a working Walloon or Brussels site would have stopped starting on the upgrade over an inert number the table overrides anyway.price.pynow carriesSUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)andvalidate.pytreats a superseded default as untouched as well. The rung is not weakened: a figure that is neither the shipped nor a superseded default — ORES's 0.1198, say — is still refused, by name, and the refusal now says which of the two figures it will accept (test_validate.py, four new checks plus the difference precondition).
Nothing hardcoded 0.06 in that rung — it read PRICE_OPTION_DEFAULTS — which
is why the interaction is a behaviour change rather than a drift, and why it
had to be decided rather than noticed.
config.yaml and PRICE_OPTION_DEFAULTS moved together and CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing,
not by being widened. ⚠️ One check did have to be rewritten rather than
re-pointed: test_price.py section 0 used to justify its own grading
constants by observing that the shipped pair was degenerate — 0.06 is exactly
twice 0.03. TARIFF-18 ends that coincidence, so the sentence is no longer true
and is not kept; the file now asserts per-figure that neither shipped default is
what it grades with, which is the property that was actually load-bearing.
TARIFF-13 - the coverage figure beside the peak, and which quarter gets it
The capacity-tariff status row's N quarter(s) banked is read from the
database instead of from process memory. It was an int on PeakTracker,
starting at 0 and counted up inside sync(), printed next to a peak read from
SQLite. A 40-hour soak caught what that means at a restart, on both instances:
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
The peak is durable and was never wrong. The count is what says how much of
the billing month that peak covers, and 365 W over 120 quarters and 365 W over
0 quarters are different claims about the same number — so mid-month, after
every restart, the page made the weaker claim while showing the stronger one's
value. TARIFF-01's rule that an unreadable store must not read as a zero peak,
one column over. Two more defects in the same variable, neither in the ticket:
it was never scoped to a month (on 1 October the row still showed
September's tally beside October's empty month), and it missed the block
restore() banks from the checkpoint at startup.
⚠️ AN UNREADABLE STORE NOW SAYS SO IN THAT CLAUSE. Where the database will
not answer a count, the row reads an unreadable number of quarter(s) banked.
0 was never available; None was rejected too, because None quarter(s) banked reads as the same zero. 0 quarter(s) banked stays true and reachable —
it is what a month nothing has been written to says.
⚠️ AND A TIED PEAK'S ATTRIBUTED QUARTER IS NOW DECIDED: the EARLIEST wins.
The wattage is what the utility bills, so this changes no bill and no guard —
peak_w is still only ever raised and SAFETY-07 reads the same watts. What it
changes is the answer to "when did my monthly peak happen", which an operator
goes and investigates; it now stops moving. The two readers of that record
did disagree: the monthly_peak upsert's strict > kept whichever tied
block was written first, while recompute_month() has always kept the
earliest — the same by accident only while blocks arrive in time order, and
measured to diverge (09:45 against 09:00) when they do not.
⚠️ THE SOAK'S DRIFTING QUARTER WAS NOT A TIE, AND IS LEFT ALONE. Driven,
exactly equal quarters never re-attributed at all. What moved the timestamp was
a genuine new maximum below the row's whole-watt resolution — 365.0000001 W
is a new peak to the database and the same 365 W to the reader. That is the
instrument being right, and printing more decimals to explain it would put
noise in the one row an operator reads when the guard engages.
Reasoning in DECISIONS/TARIFF-13-the-coverage-of-a-peak.md; parts 11 and 12
of test_tariff.py drive a real restart — a second Controller against the
same file — and pin both decisions.
TARIFF-11 - the plan's own account of itself, corrected and put on the page
Four defects, one shape: every one of them made the add-on look better than
it was. They shipped together because nothing in this repository graded the
strings a human is shown — every tariff check inspects the TariffPlan
object, and all four were in the sentences built out of it.
1. TariffPlan.words counted points and called them hours. The live
add-on logged dynamic_dayahead from generic_dayahead, 132 h for 132
quarter-hourly points = 33 hours. Belgium moved to a 15-minute MTU on
2025-10-01, so on the real feed this was wrong every time it was printed, and
⚠️ not by a constant factor: measured across a 40-hour soak the
overstatement ranged 1.79× to 5.33×, because §8.3's carried points are
hourly while the firm ones are quarter-hourly and the ratio moves with the mix
through the day. It now reads
dynamic_dayahead from generic_dayahead, 132 points over 33 h,
132 firm / 0 carried, 900 s spacing
— the same plan, with each number in its own unit. On the weekday morning that
plan is partly carried and reads 60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing, provisional instead, which is the state the soak found an
operator could learn nothing about. The N points over H h wording is
FCST-04's Price history wording on purpose, so the two rows describing the
same feed can be compared rather than being two conventions on one page.
2. The §8.3 and flat-tariff refusals did the same, and they reach an
operator. §8.3: only 1 of 96 hours carry a published price is 96 points,
which is 24 hours; the flat rung counted the same points and called them firm hours. ⚠️ This is exactly the point≠hour conflation TARIFF-14 removed from
at(), left standing in the prose that describes it — and it is not confined
to a log line, because self.arb.explain() puts it on the status page. Both
now print the two quantities separately: only 1 of 96 points carry a published price - 1 of 24 hours. The same conflation in price_forecast.py's §8.3
carry-forward log line — %d of %d hours with a carried-HOUR count over a
POINT count — is corrected in the same commit.
3. The plan reached no HTTP surface at all. origin, product, the
firm/carried split and the spacing appeared in no status row. rows["Tariff"]
was static_status()'s sentence, computed on the 30 s I/O-free tick from its
own static plan, and it never saw the provider plan — it read 'no static profile' at all 802 soak samples on a site whose feed was answering. So an
operator could not tell a working dynamic tariff from no tariff machinery at
all, and could not learn that the plan was provisional for 76 % of the soak
or mixed 900/3600 s for 70 %. The Tariff row now carries the plan.
⚠️ The two halves of that row are not computed at the same moment and the row
says so. static_status() runs on the 30 s tick; the provider plan comes off
the 1800 s background refresh and is served for up to TARIFF_MAX_AGE_S
(5400 s since TARIFF-12). So the plan half is stamped with its own age,
measured from tariff_at — which TARIFF-12 deliberately does not move on a
kept plan. A provider site with no usable plan says that too, rather than
falling silently back to the static sentence.
4. main.py's "if and only if" was false, and the row named an option that
was on. TARIFF-10's comment asserted that self.arb is None iff
arbitrage.enabled(self.o) was False. cycle() returns at the input hold —
inputs missing, bad_for < stale_input_s, 15 s by default — before it
reaches self.arb = None. Driven: arbitrage_enabled: true plus a sensor
answering None gives arbitrage.enabled(opts) = True, self.arb = None and
a row reading off (arbitrage_enabled). Past the first completed cycle the
same early return leaves the previous decision standing, which the row
rendered as a current one, up to ~15 s stale. The row now distinguishes three
facts with three remedies — trading is off; trading is on and this cycle
reached no decision; a decision, with its age — and it reads the option
rather than inferring it from a missing decision. The comment states what is
true.
⚠️ THE DELIVERABLE IS THE CHECK, NOT THE FIXES. All four survived 29 green
suites because no check in this repository had ever read a rendered row.
test_price.py section 12 renders through the shipped Controller.status_payload(),
parses each number out of the row by its own slot, and compares it against
a figure computed from the fixture's own construction parameters — never from
the property the row was built out of (MUTATION.md habit 4). It is deliberately
not a substring test over a sentence the same file wrote: "132 points" in row is satisfied by the number appearing in the other number's slot, which
habit 14 shows is a zero-byte edit away. The fixtures are a set of four
plan shapes, and the check computes and prints the table of field pairs no
fixture separates — none — so a pair that became degenerate would go red
rather than quietly vacuous. The Arbitrage row's three states are driven
through real cycles with a sensor answering None, and graded by
inequality: two facts that must not render identically.
Nothing about what the battery decides changes. No threshold, no refusal rung, no claim, no setpoint. Every edit is to a sentence, to a derived counter behind one, or to where a sentence is rendered.
TARIFF-15 - your own tariff card, as two coefficients
New price provider custom_formula, and one new option, price_formula.
A Belgian dynamic contract prices energy as B × index + A; the two
coefficients live on the customer's own tariff card and nowhere else.
There is no machine-readable source for them anywhere — CREG's consumer
tariff-card pages 404, CREG Scan is a JavaScript wizard, the annual report
carries the supplier list without per-product coefficients, VREG was
unreachable, and every coefficient found in research came off a supplier's own
PDF, one image-only. So there is nothing to scrape and a per-supplier plugin
would have no source for its numbers — which is precisely why TARIFF-07
withdrew three of them. The customer is the only party who can see their own
coefficients. They type them, once:
{"index": "epex_be", "resolution": "hourly",
"factor": 0.102, "offset": 1.00,
"units": "c_eur_per_kwh", "vat_pct": 6,
"card": {"month": "2026-09", "index_eur_per_mwh": 133.9905,
"expect_incl_vat": 15.55}}
price = (factor × index_eur_per_mwh + offset) × (1 + vat_pct/100), in
units, converted to EUR/kWh by the add-on. The operator types what the
card prints, in the card's own units; every unit conversion and the VAT
multiplication are the add-on's job, because those are the two steps that
silently cost a factor of 10, 100 or 1.06.
The index is generic_dayahead's, not a second one — same EPEX BE feed,
same Brussels-calendar range request, same columnar parser, same declared
egress hosts. The URL decision is now one function both plugins call, so a
short-horizon or override bug cannot exist in one and not the other.
resolution: quarter_hourly emits the feed's 96 points as they are;
hourly averages each hour's members. Neither is _Series.at()'s hourly
assumption returning: it decides how many points are emitted, and the
window each one covers is still derived from its neighbours (TARIFF-14).
⚠️ It declares {ENERGY, MARKUP}, not ALL_IN, and that is the whole safety
argument. A Belgian card's Tariefformule line is the energy component
only — Eneco tabulates network tariffs in one table and levies in a second,
ENGIE states that the formula excludes network, distribution, transport, grid
levies and excise. Declaring ALL_IN would lie on behalf of every operator who
typed exactly what their card says, and would drop price_taxes_eur_kwh from
every hour. Measured: 0.2125 → 0.1555 EUR/kWh, silently.
⚠️ THE CARD IS THE GRADER, AND IT IS WHY THIS IS SAFE TO SHIP. The
framework already mechanises that a plugin cannot double-count
(PluginOptions, FRAMEWORK_KEYS, code_leaks()). This provider introduces a
risk none of those can see, because it is config, not code: the operator
double-counting, by folding network charges into offset while
price_taxes_eur_kwh carries them too. So:
At startup
validate.pycomputes(factor × card.index_eur_per_mwh + offset) × (1 + vat_pct/100)and refuses to start unless it equalscard.expect_incl_vatto within0.01, naming the discrepancy, the ratio and the likely causes.
An inflated offset cannot pass: the printed estimate is the energy
component alone, so ~12 c€/kWh of network charge moves the computed 15.55 to
28.27 and the add-on stops. The same check catches VAT omitted, VAT applied
twice, a transposed digit, and a unit mismatch between the coefficients and the
printed price.
⚠️ And the limit, in the same sentence as the claim: it grades the coefficients against the card, not the card against reality. A consistent-but-wrong pair — both numbers off the wrong product — reproduces its own printed price perfectly and passes. Nothing can catch that. A second refusal covers the one unit error the first cannot see, the one applied to both sides at once: the card's own point must land inside 0.02–1.00 EUR/kWh. Its floor is applied to the energy figure rather than the all-in one, because the shipped 0.06 of taxes is three times that floor and would carry a hundredfold error over it on its own — measured, and driven red by a mutation.
The market's own arithmetic, reproduced to the digit the supplier prints. Both cards that were readable print the formula, the index they assumed and the resulting price:
| computed by the shipped code | the card prints | |
|---|---|---|
Eneco (0.102 × 133.9905 + 1) × 1.06 |
15.5471 | 15,55 |
ENGIE (0.1 × 61.96 + 0.9) × 1.06 |
7.5218 | 7,522 |
⚠️ No expression evaluation of any kind, and a check that fails if one ever
appears. price_formula takes numbers; there is no field that accepts an
expression and an unrecognised field is refused rather than ignored, so
typing "expr" is told it does not exist instead of quietly pricing the site
on the fields beside it. Two reasons, and the second decides it:
- the string is settable over HTTP — the add-on's own ingress page posts it to
/addons/self/options— soevalon it is RCE in a root container holdingSUPERVISOR_TOKEN, withhomeassistant_apiandhassio_api, driving an inverter over RS485; - an expression is unverifiable. Two coefficients can be graded against the card's printed price; an arbitrary expression cannot be graded against anything, so it would make the invisible-wrong-coefficient problem strictly worse — the exact failure this provider exists to prevent.
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
B × index + A. Future shapes become named fields (clamp_lo,
factor_negative, offset_bands), each individually refusable and each
gradeable against a card. Never a grammar. test_price.py 15f walks
app/custom_formula.py's AST for eval/exec/compile/__import__/
literal_eval and exercises the scanner's red direction on every run.
Refused at startup, not at 03:00. card absent or failing by more than
0.01; units absent (no default, on purpose — the same convention is printed
two ways by two suppliers); factor == 0, which makes every spread 0 and
arbitrage silently never fire (the message points at static_profile, which is
the first-class way to say "one price"); factor < 0, which inverts the market
so the battery charges at the evening peak; vat_pct outside 0–30 (0 stays
legitimate — injection is VAT-exempt and some cards are already VAT-inclusive);
a non-finite or non-numeric coefficient; an unknown field; an unknown index or
resolution; and the plausibility band above. A card.month older than about
six months warns and does not refuse — ENGIE freezes coefficients for a
contract's duration, so an old card is often exactly right.
⚠️ Negative output prices are NOT refused. They are legal on EPEX and they are the best charging hours of the year (§17 keeps the regression case). The card check bounds the formula's magnitude without constraining the sign of any hour.
A startup preview, so a factor of 1000 is visible in one line. The first
time the feed answers, the add-on logs the next 24 h as
index EUR/MWh -> all-in EUR/kWh with the min, median, max and the day's
spread. It is a new framework hook (PriceProvider.startup_preview(), default
silent) rather than a line in load_prices(), because only the plugin knows
what went in to its own arithmetic; it is emitted once per process and it
cannot stop a fetch — a preview that raises is caught and logged.
Surfaces. config.yaml gains price_formula (options and schema) and
custom_formula joins the price_provider enum through the derived set
PROVIDERS | RETIRED_PROVIDERS | {"off"} rather than a hand-typed list; both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, the
startup transcript, and the limit of the check stated in the same sentence as
the claim.
Not in scope, and each is a card of its own: the injection leg's own
coefficients (Eneco uses 0,102 on consumption against 0,100 on injection, and
injection is VAT-exempt where consumption is at 6 %); the hour-varying network
charge that bites Wallonia; raising the price_taxes_eur_kwh default from 0.06
to ~0.12.
TARIFF-07 - three plugins that never fetched a price, withdrawn
engie_dynamic, totalenergies_dynamic and eneco_eddy are gone from
PROVIDERS and from every surface that listed them. They did not stop
working. They never worked. Verified by hand on 2026-09-13, before anything
was deleted:
| id | endpoint | observed |
|---|---|---|
engie_dynamic |
https://api.engie.be/dynamic/be |
404, Kong no Route matched with those values |
totalenergies_dynamic |
https://api.totalenergies.be/prices/dynamic |
TLS failure — cert is CN=pge-apigee-dev.direct-energie.com, host not in the SAN list |
eneco_eddy |
https://api.eneco.be/eddy/prices |
404, empty body |
They are also structurally impossible, not merely unfinished. ENGIE's
(B × eSpot_15) − A coefficients are per-contract and sit behind the
klantenzone login, so no public unauthenticated endpoint could return a given
customer's price; there is no regulator index to read coefficients from; and
"Eddy" is not an Eneco product at all — Eneco's dynamic product is Zon &
Wind Dynamisch, indexed to Belpex/EPEX day-ahead, not imbalance.
The schema keeps all three ids on purpose, and that is the whole reason the
refusal is reachable. price_provider is a list(off|…) enum and the
Supervisor validates the options file against it before the add-on runs. Drop
a retired id from the enum and an existing config naming it becomes
schema-invalid: the operator gets an opaque expected one of ..., the add-on
never starts, and validate.py's message is never seen — on exactly the sites
that need it. PROVIDERS is two; the enum stays six. test_price.py asserts
the schema offers exactly PROVIDERS | RETIRED_PROVIDERS | {"off"}, derived
rather than typed, and register() refuses to re-register a retired id so the
refusal cannot be taken away by an @register somebody adds back.
PriceProduct.REALTIME and the widening path survive with no shipped
declarer. eneco_eddy was their sole declarer, so _WIDE,
Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's 2.5 now have none. They are
kept: it is the AC2 seam — declare REALTIME, get the wide regime, edit no
framework code — and the first real imbalance product would otherwise have to
rebuild it. ⚠️ No shipped plugin declares PriceProduct.REALTIME until one
does, test_price.py section 5 pins that so the emptiness reads as intent
rather than a dead branch, and section 10 keeps the path live from a class
declared outside price.py. See
DECISIONS/TARIFF-07-withdrawing-three-plugins.md.
⚠️ FOUR FALSE CLAIMS ARE CORRECTED, AND THEY ARE WHY THIS CARD EXISTS.
eneco_eddywas called a "Belgian imbalance product" inDOCS.md,app/price_forecast.py,app/price_history.py,app/static_tariff.py,DECISIONS/FCST-04-the-day-that-is-carried.mdand twice in this file. It is false — Eneco's dynamic product is day-ahead-indexed. The two historical entries here are annotated in place rather than rewritten; the record of what the project believed is part of the record.- The quarter-hourly examples those surfaces used
eneco_eddyfor are now Belgian day-ahead itself, which really does settle per quarter since the 15-minute MTU of 2025-10-01 —generic_dayaheadreads 96 points a day. The mechanism was always right; only the example was invented. export_price_eur_kwh's docstring claimed "no shipped supplier publishes a dynamic injection curve". Also false. Eneco publishes0,1 × BELPEX-H − 1,188on the same card as its consumption formula, identical on the January and September 2026 cards, and ENGIE publishes(B × eSpot_15) − A. What is true: an injection curve is the same two-coefficient shape with its own coefficients and its own VAT treatment — injection is VAT-exempt, consumption is at 6% — so the scalar is a simplification an operator enters by hand, not a reflection of the market.DOCS.mdsays the same._points_from_json()is deleted with the plugins it served. It read a{"data": [...]}document no feed anybody here has ever reached publishes — invented alongside these three, and graded only against fixtures written from the same guess. TARIFF-08 found that loop on the response side forgeneric_dayahead; this is the rest of it.
DOCS.md now answers the question a Belgian dynamic customer actually has.
The provider table is two rows. The new prose is explicit that
generic_dayahead gives wholesale EPEX BE plus flat markup and taxes scalars,
which approximates a retail dynamic tariff and cannot express
B × spot + A — it has no multiplier, and it cannot represent a negative
offset, which price_markup_eur_kwh: float(0,1) could not hold in any case. A
formula provider is named as the planned answer and is a separate card.
(Annotated 2026-09-13: TARIFF-15 replaced that sentence. The table is three
rows and the prose points at custom_formula.)
⚠️ One check had to be rebuilt rather than deleted. test_price.py 4b
proved code_leaks()'s docstring strip is load-bearing by finding a shipped
docstring a raw scan false-positives on — and that docstring was
engie_dynamic's. With it gone the check would have passed on a tree with
the strip deleted: a check that had quietly stopped being one. The stimulus
is constructed in the test file now, and the shipped set asserted clean
separately.
Nine mutations driven, all red; the table is in the decision record. Two carry
a +0 B delta and two end in a traceback whose red count is therefore a
lower bound — both flagged there, per MUTATION.md.
TARIFF-10 - a configured provider is fetched, whether or not you trade
amain() started the background tariff refresh only when
arbitrage_enabled was on, and that option ships false.
run_tariff_refresh is the only caller of resolve_tariff(), which is the
only caller of price_history.carry() - so on a shipped install with a price
provider configured nothing was ever fetched: no plan resolved,
/data/price_history.json never created, the Price history status row
reading empty for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about.
Measured over a 40 h soak of two instances side by side on the same feed:
| shipped default + provider | same, arbitrage armed | |
|---|---|---|
| feed requests | 0 | fetching |
/data/price_history.json |
present: false on all 802 samples |
written |
| resolved plan | none | 132 points |
⚠️ DOCS.md has described the fixed behaviour since the commit that wrote
the gate. OPT-04 (bdd7f18) added the refresh task, the
arbitrage.enabled() guard on it, and the sentence "turning a provider on
still fetches, validates and publishes prices and changes no charging
decision" — all in one change. config.yaml has said the same since
TARIFF-02 (852fc7c). The documentation and the code have therefore disagreed
for as long as both existed, and nothing compared them. config.yaml and both translations carried the same
claim, plus a second one that had gone stale in the other direction ("nothing
consumes the price series yet", untrue since OPT-04). All four now say what
the add-on does.
The gate is tariff_refresh_wanted(), and it is a STRICT WIDENING.
provider_configured(opts) or arbitrage.enabled(opts)
Every configuration that started the refresh before still starts it. The second
term is not redundant and is not there for symmetry: price_provider: off with
a static_profile and arbitrage_enabled: true is a configuration
validate.py explicitly accepts, and it trades - measured, at
cycle_cost_threshold_eur: 0.05 on a 0.20/0.284 EUR/kWh day/night profile,
ARBITRAGE_CHARGE -5000 W. Gating the refresh on the provider alone would
leave that site's tariff_plan at None forever and answer "no tariff plan
has been resolved yet" on every cycle - silently disarming a strategy the UI
says is running.
⚠️ Nothing acquires a periodic HTTPS call from this. With price_provider
off, resolve_tariff() answers out of the static profile without opening a
socket; and the true shipped default - no provider, no trading - starts no
task at all. What decides whether a request leaves the machine is
price_provider, and only that.
⚠️ AND NOTHING TRADES THAT DID NOT TRADE BEFORE. cycle()'s
if arbitrage.enabled(self.o): is untouched. Graded directly rather than
inferred from the gate having moved: Controller.cycle() is driven on a
resolved provider plan whose cheapest firm hour is now, with the peak guard
asserted clear, and arbitrage_enabled is the single option that moves between
the rows.
arbitrage_enabled |
cheapest hour is now | commanded |
|---|---|---|
false |
yes | +180 W (loop -> 180 W) |
false |
no | +180 W (loop -> 180 W) |
true |
yes | -5000 W (arbitrage -> -5000 W) |
true |
no | +5000 W (arbitrage -> 5000 W) |
TariffPlan.cheap_now() is deliberately NOT wired, and that is now a
decision rather than an inheritance. A provider-backed plan exists on every
configured site from this commit, so Cheap window - which feeds the
maintenance cycle's grid top-up, i.e. the one place a price reaches something
that moves energy without trading being armed - could now read it. It does
not: self.cheap still comes from static_status(), and on a provider-only
site (static_profile empty) that is False on every cycle, so no paid grid
top-up is ever forced. Wiring it would take that from never to sometimes.
DECISIONS/TARIFF-10-fetching-is-not-trading.md §4 carries the argument and
the list of what adopting it would take.
The check that would have caught this is test_price.py section 13, and it
grades reachability from a configuration, not the shape of an if. The
guards in amain() are evaluated against real option dicts, whatever they are
spelled as; the call graph under app/ is walked to see whether the tasks those
guards start can arrive at resolve_tariff(), wherever it has moved to; and
then the fetch is driven with a counting transport, because a graph is an
argument and a counted request is an observation. The shipped defect - the gate
back at arbitrage.enabled(opts), -4 B - is red on seven named checks.
TARIFF-12 - a returned degradation is not a refresh
run_tariff_refresh() promised in its own docstring that "a failure does not
clear the plan", and enforced it with except Exception - a branch a supplier
outage never reaches. load_prices() states the reason in its own words: it
"RETURNS, NEVER RAISES, for every failure a site can actually have", because a
degradation arriving as a traceback into the planning tick would stop the
controller regulating over an expired certificate. So a dead feed returns a
§11.4 plan, the try succeeds, and the assignment below it overwrote the good
plan unconditionally. With static_profile unset - the shipped default, and the
normal state of a dynamic-tariff site - _degrade() returns
TariffPlan(no_data, None, "none"): a complete 48-hour plan replaced by
nothing.
Measured in a 40-hour soak, static_profile empty, a 90-minute outage driven:
| Brussels | refresh | plan |
|---|---|---|
| 17:26:12 | 55 | dynamic_dayahead from generic_dayahead, 141 h, provisional |
| 17:56:12 | 56 | no tariff - generic_dayahead: ClientConnectorError: Cannot connect to host |
| 19:26:12 | 59 | restored, one refresh after the feed returned |
origin: none, n_points: 0 for all 30 outage samples. The plan was gone ~27
minutes after the last good fetch, at the first failure - so
TARIFF_MAX_AGE_S (5400 s) never governed anything, and there was no
serve-stale window at all: a transient blip cost exactly what a long outage did.
The question is now asked once, after the attempt, from the evidence that
exists - FCST-06's answer, one module over. The evidence is
TariffPlan.from_feed (did this resolution observe the configured feed) and the
shipped fresh_tariff_plan() (is an observation still current). A degradation
replaces a standing observation only once that observation has aged out, so
TARIFF_MAX_AGE_S is now the serve-stale window it reads like.
A §11.4 static fallback does not replace a fresh provider plan either,
although it carries real prices. Every point _degrade() builds is
provisional=True, so firm_points() is empty, arbitrage() refuses and
cheap_now() is False - everything a consumer may act on is already off - while
a day-ahead price for a given hour does not change once published. The fallback
is less actionable and no more current; it takes over when the observation
retires, which is what §11.4 is for. The rule is not symmetrical: a standing
plan that is itself a degradation is replaced by the next one, or an outage
would freeze the operator's static profile at the window it was first generated
for.
tariff_at does not move on a kept plan. It is the moment of the fetch that
produced the plan standing, fresh_tariff_plan() judges the age from it, and an
unmoved timestamp is the only thing that tells a kept plan from a freshly
fetched one. Re-stamping it would be the refresh asserting the feed answered
when it did not - the "freeze at its last value and look current forever" hazard
the same docstring names.
What did NOT change. The persisted price history still does not back the
plan during an outage: FCST-03 exists to stop stale prices being acted on, and
"recent" is not "current". Nothing about the control loop, the arbiter, or the
30 s tick - cycle() still reads two attributes and awaits no fetch.
test_arbitrage.py section 8b drives the whole chain with nothing stubbed but
the socket: the transport raises the way aiohttp does, load_prices() returns
no_data, _degrade() builds the §11.4 plan, and the shipped
run_tariff_refresh() decides. A check that patched resolve_tariff to raise
would have passed against the defect. Eleven mutations, eleven reds, no
survivors - see DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md.
Merged after TARIFF-10, which widened which installs run this task, and the
two configurations it newly keeps alive are graded rather than reasoned about.
A provider with trading disarmed is the case this guard was designed for and is
covered by every driven check here, because nothing in run_tariff_refresh()
reads a trading option at all - asserted on the AST symbols rather than on the
source text, after two drafts of that check went red on a correct tree for
saying "arbitrage" in a docstring and in a log line. price_provider: off + a
static_profile resolves to the CONFIGURED product (origin: "static"), so
standing.from_feed is false on every refresh and the guard never latches: the
plan is regenerated for the current window exactly as it always was. Both are
driven, and a mutation that makes the guard depend on arbitrage.enabled() is
red on five named checks.
TARIFF-14 - the trade was decided against the wrong price
On the only price feed that works, _Series.at() returned the wrong point,
and the error grew with depth into the series. It asked
s <= u < s + 1 h of every point in list order and returned the first
match. On the hourly shape every fixture in this repository carried before
TARIFF-08 that is right by accident; Belgium moved to a 15-minute MTU on
2025-10-01 and the live day-ahead feed returns 96 points at 900 s, where
it is wrong twice over. Measured against the shipped code at 5059e28:
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
ask + 0min -> 02:00 0.10
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
ask +45min -> 02:00 0.10 <-- WRONG
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
Inside the first hour the :00 point wins because its hour has not run out.
From the second hour on the answer is not even the top of the hour - the
first point still inside its own hour wins - so the error compounds.
What it cost is not "a slightly wrong price". arbitrage._act() tests
here.start == v.cheapest.start / == v.dearest.start to decide whether this
interval is the buy one or the sell one. That identity almost never held, so
the strategy idled through exactly the interval it exists to trade,
reporting "this hour is neither the buy hour nor the sell hour" - and read
§8.3's here.provisional and §17's price < 0 off the same wrong point.
static_tariff.py emits hourly points, so static sites were never affected;
this was reachable only down the dynamic path TARIFF-08/09 made work.
The rule now, in one sentence: a point covers from its own start until the next point's start, and never more than one hour.
| before | after | |
|---|---|---|
| 900 s | at(02:15) -> the 02:00 point |
-> the 02:15 point |
| 3600 s | at(02:30) -> the 02:00 point |
unchanged |
mixed [900, 3600] |
each point one hour wide | each point takes its own gap |
The mixed case is not an edge case: FCST-04's carry-forward emits hourly
carried points beside quarter-hourly firm ones, and the soak measured spacing
[900, 3600] in 70 % of samples. A window derived from one global spacing
would be wrong in the normal steady state.
The one-hour cap is load-bearing. FCST-04 leaves an hour out of the
series when nothing published it and yesterday has no value either, and
at() raising Absence for that hour is what sends the caller to the static
profile (§11.4). Uncapped, "until the next point's start" would stretch the
point before a three-hour hole across the whole hole and answer every instant
in it with a stale price - filling holes, introduced by the fix for a different
defect.
The last point's window is a decision and it is written down.
DECISIONS/TARIFF-14-the-window-a-point-covers.md §2 argues it: the last point
is given the same span as the point before it (one hour when it is the only
point). A flat one-hour tail would answer 00:30 tomorrow with today's 23:45
price, which is reaching for a neighbour across a day boundary. The cost of
the choice - a mixed series ending at the firm/carried seam loses its last 45
minutes to Absence - is stated there rather than buried, and the direction is
the safe one.
at() now reads a point's neighbours, so it would depend on the series being
sorted - and it establishes that itself rather than trusting the three
producers that happen to guarantee it today. The lookup index is sorted by UTC
start once, at construction; a series handed its 96 points in reverse order
answers identically, which is a check. covers() shared the defect exactly
(it has no rule of its own - it asks at()) and is fixed by the same change;
window() did not (it grades a point's start against a range and asks
nothing about duration), and its docstring now says so.
Nothing about §9.3 or a point's start changed. PricePoint still has
three fields; the span is derived from the neighbours, not stored.
Graded by test_price.py section 14 - 900 s, 3600 s, mixed, holes, the
tail, caller order, covers()/window() - and end to end by
test_arbitrage.py section 10, which drives decide() over a 96-point
quarter-hourly day whose cheapest and dearest quarters are deliberately not
on a whole hour. Eight mutations, all red, including a zero-byte one on the
tail rule; the table with byte deltas and the checks that were NOT reached is
in the decision note §6.
TARIFF-09 - the feed is asked for the range the caller wanted
generic_dayahead took since and until and requested the bare feed URL,
which answers one Brussels day. Everything past today was then filled from
yesterday's profile by FCST-04, flagged provisional, and §8.3 drops provisional
hours from the trade set - so the optimiser never saw tomorrow as firm, at any
hour of any day. Measured on 4257e38 at 2026-09-10 09:00 CEST over the
shipped 48 h horizon, live feed, yesterday seeded:
| 48 h horizon, one-day feed | |
|---|---|
| firm | 15 whole hours (60 quarter-hour points) |
| carried, provisional | 33 hours - 24 on D+1, 9 on D+2 |
| permitted cycle | buy 11:45, sell 19:45 - both on 2026-09-10 |
| firm local days | [2026-09-10] |
The ceiling was structural, not incidental: with a one-day window, at hour H of the local day a 48 h horizon is at most 24−H hours firm and 24+H carried. At 23:00 that is 1 firm hour and 47 carried, and the remaining firm spread (0.0464 EUR/kWh) no longer clears the default 0.10 round-trip threshold at all - so arbitrage was refused outright. This is not what FCST-04's fill is for: that covers the window until the ~13:00-14:00 CET auction, after which a healthy feed publishes tomorrow in full.
The data exists and the endpoint serves it. Verified 2026-09-10T07:45:49Z:
?bzn=BE&start=2026-09-09&end=2026-09-10 returns 192 quarter-hours across two
local days, committed unmodified beside TARIFF-08's captures. The plugin simply
never asked.
The range is now derived from since/until on the BRUSSELS calendar, as
two inclusive dates. Not the UTC calendar: 22:30 UTC in summer is already
tomorrow in Belgium, so a UTC-derived start asks for the day the controller has
just left and drops the day it is standing in, for the last one or two hours of
every day. That is price_history._bdate()'s lesson one layer up, with a worse
consequence - _bdate() misfiles an hour, this never fetches it. until is
exclusive, so a horizon ending exactly at local midnight asks for one day fewer
than the naive reading gives, and an inverted window collapses to a single day
rather than a backwards range.
Same code path, same window, a feed serving both days:
| before | after | |
|---|---|---|
| firm hours of 48 | 15 | 39 |
| carried, provisional | 33 | 9 |
| firm local days | 1 | 2 |
| permitted cycle | one calendar day | buys on one day, sells on the next |
The 9 remaining carried hours are the tail of the horizon that runs into a third Brussels day, which no day-ahead market publishes. FCST-04 still fills them and still flags them; what changed is what is firm, never what may be acted on.
Before publication nothing regresses, and that is graded rather than
assumed. Asking for tomorrow before the auction returns fewer days than
requested - measured, at 09:46 CEST the two-day request came back byte-identical
to the one-day body - and that stays a series of what exists, with FCST-04
filling the remainder. A no_data there would switch a working site's arbitrage
off every morning.
⚠️ price_feed_url is the EXACT URL and the plugin appends nothing to it, so
an overriding site keeps the short horizon. That is a decision, not an
oversight: start/end are this feed's spelling, the provider declares a second
host whose parameters are different, and rewriting a URL an operator typed is
how a request nobody intended gets made. It is not silent - the plugin logs a
WARNING at fetch time naming the consequence and the start..end it would
otherwise have asked for. See
DECISIONS/TARIFF-09-the-range-and-the-override.md.
The plugin contract - fetch(self, opts, since, until, transport) hands every
plugin the range - is still not enforced, because three shipped plugins point
at endpoints nobody has verified and a rule obliging them to pass a range would
be satisfied by an invented parameter name. Which plugins consume the range is
pinned as a literal in test_price.py instead, so the next supplier has to
decide rather than inherit the silence.
FCST-06 - the second way to be a store that cannot persist
A price cache that could not write went on answering a yesterday the file did
not contain. price_history.py's docstring states its rule unconditionally -
"A store that cannot persist now records nothing, and both of its answers say
the same thing" - and FCST-04 enforced it against not self.ok, which is one
symptom: the durable volume is missing. ok is about the DIRECTORY, and a
read-only remount and a full disk both leave it True. Reproduced on 5059e28
against two real volumes - a /data bind-mounted read-only and a full tmpfs:
with a /data that cannot be written |
|
|---|---|
last_error |
could not write ([Errno 30] Read-only file system) |
words |
2026-01-14 (24 points over 24 h); 2026-01-15 (24 points over 24 h) [could not write (...)] |
yesterday() |
series, 24 points |
| on disk | {} |
words and yesterday() were describing two different stores, and the 24
points were gone on the next restart with nothing saying so - the
silently-forgetting cache that rule exists to refuse, reached by the condition
it did not name. No shipped path reaches it: /data is the Supervisor's
durable volume and this module is its only writer. The defect is that a
guarantee the module states about itself did not hold in one condition.
"Cannot persist" is now ONE derived predicate, asked once, about the
outcome. It cannot be answered before the attempt - a directory that existed
and was writable a moment ago is not a write that completed - so _save()
returns whether the file now holds what memory holds: False with no durable
volume, False on an OSError from the write, True otherwise. _remember()
keeps only what that one answer says was kept, and the not self.ok test in
_remember() is gone rather than joined by a second one. FCST-04's M4 and
FCST-05's HIGH were the same shape - a rule enforced on the conditions somebody
thought of - so a third cause now needs no third branch.
What is undone is the MERGE, not the store. The state restored is the last
one that is on disk, so a site whose volume fills at 14:00 goes on answering
the yesterday it wrote at 13:30 - that record survives a restart, so answering
it is not a promise the store cannot keep. It simply stops adding to it. The
snapshot is a copy.deepcopy: a one-level copy shares the day record the merge
rebinds points on, and measured with one, a store holding 12 persisted points
answered 24 after a failed merge.
⚠️ A failed write is still not fatal, and that was a constraint rather than an
oversight. §6 and this module's opening: a broken cache must not be a broken
controller. An add-on that will not start because /data filled up is a worse
defect than the one being fixed, so _save() reports and returns - the failure
stays in the log, in last_error, in words and in yesterday()'s reason -
and the shipped resolve_tariff() path still returns a provider plan with no
§11.4 degradation. What changed is that it is now acted on.
test_price_history.py section 5d drives it on two real failures with different
syscalls and different errnos - a directory where the file goes (os.replace,
EISDIR) and a volume that moves out from under an open store (mkstemp,
ENOENT) - because the rule is enforced on the outcome and one cause would grade
a coincidence. See DECISIONS/FCST-06-cannot-persist-is-one-question.md.
TARIFF-08 - no network price provider had ever parsed a live response
generic_dayahead could not read one byte of api.energy-charts.info, and
had never been able to. _points_from_json() asks for doc["data"], a list
of {start, price_eur_mwh} objects. That document does not exist. Fetched live
on 2026-09-10, the feed returns columnar parallel arrays and no data key
at all:
top-level keys: ['deprecated', 'license_info', 'price', 'unit', 'unix_seconds']
unix_seconds: 96 UTC epoch seconds, first 1788991200, spacing 900 s
price: 96 floats, first 216.08
unit: 'EUR / MWh'
Driving the shipped parser against that body: SourceUnavailable: the feed carried no 'data' list. Since generic_dayahead is the only shipped provider
whose endpoint anybody has reached, dynamic pricing did not work at all.
⚠️ AND §11.4 MADE THAT SILENT, WHICH IS WHY IT LASTED. A parse error becomes
no_data, becomes the static profile, becomes arbitrage off - the correct
degradation, and it says nothing whatsoever about the cause. A site would have
sat on the fallback indefinitely while the status page reported only that the
feed was down.
⚠️ THE SUITE COULD NOT SEE IT BECAUSE EVERY FIXTURE WAS BUILT FROM THE
PARSER'S OWN ASSUMPTION. test_price.py, test_price_history.py and
test_static_tariff.py each built json.dumps({"data": [{"start": ..., "price_eur_mwh": ...}]}) - a shape invented here, published by nobody. The
checks graded the parser against the parser's guess: a closed loop that cannot
notice the guess is wrong, and no amount of coverage inside it would have. The
loop is broken by bytes nobody in this repository wrote: two unmodified
response bodies are committed under scenarios/, one quarter-hourly
(2026-09-10, 96 points at 900 s) and one hourly (2025-09-10, 24 points at
3600 s - the same endpoint before Belgium's 15-minute MTU), with URL, retrieval
time, licence-as-returned and a sha256 recorded beside them in
energy-charts-be-price-captures.json and re-derived by the replay, so a
fixture edited to make a check pass stops being a capture. The remaining
invented fixtures are renamed synthetic_row_feed at every call site.
The columnar document is read by its own function, _points_from_columnar_json().
Not by teaching the row parser to sniff for both: a parser that decides which
document it received is a parser that can decide wrong, and a wrong-but-
parseable response then becomes a price. Two shapes, two functions, and each
plugin names the one its feed publishes. Feeding the row shape to
generic_dayahead is now a graded no_data, so the sniff cannot come back
quietly.
⚠️ UNEQUAL ARRAYS ARE REFUSED RATHER THAN ZIPPED. zip() stops at the
shorter list and says nothing, so a truncated price array would have produced
a real price series with its tail missing - and the tail of a Belgian day is
the evening peak. That is worse than no series: no_data switches arbitrage
off, whereas a short series is traded on as though complete, and nothing
downstream can tell them apart. Refused in both directions, with the two lengths
in the reason.
⚠️ A UNIT CHANGE IS REFUSED FOR A REASON _validate_raw() CANNOT COVER. The
plugin converts with a fixed 0.001; if the feed ever published EUR/kWh under
the same keys, every price would come out a thousand times too small and
MAX_PLAUSIBLE_EUR_KWH would not fire, because it only bounds prices that are
too large. DATA-02's fetcher already refused on this field; the shipped path
does now too.
unix_seconds are UTC instants, said so explicitly
(datetime.fromtimestamp(t, timezone.utc)); §9.3's rendering at the Brussels
offset stays where it already lived, in assemble(), rather than being copied
into the plugin where a mutation could not tell it from a no-op. A published
null is dropped and logged with a count - a missing quarter is not a free
quarter - while an all-null body is no_data named by this parser rather than
by the boundary validator.
End to end against the live feed, 2026-09-10: resolve_tariff() returns a
TariffPlan with origin='provider', 96 quarter-hourly points, spacing
[900] s and no decimation, first point 2026-09-10T00:00:00+02:00 at 0.30608
EUR/kWh all-in. FCST-04's H1 fix was never theoretical - 900 s is the shape of
the real feed.
⚠️ WHAT THIS DOES NOT FIX. engie_dynamic, totalenergies_dynamic and
eneco_eddy are untouched: their URLs are unverified, no real body can be
captured for them, and withdrawing them is TARIFF-07's open decision. Their
fixtures are now named synthetic_row_feed so nothing reads them as evidence
that those feeds parse. And generic_dayahead still ignores since/until
and asks for no date range, so the default URL returns one published day - a
full 48 h horizon is reached only through §8.3's carry-forward fill.
FCST-05 - what FCST-04's own contract still did not hold
A single non-UTF-8 byte in /data/price_history.json stopped the add-on from
starting. _load() caught (json.JSONDecodeError, OSError).
UnicodeDecodeError is ValueError -> UnicodeError -> UnicodeDecodeError -
neither of those, and issubclass(UnicodeDecodeError, OSError) is False -
so it escaped _load() -> _open() -> PriceHistory.__init__, which
Controller.__init__ does not guard. Reproduced: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff, and the controller did not come up. That is the same class as the day-record defect the store was reworked
for, in the one byte class the rework left uncovered: valid-UTF-8-invalid-JSON,
a UTF-8 BOM and a non-dict day record all degraded correctly. The suite could
not see it because its "unreadable file" fixture writes "{ this is not json",
which is valid UTF-8. The handler now names the ValueError family - the
file's CONTENT - beside OSError, the file's BYTES, and a fixture holding a
genuinely undecodable byte is in test_price_history.py section 5.
A malformed start under the day the feed is writing to killed the tariff
plan permanently. _remember() merged a day's points with
{q["start"]: q ...} and sorted on q["start"], trusting a value read off
/data to be a hashable, sortable string. An int, null, a dict or a
list there raised TypeError; run_tariff_refresh catches Exception, so
the add-on stayed up while every refresh raised, forever - the plan
retiring after TARIFF_MAX_AGE_S and arbitrage and the cheap window off for
good, with no self-heal because _remember() raised before reaching _save().
The read path (_parse_points()) was properly defensive throughout, which is
why yesterday() survived and the existing fixture passed: it files its
malformed rows under a day the feed is not writing to, so _remember()
never opened that record. The two paths now share one validator,
_point_of(), and neither has a standard of its own. A row that is not a point
is dropped and reported, and _save() rewrites the record clean.
⚠️ AND THE STATUS ROW SAID THE STORE WAS HEALTHY WHILE THE PLAN WAS DEAD.
words read '2026-01-15 (1 h)' about the record that was killing every
refresh. _load() now counts the rows inside a kept record as well as the
record itself, so the store stops reporting plain health from the moment it is
opened; the note names what was found at open and what was dropped by
the refresh, and clears itself once the file has been rewritten.
⚠️ AND THE ROW COUNTED POINTS AND CALLED THEM HOURS. f"{d} ({_n_points(d)} h)" on the quarter-hourly feed this add-on already supports reads
'2026-01-13 (96 h)' for a 24-hour day - the same points/hours conflation the
sub-hourly defect above was about, on the row that defect's own card shipped.
The cell is now 2026-01-13 (96 points over 24 h): two facts, each under its
own unit, and the hour figure is the one that says whether tomorrow morning's
fill has a whole day to carry.
⚠️ AND THE MERGE KEY IS THE INSTANT RATHER THAN ITS SPELLING, which is what
rule 4 already said. Aware datetimes hash and compare by instant. The string
key merged by spelling, and the lexicographic sort beside it put the second
02:00 of the 25-hour October day (+01:00) before the first (+02:00);
only the stored order was affected, because _parse_points() re-sorts by
instant on the way out.
⚠️ TWO STATED REASONS WERE FALSE, AND THIS REPOSITORY TREATS THAT AS A
DEFECT. price_forecast._audit()'s "WHY IT IS STRICTER" section claimed the
rework refused two things FCST-03's audit did not. A differential over the
whole domain the audit can express - every published mask over 3 hours crossed
with every output series of length 0-3 - found zero divergence in both
directions: the reworked audit is exactly EQUAL to FCST-03's, not
stronger. The old seen count already refused the same published point emitted
twice (one point twice is one hour twice), and the old identity rule
already refused a carried point sharing a published hour. The only behavioural
change is the intended relaxation - several distinct published points in
one hour - and that stays. The docstring and the two check texts now say what
is true, and the differential is committed as a check in
test_price_forecast.py rather than left as prose.
⚠️ AND _audit IS NOT WHAT STOPS THE DECIMATION DEFECT RECURRING. It does
not check completeness: fed the decimation directly - four published points
in an hour, output emitting only the last - it accepts, and it accepts an
empty output. That is a limit, not a regression, and the module docstring said
otherwise by presenting the audit as the post-condition guarding provenance.
What actually guards it is the point count and the pass-through identity
checks in test_price_forecast.py and test_price_history.py, and the text now
points at them. It is not made complete: published is the whole read while
the fill emits only [since, until), so "every published point appears" is not
a true post-condition of those two arguments. Measured - the rule as stated
takes the 1 h, 12 h and 48 h horizon checks red on legitimate series.
No option was added, config.yaml is untouched, and nothing was deployed.
FCST-03 shipped the fill and no caller. price_forecast.fill_from_yesterday()
was complete and checked, and so were the consumers written for it -
TariffPlan.firm_points(), cheap_now()'s refusal of a provisional hour, and
arbitrage()'s refusal 3, which drops provisional hours from the trade set
rather than refusing the whole series. Nothing in app/ called the fill, so no
series ever contained a carried point and every one of those consumers was
exercising its all-firm branch.
⚠️ THE DEFECT THAT PRODUCED WAS NOT AN ERROR ANYWHERE. load_prices()
returns what the feed published and nothing widened it to the requested horizon,
so on a dynamic site between midnight and the 14-16 h CET day-ahead publication
the plan covered the rest of today and nothing more - typically 10-24 of the
48 hours price_horizon_h asked for. arbitrage() then took max - min over
that truncated window and reported a spread. The number was real, the window was
half the one configured, and nothing said so: tomorrow evening's peak, the
hour a morning charge would be sold into, was not in the series at all, so the
spread it would have created was never compared against anything.
What changes:
app/price_history.py(new) keeps the published prices of the last few Brussels days in/data/price_history.json, andresolve_tariff()now fills an unpublished hour of the horizon from yesterday's same-wall-clock hour, flagged provisional. ⚠️ "An" unpublished hour, not "every" one: the fill can carry only an hour yesterday actually published, so a partial yesterday leaves the local hours it never held out of the series - §11.4 for those hours, and asking for one raises rather than answering. Correct behaviour, and the wording is now the behaviour.- ⚠️ A sub-hourly feed keeps every print. [⚠️ The claim that follows is
FALSE and TARIFF-07 corrected it:
eneco_eddywas not an imbalance product, was not an Eneco product at all, and its endpoint never existed. The real quarter-hourly feed is Belgian DAY-AHEAD since the 15-minute MTU of 2025-10-01, which is whatgeneric_dayaheadreads. The behaviour described below is unchanged.]eneco_eddyis the imbalance product and settles per quarter; the fill walks whole hours to decide what needs carrying, and that is not a rule about what a published hour may contain. Every published point inside the horizon passes through by identity, and a carried point is added only for a whole hour with no published coverage at all. The first draft keyed the published set by hour - last point wins - and a 96-point quarter-hourly feed reached the optimiser as 15 points with its spread down from 0.143 to 0.014 EUR/kWh, on an all-firm, fully-published series. - The horizon is whole hours at both ends:
sinceis floored to the hour that contains it, and so isuntil. Asked for 48 hours at 08:17, the plan is 48 hours long and not 49. resolve_tariff(history=...)is required and keyword-only. A caller with no durable volume passes the explicitNoHistory(). A default ofNonemeaning "skip the fill" would hand a caller who forgot the argument exactly the truncated horizon this card removes, silently.- A new status row, Price history, names the days held, how many points each holds and how many whole hours they cover (see FCST-05: it shipped counting points and printing them under the hour's name).
⚠️ ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR.
Carried hours are provisional, so §8.3 keeps them out of the trade set: the fill
widens what the optimiser can see, never what it may do. What does
change a trade is that the filled series is windowed to [since, until), so
hours that have already elapsed are no longer in the spread. Before, a cheap
night that had already gone sat in the series as the day's cheapest hour - and a
cycle cannot buy in an hour that is past, so the trade was quietly never taken.
test_price_history.py section 2 grades this by finding the cheapest firm hour
with and without the fill and showing it moves.
⚠️ A DEAD FEED IS STILL §11.4, NOT YESTERDAY. The fill runs only on a
series read; a feed that published nothing still degrades to the configured
static_profile. The two are indistinguishable to the optimiser - both are
100 % provisional, so arbitrage refuses on the firm-pair rung and cheap_now()
is False - so preferring yesterday would change a documented degradation for a
difference only the status page can see.
DECISIONS/FCST-04-the-day-that-is-carried.md carries that and two more.
⚠️ A BROKEN CACHE IS NOT A BROKEN CONTROLLER. No /data, an unreadable
file, a schema this build does not know (including a version of 1.0, which
== 1), a day record that is not a record, a malformed point - each its own
state with its own reason, none of them a price and none of them an empty
series. In every one the plan is still a real provider series and the controller
still regulates; the horizon is just as short as it was before this card.
.words never raises, because Controller.__init__ reads it unguarded and a
status line that throws is an add-on that does not start.
⚠️ AND WHERE THERE IS NO /data, NOTHING IS RECORDED AT ALL - not even in
memory. An in-memory yesterday answers for one run and is gone on the next
restart, which is a cache that forgets without saying so; the honest pair is
unavailable on the status row and no prices carried.
⚠️ "YESTERDAY" IS A LOCAL DATE, AND THE ARITHMETIC IS ON THE DATE.
brussels() renders at a fixed offset, so subtracting timedelta(days=1) from
it subtracts 24 absolute hours - and the Brussels day before a
spring-forward is 23 hours long. Between 00:00 and 00:59 on the morning after
the March transition that lands on the day before yesterday. No option was
added and config.yaml is untouched.
0.9.0
⚠️ MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
POINT. 0.8.0 was minor because a config that started on 0.7.0 could be
REFUSED on it (rule 10 against 0.7.0's own default slew_w: 1000). Nothing of
that kind is true here, and it was checked rather than assumed:
options:andschema:both hold 71 top-level keys, the same 71 as 0.8.0. The one schema addition is a per-entry key,capacity_kwh: float(0.1,200)?- note the trailing?, it is OPTIONAL.- An unmodified 0.8.0-era config validates clean on 0.9.0. Measured on both the
flat single-inverter shape the reference install runs and an N=2 roster with
no
capacity_kwhon any entry: 0 problems each. - The two refusals OPT-09 adds fire only on configs that USE the new key - an unparseable value, or the key stated on one roster entry and left empty on another. Neither can reach a config written before the key existed.
So: existing installs upgrade untouched, and the bump is minor because the option surface and the behaviour grew.
Contents, in merge order since 0.8.0 (06661c5):
3d29993 |
UX-02 - the validator's refusals, next to the field, before the save |
fdb3b69 |
OPT-09 - the weights parameter nothing ever passed, and what it cannot do |
fcaa724 |
PKG-09 - refuse a deploy that would be a no-op, or that the add-on would reject |
UX-02 builds the real configuration surface in the ingress panel, beside the
Supervisor Configuration tab that cannot be removed. The prize is not styling:
validate.py writes long cross-field refusals the Supervisor UI structurally
cannot show, and until now an operator met them as exit 78 in a log, after
saving and restarting. ⚠️ Two config surfaces now exist and the DECISIONS record
names the stored options blob as authoritative, neither UI.
OPT-09 makes the architecture's one free parameter real. main.py called
resolve() without weights, so the split was a fixed 50/50 regardless of what
either pack could deliver; it now passes SoC- and capacity-aware weights.
⚠️ OPT-09 SHIPS A DISCLOSED DETECTION REGRESSION AT N=2. delivery. delinquent() reads an ABSOLUTE 500 W gap, so weighting a low-headroom unit down
to a small share puts a unit delivering none of what it was asked under the
threshold. Measured: a wedged inverter goes from named on 199 of 200 cycles to
0 of 200 at a load its sibling can carry alone. The alarm returns once the
healthy pack saturates - swept, that is a 5000 W load on the fixture, below
the proportional-regime formula's 6500 W and not above it. ⚠️ N=1 IS
UNAFFECTED: allocator.plan() returns before weights are read at all.
⚠️ CLOSED BY SAFETY-43 ON THIS BRANCH - see its section below; the regression
above is history, and the record of it is kept because the measurement that
found it is what the fix is graded against.
PKG-09 adds tools/predeploy_check.py. ⚠️ RUN IT BEFORE DEPLOYING THIS
RELEASE - it is what caught the fact that 0.8.0 had five runtime commits
behind it, which is why this section exists:
docker run --rm -v <repo>:/w:ro goodwe-addon-checks:1 \
python3 /w/goodwe_controller/tools/predeploy_check.py \
--deployed-version 0.8.0 --options /w/live-options.json
Exit 0 cleared, 1 REFUSED, 2 NOT CLEARED. ⚠️ 2 is not a pass.
✅ RUN AGAINST THIS RELEASE, 2026-09-07: CLEARED, exit 0. The reference install's options were exported from the Supervisor (70 keys) and checked twice
- the stored blob as-is, and the effective post-upgrade config, being those
70 keys merged over 0.9.0's 71 defaults. Both pass.
max_w5000,slew_w400 against a rule-10 ceiling of 555.6 W.
⚠️ THE STORED BLOB HAS 70 KEYS, NOT 71, AND THAT IS THE INTERESTING PART.
delivery_hold_s arrived with SAFETY-42 in 0.8.0, so an install running 0.7.0
has never stored it - and it is REQUIRED in the schema, no trailing ?, which
is the shape that becomes exit 78. It resolves to its options: default of
185, because the Supervisor sets App.options = deepmerge(config.yaml defaults, stored options); tools/supervisor_selfapi_probe.py §D pins that
against a real Supervisor image rather than assuming it. The second check above
exists because "your saved settings are valid" and "what the box will actually
run is valid" are different questions, and only the second one matters.
⚠️ THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS - PKG-06 six
merges, PKG-07 six, this one five - and this time it was caught by PKG-09's own
tool during PKG-09's review rather than by a person. ha apps update against an
unchanged version installs nothing and reports success. PKG-10 carries the
post-merge alarm so that noticing stops being a human's job.
Unreleased
⚠️ THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.
PKG-06's and PKG-07's rule: the Supervisor keys the built image by version:,
so ha apps update is a silent no-op on an unbumped change. This branch
deliberately does not pick the number, because the right one depends on a
fact the branch cannot observe - whether 0.8.0's image has already been built
and pulled. If it has not, this belongs inside 0.8.0's section before it ships;
if it has, it needs 0.8.1. Whoever deploys decides, and PKG-07 is the record of
what happens when nobody does.
⚠️ ANNOTATED BY PKG-14, 2026-09-16 - EVERY ROW BELOW HAS SHIPPED, AND THE PARAGRAPH ABOVE IS NO LONGER TRUE OF ANY OF THEM. It is annotated rather than rewritten, and nothing is moved out of it, because re-filing this file's misplaced entries is PKG-08's card and guessing a boundary is what that card exists to stop. What is measurable, and what PKG-14 measured:
- UX-02 shipped in 0.9.0. Its merge
3d29993is an ancestor of the 0.9.0 bump86bdf49and it is already listed in## 0.9.0's own contents table, so this row is a duplicate of a released one. - UX-04, TEST-21 and SAFETY-43 shipped in 0.10.0. All three merged after
86bdf49. TEST-21 (app/delivery.py,config.yaml,translations/) and SAFETY-43 (app/delivery.py,app/packshare.py,config.yaml) are runtime changes and are named in## 0.10.0's preamble as shipping there and being written down here; UX-04 touches no runtime file. - ⚠️ SAFETY-44 also shipped in 0.10.0 and is nowhere in this file -
app/main.pyandapp/store.py, three commits. It is named in## 0.10.0's preamble for the same reason.
3d29993 |
UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen ⚠️ SHIPPED IN 0.9.0, see that section |
bb46916 |
UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed ⚠️ SHIPPED IN 0.10.0 |
6719bd8 |
TEST-21 - delivery_hold_s's floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce ⚠️ SHIPPED IN 0.10.0 |
37dd0f0 |
SAFETY-43 - delinquency measured against the ask as well as against a flat 500 W, closing OPT-09's disclosed detection regression ⚠️ SHIPPED IN 0.10.0 |
⚠️ SAFETY-43 CLOSES THE DETECTION REGRESSION OPT-09 DISCLOSED, AND IT IS A
TWO-INVERTER FIX. N=1 IS BYTE-IDENTICAL. delivery.delinquent() named a unit
whose shortfall exceeded an absolute min_w = saturation_w = 500 W. OPT-09's
weights shrink a low-headroom unit's share, and shrinking the share shrinks the
gap - so a unit standing at +125 W and delivering 0 W, 100 % delinquent, was
never named. It now names a unit whose gap exceeds min_w OR exceeds
DELIVERY_SHARE_FRACTION (0.85) of that unit's own share. Measured on the
same closed-loop fixture, same weights, same detector:
load 1500 W OPT-09 weights the old rule named on 0/200 cycles
load 1500 W OPT-09 weights the shipped one named on 200/200 cycles
⚠️ saturation_w WAS NOT LOWERED, AND THAT IS THE POINT. Lowering it would
report normal saturation as delinquency on every unit at once - SAFETY-41's
four-attempt false-trip regime, "i'm getting really annoyed by the notifications
in my HA". This ADDS a second way to be named: all seven of test_delivery.py
part 1's existing fixtures answer identically under the old rule and the new one,
and the absolute arm's suppression window is still exactly 1000 W / 6500 W.
0.85 is derived, not chosen. admin/goodwe:sim/delivery_window.py --campaign --fractions grades it over the SAME 2640 no-fault runs that produced
delivery_hold_s's floor. The worst continuous legitimate episode is 35.0 s
under the absolute arm alone (SAFETY-42 §3.3, reproduced in the same run) and
40.0 s at 0.85. ⚠️ THE CURVE HAS NO CLIFF AND THAT IS THE FINDING: swept
at twenty fractions from 0.05 to 0.99, everything from 0.45 to 0.99 is 40.0
or 45.0 s - within one control cycle of every other - and below 0.45 it rises
gradually to 90.0 s at 0.05. ⚠️ "Zero false namings" is not achievable at any
fraction and the record says so; what is zero is false ALARMS - the longest
legitimate episode in the whole sweep is 90.0 s against a 185 s hold, so nothing
comes within 95 s of notifying anybody at any fraction measured, and 145 s at the
shipped one. The N=1 control arm is 0.0 s at every fraction in all 2640
one-inverter runs, and the flapping family is 0.0 s in all 840.
⚠️ CFG-07 GETS ONE CONTROL CYCLE WORSE AND IS NOT FIXED HERE.
delivery_hold_s is declared int(30,900) while the measured legitimate worst
was 35.0 s; it is now 40.0 s, so the band of schema-legal false-tripping values
widens from [30, 35] to [30, 40]. CFG-07 should be re-derived against 40.0 s.
⚠️ IT MUST CLOSE BEFORE INVERTER B IS COMMISSIONED (~2026-09-12), not before
the next deploy: the band is UNREACHABLE at N=1 - delinquent() answers {} for
every input at a roster of one, so no delivery_hold_s can false-trip on the
install running today - and commissioning B is the moment it becomes reachable.
⚠️ AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE - it exists as a card
reference here and in SAFETY-43's record and nowhere else, so whoever takes it
writes DECISIONS/CFG-07-*.md first.
SAFETY-43 CHANGES NO OPTION VALUE, NO SCHEMA BOUND AND NO DEFAULT - the
fraction is a derived constant with a committed harness behind it, deliberately
not a knob, for the reason there is no delivery_w. The one edit to
config.yaml is a COMMENT: the delivery_hold_s block described the check as
"short by more than saturation_w", which is now only half of it.
No behaviour change at one inverter, asserted rather than argued: plan() is
handed a weights mapping that raises if anything reads it and still answers
identically at a roster of one (and the same mapping raises at a roster of two,
so the tripwire is real), and delinquent() answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.
DECISIONS/SAFETY-43-delinquency-relative-to-the-ask.md carries the curve, the
positive controls and the CFG-07 assessment.
⚠️ TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
CLAIMED. delivery_hold_s: 185 shipped resting on "30.0 s, the worst
continuous legitimate episode at the shipped threshold across all 2640 runs" -
from a harness DECISIONS/SAFETY-42-unit-delivery.md §1 deliberately did not
commit, because it imports plantsim and frame_check out of admin/goodwe.
A shipped safety constant the next reviewer cannot re-run is PROC-01's shape.
The harness is committed now, in admin/goodwe as sim/delivery_window.py,
beside sitesim.py. It is there and not here on purpose: those two modules are
that repo's, and restating them in this tree is the CI-08 coupling that has
broken three times. The dependency runs one way instead - this repo's control,
arbiter and the shipped delivery.delinquent() are imported live out of
whichever checkout is under test. Re-run it with:
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --blag
⚠️ AND 30.0 s DID NOT REPRODUCE - IT IS 35.0 s. Reported as measured rather than adjusted to agree. What DID reproduce is the part the compatibility promise rests on: the N=1 control arm is 0.0 s at every threshold in every run, and the flapping family is 0.0 s everywhere in all 840 runs. What did not: the headline (one control cycle worse), the reversal family (20.0 → 30.0 s), the non-monotone curve (this rebuild's is monotone, so that finding is now an argument and not a measurement), and the low-threshold rows, badly. The B-lag rows moved the other way - a hypothetical unit four times slower than A gives 75.0 s, not 90.0 s - and they are relabelled throughout as a sensitivity study on a hypothesis about inverter B, which does not physically exist yet, rather than as a measurement of it.
delivery_hold_s is NOT changed. 185 s is 5.29× the re-measured worst
(was claimed 6.2×), 2.47× the four-times-slower case, 6.2× the firmware's
LINK_STALE_S veto and 4.9× inside the billing quarter: all four bounds still
clear and none narrowly. ⚠️ One consequence is worth an operator's attention:
at 35.0 s the rig's floor is now above the schema's own int(30,900)
minimum, so delivery_hold_s: 30 is below a measured legitimate episode and
would report on ordinary saturation. The option is marked derived, do not
change; raising the schema floor is left to its own card.
⚠️ UX-02 ADDS A SECOND PLACE TO CONFIGURE THIS ADD-ON, AND NO NEW WAY TO
COMMAND THE INVERTER. The ingress panel gains a Configuration page beside
Home Assistant's Configuration tab. Both edit the same options blob; neither is
authoritative, the stored blob is. What the new one can do that the old one
structurally cannot is show validate.py's CROSS-FIELD refusals - two inverters
naming one setpoint_entity, a maintenance_charge_w above max_w, a flat
setpoint_entity left set beside a roster - next to the field, before saving,
instead of as exit 78 in a log afterwards. The Supervisor's tab knows only types
and ranges and has no way to express a rule spanning two keys.
Four properties are the ones that could hurt somebody, and each is checked
rather than asserted (test_selfconfig.py):
- a blob the shipped validator refuses is never written - because an invalid blob plus a restart is exit 78, and the panel goes down with the add-on;
- a blob that changed underneath the page is refused, not clobbered, and the keys that moved are named. Two surfaces, one blob;
- every message comes from the shipped validator. Proven by inserting one
new rule into a COPY of
validate.pyand watching it arrive on the page with every UI file byte-identical; - nothing on the surface commands an inverter or clears a latch. Saving does not even restart: applying options costs a control interruption - the wind-down commands 0 W on every inverter - so that is a separate button that says so first.
⚠️ NO SCHEMA CHANGE AND NO CONTROL CHANGE. config.yaml is not modified at
all by this card: the permissions the panel needs - ingress: true,
ingress_port: 8099, hassio_api: true, homeassistant_api: true - were
already declared, and the panel was already live.
tools/supervisor_selfapi_probe.py re-measures them against Supervisor
2026.08.0's own code, and found things the card had wrong: Supervisor's own
/addons/self/options/validate is not reachable from inside an add-on, and
the Configuration tab can be removed - at the cost of all 71 options, which
is why it stays. DECISIONS/UX-02-two-config-surfaces.md carries both.
⚠️ UX-04 CHANGES NO BEHAVIOUR, NO SCHEMA AND NO DEFAULT. It adds one check
file, three assertions to a probe, and a decisions record; config.yaml,
translations/ and every module under app/ are byte-identical to 6e7b233.
It closes out UX-01, which shipped in 0.7.0 and is live, with three
criteria that were never met:
- AC 3 - "the visible count drops to ~15" - is WITHDRAWN as unsatisfiable.
options:andschema:declare the same 71 keys, so the set HA's show unused optional configuration options toggle can hide is empty, and Supervisor's ownUiOptionsmarks 59 of the 71 required - the frontend shows those unconditionally, so 59 is the floor even withoptions:emptied altogether. The target was ~4x below what any manifest can reach. ⚠️ And the 12 hides that are expressible would be bought with the deep-merge: a key absent fromoptions:has no default for the Supervisor to merge into a 0.7.0 box's stored blob, which is exactly thedelivery_hold_smechanism that makes this release's upgrade safe rather than exit 78. The count was printed bytools/supervisor_shape_probe.py§F from the day it was written and never graded, which is how this passed review; it is now three assertions, and removing one key fromoptions:turns two of them red. - AC 4's third clause - the AST check for stray in-code literal fallbacks -
is BUILT, as
test_defaults_pin.py. It discovers the six*_OPTION_DEFAULTSmirrors from the tree rather than naming them, grades all 30 mirrored keys againstconfig.yaml, and freezes the CFG-03 backlog at exactly today's 27 (module, key) pairs so it can only shrink. It found one previously unreported divergence:main.pyanswers a missingtarget_grid_wwith 0 whereconfig.yamlships -10 - unreachable on a Supervisor install, recorded rather than corrected. Its red direction runs on every invocation, and a literal planted in the real tree was shown red. - AC 5 / the CFG-04 double-literal
max_whole is closed, by UX-02'sbcee235and not by UX-01'sfef6ef2. Re-measured on this branch: the2000 -> 2500triple mutation is RED attest_selfconfig.py, where it survived 21/21 green on 0.7.0. The attribution is corrected inDECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md; nothing was re-done.
The 8 per-entry inverters: translations stay. They are shipped, validated
and measured not to reach the Supervisor tab - ha-selector-object labels
those fields fields[name].label || name and the converter sets no label -
and UX-02's ingress panel is the surface that can render them. A written
decision, not a change.
0.8.0
⚠️ A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
THIS IS A MINOR BUMP. SAFETY-39 adds validator rule 10, which refuses an
explicit slew_w above SLEW_RAIL_FRAC x the site rail, where SLEW_RAIL_FRAC
is LOOP_TICK_S / PLANT_SETTLE_S = 2/18. On a 5000 W rail that ceiling is
555.6 W.
⚠️ THE VALUE THAT BREAKS IS 0.7.0'S OWN SHIPPED DEFAULT. slew_w defaulted
to 1000 and the schema was int(50,5000); 1000 > 555.6, so an install that
never touched the key and simply took the default is refused on 0.8.0 until the
value is lowered. That is as backward-incompatible as it gets, and it is the
whole justification for MINOR over PATCH.
The schema also widens from int(50,5000) to int(0,5000), and slew_w: 0 -
a value that was not legal before - now means follow the rail and is the
shipped default. This is a new capability rather than a changed meaning.
⚠️ Refusal is SystemExit(78) (EX_CONFIG): the add-on does not start. Read
the live options and check them against the ceiling BEFORE ha apps update.
That check is NOT performed by this PR and cannot be - see the PR body.
CFG-04's rule and PKG-06's: the Supervisor keys the built image by version:,
so shipping a change without a bump means ha apps update is a silent no-op.
Six merges accumulated behind an unbumped 0.7.0 before this was caught, for
the second time in two days. The automated check that would catch it without
a human noticing is still unwritten, and that is PKG-06's standing follow-up.
Contents, in merge order since 0.7.0 (bbc903b). ⚠️ Each row carries the merge
subject's whole claim, including the half that is not flattering:
9e42cec |
CI-06 - test_peakguard.py answered two of its checks with the wall clock |
24c3d87 |
SAFETY-38 - pin site_standing with shares that cannot answer for the site |
a4290d3 |
SAFETY-39 - slew_w is a ratio to the rail, and gain is the constant that is still wrong |
c8dcb38 |
SAFETY-37 - a stale reading is None, and arrival cannot be what says so |
0581648 |
SAFETY-13 - the output freeze gets an exit, and only where it needs one |
464d398 |
SAFETY-42 - a unit that stops delivering, and the discriminator the card asked for does not exist |
⚠️ TWO DISCLOSED RESIDUALS SHIP IN THIS RELEASE. SAFETY-13's bounded freeze-exit shows 0 spurious releases on the sweep's measured-plant slice but 2 of 272 on a finer grid, and the docs carry the 2-of-272 figure rather than the flattering one. SAFETY-42's card proposed a discriminator that turned out not to exist, and the branch says so rather than shipping a weaker check under the original name.
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.
Detail notes — accumulated, NOT a separate release
⚠️ THIS HEADING USED TO SAY "Unreleased" AND IT WAS WRONG. Everything below has SHIPPED. The section accumulated across 0.6.0, 0.7.0 and 0.8.0 because each release was cut without clearing it, so it names tickets from all three (SAFETY-18 and SAFETY-30 from the 0.6.0 era through SAFETY-42 in 0.8.0). A reader who trusted the old heading would have concluded that shipped, running behaviour was still pending — and there was a SECOND heading saying the same thing further down, from the 0.4.x era.
Retitled rather than re-sorted, on purpose: splitting ~3000 lines by release is real work that does not belong inside a version bump, where it would bury the one line that changes what the Supervisor installs. Card PKG-08 carries it.
⚠️ 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 - may not clear itself at all, so
it gets one. It releases once the divergence has outlasted the plant's own
measured settling time:
SETTLE_CYCLES= 12 cycles at the measured tick.
⚠️ THE BOUND COMES FROM THE SLOWER OF THE TWO DIRECTIONS THE PLANT WAS
MEASURED IN. NOTES.md §5.10 timed the same 1.78 kW step both ways - ~18 s
up, ~23 s down - so PLANT_SETTLE_DOWN_S (23 s) joins PLANT_SETTLE_S (18 s)
as a named constant. SAFETY-39 uses 18 correctly, because its bound is a
refusal and the looser number refuses less; this bound asks how long the plant
may still be the explanation, and there the longer number is the safe one. A
bound of 9 derived from 18 fires the exit on a single inverter with nothing
else on the bus - measured in the container, 62 of 272 load-step pairs at the
plant's own measured shape, against 2 at 12.
⚠️ freeze-released IS A PLACE TO START LOOKING, NOT A FINDING. It says the
divergence outlasted the plant's measured settling time. It is not proof of
a second writer: a large load or PV swing can leave our own power coasting
outside our own command's interval, and a plant slower than the one that was
measured does the same. DOCS.md now gives the innocent causes first and the
second writer only when the state stands.
⚠️ 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 twelfth 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. ⚠️ That escalation is read from the code and from single-unit
runs; it is not measured on two inverters. Inverter B does not physically
exist yet and sim/plantsim.py's INVERTER_B is INVERTER_A copied with
measured=False, so any two-inverter figure would be two copies of A.
Graded against release/1.0 over the 3780-case sweep in test_control.py:
3267 cases bit-identical, 432 releases, 27 of those change the command -
every one a charge from prev_w == 0 that release/1.0 refused to start - and
81 differ in the saturation counter alone, because its ceiling moved with
the bound from 10 to 13. That ceiling change also removes a degenerate setting:
config.yaml accepts saturation_cycles up to 10, which is now strictly below
the release bound, so no accepted configuration can stop the block engaging.
At the old bound of 9 a setting of 9 or 10 deleted it silently.
⚠️ THE SITE TARGET IS NO LONGER SPLIT 50/50 BETWEEN TWO PACKS (OPT-09) -
AND THIS ADDS A PER-ENTRY OPTION, SO IT IS A SCHEMA CHANGE.
capacity_kwh: float(0.1,200)? joins the inverters roster as an optional
per-entry key. Optional, where SAFETY-32 made bms_soc_floor required, and for
that card's own reason: a missing cut-out would have to be defaulted and one
default for two batteries protects the shallower pack with the deeper pack's
number, whereas a missing capacity has an honest answer - no opinion about the
split, every unit weighs 1.0, which is exactly what shipped. It is all or
nothing across the roster and rule 9 refuses a partial one. There is no flat
capacity_kwh: at one inverter there is no split to weight.
The allocator has had a weights argument since ALLOC-01 and nothing had
ever passed one - main.py called resolve() without it and arbiter.py
defaulted it to 1.0 per unit - so the split was fixed 50/50 regardless of what
either pack could do. Two things now feed it, and they are not the same kind of
thing:
- an ENVELOPE, which is a permission. A new
packUNIT claim atP_SAFETYcloses a unit'shiat 0 W when its own SoC is at or under its ownbms_soc_floor, and itsloat 0 W at 100 %. The allocator obeys it absolutely, so an empty pack is not asked to discharge and its share moves to the sibling. ⚠️ Not filed at all at a roster of one - every source routes to the site there, so it would bound the SITE TARGET, and SAFETY-25's Option A (no loop-side SoC floor) stands untouched on the shipped install. - WEIGHTS, which are a preference. Between those limits each pack is asked for the fraction matching its energy headroom in kWh - not its power rating. The two packs are asymmetric in opposite directions (5000 W / 15.36 kWh against 4600 W / 20.48 kWh from datasheet), so the two ways of dividing hand the work to different packs.
⚠️ AND WEIGHTS ARE A COMFORT CHOICE FOR STABILITY, NOT FOR DELIVERY.
ARCH-01's convex combination guarantees a bad split cannot destabilise the
site; it says nothing about whether the site can regulate. Measured (two 2000 W
units, one at its floor, modelled plant, real compute()): at a 3000 W load the
weighted arm and the shipped arm command the identical +2000 W into the empty
pack and deliver the identical 2000 W, because _fill()'s last-resort pass
hands the remainder to a zero-weighted unit rather than leave watts unplaced.
Closing the envelope does not recover those watts either - it reports them:
unmet_w becomes +2000 W and the action log says so, where before the record
read impeccable. A unit that cannot deliver still parks the site short by its
share, and that is now visible instead of silent.
Also: arbitrage.decide() is sized per pack rather than at the site rating on
both sides, so a two-pack site cannot ask to buy 9600 W with one pack able to
absorb it; and a 0 W sizing is refused by name, because a 0 W arbitrage claim is
a park order that outranks the loop. ⚠️ The sell side is a no-op on every
roster today - the sell gate reads the emptiest pack, so it refuses outright in
exactly the states where the sum could be smaller - and moving that gate is
deliberately not this card's; DECISIONS/OPT-09-soc-aware-weights.md §6 says
why.
⚠️ SAFETY-42's end-to-end fixture moved, and its finding did not. That
card's evidence was that a wedged unit and a pack at its floor are byte-identical
in the power domain. After this card the cold pack is no longer asked, so the
shares differ - but the two quantities SAFETY-42's rejected discriminator reads
(saturation persistence and |grid error|) are still inseparable on both arms, so
the conclusion is unchanged and is now stated on the predicate's own inputs.
test_delivery.py part 4 carries the whole argument. The two cards meet at one
hazard from opposite sides: OPT-09 stops the site ASKING a pack whose SoC says
it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.
⚠️ AND THIS CARD NARROWS SAFETY-42's DETECTION WINDOW. IT IS A DETECTION
REGRESSION AT TWO INVERTERS AND IT IS DISCLOSED RATHER THAN FIXED. An earlier
draft of this entry and of DECISIONS/OPT-09-soc-aware-weights.md §5 said a
wedged unit's weight does not move, so OPT-09 could not see the case at all. The
ENVELOPE half is true - a wedged box reads a healthy SoC and nothing closes. The
WEIGHT half was false: a weight is a function of a pack's ENERGY HEADROOM,
not of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's. And delivery.delinquent() names a unit
only while its shortfall exceeds saturation_w - 500 W in absolute watts -
so shrinking the share shrinks the gap below the threshold and the alarm stops
firing. Measured end to end with the real loop, the real allocator and the real
detector, on a unit wedged at a healthy 15 % against its own 10 % floor (a
SAFETY-42 true positive by that card's own definition): at a 1500 W load the
healthy pack can carry alone, equal weights name it on 199 of 200 cycles and
these weights name it on none of the 200 - shares 1505/125, gap 125 W, and
125 W is under the threshold. The suppression window widens from a site target
below 1000 W to a site target below saturation_w * (w_A + w_B) / w_B, which on
that fixture is 6500 W of a 9600 W site; above it the healthy pack hits its
own rating, the allocator's last-resort pass hands the wedged unit a big share
again and the alarm returns. So in the quiet regime - where unmet_w is 0, the
meter is satisfied and no action-log line fires - a wedged inverter with less
energy headroom than its sibling can now go unreported at ordinary house
loads.
⚠️ BOTH BOUNDS ARE ARITHMETIC ABOUT _fill()'s PROPORTIONAL REGIME AND NEITHER IS A REACHABLE STEADY STATE. Swept on this fixture, the healthy pack's own 5000 W rating binds FIRST: suppressed 0/200 up to a 3000 W load, 4/200 at 4000 W, 41/200 at 4900 W (site target 5320 W, the largest reached with the wedged unit still under 500 W), then 195/200 at 5000 W once _fill() pins A at its cap and hands B 3000 W. So the alarm returns BELOW the 6500 W formula bound, not above it. The equal-weights 1000 W bound is unreachable for the same reason - the equal arm names the wedged unit 199/200 at every load swept, 1000 W included.
test_packshare.py part 8b grades it and pins the CURRENT behaviour, so
the day it changes a named check moves. Closing it means making delinquent()
relative to the ask rather than absolute, which is SAFETY-42's code and a design
change; that is filed as its own card and is deliberately not done here.
⚠️ Every two-unit figure above is two copies of inverter A through a modelled
plant - inverter B does not exist yet - and B's ratings are datasheet and serial.
⚠️ AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME
(SAFETY-42) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.
delivery_hold_s: 185 joins options: and schema: (int(30,900)). The
Supervisor keys the built image by version:, so this needs a bump before it
ships - see the 0.7.0 heading above, which makes the same point about
bms_soc_floor. The key is ADDITIVE and defaulted, so an existing options blob
stays valid; what a missing bump costs is that nobody's Supervisor re-reads the
schema.
Two or more inverters only, and at one inverter it is inert by construction.
A unit standing at a discharge share it is not producing - short by more than
saturation_w, with nothing this add-on can read to explain it, while at least
one sibling is NOT in that state - is named after delivery_hold_s. That fault
was invisible to everything: the per-unit sign check needs OPPOSITION and a unit
at 0 W is not opposing but ABSENT, the site sum still agrees with the meter in
direction, circulating power is exactly zero when one unit does nothing, and the
only remaining trace is the saturation freeze - which clamp.py itself calls an
alarm operators learn to ignore. It is HW-01's shape, which ran 6 h 25 m.
⚠️ IT REPORTS. IT DOES NOT ACT. Nothing is commanded differently, the loop is
untouched, control.py is not modified, and the site goes on running short by
that unit's share. Commanding an inverter that has stopped listening achieves
nothing (HW-01), and the only re-allocation this add-on can express is
all-or-nothing - measured on the rig, dropping a unit derated to 50 % from the
split leaves the site 400 W worse than keeping it, and this detector cannot
tell that unit from a wedged one. The status row is a CAUTION, not a failure,
because the controller has not stopped.
⚠️ THE CARD'S OWN DISCRIMINATOR WAS FALSIFIED, AND THAT IS THE MAIN CONTENT.
SAFETY-42 asked for "saturation persisting past N while |grid error| > X, both
derived". Built and run on the two-master rig first, it fails in both
directions, and no (N, X) fixes either: a pack at its own floor and a wedged unit
are identical on all 120 cycles of a run in grid, site target, every share
and every measured watt - so the benign case fires - and with a sibling that has
headroom the standing grid error collapses to 30 W while the wedged unit
stands at a +770 W share delivering nothing, so the real case does not. What
replaces it is a per-unit shortfall plus two reductions the card did not ask for:
an ACCOUNT (the pack's own bms_soc_floor, the only non-power fact that
separates an empty pack from a wedged one) and a PROPER SUBSET of the units asked
to discharge (which is what keeps a whole-site derate out, and what makes this
inert at N=1 - measured zero in all 1890 one-inverter runs).
The report survives a Home Assistant restart (SAFETY-34) under
unit/<name>/delivery_tripped, with one notification id per unit, re-raised on
the first cycle of every process and cleared by the same Start press as the
other latches. Charge shares are deliberately not graded - there is no
configured charge ceiling to explain a constant-voltage taper with, and arming
that direction would fire on every maintenance charge.
DECISIONS/SAFETY-42-unit-delivery.md carries the campaign (2640 healthy
two-inverter runs plus 1890 at one inverter as the control), the
inverter-B sensitivity sweep the margin is sized on, and the mutation ledger.
⚠️ AND THE TWO THRESHOLDS IT READS ARE NOW GRADED AT A CONFIG THAT IS NOT THE
SHIPPED ONE. saturation_w and each unit's bms_soc_floor are handed to this
check from the running config, and the promise that comes with that - raise
saturation_w and this check moves with you, there is no fourth copy of a
default to keep in step - is only true while the call site passes the
configured value. A literal equal to the shipped value keeps every fixture
green and removes the promise silently, and two such literals were survivors of
all 23 suites - measured, not argued - because no fixture that reaches this
check had ever run at a saturation_w other than the shipped 500 or at a
bms_soc_floor other than the shipped example's 10. Part 4b of
test_delivery.py now runs the same fault at a raised saturation_w and at two
cut-outs that bracket 10 from both sides, and each pair must answer two
different ways - so no constant passes. §9 of the decision record enumerates
every number this check reads and says which were already pinned this way and
which were not.
⚠️ 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) / remainingmoves atexcess / remainingW/s, so at a 1500 W floor the 1.5 W tolerance bought exactlyremaining / 1000seconds: 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 theProjectionobject 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 withrange(0, span + 1, 5), leaving 0–5 s of the open block uncovered by construction, andexcessmultiplies an uncovered second back up by900 / 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 atnow(what a real meter does, and what the docstring already claimed) plusFIXTURE_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
sumfrom the mean and from the largest share.test_watchdog.pypart 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.mdhabit 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 frommax(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()'scommanded_watmain.py:1926. At the same roster the site rail is 0.95 × 500 = 475 W, sosum= 500 W rails it while 250, 280 and 220 do not. Shipped: the peak guard goes blind on SAFETY-18's conjunction and filesfloor_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.pypart 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
standinghas one entry, sosum, 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 hittest_peakguard.py's CI-06 flake (a measured ~20% false-red rate), and the red was recorded as a mutation kill. Re-measured onrelease/1.0@86eaef2carrying only that edit: the full 21-file suite is green andtest_peakguard.pypasses 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, andstandingis a dict filled by subscript assignment, so it never enters the taint set andsum(standing.values())is not a roster site at all. 12j's own enumeration on the mutated tree listsmain.py:1553,1555,1690— not1599. All three mutations needed this card; all three are now covered by it. A single red fromtest_peakguard.pyproves 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_standingsaid 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. Noapp/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 itsmax_w: thedeviceclaim 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 atmain.py:1560was 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 tosign_reasonand 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≥2its own measured powerone and the N=1a meter readingone — 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. Noapp/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.pyfinds 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 throughdisagrees()), 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()'sdev_min/dev_maxatmain.py:3208, 0 bytes, 21/21 green, a status-page row — and twelve unpinned caller-built sentences including SAFETY-31'scirc_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 inDECISIONS/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 andmain.pyfingerprint 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_sitefixture'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 bytools/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_siteandcirculationmoved from site scope to unit scope) closes two gaps a reviewer disclosed — thoughcirculation's kill belongs entirely to pre-existing checks and is not claimed by this card. ⚠️ One false red fromtest_peakguard.pyon 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, inDECISIONS/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 asreason=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 notest_*.pymentioneddescribe()at all. Transposingrequested_wandapplied_winclamp.py:253is 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 intest_clamp.pypart 6d, pinned on the ordered pair on the contiguous literal at both consumers, with operands read offctl.targetandhass.writes[-1]— what the loop asked and what the device was sent — rather than off theClampResultthe swap lives in. - ⭐
PeakDecision.explain()— the status-page banner — the same shape.projected_w↔ceiling_watpeakguard.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 notest_*.py. Measured 21/21 green before; now red on 3 checks intest_peakguard.pypart 13, pinned on the decision and on the shipped status row atmain.py:3313. ⚠️ A correction the ticket is owed: the idle sentence does not reachmain.py:3299/3302— those are the floor-limited branch, whereexplain()returnsself.reasonbefore the idle literal is built. It reachesmain.py:3313and, on the release edge,main.py:1951. test_arbiter.py's scope-violation check made a claim WIDER than its assertion — it saidscope_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 inallocator.py'sUnitPlan.explain(), three acrossarbiter.py's twoexplain()s, two intariff.py, two inarbitrage.py's trade reasons and one inmain.py's SoC-floor notification. The other four of the fourteen are kills:allocator.py:162was already caught bytest_alloc.py, andclamp.py:253,peakguard.py:493andarbiter.py:264are 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,yielded sentences, message attributes outside thereason-shaped set, and everything outsideapp/. 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, sameunique_ids, 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 addedat a roster of one, and0 missing, 0 changed, 8 addedat 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_devicethe 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.
gridandp1_sample_age_sare announced once, on A's device, at every roster length 1–6. There is one P1 and one grid connection, andp1_sample_age_sis 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 bybuild_roster()and by the publisher — which has to know the roster before aControllerexists, because announcing happens on the broker's CONNACK.test_mqtt.py, a new suite: the recordedrelease/1.0baseline (generated by running that commit's module, not transcribed), the payload diffs, the composed entity ids modelled independently ofapp/mqtt.pyand anchored on the one id verified against the live system (sensor.goodwe_p1_sample_age), the announcement-vs-publication agreement on a realControllerwith 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
gridinto the per-unit set, andunits=()in main()'s publisher call were all green on the first run. So was a roster fallback that named two nameless entriesinverter-a. Sixteen mutations were run throughtools/mutation_run.sh; the survivors are enumerated with their size intest_mqtt.pypart 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.yamlandtranslations/nl.yaml, with anameand adescriptionfor all 70 options and for all 7 per-entry keys of theinverters: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_snow 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_wcites its 924 runs and the measured 400 W knee;meter_freeze_scites the 2399 s capture;bms_soc_floorsays it is register0x0560read off the pack. The two numbers that are genuinely the operator's —max_wandpeak_cap_w— say "THIS IS YOUR NUMBER" instead, andcycle_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.yamlreordered 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 theschema:half's (App.schema_uiisUiOptions(...)(data["schema"])); theoptions:half is reordered to match so the file reads the way the tab renders. ⚠️test_inverter.pypart 12's first-match trap is preserved: the nested per-entrymax_w:still appears BEFORE the flat one underschema:, andinverters: []still ships in theoptions:half. Moving the nested block below the flat one is a 1-byte edit and it was run: red intest_inverter.pyand intest_translations.py.- MEASURED, NOT ASSERTED.
tools/supervisor_shape_probe.pygains 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 failsSCHEMA_APP_TRANSLATIONSis dropped with a log warning and nothing else), runs its ownUiOptions, 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._convertSchemaElementsendstype: "schema"withmultipletoha-selector-object, which labels its fieldsfields[name].label || name, and the converter sets neitherlabelnortranslation_key. Thefields:translations are shipped and validated because that is the shape Supervisor's own schema defines for exactly this, and a frontend that wireslabelthrough would consume them — but on 20250925.1 they do not reach the screen, and this card does not claim they do. Theinverterslabel 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 anetwork:value that is not a string. Supervisor validates each translation file throughSCHEMA_APP_TRANSLATIONSand, on refusal, logs a warning and drops all of it, so those silently return the entire tab to raw keys; neither is reachable through theconfiguration: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 inconfig.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.pyreads 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) leavestest_control.py,test_clamp.py,test_validate.pyandtest_peakguard.pygreen. 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. NotESN/ESC(ES G2, platform 745). SPIKE-01 does not gate this site. ⚠️ A passed platform gate is not a proven control surface:specs.mdalready calls other platform-105 serials "same protocol family; untested silicon", and this repo's0x056E/0x056Fmap 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_wis graded against0x0518— 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'smin(max_w, dev_max)already bounds it correctly under either, oncedev_maxis read off the unit — somax_w: 4600is 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.pynow 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, the0x0518it 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, because0x0518appeared inconfig.yamlandDOCS.mdfor 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), theDOCS.mdcaveat deleted,config.yamlalone "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.pyandtest_watchdog.pybuild 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 thanslew_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 defecttest_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
drainphase 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
MaintenancePER ROSTER ENTRY, each with its own schedule, phase and history underunit/<name>/....store.migrate_maint_keys()moves an existing install's flat history into the first pack's namespace undermigrate_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,chargeandhold—chargeis 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 hdrainnor the 36 hchargetimeout — "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.pyrule 6 now gradesmaintenance_charge_wagainst the WEAKEST cap on the roster, like rule 5. Its own comment said it would have to move the daymaint_unitstopped beinginverters[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 thelo > hicontradiction 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: handedinverter.UNIT_Aon a roster of["garage", "shed"], the first version of this card wrote all four keys underunit/inverter-a/..., read all four back equal, reported verified, and stampedmaint_schema_version— so both packs came up believing they had never been recalibrated and the next start skipped the migration too. That is nowmisdirected: notok, 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 entryinverter-1, soinverter-ais not even the fallback for a declared roster. Every two-pack fixture that could see this now names its first packgarage; with them all calledinverter-a,self.inverters[0].nameandUNIT_Awere 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 1ok=False misdirected=4 moved=4 kept=0 marker=<absent>, construction 2ok=True misdirected=0 moved=0 kept=4and the marker stamped. On the second start rule 1 finds the wrong keys already there and routes all four tokept, somovedis 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 gradesset(moved) | set(kept); both lists are built from the sameunitargument, so gradingkeptgrades the same namespace decision.test_maintenance.pypart 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:isnot lost and not misdirected, and the mutationif 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 (keptnon-empty) and two whose write the store drops (lostnon-empty),misdirectedempty 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 onrep.oktoo, and every check that reached that gate ran on a store where nothing was namespaced yet, sorep.keptwas empty in all of them.if rep.ok or rep.kept:there stampsschema_versionon a migration that dropped the latch: the guard is off, the file still sayssign_tripped: true, and the next start skips the migration that would have re-armed it. Measured a survivor of all 19 files; now red ontest_inverter.pypart 10, which drops thesign_trippedwrite beside an already-namespacedsign_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 beforebuild_roster()(eachInverterrestores its latch in its constructor), so it is handed the constantUNIT_Aand there is no roster to grade against. On a declared roster a pre-DEV-02sign_tripped: truelands underunit/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 anyInverterexists, which isinverter.pyand SAFETY-02's semantics, with its own battery. Measured and pinned as a running check (test_maintenance.pypart 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 onrelease/1.0at2d025eewith 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.pypart 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_floorbecomes 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.mdrecords 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 whatdecide()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, not0.0—discharge_permitted()already treats an unknown floor as no permission, so the absence costs a shave and never a pack, including onsim/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_floorfor every pack — rule 8 makes it so. The reduction is written forunit_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_flooritself 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.pyre-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 passedonamd64-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()'smax(floors)→max(floors[-2:])(+5 bytes) and the call site'sself.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 toself.packs) for the slice widths no bounded roster can reach. Round 3's own battery foundself.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)inbinding_soc_floor()(zero byte delta) andself.packs→self.packs[:1]at thedecide()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'spack_floor_pct()equalspeak_soc_floor, sofloorsis a list of identical numbers andmax,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 abovepeak_soc_floor, chosen so thatmax(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 reachingdecide()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 toreturn 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 becausepack_floor_socisNoneat an empty roster.
- New part 12c2 evaluates
-
⚠️ 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, andpeakguard.py's own docstring names the two as a pair. Three rewrites ofController.pack_floor_socwere green across all 19 files on52380c0and again after the rebase onto3934ad7:min(float(v) for v in vals)→vals[:2](+4),vals[-2:](+5) andvals[: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 realcycle()s with the emptiest pack at every position of a three-pack roster and both positions of a two-pack one (the observable read offPeakDecision.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 — thevalscomprehension must iterate the bareself.inverters, and bothany()andmin()must reduce the wholevals— 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 collapsesA_PACK_FLOOR != B_PACK_FLOOR_SIMto 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 makespack_floor_socthe 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.packsinControllerwas 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_rangesand the round-2/3 sites). Four were not, and three of them are SAFETY-30's, allany(i.sign_wd.tripped for i in self.inverters)—cycle()'s stale-input hold,publish()'s status vocabulary andstatus_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, whereself.inverters[:1]ISself.inverters. The first has teeth: a latched unit must take the roster past that hold or itsfailsafeSET 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 inControllerto 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) andcycle()'s undeliverable-unit log line, wheresorted(...)[: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
Controllerclass; 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 overcaps_ix[:1](+4) at:778, andmin(caps_ix[-1:], key=…)(+5) at:893. None is an equivalent mutant. Withmax_w: 5000on inverter-a beside2000on inverter-b andsign_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 reportingarmedthat 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.pypart 12j now parses every.pyunderapp/— no module is named in the check — and finds the roster by transitive closure from one seed, theinvertersoption key: through returns, through lists accumulated in a loop over a roster (which is whatcaps,rosterandproblemsare, and what round 4's rule could not express), through attributes and properties, and through call arguments, which is how it walks intopeakguard,arbiter,allocator,circulation,maintenanceandstorewithout 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.py96,allocator.py24,validate.py19,arbiter.py14,circulation.py10,maintenance.py10,peakguard.py4,inverter.py3,store.py3.- 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 omittedmaintenance.pyandstore.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 intoapp/validate.pyat 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.pygrows a roster SET for the three rules that reduce overunit_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. Onlyminagrees 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_wwas 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 leftovermax_w: 5000andunit_sign_disagree_w: 3000produced 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 (minvsmaxis 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 outsideapp/, includingsim/inject.py; and the taint's own seam at*argsand 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.pyre-run again (2026-09-05, 2026.08.0). The rebase onto67e649amerged SPIKE-04a'smax_w: 4600into 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 ingoodwe-addon-ci(:ro,-B, fresh rsync per mutant, 0.pycbefore and after): 183/183 RED — which is tautological on its own, because the pin makes every slice red. The 51 whose first red wastest_peakguard.pywere 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 intest_peakguard.pypart 12j — 15 of them are rows and counts on the operator's page, 3 arelen(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-unitfailsafeSET thatarbiter.CLAIM_SCOPEhas classifiedSCOPE_UNITsince 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_siteSET atP_SAFETYon the site actuator, latched under flatsite_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.pycompares 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 - andtest_watchdog.pyasserts 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.yamlaccepts forgainandslew_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: 250issaturation_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: 85is 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: 300is carried - its argument is the meter's noise floor and the deadband, and the roster touches neither.site_sign_disagree_sis 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 aslew_wthat is not - reachable atgain: 0.05,slew_w: 50and 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. SeeDECISIONS/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
failsafeclaim, same banner, same single status row - and the site detector's window is never so much as opened.unit_sign_disagree_*andsite_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 toarm(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 readsign_wd.cfgand the trips in the fixture happen under either pair.test_watchdog.pypart 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 inDECISIONS/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 flatsetpoint_entity/soc_entity/batt_entity/batt_invert/max_wkeys 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.yamlandstatic_profilecarries its structure as a JSON string in astr?precisely because a nested schema was doubted. So Supervisor 2026.08.0's ownAppOptionsvalidator — the version this site runs — was run against this repository'sconfig.yamlout of the release image (tools/supervisor_shape_probe.py, committed; the transcript and the reasoning are inDECISIONS/CFG-04-inverter-roster.md). It accepts the list, enforces per-entry types, refuses a missingname/setpoint_entity/max_wby name, and renders the option in the UI as a repeatable section. And the shippedoptions:block validates as it stands while the same block with onlyinvertersremoved is answered withMissing 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.optionsis the shippedoptions:block deep-merged with the operator's stored one, soinverters: []inconfig.yamlis 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 isdocs/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_entityandmax_ware required of every entry; a missingsoc_entitymeans 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 onesetpoint_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 flatsetpoint_entityleft 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.pyrules 5 and 6 comparedsign_disagree_wandmaintenance_charge_wagainst the flatmax_w; at a roster of two 2000 W units withmax_w: 5000left over from a single-inverter install,sign_disagree_w: 3000passed 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 behindif self.o.get("setpoint_entity")— the flat key, which a roster config leaves blank — so on a two-inverter site no inverter'smax_wwas 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'sread_option()and_schema_bound()andtest_inverter.py'sread_schema_max()threw indentation away, so the nested per-invertermax_w:began outranking the flat one — silently, and correctly, because the two bounds are the sameint(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 disambiguatepeak_cap_winstead 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.pypart 11 finds everyconfig.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-inverterconfig.yamlwhose 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.yamldeclaresinverters:beforemax_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 intest_inverter.pypart 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 therun_control()check asserted a source line was present, which re-indenting it does not remove. The second is now driven through the shippedrun_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 assensor.goodwe_circulating_powerbeside 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 staysokwith both shares at −1250 W - a record-derived sensor reads 0 W - while the shipped sensor reads 2000 W.Inverter.battbecame 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
gainandslew_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: 700is 1.75× it, the headroom bought for inverter B's ramp and lag being placeholders until SPIKE-04 measures them.circulation_hold_s: 75clears three bounds - 15× the worst legitimate episode (5.0 s), 2.5× the firmware's ownLINK_STALE_Sveto, 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_notificationis 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/dataas 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| == 0for 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 published0 W).arbiter.CLAIM_SCOPEgains its ninth source andtest_peakguard.py's 10f/10g tripwires were updated deliberately, as their own comments require. Full ledger inDECISIONS/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_maxpair was read from onesetpoint_entity, so a second inverter's rating was never read at all and its clamp, its freeze rail and the peak guard'sachievable_wwere the first one's numbers. Onesocfed the maintenance FSM, SAFETY-24's pack floor and arbitrage's bounds. OneSignWatchdog- 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. Onelast_beatand one serialisedhass.call()meant a hung write to A ate B's 30 s layer-1 budget. actual_wbecame SITE level while the readings became per-instance, and those are opposite directions on purpose.compute()'s anti-windup test isabs(prev_w - actual_w) > saturation_wwithprev_wthe 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 onadmin/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 atgain: 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 intest_alloc.pypart 8 and now closed there.- The store migration is graded, not merely performed.
sign_tripped/sign_reasonmove tounit/<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. AndController.__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 of25 < 30, layer 1'sFIRMWARE_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 nosetpoint_entityis 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.pyis the new file (77 checks);test_alloc.pypart 8,test_peakguard.py10g/10h,test_watchdog.pyandtest_clamp.pywere 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()asksAllocation.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 aClaimand go throughresolve(), 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
tickit was made for, handed in by the caller soallocator.plan()stays the pure functiontest_alloc.pypart 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 afailsafe=Trueflag a caller asserts, and the argument is that the hazard §1.5 names is N-times loop gain from broadcast: atS = 0the broadcast and the correct allocation coincide -a_i = 1for 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 intest_clamp.pypart 7c. unmet_wis still notok. The gate readsokand the unit's share and neverunmet_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_KEYSis replaced byactuator_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, fusingunmet_wintook, freezing the tick on the live path, and moving the gate below the clamp - each turntest_clamp.pyortest_validate.pyred.
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 atgain: 0.6, and atgain: 1.0- whichconfig.yamlaccepts 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) = 1the gain at the meter issum(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 intest_alloc.pypart 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-levelquantisation_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 onemin(max(v, lo), hi)and substitutedvitself for a missing bound, which reads as "an absent bound cannot bind" but is not what it does: withhi == infthe expression collapses tomin(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, andUnitPlan.explain()can no longer print a share as being inside an interval that excludes it: that line now readsOUTSIDE ... - 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.pylogs 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.pystill refuses to act at N>=2, for a new and narrower reason. There is onesetpoint_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.pypart 10h) were re-aimed, not deleted -allocated=Falseis 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 shapeUnitBoundscan express: half-open above, half-open below, intervals that exclude zero, and bareUnitBounds(). 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 takestest_alloc.pyred 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_wis one inverter's power used as a site measurement, because the add-on binds a singleinverter_powersensor. 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 belowgain: 1.0. Measured, with the counterfactual, intest_alloc.pypart 8 andtools/alloc_sim.py --splits. It belongs toDEV-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(regulatesgrid_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) andsafety(stoppedis the site's switch,inputs missingis 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_wcaps 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) andmaintenance(§12's recalibration is a pack procedure). The reasoning is recorded at the line, inarbiter.CLAIM_SCOPEand again at eachclaims.appendinmain.py. - Getting it wrong is the parked-battery bug, so it is routed rather than
written. A per-inverter reserve filed as a site
hiagainst the peak guard's siteloislo > 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 raisesKeyErroron 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-01builds 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 faultARCH-01was filed about. Whatmain.pythen does is SUBSTITUTE, not abstain: it replaces the resolution with a 0 Wfailsafe, logs "refusing to broadcast the site target", and writes that 0 W throughwrite_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 withif False:had left all 17 suites green. resolve()HONOURS the roster it is handed, and that is now graded too. LeftNonethe 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.pypart 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
actuatorand it is a partition: a unit'shiand the site'sloare never both terms of onelo > hitest. 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.pyand part 10g red, because thefailsafeandmaintenanceSET claims would stop competing for the winner,explain()would name a different source andloop_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,
histill+infon 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 -
sourceis stillClaim.set()'s first positional argument precisely so the scan keeps reading it, which is whyactuatoris the LAST field ofClaimand not the first. Its spy gained**kwso the roster passes through unread rather than being silently dropped. New part 10g pinsCLAIM_SCOPEto 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 reachesdevice,supervisedandloopand 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 lefttest_arbiter.py,test_peakguard.pyandtest_arbitrage.pyall green while, at a roster of two, it built a site loop of -1500 W against the guard's siteloof 1500 W and the now-misrouted charge-onlyhi = 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 literalactuator=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 noactuator=at the call site to mis-type, and are pinned where their routing is actually chosen -peakguard.claim()by theSWEEP_SITEsweep,arbitrage.claim()bytest_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 intest_peakguard.pypart 10d - has an unstated premise: the reserve predicate anddischarge_permitted()read the same state of charge. With two packs they read different numbers. The reserve binds on the emptier pack and fileshi = 0while the peak guard, reading the other pack or an average, fileslo = excess > 0;lo > hiis 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. UnderARCH-01's host-side allocator the two claims stop sharing a list: the loop floor becomes a per-inverterhiconsumed as an interval at stage A, while the peak floor stays a siteloat 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-27now names five floors across two packs, so "the SoC" stops being a scalar the config can bound. Andtest_peakguard.pypart 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 honestStatus: OPENline 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()'sbilled_w(block, policy)tobilled_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 toblock.offtake_avg_wwheneverper_phase_offtake_avg_wis absent, and every block that reached a store anywhere in the suite was single-phase or built withper_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 thegetattr(..., 0.0)default SAFETY-07 already removed fromproject(). - The import half of the SAFETY-20 grader made a claim it did not check. It
collected
ImportFrom.moduleonly, which is blind to two of Python's three import spellings:from . import arbiter as _abis anImportFromwhose.moduleis None (the module name is in.names), andimport app.arbiteris anast.Importnode the walk never visited. Both were added toapp/tariff.pyand the shipped suite stayed green. Never a live exposure - the_nameshalf 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 walksImportas well, joinsfrom . import Xonto its base, and matches per dotted SEGMENT soapp.arbitercannot hide behind a package prefix whileclamps/arbiters/controllerare not falsely flagged. Both graders are also now shown non-vacuous againstmain.py, which really does import all three modules. - Two items on the card were already closed and are struck, not
re-implemented.
PeakReading.is_observedisself.state == OBSERVED(app/tariff.py:266), already false forNO_RECORD; andproject()'sheldread ismax(averager.held_net_w, 0.0)with nogetattrdefault, fixed by SAFETY-07 in616b62dwith a comment naming TEST-13 item 4. Restoring a default there would re-open a money guard. Removingclear_partial()also stays green and that remains a PASS: thequarter_startPRIMARY KEY plusON CONFLICT ... WHERE excluded > existingmakes 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 amaintenance_*option.main.py's eight inlineopts.get(key, <literal>)calls are gone;main.py's second fallback site (the cheap-window charge floor) readsself.maint.cfg.charge_winstead 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 itsmaintenance_discharge_wwarning 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.yamlsays 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.mdsays 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 -rfs 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.
[⚠️ FALSE, corrected by TARIFF-07: eneco_eddy was never a real-time /
imbalance product — Eneco's dynamic product is day-ahead-indexed — and the
plugin has been withdrawn. The declaration mechanism described here is
unchanged and is now exercised only from outside price.py.]
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.yamland 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_localis 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.mdnow states the capacity-tariff exposure this does not close:peak_cap_wcaps 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.pygrades it in both directions: silent over 40 minutes of the live house's real converged capture (committed asscenarios/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 realHomeWizardLocalSource— against the rig's ownhwsimfreezefault 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
followingin as a literal.test_watchdog.pynow derives it fromresolve()across twelve claim configurations, including the redirect casewinner == "loop"alone would have got wrong and thelolimit 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 atP_PEAKdoes, and would blind the watchdog for every peak event.The gate is now
watchdog.loop_is_following(), called by bothmain.pyand the test, for the same reasondisagrees()was extracted: the test held a transcription of the shipped expression, so editing the shipped gate alone towinner 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
winneras 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) exceedsmax_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, sosim/replay_sign.pystops carrying its own copy of the predicate it grades. -
held_stakes the clock instead of reading one, restoring the pure-function property the module advertises. -
The
sign_reason-before-sign_trippedwrite 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_wat or abovemax_wis refused at startup (validator rule 5). The arbiter never lets a command exceedmax_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_wabove (or below) the setpoint device's own rating. This is the one that changed behaviour. It used to warnthe device winsand run anyway, withwrite_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_sourcethat can never produce a reading - missingp1_host, missingp1_net_entity, a blank MQTT topic, a phase list disagreeing withmeter_phases, an unknown source name, or ameter_poll_sat or pastmeter_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_sabove the firmware's ingest veto. The schema acceptsint(5,300); the ESP32's threshold is a compile-timemax_age_s: "30"and it trips layer 1 atp1_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 themax_wcase. 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 asvalidate.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_sthe reading is None, which is "inputs missing", which is aP_SAFETY0 W claim - so a floor cycle there can only ever run on a reading this system already calls fresh. ⚠️ That is transport-conditional: withmeter_source: off,read_inputs()takes the grid figure straight fromhass.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_wbounds 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.
Detail notes from the 0.4.x era — NOT unreleased
⚠️ Retitled with the same correction as the section above: TEL-05 and the entries under this heading shipped long ago. Two headings in one file both claiming "Unreleased" is how a reader ends up believing running behaviour is still pending.
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:
- Highest-priority
setwins; no claim at all means 0 W. - Then every
limitwhose priority is >= that set's priority applies, most restrictive first. - 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.pyis 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.shmust 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 - whilehomeassistant_api: truemakes 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 afterconnect()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_startworks. The store's defaults suppliedauto: 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.