Commit Graph
94 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 d8e02ad3f8 PKG-06: bump 0.6.0 -> 0.7.0, because the schema changed and nobody bumped
checks / unit (pull_request) Successful in 5m2s
Six merges landed since 0.6.0 was deployed and the version never moved.
Disk and box both read 0.6.0, so `ha apps update` would have been a
NO-OP: Supervisor keys the built image by `version:`, and the update path
installs `version_latest`, which cannot differ from `version` without a
bump. Found while pre-checking the deploy, not after it silently did
nothing.

⚠️ MINOR RATHER THAN PATCH, BECAUSE THE SCHEMA CHANGED. SAFETY-32 added
`bms_soc_floor: int(0,50)` as a REQUIRED per-entry key on the `inverters`
roster - no trailing `?` - so a config declaring a roster and omitting it
on any entry is refused at startup. Verified against the deployed
2d025ee: the per-entry schema went from 6 keys to 7. Top-level options
and schema are unchanged at 63 each.

An install running `inverters: []` is unaffected, which is every
single-inverter install including the reference one: the flat
`bms_soc_floor` still describes the one pack. But CFG-04 recorded the
rule this follows - "a schema change without a bump is a schema nobody
reads" - and this is exactly that shape.

The CHANGELOG gains a 0.7.0 section listing the six merges by SHA. It
also records, rather than papers over, that 0.6.0 was never sectioned:
its content sits below the new heading because the earlier bump shipped
without cutting a section. Splitting it retroactively would mean guessing
a boundary; the deployed 0.6.0 is 2d025ee and the table says what is new
since it.

Full suite: 21 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 18:38:41 +02:00
glenn schrooyenandClaude Opus 5 3527c8a04c SAFETY-41: escalate a sustained engagement, so a runaway is never held
checks / unit (pull_request) Successful in 4m59s
Review found the case I asked it to construct, and it is not a corner
case. The rate limit alone DELAYS A RUNAWAY: a benign one-cycle clamp
that spends the budget, then a continuous §10.3-shaped runaway, held the
runaway's first notification for up to 140 s - one REASSERT_S minus a
cycle. On the live install, where ordinary railing spends the budget
essentially all the time, a runaway arriving during normal operation
would be delayed with near-certainty. That is the "benign noise
suppresses the bug signal" shape this module's docstring is written
against, and the family the first attempt at this card died of.

THE ESCALATION. An episode that has not yet spoken because an EARLIER,
unrelated episode spent the budget speaks anyway once it has engaged
SUSTAINED_CYCLES = 3 times in a row. The discriminator is run length and
the two populations genuinely differ: the live flood was runs of length
ONE (engage, de-assert, engage), while a runaway engages every cycle.

Measured, and it costs nothing it was protecting:
  live scatter flood (305 cycles, 1-in-6)   11 -> 11 notifications
  chatter 1-engaged/3-clean over 8000 s     50 -> 50 notifications
  pinned 900 s quarter (floor is 5)          6 ->  6 alarms
  runaway delay after a benign episode     140 s -> 10 s

⚠️ MY COUNTERFACTUAL CHECK COULD NOT FAIL FOR THE DEFECT IT NAMED, and
review caught that too. It read `ClampWatchdog().note(True, ...) is True`
- a FRESH object, which only exercises the `last_alarm is None` branch,
the trivially-true one. A fresh watchdog has no earlier episode to be
held back BY. It now SPENDS THE BUDGET FIRST: benign clamp, gap past
CLEAR_CYCLES, then a continuous runaway, and asserts the delay is well
under REASSERT_S. That is the third instance in this file of the family
recorded at line 216 and at the 224 s sweep, and it was guarding the most
safety-critical property in the change.

Also pinned: `last_alarm` SURVIVING the de-assert. Review built the
mutation - if the de-assert also refreshed it, chatter collapses from 50
notifications to 1 and the alarm never fires again, with the suite green.
That is the "alarm that never fires" class, a different bug from one that
fires later. Now caught.

Both new checks verified against the mutations they name: removing the
escalation -> 1 FAILED; refreshing last_alarm on de-assert -> 1 FAILED.

Full suite: 21 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 18:13:09 +02:00
glenn schrooyenandClaude Opus 5 8790c4d38f SAFETY-41: rate-limit the RISING EDGE, which is where the flood came from
checks / unit (pull_request) Successful in 5m9s
Review falsified the first version of this change, and the correction is
the substance of it.

Raising REASSERT_S 30 -> 150 removed NONE of the owner's notifications.
The re-assert interval governs only the still-engaged path; the rising
edge fired unconditionally. At the measured ~5 s cycle the live pattern -
48 engagements in ~25 minutes, about one cycle in six - leaves ~25 s of
clean cycles between engagements, past CLEAR_CYCLES x 5 s = 15 s. So the
alarm DE-ASSERTED between almost every engagement and each one returned
as a fresh rising edge. Measured on the shipped class over that window:
48 episodes, 48 notifications at REASSERT_S = 30, and still 48 at 150.

The interval now bounds the NOTIFICATION RATE rather than the repeat rate
within one episode: `last_alarm` survives a de-assert, and a new episode
speaks only if the interval has elapsed. Same window afterwards: 51
episodes, 51 engagements, 11 notifications.

⚠️ NOTHING IS DROPPED, AND THE TWO PROPERTIES THAT MATTER ARE PINNED:
  - a PINNED loop still speaks 6 times in the 900 s billing quarter,
    against a floor of 5 (part 2, unchanged);
  - a rising edge from a COLD watchdog still fires IMMEDIATELY, so a
    runaway beginning from quiet is never held back by a budget spent on
    an earlier, unrelated episode. That check is the counterfactual and it
    is the half that would go red if the limit ignored elapsed silence.
Every engagement is still counted, every episode still increments, and
the action-log row is still written on every engaged cycle. The budget is
on notifications, never on evidence.

The new pin is written in wall-clock seconds and in the shape the field
produced, so it does not move when the constant does. Verified by
reverting the rising edge to unconditional: 2 checks go RED naming it.

Full suite: 21 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 17:06:29 +02:00
glenn schrooyenandClaude Opus 5 1d393ced22 SAFETY-41: correct the comment, and fix the check it cites
Review found two false statements in the comment and one weak check. All
three are fixed here; the constant itself is unchanged at 150 s and was
never in question.

1. "That caps this constant at 900/5 = 180 s" was FALSE ABOUT THE CHECK IT
   CITED. test_clamp.py part 2 sweeps i = 0..899 and the rising edge at
   i=0 is free, so it measured `1 + floor(899/R)` - one alarm more than a
   real quarter earns. Measured: 224 s was GREEN across the whole suite.
   Anyone raising this to 200 s would have had a green board and a
   requirement quietly unmet - the same defect recorded at
   test_clamp.py:216, one notch weaker.

   The check now grades floor(QUARTER_S / REASSERT_S) directly, so it
   enforces the ceiling the comment claims for it. Verified: 150 PASS,
   180 PASS, 181 FAIL, 224 FAIL. 180 is also the true requirement edge -
   on the worst sliding 900 s window 180 earns 5 alarms and 181 earns 4.

2. The "60 cycles of the §10.3 runaway" note was attributed to this module
   and truncated. It is main.py:2479 and reads "60 cycles of the §10.3
   runaway produce 0 write-path engagements and 2 loop-side ones". Quoted
   in full, attributed correctly.

3. "48 engagements in 25 minutes" was left implying a pinned rail. It is
   roughly one cycle in six - real, and far above the 2-in-60 the guard
   was sized for, but not continuous. Restated, and labelled as a live-log
   reading rather than a measurement in this repo.

Also recorded: the 1500 W tuning derivation is in the OTHER repo
(admin/goodwe NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp
1500)"), so this repo cannot corroborate it and a reader should not go
looking here; and MIN_PER_QUARTER = 5 is a test-local integer, not a spec
figure - the 900 s quarter is system-wide but the 5 is not, so the 180 s
ceiling rests on one number a test author chose.

Full suite: 20 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 16:53:46 +02:00
glenn schrooyenandClaude Opus 5 3516947b5e SAFETY-41: raise the clamp alarm's re-assert 30 s -> 150 s
The owner reported the OUTPUT_CLAMP notification as a persistent
annoyance on the live install. It re-asserted every 30 s, all day.

⚠️ THIS IS A REGIME CHANGE, NOT A CHANGE OF MIND ABOUT THE ALARM. 30 s
was chosen when a loop-side engagement was rare - clamp.py's own note
records "60 cycles of the §10.3 runaway produce 2 loop-side engagements".
The live install produced 48 IN 25 MINUTES of ordinary operation, because
`max_w` is 5000 W where the loop's tuning was derived at a 1500 W clamp
(SAFETY-39). The integrator therefore reaches its bound constantly and
the alarm narrated it every 30 s. An alarm that speaks that often about
the normal case is how an operator learns to ignore the one that matters,
which is the exact failure clamp.py's docstring is written against.

⚠️ 150 s IS THE REQUIREMENT'S OWN EDGE WITH ONE STEP OF MARGIN, not a
comfort choice. test_clamp.py part 2 pins the cadence in WALL-CLOCK terms
rather than against this constant: a loop pinned on the rail must speak
at least MIN_PER_QUARTER = 5 times inside the 900 s quarter that bills
the capacity tariff. That caps the constant at 900/5 = 180 s. Measured:
  180 s -> 5 alarms per quarter (exactly the required 5, no margin)
  150 s -> 6 alarms per quarter
  300 s -> 3, and the floor check goes RED
180 s would sit on the boundary, so any later change to the firing logic
would tip it red for a reason unrelated to that change. The FLOOR is the
thing that must not move, and it does not.

⚠️ WHAT THIS DOES NOT DO. It changes how often the alarm repeats. It does
NOT make the alarm correct: the message still calls the integrator bound
a BUG SIGNAL and still pairs a request from one moment with a `self.target`
read at another. That is SAFETY-40. And it does not stop the loop railing
in the first place; that is SAFETY-39. This is a stop-gap and says so at
the line.

Nothing was suppressed: every engagement is still counted, the action-log
row is unchanged, and the §10.3 runaway still raises the alarm - an
earlier attempt at this that split the alarm so only `clamped` notified
was REVERTED because test_clamp.py:398 proved it would have disabled
runaway detection entirely (a runaway presents as `i_clamped`, and
control.py:78-81 says `clamped` is unreachable on a default install).

Full suite: 20 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 16:53:46 +02:00
glenn schrooyenandClaude Opus 5 a4c95f83ce SAFETY-32 round 3: a fixture of N packs cannot separate reductions that agree on N
Both blockers were the recurring defect sitting inside the fix written to close
it: a check whose TEXT claims more than its FIXTURE can observe. Both survived a
green 19-file suite on 8501e05.

  max(floors) -> max(floors[-2:])   +5 bytes.  12c2's only counterfactual raised
  the LAST pack, and every window ending at the roster's end follows the last
  pack. The check said "the reduction reads every entry"; the data reached "it
  reads the last entry".

  self.packs -> self.packs[:2]      +4 bytes.  All three 12g runs drove a TWO-pack
  roster, where [:2] IS the roster. The check said "what is reduced is the ROSTER
  and not a POSITION in it"; the true claim was the narrower "no SINGLE-POSITION
  slice passes all three runs".

The prose is cut back to what the old data reaches, and new data added for the
rest. No app/ file is touched - app/peakguard.py and app/main.py are byte-identical
to 8501e05.

12c2 - the pure function:
  * a ROSTER SET: the same four simulated cut-outs with the deepest moved to each
    of the four positions.
  * a computed FAMILY TABLE of fourteen rewrites, printed, with the roster that
    kills each. A member with an empty kill list takes the check RED naming it.
  * a LENGTH SWEEP: 78 rosters, lengths 1-12, deepest at every position of each.
    Closes every head window max(floors[:w]) and tail window max(floors[-w:]) of
    width 1-11 in one property.

12g - the call site:
  * _floor_reaching_decide() takes *floors; extra units join through the same
    second_inverter() hook.
  * THREE-pack runs with the deep cut-out at index 0, 1 and 2. Every positional
    slice of a three-pack roster drops a position, and the run whose deep pack
    sits there answers the option where the roster answers the cut-out.
  * a POSITIONAL-SLICE TABLE computed against the REAL Controller.packs each of
    the six cycles ran and the REAL PeakDecision.soc_floor it produced.
  * an EXACT STRUCTURAL PIN on the roster argument's identity. Not the substring
    test round 2 removed: "self.packs" in expr is TRUE of every slice;
    ast.unparse(arg) == "self.packs" is FALSE of all of them.

THE GENERAL LAW, and why the pin is there: a fixture of N packs cannot separate
two reductions that agree on N packs, and the regress does not terminate in a
fixture. [:1] needed two packs, [:2] three, [:3] four. This round's own battery
found self.packs[:3] (+4) and self.packs[-3:] (+5) surviving all six behavioural
runs; the structural pin is what closes them, and one more pack would only have
moved the hole.

Battery: 46 mutations over the reduction, the call site, Controller.packs and
pack_floor_pct()'s conjunction. Bytecode purged and counted 23 -> 0 for each.
43 red, 3 survivors, all three recorded:
  max(floors[:12]) / max(floors[-12:])  NOT CAUGHT - needs a 13-pack roster,
                                        reported with its size, row 27.
  max(set(floors))                      EQUIVALENT - max is duplicate-invariant.
(list(reversed(self.packs)) is equivalent too and is red on the structural pin.)

In-process mutation rows 24-27 record all of it, including the not-caught one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
glenn schrooyenandClaude Opus 5 ffbabea8ce SAFETY-32 fix round: grade the reduction on packs whose floors DIFFER
AC 6 was not met for the reduction main.py actually hands decide(). The
reviewer removed the per-pack grading twice and the 19-file suite stayed green
both times. No shipped behaviour changes here - app/ is untouched. What changes
is what observes it.

