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