config.yaml version 0.17.0 -> 0.18.0 and a closed ## 0.18.0 CHANGELOG section for OPT-17, SAFETY-112, OPT-15, MAINT-11, PRICE-01 and OPT-20. No code change. Co-Authored-By: Claude <noreply@anthropic.com>
6926 lines
447 KiB
Markdown
6926 lines
447 KiB
Markdown
# Changelog
|
||
|
||
## 0.18.0
|
||
|
||
**A minor bump: new behaviour, three new options and one new value for an
|
||
existing option.** No option was removed or renamed, and no default of an
|
||
existing option changed (83 options -> 86). **Existing installs need no option
|
||
change:** options that start 0.17.0 also start 0.18.0, and the new options are
|
||
off or empty until you use them. The changes below apply with one inverter or
|
||
two, unless a line says "Two inverters".
|
||
|
||
New options, and what they do if you leave them alone:
|
||
|
||
| option | default | left alone |
|
||
|---|---|---|
|
||
| `pv_forecast_entities`, `pv_forecast_tomorrow_entities` | `[]` | the single forecast sensors work as before ([[OPT-17]]) |
|
||
| `price_service_token` | empty | not used; it is read only with `price_provider: price_service` ([[PRICE-01]]) |
|
||
|
||
`price_provider` also accepts a new value, `price_service`. Its default is
|
||
unchanged, so the price source stays as it is.
|
||
|
||
**Price service.** The new price source `price_service` is your own price
|
||
server, reached with a token. If it does not answer, prices come from
|
||
energy-charts.info, then from the prices the add-on saved. Sites on the Home
|
||
Assistant price sensors or a fixed tariff need no change. To move a site over,
|
||
see `DOCS.md`, "Moving a site from the Home Assistant price sensors to
|
||
`price_service`".
|
||
|
||
Contents of this release, in merge order since 0.17.0 (`e36565f`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `3050fa5` | The solar forecast can come from several sensors, added up, for example one per inverter. If one of them has no reading, that day's forecast is not used, and the status says which one. The single sensor still works ([[OPT-17]]) |
|
||
| `e3e4ba6` | While Home Assistant is still starting, the page and the log now say "Waiting for Home Assistant" instead of looking like a fault. Control itself is unchanged: it starts when Home Assistant answers ([[SAFETY-112]]) |
|
||
| `79dd077` | Battery modes on only. A charge in a cheap run no longer stops at the start of a new 15-minute period, and a battery that refused to charge is tried again every 15 min. In one test, the export at the end of a charge was 48 W where the code before this fix gave 746 W ([[OPT-15]]) |
|
||
| `0ce7d35` | Two inverters: each battery card has Start cycle and Stop cycle buttons, so you choose which battery gets the monthly cycle. Only one battery can cycle at a time, and a second start is refused with a short message ([[MAINT-11]]) |
|
||
| `bf9019e` | New price source `price_service`. If it fails, prices come from energy-charts.info, then from saved prices. With no price at all and the monthly cycle on, a notice shows and the cycle waits ([[PRICE-01]]) |
|
||
| `004f859` | Battery modes on only. The 15-minute retry of a battery that refused to charge now asks that battery for 200 W and moves no other battery. In the test, without this fix, a battery in its maintenance charge took about 2,300 W for 34 s each time ([[OPT-20]]) |
|
||
|
||
### Known issues
|
||
|
||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the log gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||
- A battery in a safety stop, or one the add-on cannot reach, can be started for a cycle by name. Its drain then waits about 12 h and ends in a failed-cycle notice ([[MAINT-19]]).
|
||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||
|
||
### If you are crossing from a version older than 0.10.0
|
||
|
||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||
bare options (`jq '.data.options'`) first.
|
||
|
||
## 0.17.0
|
||
|
||
**A minor bump: changed behaviour, and no option changes.** No option was
|
||
added, removed or renamed, and no default changed (83 options). Options you
|
||
saved work as before. The changes below apply with one inverter or two, unless
|
||
a line says "Two inverters".
|
||
|
||
**Maintenance with no battery level.** When the battery level is missing,
|
||
battery maintenance now ends on its own time limits instead of waiting for the
|
||
level to come back (MAINT-14, MAINT-15). You do not need to change any option.
|
||
|
||
Contents of this release, in merge order since 0.16.0 (`451729c`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `b527e99` | Two inverters: after a drain ends, the other battery no longer swings on old readings. In the test, it held steady for 23 s where 0.16.0 moved it by 720 W ([[SAFETY-103]]) |
|
||
| `8a17711` | A full battery that stops at 98 or 99 % no longer counts as a failed charge. After 60 min with no new high, the charge counts as done; a charge that truly fails now waits 7 days and shows one notice ([[MAINT-13]]) |
|
||
| `bf3ac79` | With no battery level, a maintenance charge still ends at its time limit and a drain waits at 0 W. The log no longer repeats a line every second ([[MAINT-14]]) |
|
||
| `db9e231` | One inverter: when maintenance charges a battery that is already full, the log no longer repeats "The battery refuses to give power" every second. In the test, 1 line in 16 min where the code before this fix wrote about 1,700 in 15 min; the power does not change ([[SAFETY-107]]) |
|
||
| `580c200` | The balancing hold after a charge now ends on time when the battery level is missing. Before, it waited until the level came back ([[MAINT-15]]) |
|
||
|
||
### Known issues
|
||
|
||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||
|
||
### If you are crossing from a version older than 0.10.0
|
||
|
||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||
bare options (`jq '.data.options'`) first.
|
||
|
||
## 0.16.0
|
||
|
||
**A minor bump: changed behaviour, no option added or removed, and one default
|
||
and its meaning changed.** There are still 83 options. `maintenance_start_hour`
|
||
used to be read as UTC and its default was 10. It is now a local hour (in Home
|
||
Assistant's time zone) and its default is 19. The other changes below apply
|
||
with one inverter or two, unless a line says "Two inverters".
|
||
|
||
**Before you update (MAINT-10).** A saved `maintenance_start_hour` is kept, and
|
||
it now means local time. If you set it in UTC to get an evening start (for
|
||
example 17 for 19:00 in summer), set it back to the local hour (19). A saved
|
||
10 now starts at 10:00, not 12:00 in summer. The new default 19 only reaches
|
||
installs that have no hour saved.
|
||
|
||
Contents of this release, in merge order since 0.15.0 (`3ed3302`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `7ef7347` | After a gap in the P1 meter, the peak guard counts the missed time as high use and keeps the 15-minute period near the limit; before, it was blind until the next period. In the test, a 5 min meter silence at one inverter ended the period at 3.3 kW, against 4.8 kW on 0.15.0 ([[SAFETY-90]]) |
|
||
| `1bf6b7f` | The maintenance start hour is now a local hour, and its default is 19 (was 10, read as UTC). The log says which time zone it used; with two inverters, both use that hour and still cycle one at a time ([[MAINT-10]]) |
|
||
| `b2f5573` | Two inverters: when one battery's link drops, the other takes over in small steps, and control no longer stalls. The battery that comes back rejoins within 2 s, without a false "battery refuses" ([[SAFETY-96]]) |
|
||
| `340c7ee` | Two inverters: when a drain ends (done, switched Off, timeout, lost link), the other battery covers the house right away, without jumping to 1.6 kW, and then moves in small steps. It no longer gives into a solar surplus ([[SAFETY-95]]) |
|
||
|
||
### Known issues
|
||
|
||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
|
||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||
- A charge to 100 % can time out at 98 %. The add-on then counts the cycle as failed and drains the battery again the next day ([[MAINT-13]]).
|
||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||
- Two inverters: after a drain ends, the other battery can swing by about 800 W for about 5 s, because some readings are old ([[SAFETY-103]]).
|
||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||
|
||
### If you are crossing from a version older than 0.10.0
|
||
|
||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||
bare options (`jq '.data.options'`) first.
|
||
|
||
## 0.15.0
|
||
|
||
**A minor bump: changed behaviour, and no option changes.** No option was
|
||
added, removed or renamed, and no default changed (83 options). Options that
|
||
start 0.14.0 also start 0.15.0. The changes below apply with one inverter and
|
||
with two.
|
||
|
||
The add-on now learns and saves, per battery, what its power reading shows
|
||
at rest (`unit/<name>/idle_w`). It learns it after about two quiet minutes and
|
||
uses 0 W until then, as before. There is nothing to set.
|
||
|
||
Contents of this release, in merge order since 0.14.0 (`02ed120`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) |
|
||
| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) |
|
||
|
||
### Known issues
|
||
|
||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
|
||
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
|
||
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
|
||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||
|
||
### If you are crossing from a version older than 0.10.0
|
||
|
||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||
bare options (`jq '.data.options'`) first.
|
||
|
||
## 0.14.0
|
||
|
||
**A minor bump: new behaviour with two inverters, and no option changes.**
|
||
No option was added, removed or renamed, and no default changed (83
|
||
options). Options that start 0.13.0 also start 0.14.0. With one inverter,
|
||
control is the same as in 0.13.0.
|
||
|
||
⭐ **Two inverters are now supported** for the monthly maintenance (drain,
|
||
charge, hold) and the peak guard. To add a second inverter, follow
|
||
`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the
|
||
add-on takes control of it, needs 0.14.0. Read the known issues below first:
|
||
three of them are about two inverters.
|
||
|
||
Contents of this release, in merge order since 0.13.0 (`8c438ab`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) |
|
||
| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) |
|
||
| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) |
|
||
|
||
### Known issues
|
||
|
||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
|
||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
|
||
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
|
||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||
|
||
### If you are crossing from a version older than 0.10.0
|
||
|
||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||
bare options (`jq '.data.options'`) first.
|
||
|
||
## 0.13.0
|
||
|
||
**A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries,
|
||
Setup), and six new options.** No option was removed or renamed, and no
|
||
default of an existing option changed (77 options -> 83). The only new
|
||
start-up checks are on the new options, so options that start 0.12.0 also
|
||
start 0.13.0.
|
||
|
||
New options, and what they do if you leave them alone:
|
||
|
||
| option | default | left alone |
|
||
|---|---|---|
|
||
| `peak_margin_w` | `50` | the peak guard aims 50 W below your peak limit, or below a higher peak already reached this month; `0` is the old aim ([[SAFETY-79]]) |
|
||
| `battery_modes_enabled` | `false` | no battery modes, control as before ([[OPT-11]]) |
|
||
| `pv_forecast_entity`, `pv_forecast_tomorrow_entity` | empty | no solar forecast is used ([[OPT-11]]) |
|
||
| `solar_trust_pct` | `70` | only used with a solar forecast ([[OPT-11]]) |
|
||
| `pv_invert_entities` | `[]` | PV readings as before ([[TEL-14]]) |
|
||
|
||
⚠️ **Turn on Watchdog** on the add-on's Info tab. It is off by default. If
|
||
the control loop crashes, the add-on now sets the batteries to 0 W and stops.
|
||
Home Assistant only starts it again when Watchdog is on ([[SAFETY-61]],
|
||
[[DOC-5]]).
|
||
|
||
⚠️ **The peak guard now starts a little earlier.** It aims 50 W below your
|
||
peak limit, so the batteries give a bit more at peak times. Set
|
||
`peak_margin_w: 0` for the old aim ([[SAFETY-79]]).
|
||
|
||
Contents of this release, in merge order since 0.12.0 (`a45ed06`):
|
||
|
||
| | |
|
||
|---|---|
|
||
| `fa3d237` | Docs name the right P1 age entity and a battery list that starts ([[DOC-1]]) |
|
||
| `6e65272` | If the control loop crashes, the batteries go to 0 W and the add-on stops ([[SAFETY-61]]) |
|
||
| `3e38baf` | The check tools refuse a file they cannot read, instead of saying all clear (tools only) ([[SAFETY-60]], [[SAFETY-62]]) |
|
||
| `43b2740` | A missing value in a check is a named failure (tests only) ([[TEST-26]]) |
|
||
| `20260e8` | Docs: turn on Watchdog ([[DOC-5]]) |
|
||
| `16c796f` | New option `pv_invert_entities`: flip the sign of a PV sensor that reads backwards ([[TEL-14]]) |
|
||
| `6a8a5ff` | CI tests against the real inverter simulator (CI only) ([[CI-12]]) |
|
||
| `82f032e` | CI pin checks report failures properly (CI only) ([[TOOL-9]]) |
|
||
| `fe13251` | CI pin update (CI only) |
|
||
| `397dd15` | Alarm notifications that are still active come back after a Home Assistant restart ([[SAFETY-34]]) |
|
||
| `59415ea` | CI pin update (CI only) |
|
||
| `2503de1` | "Battery is not keeping up" is a warning with its own notification, not a "bug" alarm; its figures are correct ([[SAFETY-40]]) |
|
||
| `48ef06a` | Shorter, plainer notifications ([[UX-08]]) |
|
||
| `5c151a3` | CI pin update (CI only) |
|
||
| `d57767f` | Shorter, plainer status rows and banners ([[UX-08]]) |
|
||
| `e1d0970` | Details link works on current Home Assistant; plainer words ([[UX-09]]) |
|
||
| `a02cd4b` | Shorter refusals, errors and option texts ([[UX-08]]) |
|
||
| `32b9e56` | Plainer log lines and docs ([[UX-08]]) |
|
||
| `125b070` | CI pin update (CI only) |
|
||
| `2b14b88` | Last plain-language fixes in logs and the status page ([[UX-10]]) |
|
||
| `c4032eb` | CI pin update (CI only) |
|
||
| `d1f7e33` | Safety log lines: one plain sentence and a code ([[UX-11]]) |
|
||
| `652097a` | A battery that refuses power (e.g. empty) is asked for 0 W, and tried at 200 W for 34 s every 15 min ([[SAFETY-52]]) |
|
||
| `afd6af0` | Groundwork for the new settings pages ([[UX-13]]) |
|
||
| `a36dca6` | New page with a Home screen ([[UX-13]]) |
|
||
| `28a49cc` | With two or more inverters, a battery that refuses power is held on its own; the others take over ([[SAFETY-73]]) |
|
||
| `515c11b` | New Installer settings view ([[UX-13]]) |
|
||
| `4de9675` | New setup wizard for installers ([[UX-13]]) |
|
||
| `8c34647` | A new install with no inverter opens the setup wizard instead of stopping ([[UX-15]]) |
|
||
| `a5dddad` | Dutch text and a time-period editor ([[UX-13]]) |
|
||
| `ed739d4` | Battery modes (off by default): per-battery limits, on/off, solar order ([[OPT-11]]) |
|
||
| `7995e4d` | Simple settings view and a Batteries screen ([[UX-13]]) |
|
||
| `7337b79` | Battery modes in Home Assistant, when modes are on ([[OPT-11]]) |
|
||
| `7a00f12` | New option `peak_margin_w`: the peak guard keeps a margin below the limit ([[SAFETY-79]]) |
|
||
| `5a4cdc2` | Battery modes: charge in cheap hours, hold for expensive hours; selling is off for now ([[OPT-11]]) |
|
||
| `4f25b24` | No false "Load too high" alarms at quarter edges or with a noisy load (10 s hold) ([[SAFETY-81]]) |
|
||
| `35cdffe` | Maintenance drain with two batteries no longer stalls ([[SAFETY-78]]) |
|
||
| `1145a93` | Battery modes: a solar forecast decides how much to buy from the grid ([[OPT-11]]) |
|
||
| `9d5b7d1` | With one battery, the peak guard wins over the maintenance drain ([[SAFETY-83]]) |
|
||
| `4ad8298` | Maintenance charge with two batteries no longer stalls; it gives way to the peak guard ([[SAFETY-84]]) |
|
||
| `b8ddfd7` | The peak guard wins over the maintenance hold; the hold ends on time, with no top-up ([[SAFETY-86]]) |
|
||
| `9358781` | With two or more batteries, the peak guard uses every battery that can still give; peak alarms no longer say control stopped ([[SAFETY-87]]) |
|
||
| `61072ca` | No false "Battery is not keeping up" during maintenance; real shortfalls still warn; the status row counts each episode once ([[SAFETY-85]]) |
|
||
|
||
### Known issues
|
||
|
||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||
- Two inverters: at the end of a maintenance charge one battery can charge from the grid while the house uses a lot. Do not add a second inverter before the next release ([[SAFETY-93]]).
|
||
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
|
||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||
- Two batteries: one at its peak reserve can be used before a draining battery has used its room ([[SAFETY-91]]).
|
||
|
||
### If you are crossing from a version older than 0.10.0
|
||
|
||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||
bare options (`jq '.data.options'`) first.
|
||
|
||
## 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.
|