WHY BOTH SURVIVED, and it is this repository's recurring defect landing on this
card's own Decision 2: under any rule-8-valid config every pack's
pack_floor_pct() equals peak_soc_floor, so `floors` is a list of IDENTICAL
numbers and max/min/first/last are four spellings of one answer. The only check
using a cut-out ABOVE the EMS floor - 12c's `_n1_deep` - ran on a roster of ONE,
where every reduction agrees. So no check ever evaluated binding_soc_floor() on
two packs whose floors differ. And the degeneracy is not a defence: this PR's own
argument ("unreachable is a claim about today's rule ordering, and
validate_startup_config() is also sim/inject.py's entry point") justifies writing
the reduction and equally obliges grading it.

  * test_peakguard.py part 12c2 - NEW. binding_soc_floor() on a roster whose
    per-pack floors ALL DIFFER: four SIMULATED cut-outs 30/25/40/35 %, each
    strictly above peak_soc_floor, so max (40), min (25), first (30), last (35)
    and the mean (32.5) are five different numbers and the extremes sit at
    INTERIOR roster positions. Two packs structurally cannot separate those -
    there max==last and min==first - and neither can three; four is the smallest
    roster that can. Precondition is its own check (habit 1); the counterfactual
    raises ONE pack - the last, neither extreme - past the old maximum and the
    answer follows (habit 7); peak_soc_floor is then lifted above every cut-out
    to grade the other side of the conjunction.
  * test_peakguard.py part 12g - REWRITTEN as behaviour (habit 4). The old
    assertion was `"self.packs" in <the unparsed call site>`, which `self.packs[:1]`
    still satisfies. It now drives REAL Controller.cycle()s on a two-pack roster,
    both packs at the same SoC, and reads PeakDecision.soc_floor - the argument,
    echoed back by the shipped decide(). The cut-outs are the only thing varied,
    and the SAME PAIR is carried in BOTH ORDERS.
  * ⚠️ TWO MORE SURVIVORS, found by this round's own battery and recorded rather
    than quietly repaired. (a) The first cut of 12g varied only pack B's cut-out,
    so `self.packs[-1:]` (+5 bytes) agreed with the roster on both runs and stayed
    green across all 19 files; the swapped pair closes it, and between the two
    directions no single-position slice passes all three runs. (b)
    binding_soc_floor()'s EMPTY-roster branch rewritten to `return None`
    (-43 bytes) was green too - a defensive branch nothing in the suite reached.
    Both directions of it are pinned now.
  * wired_over_limit() gained a prepare(ctl) hook, inside its retry loop so every
    attempt grades the same site.
  * In-process mutation rows 22, 22b and 23 record all four survivors.

Nine further source mutations went through the harness on the fixed tree
(binding_soc_floor -> floors[0] / floors[-1], pack_floor_pct conjoining with
min, pack_permissions grading against packs[0], `any` -> `all` on the unknown
floor, Controller.packs reversed, decide(soc=) taking inverters[0].soc, and the
two survivors above). Seven were red on first run; the two survivors are now red
as well. Three of the nine have a ZERO byte delta and still went red, which is
the direction stale bytecode cannot fabricate - and the harness purged anyway.

MANDATORY PROOF, all in the CI image over a tree rsynced to a plain path and
bind-mounted :ro, bytecode entry count printed before and after the purge:
each of the four mutations now takes test_peakguard.py RED, and the unmutated
tree is 19/19 green (twice, plus test_peakguard.py five times in isolation -
card CI-06's flake did not reappear). The zero-byte one was proved to have RUN rather than
assumed: dis.get_instructions LOAD_GLOBALs were [float, pack_floor_pct, any,
min] and binding_soc_floor() returned 25.0 on the 12c2 roster where the shipped
code returns 40.0.

No measurement of inverter B appears anywhere. It does not physically exist.
Every second-pack and fourth-pack figure added here is SIMULATED and labelled at
its definition; only inverter A's 10 % is read off hardware. No hardware was
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
glenn schrooyenandClaude Opus 5 a9993aeda2 SPIKE-04a: inverter B is 4600 W FROM ITS DATASHEET, and the copies are pinned
checks / unit (pull_request) Successful in 4m51s
The desk half of SPIKE-04. No control behaviour changes; this is documentation
and the check that stops it drifting again.

Three places stated inverter B's rating - config.yaml's commented roster
example, DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator
would POST" - and all three said 3000 W, a number nobody had measured or read
off anything. The owner supplied B's model and serial on 2026-09-04: a GoodWe
GW5048D-ES, serial `55048ESU22AW0992`, max charging and discharging power
4600 W. Serial characters 6-8 are `ESU`, platform 105 / AA55, so SPIKE-01 does
not gate this site. All three copies now read 4600.

⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and the one ambiguity
is recorded rather than guessed. The datasheet prints it under BATTERY INPUT
DATA, so it is probably DC, while max_w is graded against 0x0518 - the
inverter's own AC power. If it is DC the AC ceiling is nearer 4324 W. Neither
reading is written in as fact; SAFETY-20's min(max_w, dev_max) already bounds
it correctly under either, once dev_max is read off the unit, so `max_w: 4600`
is what an operator should configure today. goodwe/NOTES.md §1.1b carries the
full record with the date and the method on every figure.

A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.
test_validate.py now pins all three documented rosters: findable, in agreement,
and equal to 4600 - with an explicit len(...) == 3 guard so that a regex which
stopped matching cannot leave "they all agree" green on the two sites still
read. It also grades the PROVENANCE beside the number (the datasheet, the
0x0518 it is graded against, and the 4324 W the DC reading would make it)
WITHIN 2500 characters of the roster rather than file-wide.

⚠️ The first version of that provenance check searched whole files and was
ALREADY VACUOUS on two of them: 0x0518 appeared once in config.yaml and once in
DOCS.md before this card, for unrelated reasons, so `"0x0518" in src` was green
no matter what happened next to B's rating. That is a check whose data cannot
reach the failure it names, in the file that exists to complain about them. It
is fixed, and the window is what fixes it - deleting the caveat beside the
number now goes red even where the same token survives elsewhere in the file.
The windowed version immediately found that the DOCS.md caveat did not name
0x0518 at all; DOCS.md now says it.

Four mutations, bytecode purged and counted at 0 before and after each, all
RED (two of them 0-byte deltas that change a printed answer):
  M1  probe's operator blob 4600 -> 3000        0 B   RED (value + agreement)
  M2  DOCS.md's DC/AC caveat deleted         -157 B   RED (provenance)
  M3  config.yaml alone 4600 -> 4324            0 B   RED (agreement)
  M4  probe entity renamed, regex finds nothing       RED (the vacuity guard)
Restored, green again. The probe itself was re-run against Supervisor
2026.08.0's own AppOptions validator with the 4600: all cases passed, exit 0.

⚠️ THE TEST FIXTURES KEEP 3000 W ON PURPOSE AND NOW SAY WHY AT THE LINE.
test_inverter.py, test_maintenance.py, test_peakguard.py and test_watchdog.py
build a second inverter at 3000 W against A's 5000 W. The only property those
checks consume is that B's number is unmistakably not A's, and the real pair is
a 400 W gap - narrower than slew_w, inside the range where an off-by-one-unit
bug and ordinary control arithmetic produce indistinguishable numbers.
Collapsing them onto the real values would buy realism those checks do not use
and cost them their separating power, which is exactly what test_peakguard.py's
own §10g PRECONDITION exists to prevent. Their comments no longer call 3000 W a
hypothesis about B's rating; they call it illustrative, and not a placeholder
either - the placeholder era ended on 2026-09-04.

Also recorded in the changelog for the cards that own them: 94 % battery-to-AC
=> ~88 % round trip => ~360 W burned continuously to circulate 3 kW, which is
the number SAFETY-31 argues its threshold from without having one; < 13 W night
self-consumption per unit, which nothing accounts for; and the A/B asymmetry in
opposite directions (~33 % more energy, ~8 % less power), which means MAINT-05's
12 h drain timeout was derived on A and should be re-checked against B.

⚠️ Honest survivors:
- goodwe/NOTES.md §1.1b, where all of this is actually recorded, is graded by
  nothing here. Rewriting its 16 occurrences of 4600 to 3000 and turning every
  FROM DATASHEET label into MEASURED ON THE UNIT leaves all 19 files GREEN.
  This CI cannot fetch admin/goodwe - private, and GITHUB_TOKEN is minted for
  this repository only, as .gitea/workflows/checks.yml already states - so the
  cross-repo check is not buildable here.
- No check can see whether 4600 is the RIGHT number. Everything above pins a
  datasheet reading against itself. Only the bench half of SPIKE-04 can say
  whether the unit agrees, and the DC-vs-AC ambiguity is the live example.
- The constant is still named B_RATING_HYPOTHESIS in four test files, which
  reads as a hypothesis about B's rating when it is now an illustrative
  fixture. It is left alone deliberately: renaming ~20 sites across
  test_peakguard.py and test_inverter.py would collide hard with the open
  SAFETY-32 pull request, which rewrites 1255 lines of the first. The comment
  directly above each definition says what the value is and is not.

Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and
verified 0 before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 22:19:31 +02:00
glenn schrooyenandClaude Opus 5 65cddf35f1 MAINT-05 fix round: grade the migration against the ROSTER, not against the name it was handed
checks / unit (pull_request) Successful in 4m47s
REJECTED because a survivor undoes AC 5, and it survived for the recurring
reason: two fixture values that must be distinguishable were EQUAL.

THE SURVIVOR. app/main.py:282 mutated from `self.inverters[0].name` to `UNIT_A`
left the full 19-file suite green. `inverter.UNIT_A == "inverter-a"`, and every
two-pack fixture in test_maintenance.py named its first roster entry
"inverter-a" - so the two expressions were the same string in every check that
existed. main.py's own comment made the distinction load-bearing and nothing in
the repository could see whether that comment was true.

THE FAILURE IS REAL. `build_roster()` names a declared roster's entries from the
config and numbers a nameless one POSITIONALLY (`inverter-1`), so "inverter-a"
is not even the fallback. On ["garage","shed"] with a flat last_completed of
2026-08-07 the mutant migrates into unit/inverter-a/, pack 0 reads back None,
and both packs come up believing they have never been recalibrated.

AND IT FAILED SILENTLY, which was the worse half: `_verify_moves()` read back
the keys it had just WRITTEN, under the namespace it had just been HANDED, was
satisfied, and MAINT_SCHEMA_KEY was stamped - so the next start skipped the
migration too. A postcondition that reads back the namespace it was handed
cannot detect being handed the wrong one.

WHAT CHANGED

1. THE POSTCONDITION IS GRADED AGAINST THE ROSTER. `_verify_moves()` takes the
   names the MACHINES read and reports a namespace that is not among them as
   `misdirected` - a new, differently-named failure from `lost`:
     * `lost` is "the store took the write and dropped it" - a storage fault,
       with a harmless-default case (a dropped `last_completed: None` loses
       nothing, because absent and never-recalibrated are the same statement);
     * `misdirected` is "the store did exactly as it was told, by a caller that
       was wrong". There is no harmless-default version of it - a write nobody
       reads is not information whatever its value - so it fails `ok` alone.
   `ok` gates the marker, so a misdirected migration is NOT stamped and the next
   start re-runs it. Asymmetric on purpose: a re-run is recoverable (rule 1
   makes it a no-op, rule 2 left the flat keys to recover from), a silent skip
   is not. Part 9b MEASURES the recovery rather than asserting it.

2. `migrate_maint_keys()` TAKES THE ROSTER IN PLACE OF AN `n_units` COUNT. The
   count was `len(self.inverters)` at the only call site, so the roster carries
   it, and a count beside the names is a second opinion about how many packs
   there are.

3. THE FIXTURES CAN NOW REACH THE FAILURE THEY NAME. Parts 9 and 11 run on
   ["garage","shed"], each behind a PRECONDITION check that the first name is
   not UNIT_A - so an edit that makes them equal again goes red instead of
   making the claims vacuous. Part 11's fixture date is relative to `now`, not
   a literal, so "history preserved" and "history lost" cannot converge as the
   file ages past `interval_days`.

MUTATION LEDGER - all red, purged tree, none at +0 B, full detail on the PR:
   M1  main.py  inverters[0].name -> UNIT_A            -16 B  4 checks red
   M2  store.py drop `roster=names` from _verify_moves -14 B  5 checks red
   M3  store.py drop the misdirected.append            -37 B  5 checks red
   M4  store.py ok: drop `and not self.misdirected`    -25 B  3 checks red
   M5  fixture  garage -> inverter-a (the trap itself)  +4 B  5 checks red
   M6  store.py parts[1] -> parts[-1]                   +1 B  1 check red

FOUND AND NOT FIXED - `migrate_unit_keys()` HAS THE SAME HOLE AND IT IS A LATCH.
It runs BEFORE `build_roster()` (each Inverter restores its latch in its
constructor), so it is handed the constant UNIT_A with no roster to grade
against. On a declared roster a pre-DEV-02 `sign_tripped: true` lands under
unit/inverter-a/sign_tripped, which no unit reads - and
`_grade_store_migration()`, whose whole job is to re-arm exactly this, is
DEFEATED BY THE KEY THE DEFECT WROTE: its "is anything namespaced latched" test
is satisfied by the misdirected value itself. Measured, with the flat-config
counterfactual, and pinned as a running check (test_maintenance.py part 13)
whose assertions state the WRONG behaviour on purpose. It wants its own card:
the remedy is in inverter.py and SAFETY-02's semantics, and a silent expansion
into a safety latch is how a rejected PR becomes a broken one.

Suite: 19/19 PASS, purged tree, bind-mounted :ro in goodwe-addon-ci.
No hardware was touched; every fixture here is SIMULATED and pack B's 3000 W
remains a marked hypothesis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 16:18:34 +02:00
glenn schrooyenandClaude Opus 5 ae21b71824 Merge release/1.0 into SAFETY-30
checks / unit (pull_request) Successful in 4m39s
CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved
as the union of the two sides:

- app/main.py: an import-block collision. CFG-04 added validate_unit_ranges
  to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER,
  SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither
  side dropped anything, so the resolution keeps both lists whole.
- CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept.

Both sides also independently rewrote large parts of test_peakguard.py
(60 and 47 insertions against the merge base) and git merged them
textually, so the full suite was run on the MERGE RESULT rather than
inferred from the two parents: 19 passed, 0 failed, twice.

One caveat recorded rather than hidden: test_peakguard.py failed once
during the first sequential full-suite run and then passed 10/10 in
isolation and in two further full runs on this same tree. It passes on
both parents too, so this is the pre-existing flakiness CFG-04 already
disclosed, not a merge defect - it is timing- or load-sensitive. It sits
on the required CI context and can therefore block an unrelated merge at
random, which wants a card of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 12:23:50 +02:00
glenn schrooyenandClaude Opus 5 d5c1577212 SAFETY-30: pin WHICH thresholds each sign detector is armed with, not just what the numbers are
checks / unit (pull_request) Successful in 4m48s
REVIEW FOUND A MUTATION SURVIVOR THAT UNDOES THE WHOLE PRODUCT OF AC 5, and it
is a coverage defect rather than a behaviour one - the shipped code arms the
detectors correctly today. In `_arm_sign_watchdogs()`, collapsing

    inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg, ...)

to `arm(self.sign_cfg, ...)` throws the re-derived 250 W / 85 s per-unit pair
away and arms every unit with SAFETY-02's meter-facing 300 W / 180 s - and left
ALL 19 FILES GREEN. Reproduced here before fixing anything: fresh per-mutation
tree, no __pycache__, mounted :ro, graded through tools/mutation_run.sh; -31 B;
19 pass, 0 fail.

WHY NOTHING CAUGHT IT. `grep -rn "sign_wd.cfg" test_*.py` found exactly ONE
threshold assertion, part 7's N=1 arm at (300.0, 1.0) - which the mutation
PRESERVES. Nothing at a roster of two read `inv.sign_wd.cfg` at all. The ledger
mutates the VALUES in SIGN_OPTION_DEFAULTS (M10 250->300, M11 325->180, both
correctly red through the config pins) and never the WIRING that delivers them.
Part 3's behavioural checks cannot close the gap either, and the comment now
says why: B is reversed at ~2000 W with short holds, so it trips under EITHER
pair, and a fault big enough and long enough to trip under both thresholds is
evidence about neither.

THE FIX - test_watchdog.py part 3, on the real two-inverter Controller. Every
`inv.sign_wd.cfg` equals the per-unit pair, `site_sign_wd.cfg` equals the site
pair, and each detector's `against` is its own instrument. Graded against
LITERALS, because `== self.unit_sign_cfg` cannot tell "the per-unit pair" from
"whatever this Controller was handed". Both pairs are printed.

⚠️ AND THE FIXTURE HAD TO CHANGE FOR THE PIN TO MEAN ANYTHING - the recursion
worth naming, because it is the same defect class as the one being closed.
`_sign_site` set `sign_disagree_s`, `unit_sign_disagree_s` AND
`site_sign_disagree_s` all to 1 s, with two of the three magnitude gates at
300 W. A bare equality there discriminates on `min_w` alone: a mis-wiring that
delivered the right magnitude with the WRONG HOLD compares EQUAL to the pair it
was supposed to have. A check comparing two things that happen to be equal
cannot see the difference it names (MUTATION.md's third failure mode). The
three pairs are now pairwise distinct IN BOTH COORDINATES - 250 W / 1 s per
unit, 320 W / 2 s site, 300 W / 3 s shipped - a PRECONDITION asserts that
distinctness so a later edit collapsing them goes red rather than quietly
vacuous, and a second PRECONDITION asserts the literals are the pairs this
Controller actually holds.

VERIFIED BY BREAKING IT. Three mutations, each on its own freshly copied tree
with no __pycache__, mounted :ro, through tools/mutation_run.sh:

  M14  arm() handed self.sign_cfg at every roster        -31 B  RED (was the
       survivor; the AC 5 WIRING check names the wiring)
  M15  right min_w, SHIPPED hold - hold-only mis-wire    +78 B  RED, same pin
  M16  right min_w, SITE hold - hold-only, and it still
       trips inside the drive                            +83 B  RED, and the
       ONLY red in test_watchdog.py

M16 is the one that says the hold half of the equality bites on its own.
Unmutated tree: 19 pass, 0 fail, purged and fingerprinted.

Ledger rows M14-M16 and the write-up in DECISIONS/SAFETY-30-per-unit-sign.md
section 5; a CHANGELOG bullet.

NOT IN THIS PR, so it gets a card rather than a quiet fix: a stuck per-unit
power sensor would false-trip the new per-unit check at N>=2. Already-merged
SAFETY-31 has the identical exposure on the same fixture. Pre-existing N>=2
class, unreachable at the deployed roster of one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 11:13:13 +02:00
glenn schrooyenandClaude Opus 5 22c016fadd CFG-04 fix round: the probe now measures the key it is cited for
checks / unit (pull_request) Successful in 4m33s
Review blocked this on ONE finding, and it was in the EVIDENCE, not the code.
The design and the shipped behaviour are unchanged.

THE INSTRUMENT DID NOT MEASURE WHAT FOUR DOCUMENTS SAID IT MEASURED

tools/supervisor_shape_probe.py section B validated the RECONSTRUCTED LIVE
options blob - which is missing many required keys that have nothing to do with
this card. Its refusal therefore named whichever of THOSE the validator reached
first: the committed transcript recorded `meter_freeze_s`, the reviewer's run
printed `p1_phase_export_entities`, and a run today printed `sign_disagree_w`.
The string those documents quoted - `Missing option 'inverters' in root` - was
never produced by the instrument.

And section B asserted a bare `not ok`. Any refusal at all satisfied it, so
deleting `inverters: []` from config.yaml left it printing PASS: the probe could
not detect the one regression it is the committed evidence for.

THE UNDERLYING CLAIM IS TRUE AND IS NOW MEASURED PROPERLY

Section B is an ISOLATED COUNTERFACTUAL now: the full shipped `options:` block
(expect accept), and the SAME block with `inverters` removed and nothing else
varying (expect refusal). One key differs, so the refusal has nowhere else to
come from - and the assertion is on the KEY NAME IN THE REFUSAL STRING, so the
line goes red if the refusal is ever about a different key. The probe also exits
non-zero when any case fails; without that a transcript is only evidence to
someone who reads it.

The old live-blob case survives as section B2, relabelled for what it is: an
incomplete reconstruction whose RAW half is refused for unrelated missing keys,
kept because its MERGED half genuinely demonstrates what App.options is.

VERIFIED BY BREAKING IT, WHICH IS THE WHOLE POINT

`inverters: []` deleted from config.yaml, probe re-run against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0:

  == B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
    FAIL  the shipped `options:` block validates AS IT STANDS - ...
          Missing option 'inverters' in root in GoodWe RS485 Controller
  ...
  == 2 CASE(S) FAILED ==

exit 1. Key restored, same image, same command: `== all cases passed ==`, exit
0. The transcript in DECISIONS is that restored run, re-run today, not the old
one.

THE FOUR MIS-CITATIONS

DECISIONS/CFG-04-inverter-roster.md section 4's "section B shows both halves"
sentence and its line-103 transcript; CHANGELOG.md; test_inverter.py's part-9
comment and its part-12 mutation row; and the PR description. All now attribute
the string to section B and say plainly what section B2 does not show. The
part-12 row additionally records that the probe was blind to that mutation until
review, because a mutation table that lists a defeat nothing catches is the
failure this repository keeps finding.

MUTATION.md gains it as the sixth instance of "the check's data cannot reach the
failure it names" - the first found OUTSIDE the suite, in a committed evidence
artifact, because nothing in that document had ever been pointed at tools/. Three
habits with it: assert on the CONTENT of a refusal rather than that one happened;
vary ONE thing, from an ACCEPTED baseline; and treat a committed transcript as a
check subject to the mutation battery.

TWO NON-BLOCKING FINDINGS, ALSO FIXED

  validate.py rules 5 and 6 NAMED THE WRONG INVERTER. `unit_caps()` SKIPS an
  entry with no usable `max_w`, so caps position i stops being roster position i
  the moment one entry is malformed - and rule 5 fed `caps.index(weakest)` to
  `_unit_label()`, which indexes the roster, while rule 6 paired `caps[0]` with a
  hard-wired `_unit_label(opts, 0)`. Right number, another inverter's name: an
  operator sent to the wrong device by the message that exists to stop that. New
  `unit_caps_indexed()` carries the roster index with the cap. It is latent -
  rule 9(d) refuses such a config in the same pass - but that was a property of
  today's rule ordering, not of the code, and validate_startup_config() is also
  sim/inject.py's entry point. Part 10 gains a check that asserts the fixture
  really does desynchronise the two lists, then that both messages name the right
  unit, then the counterfactual: the OLD expressions, on that same config, still
  evaluate to the wrong names.

  DOCS.md called a downgrade "safe". True of the SCHEMA - the older schema drops
  the key with a warning - and incomplete about the ADD-ON: rule 9(e) requires the
  flat `setpoint_entity` be cleared when a roster is declared, so a config that
  downgrades cleanly is by construction one whose flat actuator is blank, and the
  older version has nothing to write to. Confirmed by running release/1.0's own
  validate_startup_config() on that blob: one refusal, naming setpoint_entity.
  Recoverable and loud, but an outage until a field is refilled. The note says so
  now, and tells the operator to write the entity down before downgrading.

19/19 green in the goodwe-addon-ci image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 10:33:51 +02:00
glenn schrooyenandClaude Opus 5 753f700539 DEV-02: merge release/1.0, and close the two review blockers
checks / unit (pull_request) Successful in 4m15s
PR #43 was rejected on two findings and had gone unmergeable behind SAFETY-28
and the ALLOC-01 fix. This is the merge commit; the conflict resolution and both
fixes are below, because a merge that also changes behaviour has to say so.

THE MERGE, AND WHAT IT COST
===========================
Two conflicts in main.py, both where SAFETY-28 and this card touched the same
method:

  * `write_setpoint()` split into a site entry point plus a per-unit
    `_write_unit()` here, while SAFETY-28 put its record gate in the old
    single-unit body. `write_refusal()` now takes the INVERTER it is about.
    Asking it with `self.unit` - as release/1.0 does - would authorise A's share
    on B's wire, and would also trip test_inverter.py part 1's AST scan, which
    goes red on any read of a Controller N=1 view inside Controller.
  * the SAFETY-28 gate sits above the clamp and below the non-finite coercion,
    exactly where release/1.0 put it, and is now asked about `inv`.

Fallout the merge produced in the checks, re-aimed rather than relaxed:

  * every fixture that calls `write_setpoint()` DIRECTLY - the clamp probe, the
    hung-heartbeat experiment and its serialised counterfactual, the split
    deadline probe - now arms a real allocation record first. test_inverter.py
    grows an `arm()` built through the shipped `resolve()`, the same shape
    test_clamp.py's is: the per-unit envelopes are filed as real `supervised`
    claims and the site target is their sum, so the water-fill has exactly one
    answer and the fixture cannot quietly become its own allocator.
  * test_clamp.py's two-unit failsafe fixture assigned `c.units`, which DEV-02
    made a derived property with no setter. It appends a real `Inverter`.

BLOCKER 1 - AC 4's ARBITER-BOUND EVIDENCE WAS VACUOUS
=====================================================
test_peakguard.py claimed "each unit's `supervised` bound is ITS OWN `max_w` -
give inverter B a different rating and the claim filed on inverter B moves with
it", and compared B's bound against `_b2.max_w`. `second_inverter()` DEFAULTED
B's rating to `LIVE_MAX_W`, which is A's. IT COMPARED (-5000, 5000) AGAINST
(-5000, 5000): a number against itself, unable to observe the failure it named.

Reproduced: filing the per-unit `device`/`supervised` claims from
`self.inverters[0]`'s numbers while leaving `actuator=self.actuator_of(..., inv)`
alone left ALL 19 FILES GREEN. At a roster of two with A=5000/B=3000 that makes
inverter B's planned envelope (-5000, 5000) instead of (-3000, 3000) - a 2000 W
over-command the arbiter believes is supervised. It is a DIFFERENT defect from
the actuator variant the static routing pin catches, and only the actuator
variant was in the mutation table.

  * `second_inverter()` gives B 3000 W - a hypothesis, marked as one - for every
    fixture in the file, so no check there can compare B against A by accident.
  * the claim is asserted against LITERAL numbers rather than against an
    expression that can read the same object twice, for `supervised` AND for
    `device` (whose `dev` had the same defect), plus the envelope the ARBITER
    RESOLVED for B - a claim filed right and resolved wrong is the same defect
    one layer down.
  * a PRECONDITION check fails if the two ratings are ever made equal again.
  * the BOUND mutation is in the table, beside the actuator one, and was
    observed RED (3 checks in test_peakguard.py) via tools/mutation_run.sh.

⚠️ THIRD INSTANCE THIS SESSION of a check whose DATA cannot reach the failure it
names - ARB-03's call-site scan saw 3 of 8 appends, ALLOC-01's replay generator
saw 0 escapes where a widened one saw 39,991. MUTATION.md gains a section for it
beside the stale-bytecode and masking ones, and the rule is written at the line:
ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST BE RUN ONCE WITH X AND Y ACTUALLY
MADE DIFFERENT.

BLOCKER 2 - THE LATCH WAS SILENTLY DISARMED BY THE ROLLBACK ROUND TRIP
======================================================================
store.py's rule 2 keeps the flat keys "so a rollback still finds its latch". The
operator path that rule contemplates is a ROUND TRIP, and this card lost the
latch on it:

  1. install migrates          -> schema_version=1, unit/inverter-a/... = False
  2. operator DOWNGRADES; SAFETY-02 trips inside the downgrade window and the
     old add-on writes only the flat `sign_tripped: true`
  3. operator upgrades again   -> `rep.ran` is False, so `_grade_store_migration()`
     returned early, and `Inverter.__init__` read the namespaced key: False

Observed: the controller came up with `sign_wd.tripped = False` while
/data/state.json said `sign_tripped: true`. That is store.py's own header
sentence - "the file still says sign_tripped: true, the banner is green, and the
controller resumes driving the wrong way" - rebuilt by the version marker. A
firmware version change with a rollback path was performed on this site on
2026-09-02, so this is not hypothetical.

Three changes, each graded by a mutation observed RED:

  * THE OPERATOR'S CLEAR CLEARS BOTH SPELLINGS. That is what removes the
    "re-arm forever" objection the `rep.ran` gate existed to answer: with the
    fossil cleared, `flat True + namespaced False` is unambiguously a
    downgrade-window latch.
  * A TRIP MIRRORS THE FLAT PAIR. Rule 2 only ever covered the latch present AT
    MIGRATION TIME - nothing flat was written for a trip taken afterwards, so an
    operator who downgraded found `sign_tripped: false` and came up DRIVING.
    store.py claimed the staleness could only fail toward a SPURIOUS latch. It
    could not; that sentence is corrected rather than left standing.
  * THE GUARD RUNS ON EVERY START, and asks the STORE - not the roster - whether
    anything namespaced is latched. A mirrored trip on B must not re-arm a
    healthy A, and the store is the right place to ask because it holds every
    `unit/*/sign_tripped` this install has written, including one belonging to a
    unit that is not in today's roster.

Graded by the three-step scenario itself, run end to end in test_inverter.py
part 2, with all three preconditions asserted - including that the catching
start did NOT migrate anything, which is what used to skip the guard. Part 2's
idempotence check now PRESSES the button instead of hand-writing the store,
because the state it used to construct is not one the shipped clear path can
produce. Part 3 grades the mirror and the clear on a real roster of two.

test_watchdog.py's "the flat key is NOT written" assertion is re-aimed rather
than deleted: its argument was right about the READ and wrong about the WRITE.
Nothing at runtime reads that key, and the one thing that does ignores it
whenever any namespaced latch is set - which a sibling's trip always leaves.
What its absence actually cost was the rollback.

HANDOVER.md and DOCS.md said the flat key "is never read again", which was the
instruction an operator clearing a trip by hand would follow. Both corrected: it
is a maintained rollback view, it IS read on every start, and clearing a trip by
hand means clearing both spellings.

MUTATIONS THIS ROUND, each applied on a purged tree via tools/mutation_run.sh
and observed RED:
  * per-unit `device`/`supervised` BOUNDS from `self.inverters[0]`, actuator
    left correct                                   -> test_peakguard.py 10g
  * `_grade_store_migration()` gated on `rep.ran`   -> test_inverter.py part 2
  * the operator's clear leaving the flat keys set  -> test_inverter.py part 2/3
  * a trip not mirroring the flat pair              -> test_inverter.py part 3
  * the guard asking only `self.inverters[0]`       -> test_inverter.py part 3

19/19 files green on the merged tree. No hardware was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-03 23:37:23 +02:00
glenn schrooyenandClaude Opus 5 d48c6b971d DEV-02: correct the operator instruction the latch rename made wrong
checks / unit (pull_request) Successful in 4m13s
HANDOVER.md told an operator to "delete `sign_tripped` from
/data/state.json" to clear a sign-disagreement trip by hand. After this card
that key is a FOSSIL: the live latch is `unit/<inverter>/sign_tripped`, and
the flat one is left in the file only so a rollback finds its latch and is
never read again. Following the old instruction would delete a key that clears
nothing, on the one path an operator reaches while staring at a FAILSAFE
banner - so the instruction would look like it had failed.

DOCS.md's §SAFETY-02 section named the file but not the key; it now names both,
and says why there is one per inverter.

No code change; the suite is unaffected and green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-02 15:02:58 +02:00
glenn schrooyenandClaude Opus 5 e941bf7f62 DEV-02: zero every unit's target when the operator stops, not just the site's
checks / unit (pull_request) Successful in 4m12s
A hazard THIS CARD CREATED, found by re-reading the operator paths rather than
by a failing check - so a check now exists for it.

Before DEV-02 the heartbeat re-emitted `self.target`, so the stop button
zeroing that one scalar was enough: the next refresh carried the zero. The
heartbeat now re-emits `inv.target` - it has to, or it would put the SITE
number on every inverter, which is the ARCH-01 broadcast fault arriving once
every `heartbeat_s` on a path with no allocator in it.

So a stop that zeroed only `self.target` left each unit's LAST SHARE standing.
No cycle has run yet at that moment, so nothing else would have cleared it, and
the very next heartbeat - up to `heartbeat_s` later - would put it back on the
wire. The operator presses stop, watches the inverter go to zero, and sees it
resume seconds later.

`command_site_zero()` moves both together and carries the reason. It is one
method rather than two lines at three call sites precisely because the two
lines have to stay together: shutdown(), the stop button and maint_abort all
mean the same thing, and 0 W is the one value that may legitimately be written
site-wide.

test_inverter.py part 6 presses stop with 995/995 standing and then runs the
SHIPPED `send_heartbeats()` before any cycle, which is the racing refresh. The
mutation that zeroes the site target alone takes it red.

19 files green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-02 14:58:54 +02:00
glenn schrooyenandClaude Opus 5 e306c74cb8 DEV-02: per-instance inverter objects, so nothing is graded against a sibling
checks / unit (pull_request) Successful in 4m15s
Every piece of per-device state in the Controller was a scalar, and several of
them are safety inputs. app/inverter.py introduces an `Inverter` that owns its
entities, its rating, its device range, its SignWatchdog, its last_write and
its last_beat, under store keys namespaced by unit name. The Controller holds a
list of them and keeps only what is genuinely site-level: the P1 reading, the
site integrator, and the tariff quarter.

WHAT THE SCALARS WERE SILENTLY ASSERTING

  dev_min/dev_max   one pair, read from one setpoint_entity. A second
                    inverter's rating was NEVER READ AT ALL, so its clamp, its
                    freeze rail and the peak guard's achievable_w were the
                    first one's numbers.
  soc               one reading into the maintenance FSM, SAFETY-24's pack
                    floor and arbitrage's bounds. An average lets the emptier
                    pack be driven below its own floor.
  sign_wd           ONE SignWatchdog, whose own docstring says "one instance
                    per actuator", latched under FLAT store keys - so a trip
                    caused by B latched A across restarts and one operator
                    press cleared both.
  last_beat         one deadline and one serialised hass.call(), so a hung
                    write to A ate B's 30 s layer-1 budget.

actual_w BECAME SITE LEVEL WHILE THE READINGS BECAME PER-INSTANCE, and those
are opposite directions on purpose. compute()'s anti-windup test is
`abs(prev_w - actual_w) > saturation_w` with prev_w the SITE target, so feeding
it one inverter's power compares a site number against a unit number, trips the
saturation freeze permanently and leaves a standing grid error. Measured on
admin/goodwe's two-master rig (tools/alloc_sim.py --splits, 2 kW load, 40
cycles), worst |delta| between splits at the meter:

    gain    one unit's power      the site sum
    0.2           510 W                0 W
    0.4           250 W                0 W
    0.6            50 W                0 W

That was ALLOC-01's disclosed finding; test_alloc.py part 8 kept its stimulus
and now grades the other row.

THE STORE MIGRATION IS GRADED, NOT MERELY PERFORMED. sign_tripped/sign_reason
move to unit/<name>/... once, verified by READING BACK what was written, and
the version marker is set only when nothing that carried state was lost. The
flat keys stay in the file so a rollback still finds its latch. And __init__
compares the pre-migration flat latch against what unit A actually came up
with: if the latch did not survive it is RE-ARMED from the legacy value and
shouted about. A latch that silently fails to migrate is a disarmed safety
guard on an install whose state file says it is protected - which is strictly
worse than never persisting it, because now there is evidence saying it is
fine. test_inverter.py part 2 breaks the migration and watches the guard fire.

HEARTBEATS ARE INDEPENDENT AND THE BUDGET IS DERIVED. Each inverter owns its
deadline, the refreshes go out concurrently, and each write is bounded by
WRITE_BUDGET_S = 4 s. The inequality is graded rather than asserted: 4 +
heartbeat_s's schema maximum of 25 < 30, validate.FIRMWARE_INGEST_VETO_S.
Verified by injecting a transport that accepts and never answers.

live_units() ANSWERS HONESTLY. A unit whose every write has been rejected for a
whole layer-1 window is not commandable; the allocator drops it, the survivors
carry its share, the action log names it, and the dropped unit is commanded
0 W rather than left standing on its last share. A roster entry with NO
setpoint_entity is deliberately not handled there - that is a configuration
that cannot be delivered, and it reaches main.py's refusal branch, which is how
that branch keeps a live trigger after this card removed its old one.

AT N=1 NOTHING MOVES. The removed scalars survive as read/write properties of
the sole inverter, so every existing replay runs unmodified - and they REFUSE
at N>=2 rather than answering for the first inverter, which is the tripwire on
a consumer that was never made per-instance.

EVIDENCE. 19 files green. test_inverter.py is new (82 checks). Eighteen
mutations were applied to the shipped modules on a purged tree via
tools/mutation_run.sh and each was observed RED. Two of them SURVIVED first
time and the checks were changed rather than the table: the serialised dispatch
and the shared last_beat both passed while part 6 gathered its own writes and
computed its own `due` list. send_heartbeats() was extracted from run_control()
so the shipped dispatch is reachable by a check at all.

INVERTER B STILL DOES NOT EXIST. build_roster() returns exactly one inverter
from the flat options and is the one function CFG-04 changes; every two-unit
figure here is architecture, and the second rating in the checks is marked a
hypothesis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-02 14:50:43 +02:00
glenn schrooyenandClaude Opus 5 3fe3c7acb0 ALLOC-01: clamp each envelope end on its own, and widen the replay that missed it
checks / unit (pull_request) Successful in 4m9s
Two blocking findings from the review of 186e13c. The second is why the first
survived, and it is the one this commit is really about.

THE CLAMP. `_clamp_int()` folded both ends of the interval into one
`min(max(...))` and substituted `v` itself for a bound that was infinite. That
reads as "an absent bound cannot bind", and it is not what it does: with
`hi == inf` the expression collapses to `min(max(v, lo), v) == v`, so A MISSING
CEILING SILENTLY DISCARDED THE FLOOR. `_clamp_int(0, 2142, inf)` returned 0. It
reached the shares through all three call sites - the base, the pin clamp and
the post-floor clamp.

The worked failure: bounds `{a: [2000, +inf], b: [-5000, 5000]}` against a
+1000 W site target returned `a=+500, b=+500` - inverter-a commanded at a
quarter of its own 2000 W floor - while THE SAME CASE WITH A FINITE CEILING
correctly REFUSES on sign coherence. So one defect broke the ticket's first
named deliverable ("each share clipped to that unit's UnitBounds interval") and
suppressed a circulating-power refusal that should have fired. Both now behave
identically, and the refusal is the behaviour.

Not reachable on the live install - N=1 is a pass-through, and via main.py the
supervised claim unconditionally files a finite +/-max_w LIMIT at P_SAFETY that
pre_resolve's gate can never filter because nothing outranks P_SAFETY. But
`plan()` and `arbiter.allocate()` are the public envelope contract SAFETY-28 and
DEV-02 are being asked to build on, and `UnitBounds()` DEFAULTS TO
`(-inf, +inf)`, so a one-sided envelope is the ordinary case at N>=2 rather than
an edge, and any caller with one inherited the defect.

Fixed by clamping the two ends independently: cast to int, apply `lo` only if it
is not -inf, apply `hi` only if it is not +inf. `_int_envelope()` already passes
infinities through untouched and needed no change; it is the only other place in
the module that special-cases infinity, and the rest of the tree's `isfinite`
calls are input validation in unrelated modules.

THE RECORD. `UnitPlan.explain()` printed
`inverter-a -> +500 W in [2000, +inf] (weighted share)` - a line asserting a
share is inside an interval that excludes it, in the record SAFETY-28 gates on.
It now ASKS: `within_envelope` checks the share against the interval, and a
violated plan prints `OUTSIDE ... - ENVELOPE VIOLATED, DO NOT WRITE` instead. A
dropped unit is exempt and prints "not commanded" - it was told nothing, its
interval may be a contradiction no number is inside, and a banner there would
cry wolf on the one row that is working.

THE REPLAY THAT COULD NOT SEE IT, WHICH IS THE FINDING THAT MATTERS. The
250 000-tick replay drew every envelope as `B(-uniform(0, RATING),
uniform(0, RATING))` - finite on both ends and containing zero, every time -
while the check it fed claimed "no share ever leaves the envelope it was clamped
into", which reads as a statement about `UnitBounds`. The data could only
exercise a symmetric closed interval around zero. That is why it reported 0
escapes against a live defect while the reviewer's own 120 000 ticks reported
6 254.

The generator now draws from every shape `UnitBounds` can express: finite
straddling zero, half-open above, half-open below, finite excluding zero in each
direction, half-open AND excluding zero, and bare `UnitBounds()`. Over the same
seed and the same 250 000 ticks it draws 166 657 half-open intervals, 55 233
bare ones and 222 082 that exclude zero, and those counts are PRINTED and
asserted so "the replay covers half-open intervals" is a number a reader can see
rather than a sentence the file asserts about itself. The escape check also
dropped its 1 W tolerance: `_int_envelope()` narrows inward, so there is nothing
for slack to absorb except a defect.

GRADED, NOT MERELY APPLIED - the acceptance bar for this round. A/B on one seed
and one tick count, against the defective clamp: THE OLD GENERATOR SEES 0
ESCAPES, THE NEW ONE SEES 39 991. Reverting the fix under the widened generator
takes test_alloc.py RED on four checks (the escape count, the record check, and
both halves of the new half-open block); re-applying it takes it green. The
collapse is now a row in part 12's mutation table, recorded as the mutation that
actually shipped rather than a hypothetical one.

TWO CHECKS RE-AIMED RATHER THAN LOOSENED.
* Part 6's "the sign gate never fires on an ordinary tick" counted refusals over
  the whole replay. Envelopes that EXCLUDE zero are precisely what that gate is
  for, so the widened generator makes refusals correct there. It now counts an
  ORDINARY tick explicitly - no pin, every envelope contains zero - and asserts
  0 refusals over 77 533 of them, half-open and bare envelopes among them. That
  is stronger than what it replaced, not weaker.
* Part 1's third check summed `sum_w - sum(shares)` over ticks where
  `unmet_w == 0`, and `unmet_w` IS that difference - so it was true by
  construction and would have gone quiet rather than red. It now measures the
  one rounding that is actually left: the site-level `quantisation_w`, totalling
  -39.8 W over 137 964 allocations, i.e. -0.0003 W per tick. Largest remainder
  against the per-unit alternative is still graded in part 2, where the
  counterfactual drifts -125 W over 50 000 ticks at three inverters.

Verified: all 18 suites green in the CI image on a tree with no bytecode cache;
test_alloc.py also re-run through tools/mutation_run.sh, which purges. AC 2 and
AC 3 are untouched by this change - the rig and part 8 use symmetric finite
envelopes, which the old and new clamp treat identically - and part 8 is green.
No hardware touched; admin/goodwe not fetched, not modified, not pushed to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 11:49:18 +02:00
glenn schrooyenandClaude Opus 5 186e13ceca ALLOC-01: allocate the site grid target, sum-invariant, never broadcast
checks / unit (pull_request) Successful in 4m6s
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>
2026-09-02 10:42:33 +02:00
glenn schrooyenandClaude Opus 5 38f6680639 ARB-03: pin all eight actuator call sites statically
checks / unit (pull_request) Successful in 3m45s
The independent review of PR #40 found AC 6 met at three of the eight claim
construction sites while the check claimed to cover the class - this
repository's named failure mode, a check observing something other than what
it claimed, reproduced inside the check written to prevent it.

WHAT WAS WRONG. Part 10g's only call-site check was a real two-inverter cycle.
That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign
watchdog, the guard idle and arbitrage off, so it reaches `device`,
`supervised` and `loop` and nothing else - three of the eight direct appends in
`Controller.cycle()`. Its own check text nevertheless read "the line a
mis-typed `actuator_of(...)` at a call site goes red on", and the PR body's
mutation table presented it as covering the class. The reviewer proved the gap
on the shipped tree: `actuator_of("maintenance")` -> `actuator_of("peak")` at
the maintenance-shaping append left test_arbiter.py, test_peakguard.py and
test_arbitrage.py ALL GREEN, while at a roster of two it built a site loop of
-1500 W against the guard's site `lo` of 1500 W and the now-misrouted
charge-only `hi = 0` - `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER,
SAFETY-25 §0a's fatal case from a one-word typo. The same mutation at the
`failsafe` append survived too. The runtime detector does fire -
`scope_violations()` names it - but it only logs; nothing graded it.

THE FIX, over machinery already present. Part 10f's AST scan already reads each
`claims.append(...)` in `Controller.cycle()`. All eight direct appends expose
their actuator statically as `actuator=self.actuator_of('<literal>')`, so 10g
now requires that literal to EQUAL the source the claim is filed under, read as
SOURCE TEXT rather than evaluated - `actuator_of("peak")` at the maintenance
append is the same VALUE at the shipped roster of one and a different string
here. That pins all eight routing sites, on the line the typo is written, in
every configuration and at every roster, exactly the way 10f pins the source
set. Two further lines keep the scan from going vacuous: every direct append
must be readable in that shape (a computed or hand-written actuator fails
there), and the ten appends must still account for 10f's eight sources.

The two INDIRECT appends carry no `actuator=` at the call site to mis-type.
`peak_claim` and `arb_claim` are routed where their claim is built, and are
pinned there - `peakguard.claim()` by the SWEEP_SITE sweep over every load x
elapsed x battery x SoC x floor combination, `arbitrage.claim()` by
test_arbitrage.py. 10g now says so at the line, and says which three of the
eight its dynamic cycle actually sees, rather than rounding up.

MEASURED, NOT ARGUED. Each of the eight direct appends mutated in turn to a
wrong actuator - device->loop, supervised->peak, failsafe->safety,
safety(stopped)->device, safety(inputs missing)->maintenance,
maintenance(SET)->loop, maintenance(shaping)->peak, loop->supervised - each on
a purged tree via tools/mutation_run.sh over all 17 files. All eight RED, every
one of them on the new line.

TWO SURVIVING MUTATIONS OF THE SAME CLASS, ALSO CLOSED.

* `resolve()` did not have to honour the roster it was given: `if units is
  None:` -> `if True:` left the whole suite green. Ignoring the argument makes a
  unit that filed nothing this cycle cease to exist - a site of two reads as a
  site of one and stage 3 passes the whole grid target through to the inverter
  that spoke, which is ARCH-01's fault with its own evidence removed.
  test_arbiter.py part 7 now grades it on the one case that separates the two, a
  SILENT unit. Mutation RED in test_arbiter.py and test_peakguard.py.
* main.py's N>=2 refusal was unreachable and ungraded: `if not
  resolution.allocated:` -> `if False:` left the whole suite green, so the right
  instinct was one refactor away from being deleted as dead code. The guard is
  KEPT - the alternative default is the ARCH-01 fault - and new part 10h grades
  it on the two-inverter cycle that already existed: the 0 W, the `failsafe`
  winner, the log line naming the roster size, and the write that follows.
  Mutation RED.

AND THE WORDING CORRECTED. main.py does not "refuse to write" on
`allocated=False`. It SUBSTITUTES a 0 W failsafe and then calls
`write_setpoint(0.0)` like any other cycle. 0 W is the correct
fail-toward-inaction direction and the log text is accurate about what it
refuses - to BROADCAST the site target - but "refuses to write" overstates the
code. Said as what it does, in the CHANGELOG, at the branch in main.py, and in
10h's own check text. `resolve()`'s docstring now says the roster argument has
to be HONOURED, which is not the same as existing.

17/17 green on a purged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 03:46:40 +02:00
glenn schrooyenandClaude Opus 5 ab6fbe2f9e SAFETY-25: record the owner's decision - option a, with alloc-01 as the revisit trigger
checks / unit (pull_request) Successful in 3m43s
The product owner answered SAFETY-25's §1 on 2026-09-02: OPTION A. The BMS
cut-out is the DELIBERATE and only backstop for demand-driven discharge on the
grid-following loop, and Option B - a loop-side reserve floor - is DEFERRED
rather than rejected.

Option A is the shipped behaviour, so this is a recording, not a change. `app/`
is touched in exactly two places and both are comments that claimed the question
was still open (freeze.py's finding, peakguard.py's point 2). No setpoint, no
claim, no option, no default moved. The 17-suite run is green on the same
numbers as before.

WHY DEFERRING IS THE SAFE DIRECTION. Option B's proof - bms_soc_floor <
loop_soc_floor <= peak_soc_floor, run through the real arbiter in
test_peakguard.py part 10d - has an unstated premise: the reserve predicate and
discharge_permitted() read the SAME state of charge. With two packs they read
different numbers. The reserve binds on the emptier pack and files hi = 0; the
peak guard, reading the other pack or an average, files lo = excess > 0; lo > hi
is arbiter clause 3 and the answer is 0 W - the battery parked in the billed
quarter, which is the failure the memo itself marks fatal. And no config
validator can catch it: it is a runtime divergence between two readings, not a
config ordering, so CFG-01's shape of check passes on the exact tree that parks
the battery. Option A's cost is energy; Option B's cost built too early is the
one quarter of the month that sets the bill.

THE REVISIT TRIGGER IS ALLOC-01, recorded in §0c. Under ARCH-01's host-side
allocator the two claims stop sharing a list: the loop floor becomes a
per-inverter `hi` consumed as an INTERVAL at stage A, while the peak floor stays
a site `lo` at stage B. Different claim lists, different stages, structurally
unable to cross - 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.

WHAT HAS TO BE RE-EARNED AT N=2 is stated plainly in §0d rather than left for
someone to discover. SAFETY-27 now names five floors across two packs, so "the
SoC" stops being a scalar the config can bound. And part 10f - the pin that made
SAFETY-25 mergeable - CANNOT EXPRESS TWO PACKS: its dynamic half is a scalar
0-100 % SoC sweep against a single-series capture, and a two-pack exposure lives
in the DIFFERENCE between two readings, which that sweep visits at no
resolution. Its static half, the AST scan pinning the eight claim sources, still
bites at N=2; the dynamic half goes silent. Merging a two-pack reserve on 10f's
current green would be merging on a check that cannot see the failure.

The options analysis is kept intact on purpose - §1-§7 stand exactly as written,
including §5's "(recommended)" on Option B and §6's recommendation, with a new
§0 saying what was decided and §6 marked as answered. A memo that recorded the
verdict and deleted the reasoning is the failure this DECISIONS/ directory
exists to prevent.

Part 10e's check moved with the memo: it used to pin the honest "Status: OPEN"
line and now pins that the memo names the option, the date and the trigger AND
still carries both options and the recommendation. The tripwire itself stays -
a ninth claim source in Controller.cycle() still takes 10f red on the line it is
written, and "deferred" is precisely the state in which someone builds Option B
by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 00:46:54 +02:00
glenn schrooyenandClaude Opus 5 acbdf41248 TEST-13: grade record_block()'s policy argument, and the two import spellings SAFETY-20's check could not see
checks / unit (pull_request) Successful in 3m55s
Two survivors from TARIFF-01's review, closed in test_tariff.py only. app/ is
untouched and no shipped behaviour changes. Two further items on the card were
already closed in shipped code and are struck rather than re-implemented.

ITEM 1 - the per-phase reduction's PLUMBING was unobservable.

  Mutating PeakStore.record_block()'s `billed_w(block, policy)` to
  `billed_w(block, POLICY_NET)` (+4 B) leaves all 17 suites green. Measured
  three times now: by TARIFF-01's reviewer, re-run through TEST-15's purge
  harness this session, and re-run again here on a clean uncontended tree.

  The product code is correct - tariff.py:363 really is
  `def record_block(self, block, policy: str = POLICY_NET)` and it really does
  forward the argument. What was missing is any check that could tell that
  apart from a hardcoded call, and the reason is the third instance of one
  shape: A STIMULUS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE, after
  WITNESS-02's span normalisation and TARIFF-01's own DST window.

  billed_w() short-circuits to block.offtake_avg_w whenever
  per_phase_offtake_avg_w is absent. Every block reaching a store anywhere in
  the suite was single-phase or built as _CB(..., None) - so NET, SUM and MAX
  collapsed onto one number and the argument could not be observed at the call
  site. Section 7 grades the FUNCTION on an unbalanced fixture and says nothing
  about its CALLER.

  So section 7's own (3000, 500, -200) W block - whose three reductions are
  3300 / 3500 / 3000 W, asserted distinct three lines above - is now
  round-tripped THROUGH the store under each policy and read back from both
  tables (peak() and recompute_month(), so both writes in that single
  transaction are graded). record_block() with no policy is checked to bank the
  NET figure. An anti-degeneracy pin is stated over the three BANKED numbers,
  not over vals, because it is the banked ones this triple is made of.

  Direction, which is why this is worth plumbing-testing: NET is the SMALLEST
  of the three here, because the exporting phase nets off. A store that quietly
  billed NET while asked for MAX would UNDER-record the month's peak, and an
  under-recorded peak is maximum headroom for SAFETY-07 - the same
  under-guarding direction as the getattr(..., 0.0) default SAFETY-07 already
  removed from project().

ITEM 2 - the SAFETY-20 import grader claimed more than it checked.

  It collected `m.module for m in walk(tree) if isinstance(m, ImportFrom)` and
  matched a literal list of six spellings. That is blind to two of Python's
  three import forms:

    * `from . import arbiter as _ab` is an ImportFrom whose .module is None -
      the dots are in .level and the module name is in .names - so it collected
      "" and matched nothing;
    * `import app.arbiter` is an ast.Import node the walk filtered out entirely.

  Both were added to app/tariff.py and the shipped suite stayed GREEN, with the
  _names half not firing either (an alias import creates no Name node). Low
  severity: any USE of either alias still produces an Attribute the _names half
  catches. But the printed sentence "it imports nothing from arbiter, clamp or
  control" was false for two forms out of three, and a check that overclaims is
  worse than one that claims less, because the next reader stops looking.

  _imported_modules() now walks Import as well as ImportFrom and joins
  `from . import X` onto its base. _forbidden_imports() matches per dotted
  SEGMENT, not by substring: `app.arbiter` must not hide behind a package
  prefix, and `clamps` / `arbiters` / `controller` must not be falsely flagged -
  `controller` is not hypothetical, this package is goodwe_controller. Five
  evasion spellings and eight innocent ones grade the grader itself, and both
  the name half and the import half are shown non-vacuous against main.py,
  which carries all three import forms and really does import all three modules.

ITEMS 3 AND 4 - ALREADY CLOSED. STRUCK, NOT RE-IMPLEMENTED.

  Item 3: app/tariff.py:266-267 is `def is_observed(self): return self.state ==
  OBSERVED`, already false for NO_RECORD. Nothing to do.

  Item 4: app/tariff.py:672-680 reads `held = max(averager.held_net_w, 0.0)`
  with NO getattr default, and carries a comment naming TEST-13 item 4. Fixed
  by SAFETY-07 in 616b62d; held_net_w is a documented public property on
  QuarterAverager (p1.py:290), so a rename raises AttributeError on the first
  cycle instead of crediting 0 W held. A projection crediting 0 W held reads
  LOW, which is the under-guarding direction for SAFETY-07's floor. Restoring
  a default would re-open a money guard.

  And the recorded NON-finding stays a non-finding: removing clear_partial()
  is still green and that is a PASS. quarter_start is the PRIMARY KEY and the
  upsert carries `ON CONFLICT ... WHERE excluded.offtake_avg_w >
  quarter_blocks.offtake_avg_w`, so a replay is a structural no-op. That
  demonstrates TARIFF-01's "double counting is answered structurally, not by
  care" claim rather than contradicting it.

EVIDENCE - all through tools/mutation_run.sh (recursive __pycache__ purge,
python3 -B, tree re-read from disk, mutated line grepped off disk, byte delta
recorded), in the goodwe-addon-ci image against a :ro mount.

  baseline, 17 suites, before any edit ............................ 17 PASS
  baseline, 17 suites, final tree ................................. 17 PASS

  A1  billed_w(block, policy) -> billed_w(block, POLICY_NET)   +4 B  5 RED
      the 'sum' and 'max' round-trips, both tiers, and the distinctness pin
  A2  billed_w(block, policy) -> billed_w(block, POLICY_MAX)   +4 B  6 RED
      the 'net' and 'sum' round-trips, both tiers, the default, and the pin
  B1  add `from . import arbiter as _ab` to app/tariff.py     +29 B  1 RED
      the import sentence. SHIPPED suite on the same tree: GREEN - the hole.
  B2  add `import app.arbiter` to app/tariff.py               +19 B  1 RED
      the import sentence. SHIPPED suite on the same tree: GREEN - the hole.
  B3  add `from .control import compute` (the form the OLD grader DID
      catch, as a regression control)                                1 RED
  C1  delete the ast.Import branch from _imported_modules()          1 RED
      names the two spellings it MISSED, so the grader-grader is live
  C2  segment match -> substring match (`any(f in m for f in ...)`)  1 RED
      names clamps / arbiters / controller as falsely flagged

  CONTROL, and the point of the whole ticket: A1 graded by the SHIPPED
  test_tariff.py is green, and A1 graded by all 17 SHIPPED suites on a clean
  uncontended run is 17 PASS, rc=0. The survivor is real and it is now closed.

ONE THING THE REVIEWER SHOULD KNOW. During the first (contended - two docker
containers at once) run of that 17-suite survivor sweep, test_peakguard.py
reported FAIL once. It has passed 8 times since on the identical tree,
including three concurrent runs and one clean uncontended full sweep, and its
tail was lost to a grep so I cannot say which check it was. I believe it is a
timing-sensitive check under CPU contention and NOT related to this change -
this commit touches no product code and no peakguard fixture - but I could not
reproduce it, so I am recording it rather than asserting it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:40:09 +02:00
glenn schrooyen 6ea84a46f6 Merge remote-tracking branch 'origin/release/1.0' into TEST-16
checks / unit (pull_request) Successful in 3m41s
# Conflicts:
#	goodwe_controller/CHANGELOG.md
2026-09-01 07:54:52 +02:00
glenn schrooyenandClaude Opus 5 20f9f4969d TEST-16: pin the two one-sided arbitrage rungs, and un-mask them from each other
checks / unit (pull_request) Successful in 3m51s
SAFETY-26's AC4 gave the same tolerance mutation to every price and quantity
boundary in arbitrage.py and static_tariff.py. Everything in arbitrage.py went
red - `price < 0`, `soc <= floor_pct`, `soc >= ceiling_pct` are each red under
+/-5. Two survived, both in static_tariff.py, and this card closes them.

  D  `spread == 0.0` (:912) weakened to `spread <= 0.001` SURVIVED at +2 B.
  F  `len(firm) < 2` (:901) weakened to `len(firm) < 1` SURVIVED at +0 B - the
     delta MUTATION.md names as the risky one.

THE DEFECT IS NOT EITHER ROW, IT IS THE INTERACTION. D and F MASK EACH OTHER. A
single firm hour also has a spread of 0.0, so with F weakened the flat rung
refuses the stimulus anyway; with D weakened the small-but-real spread is still
refused, only for a lie. Each rung's failure is absorbed by the other, so BOTH
can be weakened together and a suite grading `kind == "refused"` reports
nothing. That is structurally invisible to single-mutation testing, which is how
this pair came through four rounds of review on TARIFF-03 and FCST-03 - and it
is the arrangement in which the next edit to either rung opens a real hole with
nothing to report it.

D is not cosmetic either. With the tolerance in place a series priced 0.20000 /
0.20050 - a REAL 0.0005 EUR/kWh spread - flips ALLOWED to REFUSED at
min_spread_eur_kwh=0.0, a value arbitrage()'s own docstring documents ("pass 0.0
to mean any positive spread, explicitly") and its validator at :861 accepts on
purpose. At a shipped threshold the outcome does not move, but the operator
reads the false sentence "the tariff is flat - every one of the 2 firm hours
costs 0.2000 EUR/kWh" about a tariff that is not flat.

WHAT WAS ADDED. test_static_tariff.py section 9, and the shape of it is the
argument:

  * THE REASON IS GRADED, NOT THE VERDICT. The masked case refuses CORRECTLY
    while reporting the wrong sentence, so a check asserting only "refused"
    passes on a broken tree. A `rung9()` helper names which refusal answered,
    from the sentence the operator would read; an unrecognised refusal reports
    itself as UNATTRIBUTED rather than quietly matching nothing.
  * EACH RUNG IS GIVEN A STIMULUS THE OTHER CANNOT ABSORB, or the pinning is
    circular. The flat rung gets a THREE-firm-hour flat series, which no
    `len(firm) < N` a person would write can reach. The firm-pair rung gets a
    ONE-FIRM-HOUR series, which the flat rung CAN reach - so its attribution is
    the whole check, and that is why it is written as a rung name rather than a
    verdict.
  * AC1 is a sweep of small-but-real spreads (0.0005 down to 0.000001) all
    ALLOWED at min_spread_eur_kwh=0.0, with the exactly flat series on the other
    side still refused BY THE FLAT RUNG - without that mirror the sweep would
    also pass for a rung deleted outright. 1e-6 is the floor because the shipped
    line rounds the spread to 6 decimals; that quantisation is graded in the
    open as its own row rather than left unstated.
  * AC3 is a five-row table graded on rung attribution, which denies the masking
    from both sides at once.
  * The firm-pair count is also pinned from ABOVE (`< 3`) and BELOW (`< 0`),
    which the criteria do not ask for but which is what stops this section being
    one-sided in exactly the way it was written to fix.

EVIDENCE. Eight rounds through TEST-15's tools/mutation_run.sh, used UNCHANGED:
recursive __pycache__ purge before each run, python3 -B, the tree re-copied FROM
DISK between rounds, the mutated line grepped out of the file before the run,
byte delta printed, test_static_tariff.py and test_arbitrage.py graded each
time. Baseline green.

  baseline, shipped                            +0 B  PASS
  D1  `spread == 0.0` -> `<= 0.001`            +2 B  RED, 3 checks
  D2  `spread == 0.0` -> `<= 0.000001`         +5 B  RED, AC1 alone
  D3  `spread == 0.0` -> `< 0.0`  (rung gone)  +0 B  RED (section 2 as well)
  F1  `len(firm) < 2` -> `< 1`                 +0 B  RED, 3 checks
  F2  `len(firm) < 2` -> `< 3`                 +0 B  RED, 6 checks
  F3  `len(firm) < 2` -> `< 0`  (rung gone)    +0 B  RED (ValueError)
  D1+F1  BOTH AT ONCE - THE TICKET             +2 B  RED, 5 checks

Four of the seven are +0 B, the delta MUTATION.md names as the one stale
bytecode can hide. All are REDS, the direction that defect cannot fabricate -
but they were run under the purge anyway, because "it went red so the cache
cannot have lied" is a conclusion and not a procedure.

NO SHIPPED BEHAVIOUR CHANGED. app/ is untouched; both rungs were correct all
along, and the card is explicit that a rung found actually wrong is a stop-and-
report rather than an edit. Also updated: MUTATION.md gains a short section on
masking as a SECOND way a survivor lies, with the two rules that fall out of it
(grade the reason, and mutate suspected pairs together); and test_arbitrage.py's
SAFETY-26 AC4 note is marked closed rather than left reading as an open finding.

UNSURE ABOUT. The 5e-7 row asserts the shipped `round(spread, 6)` quantisation.
That is a real property of the line and worth stating, but it does pin a
rounding the ticket never named - if a reviewer would rather that stayed
unpinned, it is one check to drop and nothing else depends on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:37:19 +02:00
glenn schrooyenandClaude Opus 5 d0b053b150 TEST-10: pin max_w beside the meter_freeze_s key TEST-06 already pinned
checks / unit (pull_request) Successful in 3m43s
max_w and the LIVE_MAX_W literal beside it were the two survivors left
after TEST-06 pinned meter_freeze_s: changing max_w 2000 -> 4500 in
config.yaml passed every check in test_freeze.py and test_tariff.py
GREEN, and LIVE_MAX_W = 5000.0 was a bare literal that silently
disagreed with the shipped max_w (2000) with nothing in the file
saying so. For contrast, deadband_w 15 -> 5 already went RED 2 + 1,
so the pinning technique was known to work - it just was not applied
to the key either side of it.

Three changes, each measured:

1. test_freeze.py gains a check comparing Tuning()'s code default
   (control.py, 2000.0) against SHIPPED_MAX_W, read from config.yaml
   by extending the file's existing _cfg_default() regex reader
   rather than reimplementing test_arbitrage.py's _shipped. Broken
   and confirmed red: max_w: 2000 -> 4500 in config.yaml now fails
   exactly "the code default and the shipped default agree on max_w
   too" and nothing else in either file; reverting returns both files
   to all-green. (AC 1, AC 4)

2. LIVE_MAX_W is renamed LIVE_INSTALL_MAX_W in both test_freeze.py
   and test_tariff.py, each with a comment stating plainly that 5000
   is the live install's own override, not config.yaml's shipped
   max_w (2000) - the defect was a literal that could be misread as
   the shipped default, not the number itself. (AC 2)

3. test_freeze.py states PINNED_KEYS = ("meter_freeze_s", "deadband_w",
   "max_w") in one place as the complete set of config keys this
   suite pins. Deliberately excludes maintenance_* - CFG-03 (merged
   into release/1.0 since this branch started, merged in here) gave
   those keys a separate single source of truth in app/maintenance.py
   and this ticket has no reason to duplicate that pin. Not every key
   in config.yaml is pinned here on purpose: meter_freeze_s,
   deadband_w and max_w are the three this file's mutual-exclusion
   arithmetic (section 2b) actually reasons about together. (AC 3)

Full 17-file suite run green under goodwe-addon-ci after the merge
with release/1.0 (f630ed9, CFG-03). CHANGELOG.md updated under
Unreleased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 06:48:05 +02:00
glenn schrooyenandClaude Opus 5 d5b1ef19b3 CFG-03: give maintenance option defaults one source of truth
checks / unit (pull_request) Successful in 3m44s
Option defaults for the maintenance cycle were declared in three places that
could disagree, and MAINT-04 proved they already did. config.yaml shipped
`maintenance_charge_w: 2000`; main.py carried TWO `opts.get(..., 2500)`
fallbacks; MaintConfig's dataclass carried a third `2500.0`. So an options dict
with the key ABSENT was ACCEPTED by validate.py's rule 6 - which read 2000 -
and then built by main.py at 2500, against a 2000 W ceiling:

    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. It is unreachable on a
Supervisor install, which always supplies every key from config.yaml, and fully
reachable from sim/inject.py and from the test suite - which is where anyone
debugging maintenance actually works.

MAINT-04 shipped a TRIPWIRE and said so: test_validate.py pinned all three
literals and asserted the consequence through the real arbiter, so the
divergence could not silently widen or be half-closed. Its PASSING CONDITION
WAS THE BUG. Both it and validate.py's comment said to delete each other when
the divergence was closed. It is closed, so both are deleted.

THE DIVERGENCE WAS WIDER THAN ONE KEY. `maintenance_discharge_w` had the
identical three-way split (config.yaml 2000, MaintConfig 2500) and the card
named only the charge side. Closing one and leaving its twin would have
satisfied the numbers and left three sources of truth one line down, which is
the state that produced the bug. Both are closed, and so is the whole family:
all eight `maintenance_*` keys go through the same mechanism.

WHAT THE SINGLE SOURCE IS. config.yaml's `options:` block - it is what the
Supervisor reads and what the operator edits, so it wins by definition. 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 - validate.py's rule 8 comment
already called that "CFG-03 read as a rule rather than as a complaint", so this
makes the rule hold where it was first broken rather than inventing a fourth
mechanism for one option family.

A MIRROR RATHER THAN A RUNTIME READ, and the reason is mechanical: 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, so there is nothing to
read at 03:00 on a client's Pi, and a YAML-subset parser that could fail at
import is a new way for the add-on not to start. What makes the copy safe is
that test_maintenance.py pins every entry by READING the shipped file - drift is
a CI failure, not a comment.

EVERY OTHER SITE NOW DERIVES FROM IT OR HAS CEASED TO EXIST:

  * MaintConfig's eight dataclass defaults are indexed out of the mirror. No
    numeric literal survives in the class that held the third opinion.
  * `MaintConfig.from_options()` is the ONLY place in the add-on that reads a
    `maintenance_*` option. main.py's eight inline `opts.get(key, <literal>)`
    calls are gone: moving the read next to the mirror means the fallback cannot
    be anything but the mirror, because there is no literal in sight of it to
    disagree with. It uses `key in opts` rather than truthiness, so a shipped
    `false` or `0` is honoured instead of being quietly replaced by the default
    - this card's own bug in a different costume.
  * main.py's SECOND fallback site - the cheap-window charge floor at the
    P_MAINT_SHAPE claim - reads `self.maint.cfg.charge_w` instead of re-opening
    the options dict with its own literal. The Controller had already parsed
    this once in __init__; asking the object that owns it cannot drift from it,
    and the rate the shaping asks for can no longer be a different number from
    the rate the state machine drains at on the same install.
  * validate.py's rule 6 and its `maintenance_discharge_w` warning take their
    fallbacks from the mirror.

WHERE THE RANGE LIVES, STATED AND CHECKED. config.yaml's `schema:` block carries
`maintenance_charge_w: int(500,5000)` - a fourth declaration site of a different
kind. It is a BOUND, not a default, so it is not this bug; but leaving the
question open is how a fifth site arrives. The decision recorded in config.yaml
and in maintenance.py is: bounds live in the schema block ONLY, because the
Supervisor is what enforces them and the Supervisor reads only that file, so a
Python copy of `int(500,5000)` would be a place to disagree and nothing else.
test_maintenance.py checks both halves of that: every mirrored key must have a
schema entry - without one the Supervisor drops the option and the add-on
silently runs on the mirror instead of the operator's value - and every shipped
default must lie INSIDE its own range, since a default the schema rejects is a
config that cannot be re-entered once it is edited. What the schema grammar
cannot express is a relation BETWEEN keys (`maintenance_charge_w` against
`max_w`), which is exactly why rule 6 is written in Python and why it does not
belong in the mirror either.

THE REPLACEMENT CHECKS, AND HOW THEY WERE VERIFIED. test_validate.py's new
section exercises the same four consumers the defect ran through, the same way:
the validator ACCEPTS a dict with every maintenance key missing; a REAL
Controller built from that same dict holds 2000 W (through main.py, not through
a regex over its source); `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`. test_maintenance.py pins the mirror to config.yaml in
both directions - equal values, and full coverage of every `maintenance_*` key
the file ships, so a ninth key cannot arrive unmirrored - and asserts the
consumers move as a RELATION rather than against numbers, reading MaintConfig's
defaults back out of `dataclasses.fields()`.

Each new check was verified by BREAKING the thing it watches and observing red,
this project's rule:

  1. config.yaml -> 1500, mirror left at 2000:  test_maintenance 1 FAILED,
     test_validate 4 FAILED.
  2. config.yaml AND the mirror -> 1500: green again, and every consumer moved
     together - MaintConfig() 1500 W, the Controller 1500 W, and the arbiter
     `-1500 W, limited by maintenance(cheap-window charge)` where it had said
     `-2000 W, limited by supervised(max_w)`. That is AC4: change the single
     source and watch the consumers follow.
  3. `charge_w: float = 2500.0` retyped into the dataclass: 2 FAILED in each of
     the two files.
  4. `opts.get("maintenance_charge_w", 2500)` restored in main.py: 1 FAILED,
     naming the file and the literal it found.
  5. `maintenance_hold_min` deleted from config.yaml's options block: 3 FAILED.

Full suite 17/17 in goodwe-addon-ci, twice, once under parallel load.

Stale prose that asserted the now-closed divergence as current is corrected in
app/watchdog.py, test_watchdog.py and test_peakguard.py - watchdog.py's example
still uses 1500 W, but because the point of the example is a charge strictly
inside the ceiling, not because 2500 is what ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 06:16:54 +02:00
glenn schrooyenandClaude Opus 5 e498dd4c1c SAFETY-25: pin the claim-source set, so the tripwire is one
checks / unit (pull_request) Successful in 3m53s
The durability claim this PR shipped was FALSE, and the reviewer of #31
disproved it by building the fix. Part 10 said, in four committed places,
that it "goes RED the day a loop-side floor is built, so the exposure
cannot change size under a green suite". The reviewer implemented the
memo's own recommended Option B in main.py - a
`Claim.limit("reserve", P_LOOP, hi=0.0)` filed under a `loop_soc_floor` -
and the ENTIRE SUITE STAYED GREEN. Part 10 green. Mutation 13 went on
printing "NOTHING PREVENTS IT, ON PURPOSE" and the same 21 % -> 10.0 %,
on a tree where something DID prevent it. Pushed to an absurd
always-binding 95 % reserve it was caught only by SAFETY-24's pre-existing
part 7 wiring checks; part 10 was still green.

THE CAUSE IS STRUCTURAL, not a missing assertion. `replay_loop()`
hand-builds main.py's claim list as a literal
`[supervised, loop, peakguard.claim(...)]`, so it is blind to any claim
main.py ADDS - which is exactly where AC 2 would be built. That is this
repository's named failure mode: a check that observed something other
than what it claimed to observe, shipped as a guarantee inside the
document that recommends the change.

THE FIX IS 10f: THE CLAIM-SOURCE SET, PINNED, two ways because the two
fail differently.

  * STATICALLY, over `Controller.cycle()`'s own AST. Every
    `claims.append(...)` in it must be one the scan can read - an
    unreadable append FAILS rather than counting as nothing, which is how
    a scan like this goes quietly vacuous - and the sources it can file
    must be exactly {device, supervised, failsafe, safety, peak,
    arbitrage, maintenance, loop}. A ninth takes part 10 red on the line
    it is written, in every configuration, whether or not any fixture
    here reaches the state that files it.
  * DYNAMICALLY, through REAL `Controller.cycle()`s with main.py's OWN
    `resolve` spied - not a list this file built and hoped matched -
    swept over the WHOLE 0..100 % SoC range in the two states 10a-10c
    replay. Idle: exactly {device, supervised, loop}. Floor-limited:
    exactly {device, supervised, loop, peak}. A claim conditioned on a
    low pack, which is the shape a loop-side floor has, cannot hide from
    a sweep that visits 0 %.

And the fold is checked rather than assumed: main.py's two P_SAFETY
rating limits are both symmetric LIMITs, so replay_loop() modelling them
as one `supervised` claim at the tighter bound is exact.

MEASURED AGAINST THE COUNTEREXAMPLE. Reproduced first on the unfixed
tree (green, as reported). On this tree the reviewer's Option B at its
recommended default takes part 10 RED on 3 checks, and the 95 % variant
on 6. Nine mutations were run in all, each against a freshly copied tree
with no `__pycache__` present, mounted `:ro`; all nine red, no survivors,
no `+0 B` delta. MUTATION.md records why a second round was needed: the
first round's mutations were all aimed at DISARMING the guard, while the
claim part 10 actually made was about the guard being FIXED.

The four false sentences are replaced with what is now true, and each
says WHICH half catches WHICH shape - 10f for an arbiter claim
(Options B and C), 10a-10c for a clamp inside control.compute() or the
guard (Option D), which the replay drives directly.

AC 2 IS STILL OPEN AND NOTHING HERE BUILDS IT. The loop-side floor was a
test mutant, reverted before commit. The memo still says "Status: OPEN.
This is a memo, not a decision."

Also, non-blocking, from the same review: the NOT READY banner had
inherited the new scope sentence and become a ~550-character paragraph
that the row three lines below repeated verbatim. A checks() row may now
carry an optional short `banner` form; the floor-limited row does, and
`status_payload()` prefers it. THE ROW IS UNCHANGED - shortening that
would have been weakening AC 3 - and rows without the key are
unaffected. Graded, and mutated: dropping the short form goes red.

Merged origin/release/1.0 first (CI-02), keeping both tickets' CHANGELOG
entries.

Full suite green: 17/17 in goodwe-addon-ci (test_ci_gate.py needs `git`,
which CI-02 added to .gitea/ci/Dockerfile but the local image lacks; run
with it installed and it passes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 05:04:22 +02:00
glenn schrooyen 3cb9d3b7d9 Merge origin/release/1.0 into SAFETY-25
CI-02 landed on release/1.0 after this branch was opened and both tickets
add a block under `## Unreleased`, so CHANGELOG.md collided. Both entries
are kept, newest-first: SAFETY-25's, then CI-02's, then SAFETY-20's.
Nothing else conflicted.
2026-09-01 04:38:46 +02:00
glenn schrooyen 29fa39eda7 Merge origin/release/1.0 into SAFETY-20
checks / unit (pull_request) Successful in 3m36s
# Conflicts:
#	goodwe_controller/CHANGELOG.md
2026-09-01 02:43:08 +02:00
glenn schrooyenandClaude Opus 5 7b4877d56d TEST-15: purge the bytecode cache before a mutation run, not after
checks / unit (pull_request) Successful in 3m35s
FCST-03's developer reverted `self.provisional` to `here.provisional`, re-ran,
and reported the mutation as a survivor. It was not one. CPython validates a
cached `.pyc` on (source size, source mtime truncated to whole seconds), and
that edit moved neither number - same length, same second - so the interpreter
loaded the bytecode it already had. Disassembly settled it at the time: the
source read `here`, the running code loaded `self`. A mutation run had graded
code that was not there, with no error and exit status 0.

This does not make the results already on the record wrong. It makes them
UNVERIFIED, and that distinction is the whole card - so nothing here re-runs a
campaign and nothing here quietly assumes the old numbers hold.

REPRODUCED, THEN CLOSED. 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 (retrying until both writes land in one whole second -
milliseconds, no sleep) and reads back which value ran. Part 1 shows the
interpreter running SELF off a file that says HERE.

AND THE TWO OBVIOUS MITIGATIONS DO NOT WORK. Measured, not read off
documentation: `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. Part 2 grades both still running the old
code. The advice in test_control.py's coverage audit led with `-B`; it is
corrected here - the purge is the half that works, and `-B` earns its place by
keeping THIS run from leaving a cache for the NEXT edit to inherit.

NO SLEEP ANYWHERE. One-second mtime resolution is the mechanism, not the enemy.
Waiting it out would slow every run down and would still only cover the edit
somebody remembered to slow down.

THE HARNESS. tools/mutation_run.sh 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, which is the reviewer's addition to this card - runs each suite
under `python3 -B`, refuses a tree with no test_*.py (CI-01's rule, for CI-01's
reason), 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 without being moved.

WHERE IT IS SAID (AC 2). MUTATION.md carries the mechanism, the measurement,
the reporting rules and the survey, and every one of the 16 suites now points at
it from its docstring. That list is DERIVED FROM THE TREE by test_bytecode.py
part 5, so a new suite that joins this directory without the pointer is red -
the same failure shape CI-01's file glob exists to prevent.

CI WAS ALREADY IMMUNE, AND IS DESCRIBED THAT WAY (AC 4). Verified rather than
reasoned: with the checkout bind mounted `-v "$WS:/w:ro"` the interpreter writes
no __pycache__ into the tree at all, and it fails SILENTLY - no error, exit
status 0 - while the same probe on a writable mount produces one. Each job also
rm -rf's its workspace and clones afresh, so nothing crosses between jobs on the
shared runner. PYTHONDONTWRITEBYTECODE=1 is added as belt and braces only: the
`:ro` is one character wide and the next person debugging a write will be
tempted by it. Part 6 grades both ON THE DOCKER COMMAND LINE rather than on the
file - the first draft searched the whole workflow and stayed green after the
flag was deleted, because the comment explaining it still named it.

THE SHIPPED ADD-ON IS NOT AFFECTED and the question is closed:
goodwe_controller/Dockerfile has set PYTHONDONTWRITEBYTECODE=1 since it was
written. There is no deploy-side finding.

THE SURVEY (AC 3), in MUTATION.md, scoped BY THE PROPERTY. The defect is
one-directional - a mutation that never took leaves the suite green, so stale
bytecode manufactures SURVIVORS and cannot manufacture a red - which puts every
"all red, no survivors" claim in the window out of scope, along with mutations
executed in-process (SAFETY-07's B1/B2/B3, test_peakguard parts 7 and 10, where
no source file is edited) and everything run after the purge discipline landed
on 2026-08-31 (SAFETY-24, SAFETY-26, OPT-04, FCST-03's re-run). What is left is
the recorded SURVIVORS from source-edit runs before that date. Two were re-run
here under the harness and both still stand: TARIFF-01's record_block() ignoring
its policy argument, and SAFETY-03's abs() on the clamp limit - 15 suites green
each, on the tree as it stood. The rest are labelled still-unverified rather
than assumed or re-run; each is one harness run to close, and the moment to
spend it is when somebody next touches that module.

OUT OF SCOPE AND RECORDED: the infra repo's sim/ replay harnesses import the
add-on and write bytecode into both trees (goodwe/sim/__pycache__ exists today),
so SAFETY-02's headline replay carries the same exposure. mutation_run.sh takes
a tree argument for exactly that, but the change belongs in that repo and some
of those harnesses want the rig.

RED AND GREEN. Every new check was broken and watched fail: the purge commented
out of the harness turns part 4 red (and its non-vacuity probe already proves
the fixture would mis-grade without it); one docstring pointer removed turns
part 5 red naming the file; `:ro` and the `-e` flag removed from the workflow
turn part 6 red. Full suite green, 16 files, in the CI image on a read-only
mount - the shape the runner uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:07:09 +02:00
glenn schrooyenandClaude Opus 5 504e8daac3 TEST-17: grade the status page's peak row at a stated month, not at today's
checks / unit (pull_request) Successful in 3m35s
release/1.0 went red at 00:00 on 2026-09-01 on a commit nobody had
touched. `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 own filter required. Four checks failed.

ISOLATED BY MOVING ONLY THE CLOCK. Same commit, same shipped-deps image,
libfaketime: green at 2026-08-30, four failures at 2026-09-01. Identical
at f4914a1, 04292c4~1 and 04292c4, so this is the calendar and not a
regression. It has been latent since TARIFF-01 merged on 2026-08-30, and
every PR that ever ran this suite ran it in August.

THE PRODUCT IS RIGHT AND THE TEST WAS WRONG, so the live page is
unchanged. Section 5 of this same file calls NO_RECORD "a normal state
on the 1st of the month" and a capacity tariff is billed monthly, so a
page showing the current month is correct. `checks()` now takes
`when: datetime | None = None` and threads it into peak() and
warning_frac() - the convention PeakStore.peak(), PeakTracker.peak(),
headroom_w() and warning_frac() all already follow. The one production
caller passes nothing and still means today.

AND THE DEFECT IS NOW DETECTABLE RATHER THAN SEASONAL. Three checks:
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 checks() with no argument must still mean
today. Verified by mutation - a checks() that drops `when` is red at
every clock tried, where the old section went green in August. The first
draft of these checks was itself wrong in the same way and reproduced
the bug: it selected rows on "peak", which the NO_RECORD row does not
contain, so it dropped the row and then graded its absence. They select
on "quarter(s) banked" instead.

Full suite green, 15 files, at the real clock and at faked clocks in
2026-08, 2026-12, 2027-02 and 2027-03.

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". That asymmetry is what made the original
filter brittle and what broke the first draft above; it is product text
with other suites reading it, so it is reported rather than changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-01 00:58:20 +02:00
glenn schrooyenandClaude Opus 5 04292c4a08 DATA-01: thirty days of the real meter, and what AC 3 actually measures
checks / unit (pull_request) Successful in 3m36s
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, because two days of capture existed and it was believed
nothing longer could be produced. That belief was a true statement about
the past read as a claim about the future: HA's recorder still held the
whole month, and the reason nobody found out is that the 2026-08-25
export was a one-off script that left no artefact.

So the exporter is committed and runnable now.
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 schema the
two committed days already fixed.

VERIFIED AGAINST THE DAYS THAT EXIST. `--verify` re-fetches 2026-08-20
and 2026-08-23 by this exact route and reproduces both committed files
BYTE FOR BYTE after decompression (865,024 B / 15,600 samples and
894,387 B / 16,351 samples). The gzip containers differ by design: this
exporter writes mtime 0 so its output is reproducible, where the one-off
wrote a wall clock.

A FAILED READ IS NOT AN OBSERVATION. A day whose read fails writes no
file, is recorded "read": "FAILED", and the run exits non-zero; the
entity is proved to exist through /api/states before any empty day is
believed, because a typo would otherwise answer 200 [] thirty times and
produce a consistent dataset of nothing. Nothing is interpolated, so the
month's 13 gaps (1291 s, 99.95 % covered) and its 3 `unavailable`
samples stay visible as holes.

SIZE: COMMITTED, DELIBERATELY, AND MEASURED. A git bundle of the whole
history goes from 1.58 MB (release/1.0 @ 86c43eb) to 5.28 MB - +3.70 MB,
x3.3 - and a Home Assistant supervisor clones that on every install.
Downsampling was not on the table: meter_max_age_s is 30 s, so any
coarser resample turns real coverage into modelled meter-gap and moves
the number being measured. "Outside the repo with a pointer" is what
already happened and is why this card exists. The files sit in a
SUBDIRECTORY so the non-recursive globs in test_tariff.py part 9 and
test_peakguard.py part 6 do not pick them up - CI keeps replaying the two
small days instead of growing ~50 s per PR.

AC 3, MEASURED OVER 30 DAYS (tools/replay_ac3.py, which must reproduce
test_peakguard part 6's 189/190 to be believed):

  uncontrolled  2847/2879 = 98.89 % of quarters at or under 3500 W
  guarded       2850/2879 = 98.99 %
  month peak    5029.8 W -> 4107.3 W

Met on the ">= 95 % of intervals" reading - and the uncontrolled figure
is the finding, because a month with no controller at all passes it. Not
met on the other reading of the same sentence: 4107.3 W is still over the
soft limit. The guard pulls 31 of the 32 over-cap quarters down but lands
only 3 under the cap, because CEILING_MONTH_PEAK defends the running
month peak rather than the cap once one is set. Both suites' honesty
notes are corrected to point at the month instead of at its absence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 11:15:02 +02:00
glenn schrooyenandClaude Opus 5 d57122ec74 DATA-02: a real Belgian price year, so AC4 stops being unmeetable
checks / unit (pull_request) Successful in 3m36s
OPT-04 reported AC4 - "net-positive across 12 months of replayed prices" -
as NOT MET and replayed a synthetic year, because scenarios/ held only P1
meter captures. Right call, wrong diagnosis: the criterion was being carried
beside a 30-day METER criterion as though both needed waiting. EPEX BE
day-ahead is public data, not a capture. It needed a fetch.

8760 hourly points, 2025-09-01 to 2026-09-01 Brussels-local, canonical three
fields. Licence checked BEFORE committing: Energy-Charts publishes the BE
zone under CC BY 4.0 from Bundesnetzagentur | SMARD.de and other zones under
private-use-only terms, so this one is redistributable with attribution. The
fetcher refuses to write a file whose per-response license_info stops saying
so. Cross-checked against SMARD's own 4996/DE: 96 of 96 quarter-hours, max
difference 0.00 EUR/MWh.

Raw wholesale, and the file says so. all_in: false, with the markup/tax
assumption carried as numbers to APPLY rather than numbers applied -
TARIFF-02's trap in data form. Section 7B applies it in one visible place and
asserts arithmetically that its additive terms cancel out of every daily
spread, so the trade count turns on the VAT multiplier alone; the raw replay
is run too, so how much rests on the guess is reported (110 vs 88).

DST by construction: iterate in UTC, render through price.brussels().
2025-10-26 is 25 points, 2026-03-29 is 23, verified by counting per Brussels
day rather than asserting 24, and by checking October's 02:00 appears twice
at two offsets with two prices while March's never appears. Both bugs the
point-and-gap accounting caught were real: a UTC-bounded window made the
first day 22 hours, and an inclusive-end off-by-one dropped the last day
silently. A day nobody asked for is not a day with no prices.

THE RESULT, AT THE SHIPPED DEFAULT, WHATEVER IT IS. At 0.10 widened 1.5x to
0.1500: 110 round trips, +24.88 EUR net of the cycle cost. Unwidened, 173.
Nothing tuned - the threshold and the 2.5/1.5 factors are untouched.

And what the owner's decision now has under it: the four reports that a
static ~0.0840 spread does not clear 0.10 all STAND. What was missing is that
a dynamic site clears the widened requirement on 121 of 365 days, median
all-in spread 0.1130, so a median day would clear at 0.0754 - 156 trips,
+35.42 EUR. Reported, not applied.

The synthetic year stays, because the real one cannot grade section 17: not
one of 365 days has a negative DEAREST hour, so a zero there counts something
that could not happen. 7B asserts that degeneracy rather than reporting the
zero.

Size measured, not assumed: 893 KB plain but 74.6 KB as a git object against
56.5 KB gzipped. Git already compresses; 18 KB is not worth making the
provenance block - the licence claim a reviewer must check - unreadable in a
diff.

Nothing wired into the add-on; generic_dayahead is still the live path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 11:01:42 +02:00
glenn schrooyenandClaude Opus 5 9234016419 Merge release/1.0 (OPT-04) into SAFETY-24, and fix one grader the merge broke
checks / unit (pull_request) Successful in 3m36s
OPT-04 landed after the previous rebase. Conflicts were CHANGELOG.md and one
IMPORT LINE in validate.py; both refusal blocks and both option sets survive
untouched, and DOCS.md, main.py and config.yaml auto-merged.

⚠️ AND THE MERGE BROKE A CHECK THAT NEITHER CARD COULD HAVE SEEN ALONE.
test_peakguard.py graded "the first `.decide(` call in main.py" and asserted
there was exactly one. That was true when SAFETY-24 was written; OPT-04 added
`arbitrage.decide(...)`, so the merged tree had two and the assertion failed.
It failed loudly rather than grading the wrong call - the guard did its job -
but it cannot ship red.

Fixed by selecting the call by QUALIFIED NAME (`peakguard.decide`) instead of
by position, and requiring exactly one of THAT. Other modules may now call
their own decide(); this grader can no longer be moved by one of them. Verified
still discriminating: a decoy `peakguard.decide` added to main.py turns it red
on 7 assertions, and the `maintenance_soc_floor` swap still turns it red on 6.

Also folded in the review's wording finding. An assertion read "a CONTROLLER
whose SoC entity does not answer hands decide() None" while driving no
Controller. The wiring property is stronger than the one claimed, and is now
asserted on the SERVICE CALL: a Controller with no SoC never reaches the guard
at all, because `inputs_ok` is False and cycle() stops at the safety claim
first - so a `float(soc or 100)` defaulting line above the call site cannot
disarm the floor. The original decide()-level check is kept, reworded to say
what it actually grades.

Full suite green on the merged tree: 15 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 07:39:40 +02:00
glenn schrooyenandClaude Opus 5 2b8b8cb97f SAFETY-26: pin the negative-price refusal from below, not only from above
checks / unit (pull_request) Successful in 3m15s
OPT-04's reviewer weakened `if price < 0` to `if price < -0.001` and every
suite stayed green while a -0.0005 EUR/kWh sell hour discharged. The shipped
code was right the whole time; the CHECK could not tell correct from
nearly-correct. The only probe near the boundary sat at 0.0000, which catches a
`<=` and says nothing about a tolerance invented on the other side - and a
tolerance on the other side is §17's named regression walking through with the
suite passing. A dearest hour at -0.0005 is an ordinary Belgian day-ahead
print, not a fuzz value.

`app/` IS NOT TOUCHED. `if price < 0` is already correct and changing it would
be the defect; this card closes the check, not the behaviour. The 0.0000 probe
stays and is now labelled as the half that pins the boundary from above.

The new probe is a SWEEP rather than a point, because one probe at -0.0005
closes `-0.001` and nothing else - a mutation reaching for `-0.01` would be the
identical defect one decimal further out. Five prices span four orders of
magnitude down to -0.000001, the last place the canonical six-decimal format
can express; each is asserted to survive that rounding as strictly negative,
to still be the DEAREST hour, and to still clear the widened threshold, so §17
is the only rung that can refuse. The same five magnitudes with the sign
flipped all discharge, so this is a boundary at exactly zero rather than a
refusal of small numbers.

AC4, the look: every price/quantity boundary in arbitrage.py and
static_tariff.py was given the same tolerance mutation, both directions where
both exist. Two survive, both in static_tariff.py and both NAMED RATHER THAN
FIXED - they are TARIFF-03's rungs and want probes in its own file. `spread ==
0.0` loosened to `<= 0.001` flips a real 0.0005 spread from allowed to refused
at min_spread_eur_kwh=0.0, a documented supported value, and otherwise prints a
false reason about a tariff that is not flat. `len(firm) < 2` loosened to `< 1`
survives at +0 bytes - TEST-15's risky delta - and is masked only by the flat
rung, so the two mask each other and a later edit to either opens a hole
nothing reports.

Mutation verified under `python -B` with a recursive __pycache__ purge, byte
delta printed (+5 B), the mutated line asserted changed, and - per the
reviewer's addition to TEST-15 - the tree re-read from disk and asserted to be
the tree that was mutated before each run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 02:07:56 +02:00
glenn schrooyenandClaude Opus 5 bdd7f18a54 OPT-04: charge cheap, discharge expensive - and only when it pays
checks / unit (pull_request) Successful in 3m16s
Four cards built the price stack and nothing traded on it. app/arbitrage.py is
§10.1's priority 3, and it consumes all four rather than re-deciding anything:
whether a trade is permitted at all, on which two hours, against which widened
threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be
obtained without first saying whether the peak guard is holding. §9.1's "peak
shaving outranks arbitrage" is therefore honoured structurally - there is no
path to a spread that skips the question - and again in the arbiter, where
P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30).

A guard that never ran is not a guard that is clear. On meter_source off the
peak guard is never evaluated; that third state is carried as None and refuses,
because charging at the rail creates the billed quantity while nothing measures
the quarter. validate.py refuses that configuration too, along with arbitrage
armed with no price source 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 clears any threshold and
its dearest hour is still negative. Buying in a negative hour stays allowed.

resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32
heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick
reads an attribute and an age bound. test_arbitrage.py section 8 asserts that
structurally on the shipped AST: a fetch put back on cycle() is red.

AC4 is NOT met and is not restated - this repository holds no price history.
Section 7 replays a synthetic year and asserts the property that does not need
real prices: every executed round trip cleared its own widened requirement, and
zero discharges landed in the 522 negative-priced hours. 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 FCST-03's widening factors are one product
decision and it is the owner's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 01:36:09 +02:00
glenn schrooyenandClaude Opus 5 a1376e44e4 Merge release/1.0 (FCST-03) into SAFETY-24 - CHANGELOG only
checks / unit (pull_request) Successful in 3m28s
FCST-03 landed while this was in review, so both branches had prepended an
Unreleased stanza. Only CHANGELOG.md conflicted, exactly as predicted;
app/, config.yaml, validate.py and DOCS.md all auto-merged, and this card
touches neither price_forecast.py nor static_tariff.py.

Both stanzas kept verbatim, SAFETY-24's above FCST-03's - no text from
either author edited. Full suite green on the merged tree: 14 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 00:43:58 +02:00
glenn schrooyenandClaude Opus 5 e709554857 SAFETY-24: an energy floor under the peak guard's forced discharge
checks / unit (pull_request) Successful in 3m29s
SAFETY-07 can command a discharge to hold a billed quarter down, and
nothing below it consulted the state of charge. On the 2026-08-20 replay
it 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. The first
symptom is a pack never available when wanted, which is indistinguishable
from the SoC drift MAINT-01 exists to correct.

decide() now takes soc and soc_floor, both required and neither
defaulted, and discharge_permitted() is the one place the question is
answered. 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 is never negative, so 9.1's asymmetry
survives as a property of the object (0 negative floors over 1296 swept
combinations, SoC included).

The SoC is graded only after `wanted > 0` is established, so it is never
evaluated where it governs nothing. 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 shipped that defaulting once already.

Failing to shave is not silent: one notification per episode
(goodwe_peak_soc_floor), distinct from the saturation alarm because the
two ask for opposite operator actions, plus its own status row and an
action-log line.

Verified by replay. 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 holds 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 4338 W -> 5030 W, because a floor-limited guard shaves less
on purpose.

Two new options, mirrored once 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 - so 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 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 appears.

Eleven mutations of the shipped source were run against the checks; all
eleven go red and there are no survivors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 00:29:36 +02:00
glenn schrooyenandClaude Opus 5 71cd7849e4 Merge release/1.0 (SAFETY-07) into FCST-03, and close two degenerate stimuli
checks / unit (pull_request) Successful in 3m13s
CHANGELOG.md was the entire collision - SAFETY-07 lands in peakguard.py, main.py
and the arbiter, none of which this branch touches. Both entries kept.

X1. `_firm_or_die` guarding only `cheapest` survived, because the check passed
the SAME provisional point as both cheapest and dearest: a guard on either role
alone raises, so the stimulus could not tell "the buy hour is guarded" from
"both are". Now one call per role with a firm point in the other seat, plus the
positive arm so the two refusals are discriminating.

X6. `firm_points()` returning the carried hours too survived, because its two
consumers - the §11.4 refusal and cheap_now - guard provenance independently and
both keep answering correctly. The accessor is now graded directly: exactly the
published points, by identity and in order.

Both are the degeneracy rule this file states at the top, in a new dress - a
stimulus that cannot distinguish the two things it grades.

⚠️ AND THE REASON THE M9 MUTATION "SURVIVED" IN MY EARLIER REPORT: a stale
__pycache__. Reverting `self.provisional` to `here.provisional` changes neither
the file size nor, at one-second resolution, the mtime, so CPython kept the
MUTATED bytecode and a green run afterwards proved nothing about that line. Every
mutation here now clears __pycache__ first; re-run that way, M9 is caught. The
committed source was always correct - the checks were not being run against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 00:15:32 +02:00
glenn schrooyenandClaude Opus 5 dd40c56ab4 FCST-03: yesterday's prices, flagged as yesterday's, and unable to move anything
checks / unit (pull_request) Successful in 2m43s
§8.3 fills the hours a day-ahead feed has not published yet with yesterday's
profile and flags them provisional, and forbids any irreversible action on such
a price. The whole risk of this ticket is one line - a carried point and a
published point are three fields and a bool, so `provisional=y.provisional`
copies yesterday's False onto today's guess and produces stale data presented as
current. That is SAFETY-18's frozen-but-fresh meter one layer up.

So the flag is never passed through. Carried points come from a constructor with
no provisional parameter; published points pass through BY IDENTITY, so no copy
exists that could lose one; an audit re-derives the expected flag for every
emitted point and raises rather than repairing; and the check walks the module's
own AST so the pass-through idiom cannot come back looking ordinary. A published
point may itself be provisional (eneco_eddy's imbalance print is), so the rule
for a published hour is "whatever the feed said", never False.

The fill keys on the Brussels wall clock rather than t-24h, so the 23- and
25-hour days are right; a late publication rebuilds the series, moving the
number and the flag together.

AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside
it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is
provisional" would have switched arbitrage off every morning on every dynamic
site - and refusal 5 is new: the spread must clear a threshold widened by the
widest active uncertainty source (max, never a product). The permitting variant
now refuses to be constructed around a provisional point at all, and the
cheap-window flag became per-hour for the same reason.

The irreversible action is a value carrying the hours it commits, derived from
maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain
whose window reaches into a carried hour, and the phase machine stays idle. It
is the price-justified entry point only - a gate that blocked on no_data would
stop the monthly cycle on every default install.

Zero new config options; TEST-14's signature scan ported to cover both Gate's
and Arbitrage's constructors, closing _Refused.reason's open hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:54:07 +02:00
glenn schrooyenandClaude Opus 5 c2ed509565 Merge release/1.0 (TARIFF-03) into SAFETY-07
checks / unit (pull_request) Successful in 3m12s
The anticipated conflict again, and only it: both cards prepend to CHANGELOG.md's
Unreleased section. Both entries kept, SAFETY-07 first. app/main.py and DOCS.md
auto-merged, so the overlap claim on #18 still holds - config.yaml and
validate.py remain untouched by this card even after TARIFF-02 and TARIFF-03
widened both.

Full suite green on the merge result, test_price.py and test_static_tariff.py
included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:43:35 +02:00
glenn schrooyenandClaude Opus 5 2d570d84ce SAFETY-07 review: defend the three wiring mutations that survived
The reviewer's finding was correct and the shape of it matters: everything the
first cut graded was in peakguard.py, and grading the module is not the same as
grading the guard. Three one-line changes to main.py's CALL SITE left all twelve
suites green while a default install parked at 0 W in the quarter that sets the
month's bill.

B2 was the serious one. My own comment described that defect - "that entity is
optional and empty on a default install, so until now the suspension could not
fire on the site that needed it" - and nothing could regress it. A comment that
documents a fix nobody can test is the same shape as a corrected comment nobody
tests, which this project has already been caught by once.

WHAT CHANGED

The suppression conjunction moved out of main.py and into
peakguard.shaping_suspended(), for the reason watchdog.loop_is_following() and
freeze.looks_frozen() live where they do: the check that grades a safety
predicate must assert on THE SHIPPED EXPRESSION, not on a transcription that can
drift apart from it in silence. Its docstring carries both terms and why neither
is sufficient alone. main.py's call site now says READ THAT FUNCTION FIRST, and
records that a reviewer mutated it back to its historical broken form with every
suite staying green.

test_peakguard.py part 7 gains a section that EXECUTES the three mutations
rather than describing them. Each one is put in place on the shipped module and
the REAL Controller.cycle() is driven with it, on the dangerous fixture: a
default install (no peak_forecast_entity, so peak_fc is None and peak_at_risk is
False forever) with maintenance in its CHARGE phase, so a P_MAINT_SHAPE `hi <= 0`
limit is live, and the guard genuinely engaged on the measurement. That the
mutations are monkeypatched onto the shipped module is itself load-bearing: if
main.py inlined either expression again the patch would do nothing and these
checks would pass vacuously.

    SHIPPED   suspended=True    loop -> 1498 W, limited by peak    +1497.8 W
    B1        suspended=False   CONTRADICTION between limits           0.0 W
    B2        suspended=False   CONTRADICTION between limits           0.0 W
    B2b (forecast term dropped instead) - caught by its own check

B3 is graded three ways, because the single-cycle form is only INFLATED and the
failure is what happens on the next cycle:

  * the value main.py actually hands decide() is recorded off the real call, with
    the measured battery at 0 W and the command standing at +3000 W;
  * the one-cycle difference is asserted to be exactly the standing command - the
    whole defect in one line, watts that were asked for and never arrived counted
    as if they had;
  * both forms are then run forward against the REAL controller's own projection
    with an inverter that never follows:

        intent-sized floor    4482 5000 5000 5000 ...  SATURATED at cycle 2
        delivered-power floor 1482 1482 1482 1482 ...  stationary

  * and the call site is graded on parsed syntax, so a rename cannot quietly
    reintroduce it: `decide(batt_w=...)` must unparse to `self.batt`.

SAFETY-24 is now named in peakguard.py, in its own section, with the number that
makes it real - 878 Wh of forced discharge on the 2026-08-20 replay - and the
statement that this must not go to a client install before that ticket lands. A
ticket nobody can find from the code is a ticket nobody reads before shipping.

RED AND GREEN

Fifteen mutations run against the shipped modules, each reverted after: the six
new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer) and
the eight from the first round, re-run to confirm they still bite. All fifteen
turn test_peakguard.py red. Full suite green, test_price.py and TARIFF-03's
included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:40:39 +02:00
glenn schrooyenandClaude Opus 5 9f8b721a4d Merge release/1.0 (TARIFF-02) into SAFETY-07
checks / unit (pull_request) Successful in 3m9s
Conflict was the one anticipated: both cards prepend to CHANGELOG.md's
Unreleased section. Both entries kept, SAFETY-07 first. Nothing else of
TARIFF-02's surface (app/price.py, validate.py rule 7, config.yaml, DOCS.md)
overlaps this card - config.yaml and validate.py are untouched here, so the
CFG-03 check still reads a config.yaml that only TARIFF-02 has widened.

Full suite green on the merge result, test_price.py included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:13:29 +02:00
glenn schrooyenandClaude Opus 5 303525cfc5 TARIFF-03: static day/night and fully-fixed tariffs, first-class
checks / unit (pull_request) Successful in 2m43s
§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 - and app/static_tariff.py turns it into the same canonical
{start, eur_per_kwh, provisional} series the fetched providers emit.

Not a sixth provider plugin, for two mechanical reasons. 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 and which one a site is
on is a property of the operator's data; StaticProfile.product derives it from
the number of distinct prices. And the plugin framework exists to stop a
FETCHED figure being marked up twice, which a number typed as all-in cannot be.
So the profile sits where §11.4 already says it does - under the providers, as
the fallback. Configured product and feed-is-down are one generator differing
in one flag, so the degradation path cannot rot unused.

"No arbitrage" is a type. TariffPlan.arbitrage() returns a variant whose
ALLOWED arm carries the spread and the cheapest/dearest hours and whose REFUSED
arm carries a reason and nothing else - .spread_eur_kwh on a refusal raises
rather than answering 0.0, so an optimiser cannot compute a trade from one.
Four refusals, all derived: peak guard holding, no price data, plan
provisional, or the series is FLAT. The last is AC2: a fixed tariff disables
arbitrage by arithmetic, not by matching on an enum. peak_guard_active is a
required keyword argument, so §10.1's priority 2 over 3 cannot be skipped.

Time-window shifting reaches the one shiftable load that exists: main.py's
cheap-window flag falls back to the static profile, and the UI gained a Tariff
row. Cheap is STRICTLY below the seven-day mean - `<=` would call every hour of
a flat tariff cheap, and 48 h taken on a Friday is two-thirds weekend.

A profile must tile every day type exactly once, checked at startup; a gap is
refused with the range named rather than filled in. `22:00-07:00 mon-fri` does
not cover Saturday morning, and that trap is what the check catches.

validate.py rule 7's stated reason expired exactly as TARIFF-02 predicted: the
test_price.py section 12 tripwire went red naming static_tariff.py. The message
now says what is true instead, and the tripwire is re-aimed rather than removed
- consumer set pinned, expired phrase asserted gone. Rule 7 also grew a second
refusal for static_profile alongside the HA price pair, the same silent-loser
conflict and a live one.

CFG-03 is not widened: static_profile went into price.py's PRICE_OPTION_DEFAULTS
- the one in-code mirror pinned to config.yaml by a check - and the new module
carries no defaults dict of its own.

Measured on the two committed days of this house's real P1 meter. 2026-08-20
(Thursday): 15.220 kWh imported, 9.606 day / 5.614 night, EUR 4.7185 under
day/night vs EUR 4.6725 flat, shifting saves EUR 0.8069 = 9.606 kWh x the
0.0840 spread, derived twice. 2026-08-23 (Sunday): 0.728 kWh, all of it
night-priced, which is the weekend row proving itself. §17 names six Belgian
scenarios; this is the seventh, and it is two days rather than a season - the
file says so rather than implying an annual figure.

All 12 suites green. Eleven mutations of static_tariff.py were each caught by
at least one check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:07:53 +02:00
glenn schrooyenandClaude Opus 5 616b62d3e9 SAFETY-07: cap the loop before it sets the month's billed capacity peak
checks / unit (pull_request) Successful in 3m7s
TARIFF-01 measured the quarter and said the consumer would be this card. It is
one new module, app/peakguard.py, and one new claim.

THE MECHANISM. Every cycle the guard projects the open 900 s block to its
end-of-interval average - from P1 telegrams only, never from the command - and
compares it to a ceiling. Over the ceiling it files ONE arbiter claim: a lower
bound on the setpoint at P_PEAK. That single 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. 9.1's asymmetry becomes a property
of the object instead of a rule to remember.

    excess = 900 x (projected - ceiling) / seconds remaining
    floor  = MEASURED battery power + excess

The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard
is gentlest where its projection rests on the least evidence. The floor is sized
from delivered battery power, not the standing command: a floor built on intent
re-adds its own unmet ask every cycle and winds to the rail whenever the inverter
fails to follow, and test_peakguard.py part 1 shows the two diverging.

IT TRIPS, 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 - the cost SAFETY-18 could not bound and this one can. 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
and AC 3 remains TARIFF-01's open item.

BLINDNESS IS CHECKED BEFORE THE PROJECTION IS GRADED, and it is a different
action. 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;
a frozen meter reading export projects 0 W at full coverage. All three become a
floor of 0 W - no importing while the billed quantity is unobservable - 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, on this cycle's standing command.

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 and never looser. The gate is the reading's STATE, not whether a
number happens to be attached: today a `peak_w is not None` test behaves
identically, and depending on that coincidence is how a future last-known-peak
field would raise the ceiling off a read that failed. headroom_w() is
deliberately not consumed - cap minus peak turns a zero peak into FULL freedom.

TWO DEFECTS FOUND BUILDING IT, both in the interaction rather than the
arithmetic, and both caught by running the thing rather than by reading it:

  * 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 - the battery parked in the exact quarter
    it should shave. Fixed by suppression, not 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 empty on a default
    install, so until now the suspension could not fire on the site that needed
    it.
  * Thresholding `excess > 0` released the claim the instant it began working,
    producing seventeen engage/release cycles in one 900 s quarter and leaving
    that quarter 49 W over the limit it had spent fifteen minutes holding. That
    is 10.2's oscillation. 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 disarming - a rename credits
0 W held, the projection reads low, the money guard does not engage.
QuarterAverager.held_net_w is a documented public property now, 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. 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 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.

test_peakguard.py: eight parts, all figures derived from the shipped config.yaml
and the committed captures. Part 8 runs seven one-line defeats of the guard and
reports which check bites; six do, and the seventh is reported as NOT caught,
because max(cap, peak) makes a zero peak the strictest ceiling there is and there
is nothing left to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 23:05:05 +02:00
glenn schrooyenandClaude Opus 5 f4914a174a TARIFF-01: measure the month's billed peak, and make it survive a restart
checks / unit (pull_request) Successful in 2m38s
The 15-minute block average was already built and unwired. p1.QuarterAverager
computes clock-aligned 900 s blocks, time-weighted over irregular telegram
spacing, offtake-only and per-phase; P1Ingest.blocks was a 96-entry list in
memory that a restart erased. So this is the persistence and the monthly
reduction, not a second accumulator - a second one would be a second source of
truth for the exact quantity the money depends on.

BLOCKS, NOT A SLIDING WINDOW. The card says "rolling"; 9.1 bills "the highest
15-minute average offtake" and states its own worked case per QUARTER - one
4998 W quarter moving a monthly peak from 4832 to 5013 W. A trailing window
reports a peak the meter never bills, and reports it HIGH: a burst straddling
:15 fills the window completely while contributing to two block averages at
partial weight.

DELIVERED, NOT INTENDED. tariff.py only ever sees P1Sample - the meter's own
reading - and never a setpoint. SAFETY-20's open question does not arise for a
capacity tariff: the utility integrates the current that flowed.

AN UNREADABLE STORE IS NOT A ZERO PEAK. peak() answers OBSERVED / NO_RECORD /
NOT_OBSERVED with peak_w None unless observed, which is riglock.holder()'s fix
applied to something that spends money. A peak read as 0 W hands SAFETY-07
maximum headroom and disarms the guard at the moment its own storage breaks.

Restart mid-block resumes the accumulator through a new snapshot()/restore()
pair, checkpointed every 15 s and cleared in the same breath as a block close.
The held sample keeps its ORIGINAL timestamp, so an outage is integrated under
the same max_hold_s rule a running process would apply. quarter_start is the
primary key and a re-bank may only ever RAISE a figure.

Calendar months are LOCAL: a quarter starting 22:15 UTC on 31 August bills in
September.

No new option (the limit is the existing peak_cap_w), so config.yaml and
validate.py are untouched and CFG-03 is not widened. No second peak entity:
peak_forecast_entity stays the input to maintenance_charge_floor(), because
repointing that consumer changes what moves energy and belongs to SAFETY-07.

AC 3 is NOT met. It asks for 30 days of replay; two days of this house's real
meter exist and are committed here. test_tariff.py part 9 replays them, reports
the percentage next to the sample size in days, and asserts nothing about 95 %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 19:13:58 +02:00
glenn schrooyenandClaude Opus 5 4b521b2df0 ci: trigger a run on the rebased head
checks / unit (pull_request) Successful in 2m33s
No content change. The gate never created a run for 0942228d - the newest
run stayed on d31a12b7, the pre-rebase head - so `checks / unit` had
nothing to report and sat pending. Why no run was created is CI-02 and
belongs to CI-01's owner; this commit only asks for one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 18:11:21 +02:00
glenn schrooyenandClaude Opus 5 0942228d65 Merge release/1.0 (SAFETY-03) into TEST-06
checks / unit (pull_request) Canceled after 0s
Two conflicts, both predicted, both mine as the later merger.

app/main.py - #12 inserted `await self.check_output_clamp()` on the line
immediately above `await self.check_meter_frozen()`, so its hunk abuts my
comment correction. Resolved so EACH CALL CARRIES ITS OWN BOUNDARY,
because the two guards do not share one:

  - check_output_clamp() reads `self.clamp_last`, which is None until
    write_setpoint() has run, so its boundary IS the write;
  - check_meter_frozen() grades `self.target`, assigned further up, so
    its boundary is the assignment.

Letting my correction generalise across both would have been worse than
the vague comment it replaces, because it would read as deliberate. The
AST of app/main.py is identical to merged release/1.0 (+1251 bytes, all
comment).

test_freeze.py - #12 independently added `frozen_notes()` keyed on the
same id. `notes_of()` subsumes it: same id, plus the title fallback that
keeps the id's own check independently falsifiable, plus `all_notes_of()`
for claims about the whole surface.

THE THREE SURFACE-WIDE ASSERTIONS, DECIDED ONE AT A TIME. Measured on the
merged tree:

  scenario                              surface  ids             freeze
  frozen meter, railed command             2     frozen + clamp     1
  TEL-05 converged, -14.2 W                0     -                  0
  moving meter, a real 8 kW load           1     clamp              0
  uncovered transport, no unchanged_s      1     clamp              0

  - the wiring scenario STAYS SURFACE-WIDE and now asserts exactly TWO
    alarms with BOTH IDS NAMED. The answer to "is this still meant to
    raise one alarm" is no, it raises two, and a third guard has to come
    back to this line. That is the one assertion in the file that
    accounts for the controller's entire alarm output;
  - the CONVERGED case stays surface-wide and stays silent: at the
    operating point the whole add-on should have nothing to say, that is
    still true, and narrowing it would give away a real property;
  - the other two NARROW to notes_of(). The clamp alarm there is correct
    and is SAFETY-03's subject: a real 8 kW load does drive the loop past
    the rating, and an uncovered transport does not stop it. Asserting
    whole-surface silence would make this file fail whenever another
    guard correctly does its job. Each still owns the load-bearing half -
    THE FREEZE DETECTOR SAID NOTHING - and the surface is PRINTED so a
    reader sees what else fired without this file grading it.

Re-verified on the merged tree: M1 RED 4, M2 RED 5, M3 RED 3 + RED 2,
N6 RED 1, N3 green (correctly), N8 RED 4, R1 RED 2, R2 RED 1. All nine
suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 17:45:09 +02:00
glenn schrooyenandClaude Opus 5 d31a12b7c6 TEST-06: the id-selection note said 'the only assertion'; there are two
checks / unit (pull_request) Successful in 2m32s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 17:10:42 +02:00
glenn schrooyen 2983d7c3b0 Merge remote-tracking branch 'origin/release/1.0' into TEST-06
checks / unit (pull_request) Canceled after 0s
2026-08-30 17:05:35 +02:00
glenn schrooyenandClaude Opus 5 611d3dbcdf TEST-06 review follow-ups: two notification surfaces, the real ordering boundary, the schema bounds
1. notes_of() could not tell this guard's alarm from anybody else's. It
   selected by SERVICE, so against #12's app/ (which adds
   goodwe_output_clamp) eight assertions went red, AC1's core check among
   them. Blanket id-keying is not the repair and was measured, not
   guessed:

       selector                    vs #12's app/   id renamed
       by service (as committed)     8 red           1 red
       by id alone                   3 red           6 red
       by id or title (shipped)      3 red           1 red

   all_notes_of() is the whole surface; notes_of() is this guard's,
   recognised by the id it should carry OR the title it writes - the `or`
   is what keeps the one assertion whose subject IS the id able to fail on
   its own. The three surface-wide claims stay surface-wide, each with a
   comment naming which surface it is about and why the next guard's
   author must decide rather than narrow them. They are the three that go
   red against #12, which is the correct signal.

2. The ordering comment named the wrong boundary. N3 - moving the call to
   immediately before write_setpoint() - is green and correctly so:
   self.target is assigned two lines earlier, write_setpoint() clamps only
   a local and can skip the write entirely. So the invariant is "after
   `self.target = resolution.target_w`", and the detector grades pre-clamp
   INTENT, not the wire. Corrected in test_freeze.py and in main.py's own
   note above the call (comment-only; I merge second and own the
   conflict). N8 - moving the call above the assignment - is RED 4.

3. The schema range's own bounds are now pinned. int(60,3600) ->
   int(1,86400) survived green: an operator could set meter_freeze_s:
   86400 and the guard is dead with nothing red. Both bounds are
   re-derived rather than restated.

4. find_hwsim() crashed the whole suite on a checkout one level below the
   root (HERE.parents[1] -> IndexError, reproduced at C:/goodwe_controller).
   Guarded. And part 3 now names its transport in the summary: the rig
   hwsim and the stub are not equivalent - the rig path failed 1 run in 3
   - so a green log has to say which one backed it.

Also: the "revived transport re-rails" check is labelled a PRECONDITION
like its sibling; freeze_rows() records that flipping p1_enabled exercises
the row's content and never its gate; and the note about the deleted
assertion is corrected - it was REDUNDANT, not undecidable. The reviewer
falsified it in one line (move the hass.call above `if not fired: return`,
RED 9). "I could not construct a falsifier, therefore it cannot fail" was
exactly the inference this file exists to distrust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 17:05:28 +02:00
glenn schrooyenandClaude Opus 5 46383c5ae5 Merge release/1.0 (CI-01) into SAFETY-03 so the required check can run
checks / unit (pull_request) Successful in 2m32s
Branch protection on release/1.0 now requires the context
`checks / unit (pull_request)`, and CI-01's workflow arrived after this
branch was cut - so no run had ever been triggered for this PR and the
required context would have sat pending forever. Clean merge, no
conflicts: .gitattributes, .gitea/ci/Dockerfile and
.gitea/workflows/checks.yml only. No add-on source or check is touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 16:03:32 +02:00
glenn schrooyenandClaude Opus 5 4e66583a52 SAFETY-03 review fixes: both clamp stages always run, and two checks that could not fail
1. BEHAVIOURAL, and a regression this PR introduced. clamp_to_rating() returned
early from the max_w stage, so the device bound was applied 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 +4500 was correctly held to +4000. It undid the pre-PR guarantee "never
send a value the device will reject outright" and undercut this module's own
case for max_w-not-min(), which rests 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). Asserted over a 492-point sweep, because the defect was
invisible to any single-value check sitting inside one stage.

2. The hysteresis check issued range(CLEAR_CYCLES - 1) clean cycles, which at
CLEAR_CYCLES = 1 issues zero, passes, and prints "0 clean cycles do NOT clear
it" - green for the chatter setting the constant exists to prevent. Now: ONE
clean cycle must not clear the alarm, a literal that does not move with the
constant, and the de-assert count is measured by looping until it clears.

3. The re-assert check stimulated at REASSERT_S + 9, green for any value
including 3600 - the hour of silence the module promises not to produce. Now
stated in wall-clock terms: a loop pinned across the 900 s capacity-tariff
quarter must speak at least 5 times and on no more than 20% of cycles. Pinned in
both directions.

Red confirmed for each: the restored early return (3 checks), CLEAR_CYCLES = 1
(1), REASSERT_S = 3600 (1) and REASSERT_S = 0 (3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 15:52:09 +02:00
glenn schrooyenandClaude Opus 5 c34b5a2922 TEST-06: close the three gaps that survived SAFETY-18's suite green
None of the three is a bug in the shipped code. They are gaps in what the
suite could detect, and the next person to touch freeze.py had less
protection than the passing count implied.

1. WHERE check_meter_frozen() runs in cycle(). Moving it to the top left
   the whole file green. Pinned by CONSEQUENCE, not by source order, so a
   refactor that keeps the semantics stays green:
     - the alarm must land on the SAME cycle the command reaches the rail
       (at the top it grades the previous command against this cycle's
       meter and arrives a cycle late);
     - the dead-poll-task composition: poll task dies, stale_input_s is
       crossed, the arbiter commands 0 W, and on THAT cycle the alarm is
       already clear - because the detector ran after the 0 W write.
   wired() now returns a per-cycle trace, which is what makes "on which
   cycle" an observation rather than a hope.

2. The NOT COVERED status row. The old check only asserted that an
   uncovered transport is SILENT, which a page claiming `armed` on a guard
   that cannot run also satisfies. The string is now asserted in both
   directions: the uncovered row must say NOT COVERED, name the
   meter_source and homewizard_local, and warn; the covered row must still
   say armed, or "does not say armed" would be satisfied by deleting the
   armed branch.

3. meter_freeze_s. Pinned in test_validate.py by MAINT-04's regex
   technique (no PyYAML in the image), and in test_freeze.py by its
   consequences: the shipped value drives the replays, must stay inside
   the 900 s billing quarter, and 3600/hold_s must stay inside the shipped
   deadband. A Controller built with the key ABSENT must land on the same
   number, which pins main.py's fallback without touching CFG-03.

All three demonstrated red before being accepted green, and every new
check was mutated to confirm it can fail; one candidate assertion ("no new
notification while parked at 0 W") was DELETED because no mutation could
break it, and replaced by the exact notification count across a revived
transport, which can.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 15:50:08 +02:00
glenn schrooyenandClaude Opus 5 9c1a23328a CI-01: this repository's first automated check
checks / unit (pull_request) Successful in 2m39s
Before this commit `admin/goodwe-addon` had no `.gitea/workflows/` directory at
all. Eight runnable test files - `test_loop.py` alone drives the real
`run_control()` for 93 s of wall clock - and not one of them had ever been run
by a machine. Every merge here was a human reading a diff, and the product
owner's requirement that a green CI gate a merge could not be met on a
repository with no CI to be green.

One job, `unit`, with no `paths:` filter, so it reports on every PR and can
therefore be a REQUIRED status check: Gitea leaves a required context it never
receives pending forever, which is the failure mode on the other side of this
ticket. Concurrency is scoped per PR from the start rather than to a bare group
name - the bare name is what let the infra repo's run 521 be superseded while
still pending, so its four jobs concluded `cancelled` with `started_at` at the
Unix epoch and its PR merged on no evidence at all.

The tests run in `.gitea/ci/Dockerfile`: `alpine:3` plus `py3-aiohttp` and
`py3-paho-mqtt` from apk, matching goodwe_controller/Dockerfile exactly, because
pip would install glibc wheels of a library the shipped image gets from Alpine.
The runner itself cannot host them - bare python3, no aiohttp, no pip - and
mutating a shared long-lived runner with `apk add` is invisible to anyone
reading this repo.

Two things measured while building it and recorded in the file rather than
learned again: the checkout must sit two levels below the mount root or
test_freeze.py's `find_hwsim()` dies on `IndexError: 1` before its first
assertion, and test_freeze's hwsim section is silently absent here because
admin/goodwe is private - so its PASS is a weaker claim than it looks, and the
file says so.

Verified green on release/1.0 content before this workflow was written: 8 of 8
files pass, ~4 minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 15:46:30 +02:00
glenn schrooyenandClaude Opus 5 c040aac4b8 SAFETY-03: alarm when a computed setpoint exceeds the rating, do not just clip it
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 pinned at
the 5 kW rail; clipped silently that is a flat, legal-looking 5000 W hold and
nothing says the loop asked for three times it.

write_setpoint() - the one path every write takes - now clamps to the VALIDATED
max_w and reports whether the raw value differed; check_output_clamp() raises
OUTPUT_CLAMP as a notification, an ERROR, an event row and a red status banner.
Not a trip: the clamped value is written and the loop keeps running.

The rating is max_w and nothing is min()ed at runtime - CFG-01 already refuses a
max_w outside the device range at startup. The device range is applied second as
defence in depth; it cannot bite after a successful validation, so if it ever
does it alarms too and names the bound. No new config key.

Decision grew i_clamped / clamped booleans, and that is the load-bearing part:
`reason` is one string that slew-limiting and the saturation freeze overwrite,
so an alarm keyed on it is silent on exactly the cycles a winding loop produces.
Mutating main.py back onto the string drops the 10.3 replay from 2 engagements
to 0.

Distinct from saturation by construction - no shared reason code, notification
id, status row or counter - and checked in the negative, after first asserting
the saturation freeze really did engage.

Two things found by running it rather than by reading it: a row per engagement
flushed the 40-row event log and evicted SAFETY-18's own METER FROZEN? row (now
coalesced per episode with an exact count), and a naive clamp turns a nan into a
command at the full rating because every comparison against nan is False.

test_clamp.py is new: assert-based, no framework. Six author mutations
reproduced red, including "clamp kept, alarm deleted". Two of the check's own
hypotheses about the freeze/clamp interaction were refused by its sweep and
rewritten to what it measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 15:22:22 +02:00
glenn schrooyenandClaude Opus 5 7cde5d862a Merge release/1.0 into SAFETY-18 - CHANGELOG only
MAINT-04 (#10) landed on release/1.0 mid-review, so both branches had
prepended a stanza. config.yaml and DOCS.md auto-merged clean; only the
CHANGELOG needed a hand. Both stanzas are kept verbatim, MAINT-04's
Unreleased above the new 0.5.2 - no text from either author edited.
MAINT-04 is unreleased and 0.5.2 is being cut here, so a release manager
may want to fold the two; that is an editorial call, not a reviewer's.

Full suite green on the merged tree: arbiter, control, loop, maintenance,
p1, validate, watchdog, freeze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 03:20:26 +02:00
glenn schrooyenandClaude Opus 5 ffb8ec6373 MAINT-04 review: pin the default divergence with a check, not a comment
validate.py's comment said "if config.yaml's value moves, move this and
main.py's together" and nothing enforced it - a comment where a check
belonged, in a PR that elsewhere proves it knows the difference.

test_validate.py now reads main.py and maintenance.py directly and asserts
the divergence EXACTLY AS IT STANDS: config.yaml/validate.py at 2000,
main.py's two fallbacks and MaintConfig's dataclass default at 2500. It
records the mismatch rather than demanding it be closed, so it passes today
and goes red if either side moves - including when somebody closes it in
main.py, at which point the check and validate.py's block are deleted
together. Nothing in a file another ticket owns is touched.

The consequence is asserted through the real code as well as the literals:
with the key absent the validator accepts, MaintConfig builds 2500 W against
a 2000 W ceiling, and the arbiter resolves it to 0 W as a contradiction -
the bug rule 6 exists to catch, passing rule 6. Production-unreachable (the
Supervisor always supplies every key) but fully reachable from sim/inject.py
and the tests, which is where anyone debugging maintenance works.

Also: a CHANGELOG section for the upgrade path, covering both the case where
the Supervisor re-derives the default and the case where it persisted the
old one, since which applies was not verifiable without probing the live
install. And maintenance.py's timeout comment said 13.5 kWh where everything
else says 13.8 (300 Ah x 51.2 V, DoD 90); SAFETY-18 does not touch that file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:51:40 +02:00
glenn schrooyenandClaude Opus 5 0ba3a200fe SAFETY-18: verify on the real rig, and record what it showed
AC3 says "verified on the rig". The previous commit drove hwsim in-process
because `riglock status` reported UNKNOWN - which is the tool declining to
observe, not an observation. riglock shells out to the local docker CLI; on a
Windows host without DOCKER_HOST that resolves to a Docker Desktop npipe which
is not running, and the result is indistinguishable from a genuinely absent rig.
The rig was up the whole time. With DOCKER_HOST=ssh://homelab@192.168.2.147 it
reads FREE.

Re-run under sim/riglock.py against the real goodwe-sim compose project, with
the shipped modules staged into goodwe-sim-plant so they poll that container's
own hwsim while plantsim drives it. Both phases passed, lock released cleanly.

The rig produced a result the in-process stub could not, and it strengthens the
design rather than merely confirming it: in the NEGATIVE twin - +8000 W of house
load, healthy meter, correctly railed command - `active_power_w` did not move at
all, sitting at exactly 10130.0 W for 26 consecutive polls. The only thing
keeping `unchanged_s` at 0.0 s was the kWh registers advancing.

So the register terms in measurement_fingerprint() are not belt-and-braces for
this detector, they ARE the negative case: fingerprinted on the power figure
alone it would have raised a false alarm on a healthy meter under a real load,
which is precisely how a safety guard gets switched off. Recorded in freeze.py
so nobody simplifies the fingerprint without meeting that run first.

No behaviour change - documentation and the harness note only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:49:55 +02:00
glenn schrooyenandClaude Opus 5 7aa1bd052e SAFETY-18: a meter frozen at a wrong-but-fresh value now raises an alarm
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 while the
loop integrates a constant error and rails: 360/135/75 s at the live install's
max_w: 5000 for a meter frozen at 350/1000/>=3000 W. Fifteen minutes of a 5 kW
unattended import sets the month's billed capaciteitstarief peak.

The detector 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, and 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 in seconds. At
the shipped 300 s the relation is structural rather than 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.

Notification only, deliberately: an HA persistent notification with a fixed
notification_id, an ERROR log line, a UI event and a red status row. No claim,
no latch, no 0 W. A false trip parks the battery; a false notification costs an
alert. Only meter_source: homewizard_local is covered - the status page says
NOT COVERED elsewhere rather than pretending the guard is armed.

DOCS.md states the capacity-tariff exposure this does NOT close: peak_cap_w caps
maintenance charging and nothing caps the loop. That cap is SAFETY-07.

test_freeze.py grades both directions and was verified by breaking what it
watches (six mutations, all red): silent over the committed 40-minute converged
capture, silent over an hour of TEL-05's low-power case where the rejected bare
threshold is shown to fire, alarming on a genuinely frozen meter driven over
real HTTP through the real HomeWizardLocalSource (the rig's own hwsim freeze
fault when reachable), silent on the same railed command against a meter that is
still moving, and asserting on the service call that leaves Controller.cycle()
rather than on the watchdog's own flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:39:54 +02:00
glenn schrooyenandClaude Opus 5 878a6ce452 MAINT-04: refuse a maintenance charge the ceiling cannot deliver
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 the operator set.

validate_startup_config() now refuses maintenance_charge_w > max_w, the
same shape as CFG-01's sign_disagree_w >= max_w. Equality is reachable
(-max_w is a single valid point) and is not refused. Demonstrated by
starting the add-on on the pre-fix pair: exit 78 and the refusal banner.

maintenance_discharge_w was checked rather than assumed and is NOT the same
case: the drain phase OWNS the actuator (Claim.set at P_MAINTENANCE), so
the ceiling clamps it to +2000 W and the arbiter's explain() names the
clamp every cycle. Loud, and it still moves energy - warned, not refused.

The shipped maintenance rates drop to 2000 so the defaults pass the rule;
max_w was deliberately not raised, because it is the supervised safety
ceiling. test_validate.py now reads config.yaml itself, so a default the
validator refuses cannot ship again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 02:28:09 +02:00
glenn schrooyenandClaude Opus 5 e40eb0768d SAFETY-02: the P_PEAK assertion was pinning the test's copy of the gate
Review finding 2, and it is the same duplication `disagrees()` just removed, so
it gets the same fix rather than a follow-up card.

`following_from()` in test_watchdog.py was a TRANSCRIPTION of the gate in
main.py, carrying "keep the two in step" as an instruction - which is exactly the
arrangement that does not keep two things in step. Measured: editing the shipped
gate alone to `winner in ("loop", "peak")` left every suite green while the
watchdog went blind for the duration of every peak event. The row whose whole job
is to force whoever lands peak shaving to come and change an expectation did not
do that.

The expression is now `watchdog.loop_is_following()`, called by main.py and by
the test. One definition, two callers, same reasoning as `disagrees()`: the copy
that drifts is always the one in the harness doing the grading.

⚠️ AND EXTRACTING IT WAS NOT SUFFICIENT - the drift proof still came out green
after the extraction, which is why it was worth running rather than assuming. The
realistic P_PEAK row has peak shaving asking for MORE than the loop, so the
magnitude term rejects it whatever `winner` says and masks the term under test. A
second row with peak asking for LESS makes `winner` the only deciding term. With
that row, widening the shipped gate fails two checks; without it, none.

Also enforcing the write ordering that was documented and not asserted: swapping
the two `store.set` calls passed everything. A store that fails the second write
now proves the property the ordering exists for - a crash mid-trip leaves a
reason with no latch, never a latch with no explanation. Swapping them now turns
that check red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 22:59:46 +02:00
glenn schrooyenandClaude Opus 5 cc4e88490b SAFETY-02 review: a latch that a restart clears is not a latch
BLOCKER. `auto` was persisted and `sign_wd.tripped` was not, so reconstructing
the Controller against the same store - which is what a container restart does -
brought it back ARMED, into the same fault, unattended: tripped=False, banner
back to NOT READY, and it resumed commanding -1000 then -2000 W before
re-tripping 180 s later. "Restart the add-on" is the first thing an operator
looking at a FAILSAFE banner and a 0 W inverter will try, and under a supervisor
that restarts a crashed add-on it is a loop of three-minute wrong-direction
bursts. DOCS.md's "it does not clear itself" was false across a restart.

`sign_tripped` and `sign_reason` are Store keys now, beside the maintenance
state and for the reason store.py's own docstring gives. Written the moment the
trip happens - reason first, flag second, so a crash between the two can never
persist a latch with no explanation - restored in Controller.__init__, cleared
only by the operator's start. The regression is covered: reverting the restore
fails seven checks in test_watchdog.py.

Review follow-ups, no behaviour change:

- The arming gate is tested against the REAL arbiter. It is the decision the
  ticket turns on and it was asserted in three comment blocks and verified by
  nothing - every case passed `following` in as a literal. Twelve claim
  configurations now derive it from resolve(), including the redirect case
  `winner == "loop"` alone would have got wrong and the `lo` limit that raises
  magnitude without flipping sign.
- The MQTT `status: "failsafe"` value is tested. The check that claimed to cover
  it restated the line above it and never touched the payload.
- The worked example described a config that does not ship: at the defaults
  maintenance_charge_w 2500 against max_w 2000 is an arbiter contradiction and
  0 W, not a -2500 W charge. Rewritten at 1500 W. The contradiction is a real
  pre-existing bug - a default cheap-window maintenance charge silently never
  happens - filed separately and deliberately not fixed here.
- Forward hazard recorded at the gate: `winner == "loop"` disarms the watchdog
  under any higher-priority SET, and a peak-shaving strategy at P_PEAK IS
  grid-following in sign, so it would blind this for every peak event.
- disagrees() is public so the replay harness stops carrying its own copy of the
  predicate it grades.
- held_s takes the clock instead of reading one.
- The two limits are now each stated in both places rather than one each.
- The no-false-positive argument is tied to control.py's freeze permitting the
  command to cross zero; under the shrink-only encoding control.py records as
  rejected, this would false-trip on a load step.
- Measured figures corrected: 93.4 % / 4212 of 17280, per-cluster
  0.3/0.9/10.5/0.0, and the near-circularity of those figures is now stated
  rather than leaned on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 22:33:15 +02:00
glenn schrooyenandClaude Opus 5 9aed43990b SAFETY-02: a loop can be fresh, tracking and inside its rating and still push the wrong way
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, reaching a commanded -14 768 W against a
±5000 W inverter. app/watchdog.py compares the emitted setpoint against the
authoritative P1 figure and, when they oppose each other with both over 300 W
for 180 s continuously, files a failsafe SET claim at P_SAFETY, commands 0 W and
latches there until an operator starts control again.

Validated against that day rather than against an argument: sim/replay_sign.py
in the infra repo drives this module over the committed captures of both
2026-08-20 and the healthy 2026-08-23 and reaches 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: 0.3 / 0.9 / 10.5 / 0.0 % sign
agreement inside them against 92.9 % healthy.

Three decisions the ticket could not make:

- 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, and 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.
  The gate tests the emitted number, not the winning claim's name, because a
  limit claim can let the loop "win" and still replace its value.
- The trip LATCHES. Commanding 0 W removes the command side of the comparison,
  so the disagreement always clears the moment the trip works.
- sign_disagree_w at or above max_w is refused at startup. The arbiter never
  lets a command exceed max_w, so such a threshold leaves the watchdog incapable
  of firing while the status page still reports it armed.

What it does not catch, said out loud: an inverter that ACTS opposite to its
command moves the meter further the same way, leaving command and meter in
agreement. That is the saturation freeze's job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 21:51:35 +02:00
glenn schrooyenandClaude Opus 5 aae74d7571 SAFETY-12 review: name the safety claim that could not fire, and fix two figures
Review fixes.

A. test_loop.py takes 73 s, not the ~35 s claimed in three places. That number
is the whole defence against a future developer deleting a slow test, so it was
understated by 2x in exactly the place it matters. Corrected - and it is now
~95 s (measured 93 s), because this commit adds the scenario below rather than
trimming windows to make an inconvenient number smaller. Both call sites carry
an instruction to re-measure if the windows change.

B. "242 checks" was test_p1's own count quoted as the total for all five
suites. The real figure is 395 across the five, plus 14 here.

And the finding this PR should have led with. stale_input_s and the
`inputs missing -> 0 W` claim both live INSIDE cycle(), so pre-fix, on a meter
whose value happened to repeat, that claim could not fire at all. Constant
meter, SoC entity dying at t=4 s, one binary:

  pre-fix   final target 220.0 W, reason 'loop -> 220 W'
  post-fix  final target   0.0 W, reason 'safety -> 0 W'

The old controller held 220 W through the whole input outage with its status
line still reading loop -> 220 W, and layer 1 does not cover that 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. The pre-fix
behaviour was not "holds a stale command safely"; it was a failsafe a repeating
meter switched off. Asserted in test_loop.py, both halves.

Three points of reasoning tightened, none of them wrong before:

- the re-tune is unreachable by construction, not merely by the reset: the
  combined cycle rate is max(arrival rate, 1/floor), never the sum, and 1/15 s
  is strictly slower than the ~1/5 s arrival rate. That holds only while the
  floor exceeds the meter's update interval - and the schema minimum of 5 s is
  exactly the reference meter's interval. Flagged for validate.py, not fixed
  here.
- the ~11 s lower bracket is not only a dropped poll: a 5.0 s poller against a
  4.97 s meter beats, so about every 14 minutes the poller steps over an update
  entirely. Reaching 15 s takes a beat-skip AND a failed poll.
- the "freshness is decided upstream" guarantee is transport-conditional. On
  `meter_source: off` the grid figure comes straight from hass.number() with no
  age gate. Running the full law is still right; the claim is narrower than it
  was stated.

The 15 s default is not independently justified - it rests on the firmware's
30 s ingest veto and moves if that ever does. Said beside the constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 21:14:58 +02:00
glenn schrooyenandClaude Opus 5 8cc9438aec SAFETY-12: record the six-minute observation, not just the unit check
test_loop.py proves the property in 35 s. This is the same three scenarios run
for six and three real minutes each, on one binary, and it is what AC2 and AC3
were actually asked for: 1 cycle before vs 24 after on a constant meter with the
command stuck at 220 W against a 350 W import, and byte-identical write
sequences on both the converged and the normally-changing meter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 20:48:49 +02:00
glenn schrooyenandClaude Opus 5 96a808a057 HANDOVER: run every suite before deploying, and say why one is slow
The pre-deploy command listed three of the five suites; test_validate and
test_p1 were never added when they arrived. Adds those and test_loop, with a
note that test_loop's ~35 s is the cost of proving a wall-clock property about a
loop that sleeps 1 s a tick rather than a sign something is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 20:32:00 +02:00
glenn schrooyenandClaude Opus 5 88cd97cc7a SAFETY-12: the loop thinks on a clock, not only when the number moves
run_control() ran cycle() only when the meter value differed from the previous
poll. A meter that repeats a value therefore ran no control cycles at all -
while the heartbeat kept re-emitting the last command and the firmware kept
putting it on the wire every 2 s. Layer 1 stayed fed and every indicator read
healthy, so a command computed from an old reading went on being executed
through whatever the load did next. TEL-05 measured it: 70 s at a literally
constant 350.0 W, 14 arrivals, zero cycles.

cycle_floor_s (default 15 s) is a deadline on the GAP between cycles, reset by
every cycle whichever trigger fired - not a second scheduler. On a healthy meter
it never elapses, so the cycle rate and the commanded sequence are unchanged;
test_loop.py asserts that by driving the real loop twice on one binary (a huge
cycle_floor_s reproduces the old behaviour) and diffing the writes.

15 s is bracketed: above the ~11 s worst gap on a merely-unlucky healthy meter
(~5 s arrivals, 1 s detection, one dropped poll retried on the next tick), below
meter_max_age_s and the firmware's 30 s ingest veto, and equal to stale_input_s.

A floor cycle runs the full law. Freshness is already decided upstream - past
meter_max_age_s the reading is None, which is a P_SAFETY 0 W claim - and the
integrator advances per cycle, not per second, so bounding the gap bounds the
accumulation.

Not a freeze detector: nothing here decides whether a repeated reading is true.
DOCS.md's "accepted deviation" passage is qualified rather than deleted - the
window is now bounded at 3 x cycle_floor_s instead of open-ended, and its
"nothing accumulates while it is stalled" argument is called out as having
covered windup and not staleness. Same correction in control.py's comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 20:29:16 +02:00
glenn schrooyenandClaude Opus 5 547d796c5d CFG-01 review: widen the rule, refuse the firmware-veto case, fix the stop footgun
Review findings, all confirmed in code before changing anything.

THE RULE DID NOT AUTHORISE ITS OWN BEST REFUSAL. The stated test was
"makes a safety mechanism inoperative, or makes a stated limit untrue",
and a blank setpoint_entity does neither - layer 1 pinning the inverter
at 0 W is the failsafe WORKING, as validate.py's own text admitted. The
real justification is a second, separate category: the add-on is inert
and indistinguishable from healthy. Stated as (a) and (b), with each
refusal tagged, and with both halves explicitly bounded - (a) is not
"any two numbers that could disagree", (b) is not "anything degraded".
Left implicit, the gap is where trivia-creep comes back in.

meter_max_age_s ABOVE THE FIRMWARE'S VETO IS NOW REFUSED. The schema
allows int(5,300); the firmware's threshold is a compile-time
`max_age_s: "30"` and it trips layer 1 at `p1_age_s >= 30`. At 120 the
add-on believes a 119 s reading is fresh and keeps commanding while the
board has already vetoed and is holding at 0 W - healthy meter, running
controller, nothing moving. Structurally identical to the max_w case,
and unlike max_w it is settable on this install, so the headline
behaviour finally has a live demonstration. That firmware file's own
comment says a lower bound on this "belongs in CFG-01, not here".

Only ABOVE is refused: below the veto the add-on gives up first, which
is fail-safe and visible on its own status page. Scoped to P1 being
enabled, because meter_max_age_s governs nothing otherwise.
FIRMWARE_INGEST_VETO_S cannot be imported from a compile-time YAML
substitution, so it carries a note on the single direction in which it
can go stale - and a warning that TEL-06 is editing `ingest_stale`, so a
matching integer may stop meaning a matching rule.

stop= WAS A LIVE FOOTGUN. It defaulted to None, which made a Controller
that cannot stop itself the DEFAULT one. On the late-refusal path that
meant: log the banner, return, and leave the process alive forever with
no control loop and no exit - the exact "inert but looks healthy"
failure this module exists to refuse. Now required and keyword-only, so
the broken shape does not construct; test_p1.py's five call sites pass
an Event.

That whole path had no test and no live demo. It has one now: it drives
run_control() against a device that only reports a narrower range once
the loop starts, and asserts it refuses, signals, returns, and writes
NOTHING - plus the converse, that an agreeing device leads to real
writes. This also covers the max_w composition that cannot be reached
through the Supervisor on the live install.

max_w <= 0 is refused. Schema-blocked in production, but
validate_startup_config() is the simulation suite's entry point too, and
a non-positive max_w inverts the arbiter's supervised bounds into a
contradiction that reads as a safety trip rather than a typo.

Verified live 2026-08-28 (backup goodwe_controller-cfg01r2-1787940076):
meter_max_age_s 120 refused with a message naming 120, 30, and THE
FIRMWARE WINS; no traceback; add-on restored byte-exact and running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 20:05:10 +02:00
glenn schrooyenandClaude Opus 5 fc9bd89dca CFG-01: refuse to start on a configuration that cannot be honoured
main.py used to read the setpoint device's own +-5000 W rating, notice a
configured max_w above it, log one WARNING, and run anyway with
write_setpoint() quietly clamping. The operator's stated ceiling was never
the real ceiling and the only evidence had scrolled off the log. specs
§1.5 is explicit that such a config is refused at startup, not survived at
runtime.

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 exactly three things, all sharing one
property - continuing makes a safety mechanism inoperative or makes a
stated limit untrue:

  1. max_w outside the setpoint device's own rating.
  2. No setpoint_entity: 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.
  3. A meter_source that can never produce a reading. These were already
     caught, but they only DISABLED ingestion, which leaves grid None
     forever: permanent inputs-missing, 0 W, and a status page
     indistinguishable from a healthy meter nobody has fed yet.

Those last predicates move into p1.validate_meter_options(), which
build_source() also calls - one definition, two callers, not two
competing validators. ha_dsmr with half its entities and mqtt_p1 with a
blank topic gained the check the other two transports already had.

Deliberately not refused, because untidy is not unsafe: integrator_max_w
above max_w (the arbiter still bounds the wire to +-max_w, so the cost is
unwind latency - warned), a missing soc/batt entity (fail-safe and
already a red NOT READY banner), a setpoint entity that has not appeared
on the Core API yet (ESPHome is routinely late; re-checked in
run_control() once the real rating arrives), and a thin meter_poll_s
margin.

A refusal is legible as one: a banner saying in words that this is
configuration and nothing is broken, numbered problems, no traceback, and
exit 78 (EX_CONFIG).

Verified on the live add-on 2026-08-28, not by reading the code: a blank
setpoint_entity and a meter_poll_s equal to meter_max_age_s each stopped
the add-on with the banner and no stack trace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-28 19:29:35 +02:00
glenn schrooyenandClaude Opus 5 0422127cab HANDOVER said 0.2.0; the running add-on is 0.4.0
Two versions stale. 0.3.0 shipped SAFETY-04 and TEL-01, 0.4.0 shipped TEL-04
and TEL-05 and changed the meter transport to homewizard_local - so a reader
taking this line at face value would look for a meter path that is no longer
how this system reads the grid.

Found in the 2026-08-27 backlog audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-27 23:55:37 +02:00
glenn schrooyenandClaude Opus 5 80ebb96e7d Two comments asserted an entity id that has never existed
mqtt.py said the P1 age entity "must be exactly sensor.p1_sample_age_s,
because SAFETY-01's firmware watchdog subscribes to that literal id", and
main.py:340 repeated it. Both are wrong, and wrong in the direction that
disarms a safety layer.

On HA 2026.8 object_id in a discovery payload does not determine entity_id.
HA composes it from the device name plus the entity name, so this one is
sensor.goodwe_p1_sample_age. That is what the firmware subscribes to, that is
what exists on the live system (verified 2026-08-26), and the firmware's own
comment records that sensor.p1_sample_age_s never existed on any HA.

The hazard is not the stale name itself, it is that two shipped artefacts
disagreed about a safety-critical id while sounding certain. A reader who
trusted these comments would have "corrected" the firmware to a name that
resolves to nothing - and the failure is silent, because has_state() simply
stays false forever and the ingest guard never arms. The firmware calls that
out as the shape that FAILS SILENTLY AND LOOKS CORRECT.

The comment now says what actually determines the id, and states plainly that
renaming the device or the entity's friendly name changes the composed id and
disarms the guard. It also cross-references TEL-06, since the guard reads this
sensor's value rather than its arrival and so cannot fire for a dead publisher.

Found in a backlog audit, as the substance behind PKG-02's premise: the real
coupling between firmware and add-on is names, not versions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-27 23:48:40 +02:00
glenn schrooyenandClaude Opus 5 c70239cac2 0.4.0: the meter transports that fit this house
TEL-04 and TEL-05 merged without a version bump, so Home Assistant would not
have offered the update - the same trap DEPLOY-01 caught at 0.2.1, recurring
because merging a feature does not touch the version.

ha_signed reads one signed entity. homewizard_local polls the meter's own API,
where every HTTP response is a genuine arrival - the property the firmware
watchdog needs, and the one HA cannot provide for a repeated reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:31:54 +02:00
glenn schrooyenandClaude Opus 5 08f17f65dd TEL-05: read the meter, not Home Assistant's opinion of the meter
sensor.p1_sample_age_s measured time since the value CHANGED, not since the
meter REPORTED. Home Assistant fires no state_changed for a repeated reading and
exposes no arrival signal at all - proven on the rig three ways and confirmed
against the live house, where ten repeated meter values left last_reported
frozen every time. Real captured data in sim/scenarios/ has inter-change gaps of
42.2 s and 97.0 s, and the firmware watchdog thresholds that age at 30 s, so it
would have commanded 0 W on a perfectly healthy meter.

meter_source: homewizard_local polls GET /api/v1/data. Every HTTP response is a
genuine arrival. Same pipeline as ha_signed - only the arrival mechanism changed.

The decisive evidence is the STEADY case, not the frozen one: hwsim served a
literally constant 350.0 W for 70 s - the exact state where the HA path recorded
samples: 0 across 45 s - and this transport took 14 arrivals with the age never
exceeding the poll cadence. Reproduced independently by the reviewer against the
real hwsim, and again by the tester at 14/14.

179 -> 242 checks. Twelve mutants from the reviewer and six from the developer,
every one reddening a NAMED check rather than aborting the suite.

WHAT THIS DOES NOT DO. The ticket asked for two things that cannot both be true:
that the age reset on every HTTP response including an unchanged value, AND that
a frozen meter show a climbing age. A frozen meter still answers 200 OK - it IS
arriving - so no arrival detector can do both. That acceptance criterion was
wrong and was written by the PM; the developer refused it rather than quietly
building half of it.

Freeze detection is delivered separately as unchanged_s on the energy registers,
which the local API exposes and HA never did: 70.2 s frozen against 10.0 s
steady. Deliberately unthresholded, because at the converged -10 W this
controller aims for, a 1 Wh register needs about six minutes to move while the
power figure legitimately repeats - any threshold would rebuild the false-trip
cycle at the target operating point. It is surfaced on the status line so a
human can read it, which is what makes leaving it unthresholded defensible
rather than a dodge.

So the flash covers meter SILENCE, not meter DISHONESTY.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 21:27:13 +02:00
glenn schrooyenandClaude Opus 5 4d41b0a79e TEL-05 review follow-ups: show unchanged_s, name a timeout, keep the poll task alive
Four follow-ups on the reviewed and approved TEL-05 work. Additive; no shipped
behaviour changes except the two failure paths below.

1. unchanged_s had no operator surface. DOCS.md told a reader "the transport
   tracks it as unchanged_s" and there was nowhere to look: main.py built the
   transport, scheduled run(), and never read the object again. The status page
   now shows it on the healthy P1 line. Still NOT thresholded and NOT folded
   into the age - that refusal was reviewed and upheld, because at the converged
   -10 W this controller aims for a 1 Wh register needs ~6 minutes to move, so
   any limit false-trips at the target operating point. The whole argument for
   leaving it to a human requires the human being able to see it.

2. The "equivalent mutant" note on the content_type guard was wrong, and the
   comment is downgraded to say so. web.Response(text=...) defaults to
   text/plain, so the fake meter CAN serve valid JSON under the wrong mimetype.
   Test added; shipped behaviour was already correct.

3. A timed-out poll logged an empty reason: str(asyncio.TimeoutError()) is "",
   so the status page read "last error:" and then nothing, on a hung meter, at
   the moment the battery had just gone to 0 W. Falls back to the class name.
   Note str(err), not `err or ...` - an exception object is always truthy.

4. submit() sat outside the try in poll_once() and run() had no except, so a
   raise would kill the poll task permanently and SILENTLY - safe (the age
   climbs, the controller commands 0 W) but indistinguishable from a dead meter.
   Both wrapped; poll_s is already the retry cadence, so no backoff.

Also a comment at the parse_homewizard range(phases) slice: a 3-phase meter
configured as 1-phase understates the capacity-tariff figure. Filed separately,
not fixed here.

242 checks in test_p1.py (236 before, 6 new). test_control 55, test_arbiter 18,
test_maintenance 21, all untouched and green. Each new check proved non-vacuous:
six mutations, six named reds, no suite aborts, sources restored byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 21:17:36 +02:00
glenn schrooyenandClaude Opus 5 98109a9b91 TEL-05: read the meter, not Home Assistant's opinion of the meter
A fourth meter_source, `homewizard_local`, polling a HomeWizard P1's own
local API (GET /api/v1/data) instead of watching an HA entity.

The point is the age sensor. sensor.p1_sample_age_s is FW-01's watchdog
input, and on every transport we had it measured "time since the value
CHANGED", not "time since the meter REPORTED". Home Assistant offers
nothing better: a repeated reading emits no state_changed, advances
last_reported on neither serialiser, and state_reported cannot be
subscribed to at all. Measured twice - 70 s of a frozen meter on the
ENV-01 rig, and ten repeated readings against the live house. 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, so the age sensor would
have commanded 0 W on a perfectly healthy meter.

Here every HTTP response is an arrival. The meter answered, now, with
its current reading; whether the number moved is not consulted. Five
identical readings are five arrivals.

Reuses TEL-01's pipeline rather than restructuring it: same split_signed
sign convention as ha_signed, same make_sample, same ingest stamping,
meter_max_age_s, clock-recomputed age, plausibility bounds and the §20
unsigned-decode rejection. A failed or timed-out poll submits nothing,
so it is a missing reading - never 0 W - and does not reset the age.
meter_poll_s (default 5 s, the meter's own rate) is checked against
meter_max_age_s once at startup, like the ha_signed entity ids.

⚠️ An arrival stamp cannot see a FROZEN meter, and no arrival detector
can - one answering 200 OK with a stale number is arriving. The local
API does expose what HA never had (the total_power_*_kwh registers stop
advancing) and the transport tracks it as `unchanged_s`, but it is
deliberately not folded into the age and not thresholded: this
controller regulates grid 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 that would rebuild
the false-trip limit cycle at the exact operating point we aim for.

test_p1.py 179 -> 236 checks. Still defaults to off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 20:51:23 +02:00
glenn schrooyenandClaude Opus 5 ad9c5772a4 TEL-04: a meter source for the meter this house actually has
TEL-01 shipped ha_dsmr and mqtt_p1. Neither fits the installed meter - a
HomeWizard P1 publishing one signed figure, where ha_dsmr requires two unsigned
registers and refuses negatives. So sensor.p1_sample_age_s could not be produced
here at all.

ha_signed is a subclass of HaDsmrSource overriding only _wanted() and build(),
so ingest timestamping, staleness, the clock-recomputed age, plausibility bounds
and unavailable-is-never-zero are inherited by construction rather than copied.
A reviewer traced every inherited member and confirmed nothing in the base
assumes two entities.

122 -> 179 checks. Sign convention asserted against real captures in
sim/scenarios/: -5710 W at 13:46 local under full sun, +775 W at midnight,
verified to the timestamp by two people independently.

WHAT THIS DOES NOT DO, documented in DOCS.md, the docstring and the CHANGELOG
rather than discovered later: the age it publishes measures time since the VALUE
CHANGED, not since the meter reported. Home Assistant exposes no arrival signal
for a repeated reading - proven on the rig against a frozen meter (no
state_changed, last_reported advancing on neither serialiser, state_reported
rejected outright) and confirmed independently against the live house, where ten
repeated values all left last_reported frozen. So this entity must NOT yet be
thresholded by the firmware watchdog. ha_dsmr has the same blind spot and
escapes only statistically, because a DSMR telegram moves several entities.

The fix is to read the meter's own API, where every response is an arrival.
That is TEL-05, and it is FW-01's real gate.

Two equivalent mutants are known and recorded: the per-phase split, documented
at the site, and the incomplete-phase-set guard, which is cosmetic - both paths
return False, one via an extra log line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 18:00:42 +02:00
glenn schrooyenandClaude Opus 5 1b343da8e3 DOCS: say plainly that ha_dsmr and mqtt_p1 have never seen real hardware
No meter in this installation uses either transport. Both were written to
specs.md 5.2's assumption that a Belgian P1 exposes two unsigned registers,
which the meter actually fitted here does not - it is the HomeWizard P1 that
ha_signed reads. Their only coverage is test_p1.py and an end-to-end test
against a fake Home Assistant.

Deliberately not called "experimental". That word says the design is
unfinished, which is not the defect and is vaguer than the truth; these are
complete and reviewed, they have simply never had a real telegram through
them. The failure this note is guarding against is a future session debugging
a meter problem, treating those two paths as proven, and looking elsewhere.

Placed where the mode is chosen rather than in a footnote, because the choice
is the decision it should inform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 17:35:11 +02:00
glenn schrooyenandClaude Opus 5 e663e10245 TEL-04 review: unshadow the helper, validate config at startup, and record
what the rig proved about the age sensor

Review findings 1, 3, 5 and 6. Finding 2 is deliberately untouched - it is
its own ticket.

3. `built` was rebound at test_p1.py:816 by `built = build_source(...)`,
   silently disarming the build() wrapper for anything appended below it.
   Renamed to `sel`. Reproduced the reviewer's failure before fixing:
   appending a check that calls built() after that line gives
   `TypeError: 'HaSignedSource' object is not callable` and aborts at 163 of
   180; with the rename the same probe reaches 180 and passes.

5. DOCS.md now states the "length must equal meter_phases" constraint that
   config.yaml already carried, plus what leaving the list empty actually
   costs: on the surveyed reading the phases carry 2769 W of import while the
   connection nets 187 W, so the tariff quantity is understated ~15x.

6. build_source now checks the ha_signed wiring once at startup instead of
   once per telegram: a blank p1_net_entity, or a phase list whose length
   disagrees with meter_phases, logs an error and disables ingestion. Both
   otherwise fail in the single way indistinguishable from a healthy source
   nobody has fed yet - no samples, a climbing age, the watchdog holding the
   battery at 0 W, and nothing in the log.

1. THE AGE SENSOR. Measured on the ENV-01 rig against the real HomeWizard
   integration, meter frozen via hwsim's `?fault=freeze` seam (cleared in a
   finally:, rig verified restored):

     - websocket state_changed for the meter over 70 s : 0
     - last_reported advanced (REST serialiser)        : no
     - last_reported advanced (websocket serialiser)   : no
     - subscribe_events(state_reported)                : rejected,
       "Event filter is required for event state_reported"

   So Home Assistant exposes NO arrival signal for a repeated reading, and
   the proposed fix - stamp from last_reported via subscribe_entities - is
   not available. subscribe_entities listens only to EVENT_STATE_CHANGED, and
   as_compressed_state carries no last_reported at all.

   The age is therefore "time since the value changed", which on ha_dsmr is
   mostly harmless (a telegram moves several entities) and on ha_signed is
   not: one entity means a healthy meter under a flat load is
   indistinguishable from a dead one. Recorded loudly in DOCS.md, in the
   HaSignedSource docstring and in the CHANGELOG, with the measured 42.2 s
   and 97.0 s gaps from our own capture.

   meter_max_age_s is deliberately NOT widened. The two conditions produce an
   identical signal, so a larger number does not separate them - it only
   chooses which of the two errors you get, and it would disarm the watchdog
   for a genuinely dead meter as well. The honest fix is an arrival stamp the
   meter itself provides.

test_p1.py: 174 -> 179 checks, all green. Other three suites unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 17:34:37 +02:00
glenn schrooyenandClaude Opus 5 632be44f6c TEL-04: make a missing build() guard fail legibly instead of aborting
Review point from the non-vacuity run. Deleting build()'s "is the net entity
cached at all" guard makes build() raise KeyError rather than return False.
That still failed the suite, but by aborting it with a traceback at whichever
check ran first - a red that costs the next person ten minutes deciding
whether the suite is broken or the code is.

New `built()` helper in test_p1.py wraps build() and turns an escaping
exception into a returned value, so the comparison against True/False fails
by name. Applied only to the ha_signed section; TEL-01's own checks are
untouched.

Mutation 4 before: 0 named checks red, aborted at check 123 of 174.
Mutation 4 after:  2 named checks red - "nothing cached yet builds nothing"
and "an unavailable entity does not build a sample" - all 174 reached.

Still 174 checks, all green, and the other nine mutations are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 17:01:33 +02:00
glenn schrooyenandClaude Opus 5 8b51a51e20 TEL-04: a third meter_source for a single signed entity
TEL-01 shipped ha_dsmr and mqtt_p1, and neither can read the meter that is
actually fitted here. The house has a HomeWizard P1 exposing ONE signed
entity, sensor.p1_meter_active_power (+ import, - export); ha_dsmr wants two
unsigned registers and refuses a negative one outright, which is every
exporting telegram. So sensor.p1_sample_age_s could not be produced at this
site, and FW-01's watchdog needs it - measured, not theoretical: the house P1
went 51.1 s and 36.2 s without a state change overnight, both past
meter_max_age_s 30, so without the age sensor the watchdog would false-trip
the battery to 0 W.

Adds meter_source: ha_signed, reading p1_net_entity (and optionally
p1_phase_net_entities in L1..L3 order for the capacity-tariff peak). The
derivation is split_signed(), sitting next to make_sample's subtraction for
the same reason it does - the moment a user is asked to write two template
sensors that split a signed value, the sign convention is back in unreviewed
YAML underneath a safety input, which is exactly what TEL-01 removed.

The transport is a subclass of HaDsmrSource overriding only _wanted() and
build(), so every rule TEL-01 established is inherited rather than
re-implemented: ingest timestamping, meter_max_age_s, the clock-recomputed
sensor.p1_sample_age_s republished ~1 Hz, the plausibility ceiling, the
"prime the cache from get_states but never build a sample out of it" rule,
"a reconnect emits nothing", and unavailable/unknown treated as a MISSING
reading and never as 0 W.

Defaults to off. An existing install is unaffected until it opts in.

test_p1.py: 122 -> 174 checks. Includes an end-to-end run of the new
transport against a fake Home Assistant websocket, and the sign convention
asserted against real captured readings from
sim/scenarios/ha-p1_meter_active_power-2026-08-{20,23}.json (-5710 W at
13:46 local under full sun is export; +775 W at midnight is import).

Non-vacuity: ten mutations of the new rules, each applied alone and reverted
byte-identical. Nine turn the suite red. The tenth - splitting the per-phase
signed values rather than passing them through - is an equivalent mutant,
because make_sample subtracts the two lists again and does not sign-check
per-phase figures. That is recorded in a ponytail: comment at the site rather
than left for the next reviewer to rediscover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 16:44:22 +02:00
glenn schrooyenandClaude Opus 5 c24bc0a011 Pin LF, because the deployment target is a Linux container
core.autocrlf=true gave the checkout CRLF .py and .yaml. run.sh happened to be
LF, which is the only reason a plain copy would not have produced a "bad
interpreter" failure on the add-on's entrypoint.

0.3.0 was deployed by extracting from git with autocrlf forced off and verified
byte-identical to the blobs before copying. This makes that the default rather
than something the deployer has to remember.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:01:44 +02:00
glenn schrooyenandClaude Opus 5 80402c978f DEPLOY-01: 0.3.0, so the update is installable at all
release/1.0 carried SAFETY-04 and TEL-01 but still declared version 0.2.1 -
identical to what is installed and running on the house. Home Assistant keys
add-on updates off the version string, so the update would never have been
offered.

Two files. No code, no option defaults. What reaches the house at these
defaults is SAFETY-04's control law alone, and it carries a 4,928-case
equivalence proof against the previous law at integrator_max_w: 0. TEL-01 is
inert until meter_source is turned on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:52:47 +02:00
glenn schrooyenandClaude Opus 5 6c980e87b0 DEPLOY-01: bump version to 0.3.0, changelog for SAFETY-04 and TEL-01
Fixes the version collision noticed while planning DEPLOY-01: release/1.0
still carried version 0.2.1, identical to what is already running on the
live system, so Home Assistant would not have offered the update at all.

- config.yaml: version 0.2.1 -> 0.3.0 (minor: TEL-01 adds a feature,
  SAFETY-04 changes the control law's internals)
- CHANGELOG.md: 0.3.0 entry for SAFETY-04 and TEL-01, in the existing voice

No code under app/ touched, no option defaults changed. Verified:
meter_source: off, integrator_max_w: 0, target_grid_w: -10 all unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-25 10:33:17 +02:00
glenn schrooyenandClaude Opus 5 505a847d85 TEL-01: P1 ingestion, with the derivation and the sample age the EMS owns
Evidence at sign-off: 122 checks in test_p1.py, 14 mutations all red with the
tree restored byte-identical, and an end-to-end run of the HA transport against
a fake Home Assistant websocket server with a real auth handshake. Age
semantics verified live rather than from fixtures - a real 2.2 s sleep with no
telegram arriving, age climbing 2.2004 s.

The deliverable that matters beyond this ticket is sensor.p1_sample_age_s:
recomputed against a monotonic clock and republished ~1 Hz rather than stamped
per telegram, so a meter frozen at a constant value - which pushes no state
change and therefore emits nothing - still shows an age that climbs. SAFETY-01's
firmware subscribes to it and trips on has_state() && state >= max_age_s.

Two blockers on the way, both of which would have shipped. With meter_source
off - the default, chosen for zero regression - the age was published anyway
and climbed without bound, which would have crossed max_age_s within half a
minute and pinned every installed inverter at 0 W. And a reconnect emitted a
synthetic sample that reset the age, hiding an outage from the watchdog that
exists to catch it, contradicting the module's own docstring while a test
asserted the violation.

Not verifiable without hardware, and not claimed: real DSMR entity ids and
units, whether a real P1 MQTT bridge matches the documented strict schema, the
0.35 s debounce against real telegram timing, and MQTT reconnect against a real
broker.

Known limits, both documented and filed as SAFETY-12: mqtt_p1 cannot detect a
frozen bridge that keeps republishing, and a value-frozen meter stops the
control loop cycling at all - the latter pre-existing and affecting the legacy
meter_entity path today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:17:34 +02:00
glenn schrooyenandClaude Opus 5 f498d5fa54 SAFETY-04: bound the integrator independently of the output
Evidence at sign-off: 55 checks in test_control.py (24 on release/1.0),
independently reproduced. 108,031 failsafe-release combinations swept across
both grid directions, 2,500 randomised carried-i_w trajectories at 200 ticks,
300 repeated-meter-value stall scenarios - zero anomalies. The historical
runaway regression uses the real 10.3 numbers and goes red when the protection
is removed.

Two rejections on the way. The first cut deadlocked the loop at small |i_w| and
let the accumulator run 50% past the rail, which ADDED windup this codebase
never had - the accumulator used to be the post-clamp command, so it could not
exceed max_w by construction. The second deadlocked at i_w == 0.0 in the export
direction, found by sweeping the boundary after three reviewers had each
covered the same half of it.

The finding worth keeping: deleting the integrator freeze outright failed 0 of
55 checks, because the integrator bound truncated to exactly the value the
fixture asserted. A neighbouring mechanism was standing in for the one under
test. The same pattern turned up again in the output clamp. test_control.py now
carries the audit table and its invariant - every mechanism in compute() must
be noticed by at least two checks when deleted.

Deliberately not met as literally written: the detector counts cycles, not the
10 s the AC specifies. compute() is clockless and cycle() runs only on a
changed meter reading, so there is no wall-clock window at all. Documented in
the code and in DOCS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:17:18 +02:00
glenn schrooyenandClaude Opus 5 4bd659c499 TEL-01 review fixes: stop the age sensor tripping installs that have no meter
T-1, and it was a fleet-wide trip to zero. publish() emitted p1_age
unconditionally, and published_age_s counts from P1Ingest.__init__ when no
sample has ever arrived. With meter_source defaulting to off, every existing
install would have published sensor.p1_sample_age_s climbing without bound; the
ESP32 does `has_state() && state >= max_age_s` and forces the layer-1 failsafe,
so each of them would have pinned its inverter at 0 W within 30 s. Exactly the
opposite of the zero-regression the off default was for. The key is now omitted
from the payload AND from MQTT discovery when P1 is off, so the entity does not
exist at all - which is the status quo, and what has_state() is testing for.
The predicate is one function, is_enabled(), because the grid reading, the task
start and the discovery announcement have to agree or this comes back.

T-2, connect no longer manufactures a sample. get_states returns whatever HA
currently holds, which after a Core restart is a RestoreEntity value of unknown
age; stamping it with ingest_ts=now reset the age and reported a fresh meter
that could have been dead for an hour. run()'s own docstring already said a
reconnect must emit nothing - the code disagreed with it, and a test asserted
the violation. The cache is still primed, so the first real state_changed
builds a complete sample; the age just stays honest until one arrives.

T-3, gaps are no longer filled with the last held value. The averager held a
sample forward across any interval, so a meter dying at 5 kW and returning ten
minutes later credited 5 kW x 600 s to the capacity-tariff accumulator - a
fabricated peak on a permanent record. The hold is capped at max_age_s: past
that the stretch is walked so block boundaries still land correctly, but
nothing accumulates and elapsed does not grow, which is what finally makes the
comment about a gap dragging the billed average down true. Same threshold for
control and billing: a reading too old to steer by is too old to bill by.

T-4, the out-of-order/duplicate guard is covered. It was untested, and the
reason is worth recording: the obvious assertion passes without the guard,
because the negative interval is separately refused by the covered > 0 test.
What the guard prevents is the timestamp REWIND, which only shows up one sample
later as a re-integrated window. The test now goes one sample later.

T-6, DOCS was wrong about latency. meter_max_age_s and stale_input_s stack, so
meter death to 0 W is 45 s and not 30. Documented as a table with both clocks.

Also documented the T-5 asymmetry rather than papering over it: the age
measures arrival, not change, so a stuck MQTT bridge republishing its last
telegram still looks fresh. Correct on ha_dsmr, not detectable on mqtt_p1
without a change-detector. Written up as a known limit.

Writing the T-1 test caught a second defect in the test itself: it recorded
only MQTT topics, and object_id lives in the payload, so "the age sensor is not
announced" had been passing for the wrong reason.

test_p1.py: 99 -> 122 checks. 14 mutations run, all 14 red, files restored
byte-identical - including one per fix above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 23:16:31 +02:00
glenn schrooyenandClaude Opus 5 f47f1f0129 TEL-01: P1 ingestion, with the derivation and the age the EMS owns
A Belgian P1 meter publishes two UNSIGNED registers, not one signed figure.
Until now the add-on asked the installer to bridge that gap with a template
sensor, which put the sign convention of the whole control loop in a text box.
This moves it into the EMS: net = import - export, derived once, in one place,
with a test that fails if anyone inverts it.

Two transports behind one contract, chosen by `meter_source`: the HA WebSocket
subscribing to the DSMR integration's entities, and MQTT on a configurable
topic. Everything downstream reads P1Ingest, so switching is a config edit.
`meter_source: off` is the default and keeps the existing meter_entity path,
so no installed system changes until it opts in.

The other half is the timestamp. Every accepted sample is stamped at ingest
with a monotonic clock, `meter_max_age_s` is applied to it, and the age is
published as sensor.p1_sample_age_s for the ESP32's stale-input watchdog. That
entity is recomputed against the clock every second rather than only when a
telegram lands, because HA pushes state only on change: a meter frozen at a
constant reading emits nothing and looks, to anything watching the value,
exactly like a meter that has died. The age tells them apart.

Deliberately absent: any fallback to an inverter-side power figure. The
inverter's own AC power correlates 0.998 with battery power and 0.09 with the
real meter, so failing over to it means regulating against your own output.
A gap stays a gap - a reconnect emits no synthetic sample, and a rejected
telegram never resolves to 0 W or refreshes the timestamp.

Quarter-hour averages are time-weighted over clock-aligned blocks rather than
a mean of samples, so a cadence change cannot bias the capacity-tariff figure,
and only offtake is accumulated so a quarter of pure export averages to 0 kW.
Per-phase import is kept separately: on an unbalanced three-phase load the
phase sum and the connection net are different numbers, and only one of them
is billed.

test_p1.py: 99 checks, runnable with a bare interpreter and no meter. Includes
an end-to-end run of the HA transport against a fake Home Assistant websocket.

Stacked on SAFETY-04; nothing here touches control.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 23:16:31 +02:00
glenn schrooyenandClaude Opus 5 680461c9bf SAFETY-04: record the coverage audit as a comment, with its invariant
The audit found a dead mechanism twice, both times a clamp standing in for the
mechanism under test, so the technique has to survive this ticket. Not as a
script: the only cheap way to automate it is to key on source lines, that goes
stale silently, and a green audit that has quietly stopped testing anything is
this ticket's own failure mode one level up. Automating it properly would mean
decomposing compute() to make its statements separately addressable, which is a
refactor of the most safety-critical function in the repo for the benefit of
test tooling.

So it goes in as a comment block next to the checks it describes, carrying the
commit it was measured at, the thirteen figures, and the invariant with the
teeth in it: every mechanism must be noticed by at least two checks when it is
deleted, and adding a mechanism means re-running the audit. A comment cannot go
stale-green, because it never claims to be running.

Also recorded: fixtures must sit clear of every rail they are not testing,
which is the rule both misses violated; and the `python -B` / clear-pycache
discipline, with the reason (CPython invalidates on source mtime-in-seconds
plus size, so a same-second same-size rewrite reuses stale bytecode) and the
reason it casts no doubt on the figures (the error is one-directional, so every
number is a lower bound).

Figures are the lead's independent reproduction. I re-measured the one that
differed: the detector is 11 for `saturated_now = False` and 10 for the weaker
`frozen = False` form, so the table names the form.

test_control.py stays at 55 checks, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 23:07:58 +02:00
glenn schrooyenandClaude Opus 5 389d9ecd6d SAFETY-04: stop the clamps standing in for the mechanisms under test
AC 3 - "integration freezes while saturated" - had no non-vacuous test.
Deleting the integrator freeze outright failed 0 of 55 checks: the two checks
that name it used a fixture at max_w 2000 with the integrator bound following
it, so a wound value was truncated back to exactly 2000 and the assertion
passed on the clamp instead. Fixture lifted to max_w 5000, clear of every rail.
Deleting the freeze now fails 2.

Then swept the whole function for the same pattern, one mechanism at a time:
delete it, count which checks notice. It found a second instance - the OUTPUT
clamp. `clamped to max_w` and `clamped to -max_w` were both satisfied by the
integrator bound truncating first, so removing the output clamp failed only the
reason-string check. Those two fixtures now set integrator_max_w above max_w so
the mechanism they name is the binding one; the output clamp goes from 1 failure
to 3.

Every mechanism in compute() is now caught by a check that names it: freeze 2,
integrator clamp 6, bound-follows-max_w 4, output clamp 3, slew 4, output freeze
2, deadband 5, quantise 2, detector 10, duration 2, counter reset 6, grid bias 3,
None-seeding 6. No mechanism at zero.

Method note: the audit disables bytecode caching. Rewriting control.py inside
one second leaves a stale app/__pycache__ entry and silently under-reports -
it under-reported one mutation as 2 failures where the true figure is 6.

test_control.py stays at 55 checks, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 22:59:29 +02:00
glenn schrooyenandClaude Opus 5 53d301b920 SAFETY-04: exactly zero is its own case in the freeze tie-break
`min(moved, i_w) if i_w > 0 else max(moved, i_w)` files i_w == 0.0 under
rising-only, so the first push toward charging from exactly zero was blocked
permanently - the S-1 deadlock again, mirrored in sign. main.py resets i_w to
exactly 0.0 on every stop and every reseed, so it is a normal state.

Zero is now handled explicitly and both directions are allowed: nothing is
wound, so "may not wind further" has no referent, and a first step from zero is
bounded by the gain, the output clamp and the slew limit like any other.

Measured before the fix, at i_w == 0.0 and frozen: 12 800 of 25 920 ticks held
the integrator and 8 304 of those changed the emitted command, worst case
abandoning a 2 kW charge into a 4 kW export. Note this is NOT the same as the
reported symptom: at prev_w == 0 the command holds at 0 W either way, because
the output freeze forbids starting a charge while saturated, and that rule is
release/1.0's and unchanged. There is now a test asserting it deliberately.

Tests. The durable part is a property rather than more points: over 13 041
frozen states the integrator may be held ONLY by a correction pushing it
further from zero on the side it already sits, and any other hold fails. Both
signs at exactly 0.0. Mirrors added everywhere the suite tested one direction
of two - freeze wind/unwind while charging, i_w=-100, the export-direction
runaway, the negative clamp and slew.

DOCS: the cycles-vs-seconds deviation is now written down as a deviation - the
"> 10 s" criterion is not met as literally written, a cycle is one CHANGED
meter reading, and there is no guaranteed wall-clock window.

test_control.py: 43 -> 55 checks, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 22:49:01 +02:00
glenn schrooyenandClaude Opus 5 7123aa00a4 SAFETY-04: revive the clamp reason, and compare reasons in the sweep
`want = i_w` after the integrator bound, so at the default limit == max_w the
output clamp can never fire and `reason == "clamped"` had become unreachable.
Observability only today - nothing gates on the string - but SAFETY-03 exists
to alarm on exactly that engagement, so its hook was dead before it was built.

The integrator bound now reports "i-clamped", and that is the signal SAFETY-03
must watch: it is the one that fires on a default install. "clamped" stays
reachable for a configuration that lets the integrator run above the rail,
where both fire and the output clamp - which describes the value actually
emitted - is the one reported. Two names because the two events want different
alarms: the loop winding, versus a command that came out over the rating.

The real fix is the second half. The equivalence sweep compared
(target_w, sat_count), which is how a dead reason survived 3024 cases. It now
compares (target_w, sat_count, frozen, reason) and it catches this defect:
dropping the emit turns it red. Deliberate rename aliased explicitly, so any
OTHER reason divergence still fails.

Result of adding reason to the tuple: 105 of 3024 cases differ, and every one
of them is the i-clamped/clamped rename. Zero value divergences, `frozen`
included. Nothing else surfaced.

test_control.py: 41 -> 43 checks, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 22:29:10 +02:00
glenn schrooyenandClaude Opus 5 e46175559b SAFETY-04 review fixes: the freeze deadlocked, the bound was too loose
S-1. The frozen branch admitted a correction only if it shrank |i_w|. That is
unsatisfiable for BOTH signs of error whenever |correction| > 2*|i_w|, i.e.
whenever the integrator is near zero, so the loop stopped moving and the freeze
could never clear - it clears when the inverter tracks, and not tracking is
what saturation means. Measured: 0 W held into a 2 kW import indefinitely,
where release/1.0 recovers on the next cycle. Re-encoded as the same asymmetric
rule the output freeze has always used: may not wind further in the direction
it is already pushing, may fall, cross zero or reverse. Same interpretation,
an encoding that cannot deadlock.

S-2. integrator_max_w defaulted to 1.5x max_w, which ADDED windup: in
release/1.0 the accumulator was the post-clamp command and could never pass the
rail. Default is now "follow max_w" (config 0 = unset). Measured on the 4000 W
load-drop sim, first cycle after the drop: 1000 W at the new default, 1800 W at
3000. DOCS row inverted - the useful direction is below max_w, and the 14 768 W
anecdote is a vendor controller, not evidence about this code.

S-3. The claim that i_w=None preserved release/1.0 exactly was false, because
the S-1 gate ran regardless of seeding. It is true again, and now asserted
rather than asserted-about: 3024-case exhaustive comparison against a
transcription of the old law, over both freeze states, both signs and either
side of the deadband. Added the carried-i_w convergence/overshoot sim that the
shipped configuration was missing.

S-4. Cycles are distinct meter values, not seconds: cycle() runs only when the
meter reading changes, so the window has no wall-clock bound. Comment and DOCS
corrected; the stall is detection latency, not a windup hazard, because the
same condition stalls the whole loop.

test_control.py: 33 -> 41 checks, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 22:13:55 +02:00
glenn schrooyenandClaude Opus 5 37bac79ad8 SAFETY-04: clamp the integrator, not only the output
The loop was in velocity form: the accumulator WAS the commanded power, so
"clamp the integrator" and "clamp the output" were the same line of code and
could not be set apart. This makes the accumulator an explicit carried value
(`i_w`), bounds it with its own `integrator_max_w`, and keeps the output clamp
where it was. Killing either mechanism now still leaves the other holding -
which is the point of the ticket, and what the new regression test asserts.

Freeze semantics: while saturated the integrator may unwind but not wind
further. A strict freeze would strand the command at whatever it reached,
because the condition that releases it is the inverter tracking again, and not
tracking is exactly what saturation means.

The integrator is re-seeded from the arbiter's actual output whenever the loop
did not get what it asked for, so entering any failsafe (all of which resolve
to 0 W) zeroes it, and the first cycle after release does not dump the stale
period as power.

test_control.py: 24 -> 33 checks, all passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 21:44:05 +02:00
glenn schrooyenandClaude Opus 5 5184a2cfc2 Rest the meter just below zero, not at zero
The deadband is a one-way ratchet: any resting point inside it holds
indefinitely. Import and export are separate registers on the meter, so a
loop resting at +14 W bills 0.34 kWh/day while behaving perfectly.

target_grid_w (default -10 W) moves that residue onto the export register.
Worst billed rest point drops from 15 W to under 5 W. Behaviour is unchanged
at target_grid_w: 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:20:07 +02:00