checks / unit (pull_request) Successful in 3m43s
max_w and the LIVE_MAX_W literal beside it were the two survivors left
after TEST-06 pinned meter_freeze_s: changing max_w 2000 -> 4500 in
config.yaml passed every check in test_freeze.py and test_tariff.py
GREEN, and LIVE_MAX_W = 5000.0 was a bare literal that silently
disagreed with the shipped max_w (2000) with nothing in the file
saying so. For contrast, deadband_w 15 -> 5 already went RED 2 + 1,
so the pinning technique was known to work - it just was not applied
to the key either side of it.
Three changes, each measured:
1. test_freeze.py gains 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. Broken
and confirmed red: 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 in either file; reverting returns both files
to all-green. (AC 1, AC 4)
2. LIVE_MAX_W is renamed LIVE_INSTALL_MAX_W in both test_freeze.py
and test_tariff.py, each with a comment stating plainly that 5000
is the live install's own override, not config.yaml's shipped
max_w (2000) - the defect was a literal that could be misread as
the shipped default, not the number itself. (AC 2)
3. test_freeze.py states PINNED_KEYS = ("meter_freeze_s", "deadband_w",
"max_w") in one place as the complete set of config keys this
suite pins. Deliberately excludes maintenance_* - CFG-03 (merged
into release/1.0 since this branch started, merged in here) gave
those keys a separate single source of truth in app/maintenance.py
and this ticket has no reason to duplicate that pin. Not every key
in config.yaml is pinned here on purpose: meter_freeze_s,
deadband_w and max_w are the three this file's mutual-exclusion
arithmetic (section 2b) actually reasons about together. (AC 3)
Full 17-file suite run green under goodwe-addon-ci after the merge
with release/1.0 (f630ed9, CFG-03). CHANGELOG.md updated under
Unreleased.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1753 lines
108 KiB
Markdown
1753 lines
108 KiB
Markdown
# Changelog
|
||
|
||
## Unreleased
|
||
|
||
**`max_w` and the `LIVE_MAX_W` literal beside it were unpinned in test_freeze.py
|
||
and test_tariff.py, the same gap TEST-06 closed one key over for
|
||
`meter_freeze_s` (TEST-10).** Measured before the fix: `max_w: 2000 -> 4500`
|
||
in `config.yaml` left every check in both files GREEN, and `LIVE_MAX_W = 5000.0`
|
||
was a bare literal that silently disagreed with the shipped default (2000) with
|
||
nothing saying so — for contrast, `deadband_w: 15 -> 5` already went RED 2 + 1,
|
||
so the pinning technique itself was known to work, just not applied here.
|
||
Fixed three ways. First, `test_freeze.py` gained a check comparing `Tuning()`'s
|
||
code default (`control.py`, 2000.0) against `SHIPPED_MAX_W`, read from
|
||
`config.yaml` by extending the file's existing `_cfg_default()` regex reader
|
||
rather than reimplementing test_arbitrage.py's `_shipped`. Second, the literal
|
||
itself is renamed `LIVE_INSTALL_MAX_W` in both files, with a comment stating
|
||
plainly that 5000 is the live install's own override and not config.yaml's
|
||
shipped `max_w` — the defect was the silent literal, not the number. Third,
|
||
`test_freeze.py` now states `PINNED_KEYS = ("meter_freeze_s", "deadband_w",
|
||
"max_w")` in one place as the complete set of config keys this suite pins,
|
||
deliberately excluding `maintenance_*` (CFG-03 gave those a separate source of
|
||
truth in `app/maintenance.py`). Verified by breaking what the new check
|
||
watches: `max_w: 2000 -> 4500` in `config.yaml` now fails exactly
|
||
`"the code default and the shipped default agree on max_w too"` and nothing
|
||
else, and reverting returns the file to all-green.
|
||
|
||
**Maintenance option defaults lived in three places that had already disagreed,
|
||
and MAINT-04's tripwire was holding them apart rather than closing them
|
||
(CFG-03).** `config.yaml` shipped `maintenance_charge_w: 2000`, `main.py` carried
|
||
two `opts.get("maintenance_charge_w", 2500)` fallbacks, and `MaintConfig`'s
|
||
dataclass carried a third `2500.0`. An options dict with the key **absent** was
|
||
therefore accepted by `validate.py`'s rule 6 — which read 2000 — and then built
|
||
by `main.py` at 2500, against a 2000 W ceiling. Demonstrated through the shipped
|
||
code:
|
||
|
||
options dict with maintenance_charge_w ABSENT, max_w=2000
|
||
validate_startup_config -> [] (ACCEPTED, no problems)
|
||
MaintConfig().charge_w -> 2500.0
|
||
arbiter.resolve() -> 0.0 W, contradiction=True
|
||
|
||
That is the bug rule 6 exists to refuse, **passing rule 6**. Unreachable through
|
||
the Supervisor, which always supplies every key from `config.yaml`; fully
|
||
reachable from `sim/inject.py` and from the test suite, which is where anyone
|
||
debugging maintenance is.
|
||
|
||
**`maintenance_discharge_w` had the identical divergence** (`config.yaml` 2000,
|
||
`MaintConfig` 2500) and the card named only the charge side. Closing one key and
|
||
leaving its twin would have reproduced the defect one line down, so both are
|
||
closed.
|
||
|
||
**The single source of truth is `config.yaml`'s `options:` block** — the file the
|
||
Supervisor reads and the operator edits. There is now exactly **one** in-code
|
||
copy, `maintenance.MAINT_OPTION_DEFAULTS`, which is TARIFF-02's
|
||
`PRICE_OPTION_DEFAULTS` and SAFETY-24's `PEAK_OPTION_DEFAULTS` pattern applied to
|
||
the family that named the card. A mirror rather than a runtime read because the
|
||
`Dockerfile` copies `app/` and `run.sh` into the image and **not** `config.yaml`:
|
||
the running add-on cannot open the file it is configured by. `test_maintenance.py`
|
||
pins every entry by **reading** the shipped file, so a drift is a CI failure
|
||
rather than a comment. Every other declaration site now derives from it or has
|
||
ceased to exist:
|
||
|
||
- `MaintConfig`'s eight dataclass defaults are indexed out of the mirror — no
|
||
numeric literal survives in the class that held the third opinion;
|
||
- `MaintConfig.from_options()` is the **only** place in the add-on that reads a
|
||
`maintenance_*` option. `main.py`'s eight inline `opts.get(key, <literal>)`
|
||
calls are gone;
|
||
- `main.py`'s second fallback site (the cheap-window charge floor) reads
|
||
`self.maint.cfg.charge_w` instead of re-opening the options dict, so the rate
|
||
the shaping asks for and the rate the state machine drains at cannot be two
|
||
numbers from one install;
|
||
- `validate.py`'s rule 6 and its `maintenance_discharge_w` warning take their
|
||
fallbacks from the mirror.
|
||
|
||
**Where the RANGE lives is now stated and checked, so the next person does not
|
||
add a fifth site.** `maintenance_charge_w: int(500,5000)` in `config.yaml`'s
|
||
`schema:` block is a fourth declaration of a different kind — a bound, not a
|
||
default. Bounds live in the schema block **only**: the Supervisor enforces them
|
||
and reads only that file. `test_maintenance.py` asserts that every mirrored key
|
||
has a schema entry (or the Supervisor silently drops the option and the add-on
|
||
runs on the mirror instead of the operator's value) and that every shipped
|
||
default lies **inside** its own range. What the schema grammar cannot express is
|
||
a relation *between* keys — `maintenance_charge_w` against `max_w` — which is
|
||
why rule 6 is written in Python and does not belong in the mirror either.
|
||
|
||
**MAINT-04's tripwire in `test_validate.py` is DELETED, not left passing**, as it
|
||
and `validate.py`'s ⚠️ block both instructed each other to do. Its passing
|
||
condition *was* the bug. In its place, the same four consumers exercised the same
|
||
way and now agreeing: the validator accepts a dict with every `maintenance_*` key
|
||
missing, a **real `Controller`** built from that dict holds 2000 W, `MaintConfig()`
|
||
answers 2000 W with no dict at all, and the real arbiter emits `-2000 W, limited
|
||
by supervised(max_w)` instead of `0 W, contradiction=True`.
|
||
|
||
**Checked by breaking it, five ways, each observed red before being reverted:**
|
||
move `config.yaml` alone → the mirror pin fails; move `config.yaml` **and** the
|
||
mirror to 1500 → every consumer follows together (`MaintConfig()` 1500 W, the
|
||
`Controller` 1500 W, the arbiter `-1500 W, limited by maintenance(cheap-window
|
||
charge)`) and both suites go green again; retype `charge_w: float = 2500.0` into
|
||
the dataclass → red; restore `opts.get("maintenance_charge_w", 2500)` in
|
||
`main.py` → red; delete a key from `config.yaml`'s options block → red.
|
||
|
||
Stale prose that asserted the closed divergence as current is corrected in
|
||
`app/watchdog.py`, `test_watchdog.py` and `test_peakguard.py`.
|
||
|
||
**`peak_soc_floor` reads like a pack-wide reserve and is not one, and on a
|
||
sustained-load evening the grid-following loop discharges straight past it to
|
||
the BMS cut-out (SAFETY-25).** The gap has been written down four times without
|
||
being filed — SAFETY-18's finding (`freeze.py`), SAFETY-24's module note, its PR
|
||
body, and its honestly-uncaught 13th mutation — and never once measured. It is
|
||
measured now, on the committed 2026-08-20 capture, driving the shipped
|
||
`control.compute()`, `peakguard.decide()`, `peakguard.claim()` and the real
|
||
`arbiter.resolve()`:
|
||
|
||
19:00-22:00 UTC, 830 W mean, minimum 701 W, not one watt of export
|
||
reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
|
||
above peak_soc_floor
|
||
|
||
ends at 10.0 % - the bms_soc_floor cut-out
|
||
1525 Wh delivered by the LOOP over 587 cycles
|
||
549 cycles below peak_soc_floor, 343 of them still discharging
|
||
0 cycles on which the peak guard filed any floor at all
|
||
|
||
**And it is a behaviour, not a config value.** The same evening ends at
|
||
`9.9715 %` at `peak_soc_floor` = 5, 11, 20 **and** 60 — the shipped value and
|
||
both ends of the schema range — identical to four decimal places. The option an
|
||
operator would reach for cannot move the number by any amount. On the same day's
|
||
high-load block the two states coexist: 49 cycles reporting `PEAK GUARD
|
||
FLOOR-LIMITED` and, on **all 49**, the arbiter simultaneously granting the loop
|
||
up to 2000 W of discharge.
|
||
|
||
**Why SAFETY-24 could not close it.** The peak guard files a `lo` limit — a
|
||
*lower* bound. That asymmetry is what makes it provably unable to lower a target,
|
||
cap a discharge or contradict the arbiter, and it is therefore equally unable to
|
||
stop a discharge the loop itself wants. A floor on the guard cannot become a
|
||
floor on the pack by being raised.
|
||
|
||
**What changed is the honesty, on four operator-facing surfaces**, because that
|
||
half is required whichever way the open question is answered and it is the actual
|
||
misstatement on the live system today:
|
||
|
||
- `config.yaml` says next to the option that it **bounds the peak guard only** and
|
||
is **not a pack-wide reserve**, with the replayed evening and the sweep;
|
||
- `DOCS.md` says it in the options table and in a new section carrying the
|
||
figures above;
|
||
- the **status-page row** for a floor-limited guard carries the scope sentence —
|
||
it is the one line a human reads at the moment the guard stops, and read alone
|
||
it implies a reserve the replay shows does not exist;
|
||
- the **persistent notification** says what has stopped (the guard buying a lower
|
||
bill) and what has not (the pack being spent).
|
||
|
||
`peakguard.py` and `freeze.py` carry the measurement beside the findings they
|
||
already recorded.
|
||
|
||
**The product decision is NOT taken here.** Whether the loop gets a reserve floor
|
||
of its own moves real energy on this house and belongs to the owner.
|
||
`DECISIONS/SAFETY-25-loop-soc-floor.md` records the options, what each costs and
|
||
protects, weighed against MAINT-01 and against the fact that this would be the
|
||
**fifth** SoC key (OPT-04's `arbitrage_soc_floor` fired SAFETY-24's "revisit if a
|
||
third floor appears" trigger unremarked), and a recommendation. It also records a
|
||
trap found while writing it and now checked through the real arbiter: the obvious
|
||
shape — a `hi` limit on the loop — crosses the peak guard's `lo` and resolves to a
|
||
**CONTRADICTION at 0 W**, the battery parked in the quarter that sets the bill,
|
||
unless `bms_soc_floor < loop_soc_floor <= peak_soc_floor` is enforced at startup.
|
||
|
||
**`test_peakguard.py` part 10 is a tripwire, not a guarantee, and part 10f is
|
||
what makes it one.** The replay half records the defect; on its own it could not
|
||
see the defect being FIXED, because `replay_loop()` hand-builds main.py's claim
|
||
list and is therefore blind to any claim main.py adds — which is exactly where a
|
||
loop-side floor would be built. PR #31's reviewer proved that by building the
|
||
memo's own Option B in `main.py` and watching the whole suite stay green. **10f
|
||
pins the claim-source set:** every `claims.append(...)` in `Controller.cycle()`
|
||
is read off its AST and must be one of eight named sources, and real
|
||
`Controller.cycle()`s — spied at main.py's own `resolve`, swept over the whole
|
||
0–100 % state-of-charge range in both the idle and the floor-limited state — must
|
||
hand the arbiter exactly `{device, supervised, loop}` and
|
||
`{device, supervised, loop, peak}`. A ninth source, at any state of charge, in
|
||
any configuration, takes part 10 red on the line it is written. A floor built
|
||
inside `control.compute()` or the guard instead moves 10a–10c's replayed numbers,
|
||
which the replay drives directly. So the exposure cannot change size under a
|
||
green suite.
|
||
|
||
**Nine mutations, re-run against the fixed tree**, each against a freshly copied
|
||
tree with no `__pycache__` present at all, mounted `:ro` — the same structural
|
||
immunity `MUTATION.md` records for CI. Six are the AC 3 and behaviour mutations
|
||
already on this ticket's record (the status row's scope sentence, the
|
||
notification's, `config.yaml`'s, the memo deleted, the loop's gain zeroed, the
|
||
guard's claim given a `hi`); two are the reviewer's, and are the ones that
|
||
motivated 10f — the memo's own Option B at its recommended default, **now three
|
||
checks red where it was fully green**, and an absurd always-binding 95 % reserve,
|
||
**six red**; one is new, against the banner short form below. All nine are red,
|
||
no survivors, and no byte delta is `+0 B`.
|
||
|
||
**The gate has never once graded the content a pull request would land, and a
|
||
run that never started reported as a test failure (CI-02).** Both halves were
|
||
measured on a deliberately conflicted throwaway pull request rather than read
|
||
off the workflow, because the Actions log endpoints 404 on this Gitea and a
|
||
step's own conclusion is the only channel out of a job.
|
||
|
||
**Case 1, and the ticket's mechanism was wrong in a way that made it worse.**
|
||
CI-02 was filed saying a conflicted pull request loses its merge ref, so that
|
||
`git fetch $GITHUB_REF || git fetch $GITHUB_SHA` falls through and grades the
|
||
bare branch head. The symptom was real — PR #14 reported `checks / unit ->
|
||
success` while `mergeable: false` — but the mechanism is not. On this server
|
||
`GITHUB_REF` **is** `refs/pull/<n>/head`, `GITHUB_SHA` is the same commit, and
|
||
`git ls-remote origin 'refs/pull/*'` advertises no `/merge` refs at all, for
|
||
open and closed pull requests alike. The first fetch has always succeeded; **the
|
||
fallback has never fired once.** So the gate did not lose merged content on
|
||
conflicted pull requests — it has never had merged content, on any pull request
|
||
in this repository's history. Green has meant "this branch passed on its own",
|
||
which on a base that has moved is not what lands, and `block_on_outdated_branch`
|
||
is off on release/1.0 so the base usually has moved.
|
||
|
||
**The checkout now fetches the base tip and the pull request head, merges them,
|
||
and refuses if the merge does not apply** — as its own named step, so
|
||
`/actions/runs/<id>/jobs` distinguishes "nothing was tested" from "the tests
|
||
spoke". Verified by producing it: with the fix on a conflicted pull request the
|
||
required context went `failure` in 2 s with the two test steps `skipped`. The
|
||
`--depth 1` is gone (a merge needs a merge base; a full fetch of this repository
|
||
is 6.8 MB and 1.1 s), and the fallback survives only on the `workflow_dispatch`
|
||
path, which has no pull request to merge and now says so in its output.
|
||
|
||
**Case 2: `concurrency:` is gone, and `cancel-in-progress` was never the knob.**
|
||
Runs 531 and 533 on PR #14 were cancelled at 0 s each at the exact second the
|
||
next run was created, having never started; a cancelled run posts `failure` on
|
||
the required context, described `Canceled after 0s`, and branch protection
|
||
compares state and not description — so "nothing ran" and "your tests are
|
||
broken" arrive at the gate as the same red, wanting opposite responses. Three
|
||
settings were measured with one run holding the single runner slot and two
|
||
pushes behind it: with **no block at all** both queued runs survived and
|
||
executed; with the group kept and **`cancel-in-progress: false`** the queued run
|
||
was still cancelled at 0 s. A shared group cancels a run that has not started
|
||
regardless of that flag. Having no group is the only configuration measured to
|
||
leave a queued run alive, and it is what ships. The cost — a developer who
|
||
pushes four times occupies the shared runner four times — is CI-01's own trade
|
||
written the other way round: a delayed verdict, never a destroyed one.
|
||
|
||
**`test_ci_gate.py` grades the gate by running it, not by reading it.** It
|
||
extracts the checkout and merge steps out of `checks.yml` and executes the
|
||
shipped commands against two throwaway local repositories it builds — one whose
|
||
pull request merges, one whose does not — and pins that the old one-liner exits
|
||
0 on the unmergeable one while the new steps refuse. It also pins the two tokens
|
||
that assemble `checks / unit (pull_request)`: renaming `name:` or the `unit:`
|
||
job key would leave every open and future pull request waiting forever on a
|
||
context that never arrives, with no error anywhere in that sequence. Seven
|
||
mutations of `checks.yml` were each confirmed red. `.gitea/ci/Dockerfile` gains
|
||
`git` for it.
|
||
|
||
**What was NOT established, stated so nobody reads more into this.** The
|
||
original report that "no run was ever created for `0942228d`" was already
|
||
corrected on the card — run 533 was created and cancelled — and this work
|
||
confirms that from the runs list and the per-commit status history. A commit
|
||
that never had a run carries an empty `/statuses/<sha>` list; a queued one
|
||
carries `pending / Waiting to run` within seconds, and a concurrency-held one
|
||
`pending / Blocked by required conditions`. Those are distinguishable at the
|
||
API. `/actions/tasks`, which is what was polled originally, lists only tasks
|
||
that started, and cannot answer the question at all.
|
||
|
||
**The NOT READY banner no longer repeats the row underneath it.**
|
||
`status_payload()` renders the first hard-failing check as the page headline, so
|
||
the scope sentence above turned the loudest element on the page into a
|
||
550-character paragraph that the checks list then repeated verbatim. A row may
|
||
now carry an optional short `banner` form; the floor-limited row does, and its
|
||
headline is the guard's own line plus a 68-character scope tag. **The row itself
|
||
is unchanged** — the checks list is where an operator reads what the floor does
|
||
and does not bound, and shortening that would have been weakening AC 3. Rows
|
||
without the key are unaffected, and the page still renders with `textContent`.
|
||
|
||
**A setpoint device smaller than `max_w` silently disarmed the frozen-meter
|
||
detector, and the status page reported it as armed (SAFETY-20).** SAFETY-18's
|
||
conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the emitted command is
|
||
clipped to the setpoint device's own `[dev_min, dev_max]` range as well as to
|
||
`max_w` — in `clamp_to_rating()` and again as the arbiter's `device` P_SAFETY
|
||
limit claim. Measured on the shipped predicate at the ticket's numbers,
|
||
`max_w: 5000` on a ±4700 W inverter:
|
||
|
||
looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000) -> False
|
||
|
||
The command is pinned at the hardware's rail, the meter has been frozen for an
|
||
hour, and term 2 is unsatisfiable by construction because 4700 < 4750. Any
|
||
rating at or below `0.95 * max_w` disarms the conjunction outright. The page
|
||
then printed `armed (… command is over 4750 W)` — a guard that cannot fire,
|
||
advertising that it can, which is the failure SAFETY-15, SAFETY-19 and
|
||
validate.py rule 5 all exist to prevent.
|
||
|
||
**The rail is now measured against `min(max_w, |dev_min|, |dev_max|)`** —
|
||
`freeze.rail_ceiling_w()`, threaded through `looks_frozen()`,
|
||
`FreezeWatchdog.evaluate()` and both call sites in `main.py`. An unread rating
|
||
(`hass.limits()` returning `None` when ESPHome is not up) leaves the old
|
||
behaviour exactly, and correctly: on that path no device clamp is applied
|
||
either, so `max_w` really is the ceiling. A rating *larger* than `max_w` cannot
|
||
raise the rail — this is a `min()`, not a swap. The status row prints the rail
|
||
the predicate actually uses and names the device when the device is what capped
|
||
it; the log line and the HA notification do the same, since they outlive the
|
||
page.
|
||
|
||
**The smaller of the two device bounds, not a per-sign pair.** A per-sign rail
|
||
is tighter and is not wrong, but it is two rails needing two coverage verdicts
|
||
on a status row that carries one number, and the only configuration that
|
||
distinguishes them is refused at startup anyway. The symmetric ceiling can only
|
||
*lower* the rail, which widens term 2 rather than disarming it; term 1 carries
|
||
the no-false-positive argument and is untouched.
|
||
|
||
**A ceiling of 0 W is NOT COVERED, not "armed at 0 W".** A device that can
|
||
command nothing in one direction would make `abs(command) >= 0` true of every
|
||
command including a parked one — term 2 degenerately *true*, collapsing the
|
||
conjunction into the bare `unchanged_s > N` threshold TEL-05 refused for
|
||
false-tripping at this controller's own operating point. `rail_unreachable()`
|
||
returns the reason, `looks_frozen()` stays silent, and the page uses the NOT
|
||
COVERED row the unsupported-transport case already has — a second reason to use
|
||
that surface, not a new mechanism.
|
||
|
||
**AC4, decided rather than assumed: no new `validate.py` rule.** The
|
||
configuration is `max_w` above the device rating, which rule 2 (CFG-01) already
|
||
refuses on the identical predicate, re-run by `run_control()` once the real
|
||
rating is known. A rail-specific rule would fire on a strict subset of what rule
|
||
2 refuses (`0.95 * max_w > dev_max` implies `max_w > dev_max`) and could never
|
||
be the rule that stopped a start. The residue is the state where `hass.limits()`
|
||
answered `(None, None)` and the range checks were skipped — there is nothing to
|
||
refuse there, by construction, which is why the answer is a runtime row. Written
|
||
into validate.py's "deliberately not refused" list, and *pinned* by four checks
|
||
in `test_validate.py` so the decision's premise goes red if rule 2 is ever
|
||
softened back to a warning.
|
||
|
||
**And it was verified by breaking it.** Reverting `rail_w()` to bare `max_w`
|
||
fails nine checks; deleting the NOT COVERED handling fails five; and — the one
|
||
that mattered — shipping the corrected predicate while *not* passing the rating
|
||
from `check_meter_frozen()` originally left the whole suite green, because
|
||
nothing graded the caller. `test_freeze.py` part 5b now drives the real
|
||
`cycle()` on a ±4700 W device and asserts on the notification that leaves the
|
||
process; the unwiring mutation fails three checks there. The peak-guard call
|
||
site's wiring is still ungraded — it sits behind `p1_enabled` and needs a fed
|
||
averager to have any observable consequence — and that gap is named in the file
|
||
rather than implied.
|
||
**A mutation run could grade code that was not there, and the fix is a purge
|
||
rather than a flag (TEST-15).** FCST-03's developer reverted `self.provisional`
|
||
to `here.provisional`, re-ran, and reported a survivor; disassembly showed the
|
||
source read `here` while the running code loaded `self`. CPython validates a
|
||
`.pyc` on (source size, source mtime truncated to whole seconds), and a
|
||
size-preserving edit reverted inside one second moves neither number, so the
|
||
stale bytecode was reused. That does not make past results wrong - it made them
|
||
unverified, and the distinction is the whole card.
|
||
|
||
Reproduced rather than reasoned about. `test_bytecode.py` builds a real package
|
||
whose two return values are the same length, imports it in a real subprocess,
|
||
rewrites it inside the same second and reads back which value ran. Part 1 shows
|
||
the interpreter running `SELF` off a file that says `HERE`.
|
||
|
||
**And it shows the two obvious mitigations do not work.** `python3 -B` and
|
||
`PYTHONDONTWRITEBYTECODE=1` are write-side only: neither stops an EXISTING stale
|
||
`.pyc` being read, and a mutation run always has one because the previous run
|
||
wrote it. The advice already in `test_control.py`'s coverage audit led with `-B`
|
||
and is corrected here - the purge is the half that works, and `-B` earns its
|
||
place by protecting the NEXT edit rather than this one. No `sleep` anywhere:
|
||
one-second mtime resolution is the mechanism, and waiting it out would slow every
|
||
run down while still only covering the edit somebody remembered to slow.
|
||
|
||
`goodwe_controller/tools/mutation_run.sh` is the harness the house adopts: it
|
||
purges, prints a sha256/byte fingerprint of every `app/*.py` it is about to
|
||
grade - so a mutation report can say WHICH source produced it - runs each suite
|
||
under `python3 -B`, refuses a tree with no `test_*.py` (CI-01's rule), and fails
|
||
if a cache appears during the run. It takes the tree as an argument so the infra
|
||
repo's replay harnesses can call it.
|
||
|
||
`MUTATION.md` states the guarantee where a mutation gets written, and every one
|
||
of the 16 suites now points at it from its docstring - a list `test_bytecode.py`
|
||
derives from the tree, so a new suite joining without the pointer is red.
|
||
|
||
CI was already immune and is described that way rather than as a hole being
|
||
closed: `checks.yml` mounts the checkout `-v "$WS:/w:ro"`, and a read-only tree
|
||
cannot take a `__pycache__` at all - measured, and it fails silently, exit
|
||
status 0 - while each job `rm -rf`s its workspace and clones afresh.
|
||
`PYTHONDONTWRITEBYTECODE=1` is added as belt and braces, because the `:ro` is one
|
||
character wide. The shipped add-on was never affected: `Dockerfile` has set
|
||
`PYTHONDONTWRITEBYTECODE=1` since it was written.
|
||
|
||
**The survey of this week's mutation claims is in `MUTATION.md`, scoped by the
|
||
property rather than by re-running everything.** The defect is one-directional -
|
||
stale bytecode turns a red into a green survivor and never the reverse - so every
|
||
"all red, no survivors" result in the window is not at risk, and the exposure is
|
||
the recorded SURVIVORS from source-edit runs made before the purge discipline
|
||
landed on 2026-08-31. Two of them were re-run here under the harness and both
|
||
still stand (TARIFF-01's `record_block()` ignoring its `policy` argument;
|
||
SAFETY-03's `abs()` on the clamp limit). The rest are labelled still-unverified
|
||
rather than quietly assumed or noisily re-run.
|
||
|
||
Out of scope and recorded: the infra repo's `sim/` replay harnesses import the
|
||
add-on and write bytecode into both trees. Anyone mutating a module graded by a
|
||
replay must purge both by hand until that repo adopts the harness.
|
||
|
||
**`release/1.0` went red at midnight on a commit nobody had touched (TEST-17).**
|
||
`Controller.checks()` read the capacity-tariff peak with `self.peak.peak()` - no
|
||
argument - so `tariff.py` resolved the month from `datetime.now()`, while
|
||
`test_tariff.py` section 10 banks its quarter at the fixed fixture
|
||
`2026-08-30`. From 1 September the page was correctly asked for September,
|
||
correctly answered `NO_RECORD`, and the row read *"no completed quarter recorded
|
||
yet"* - a string containing no `"peak"`, which the section's filter required.
|
||
Four checks failed. The suite had been a latent time bomb since TARIFF-01
|
||
merged on 2026-08-30 and every PR that ran it ran in August.
|
||
|
||
Isolated by holding the commit and the image constant and moving only the clock
|
||
under `libfaketime`: green at 2026-08-30, four failures at 2026-09-01, identical
|
||
at `f4914a1`, `04292c4~1` and `04292c4`. The calendar, not a regression.
|
||
|
||
**The product was right and the test was wrong**, so the fix does not change what
|
||
the live page says. `checks()` takes `when: datetime | None = None` and threads
|
||
it into `peak()` and `warning_frac()` - the convention every other peak query in
|
||
`tariff.py` already follows. The single production caller passes nothing and
|
||
still means today. Section 10 now grades at its own fixture's month.
|
||
|
||
Three new checks make the defect detectable rather than seasonal: the same
|
||
controller read at two different reference months must produce two different
|
||
rows, which no clock can satisfy; the later month must read `NO_RECORD` and stay
|
||
distinguishable from both an observed peak and an unreadable store; and calling
|
||
`checks()` with no argument must still mean today. Verified by mutation - a
|
||
`checks()` that drops `when` is now red at every clock tried, where the old
|
||
section went green in August. Full suite green, 15 files, at the real clock and
|
||
at faked clocks in four other months.
|
||
|
||
One thing left alone deliberately: only the UNREADABLE branch prefixes its row
|
||
with `capacity-tariff peak:`. The `NO_RECORD` row carries neither that phrase
|
||
nor the word `peak`, which is what made the original filter brittle and what
|
||
broke the first draft of the new checks too. It is product text with other
|
||
suites reading it, so it is noted here rather than changed.
|
||
|
||
**Thirty days of the real meter, and the acceptance criterion three cards
|
||
deferred is now a number (DATA-01).** TARIFF-01 and SAFETY-07 both merged with
|
||
AC 3 - *"monthly peak held under the soft limit in >= 95 % of intervals over a
|
||
30-day simulation replay"* - honestly unmet, on the belief that only two days of
|
||
capture existed and nothing longer could be produced. That belief came from a
|
||
true statement about the past being read as one about the future: HA's recorder
|
||
still held the whole month. `scenarios/p1-net-30d-2026-08/` is 2026-08-01 to
|
||
2026-08-30, **485,841 samples, 30/30 days read, 0 failed, 4.13 MB gzipped**, in
|
||
the same schema as the two days that were already there.
|
||
|
||
`tools/export_p1_history.py` is the exporter, committed and runnable - the
|
||
2026-08-25 one-off was not, and that is exactly why the data was thought
|
||
unobtainable. It reads day-at-a-time, retries, and **never lets a failed read
|
||
look like an empty hour**: a day whose read fails writes no file, is recorded as
|
||
`"read": "FAILED"`, and the run exits non-zero; the entity is proved to exist
|
||
before any empty day is believed. Nothing is interpolated, so the month's 13
|
||
gaps (1291 s, 99.95 % covered) and its 3 `unavailable` samples stay visible.
|
||
`--verify` re-fetches the two committed days by the same route and reproduces
|
||
them **byte for byte** after decompression.
|
||
|
||
`tools/replay_ac3.py` runs the month. Over 30 days the criterion measures
|
||
**98.89 % uncontrolled and 98.99 % guarded**, so it is met on that reading - and
|
||
the uncontrolled figure is the finding: a month with no controller at all passes
|
||
it. On the other reading of the same sentence it is not met - the guard takes
|
||
August's peak from **5029.8 W to 4107.3 W**, still over the 3500 W soft limit,
|
||
because once a month peak is set `CEILING_MONTH_PEAK` defends that peak rather
|
||
than the cap. Neither number is hidden; both are in that directory's README.
|
||
|
||
The size decision is committed and measured, not absorbed: a `git bundle` of
|
||
the whole history goes from **1.58 MB to 5.28 MB** (+3.70 MB, x3.3), which a
|
||
Home Assistant supervisor clones on every install. Downsampling was not
|
||
available - `meter_max_age_s` is 30 s, so any coarser resample converts real
|
||
coverage into modelled meter-gap and moves the number being measured.
|
||
|
||
The thirty files sit in a SUBDIRECTORY on purpose: `test_tariff.py` part 9 and
|
||
`test_peakguard.py` part 6 glob `scenarios/p1-net-*.jsonl.gz` non-recursively,
|
||
so CI keeps replaying the two small days and does not grow ~50 s per PR. Both
|
||
files' honesty notes are corrected to point at the month.
|
||
|
||
**OPT-04's AC4 is met: twelve months of real EPEX BE day-ahead prices are now in
|
||
the repository, and the strategy has been replayed against them (DATA-02).**
|
||
AC4 asked for "net-positive across 12 months of replayed prices". OPT-04 shipped
|
||
it as NOT MET and replayed a synthetic year instead, because `scenarios/` held
|
||
only P1 meter captures. That was the right call at the time and the wrong
|
||
diagnosis of why: the criterion was being carried alongside a 30-day *meter*
|
||
criterion as though both needed waiting, and EPEX BE day-ahead is **public data,
|
||
not a capture**. It needed a fetch, not a month.
|
||
|
||
`scenarios/epex-be-dayahead-2025-09-01_2026-09-01.json` — **8760 hourly points**,
|
||
2025-09-01 to 2026-09-01 Brussels-local, in §9.3's canonical three-field shape.
|
||
`scenarios/fetch_epex_be.py` produced it and `--verify` re-derives every claim it
|
||
makes about itself without touching the network.
|
||
|
||
**Licensing was checked before anything was committed.** The source is
|
||
Fraunhofer ISE's Energy-Charts API, which publishes the **BE** zone under
|
||
**CC BY 4.0 from Bundesnetzagentur | SMARD.de** and other zones under
|
||
private-use-only terms — so this zone is redistributable with attribution and the
|
||
data is committed rather than left as a script plus a checksum. The fetcher
|
||
**refuses to write a file** whose per-response `license_info` stops saying
|
||
CC BY 4.0. Cross-checked against the upstream: SMARD's own `4996/DE` series
|
||
matched this feed on all 96 quarter-hours of 2026-04-10, maximum difference
|
||
0.00 EUR/MWh.
|
||
|
||
**The figures are raw wholesale and the file says so.** `"all_in": false`, with
|
||
the markup/tax assumption carried in `provenance.all_in_assumption` as *numbers
|
||
to apply* rather than numbers already applied — TARIFF-02's double-counting trap
|
||
in data form. `test_arbitrage.py` section 7B applies it in one visible place and
|
||
**asserts arithmetically** that its two additive terms cancel out of every daily
|
||
spread, so the trade counts depend on the VAT multiplier alone and not on the
|
||
guessed markup. The same replay is run on raw wholesale as well, so how much of
|
||
the answer rests on the guess is reported (110 trades all-in, 88 raw).
|
||
|
||
**DST is handled by construction, not by a special case.** The fetcher iterates
|
||
in UTC and renders through `price.brussels()`, so **2025-10-26 comes out 25
|
||
points and 2026-03-29 comes out 23** — verified by counting per Brussels day
|
||
rather than asserting 24, and by checking that October's 02:00 appears twice at
|
||
`+02:00` and `+01:00` with two different prices while March's 02:00 does not
|
||
appear at all. The window is bounded at *local* midnight: bounding it at 00:00
|
||
UTC produced a 22-hour first day, and a 24-hour day silently missing from the
|
||
end, both of which the point-and-gap accounting caught.
|
||
|
||
**The result, at the shipped default, whatever it is.** At
|
||
`cycle_cost_threshold_eur` **0.10** widened by FCST-03's no-weather **1.5×** to a
|
||
required 0.1500 EUR/kWh, the real year completes **110 round trips** and is
|
||
**net-positive at +24.88 EUR** of benefit net of the cycle cost (2 kWh a trade).
|
||
Unwidened it completes 173. **Nothing was tuned:** the shipped default and the
|
||
2.5/1.5 factors are untouched, and every figure comes out of the same `decide()`.
|
||
|
||
**What the product owner's open decision now has under it.** The four
|
||
independent reports that a static day/night spread of ~0.0840 does not clear
|
||
0.10 **all stand** — a static-tariff site still never trades. What was missing
|
||
was the other half: a *dynamic* site's daily spread clears the widened
|
||
requirement on **121 of 365 days**, median daily all-in spread **0.1130**. The
|
||
threshold at which a median day would clear is **0.0754**, and at that value the
|
||
same year completes 156 trips for +35.42 EUR. Both numbers are reported; neither
|
||
is applied.
|
||
|
||
**And the synthetic year stays, because the real one cannot grade §17.** Not one
|
||
of the 365 real days has a negative *dearest* hour — 372 negative wholesale hours
|
||
and 0 days where the strategy would have sold into one — so "0 discharges into a
|
||
negative-price hour" over real prices is a count of something that could not
|
||
happen, and deleting the guard would not change it. Section 7B asserts that
|
||
degeneracy instead of quietly reporting the zero; section 7's generator is still
|
||
the only thing here that puts a negative price where the strategy would sell.
|
||
|
||
**Size, measured rather than assumed.** Plain JSON: 893 KB in the working tree
|
||
but **74.6 KB as a git object**, against 56.5 KB for a committed `.json.gz`. Git
|
||
already compresses blobs, so gzipping saves 18 KB and costs a reviewer the
|
||
ability to read the provenance block — including the licence claim they are
|
||
meant to check — in the diff. Plain, and `load()` still accepts `.gz` for a
|
||
future dataset that earns it.
|
||
|
||
**Nothing is wired into the add-on.** `generic_dayahead` remains the live fetch
|
||
path; nothing under `app/` imports the script, and the dataset is for replay.
|
||
|
||
**The capacity-peak guard's forced discharge now has an EMS-side energy floor
|
||
(SAFETY-24).** SAFETY-07 can command a discharge to hold a billed quarter down,
|
||
and nothing below it consulted the state of charge — `soc_floor` belongs to the
|
||
maintenance cycle, and freeze.py already recorded that for the grid-following
|
||
loop "the BMS is the sole energy backstop". On the 2026-08-20 replay the guard
|
||
asks for **878 Wh in a day**. Relying on the BMS is not absurd — it is a real
|
||
floor and it works — but it is the pack's protection rather than the operator's,
|
||
and a month of aggressive quarters leaves the battery repeatedly at that cut-out
|
||
with no EMS-side record of why.
|
||
|
||
`decide()` now takes `soc` and `soc_floor`, both **required** and neither
|
||
defaulted. Above the floor nothing changes. At or below it the guard files a
|
||
**0 W** floor instead of a sized one — one-directional, so charging stays blocked
|
||
and no discharge is capped — and reports `floor_limited` with the reason it
|
||
bound. It is not a trip: every stop in §11.2's table still outranks the claim,
|
||
and the floor can never be negative, so §9.1's "discharge and export are never
|
||
capped by the peak guard" survives as a property of the object (swept: 0 negative
|
||
floors across 1296 combinations of load, elapsed, battery, SoC and floor).
|
||
|
||
**Failing to shave is not silent.** A floor-limited cycle raises one notification
|
||
per episode (`goodwe_peak_soc_floor`), distinct from the saturation alarm because
|
||
the two ask the operator for opposite things, and renders its own status row.
|
||
An **unreadable SoC is floor-limited, not a full battery** — a full battery is
|
||
exactly the value that would disarm the floor, and this project has shipped that
|
||
defaulting once before (`| float(100)` in the stage-5 loop).
|
||
|
||
**Verified by replay, not by argument.** The 2026-08-20 capture is driven against
|
||
a modelled pack sized from the day's own measured appetite, so it genuinely
|
||
exhausts: without the floor it reaches the BMS cut-out and sits there for 1227
|
||
guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The
|
||
energy withheld equals the energy between the two floors to within one cycle at
|
||
the rail. The honest half is reported too — the worst quarter goes from 4338 W to
|
||
5030 W, because a floor-limited guard shaves less on purpose.
|
||
|
||
**Two new options, mirrored once.** `peak_soc_floor` (20) and `bms_soc_floor`
|
||
(10), whose defaults live in `peakguard.PEAK_OPTION_DEFAULTS` — TARIFF-02's
|
||
pattern — imported by `validate.py` and `main.py` rather than typed as literals,
|
||
and pinned to `config.yaml` by a check that reads the file. CFG-03 still tracks
|
||
three places. `validate.py` rule 8 refuses `peak_soc_floor <= bms_soc_floor` at
|
||
startup, in CFG-01/MAINT-04's manner. `maintenance_soc_floor` is deliberately
|
||
**not** reused and deliberately **not** renamed: it is a drain target, not a
|
||
spend limit, and the rename is recorded as a decision to revisit if a third
|
||
floor ever appears.
|
||
|
||
**Price arbitrage - the first strategy that trades on a price (OPT-04).**
|
||
Four cards built the price stack and nothing traded on it. `app/arbitrage.py`
|
||
is §10.1's priority 3: charge in the cheapest published hour, discharge in the
|
||
dearest, **and only when the spread covers `cycle_cost_threshold_eur`**. It is
|
||
off by default (`arbitrage_enabled: false`) - it is the first strategy in this
|
||
add-on that commands the battery against the meter on purpose.
|
||
|
||
**It consumes the four cards rather than re-deciding anything.** Whether a
|
||
trade is permitted at all, on which two hours, and against which widened
|
||
threshold is `TariffPlan.arbitrage()`'s answer (TARIFF-03), and that answer
|
||
cannot be obtained without first stating whether the peak guard is holding. So
|
||
§9.1's "give peak shaving priority over arbitrage" is honoured
|
||
**structurally**: there is no code path to a tradeable spread that skips the
|
||
question. And because the claim is filed at `P_ARBITRAGE` (30) while peakguard
|
||
files its floor at `P_PEAK` (50), the arbiter clamps an arbitrage charge even
|
||
if that interlock were bypassed. Two independent mechanisms, neither
|
||
re-implemented here.
|
||
|
||
**A guard that never ran is not a guard that is clear.** On `meter_source: off`
|
||
there is no telegram stream, so the peak guard is never evaluated. That third
|
||
state is carried as `None` and it **refuses**: charging at the rail creates the
|
||
billed quantity while nothing measures the quarter it lands in. `validate.py`
|
||
refuses that configuration at startup too, along with arbitrage armed on a site
|
||
with no price source at all, and a SoC floor at or above the ceiling.
|
||
|
||
**§17: never export into a negative price.** The guard is on the hour being
|
||
sold into, not on the spread - a day priced -0.30 ... -0.05 has a 0.25 EUR/kWh
|
||
spread that clears any shipped threshold and a dearest hour that is still
|
||
negative. Buying in a negative hour stays allowed; being paid to charge is the
|
||
right trade.
|
||
|
||
**Prices are fetched in the background, never on the tick.** `resolve_tariff()`
|
||
may open an HTTPS connection, and the control tick is what feeds the ESP32
|
||
heartbeat - a supplier's TLS timeout there is a supplier able to push the
|
||
add-on towards layer 1's 30 s veto. A separate task refreshes every 30 minutes;
|
||
the tick reads an attribute and an age bound, and a plan whose refresh has
|
||
stopped answering retires rather than standing as if it were current.
|
||
|
||
**What is honestly not measured.** The acceptance criterion asked for
|
||
net-positivity across 12 months of *replayed* prices. This repository holds no
|
||
price history - `scenarios/` is P1 meter data - so that criterion is **not met,
|
||
and is not restated**. What `test_arbitrage.py` section 7 does instead is
|
||
replay a synthetic year and assert the property that does not need real prices:
|
||
every executed round trip cleared its own widened requirement, on all of them,
|
||
across a year containing 522 hours priced below zero and zero discharges into
|
||
one. And at the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night
|
||
site **does not trade at all** - reported, not tuned. That number, and the
|
||
widening factors beside it, are one product decision and it is open.
|
||
|
||
**Provisional prices, and what may not rest on one (FCST-03).**
|
||
Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says
|
||
the unknown hours use yesterday's profile and are flagged `provisional: true` —
|
||
and **no irreversible action may be taken on a provisional price.**
|
||
`app/price_forecast.py` does the fill, and the whole module is shaped by one
|
||
defect: a carried-forward point and a published one are the same three fields
|
||
and a bool, so the natural line `PricePoint(..., provisional=y.provisional)`
|
||
copies yesterday's `False` onto today's guess and produces stale data presented
|
||
as current. That is the meter-side SAFETY-18 failure one layer up — the reading
|
||
arrives on time, looks fresh, and is wrong.
|
||
|
||
**So the flag is never passed through.** Carried points are built by a
|
||
constructor that has no `provisional` parameter at all (the literal `True` lives
|
||
in one place, and there is nothing for a caller to forget or copy); published
|
||
points are passed through **by identity**, so there is no copy that could lose a
|
||
flag; and an audit re-derives the expected flag for every emitted point from the
|
||
set of hours the feed actually published and *raises* rather than repairing one.
|
||
A published point may itself be provisional — `eneco_eddy`'s current-quarter
|
||
imbalance print is — so the audit's rule for a published hour is "whatever the
|
||
feed said", never "False".
|
||
|
||
**The fill keys on the Brussels wall clock, not on `t − 24 h`.** A 23-hour day
|
||
has no 02:00 and a 25-hour day has two, so a fixed 24-hour subtraction shifts
|
||
every carried hour by one across a transition and misprices a whole day with
|
||
nothing to show for it. Re-running the fill after a late publication replaces
|
||
the carried hours with the real print: the number and the flag move together
|
||
because the series is rebuilt, never patched.
|
||
|
||
**Arbitrage now drops provisional hours instead of refusing the whole day.**
|
||
The refusal that read "the plan is provisional" would have switched arbitrage off
|
||
every morning on every dynamic site once carry-forward filling landed — a
|
||
part-firm series is the *normal* state before the print. What §8.3 forbids is
|
||
acting **on** a provisional price, so the provisional hours are dropped and the
|
||
spread, the cheapest hour and the dearest hour are computed over the published
|
||
ones only; fewer than two firm hours is still a refusal, which is where the
|
||
§11.4 fallback (wholly provisional) lands. The permitting variant now refuses to
|
||
be *constructed* around a provisional point at all. The cheap-window flag became
|
||
per-hour for the same reason, so a site's maintenance charge floor does not go
|
||
dark every morning.
|
||
|
||
**Real-time / imbalance products get measurably wider thresholds.** One widening
|
||
path, driven by a set of active uncertainty sources: the product's own (§9.2 row
|
||
2, derived from the declared `PriceProduct` and never from a plugin id) and
|
||
§11.4's "no weather data" row, which the spec widens by a different trigger and
|
||
which is the same mechanism. The combination rule is `max`, not a product — two
|
||
independent doubts must not compound into a threshold nothing can clear. The
|
||
caller supplies the base round-trip cost threshold, so OPT-04 still owns what
|
||
the number *is*; this owns only how much wider it gets and why, and the allowed
|
||
verdict reports the threshold it cleared.
|
||
|
||
**An irreversible action is a value, not a judgement call at each call site.**
|
||
`Irreversible` carries the hours an action commits — the maintenance drain's
|
||
window is derived from `maintenance.DRAIN_TIMEOUT_MIN` rather than typed twice —
|
||
and the gate looks at exactly those hours, blocking on a provisional one, on an
|
||
hour the series does not cover, and on no price data at all: three different
|
||
reasons, none of them an answer. ⚠️ It is the entry point for a start being
|
||
*justified by a price*; the calendar path main.py runs today is untouched,
|
||
because a gate that blocked on `no_data` would stop the monthly cycle on every
|
||
default install — §12's opening sentence.
|
||
|
||
**Zero new options.** No `config.yaml` key, no new defaults dict, no `o.get(k,
|
||
X)` in main.py: the module reads no options at all, so CFG-03 did not widen.
|
||
|
||
**The grid-following loop is now capped against the month's billed capacity peak
|
||
(SAFETY-07).** New `app/peakguard.py`. TARIFF-01 measured the quarter; this is the
|
||
consumer it was built for. Every cycle the guard projects the open quarter,
|
||
compares it to a ceiling and files ONE arbiter claim: a lower bound on the
|
||
setpoint at `P_PEAK`. That shape is both behaviours §10.1 asks for — a hard clamp
|
||
on charging and pre-emptive discharge — and it cannot cap discharge or export,
|
||
because a floor cannot. `excess = 900 × (projected − ceiling) ÷ remaining`, and
|
||
the floor is the MEASURED battery power plus that: a floor built on the standing
|
||
command winds itself to the rail whenever the inverter fails to follow, and one
|
||
built on delivered power cannot.
|
||
|
||
**It trips where SAFETY-18 only notifies, and the captures decided that.** On the
|
||
healthy reference day it engages on zero of 95 quarters; the action is
|
||
level-triggered, unlatched and re-derived every cycle, so a false engagement costs
|
||
one cycle of discharge rather than a parked battery. Replayed on 2026-08-20 the
|
||
billed quarter goes from **5029.8 W — above the inverter's own 5000 W rating — to
|
||
3485 W**, under the soft limit and without saturating. Quarters under the limit
|
||
across both committed days: 189/190 → 190/190. Two days is not thirty; AC 3 is
|
||
still TARIFF-01's open item and this does not claim it.
|
||
|
||
**Blindness forbids importing, and it is checked before the projection is
|
||
graded.** A fresh averager projects a confident 0 W, which is under every ceiling
|
||
there is; a meter gap longer than `meter_max_age_s` makes the projection read LOW;
|
||
and a frozen meter reading export projects 0 W at full coverage. All three become
|
||
a floor of 0 W, which is one-directional and cannot cost pack energy. That is the
|
||
backstop SAFETY-18 could not be — its alarm arrives at 300 s of a 900 s quarter
|
||
and commands nothing. The frozen term is `freeze.looks_frozen()` itself, evaluated
|
||
on this cycle's standing command, not a second copy of the conjunction.
|
||
|
||
**The ceiling is `peak_cap_w`, raised only by an OBSERVED reading.** `NO_RECORD`
|
||
and `NOT_OBSERVED` both leave it at the cap, so an unreadable peak store makes the
|
||
guard STRICTER. The gate is the reading's STATE, not whether a number happens to
|
||
be attached to it: today a `peak_w is not None` test behaves identically, and
|
||
depending on that coincidence is how a future last-known-peak field would silently
|
||
raise the ceiling off a read that failed. `headroom_w()` is deliberately not
|
||
consumed — `cap − peak` turns a zero peak into FULL freedom.
|
||
|
||
**Two defects found building it, both in the interaction rather than the
|
||
arithmetic.** A LIMIT does not out-vote another LIMIT — `arbiter.resolve()` applies
|
||
every limit at or above the winner's priority and takes the most restrictive — so
|
||
the peak floor (`lo ≥ 0`) and the maintenance charge-only shaping (`hi ≤ 0`)
|
||
resolved to a CONTRADICTION and 0 W, parking the battery in the exact quarter it
|
||
should shave. Fixed by suppression rather than priority: main.py drops the
|
||
maintenance shaping while the guard is engaged, which is what that branch already
|
||
did for the optional `peak_forecast_entity` — a key that is empty on a default
|
||
install, so until now the suspension could not fire on the site that needed it.
|
||
And thresholding `excess > 0` released the claim the instant it started working,
|
||
producing seventeen engage/release cycles in one 900 s quarter (§10.2's
|
||
oscillation) and leaving that quarter 49 W over; the threshold is now the floor
|
||
itself, which is the same arithmetic read as a level rather than as a switch.
|
||
|
||
**`PeakTracker.project()` no longer reads a private name with a default.**
|
||
`getattr(averager, "_last_net", 0.0)` failed quiet AND in the disarming direction:
|
||
a rename credits 0 W held, the projection reads low, and the money guard does not
|
||
engage. `QuarterAverager.held_net_w` is now a documented public property, read
|
||
without a default, and `Projection` carries it. TEST-13 item 4.
|
||
|
||
**No new option, and no fourth defaults site.** The ceiling is `peak_cap_w`, the
|
||
coverage threshold is `meter_max_age_s`, the freeze predicate is `meter_freeze_s`,
|
||
and the achievable discharge comes from `max_w` and the device rating.
|
||
`config.yaml` and `validate.py` are untouched; §14's `capacity_peak_limit_kw`
|
||
stays declined as the kW/W mismatch trap it is. On `meter_source: off` there are
|
||
no telegrams, so the guard does not run at all and the status page says NOT
|
||
COVERED — running it there would have blinded it permanently and stopped every
|
||
legacy install from charging, which is what the first cut did until
|
||
`test_clamp.py`'s export case caught it.
|
||
|
||
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
|
||
§9.2 puts a large share of the Belgian installed base on a static profile, and
|
||
an EMS that only works under dynamic pricing is an EMS for a minority. A new
|
||
`static_profile` option describes a two-period day/night tariff — or a single
|
||
fixed price — as JSON, and `app/static_tariff.py` turns it into the same
|
||
canonical `{start, eur_per_kwh, provisional}` series the fetched providers emit,
|
||
so nothing downstream learns that a tariff can be static.
|
||
|
||
**It is not a sixth provider plugin, and the reason is mechanical.**
|
||
`PriceProvider.capabilities()` is a classmethod, so a plugin's `PriceProduct` is
|
||
fixed when the class is written — but §9.2 makes STATIC and FIXED two different
|
||
products with two different required behaviours, and which one a site is on is a
|
||
property of the operator's data, not of any class. `StaticProfile.product`
|
||
derives it from the number of distinct prices instead. The second reason: the
|
||
plugin framework exists to stop a *fetched* figure being marked up twice, and a
|
||
number the operator typed as all-in has no publisher to disagree with.
|
||
|
||
**So the profile sits where §11.4 already says it does — under the providers, as
|
||
the fallback.** The configured-product path and the feed-is-down path are the
|
||
same generator differing in one flag: `provisional`. A static-tariff site runs
|
||
that path every day, so the degradation path for a dynamic site cannot rot
|
||
unused.
|
||
|
||
**"No arbitrage" is a type, not a boolean somebody has to remember to check.**
|
||
`TariffPlan.arbitrage()` returns a variant: the *allowed* arm carries the
|
||
spread and the cheapest and dearest hours, and the *refused* arm carries a
|
||
reason and nothing else — `.spread_eur_kwh` on a refusal raises rather than
|
||
answering 0.0. An optimiser cannot compute a trade from a refusal because it
|
||
cannot get the inputs out of one. Four refusals, all derived: the peak guard is
|
||
holding, there is no price data, the plan is provisional, or **the series is
|
||
flat**. That last one is AC2 — a fully-fixed tariff disables arbitrage by
|
||
arithmetic rather than by matching on an enum, and a dynamic feed that happens
|
||
to print one price all day is refused for the same true reason.
|
||
|
||
**Peak shaving outranks arbitrage, expressed so OPT-04 cannot skip it.**
|
||
`arbitrage()` takes `peak_guard_active` as a required keyword argument, so the
|
||
spread is not obtainable without first answering whether §10.1's priority 2 is
|
||
holding. §9.1's capacity tariff is a network cost charged whatever the energy
|
||
product is.
|
||
|
||
**Time-window shifting reaches the one shiftable load that exists.** main.py's
|
||
cheap-window flag — previously fed only by the `price_now_entity` /
|
||
`price_avg_entity` pair — now falls back to the static profile, and the Web UI
|
||
gained a **Tariff** row naming the window, the price, when it next changes and
|
||
why it is or is not cheap. The rule is *strictly* below the seven-day mean:
|
||
under a flat tariff `<=` would call every hour cheap and a fixed-tariff site
|
||
would top up from the grid at any hour, which is the opposite of
|
||
self-consumption only. Seven days rather than 48 hours because a 48 h mean taken
|
||
on a Friday is two-thirds weekend, and the same Tuesday 09:00 would come out
|
||
cheap or dear depending on when you asked.
|
||
|
||
**A profile must tile every day type exactly once, checked at startup.** A gap
|
||
is refused with the uncovered range named rather than filled in — an invented
|
||
price is one an optimiser would trade on — and an overlap is refused because
|
||
otherwise one of two prices wins silently on list order. The trap this catches
|
||
in practice: `22:00–07:00 mon-fri` does **not** cover Saturday morning, because
|
||
a window applies on the day the clock reads.
|
||
|
||
**validate.py rule 7's stated reason expired, exactly as TARIFF-02 predicted.**
|
||
That ticket left a tripwire in `test_price.py` section 12 asserting that nothing
|
||
in `app/` consumed `load_prices()`, with instructions to fix the refusal's
|
||
wording in the same commit when it went red. It went red naming
|
||
`static_tariff.py`. The message now says what is true instead — the series *is*
|
||
consumed, but main.py acts on the static side of it and not on a fetched one —
|
||
and the tripwire is re-aimed rather than removed: the consumer set is pinned, so
|
||
a second consumer (OPT-04) goes red again, and the expired phrase is asserted
|
||
gone so a bad merge cannot restore it. Rule 7 also grew a second refusal:
|
||
`static_profile` alongside the Home Assistant price pair is the same
|
||
silent-loser conflict, and a live one.
|
||
|
||
**No new place for an option default (CFG-03 is not widened).**
|
||
`static_profile` was added to `app/price.py`'s `PRICE_OPTION_DEFAULTS` — the one
|
||
in-code mirror TARIFF-02 created and pinned to `config.yaml` by a check — rather
|
||
than to a defaults dict in the new module, which has none. main.py grew no
|
||
`o.get("static_profile", …)` fallback. That is TARIFF-02's pattern, stated
|
||
here so a reviewer does not have to infer it.
|
||
|
||
**Measured, on the data that exists.** `test_static_tariff.py` replays the two
|
||
committed days of this house's real P1 meter (`scenarios/p1-net-2026-08-{20,23}`)
|
||
priced under a Belgian day/night profile. 2026-08-20 is a Thursday: 15.220 kWh
|
||
imported, 9.606 in the day window and 5.614 in the night one, EUR 4.7185 under
|
||
day/night against EUR 4.6725 flat, and shifting the day-window energy into the
|
||
night window saves EUR 0.8069 — which is 9.606 kWh x the 0.0840 spread, derived
|
||
twice and required to agree. 2026-08-23 is a Sunday: 0.728 kWh, **all** of it
|
||
night-priced, which is the weekend row proving itself on real data. §17 names
|
||
six Belgian scenarios and a static-profile case is not among them; this is the
|
||
seventh, and it is two captured days rather than a season — the file says so
|
||
rather than implying an annual figure.
|
||
|
||
Also worth knowing before OPT-04: on realistic Belgian day/night numbers the
|
||
whole-tariff spread is about 0.08 EUR/kWh, **below** the shipped
|
||
`cycle_cost_threshold_eur` of 0.10. A correct optimiser will decline to cycle
|
||
for the tariff alone on such a site. That is a product finding, not a defect.
|
||
|
||
**Price provider plugins, and one canonical all-in price format (TARIFF-02).**
|
||
`app/price.py` ships five providers - `generic_dayahead` (wholesale EPEX BE),
|
||
`engie_dynamic`, `totalenergies_dynamic`, `eneco_eddy` and the offline
|
||
`manual_csv` - all emitting exactly `{"prices": [{"start", "eur_per_kwh",
|
||
"provisional"}]}`, tz-aware at the Brussels offset, EUR/kWh, all-in. **Nothing
|
||
consumes it yet**: this is the seam, landed first, so that the arbitrage work
|
||
that follows never learns a supplier's name.
|
||
|
||
**Double-counting the markup is refused rather than discouraged, given a
|
||
correct declaration.** A plugin does not return a price; it returns the figure
|
||
its source published and declares which of {energy, markup, taxes} that figure
|
||
contains. The framework adds the complement, once. Engie and TotalEnergies
|
||
publish a retail figure with their margin already inside; adding the configured
|
||
3 c/kWh on top would make every future arbitrage decision wrong by that amount
|
||
with nothing reporting it.
|
||
|
||
Two mechanisms, because one was not enough. `PluginOptions` raises on the
|
||
framework-owned keys - and review of PR #16 then wrote a double-counting plugin
|
||
three more ways, one of them touching nothing but `PRICE_OPTION_DEFAULTS`, a
|
||
public dict in the module every plugin already imports. So `register()` now also
|
||
**refuses at import any plugin whose own code mentions a framework key**
|
||
(`code_leaks()`, docstrings stripped via `ast` so a plugin's mandated
|
||
`SOURCES:` line does not false-positive). Honest wholesale baseline 0.188
|
||
EUR/kWh; every attack landed 0.219.
|
||
|
||
**The guarantee's precise shape**, stated because an overclaim is worse than no
|
||
claim: the framework adds the complement exactly once *given a correct `sources`
|
||
declaration*. A plugin that declares `{energy}` while its feed is really all-in
|
||
still comes out one markup high while touching no framework name, and no code
|
||
can catch that - the declaration is the plugin's unverifiable claim about
|
||
somebody else's tariff. What is mechanised is reachability; what stays human is
|
||
truthfulness.
|
||
|
||
**The product type is declared, not inferred from the plugin's name.**
|
||
`eneco_eddy` is a real-time/imbalance product and gets widened arbitrage
|
||
thresholds; that reaches a consumer through `capabilities().product` and
|
||
`volatility_regime()`, never through a name comparison. `test_price.py` asserts
|
||
mechanically that no plugin id appears anywhere in the framework's own code,
|
||
and drives a sixth provider defined inside the test file to the correct regime
|
||
with `price.py` untouched.
|
||
|
||
**"No price data" is a type, not an empty list.** Following the same rework
|
||
`sim/riglock.py` took, a fetch returns `PriceRead.series(...)` or
|
||
`PriceRead.no_data(reason)`, and only the first carries fields. An empty series
|
||
is not constructible; `no_data` has no `.points` to iterate, no `.at()`, and
|
||
`.get("points", [])` raises rather than returning the default. **A day of free
|
||
electricity and a day with no prices are different instructions** - arbitrage is
|
||
pointless under the first and forbidden under the second - and an implementation
|
||
that returns `[]` on a failed fetch cannot tell them apart. Every site-level
|
||
failure returns `no_data`; nothing raises into the control tick.
|
||
|
||
**What is not verified, and cannot be by an offline suite:** every fixture is
|
||
synthetic, so the five feed URLs and JSON shapes are checked against a belief
|
||
about what those suppliers publish, not against the feeds. The parse layer is
|
||
exercised; the contract with each supplier is not. Expect the first live fetch
|
||
of each to need a shape fix, and note that a wrong shape degrades to
|
||
no-price-data rather than to a wrong price.
|
||
|
||
**`manual_csv` is the documented offline fallback** and needs no network, no
|
||
supplier account and no HTTP stack. See DOCS.md for the format. Its `start`
|
||
column must carry a UTC offset - a bare local timestamp is refused, because one
|
||
hour every October names two moments.
|
||
|
||
**Injection is priced separately** (`export_price_eur_kwh`), a scalar rather
|
||
than a second series, and negative values are accepted. Consumption plugins
|
||
cannot reach it.
|
||
|
||
**`price_provider` may not be combined with `price_now_entity` /
|
||
`price_avg_entity`** - the add-on refuses to start on that pair. The Home
|
||
Assistant sensors are the existing delegation of pricing to HA and still work
|
||
alone; with both configured they would silently keep deciding while the status
|
||
page named a supplier that decided nothing.
|
||
|
||
**No timezone database is used.** The shipped Alpine image has no `tzdata`, so
|
||
Brussels offsets are computed from the EU rule and every instant is carried as
|
||
UTC and rendered at the offset that applies to it. The checks drive both 2026
|
||
transitions - the 23-hour and 25-hour days - rather than a normal day, where a
|
||
fixed 24-slot implementation and a correct one agree.
|
||
|
||
**The capacity-tariff peak is now measured, banked and survives a restart
|
||
(TARIFF-01).** New `app/tariff.py`. The 15-minute block average itself was
|
||
already built and unwired - `p1.QuarterAverager` computes clock-aligned 900 s
|
||
blocks, time-weighted over irregular telegram spacing, offtake-only and
|
||
per-phase - and `P1Ingest.blocks` was a 96-entry list a restart erased. This
|
||
adds the SQLite behind it (`/data/tariff.db`): one row per completed quarter in
|
||
the indefinite 15-min tier, one row per calendar month in the permanent peak
|
||
tier, and no `DELETE` against either anywhere in the module.
|
||
|
||
**Blocks, not a sliding window.** The card says "rolling"; 9.1 bills "the
|
||
highest 15-minute average offtake", stated per QUARTER. "Rolling" here means
|
||
continuously updated, not a trailing-15-minutes window - a trailing window
|
||
reports a peak the meter never bills, and reports it HIGH, because a burst
|
||
straddling :15 fills it completely while contributing to two block averages at
|
||
partial weight.
|
||
|
||
**A restart mid-block resumes it, and a restart that outlives the block banks
|
||
it.** `QuarterAverager` grew `snapshot()`/`restore()`; the tracker checkpoints
|
||
the open block every 15 s (worst case 1.7 % of one quarter) and clears the
|
||
checkpoint in the same breath as a block close. The held sample keeps its
|
||
ORIGINAL timestamp across the restart, so the outage is integrated under the
|
||
same `max_hold_s` rule a running process would apply and uncovered seconds drag
|
||
the closed block DOWN rather than being held forward. `quarter_start` is the
|
||
primary key and a re-bank may only ever RAISE a figure, so a replay cannot
|
||
double-count and a partial re-run cannot lower a quarter already measured.
|
||
|
||
**An unreadable store is not a zero peak.** `peak()` answers `OBSERVED` /
|
||
`NO_RECORD` / `NOT_OBSERVED` and carries `peak_w = None` unless observed - the
|
||
`riglock.holder()` fix applied to something that spends money. A peak read as
|
||
0 W hands SAFETY-07 maximum headroom and silently disarms the guard at the
|
||
moment its own storage breaks, so there is no accessor that defaults it: a
|
||
caller who forgets gets a `TypeError`, and the status page prints a different
|
||
row for "no quarter yet" than for "the database did not answer".
|
||
|
||
**Calendar months are LOCAL, blocks are UTC.** A quarter starting 22:15 UTC on
|
||
31 August bills in September. Block alignment still needs no timezone.
|
||
|
||
**No new option, and no second peak entity.** The soft limit is the existing
|
||
`peak_cap_w`; `config.yaml` and `validate.py` are untouched, so CFG-03 is not
|
||
widened. `peak_forecast_entity` stays the input to `maintenance_charge_floor()`
|
||
- repointing that consumer changes what moves energy and belongs to SAFETY-07.
|
||
|
||
**It measures and nothing else.** No claim, no clamp, no command; graded on the
|
||
parsed syntax in `test_tariff.py`, which also proves the same grader lights up
|
||
on `main.py`. Per SAFETY-20, the quantity is DELIVERED power - `tariff.py` only
|
||
ever sees `P1Sample`, the meter's own reading, never a setpoint.
|
||
|
||
**AC 3 is not met and the check says so.** It asks for 30 days of replay; two
|
||
days of this house's real meter exist (2026-08-20, 2026-08-23), now committed as
|
||
`scenarios/p1-net-*.jsonl.gz`. `test_tariff.py` part 9 replays them, reports
|
||
the percentage under the shipped limit next to the sample size in days, and
|
||
asserts nothing about the 95 % figure.
|
||
|
||
**A computed setpoint over the rating now alarms instead of being clipped in
|
||
silence (SAFETY-03).** The clamp was never the missing half - specs.md 10.3
|
||
records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting
|
||
**14 768 W** while pinned at the 5 kW rail, and a silent clamp turns exactly that
|
||
into a flat, legal-looking 5000 W hold. `write_setpoint()` - the one path every
|
||
write takes, cycle, heartbeat, operator button and shutdown alike - now clamps to
|
||
the **validated** `max_w` and reports whether the raw value differed, and
|
||
`Controller.check_output_clamp()` raises `OUTPUT_CLAMP`: a persistent
|
||
notification (`goodwe_output_clamp`), an `ERROR`, an event row and a red status
|
||
banner. **Not a trip**: the clamped value is written and the loop keeps running,
|
||
per 11.2's "clamp *and alarm*".
|
||
|
||
**The bound is `max_w` and nothing is `min()`ed at runtime.** Section 14 requires
|
||
the loop clamp and the driver's `setpoint_limit_w` to agree, validated at startup
|
||
with a refusal on mismatch - which CFG-01 already ships. The device range is
|
||
still applied second, as defence in depth; after a successful validation it
|
||
cannot engage, so if it ever does, that is louder than the first stage and it
|
||
alarms too, naming the bound. No new config key: the guard's limit is the one
|
||
the operator already set, and its cadence is a module constant.
|
||
|
||
**`Decision` grew `i_clamped` / `clamped` booleans, and that is the load-bearing
|
||
part.** `reason` is one string that later stages overwrite - a cycle that clamps
|
||
the integrator and is then slew limited reports `slew-limited`, and one that
|
||
clamps while saturated reports `saturated-freeze`. Both *are* the winding case,
|
||
so an alarm keyed on the string is silent on exactly the cycles it exists for:
|
||
mutating main.py back onto `"clamp" in decision.reason` drops the 10.3 replay
|
||
from 2 engagements to **0**. The flags are set where the clamp happens and are
|
||
never overwritten. On a default install (`integrator_max_w` following `max_w`)
|
||
the integrator bound is the one that bites and the output clamp is unreachable,
|
||
so the integrator flag is the whole signal.
|
||
|
||
**Distinct from the saturation freeze, and checked in the negative.** A clamp is
|
||
a *software* bug signal; saturation is the *hardware* failing to follow a legal
|
||
command, and its action is to freeze the integrator, not to alarm. They share no
|
||
reason code, notification id, status row or counter. `test_clamp.py` drives a
|
||
loop that saturates for 10 consecutive cycles at 1.4 % of the rating and asserts
|
||
zero clamp notifications, zero engagements, no log row and no status row - after
|
||
first asserting that the saturation freeze *really did* engage, so the silence is
|
||
an observation rather than a scenario that did nothing.
|
||
|
||
**The action log is coalesced per episode, and that was a real bug.**
|
||
`log_event()` keeps 40 rows; one row per engagement flushed the entire ring
|
||
within 40 cycles of a winding loop, and the first thing it evicted was
|
||
SAFETY-18's own `METER FROZEN?` row - this guard silencing another guard's log.
|
||
Caught by `test_freeze.py` going red, not by inspection. One row per episode now,
|
||
carrying the exact engagement count read off the counter.
|
||
|
||
**A non-finite setpoint is refused, and it was worse than passing one through.**
|
||
`nan` is loss of input (11.1 rule 1: command zero, keep commanding), not an
|
||
out-of-range value. Measured: because every comparison against `nan` is False, a
|
||
naive `max(-limit, min(limit, nan))` keeps its *first* argument and turns a `nan`
|
||
into a command **at the full rating**, then reports it as a clamp engagement.
|
||
|
||
**Review fixes (PR #12).** Three, and the first was a behavioural regression
|
||
this PR introduced: `clamp_to_rating()` **returned early** from the `max_w`
|
||
stage, so the device bound - the "applied second, defence in depth" half - was
|
||
reached only when the rating bound had NOT fired. The bigger the overshoot, the
|
||
less the guard did. At `max_w` 5000 against a +-4000 device, +9999 emitted
|
||
**+5000 W, outside the device range**, while a mere +4500 was correctly held to
|
||
+4000. That undid the pre-PR guarantee "never send a value the device will
|
||
reject outright" and undercut this module's own case for clamping to `max_w`
|
||
rather than a runtime `min()`, which rests entirely on that stage catching a
|
||
validation that did not run. Both stages now always run, the result names both
|
||
bounds when both bite (`max_w+device`), and the property is asserted over a
|
||
492-point sweep rather than on witness values - the defect was invisible to any
|
||
single-value check that happened to sit inside one stage. No reachable
|
||
production path was found for it (two validation gates and the arbiter bound sit
|
||
in front), but latent is not harmless.
|
||
|
||
Two harness defects, both of the kind where a check cannot fail for the value it
|
||
forbids: the hysteresis check issued `range(CLEAR_CYCLES - 1)` clean cycles,
|
||
which at `CLEAR_CYCLES = 1` issues **zero** and passes while printing
|
||
"0 clean cycles do NOT clear it" - green for the chatter setting the constant
|
||
exists to prevent. And the re-assert check stimulated at `REASSERT_S + 9`, green
|
||
for **any** value including 3600, i.e. the hour of silence the module promises
|
||
not to produce. Both are now written in wall-clock terms that do not move when
|
||
the constant does: **one** clean cycle must not clear the alarm, and a loop
|
||
pinned across the 900 s capacity-tariff quarter must speak at least 5 times and
|
||
on no more than 20 % of cycles. `CLEAR_CYCLES = 1`, `REASSERT_S = 3600` and
|
||
`REASSERT_S = 0` each go red.
|
||
|
||
**`test_clamp.py`** (new, assert-based, no framework) covers the clamp
|
||
arithmetic and its boundaries - exactly +-`max_w` is *not* an engagement - the
|
||
alarm's edge/re-assert/hysteresis cadence, the 10.3 runaway replayed through the
|
||
real `Controller.cycle()` against an unclamped transcription, the saturation
|
||
negative, and the direct-write path. Six author mutations reproduced red,
|
||
including "clamp kept, alarm deleted" (the historical build) and "alarm keyed on
|
||
`reason`". Two of the check's own hypotheses about how the freeze and the clamp
|
||
interact were **refused by the sweep** and rewritten to what it measured: a
|
||
frozen cycle *can* clamp, and does so exactly when the integrator reverses.
|
||
|
||
`test_freeze.py`'s notification assertions now filter by `notification_id`
|
||
rather than by service - the add-on raises more than one kind of notification,
|
||
and its railing scenarios legitimately raise this one too.
|
||
|
||
**Cheap-window maintenance charging was unreachable at the shipped defaults, and
|
||
now the config that makes it unreachable is refused (MAINT-04).** `config.yaml`
|
||
shipped `max_w: 2000` against `maintenance_charge_w: 2500`. The cheap-window
|
||
shaping files `Claim.limit(hi = -maintenance_charge_w)` against the supervised
|
||
SAFETY limit's `lo = -max_w`, so `lo > hi` — the arbiter's clause 3, correctly
|
||
resolved to **0 W** and logged as a CONTRADICTION. **Maintenance silently never
|
||
charged in a cheap window on a default install**, and the only evidence read
|
||
like a bug in the arbiter rather than like the two numbers an operator set.
|
||
Measured against the real arbiter, loop asking +500 W:
|
||
|
||
```
|
||
max_w=2000 hi=-2500 -> +0 W contradiction=True
|
||
max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w)
|
||
max_w=3000 hi=-2500 -> -2500 W limited by maintenance(cheap-window charge)
|
||
```
|
||
|
||
`validate_startup_config()` now refuses `maintenance_charge_w > max_w` (rule 6),
|
||
the same shape as CFG-01's `sign_disagree_w >= max_w`: a number the operator set,
|
||
overridden by a layer they cannot see. Equality is reachable (`-max_w` is a
|
||
single valid point) and deliberately not refused. Verified by starting the
|
||
add-on on the pre-fix pair: exit 78, the refusal banner, no traceback.
|
||
|
||
**`maintenance_discharge_w` was checked, not assumed, and it is NOT the same
|
||
case.** The shaping is not symmetric. The drain phase does not shape the loop,
|
||
it OWNS the actuator — `Claim.set(P_MAINTENANCE, +discharge_w)` — and a SET is
|
||
*clamped* by the limits above it rather than contradicted by them. 2500 against
|
||
a 2000 W ceiling emits **+2000 W**, and the arbiter's own `explain()` prints
|
||
`maintenance -> 2000 W, limited by supervised(max_w)` every cycle. Nothing is
|
||
silent and energy still moves; the cycle completes slower (13.8 kWh usable at
|
||
2000 W is ~6.9 h against the 720 min drain timeout). **Warned about, not
|
||
refused.**
|
||
|
||
**The shipped defaults changed so the validator does not reject them.**
|
||
`maintenance_charge_w` and `maintenance_discharge_w` are now **2000**, at the
|
||
shipped `max_w`. `max_w` was deliberately NOT raised instead: it is the
|
||
supervised safety ceiling and raising a default upward ships more power to every
|
||
new install. Raising `max_w` (the reference install runs 5000) is what unlocks a
|
||
faster maintenance cycle — raise it first, then these. `test_validate.py` now
|
||
reads `config.yaml` itself and asserts the shipped numbers pass the rule, so a
|
||
default that the validator refuses cannot ship again.
|
||
|
||
### ⚠️ Upgrading an existing default install: the add-on may refuse to start
|
||
|
||
An install still carrying `maintenance_charge_w: 2500` against `max_w: 2000`
|
||
now meets rule 6 and **exits 78 with the refusal banner** instead of starting.
|
||
That is the intended behaviour — the configuration was already broken, it was
|
||
just silent about it — but an add-on that will not come back after an update
|
||
needs the fix stated here rather than only in the log.
|
||
|
||
**The fix, either way: set `maintenance_charge_w` to `2000` or lower, OR raise
|
||
`max_w` to `2500` or higher** (Settings → Add-ons → GoodWe RS485 Controller →
|
||
Configuration), then start it. The banner in the log says the same thing and
|
||
names both numbers.
|
||
|
||
Whether you hit this at all depends on whether the Supervisor kept the old
|
||
value, and **both cases are covered above because we did not verify which one
|
||
applies here.** The Supervisor merges an add-on's *stored* options over the
|
||
defaults in `config.yaml`, so:
|
||
|
||
- **A key the operator never edited** re-derives from the new `config.yaml` and
|
||
becomes 2000 on upgrade. Non-event, nothing to do.
|
||
- **A key the operator did set** persists at 2500 and the add-on refuses to
|
||
start until it is changed. Note this includes anyone who used the
|
||
Configuration page's YAML editor, which writes the whole options block back —
|
||
so "I never touched that field" is not a guarantee the old value is gone.
|
||
|
||
⚠️ **This is reasoned from the Supervisor's documented merge behaviour, not
|
||
measured on the live install.** The reference install's stored options could not
|
||
be read (`/api/hassio/` refuses a long-lived token) and probing it was out of
|
||
scope — it regulates a real battery. It runs `max_w: 5000` and is unaffected
|
||
either way.
|
||
|
||
⚠️ **Known, deliberately left, and worth a follow-up:** `main.py` and
|
||
`maintenance.py` still fall back to **2500** when the key is absent from the
|
||
options dict, while `config.yaml` and `validate.py` now say 2000. Unreachable on
|
||
a Supervisor install (every key is always supplied) and reachable only from
|
||
tests/`sim`. The real fix is one source of truth for option defaults, which is
|
||
bigger than this ticket and touches files another ticket is in.
|
||
|
||
## 0.5.2
|
||
|
||
**A meter frozen at a wrong-but-fresh value now raises an alarm. It still is not
|
||
stopped.** (SAFETY-18)
|
||
|
||
A meter that answers `200 OK` with a stale document forever is *arriving* on
|
||
every poll, so every freshness mechanism in the stack reads it as healthy:
|
||
`meter_max_age_s` is reset by each response, the firmware watchdog is fed by the
|
||
heartbeat and by a sample age that never climbs, and the arbiter contributes only
|
||
±`max_w`. The loop integrates a constant error and rails — replaying the real
|
||
`compute()` at 15 s cycles, in 150/60/30 s at `max_w: 2000` for a meter frozen at
|
||
350/1000/≥3000 W, and 360/135/75 s at `max_w: 5000`. **The live install runs
|
||
`max_w: 5000`**, which is both the schema maximum and the device rating.
|
||
|
||
The new detector (`app/freeze.py`) is a **conjunction**, not the bare
|
||
`unchanged_s` threshold TEL-05 measured and rejected:
|
||
|
||
unchanged_s ≥ meter_freeze_s AND |command| within 5 % of max_w
|
||
|
||
Both terms already existed and were already on the status page. They fail for
|
||
independent reasons: at the converged point the command is nowhere near the rail,
|
||
and at the rail against a real load the kWh registers move within seconds. At the
|
||
shipped 300 s there is also a structural relation, not just a margin — the
|
||
slowest a healthy 1 Wh register can tick and still look unchanged is 12 W, which
|
||
is inside the 15 W deadband, so the command there is parked at 0 W and the two
|
||
halves cannot both be true.
|
||
|
||
**Notification only, deliberately.** An HA persistent notification (fixed
|
||
`notification_id`, so a repeat replaces rather than stacks), an `ERROR` log line,
|
||
a UI event and a red status banner. No claim, no latch, no 0 W: a false trip
|
||
parks the battery and the operator's next move is to switch the guard off; a
|
||
false notification costs an alert. The trip is a separate decision that needs
|
||
data from a real install.
|
||
|
||
- New option `meter_freeze_s` (default 300, range 60–3600, no "off").
|
||
- **Only `meter_source: homewizard_local` is covered** — it is the only transport
|
||
that sees the meter's own document. Everywhere else the status page says
|
||
**NOT COVERED** rather than "armed".
|
||
- `DOCS.md` now states the **capacity-tariff exposure** this does *not* close:
|
||
`peak_cap_w` caps maintenance charging and nothing caps the loop, so a lying
|
||
meter can drive a 5 kW unattended import, and ≥15 minutes of that sets the
|
||
month's billed peak (measured: one 4998 W quarter moved a monthly peak from
|
||
4832 → 5013 W). That cap is SAFETY-07.
|
||
- `test_freeze.py` grades it in both directions: silent over 40 minutes of the
|
||
live house's real converged capture (committed as
|
||
`scenarios/p1-age-2026-08-26.jsonl.gz`), silent over an hour of TEL-05's
|
||
low-power case where a bare threshold is shown to fire, and alarming on a
|
||
genuinely frozen meter driven over real HTTP through the real
|
||
`HomeWizardLocalSource` — against the rig's own `hwsim` `freeze` fault when the
|
||
infra repo is reachable, an equivalent stub otherwise. Its negative twin is the
|
||
load-bearing one: the same railed command against a meter that is still moving
|
||
must stay silent at the same threshold.
|
||
|
||
## 0.5.1
|
||
|
||
**SAFETY-02's latch now survives an add-on restart, which is the difference
|
||
between a latch and a pause.** `auto` was persisted and `sign_wd.tripped` was
|
||
not, so reconstructing the `Controller` against the same store — exactly what a
|
||
container restart does — brought it back **armed, into the same fault, with no
|
||
operator involved**: `tripped=False`, banner back to `NOT READY`, and it resumed
|
||
commanding (measured: −1000 W, −2000 W) before re-tripping 180 s later. Under a
|
||
Supervisor that restarts a crashed add-on that is a loop of three-minute
|
||
wrong-direction bursts — and "restart the add-on" is the first thing an operator
|
||
looking at a `FAILSAFE` banner and a 0 W inverter will try.
|
||
|
||
`sign_tripped` and `sign_reason` are now `Store` keys beside the maintenance
|
||
state, written the moment the trip happens (reason first, flag second, so a crash
|
||
between the two can never persist a latch with no explanation) and restored in
|
||
`Controller.__init__`. Cleared only by the operator's start. `DOCS.md` said "it
|
||
does not clear itself"; that was false across a restart and now is not.
|
||
|
||
Also in this release, all of it review follow-up rather than new behaviour:
|
||
|
||
- **The arming gate is tested against the real arbiter.** It was the decision the
|
||
whole ticket turns on and it was asserted in three comment blocks and verified
|
||
by nothing — every case passed `following` in as a literal. `test_watchdog.py`
|
||
now derives it from `resolve()` across twelve claim configurations, including
|
||
the redirect case `winner == "loop"` alone would have got wrong and the `lo`
|
||
limit that raises magnitude without flipping sign.
|
||
- **The MQTT `status: "failsafe"` value is tested.** The check that claimed to
|
||
cover it restated the assertion three lines above it and never touched the
|
||
payload.
|
||
- **A forward hazard written down where it will be read, and made to bite.**
|
||
`winner == "loop"` disarms the watchdog under any higher-priority SET claim.
|
||
Today only maintenance and the safety stops emit one and neither follows the
|
||
meter; a peak-shaving strategy at `P_PEAK` **does**, and would blind the
|
||
watchdog for every peak event.
|
||
|
||
The gate is now `watchdog.loop_is_following()`, called by both `main.py` and
|
||
the test, for the same reason `disagrees()` was extracted: the test held a
|
||
*transcription* of the shipped expression, so editing the shipped gate alone to
|
||
`winner in ("loop", "peak")` left every suite green while the watchdog went
|
||
blind. Two copies, and the one that drifts is the one doing the grading.
|
||
|
||
⚠️ Extracting it was not sufficient on its own. The realistic P_PEAK row —
|
||
peak shaving asking for *more* than the loop — still could not detect the
|
||
widened gate, because the magnitude term rejects it regardless and masks the
|
||
winner term. A second row with peak asking for *less* isolates `winner` as the
|
||
only deciding term, and that is the one that turns red.
|
||
- **The worked example described a configuration that does not ship.** At the
|
||
defaults `maintenance_charge_w` (2500) exceeds `max_w` (2000), so that claim is
|
||
an arbiter *contradiction* resolving to 0 W, not a −2500 W charge. The example
|
||
is now written at 1500 W. (The contradiction is a real pre-existing bug —
|
||
a default cheap-window maintenance charge silently never happens — filed
|
||
separately, deliberately not fixed here.)
|
||
- **`disagrees()` is public**, so `sim/replay_sign.py` stops carrying its own copy
|
||
of the predicate it grades.
|
||
- `held_s` takes the clock instead of reading one, restoring the pure-function
|
||
property the module advertises.
|
||
- **The `sign_reason`-before-`sign_tripped` write ordering is enforced, not just
|
||
documented.** Swapping the two lines used to pass every check. A store that
|
||
fails the second write now proves the property the ordering exists for: what
|
||
survives a crash mid-trip is a reason with no latch, never a latch with no
|
||
explanation.
|
||
- Measured figures corrected throughout: 93.4 % / 4212 of 17280 (was 92.9 % /
|
||
4127 of 16349), per-cluster 0.3 / 0.9 / 10.5 / 0.0 %.
|
||
|
||
## 0.5.0
|
||
|
||
**SAFETY-02 — the sign-disagreement watchdog.** The add-on now watches whether
|
||
the setpoint it is emitting and the grid figure it is regulating on point the
|
||
same way. If they oppose each other, both over `sign_disagree_w` (300 W), for
|
||
`sign_disagree_s` (180 s) **continuously**, it files a failsafe claim at
|
||
`P_SAFETY`, commands **0 W**, and latches there until an operator starts control
|
||
again. New module `app/watchdog.py`; new options `sign_disagree_w` and
|
||
`sign_disagree_s`.
|
||
|
||
This is the gap the existing failsafes leave open. The stale-input watchdog asks
|
||
whether the meter is still talking, the saturation freeze asks whether the
|
||
inverter can follow, the output clamp asks whether the number is too big — a
|
||
loop can pass all three while charging the battery out of the grid, because the
|
||
only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly
|
||
that four times in one day, for up to 77 minutes at a stretch, and reached a
|
||
commanded −14 768 W against a ±5000 W inverter.
|
||
|
||
Validated against that day. `sim/replay_sign.py` in the infra repo drives this
|
||
module over the committed RS485 captures of both 2026-08-20 (the four episodes)
|
||
and 2026-08-23 (healthy): **18 trips on the fault day, every one inside a window
|
||
`NOTES.md` §4.7 accounts for, and 0 on the healthy day.** The four episodes do
|
||
share one signature rather than merely resembling each other — sign agreement
|
||
runs 0.3 / 0.9 / 10.5 / 0.0 % inside them against 93.4 % on the healthy day.
|
||
|
||
Three decisions worth knowing about:
|
||
|
||
- **It is disarmed whenever the grid-following loop is not what is driving.**
|
||
Sign agreement is a property of a loop that follows the meter. A maintenance
|
||
grid charge opposes the meter *by design* — 2500 W of charge into a house
|
||
drawing 500 W is +3000 W on the meter against a −2500 W command, for hours —
|
||
and armed unconditionally this would trip on every maintenance cycle. The gate
|
||
tests the emitted number, not the winning claim's name: a limit claim can let
|
||
the loop "win" and still replace its value with one pointing the other way.
|
||
- **The trip latches.** Commanding 0 W removes the command side of the
|
||
comparison, so the disagreement always clears the moment the trip works.
|
||
Un-latched, this would trip, clear, and hand the actuator back to the loop that
|
||
was driving the wrong way, every three minutes forever.
|
||
- **`sign_disagree_w` at or above `max_w` is refused at startup** (validator rule
|
||
5). The arbiter never lets a command exceed `max_w`, so such a threshold makes
|
||
the watchdog incapable of firing while the status page still reports it armed.
|
||
A disarmed guard that advertises itself as armed is the failure mode this
|
||
project keeps refusing to ship.
|
||
|
||
What it does **not** catch, stated so nobody assumes otherwise: an inverter that
|
||
acts opposite to what it was told moves the meter *further* the same way, so
|
||
command and meter agree while everything runs away. That is the saturation
|
||
freeze's job, which compares the command against the battery's actual power.
|
||
|
||
`test_validate.py`'s "the smallest sane max_w still starts" case now names a
|
||
reachable sign threshold, because a 1 W ceiling genuinely does disarm the 300 W
|
||
default and is now correctly refused.
|
||
|
||
## 0.4.0
|
||
|
||
**CFG-01.** The add-on now refuses to start on a configuration it cannot honour,
|
||
instead of logging a line and carrying on. `validate_startup_config()` runs in
|
||
`amain()` before the broker, the web server or any task exists, so a refusal
|
||
cannot leave half a controller running behind a UI.
|
||
|
||
It refuses only where continuing produces one of two outcomes: **(a)** a stated
|
||
limit is untrue - the operator configured a bound and a lower layer silently
|
||
enforces a different one - or **(b)** the add-on is inert and indistinguishable
|
||
from healthy. (b) is not a special case of (a): with no `setpoint_entity`, layer
|
||
1 pinning the inverter at 0 W is the failsafe *working*. The system does not
|
||
become unsafe, it becomes a convincing impression of a working controller.
|
||
|
||
Four things qualify:
|
||
|
||
- **`max_w` above (or below) the setpoint device's own rating.** This is the one
|
||
that changed behaviour. It used to warn `the device wins` and run anyway, with
|
||
`write_setpoint()` silently clamping, so the configured ceiling was never the
|
||
real one and the only evidence had scrolled off the log. The refusal names the
|
||
configured value, the device's value, and which wins.
|
||
- **No `setpoint_entity`.** A controller with no actuator: every write is a
|
||
no-op, the device rating is never read, and the ESP32 heartbeat that holds off
|
||
the layer-1 failsafe never happens. It ran, looked healthy, and moved nothing.
|
||
- **A `meter_source` that can never produce a reading** - missing `p1_host`,
|
||
missing `p1_net_entity`, a blank MQTT topic, a phase list disagreeing with
|
||
`meter_phases`, an unknown source name, or a `meter_poll_s` at or past
|
||
`meter_max_age_s`. These were already caught, but they only *disabled
|
||
ingestion*, which leaves the grid reading None forever: permanent "inputs
|
||
missing", 0 W, and a status page indistinguishable from a healthy meter nobody
|
||
has sent anything to yet.
|
||
- **`meter_max_age_s` above the firmware's ingest veto.** The schema accepts
|
||
`int(5,300)`; the ESP32's threshold is a compile-time `max_age_s: "30"` and it
|
||
trips layer 1 at `p1_age_s >= 30`. Set 120 and the add-on treats a 119 s
|
||
reading as fresh and keeps commanding, while the firmware has already vetoed
|
||
and is holding the inverter at 0 W - healthy meter, running controller,
|
||
nothing moving, and no line anywhere saying the number you set was overruled.
|
||
Structurally the same fault as the `max_w` case. Only *above* the veto is
|
||
refused: below it the add-on gives up first, which is fail-safe and visible on
|
||
its own status page. Mirrored as `validate.FIRMWARE_INGEST_VETO_S` - it cannot
|
||
be imported from a compile-time YAML substitution, so it carries a note about
|
||
the one direction in which it can go stale.
|
||
|
||
`max_w` at or below zero is refused too. Schema-blocked on a real install, but
|
||
`validate_startup_config()` is now also the simulation suite's entry point, and
|
||
a non-positive `max_w` inverts the arbiter's `supervised` bounds into a
|
||
contradiction that resolves to 0 W - i.e. it would read as a safety trip rather
|
||
than as the typo it is.
|
||
|
||
The predicates for the meter group still live in `p1.validate_meter_options()`,
|
||
which `build_source()` also calls - one definition, two callers, not two
|
||
competing validators. Two transports gained a check they never had: `ha_dsmr`
|
||
with only one of its two entities, and `mqtt_p1` with a blank topic, both of
|
||
which previously built a transport that could never read.
|
||
|
||
Deliberately **not** refused, because it is untidy rather than unsafe:
|
||
`integrator_max_w` above `max_w` (the arbiter still bounds the wire to ±max_w,
|
||
so the cost is unwind latency, not amperage - warned), a missing `soc_entity` /
|
||
`batt_entity` (fail-safe, and already a red NOT READY banner naming it), a
|
||
`setpoint_entity` that has not appeared on the Core API yet (ESPHome is routinely
|
||
late; refusing would make a slow boot need a human - re-checked once the real
|
||
rating arrives), and a thin-but-nonzero `meter_poll_s` margin.
|
||
|
||
A refusal is legible as a refusal: a banner that says in words that this is a
|
||
configuration problem and nothing is broken, the numbered problems, no traceback,
|
||
and exit status 78 (`EX_CONFIG`).
|
||
|
||
**SAFETY-12.** The control loop now evaluates on a clock as well as on a change.
|
||
`run_control()` ran `cycle()` only when the meter value differed from the
|
||
previous poll, so a meter that repeated a value ran **no control cycles at
|
||
all** - while the heartbeat went on re-emitting the last command and the
|
||
firmware went on putting it on the wire every 2 s. Layer 1 stayed fed, every
|
||
indicator read healthy, and a command computed from an old reading kept being
|
||
executed through whatever the load did next. Measured on the rig during TEL-05:
|
||
70 s at a literally constant 350.0 W with 14 arrivals and zero cycles.
|
||
|
||
**It was worse than a latency limit: it was a safety claim that could not
|
||
fire.** `stale_input_s` and the `inputs missing -> 0 W` claim both live *inside*
|
||
`cycle()`. Pre-fix, if the meter value happened to be constant, `cycle()` was
|
||
never called again - so that claim had no way to run. Constant meter, SoC entity
|
||
dying at t=4 s, same binary:
|
||
|
||
```
|
||
pre-fix writes: [(0.0, 220.0), (0.0, 220.0), (10.1, 220.0)]
|
||
final target: 220.0 W reason: 'loop -> 220 W'
|
||
post-fix writes: [(0.0, 220.0), (0.0, 220.0), (3.0, 430.0), (9.1, 0.0), (10.1, 0.0)]
|
||
final target: 0.0 W reason: 'safety -> 0 W'
|
||
```
|
||
|
||
The pre-fix controller held 220 W through the entire input outage with its
|
||
status line still reading `loop -> 220 W`, and layer 1 does not cover that case
|
||
on a `meter_source: off` install: no P1 age sensor is published, so the firmware
|
||
has nothing to veto on, while the heartbeat keeps its watchdog fed. So the old
|
||
behaviour was not "holds a stale command safely" - it was a failsafe that a
|
||
repeating meter switched off. `test_loop.py` asserts both halves.
|
||
|
||
`cycle_floor_s` (default **15 s**) is a deadline on the gap between cycles, not
|
||
a second scheduler: every cycle resets it, whichever trigger fired. On a healthy
|
||
meter it never elapses, so the cycle rate, the write sequence and therefore the
|
||
per-cycle gain the tuning was measured against are unchanged - asserted in
|
||
`test_loop.py` by driving the real loop twice on one binary (a huge
|
||
`cycle_floor_s` reproduces the old behaviour exactly) and comparing the
|
||
commanded values.
|
||
|
||
The reset is not the only thing holding that up, and the other argument is the
|
||
sturdier one: the combined cycle rate is `max(arrival rate, 1/floor)`, never the
|
||
sum. At 1/15 s the floor is strictly slower than the ~1/5 s arrival rate, so
|
||
even a hypothetical floor racing the arrivals could not exceed the nominal
|
||
healthy rate. The re-tune is unreachable by construction. ⚠️ That holds only
|
||
while `cycle_floor_s` stays above the meter's update interval, and the schema
|
||
minimum is 5 s - which is exactly the reference meter's interval. Nothing
|
||
enforces the relationship; `validate.py` is the obvious home for it and does not
|
||
have it yet.
|
||
|
||
Observed over six real minutes on the same rig, one binary, meter held at a
|
||
literally constant 350.0 W: **before, 1 cycle** (at startup) and then nothing,
|
||
the command stuck at 220 W against a 350 W import for the rest of the window.
|
||
**After, 24 cycles**, worst gap 15.14 s, and the ordinary PI ramp doing its job -
|
||
220, 430, 650, 860, 1080, 1300, 1510, 1730, 1940, 2000 W, one step per cycle,
|
||
rail at t=136 s and flat after. Three minutes with the meter parked at the
|
||
target instead: **1 cycle before, 12 after, and 19 writes either way - the same
|
||
19**. Three minutes on a normally-changing meter: **36 cycles and 49 writes on
|
||
both sides, the same values in the same order.** The heartbeat write count never
|
||
moves; the only writes the fix adds are the ten the loop was supposed to be
|
||
making all along.
|
||
|
||
15 s is bracketed, not round.
|
||
|
||
**Below it, ~11 s, and it arises more routinely than a dropped poll.**
|
||
`HomeWizardLocalSource` polls at 5.0 s against a meter that updates every 4.97 s
|
||
(NOTES.md:640). Two near-equal periods beat, so roughly once every ~14 minutes
|
||
the poller steps over an update entirely - a ~10 s gap between changed values on
|
||
a perfectly healthy meter, plus up to 1 s for the 1 s tick to notice. A failed
|
||
HTTP poll (retried on the next tick, no backoff) adds another 5 s. The floor
|
||
sits above the beat-skip so it does not fire on that alone; what it takes to
|
||
reach 15 s is a beat-skip *and* a failed poll coinciding.
|
||
|
||
**Above it, 30 s.** `meter_max_age_s` is 30 s, past which there is no reading to
|
||
evaluate, and the firmware's ingest veto is also 30 s - so the loop is
|
||
guaranteed to have re-decided at least twice inside any window in which layer 1
|
||
might otherwise be the only thing deciding. ⚠️ The default is therefore not
|
||
independently justified at 15: if the firmware's veto ever moves, this moves
|
||
with it. Noted beside the constant in `main.py`.
|
||
|
||
It is also exactly `stale_input_s`, this system's existing answer to "how long
|
||
may we hold without re-deciding".
|
||
|
||
A floor cycle runs the **full** law on the same inputs, integrator included. In
|
||
P1 mode the freshness question is already answered upstream and answered harder
|
||
- past `meter_max_age_s` the reading is None, which is "inputs missing", which
|
||
is a `P_SAFETY` 0 W claim - so a floor cycle there can only ever run on a reading
|
||
this system already calls fresh. ⚠️ That is transport-conditional: with
|
||
`meter_source: off`, `read_inputs()` takes the grid figure straight from
|
||
`hass.number()` with **no age gate at all**, so a Home Assistant entity that has
|
||
gone quiet reads as current on that path, with or without this change. Running
|
||
the full law is still right there - the alternative is a degraded second law
|
||
reasoning about an age nobody measured - but the floor does not make that path
|
||
fresh. The integrator advances per *cycle*, not per second,
|
||
so bounding the gap bounds the accumulation; `integrator_max_w` bounds it after
|
||
that. A gentler second law for floor cycles would be a second set of dynamics
|
||
nobody can test on this hardware.
|
||
|
||
⚠️ **This is not a freeze detector**, and TEL-05 established one cannot be built
|
||
here: at the converged -10 W target a 1 Wh register needs ~6 minutes to move
|
||
while the power figure legitimately repeats, so any threshold on `unchanged_s`
|
||
false-trips at the exact operating point we run at. Nothing in this change
|
||
decides whether a repeated reading is *true*. It stops "the number is the same"
|
||
from meaning "do not think". The consequence, stated plainly: against a meter
|
||
genuinely frozen at a wrong value the loop will now correct toward that wrong
|
||
value rather than hold a stale command, bounded by `max_w`, the arbiter and the
|
||
device rating. That was already what happened to a lying meter whose value
|
||
merely jittered; bit-exact repetition was never a safety property.
|
||
|
||
`DOCS.md`'s "Saturation is counted in cycles, not seconds" section documented
|
||
the old behaviour as an accepted deviation. It is **qualified, not deleted**:
|
||
the deviation survives (the window is still counted in cycles, and 3 x 15 s is
|
||
still not the specification's 10 s) but it is now bounded at 45 s instead of
|
||
open-ended, and the passage's second justification - "nothing accumulates while
|
||
it is stalled" - is called out as having covered integrator windup and not
|
||
staleness, which was the actual hazard. The same wrong argument in
|
||
`control.py`'s saturation comment is corrected too.
|
||
|
||
**TEL-04.** `meter_source: ha_signed` reads a single *signed* power entity, which
|
||
is the shape a HomeWizard P1 publishes. Both transports 0.3.0 shipped require two
|
||
unsigned registers and refuse negatives, so neither fits the meter installed here.
|
||
|
||
**TEL-05.** `meter_source: homewizard_local` polls the meter's own local API
|
||
directly. This exists because of something the HA paths cannot do: Home Assistant
|
||
fires no state change for a repeated reading and exposes no arrival signal at
|
||
all, so `sensor.p1_sample_age_s` on those transports measures time since the
|
||
*value changed*, not since the meter *reported*. Real captured data has gaps of
|
||
42 s and 97 s between changes on a perfectly healthy meter - long enough for a
|
||
30 s staleness threshold to command 0 W for no reason. Every HTTP response to the
|
||
local API is a genuine arrival, so the age means what it says.
|
||
|
||
Verified on the rig against a literally constant 350.0 W for 70 s - the exact
|
||
state where the HA path recorded zero samples in 45 s - with 14 arrivals and the
|
||
age never exceeding the poll cadence.
|
||
|
||
Set `p1_host` to the meter's address and `meter_poll_s` (default 5 s, matching
|
||
the meter's own ~5 s update). Still defaults to `off`; nothing changes for an
|
||
install that does not opt in.
|
||
|
||
Freeze detection is separate and deliberately not thresholded: a meter answering
|
||
200 OK with a stale number *is* arriving, so no arrival stamp can flag it. The
|
||
energy registers carry that as `unchanged_s`, surfaced on the status line for a
|
||
human to read rather than alarmed on - at a converged -10 W a 1 Wh register needs
|
||
about six minutes to move, so any threshold would false-trip at the target
|
||
operating point.
|
||
|
||
## Unreleased
|
||
|
||
**TEL-05.** A fourth `meter_source`, `homewizard_local`, which polls a
|
||
HomeWizard P1's **own local API** (`GET /api/v1/data`) instead of watching a
|
||
Home Assistant entity. Set `p1_host` to the meter's address; `meter_poll_s`
|
||
(default 5 s, the meter's own update rate) sets the cadence.
|
||
|
||
✅ **This is the first transport whose `sensor.p1_sample_age_s` measures when
|
||
the meter *reported*, and therefore the first one a firmware watchdog may
|
||
threshold.** Every HTTP response is an arrival: the meter answered, now, with
|
||
its current reading, and whether the *number* moved is not consulted. Home
|
||
Assistant cannot express that at all — a repeated reading emits no
|
||
`state_changed`, advances `last_reported` on neither serialiser, and
|
||
`state_reported` is not subscribable ("Event filter is required"). On
|
||
`ha_signed` that made a healthy meter under a flat load indistinguishable from
|
||
a dead one, and our own capture of this house's meter goes 42.2 s and 97.0 s
|
||
between changes — both past the default `meter_max_age_s` of 30, i.e. a false
|
||
trip to 0 W on a meter that is fine. If you have a HomeWizard P1, move to this
|
||
mode.
|
||
|
||
Everything TEL-01 established is reused, not re-implemented: ingest
|
||
timestamping, `meter_max_age_s`, the clock-recomputed age, the plausibility
|
||
bounds and the §20 unsigned-decode rejection, and the same `split_signed` sign
|
||
convention `ha_signed` uses. A failed or timed-out poll submits nothing, so it
|
||
is a *missing* reading — never 0 W — and it does not reset the age.
|
||
|
||
Verified on the ENV-01 rig against `sim/hwsim.py`, steady and with `--fault
|
||
freeze` injected. Still defaults to `off`.
|
||
|
||
⚠️ **An arrival stamp still cannot see a *frozen* meter**, and no arrival
|
||
detector can: a meter answering `200 OK` forever with a stale number is
|
||
arriving. The local API does expose what the HA path never had — the
|
||
`total_power_*_kwh` registers stop advancing — and the transport tracks it as
|
||
`unchanged_s`, but that is deliberately **not** folded into the age and not
|
||
thresholded: this controller regulates grid power toward ~0 W, and at a
|
||
converged −10 W the export register needs six minutes to move by its 1 Wh
|
||
resolution while the power figure legitimately repeats. Thresholding it at 30 s
|
||
would rebuild the false-trip limit cycle at the exact operating point we aim
|
||
for. Freeze detection needs the low-power case solved first, separately. It is
|
||
shown on the status page's P1 line instead — leaving it unthresholded only
|
||
holds up if a human can read it, so now they can.
|
||
|
||
**TEL-04.** A third `meter_source`, `ha_signed`, reading **one signed** Home
|
||
Assistant entity: positive = import, negative = export. That is the shape a
|
||
HomeWizard P1 publishes (`sensor.p1_meter_active_power`), and it is the meter
|
||
actually fitted here - which neither TEL-01 transport can read, because
|
||
`ha_dsmr` needs two unsigned registers and refuses a negative one, i.e. every
|
||
exporting telegram. Set `p1_net_entity`, and `p1_phase_net_entities` for the
|
||
per-phase capacity-tariff figures on a three-phase connection.
|
||
|
||
Everything TEL-01 established is inherited rather than re-implemented - the
|
||
new transport is a subclass of the `ha_dsmr` one overriding only which
|
||
entities it wants and how they become a sample. So ingest timestamping,
|
||
`meter_max_age_s`, `sensor.p1_sample_age_s` recomputed against the clock and
|
||
republished once a second, the plausibility bounds, and `unavailable` /
|
||
`unknown` treated as a *missing reading and never 0 W* all behave identically
|
||
across the three sources.
|
||
|
||
Still defaults to `off`; an existing install is unaffected until it opts in.
|
||
|
||
⚠️ **`sensor.p1_sample_age_s` is published on `ha_signed`, but must not yet be
|
||
thresholded by the ESP32 stale-input watchdog.** On the HA WebSocket paths the
|
||
age is stamped from `state_changed`, so it measures time since the value
|
||
*changed*, not since the meter *reported* - and Home Assistant exposes no
|
||
arrival signal for a repeated reading (no `state_changed`, no `last_reported`
|
||
movement on either serialiser, and `state_reported` is not subscribable over
|
||
the WebSocket). Measured on the ENV-01 rig against the real HomeWizard
|
||
integration. `ha_dsmr` mostly escapes it because a telegram moves several
|
||
entities at once; `ha_signed` has one, so a healthy meter under a flat load is
|
||
indistinguishable from a dead one. Our own capture has the house meter going
|
||
42.2 s and 97.0 s between changes. Raising `meter_max_age_s` does not fix that,
|
||
it only chooses which error you get; the fix is an arrival stamp from the meter
|
||
itself and is a separate ticket. Full detail in DOCS.md.
|
||
|
||
## 0.3.0
|
||
|
||
**SAFETY-04.** The control law's integrator is now an explicit accumulator,
|
||
bounded independently of the output clamp instead of inheriting whatever
|
||
headroom the clamp happened to leave. It also freezes while the inverter is
|
||
not tracking, rather than continuing to wind up against a command nothing is
|
||
acting on. `integrator_max_w` (default `0`) governs the bound; `0` means
|
||
"follow `max_w`", which is the existing behaviour.
|
||
|
||
Behaviour is unchanged at the defaults - a 4,928-case equivalence sweep
|
||
against the previous control law confirms it decides identically at
|
||
`integrator_max_w: 0`.
|
||
|
||
**TEL-01.** P1 meter ingestion, so a Belgian P1's two unsigned registers
|
||
(consumption, injection) no longer need a hand-written signed template
|
||
sensor: the subtraction moves into the add-on, done once and tested. Two
|
||
transports, chosen with the new `meter_source` option: `ha_dsmr` subscribes
|
||
to the DSMR integration over the HA WebSocket, `mqtt_p1` reads a topic.
|
||
Defaults to `off`, which keeps the existing `meter_entity` path untouched -
|
||
nothing changes for an install that does not opt in.
|
||
|
||
Enabling it publishes `sensor.p1_sample_age_s`: seconds since the newest
|
||
accepted telegram, recomputed against the clock and republished roughly once
|
||
a second rather than only when a telegram lands. That is deliberate - Home
|
||
Assistant only pushes a state on change, so a meter sitting at a genuinely
|
||
constant reading would otherwise look identical to a dead one. Watching the
|
||
age instead means a frozen meter shows a climbing age, not a flat line. The
|
||
firmware watchdog subscribes to this exact entity id.
|
||
|
||
Known limits, both already in DOCS.md: on `mqtt_p1`, a bridge stuck
|
||
republishing its last telegram still "arrives", so the age cannot detect
|
||
that particular failure - prefer `ha_dsmr` where both are available. And a
|
||
dead P1 meter takes 45 s to reach 0 W commanded (30 s for `meter_max_age_s`
|
||
to call the reading stale, then 15 s of `stale_input_s` on top), which is
|
||
`meter_max_age_s` and `stale_input_s` stacking, not either one alone.
|
||
|
||
## 0.2.1
|
||
|
||
`target_grid_w` (default -10 W): what the meter should rest at. The deadband
|
||
holds any resting point inside it indefinitely, and the meter bills import and
|
||
export on separate registers, so resting at +14 W import costs 0.34 kWh/day
|
||
with the loop behaving perfectly. Biasing the target slightly negative moves
|
||
that residue onto the export register. Configurable in the Configuration tab;
|
||
see DOCS.md for the trade-off table.
|
||
|
||
Behaviour is unchanged at `target_grid_w: 0`.
|
||
|
||
## 0.2.0
|
||
|
||
Precedence between strategies is now a first-class object instead of an if/else
|
||
ladder, ahead of there being more than three of them.
|
||
|
||
Every strategy returns a CLAIM each cycle - `set` ("I want X") or `limit` ("the
|
||
result must stay within these bounds") - and `arbiter.py` resolves them by one
|
||
rule:
|
||
|
||
1. Highest-priority `set` wins; no claim at all means 0 W.
|
||
2. Then every `limit` whose priority is >= that set's priority applies, most
|
||
restrictive first.
|
||
3. Contradictory limits are a BUG: command 0 W and say so.
|
||
|
||
Clause 2 is why "money outranks maintenance" is now a consequence of the
|
||
priorities rather than a special case in a Jinja template: the maintenance
|
||
charge-only limit binds the loop, but will not bind a higher-priority peak
|
||
shaving claim when one exists.
|
||
|
||
- Maintenance shaping (charge-only, cheap-window floor) moved out of the control
|
||
law. `control.py` is once again only a controller that tracks the meter.
|
||
- The loop computes from the ARBITER's last output, not its own last wish. If
|
||
something outranked it, that is what the hardware actually did, and tracking
|
||
anything else makes it jump when it regains control.
|
||
- Every decision is explainable: "loop -> 0 W, limited by maintenance
|
||
(charge-only)" now appears in the UI and the log, instead of a bare number.
|
||
- Safety limits (device rating, supervised max_w) bind every strategy including
|
||
the highest, and are still enforced a second time at the point of writing.
|
||
|
||
## 0.1.6
|
||
|
||
Findings from installing this on a live system, replacing a working YAML
|
||
implementation. Every one of these was silent - the add-on looked healthy while
|
||
being completely unable to do its job.
|
||
|
||
- **`run.sh` must use `#!/usr/bin/with-contenv sh`.** The HA base images run
|
||
s6-overlay, which starts services with a SANITISED environment. With a plain
|
||
shebang, SUPERVISOR_TOKEN is simply absent and every Home Assistant call
|
||
returns 401 - while `homeassistant_api: true` makes the permissions look
|
||
correctly granted. Startup now logs the token length and probes the Core API,
|
||
so the next person sees it in one line.
|
||
- **Bump `version:` for every change.** Supervisor keys the built image by
|
||
version, so editing source and rebuilding silently reuses the old image. Two
|
||
fixes appeared not to work because of this.
|
||
- **Dependencies come from apk, not pip.** Alpine is musl and there are no musl
|
||
wheels for aiohttp; pip would compile it on the client's Pi.
|
||
- **paho-mqtt 1.x and 2.x are both supported.** Alpine ships 1.x, which has no
|
||
`CallbackAPIVersion`; that raised and took the whole add-on down with it.
|
||
- **MQTT can no longer take down control.** Publisher construction is wrapped -
|
||
observability must never stop the controller.
|
||
- **MQTT discovery is published from `on_connect`.** paho drops QoS-0 publishes
|
||
issued before the CONNACK, so announcing straight after `connect()` published
|
||
nothing at all while logging "MQTT connected".
|
||
- **Repeated failures log at most once a minute.** The control loop retries every
|
||
second; unthrottled warnings rolled the log buffer and destroyed the startup
|
||
diagnostics needed to debug the 401 above.
|
||
- **`auto_start` works.** The store's defaults supplied `auto: False`, so the
|
||
fallback to the option could never fire.
|
||
|
||
Known issue: after deleting the MQTT entities from the registry during
|
||
development, Home Assistant would not re-adopt them from retained discovery -
|
||
not even after clearing the retained topics and reconnecting. The add-on
|
||
publishes correct discovery and live state (verified on the broker); this is an
|
||
HA-side adoption problem and affects status entities only, never control.
|
||
|
||
## 0.1.0
|
||
|
||
First packaged release. Ports the control loop and the monthly maintenance
|
||
cycle from the reference Home Assistant implementation into an add-on.
|
||
|
||
- Grid-following control: gain/slew/clamp/deadband with anti-windup, all tuned
|
||
against measured hardware behaviour (see FIELD-GUIDE.md §14).
|
||
- Saturation freeze **with the duration term** — three consecutive diverging
|
||
cycles, not one. The instantaneous test fires on every large correction,
|
||
because the plant itself needs 3-6 s to settle.
|
||
- Monthly maintenance cycle as an ownership state machine: drain / charge /
|
||
hold, with exactly one writer of the setpoint at any moment.
|
||
- Capacity-tariff awareness: the maintenance charge is capped by quarter-hour
|
||
peak headroom, and peak shaving outranks the maintenance schedule.
|
||
- Failsafe behaviour: commands 0 W on missing inputs, on stop, and on shutdown.
|
||
Never replays a stale setpoint - the reference implementation did, and the
|
||
hardware watchdog cannot catch that.
|
||
- Ingress UI with a commissioning checklist that names problems in words.
|
||
- Optional MQTT discovery for status entities.
|
||
|
||
Known limits:
|
||
|
||
- Home Assistant OS / Supervised only (add-ons cannot run on Container/Core).
|
||
- The inverter protocol is reverse-engineered; no vendor contract.
|
||
- Without the optional RS485 e-stop, nothing covers the host machine dying.
|