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