checks / unit (pull_request) Successful in 8m5s
One runtime merge since 0.11.0 (cccb2fa): TEL-12 (#116), a new optional
list option `pv_entities` beside `pv_entity`. Option surface 76 -> 77, so
a minor bump. config.yaml version and a closed `## 0.12.0` section only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
6597 lines
422 KiB
Markdown
6597 lines
422 KiB
Markdown
# Changelog
|
||
|
||
## 0.12.0
|
||
|
||
**A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with
|
||
more than one PV inverter.** The option surface moves **76 keys -> 77**,
|
||
`options:` and `schema:` in step. 0.11.0's `pv_entity` takes one entity; a roof
|
||
with two or three inverters, each publishing its own power sensor into Home
|
||
Assistant, could only record one of them. `pv_entities` is a list, and the
|
||
Configuration tab renders it as a repeatable field, so you add your second and
|
||
third inverter there rather than in YAML.
|
||
|
||
⚠️ **UNSET, NOTHING CHANGES.** `pv_entities` ships as `[]`, which is what every
|
||
install that presses *Update* lands on, and empty means "use `pv_entity`" - so
|
||
an install with no PV entity still has no PV channel, and an install with one
|
||
`pv_entity` keeps exactly the single-entity channel 0.11.0 gave it. **No rule was
|
||
added to `validate.py`**, which mentions neither key, so **a stored options blob
|
||
that starts the add-on on 0.11.0 starts it on 0.12.0**. There is no
|
||
`SystemExit(78)` to plan around on this hop.
|
||
|
||
⚠️ **IT IS INSTRUMENTATION ONLY, NOT A CONTROL INPUT.** `app/control.py`,
|
||
`allocator.py`, `arbiter.py`, `peakguard.py` and the watchdogs are not in this
|
||
release's diff; the runtime files that move are `app/pv.py`, `app/main.py`,
|
||
`app/mqtt.py`, `config.yaml` and `translations/`. `tools/tel11_pv_trace.py`,
|
||
extended for this card, hashes the controller's decisions on 0.11.0 and on this
|
||
tree: the control document is **`846a10a5b89e…` with PV unset and with one
|
||
entity, on both trees** - the same hash TEL-11 recorded - and with a
|
||
three-entity roster it equals each grid world's unset document, including every
|
||
cell where one inverter is dead. Neither the total nor any partial total reaches
|
||
a decision. With PV unset or with one entity, the **telemetry** is byte-identical
|
||
to 0.11.0's as well.
|
||
|
||
Contents of this release, in merge order since 0.11.0 (`cccb2fa`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `f863eca` | TEL-12 - several PV inverters, and one absent member is an absent TOTAL **⚠️ NEW OPTIONAL OPTION, EMPTY ON EVERY EXISTING INSTALL** |
|
||
|
||
### TEL-12 — `pv_entities`: the PV channel for a roof with several inverters
|
||
|
||
**WHAT TO DO ON A SITE WITH MORE THAN ONE PV INVERTER.** List every inverter's
|
||
power sensor under `pv_entities`:
|
||
|
||
```yaml
|
||
pv_entities:
|
||
- sensor.pv_east_power
|
||
- sensor.pv_west_power
|
||
- sensor.pv_garage_power
|
||
```
|
||
|
||
`sensor.goodwe_pv_power` then carries the **site total**. Each entity's own
|
||
`unit_of_measurement` is read, so a kW inverter beside a W one sums correctly;
|
||
a sensor with no unit or a non-power unit is refused by name on the status
|
||
page, as in 0.11.0.
|
||
|
||
⚠️ **`pv_entities` REPLACES `pv_entity` - IT DOES NOT ADD TO IT.** When the list
|
||
is set, `pv_entity` is not read at all. **If you are upgrading from 0.11.0 with
|
||
`pv_entity` already set, the inverter it names must also be in the list**, or it
|
||
drops out of the total. Put it in the list and it is **counted once**, not
|
||
twice - that is why the rule is replacement: under a union, the natural action
|
||
(put all your inverters in the new field) would double-count the one the old
|
||
key already names. While both keys are set the status page shows a caution
|
||
saying which one won - *"`pv_entities` is set, so `pv_entity` is IGNORED - the N
|
||
entities in the list are what is summed. Clear `pv_entity` to remove this
|
||
caution"*. It is a caution, never a refusal to start and never a NOT READY row.
|
||
A repeated id inside the list is also counted once.
|
||
|
||
⚠️ **IF ANY CONFIGURED PV ENTITY IS ABSENT, THE PUBLISHED TOTAL IS `unknown` -
|
||
NOT THE SUM OF THE ONES THAT ANSWERED.** Three inverters configured and one
|
||
`unavailable`: `sensor.goodwe_pv_power` goes to `unknown` and the recorder
|
||
stores a **gap**. The sum of the other two would be a smaller number
|
||
indistinguishable from a real one, and a week later it would read as *"there
|
||
was less sun than there was"* - the conclusion this channel exists to test
|
||
rather than to manufacture. **A gap in the history, never an understated
|
||
number.** The status page, read live, still gives you the diagnostic: `PV —`
|
||
plus a caution that says there is **no site total**, names the missing entity
|
||
and its reason, and lists what the others reported. An inverter honestly
|
||
reporting **0 W** is summed, not treated as absent - shading and nightfall are
|
||
real zeros.
|
||
|
||
| what happened | `sensor.goodwe_pv_power` | the status page |
|
||
|---|---|---|
|
||
| neither key set | the entity does not exist | no row |
|
||
| all three answer, 1200 W + 2.1 kW + 450 W | `3750.0` | `PV 3 750 W` |
|
||
| all three answer, one of them `0 W` | the sum of the two others | the sum |
|
||
| one (or more) `unavailable` / missing / bad unit | `unknown` - a recorder gap | `PV —` + a caution naming what is missing |
|
||
|
||
⚠️ **THE FIX FOR A MISSING INVERTER IS NOT A TEMPLATE SENSOR THAT SUMS THEM.**
|
||
You can still point `pv_entity` at one if you prefer, but a template sensor
|
||
summing an `unavailable` source yields the partial total silently - the same
|
||
defect one layer down, where this add-on cannot see it or report it.
|
||
|
||
**WHY A SECOND KEY, RATHER THAN MAKING `pv_entity` A LIST.** Because an 0.11.0
|
||
install that has `pv_entity` set would not start. Measured against Supervisor
|
||
**2026.08.0** by `tools/supervisor_shape_probe.py` §G: with `pv_entity` declared
|
||
as a list, the string an 0.11.0 install has persisted is refused -
|
||
`Invalid list for option 'pv_entity'` - and a shipped `[]` default does not
|
||
rescue it, because the Supervisor's merge keeps your stored string. The one
|
||
single-key spelling that accepts both shapes, `match(.*)?`, validates nothing
|
||
(it accepts the integer `417`) and renders as a plain text box, so you could not
|
||
add a second inverter in the UI. So `pv_entity` stays a string, and the same
|
||
probe shows an 0.11.0 options blob - `pv_entity` set, no `pv_entities` key at
|
||
all - validating against this version untouched. ⚠️ **Rolling back to 0.11.0 is
|
||
also safe**: a schema without `pv_entities` drops the key with a warning and
|
||
leaves `pv_entity` standing.
|
||
|
||
**Why it matters now:** [[OPT-10]] needs a week of PV history to attribute the
|
||
surplus that left the reference site while the pack had room, and that site has
|
||
several PV inverters. The week starts when the site total is recorded.
|
||
|
||
**Full account: `DECISIONS/TEL-12-several-pv-inverters.md`, with the operator's
|
||
version under "Several PV inverters" in `DOCS.md`.**
|
||
|
||
### If you are crossing from a version older than 0.10.0
|
||
|
||
⚠️ **THIS SECTION IS NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.**
|
||
Updating installs 0.12.0 in one step, so every validator rule added in
|
||
0.8.0, 0.9.0 and 0.10.0 arrives at the same moment. Two of them can refuse a
|
||
config nobody has edited: [[SAFETY-39]]'s **rule 10** (added in 0.8.0) against
|
||
0.7.0's shipped default `slew_w: 1000` - the ceiling is **555.6 W** on a 5000 W
|
||
rail - and [[TARIFF-07]]'s three withdrawn `price_provider` ids,
|
||
**`engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy`**, each still
|
||
refused by name at startup on 0.12.0. Neither is new here, and a site already on
|
||
0.10.0 or later has already met both. **The full account of both, including
|
||
what to set instead of each withdrawn id, is in `## 0.10.0`**, which withdrew
|
||
them and is not superseded by this section.
|
||
|
||
`tools/predeploy_check.py` checks your live options against this version's
|
||
validator before you press *Update*, and on this tree reports a real version
|
||
change from 0.11.0. ⚠️ **Pass it the bare options dict - the `data.options`
|
||
object - NOT the whole `ha apps info --raw-json` document.** Handed the wrapped
|
||
export it currently prints CLEARED while having checked nothing ([[SAFETY-60]],
|
||
open). Extract the options first, e.g. `jq '.data.options'`.
|
||
|
||
## 0.11.0
|
||
|
||
**A MINOR BUMP, AND ONE NEW OPTION IS THE WHOLE REASON FOR THE SECOND DIGIT:
|
||
`pv_entity` ([[TEL-11]]).** The option surface moves - **75 keys -> 76**,
|
||
`options:` and `schema:` in step - which is the same test 0.8.0, 0.9.0 and
|
||
0.10.0 were each cut on. ⚠️ **AND UNLIKE 0.10.0, NOTHING HERE CAN REFUSE A
|
||
STORED OPTIONS BLOB.** No key was removed, no default moved, no enum widened,
|
||
and **no rule was added to `validate.py`** - the validator does not mention
|
||
`pv_entity` at all, because nothing else reads it and it cannot conflict with
|
||
anything. The new key is `str?` - **note the trailing `?`, it is OPTIONAL** -
|
||
with an `options:` default of `""`, so a blob written on 0.10.0 or 0.10.1 does
|
||
not carry it, the Supervisor's merge supplies the empty string, and the empty
|
||
string is *off*. ⚠️ **A stored options blob that starts the add-on on 0.10.1
|
||
starts it on 0.11.0. There is no `SystemExit(78)` to plan around on this hop**,
|
||
and a site that presses *Update* and changes nothing else gets an add-on that
|
||
behaves exactly as its 0.10.1 did.
|
||
|
||
⚠️ **WHAT YOU ARE ACTUALLY DECIDING, IN ONE LINE EACH. TWO TICKETS HERE ARE
|
||
OBSERVABLE ON AN INSTALLED ADD-ON, AND NEITHER CHANGES A WATT.**
|
||
|
||
* [[SAFETY-56]] - **three messages stop naming your hardware as the fault** in a
|
||
state where the add-on cannot tell a dead register from a battery that is
|
||
simply empty at its BMS floor. **Text only; the behaviour is measured
|
||
byte-identical.** If you have ever opened Developer Tools because the add-on
|
||
told you a sensor had failed, and found the sensor working, this is that
|
||
defect. On the reference install the state it misdiagnoses arrives on most
|
||
nights and on every cloudy morning after a deep discharge.
|
||
* [[TEL-11]] - **one new optional key, `pv_entity`, and nothing whatever happens
|
||
until you fill it in.** Set it and the add-on records what your solar is
|
||
producing beside what the meter and the battery are doing. ⚠️ **It is not a
|
||
control input and it does not change what the loop does.**
|
||
|
||
⚠️ **THERE IS NO CONTROL-LAW CHANGE IN THIS RELEASE, WHICH IS WORTH SAYING
|
||
BECAUSE 0.10.0 HAD ONE AND NOTHING IN ITS PREAMBLE COULD WARN YOU.**
|
||
`app/control.py` is not in this release's diff at all, and neither are
|
||
`allocator.py`, `arbiter.py`, `arbitrage.py`, `clamp.py`, `peakguard.py`,
|
||
`maintenance.py` or `watchdog.py`. The runtime files that move are
|
||
`app/freshness.py` and `app/main.py` (SAFETY-56's three sentences), and
|
||
`app/pv.py` - new - with `app/main.py`, `app/mqtt.py`, `config.yaml` and
|
||
`translations/` (TEL-11). Contrast [[SAFETY-48]] in `## 0.10.0`, which altered
|
||
what the inverter is asked to do while touching no option, no default and no
|
||
validator rule, so that nothing a config check could see would mention it. This
|
||
release has nothing of that shape in it.
|
||
|
||
⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS
|
||
STILL NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons
|
||
update` does not walk the versions; it installs 0.11.0 in one step, so every
|
||
validator rule added in 0.8.0, 0.9.0 and 0.10.0 arrives at the same moment as
|
||
this section's nothing. Two of them can refuse a config that nobody has edited:
|
||
|
||
> [[SAFETY-39]]'s **rule 10**, added in 0.8.0, against 0.7.0's own shipped
|
||
> default `slew_w: 1000` - the ceiling is 2/18 of the site rail, which is
|
||
> **555.6 W** on a 5000 W rail.
|
||
>
|
||
> [[TARIFF-07]]'s three withdrawn `price_provider` ids - **`engie_dynamic`,
|
||
> `totalenergies_dynamic` and `eneco_eddy`** - each of which is **still refused
|
||
> by name at startup on 0.11.0**, with the same message and for the same
|
||
> measured reasons: a 404 from a gateway with no route, a TLS handshake against
|
||
> a hostname the certificate does not cover, and a 404 with an empty body.
|
||
|
||
⚠️ **NEITHER OF THOSE IS NEW IN 0.11.0, AND NEITHER IS NEWS TO A SITE ALREADY ON
|
||
0.10.0 OR 0.10.1 - IT HAS ALREADY MET THEM.** They are restated here only
|
||
because this is now the newest section, and a reader who opens the newest section
|
||
and stops would otherwise never meet the one class of thing that can stop the
|
||
add-on starting. **The full account of both is in `## 0.10.0` below**: what to
|
||
set instead of each withdrawn id, which tariff a site left on one of them has
|
||
really been running, why the refusal *is* the migration, and why all three ids
|
||
stay in `config.yaml`'s `price_provider` enum even though `PROVIDERS` is down to
|
||
three. **That is the section that withdrew them, it is where the evidence is,
|
||
and it is not superseded by this one.** ⚠️ The reference install is on
|
||
`price_provider: off` and at `slew_w: 400`, so neither applies to it.
|
||
`tools/predeploy_check.py` checks both against your live options blob before you
|
||
press *Update*, and on this tree it reports a real version change rather than
|
||
0.10.1's no-op.
|
||
|
||
Contents of this release, in merge order since 0.10.1 (`b0b24e5`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `c9aaf7f` | SAFETY-56 - the gate cannot tell a dead register from a pack that cannot comply, so stop saying it can **⚠️ TEXT ONLY, BEHAVIOUR BYTE-IDENTICAL** |
|
||
| `94da37f` | TEL-11 - PV production stops being invisible, and stays out of the control law **⚠️ NEW OPTIONAL OPTION, UNSET ON EVERY EXISTING INSTALL** |
|
||
|
||
⚠️ **TWO MORE MERGES LANDED IN THIS WINDOW AND ARE DELIBERATELY NOT IN THE TABLE
|
||
ABOVE: [[CI-9]]'S FOLLOW-UP (`9f6b9f1`) AND [[SEC-2]] (`239e120`). THE RULE IS
|
||
[[PKG-15]]'S - AUDIENCE, NOT IMPORTANCE - AND BOTH FAIL IT THE SAME WAY.** This
|
||
file is the **add-on's** release note: the text the Supervisor renders beside the
|
||
version number, read by somebody deciding whether to press *Update* on a battery
|
||
that is currently regulating. A row here has to be something that reader can act
|
||
on.
|
||
|
||
* **CI-9's follow-up changes one file, `.gitea/workflows/checks.yml`**, exactly
|
||
as CI-9 itself did, and it is comment-only within that file. It is not under
|
||
`goodwe_controller/`, it is not in `predeploy_check.py`'s `RUNTIME` tuple, it
|
||
is not in the built image, and **there is no version of this add-on on which an
|
||
operator can observe it.** `## 0.10.0` excluded CI-9 on exactly that reasoning;
|
||
excluding the follow-up on any other reasoning would make the rule mean
|
||
nothing.
|
||
* **SEC-2 adds `docs/RELEASE.md` and two lines to `README.md`, and this one is
|
||
the closer call, so here is the whole of it.** SEC-2 is by some distance the
|
||
more consequential of the two: it is the ticket that made `master` refuse a
|
||
direct push, and it is *why this release reaches you as a pull request into
|
||
`release/1.0` and then into `master`, rather than as a push straight to the
|
||
branch the Supervisor clones* - which is how 0.10.1 got there. It is also
|
||
**purely a procedure for whoever cuts a release**; both files sit outside
|
||
`goodwe_controller/`, neither is in the image, and no install can tell whether
|
||
that document exists or what it says. An operator reading it learns nothing
|
||
about 0.11.0 that helps them decide whether to take it. So it is excluded, and
|
||
it is excluded under the same sentence as CI-9: ⚠️ **importance to this
|
||
repository is not the test. Observability on an installed add-on is.**
|
||
|
||
⚠️ **AND BOTH ARE WRITTEN DOWN, JUST NOT HERE.** CI-9's follow-up is PR #102 plus
|
||
the comment block at the top of `.gitea/workflows/checks.yml`, which carries the
|
||
measurements in full; SEC-2 is PR #108 plus `docs/RELEASE.md` itself, which is
|
||
now the canonical account of the release path and is the procedure this release
|
||
followed. ⚠️ **If a later CI or packaging ticket changes what the shipped image
|
||
contains, it belongs in this table.** Neither of these does.
|
||
|
||
### SAFETY-56 — three messages stop blaming healthy hardware
|
||
|
||
⚠️ **NOTHING THE ADD-ON DOES CHANGES. THREE THINGS IT SAYS DO.** Same
|
||
conjunction, same limits, same `MISSING`, same 0 W command, same NOT READY row,
|
||
same [[SAFETY-57]] release at 32 s. `tools/safety56_behaviour_trace.py` is what
|
||
says so rather than a commit message: it hashes a document with no sentence
|
||
anywhere in it, built from **2390 one-second reads replayed out of the committed
|
||
2026-09-21 bus capture**, a **66-cell command × register grid stepped 120 times
|
||
each**, and **six driven controller worlds**. That document is
|
||
`6030175ed2ab9a41…` on `release/1.0` and on this tree, byte for byte, while the
|
||
sentences differ. ⚠️ **And the tool REFUSES to report "behaviour unchanged" when
|
||
the sentence halves agree**, so an identical pair is a result rather than one
|
||
tree compared with itself.
|
||
|
||
**THE DEFECT, AND IT IS NOT A TYPO.** [[SAFETY-37]]'s freshness gate fires when a
|
||
battery-power register holds one value while the standing command moves. That
|
||
conjunction is satisfied by **two disjoint worlds**:
|
||
|
||
1. the register has stopped reporting reality - which is what the gate was built
|
||
to catch; or
|
||
2. **reality is not moving**, because the source cannot comply with the command.
|
||
A BMS refusing a discharge at its floor holds the pack still under any
|
||
setpoint, and a register reporting that honestly publishes the same unchanged
|
||
number a dead one does.
|
||
|
||
**Nothing the gate is handed separates them, and three sentences asserted the
|
||
first.** ⚠️ **On the one episode this repository has measured, it was the
|
||
second.** On 2026-09-21 the pack read **11 %** for the whole 850 s hold, **every
|
||
command inside that hold was a discharge**, and the register **answered the first
|
||
CHARGE command three seconds later**. All three numbers are read off the
|
||
committed capture by `test_freshness.py` part 11a rather than typed there. The
|
||
register was healthy and truthful throughout, and the add-on said it had failed.
|
||
|
||
**THE THREE SURFACES, so you can recognise what moved:**
|
||
|
||
| where you read it | what 0.10.1 said | what 0.11.0 says |
|
||
|---|---|---|
|
||
| the **action log** and the **status page** — both quote `freshness.py`'s `STALE_READING:` line verbatim, through `_read_unit()` and `stale_note()` | `… the reading is not following, so it is reported as MISSING` | `… TWO THINGS LOOK LIKE THIS AND THIS GATE CANNOT TELL THEM APART: the register may have stopped reporting reality, or the source may be unable to comply with the command …` |
|
||
| the **add-on log**, at ERROR, from `main.py` | `SAFETY-37: <unit> battery-power register is not following its own command` | `SAFETY-37: <unit> battery-power reading did not move under a command that did, WHICH IS NOT BY ITSELF A VERDICT ON THE REGISTER` |
|
||
| the **readiness row** on the diagnostics page | `battery power: <entity> is missing or not numeric` | `battery power: no usable value this cycle from <entity> - and this row cannot tell which of two things that is … Confirm the entity in Developer Tools before replacing it` |
|
||
|
||
⚠️ **THE THIRD ROW IS THE ONE THAT COST AN OPERATOR AN EVENING, AND IT IS THE
|
||
WORST THING A DIAGNOSTIC CAN DO: IT ASSERTED A FALSE FACT ABOUT A NAMED
|
||
ENTITY.** It read *"is missing or not numeric"* for **any** `None`, and one of
|
||
the ways that value becomes `None` is the freshness gate withholding a reading
|
||
that is present, numeric and updating - `Freshness.reading()` returns `None`
|
||
there **by design**. So the operator opened Developer Tools on the entity the row
|
||
named, found it working, and had every reason to conclude the diagnostics were
|
||
broken. **It is fixed by saying less, not by looking harder:** the row does not
|
||
branch on the gate's state, because the gate already has a surface of its own -
|
||
`stale_note()` rides `liveness_notes()` a few rows below with the whole
|
||
`STALE_READING` sentence in it - so your discriminator is *"is that row there?"*,
|
||
and this row's only job is to stop pre-empting the answer.
|
||
|
||
⚠️ **WHAT IT DOES NOT GIVE YOU IS WHICH OF THE TWO IT WAS.** Naming which would
|
||
need the SoC and the pack floor, and those are deliberately not `freshness.py`'s
|
||
inputs - they are [[TEL-08]]'s and [[SAFETY-32]]'s numbers, on their own rows.
|
||
**SoC is STATED in these sentences and never WIRED into them**: part 11b drives
|
||
the same two worlds at 50 % and at 11 % and the sentence does not move one byte.
|
||
What changed is that the add-on now tells you it cannot tell, instead of guessing
|
||
and blaming your hardware. **The reading rule is unchanged from
|
||
`### SAFETY-57` below**: if the stale/withdrawn pair keeps repeating, the unit
|
||
really does want looking at; if it happens once at a low SoC and stops, the pack
|
||
was empty and the site has resumed.
|
||
|
||
⚠️ **AND THE THREE WORDINGS SHARE NO HELPER, ON PURPOSE.** Two of them are log
|
||
lines about a **behaviour** and the third is a banner about an **entity**; they
|
||
answer different questions ("what did the register do" against "can I trust this
|
||
entity"), and one helper serving both would have to drop whichever half the other
|
||
caller does not need - and the half it dropped would be the operator's next step.
|
||
|
||
**Full account: `DECISIONS/SAFETY-56-what-the-gate-cannot-tell-apart.md`.**
|
||
|
||
### TEL-11 — `pv_entity`: PV production gets recorded, and stays out of the control law
|
||
|
||
⚠️ **UNSET, THIS IS NOT A CHANGE AT ALL - AND THAT IS MEASURED RATHER THAN
|
||
ASSERTED.** `pv_entity` ships empty, which is what **every** install that
|
||
upgrades into this version lands on. Empty means the add-on **reads no entity,
|
||
announces no entity, publishes no topic, shows no status row and adds no page
|
||
row.** `tools/tel11_pv_trace.py` drives five grid behaviours × eight
|
||
PV-sensor behaviours through 24 cycles of the **shipped** `Controller`, on
|
||
`release/1.0` and on this tree, and hashes two separate documents - one of
|
||
**control** decisions and one of **telemetry**:
|
||
|
||
| document | tree | `pv_entity` | sha256 |
|
||
|---|---|---|---|
|
||
| control | `release/1.0` | unset | `846a10a5b89e3210…` |
|
||
| control | TEL-11 | unset | `846a10a5b89e3210…` |
|
||
| control | TEL-11 | **set** | `846a10a5b89e3210…` |
|
||
| telemetry | `release/1.0` | unset | `a443a6ceaffe072b…` |
|
||
| telemetry | TEL-11 | unset | `a443a6ceaffe072b…` |
|
||
| telemetry | TEL-11 | **set** | `4e6d1cb289a95722…` |
|
||
|
||
**Read the two `unset` rows first.** They are byte-identical in **both**
|
||
documents, which is a stronger claim than "the control path is untouched": an
|
||
install that does not set the option sees no change of any kind, announcements
|
||
and status rows included. **Then read the `set` row of the control document** -
|
||
identical again, while telemetry moves. ⚠️ The tool **REFUSES** unless the two
|
||
trees' `app/` fingerprints differ and unless exactly one side reacts to the
|
||
option, so a matching pair cannot be two runs of the same tree.
|
||
|
||
⚠️ **IT IS NOT A CONTROL INPUT, AND IT DOES NOT CHANGE WHAT THE LOOP DOES.** It
|
||
is instrumentation and nothing else. It is not read by the control law, the
|
||
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog; it
|
||
is **not part of `inputs_ok`**, so a PV integration that breaks cannot stop your
|
||
battery being regulated; and its status row is a **caution** (`warn`), never a
|
||
`bad` row, so a telemetry channel can never put a battery site into NOT READY.
|
||
`test_pv.py` part 6 drives 40 cycles of the real `cycle()` and asserts the
|
||
commands are bit-identical across unset / no reading / 0 W / 8 kW, with a
|
||
**computed** census of all 33 modules under `app/` showing the material is
|
||
mentioned in exactly three of them: `pv.py`, `main.py`, `mqtt.py`. Wiring
|
||
production into the control law would be a behaviour change on a system driving a
|
||
real battery - and it would destroy the measurement, because the loop would then
|
||
be reacting to the thing being measured.
|
||
|
||
**WHAT YOU GET IF YOU DO SET IT.** One new MQTT-discovery sensor,
|
||
`sensor.goodwe_pv_power`, `device_class: power` and ⚠️ **`state_class:
|
||
measurement`** - which is the point of the row, because that is what puts the
|
||
number into the recorder's long-term statistics, and a week of statistics is what
|
||
the attribution needs. **Positive is producing.** The unit is **read off your
|
||
entity's own `unit_of_measurement`** - `mW`, `W`, `kW`, `MW`, `GW`, `TW`, Home
|
||
Assistant's own power units, every factor an exact power of ten. A sensor with
|
||
**no** unit, or with a unit that is not power, is **refused by name, and the
|
||
status page says which entity and which unit.** There is deliberately no
|
||
magnitude heuristic: nothing can separate 30 W of dusk from 30 kW of noon, and a
|
||
silent factor of 1000 in the one channel that exists to be believed a week later
|
||
is worse than a refusal you can read.
|
||
|
||
⚠️ **ABSENCE IS PUBLISHED AS `unknown`, NOT AS 0 AND NOT SKIPPED. THIS IS THE
|
||
DESIGN DECISION WORTH YOUR ATTENTION.**
|
||
|
||
| what happened | `sensor.goodwe_pv_power` | the status page |
|
||
|---|---|---|
|
||
| `pv_entity` not set | the entity does not exist | no row at all |
|
||
| set, sensor answered 4100 W | `4100.0` | `PV 4 100 W` |
|
||
| set, sensor answered 0 W | `0.0` | `PV 0 W` |
|
||
| set, sensor unavailable / missing / bad unit | `unknown` | `PV —`, plus a caution naming the reason |
|
||
|
||
The last two rows are why the key is shaped this way. `publish()` normally
|
||
**drops** a `None`, which is right for `circulation` - there the last figure
|
||
standing beats a zero that reads as health - and **exactly wrong here**: a
|
||
dropped row leaves Home Assistant holding the last number it was told, for ever,
|
||
and the recorder then draws a **flat line through the outage**, which is
|
||
indistinguishable from a measurement. Anyone reading that week back would
|
||
conclude the sun was not shining, ⚠️ **which is precisely the conclusion this
|
||
channel exists to TEST rather than to manufacture.** So the absence is published,
|
||
using Home Assistant's own no-value payload (`mqtt/const.py` declares
|
||
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it *before* the numeric
|
||
branch), the entity's state becomes **`unknown`**, and the recorder stores a
|
||
**gap**. A failed read also **clears** the previous good one: the value never
|
||
outlives its own sensor.
|
||
|
||
⚠️ **WHY IT IS A HOME ASSISTANT ENTITY AND NOT A REGISTER.** The RS485 bus this
|
||
add-on speaks carries the **battery** inverter it drives. On nearly every site the
|
||
PV inverter is a different box from a different vendor on its own connection,
|
||
reporting into Home Assistant through its own integration - so this takes
|
||
whatever `sensor.*` your PV integration already provides, **any brand Home
|
||
Assistant can read**, and nothing anywhere assumes it is on the bus.
|
||
|
||
**THERE IS NO `pv_invert`, AND THAT IS NOT AN OVERSIGHT.** `meter_invert` and
|
||
`batt_invert` exist because those two quantities are **bipolar and are read by
|
||
the control path**, where a reversed sensor hands the site a plausible number it
|
||
acts on. Production is unipolar and is read by **nothing**, so a reversed sensor
|
||
cannot mislead a decision, and it announces itself as **−4 kW at noon** in the
|
||
very history this key exists to create. A flag would only let an installer hide
|
||
it. There is no negative-value alarm either: a PV inverter's own standby draw
|
||
makes a small negative reading at night ordinary, so any threshold would be a
|
||
number nobody has measured.
|
||
|
||
⚠️ **WHAT THIS RELEASE DOES NOT DO, AND IT IS THE REASON THE CARD EXISTS.** It
|
||
does not answer [[OPT-10]]'s question. That card measured this site over sixteen
|
||
days: the pack reached 90 % SoC on **two** of them, and **20.5 kWh of surplus
|
||
left the site while the pack still had room** - 12.6 kWh of it with the battery
|
||
not charging at all - and it could not say **why**, because PV production was
|
||
recorded **nowhere**. Three causes need three different fixes (no irradiance:
|
||
nothing to fix; a charge-power limit; the optimiser declining to charge, which is
|
||
the only software defect), and guessing between them is how an optimiser gets
|
||
tuned to solve a cloud problem. **0.11.0 adds the channel. The attribution needs
|
||
a week of history through it, and is OPT-10's own card.** So the reason to set
|
||
`pv_entity` on the day you take this update is that the clock on that week starts
|
||
when you do.
|
||
|
||
**Full account: `DECISIONS/TEL-11-pv-production.md`, with the operator's version
|
||
under "PV production" in `DOCS.md`.**
|
||
|
||
## 0.10.1
|
||
|
||
**A PATCH RELEASE, AND ONE TICKET IS THE WHOLE OF IT: [[SAFETY-57]].** The last
|
||
place moves because this is a bug fix to a version that has shipped and is
|
||
regulating a battery, not a change to what the add-on offers. **The option
|
||
surface does not move at all** - no key added, none removed, no default changed,
|
||
no enum widened, no rule added to `validate.py` - so ⚠️ **a stored options blob
|
||
that starts the add-on on 0.10.0 starts it on 0.10.1**, and there is no
|
||
`SystemExit(78)` to plan around on this hop. If you are on 0.10.0, the only
|
||
question worth your time is whether you have been hit by what it fixes, and
|
||
`### SAFETY-57` below is written to answer exactly that.
|
||
|
||
⚠️ **WHY THIS IS A NEW NUMBER RATHER THAN A FOLD INTO 0.10.0, WHICH IS THE
|
||
REASON THE TICKET EXISTS.** The Supervisor decides that an update is available
|
||
by comparing `config.yaml`'s version string with the one the box reports.
|
||
0.10.0 is installed and running, so a fix delivered inside `## 0.10.0` would
|
||
leave every box reporting `0.10.0` believing it was already current - the
|
||
PKG-06/PKG-07 no-op, in the form where the tree it hides is a safety fix.
|
||
0.10.0's own section argued it could still be folded into; ⚠️ **that argument
|
||
was explicitly conditional on 0.10.0 never having been installed, and the
|
||
condition has fired** - see the closing note at the end of `## 0.10.0` below.
|
||
|
||
⚠️ **IF YOU ARE CROSSING FROM A VERSION OLDER THAN 0.10.0, THIS SECTION IS
|
||
NOT THE ONE THAT CAN REFUSE YOU - `## 0.10.0` BELOW IS.** `ha addons update`
|
||
does not walk the versions; it installs 0.10.1 in one step, so every validator
|
||
rule added in 0.8.0, 0.9.0 and 0.10.0 arrives at the same moment as this
|
||
section's nothing. Two of them can refuse a config that nobody has edited:
|
||
[[SAFETY-39]]'s **rule 10** against 0.7.0's own shipped default `slew_w: 1000`,
|
||
and [[TARIFF-07]]'s three withdrawn `price_provider` ids - **`engie_dynamic`,
|
||
`totalenergies_dynamic` and `eneco_eddy`** - each of which is still refused by
|
||
name at startup on this version, with the same message. Both are written out in
|
||
full under `## 0.10.0`, and `tools/predeploy_check.py` checks both against your
|
||
live options before you press *Update*.
|
||
|
||
Contents of this release, in merge order since 0.10.0 (`cb3f686`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `b9f7bd0` | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** |
|
||
|
||
⚠️ **AND THAT IS THE WHOLE TABLE, WHICH IS THE POINT OF A PATCH NUMBER.**
|
||
[[TEL-10]] and [[SAFETY-48]] also merged after `cb3f686`, but [[PKG-15]] folded
|
||
both into `## 0.10.0` **before 0.10.0 was installed**, so they shipped under
|
||
that number and are recorded there rather than here. [[CI-9]] and [[PKG-15]]
|
||
itself touch `.gitea/workflows/checks.yml` and this file only - nothing an
|
||
operator can observe on an installed add-on - and are excluded for the reason
|
||
`## 0.10.0` already gives. ⚠️ **Two defects that the reference install's own
|
||
field history found are NOT in this release**: [[SAFETY-56]] (the stale-reading
|
||
message says *"is not following its own command"* when the true cause is an
|
||
empty pack, and the NOT READY row that goes with it) and [[SAFETY-58]] (a trip
|
||
on the first read after a rest). Both were open when this section was cut, both
|
||
are wording or bound changes rather than deadlocks, and neither was allowed to
|
||
delay this one. ⚠️ **[[SAFETY-56]] HAS SINCE BEEN FIXED AND IS IN `## 0.11.0`
|
||
ABOVE.** The wording quoted in this paragraph is what **0.10.1 actually ships**
|
||
and is left standing for that reason - if the box in front of you reports
|
||
`0.10.1`, that is the sentence it will print at you. It is not what 0.11.0 says.
|
||
[[SAFETY-58]] is still open.
|
||
|
||
### SAFETY-57 — the stale-reading verdict gets an exit
|
||
|
||
⚠️ **WHETHER YOU WANT THIS UPDATE, AND HOW TO TELL.** On 0.10.0 the add-on
|
||
can enter a state it cannot leave: the command is parked at **0 W**, the
|
||
battery-power reading is gated to **MISSING**, and the only condition that
|
||
would clear the gate is one that a parked command cannot produce. **It is a
|
||
deadlock, not a slow recovery.** On the reference site on 2026-09-21 it held
|
||
for about **thirteen minutes while the house exported 2.4 kW**, and it ended
|
||
only because a human restarted the add-on.
|
||
|
||
**Recognising it costs one look at the status page and one at the log:**
|
||
|
||
| | |
|
||
|---|---|
|
||
| the setpoint | sits at **exactly 0 W** and does not move, while the house is plainly importing or exporting |
|
||
| the battery-power sensor | is **flat at one number** for the whole of it - on the live episode, −3 W for 850 s |
|
||
| the action log | carries a `STALE batt` line and **no** clearing line after it |
|
||
|
||
**What ends it on 0.10.0 is a restart of the add-on**, and a restart is enough -
|
||
nothing is latched to disk and the pack is not harmed. ⚠️ **But nothing ends it
|
||
by itself**, and the replay below says the wait can run to hours. On 0.10.1 the
|
||
state releases itself after a bounded **32 s** and the register is re-graded
|
||
from scratch.
|
||
|
||
⚠️ **IT IS NOT AN EMERGENCY AND IT IS NOT COSMETIC.** The pack is not at
|
||
risk in this state - 0 W is the safe command, and it is the command §11.1 rule 1
|
||
asks for on a missing input. What is lost is the site's own control while it
|
||
lasts: the battery neither charges nor discharges, so a solar export goes to the
|
||
grid and an evening load comes off it.
|
||
|
||
⚠️ **WHAT IT FIXES, ON THE OWNER'S OWN BATTERY.** [[SAFETY-37]]'s freshness gate
|
||
reports a battery-power register as MISSING when it stops following its own
|
||
command, and a missing input is `command 0 W and keep commanding` (§11.1
|
||
rule 1). The gate's only exit was *"an arrival carrying a different number"* —
|
||
**and at 0 W nothing is asking the register to be anywhere.** The state had no
|
||
exit reachable from inside it, and the log line said the opposite.
|
||
|
||
Measured off the RS-485 bus, 2026-09-21, and committed as
|
||
`scenarios/safety57-2026-09-21/`:
|
||
|
||
| | |
|
||
|---|---|
|
||
| 10:51:24 | 0x0518 reaches **−3 W**. The pack is at 11 %, the loop has just reversed out of a charge, and the BMS refuses the discharge. The register is **healthy and truthful**. |
|
||
| 10:51:29 | the command reaches **+1060 W**, 800 W of travel |
|
||
| 10:52:00 | the gate arms. `Inverter.batt` becomes `None` |
|
||
| 10:52:21 | **the site commands 0 W — on the wire, in the capture** |
|
||
| … | the register does not change value for **850 s**. The setpoint does not change value for **790 s**. The house exports 2.4 kW throughout |
|
||
| ~11:04 | **a human restarts the add-on.** That is what ends it |
|
||
|
||
It had been escaping by luck - and ⚠️ **on this episode it did not escape at
|
||
all.** `0x0518` answered the same −3 W on 90 consecutive responses and never
|
||
once carried a different number between 10:51:24 and 11:05:35. The register did
|
||
not move.
|
||
|
||
⚠️ **AND WHY IT USUALLY DOES MOVE IS NOT SOMETHING THIS REPOSITORY HAS
|
||
MEASURED.** An earlier draft of this entry named a BMS keep-alive pulse as the
|
||
thing that twitches the register, stated as fact; **no committed capture
|
||
identifies such a pulse**, distinguishes one from the register simply following
|
||
a command, or sizes it. The honest statement is that the register changes value
|
||
for reasons that belong to the battery's firmware. What IS measured is how long
|
||
it goes without changing: replaying the committed 16-day loop capture through
|
||
the shipped conjunction finds **93 trips** whose wait has a **median of 143 s**
|
||
and a worst case of **5 h 57 m**, with **16 of the 93** at or beyond this
|
||
episode's **790 s**. That figure is stronger than the firmware story it
|
||
replaces, and it needs no firmware story at all.
|
||
|
||
**The rule, and it is [[SAFETY-13]]'s shape one module over** — which latches
|
||
get an exit, not how long the block lasts:
|
||
|
||
> the verdict is withdrawn when the obligation that produced it has been gone
|
||
> for `RELEASE_S`, and the register is then re-graded from scratch.
|
||
|
||
`RELEASE_S` is **17.0 s**, the obligation window already derived in
|
||
`freshness.py` — 6.0 s of plant, one 10 s ESPHome publish, one 1 s add-on read.
|
||
It is the **floor** of its band rather than a preference: below it the release
|
||
pre-empts the cheaper exit (the register answering by itself), and being wrong
|
||
*long* is the failure being fixed. **The whole parked interval is now
|
||
`stale_input_s` + `RELEASE_S` = 32 s**, bounded by two shipped numbers and by
|
||
nothing the battery does. On the live episode: telemetry back at **10:52:39
|
||
instead of 11:05:35**, with **0x0518 still answering the same −3 W**.
|
||
|
||
⚠️ **WHAT IT COSTS, AND IT IS NOT SMALL.** A register that is dead for good is
|
||
re-graded for ever. Driven over 1201 reads at the shipped defaults: the held
|
||
number reaches a consumer on **630 reads (52.5 %)** against 35 (2.9 %) before.
|
||
That is payable only because **one re-grading window is 35 s and the two
|
||
consumers hold for 75 s and 85 s** — neither can reach a verdict inside one,
|
||
and the re-trip that ends each window hands them a `None`, which *clears* their
|
||
windows. **No detection SAFETY-37 had is given back**: a permanent `None`
|
||
clears those same windows for ever too. What the site gets back is its
|
||
meter-derived control, and the command was never derived from `batt`.
|
||
|
||
⚠️ **THAT 35 s HOLDS WHILE THE OBLIGATION COMES BACK, AND ONLY THEN - IT IS NOT
|
||
A UNIVERSAL BOUND, AND THE FIXTURE THAT GRADES IT CANNOT SHOW OTHERWISE.** A
|
||
window closes when the gate re-arms, and the gate needs a command that is
|
||
*moving* to re-arm against. If the release lands where the site then regulates
|
||
below `MOVE_W`, nothing re-arms, the window never closes, and the held number
|
||
reaches a consumer for an unbounded time - where before this card a `None` did.
|
||
The driver behind the 35 < 75 < 85 inequality puts a 1300 W obligation back on
|
||
every release, so it is the one shape that cannot produce that case. On the
|
||
shipped roster of **one** inverter neither consumer can reach it anyway - the
|
||
per-unit sign watchdog is compared against the **meter** at `n_units <= 1`, and
|
||
`circulating_w()` is identically `0.0` there - so this is **a stated gap rather
|
||
than a covered one**, and it needs a second inverter to bite.
|
||
|
||
⚠️ **WHAT YOU WILL SEE.** A new `STALE WITHDRAWN <unit>` line in the action log
|
||
beside the existing `STALE <unit>` one, and a log warning that says in its own
|
||
words that **nothing has been learned about the register**. If the pair repeats
|
||
every ~70 s, the register really is not following and the unit needs looking at;
|
||
if it happens once and stops, the pack was simply empty and the site has
|
||
resumed. The stale line's escape clause was also corrected — it used to promise
|
||
a clearance the state could not produce. ⚠️ Its **diagnosis** half still says
|
||
*"is not following its own command"*, which is wrong in a different way when the
|
||
pack is merely empty; that wording is [[SAFETY-56]]'s and is deliberately
|
||
untouched here.
|
||
|
||
⚠️ **AND THAT IS NOW A STATEMENT ABOUT 0.10.1 ONLY — [[SAFETY-56]] REWROTE THE
|
||
DIAGNOSIS HALF, AND `## 0.11.0` ABOVE CARRIES THE NEW WORDING.** The quotation
|
||
above is left exactly as it stands because it is what the 0.10.1 image prints,
|
||
and a reader on 0.10.1 needs to recognise it. The same applies to the reading
|
||
rule in the paragraph above: on 0.11.0 the add-on no longer asserts that
|
||
*"the register really is not following"* — it names both explanations and says
|
||
it cannot tell them apart — so *"the pair repeats → look at the unit, it happens
|
||
once and stops → the pack was empty"* stops being a correction of the message
|
||
and becomes the message's own advice. Nothing about **what** the gate does moved
|
||
in either card.
|
||
|
||
## 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_provider` IDS ARE RETIRED, AND A SITE SET
|
||
TO ONE OF THEM IS REFUSED BY NAME AT STARTUP.** `engie_dynamic`,
|
||
`totalenergies_dynamic` and `eneco_eddy` are gone from `PROVIDERS`. 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 in `SourceUnavailable`, 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 in `config.yaml`'s
|
||
`price_provider` enum on purpose**: drop one and the Supervisor rejects the
|
||
options file *before the add-on runs*, the operator gets an opaque `expected
|
||
one of ...`, and `validate.py`'s message is never seen - on exactly the sites
|
||
that need it. `PROVIDERS` is three; the enum is seven.
|
||
⚠️ **The reference install is on `price_provider: off` and is unaffected.**
|
||
|
||
* **[[TARIFF-18]] - `price_taxes_eur_kwh` moves from `0.06` to `0.11736`, and
|
||
the non-obvious half is the refusal that deliberately does NOT happen.** A
|
||
site that *stored* `0.06` is unaffected in both directions. It keeps `0.06`,
|
||
because `App.options` is `deepmerge(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 a `price_taxes_profile` period
|
||
table beside a **non-default** `price_taxes_eur_kwh` a 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 stored
|
||
`0.06` into "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.py` carries `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)` and
|
||
`validate.py` reads 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's `0.1198` - is still refused, by name, and the
|
||
refusal now says which two figures it will accept.
|
||
|
||
* **[[SAFETY-10]] - a new `shadow_mode` option, shipped `auto`, and BOTH halves
|
||
of what that does matter.** ⚠️ **AN EXISTING INSTALL THAT IS CONTROLLING DOES
|
||
NOT CHANGE BY ONE WATT.** `auto` is not a constant the Supervisor merges; it
|
||
is a value the add-on **resolves**, and it resolves to `not control_started`
|
||
- `shadow.engaged()` reads `Controller.auto`, the persisted start/stop state.
|
||
An install with `auto_start: true`, or whose operator has pressed *Start /
|
||
stop control*, is started, so `auto` resolves 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 ships `auto_start: false`, has no stored `auto` key, 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 literal `shadow_mode: true` default would
|
||
have stopped the battery at the reference site on the next update.
|
||
**Measured cross-revision** - the same stimulus through the shipped
|
||
`Controller.cycle()` on `8f82dd4` (the merge before SAFETY-10) and on
|
||
`e6da478` (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 with
|
||
`shadow_mode: on` and 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 in `test_clamp.py` part 8 on what reached `set_number()`, against
|
||
a precondition run that puts 30 non-zero writes on the wire. ⚠️ An
|
||
unrecognised `shadow_mode` fails 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 0.10.0 CHANGES THE CONTROL LAW ITSELF, WHICH NOTHING ABOVE AND NO
|
||
CONFIG CHECK WILL TELL YOU ABOUT.** [[SAFETY-48]] bounds how far the
|
||
integrator may run ahead of the command on the cycles where the slew limit is
|
||
what stopped the command moving. It adds no option, moves no default and
|
||
refuses no blob, so every check in this preamble passes it in silence - and it
|
||
is the one change here that alters what the inverter is ASKED TO DO on a tree
|
||
that is otherwise configured identically. ⚠️ **What it is measured to buy is
|
||
less EXPORT. It is NOT a lower capacity-tariff peak, and your billed quarter
|
||
should not be expected to improve.** The whole account, including what it costs
|
||
and what it does not establish, is `### SAFETY-48` below.
|
||
|
||
⚠️ **AND IF YOU ARE UPGRADING FROM 0.7.0 - WHICH THE REFERENCE INSTALL WAS
|
||
WHEN THIS WAS WRITTEN, AND HAS NOT BEEN SINCE 2026-09-20 - 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_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** - and ⚠️ **0.7.0's own shipped default was `slew_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_w` against the ceiling and `price_provider` against the withdrawn list
|
||
BEFORE `ha addons update`**, with `tools/predeploy_check.py`, which does both.
|
||
|
||
✅ **RUN AGAINST THIS RELEASE: CLEARED, exit 0.** ⚠️ This is the record of
|
||
a run made on 2026-09-19, when the reference box was still on 0.7.0. That
|
||
crossing has since been made; the box is on 0.10.0 and the transcript below is
|
||
history rather than the state of any install.
|
||
|
||
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.
|
||
|
||
⚠️ **THAT RUN WAS MADE ON PKG-14'S TREE, AND [[PKG-15]] DID NOT RE-RUN IT.
|
||
HERE IS WHY IT STILL HOLDS, CHECKED RATHER THAN ASSUMED.** The two tickets
|
||
folded in below touch three files between them - `app/control.py`,
|
||
`app/main.py` and `app/mqtt.py` - and **`config.yaml` is not among them**, so
|
||
the option surface and the 75 defaults the blob is merged over are byte-for-
|
||
byte what that run measured. `app/validate.py` imports neither `main` nor
|
||
`mqtt`; from `control` it imports exactly `SLEW_RAIL_FRAC`, `LOOP_TICK_S` and
|
||
`PLANT_SETTLE_S`, and all three are **unchanged** (`2.0`, `18.0`, and their
|
||
quotient) - [[SAFETY-48]] adds `SLEW_LEAD_STEPS` and reads it inside
|
||
`compute()`, which the validator never calls. ⚠️ **And the tool's OTHER half -
|
||
the no-op check - did not run at all in that transcript**, because it is
|
||
gated on `shipped == deployed` and the box reported `0.7.0` at the time. It is
|
||
the half that refuses this fold on a box already running 0.10.0 - ⚠️ **which
|
||
the reference box now is**; see the closing note below the contents table.
|
||
|
||
**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.
|
||
|
||
⚠️ **RE-MEASURED BY [[PKG-15]] ON 2026-09-19, AFTER FOLDING [[TEL-10]] AND
|
||
[[SAFETY-48]] INTO THIS SECTION: 32 RUNTIME COMMITS SINCE `86bdf49`, OF WHICH
|
||
THE TABLE BELOW NOW ACCOUNTS FOR 24.** Re-derived with `is_runtime()` itself
|
||
rather than by adding to the old figure. The other eight are the three misfiled
|
||
tickets in the table above - SAFETY-43 two commits, SAFETY-44 three, TEST-21
|
||
two - plus `cb3f686`, PKG-14's own bump, which edits `config.yaml` and is
|
||
therefore a runtime commit by that function's rule; it was `HEAD` when PKG-14
|
||
counted, so `last..HEAD` excluded it then and includes it now. The three
|
||
misfiled tickets are still misfiled and still [[PKG-08]]'s card; nothing below
|
||
moves them.
|
||
|
||
⚠️ **THE VERSION WAS NOT BUMPED FOR THE FOLDS ABOVE, AND THAT ARGUMENT HAS
|
||
NOW EXPIRED. THIS SECTION IS CLOSED.** As written on 2026-09-19 it ran: 0.10.0
|
||
was cut on 2026-09-16, had never been built or pulled anywhere, the reference
|
||
install was on 0.7.0 and this repository carried exactly one tag (`v0.2.1`) and
|
||
no release - so the string `0.10.0` did not yet name a tree anyone was running,
|
||
the PKG-06/PKG-07 no-op could not apply, and cutting 0.11.0 would have burned a
|
||
number on a release that never left the repository. ⚠️ **IT WAS STATED AS
|
||
CONDITIONAL ON THAT REMAINING TRUE, AND IT STOPPED BEING TRUE ON 2026-09-20 AT
|
||
ABOUT 22:18 CEST**, when 0.10.0 was installed on the reference site as a
|
||
repository add-on (slug `68285ac7_goodwe_controller`) and began regulating a
|
||
real battery. **0.10.0 has been built and pulled, and the reference install is
|
||
on 0.10.0, not on 0.7.0.**
|
||
|
||
⚠️ **AND THE FIELD HISTORY IS NOW THE STRONGEST EVIDENCE IN THIS FILE, WHICH
|
||
IS WHY THE CORRECTION IS WORTH ITS SPACE.** That install has since run a full
|
||
day-night cycle on 0.10.0 - an empty pack, a guard trip, a 2 h 47 min saturation
|
||
freeze, the [[SAFETY-57]] deadlock and a clean recovery - and **two defects came
|
||
out of it that no test in this repository had reached**: [[SAFETY-56]] and
|
||
[[SAFETY-57]]. So nothing may be folded into this section again, and
|
||
`tools/predeploy_check.py` enforces that by itself: run against a box already
|
||
reporting `0.10.0` it refuses with `THE DEPLOY WOULD BE A NO-OP` and names the
|
||
runtime commits since the bump. **SAFETY-57's fix is in `## 0.10.1` above**,
|
||
which is what a patch number is for. ⚠️ **AND SAFETY-56'S IS IN `## 0.11.0`**,
|
||
which is where it went because it landed after 0.10.1 had already been cut. Both
|
||
defects named in this paragraph are now fixed; this section, and `## 0.10.1`
|
||
below it, describe images that still contain them, and are left saying so.
|
||
|
||
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** |
|
||
| `660d079` | TEL-10 - the loop's integrator stops being invisible: three new site sensors **⚠️ THREE NEW ENTITIES** |
|
||
| `186a044` | SAFETY-48 - the integrator's lead over the command is bounded at one slew step **⚠️ THE CONTROL LAW CHANGED** |
|
||
| _this branch_ | SAFETY-57 - the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested **⚠️ MEASURED ON THE LIVE SITE** |
|
||
|
||
⚠️ **[[CI-9]] ALSO MERGED INTO `release/1.0` IN THIS WINDOW AND IS
|
||
DELIBERATELY NOT IN THE TABLE ABOVE, NOR ANYWHERE ELSE IN THIS FILE.** It
|
||
changes exactly one file, `.gitea/workflows/checks.yml`, which is not under
|
||
`goodwe_controller/` at all: it is not in the built image, it is not in
|
||
`predeploy_check.py`'s `RUNTIME` list, and there is no version of this add-on
|
||
on which an operator can observe it. This file is the **add-on's** release note
|
||
- the text the Supervisor puts beside the version number - and the rule PKG-15
|
||
applied is that a row here has to be something the person deciding whether to
|
||
press *Update* could act on. CI-9 is not; the repository's history and PR #95
|
||
are where it is written down, and they have it in full. ⚠️ **THE RULE IS ABOUT
|
||
THE AUDIENCE, NOT ABOUT IMPORTANCE.** CI-9 repaired a merge gate that was
|
||
grading the wrong tree, which is the same class of defect this ticket exists to
|
||
clean up after - and it is still not an operator-facing change. If a future CI
|
||
ticket changes what the shipped image contains, it belongs here; this one does
|
||
not.
|
||
|
||
### SAFETY-48 — the integrator's lead over the command is bounded, and the control law changed
|
||
|
||
⚠️ **THIS CHANGES WHAT THE INVERTER IS ASKED TO DO.** It is the only entry in
|
||
0.10.0 that does so without touching an option, a default or the validator, so
|
||
nothing in this section's preamble and no run of `tools/predeploy_check.py`
|
||
will warn you about it. `app/control.py` is in the diff; `config.yaml` is not.
|
||
|
||
**The defect is a missing case, not a wrong constant.** `compute()` freezes the
|
||
integrator when the inverter is not following, and the comment beside that
|
||
block states the requirement outright — *the INTEGRATOR must stop accumulating,
|
||
not only the command*. But `frozen` was computed from one signal,
|
||
`abs(prev_w - actual_w) > saturation_w`, and **slew-limiting never entered it**.
|
||
The integrator moves `gain × error` per cycle while the command may move at
|
||
most `slew_w`, so the integrator outruns the command whenever
|
||
`|error| > slew_w / gain` — **667 W** at the live tuning, against a measured
|
||
median grid error of **1114 W** at the onset of a recorded episode. ⚠️
|
||
[[SAFETY-39]]'s retune made this **more** frequent, not less: a smaller step is
|
||
a lower threshold.
|
||
|
||
**What ships** bounds the integrator's **lead over the command** at one slew
|
||
step, inside the branch where the slew is what bound the command, and only in
|
||
the leading direction. Nothing else in the law moves: the output clamp, the
|
||
saturation freeze, the deadband and the 10 W quantisation are unchanged, and
|
||
`test_control.py`'s exhaustive `i_w=None` equivalence sweep against the
|
||
transcribed release/1.0 law still passes untouched — the cap moves `i_w`, not
|
||
`target`.
|
||
|
||
⚠️ **WHAT IT IS MEASURED TO BUY IS LESS EXPORT — AND EXPLICITLY NOT A LOWER
|
||
CAPACITY-TARIFF PEAK. DO NOT EXPECT YOUR BILLED QUARTER TO IMPROVE.** Replayed
|
||
over 256 252 recorded meter instants spanning 16.00 days of this install
|
||
(`scenarios/loop-2026-09-02_2026-09-17`, committed), same ticks, same
|
||
disturbance, same plant, same seed:
|
||
|
||
| | release/1.0 | SAFETY-48 |
|
||
|---|---:|---:|
|
||
| **export kWh** | **39.61** | **34.07** |
|
||
| wasted throughput kWh | 4.67 | 3.19 |
|
||
| battery sign reversals | 2992 | 2770 |
|
||
| median recovery s | 232 | 199 |
|
||
| unrecovered (>900 s) | 53 | 31 |
|
||
| worst \|i_w − prev_w\| W | 9200 | 800 |
|
||
|
||
That is **5.54 kWh less export — 14 % of the replayed figure**, and ⚠️ **the
|
||
direction is what is robust, not the size**: across every plant and tuning
|
||
variant swept it falls, by between ~4 % (at `gain: 0.3`) and ~16 % (at a
|
||
re-fitted gain/offset), the median variant landing near an eighth. At `gain: 0.3` and at `slew_w` set to the rail the
|
||
mean-quarter effect is a tie (−0.17 W and **+0.16 W**), because
|
||
`|error| > slew_w / gain` is rarely satisfied in those regimes and there is
|
||
little for the cap to bite on.
|
||
|
||
⚠️ **AND THE BILLED QUARTER IS NOT A CLAIM THIS RELEASE MAKES.** The A/B above
|
||
also shows the worst 15-minute average **offtake** moving 3739 → 3434 W, and
|
||
that figure exists at **one plant model**. `fit_plant()` selects a 7.5 s
|
||
inverter lag on battery-power RMS, which separates it from 2.5 s by **0.3 %**,
|
||
while the instrument's own fidelity measure — replayed export against the
|
||
**29.43 kWh this install actually exported** — prefers 2.5 s by the whole 35 %
|
||
gap. Swept properly: **at every lag ≤ 5.0 s, including the one that best
|
||
reproduces the recorded export, release/1.0's worst quarter is already 3434 W
|
||
and this change moves it by exactly zero.** The lag is not identified by this
|
||
recording, and identifying it needs a driven step response that does not exist
|
||
yet. **What this release can say about the billed quantity is that it does not
|
||
make it worse at any lag tried — not that it improves it.**
|
||
|
||
⚠️ **AND EVERY NUMBER ABOVE IS SIMULATED.** The replay is a first-order plant
|
||
model with a 375 W RMS residual; it prints its own fidelity first and it
|
||
reproduces **39.6 kWh** of export where the install exported **29.4**, i.e. it
|
||
is ~35 % pessimistic about the loop's tracking. Only the **delta** is claimed.
|
||
Nothing here drove an inverter.
|
||
|
||
**What it costs, stated rather than smoothed:**
|
||
|
||
* ⚠️ **The cap does move a saturation-frozen integrator**, because it runs
|
||
before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks
|
||
it changed `i_w` on **1095**, and on **679** of those it moved it further out
|
||
on the side it was already on — putting back magnitude the freeze had just
|
||
let fall, which is the one direction the freeze's own rule forbids. Worst
|
||
case **2424 W**. It never overrides a *same-cycle* refusal.
|
||
* **Two committed numbers moved.** [[SAFETY-13]]'s released command settles at
|
||
−800 W and −1200 W where release/1.0 settled at −1190 W and −2390 W, because
|
||
there is less wound integrator to settle onto; the property asserted is that
|
||
it stops at the frozen integrator rather than at the rail.
|
||
* ⚠️ **[[SAFETY-39]]'s claim that "the slew does not own the over-absorption"
|
||
is now FALSE — inverted**, on the independent first-order plant
|
||
`test_control.py` writes for itself: 1005/1109/1285/1330 W rising with a
|
||
slower slew becomes 729/550/424/326 falling. Two plant models that share
|
||
nothing agree on the sign. SAFETY-39's record carries a forward reference to
|
||
this one.
|
||
* **The form the card originally asked for was rejected on measurement**, and
|
||
this is the least lag-sensitive result in the work: reusing the asymmetric
|
||
freeze on a carried slew flag moves the worst quarter-hour offtake
|
||
3739 → **3935 W** at the fitted plant, and across the lag sweep it is the
|
||
worst of the three at **every lag from 2.5 s upward** — 3732 / 3711 / 3935 /
|
||
4326 W against release/1.0's 3434 / 3434 / 3739 / 3954 — with a tie at lag
|
||
0.0, and it is worse at **every top-N cut** of the quarters. It also raises
|
||
the mean quarter (+2.73 W).
|
||
|
||
`DECISIONS/SAFETY-48-bounding-the-integrator-lead.md` carries all of it,
|
||
including §8, *what this card does NOT establish*.
|
||
|
||
### TEL-10 — the loop's integrator stops being invisible
|
||
|
||
**Three new MQTT sensors, and nothing in the control path moved.**
|
||
`app/control.py` is not in this diff: `compute()`, its returned `Decision` and
|
||
every constant it reads are identical to release/1.0.
|
||
|
||
`self.i_w` has been a separate quantity from the command since [[SAFETY-04]]
|
||
and left the process **nowhere at all** — no entity, no attribute, no log line.
|
||
The investigation behind SAFETY-48 could only *reconstruct* it from the grid
|
||
trace plus a replay; the reconstruction reproduced about a third of the
|
||
engagements it was checked against and was discarded, so a control card had to
|
||
be argued from a measurement nobody could stand behind. It could not have been
|
||
made to work, either: `i_w` depends on every cycle before it, including the
|
||
ones where the arbiter overrode the loop and `main.py` re-seeded it — and that
|
||
re-seed is invisible in the grid trace.
|
||
|
||
| entity | topic | unit | what it is |
|
||
|---|---|---|---|
|
||
| `Loop integrator` | `goodwe_loop_integrator` | W | the accumulator **after** this cycle — before the output clamp, the slew limit and quantisation |
|
||
| `Loop integrator lead` | `goodwe_loop_integrator_lead` | W | that value minus **the command `compute()` was handed**, i.e. the one the previous cycle left standing |
|
||
| `Loop reason` | `goodwe_loop_reason` | — | the control law's own word for this cycle: `tracking`, `deadband`, `i-clamped`, `clamped`, `slew-limited`, `saturated-freeze`, `freeze-released` or `not-running` |
|
||
|
||
⚠️ **`Loop integrator lead` IS NOT A SUBTRACTION OF TWO PUBLISHED ROWS, WHICH
|
||
IS WHY IT IS ITS OWN ENTITY.** `Setpoint` is the **arbiter's** output after
|
||
every strategy has had its say, and it is **this** cycle's; the lead is
|
||
measured against the command `compute()` was given, which is the previous one.
|
||
On any cycle the arbiter overrode the loop the two differ — and that is exactly
|
||
the cycle on which the integrator was re-seeded, so a dashboard doing the
|
||
subtraction would get a different number precisely where it matters.
|
||
|
||
⚠️ **`Loop reason` IS NOT THE `Why` ROW AND NOT `Controller status`.** Both of
|
||
those carry the *arbiter's* sentence — `loop -> -5000 W` — which names the
|
||
winning claim and its watts and has never carried that claim's own reason.
|
||
`slew-limited`, the value [[SAFETY-48]] above is entirely about, appears in
|
||
neither. Measured on one cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`.
|
||
|
||
⚠️ **NOTHING EXISTING IS RENAMED, RE-TYPED OR RE-ORDERED.** The three are
|
||
**appended** after the nine messages release/1.0 already announces, and all
|
||
nine stay **byte-identical**. ⚠️ Eight of them also stay **first**, which is
|
||
what `test_mqtt.py` part 1's prefix comparison grades; the ninth,
|
||
`goodwe_ctl/availability`, is byte-identical but deliberately moves to **last**,
|
||
so a prefix comparison cannot cover it and the check on the next line pins it
|
||
in its new position instead. **They are SITE rows, not per-unit ones** — there
|
||
is one integrator, so a per-unit copy would invent N of a thing there is one of
|
||
— and they therefore appear at **every** roster length, **including a roster of
|
||
one**, where the per-unit machinery announces nothing new at all.
|
||
|
||
Both numbers carry `state_class: measurement`, so the recorder keeps long-term
|
||
statistics for them. `DOCS.md` has the operator account, including how to read
|
||
a standing lead: the cycles needed to unwind it are that lead divided by
|
||
`slew_w`.
|
||
|
||
### 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_w` still 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. See `DECISIONS/SAFETY-45-sizing-the-trade.md` §5, and §2 there for the
|
||
negative-`lo` design that was rejected because it creates a new `lo > hi`
|
||
contradiction 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:
|
||
|
||
1. **Its own factor.** Eneco `0,102` on consumption against `0,100` on
|
||
injection; Aspiravi `0,07` against about `0,10` on its consumption leg. Not
|
||
the consumption `B` with a sign flip.
|
||
2. **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.
|
||
3. **VAT-exempt** — *"Injectietarieven zijn vrijgesteld van btw"* — while
|
||
consumption is at 6%. `vat_pct` is now **per leg**, so the six-point
|
||
asymmetry is expressible; a scalar carries no VAT field at all.
|
||
4. **It goes negative with no floor**, below `11,88 EUR/MWh` of 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 when
|
||
`spot < −(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.py` section 2c drives both sides through the shipped
|
||
`decide()`, 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_kwh` itself: 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 at `0.06` and 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's `tools/predeploy_check.py --options <live blob>` against the live
|
||
options is what would confirm it before deploying.
|
||
* ⚠️ **A site with `price_taxes_profile` SET would have been refused at startup,
|
||
and is deliberately not.** TARIFF-17 made a taxes period table beside a
|
||
**non-default** `price_taxes_eur_kwh` a `SystemExit(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 stored `0.06`
|
||
into "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.py` now carries
|
||
`SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)` and `validate.py` treats 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:
|
||
|
||
```json
|
||
{"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.py` computes
|
||
> `(factor × card.index_eur_per_mwh + offset) × (1 + vat_pct/100)` and
|
||
> **refuses to start** unless it equals `card.expect_incl_vat` to within
|
||
> `0.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` — so `eval` on it is RCE in a root container holding
|
||
`SUPERVISOR_TOKEN`, with `homeassistant_api` and `hassio_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_eddy` was called a "Belgian imbalance product" in `DOCS.md`,
|
||
`app/price_forecast.py`, `app/price_history.py`, `app/static_tariff.py`,
|
||
`DECISIONS/FCST-04-the-day-that-is-carried.md` and 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_eddy` for are now
|
||
**Belgian day-ahead itself**, which really does settle per quarter since the
|
||
15-minute MTU of 2025-10-01 — `generic_dayahead` reads 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 publishes
|
||
`0,1 × BELPEX-H − 1,188` on 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.md` says 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 for `generic_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`, and `resolve_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_eddy` was 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 what `generic_dayahead` reads. The behaviour described
|
||
below is unchanged.**] `eneco_eddy` is 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**: `since` is floored to the hour
|
||
that contains it, and so is `until`. 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 explicit `NoHistory()`. A default of `None`
|
||
meaning "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:` and `schema:` 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_kwh` on 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_w` 5000, `slew_w` 400
|
||
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 `3d29993` is an ancestor of the 0.9.0
|
||
bump `86bdf49` and 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.py` and `app/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.py` and 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:` and `schema:` declare the same 71 keys, so the set HA's *show
|
||
unused optional configuration options* toggle can hide is **empty**, and
|
||
Supervisor's own `UiOptions` marks **59 of the 71 required** - the frontend
|
||
shows those unconditionally, so 59 is the floor even with `options:` 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 from `options:` has no default for the Supervisor to merge into a
|
||
0.7.0 box's stored blob, which is exactly the `delivery_hold_s` mechanism
|
||
that makes this release's upgrade safe rather than exit 78. The count was
|
||
**printed** by `tools/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 from `options:` 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_DEFAULTS` mirrors from the tree rather than naming them, grades all
|
||
30 mirrored keys against `config.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.py` answers a missing
|
||
`target_grid_w` with **0** where `config.yaml` ships **-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_w` hole is closed, by
|
||
[[UX-02]]'s `bcee235` and not by UX-01's `fef6ef2`.** Re-measured on this
|
||
branch: the `2000 -> 2500` triple mutation is **RED at `test_selfconfig.py`**,
|
||
where it survived 21/21 green on 0.7.0. The attribution is corrected in
|
||
`DECISIONS/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 `pack` UNIT claim at `P_SAFETY`
|
||
closes a unit's `hi` at 0 W when its own SoC is at or under its own
|
||
`bms_soc_floor`, and its `lo` at 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) /
|
||
remaining` moves at `excess / remaining` W/s, so at a 1500 W floor the 1.5 W
|
||
tolerance bought exactly **`remaining / 1000` seconds**: 333 ms with 880 s
|
||
left in the quarter, **6 ms with 10 s left**. The gap between the two
|
||
statements measured **12.7–39.5 ms** over 26 instrumented runs. The check was
|
||
decided by *where in the quarter it ran*. It now keeps the `Projection`
|
||
**object** the real cycle graded and decides both forms from it — the clock is
|
||
out of the comparison and the tolerance is **exact**, not widened. Measured on
|
||
a real cycle: re-deciding the delivered form from that object reproduces the
|
||
controller's own floor to **0.000000000000 W**.
|
||
* **The fixture's own room.** `wired_over_limit()` laid telegrams with
|
||
`range(0, span + 1, 5)`, leaving **0–5 s of the open block uncovered by
|
||
construction**, and `excess` multiplies an uncovered second back up by
|
||
`900 / remaining`. Measured on the shipped file: at **12 s of remaining a
|
||
fixture that asked for a 1500 W floor delivered 613 W (−59 %)**, and at 2 s
|
||
the guard **did not engage at all** — which fails "the real cycle files the
|
||
peak claim", "…and the number written to the inverter is the guard's floor"
|
||
and "the STATUS PAGE row for a floor-limited guard" **together**. The
|
||
four-attempt retry could not help: four attempts take ~40 ms and all land in
|
||
the same dying quarter. Fixed with one telegram stamped at `now` (what a real
|
||
meter does, and what the docstring already claimed) plus `FIXTURE_ROOM_S`,
|
||
**derived** from the 1 % accuracy the helper promises rather than picked:
|
||
`net_w × lag / (remaining × want) ≤ 0.01 − lag/900` → **23.6 s → 24 s**, at
|
||
the measured 0.1 s worst-case lag.
|
||
|
||
**Nothing was skipped, disabled or marked xfail** — the file still carries all
|
||
of SAFETY-07 and SAFETY-25's forced-discharge SoC floor, and it gains a check:
|
||
the delivered form re-decided from the cycle's own projection must reproduce the
|
||
real controller's number to the bit, which pins the test's `decide()` kwargs as
|
||
main.py's actual call rather than an approximation of it. **One tolerance got
|
||
stricter** (1.5 W → exact), because removing the clock made exactness possible.
|
||
|
||
**`site_standing` — the one quantity BOTH site guards are asked about — is now
|
||
pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).**
|
||
`app/main.py`'s `site_standing = float(sum(standing.values()))` is the first
|
||
argument of [[SAFETY-30]]'s **latching, site-stopping** sign detector *and* the
|
||
`commanded_w` handed to [[SAFETY-18]]'s `looks_frozen()`. It was pinned by
|
||
**nothing**, and three mutations of it disarmed both guards with the whole suite
|
||
green. **This card changes coverage only: not one byte of `app/` moves — the
|
||
shipped `sum` is correct and must not be "fixed".**
|
||
|
||
* **⭐ THE FIXTURE THE SUITE COULD NOT BUILD: per-unit shares EACH BELOW the
|
||
site gate whose SUM clears it.** Two simulated units rated 220 W and 280 W,
|
||
both obeying exactly, inverted gain, house importing 2000 W — the site
|
||
commands **−500 W against a +2500 W meter** and no single share clears the
|
||
300 W gate. That is the case the site detector exists for, and it is the only
|
||
arrangement that can separate `sum` from the mean and from the largest share.
|
||
`test_watchdog.py` part 9a.
|
||
* **WHY THE EXISTING FIXTURE COULD NOT.** `_sign_site`, the suite's only N≥2
|
||
site fixture, runs shares of about −2500 W **each** — every one individually
|
||
over the gate — so sum, mean and largest-share all agree on it. *Its values
|
||
happen to be equal, and a check comparing two things that happen to be equal
|
||
cannot see the difference it names.* `MUTATION.md` habit 4, ninth instance,
|
||
inside the site half of the very card that names it.
|
||
* **FOUR CANDIDATE REDUCTIONS, FOUR DISTINCT ANSWERS, ONE OVER THE GATE** —
|
||
`sum` −500, mean −250, `max(key=abs)` −280, `[0]` −220 — and that is asserted
|
||
as a precondition, not described. The asymmetric ratings are load-bearing:
|
||
equal shares collapse three of the four onto one number, and the **smaller**
|
||
unit is first so `[0]` stays distinct from `max(key=abs)`. The **gate is the
|
||
shipped 300 W**; only the hold is shortened, because the hold is a wall clock.
|
||
* **⭐ THE SECOND CONSUMER IS COVERED, NOT SCOPED OUT.** The same value is
|
||
`looks_frozen()`'s `commanded_w` at `main.py:1926`. At the same roster the
|
||
site rail is 0.95 × 500 = **475 W**, so `sum` = 500 W rails it while 250, 280
|
||
and 220 do not. Shipped: the peak guard goes blind on SAFETY-18's conjunction
|
||
and files `floor_w = 0.0`. Under both surviving mutations it is **not blind at
|
||
all and files no floor**, so the battery may keep importing through a quarter
|
||
whose billed average is unobservable. `test_watchdog.py` part 9b. The open
|
||
quarter is deliberately backfilled with the stuck reading so the mutant cannot
|
||
reach the same action by the *coverage* route and mask the difference.
|
||
* **AT N=1 NOTHING CHANGES, AND IT IS ASSERTED.** On a real one-inverter
|
||
controller `standing` has one entry, so `sum`, the mean, `max(key=abs)` and
|
||
`[0]` are **the same number** — all three mutations are genuinely *equivalent*
|
||
on a shipped install and no N=1 fixture could ever separate them. Printed and
|
||
checked, beside the site detector still being absent there. Part 9c.
|
||
* **⚠️ A CORRECTION, AND IT IS THE MOST USEFUL LINE IN THIS CARD.** An earlier
|
||
revision of this entry said one of the three — `list(standing.values())[0]` —
|
||
was **already closed** by [[SAFETY-32]]'s package-wide structural pin, and
|
||
told future authors not to re-cover it. **That was false.** It had been
|
||
measured **once**, that run hit `test_peakguard.py`'s [[CI-06]] flake (a
|
||
measured **~20%** false-red rate), and the red was recorded as a mutation
|
||
**kill**. Re-measured on `release/1.0` @ `86eaef2` carrying only that edit:
|
||
the **full 21-file suite is green** and `test_peakguard.py` passes **five for
|
||
five** in isolation — **the mutation survives.** The reason SAFETY-32 cannot
|
||
see it is not the shape: part 12j taints names *bound* to roster values and
|
||
lists *accumulated* in a loop, and `standing` is a dict filled by **subscript
|
||
assignment**, so it never enters the taint set and `sum(standing.values())` is
|
||
not a roster site at all. 12j's own enumeration on the mutated tree lists
|
||
`main.py:1553`, `1555`, `1690` — **not `1599`**. **All three mutations needed
|
||
this card; all three are now covered by it.** *A single red from
|
||
`test_peakguard.py` proves nothing — re-run it five times in isolation and
|
||
name the failing check.*
|
||
* **⚠️ AND A ZERO-BYTE SURVIVOR ONE LINE BELOW THE ONE THIS CARD PINNED.**
|
||
Pinning the *value* `site_standing` said nothing about **where it goes**.
|
||
Swapping the detector's first two arguments —
|
||
`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`
|
||
— is **zero bytes** and left the tree fully green, because the check asserted
|
||
only that `"-500 W"` appeared **somewhere** in the banner. It still does:
|
||
**in the meter slot.** The swapped banner reads *"commanded +2500 W against a
|
||
meter reading −500 W"* — a site charging at −500 W into a +2500 W import, told
|
||
to the operator **exactly backwards**, on the one line somebody woken by a
|
||
latching site-stopping alarm reads first. The check now asserts the **ordered
|
||
pair** rather than a substring, and is red on it. **No `app/` change:** the
|
||
hole was in the test.
|
||
* **⭐ A SURVIVOR THIS CARD WENT LOOKING FOR AND CLOSED.**
|
||
`site_standing = float(self.target)` — the arbiter's site *target* instead of
|
||
the sum of what actually **stands on the wire** — is **−11 bytes** and
|
||
survived the full suite, because in every fixture that existed the two are the
|
||
same number. They are not when a device's reported range is narrower than its
|
||
`max_w`: the `device` claim is filed **per actuator**, so it bounds each
|
||
unit's share and never the site target. A site asking for −500 W while −250 W
|
||
stands would then take a **latching site-wide false trip** under its own
|
||
300 W gate. Closed by part 9a's device-clamped counterfactual.
|
||
* **⚠️ AND THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER — THE ONE A SHIPPED
|
||
INSTALL CAN ACTUALLY SEE.** The ordered pair went onto `main.py:1620`, the
|
||
**site** detector. That detector is armed only at N≥2, and part 9c asserts it
|
||
is **absent at a roster of one** — so on every install this add-on has, the
|
||
**per-unit** banner is the only sign-disagreement sentence that can ever be
|
||
printed, and its call at **`main.py:1560`** was still unpinned. The identical
|
||
swap there is **0 bytes** and left the tree **21/21 green**, and nothing
|
||
behavioural could ever have caught it: `disagrees()` is **symmetric** in those
|
||
two arguments (`commanded_w * against_w < 0`, `abs()` gate on both), so the
|
||
mutant takes **identical actions on every input**. What moves is the sentence
|
||
— measured, **9 emitted banner lines invert in 3 distinct sentences**,
|
||
including the one persisted to `sign_reason` and read back after a restart:
|
||
*"commanded +1000 W against its own measured power −1000 W"* becomes
|
||
*"commanded −1000 W against its own measured power +1000 W"*. Closed by the
|
||
ordered pair on **both** per-unit banners — the N≥2 `its own measured power`
|
||
one and the **N=1** `a meter reading` one — with both operands read off the
|
||
**observed cycle** rather than off the arguments the detector was handed, plus
|
||
a precondition on each that the two operands really are different and opposite
|
||
in sign. **Red on four named checks, green in the other twenty files. No
|
||
`app/` change.**
|
||
* **⭐ AND THE FAMILY IS NOW ENUMERATED BY MACHINE, because it had been
|
||
enumerated by hand twice and was short both times.**
|
||
`tools/symmetric_message_scan.py` finds every call whose argument order is
|
||
load-bearing for a **message** rather than for a decision: it renames each
|
||
parameter pair, erases every formatted string, canonicalises the commutative
|
||
operators and iterates to a **fixpoint** (the symmetry is usually one call
|
||
deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||
classifies each sentence by where it goes. A second census covers sentences
|
||
the **caller** builds. It found **one open survivor this card does not close
|
||
and now discloses** — `rail_unreachable()`'s `dev_min`/`dev_max` at
|
||
`main.py:3208`, **0 bytes, 21/21 green**, a status-page row — and **twelve**
|
||
unpinned caller-built sentences including SAFETY-31's `circ_reason`, which is
|
||
the same shape and is also persisted. The list, the pinned status of every
|
||
member and the instrument's own blind spots are in
|
||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7.
|
||
* **⚠️ AND A LEDGER SENTENCE THIS CARD ITSELF GOT WRONG, CORRECTED WITH THE
|
||
MEASUREMENT.** An earlier revision said *"before part 9 existed, **three** of
|
||
these left the whole suite green — M1, M2 and M3 — and the remaining seven
|
||
were caught by SAFETY-30's own N≥2 checks"*. **Eight** did. Re-run one at a
|
||
time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged,
|
||
byte delta and `main.py` fingerprint printed, **full 21-file suite** every
|
||
time: M1, M2, M3, M4, M7, M8, M9 and M10 are all **21/21 PASS**; only M5 and
|
||
M6 go red. It is the same species of error this round exists to fix — **a
|
||
survivor recorded as a kill** — and the file contradicted itself three times
|
||
while saying it. **And the two that ARE caught make the card's case:** M5
|
||
(`sum(abs …)`) and M6 (`-sum`) are the only two that reverse the **sign**.
|
||
Every other entry changes only the **magnitude**, and the old `_sign_site`
|
||
fixture's ~−2500 W shares put nine different magnitudes on the **same side of
|
||
the same 320 W gate**. The pre-existing coverage could see a sign and nothing
|
||
else.
|
||
* **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to
|
||
the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`,
|
||
bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the
|
||
byte delta printed. All 13 red.** Every one was **re-run independently** in
|
||
this round rather than carried forward from the previous round's report. The
|
||
claim-wiring pair (`sign_site` and `circulation` moved from site scope to unit
|
||
scope) closes two gaps a reviewer disclosed — though **`circulation`'s kill
|
||
belongs entirely to pre-existing checks and is not claimed by this card.**
|
||
⚠️ One **false red** from `test_peakguard.py` on M10 did not reproduce in four
|
||
subsequent runs; it is CI-06's known flakiness, no claim here rests on it, and
|
||
it is recorded rather than quietly dropped — unlike the one that *was*
|
||
believed, two bullets up. Ledger, and an **enumerated list of what was not
|
||
mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a.
|
||
* **⭐ ROUND 4: THE SCANNER COVERED ONE SYNTACTIC SINK AND CALLED IT THE
|
||
FAMILY, AND TWO LIVE MEMBERS WERE SITTING IN THE HOLE.** Every one of census
|
||
B's 16 members was a sentence that is **appended, assigned, or logged**. Not
|
||
one was **RETURNED** — and census A's return bucket only ever held a callee
|
||
formatting its own *parameters*, so a method formatting its own **attributes**
|
||
and handing the string back was invisible to both. That is the whole
|
||
`describe()` / `explain()` family, which is where this codebase keeps its
|
||
operator sentences. **Worse than the gap: it was in none of the five blind
|
||
spots the tool printed about itself.** The tool now scans the return sink
|
||
(census C), resolving it through the returning method's **call sites** so that
|
||
a returned *key* — `store.unit_key()`, `mqtt.unit_state_key()` — is not
|
||
mistaken for a returned *sentence*; it also scores a sentence handed to a
|
||
constructor as `reason=` or placed under a `"text"` dict key, which added
|
||
**six more caller-built members** it had been stepping past. Re-run, it
|
||
**rediscovered both of the members this round was sent to fix without being
|
||
told where to look**.
|
||
* **⭐ `ClampResult.describe()` — the clamp alarm the owner has been complaining
|
||
about all day — had a SECOND independent way to lie, and no `test_*.py`
|
||
mentioned `describe()` at all.** Transposing `requested_w` and `applied_w` in
|
||
`clamp.py:253` is a **zero-byte** edit that leaves the guard reporting a
|
||
setpoint clamped **UPWARD**, in the measured case to +9999 W by a bound whose
|
||
rating the same sentence gives as 2000 W. It is the §15 action-log row
|
||
(`main.py:1366`) **and** the ClampWatchdog notification reason
|
||
(`main.py:2452`). Measured **21/21 green** before; now **red on 4 checks** in
|
||
`test_clamp.py` part 6d, pinned on the **ordered pair** on the contiguous
|
||
literal at **both** consumers, with operands read off `ctl.target` and
|
||
`hass.writes[-1]` — what the loop asked and what the device was sent — rather
|
||
than off the `ClampResult` the swap lives in.
|
||
* **⭐ `PeakDecision.explain()` — the status-page banner — the same shape.**
|
||
`projected_w` ↔ `ceiling_w` at `peakguard.py:493`, **zero bytes**, and the
|
||
row then reads *"quarter projects 5013 W under a 2300 W ceiling"*: a quarter
|
||
**2713 W over** its ceiling, on a row whose own words say *idle* and whose own
|
||
flag says **ok**. `"peak guard idle"` appeared in no `test_*.py`. Measured
|
||
**21/21 green** before; now **red on 3 checks** in `test_peakguard.py` part 13,
|
||
pinned on the decision and on the shipped status row at `main.py:3313`.
|
||
⚠️ **A correction the ticket is owed:** the idle sentence does not reach
|
||
`main.py:3299/3302` — those are the *floor-limited* branch, where `explain()`
|
||
returns `self.reason` before the idle literal is built. It reaches
|
||
**`main.py:3313`** and, on the release edge, `main.py:1951`.
|
||
* **`test_arbiter.py`'s scope-violation check made a claim WIDER than its
|
||
assertion** — it said `scope_violations` *"names the source and the actuator
|
||
it does not belong on"* and asserted `"peak" in scope_errors[0]`, which is
|
||
still true after the two swap roles. Measured this round: **0 bytes, 21/21
|
||
green**; now red on 2 checks, pinned on the ordered pair.
|
||
* **14 more zero-byte mutations measured, and 10 SURVIVORS ARE DISCLOSED RATHER
|
||
THAN CLOSED**, each with its file fingerprint and its 21/21 verdict, in
|
||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7e–§7g: **two** in
|
||
`allocator.py`'s `UnitPlan.explain()`, three across `arbiter.py`'s two
|
||
`explain()`s, two in `tariff.py`, two in `arbitrage.py`'s trade reasons and
|
||
one in `main.py`'s SoC-floor notification. The other four of the fourteen are
|
||
kills: `allocator.py:162` was already caught by `test_alloc.py`, and
|
||
`clamp.py:253`, `peakguard.py:493` and `arbiter.py:264` are closed here. **An enumerated list with honest
|
||
gaps beats a fifth round finding a fifth instance.**
|
||
⚠️ **And the probe that grades those mutations was itself the defect, once.**
|
||
Its first cut numbered the operands **by order of appearance**, so after a
|
||
transposition the other leaf took the same number and all thirteen renders came
|
||
back **byte-identical** — which reads exactly like *the edit is inert*. Keying
|
||
the value to the **name** fixed it. A measurement comparing the swap against
|
||
itself, in the instrument this time.
|
||
* **The tool now states what it does NOT scan**, largest hole first:
|
||
`%s`-style lazy logging (`_LOG.error("… %s … %s", a, b)` puts the operands in
|
||
*arguments*, so no census here looks at them and the transposition is the same
|
||
zero-byte edit — **31 such calls across 8 modules, counted and listed by the
|
||
tool rather than asserted**), `.format()`/`%`/`+`/`.join()` sentences, a sentence that
|
||
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
|
||
sentences, message attributes outside the `reason`-shaped set, and everything
|
||
outside `app/`. Its five over/under-report gaps stay, and every census row now
|
||
prints whether the two interpolations **share a format spec** — the difference
|
||
between a transposition that is silent and one that merely looks broken.
|
||
|
||
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
|
||
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
|
||
one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere
|
||
to go. It has one now — **without touching a single entity that already
|
||
exists.**
|
||
|
||
* **⭐ A's discovery payloads are byte-identical to `release/1.0`.** Same topics,
|
||
same `unique_id`s, same payload bytes, same retain flags, in the same order —
|
||
verified by **diffing the announced payloads**, not by reading the code:
|
||
`9 topics, 0 missing, 0 changed, 0 added` at a roster of one, and
|
||
`0 missing, 0 changed, 8 added` at a roster of two. That is what protects the
|
||
live install's recorded history and the three watchdog automations that name
|
||
its entities.
|
||
* **⚠️ THE ASYMMETRY IS DELIBERATE — DO NOT "CLEAN IT UP".** The first roster
|
||
entry's per-unit rows go on **the device it already has**; every later entry
|
||
gets its **own** device (`via_device` the controller). "One device per
|
||
inverter" is tidier and is the change that renames every entity on the live
|
||
system — and because HA keeps the id it composed at first discovery, a rename
|
||
produces a **new entity beside a dead one**: the history stays behind and
|
||
every dashboard, template and automation goes on pointing at a sensor that
|
||
quietly stopped updating. The composed ids come out symmetric anyway
|
||
(`sensor.goodwe_inverter_a_battery_power` /
|
||
`sensor.goodwe_inverter_b_battery_power`).
|
||
* **Four per-unit rows** — setpoint, battery power, SoC and the SAFETY-02 sign
|
||
latch — announced **only from a roster of two**. At N=1 nothing is announced
|
||
and nothing is published: no second device, no renamed entity, no added topic,
|
||
asserted rather than assumed.
|
||
* **Site-scoped sensors stay site-scoped.** `grid` and `p1_sample_age_s` are
|
||
announced **once**, on A's device, at every roster length 1–6. There is one P1
|
||
and one grid connection, and `p1_sample_age_s` is a **watchdog input** — a
|
||
second copy is a second answer to "is the meter fresh?", one of which is
|
||
always the one nobody is looking at. Circulating power, controller status,
|
||
maintenance phase and the capacity-tariff peak are site for the same reason.
|
||
* **`inverter.roster_names()`** is now the one answer to "what are the units
|
||
called", read by `build_roster()` **and** by the publisher — which has to know
|
||
the roster **before** a `Controller` exists, because announcing happens on the
|
||
broker's CONNACK.
|
||
* **`test_mqtt.py`**, a new suite: the recorded `release/1.0` baseline
|
||
(generated by running that commit's module, not transcribed), the payload
|
||
diffs, the composed entity ids modelled **independently** of `app/mqtt.py` and
|
||
anchored on the one id verified against the live system
|
||
(`sensor.goodwe_p1_sample_age`), the announcement-vs-publication agreement on
|
||
a real `Controller` with **different** SoC, battery sign and latch state on
|
||
the two units, and **twelve mutations of the shipped module, all red, each on
|
||
a named property**.
|
||
* **MEASURED, NOT ASSERTED.** Three of those properties exist because the
|
||
mutation table found their absence: dropping the unit from the *state topic*,
|
||
moving `grid` into the per-unit set, and `units=()` in main()'s publisher call
|
||
were all **green** on the first run. So was a roster fallback that named two
|
||
nameless entries `inverter-a`. Sixteen mutations were run through
|
||
`tools/mutation_run.sh`; the survivors are enumerated **with their size** in
|
||
`test_mqtt.py` part 9.
|
||
* ⚠️ **Inverter B does not physically exist** (~2026-09-12). Every fixture here
|
||
is **simulated**, from the datasheet and serial in `NOTES.md` §1.1b. Nothing
|
||
in this card was measured on a second inverter.
|
||
|
||
**The add-on configuration screen now speaks English and Dutch instead of raw
|
||
option keys (UX-01).** There was no `translations/` directory at all, so Home
|
||
Assistant fell back to rendering the option KEYS — an operator opening
|
||
*Configuration* read `unit_sign_disagree_w`, `saturation_cycles`,
|
||
`integrator_max_w`, seventy of them, in file order, with no description and no
|
||
way to tell a constant derived from 1890 closed-loop test runs from their own
|
||
inverter's rating. **This card changes presentation only: not one default, not
|
||
one range and not one line of behaviour moved.**
|
||
|
||
* **`translations/en.yaml` and `translations/nl.yaml`**, with a `name` and a
|
||
`description` for **all 70 options** and for **all 7 per-entry keys** of the
|
||
`inverters:` repeatable section. The Dutch is written for a Flemish
|
||
homeowner rather than transliterated: *afname* and *injectie*, *kwartierpiek*
|
||
and *capaciteitstarief*, *laadniveau*, *uitschakelgrens* — the words that are
|
||
on the bill and on the meter.
|
||
* **⭐ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM AND WHETHER TO TOUCH IT.**
|
||
`unit_sign_disagree_s` now reads *"derived from 1890 closed-loop test runs
|
||
(SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs
|
||
produce… do not change it without re-deriving"*; `circulation_w` cites its 924
|
||
runs and the measured 400 W knee; `meter_freeze_s` cites the 2399 s capture;
|
||
`bms_soc_floor` says it is register `0x0560` read off the pack. The two
|
||
numbers that are genuinely the operator's — `max_w` and `peak_cap_w` — say
|
||
**"THIS IS YOUR NUMBER"** instead, and `cycle_cost_threshold_eur`, which is
|
||
neither measured nor the operator's, admits it is an open product question
|
||
(OPT-08) rather than borrowing the others' authority.
|
||
* **`config.yaml` reordered into task order** — hardware/entities → meter →
|
||
ratings and limits → tariff → maintenance → safety → advanced — in **both**
|
||
halves, with every comment carried with its key. ⚠️ The rendered order is the
|
||
**`schema:`** half's (`App.schema_ui` is `UiOptions(...)(data["schema"])`);
|
||
the `options:` half is reordered to match so the file reads the way the tab
|
||
renders. ⚠️ **`test_inverter.py` part 12's first-match trap is preserved**:
|
||
the nested per-entry `max_w:` still appears BEFORE the flat one under
|
||
`schema:`, and `inverters: []` still ships in the `options:` half. Moving the
|
||
nested block below the flat one is a **1-byte** edit and it was run: red in
|
||
`test_inverter.py` and in `test_translations.py`.
|
||
* **MEASURED, NOT ASSERTED.** `tools/supervisor_shape_probe.py` gains a section
|
||
F that loads both files with Supervisor 2026.08.0's *own*
|
||
`_read_app_translations()` out of the release image (a file that fails
|
||
`SCHEMA_APP_TRANSLATIONS` is **dropped with a log warning and nothing else**),
|
||
runs its own `UiOptions`, replays the frontend's label resolution transcribed
|
||
from the shipped bundle, and prints every rendered row in both languages.
|
||
* **⚠️ ONE MEASURED NEGATIVE RESULT, RECORDED RATHER THAN GLOSSED.** The
|
||
per-entry keys inside the `inverters:` repeatable section still render RAW.
|
||
`_convertSchemaElement` sends `type: "schema"` **with** `multiple` to
|
||
`ha-selector-object`, which labels its fields `fields[name].label || name`,
|
||
and the converter sets neither `label` nor `translation_key`. The `fields:`
|
||
translations are shipped and validated because that is the shape Supervisor's
|
||
own schema defines for exactly this, and a frontend that wires `label` through
|
||
would consume them — but on 20250925.1 they do not reach the screen, and this
|
||
card does not claim they do. The `inverters` label and description themselves
|
||
do.
|
||
* **A new check, `test_translations.py`, so this cannot rot.** A translation key
|
||
naming an option that does not exist, or an option with no translation, fails
|
||
CI — in both directions, for both languages, top-level and per-entry. It is
|
||
**mutation-tested both ways**: eleven mutations applied in-process, each
|
||
required to NAME the key it broke rather than merely to go red, plus a
|
||
negative control that the unmutated input is clean. Two of the eleven are the
|
||
ones that cost the WHOLE FILE rather than one label — an unrecognised
|
||
top-level block, and a `network:` value that is not a string. Supervisor
|
||
validates each translation file through `SCHEMA_APP_TRANSLATIONS` and, on
|
||
refusal, **logs a warning and drops all of it**, so those silently return the
|
||
entire tab to raw keys; neither is reachable through the `configuration:`
|
||
coverage, which is why they were hunted for and closed. Six more were run **on disk**, through the
|
||
purge-and-re-read protocol: deleting a translation (−65 B), renaming an option
|
||
in `config.yaml` (+4 B), adding an orphan key (+47 B), emptying a Dutch
|
||
description (−711 B), breaking one label's group prefix (−4 B) and deleting a
|
||
provenance citation (−19 B) each go red.
|
||
* **The parser has a licence.** There is no PyYAML in the add-on's CI image, so
|
||
`tools/translations_lint.py` reads these files by hand — and a hand parser
|
||
that quietly disagrees with the real loader is the defect this repository
|
||
keeps finding. The probe imports **that same module** and asserts, key by key
|
||
and string by string, that it returns what `_read_app_translations()` returns.
|
||
The parser is deliberately strict and **raises** on anything outside the
|
||
subset; nine malformed shapes are fed to it to prove it does.
|
||
* **⚠️ WHAT THIS CHECK CANNOT SEE, stated with its size.** It grades coverage
|
||
and provenance *citations*, not truth. Falsifying a figure inside a
|
||
description that is not one of the cited tokens survives (run: *"8.5 times"* →
|
||
*"3 times"*, −2 B, green), and swapping two options' descriptions survives
|
||
(0 B, green). Separately and out of this card's scope: changing a shipped
|
||
default that no suite pins survives — `step_w: 10` → `20` (0 B) leaves
|
||
`test_control.py`, `test_clamp.py`, `test_validate.py` and
|
||
`test_peakguard.py` green. That is the same family as CFG-04's DEFECT 2 and
|
||
the UX-01 rescope moved it to its own card on purpose.
|
||
|
||
**Inverter B's rating is 4600 W, FROM ITS DATASHEET, and every documented copy
|
||
of it now says so (SPIKE-04a).** Three places stated B's rating — `config.yaml`'s
|
||
commented roster example, `DOCS.md`'s, and `tools/supervisor_shape_probe.py`'s
|
||
"a blob an operator would POST" — and all three said **3000 W**, a number that
|
||
was never measured, never read off a datasheet, and simply wrong. The owner
|
||
supplied B's model and serial on 2026-09-04: a **GoodWe GW5048D-ES**, serial
|
||
`55048ESU22AW0992`, max charging and discharging power **4600 W**. No control
|
||
behaviour changes here; this is documentation and its pin.
|
||
|
||
* **Serial characters 6-8 read `ESU`** → platform 105, AA55, the supported set.
|
||
Not `ESN`/`ESC` (ES G2, platform 745). SPIKE-01 does not gate this site.
|
||
⚠️ A passed platform gate is **not** a proven control surface: `specs.md`
|
||
already calls other platform-105 serials *"same protocol family; untested
|
||
silicon"*, and this repo's `0x056E`/`0x056F` map was reverse-engineered from a
|
||
passive tap on **one BPS unit**. That check is the bench half of SPIKE-04.
|
||
* **⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and one ambiguity is
|
||
recorded rather than guessed.** The datasheet prints it under *Battery Input
|
||
Data*, so it is probably **DC**, while `max_w` is graded against `0x0518` —
|
||
the inverter's own **AC** power. If it is DC the AC ceiling is nearer
|
||
4600 × 0.94 ≈ **4324 W**, and the charge direction moves the other way
|
||
(≈ 4894 W drawn AC-side). **Neither reading is written in as fact.**
|
||
SAFETY-20's `min(max_w, dev_max)` already bounds it correctly under either,
|
||
once `dev_max` is read off the unit — so `max_w: 4600` is what to configure
|
||
today. `goodwe/NOTES.md` §1.1b carries the full record, with the date and the
|
||
method on every figure.
|
||
* **A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.**
|
||
`test_validate.py` now pins all three documented rosters: they must be
|
||
findable, they must agree, and they must say **4600**. It also grades the
|
||
**provenance beside the number** — the datasheet it came from, the `0x0518`
|
||
it is graded against, and the 4324 W the DC reading would make it — **within
|
||
2500 characters of the roster, not file-wide.** The first version searched
|
||
whole files and was already vacuous on two of them, because `0x0518` appeared
|
||
in `config.yaml` and `DOCS.md` for unrelated reasons before this card: a
|
||
check whose data could not reach the failure it named, in the file that
|
||
exists to complain about those. Four mutations were run against the fixed
|
||
version — the probe back to 3000 (0-byte delta), the `DOCS.md` caveat
|
||
deleted, `config.yaml` alone "corrected" to 4324 (0-byte delta), and the
|
||
probe's entity renamed so the regex finds nothing — and all four are red.
|
||
* **THE TEST FIXTURES KEEP 3000 W ON PURPOSE, AND NOW SAY WHY AT THE LINE.**
|
||
`test_inverter.py`, `test_maintenance.py`, `test_peakguard.py` and
|
||
`test_watchdog.py` build a second inverter at 3000 W against A's 5000 W. The
|
||
only property those checks consume is that B's number is **unmistakably** not
|
||
A's, and the real pair is a **400 W** gap — narrower than `slew_w`, inside the
|
||
range where an off-by-one-unit bug and ordinary control arithmetic produce
|
||
indistinguishable numbers. Collapsing them onto the real values would buy
|
||
realism those checks do not use and cost them their separating power, which
|
||
is the defect `test_peakguard.py`'s own §10g precondition exists to prevent.
|
||
They are **illustrative, not B's rating, and no longer a placeholder for it**.
|
||
* **Two consequences with numbers, recorded in `goodwe/NOTES.md` §1.1b for the
|
||
cards that own them.** At 94 % battery-to-AC a round trip is ≈ 88 %, so A
|
||
charging 3 kW while B discharges 3 kW burns ≈ **360 W continuously** with the
|
||
meter at zero — SAFETY-31 argues its threshold from *"a few watts"* and has no
|
||
figure. And **< 13 W night self-consumption per unit** (≈ 26 W for two,
|
||
≈ 0.6 kWh/day), which nothing in this add-on accounts for anywhere.
|
||
* **The site is asymmetric in opposite directions:** A = 5000 W / 15.36 kWh
|
||
nominal, B = 4600 W / 20.48 kWh — ~33 % more energy, ~8 % less power. No
|
||
stability implication (ARCH-01's ḡ = Σαᵢgᵢ is a convex combination), but a
|
||
full recalibration of B moves 33 % more energy and **MAINT-05's 12 h `drain`
|
||
phase timeout was derived on A**; it should be re-checked against B rather
|
||
than assumed to carry.
|
||
|
||
**Every pack is recalibrated on its own schedule, and only one moves power at a
|
||
time (MAINT-05).** §12's monthly cycle drains a pack to empty and refills it to
|
||
true its coulomb counter. There was exactly one `Maintenance` state machine and
|
||
one un-namespaced history, so at two packs **only the first was ever
|
||
recalibrated** and the second's counter drifted forever — and nothing prevented
|
||
the cycle draining pack A while the loop or arbitrage charged pack B, which is
|
||
two power-moving phases on one grid connection pulling against each other.
|
||
|
||
* **ONE `Maintenance` PER ROSTER ENTRY**, each with its own schedule, phase and
|
||
history under `unit/<name>/...`. `store.migrate_maint_keys()` moves an
|
||
existing install's flat history into the first pack's namespace under
|
||
`migrate_unit_keys()`'s three rules — shared in code, not restated — with the
|
||
same read-back postcondition, and the flat keys are kept **live** as the
|
||
rollback view so a downgrade finds a current history rather than one dated
|
||
migration day.
|
||
* **ONE MAINTENANCE SLOT PER SITE.** At most one pack may be in a power-moving
|
||
phase (`drain`, `charge` **and** `hold` — `charge` is in the set because a
|
||
pack pulled full from the grid beside a pack exporting to empty is 4 kW
|
||
circulating with the meter at zero). The holder is **derived** from the packs'
|
||
own stored phases, not persisted, so a restart mid-drain re-derives it. When
|
||
several packs are due in the same hour the slot goes to the one that has gone
|
||
**longest without a completed cycle**; roster order breaks a numeric tie and
|
||
nothing else. Nothing preempts a cycle in flight.
|
||
* **THE DEFERRED PACK IS NOT STARVED AND IS NOT A FAULT.** A deferral does not
|
||
stamp `last_start_attempt` (that is the 90 min post-abort cooldown, inside a
|
||
60 min start hour), and does not move the phase, so neither the 12 h `drain`
|
||
nor the 36 h `charge` timeout — *"the battery is not following commands"* —
|
||
can be reached by waiting. It says WAITING, names the pack it waits for, and
|
||
recalibrates at the next start hour; a test drives the winner's whole cycle
|
||
and observes it start.
|
||
* **THE SCHEDULE IS NOT STAGGERED BY CONSTRUCTION.** The interlock defers and
|
||
the stagger is what comes out of it: one contested month separates two packs
|
||
by one cycle length and they stay separated. `DECISIONS/MAINT-05-per-pack-maintenance.md`
|
||
§3 carries the argument.
|
||
* **`validate.py` rule 6 now grades `maintenance_charge_w` against the WEAKEST
|
||
cap on the roster**, like rule 5. Its own comment said it would have to move
|
||
the day `maint_unit` stopped being `inverters[0]`; that day is this card. It
|
||
refuses configurations that used to start — a 3000 W charge beside a 2000 W
|
||
pack that is now recalibrated too — which is the `lo > hi` contradiction rule
|
||
6 exists to refuse, caught when the config is written. Identical at N=1.
|
||
* **AT A ROSTER OF ONE, NOTHING CHANGES**, and it is asserted rather than
|
||
assumed: the state file keeps the flat keys and gains none, the migration does
|
||
not run, the four cycle events are the shipped strings character for
|
||
character, and the status banner is the bare phase with no pack name.
|
||
* **THE MIGRATION IS GRADED AGAINST THE ROSTER, NOT AGAINST THE NAME IT WAS
|
||
HANDED** (fix round). `migrate_maint_keys()` takes the roster rather than a
|
||
pack count and `_verify_moves()` checks the namespace it wrote against the
|
||
names the machines actually read. A postcondition that reads back the
|
||
namespace it was given cannot detect being given the wrong one: handed
|
||
`inverter.UNIT_A` on a roster of `["garage", "shed"]`, the first version of
|
||
this card wrote all four keys under `unit/inverter-a/...`, read all four back
|
||
equal, reported **verified**, and stamped `maint_schema_version` — so both
|
||
packs came up believing they had never been recalibrated and the next start
|
||
skipped the migration too. That is now `misdirected`: not `ok`, the marker is
|
||
**not** written, and the next start re-runs and recovers (rule 1 makes the
|
||
re-run a no-op). `build_roster()` numbers a nameless roster entry
|
||
`inverter-1`, so `inverter-a` is not even the fallback for a declared roster.
|
||
Every two-pack fixture that could see this now names its first pack
|
||
`garage`; with them all called `inverter-a`, `self.inverters[0].name` and
|
||
`UNIT_A` were the same string and the swap left all 19 suites green.
|
||
* **AND THE REFUSAL NOW SURVIVES THE NEXT START** (second fix round). The fix
|
||
above graded only the keys it had just *written*, so it refused exactly once:
|
||
measured at Controller level over one persisted store, roster
|
||
`["garage", "shed"]`, with the call site still wrong on both starts —
|
||
construction 1 `ok=False misdirected=4 moved=4 kept=0 marker=<absent>`,
|
||
construction 2 `ok=True misdirected=0 moved=0 kept=4` and the marker
|
||
**stamped**. On the second start rule 1 finds the wrong keys already there and
|
||
routes all four to `kept`, so `moved` is empty and the roster loop grades
|
||
nothing — and start 3 skips the migration forever, *including after the call
|
||
site is fixed*. Loud-and-recoverable became silent-and-permanent after one
|
||
restart. `_verify_moves()` now grades `set(moved) | set(kept)`; both lists are
|
||
built from the same `unit` argument, so grading `kept` grades the same
|
||
namespace decision. `test_maintenance.py` part **9c** drives two starts with
|
||
the same wrong unit over one store — the shape nothing in the suite had, since
|
||
9b measured recovery along the path where the operator has *already* fixed the
|
||
call site.
|
||
* **AND THE MARKER GATE IS PINNED ON BOTH GUARDS.** `if rep.ok:` is
|
||
`not lost and not misdirected`, and the mutation `if rep.ok or rep.kept:`
|
||
stamped the marker on a migration that **lost a month of history** with all 19
|
||
suites green. Part **9d** is the fixture that separates the two guards: two
|
||
keys already namespaced (`kept` non-empty) and two whose write the store
|
||
drops (`lost` non-empty), `misdirected` empty throughout.
|
||
* ⚠️ **AND THE SAME MUTATION ONE FUNCTION UP WAS A SURVIVOR, FOUND HERE AND
|
||
CLOSED.** `migrate_unit_keys()` — SAFETY-02's **latch** migration — gates its
|
||
own marker on `rep.ok` too, and every check that reached that gate ran on a
|
||
store where nothing was namespaced yet, so `rep.kept` was empty in all of
|
||
them. `if rep.ok or rep.kept:` there stamps `schema_version` on a migration
|
||
that **dropped the latch**: the guard is off, the file still says
|
||
`sign_tripped: true`, and the next start skips the migration that would have
|
||
re-armed it. Measured a survivor of all 19 files; now red on
|
||
`test_inverter.py` part 10, which drops the `sign_tripped` write beside an
|
||
already-namespaced `sign_reason`. No source change — the shipped gate was
|
||
right; nothing could see it.
|
||
* ⚠️ **FOUND, RECORDED, NOT FIXED (SECOND ONE).** `migrate_unit_keys()` —
|
||
SAFETY-02's latch migration — has the identical hole and it costs a **guard**
|
||
rather than a history. It runs before `build_roster()` (each `Inverter`
|
||
restores its latch in its constructor), so it is handed the constant `UNIT_A`
|
||
and there is no roster to grade against. On a declared roster a pre-DEV-02
|
||
`sign_tripped: true` lands under `unit/inverter-a/sign_tripped`, which no unit
|
||
reads — and `_grade_store_migration()`, whose job is to re-arm exactly this,
|
||
is **defeated by the key the defect wrote**: its "is anything namespaced
|
||
latched" test is satisfied by the misdirected value itself. Fixing it means
|
||
naming the migration's target from the config before any `Inverter` exists,
|
||
which is `inverter.py` and SAFETY-02's semantics, with its own battery.
|
||
Measured and pinned as a running check (`test_maintenance.py` part 13), whose
|
||
assertions state the WRONG behaviour on purpose.
|
||
* ⚠️ **FOUND, RECORDED, NOT FIXED.** A maintenance drain **pins** its pack while
|
||
the loop's target is a **site** claim, so when the site target is below the
|
||
pin the only split asks the sibling to charge against the drain and
|
||
`allocator.plan()` refuses it — the site parks at 0 W and the pack's 12 h
|
||
timeout would eventually report *"drain stuck"* about a healthy battery.
|
||
Pre-existing on `release/1.0` at `2d025ee` with the unit names swapped; fixing
|
||
it means changing what the loop's target means while a unit is pinned, which
|
||
is loop and allocator territory. Pinned as a running check
|
||
(`test_maintenance.py` part 12) so it cannot be forgotten.
|
||
|
||
**`bms_soc_floor` describes a BATTERY, so at two packs it stops being one number
|
||
(SAFETY-32).** The peak guard's energy backstop rests on two figures: the EMS
|
||
floor `peak_soc_floor` (the depth a money-saving discharge may never reach) and
|
||
`bms_soc_floor`, the percent **that pack's own BMS** cuts out at — register
|
||
`0x0560`, read as 10 on the reference GW5000S-BP. Rule 8 refuses an EMS floor at
|
||
or below the cut-out, because such a floor can never bind: the BMS wins and the
|
||
status page reports a backstop that does not exist. With ONE cut-out for two
|
||
packs of different models that rule graded the EMS floor against whichever
|
||
battery happened to be written into the flat key, and a site with cut-outs of
|
||
10 % and 30 % under `peak_soc_floor: 20` **started** — the guard would then
|
||
command a forced discharge that pack B's BMS, not the operator's floor, is the
|
||
thing that stops. Rule 8's own failure mode, reached through rule 8.
|
||
|
||
* **`bms_soc_floor` becomes a per-entry roster key, and it is REQUIRED there.**
|
||
Nothing is inherited: an entry that omits it has no cut-out, does not borrow
|
||
the flat one, and is refused by name at startup. A per-entry *default* was
|
||
considered and declined — a default is one number for every pack, which is the
|
||
defect this card removes; `DECISIONS/SAFETY-32-per-pack-bms-floor.md` records
|
||
the decision and its migration cost. The flat key is unchanged and is now
|
||
documented as the single-inverter shape's own cut-out.
|
||
* **Rule 8 grades EVERY unit and NAMES the one it refuses**, through a new
|
||
`unit_bms_floors_indexed()` that carries the **roster** index the value came
|
||
off. CFG-04 had just fixed a rule 5/6 bug of exactly this shape — a caps-list
|
||
index fed to a function that indexes the roster, so the message named the wrong
|
||
inverter — and this reader skips nothing, so there is no second list for an
|
||
index to desynchronise from. Graded on a roster whose offender is the SECOND
|
||
entry *and* on the same two numbers in the other order.
|
||
* **The permission is answered PER PACK.** `Controller.pack_floor_soc` — the
|
||
emptiest measured pack — is untouched and is still what `decide()` grades;
|
||
what changed is the floor. `peakguard.pack_permissions()` answers one
|
||
`(name, reason)` per pack against that pack's own floor, and the floor-limited
|
||
notification now names the pack that stopped the site rather than "the
|
||
battery". At a roster of one the alarm is byte-for-byte what SAFETY-25 shipped.
|
||
* **An unstated cut-out withholds rather than defaults.** `None`, not `0.0` —
|
||
`discharge_permitted()` already treats an unknown floor as no permission, so
|
||
the absence costs a shave and never a pack, including on `sim/inject.py`'s path
|
||
which does not run the startup gate.
|
||
* ⚠️ **Stated rather than hidden:** under any config rule 8 accepts, the per-pack
|
||
*floor* reduces to `peak_soc_floor` for every pack — rule 8 makes it so. The
|
||
reduction is written for `unit_caps_indexed()`'s reason ("unreachable" is a
|
||
claim about rule ordering, not a property of a function) and is graded on both
|
||
sides: 1775 rule-8-valid `(peak_soc_floor, bms_soc_floor)` pairs answer the
|
||
option, and the pair rule 8 refuses answers the cut-out. `peak_soc_floor`
|
||
itself is still one number for the site, so the "deeper pack needlessly
|
||
withheld" half is filed as an open question, not closed.
|
||
* **`tools/supervisor_shape_probe.py` re-run and repaired.** MUTATION.md habit 8:
|
||
a committed transcript is a check and it decays. The required key broke §C and
|
||
left §D printing PASS on a refusal about a *different* key. §D is now an
|
||
isolated counterfactual over an accepted, merged baseline, one key varied per
|
||
case, asserting the key **name** in the refusal — `all cases passed` on
|
||
`amd64-hassio-supervisor:2026.08.0`.
|
||
* ⚠️ **SECOND FIX ROUND — the round-2 fix carried the defect it was written to
|
||
close, in its prose.** Two more rewrites survived a green 19-file suite on
|
||
`8501e05`: `binding_soc_floor()`'s `max(floors)` → `max(floors[-2:])` (+5
|
||
bytes) and the call site's `self.packs` → `self.packs[:2]` (+4 bytes). Both are
|
||
checks whose TEXT claimed more than their FIXTURE could reach — 12c2's only
|
||
counterfactual varied the **last** pack, which every window ending at the
|
||
roster's end follows, and all three 12g runs drove a **two**-pack roster, where
|
||
`[:2]` *is* the roster. **The general law, now stated in the file: a fixture of
|
||
N packs cannot separate two reductions that agree on N packs**, and the regress
|
||
does not terminate in a fixture — `[:1]` needed two packs, `[:2]` three, `[:3]`
|
||
four. So the reduction is now graded by a **length sweep** (78 simulated
|
||
rosters, lengths 1–12, deepest cut-out at every position, closing head and tail
|
||
windows of width 1–11 at once) plus a **computed family table** of fourteen
|
||
rewrites that goes red naming any member no roster kills; the call site is
|
||
graded by **three-pack runs** with the deep cut-out at index 0, 1 and 2, plus
|
||
an **exact structural pin** (the roster argument must unparse to `self.packs`)
|
||
for the slice widths no bounded roster can reach. Round 3's own battery found
|
||
`self.packs[:3]` / `[-3:]` surviving all six behavioural runs — the structural
|
||
pin is what closes them — and one row is recorded **NOT CAUGHT**: a reduction
|
||
over a window twelve packs wide, which needs a 13-pack roster to observe.
|
||
No shipped behaviour changed in this round; only what observes it.
|
||
* **Twelve source mutations, eleven red, one argued survivor**, through
|
||
`tools/mutation_run.sh` (bytecode purged). The battery also found a real
|
||
weakness of its own: `_flat_bms_floor()` had the default in two places and each
|
||
covered for the other, so deleting either survived — masking, in three lines of
|
||
option reading. One defaulting line now.
|
||
* ⚠️ **FIX ROUND — that battery was INCOMPLETE, and the review found what it
|
||
missed.** Neither of the two mutations below appears in the twelve rows above:
|
||
the ledger graded "the reduction dropped" and "the bare option handed over"
|
||
and never asked what happens when the reduction is simply the *wrong* one, or
|
||
when the roster handed to it is *sliced*. No shipped behaviour changed here;
|
||
two *checks* whose data could not reach the failure they named did. `max(floors)` → `min(floors)` in
|
||
`binding_soc_floor()` (zero byte delta) and `self.packs` → `self.packs[:1]` at
|
||
the `decide()` call site (+4 bytes) were each green across all 19 files. Both
|
||
survived for the same reason: **under any rule-8-valid config every pack's
|
||
`pack_floor_pct()` equals `peak_soc_floor`**, so `floors` is a list of
|
||
identical numbers and `max`, `min`, first and last are four spellings of one
|
||
answer — and the one check using a cut-out above the EMS floor ran on a roster
|
||
of ONE, where every reduction agrees. The second also walked through part 12g,
|
||
which asserted `"self.packs" in <the unparsed call site>`: a slice still
|
||
contains that substring, so the pin passed on the mutation that grades the
|
||
whole site against pack A. MUTATION.md habit 4 — a source-text test is a check
|
||
on how code is spelled, not on what it does.
|
||
* **New part 12c2** evaluates `binding_soc_floor()` on a roster whose per-pack
|
||
floors **all differ** — four simulated cut-outs of 30/25/40/35 %, every one
|
||
above `peak_soc_floor`, chosen so that `max` (40), `min` (25), first (30),
|
||
last (35) and the mean (32.5) are five different numbers and the two extremes
|
||
sit at *interior* roster positions. Two packs cannot separate those four;
|
||
four is the smallest roster that can. The precondition is its own check
|
||
(habit 1) and one pack — the last, neither deepest nor shallowest — is then
|
||
raised past the old maximum and the answer follows it (habit 7).
|
||
* **Part 12g is now behaviour, not source text.** Real `Controller.cycle()`s on
|
||
a two-pack roster, both packs at the same SoC, with the cut-outs as the only
|
||
thing varied: move the **non-first** pack's cut-out and the floor reaching
|
||
`decide()` moves 20 % → 35 %, with the site's verdict flipping from a sized
|
||
discharge to FLOOR-LIMITED. `self.packs[:1]` answers 20 % both times. Which
|
||
option key the floor comes from stays pinned on the source where it already
|
||
was, because that claim genuinely is about the code's shape.
|
||
* ⚠️ **Two MORE survivors, found by this round's own battery and recorded
|
||
rather than quietly repaired.** (a) The first cut of part 12g varied only
|
||
pack B's cut-out, so `self.packs[-1:]` — grade the site against the **last**
|
||
pack — agreed with the full roster on both runs and stayed green across all
|
||
19 files (+5 bytes). A two-run fixture pins one slice and not the other.
|
||
Part 12g now runs the **same pair of cut-outs in both orders**, so the
|
||
deeper one is on the second pack in one run and on the first in another and
|
||
the site floor is 35 % in both; no single-position slice passes all three
|
||
runs. (b) `binding_soc_floor()`'s empty-roster branch rewritten to
|
||
`return None` (−43 bytes) was also green — a defensive branch nothing in the
|
||
suite reached. Both directions of it are pinned now, with the note that it
|
||
cannot permit anything either way because `pack_floor_soc` is `None` at an
|
||
empty roster.
|
||
|
||
* ⚠️ **THIRD FIX ROUND — the same family was alive at the SIBLING ARGUMENT of
|
||
the same call.** Rounds 2 and 3 hardened the FLOOR axis; `decide()` is handed
|
||
a second reduction over the same roster beside it, `soc=self.pack_floor_soc`,
|
||
and `peakguard.py`'s own docstring names the two as a pair. Three rewrites of
|
||
`Controller.pack_floor_soc` were green across all 19 files on `52380c0` and
|
||
again after the rebase onto `3934ad7`: `min(float(v) for v in vals)` →
|
||
`vals[:2]` (+4), `vals[-2:]` (+5) and `vals[:3]` (+4), while `[:1]`, `[-1:]`,
|
||
`[1:]`, `[:-1]`, `max()` and the mean were all red. **The suite's separating
|
||
power on the SoC axis stopped at exactly N=2** — the state the floor axis was
|
||
in before round 3. It changes a real answer: a three-pack roster at
|
||
50 / 60 / 11 % with the emptiest pack THIRD answers 11 % shipped and 50 %
|
||
mutated, so the site is graded for discharge PERMISSION against a pack 39
|
||
points fuller than the emptiest and spends a pack its own BMS stops. Round 3's
|
||
machinery transfers directly — new **part 12i**: six real `cycle()`s with the
|
||
emptiest pack at every position of a three-pack roster and both positions of a
|
||
two-pack one (the observable read off `PeakDecision.soc`, cut-outs held flat
|
||
so the floor axis cannot be what moved the answers); computed slice and
|
||
non-slice tables that go red naming any member nothing kills; a **length
|
||
sweep** of 78 real rosters, lengths 1–12, closing head and tail windows of
|
||
width 1–11 and recording width 12 as honestly unreached; a **none-guard
|
||
sweep** over the same 78; and **three exact structural pins** — the `vals`
|
||
comprehension must iterate the bare `self.inverters`, and both `any()` and
|
||
`min()` must reduce the whole `vals` — which close every slice width at every
|
||
roster length. No shipped behaviour changed; only what observes it.
|
||
* ⚠️ **SPIKE-04: production is DEGENERATE on the floor axis, so the
|
||
differing-floor fixtures are the entire exposure.** Pack B is 4 × Dyness
|
||
DL5.0C, DoD 90 → floor 10 % — **FROM THE DATASHEET AND THE SERIAL; inverter B
|
||
does not exist and nothing about it is measured.** Pack A is DoD 90 → 10 % as
|
||
well, and that one *is* measured. Both packs therefore floor at the same value
|
||
in the field, `max(10, 10)` is 10 whatever it reduces, and a differing floor
|
||
exists **only in the fixtures**. That is the argument for keeping them, not for
|
||
simplifying them: anyone who collapses `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to
|
||
one number deletes the only data this repository has that can see the
|
||
reduction at all, and part 12c2's precondition check goes red if they do. The
|
||
**SoC** axis has no such degeneracy — two batteries at the same state of
|
||
charge is a coincidence, not a configuration — which is what makes
|
||
`pack_floor_soc` the half of the pair that will be live from the day inverter
|
||
B is wired up.
|
||
* ⚠️ **The FAMILY enumerated rather than the member closed, and it found four
|
||
more survivors in code this card never touched.** Every reduction over
|
||
`self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||
mutated to a slice against the full suite. Eighteen were already graded
|
||
(`site_batt_w`, `site_rating_w`, `site_max_w`, `site_dev_bound`,
|
||
`site_circulating_w`, `inputs_ok`, `units`, `Controller.packs`, `maint_unit`,
|
||
the maintenance-due minimum, the store-migration roster, `validate_unit_ranges`
|
||
and the round-2/3 sites). **Four were not, and three of them are SAFETY-30's,
|
||
all `any(i.sign_wd.tripped for i in self.inverters)`** — `cycle()`'s
|
||
stale-input hold, `publish()`'s status vocabulary and `status_payload()`'s
|
||
banner — each +4 bytes and green across all 19 files, for the reason every
|
||
blocker on this card was invisible: **the only fixture that reached them was a
|
||
roster of ONE, where `self.inverters[:1]` IS `self.inverters`.** The first has
|
||
teeth: a latched unit must take the roster past that hold or its `failsafe`
|
||
SET stops being re-filed the moment a poll is missed, which is main.py's own
|
||
sentence for it. New **test_watchdog.py part 8** grades all three with the
|
||
latch at EACH position plus a no-latch counterfactual, and new **part 12j**
|
||
pins all 26 roster comprehensions in `Controller` to iterating the roster
|
||
whole — so a reduction added over a slice anywhere in the class is red on the
|
||
line it is written, with no fixture required. Two rows are reported open
|
||
rather than closed: `_grade_store_migration()`'s second disjunct (now red on
|
||
12j, and argued to be an equivalent mutant because a unit tripped at startup
|
||
implies its namespaced key is set) and `cycle()`'s undeliverable-unit **log
|
||
line**, where `sorted(...)[:1]` (+4 bytes) is still green — it truncates names
|
||
in one event message; the refusal itself is a different expression and is
|
||
graded, so the site still stops.
|
||
* ⚠️ **FOURTH FIX ROUND — the same family one MODULE over, and the pin widened
|
||
until it discovers its own scope.** Round 4 enumerated the family and closed
|
||
it inside the `Controller` class; the review found four more survivors
|
||
immediately, in **validate.py**, on a 19/19 green suite: `min(caps)` →
|
||
`min(caps[:1])` (+4) and `→ min(caps[-1:])` (+5) at `:779`, the caps
|
||
comprehension over `caps_ix[:1]` (+4) at `:778`, and
|
||
`min(caps_ix[-1:], key=…)` (+5) at `:893`. **None is an equivalent mutant.**
|
||
With `max_w: 5000` on inverter-a beside `2000` on inverter-b and
|
||
`sign_disagree_w: 3000`, the shipped validator refuses — *"the SAFETY-02
|
||
sign-disagreement watchdog can never fire"* — and all four **accept**: the
|
||
site starts with a watchdog reporting `armed` that cannot trip on pack B.
|
||
Rule 5's own failure mode reached through rule 5; rule 6 loses its refusal the
|
||
same way. **No shipped behaviour changed by the pin; what changed is that a
|
||
hand-written scope stopped being trusted.**
|
||
* **`test_peakguard.py` part 12j now parses every `.py` under `app/`** — no
|
||
module is named in the check — and finds the roster by transitive closure
|
||
from **one seed**, the `inverters` option key: through returns, through
|
||
lists **accumulated in a loop over a roster** (which is what `caps`,
|
||
`roster` and `problems` are, and what round 4's rule could not express),
|
||
through attributes and properties, and **through call arguments**, which is
|
||
how it walks into `peakguard`, `arbiter`, `allocator`, `circulation`,
|
||
`maintenance` and `store` without being told they exist. A slice of a roster
|
||
stays a roster so a mutation cannot cut the taint; an index is one unit and
|
||
does not. **183 reductions across 9 modules** out of 23 files parsed — `main.py` 96,
|
||
`allocator.py` 24, `validate.py` 19, `arbiter.py` 14, `circulation.py` 10,
|
||
`maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, `store.py` 3.
|
||
* **The hand-written module list would have been wrong in BOTH directions.**
|
||
It named `watchdog.py`, which reduces over no roster at all — SAFETY-30's
|
||
sign watchdogs are strictly per-unit — and omitted `maintenance.py` and
|
||
`store.py`, which both do. A vacuous claim about one module and no claim
|
||
about two others.
|
||
* **The pin rediscovers all four blockers without being told where to look,**
|
||
and finds a fifth beside them (`validate.py:783`, rule 5's message) that no
|
||
review round named. Its counterfactual runs at two depths: every enumerated
|
||
site's own expression mutated with each of the four cuts this card has been
|
||
defeated by (732 spellings, all reported), and `[:1]` written into
|
||
`app/validate.py` at each blocker position with the **whole** analysis
|
||
re-run over the mutated source — a sliced site required at that exact line,
|
||
where the clean package reports none.
|
||
* **And the behavioural half, because a shape pin grades an identity and never
|
||
a meaning.** `test_validate.py` grows a **roster SET** for the three rules
|
||
that reduce over `unit_caps_indexed()` — widths 2, 3, 4, 5 with the one
|
||
small cap at every position, 42 runs of the real validator — plus a
|
||
**computed family table** of twelve candidate reductions each compared with
|
||
what the shipped validator actually said on the same roster. Only `min`
|
||
agrees everywhere; a member no roster kills is printed as a named survivor.
|
||
* ⚠️ **A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's** —
|
||
found by reading, **not** by the pin, which is the shape of what the pin
|
||
cannot see. `unit_sign_disagree_w` was graded against
|
||
`_number(opts, "max_w", 2000.0)` three lines under a branch that had already
|
||
stopped doing that. **Both directions were measured:** two 2000 W units
|
||
beside a leftover `max_w: 5000` and `unit_sign_disagree_w: 3000` produced
|
||
**zero problems** — the site started with a per-unit watchdog that could not
|
||
trip on either unit — and two 5000 W units with no flat key were **refused**
|
||
against a 2000 W default the operator never wrote. It takes the weakest
|
||
unit's own cap now and names that unit; at a roster of one it is the same
|
||
comparison, boundary and sentence it always was.
|
||
* ⚠️ **What the pin cannot see, with sizes**, in
|
||
`DECISIONS/SAFETY-32-per-pack-bms-floor.md` §4d: semantics (`min` vs `max`
|
||
is a zero-byte edit); direct index sites that are not reductions; a
|
||
subscript by a *variable* (`weights[u]` is why); a slice through an
|
||
intermediate call; anything outside `app/`, including `sim/inject.py`; and
|
||
the taint's own seam at `*args` and unresolvable receivers, where everything
|
||
downstream goes **unenumerated rather than reported unreachable**.
|
||
`cycle()`'s undeliverable-unit log line is still green at +4 bytes and still
|
||
filed open, unchanged from round 4.
|
||
* **`tools/supervisor_shape_probe.py` re-run again (2026-09-05, 2026.08.0).**
|
||
The rebase onto `67e649a` merged SPIKE-04a's `max_w: 4600` into the same
|
||
§C/§D blocks this card had rewritten, and a transcript stitched by hand out
|
||
of two runs is not a measurement. `all cases passed`, exit 0, with B's
|
||
rating reading 4600 — its **datasheet** figure, still not a measurement of
|
||
any unit.
|
||
* ⚠️ **THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE.** All 183
|
||
sites mutated to `[:1]`, one at a time, against the full 19-file suite in
|
||
`goodwe-addon-ci` (`:ro`, `-B`, fresh rsync per mutant, 0 `.pyc` before and
|
||
after): **183/183 RED** — which is tautological on its own, because the pin
|
||
makes every slice red. The 51 whose first red was `test_peakguard.py` were
|
||
re-run with the pin **neutralised**: **145 of 183 have a behavioural fixture
|
||
that separates them from a slice of themselves; 38 have only the structural
|
||
claim.** All 38 are listed by site, with what a `[:1]` there would cost, in
|
||
§4d and in `test_peakguard.py` part 12j — 15 of them are rows and counts on
|
||
the operator's page, 3 are `len(targets)` arithmetic beside a write path
|
||
that IS graded, 6 are allocator internals ALLOC-01's fixtures reach but not
|
||
with enough units to separate. **None of the 38 is closed behaviourally by
|
||
this card, and none is counted as covered.**
|
||
|
||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||
exist yet. Every second-pack figure — in fixtures, in the probe, in
|
||
config.yaml's commented example — is **simulated and labelled**; only inverter
|
||
A's 10 % is read off hardware.
|
||
|
||
**At two inverters the sign watchdog stops comparing one unit against the whole
|
||
site meter, and becomes two checks instead of one (SAFETY-30).**
|
||
`disagrees(commanded_w, grid_w, ...)` compared the command **this controller put
|
||
on ONE actuator** against **what the whole site** is exchanging with the network.
|
||
At a roster of one those are two views of the same thing - §6.2's identity - and
|
||
that is what makes the check sound and what earned it the best record in the
|
||
safety table. At two they are not, and it fails **in both directions**: inverter
|
||
A commanded −1500 W while the meter reads +1500 W is evidence of nothing when
|
||
inverter B is discharging 3000 W exactly as told, and - worse - a unit genuinely
|
||
driving the wrong way is **MASKED** by a sibling that covers for it, because the
|
||
site figure nets. No threshold recovers that; the summation destroys the
|
||
evidence.
|
||
|
||
* **PER UNIT: each inverter's own share against its OWN measured power**
|
||
(`Inverter.batt`, holding register 0x0518, per-instance since DEV-02).
|
||
Conclusion: *the actuator I am driving is not doing what I told it* - evidence
|
||
about one device, so the remedy stays the per-unit `failsafe` SET that
|
||
`arbiter.CLAIM_SCOPE` has classified `SCOPE_UNIT` since it was written. That
|
||
entry read *"the evidence is site-wide and the remedy is not"*; after this card
|
||
the first half is simply true. Verified by making one unit reverse while its
|
||
sibling covers it at the meter: the shipped comparison is false on **0 of 14**
|
||
cycles while the new one convicts that unit on every one, and only that unit is
|
||
parked.
|
||
* **SITE: the SUM of the standing commands against the meter**, as a new
|
||
`sign_site` SET at `P_SAFETY` on the site actuator, latched under flat
|
||
`site_sign_tripped` / `site_sign_reason`. This is the fault the 2026-08-20
|
||
capture contains and **no per-unit check can see it**, because every inverter
|
||
is faithfully executing its share - what is wrong is the number they were
|
||
given. It stops every inverter and the banner names the site rather than an
|
||
inverter, so nobody is sent to swap the wrong box.
|
||
* **⚠️ Different questions from SAFETY-31, and the PR and the decision record
|
||
both say which.** `circulation.py` compares the units **against each other**
|
||
and catches two inverters pushing *through* each other, which nets to zero at
|
||
the meter and which no single command is evidence about. This compares each
|
||
unit against **its own command**. They overlap in exactly one scenario - a unit
|
||
reversing while its sibling pulls the other way - and `test_watchdog.py`
|
||
asserts on that fixture that both fire and what each concludes. It is also not
|
||
the saturation freeze: that is a magnitude test (`|command − measured| >
|
||
500 W`) that shapes the loop and fires on a merely saturated unit; this is a
|
||
direction test that latches and parks one.
|
||
* **⚠️ All four thresholds RE-DERIVED at two inverters - one carried with a
|
||
justification, one refused.** 1890 closed-loop runs in which nothing is faulty,
|
||
over the whole range `config.yaml` accepts for `gain` and `slew_w`, across load
|
||
reversals with write staggers and flapping loads, **run at both rosters so the
|
||
N=1 arm is the control.** `unit_sign_disagree_w: 250` is `saturation_w / 2`, so
|
||
the smallest divergence this can see is exactly the gap at which the control
|
||
law itself already says the inverter is not following; `unit_sign_disagree_s:
|
||
85` is 8.5× the worst legitimate opposition those runs produce (10.0 s), 2.8×
|
||
the firmware's own 30 s link veto and under half the site hold.
|
||
`site_sign_disagree_w: 300` is **carried** - its argument is the meter's noise
|
||
floor and the deadband, and the roster touches neither. **`site_sign_disagree_s`
|
||
is NOT: 180 s would have shipped a false trip.** The worst legitimate crossing
|
||
at 300 W is 110 s with one inverter and **215 s with two**, because the command
|
||
must travel a rail that is the roster sum at a `slew_w` that is not - reachable
|
||
at `gain: 0.05`, `slew_w: 50` and a 2.2 kW load reversal, all schema-legal.
|
||
325 s is 1.51× that, inside the 900 s billing quarter and inside every episode
|
||
of the fault day. See `DECISIONS/SAFETY-30-per-unit-sign.md`.
|
||
* **⚠️ At ONE inverter this changes nothing, and "nothing" is asserted rather
|
||
than assumed.** The two questions are the same sentence at a roster of one, so
|
||
only the detector that shipped is armed: same comparison against the meter,
|
||
same 300 W / 180 s, same reason string byte for byte, same namespaced latch and
|
||
flat rollback mirror, same `failsafe` claim, same banner, same single status
|
||
row - and the site detector's window is never so much as opened.
|
||
`unit_sign_disagree_*` and `site_sign_disagree_*` do nothing on a
|
||
one-inverter install.
|
||
* **The threshold WIRING is pinned, not just the threshold values.** Review
|
||
found that `arm(self.sign_cfg if one else self.unit_sign_cfg, ...)` could be
|
||
collapsed to `arm(self.sign_cfg, ...)` — arming every unit with the
|
||
meter-facing pair instead of the re-derived per-unit one — with all 19 test
|
||
files still green, because nothing at a roster of two read `sign_wd.cfg` and
|
||
the trips in the fixture happen under either pair. `test_watchdog.py` part 3
|
||
now asserts the pair and the instrument on **every** detector against
|
||
literals, and the fixture's three threshold pairs were made pairwise distinct
|
||
in both coordinates so that a hold delivered from the wrong pair cannot
|
||
compare equal to the right one. M14–M16 in
|
||
`DECISIONS/SAFETY-30-per-unit-sign.md` §5.
|
||
* **Known and recorded rather than fixed:** the site comparison is defeated by a
|
||
square-wave load whose half period is about one control cycle, which holds it
|
||
opposed for ~1000 s at *both* rosters. The N=1 control arm is what says this is
|
||
a property of comparing a lagged command against a meter and has nothing to do
|
||
with the second inverter, so it is not acted on here - it wants an alternation
|
||
test, not a number, and a card of its own.
|
||
|
||
**An operator can declare a second inverter, and the shape was measured against
|
||
the Supervisor rather than assumed (CFG-04).** DEV-02 made rating, SoC,
|
||
watchdogs, heartbeat and store keys per-instance, so the code no longer assumed
|
||
one inverter — but the options schema still described exactly one, and
|
||
`build_roster()` returned a list of length one and said so in its own docstring.
|
||
The roster could only ever be a roster of one. **Version bumped to 0.6.0,
|
||
because the Supervisor keys the built image by `version:` and a schema change
|
||
without a bump is a schema nobody reads.**
|
||
|
||
* **`inverters:` — a list of objects, defaulting to `[]`.** Empty is not
|
||
"unconfigured", it is **the single-inverter configuration**: the flat
|
||
`setpoint_entity` / `soc_entity` / `batt_entity` / `batt_invert` / `max_w`
|
||
keys describe the one unit exactly as they always did. An install that
|
||
upgrades reads identically — same entities, same rating, same behaviour, no
|
||
operator action, and the whole existing check suite runs against it unchanged.
|
||
* **⚠️ The shape was ESTABLISHED, not assumed, and the empty default is the
|
||
load-bearing part.** There was no list-of-objects anywhere in `config.yaml`
|
||
and `static_profile` carries its structure as a JSON string in a `str?`
|
||
precisely because a nested schema was doubted. So Supervisor **2026.08.0**'s
|
||
own `AppOptions` validator — the version this site runs — was run against this
|
||
repository's `config.yaml` out of the release image
|
||
(`tools/supervisor_shape_probe.py`, committed; the transcript and the
|
||
reasoning are in `DECISIONS/CFG-04-inverter-roster.md`). It accepts the list,
|
||
enforces per-entry types, refuses a missing `name` / `setpoint_entity` /
|
||
`max_w` **by name**, and renders the option in the UI as a repeatable section.
|
||
And the shipped `options:` block validates as it stands while the **same
|
||
block with only `inverters` removed** is answered with **`Missing option
|
||
'inverters' in root`** — one key varied, so the refusal is about that key —
|
||
which is every options blob written before this version, since the key did not
|
||
exist when they were written. `App.options` is
|
||
the shipped `options:` block deep-merged with the operator's stored one, so
|
||
`inverters: []` in `config.yaml` is the difference between an upgrade nobody
|
||
notices and an add-on that cannot start with a schema pinned to the version
|
||
that would fix it. That is `docs/deploy-rollback.md` §4's trap in the forward
|
||
direction, found before it was sprung rather than after.
|
||
* **Nothing is inherited by an entry that omits a key, and that is DEV-02
|
||
restated as a config rule.** `name`, `setpoint_entity` and `max_w` are
|
||
required of every entry; a missing `soc_entity` means *this unit has no SoC
|
||
sensor*, never *use the other one's*. A three-line fallback to the flat keys
|
||
would have rebuilt the sibling-grading defect DEV-02 spent a card removing, in
|
||
YAML. Defaults still live in exactly one place (CFG-03): there is no
|
||
per-entry default anywhere, because there is no optional per-entry number.
|
||
* **SAFETY-28's `actuator_writers()` refuses two inverters naming one
|
||
`setpoint_entity` — the first configuration in this add-on's life in which
|
||
that was expressible.** The census was already structural and case-folding, so
|
||
it needed no change; what it needed was a config that could express the
|
||
hazard, and now there is one. Its sibling refusal is new: a flat
|
||
`setpoint_entity` left set beside a declared roster names an actuator nothing
|
||
will write, and is refused rather than ignored.
|
||
* **⚠️ Two PERMISSIVE READERS in shipped code, found and fixed.** Both failed in
|
||
the direction that hides a defect. (1) `validate.py` rules 5 and 6 compared
|
||
`sign_disagree_w` and `maintenance_charge_w` against the **flat** `max_w`; at
|
||
a roster of two 2000 W units with `max_w: 5000` left over from a
|
||
single-inverter install, `sign_disagree_w: 3000` passed rule 5 while
|
||
SAFETY-02's watchdog could fire on **neither** unit — rule 5's own failure
|
||
mode, reached through rule 5. They now read the roster's caps
|
||
(`unit_caps()`), which at one inverter is `[max_w]` exactly. (2)
|
||
`run_control()` re-ran the device-rating check behind `if
|
||
self.o.get("setpoint_entity")` — the flat key, which a roster config leaves
|
||
blank — so on a two-inverter site **no** inverter's `max_w` was ever graded
|
||
against the device it is written to. `validate_unit_ranges()` grades each unit
|
||
against its own ESPHome and runs unconditionally.
|
||
* **⚠️ Three config.yaml READERS IN THE CHECKS took the first match on a
|
||
stripped line, and one of them was already known.** `test_peakguard.py`'s
|
||
`read_option()` and `_schema_bound()` and `test_inverter.py`'s
|
||
`read_schema_max()` threw indentation away, so the nested per-inverter
|
||
`max_w:` began outranking the flat one — **silently, and correctly**, because
|
||
the two bounds are the same `int(100,5000)` today. A reader that is right by
|
||
coincidence is the failure mode this project is named after. All three are
|
||
anchored at exactly two spaces now, the halves of the file disambiguate
|
||
`peak_cap_w` instead of first-match, and an ambiguous key raises rather than
|
||
picking one. `test_tariff.py`'s copy of the idiom went with them.
|
||
* **The audit is a check, not a promise.** `test_inverter.py` part 11 finds
|
||
every `config.yaml`-reading function in the suite — transitively, so a reader
|
||
that never names the file because it delegates is still found — **executes**
|
||
each one against a synthetic two-inverter `config.yaml` whose per-inverter
|
||
numbers differ from its flat ones, and fails if any returns the per-inverter
|
||
value. The table is not maintained by hand: the signature decides, so the
|
||
eleventh reader is named on its first run. `config.yaml` declares `inverters:`
|
||
**before** `max_w:` on purpose, and part 11 asserts that ordering, so the trap
|
||
stays baited rather than sorted out by an ordering nobody would maintain.
|
||
* **Ten mutations run and observed red** (`tools/mutation_run.sh`), listed in
|
||
`test_inverter.py` part 12 with the check that bites each. **Two survived the
|
||
first run and the checks were changed, not the table:** the inheritance check
|
||
had blank flat keys, so "inherited" and "not inherited" were the same empty
|
||
string; and the `run_control()` check asserted a **source line** was present,
|
||
which re-indenting it does not remove. The second is now driven through the
|
||
shipped `run_control()` under a deadline, because that mutation's real symptom
|
||
is a hang rather than a red.
|
||
|
||
**Circulating power - two inverters pushing against each other - becomes a
|
||
measured quantity, a bounded one, and an alarm that survives a restart
|
||
(SAFETY-31).** Inverter A charging at 3 kW while inverter B discharges at 3 kW
|
||
nets ~0 W at P1: the site reads idle while the packs are cycled through two
|
||
round-trip conversion losses continuously. **Every guard in this add-on is a
|
||
consumer of that net figure**, so none of them can see it - SAFETY-02's sign
|
||
watchdog gates on `abs(grid_w) > 300` and the gate never opens, and both the
|
||
peak guard and the capacity tariff bill grid exchange.
|
||
|
||
* **A site sensor, `Σ|Bᵢ| − |ΣBᵢ|`.** Zero when the units pull the same way,
|
||
`2 × min(|B_a|,|B_b|)` when they oppose. Published as
|
||
`sensor.goodwe_circulating_power` beside the grid figure, because *"grid 0 W,
|
||
circulating 6000 W"* is the whole phenomenon in two dashboard rows and no
|
||
arithmetic on the grid figure alone can produce the second one.
|
||
* **⚠️ Derived from MEASURED per-inverter power, never from the allocation
|
||
record, and that is the whole card.** ALLOC-01's sign-coherence gate
|
||
(`REFUSE_SIGN`, its AC 7) already refuses any allocation whose shares carry
|
||
two signs, so a record-derived sensor reads zero **by construction** and could
|
||
only ever confirm that the allocator agrees with itself. What this detects is
|
||
circulation **nobody commanded**: a unit ignoring or lagging its share, a
|
||
stale share still being executed, a maintenance or SAFETY-25 pin holding one
|
||
unit while the loop drives the other, or HW-01's power stage running its last
|
||
instruction for **6 h 25 m** with the comms dead and the allocation record
|
||
impeccable throughout. Verified by making one unit ignore its share on the
|
||
TEST-19 rig: the record stays `ok` with both shares at −1250 W - a
|
||
record-derived sensor reads **0 W** - while the shipped sensor reads
|
||
**2000 W**. `Inverter.batt` became per-instance in DEV-02; before that this
|
||
sensor could not have been written.
|
||
* **The trip stops the site, and the decision record says why that is an action
|
||
rather than theatre.** Sustained opposition files `Claim.set("circulation",
|
||
P_SAFETY, 0.0)` on the **site** actuator - every inverter to 0 W, latched.
|
||
HW-01 established that a wedged inverter cannot be commanded at all, so the
|
||
remedy is not aimed at it: a circulating loop needs **two** participants and
|
||
**one of them is this controller**, faithfully driving the listening sibling
|
||
into the one that stopped listening. Stopping halves the loop, and - the more
|
||
important half - **makes the residue visible at the meter**, where every
|
||
existing guard can finally see it. What it cannot do is stop the wedged unit;
|
||
the notification says so, and names the isolator as the last actuator.
|
||
* **The threshold and the window were measured, not chosen (AC 3).** 924
|
||
closed-loop runs on the TEST-19 two-master rig in which *nothing* is faulty,
|
||
swept over the whole range the schema accepts for `gain` and `slew_w`. Worst
|
||
**continuous** legitimate opposition by threshold: **505.0 s at 100 W, 40.0 s
|
||
at 250 W, 10.0 s at 300 W, 5.0 s at 400 W and above**. The knee is 400 W and
|
||
it is the schema floor. `circulation_w: 700` is 1.75× it, the headroom bought
|
||
for inverter B's ramp and lag being **placeholders until SPIKE-04 measures
|
||
them**. `circulation_hold_s: 75` clears three bounds - 15× the worst
|
||
legitimate episode (5.0 s), 2.5× the firmware's own `LINK_STALE_S` veto, and
|
||
12× inside the 900 s capacity-tariff quarter. **A momentary opposition while
|
||
one inverter slews through zero is legitimate** - the serialised write path
|
||
lands B's number up to a control cycle after A's - and the guard does not fire
|
||
on it, in any of the 924 runs.
|
||
* **The alarm survives a Home Assistant restart (SAFETY-34).**
|
||
`persistent_notification` is in memory, which is exactly how the 2026-09-02
|
||
outage went unseen for 6 h 25 m with the detector correct throughout. The
|
||
latch is persisted to `/data` as flat, site-level keys - flat because the
|
||
quantity is defined over the roster and is identically zero for any one unit,
|
||
so there is no inverter to attribute it to - and the notification is
|
||
**re-raised on the first cycle after every start**, through the same code path
|
||
as a fresh trip.
|
||
* **At one inverter the sensor is identically zero and nothing changes (AC 5).**
|
||
`|B| − |B| == 0` for every reading, including one nobody took; no claim is
|
||
ever filed, the arbitration is byte-identical, and the status page says *n/a*
|
||
rather than claiming to watch something that cannot happen.
|
||
* **Verified by breaking it.** 18 mutations against the sensor, the window, the
|
||
latch, the claim, the store, the alarm, the routing table and both config
|
||
copies - **all red**, each on a named check; one equivalent mutant argued
|
||
rather than claimed; and one genuine **survivor found and closed** (`publish()`
|
||
turning an unknown into a published `0 W`). `arbiter.CLAIM_SCOPE` gains its
|
||
ninth source and `test_peakguard.py`'s 10f/10g tripwires were updated
|
||
deliberately, as their own comments require. Full ledger in
|
||
`DECISIONS/SAFETY-31-circulating-power.md`.
|
||
|
||
**Every per-device value stops being a Controller scalar: rating, SoC, battery
|
||
power, watchdogs, heartbeat, write bookkeeping and store keys are now
|
||
per-instance (DEV-02).** `app/inverter.py` introduces an `Inverter` object that
|
||
owns its entities, its rating, its device range, its `SignWatchdog`, its
|
||
`last_write` and its `last_beat`, under **store keys namespaced by unit name**.
|
||
The Controller holds a list of them and keeps only what is genuinely site-level:
|
||
the P1 reading, the site integrator, and the tariff quarter.
|
||
|
||
* **What the scalars were silently asserting.** One `dev_min`/`dev_max` pair was
|
||
read from one `setpoint_entity`, so a second inverter's rating was **never
|
||
read at all** and its clamp, its freeze rail and the peak guard's
|
||
`achievable_w` were the first one's numbers. One `soc` fed the maintenance
|
||
FSM, SAFETY-24's pack floor and arbitrage's bounds. One `SignWatchdog` - whose
|
||
own docstring says *"one instance per actuator"* - latched under **flat** store
|
||
keys, so a trip caused by B latched A across restarts and one operator press
|
||
cleared both. One `last_beat` and one serialised `hass.call()` meant a hung
|
||
write to A ate B's 30 s layer-1 budget.
|
||
* **`actual_w` became SITE level while the readings became per-instance, and
|
||
those are opposite directions on purpose.** `compute()`'s anti-windup test is
|
||
`abs(prev_w - actual_w) > saturation_w` with `prev_w` the **site** target, so
|
||
feeding it one inverter's power compares a site number against a unit number,
|
||
trips the saturation freeze permanently and leaves a standing grid error.
|
||
Measured on `admin/goodwe`'s two-master rig, 2 kW load: with one unit's power
|
||
the aggregate trace at the meter is split-dependent by **510 W at `gain: 0.2`,
|
||
250 W at 0.4 and 50 W at 0.6**; with the site sum **every split is
|
||
bit-identical at every gain the schema accepts**. That was ALLOC-01's
|
||
disclosed finding, graded in `test_alloc.py` part 8 and now closed there.
|
||
* **The store migration is graded, not merely performed.** `sign_tripped` /
|
||
`sign_reason` move to `unit/<name>/...` once, verified by **reading back**
|
||
what was written, and the version marker is only set when nothing that carried
|
||
state was lost. The flat keys are left in the file so a rollback still finds
|
||
its latch. And `Controller.__init__` compares the pre-migration flat latch
|
||
against what unit A actually came up with: if the latch did not survive it is
|
||
**re-armed from the legacy value and shouted about**, because a latch that
|
||
silently fails to migrate is a disarmed safety guard on an install whose state
|
||
file says it is protected.
|
||
* **Heartbeats are independent, and the budget is derived.** Each inverter owns
|
||
its own deadline, the refreshes are dispatched **concurrently**, and each write
|
||
is bounded by `WRITE_BUDGET_S` (4 s). The inequality that fixes that number is
|
||
graded rather than asserted: `4 + heartbeat_s`'s schema maximum of `25 < 30`,
|
||
layer 1's `FIRMWARE_INGEST_VETO_S`. Verified by injecting a transport that
|
||
accepts and never answers: B's write reaches the wire while A is still
|
||
hanging, and the serialised shape it replaces is run beside it.
|
||
* **`live_units()` answers honestly.** A unit whose every write has been rejected
|
||
for a whole layer-1 window is not commandable; the allocator drops it, the
|
||
survivors carry its share, the action log names it, and the dropped unit is
|
||
commanded **0 W** rather than left standing on its last share. A roster entry
|
||
with **no** `setpoint_entity` is deliberately *not* handled there - that is a
|
||
configuration that cannot be delivered, and it reaches main.py's refusal
|
||
branch, which commands 0 W and names the inverter.
|
||
* **At N=1 nothing moves.** The removed scalars survive as read/write properties
|
||
of the sole inverter, so every existing replay in this repository runs
|
||
unmodified - and they **refuse at N>=2** rather than answering for the first
|
||
inverter, which is the tripwire on a consumer that was never made
|
||
per-instance. `test_inverter.py` is the new file (77 checks); `test_alloc.py`
|
||
part 8, `test_peakguard.py` 10g/10h, `test_watchdog.py` and `test_clamp.py`
|
||
were re-aimed rather than relaxed.
|
||
* **Inverter B still does not exist.** `build_roster()` returns exactly one
|
||
inverter, from the flat options, and is the one function CFG-04 changes. Every
|
||
two-unit figure above is architecture, not a measurement of a second device;
|
||
the second rating in the checks is marked as a hypothesis.
|
||
|
||
**The write path is gated on the allocation record, not on how many config keys
|
||
exist (SAFETY-28).** specs §1.5 makes *"any configuration in which two writers
|
||
can reach one actuator"* a non-goal. Until now that non-goal was enforced by
|
||
`len(validate.ACTUATOR_WRITE_KEYS) == 1` - there is exactly one option key, so a
|
||
second writer is not expressible in the schema. True, and a statement about **the
|
||
shape of the options schema rather than about the writes**: add a per-inverter
|
||
config (CFG-04) and there are two real writers with that length still 1, so the
|
||
check stays green while the property it protects is gone. `sim/inject.py`'s
|
||
`two_writers` scenario asserted on the same tuple and went green with it.
|
||
|
||
* **The enforcement moved onto the writes.** `write_setpoint()` now refuses any
|
||
value that is not in **this tick's** allocation record from ALLOC-01:
|
||
`Controller.write_refusal()` asks `Allocation.authorises()` whether the single
|
||
allocator placed exactly this number, for this unit, on this tick. A second
|
||
writer with arithmetic of its own cannot satisfy that - its number appears in
|
||
no record - so it has to file a `Claim` and go through `resolve()`, which is
|
||
the one allocator. "Exactly one allocator per shared resource" stops being a
|
||
convention and becomes something the write path checks.
|
||
* **A refusal is a refusal, not a log line.** The unauthorised value is replaced
|
||
with **0 W and written**. Returning instead would leave the inverter latched
|
||
on its last command with the ESP32 heartbeat still being fed - holding an
|
||
unauthorised setpoint quietly, which is the outcome the gate exists to prevent.
|
||
* **A stale record cannot be replayed.** Every record now carries the `tick` it
|
||
was made for, handed *in* by the caller so `allocator.plan()` stays the pure
|
||
function `test_alloc.py` part 10 grades. The counter advances **once per
|
||
resolution, not once per loop iteration**: a new allocation makes the previous
|
||
record stale immediately, while the "inputs missing, not yet stale" hold -
|
||
which returns before the allocator runs, so one dropped meter reading does not
|
||
zero the inverter - leaves the standing authorisation intact for the heartbeat.
|
||
An **unstamped** record authorises nothing; "I cannot say when I was made"
|
||
fails closed.
|
||
* **⚠️ 0 W IS AUTHORISED UNCONDITIONALLY, SO THE FAILSAFE CANNOT DEADLOCK BEHIND
|
||
THE GATE.** `write_setpoint()` is also how layer 2 commands zero, and every
|
||
path that does so - `cycle()`'s two refusal branches, `shutdown()`, the
|
||
operator's stop button - runs while the allocator has *refused*. A gate that
|
||
asked the allocator's permission to command zero would block the safety write
|
||
behind the very component whose failure the safety write answers. The
|
||
exemption is on the **value**, not on a `failsafe=True` flag a caller asserts,
|
||
and the argument is that the hazard §1.5 names is N-times loop gain from
|
||
*broadcast*: at `S = 0` the broadcast and the correct allocation coincide -
|
||
`a_i = 1` for every unit gives every unit 0 W, which is exactly its share - so
|
||
there is no over-command to make at zero. Driven end to end through the real
|
||
refusal branch in `test_clamp.py` part 7c.
|
||
* **`unmet_w` is still not `ok`.** The gate reads `ok` and the unit's share and
|
||
never `unmet_w`, because an allocation that could not place every watt is
|
||
still safe to write; fusing them would let one saturated inverter park the
|
||
whole site. Graded on a record with 3800 W unmet.
|
||
* **`ACTUATOR_WRITE_KEYS` is replaced by `actuator_writers()`**, which counts
|
||
**writers per actuator** rather than option keys, and finds them by walking
|
||
the options tree rather than by looking for a container name CFG-04 has not
|
||
chosen yet. Two inverters with two entities are accepted - that is what the
|
||
per-inverter roster is *for*; two declarations aimed at one entity are refused,
|
||
naming the actuator and every writer pointed at it. The tuple survives only as
|
||
the enumeration a census needs, and nothing asserts on its length any more.
|
||
* **What neither check observes, said out loud.** The census sees *configured*
|
||
writers, so a second write path added in **code** is invisible to it - exactly
|
||
as it was invisible to the tuple. That half is closed at runtime by the record
|
||
gate, which does not care what the options look like or who called the method.
|
||
* Verified by breaking it: seven mutations on a purged tree via
|
||
`tools/mutation_run.sh` - removing the gate, making the zero exit an amnesty,
|
||
dropping the tick comparison, deleting the census refusal, fusing `unmet_w`
|
||
into `ok`, freezing the tick on the live path, and moving the gate below the
|
||
clamp - each turn `test_clamp.py` or `test_validate.py` red.
|
||
|
||
**The site grid target is now ALLOCATED across N inverters, sum-invariant, and
|
||
never broadcast (ALLOC-01).** `ARB-03` left `allocate()` as a documented seam
|
||
that passed through at N<=1 and DECLINED at N>=2; this fills it. The new
|
||
`app/allocator.py` turns one site target `S` into per-inverter shares with one
|
||
invariant - **`sum(shares) + unmet_w == S`, in integer arithmetic** - and each
|
||
inverter is then told a function of **its own share**, never of the shared
|
||
measurement.
|
||
|
||
* **Why a broadcast is a fault and not merely an inefficiency.** The control
|
||
primitive is meter emulation: the master reports a *fabricated grid reading*
|
||
and the inverter regulates itself to it, so one number means "the battery
|
||
moves X W" here and "the grid is at X W" there - an identity that holds only
|
||
at N=1. Hand both inverters the same target and each supplies it in full, so
|
||
the loop gain at the meter becomes N times the per-unit gain. Measured on
|
||
`admin/goodwe`'s two-master rig (TEST-19): **-400 W export overshoot at
|
||
`gain: 0.6`, and at `gain: 1.0` - which `config.yaml` accepts as its
|
||
documented maximum - a permanent 4000 W peak-to-peak limit cycle with not one
|
||
guard firing.** The sign watchdog is evaluated on every cycle and never trips,
|
||
because the disagreement flips on alternate ticks and never holds its 180 s
|
||
window.
|
||
* **The stability property is structural, not tuned.** With `sum(a_i) = 1` the
|
||
gain at the meter is `sum(a_i * g_i)`, a **convex combination** of the
|
||
per-unit gains, so it lies in `[min g_i, max g_i]` **for any split**. That is
|
||
what makes the weights a comfort choice (SoC, thermals, wear) rather than a
|
||
tuning parameter, and it means a bad split cannot destabilise the site.
|
||
Graded directly in `test_alloc.py` part 9, and measured end-to-end: a 90/10,
|
||
a 50/50 and a 100/0 split produce a **bit-identical aggregate trace at the
|
||
meter**, and a gain sweep to 1.0 at every split shows a flat tail at all 84
|
||
combinations.
|
||
* **Rounding is a safety property here.** The register is 1 W, and a per-unit
|
||
`round()` biases the sum - measured at **-125 W over 50 000 ticks at three
|
||
inverters**. At 1-2 Hz that is a permanent standing error the site integrator
|
||
chases and never reaches. So the target is rounded **once**, at the top, and
|
||
the fractional parts are settled by **largest remainder** with a deterministic
|
||
tie-break; every sum below that line is integer arithmetic and cannot drift.
|
||
Verified over 250 000 randomised ticks: **0 sum failures, 0 envelope escapes**,
|
||
and the one rounding that is left - the single site-level `quantisation_w`,
|
||
never distributed into a per-unit bias - totals **-39.8 W over 137 964
|
||
allocations, i.e. -0.0003 W per tick.**
|
||
* **Each share is clipped to that unit's interval, and THE TWO ENDS OF THE
|
||
INTERVAL ARE APPLIED INDEPENDENTLY.** `UnitBounds()` defaults to
|
||
`(-inf, +inf)`, so a one-sided envelope is the ordinary case at N>=2 rather
|
||
than an edge. The first cut of this work folded both ends into one
|
||
`min(max(v, lo), hi)` and substituted `v` itself for a missing bound, which
|
||
reads as "an absent bound cannot bind" but is not what it does: with
|
||
`hi == inf` the expression collapses to `min(max(v, lo), v) == v`, so **a
|
||
missing ceiling silently discarded the floor.** `_clamp_int(0, 2142, inf)`
|
||
returned 0. The worked failure it caused: a unit with a 2000 W floor and no
|
||
ceiling, beside a symmetric unit, against a +1000 W site target, was allocated
|
||
**+500 W - a quarter of its own floor** - and the sign-coherence refusal that
|
||
the same case with a finite ceiling correctly raises was **suppressed** with
|
||
it. Fixed by clamping each end on its own, and `UnitPlan.explain()` can no
|
||
longer print a share as being inside an interval that excludes it: that line
|
||
now reads `OUTSIDE ... - ENVELOPE VIOLATED, DO NOT WRITE`.
|
||
* **The envelope is read per tick, never cached.** `plan()` is a pure function
|
||
of that tick's intervals and holds no state, because a unit's range moves for
|
||
reasons unrelated to the allocator - a stale link, a pack at its floor, a
|
||
device rating that failed to read.
|
||
* **A dead link is announced, not absorbed.** A unit whose master is stale is
|
||
dropped from the envelope set; the survivors carry its share, and the
|
||
allocation record names the unit and the reason so `main.py` logs it. A site
|
||
running at half its inverters and reporting nothing reads as healthy on every
|
||
dashboard.
|
||
* **Opposite directions are refused, not averaged.** One pack discharging while
|
||
another charges nets to exactly the right number at the meter, so no existing
|
||
guard can see it. A sign-coherence gate makes it inexpressible.
|
||
* **`scope_violations()` is now consumed, and it is consumed as a refusal.**
|
||
ARB-03 left it logging without changing the resolution. A mis-routed claim
|
||
means the number about to be divided may itself be wrong, and dividing a
|
||
number that may be wrong is worse than not dividing it - the same answer
|
||
clause 3 already gives a contradiction. It is *not* promoted into an envelope:
|
||
a unit-scoped claim that landed on the site names no unit, so promoting it
|
||
would mean guessing one.
|
||
* **At N<=1 it is a pass-through, byte-identical.** The share is the site
|
||
target, as a float, unrounded and unclamped. Every one of the existing suites
|
||
replays unedited and green.
|
||
* **`main.py` still refuses to act at N>=2, for a new and narrower reason.**
|
||
There is one `setpoint_entity`, so this controller can command one inverter; a
|
||
valid two-way split names an inverter it has no writer for, and commanding a
|
||
partial site is a worse instruction than commanding nothing. The old
|
||
"no allocator" refusal and its test (`test_peakguard.py` part 10h) were
|
||
**re-aimed, not deleted** - `allocated=False` is now reachable on a mis-routed
|
||
claim, an empty envelope set, or sign incoherence, and all three are graded on
|
||
real cycles.
|
||
* **And the replay that missed it was widened, so the fix is graded rather than
|
||
merely applied.** The 250 000-tick replay drew every envelope as
|
||
`B(-uniform(0, rating), uniform(0, rating))` - **finite on both ends and
|
||
containing zero, every time** - while the check it fed claimed "no share ever
|
||
leaves the envelope it was clamped into". The data could only exercise a
|
||
symmetric closed interval around zero, so it reported 0 escapes against a live
|
||
defect. The generator now draws from **every shape `UnitBounds` can express**:
|
||
half-open above, half-open below, intervals that exclude zero, and bare
|
||
`UnitBounds()`. Measured A/B against the defective clamp on the same seed and
|
||
the same 250 000 ticks: **the old generator sees 0 escapes, the new one sees
|
||
39 991.** Reverting the clamp fix under the widened generator takes
|
||
`test_alloc.py` red on four checks; re-applying it takes it green. The
|
||
collapse is now a row in part 12's mutation table.
|
||
* **Known and not closed here:** at a roster of two the loop's `actual_w` is one
|
||
inverter's power used as a site measurement, because the add-on binds a single
|
||
`inverter_power` sensor. That is the *sensing* half of the same N=1 identity
|
||
this card removes from the *commanding* half, and it makes the aggregate trace
|
||
split-dependent below `gain: 1.0`. Measured, with the counterfactual, in
|
||
`test_alloc.py` part 8 and `tools/alloc_sim.py --splits`. It belongs to
|
||
`DEV-02`.
|
||
|
||
**`Claim` carries the `actuator` specs.md always said it did, and `resolve()`
|
||
is three-stage (ARB-03).** `specs.md` §6.1 declares `actuator: str` as the first
|
||
field of `Claim`; the shipped `app/arbiter.py` had no such field, so every claim
|
||
was implicitly about "the one and only actuator", `resolve()` returned one
|
||
scalar, and `main.py` assigned it to one `self.target`. **There was no seam an
|
||
allocator could occupy** - and `ARCH-01` decided the site grid target is
|
||
ALLOCATED across several inverters and never broadcast. This is a regression fix
|
||
against the written design, and it creates that seam. **At N=1 it is a pure
|
||
refactor: every one of the 17 suites replays unedited and green, on the same
|
||
numbers.**
|
||
|
||
* **The split, verified at each construction site rather than assumed.** Four
|
||
sources are about the **grid connection** and are SITE claims - `loop`
|
||
(regulates `grid_w`, the P1 measurement), `peak` (the quarter-hour average
|
||
offtake the capacity tariff bills), `arbitrage` (buys and sells at the grid
|
||
against a day-ahead price) and `safety` (`stopped` is the site's switch,
|
||
`inputs missing` is the site's own meter). Four are about **a pack, a device
|
||
or one write** and are PER-INVERTER - `device` (one inverter's own reported
|
||
rating), `supervised` (`max_w` caps what may be WRITTEN to one setpoint
|
||
entity), `failsafe` (**site-wide evidence, per-unit remedy**: SAFETY-02 grades
|
||
the command THIS controller put on THIS actuator, and at N>=2 that evidence
|
||
cannot be pinned on a sibling nobody here commands) and `maintenance` (§12's
|
||
recalibration is a pack procedure). The reasoning is recorded **at the line**,
|
||
in `arbiter.CLAIM_SCOPE` and again at each `claims.append` in `main.py`.
|
||
* **Getting it wrong is the parked-battery bug, so it is routed rather than
|
||
written.** A per-inverter reserve filed as a site `hi` against the peak guard's
|
||
site `lo` is `lo > hi`, arbiter clause 3, **0 W in the billed quarter** -
|
||
SAFETY-25 §0a's fatal case. `actuator_for()` is the one routing point and it
|
||
**raises `KeyError` on an unclassified source** rather than defaulting to the
|
||
site, because defaulting to the site is the failure itself.
|
||
* **Three stages, each a function a test can construct a case at.**
|
||
`pre_resolve()` turns one inverter's own claims into that unit's interval;
|
||
`site_resolve()` turns the site claims into one grid target and **is the
|
||
shipped arbiter, unchanged**; `allocate()` is **the seam, and deliberately not
|
||
an allocator** - `ALLOC-01` builds it. With at most one unit it passes the
|
||
target through (§6.2's N=1 identity); with two or more **it declines, no
|
||
shares, `allocated=False`**, because the obvious placeholder - hand the target
|
||
to each - IS the fault `ARCH-01` was filed about. **What `main.py` then does
|
||
is SUBSTITUTE, not abstain**: it replaces the resolution with a 0 W `failsafe`,
|
||
logs "refusing to broadcast the site target", and writes that 0 W through
|
||
`write_setpoint()` like any other cycle. The refusal is to BROADCAST; the
|
||
write still happens, in the fail-toward-inaction direction. Graded end to end
|
||
on a real two-inverter cycle by new **part 10h** - the branch is unreachable
|
||
on the shipped roster, and replacing it with `if False:` had left all 17
|
||
suites green.
|
||
* **`resolve()` HONOURS the roster it is handed, and that is now graded too.**
|
||
Left `None` the roster is derived from the claims, which is the documented
|
||
default; passed explicitly it is used. Deriving it *always* - ignoring the
|
||
argument - makes a unit that filed nothing this cycle cease to exist, so a
|
||
site of two reads as a site of one and the seam passes the whole grid target
|
||
through to the inverter that spoke: `ARCH-01`'s fault with its own evidence
|
||
removed. `test_arbiter.py` part 7 grades it on the one case that separates the
|
||
two, a **silent unit**.
|
||
* **The contradiction is no longer guarded against, it CANNOT BE WRITTEN DOWN.**
|
||
The partition is by `actuator` and it is a partition: a unit's `hi` and the
|
||
site's `lo` are never both terms of one `lo > hi` test. Constructed and swept -
|
||
40 filings (every per-inverter source x unit x bound, a unit contradicting
|
||
ITSELF included) against a live site floor, and not one moves the site target
|
||
or raises a contradiction. A unit's own impossible pair parks that unit and
|
||
says so on its own bounds; it does not park the site. That is SAFETY-25 §0c's
|
||
table made mechanical.
|
||
* **At N=1 nothing splits, and that is §6.2's identity rather than the
|
||
classification being ignored.** With one inverter the fabricated grid reading
|
||
and the battery setpoint are the same number, so the grid connection and the
|
||
only unit ARE one actuator and every source routes to the site. **Measured:
|
||
dropping that identity - splitting at N=1 - takes `test_peakguard.py`,
|
||
`test_freeze.py` and part 10g red**, because the `failsafe` and `maintenance`
|
||
SET claims would stop competing for the winner, `explain()` would name a
|
||
different source and `loop_is_following()` reads that name.
|
||
* **SAFETY-24's floor asymmetry survives, and now has a second half.** The
|
||
combination sweep is re-run and still holds - no negative floor, `hi` still
|
||
`+inf` on every claim the guard files - and it now also asserts every one of
|
||
them is on the **site** actuator, because a floor bound to one inverter's share
|
||
would leave the site, the thing the capacity tariff meters, with no floor.
|
||
* **The part 10f tripwire is kept and widened, not adapted around.** Its static
|
||
AST scan and its dynamic SoC sweep are untouched in substance - `source` is
|
||
still `Claim.set()`'s first positional argument precisely so the scan keeps
|
||
reading it, which is why `actuator` is the LAST field of `Claim` and not the
|
||
first. Its spy gained `**kw` so the roster passes through unread rather than
|
||
being silently dropped. New **part 10g** pins `CLAIM_SCOPE` to the same
|
||
eight-source set, so a ninth source now goes red twice. Verified by breaking
|
||
it: building SAFETY-25's Option B as a ninth source still takes 10f red on the
|
||
line it is written.
|
||
* **And the CALL SITES are pinned statically, at all eight of them.** 10g's
|
||
first cut graded routing only through a **real two-inverter cycle** and called
|
||
that "the line a mis-typed `actuator_of(...)` at a call site goes red on".
|
||
**It was not.** That cycle reaches `device`, `supervised` and `loop` and
|
||
nothing else - three of the eight direct appends - so a mis-type at the other
|
||
five survived every suite in this repository. Proved on the shipped tree by PR
|
||
#40's reviewer: `actuator_of("maintenance")` -> `actuator_of("peak")` at the
|
||
maintenance-shaping append left `test_arbiter.py`, `test_peakguard.py` and
|
||
`test_arbitrage.py` all green while, at a roster of two, it built a site loop
|
||
of -1500 W against the guard's site `lo` of 1500 W and the now-misrouted
|
||
charge-only `hi = 0` - `lo > hi`, arbiter clause 3, **0 W in the billed
|
||
quarter**, SAFETY-25 §0a's fatal case from a one-word typo. That is this
|
||
repository's named failure mode - **a check observing something other than
|
||
what it claimed** - reproduced inside the check written to prevent it, so it
|
||
is closed the way 10f closes it: over the **same AST scan**. All eight direct
|
||
appends expose their actuator as a literal `actuator=self.actuator_of('...')`,
|
||
so the literal is required to EQUAL the source the claim is filed under - all
|
||
eight, on the line the typo is written, in every configuration and at every
|
||
roster, whether or not any fixture reaches the state that files them.
|
||
**Measured: each of the eight mutated to a wrong actuator in turn, each one
|
||
red.** The two INDIRECT appends (`peak_claim`, `arb_claim`) carry no
|
||
`actuator=` at the call site to mis-type, and are pinned where their routing
|
||
is actually chosen - `peakguard.claim()` by the `SWEEP_SITE` sweep,
|
||
`arbitrage.claim()` by `test_arbitrage.py`. 10g's dynamic cycle is kept and
|
||
now says which three of the eight it sees instead of claiming the class.
|
||
|
||
**SAFETY-25's open question is ANSWERED: the BMS cut-out is the DELIBERATE and
|
||
only backstop for demand-driven discharge, and a loop-side reserve floor is
|
||
DEFERRED to `ALLOC-01` rather than rejected (SAFETY-25).** Decided by the
|
||
product owner on 2026-09-02. **Option A is the shipped behaviour, so `app/`
|
||
changed only where a comment claimed the question was still open** - no
|
||
setpoint, no claim, no option, no default moved, and the 17-suite run is green
|
||
on the same numbers as before.
|
||
|
||
* **Why deferring is the safe direction, and it is a property of the shape
|
||
rather than a preference.** Option B's proof - `bms_soc_floor <
|
||
loop_soc_floor <= peak_soc_floor`, verified through the real arbiter in
|
||
`test_peakguard.py` part 10d - has an unstated premise: the reserve predicate
|
||
and `discharge_permitted()` read the **same** state of charge. With two packs
|
||
they read different numbers. The reserve binds on the emptier pack and files
|
||
`hi = 0` while the peak guard, reading the other pack or an average, files
|
||
`lo = excess > 0`; `lo > hi` is arbiter clause 3 and the answer is **0 W, the
|
||
battery parked in the billed quarter** - the failure the memo itself marks
|
||
fatal. **No config-ordering validator can catch it**, because it is a runtime
|
||
divergence between two readings and not a config ordering, so CFG-01's shape
|
||
of check would pass on the exact tree that parks the battery. Option A's cost
|
||
is energy; Option B's cost built too early is a parked battery in the one
|
||
quarter of the month that sets the bill.
|
||
* **The revisit trigger is recorded explicitly, and it is `ALLOC-01`.** Under
|
||
`ARCH-01`'s host-side allocator the two claims stop sharing a list: the loop
|
||
floor becomes a **per-inverter `hi` consumed as an interval at stage A**,
|
||
while the peak floor stays a **site `lo` at stage B**. Different claim lists,
|
||
different stages, **structurally unable to cross** - which is a stronger
|
||
argument than the ordering rule, because it makes the crossing unreachable
|
||
rather than harmless. That is the shape in which Option B becomes safe to
|
||
build, and the memo says so at the point a future reader will look.
|
||
* **What will have to be RE-EARNED at N=2 is stated plainly rather than left
|
||
for someone to discover.** `SAFETY-27` now names **five floors across two
|
||
packs**, so "the SoC" stops being a scalar the config can bound. And
|
||
`test_peakguard.py` **part 10f - the pin that made SAFETY-25 mergeable** -
|
||
**cannot express two packs**: its dynamic half is a scalar 0-100 % SoC sweep
|
||
against a single-series capture, and a two-pack exposure lives in the
|
||
*difference* between two readings, which that sweep visits at no resolution.
|
||
Its static half (the AST scan pinning the eight claim sources) still bites at
|
||
N=2; the dynamic half goes silent. Merging a two-pack reserve on 10f's
|
||
current green would be merging on a check that cannot see the failure.
|
||
* **The options analysis is kept intact on purpose.** A memo that recorded the
|
||
verdict and deleted the reasoning is the failure mode this `DECISIONS/`
|
||
directory exists to prevent, so §1-§7 stand exactly as written - including
|
||
§5's *"(recommended)"* on Option B and §6's recommendation - with the new §0
|
||
saying what was decided and §6 marked as answered. Part 10e's check moved
|
||
with the memo: it used to pin the honest `Status: OPEN` line and now pins
|
||
that the memo names the option, the date and the trigger **and still carries
|
||
both options and the recommendation**.
|
||
* **The tripwire stays, and "deferred" is why it stays.** Part 10 and mutation
|
||
13 are unchanged in substance: a ninth claim source in `Controller.cycle()`
|
||
still takes 10f red on the line it is written. Deferred, not rejected, is
|
||
precisely the state in which someone builds Option B by accident.
|
||
|
||
Operator-facing surfaces follow the decision and nothing else changed on them:
|
||
`DOCS.md`'s SAFETY-25 section now says a choice was made and points at the
|
||
trigger, and `app/freeze.py`'s finding and `app/peakguard.py`'s point 2 say
|
||
"decided, on purpose" where they said "open".
|
||
|
||
**`PeakStore.record_block()` now has its POLICY ARGUMENT graded, and the
|
||
SAFETY-20 import check now looks at all three ways of writing an import
|
||
(TEST-13).** Two survivors from TARIFF-01's review, both closed in
|
||
`test_tariff.py` only - `app/` is untouched and no shipped behaviour changed.
|
||
|
||
* **The plumbing of the per-phase reduction was unobservable.** Mutating
|
||
`record_block()`'s `billed_w(block, policy)` to `billed_w(block, POLICY_NET)`
|
||
left all 17 suites green (+4 B; recorded by TARIFF-01's reviewer, re-run
|
||
through the purge harness for TEST-15, and re-run again here). The cause is
|
||
the third instance of the same shape as WITNESS-02's span normalisation and
|
||
TARIFF-01's own DST window: **a stimulus evaluated where the quantity it
|
||
grades is degenerate.** `billed_w()` short-circuits to `block.offtake_avg_w`
|
||
whenever `per_phase_offtake_avg_w` is absent, and every block that reached a
|
||
store anywhere in the suite was single-phase or built with
|
||
`per_phase_offtake_avg_w=None` - so NET, SUM and MAX collapsed onto one
|
||
number and the argument could not be seen. Section 7 already owns an
|
||
UNBALANCED fixture, `(3000, 500, -200) W`, whose three reductions are 3300 /
|
||
3500 / 3000 W; that block is now round-tripped **through the store** under
|
||
each policy and read back from both tables. NET is the smallest of the three
|
||
here, so a store that quietly billed NET while asked for MAX would
|
||
under-record the month's peak - maximum headroom for SAFETY-07, the same
|
||
under-guarding direction as the `getattr(..., 0.0)` default SAFETY-07 already
|
||
removed from `project()`.
|
||
* **The import half of the SAFETY-20 grader made a claim it did not check.** It
|
||
collected `ImportFrom.module` only, which is blind to two of Python's three
|
||
import spellings: `from . import arbiter as _ab` is an `ImportFrom` whose
|
||
`.module` is **None** (the module name is in `.names`), and `import
|
||
app.arbiter` is an `ast.Import` node the walk never visited. Both were added
|
||
to `app/tariff.py` and the shipped suite stayed **green**. Never a live
|
||
exposure - the `_names` half still catches any USE of either alias - but the
|
||
printed sentence "it imports nothing from arbiter, clamp or control" was
|
||
false for two forms out of three, which is worse than claiming less. The
|
||
grader now walks `Import` as well, joins `from . import X` onto its base, and
|
||
matches per dotted SEGMENT so `app.arbiter` cannot hide behind a package
|
||
prefix while `clamps` / `arbiters` / `controller` are not falsely flagged.
|
||
Both graders are also now shown non-vacuous against `main.py`, which really
|
||
does import all three modules.
|
||
* **Two items on the card were already closed and are struck, not
|
||
re-implemented.** `PeakReading.is_observed` is `self.state == OBSERVED`
|
||
(`app/tariff.py:266`), already false for `NO_RECORD`; and `project()`'s
|
||
`held` read is `max(averager.held_net_w, 0.0)` with **no** `getattr` default,
|
||
fixed by SAFETY-07 in 616b62d with a comment naming TEST-13 item 4. Restoring
|
||
a default there would re-open a money guard. Removing `clear_partial()` also
|
||
stays green and that remains a **PASS**: the `quarter_start` PRIMARY KEY plus
|
||
`ON CONFLICT ... WHERE excluded > existing` makes a replay a structural
|
||
no-op, which is TARIFF-01's claim rather than a hole in it.
|
||
|
||
Verified through `tools/mutation_run.sh` (purge, `python3 -B`, tree re-read from
|
||
disk, mutated line grepped off disk, byte delta recorded): 7 mutations, all red
|
||
on the named checks, plus green baselines before and after and a control run
|
||
showing the shipped suite passing every one of them.
|
||
|
||
**The two `arbitrage()` rungs SAFETY-26's AC4 sweep found pinned from ONE SIDE
|
||
ONLY are now pinned from both - and, the actual finding, are pinned so they stop
|
||
MASKING EACH OTHER (TEST-16).** `spread == 0.0` weakened to `<= 0.001` survived
|
||
the whole suite at +2 B, and `len(firm) < 2` weakened to `< 1` survived at
|
||
+0 B - the delta MUTATION.md names as the risky one. Neither was individually
|
||
live, and that was the problem: a single firm hour *also* has a spread of zero,
|
||
so each rung's failure was absorbed by the other and both could be weakened
|
||
together with nothing reporting it. That is invisible to single-mutation
|
||
testing, which is how the pair survived four rounds of review on TARIFF-03 and
|
||
FCST-03. `test_static_tariff.py` gains section 9, which grades WHICH RUNG
|
||
answered each stimulus rather than only whether it refused - the masked case
|
||
refuses correctly while reporting the false sentence "the tariff is flat" about
|
||
a series that is not flat, so a check asserting only "refused" passes on a
|
||
broken tree. The flat rung is swept with small-but-REAL spreads down to
|
||
0.000001 EUR/kWh at `min_spread_eur_kwh=0.0` (a value `arbitrage()`'s docstring
|
||
documents and its validator accepts on purpose), the firm-pair rung is probed
|
||
with a ONE-FIRM-HOUR series and graded on §8.3's sentence coming back rather
|
||
than the flat rung's, and a five-row table denies the masking on both sides.
|
||
Verified through TEST-15's `tools/mutation_run.sh` unchanged - recursive
|
||
`__pycache__` purge, `python3 -B`, tree re-copied from disk between rounds,
|
||
mutated line grepped off disk, byte delta printed: 7 mutations plus a green
|
||
baseline, 4 of them at +0 B, ALL RED, including both rungs weakened AT ONCE
|
||
(5 checks red). **No shipped behaviour changed - both rungs were correct all
|
||
along**; `app/` is untouched. `MUTATION.md` gains a section on masking as a
|
||
second way a survivor lies, and `test_arbitrage.py`'s SAFETY-26 AC4 note is
|
||
marked closed rather than left reading as an open finding.
|
||
|
||
**`check_output_clamp()` could be moved above `write_setpoint()` in `cycle()`
|
||
with test_clamp.py, test_freeze.py and test_loop.py all staying GREEN — the
|
||
guard's write-path evidence, `self.clamp_last`, had nothing watching it.**
|
||
Measured before the fix, on the move itself: all three suites green. The guard
|
||
has two evidence sources and only one of them was covered. `self.loop_clamped`
|
||
is set from the `Decision` well above the write, so it survives the move — and
|
||
on the §10.3 runaway replay it is the source of every engagement the alarm
|
||
reports (0 write-path engagements against 2 loop-side ones), which is precisely
|
||
what made the move nearly invisible. `self.clamp_last` is assigned by
|
||
`write_setpoint()` and by nothing else (`main.py`), is `None` until it has run,
|
||
and is what `engaged_write` reads; moved above the write, that consumer goes
|
||
quietly false and a DEVICE-bound clamp — `max_w` above the inverter's own
|
||
rating, SAFETY-20's install — stops being reported by anything.
|
||
test_clamp.py gains part 6, and it grades the CONSEQUENCE rather than the
|
||
position: a spy subclassed from the shipped `ClampWatchdog` records what
|
||
`self.clamp_last` held each time the guard consulted it, over a slew-limited
|
||
ramp where every cycle's command is a different number, and asserts the
|
||
evidence is always the write of THAT cycle. Hooking `clamp_wd.note()` rather
|
||
than the guard's name is what keeps a rename legitimate; the same assertions
|
||
are re-run through a controller whose guard is renamed and whose write lives in
|
||
a helper, and were verified against `main.py` itself — renaming the method
|
||
throughout and moving both calls into a new `_emit_and_guard()` helper keeps
|
||
part 6 green, while swapping the two calls turns it red on a line that names
|
||
`clamp_last`. AC 3 needed a stimulus of its own: through the real `cycle()` the
|
||
write-path half can never engage, because the arbiter files the device range
|
||
and `max_w` as P_SAFETY LIMIT claims and `resolution.target_w` is already
|
||
inside both bounds before `write_setpoint()` re-applies them — that duplication
|
||
is deliberate defence in depth, and part 6 now measures the 0 engagements
|
||
rather than leaving it as a claim in a comment. So the write path is driven
|
||
directly at `max_w` 5000 against a ±2500 W device: the wire carries 2500 W,
|
||
every loop-side flag stays clean, and the alarm fires on `clamp_last` alone —
|
||
with the two calls swapped, the same stimulus clamps the same value and the
|
||
alarm is silent. `main.py` gains one comment sentence pointing at the check,
|
||
mirroring the one SAFETY-18's block already carries for test_freeze.py part 5.
|
||
|
||
**`max_w` and the `LIVE_MAX_W` literal beside it were unpinned in test_freeze.py
|
||
and test_tariff.py, the same gap TEST-06 closed one key over for
|
||
`meter_freeze_s` (TEST-10).** Measured before the fix: `max_w: 2000 -> 4500`
|
||
in `config.yaml` left every check in both files GREEN, and `LIVE_MAX_W = 5000.0`
|
||
was a bare literal that silently disagreed with the shipped default (2000) with
|
||
nothing saying so — for contrast, `deadband_w: 15 -> 5` already went RED 2 + 1,
|
||
so the pinning technique itself was known to work, just not applied here.
|
||
Fixed three ways. First, `test_freeze.py` gained a check comparing `Tuning()`'s
|
||
code default (`control.py`, 2000.0) against `SHIPPED_MAX_W`, read from
|
||
`config.yaml` by extending the file's existing `_cfg_default()` regex reader
|
||
rather than reimplementing test_arbitrage.py's `_shipped`. Second, the literal
|
||
itself is renamed `LIVE_INSTALL_MAX_W` in both files, with a comment stating
|
||
plainly that 5000 is the live install's own override and not config.yaml's
|
||
shipped `max_w` — the defect was the silent literal, not the number. Third,
|
||
`test_freeze.py` now states `PINNED_KEYS = ("meter_freeze_s", "deadband_w",
|
||
"max_w")` in one place as the complete set of config keys this suite pins,
|
||
deliberately excluding `maintenance_*` (CFG-03 gave those a separate source of
|
||
truth in `app/maintenance.py`). Verified by breaking what the new check
|
||
watches: `max_w: 2000 -> 4500` in `config.yaml` now fails exactly
|
||
`"the code default and the shipped default agree on max_w too"` and nothing
|
||
else, and reverting returns the file to all-green.
|
||
|
||
**Maintenance option defaults lived in three places that had already disagreed,
|
||
and MAINT-04's tripwire was holding them apart rather than closing them
|
||
(CFG-03).** `config.yaml` shipped `maintenance_charge_w: 2000`, `main.py` carried
|
||
two `opts.get("maintenance_charge_w", 2500)` fallbacks, and `MaintConfig`'s
|
||
dataclass carried a third `2500.0`. An options dict with the key **absent** was
|
||
therefore accepted by `validate.py`'s rule 6 — which read 2000 — and then built
|
||
by `main.py` at 2500, against a 2000 W ceiling. Demonstrated through the shipped
|
||
code:
|
||
|
||
options dict with maintenance_charge_w ABSENT, max_w=2000
|
||
validate_startup_config -> [] (ACCEPTED, no problems)
|
||
MaintConfig().charge_w -> 2500.0
|
||
arbiter.resolve() -> 0.0 W, contradiction=True
|
||
|
||
That is the bug rule 6 exists to refuse, **passing rule 6**. Unreachable through
|
||
the Supervisor, which always supplies every key from `config.yaml`; fully
|
||
reachable from `sim/inject.py` and from the test suite, which is where anyone
|
||
debugging maintenance is.
|
||
|
||
**`maintenance_discharge_w` had the identical divergence** (`config.yaml` 2000,
|
||
`MaintConfig` 2500) and the card named only the charge side. Closing one key and
|
||
leaving its twin would have reproduced the defect one line down, so both are
|
||
closed.
|
||
|
||
**The single source of truth is `config.yaml`'s `options:` block** — the file the
|
||
Supervisor reads and the operator edits. There is now exactly **one** in-code
|
||
copy, `maintenance.MAINT_OPTION_DEFAULTS`, which is TARIFF-02's
|
||
`PRICE_OPTION_DEFAULTS` and SAFETY-24's `PEAK_OPTION_DEFAULTS` pattern applied to
|
||
the family that named the card. A mirror rather than a runtime read because the
|
||
`Dockerfile` copies `app/` and `run.sh` into the image and **not** `config.yaml`:
|
||
the running add-on cannot open the file it is configured by. `test_maintenance.py`
|
||
pins every entry by **reading** the shipped file, so a drift is a CI failure
|
||
rather than a comment. Every other declaration site now derives from it or has
|
||
ceased to exist:
|
||
|
||
- `MaintConfig`'s eight dataclass defaults are indexed out of the mirror — no
|
||
numeric literal survives in the class that held the third opinion;
|
||
- `MaintConfig.from_options()` is the **only** place in the add-on that reads a
|
||
`maintenance_*` option. `main.py`'s eight inline `opts.get(key, <literal>)`
|
||
calls are gone;
|
||
- `main.py`'s second fallback site (the cheap-window charge floor) reads
|
||
`self.maint.cfg.charge_w` instead of re-opening the options dict, so the rate
|
||
the shaping asks for and the rate the state machine drains at cannot be two
|
||
numbers from one install;
|
||
- `validate.py`'s rule 6 and its `maintenance_discharge_w` warning take their
|
||
fallbacks from the mirror.
|
||
|
||
**Where the RANGE lives is now stated and checked, so the next person does not
|
||
add a fifth site.** `maintenance_charge_w: int(500,5000)` in `config.yaml`'s
|
||
`schema:` block is a fourth declaration of a different kind — a bound, not a
|
||
default. Bounds live in the schema block **only**: the Supervisor enforces them
|
||
and reads only that file. `test_maintenance.py` asserts that every mirrored key
|
||
has a schema entry (or the Supervisor silently drops the option and the add-on
|
||
runs on the mirror instead of the operator's value) and that every shipped
|
||
default lies **inside** its own range. What the schema grammar cannot express is
|
||
a relation *between* keys — `maintenance_charge_w` against `max_w` — which is
|
||
why rule 6 is written in Python and does not belong in the mirror either.
|
||
|
||
**MAINT-04's tripwire in `test_validate.py` is DELETED, not left passing**, as it
|
||
and `validate.py`'s ⚠️ block both instructed each other to do. Its passing
|
||
condition *was* the bug. In its place, the same four consumers exercised the same
|
||
way and now agreeing: the validator accepts a dict with every `maintenance_*` key
|
||
missing, a **real `Controller`** built from that dict holds 2000 W, `MaintConfig()`
|
||
answers 2000 W with no dict at all, and the real arbiter emits `-2000 W, limited
|
||
by supervised(max_w)` instead of `0 W, contradiction=True`.
|
||
|
||
**Checked by breaking it, five ways, each observed red before being reverted:**
|
||
move `config.yaml` alone → the mirror pin fails; move `config.yaml` **and** the
|
||
mirror to 1500 → every consumer follows together (`MaintConfig()` 1500 W, the
|
||
`Controller` 1500 W, the arbiter `-1500 W, limited by maintenance(cheap-window
|
||
charge)`) and both suites go green again; retype `charge_w: float = 2500.0` into
|
||
the dataclass → red; restore `opts.get("maintenance_charge_w", 2500)` in
|
||
`main.py` → red; delete a key from `config.yaml`'s options block → red.
|
||
|
||
Stale prose that asserted the closed divergence as current is corrected in
|
||
`app/watchdog.py`, `test_watchdog.py` and `test_peakguard.py`.
|
||
|
||
**`peak_soc_floor` reads like a pack-wide reserve and is not one, and on a
|
||
sustained-load evening the grid-following loop discharges straight past it to
|
||
the BMS cut-out (SAFETY-25).** The gap has been written down four times without
|
||
being filed — SAFETY-18's finding (`freeze.py`), SAFETY-24's module note, its PR
|
||
body, and its honestly-uncaught 13th mutation — and never once measured. It is
|
||
measured now, on the committed 2026-08-20 capture, driving the shipped
|
||
`control.compute()`, `peakguard.decide()`, `peakguard.claim()` and the real
|
||
`arbiter.resolve()`:
|
||
|
||
19:00-22:00 UTC, 830 W mean, minimum 701 W, not one watt of export
|
||
reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
|
||
above peak_soc_floor
|
||
|
||
ends at 10.0 % - the bms_soc_floor cut-out
|
||
1525 Wh delivered by the LOOP over 587 cycles
|
||
549 cycles below peak_soc_floor, 343 of them still discharging
|
||
0 cycles on which the peak guard filed any floor at all
|
||
|
||
**And it is a behaviour, not a config value.** The same evening ends at
|
||
`9.9715 %` at `peak_soc_floor` = 5, 11, 20 **and** 60 — the shipped value and
|
||
both ends of the schema range — identical to four decimal places. The option an
|
||
operator would reach for cannot move the number by any amount. On the same day's
|
||
high-load block the two states coexist: 49 cycles reporting `PEAK GUARD
|
||
FLOOR-LIMITED` and, on **all 49**, the arbiter simultaneously granting the loop
|
||
up to 2000 W of discharge.
|
||
|
||
**Why SAFETY-24 could not close it.** The peak guard files a `lo` limit — a
|
||
*lower* bound. That asymmetry is what makes it provably unable to lower a target,
|
||
cap a discharge or contradict the arbiter, and it is therefore equally unable to
|
||
stop a discharge the loop itself wants. A floor on the guard cannot become a
|
||
floor on the pack by being raised.
|
||
|
||
**What changed is the honesty, on four operator-facing surfaces**, because that
|
||
half is required whichever way the open question is answered and it is the actual
|
||
misstatement on the live system today:
|
||
|
||
- `config.yaml` says next to the option that it **bounds the peak guard only** and
|
||
is **not a pack-wide reserve**, with the replayed evening and the sweep;
|
||
- `DOCS.md` says it in the options table and in a new section carrying the
|
||
figures above;
|
||
- the **status-page row** for a floor-limited guard carries the scope sentence —
|
||
it is the one line a human reads at the moment the guard stops, and read alone
|
||
it implies a reserve the replay shows does not exist;
|
||
- the **persistent notification** says what has stopped (the guard buying a lower
|
||
bill) and what has not (the pack being spent).
|
||
|
||
`peakguard.py` and `freeze.py` carry the measurement beside the findings they
|
||
already recorded.
|
||
|
||
**The product decision is NOT taken here.** Whether the loop gets a reserve floor
|
||
of its own moves real energy on this house and belongs to the owner.
|
||
`DECISIONS/SAFETY-25-loop-soc-floor.md` records the options, what each costs and
|
||
protects, weighed against MAINT-01 and against the fact that this would be the
|
||
**fifth** SoC key (OPT-04's `arbitrage_soc_floor` fired SAFETY-24's "revisit if a
|
||
third floor appears" trigger unremarked), and a recommendation. It also records a
|
||
trap found while writing it and now checked through the real arbiter: the obvious
|
||
shape — a `hi` limit on the loop — crosses the peak guard's `lo` and resolves to a
|
||
**CONTRADICTION at 0 W**, the battery parked in the quarter that sets the bill,
|
||
unless `bms_soc_floor < loop_soc_floor <= peak_soc_floor` is enforced at startup.
|
||
|
||
**`test_peakguard.py` part 10 is a tripwire, not a guarantee, and part 10f is
|
||
what makes it one.** The replay half records the defect; on its own it could not
|
||
see the defect being FIXED, because `replay_loop()` hand-builds main.py's claim
|
||
list and is therefore blind to any claim main.py adds — which is exactly where a
|
||
loop-side floor would be built. PR #31's reviewer proved that by building the
|
||
memo's own Option B in `main.py` and watching the whole suite stay green. **10f
|
||
pins the claim-source set:** every `claims.append(...)` in `Controller.cycle()`
|
||
is read off its AST and must be one of eight named sources, and real
|
||
`Controller.cycle()`s — spied at main.py's own `resolve`, swept over the whole
|
||
0–100 % state-of-charge range in both the idle and the floor-limited state — must
|
||
hand the arbiter exactly `{device, supervised, loop}` and
|
||
`{device, supervised, loop, peak}`. A ninth source, at any state of charge, in
|
||
any configuration, takes part 10 red on the line it is written. A floor built
|
||
inside `control.compute()` or the guard instead moves 10a–10c's replayed numbers,
|
||
which the replay drives directly. So the exposure cannot change size under a
|
||
green suite.
|
||
|
||
**Nine mutations, re-run against the fixed tree**, each against a freshly copied
|
||
tree with no `__pycache__` present at all, mounted `:ro` — the same structural
|
||
immunity `MUTATION.md` records for CI. Six are the AC 3 and behaviour mutations
|
||
already on this ticket's record (the status row's scope sentence, the
|
||
notification's, `config.yaml`'s, the memo deleted, the loop's gain zeroed, the
|
||
guard's claim given a `hi`); two are the reviewer's, and are the ones that
|
||
motivated 10f — the memo's own Option B at its recommended default, **now three
|
||
checks red where it was fully green**, and an absurd always-binding 95 % reserve,
|
||
**six red**; one is new, against the banner short form below. All nine are red,
|
||
no survivors, and no byte delta is `+0 B`.
|
||
|
||
**The gate has never once graded the content a pull request would land, and a
|
||
run that never started reported as a test failure (CI-02).** Both halves were
|
||
measured on a deliberately conflicted throwaway pull request rather than read
|
||
off the workflow, because the Actions log endpoints 404 on this Gitea and a
|
||
step's own conclusion is the only channel out of a job.
|
||
|
||
**Case 1, and the ticket's mechanism was wrong in a way that made it worse.**
|
||
CI-02 was filed saying a conflicted pull request loses its merge ref, so that
|
||
`git fetch $GITHUB_REF || git fetch $GITHUB_SHA` falls through and grades the
|
||
bare branch head. The symptom was real — PR #14 reported `checks / unit ->
|
||
success` while `mergeable: false` — but the mechanism is not. On this server
|
||
`GITHUB_REF` **is** `refs/pull/<n>/head`, `GITHUB_SHA` is the same commit, and
|
||
`git ls-remote origin 'refs/pull/*'` advertises no `/merge` refs at all, for
|
||
open and closed pull requests alike. The first fetch has always succeeded; **the
|
||
fallback has never fired once.** So the gate did not lose merged content on
|
||
conflicted pull requests — it has never had merged content, on any pull request
|
||
in this repository's history. Green has meant "this branch passed on its own",
|
||
which on a base that has moved is not what lands, and `block_on_outdated_branch`
|
||
is off on release/1.0 so the base usually has moved.
|
||
|
||
**The checkout now fetches the base tip and the pull request head, merges them,
|
||
and refuses if the merge does not apply** — as its own named step, so
|
||
`/actions/runs/<id>/jobs` distinguishes "nothing was tested" from "the tests
|
||
spoke". Verified by producing it: with the fix on a conflicted pull request the
|
||
required context went `failure` in 2 s with the two test steps `skipped`. The
|
||
`--depth 1` is gone (a merge needs a merge base; a full fetch of this repository
|
||
is 6.8 MB and 1.1 s), and the fallback survives only on the `workflow_dispatch`
|
||
path, which has no pull request to merge and now says so in its output.
|
||
|
||
**Case 2: `concurrency:` is gone, and `cancel-in-progress` was never the knob.**
|
||
Runs 531 and 533 on PR #14 were cancelled at 0 s each at the exact second the
|
||
next run was created, having never started; a cancelled run posts `failure` on
|
||
the required context, described `Canceled after 0s`, and branch protection
|
||
compares state and not description — so "nothing ran" and "your tests are
|
||
broken" arrive at the gate as the same red, wanting opposite responses. Three
|
||
settings were measured with one run holding the single runner slot and two
|
||
pushes behind it: with **no block at all** both queued runs survived and
|
||
executed; with the group kept and **`cancel-in-progress: false`** the queued run
|
||
was still cancelled at 0 s. A shared group cancels a run that has not started
|
||
regardless of that flag. Having no group is the only configuration measured to
|
||
leave a queued run alive, and it is what ships. The cost — a developer who
|
||
pushes four times occupies the shared runner four times — is CI-01's own trade
|
||
written the other way round: a delayed verdict, never a destroyed one.
|
||
|
||
**`test_ci_gate.py` grades the gate by running it, not by reading it.** It
|
||
extracts the checkout and merge steps out of `checks.yml` and executes the
|
||
shipped commands against two throwaway local repositories it builds — one whose
|
||
pull request merges, one whose does not — and pins that the old one-liner exits
|
||
0 on the unmergeable one while the new steps refuse. It also pins the two tokens
|
||
that assemble `checks / unit (pull_request)`: renaming `name:` or the `unit:`
|
||
job key would leave every open and future pull request waiting forever on a
|
||
context that never arrives, with no error anywhere in that sequence. Seven
|
||
mutations of `checks.yml` were each confirmed red. `.gitea/ci/Dockerfile` gains
|
||
`git` for it.
|
||
|
||
**What was NOT established, stated so nobody reads more into this.** The
|
||
original report that "no run was ever created for `0942228d`" was already
|
||
corrected on the card — run 533 was created and cancelled — and this work
|
||
confirms that from the runs list and the per-commit status history. A commit
|
||
that never had a run carries an empty `/statuses/<sha>` list; a queued one
|
||
carries `pending / Waiting to run` within seconds, and a concurrency-held one
|
||
`pending / Blocked by required conditions`. Those are distinguishable at the
|
||
API. `/actions/tasks`, which is what was polled originally, lists only tasks
|
||
that started, and cannot answer the question at all.
|
||
|
||
**The NOT READY banner no longer repeats the row underneath it.**
|
||
`status_payload()` renders the first hard-failing check as the page headline, so
|
||
the scope sentence above turned the loudest element on the page into a
|
||
550-character paragraph that the checks list then repeated verbatim. A row may
|
||
now carry an optional short `banner` form; the floor-limited row does, and its
|
||
headline is the guard's own line plus a 68-character scope tag. **The row itself
|
||
is unchanged** — the checks list is where an operator reads what the floor does
|
||
and does not bound, and shortening that would have been weakening AC 3. Rows
|
||
without the key are unaffected, and the page still renders with `textContent`.
|
||
|
||
**A setpoint device smaller than `max_w` silently disarmed the frozen-meter
|
||
detector, and the status page reported it as armed (SAFETY-20).** SAFETY-18's
|
||
conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the emitted command is
|
||
clipped to the setpoint device's own `[dev_min, dev_max]` range as well as to
|
||
`max_w` — in `clamp_to_rating()` and again as the arbiter's `device` P_SAFETY
|
||
limit claim. Measured on the shipped predicate at the ticket's numbers,
|
||
`max_w: 5000` on a ±4700 W inverter:
|
||
|
||
looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000) -> False
|
||
|
||
The command is pinned at the hardware's rail, the meter has been frozen for an
|
||
hour, and term 2 is unsatisfiable by construction because 4700 < 4750. Any
|
||
rating at or below `0.95 * max_w` disarms the conjunction outright. The page
|
||
then printed `armed (… command is over 4750 W)` — a guard that cannot fire,
|
||
advertising that it can, which is the failure SAFETY-15, SAFETY-19 and
|
||
validate.py rule 5 all exist to prevent.
|
||
|
||
**The rail is now measured against `min(max_w, |dev_min|, |dev_max|)`** —
|
||
`freeze.rail_ceiling_w()`, threaded through `looks_frozen()`,
|
||
`FreezeWatchdog.evaluate()` and both call sites in `main.py`. An unread rating
|
||
(`hass.limits()` returning `None` when ESPHome is not up) leaves the old
|
||
behaviour exactly, and correctly: on that path no device clamp is applied
|
||
either, so `max_w` really is the ceiling. A rating *larger* than `max_w` cannot
|
||
raise the rail — this is a `min()`, not a swap. The status row prints the rail
|
||
the predicate actually uses and names the device when the device is what capped
|
||
it; the log line and the HA notification do the same, since they outlive the
|
||
page.
|
||
|
||
**The smaller of the two device bounds, not a per-sign pair.** A per-sign rail
|
||
is tighter and is not wrong, but it is two rails needing two coverage verdicts
|
||
on a status row that carries one number, and the only configuration that
|
||
distinguishes them is refused at startup anyway. The symmetric ceiling can only
|
||
*lower* the rail, which widens term 2 rather than disarming it; term 1 carries
|
||
the no-false-positive argument and is untouched.
|
||
|
||
**A ceiling of 0 W is NOT COVERED, not "armed at 0 W".** A device that can
|
||
command nothing in one direction would make `abs(command) >= 0` true of every
|
||
command including a parked one — term 2 degenerately *true*, collapsing the
|
||
conjunction into the bare `unchanged_s > N` threshold TEL-05 refused for
|
||
false-tripping at this controller's own operating point. `rail_unreachable()`
|
||
returns the reason, `looks_frozen()` stays silent, and the page uses the NOT
|
||
COVERED row the unsupported-transport case already has — a second reason to use
|
||
that surface, not a new mechanism.
|
||
|
||
**AC4, decided rather than assumed: no new `validate.py` rule.** The
|
||
configuration is `max_w` above the device rating, which rule 2 (CFG-01) already
|
||
refuses on the identical predicate, re-run by `run_control()` once the real
|
||
rating is known. A rail-specific rule would fire on a strict subset of what rule
|
||
2 refuses (`0.95 * max_w > dev_max` implies `max_w > dev_max`) and could never
|
||
be the rule that stopped a start. The residue is the state where `hass.limits()`
|
||
answered `(None, None)` and the range checks were skipped — there is nothing to
|
||
refuse there, by construction, which is why the answer is a runtime row. Written
|
||
into validate.py's "deliberately not refused" list, and *pinned* by four checks
|
||
in `test_validate.py` so the decision's premise goes red if rule 2 is ever
|
||
softened back to a warning.
|
||
|
||
**And it was verified by breaking it.** Reverting `rail_w()` to bare `max_w`
|
||
fails nine checks; deleting the NOT COVERED handling fails five; and — the one
|
||
that mattered — shipping the corrected predicate while *not* passing the rating
|
||
from `check_meter_frozen()` originally left the whole suite green, because
|
||
nothing graded the caller. `test_freeze.py` part 5b now drives the real
|
||
`cycle()` on a ±4700 W device and asserts on the notification that leaves the
|
||
process; the unwiring mutation fails three checks there. The peak-guard call
|
||
site's wiring is still ungraded — it sits behind `p1_enabled` and needs a fed
|
||
averager to have any observable consequence — and that gap is named in the file
|
||
rather than implied.
|
||
**A mutation run could grade code that was not there, and the fix is a purge
|
||
rather than a flag (TEST-15).** FCST-03's developer reverted `self.provisional`
|
||
to `here.provisional`, re-ran, and reported a survivor; disassembly showed the
|
||
source read `here` while the running code loaded `self`. CPython validates a
|
||
`.pyc` on (source size, source mtime truncated to whole seconds), and a
|
||
size-preserving edit reverted inside one second moves neither number, so the
|
||
stale bytecode was reused. That does not make past results wrong - it made them
|
||
unverified, and the distinction is the whole card.
|
||
|
||
Reproduced rather than reasoned about. `test_bytecode.py` builds a real package
|
||
whose two return values are the same length, imports it in a real subprocess,
|
||
rewrites it inside the same second and reads back which value ran. Part 1 shows
|
||
the interpreter running `SELF` off a file that says `HERE`.
|
||
|
||
**And it shows the two obvious mitigations do not work.** `python3 -B` and
|
||
`PYTHONDONTWRITEBYTECODE=1` are write-side only: neither stops an EXISTING stale
|
||
`.pyc` being read, and a mutation run always has one because the previous run
|
||
wrote it. The advice already in `test_control.py`'s coverage audit led with `-B`
|
||
and is corrected here - the purge is the half that works, and `-B` earns its
|
||
place by protecting the NEXT edit rather than this one. No `sleep` anywhere:
|
||
one-second mtime resolution is the mechanism, and waiting it out would slow every
|
||
run down while still only covering the edit somebody remembered to slow.
|
||
|
||
`goodwe_controller/tools/mutation_run.sh` is the harness the house adopts: it
|
||
purges, prints a sha256/byte fingerprint of every `app/*.py` it is about to
|
||
grade - so a mutation report can say WHICH source produced it - runs each suite
|
||
under `python3 -B`, refuses a tree with no `test_*.py` (CI-01's rule), and fails
|
||
if a cache appears during the run. It takes the tree as an argument so the infra
|
||
repo's replay harnesses can call it.
|
||
|
||
`MUTATION.md` states the guarantee where a mutation gets written, and every one
|
||
of the 16 suites now points at it from its docstring - a list `test_bytecode.py`
|
||
derives from the tree, so a new suite joining without the pointer is red.
|
||
|
||
CI was already immune and is described that way rather than as a hole being
|
||
closed: `checks.yml` mounts the checkout `-v "$WS:/w:ro"`, and a read-only tree
|
||
cannot take a `__pycache__` at all - measured, and it fails silently, exit
|
||
status 0 - while each job `rm -rf`s its workspace and clones afresh.
|
||
`PYTHONDONTWRITEBYTECODE=1` is added as belt and braces, because the `:ro` is one
|
||
character wide. The shipped add-on was never affected: `Dockerfile` has set
|
||
`PYTHONDONTWRITEBYTECODE=1` since it was written.
|
||
|
||
**The survey of this week's mutation claims is in `MUTATION.md`, scoped by the
|
||
property rather than by re-running everything.** The defect is one-directional -
|
||
stale bytecode turns a red into a green survivor and never the reverse - so every
|
||
"all red, no survivors" result in the window is not at risk, and the exposure is
|
||
the recorded SURVIVORS from source-edit runs made before the purge discipline
|
||
landed on 2026-08-31. Two of them were re-run here under the harness and both
|
||
still stand (TARIFF-01's `record_block()` ignoring its `policy` argument;
|
||
SAFETY-03's `abs()` on the clamp limit). The rest are labelled still-unverified
|
||
rather than quietly assumed or noisily re-run.
|
||
|
||
Out of scope and recorded: the infra repo's `sim/` replay harnesses import the
|
||
add-on and write bytecode into both trees. Anyone mutating a module graded by a
|
||
replay must purge both by hand until that repo adopts the harness.
|
||
|
||
**`release/1.0` went red at midnight on a commit nobody had touched (TEST-17).**
|
||
`Controller.checks()` read the capacity-tariff peak with `self.peak.peak()` - no
|
||
argument - so `tariff.py` resolved the month from `datetime.now()`, while
|
||
`test_tariff.py` section 10 banks its quarter at the fixed fixture
|
||
`2026-08-30`. From 1 September the page was correctly asked for September,
|
||
correctly answered `NO_RECORD`, and the row read *"no completed quarter recorded
|
||
yet"* - a string containing no `"peak"`, which the section's filter required.
|
||
Four checks failed. The suite had been a latent time bomb since TARIFF-01
|
||
merged on 2026-08-30 and every PR that ran it ran in August.
|
||
|
||
Isolated by holding the commit and the image constant and moving only the clock
|
||
under `libfaketime`: green at 2026-08-30, four failures at 2026-09-01, identical
|
||
at `f4914a1`, `04292c4~1` and `04292c4`. The calendar, not a regression.
|
||
|
||
**The product was right and the test was wrong**, so the fix does not change what
|
||
the live page says. `checks()` takes `when: datetime | None = None` and threads
|
||
it into `peak()` and `warning_frac()` - the convention every other peak query in
|
||
`tariff.py` already follows. The single production caller passes nothing and
|
||
still means today. Section 10 now grades at its own fixture's month.
|
||
|
||
Three new checks make the defect detectable rather than seasonal: the same
|
||
controller read at two different reference months must produce two different
|
||
rows, which no clock can satisfy; the later month must read `NO_RECORD` and stay
|
||
distinguishable from both an observed peak and an unreadable store; and calling
|
||
`checks()` with no argument must still mean today. Verified by mutation - a
|
||
`checks()` that drops `when` is now red at every clock tried, where the old
|
||
section went green in August. Full suite green, 15 files, at the real clock and
|
||
at faked clocks in four other months.
|
||
|
||
One thing left alone deliberately: only the UNREADABLE branch prefixes its row
|
||
with `capacity-tariff peak:`. The `NO_RECORD` row carries neither that phrase
|
||
nor the word `peak`, which is what made the original filter brittle and what
|
||
broke the first draft of the new checks too. It is product text with other
|
||
suites reading it, so it is noted here rather than changed.
|
||
|
||
**Thirty days of the real meter, and the acceptance criterion three cards
|
||
deferred is now a number (DATA-01).** TARIFF-01 and SAFETY-07 both merged with
|
||
AC 3 - *"monthly peak held under the soft limit in >= 95 % of intervals over a
|
||
30-day simulation replay"* - honestly unmet, on the belief that only two days of
|
||
capture existed and nothing longer could be produced. That belief came from a
|
||
true statement about the past being read as one about the future: HA's recorder
|
||
still held the whole month. `scenarios/p1-net-30d-2026-08/` is 2026-08-01 to
|
||
2026-08-30, **485,841 samples, 30/30 days read, 0 failed, 4.13 MB gzipped**, in
|
||
the same schema as the two days that were already there.
|
||
|
||
`tools/export_p1_history.py` is the exporter, committed and runnable - the
|
||
2026-08-25 one-off was not, and that is exactly why the data was thought
|
||
unobtainable. It reads day-at-a-time, retries, and **never lets a failed read
|
||
look like an empty hour**: a day whose read fails writes no file, is recorded as
|
||
`"read": "FAILED"`, and the run exits non-zero; the entity is proved to exist
|
||
before any empty day is believed. Nothing is interpolated, so the month's 13
|
||
gaps (1291 s, 99.95 % covered) and its 3 `unavailable` samples stay visible.
|
||
`--verify` re-fetches the two committed days by the same route and reproduces
|
||
them **byte for byte** after decompression.
|
||
|
||
`tools/replay_ac3.py` runs the month. Over 30 days the criterion measures
|
||
**98.89 % uncontrolled and 98.99 % guarded**, so it is met on that reading - and
|
||
the uncontrolled figure is the finding: a month with no controller at all passes
|
||
it. On the other reading of the same sentence it is not met - the guard takes
|
||
August's peak from **5029.8 W to 4107.3 W**, still over the 3500 W soft limit,
|
||
because once a month peak is set `CEILING_MONTH_PEAK` defends that peak rather
|
||
than the cap. Neither number is hidden; both are in that directory's README.
|
||
|
||
The size decision is committed and measured, not absorbed: a `git bundle` of
|
||
the whole history goes from **1.58 MB to 5.28 MB** (+3.70 MB, x3.3), which a
|
||
Home Assistant supervisor clones on every install. Downsampling was not
|
||
available - `meter_max_age_s` is 30 s, so any coarser resample converts real
|
||
coverage into modelled meter-gap and moves the number being measured.
|
||
|
||
The thirty files sit in a SUBDIRECTORY on purpose: `test_tariff.py` part 9 and
|
||
`test_peakguard.py` part 6 glob `scenarios/p1-net-*.jsonl.gz` non-recursively,
|
||
so CI keeps replaying the two small days and does not grow ~50 s per PR. Both
|
||
files' honesty notes are corrected to point at the month.
|
||
|
||
**OPT-04's AC4 is met: twelve months of real EPEX BE day-ahead prices are now in
|
||
the repository, and the strategy has been replayed against them (DATA-02).**
|
||
AC4 asked for "net-positive across 12 months of replayed prices". OPT-04 shipped
|
||
it as NOT MET and replayed a synthetic year instead, because `scenarios/` held
|
||
only P1 meter captures. That was the right call at the time and the wrong
|
||
diagnosis of why: the criterion was being carried alongside a 30-day *meter*
|
||
criterion as though both needed waiting, and EPEX BE day-ahead is **public data,
|
||
not a capture**. It needed a fetch, not a month.
|
||
|
||
`scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json` — **8760 hourly points**,
|
||
2025-09-01 to 2026-09-01 Brussels-local, in §9.3's canonical three-field shape.
|
||
`scenarios/fetch_epex_be.py` produced it and `--verify` re-derives every claim it
|
||
makes about itself without touching the network.
|
||
|
||
**Licensing was checked before anything was committed.** The source is
|
||
Fraunhofer ISE's Energy-Charts API, which publishes the **BE** zone under
|
||
**CC BY 4.0 from Bundesnetzagentur | SMARD.de** and other zones under
|
||
private-use-only terms — so this zone is redistributable with attribution and the
|
||
data is committed rather than left as a script plus a checksum. The fetcher
|
||
**refuses to write a file** whose per-response `license_info` stops saying
|
||
CC BY 4.0. Cross-checked against the upstream: SMARD's own `4996/DE` series
|
||
matched this feed on all 96 quarter-hours of 2026-04-10, maximum difference
|
||
0.00 EUR/MWh.
|
||
|
||
**The figures are raw wholesale and the file says so.** `"all_in": false`, with
|
||
the markup/tax assumption carried in `provenance.all_in_assumption` as *numbers
|
||
to apply* rather than numbers already applied — TARIFF-02's double-counting trap
|
||
in data form. `test_arbitrage.py` section 7B applies it in one visible place and
|
||
**asserts arithmetically** that its two additive terms cancel out of every daily
|
||
spread, so the trade counts depend on the VAT multiplier alone and not on the
|
||
guessed markup. The same replay is run on raw wholesale as well, so how much of
|
||
the answer rests on the guess is reported (110 trades all-in, 88 raw).
|
||
|
||
**DST is handled by construction, not by a special case.** The fetcher iterates
|
||
in UTC and renders through `price.brussels()`, so **2025-10-26 comes out 25
|
||
points and 2026-03-29 comes out 23** — verified by counting per Brussels day
|
||
rather than asserting 24, and by checking that October's 02:00 appears twice at
|
||
`+02:00` and `+01:00` with two different prices while March's 02:00 does not
|
||
appear at all. The window is bounded at *local* midnight: bounding it at 00:00
|
||
UTC produced a 22-hour first day, and a 24-hour day silently missing from the
|
||
end, both of which the point-and-gap accounting caught.
|
||
|
||
**The result, at the shipped default, whatever it is.** At
|
||
`cycle_cost_threshold_eur` **0.10** widened by FCST-03's no-weather **1.5×** to a
|
||
required 0.1500 EUR/kWh, the real year completes **110 round trips** and is
|
||
**net-positive at +24.88 EUR** of benefit net of the cycle cost (2 kWh a trade).
|
||
Unwidened it completes 173. **Nothing was tuned:** the shipped default and the
|
||
2.5/1.5 factors are untouched, and every figure comes out of the same `decide()`.
|
||
|
||
**What the product owner's open decision now has under it.** The four
|
||
independent reports that a static day/night spread of ~0.0840 does not clear
|
||
0.10 **all stand** — a static-tariff site still never trades. What was missing
|
||
was the other half: a *dynamic* site's daily spread clears the widened
|
||
requirement on **121 of 365 days**, median daily all-in spread **0.1130**. The
|
||
threshold at which a median day would clear is **0.0754**, and at that value the
|
||
same year completes 156 trips for +35.42 EUR. Both numbers are reported; neither
|
||
is applied.
|
||
|
||
**And the synthetic year stays, because the real one cannot grade §17.** Not one
|
||
of the 365 real days has a negative *dearest* hour — 372 negative wholesale hours
|
||
and 0 days where the strategy would have sold into one — so "0 discharges into a
|
||
negative-price hour" over real prices is a count of something that could not
|
||
happen, and deleting the guard would not change it. Section 7B asserts that
|
||
degeneracy instead of quietly reporting the zero; section 7's generator is still
|
||
the only thing here that puts a negative price where the strategy would sell.
|
||
|
||
**Size, measured rather than assumed.** Plain JSON: 893 KB in the working tree
|
||
but **74.6 KB as a git object**, against 56.5 KB for a committed `.json.gz`. Git
|
||
already compresses blobs, so gzipping saves 18 KB and costs a reviewer the
|
||
ability to read the provenance block — including the licence claim they are
|
||
meant to check — in the diff. Plain, and `load()` still accepts `.gz` for a
|
||
future dataset that earns it.
|
||
|
||
**Nothing is wired into the add-on.** `generic_dayahead` remains the live fetch
|
||
path; nothing under `app/` imports the script, and the dataset is for replay.
|
||
|
||
**The capacity-peak guard's forced discharge now has an EMS-side energy floor
|
||
(SAFETY-24).** SAFETY-07 can command a discharge to hold a billed quarter down,
|
||
and nothing below it consulted the state of charge — `soc_floor` belongs to the
|
||
maintenance cycle, and freeze.py already recorded that for the grid-following
|
||
loop "the BMS is the sole energy backstop". On the 2026-08-20 replay the guard
|
||
asks for **878 Wh in a day**. Relying on the BMS is not absurd — it is a real
|
||
floor and it works — but it is the pack's protection rather than the operator's,
|
||
and a month of aggressive quarters leaves the battery repeatedly at that cut-out
|
||
with no EMS-side record of why.
|
||
|
||
`decide()` now takes `soc` and `soc_floor`, both **required** and neither
|
||
defaulted. Above the floor nothing changes. At or below it the guard files a
|
||
**0 W** floor instead of a sized one — one-directional, so charging stays blocked
|
||
and no discharge is capped — and reports `floor_limited` with the reason it
|
||
bound. It is not a trip: every stop in §11.2's table still outranks the claim,
|
||
and the floor can never be negative, so §9.1's "discharge and export are never
|
||
capped by the peak guard" survives as a property of the object (swept: 0 negative
|
||
floors across 1296 combinations of load, elapsed, battery, SoC and floor).
|
||
|
||
**Failing to shave is not silent.** A floor-limited cycle raises one notification
|
||
per episode (`goodwe_peak_soc_floor`), distinct from the saturation alarm because
|
||
the two ask the operator for opposite things, and renders its own status row.
|
||
An **unreadable SoC is floor-limited, not a full battery** — a full battery is
|
||
exactly the value that would disarm the floor, and this project has shipped that
|
||
defaulting once before (`| float(100)` in the stage-5 loop).
|
||
|
||
**Verified by replay, not by argument.** The 2026-08-20 capture is driven against
|
||
a modelled pack sized from the day's own measured appetite, so it genuinely
|
||
exhausts: without the floor it reaches the BMS cut-out and sits there for 1227
|
||
guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The
|
||
energy withheld equals the energy between the two floors to within one cycle at
|
||
the rail. The honest half is reported too — the worst quarter goes from 4338 W to
|
||
5030 W, because a floor-limited guard shaves less on purpose.
|
||
|
||
**Two new options, mirrored once.** `peak_soc_floor` (20) and `bms_soc_floor`
|
||
(10), whose defaults live in `peakguard.PEAK_OPTION_DEFAULTS` — TARIFF-02's
|
||
pattern — imported by `validate.py` and `main.py` rather than typed as literals,
|
||
and pinned to `config.yaml` by a check that reads the file. CFG-03 still tracks
|
||
three places. `validate.py` rule 8 refuses `peak_soc_floor <= bms_soc_floor` at
|
||
startup, in CFG-01/MAINT-04's manner. `maintenance_soc_floor` is deliberately
|
||
**not** reused and deliberately **not** renamed: it is a drain target, not a
|
||
spend limit, and the rename is recorded as a decision to revisit if a third
|
||
floor ever appears.
|
||
|
||
**Price arbitrage - the first strategy that trades on a price (OPT-04).**
|
||
Four cards built the price stack and nothing traded on it. `app/arbitrage.py`
|
||
is §10.1's priority 3: charge in the cheapest published hour, discharge in the
|
||
dearest, **and only when the spread covers `cycle_cost_threshold_eur`**. It is
|
||
off by default (`arbitrage_enabled: false`) - it is the first strategy in this
|
||
add-on that commands the battery against the meter on purpose.
|
||
|
||
**It consumes the four cards rather than re-deciding anything.** Whether a
|
||
trade is permitted at all, on which two hours, and against which widened
|
||
threshold is `TariffPlan.arbitrage()`'s answer (TARIFF-03), and that answer
|
||
cannot be obtained without first stating whether the peak guard is holding. So
|
||
§9.1's "give peak shaving priority over arbitrage" is honoured
|
||
**structurally**: there is no code path to a tradeable spread that skips the
|
||
question. And because the claim is filed at `P_ARBITRAGE` (30) while peakguard
|
||
files its floor at `P_PEAK` (50), the arbiter clamps an arbitrage charge even
|
||
if that interlock were bypassed. Two independent mechanisms, neither
|
||
re-implemented here.
|
||
|
||
**A guard that never ran is not a guard that is clear.** On `meter_source: off`
|
||
there is no telegram stream, so the peak guard is never evaluated. That third
|
||
state is carried as `None` and it **refuses**: charging at the rail creates the
|
||
billed quantity while nothing measures the quarter it lands in. `validate.py`
|
||
refuses that configuration at startup too, along with arbitrage armed on a site
|
||
with no price source at all, and a SoC floor at or above the ceiling.
|
||
|
||
**§17: never export into a negative price.** The guard is on the hour being
|
||
sold into, not on the spread - a day priced -0.30 ... -0.05 has a 0.25 EUR/kWh
|
||
spread that clears any shipped threshold and a dearest hour that is still
|
||
negative. Buying in a negative hour stays allowed; being paid to charge is the
|
||
right trade.
|
||
|
||
**Prices are fetched in the background, never on the tick.** `resolve_tariff()`
|
||
may open an HTTPS connection, and the control tick is what feeds the ESP32
|
||
heartbeat - a supplier's TLS timeout there is a supplier able to push the
|
||
add-on towards layer 1's 30 s veto. A separate task refreshes every 30 minutes;
|
||
the tick reads an attribute and an age bound, and a plan whose refresh has
|
||
stopped answering retires rather than standing as if it were current.
|
||
|
||
**What is honestly not measured.** The acceptance criterion asked for
|
||
net-positivity across 12 months of *replayed* prices. This repository holds no
|
||
price history - `scenarios/` is P1 meter data - so that criterion is **not met,
|
||
and is not restated**. What `test_arbitrage.py` section 7 does instead is
|
||
replay a synthetic year and assert the property that does not need real prices:
|
||
every executed round trip cleared its own widened requirement, on all of them,
|
||
across a year containing 522 hours priced below zero and zero discharges into
|
||
one. And at the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night
|
||
site **does not trade at all** - reported, not tuned. That number, and the
|
||
widening factors beside it, are one product decision and it is open.
|
||
|
||
**Provisional prices, and what may not rest on one (FCST-03).**
|
||
Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says
|
||
the unknown hours use yesterday's profile and are flagged `provisional: true` —
|
||
and **no irreversible action may be taken on a provisional price.**
|
||
`app/price_forecast.py` does the fill, and the whole module is shaped by one
|
||
defect: a carried-forward point and a published one are the same three fields
|
||
and a bool, so the natural line `PricePoint(..., provisional=y.provisional)`
|
||
copies yesterday's `False` onto today's guess and produces stale data presented
|
||
as current. That is the meter-side SAFETY-18 failure one layer up — the reading
|
||
arrives on time, looks fresh, and is wrong.
|
||
|
||
**So the flag is never passed through.** Carried points are built by a
|
||
constructor that has no `provisional` parameter at all (the literal `True` lives
|
||
in one place, and there is nothing for a caller to forget or copy); published
|
||
points are passed through **by identity**, so there is no copy that could lose a
|
||
flag; and an audit re-derives the expected flag for every emitted point from the
|
||
set of hours the feed actually published and *raises* rather than repairing one.
|
||
A published point may itself be provisional — `eneco_eddy`'s current-quarter
|
||
imbalance print is — so the audit's rule for a published hour is "whatever the
|
||
feed said", never "False".
|
||
|
||
**The fill keys on the Brussels wall clock, not on `t − 24 h`.** A 23-hour day
|
||
has no 02:00 and a 25-hour day has two, so a fixed 24-hour subtraction shifts
|
||
every carried hour by one across a transition and misprices a whole day with
|
||
nothing to show for it. Re-running the fill after a late publication replaces
|
||
the carried hours with the real print: the number and the flag move together
|
||
because the series is rebuilt, never patched.
|
||
|
||
**Arbitrage now drops provisional hours instead of refusing the whole day.**
|
||
The refusal that read "the plan is provisional" would have switched arbitrage off
|
||
every morning on every dynamic site once carry-forward filling landed — a
|
||
part-firm series is the *normal* state before the print. What §8.3 forbids is
|
||
acting **on** a provisional price, so the provisional hours are dropped and the
|
||
spread, the cheapest hour and the dearest hour are computed over the published
|
||
ones only; fewer than two firm hours is still a refusal, which is where the
|
||
§11.4 fallback (wholly provisional) lands. The permitting variant now refuses to
|
||
be *constructed* around a provisional point at all. The cheap-window flag became
|
||
per-hour for the same reason, so a site's maintenance charge floor does not go
|
||
dark every morning.
|
||
|
||
**Real-time / imbalance products get measurably wider thresholds.** One widening
|
||
path, driven by a set of active uncertainty sources: the product's own (§9.2 row
|
||
2, derived from the declared `PriceProduct` and never from a plugin id) and
|
||
§11.4's "no weather data" row, which the spec widens by a different trigger and
|
||
which is the same mechanism. The combination rule is `max`, not a product — two
|
||
independent doubts must not compound into a threshold nothing can clear. The
|
||
caller supplies the base round-trip cost threshold, so OPT-04 still owns what
|
||
the number *is*; this owns only how much wider it gets and why, and the allowed
|
||
verdict reports the threshold it cleared.
|
||
|
||
**An irreversible action is a value, not a judgement call at each call site.**
|
||
`Irreversible` carries the hours an action commits — the maintenance drain's
|
||
window is derived from `maintenance.DRAIN_TIMEOUT_MIN` rather than typed twice —
|
||
and the gate looks at exactly those hours, blocking on a provisional one, on an
|
||
hour the series does not cover, and on no price data at all: three different
|
||
reasons, none of them an answer. ⚠️ It is the entry point for a start being
|
||
*justified by a price*; the calendar path main.py runs today is untouched,
|
||
because a gate that blocked on `no_data` would stop the monthly cycle on every
|
||
default install — §12's opening sentence.
|
||
|
||
**Zero new options.** No `config.yaml` key, no new defaults dict, no `o.get(k,
|
||
X)` in main.py: the module reads no options at all, so CFG-03 did not widen.
|
||
|
||
**The grid-following loop is now capped against the month's billed capacity peak
|
||
(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the
|
||
consumer it was built for. Every cycle the guard projects the open quarter,
|
||
compares it to a ceiling and files ONE arbiter claim: a lower bound on the
|
||
setpoint at `P_PEAK`. That shape is both behaviours §10.1 asks for — a hard clamp
|
||
on charging and pre-emptive discharge — and it cannot cap discharge or export,
|
||
because a floor cannot. `excess = 900 × (projected − ceiling) ÷ remaining`, and
|
||
the floor is the MEASURED battery power plus that: a floor built on the standing
|
||
command winds itself to the rail whenever the inverter fails to follow, and one
|
||
built on delivered power cannot.
|
||
|
||
**It trips where SAFETY-18 only notifies, and the captures decided that.** On the
|
||
healthy reference day it engages on zero of 95 quarters; the action is
|
||
level-triggered, unlatched and re-derived every cycle, so a false engagement costs
|
||
one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the
|
||
billed quarter goes from **5029.8 W — above the inverter's own 5000 W rating — to
|
||
3485 W**, under the soft limit and without saturating. Quarters under the limit
|
||
across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is
|
||
still TARIFF-01's open item and this does not claim it.
|
||
|
||
**Blindness forbids importing, and it is checked before the projection is
|
||
graded.** A fresh averager projects a confident 0 W, which is under every ceiling
|
||
there is; a meter gap longer than `meter_max_age_s` makes the projection read LOW;
|
||
and a frozen meter reading export projects 0 W at full coverage. All three become
|
||
a floor of 0 W, which is one-directional and cannot cost pack energy. That is the
|
||
backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter
|
||
and commands nothing. The frozen term is `freeze.looks_frozen()` itself, evaluated
|
||
on this cycle's standing command, not a second copy of the conjunction.
|
||
|
||
**The ceiling is `peak_cap_w`, raised only by an OBSERVED reading.** `NO_RECORD`
|
||
and `NOT_OBSERVED` both leave it at the cap, so an unreadable peak store makes the
|
||
guard STRICTER. The gate is the reading's STATE, not whether a number happens to
|
||
be attached to it: today a `peak_w is not None` test behaves identically, and
|
||
depending on that coincidence is how a future last-known-peak field would silently
|
||
raise the ceiling off a read that failed. `headroom_w()` is deliberately not
|
||
consumed — `cap − peak` turns a zero peak into FULL freedom.
|
||
|
||
**Two defects found building it, both in the interaction rather than the
|
||
arithmetic.** A LIMIT does not out-vote another LIMIT — `arbiter.resolve()` applies
|
||
every limit at or above the winner's priority and takes the most restrictive — so
|
||
the peak floor (`lo ≥ 0`) and the maintenance charge-only shaping (`hi ≤ 0`)
|
||
resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it
|
||
should shave. Fixed by suppression rather than priority: main.py drops the
|
||
maintenance shaping while the guard is engaged, which is what that branch already
|
||
did for the optional `peak_forecast_entity` — a key that is empty on a default
|
||
install, so until now the suspension could not fire on the site that needed it.
|
||
And thresholding `excess > 0` released the claim the instant it started working,
|
||
producing seventeen engage/release cycles in one 900 s quarter (§10.2's
|
||
oscillation) and leaving that quarter 49 W over; the threshold is now the floor
|
||
itself, which is the same arithmetic read as a level rather than as a switch.
|
||
|
||
**`PeakTracker.project()` no longer reads a private name with a default.**
|
||
`getattr(averager, "_last_net", 0.0)` failed quiet AND in the disarming direction:
|
||
a rename credits 0 W held, the projection reads low, and the money guard does not
|
||
engage. `QuarterAverager.held_net_w` is now a documented public property, read
|
||
without a default, and `Projection` carries it. TEST-13 item 4.
|
||
|
||
**No new option, and no fourth defaults site.** The ceiling is `peak_cap_w`, the
|
||
coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`,
|
||
and the achievable discharge comes from `max_w` and the device rating.
|
||
`config.yaml` and `validate.py` are untouched; §14's `capacity_peak_limit_kw`
|
||
stays declined as the kW/W mismatch trap it is. On `meter_source: off` there are
|
||
no telegrams, so the guard does not run at all and the status page says NOT
|
||
COVERED — running it there would have blinded it permanently and stopped every
|
||
legacy install from charging, which is what the first cut did until
|
||
`test_clamp.py`'s export case caught it.
|
||
|
||
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
|
||
§9.2 puts a large share of the Belgian installed base on a static profile, and
|
||
an EMS that only works under dynamic pricing is an EMS for a minority. A new
|
||
`static_profile` option describes a two-period day/night tariff — or a single
|
||
fixed price — as JSON, and `app/static_tariff.py` turns it into the same
|
||
canonical `{start, eur_per_kwh, provisional}` series the fetched providers emit,
|
||
so nothing downstream learns that a tariff can be static.
|
||
|
||
**It is not a sixth provider plugin, and the reason is mechanical.**
|
||
`PriceProvider.capabilities()` is a classmethod, so a plugin's `PriceProduct` is
|
||
fixed when the class is written — but §9.2 makes STATIC and FIXED two different
|
||
products with two different required behaviours, and which one a site is on is a
|
||
property of the operator's data, not of any class. `StaticProfile.product`
|
||
derives it from the number of distinct prices instead. The second reason: the
|
||
plugin framework exists to stop a *fetched* figure being marked up twice, and a
|
||
number the operator typed as all-in has no publisher to disagree with.
|
||
|
||
**So the profile sits where §11.4 already says it does — under the providers, as
|
||
the fallback.** The configured-product path and the feed-is-down path are the
|
||
same generator differing in one flag: `provisional`. A static-tariff site runs
|
||
that path every day, so the degradation path for a dynamic site cannot rot
|
||
unused.
|
||
|
||
**"No arbitrage" is a type, not a boolean somebody has to remember to check.**
|
||
`TariffPlan.arbitrage()` returns a variant: the *allowed* arm carries the
|
||
spread and the cheapest and dearest hours, and the *refused* arm carries a
|
||
reason and nothing else — `.spread_eur_kwh` on a refusal raises rather than
|
||
answering 0.0. An optimiser cannot compute a trade from a refusal because it
|
||
cannot get the inputs out of one. Four refusals, all derived: the peak guard is
|
||
holding, there is no price data, the plan is provisional, or **the series is
|
||
flat**. That last one is AC2 — a fully-fixed tariff disables arbitrage by
|
||
arithmetic rather than by matching on an enum, and a dynamic feed that happens
|
||
to print one price all day is refused for the same true reason.
|
||
|
||
**Peak shaving outranks arbitrage, expressed so OPT-04 cannot skip it.**
|
||
`arbitrage()` takes `peak_guard_active` as a required keyword argument, so the
|
||
spread is not obtainable without first answering whether §10.1's priority 2 is
|
||
holding. §9.1's capacity tariff is a network cost charged whatever the energy
|
||
product is.
|
||
|
||
**Time-window shifting reaches the one shiftable load that exists.** main.py's
|
||
cheap-window flag — previously fed only by the `price_now_entity` /
|
||
`price_avg_entity` pair — now falls back to the static profile, and the Web UI
|
||
gained a **Tariff** row naming the window, the price, when it next changes and
|
||
why it is or is not cheap. The rule is *strictly* below the seven-day mean:
|
||
under a flat tariff `<=` would call every hour cheap and a fixed-tariff site
|
||
would top up from the grid at any hour, which is the opposite of
|
||
self-consumption only. Seven days rather than 48 hours because a 48 h mean taken
|
||
on a Friday is two-thirds weekend, and the same Tuesday 09:00 would come out
|
||
cheap or dear depending on when you asked.
|
||
|
||
**A profile must tile every day type exactly once, checked at startup.** A gap
|
||
is refused with the uncovered range named rather than filled in — an invented
|
||
price is one an optimiser would trade on — and an overlap is refused because
|
||
otherwise one of two prices wins silently on list order. The trap this catches
|
||
in practice: `22:00–07:00 mon-fri` does **not** cover Saturday morning, because
|
||
a window applies on the day the clock reads.
|
||
|
||
**validate.py rule 7's stated reason expired, exactly as TARIFF-02 predicted.**
|
||
That ticket left a tripwire in `test_price.py` section 12 asserting that nothing
|
||
in `app/` consumed `load_prices()`, with instructions to fix the refusal's
|
||
wording in the same commit when it went red. It went red naming
|
||
`static_tariff.py`. The message now says what is true instead — the series *is*
|
||
consumed, but main.py acts on the static side of it and not on a fetched one —
|
||
and the tripwire is re-aimed rather than removed: the consumer set is pinned, so
|
||
a second consumer (OPT-04) goes red again, and the expired phrase is asserted
|
||
gone so a bad merge cannot restore it. Rule 7 also grew a second refusal:
|
||
`static_profile` alongside the Home Assistant price pair is the same
|
||
silent-loser conflict, and a live one.
|
||
|
||
**No new place for an option default (CFG-03 is not widened).**
|
||
`static_profile` was added to `app/price.py`'s `PRICE_OPTION_DEFAULTS` — the one
|
||
in-code mirror TARIFF-02 created and pinned to `config.yaml` by a check — rather
|
||
than to a defaults dict in the new module, which has none. main.py grew no
|
||
`o.get("static_profile", …)` fallback. That is TARIFF-02's pattern, stated
|
||
here so a reviewer does not have to infer it.
|
||
|
||
**Measured, on the data that exists.** `test_static_tariff.py` replays the two
|
||
committed days of this house's real P1 meter (`scenarios/p1-net-2026-08-{20,23}`)
|
||
priced under a Belgian day/night profile. 2026-08-20 is a Thursday: 15.220 kWh
|
||
imported, 9.606 in the day window and 5.614 in the night one, EUR 4.7185 under
|
||
day/night against EUR 4.6725 flat, and shifting the day-window energy into the
|
||
night window saves EUR 0.8069 — which is 9.606 kWh x the 0.0840 spread, derived
|
||
twice and required to agree. 2026-08-23 is a Sunday: 0.728 kWh, **all** of it
|
||
night-priced, which is the weekend row proving itself on real data. §17 names
|
||
six Belgian scenarios and a static-profile case is not among them; this is the
|
||
seventh, and it is two captured days rather than a season — the file says so
|
||
rather than implying an annual figure.
|
||
|
||
Also worth knowing before OPT-04: on realistic Belgian day/night numbers the
|
||
whole-tariff spread is about 0.08 EUR/kWh, **below** the shipped
|
||
`cycle_cost_threshold_eur` of 0.10. A correct optimiser will decline to cycle
|
||
for the tariff alone on such a site. That is a product finding, not a defect.
|
||
|
||
**Price provider plugins, and one canonical all-in price format (TARIFF-02).**
|
||
`app/price.py` ships five providers - `generic_dayahead` (wholesale EPEX BE),
|
||
`engie_dynamic`, `totalenergies_dynamic`, `eneco_eddy` and the offline
|
||
`manual_csv` - all emitting exactly `{"prices": [{"start", "eur_per_kwh",
|
||
"provisional"}]}`, tz-aware at the Brussels offset, EUR/kWh, all-in. **Nothing
|
||
consumes it yet**: this is the seam, landed first, so that the arbitrage work
|
||
that follows never learns a supplier's name.
|
||
|
||
**Double-counting the markup is refused rather than discouraged, given a
|
||
correct declaration.** A plugin does not return a price; it returns the figure
|
||
its source published and declares which of {energy, markup, taxes} that figure
|
||
contains. The framework adds the complement, once. Engie and TotalEnergies
|
||
publish a retail figure with their margin already inside; adding the configured
|
||
3 c/kWh on top would make every future arbitrage decision wrong by that amount
|
||
with nothing reporting it.
|
||
|
||
Two mechanisms, because one was not enough. `PluginOptions` raises on the
|
||
framework-owned keys - and review of PR #16 then wrote a double-counting plugin
|
||
three more ways, one of them touching nothing but `PRICE_OPTION_DEFAULTS`, a
|
||
public dict in the module every plugin already imports. So `register()` now also
|
||
**refuses at import any plugin whose own code mentions a framework key**
|
||
(`code_leaks()`, docstrings stripped via `ast` so a plugin's mandated
|
||
`SOURCES:` line does not false-positive). Honest wholesale baseline 0.188
|
||
EUR/kWh; every attack landed 0.219.
|
||
|
||
**The guarantee's precise shape**, stated because an overclaim is worse than no
|
||
claim: the framework adds the complement exactly once *given a correct `sources`
|
||
declaration*. A plugin that declares `{energy}` while its feed is really all-in
|
||
still comes out one markup high while touching no framework name, and no code
|
||
can catch that - the declaration is the plugin's unverifiable claim about
|
||
somebody else's tariff. What is mechanised is reachability; what stays human is
|
||
truthfulness.
|
||
|
||
**The product type is declared, not inferred from the plugin's name.**
|
||
[⚠️ **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.yaml` and
|
||
becomes 2000 on upgrade. Non-event, nothing to do.
|
||
- **A key the operator did set** persists at 2500 and the add-on refuses to
|
||
start until it is changed. Note this includes anyone who used the
|
||
Configuration page's YAML editor, which writes the whole options block back —
|
||
so "I never touched that field" is not a guarantee the old value is gone.
|
||
|
||
⚠️ **This is reasoned from the Supervisor's documented merge behaviour, not
|
||
measured on the live install.** The reference install's stored options could not
|
||
be read (`/api/hassio/` refuses a long-lived token) and probing it was out of
|
||
scope — it regulates a real battery. It runs `max_w: 5000` and is unaffected
|
||
either way.
|
||
|
||
⚠️ **Known, deliberately left, and worth a follow-up:** `main.py` and
|
||
`maintenance.py` still fall back to **2500** when the key is absent from the
|
||
options dict, while `config.yaml` and `validate.py` now say 2000. Unreachable on
|
||
a Supervisor install (every key is always supplied) and reachable only from
|
||
tests/`sim`. The real fix is one source of truth for option defaults, which is
|
||
bigger than this ticket and touches files another ticket is in.
|
||
|
||
## 0.5.2
|
||
|
||
**A meter frozen at a wrong-but-fresh value now raises an alarm. It still is not
|
||
stopped.** (SAFETY-18)
|
||
|
||
A meter that answers `200 OK` with a stale document forever is *arriving* on
|
||
every poll, so every freshness mechanism in the stack reads it as healthy:
|
||
`meter_max_age_s` is reset by each response, the firmware watchdog is fed by the
|
||
heartbeat and by a sample age that never climbs, and the arbiter contributes only
|
||
±`max_w`. The loop integrates a constant error and rails — replaying the real
|
||
`compute()` at 15 s cycles, in 150/60/30 s at `max_w: 2000` for a meter frozen at
|
||
350/1000/≥3000 W, and 360/135/75 s at `max_w: 5000`. **The live install runs
|
||
`max_w: 5000`**, which is both the schema maximum and the device rating.
|
||
|
||
The new detector (`app/freeze.py`) is a **conjunction**, not the bare
|
||
`unchanged_s` threshold TEL-05 measured and rejected:
|
||
|
||
unchanged_s ≥ meter_freeze_s AND |command| within 5 % of max_w
|
||
|
||
Both terms already existed and were already on the status page. They fail for
|
||
independent reasons: at the converged point the command is nowhere near the rail,
|
||
and at the rail against a real load the kWh registers move within seconds. At the
|
||
shipped 300 s there is also a structural relation, not just a margin — the
|
||
slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which
|
||
is inside the 15 W deadband, so the command there is parked at 0 W and the two
|
||
halves cannot both be true.
|
||
|
||
**Notification only, deliberately.** An HA persistent notification (fixed
|
||
`notification_id`, so a repeat replaces rather than stacks), an `ERROR` log line,
|
||
a UI event and a red status banner. No claim, no latch, no 0 W: a false trip
|
||
parks the battery and the operator's next move is to switch the guard off; a
|
||
false notification costs an alert. The trip is a separate decision that needs
|
||
data from a real install.
|
||
|
||
- New option `meter_freeze_s` (default 300, range 60–3600, no "off").
|
||
- **Only `meter_source: homewizard_local` is covered** — it is the only transport
|
||
that sees the meter's own document. Everywhere else the status page says
|
||
**NOT COVERED** rather than "armed".
|
||
- `DOCS.md` now states the **capacity-tariff exposure** this does *not* close:
|
||
`peak_cap_w` caps maintenance charging and nothing caps the loop, so a lying
|
||
meter can drive a 5 kW unattended import, and ≥15 minutes of that sets the
|
||
month's billed peak (measured: one 4998 W quarter moved a monthly peak from
|
||
4832 → 5013 W). That cap is SAFETY-07.
|
||
- `test_freeze.py` grades it in both directions: silent over 40 minutes of the
|
||
live house's real converged capture (committed as
|
||
`scenarios/p1-age-2026-08-26.jsonl.gz`), silent over an hour of TEL-05's
|
||
low-power case where a bare threshold is shown to fire, and alarming on a
|
||
genuinely frozen meter driven over real HTTP through the real
|
||
`HomeWizardLocalSource` — against the rig's own `hwsim` `freeze` fault when the
|
||
infra repo is reachable, an equivalent stub otherwise. Its negative twin is the
|
||
load-bearing one: the same railed command against a meter that is still moving
|
||
must stay silent at the same threshold.
|
||
|
||
## 0.5.1
|
||
|
||
**SAFETY-02's latch now survives an add-on restart, which is the difference
|
||
between a latch and a pause.** `auto` was persisted and `sign_wd.tripped` was
|
||
not, so reconstructing the `Controller` against the same store — exactly what a
|
||
container restart does — brought it back **armed, into the same fault, with no
|
||
operator involved**: `tripped=False`, banner back to `NOT READY`, and it resumed
|
||
commanding (measured: −1000 W, −2000 W) before re-tripping 180 s later. Under a
|
||
Supervisor that restarts a crashed add-on that is a loop of three-minute
|
||
wrong-direction bursts — and "restart the add-on" is the first thing an operator
|
||
looking at a `FAILSAFE` banner and a 0 W inverter will try.
|
||
|
||
`sign_tripped` and `sign_reason` are now `Store` keys beside the maintenance
|
||
state, written the moment the trip happens (reason first, flag second, so a crash
|
||
between the two can never persist a latch with no explanation) and restored in
|
||
`Controller.__init__`. Cleared only by the operator's start. `DOCS.md` said "it
|
||
does not clear itself"; that was false across a restart and now is not.
|
||
|
||
Also in this release, all of it review follow-up rather than new behaviour:
|
||
|
||
- **The arming gate is tested against the real arbiter.** It was the decision the
|
||
whole ticket turns on and it was asserted in three comment blocks and verified
|
||
by nothing — every case passed `following` in as a literal. `test_watchdog.py`
|
||
now derives it from `resolve()` across twelve claim configurations, including
|
||
the redirect case `winner == "loop"` alone would have got wrong and the `lo`
|
||
limit that raises magnitude without flipping sign.
|
||
- **The MQTT `status: "failsafe"` value is tested.** The check that claimed to
|
||
cover it restated the assertion three lines above it and never touched the
|
||
payload.
|
||
- **A forward hazard written down where it will be read, and made to bite.**
|
||
`winner == "loop"` disarms the watchdog under any higher-priority SET claim.
|
||
Today only maintenance and the safety stops emit one and neither follows the
|
||
meter; a peak-shaving strategy at `P_PEAK` **does**, and would blind the
|
||
watchdog for every peak event.
|
||
|
||
The gate is now `watchdog.loop_is_following()`, called by both `main.py` and
|
||
the test, for the same reason `disagrees()` was extracted: the test held a
|
||
*transcription* of the shipped expression, so editing the shipped gate alone to
|
||
`winner in ("loop", "peak")` left every suite green while the watchdog went
|
||
blind. Two copies, and the one that drifts is the one doing the grading.
|
||
|
||
⚠️ Extracting it was not sufficient on its own. The realistic P_PEAK row —
|
||
peak shaving asking for *more* than the loop — still could not detect the
|
||
widened gate, because the magnitude term rejects it regardless and masks the
|
||
winner term. A second row with peak asking for *less* isolates `winner` as the
|
||
only deciding term, and that is the one that turns red.
|
||
- **The worked example described a configuration that does not ship.** At the
|
||
defaults `maintenance_charge_w` (2500) exceeds `max_w` (2000), so that claim is
|
||
an arbiter *contradiction* resolving to 0 W, not a −2500 W charge. The example
|
||
is now written at 1500 W. (The contradiction is a real pre-existing bug —
|
||
a default cheap-window maintenance charge silently never happens — filed
|
||
separately, deliberately not fixed here.)
|
||
- **`disagrees()` is public**, so `sim/replay_sign.py` stops carrying its own copy
|
||
of the predicate it grades.
|
||
- `held_s` takes the clock instead of reading one, restoring the pure-function
|
||
property the module advertises.
|
||
- **The `sign_reason`-before-`sign_tripped` write ordering is enforced, not just
|
||
documented.** Swapping the two lines used to pass every check. A store that
|
||
fails the second write now proves the property the ordering exists for: what
|
||
survives a crash mid-trip is a reason with no latch, never a latch with no
|
||
explanation.
|
||
- Measured figures corrected throughout: 93.4 % / 4212 of 17280 (was 92.9 % /
|
||
4127 of 16349), per-cluster 0.3 / 0.9 / 10.5 / 0.0 %.
|
||
|
||
## 0.5.0
|
||
|
||
**SAFETY-02 — the sign-disagreement watchdog.** The add-on now watches whether
|
||
the setpoint it is emitting and the grid figure it is regulating on point the
|
||
same way. If they oppose each other, both over `sign_disagree_w` (300 W), for
|
||
`sign_disagree_s` (180 s) **continuously**, it files a failsafe claim at
|
||
`P_SAFETY`, commands **0 W**, and latches there until an operator starts control
|
||
again. New module `app/watchdog.py`; new options `sign_disagree_w` and
|
||
`sign_disagree_s`.
|
||
|
||
This is the gap the existing failsafes leave open. The stale-input watchdog asks
|
||
whether the meter is still talking, the saturation freeze asks whether the
|
||
inverter can follow, the output clamp asks whether the number is too big — a
|
||
loop can pass all three while charging the battery out of the grid, because the
|
||
only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly
|
||
that four times in one day, for up to 77 minutes at a stretch, and reached a
|
||
commanded −14 768 W against a ±5000 W inverter.
|
||
|
||
Validated against that day. `sim/replay_sign.py` in the infra repo drives this
|
||
module over the committed RS485 captures of both 2026-08-20 (the four episodes)
|
||
and 2026-08-23 (healthy): **18 trips on the fault day, every one inside a window
|
||
`NOTES.md` §4.7 accounts for, and 0 on the healthy day.** The four episodes do
|
||
share one signature rather than merely resembling each other — sign agreement
|
||
runs 0.3 / 0.9 / 10.5 / 0.0 % inside them against 93.4 % on the healthy day.
|
||
|
||
Three decisions worth knowing about:
|
||
|
||
- **It is disarmed whenever the grid-following loop is not what is driving.**
|
||
Sign agreement is a property of a loop that follows the meter. A maintenance
|
||
grid charge opposes the meter *by design* — 2500 W of charge into a house
|
||
drawing 500 W is +3000 W on the meter against a −2500 W command, for hours —
|
||
and armed unconditionally this would trip on every maintenance cycle. The gate
|
||
tests the emitted number, not the winning claim's name: a limit claim can let
|
||
the loop "win" and still replace its value with one pointing the other way.
|
||
- **The trip latches.** Commanding 0 W removes the command side of the
|
||
comparison, so the disagreement always clears the moment the trip works.
|
||
Un-latched, this would trip, clear, and hand the actuator back to the loop that
|
||
was driving the wrong way, every three minutes forever.
|
||
- **`sign_disagree_w` at or above `max_w` is refused at startup** (validator rule
|
||
5). The arbiter never lets a command exceed `max_w`, so such a threshold makes
|
||
the watchdog incapable of firing while the status page still reports it armed.
|
||
A disarmed guard that advertises itself as armed is the failure mode this
|
||
project keeps refusing to ship.
|
||
|
||
What it does **not** catch, stated so nobody assumes otherwise: an inverter that
|
||
acts opposite to what it was told moves the meter *further* the same way, so
|
||
command and meter agree while everything runs away. That is the saturation
|
||
freeze's job, which compares the command against the battery's actual power.
|
||
|
||
`test_validate.py`'s "the smallest sane max_w still starts" case now names a
|
||
reachable sign threshold, because a 1 W ceiling genuinely does disarm the 300 W
|
||
default and is now correctly refused.
|
||
|
||
## 0.4.0
|
||
|
||
**CFG-01.** The add-on now refuses to start on a configuration it cannot honour,
|
||
instead of logging a line and carrying on. `validate_startup_config()` runs in
|
||
`amain()` before the broker, the web server or any task exists, so a refusal
|
||
cannot leave half a controller running behind a UI.
|
||
|
||
It refuses only where continuing produces one of two outcomes: **(a)** a stated
|
||
limit is untrue - the operator configured a bound and a lower layer silently
|
||
enforces a different one - or **(b)** the add-on is inert and indistinguishable
|
||
from healthy. (b) is not a special case of (a): with no `setpoint_entity`, layer
|
||
1 pinning the inverter at 0 W is the failsafe *working*. The system does not
|
||
become unsafe, it becomes a convincing impression of a working controller.
|
||
|
||
Four things qualify:
|
||
|
||
- **`max_w` above (or below) the setpoint device's own rating.** This is the one
|
||
that changed behaviour. It used to warn `the device wins` and run anyway, with
|
||
`write_setpoint()` silently clamping, so the configured ceiling was never the
|
||
real one and the only evidence had scrolled off the log. The refusal names the
|
||
configured value, the device's value, and which wins.
|
||
- **No `setpoint_entity`.** A controller with no actuator: every write is a
|
||
no-op, the device rating is never read, and the ESP32 heartbeat that holds off
|
||
the layer-1 failsafe never happens. It ran, looked healthy, and moved nothing.
|
||
- **A `meter_source` that can never produce a reading** - missing `p1_host`,
|
||
missing `p1_net_entity`, a blank MQTT topic, a phase list disagreeing with
|
||
`meter_phases`, an unknown source name, or a `meter_poll_s` at or past
|
||
`meter_max_age_s`. These were already caught, but they only *disabled
|
||
ingestion*, which leaves the grid reading None forever: permanent "inputs
|
||
missing", 0 W, and a status page indistinguishable from a healthy meter nobody
|
||
has sent anything to yet.
|
||
- **`meter_max_age_s` above the firmware's ingest veto.** The schema accepts
|
||
`int(5,300)`; the ESP32's threshold is a compile-time `max_age_s: "30"` and it
|
||
trips layer 1 at `p1_age_s >= 30`. Set 120 and the add-on treats a 119 s
|
||
reading as fresh and keeps commanding, while the firmware has already vetoed
|
||
and is holding the inverter at 0 W - healthy meter, running controller,
|
||
nothing moving, and no line anywhere saying the number you set was overruled.
|
||
Structurally the same fault as the `max_w` case. Only *above* the veto is
|
||
refused: below it the add-on gives up first, which is fail-safe and visible on
|
||
its own status page. Mirrored as `validate.FIRMWARE_INGEST_VETO_S` - it cannot
|
||
be imported from a compile-time YAML substitution, so it carries a note about
|
||
the one direction in which it can go stale.
|
||
|
||
`max_w` at or below zero is refused too. Schema-blocked on a real install, but
|
||
`validate_startup_config()` is now also the simulation suite's entry point, and
|
||
a non-positive `max_w` inverts the arbiter's `supervised` bounds into a
|
||
contradiction that resolves to 0 W - i.e. it would read as a safety trip rather
|
||
than as the typo it is.
|
||
|
||
The predicates for the meter group still live in `p1.validate_meter_options()`,
|
||
which `build_source()` also calls - one definition, two callers, not two
|
||
competing validators. Two transports gained a check they never had: `ha_dsmr`
|
||
with only one of its two entities, and `mqtt_p1` with a blank topic, both of
|
||
which previously built a transport that could never read.
|
||
|
||
Deliberately **not** refused, because it is untidy rather than unsafe:
|
||
`integrator_max_w` above `max_w` (the arbiter still bounds the wire to ±max_w,
|
||
so the cost is unwind latency, not amperage - warned), a missing `soc_entity` /
|
||
`batt_entity` (fail-safe, and already a red NOT READY banner naming it), a
|
||
`setpoint_entity` that has not appeared on the Core API yet (ESPHome is routinely
|
||
late; refusing would make a slow boot need a human - re-checked once the real
|
||
rating arrives), and a thin-but-nonzero `meter_poll_s` margin.
|
||
|
||
A refusal is legible as a refusal: a banner that says in words that this is a
|
||
configuration problem and nothing is broken, the numbered problems, no traceback,
|
||
and exit status 78 (`EX_CONFIG`).
|
||
|
||
**SAFETY-12.** The control loop now evaluates on a clock as well as on a change.
|
||
`run_control()` ran `cycle()` only when the meter value differed from the
|
||
previous poll, so a meter that repeated a value ran **no control cycles at
|
||
all** - while the heartbeat went on re-emitting the last command and the
|
||
firmware went on putting it on the wire every 2 s. Layer 1 stayed fed, every
|
||
indicator read healthy, and a command computed from an old reading kept being
|
||
executed through whatever the load did next. Measured on the rig during TEL-05:
|
||
70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
|
||
|
||
**It was worse than a latency limit: it was a safety claim that could not
|
||
fire.** `stale_input_s` and the `inputs missing -> 0 W` claim both live *inside*
|
||
`cycle()`. Pre-fix, if the meter value happened to be constant, `cycle()` was
|
||
never called again - so that claim had no way to run. Constant meter, SoC entity
|
||
dying at t=4 s, same binary:
|
||
|
||
```
|
||
pre-fix writes: [(0.0, 220.0), (0.0, 220.0), (10.1, 220.0)]
|
||
final target: 220.0 W reason: 'loop -> 220 W'
|
||
post-fix writes: [(0.0, 220.0), (0.0, 220.0), (3.0, 430.0), (9.1, 0.0), (10.1, 0.0)]
|
||
final target: 0.0 W reason: 'safety -> 0 W'
|
||
```
|
||
|
||
The pre-fix controller held 220 W through the entire input outage with its
|
||
status line still reading `loop -> 220 W`, and layer 1 does not cover that case
|
||
on a `meter_source: off` install: no P1 age sensor is published, so the firmware
|
||
has nothing to veto on, while the heartbeat keeps its watchdog fed. So the old
|
||
behaviour was not "holds a stale command safely" - it was a failsafe that a
|
||
repeating meter switched off. `test_loop.py` asserts both halves.
|
||
|
||
`cycle_floor_s` (default **15 s**) is a deadline on the gap between cycles, not
|
||
a second scheduler: every cycle resets it, whichever trigger fired. On a healthy
|
||
meter it never elapses, so the cycle rate, the write sequence and therefore the
|
||
per-cycle gain the tuning was measured against are unchanged - asserted in
|
||
`test_loop.py` by driving the real loop twice on one binary (a huge
|
||
`cycle_floor_s` reproduces the old behaviour exactly) and comparing the
|
||
commanded values.
|
||
|
||
The reset is not the only thing holding that up, and the other argument is the
|
||
sturdier one: the combined cycle rate is `max(arrival rate, 1/floor)`, never the
|
||
sum. At 1/15 s the floor is strictly slower than the ~1/5 s arrival rate, so
|
||
even a hypothetical floor racing the arrivals could not exceed the nominal
|
||
healthy rate. The re-tune is unreachable by construction. ⚠️ That holds only
|
||
while `cycle_floor_s` stays above the meter's update interval, and the schema
|
||
minimum is 5 s - which is exactly the reference meter's interval. Nothing
|
||
enforces the relationship; `validate.py` is the obvious home for it and does not
|
||
have it yet.
|
||
|
||
Observed over six real minutes on the same rig, one binary, meter held at a
|
||
literally constant 350.0 W: **before, 1 cycle** (at startup) and then nothing,
|
||
the command stuck at 220 W against a 350 W import for the rest of the window.
|
||
**After, 24 cycles**, worst gap 15.14 s, and the ordinary PI ramp doing its job -
|
||
220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W, one step per cycle,
|
||
rail at t=136 s and flat after. Three minutes with the meter parked at the
|
||
target instead: **1 cycle before, 12 after, and 19 writes either way - the same
|
||
19**. Three minutes on a normally-changing meter: **36 cycles and 49 writes on
|
||
both sides, the same values in the same order.** The heartbeat write count never
|
||
moves; the only writes the fix adds are the ten the loop was supposed to be
|
||
making all along.
|
||
|
||
15 s is bracketed, not round.
|
||
|
||
**Below it, ~11 s, and it arises more routinely than a dropped poll.**
|
||
`HomeWizardLocalSource` polls at 5.0 s against a meter that updates every 4.97 s
|
||
(NOTES.md:640). Two near-equal periods beat, so roughly once every ~14 minutes
|
||
the poller steps over an update entirely - a ~10 s gap between changed values on
|
||
a perfectly healthy meter, plus up to 1 s for the 1 s tick to notice. A failed
|
||
HTTP poll (retried on the next tick, no backoff) adds another 5 s. The floor
|
||
sits above the beat-skip so it does not fire on that alone; what it takes to
|
||
reach 15 s is a beat-skip *and* a failed poll coinciding.
|
||
|
||
**Above it, 30 s.** `meter_max_age_s` is 30 s, past which there is no reading to
|
||
evaluate, and the firmware's ingest veto is also 30 s - so the loop is
|
||
guaranteed to have re-decided at least twice inside any window in which layer 1
|
||
might otherwise be the only thing deciding. ⚠️ The default is therefore not
|
||
independently justified at 15: if the firmware's veto ever moves, this moves
|
||
with it. Noted beside the constant in `main.py`.
|
||
|
||
It is also exactly `stale_input_s`, this system's existing answer to "how long
|
||
may we hold without re-deciding".
|
||
|
||
A floor cycle runs the **full** law on the same inputs, integrator included. In
|
||
P1 mode the freshness question is already answered upstream and answered harder
|
||
- past `meter_max_age_s` the reading is None, which is "inputs missing", which
|
||
is a `P_SAFETY` 0 W claim - so a floor cycle there can only ever run on a reading
|
||
this system already calls fresh. ⚠️ That is transport-conditional: with
|
||
`meter_source: off`, `read_inputs()` takes the grid figure straight from
|
||
`hass.number()` with **no age gate at all**, so a Home Assistant entity that has
|
||
gone quiet reads as current on that path, with or without this change. Running
|
||
the full law is still right there - the alternative is a degraded second law
|
||
reasoning about an age nobody measured - but the floor does not make that path
|
||
fresh. The integrator advances per *cycle*, not per second,
|
||
so bounding the gap bounds the accumulation; `integrator_max_w` bounds it after
|
||
that. A gentler second law for floor cycles would be a second set of dynamics
|
||
nobody can test on this hardware.
|
||
|
||
⚠️ **This is not a freeze detector**, and TEL-05 established one cannot be built
|
||
here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move
|
||
while the power figure legitimately repeats, so any threshold on `unchanged_s`
|
||
false-trips at the exact operating point we run at. Nothing in this change
|
||
decides whether a repeated reading is *true*. It stops "the number is the same"
|
||
from meaning "do not think". The consequence, stated plainly: against a meter
|
||
genuinely frozen at a wrong value the loop will now correct toward that wrong
|
||
value rather than hold a stale command, bounded by `max_w`, the arbiter and the
|
||
device rating. That was already what happened to a lying meter whose value
|
||
merely jittered; bit-exact repetition was never a safety property.
|
||
|
||
`DOCS.md`'s "Saturation is counted in cycles, not seconds" section documented
|
||
the old behaviour as an accepted deviation. It is **qualified, not deleted**:
|
||
the deviation survives (the window is still counted in cycles, and 3 x 15 s is
|
||
still not the specification's 10 s) but it is now bounded at 45 s instead of
|
||
open-ended, and the passage's second justification - "nothing accumulates while
|
||
it is stalled" - is called out as having covered integrator windup and not
|
||
staleness, which was the actual hazard. The same wrong argument in
|
||
`control.py`'s saturation comment is corrected too.
|
||
|
||
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
|
||
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
|
||
unsigned registers and refuse negatives, so neither fits the meter installed here.
|
||
|
||
**TEL-05.** `meter_source: homewizard_local` polls the meter's own local API
|
||
directly. This exists because of something the HA paths cannot do: Home Assistant
|
||
fires no state change for a repeated reading and exposes no arrival signal at
|
||
all, so `sensor.p1_sample_age_s` on those transports measures time since the
|
||
*value changed*, not since the meter *reported*. Real captured data has gaps of
|
||
42 s and 97 s between changes on a perfectly healthy meter - long enough for a
|
||
30 s staleness threshold to command 0 W for no reason. Every HTTP response to the
|
||
local API is a genuine arrival, so the age means what it says.
|
||
|
||
Verified on the rig against a literally constant 350.0 W for 70 s - the exact
|
||
state where the HA path recorded zero samples in 45 s - with 14 arrivals and the
|
||
age never exceeding the poll cadence.
|
||
|
||
Set `p1_host` to the meter's address and `meter_poll_s` (default 5 s, matching
|
||
the meter's own ~5 s update). Still defaults to `off`; nothing changes for an
|
||
install that does not opt in.
|
||
|
||
Freeze detection is separate and deliberately not thresholded: a meter answering
|
||
200 OK with a stale number *is* arriving, so no arrival stamp can flag it. The
|
||
energy registers carry that as `unchanged_s`, surfaced on the status line for a
|
||
human to read rather than alarmed on - at a converged -10 W a 1 Wh register needs
|
||
about six minutes to move, so any threshold would false-trip at the target
|
||
operating point.
|
||
|
||
## 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:
|
||
|
||
1. Highest-priority `set` wins; no claim at all means 0 W.
|
||
2. Then every `limit` whose priority is >= that set's priority applies, most
|
||
restrictive first.
|
||
3. Contradictory limits are a BUG: command 0 W and say so.
|
||
|
||
Clause 2 is why "money outranks maintenance" is now a consequence of the
|
||
priorities rather than a special case in a Jinja template: the maintenance
|
||
charge-only limit binds the loop, but will not bind a higher-priority peak
|
||
shaving claim when one exists.
|
||
|
||
- Maintenance shaping (charge-only, cheap-window floor) moved out of the control
|
||
law. `control.py` is once again only a controller that tracks the meter.
|
||
- The loop computes from the ARBITER's last output, not its own last wish. If
|
||
something outranked it, that is what the hardware actually did, and tracking
|
||
anything else makes it jump when it regains control.
|
||
- Every decision is explainable: "loop -> 0 W, limited by maintenance
|
||
(charge-only)" now appears in the UI and the log, instead of a bare number.
|
||
- Safety limits (device rating, supervised max_w) bind every strategy including
|
||
the highest, and are still enforced a second time at the point of writing.
|
||
|
||
## 0.1.6
|
||
|
||
Findings from installing this on a live system, replacing a working YAML
|
||
implementation. Every one of these was silent - the add-on looked healthy while
|
||
being completely unable to do its job.
|
||
|
||
- **`run.sh` must use `#!/usr/bin/with-contenv sh`.** The HA base images run
|
||
s6-overlay, which starts services with a SANITISED environment. With a plain
|
||
shebang, SUPERVISOR_TOKEN is simply absent and every Home Assistant call
|
||
returns 401 - while `homeassistant_api: true` makes the permissions look
|
||
correctly granted. Startup now logs the token length and probes the Core API,
|
||
so the next person sees it in one line.
|
||
- **Bump `version:` for every change.** Supervisor keys the built image by
|
||
version, so editing source and rebuilding silently reuses the old image. Two
|
||
fixes appeared not to work because of this.
|
||
- **Dependencies come from apk, not pip.** Alpine is musl and there are no musl
|
||
wheels for aiohttp; pip would compile it on the client's Pi.
|
||
- **paho-mqtt 1.x and 2.x are both supported.** Alpine ships 1.x, which has no
|
||
`CallbackAPIVersion`; that raised and took the whole add-on down with it.
|
||
- **MQTT can no longer take down control.** Publisher construction is wrapped -
|
||
observability must never stop the controller.
|
||
- **MQTT discovery is published from `on_connect`.** paho drops QoS-0 publishes
|
||
issued before the CONNACK, so announcing straight after `connect()` published
|
||
nothing at all while logging "MQTT connected".
|
||
- **Repeated failures log at most once a minute.** The control loop retries every
|
||
second; unthrottled warnings rolled the log buffer and destroyed the startup
|
||
diagnostics needed to debug the 401 above.
|
||
- **`auto_start` works.** The store's defaults supplied `auto: False`, so the
|
||
fallback to the option could never fire.
|
||
|
||
Known issue: after deleting the MQTT entities from the registry during
|
||
development, Home Assistant would not re-adopt them from retained discovery -
|
||
not even after clearing the retained topics and reconnecting. The add-on
|
||
publishes correct discovery and live state (verified on the broker); this is an
|
||
HA-side adoption problem and affects status entities only, never control.
|
||
|
||
## 0.1.0
|
||
|
||
First packaged release. Ports the control loop and the monthly maintenance
|
||
cycle from the reference Home Assistant implementation into an add-on.
|
||
|
||
- Grid-following control: gain/slew/clamp/deadband with anti-windup, all tuned
|
||
against measured hardware behaviour (see FIELD-GUIDE.md §14).
|
||
- Saturation freeze **with the duration term** — three consecutive diverging
|
||
cycles, not one. The instantaneous test fires on every large correction,
|
||
because the plant itself needs 3-6 s to settle.
|
||
- Monthly maintenance cycle as an ownership state machine: drain / charge /
|
||
hold, with exactly one writer of the setpoint at any moment.
|
||
- Capacity-tariff awareness: the maintenance charge is capped by quarter-hour
|
||
peak headroom, and peak shaving outranks the maintenance schedule.
|
||
- Failsafe behaviour: commands 0 W on missing inputs, on stop, and on shutdown.
|
||
Never replays a stale setpoint - the reference implementation did, and the
|
||
hardware watchdog cannot catch that.
|
||
- Ingress UI with a commissioning checklist that names problems in words.
|
||
- Optional MQTT discovery for status entities.
|
||
|
||
Known limits:
|
||
|
||
- Home Assistant OS / Supervised only (add-ons cannot run on Container/Core).
|
||
- The inverter protocol is reverse-engineered; no vendor contract.
|
||
- Without the optional RS485 e-stop, nothing covers the host machine dying.
|