Commit Graph
136 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 1ad4d3a5bb PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0
checks / unit (pull_request) Successful in 8m5s
One runtime merge since 0.11.0 (cccb2fa): TEL-12 (#116), a new optional
list option `pv_entities` beside `pv_entity`. Option surface 76 -> 77, so
a minor bump. config.yaml version and a closed `## 0.12.0` section only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:59:44 +02:00
adminandClaude Opus 5 b11bea70d3 PKG-17: the SAFETY-57 correction pointed at the wrong paragraph
checks / unit (pull_request) Successful in 8m13s
Self-review. The note added to `### SAFETY-57` said the reading rule it is
qualifying is "two paragraphs up"; it is in the paragraph directly above - the
same one the note follows. A changelog that misdirects a reader to a paragraph
that is not there is the small end of exactly the defect this cut is fixing
three sentences of elsewhere, so it is corrected rather than left.

Text only, one line pair, still two files in the branch's diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 19:01:35 +02:00
adminandClaude Opus 5 cfafdffaed PKG-17: cut 0.11.0, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1
checks / unit (pull_request) Successful in 8m0s
Two files, and no third. config.yaml 0.10.1 -> 0.11.0; CHANGELOG.md gains
`## 0.11.0` and three corrections to sections below it.

A MINOR BUMP, NOT A PATCH. TEL-11 adds an option - `pv_entity`, 75 keys -> 76 in
`options:` and `schema:` together - which is the same test 0.8.0, 0.9.0 and
0.10.0 were cut on. Nothing here can refuse a stored blob: the key is `str?`
with an `options:` default of "", validate.py does not mention it, no default
moved and no enum widened, so a blob that starts the add-on on 0.10.1 starts it
on 0.11.0.

THE TABLE HAS TWO ROWS AND THE WINDOW HAD FOUR MERGES. CI-9's follow-up
(9f6b9f1, `.gitea/workflows/checks.yml` only) and SEC-2 (239e120,
`docs/RELEASE.md` + README.md) are excluded under PKG-15's rule - AUDIENCE, NOT
IMPORTANCE. Neither file is under `goodwe_controller/`, neither is in the built
image, neither is in predeploy_check.py's RUNTIME tuple, and there is no version
of this add-on on which an operator can observe either. SEC-2 is the closer call
and the section argues it in full rather than asserting it: it is the more
consequential ticket of the two - it is why this release is a pull request
rather than a push to master - and it is still a procedure for whoever cuts a
release, not something the person deciding whether to press Update can act on.
Both are named in the section with pointers to PR #102 / checks.yml and PR #108
/ docs/RELEASE.md.

WHAT THE ENTRIES LET AN OPERATOR DECIDE. SAFETY-56: behaviour byte-identical
(6030175ed2ab..., reproduced in the CI image over 2390 capture reads, a 66-cell
grid stepped 120 times and six driven worlds), three surfaces named with the old
and new wording side by side - the STALE_READING line quoted onto the action log
and status page, the SAFETY-37 ERROR log line, and the readiness row that
asserted a false fact about a named entity. TEL-11: unset is byte-identical in
BOTH the control and telemetry documents, set is byte-identical in the control
document only; the three TEL-11 rows of that table were re-measured in the CI
image on this tree (control 846a10a5... twice, telemetry a443a6ce... / 4e6d1cb2...).
It is stated plainly that pv_entity is not a control input and does not change
what the loop does, and that absence publishes `unknown` rather than 0.

THREE CORRECTIONS BELOW THE NEW SECTION, WITHOUT REWRITING HISTORY. The 0.10.1
preamble said SAFETY-56 was open; the SAFETY-57 entry said its accusatory
diagnosis half was "deliberately untouched"; the 0.10.0 preamble left SAFETY-56
with no pointer while SAFETY-57 had one. All three quotations are what the
0.10.1 image actually prints and are left standing - a reader on 0.10.1 has to
recognise them - each now scoped to 0.10.1 and pointed at `## 0.11.0`.

VERIFIED. test_version_gate.py part 11 all four: 11a one contents table and the
slicer closed (302 lines under '0.11.0'), 11b 0.11.0 == config.yaml, 11c all
three RETIRED_PROVIDERS ids named - scoped to a pre-0.10.0 crossing and pointing
at `## 0.10.0`, which is where the evidence is - 11d still exactly one
`## Unreleased` below, PKG-08's, untouched. 31/31 in the CI image, checks.yml's
exact command, purged tree, `:ro`.

Not deployed and not installed. Shipping is a separate pull request from
release/1.0 into master, per docs/RELEASE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 18:50:13 +02:00
glenn schrooyenandClaude Opus 5 eb88646899 PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0
checks / unit (pull_request) Successful in 8m0s
The Supervisor decides an update exists by comparing config.yaml's version
string with the one the box reports. master and release/1.0 both said 0.10.0,
so SAFETY-57 - a deadlock that parked the reference battery for ~13 min on
2026-09-21 while the house exported 2.4 kW - had no way of reaching the box
that has it. A patch bump is the delivery mechanism, not bookkeeping.

- config.yaml: 0.10.0 -> 0.10.1. Nothing else in the option surface moves:
  no key added or removed, no default changed, no enum widened, no rule added
  to validate.py, so a blob that starts on 0.10.0 starts on 0.10.1.

- CHANGELOG.md: a new `## 0.10.1` section, holding SAFETY-57's entry moved out
  of `## 0.10.0`. The entry opened by saying it was misfiled and that whoever
  cut the next version owned moving it; this is that cut, so that paragraph is
  replaced by what an operator actually needs in order to decide - what the
  deadlock does (command parked at 0 W, batt reading gated to MISSING, escape
  condition unreachable from inside the state), how to recognise it (setpoint
  stuck at exactly 0 W with the battery register flat, a STALE line and no
  clearing line) and that a restart of the add-on clears it.

- PKG-16 proper: `## 0.10.0`'s preamble still claimed 0.10.0 "has never been
  built or pulled anywhere" and that the reference install was on 0.7.0. Both
  stopped being true on 2026-09-20 ~22:18 CEST, when 0.10.0 was installed as a
  repository add-on (slug 68285ac7_goodwe_controller) and began regulating a
  real battery. The section's own bump argument was explicitly conditional on
  that; the condition has fired, so the argument is retold as history and the
  section is closed. This text is what the Supervisor shows beside Update.

Also fixes, in this file only, the three non-blocking documentation defects
raised on PR #104 - issuecomments 1445, 1446 and 1447:

- 1446: the live episode is 790 s, not 710 s. Re-derived rather than rescaled:
  at >= 790 s the committed 16-day replay gives 16 of 93, not 17.
- 1447: the BMS keep-alive pulse was an inference stated as a measurement. No
  committed capture identifies one, and on this episode 0x0518 never moved at
  all. The measured claim replaces the firmware story.
- 1445: "neither consumer can reach a verdict inside one window" is universal
  as written and graded on a fixture that puts the obligation back on every
  release. It now carries the qualifier the reviewer asked for, and names the
  unbounded case and why the shipped one-inverter roster cannot reach it.

The same three defects also sit in app/freshness.py, test_freshness.py,
DOCS.md and the DECISIONS note. They are deliberately NOT touched here: this
PR is config.yaml and CHANGELOG.md only, and none of them is worth delaying a
live safety fix. SAFETY-56 and SAFETY-58 are likewise untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:22:03 +02:00
adminandClaude Opus 5 43c144b8d9 SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
checks / unit (pull_request) Successful in 7m58s
SAFETY-37's freshness gate reports a battery-power register as MISSING when it
stops following its own command, and a missing input is "command 0 W and keep
commanding" (§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" - and at 0 W nothing is asking the register to be anywhere.
The state had no exit reachable from inside it, and the log line promised the
opposite in the same sentence that announced the 0 W.

REPRODUCED FROM THE WIRE, NOT MODELLED. scenarios/safety57-2026-09-21/ is 3840
verbatim frames off the RS-485 pair, read from the passive sniffer, decoded by
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
mode->sign convention is graded off the capture, not trusted). The box was
running 0.10.0, so the 0 W in the trace IS the add-on's own claim:

  10:51:24.7  0x0518 reaches -3 W. Pack at 11 %, the BMS refuses the discharge,
              the inverter idles. THE REGISTER IS HEALTHY AND TRUTHFUL.
  10:51:29.7  the command reaches +1060 W - 800 W of travel
  10:52:00    the conjunction arms. Inverter.batt becomes None
  10:52:21.7  the site commands 0 W, on the wire
  ...         0x0518 does not change value for 850.0 s. The setpoint does not
              change value for 790.0 s. The house exports 2.4 kW throughout
  ~11:04      a human restarts the add-on. That is what ends it

AND IT HAD BEEN ESCAPING BY LUCK. Replaying the committed 16-day loop capture
through the shipped conjunction finds 93 trips whose wait for the BMS
keep-alive pulse that cleared them has a median of 143.4 s, a worst case of
21440.1 s, and 17 of 93 at or beyond this episode's 710 s. That distribution
belongs to the battery's firmware, not to this code.

THE CARD'S OPTION 2 ALREADY HELD AND IS NOT THE BUG. _grade() has required
|standing| >= MOVE_W since SAFETY-37. The trip was at +1060 W and the stuck
state is at 0 W; they are different moments, and an entry-side rule cannot
reach a state entered legitimately and then persisted. Nothing in the
conjunction is weakened here.

THE RULE, SAFETY-13's SHAPE ONE MODULE OVER - which latches get an exit, not
how long the block lasts:

  the verdict is withdrawn when the obligation that produced it has been gone
  for RELEASE_S, and the register is then re-graded from scratch.

RELEASE_S is BATT_OBLIGED_S, 17.0 s - the obligation window already derived in
the file (6.0 plant + 10.0 publish + 1.0 read), taken at the FLOOR of its band
for the mirror of MOVE_W's reason. Which half is safe is the opposite of
SAFETY-13's: being wrong long is the failure being fixed, being wrong short
costs one bounded re-grading window. Below 17 s the release pre-empts the
cheaper exit - the register answering by itself - and that is graded
behaviourally, not asserted.

The whole parked interval is now stale_input_s + RELEASE_S = 32 s. On the live
episode: telemetry back at 10:52:39 instead of 11:05:35, with 0x0518 still
answering the same -3 W. RED BEFORE (release_s=inf, a graded counterfactual and
not a second implementation), GREEN AFTER.

WHAT IT COSTS, AND IT IS NOT SMALL. A register dead for good is re-graded for
ever: over 1201 reads the held number reaches a consumer on 630 (52.5 %)
against 35 (2.9 %) before. Payable only because one window is at most 35 s and
the two consumers hold for 75 s and 85 s, so neither can reach a verdict inside
one and the re-trip clears both windows. No detection SAFETY-37 had is given
back - a permanent None clears the same windows for ever too - and the command
is derived from the P1 meter, never from batt.

Two clocks, deliberately: changed_mono/unchanged_s() stay facts about the
REGISTER, graded_from/graded_s() are the VERDICT's window. Collapsing them
either lies to the operator or defeats the re-grading.

main.py's stale line had its ESCAPE CLAUSE corrected - it promised a clearance
the state could not produce - and gained a STALE WITHDRAWN edge that says in
its own words that nothing has been learned about the register. The DIAGNOSIS
half is SAFETY-56's and is deliberately untouched, with a named check asserting
it is still there so that rewrite has to be deliberate.

Battery: 22 mutations, 21 red, 1 argued equivalent, 0 unexplained survivors.
Two findings kept in DECISIONS: _release()'s move_w test swapped for
unchanged_s SURVIVED at +5 B because every fixture rested at exactly 0 W (habit
11, closed by a 0/100/300 W fixture); and the code shipped a habit-22 comment
claiming the _release/_grade order was load-bearing, which the battery
falsified - the two act on mutually exclusive predicates, so the order is not
observable. MUTATION.md gains habit 23 for a mutation that ran, fingerprinted
clean, and was a no-op on the fixture because `0.0 or x` is x.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one
file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-21 12:33:56 +02:00
adminandClaude Opus 5 5aba220046 PKG-15: fold TEL-10 and SAFETY-48 into 0.10.0, so the version names its contents
checks / unit (pull_request) Successful in 7m53s
0.10.0 was cut by PKG-14 on 2026-09-16 and three tickets have merged into
release/1.0 since. Two of them change the built image - one is a CONTROL LAW
change and one adds three MQTT entities - and neither is described anywhere in
this file. Tagging that tree today would stamp a number on contents the
changelog does not account for.

FOLDED RATHER THAN CUT AS 0.11.0, AND THE ARGUMENT IS CONDITIONAL.
0.10.0 has never been built or pulled anywhere: the reference install is on
0.7.0 (PKG-14's own predeploy run records `deployed on the box : 0.7.0`), the
repository carries exactly one tag, `v0.2.1`, and no release. So the string
`0.10.0` does not yet name a tree anyone is running, and the PKG-06/PKG-07
no-op that forbade re-cutting `0.9.0` does not reach it. Cutting 0.11.0 would
burn a number on a release that never left the repository. The condition is
checkable and the section now says so: run against a box already reporting
0.10.0, `tools/predeploy_check.py` refuses with THE DEPLOY WOULD BE A NO-OP and
names the runtime commits since the bump. `config.yaml` stays at 0.10.0, so
`test_version_gate.py` 11b still finds the two sides equal.

SAFETY-48 IS THE ENTRY THAT MATTERED MOST TO GET RIGHT, AND WHAT IT PROMISES
IS LESS EXPORT - NOT A LOWER BILL. The measured result is export 39.61 -> 34.07
kWh over the committed 16-day replay, 14 % of the replayed figure, falling at
every plant and tuning variant swept (~4 % to ~16 %, median near an eighth).
The section says in its own heading and its first line that the control law
changed, and it says PLAINLY that the billed quarter is not a claim: at every
plant lag <= 5.0 s - including the one that best reproduces the recorded export
- release/1.0's worst quarter is ALREADY 3434 W and this changes it by exactly
zero. The 3739 -> 3434 W headline exists at the single longest fitted lag, and
the lag is not identified by the recording. The costs are in the entry too: the
cap moves a saturation-frozen integrator on 1095 of 79 160 frozen ticks, 679 of
them outward; SAFETY-13's settle values moved; SAFETY-39's "the slew does not
own the over-absorption" is now false.

TEL-10 gets the three site rows, what each one is, and the two things a reader
would otherwise get wrong: the lead is NOT a subtraction of two published rows,
and `Loop reason` is not the `Why` row. It repeats neither of the two sentences
TEL-10's own review retracted - EIGHT messages stay first and the ninth,
`goodwe_ctl/availability`, is byte-identical but deliberately moves to last.

CI-9 IS DELIBERATELY NOT IN THIS FILE, AND THE OMISSION IS WRITTEN DOWN RATHER
THAN SILENT. It changes one file, `.gitea/workflows/checks.yml`, outside
`goodwe_controller/` entirely: not in the built image, not in `is_runtime()`'s
list, and not observable on any version of the add-on. This file is the release
note the Supervisor shows beside the version number, and a row here has to be
something the person deciding whether to press Update could act on. The note
above SAFETY-48 says so and says the rule is about audience, not importance.

THE STRAY `## Unreleased` AT LINE 1777 IS NOT TOUCHED, AND THAT IS A DECISION.
It is the 0.8.0-era block PKG-14 annotated: every row in it has shipped, the
annotation says which release each went to, and re-filing it is PKG-08's card.
It is also load-bearing - `test_version_gate.py` 11d pins the count of
`## Unreleased` blocks below the newest section at EXACTLY one, so deleting it
is red on the commit that does it, and so is adding a second.

TWO MEASURED FIGURES IN PKG-14'S PROSE WENT STALE AND ARE RE-DERIVED, NOT
ADJUSTED. The runtime-commit count since `86bdf49` is now 32, not 28, of which
the contents table accounts for 24, not 21; both re-run through
`predeploy_check.is_runtime()` itself. The other eight are the three misfiled
tickets (SAFETY-43 x2, SAFETY-44 x3, TEST-21 x2) and `cb3f686`, PKG-14's own
bump, which edits `config.yaml` and so counts as runtime - it was HEAD when
PKG-14 measured and is inside the range now.

PKG-14's `CLEARED, exit 0` predeploy transcript was NOT re-run and now carries
the check that says why it still holds: `config.yaml` is untouched by both
tickets; `app/validate.py` imports neither `main` nor `mqtt`, and the three
names it takes from `control` - SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S -
are byte-identical across cb3f686..HEAD.

Verified: the full suite in the CI image, `cp -a` of the tree, purged, `:ro`
mount, one file at a time, exactly the command in .gitea/workflows/checks.yml -
30/30, exit 0. No runtime file is in this diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 18:49:09 +02:00
adminandClaude Opus 5 cb3f686555 PKG-14: cut 0.10.0, and say what an upgrade from 0.7.0 crosses
checks / unit (pull_request) Successful in 7m51s
`config.yaml` 0.9.0 -> 0.10.0, and the top `## Unreleased` block is closed
into `## 0.10.0` with a preamble that names the backward-incompatible
changes and who they reach.

MINOR, and NOT because eighteen tickets landed. A stored options blob the
previous version started on is REFUSED on this one:

  * TARIFF-07 retires three `price_provider` ids; a site set to one is
    refused BY NAME at startup. Measured on the reference blob, with a
    control on an accepted id so the refusals are about the ID.
  * TARIFF-18 moves `price_taxes_eur_kwh` 0.06 -> 0.11736. A site that
    STORED 0.06 is untouched, and - the non-obvious half - a taxes period
    table beside that stored 0.06 does NOT become a startup refusal on
    upgrade, because `SUPERSEDED_TAXES_DEFAULTS_EUR_KWH` reads it as
    untouched. Measured, with the counterfactual that the rung is not
    weakened (ORES's 0.1198 is still refused).
  * SAFETY-10 adds `shadow_mode`, shipped `auto`. An existing CONTROLLING
    install does not change by one watt and a FRESH one comes up observing.
    Measured cross-revision, 8f82dd4 -> e6da478, same stimulus through the
    shipped `Controller.cycle()`: byte-identical write streams, 13/13/25
    writes. Positive control: the same driver at `shadow_mode: on` gets 13
    writes to 5000 W on the older tree and one 0 W write on the newer.

And the one a reader of the newest section alone would miss: `ha addons
update` does not walk the versions, so a 0.7.0 install meets 0.8.0's and
0.9.0's rules at the same moment as 0.10.0's. Of those, SAFETY-39's rule 10
refuses 0.7.0's OWN shipped default `slew_w: 1000` against a 555.6 W
ceiling on a 5000 W rail. Measured red on this tree; the reference
install's `slew_w: 400` is measured green.

SCOPE, and it is drawn honestly rather than tidily. The top block's 18
entries all merged after the 0.9.0 bump `86bdf49`, so the block is closed
as-is and its `_this branch_` placeholders are resolved to merge shas.
⚠️ 0.10.0 SHIPS THREE MORE RUNTIME TICKETS THAT ARE NOT IN THAT TABLE -
SAFETY-43, TEST-21 and SAFETY-44 - and they are NAMED in the preamble
rather than moved, because re-filing this file's misplaced entries is
PKG-08. SAFETY-44 (`app/main.py`, `app/store.py`) is in the changelog
nowhere at all; that is said in both places. The second `## Unreleased`
block below `## 0.9.0` is ANNOTATED, not rewritten or emptied - UX-02 in it
shipped in 0.9.0, the other three in 0.10.0. No released section is
rewritten.

`test_version_gate.py` gains part 11: the bump and the closed section are
one act. PKG-09 refuses a deploy whose version did not move; nothing graded
the mirror failure, in which the version moves and the block is never
closed. Two sides from two files, a slicer with a synthetic positive
control run on every invocation, the retired ids read off `app/price.py` by
AST rather than transcribed, and the count of still-misfiled blocks frozen
so it can only shrink.

Mutations, all purged, tree bind-mounted :ro, backups at mktemp paths and
the revert fingerprinted:

  M1  `## 0.10.0` -> `## Unreleased`       +4 B    RED 2
  M2  version 0.10.0 -> 0.10.1             +0 B    RED 1   (ZERO-BYTE)
  M3  an id stripped from the NEWEST       +10 B   RED 1
      section only, left below
  M4  a 4th retired provider in price.py   +62 B   RED 1 here, 4 in
                                                   test_price.py
  M5  `## 0.9.0` heading lost              -3 B    RED 1
  M6  a 2nd `## Unreleased` left standing  +27 B   RED 1
  M7  the slicer never closes              -32 B   RED 3
  M8  SUPERSEDED_TAXES... emptied          -5 B    RED 2 on the evidence
                                                   instrument, plus
                                                   test_validate/test_price

⚠️ M5 WAS A SURVIVOR ON THE FIRST DRAFT. The slicer control was a sentinel
(`## 0.8.0`) that only a slicer running to EOF could reach, and deleting one
heading never gets there - ARCH-02's fifteenth instance, in a fresh costume.
11a now asserts the invariant instead: exactly one contents table per
section, which is what a lost heading breaks.

29/29 test files green. `tools/predeploy_check.py --deployed-version 0.7.0
--options <live blob>` CLEARED, exit 0, before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-16 02:48:27 +02:00
glenn schrooyenandClaude Opus 5 e3ccd9f6ea SAFETY-47: what a shadow run can show, and the watts on its status row
checks / unit (pull_request) Successful in 7m48s
Not a leak and not a write-path defect. Shadow mode's safety is intact -
the substitution, the re-seed, the authorisation gate and the clamp are
untouched. What was wrong is what three merged documents SAY a shadow run
can show you, and a number on the status row that can be 6.61x what a live
controller would command.

SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT, AND THE DOCUMENTS NOW SAY
SO. The battery is held at 0 W and the carried command is re-seeded to it,
so every measured input - the meter, the billed-quarter accumulator behind
it, the battery power, the SoC - describes a plant that did not respond. A
closed-loop defect is one whose stimulus IS that response. This is the same
property that makes shadow mode safe, so it is not a gap to close later:
the closed-loop answer comes from the closed-loop rig or from a live run.
SAFETY-10 §5 already conceded this for the grid-following loop. It was
never the loop leg's limitation; it is the general case.

THE "STRATEGIES ARE UNAFFECTED" EXEMPTION WAS TRUE UNTIL SAFETY-45 MERGED,
ONE COMMIT EARLIER. That card sizes the buy leg as min(asked, headroom)
with headroom = (900/remaining) x (ceiling - projected), and `projected`
reads the open quarter's accumulator - the meter - which is a function of
what the battery did earlier in that quarter. Measured on TEST-23 stage 3
at peak_cap_w 1500 / house 1200 / max_w 2000, one hour at 5 s:

  SAFETY-45's limit cycle  NOT visible. 28 onsets live vs 1; grid
                           -455.5..+3200.0 W vs flat at the 1200 W house;
                           guard engaged 410/720 cycles vs 0/720.
  SAFETY-46's SoC churn    NOT visible. The SoC moves 0.000000000 pp.
  TARIFF-14's wrong hour   VISIBLE, and it is the only one: which hour, at
                           what price, clearing what spread - identical on
                           720 of 720 charging cycles at a binding cap.

The exemption narrows to exactly what survives: WHICH hour a strategy picks
and WHY are unaffected; HOW HARD it trades is not. The peak guard's ceiling
and reasons are worth reading from a shadow run; its ENGAGEMENTS are not.

THE NUMBER IS CAVEATED, NOT RE-COMPUTED, AND THAT IS THE DECISION. The
arithmetic is not wrong - 900/remaining is correct for a meter genuinely
under the ceiling, and in shadow the meter genuinely is. It is the right
answer to a question nobody asked. Both surfaces now introduce the watts as
what would have been commanded ON THIS CYCLE, in every state, and carry
shadow.SIZED_FROM_METER whenever a measured billed quarter sized them.
Rejected, and recorded in the decision doc: re-computing it (the recorded
decision would no longer be the decision the shipped path reached, while the
arbiter, clamp, SAFETY-28 gate, log and MQTT all ran on the real one - a
second controller everywhere except the wire); recording nothing (blanks the
row exactly when arbitrage is trading, and the log's edge key is the watts);
and recording the unsized ask beside it (a second number, no warning, and at
the shipped 3500/2000 both figures read 2000 W).

"UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED. Charging
only adds offtake, so the shadow size is >= the live size with EQUALITY at
the start of every quarter. Pairwise over 3600 charging cycles in five
configurations: 0 below, 730 exactly equal. The first draft said "smaller",
which would have been false on every one of those 730.

AND THE PREDICATE IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. arbitrage's
own sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the
headroom came out below the ask - in a shadow run, the START of the quarter,
where the two runs agree to the float. By the end, 900/remaining has
multiplied the headroom past the ask, the trade is not capped, the sentence
is silent, and the record is at its most wrong. A caveat keyed on it would
be quietest exactly where it is needed loudest.

SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED, FOR TWO
INDEPENDENT REASONS, AND CLOSING EITHER ALONE CLOSES NOTHING. Its fixture is
peak_cap_w 15000 against a 350 W house, so the guard never engages and the
sizing is inert - the harness's own comment says so. AND every identity row
reads the FIRST charging sample, where at elapsed=0 the projection IS the
held level and the two runs agree to the float even at a cap that does bind.
Measured: widening the fixture alone leaves that comparison passing. That
second reason is a new shape for MUTATION.md's habit-4 list - an INDEX, not
a fixture - and it is recorded there as the twelfth instance. §6 is re-taken
at a binding cap across the whole run: 716 of 720 charging cycles differ,
headroom 300.0..54000.0 W in shadow against 299.9..307.8 W live, recorded
-2000.0 W against a live -302.4 W.

THE GRADER IS test_arbitrage.py PART 3d, beside SAFETY-45's own closed-loop
regression and built on the same s45_loop() harness, so the runs differ in
one argument. Three lines stand in for _write_unit()'s substitution and
_shadow_settle()'s record and re-seed, marked as a transcription;
test_clamp.py part 8 remains the check on the shipped placement. The section
carries its own precondition into every check: a mutation that stopped
either run trading made it raise on its first min(), kill the file and
report ZERO reds - FCST-06's shape, driven rather than imagined.

AND THE FIX'S OWN FIRST DRAFT CARRIED THE SAME DEFECT. Deleting the caveat
from the action-log line left all 29 files green, because the sentence was
an f-string inside _shadow_settle() and the only suite that drives it uses a
meter_source:off fixture where no arbitrage decision exists. It is
shadow.log_line() now, for the reason words() is a function.

14 mutations, no survivors; M13 and M14 are +0 B and were confirmed on disk
by sha256 with every run purged. M14's first form was aimed at the wrong
direction and correctly survived - min->max makes the shadow record larger,
which is still an upper bound. All 29 check files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-15 01:26:57 +02:00
Claude 8fa4febfef SAFETY-10: the whole decision path runs and nothing reaches the inverter
checks / unit (pull_request) Successful in 7m58s
Every optimiser defect this project has found was invisible from outside
while it was happening, and each was paid for at the meter first: SAFETY-45's
28 charge onsets an hour against a binding peak_cap_w, TARIFF-14's wrong
quarter-hour for 45 minutes in every 60, SAFETY-46's 598 Wh spent to gain
310 Wh. There was no way to watch what the optimiser WOULD do without letting
it do it.

`shadow_mode` runs strategies, arbiter, clamps and guards, records the
decision with its reason, and commands nothing.

⚠️ IT WRITES. It writes 0 W, on the ordinary heartbeat, through the ordinary
single writer. The inverter holds its last command forever - it has no meter
timeout - so "commands nothing" implemented as "stops writing" would leave
whatever was standing when shadow mode came on standing for as long as it was
left on, under a page saying nothing is being commanded. That is the failure
this repository keeps finding. Refreshing 0 W is also what
firmware/goodwe-master.yaml's 30 s stale-input watchdog requires of us; going
quiet would keep that board tripped for the whole run, which is an alarm being
used as a mode.

⚠️ IT IS NOT A SECOND WRITE PATH. No claim, no transport, no arithmetic of its
own. It is a substitution inside Controller._write_unit() - the single choke
point every write already reaches - in the same form as the three above it,
and it is the LAST of them, so SAFETY-28's gate and SAFETY-03's clamp still
grade the number the decision path produced.

⚠️ THE DEFAULT IS `auto`, NOT `true`, AND THAT IS THE CARD'S REAL CONTENT. The
Supervisor composes options as {**config.yaml defaults, **stored user
options}, so a new key with a new default reaches EVERY EXISTING INSTALL on
the next update - including the one regulating a real battery. A boolean
default-on would have stopped it silently. There is no mechanism by which one
key can default differently for a new install and an existing one, so
"default-on for new" is a value the add-on RESOLVES: `auto` is shadow while
control is STOPPED and live once it is STARTED. A fresh install ships
`auto_start: false` and comes up observing; an install that is controlling is
started and does not change by one watt.

THE FAILSAFES. Sign watchdogs (SAFETY-02/30): INERT - they grade
Inverter.standing_w(), which shadow mode holds at 0 W. Fed the shadow decision
they would disagree with a meter that never saw it and LATCH A TRIP THAT
SURVIVES INTO LIVE OPERATION AND A RESTART. Clamp (SAFETY-03), write gate
(SAFETY-28), meter freeze (SAFETY-18), peak guard: OBSERVE. Circulation
(SAFETY-31) and delivery (SAFETY-42): observe and may legitimately latch,
because their evidence is measured power. The operator stop is the one
deliberate change: enforced at the wire rather than as a P_SAFETY claim, which
would otherwise make every recorded decision read "stopped".

⚠️ RECORDED LIMITATION: _shadow_settle() re-seeds the carried command to what
stands on the wire, which makes the exit safe by construction - and therefore
the loop leg's shadow decision is ONE SLEW STEP, not a trajectory. A shadow
run answers "what would it command now", not "what would it have done next".
The strategies are unaffected; all three defects above are visible from one.

Graded on the writes and on the rendered surface, never on the flag: 22
mutations, no survivors, including one that lets a write escape and one that
goes silent instead of commanding 0 W. Closed loop on TEST-23 stage 3's rig,
same hour twice: live drives the pack to -2000 W and SoC 50.000 -> 62.331 %;
shadow commands 0 W throughout, SoC stays 50.0 exactly, and the arbitrage
decision is identical in action, instant, signed power, reason and arbiter
winner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-14 17:25:43 +02:00
glenn schrooyenandClaude Opus 5 e1b9254626 SAFETY-45: a price-driven charge is SIZED by the capacity guard, not switched by it
checks / unit (pull_request) Successful in 7m42s
With arbitrage armed at the shipped defaults, arbitrage and the capacity-tariff
guard drove each other in a sustained limit cycle. Re-measured on TEST-23 stage
3's closed rig against 478ba93, metrics recomputed rather than taken from the
card: `peak_cap_w` 1500 / 1200 W house -> 28 charge onsets an hour, mean period
131.1 s, the grid swinging -455.5 to +3200.0 W; at the SHIPPED
`peak_cap_w: 3500` with an 1800 W house -> 8/h, with a 3000 W house -> 32/h.
The bite rule is `load + max_w > peak_cap_w`, i.e. any house over 1500 W in the
cheapest quarter. §10.2: "an EMS that oscillates is worse than no EMS, both for
hardware wear and - per §9.1 - for the peak it is trying to avoid." And it was
losing the argument it was having: EVERY billed quarter in the three affected
configurations finished OVER the cap.

⚠️ THE CARD'S STATED MECHANISM IS WRONG AND THE CORRECTION IS ON THE RECORD.
The card, and .cache/s45.md before it, say the loop is the quarter boundary
resetting the projection and arbitrage re-arming at full power. It is not. Of
the 27 gaps between the 28 onsets, 23 are 90-120 s (mean 111.5 s) and lie
ENTIRELY INSIDE one quarter; the four long gaps - 230, 240, 250, 255 s - are
exactly the four that SPAN a boundary, so the boundary LENGTHENS the interval
rather than causing an onset. The real cycle, off the recorded fields: the trade
charges at max_w while `wanted = batt_w + excess` is negative and the guard
files NOTHING; `wanted` crosses zero; the guard engages on a floor of 5.3 W;
that flips `arbitrage.guard_state()`, which cancels the WHOLE trade; the
grid-following loop - which targets grid = 0, not grid = ceiling - takes the
actuator and discharges to +1870 W, exporting 420 W; the projection collapses;
the guard releases; the trade re-arms at max_w. A binary interlock keyed on a
continuous quantity. A fix aimed at the boundary would have removed four of
twenty-eight onsets.

THE FIX IS A SHAPE THIS ADD-ON ALREADY HAD. `control.maintenance_charge_floor()`
caps a MAINTENANCE charge at "the headroom left under the site's cap", because
"a maintenance charge is the only thing this system does that is big enough and
long enough to set that peak". ⚠️ That sentence stopped being true when OPT-04
landed. `peakguard.charge_headroom_w()` publishes the same quantity off the
MEASURED guard rather than off the optional `peak_forecast_entity` - the hole
SAFETY-25 had to close in `shaping_suspended()` - and `arbitrage.decide()` sizes
its BUY leg to it, keyword-only and with no default.

NO NEW ARITHMETIC AND NO NEW CONSTANT. The number is `wanted_w` read in the
other direction: `decide()` already computes the DISCHARGE that lands the
quarter on the ceiling, and a negative one is a permitted CHARGE of `-wanted`.
⚠️ `peakguard.decide()` IS UNCHANGED - sha256(decide) = 200090d0835a3a55, 7252 B
on this branch and on 478ba93 - so 2026-08-20's anti-bang-bang rung is exactly
where it was, and tools/replay_ac3.py reproduces 190/190 guarded quarters, 110
engaged cycles, 5 blind and 878 Wh on both trees.

TWO PROPERTIES MAKE IT SAFE TO HAND A STRATEGY, BOTH SWEPT RATHER THAN ARGUED.
It is INVARIANT IN THE BATTERY - raise the charge by dW, the meter rises by dW,
so `excess` rises by dW while `batt_w` falls by dW and `wanted` does not move:
1350.0 W at every point across batt_w -5000..+2000 W on one banked quarter,
while the `excess` those runs see takes six distinct values. It is feed-forward,
so consuming it adds no feedback path around the plant. And HOLDING IMPLIES ZERO
OR UNKNOWN: across 1080 swept combinations not one ENGAGED decision answers a
positive headroom.

AFTER, same rig, same command. 1500/1200: 28 onsets -> 1, grid swing 3655.5 ->
282.0 W, energy bought 180.0 -> 285.2 Wh (+58%, because a continuous 300 W trade
beats a 2000 W trade cancelled for 57% of the hour), billed quarters
1490/1497/1509 -> 1500.0/1500.0/1500.0. 3500/1800: 8 -> 1, 3511/3536/3559 ->
3500/3500/3500. 3500/3000: 32 -> 1, 3519/3542/3557 -> 3500/3500/3500. The two
shipped-default rows buy 2.2% and 1.6% LESS energy and in exchange the worst
quarter goes from 59 W over the cap to zero - which is the ordering §10.1 asks
for, since the capacity tariff bills the month's single worst quarter.
⚠️ AND THE NEGATIVE CONTROL: at peak_cap_w 5000 with a 1200 W house, where
`load + max_w` never reaches the cap, the two runs are BIT-IDENTICAL.

⚠️ A NEGATIVE `lo` WAS CONSIDERED AND REJECTED, AND THE REASON IS IN THE CODE.
Deleting `decide()`'s `wanted <= 0` gate and filing `lo = wanted` is the tidier
answer arithmetically. It creates a new `lo > hi` CONTRADICTION against
main.py's cheap-window maintenance shaping, which files a NEGATIVE `hi`;
arbiter.py clause 2 has no notion of one limit out-ranking another, so it
resolves to 0 W - the battery PARKED in the quarter that sets the bill - and the
only way to close that suspends the maintenance charge shaping PERMANENTLY on
every site this card is about. A backoff or cooldown in arbitrage was rejected
too: both modules' design notes say no timer is invented, and the trade now
re-arms at `ceiling - load` rather than at max_w, which is what learning from a
refusal means when the refusal is a measurement.

THE REGRESSION IS THE LOOP SETTLING, NOT A UNIT TEST ON decide().
test_arbitrage.py 3c closes the loop over the shipped guard, strategy, arbiter,
control law and QuarterAverager with a modelled plant, and asserts BOTH
directions: unsized it must run away (24 onsets, -414..+3200 W, a quarter over
the cap) and sized it must settle (1 onset, 303 W swing, three quarters at
exactly 1500.0 W). The two runs differ in one argument. A third pair asserts the
card is INERT where the defect is inert.

Evidence: 29/29 green. 14 mutations, every one RED on a named check, no
survivors; two are ZERO BYTES - `min()` -> `max()` on the sizing, and the
sized-trade sentence's two operands transposed - and both are red. M13 reverts
the 2026-08-20 rung and is red on EIGHT named checks in test_peakguard and three
in test_arbitrage. Every revert verified by a tree fingerprint AFTER the revert,
which is how a defect in the battery's own harness was found: two edits to one
file, restored in list order, left the first one on disk (MUTATION.md habit 18).
test_peakguard.py part 1 also gained a None-safe format, because the mutation it
exists to catch made it raise TypeError one line later and ABORT the file -
parts 2-13 were ungraded and M13's recorded coverage was one check instead of
eight.

Not fixed, and named: TEST-23 finding 1's export overshoot at the END of a trade
(356-938 W, ~0.37 x max_w) is a different card; a house whose load ALONE exceeds
the cap still has the trade refused outright and the loop drive to grid = 0
rather than to the ceiling, and no swept pair reaches that regime; the container
leg (hadrive.py) was not re-run because test23s3-addon is deliberately stopped
as a second writer on the setpoint entity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-14 16:10:34 +02:00
glenn schrooyenandClaude Opus 5 995da777ce TARIFF-16: the injection leg stops being one number
checks / unit (pull_request) Successful in 7m42s
`price.py` modelled what a site is PAID for export as ONE scalar. The market
publishes a line, and the cards say so in their own words: Eneco Zon & Wind
Dynamisch `0,1 x BELPEX-H - 1,188` c EUR/kWh - "het injectietarief is dynamisch
en kan per uur wijzigen", identical on the January and the September 2026 cards;
ENGIE Dynamic `(B x eSpot_15) - A`; Aspiravi `0,07 x Belpex - 2`.
`price_export_formula` carries that line with its OWN coefficients.

⚠️ THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT.
`arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment
says so, citing §9.3's near-zero Belgian injection price as the reason the
economically sound trade on this market is charge-cheap-to-displace-expensive-
IMPORT. Both hours it compares are all-in CONSUMPTION prices. No trade this
add-on takes today is decided by an injection price and nothing here changes
one. What it changes is what an operator is told, and what a future
export-aware strategy rests on: OPT-06 and any export logic would otherwise
inherit a scalar that cannot represent the market, and the 6% VAT asymmetry
would be invisible in the arithmetic. test_price.py 17h parses every module
under app/ and goes red the day something calls an injection reader, so that
sentence is graded rather than attested.

FOUR PROPERTIES A SCALAR CANNOT HOLD. Its own factor (Eneco 0,102 consumption
against 0,100 injection; Aspiravi 0,07 against ~0,10 - not the consumption B
with a sign flip). A negative offset, which `price_markup_eur_kwh: float(0,1)`
cannot express at all. VAT-exemption - "injectietarieven zijn vrijgesteld van
btw" - against 6% on consumption, representable only because `vat_pct` is PER
SPEC. And a price that goes below zero with no floor, under 11,88 EUR/MWh of
BELPEX-H, which is the middle of a sunny day.

⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, AND THAT NEEDED A DIFFERENT BAND
RATHER THAN A LOWER FLOOR. `implausible_all_in()` floors the consumption leg at
+0.02 EUR/kWh of energy; applied to injection that refuses the real market in
the hours it matters most. Measured rather than argued: 17c puts ONE spec
through BOTH bands and prints that the consumption band refuses -0.03008
EUR/kWh while the injection band accepts it. The floor was not decoration - it
is what catches a card read a hundred times too small, the one unit error the
card self-check cannot see because it cancels out of a comparison between the
two sides - so the bound moves OFF the price and ONTO the coefficients, where
it is index-free and sign-free: a pass-through band (Eneco's injection leg is
1.00x, Aspiravi's 0.70x; the band is 0.10-10x) and an offset magnitude bound
(0.20 EUR/kWh against published 0.01188 and 0.02). Two rungs that can both
answer a hundredfold error, so each is graded on its REASON, each gets a
stimulus the other cannot reach with the precondition asserted as its own
check, and they are mutated TOGETHER (TEST-16).

⚠️ THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT
THAT IS STATED RATHER THAN WORKED AROUND. `price_formula` can require a `card`
block because a Belgian card prints the consumption formula, the reference
index AND the resulting price. Eneco's INJECTION formula is printed with no
price beside it. Requiring a block would make the published line inexpressible;
inventing the estimate would grade the coefficients against arithmetic done
with the same coefficients. So the block is OPTIONAL on this leg and unchanged
on the other, the self-check runs at the same tolerance wherever a printed
estimate exists, and validate.py says at startup - "its coefficients are
UNGRADED" - when it does not, naming the two bounds that ran instead. This is
TARIFF-15 demonstrating two tariffs end to end rather than three, one card
later. 17e also MEASURES the size of that gap: a factor of 0.070 where the
contract says 0.100 - 30% of every export payment - passes both bounds, because
0.70x is a pass-through somebody really publishes.

ONE PARSER, NOT TWO. `parse_export_formula()` is `parse_price_formula()` with
its OPTION NAME as an argument - TARIFF-17's move on `parse_static_profile()`,
exactly - and everything that differs between the legs is a string in one
`_LEGS` table. A second copy would be a second unit table, a second NaN rule, a
second unknown-key rule and a second `to_resolution()`, diverging first on the
23- and 25-hour days. test_price.py 17b pins it two ways: all 32 grammar names
are defined in exactly ONE module under app/, with the scope COMPUTED by
parsing every file rather than listed; and twelve refusal sentences for the new
option are DERIVED from the old option's own sentence rather than transcribed.
The four sentences that deliberately differ are named, with what differs, and
asserted to differ.

NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "";
`export_price_eur_kwh()`'s body is byte-identical, pinned on the AST rather
than on the source text; `export_price_at()` falls through to it for every
index value. 17f compares seven option dicts x five index values against the
pre-TARIFF-16 expression with `==`.

`price_export_formula` joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived
from that set, and `code_leaks()` refuses a plugin that names it with nothing
else edited.

ALSO CORRECTED HERE: config.yaml's comment beside `export_price_eur_kwh` still
read "no shipped supplier publishes a dynamic injection curve". TARIFF-07
struck that claim from app/price.py and DOCS.md and did not reach config.yaml,
where it has been shipping since.

20 mutations, every one red on a named check, ledger in test_price.py below
section 17 - including a ZERO-byte one (the two legs transposed in the
VAT-asymmetry banner: no decision moves, only the sentence) and a ONE-byte one
(`card_required=True` on the injection leg). Three run over all 29 files and
red on test_price.py alone. 29/29 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 18:41:06 +02:00
glenn schrooyenandClaude Opus 5 9c65ac2cf8 TARIFF-18: price_taxes_eur_kwh stops shipping at half the real figure
checks / unit (pull_request) Successful in 7m48s
`price_taxes_eur_kwh` shipped at 0.06 EUR/kWh with no recorded provenance -
no card, no date, no component list - and roughly HALF the true Flemish
all-in non-energy figure. It now ships at 0.11736, and the rows it is made
of are in the code rather than in this message:

  Fluvius distribution (Antwerpen)              5,35   c EUR/kWh
  Bijzondere accijns, "Alle verbruik"           4,876
  Bijdrage groene stroom en WKK Vlaanderen      1,51
  Energiebijdrage (0 EUR/MWh since 2026-08-01)  0
                                               ------
                                               11,736 c EUR/kWh = 0.11736

Eneco residential card for Flanders, September 2026, incl. 6 % VAT.
`app/price.py`'s FLEMISH_TAXES_COMPONENTS_EUR_KWH carries the rows and
test_price.py section 1b re-adds them - and also compares the total against
the card's own figures typed in the test file, so an edit that moved the
rows and the default together is still caught.

WHAT THIS IS NOT. It is not an arbitrage-correctness fix and it cannot be.
This scalar is a LEVEL component: an additive constant cancels exactly in
`max - min` and leaves the argmin where it was. TARIFF-17 shipped that proof
and it is unchanged - test_price.py 16b drives six flat scalars spanning and
exceeding the whole per-DSO range and gets the identical cheapest hour and
the identical spread to the last digit. DOCS and the CHANGELOG say so in
those words.

WHAT IT IS. A display and guard fix:

  * every number an operator reads, which at half the true value made the
    add-on look considerably better than it is; and
  * §17's negative-price guard in arbitrage.py - the one rung that tests an
    ABSOLUTE all-in price rather than a difference. It fires when
    `spot < -(markup + taxes)`, so its boundary moves -0.09 -> -0.14736
    EUR/kWh of spot. test_arbitrage.py section 2c drives both sides through
    the shipped decide() and pins the boundary from below as well.

The default is Antwerpen's real row, near the LOW end of the 0.117-0.131
per-DSO band, and that is deliberate: the error directions are not
symmetric. Too high carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal - §17's own case. Too low only
declines a discharge that would have paid.

TARIFF-17's INTERACTION, WHICH HAD TO BE DECIDED. Nothing in that rung
hardcoded 0.06; it read PRICE_OPTION_DEFAULTS, so moving the default moves
what it refuses. The Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`, so a saved blob carries the key whether it was
typed or not - every stored 0.06 became "non-default" with nobody editing
anything, and a working Walloon or Brussels site with a taxes table would
have met SystemExit(78) on the upgrade over an inert number. price.py now
carries SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) and validate.py reads a
superseded default as untouched too. The rung is NOT weakened: 0.1198 beside
a table is still refused by name, and the refusal now names both figures.

config.yaml and PRICE_OPTION_DEFAULTS moved together; CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing.
test_price.py section 0's fourth check was REWRITTEN rather than re-pointed:
it used to justify the file's own grading constants by observing that the
shipped pair was degenerate (0.06 is exactly twice 0.03). TARIFF-18 ends
that coincidence, so the sentence is no longer true and is not kept.

29/29 test files green. Mutation ledger on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 16:24:56 +02:00
glenn schrooyenandClaude Opus 5 2fac0aaf06 TARIFF-17: a price component that varies by hour has somewhere to live
checks / unit (pull_request) Successful in 7m25s
`price.py` modelled the whole non-energy part of a Belgian bill as ONE flat
scalar. This adds `price_taxes_profile`, which carries the TAXES component as
a period table, and makes `assemble()` evaluate it at each point's own instant.

⚠️ THE FLAT SCALAR WAS NOT A WRONG NUMBER, AND THIS IS NOT A CORRECTION OF ONE.
The optimiser reads exactly two quantities off a series - the spread
`max - min` over the firm points, and which hour is cheapest - and an additive
constant identical in every hour cancels out of the first and leaves the second
where it was. It is provably inert. Measured, and printed by test_price.py 16b
on every run: with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00
EUR/kWh the same 24-hour feed is cheapest at 13:00 in all five cases and its
spread is 0.1500 EUR/kWh in all five cases. What was missing is anywhere for an
HOUR-VARYING non-energy component to live. This is a missing capability, not a
bad constant.

Belgium has one. Fluvius distribution is a single rate - its tariff-card row
reads `Normaal | Uitsl. nacht`, where exclusive night is a separate physical
circuit and not a night register - so nothing changes for the reference site in
Flanders. The Walloon DSOs and Sibelga are on the same card with four columns,
`Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real money (c/kWh):
ORES 13,27 / 7,39; AIESH 15,17 / 8,21; Tecteo RESA 12,20 / 7,01; Sibelga
9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090.

⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO. The
cheap network hours are the cheap spot hours, so flattening them does not shift
the curve - it UNDERSTATES the spread and declines cycles that would have paid.
Same feed, same markup, the only difference being the taxes argument:

    flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000
                                       -> cheapest hour 13, spread 0.1500
    ORES dag 0.1327 / nacht 0.0739     -> cheapest hour 03, spread 0.1788

The regression check grades WHICH HOUR IS CHEAPEST, not the spread: an
implementation that reads the table once and adds one number to every point is
the LEVEL bucket wearing a period table - right magnitude, wrong shape - and a
spread assertion passes on it. Measured: hoisting the lookup out of
`assemble()`'s loop (+17 B) is red on six named checks in test_price.py and
GREEN in the other 28 files.

ONE GRAMMAR, NOT TWO. `static_tariff.py` already shipped the periods, the day
selectors, the tiling rule and the DST handling, so `parse_taxes_profile()` is
the same parser with the option name and the "what this number means" clause as
arguments. A second tiling rule would be a second set of 23- and 25-hour-day
bugs. test_static_tariff.py section 10 pins that two ways: by parsing every
module under `app/` and asserting the grammar is defined in exactly one of
them, and by deriving each of thirteen refusal sentences for the new option
from the old option's own sentence rather than transcribing them.

`Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee
are untouched. The new key joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is
derived from that set, and a plugin that names it is refused at `register()`.
It reaches both plugins that take taxes - `custom_formula` ({ENERGY, MARKUP})
and `generic_dayahead` ({ENERGY}) - and not `manual_csv`, which is all-in: the
table is routed through `capabilities().adds`, exactly as the scalar is.

⚠️ A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH. It is read
on the ~30 s planning path, and a table that first fails at 03:00 is a site
that stops pricing at 03:00, which §11.4 turns into a quiet fallback
indistinguishable from a healthy fixed-tariff day. validate.py refuses three
things: a table that does not tile the day; a table set beside a NON-DEFAULT
`price_taxes_eur_kwh`, because the table wins and the scalar would then be a
number the operator typed that decides nothing; and a whole card typed in
CENTS, which is how these figures are printed. That last rung is not a
restatement of the parser's +-10 EUR/kWh bound - ORES's 13,27 trips the parser,
but Sibelga's entire card, 9,39 and 7,10, is under it and would otherwise have
started as a 9.39 EUR/kWh levy. Both rungs are graded by the REASON each gives,
and mutating them together (TEST-16) moves three rows.

NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and the
scalar path is unchanged arithmetically rather than merely in intent:
test_price.py 16f reproduces the pre-TARIFF-17 expression and compares 12
capability/scalar combinations point for point with `==`.

13 mutations, every one red on a named check, ledger in test_price.py below
section 16k with byte deltas and the one equivalent mutant named. 29/29 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 15:31:15 +02:00
admin a11597e0b3 Merge remote-tracking branch 'origin/release/1.0' into TARIFF-11
checks / unit (pull_request) Successful in 7m20s
# Conflicts:
#	goodwe_controller/CHANGELOG.md
2026-09-13 13:51:35 +02:00
adminandClaude Opus 5 610725bed9 TARIFF-11: the plan's account of itself, corrected and put on the page
Four defects of one shape - the add-on's report of its own tariff plan was
wrong, always in the direction that flattered it - and one check, which is
the deliverable.

1. `TariffPlan.words` counted POINTS and called them HOURS. The live add-on
   logged `dynamic_dayahead from generic_dayahead, 132 h` for 132
   quarter-hourly points = 33 hours. Belgium moved to a 15-minute MTU on
   2025-10-01, so this was wrong every time it was printed, and NOT by a
   constant factor: a 40-hour soak measured 1.79x .. 5.33x, because §8.3's
   carried points are hourly while the firm ones are quarter-hourly.
   `words` now reports both quantities, plus the firm/carried split and the
   spacing, in FCST-04's *Price history* wording so the two rows can be
   compared.

2. §8.3's firm-pair refusal and the flat-tariff refusal did the same, and
   they reach an operator through `self.arb.explain()`. This is exactly the
   point<->hour conflation TARIFF-14 removed from `at()`, left standing in
   the prose. `price_forecast.py`'s §8.3 carry-forward log line carried the
   third instance - a carried-HOUR count over a POINT count - and DOCS
   quotes it verbatim, so it is corrected here too.

3. The plan reached no HTTP surface at all. `rows["Tariff"]` was
   `static_status()`'s 30 s-tick sentence and never saw the provider plan -
   it read 'no static profile' at all 802 soak samples on a site whose feed
   was answering. It now carries the plan's shape. ⚠️ The two sources are
   kept distinguishable: the plan half is stamped with its own age, measured
   from `tariff_at`, because it can be up to TARIFF_MAX_AGE_S (5400 s) old.
   FCST-04 pins that row to DOCS as a pair; DOCS moves in this commit.

4. `main.py`'s "if and only if" was false. `cycle()` returns at the input
   hold before `self.arb = None`, so an armed site read
   `off (arbitrage_enabled)` - naming an option that is on - and, after one
   completed cycle, showed a decision up to 15 s stale as a current one. The
   row now reads the OPTION and separates three facts; `cycle_at` and
   `arb_at` make "this cycle reached no decision" an observation.

THE DELIVERABLE IS THE CHECK. All four survived 29 green suites because
nothing graded the strings a human is shown. `test_price.py` section 12
renders through the shipped `status_payload()`, parses each number out of
its OWN SLOT and compares it against a figure computed from the fixture's
construction parameters - not from the property the row was built out of
(habit 4), and not as a substring of a sentence the same file wrote (habit
14). The fixture set is four plan shapes and the check computes and prints
the field pairs no fixture separates. The *Arbitrage* row's states are
driven through real cycles with a sensor answering None and graded by
INEQUALITY.

No behaviour changed: no threshold, refusal rung, claim or setpoint moved.

MUTATIONS: 16, no survivors, one at +0 B (the risky delta) and red. Two were
found by the battery INSIDE THIS CARD'S OWN CHECK: a -190 B survivor whose
fixture varied two things at once (habit 4), and a mutation whose red was a
TRACEBACK in an f-string operand and therefore named nothing (FCST-06). Both
closed; the table is on the PR.

All 29 test files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 13:51:05 +02:00
glenn schrooyenandClaude Opus 5 a991438e40 TARIFF-13: the coverage figure beside the peak stops resetting at a restart
checks / unit (pull_request) Successful in 7m27s
The capacity-tariff status row printed a peak read from SQLite next to a count
of banked quarters read from PROCESS MEMORY - `PeakTracker.recorded`, an `int`
set to 0 in `__init__` and counted up inside `sync()`. A 40-hour soak caught
what that means at a driven restart, on both instances independently:

  before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
          - 10 % of the 3500 W soft limit; 120 quarter(s) banked
  after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
          - 10 % of the 3500 W soft limit;   0 quarter(s) banked

Reproduced here before anything changed, through the real Controller and a real
restart - a second Controller object against the same file on disk - at 12 -> 0
with 12 rows standing in `quarter_blocks`.

The peak is durable and was never wrong. The COUNT is what says how much of the
billing month that peak covers, and 365 W over 120 quarters and 365 W over 0
quarters are different claims about the same number. Mid-month, after every
restart, the page made the weaker claim while displaying the stronger one's
value. That is TARIFF-01's own argument - an unreadable store must not read as a
zero peak, because a zero peak is disarming - one column over.

`PeakStore.banked_count(month)` asks the database, and `recorded` is a property
over it. Two more defects in the same variable fell out once it was driven, and
neither was in the ticket: it was never scoped to a MONTH (on 1 October the row
still showed September's tally beside October's empty month - measured), and it
missed the block `restore()` banks from the checkpoint, which never passes
through `sync()`.

⚠️ NOT 0, AND NOT None, WHEN THE STORE WILL NOT ANSWER. `banked_count()` returns
None and the row renders `an unreadable number of quarter(s) banked`.
`f"{None} quarter(s) banked"` was rejected because it reads as the same zero.
`0 quarter(s) banked` stays true and reachable - it is what a month nothing has
been written to says, and the two are asserted to be different text.

⚠️ THE DEDUPE IS NOT REMOVED WITH THE COUNTER. `_seen` still stops `cycle()`'s
once-a-second `sync()` re-banking a block `P1Ingest.blocks` keeps for a day. The
check that graded it asserted `recorded == 1` - the dedupe and the figure
reporting it were the same variable - and now grades `sync()`'s RETURN, because
counting rows cannot see a re-bank at all (PRIMARY KEY no-op).

THE TIE RULE, DECIDED RATHER THAN EMERGENT: at equal wattage the EARLIEST
quarter keeps the attribution. No bill and no guard moves - `peak_w` is still
only ever raised, the `>` term is untouched, SAFETY-07 reads the same watts.
The two readers DID disagree: the upsert's strict `>` kept whichever tied block
was WRITTEN first while `recompute_month()` has always kept the earliest, the
same only while blocks arrive in time order, and measured to diverge (09:45
against 09:00) fed newest-first.

⚠️ AND THE SOAK'S DRIFTING QUARTER WAS NOT A TIE. Six exactly equal 365.0 W
quarters re-attributed zero times on the shipped tree - `>` is strict. What
moved it was a real new maximum BELOW THE ROW'S OWN RESOLUTION: the row renders
whole watts, so 365.0000001 W is a new peak to the database and the same 365 W
to the reader. Left alone deliberately, and pinned so it is not re-diagnosed.

test_tariff.py parts 11 and 12. 12 mutations, all red, listed on the PR; M2 -
`banked_count()`'s sqlite3.Error arm returning 0 - was a SURVIVOR at -3 B on the
first battery, because a handle that never opened short-circuits before any SQL
runs and nothing reached the except arm. Closed with an open store whose table
is dropped under it, with a precondition that the same call answered 4 first.

main.py is NOT touched - TARIFF-11 holds its status rows. The row picks this up
without editing because `recorded` is an attribute access and is now a property.
The residual is stated in DECISIONS §3: `recorded` answers for now() while the
peak follows `checks(when=...)`, and the shipped install has exactly one call
site - `self.checks()` with no argument, main.py:4359 - where they are the same
month, so the live page is correct and durable. `recorded_in(when)` is shipped
and the row's move onto it is one token, for whoever holds main.py next; part 10
pins the present behaviour against a row count taken from `quarter_blocks` so
the gap cannot be quietly forgotten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 12:54:15 +02:00
Claude Opus 5 ac9854886b TARIFF-15: your own tariff card, as two coefficients the card itself grades
checks / unit (pull_request) Successful in 7m14s
A Belgian dynamic contract prices energy as `B x index + A`, and the two
coefficients exist on exactly one artefact: the customer's own tariff card.
There is no machine-readable source for them anywhere - CREG's consumer
tariff-card pages 404, CREG Scan is a JS wizard, the annual report has the
supplier list without per-product coefficients, VREG was unreachable, and
every coefficient found in research came off a supplier's own PDF, one
image-only. So nothing can be scraped and a per-supplier plugin would have no
source for its numbers, which is why TARIFF-07 withdrew three of them. The
customer is the only party who can see their own coefficients; they type them.

New provider `custom_formula`, one new option `price_formula` (a JSON string,
the `static_profile` precedent exactly), and a new module
`app/custom_formula.py`. The index path is `generic_dayahead`'s, not a second
one: the URL/override decision is now one shared `dayahead_feed_url()` both
plugins call, same feed, same Brussels-calendar range, same columnar parser,
same declared egress hosts.

It declares {ENERGY, MARKUP}, NOT ALL_IN. A card's Tariefformule line is the
energy component alone - Eneco tabulates network tariffs and levies
separately, ENGIE states the formula excludes network, distribution,
transport, levies and excise - so ALL_IN would lie for every operator who
typed what their card says and would drop the taxes from every hour
(0.2125 -> 0.1555 EUR/kWh, measured).

THE CARD IS THE GRADER, and that is why this is safe to ship. The framework
already mechanises that a PLUGIN cannot double-count. This introduces a risk
none of those guards can see because it is config, not code: the OPERATOR
double-counting, by folding network charges into `offset` while
price_taxes_eur_kwh carries them too. So validate.py recomputes the card's own
printed price from the operator's coefficients at startup and refuses to start
unless the two agree to within 0.01, naming the discrepancy. An inflated
offset cannot pass: 15.55 becomes 28.27. The same check catches VAT omitted,
VAT doubled, a transposed digit and a unit mismatch between the two sides.

And the limit, in the same sentence as the claim: it grades the coefficients
against the card, NOT the card against reality. A consistent-but-wrong pair
passes. A second refusal covers the one unit error the first cannot see - the
one applied to both sides at once - by bounding the card's own point to
0.02-1.00 EUR/kWh. Its FLOOR is applied to the energy figure rather than the
all-in one because the shipped 0.06 of taxes is three times that floor and
would carry a hundredfold error over it alone; found by writing the mutation,
not by review (M10, +0 B).

The market's own arithmetic, reproduced through the shipped code:

  Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.5471   card prints 15,55
  ENGIE (0.1   x  61.96   + 0.9) x 1.06 = 7.5218  card prints 7,522

NO EXPRESSION EVALUATION OF ANY KIND. The string is settable over HTTP through
the add-on's own ingress page, so eval on it is RCE in a root container
holding SUPERVISOR_TOKEN and driving an inverter over RS485 - but the deciding
argument is that an expression is UNVERIFIABLE: two coefficients can be graded
against the card, an arbitrary expression cannot be graded against anything,
so it would make the invisible-wrong-coefficient problem strictly worse. An
unknown field is REFUSED rather than ignored, so `"expr"` is told it does not
exist. Future shapes become named fields, never a grammar. test_price.py 15f
walks the module's AST for eval/exec/compile/__import__/literal_eval and
exercises the scanner's red direction on every run.

Refused at startup: card absent or out by >0.01; `units` absent (no default);
factor == 0 (every spread 0, arbitrage silently never fires - the message
points at static_profile); factor < 0 (inverts the market); vat_pct outside
0-30 (0 stays legitimate); non-finite or non-numeric; unknown field, index or
resolution; the plausibility band. A card older than ~6 months WARNS and does
not refuse - ENGIE freezes coefficients for a contract's duration. Negative
output prices are not refused anywhere.

A startup preview logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with
min/median/max and the day's spread, through a new default-silent
`PriceProvider.startup_preview()` hook - only the plugin knows what went in to
its own arithmetic. Emitted once per process, and a preview that raises cannot
cost the site its prices.

`custom_formula` joins the config.yaml enum through the DERIVED set
PROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hardcoded list. Both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, a real
startup transcript and the limit of the check stated beside the claim.

22 mutations, one per check added, no survivors - MUTATION.md carries the
ledger. M13 (the preview's index column) was a -13 B survivor of the whole
suite on the first attempt because the check graded the HEADING and not the
row; it is graded on a rendered row now. All 29 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 03:49:08 +02:00
adminandClaude Opus 5 f620f7cf80 TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so
checks / unit (pull_request) Successful in 7m12s
engie_dynamic, totalenergies_dynamic and eneco_eddy are removed from PROVIDERS
and from every surface that listed them. They did not stop working - they never
worked. Re-verified by hand on 2026-09-13 before deleting anything: 404 with
Kong's "no Route matched with those values"; a TLS handshake that fails because
api.totalenergies.be is absent from the certificate's SAN list; 404 with an
empty body. All three are also structurally impossible - the coefficients are
per-contract and behind a customer login, there is no regulator index, and
"Eddy" is not an Eneco product at all.

⚠️ BACKWARD-INCOMPATIBLE. A config legal on the current version is refused on
the next. That is the point of the change: §11.4 turned every failed fetch into
a silent fall back, so a site on one of these ids has been running on
static_profile - or on nothing - since the day it was set, with nothing on its
status page ever differing from a working provider that never found a cheap
hour. validate.py now refuses a retired id with a message naming the id, the
endpoint, what that endpoint answers, which tariff has really been in force,
and what to set instead. No version number is picked here; that depends on a
fact about the live box.

⚠️ config.yaml's price_provider enum deliberately KEEPS all three ids. The
Supervisor validates the options file against that enum before the add-on
starts, so removing them would make an existing config schema-invalid and the
refusal above would never be reached on exactly the sites that need it.
PROVIDERS is two; the enum stays six; test_price.py asserts the schema offers
exactly PROVIDERS | RETIRED_PROVIDERS | {"off"}, derived rather than typed, and
register() refuses to re-register a retired id.

PriceProduct.REALTIME, _WIDE, Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's
2.5 are KEPT with no shipped declarer - it is the AC2 seam, and a reviewer
finding a mechanism with zero callers would reasonably delete it. Recorded in
DECISIONS/TARIFF-07-withdrawing-three-plugins.md and pinned by a check.

False claims corrected: every "Belgian imbalance product" claim about
eneco_eddy (DOCS.md, price_forecast.py, price_history.py, static_tariff.py,
FCST-04, CHANGELOG - the two released entries annotated in place rather than
rewritten); the quarter-hourly examples now cite Belgian day-ahead, which
really does settle per quarter since the 15-minute MTU; and
export_price_eur_kwh's "no shipped supplier publishes a dynamic injection
curve", which is false - Eneco publishes 0,1 x BELPEX-H - 1,188 and ENGIE
(B x eSpot_15) - A, same two-coefficient shape with its own coefficients and
its own VAT treatment, so the scalar is a simplification and not the market.

DOCS.md's provider table is two rows and now says honestly what a Belgian
dynamic customer should do today, including that generic_dayahead cannot
express B x spot + A and that price_markup_eur_kwh: float(0,1) could not hold a
negative offset. The formula provider is referenced as planned, not built.

_points_from_json() went with the plugins it served: it read a shape no feed
anybody here has ever reached publishes. The one place it survives is a
negative stimulus, named row_shape_body().

Nine mutations driven, all red; table in the decision record. 29/29 suites
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 02:46:15 +02:00
glenn schrooyenandClaude Opus 5 028c3e53fb Merge release/1.0 (TARIFF-12) into TARIFF-14
checks / unit (pull_request) Successful in 7m13s
Second base move while this card was open: 559f0eb -> a6e64fb. Only
CHANGELOG.md conflicted; test_arbitrage.py auto-merged (TARIFF-12 used
section 8b, this card uses 10, so the two do not collide).

CHANGELOG: both entries under the single ## Unreleased in MERGE ORDER -
TARIFF-12's row and section precede this branch's, because it landed
first. No version number is picked; PKG-06/07's note stands untouched.

WHAT TARIFF-12 CHANGES FOR THIS CARD - written out in decision note §8
rather than left silent, because the question is a fair one.

A served plan can now be up to TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
= 5400 s old, where before it was at most one refresh interval, and
at() is what answers "what does it cost right now" out of that older
plan.

* PER-POINT SPAN: unaffected, structurally. Every window is derived
  from the neighbours' ABSOLUTE start instants - datetimes in a frozen
  dataclass in a tuple, which cannot move - and _cover_table() runs
  once at construction over data that cannot change. A plan's AGE is a
  property of fetched_at/tariff_at, never of a window. The index cannot
  go stale, which is a small positive under TARIFF-12: a kept plan now
  lives longer and there is nothing in it that needed recomputing.

* ONE-HOUR CAP: unaffected. It bites only at a hole; walking further
  into a series crosses more windows, each resolved on its own
  neighbours.

* THE TAIL: reachable for the first time, and §2's choice survives it.
  At the shipped 48 h horizon the tail is ~46.5 h beyond anything a
  90-minute-stale plan can be asked about. But price_horizon_h is
  int(1,72), so a 1 h horizon is a SUPPORTED configuration, and a
  90-minute serve-stale window against a 1 h series puts `now` past the
  end. Under the chosen rule a quarter-hourly 1 h series ends at :45,
  its window closes on the hour, at() raises Absence, _act() idles and
  §11.4 sends the site to the static profile. Under the REJECTED flat
  one-hour tail the same stale plan would have traded against the :45
  print for up to 45 minutes after the interval it describes had ended.
  So TARIFF-12 does not weaken the decision - it is the first thing
  that makes it reachable, and it selects the same answer. The rule
  should be re-read if TARIFF_MAX_AGE_S is ever widened again.

* TariffPlan.from_feed is a pure predicate on `origin` and TARIFF-12
  keeps or replaces a WHOLE PLAN OBJECT. It never splices two reads
  into one series, so it cannot hand at() an unsorted or duplicated
  point list.

RE-GRADED ON THIS BASE, not carried over: all 29 test files green;
app/price.py byte-identical to the TARIFF-10 merge (90d8ca31f7c0109d,
85953 B) because TARIFF-12 does not touch it; M1 re-driven at +68 B,
red on the same 24 named checks across both files; M3 re-driven at
ZERO BYTES, red on the same 4. Each purged before the run and reverted
with the fingerprint re-checked, from a card-specific harness at
/home/homelab/.cache/t14-mut/ with a mktemp backup inside it.

This branch contributes zero diff to app/main.py and app/static_tariff.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 01:47:17 +02:00
glenn schrooyenandClaude Opus 5 435e1b6f7c Merge release/1.0 (TARIFF-10) into TARIFF-14
checks / unit (pull_request) Successful in 7m10s
CI on PR #80 was NOT a test failure. The workflow's "grade the merge, not
the branch" step refused the PR because it does not apply, and said so
itself: "REFUSED: refs/pull/80/head does not merge into release/1.0.
NOTHING HAS BEEN TESTED. This is not a test failure - no test ran."
Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged.

CONFLICTS RESOLVED

* CHANGELOG.md - both entries belong under ## Unreleased. TARIFF-10's
  table row and section come first because it landed first. No version
  number is picked here; PKG-06/07's note stands untouched.

* test_price.py - BOTH cards appended a section "13". Nothing is
  dropped: TARIFF-10's stays 13, this card's is renumbered 14, and
  TARIFF-10's temp-directory teardown stays at the end of the file. The
  two share no fixture, option dict or helper name.

WHAT TARIFF-10 CHANGES FOR THIS CARD - nothing in the reasoning, and the
check is recorded rather than asserted (decision note §7).

In app/price.py the two do not meet: TARIFF-10 added provider_id() and
provider_configured() beside price_option() and routed load_prices()
through them - the option-reading layer, touching nothing in _Series,
at(), covers(), window() or _cover_table(). The merged at() is
byte-identical to the one the mutations were driven against.

Behaviourally TARIFF-10 makes run_tariff_refresh() run with arbitrage
OFF, so a provider-configured site now has a standing plan - and
therefore a _Series - where before it had none. It does NOT add an at()
consumer to the 30 s tick: static_status() builds a STATIC plan (hourly,
immune), main.py calls only plan.words on the fetched plan, and
price_forecast.price_permits()/begin_phase() have no caller in main.py
at all. So TARIFF-10 neither creates nor closes any exposure this card
is about - it enlarges the defect's reach the moment such a site arms
trading, which strengthens the fix without changing it.

RE-GRADED ON THE MERGED TREE, not carried over

* all 29 test files green;
* all EIGHT mutations re-driven, purged before each run, each reverted
  with the fingerprint re-checked. Same verdicts and same red-check
  counts as before the merge: M1 (the shipped one-hour scan, +68 B) red
  on 24 named checks across both files; M3 (the ZERO-BYTE tail rule) red
  on 4; M2/M4/M6 on 1 each, M5 on 2, M7 on 16, M8 on 14.
* Clean app/price.py on the merged tree: 90d8ca31f7c0109d, 85953 B.
* None of TARIFF-10's 650 new lines of checks moves under any of the
  eight - they grade resolve_tariff()'s reachability from a
  configuration, not the lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 21:38:28 +02:00
Claude f5ea39784d Merge release/1.0 (TARIFF-10) into TARIFF-12
checks / unit (pull_request) Successful in 7m16s
CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.

app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:

  * a provider configured with trading DISARMED is the case the guard
    was designed for, and needs no second fixture because nothing on
    this path reads `arbitrage_enabled` at all - asserted over the AST's
    symbols and non-docstring constants, after two drafts of that check
    went red on a CORRECT tree for matching the word "arbitrage" in a
    docstring and in a log line;
  * `price_provider: off` + a `static_profile` resolves to the
    CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
    false on every refresh and the guard never latches - driven twice in
    a row, because a guard that latched there would freeze a static
    site's plan at its first window for the life of the install.

Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.

All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 21:31:27 +02:00
glenn schrooyenandClaude Opus 5 7f2c4e20e1 TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s
`_Series.at()` asked `s <= u < s + 1 h` of every point IN LIST ORDER and
returned the FIRST match. On the hourly shape every fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium
moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
returns 96 points at 900 s, where it is wrong twice over - measured
against the shipped code at 5059e28:

    series: 02:00=0.10  02:15=0.11  02:30=0.12  02:45=0.13  03:00=0.14

    ask +15min -> 02:00  0.10   <-- WRONG (wanted 02:15)
    ask +60min -> 02:15  0.11   <-- WRONG (wanted 03:00)
    ask +75min -> 02:30  0.12   <-- WRONG (wanted 03:15)

Inside the first hour the :00 point wins because its hour has not run
out. From the second hour on the answer is not even the top of the hour
- the first point still inside its own hour wins - so the error
compounds with depth into the series.

`arbitrage._act()` tests `here.start == v.cheapest.start` /
`== v.dearest.start` to decide whether this interval is the buy one or
the sell one. That identity almost never held, so the strategy idled
through exactly the interval it exists to trade, and read §8.3's
`here.provisional` and §17's `price < 0` off the same wrong point.
`static_tariff.py` emits hourly points, so static sites were never
affected; this is reachable only down the dynamic path TARIFF-08/09
made work.

THE RULE: a point covers from its own start until the next point's
start, and never more than one hour. Quarter-hourly takes 900 s,
hourly is unchanged, and a MIXED series - FCST-04's carry-forward puts
hourly carried points beside quarter-hourly firm ones, spacing
[900, 3600] in 70 % of soak samples - takes each point's OWN gap.

The one-hour cap is load-bearing: FCST-04 leaves an hour OUT when
nothing published it and yesterday has no value, and `at()` raising
Absence for it is what sends the caller to the static profile (§11.4).
Uncapped, "until the next point's start" would stretch the point before
a three-hour hole across the whole hole and answer every instant in it
with a stale price.

THE LAST POINT is given the same span as the point before it (one hour
when it is the only point). A flat one-hour tail would answer 00:30
TOMORROW with today's 23:45 price. The cost - a mixed series ending at
the firm/carried seam loses its last 45 minutes to Absence - is argued
in DECISIONS/TARIFF-14-the-window-a-point-covers.md §2 rather than
buried; the direction is the safe one.

`at()` now reads a point's NEIGHBOURS, so it would depend on
sortedness. It establishes that itself: the lookup index is sorted by
UTC start once, at construction, so no caller's list order reaches it.
A series handed its 96 points in reverse answers identically, and that
is a check. `covers()` shared the defect exactly - it has no rule of
its own, it asks `at()` - and is fixed by the same change; `window()`
did not, and its docstring now says why.

Nothing about §9.3 or a point's `start` changed. PricePoint still has
three fields; the span is derived from the neighbours, never stored.

Graded by test_price.py section 13 (900 s, 3600 s, mixed, holes, the
tail, caller order, duplicate starts, covers()/window(), the no-data
contract) and end to end by test_arbitrage.py section 10, which drives
decide() over a 96-point quarter-hourly day whose cheapest and dearest
quarters are deliberately NOT on a whole hour, plus §17, §8.3 and the
mixed series. Eight mutations, all red, including a ZERO-BYTE one on
the tail rule; byte deltas and what the arbitrage file could not see
are in the decision note §6. All 29 test files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 21:22:28 +02:00
Claude c81289527f TARIFF-12: a returned degradation is not a refresh
checks / unit (pull_request) Successful in 7m12s
`run_tariff_refresh()` promised in its own docstring that "a failure does
not clear the plan", and enforced it with `except Exception` - a branch a
supplier outage never reaches. `load_prices()` states the reason in its
own words: it "RETURNS, NEVER RAISES, for every failure a site can
actually have", because a degradation arriving as a traceback into the
planning tick would stop the controller regulating over an expired
certificate. So a dead feed RETURNS a §11.4 plan, the `try` succeeds, and
the assignment below it overwrote the good plan unconditionally. With
`static_profile` unset - the shipped default, and the normal state of a
dynamic-tariff site - `_degrade()` returns `TariffPlan(no_data, None,
"none")`: a complete 48-hour plan replaced by nothing, at the FIRST
failed fetch, ~27 minutes after the last good one. `TARIFF_MAX_AGE_S`
(5400 s) never governed anything and there was no serve-stale window at
all.

Fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's HIGH
(`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and
now `except Exception` only, for a path that never raises.

The question is now asked once, after the attempt, from the evidence
that exists - FCST-06's answer, one module over. The evidence is
`TariffPlan.from_feed` (did this resolution observe the configured feed)
and the shipped `fresh_tariff_plan()`, reused rather than transcribed. A
degradation replaces a standing observation only once that observation
has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it
reads like.

A §11.4 static fallback does not replace a fresh provider plan either,
although it carries real prices: every point `_degrade()` builds is
`provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses
and `cheap_now()` is False - everything a consumer may act on is already
off - while a day-ahead price for a given hour does not change once
published. The rule is deliberately not symmetrical: a standing plan
that is itself a degradation IS replaced, or an outage would freeze the
operator's static profile at the window it was first generated for.

`tariff_at` does not move on a kept plan. It is the moment of the fetch
that produced the plan standing, `fresh_tariff_plan()` judges the age
from it, and an unmoved timestamp is the only thing that tells a kept
plan from a freshly fetched one.

test_arbitrage.py section 8b drives the whole chain with nothing stubbed
but the socket: the transport raises the way aiohttp does, load_prices()
returns no_data, _degrade() builds the §11.4 plan, and the shipped
run_tariff_refresh() decides. A check that patched resolve_tariff to
RAISE would have passed against the defect. Ten mutations, ten reds, no
survivors; 29/29 suites green.

Out of scope and unchanged: the persisted price store still does not
back the plan during an outage (FCST-03), and the status banner and the
Arbitrage row's lag are separately carded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 21:06:45 +02:00
adminandClaude Opus 5 e6c45a61e7 TARIFF-10: configuring a price provider is what fetches prices
checks / unit (pull_request) Successful in 7m19s
amain() started the background tariff refresh only when arbitrage_enabled was
on, and that option ships false. run_tariff_refresh is the only caller of
resolve_tariff(), which is the only caller of price_history.carry() - so on a
shipped install with a price provider configured nothing was ever fetched: no
plan resolved, /data/price_history.json never created, the Price history status
row reading `empty` for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about. Measured over
a 40 h two-instance soak on one feed: 0 requests and `present: false` across all
802 samples, against a resolved 132-point plan on the instance with arbitrage
armed.

git log -S puts the refresh task, its arbitrage.enabled() guard AND the DOCS.md
sentence saying a configured provider still fetches in the same commit - OPT-04,
bdd7f18; config.yaml has carried the claim since TARIFF-02, 852fc7c. The prose
and the code disagreed from the moment both existed and nothing compared them.
config.yaml and both translations additionally carried a claim that had rotted
the other way - "nothing consumes the price series yet", untrue since OPT-04
shipped the consumer. All four surfaces now say what the add-on does, and a
check pairs DOCS against the DRIVEN behaviour so they cannot drift again.

The gate is tariff_refresh_wanted() = provider_configured(opts) or
arbitrage.enabled(opts), and the `or` is a STRICT WIDENING: every configuration
that started the task before still starts it. The second term is not there for
symmetry. price_provider: off + a static_profile + arbitrage_enabled: true is a
configuration validate.py explicitly ACCEPTS and it trades - measured, at
cycle_cost_threshold_eur 0.05 on a 0.20/0.284 day/night profile,
ARBITRAGE_CHARGE -5000 W. Gating on the provider alone would leave that site's
tariff_plan at None forever and answer "no tariff plan has been resolved yet" on
every cycle, silently disarming a strategy the UI says is running.

Nothing acquires a periodic HTTPS call. With price_provider off, resolve_tariff()
answers out of the static profile without opening a socket (driven and counted:
0 requests), and the true shipped default - no provider, no trading - starts no
task at all. What decides whether a request leaves the machine is price_provider
and only that. The literal reading of the ticket's AC2 - drop the `or` - is one
token and is recorded in DECISIONS §3 with what it costs, for the owner.

AND NOTHING TRADES THAT DID NOT TRADE BEFORE. cycle()'s
`if arbitrage.enabled(self.o):` is untouched, and that is graded directly rather
than inferred from the gate having moved: Controller.cycle() is driven on a
resolved provider plan with the peak guard asserted clear, and arbitrage_enabled
is the only option that moves between the rows - false/false give +180 W
("loop -> 180 W") on both the cheap-now and dear-now plans, true/true give
-5000 W and +5000 W ("arbitrage -> ...").

TariffPlan.cheap_now() is deliberately NOT wired, decided rather than inherited
(DECISIONS §4). A provider-backed plan now exists on every configured site, so
Cheap window could read it - and Cheap window feeds the maintenance cycle's grid
top-up, the one place a price moves energy without trading being armed. On a
provider-only site static_status() returns False on every cycle, so adopting it
would take a paid grid top-up from never to sometimes. self.cheap stays on
static_status(); what adoption would take is listed rather than left implied.

THE CHECK THAT WOULD HAVE CAUGHT THIS is test_price.py section 13, and it grades
REACHABILITY FROM A CONFIGURATION rather than the shape of an `if` - the `if` was
exactly what somebody meant to write. amain()'s guards are EVALUATED against real
option dicts, whatever they are spelled as; the call graph under app/ is walked
to see whether the tasks those guards start arrive at resolve_tariff(), wherever
it has moved to; and then the fetch is DRIVEN with a counting transport, because
a graph is an argument and a counted request is an observation. What the
instrument cannot see is printed on every run, largest hole first.

Evidence: 29/29 green. 13 mutations in 14 runs - M10 re-run as M10b once it was
closed - each applied to a fresh cp -a of the clean worktree rather than
reverted (MUTATION.md habit 18), purged, :ro, -B. The
shipped defect - the gate back at arbitrage.enabled(opts), -4 B - is red on
seven named checks. Dropping the `or` (-27 B) is red on the strict-widening row;
removing cycle()'s trading gate (+8 B) is red on both AC3 rows. The instrument
is mutated too (habit 16): _reaches() always True and _task_sites() returning []
are both +0 B and both red.

Two findings are recorded rather than rounded off. provider_id() dropping
.strip() (-8 B) was a real SURVIVOR across four price suites - nothing fed a
padded provider id - and is closed in this commit by the padded-id pair; and the
first draft of the AC3 fixture fed five minutes of P1 telegrams, so whether the
peak guard was blind depended on where the wall clock sat inside the billing
quarter and the armed baseline failed to trade on some runs. It is 900 s now,
and the guard's clear state is a check rather than an assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-12 20:58:48 +02:00
Claude Opus 5 17b7b447c4 TARIFF-09: ask the day-ahead feed for the horizon it was handed
checks / unit (pull_request) Successful in 7m10s
`generic_dayahead.fetch()` accepted `since` and `until` and requested the
bare `?bzn=BE`, which answers ONE Brussels day. Everything past today was
then filled from yesterday's profile by FCST-04, flagged provisional, and
§8.3 drops provisional hours from the trade set -- so the optimiser never
saw tomorrow as firm, at any hour of any day, and nothing reported it.

Measured on 4257e38 at 2026-09-10T09:00+02:00, 48 h horizon, live feed,
yesterday seeded through the shipped read path:

    60 firm points (15 whole hours) + 33 carried hours of 48 requested
    carried: 24 h on 2026-09-11, 9 h on 2026-09-12
    arbitrage(0.10): buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
    firm local days = [2026-09-10]

The ceiling was structural: with a one-day window, at hour H a 48 h horizon
is at most 24-H firm and 24+H carried. At 23:00 that is 1 and 47, and the
remaining firm spread (0.0464 EUR/kWh) no longer clears the default 0.10
threshold, so arbitrage is refused outright. FCST-04's fill covers the
window until the ~13:00-14:00 CET auction; it was never meant to cover
every hour of every day.

The data exists and the endpoint serves it: verified 2026-09-10T07:45:49Z,
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns 192 quarter-hours across
two local days. Committed unmodified beside TARIFF-08's captures, with the
URL, retrieval time and sha256 recorded and RE-DERIVED by the replay.

THE RANGE IS DERIVED ON THE BRUSSELS CALENDAR, not the UTC one. 22:30 UTC
in summer is already tomorrow in Belgium, so a UTC-derived start asks for
the day the controller has just left and drops the day it is standing in,
for the last one or two hours of every day. That is `_bdate()`'s lesson one
layer up with a worse consequence: `_bdate()` misfiles an hour, this never
fetches it. `until` is exclusive, so local midnight belongs to the day
before it; an inverted window collapses to one day rather than a backwards
range. Both ends are graded and both are driven red by mutation.

After, same code path and window, a feed serving both days: 39 firm hours
and 9 carried (was 15 and 33), firm across TWO Brussels days, and the
permitted cycle buys on one day and sells on the next.

`price_feed_url` IS THE EXACT URL AND THE PLUGIN APPENDS NOTHING. `start`/
`end` are this feed's spelling and the provider declares a second host
whose parameters are different, so merging would produce a request nobody
typed and the short horizon anyway. The cost is real, so it is not silent:
a WARNING on the shipped logger at fetch time names the consequence and the
start..end that would otherwise have been asked for. `dated_feed_url()`
refuses to append to a base that already carries a query string, so the
blind-append shape cannot come back by edit.
DECISIONS/TARIFF-09-the-range-and-the-override.md argues the alternative.

Before publication nothing regresses and it is graded, not assumed: at
09:46 CEST the two-day request came back byte-identical to the one-day
body, and that stays a `series` of what exists with FCST-04 filling the
rest. A `no_data` there would switch a working site's arbitrage off every
morning -- the mutation that adds that rule is red by name.

The plugin contract is still NOT enforced: three shipped plugins point at
endpoints nobody has verified, and a rule obliging them to pass a range
would be satisfied by an invented parameter name -- TARIFF-08's defect
moved from the response to the request. Which plugins consume the range is
pinned as a literal instead.

11 mutations, all red, no survivors; each applied to a fresh copy of the
tree rather than reverted, through tools/mutation_run.sh. One is +0 B (a
single price digit inside the committed capture) and the re-derived sha256
catches it. MUTATION.md habit 20 records why a fixture could never have
reached this: a captured body is the answer to a URL, and no property of a
body says which URL was asked for. All 29 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 10:35:12 +02:00
adminandClaude Opus 5 7a5281e97f Merge release/1.0 (FCST-06) into TARIFF-08
checks / unit (pull_request) Successful in 7m10s
One conflict, in CHANGELOG.md's `## Unreleased` block, exactly as expected
from two branches landing beside each other: both `_this branch_` table rows
and both sections are kept, FCST-06's first. Nothing else conflicted -
FCST-06's `test_price_history.py` section 5d and this branch's columnar
`feed_body()` merged cleanly - and all 29 test files are green on the result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 09:06:39 +02:00
adminandClaude Opus 5 6eba18968c TARIFF-08: the day-ahead feed is read in the shape it actually publishes
checks / unit (pull_request) Failing after 3s
`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
never had been able to. `_points_from_json()` asks for `doc["data"]`, a list of
`{start, price_eur_mwh}` objects; that document does not exist. Fetched live on
2026-09-10 the feed returns COLUMNAR parallel arrays and no `data` key at all -
`unix_seconds` (96 UTC epoch seconds, 900 s apart), `price` (96 floats),
`unit: 'EUR / MWh'`. Driving the shipped parser against that body:
`SourceUnavailable: the feed carried no 'data' list`. It is the only shipped
provider whose endpoint anybody has reached, so dynamic pricing did not work.

AND §11.4 MADE IT SILENT, which is why it survived to release: a parse error
becomes no_data, becomes the static profile, becomes arbitrage off - the
CORRECT degradation, saying nothing about the cause. A site would have sat on
the fallback indefinitely.

AND THE SUITE COULD NOT SEE IT, because every fixture was built from the
parser's own assumption. test_price.py, test_price_history.py and
test_static_tariff.py each built `json.dumps({"data": [...]})`, a shape invented
here and published by nobody, so the checks graded the parser against the
parser's guess - a closed loop no coverage inside it could ever have escaped.
MUTATION.md habit 19 records it as the fourteenth instance of habit 4 and the
first where the fixture and the code under test shared a SHAPE, not a number.

WHAT CHANGED

* `_points_from_columnar_json()` - a SECOND function, not a sniff. A parser that
  decides which document it received is one that can decide wrong, and a
  wrong-but-parseable response then becomes a price. Feeding the row shape to
  `generic_dayahead` is now a graded no_data.
* Unequal arrays are refused in both directions, with the two lengths in the
  reason. `zip()` stops at the shorter list silently, and a truncated `price`
  array would have produced a real series missing the evening peak - traded on
  as complete, where no data at all switches arbitrage off.
* A unit that is not EUR/MWh is refused. The plugin converts with a fixed
  0.001, and `MAX_PLAUSIBLE_EUR_KWH` only bounds prices that are too LARGE.
* `unix_seconds` are read as UTC instants explicitly; §9.3's Brussels rendering
  stays in `assemble()` rather than being copied into the plugin, where a
  mutation could not tell it from a no-op (M4 proved exactly that on a first
  draft, and the line was removed rather than fenced).
* A published null is dropped and logged with a count; an all-null body is
  no_data named by this parser rather than by the boundary validator.

FIXTURES

Two unmodified response bodies under `scenarios/`, captured by hand on
2026-09-10: quarter-hourly (2026-09-10, 96 points at 900 s) and hourly
(2025-09-10, 24 at 3600 s - the same endpoint before Belgium's 15-minute MTU).
`energy-charts-be-price-captures.json` records the URL, retrieval time, unit,
licence-as-returned and a sha256 of each, and the replay RE-DERIVES the
checksums, so a fixture edited to make a check pass stops being a capture. Data
CC BY 4.0, Bundesnetzagentur | SMARD.de, via Energy-Charts (Fraunhofer ISE),
published without changes - the same BE-zone licence check DATA-02 established.

The surviving invented fixtures are renamed `synthetic_row_feed` at every call
site. `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` are untouched
and have no capture: their URLs are unverified and withdrawing them is
TARIFF-07's open decision. Nothing should read their green checks as evidence
that those feeds parse.

MUTATIONS - 12, all red, no survivors. Purged tree, mounted `:ro`, `python3 -B`
(TEST-15). M1 drop the equal-length refusal; M2 make it one-sided; M3 drop the
unit check; M4 `assemble()` stops rendering at the Brussels offset; M5 read
`unix_seconds` as a naive local clock; M6 zero-fill a null instead of dropping
it; M7 drop the all-null refusal; M8 sniff for the row shape; M9 drop the
deprecated warning; M10 drop the dropped-nulls warning; M11 edit one price in
the committed capture; M12 point `generic_dayahead` back at the row parser -
red across all three price suites.

END TO END, LIVE, 2026-09-10: `resolve_tariff()` returns a `TariffPlan`,
`origin='provider'`, 96 quarter-hourly points, spacing [900] s, no decimation,
first point 2026-09-10T00:00:00+02:00 at 0.30608 EUR/kWh all-in. FCST-04's H1
fix was never theoretical. All 29 test files green.

STILL OPEN: `generic_dayahead` asks for no date range, so the default URL
returns one published day - a full 48 h horizon is reached only through §8.3's
carry-forward fill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 08:57:32 +02:00
adminandClaude Opus 5 c45893ffad FCST-06: "cannot persist" becomes one question, asked once, about the outcome
checks / unit (pull_request) Successful in 7m14s
price_history.py's docstring states its rule unconditionally - "A store that
cannot persist now records nothing, and both of its answers say the same
thing" - and FCST-04 enforced it against `not self.ok` only, which is one
SYMPTOM: the durable volume is missing. `ok` is about the DIRECTORY, and a
read-only remount and a full disk both leave it True. Reproduced on 5059e28
against two real volumes, a /data bind-mounted read-only and a full tmpfs:

    last_error = 'could not write ([Errno 30] Read-only file system)'
    words      = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
                  over 24 h) [could not write (...)]'
    yesterday()= series, 24 points
    ON DISK    = {}

`words` and `yesterday()` were describing two different stores, and the 24
points were gone on the next restart with nothing saying so - the
silently-forgetting cache that rule exists to refuse, reached by the condition
it did not name. No shipped path reaches it: /data is the Supervisor's durable
volume and this module is its only writer. The defect is that a guarantee the
module states about itself did not hold in one condition.

Route taken: `_remember()` refuses to retain what it could not write, and the
docstring's claim stands as written. It is now ONE derived predicate rather
than two places testing two symptoms. "Can this store persist?" cannot be
answered BEFORE the attempt - a directory that existed and was writable a
moment ago is not a write that completed - so it is answered after it, once,
by the only thing holding the evidence: `_save()` returns whether the file now
holds what memory holds. No volume is one False, an OSError is another.
`_remember()` keeps only what that one answer says was kept, and the
`not self.ok` test in `_remember()` is GONE rather than joined by a second one.
FCST-04's M4 and FCST-05's HIGH were the same shape, so a third cause now needs
no third branch.

What is undone is the MERGE, not the store. The state restored is the last one
that IS on disk, so a site whose volume fills at 14:00 goes on answering the
yesterday it wrote at 13:30 - that record survives a restart, so answering it
is not a promise the store cannot keep. It stops adding to it. The snapshot is
copy.deepcopy: a one-level copy shares the day record the merge rebinds
`points` on, and measured with one, a store holding 12 persisted points
answered 24 after a failed merge.

Not fatal, deliberately: §6 and this module's opening - a broken cache must not
be a broken controller, and an add-on that will not start because /data filled
up is a worse defect than this one. `_save()` reports and returns; the shipped
resolve_tariff() path still gives a provider plan with no §11.4 degradation.
What changed is that the failure is now acted on rather than only logged.

test_price_history.py section 5d drives it on two REAL failures with different
syscalls and different errnos - a directory where the file goes (os.replace,
EISDIR) and a volume moved out from under an open store (mkstemp, ENOENT) -
because the rule is enforced on the outcome and one cause would grade a
coincidence. DOCS.md's "where there is no /data" sentence is widened to the
condition the code now holds to.

MUTATION.md gains what M4 exposed: a mutation whose red is a TRACEBACK reports
however many checks ran before it and grades none after it. M4 first printed
1 FAIL; the true figure is 3. An f-string operand read `len(read.points)`, and
`.points` on a no_data raises `Absence` by design - why()'s rule arriving in
what a check PRINTS rather than in what it asserts. `npts()` is now beside
why(), and a red count is a lower bound until the run is seen to reach the end
of its section.

6 mutations, purged tree, every one RED on named checks, no survivors:
M1 self._days = before -> dict(self._days)          +10 B  9 red
M2 _save() OSError handler returns True             -1 B   9 red
M3 _save() returns True on `not self.ok`            -1 B   3 red
M4 self._days = before -> self._days = {}           -4 B   3 red
M5 copy.deepcopy -> dict                            -9 B   3 red
M6 `if not self.ok: return` back in _remember()     +43 B  1 red

All 29 test files green. Nothing deployed; no option added; config.yaml and
app/ behaviour on a healthy /data untouched. No version number picked - that
is the open decision the Unreleased block names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 08:43:17 +02:00
glenn schrooyenandClaude Opus 5 375a1e29d2 FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard
checks / unit (pull_request) Successful in 7m10s
HIGH - a non-UTF-8 byte in /data/price_history.json stopped the add-on
from starting. `_load()` caught `(json.JSONDecodeError, OSError)`.
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError`:
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is False. So
it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
`Controller.__init__` does not guard - breaking `_load()`'s own stated
contract, "a corrupt cache must not stop the controller". The handler now
names the ValueError FAMILY (the file's content) beside OSError (its bytes),
because a third subclass nobody has thought of is how this arrived the first
time. The suite could not see it: its "unreadable file" fixture writes
"{ this is not json", which is valid UTF-8 and fails one step later, in
`json`. A fixture holding a genuinely undecodable byte is now beside it,
with the difference between the two asserted as a precondition.

MEDIUM - a malformed `start` under the day the feed is writing to killed the
tariff plan permanently. `_remember()` merged with `{q["start"]: q ...}` and
sorted on `q["start"]`, trusting a value read off /data to be a hashable,
sortable string. An int, null, a dict or a list raised TypeError out of
`carry()` -> `resolve_tariff()` -> `run_tariff_refresh`, which catches
Exception - so the add-on stayed UP while every refresh raised forever, the
plan retiring after TARIFF_MAX_AGE_S with arbitrage and the cheap window off
for good, and no self-heal because `_remember()` raised before `_save()`.
The read path was properly defensive throughout, which is why `yesterday()`
survived and the existing fixture passed - it files its malformed rows under
a day the feed is NOT writing to. Both paths now share one validator,
`_point_of()`. A row that is not a point is dropped, reported, and written
back out clean.

...and the status row said the store was healthy while the plan was dead:
`words` read '2026-01-15 (1 h)'. `_load()` now counts the rows inside a kept
record as well as the record itself, so the store stops reporting plain
health from the moment it is opened; the note names what was FOUND at open
and what was DROPPED by the refresh, and clears once the file is rewritten.

...and the merge key is the validated INSTANT rather than its spelling,
which is what rule 4 already said. The lexicographic sort beside the string
key also put the second 02:00 of the 25-hour October day (+01:00) before the
first (+02:00); stored order only, since `_parse_points()` re-sorts.

LOW - the status row counted POINTS and printed them under the HOUR's name:
'2026-01-13 (96 h)' for a 24-hour day on the quarter-hourly feed this add-on
already supports. It is now '96 points over 24 h' - two facts, each under
its own unit.

LOW - two stated reasons were false. `_audit`'s "WHY IT IS STRICTER" claimed
the rework refused the same published point emitted twice and a carried
point sharing a published hour, "which the old count never looked at". A
differential over the whole domain the audit can express finds ZERO
divergence in both directions: the old `seen` count already refused the
first (one point twice is one hour twice) and the old IDENTITY rule already
refused the second. The reworked audit is EQUAL to FCST-03's, not stronger;
the only behavioural change is the intended relaxation, and it stays. The
differential is committed as a check rather than left as prose.

...and the audit's real limit is named: it does not check COMPLETENESS. Fed
the decimation defect FCST-04 was raised for it ACCEPTS, and it accepts an
empty output. So `_audit` is not what stops that recurring - the count and
identity checks are, and the text now points at them. Not made complete:
`published` is the whole read while the fill emits only [since, until), so
the rule as stated is wrong, and building it takes the 1/12/48 h horizon
checks red on a healthy feed. Measured, not assumed.

12 mutations, purged tree, every one RED, no survivors. All 29 test files
green. Nothing deployed; no option added; config.yaml untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-10 01:04:30 +02:00
glenn schrooyenandClaude Opus 5 b59f5ce31b FCST-04: the carry-forward stops decimating a quarter-hourly feed, and a broken cache stops stopping the add-on
checks / unit (pull_request) Successful in 7m22s
Rework of the FCST-04 branch against review. Every finding below was proven by
a runnable script before it was touched and re-run after; every check added or
repaired was driven red by a mutation, `__pycache__` purged first (MUTATION.md,
TEST-15). 29/29 test files green.

⚠️ H1 - A SUB-HOURLY FEED WAS DECIMATED TO ONE POINT AN HOUR, AND THE SPREAD
WENT WITH IT. `fill_from_yesterday()` keyed the published set as
`{_utc_hour(p.start): p for p in r.points}` - LAST POINT OF THE HOUR WINS, the
other three dropped. `eneco_eddy` is the Belgian imbalance product and settles
per QUARTER, and `_validate_raw()` accepts any strictly-increasing spacing, so
this is a shape the product supports today. Measured on a 96-point quarter-hourly
feed - ALL FIRM, FULLY PUBLISHED, nothing to carry: the plan reached the
optimiser as 15 points, the arbitrage spread fell from 0.143 to 0.014 EUR/kWh,
and the cheapest "hour" became a `:45` point whose `at()` window runs into the
next one. Not the windowing effect this card is about; a feed losing 84 % of its
own prints, with no flag anywhere reporting it. The fill now decides only WHICH
WHOLE HOURS NEED CARRYING: every published point inside the window passes
through BY IDENTITY, and a carried point is added only for a whole hour with no
published coverage at all. Same feed, same fixture: 60 of 60 firm points kept,
spread 0.134 over the hours still ahead.

⚠️ AND `_audit()` GOT STRICTER, NOT LOOSER - that was the condition on doing it
at all. The old post-condition refused any repeated `_utc_hour`, a count that
cannot express four points in one hour. Two rules replace it and SUBSUME it: a
published point must BE one of that hour's published points by identity and may
be emitted only once; a carried point may stand only in an hour absent from the
published set, must be flagged True, and may not repeat. So the duplicated
carried hour it existed to catch still raises, the same published point emitted
twice now raises, and a carried point sharing an hour with a real print now
raises too - which the count never looked at. Three mutations, three reds.

⚠️ H2 - A DAY RECORD THAT WAS NOT A RECORD STOPPED THE ADD-ON FROM STARTING.
`_load()` validated `version` and that `days` is a dict, never that each VALUE
is one. `{"version": 1, "days": {"2026-01-14": "oops"}}` loaded as ok=True,
last_error=None, and then `rec.get(...)` raised AttributeError out of `words`,
`yesterday()` and `resolve_tariff()` alike. `PriceHistory(...)` and `.words` are
both unguarded in `Controller.__init__`, so the controller did not come up - and
it never self-healed, because `_remember()` raised on the same record before it
could reach `_save()`. That falsifies the CHANGELOG's "A BROKEN CACHE IS NOT A
BROKEN CONTROLLER" in the one direction that matters. The asymmetry that
produced it is visible in the file: `_parse_points()` is carefully defensive
about every field of a POINT and `_load()` was not defensive about the record
holding them. Unusable records are now dropped and REPORTED (`words` says how
many and why), and `words` is total by construction as a second line.

M3 - `yesterday()` WAS OFF BY ONE DAY FOR 00:00-00:59 AFTER SPRING-FORWARD.
`brussels()` renders at the FIXED offset for that instant, so `brussels(stamp) -
timedelta(days=1)` subtracts 24 ABSOLUTE hours - and the Brussels day before the
March transition is 23 hours long. At 2026-03-30T00:30+02:00 it returned
2026-03-28's prices as "yesterday", or `no_data` naming a date that is not
yesterday. The arithmetic is now on the local DATE, which has no offset to lose,
and both transitions have a check - the module's headline claim had none.

M4 - WITH NO /data THE STORE STILL FILLED, IN MEMORY. `_save()` short-circuited
on `not ok` while `_remember()` populated `self._days` anyway: `words` said
"unavailable" and `yesterday()` answered A SERIES that is gone on the next
restart. The module docstring rejects exactly that ("a yesterday that silently
forgets") as its reason for not calling makedirs, so the code now matches the
docstring rather than contradicting it.

M5 - THE PRUNE MUTATION SURVIVED BECAUSE THE STIMULUS WAS DEGENERATE. §8's
clock-jump check seeded ONE day, so `if len(self._days) <= KEEP_DAYS: return`
fired and the body it is named after never ran: pruning by arithmetic against
the clock - which empties the store on exactly the morning the fill is for -
passed the whole 29-file suite. The fixture is now written to a file and
reopened (`_load()` does not prune, which is how a store legitimately holds more
than the cap) with KEEP_DAYS + 2 days, and the mutant is red.

M6 - THE AST ORDERING CHECK COULD NOT CATCH A REVERSAL, AND TWO DOCUMENTS CITED
IT AS A GUARANTEE. `_calls.index("_remember") < _calls.index("yesterday")` grades
`ast.walk` BFS DEPTH: `self._remember(...)` is a statement and
`self.yesterday(...)` is an ARGUMENT of the fill, one level deeper, so
`_remember` came out first whichever statement was written first - shipped and
reversed both scored True. It is now line numbers AND, the half that matters,
the behaviour: a feed carrying yesterday alongside today into an EMPTY store
must fill the whole horizon; reversed, the lookup finds nothing and the plan is
only the published hours. Reversing the two statements is red on both.

L14/L9 - KEEP_DAYS = 3 IS THE STEADY STATE EXACTLY, so a newest-N cap has no
slack: a feed publishing to D+2, or ONE stray point in the wrong year - which
sorts LAST and can never age out - pushes yesterday out for good. `_prune()` now
PINS the two days a lookup can actually ask for on top of the cap. Pinning is
additive, so it can only RETAIN and the WITNESS-03 property (nothing is deleted
by clock arithmetic) is preserved by construction; `stamp` stops being a dead
parameter; the store is bounded at KEEP_DAYS + 2.

L8 - the horizon end was not floored while `since` was, so a 48 h request made
at any instant off the hour returned 49 points. §1's count was green only
because its NOW was exactly :00:00, a shape production never has; the fixture
now sits at :17:43. L10 - a failed `_save()` leaked its temp file; at 48 writes
a day a persistent write failure leaked 48 inodes a day onto the volume that was
already the problem. L15 - `{"version": 1.0}` passed, since `1.0 == 1`. L16 -
the `_remember` tree walk scanned `app/*.py` only and matched a bare string; it
now parses `app/`, `tools/`, `scenarios/` and `estop/` and looks for the NAME,
so a comment does not fail it and a call in `tools/` cannot pass unseen.

CLAIM-ACCURACY DEFECTS, WHICH THIS REPO TREATS AS REAL:

* L7. DOCS said "*Tariff* on the status page reads `provisional` through the
  morning". FALSE - `tariff_words` is assigned only from the HA price-entity
  branch and from `static_status()`, both on the 30 s tick, and the plan's own
  sentence reaches `_LOG.info`. The doc now says what an operator can see (the
  Price history row, the Arbitrage row's own refusal sentence, the refresh log
  line) and a check pins the PAIRING - wiring the plan into the row is fine,
  correcting the doc is fine, doing one without the other is red.
* L11. "fills EVERY unpublished hour" is conditional: a partial yesterday leaves
  the local hours it never held out of the series (§11.4 for those hours, and
  `at()` raises rather than answering). Correct behaviour, overclaimed wording,
  and untested - all three fixed.
* L12. DECISIONS §1 leaned on `TariffPlan.cheap_now()`, which has NO PRODUCTION
  CALLER on the provider plan (`self.cheap` comes from `static_status()`, which
  builds its own). The conclusion holds on `arbitrage()`'s firm-pair refusal
  alone, which does reach control; the evidence is now stated at the reach it
  has.
* L13. §1's "passed through by identity" check compared `eur_per_kwh` between
  two SEPARATE `resolve_tariff()` calls, where `is` could never have held. The
  value comparison is kept and now says what it grades; identity is graded on a
  read the file constructs and still holds.
* L17. `PriceHistory.words` and the new status row had no check at all - and
  `words` is one of the two things H2 crashed. Both now have one.
* L18. main.py's inline note carried the "widens what the optimiser can SEE,
  never what it may act on" guarantee without the windowing caveat that the
  CHANGELOG, DOCS and `resolve_tariff()`'s docstring all carry. It is the one
  place a maintainer reads it unqualified.

EVIDENCE. All eight reviewer proofs re-run: p1/p1b return 29 March, p4 keeps
60 of 60 firm points at spread 0.134, p5 starts and reports, p6 D leaves no
temp, p6 E refuses 1.0, p6 F records nothing, p7 returns 48 for 48. p2's script
still scores True/True because it re-implements the OLD algorithm - the
reversal is now driven as a mutation instead, and is red. p3's script always
showed the shipped code correct; what was wrong was the fixture, and its mutant
is now red. Twenty mutations driven, no survivors, tree fingerprinted and
`__pycache__` purged before each: decimation (2 suites red), audit x3, unfloored
`until` (2), unvalidated day records (7 checks), instant-subtraction yesterday,
in-memory fill on a volume-less store, clock-arithmetic prune, cap without the
pin, leaked temp, loose schema type, empty `words` (5), reversed `carry()` (2),
a `_remember` leak in tools/ spelled two ways, a neighbour-filling fallback, a
copy instead of identity (2), the DOCS claim in both directions, and the caveat
removed from main.py. Baseline and the unmutated tree both green.

No option added, config.yaml untouched, and the CHANGELOG entry still picks no
version: PKG-07's rule, and only the deployer knows whether 0.9.0's image has
been pulled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-09 23:54:23 +02:00
glenn schrooyenandClaude Opus 5 0618194d9c FCST-04: the carry-forward fill reaches the plan, and the day it carries survives a restart
checks / unit (pull_request) Successful in 7m11s
FCST-03 shipped `fill_from_yesterday()` complete and checked, and shipped the
consumers written for it - `TariffPlan.firm_points()`, `cheap_now()`'s refusal
of a provisional hour, and `arbitrage()`'s refusal 3, which drops provisional
hours from the trade set rather than refusing the whole series. It shipped NO
CALLER. Nothing in `app/` named the fill, so no series ever contained a carried
point and every one of those consumers was exercising its all-firm branch.

⚠️ THE DEFECT THAT PRODUCED WAS NOT AN ERROR ANYWHERE. `load_prices()` returns
what the feed published and nothing widened it to the requested horizon, so on
a dynamic site between midnight and the 14-16 h CET publication the plan
covered the rest of today and nothing more - measured on the fixture here, 24 h
against a 48 h request. `arbitrage()` then took `max - min` over that truncated
window and reported a spread. The number was real, the window was half the one
configured, and nothing said so: tomorrow evening's peak, the hour a morning
charge would be sold into, was not in the series to be compared against
anything. A missing hour is not a wrong price - it is an hour the optimiser
never learns exists.

WHAT THIS ADDS. `app/price_history.py` keeps the PUBLISHED prices of the last
KEEP_DAYS Brussels days in /data/price_history.json; `resolve_tariff()` gains a
required keyword-only `history` and fills every unpublished hour of the horizon
from yesterday's same-wall-clock hour, flagged. Persistence rather than a
variable on the refresh task, because the window the fill exists for is
midnight -> publication and an add-on restarted at 08:00 would otherwise spend
all of it on the truncated series. Not in state.json: the refresh runs every
1800 s and that file holds the maintenance history and SAFETY-02's latch.

⚠️ ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR.
Carried hours are provisional, so §8.3 keeps them out of the trade set: the
fill widens what the optimiser can SEE, never what it may DO, and
test_price_history.py checks that so it cannot be overclaimed later. What does
change a trade is that the filled series is windowed to [since, until), so
hours that have ALREADY ELAPSED are no longer in the spread. Before, a cheap
night that had gone sat in the series as the day's cheapest hour - and a cycle
cannot buy in an hour that is past, so the trade was quietly never taken.
Section 2 grades this by finding the cheapest firm hour with and without the
fill and showing it MOVES, from 02:00 UTC to 08:00 UTC on the fixture.

⚠️ A DEAD FEED IS STILL §11.4, NOT YESTERDAY. The fill runs only on a `series`
read. The two are indistinguishable to the optimiser - both 100 % provisional,
so arbitrage refuses on the firm-pair rung and `cheap_now()` is False - so
preferring yesterday's feed over the `static_profile` an operator configured
FOR that case would change a documented degradation for a difference only the
status page can see. That, and two more, in
DECISIONS/FCST-04-the-day-that-is-carried.md.

⚠️ THE ANTI-DECAY RULE IS AN ORDERING, NOT A VALIDATION, and saying so is more
useful than a guarantee that would not hold. Feeding the filled series back in
is how this rots - day 2 carries day 1's guesses, day 3 carries day 2's carry
of them, behind a `provisional` flag that stays TRUE while the NUMBER decays.
No value-level check can catch it: a carried point and `eneco_eddy`'s
published-provisional quarter are the same three fields with the same flag. So
`carry()` is the only public entry, it records BEFORE it fills, the filled
series exists only as a return value the object never sees again, and
test_price_history.py walks app/ for any other module naming `_remember` and
reads `carry()`'s AST to pin the order.

EVIDENCE. 29/29 test files pass in the CI image. Eight mutations driven against
app/price_history.py and app/static_tariff.py, __pycache__ purged before each
run per MUTATION.md/TEST-15: newest-record lookup (5 red), recording the filled
series (1), replace-instead-of-merge (10), UTC-date keying (10), removing the
fill call entirely (12), a lost price field defaulting to 0.0 (2), prune by
clock arithmetic (1), a degraded reason that stops naming its cause (1). No
survivors; baseline and revert both green. Three of those first crashed the
harness instead of reporting a FAIL - "it was refused" and "it broke on the way
to being refused" must not grade the same - so `why()` and `_priced()` were
added and the run repeated.

No option added, config.yaml untouched, and the CHANGELOG entry deliberately
picks no version: PKG-07's rule, and only the deployer knows whether 0.9.0's
image has been pulled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-09 19:37:45 +02:00
Claude Opus 5 b21e229adf SAFETY-43 round 2: the cut-out "positive control" was vacuous, and the record
checks / unit (pull_request) Successful in 6m29s
stated the wrong mechanism

⚠️ REVIEW FINDING, BLOCKING, AND IT IS THE EVIDENCE THAT WAS WRONG RATHER THAN
THE BEHAVIOUR. Part 8b's cut-out arm claimed an "identical power trace" to the
wedged arm and that "what separates them is `account()`". EVERY PART OF THAT WAS
FALSE, and the check survived deleting the gate it named. Measured:

    cut-out arm, account() live        named 0/200, distinct B shares: [0]
    cut-out arm, account() FORCED None named 0/200, distinct B shares: [0]
    wedged  arm, account() live        named 200/200, shares 77/125/133/154 W

At its own cut-out the pack's usable energy ABOVE that cut-out is zero, so
`weights()` answers 0.0, `_fill()`'s preferred pass gives it nothing, the healthy
sibling covers the load alone so the last-resort pass never runs, and its share
is 0 W ON ALL 200 CYCLES. A unit with a 0 W share is not in `asked`, so
`len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()` is
never consulted. Nothing was identical, "100 % of the ask" was 0/0, and the arm
never reached the comparison it was written to grade.

WHAT REPLACES IT IS A BETTER FACT, NOT A WEAKER ONE: at its own cut-out OPT-09
STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached. That
is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the
allocator declines to ask before the detector has to decline to alarm. So the arm
is kept and re-pointed, not deleted:

  * the 0 W share is now ASSERTED (`_sat_last[1][Bee] == 0`), not inferred from
    the silence - a control that cannot say WHY it is quiet cannot fail;
  * the arm runs a SECOND time with `account()` DELETED (`no_account=True`) and
    must still be silent, which is what proves which mechanism does the work;
  * the account gate's claim is re-pointed at test_delivery.py part 1, where it
    is graded on a REAL positive 125 W share.

⚠️ AND IT WAS INVISIBLE FOR ONE REASON WORTH KEEPING: that was the ONLY line in
part 8b that did not print its shares. `inverter-b+0` would have been on screen
from the first run. Every line in the part prints them now. The derated control's
vacuity guard also now asserts ITS OWN unit is asked, rather than re-asserting a
different run's 200/200 - which is exactly why the old guard could not see this.

ALSO, from the same review:

  N1  app/delivery.py said 0.05 was "twelve times more sensitive" than 0.85.
      0.85/0.05 = 17 and no reading of the pair gives 12. An unsupported number
      in shipped source; replaced with what was actually measured.
  N2  M14's recorded count was 4 named checks in test_delivery.py and it is 5 -
      part 1's pre-existing "a shortfall AT the threshold is not one" also goes
      red. It under-claimed, so it never inflated the evidence, but a recorded
      figure that does not reproduce is still one.
  N3  test_delivery.py part 1 printed the alarm sentence with a bare
      `delinquent(...)["b"]`, which raises KeyError under M12/M13 and ABORTED
      THE FILE AT PART 1, leaving parts 2-8 ungraded. The direction is safe (an
      abort is a red; MUTATION.md's defect fabricates only greens) but the
      recorded coverage read smaller than the truth. It is a `.get` now, and
      M12/M13's counts are re-run: 7 and 7 named checks, not 5 and 6.

CFG-07: the assessment is unchanged (worse by one control cycle, 35.0 -> 40.0 s,
deliberately not fixed here) and now says WHEN it has to close - BEFORE INVERTER
B IS COMMISSIONED (~2026-09-12), because the band is unreachable at N=1 and
commissioning B is when it first becomes reachable - and that CFG-07 HAS NO
DECISION DOCUMENT IN THIS TREE, so whoever takes it writes one first.

No behaviour change. `delinquent()`'s two arms, the fraction, `min_w` and the
N=1 path are all untouched by this commit. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 04:15:07 +02:00
Claude Opus 5 2271e147c6 SAFETY-43: delinquency measured against the ask, not against a flat 500 W
checks / unit (pull_request) Successful in 6m20s
`delivery.delinquent()` named a unit whose `short_by()` gap exceeded an
ABSOLUTE `min_w = Tuning.saturation_w = 500 W`. OPT-09's SoC-aware weights
shrink a low-headroom unit's share, and shrinking the share shrinks the gap, so
a unit standing at +125 W and delivering 0 W - 100 % delinquent - was never
named. Measured end to end on the real loop (test_packshare.py part 8b):

    load 1500 W, B wedged at 15 %, equal weights    named on 199/200 cycles
    load 1500 W, B wedged at 15 %, OPT-09 weights   named on   0/200 cycles

⚠️ The suppressed regime is the quiet one: at a load the healthy sibling covers,
the meter is satisfied, `unmet_w` is 0 and no action-log line fires, so this
alarm is the ONLY instrument that would have told the operator.

WHAT CHANGED, in one line:

    named if  short_by() > min_w  OR  short_by() > frac * share

⚠️ `min_w` WAS NOT LOWERED. It exists to stop normal saturation being reported
as delinquency, and lowering it trades a missed alarm for a false one on every
unit at once - SAFETY-41's four-attempt regime. This is a SECOND way to be
named, not a looser first one: all seven of test_delivery.py part 1's existing
fixtures answer identically under both rules, and the absolute arm's suppression
window is still exactly 1000 W / 6500 W, still asserted in part 8b.

THE FRACTION IS DERIVED, 0.85. admin/goodwe:sim/delivery_window.py gained a
`--fractions` axis and grades the constant over the SAME 2640 no-fault runs that
produced `delivery_hold_s`'s 35.0 s floor. Worst continuous legitimate episode,
worst of three families, twenty fractions:

    frac   0.05  0.10  0.15  0.20  0.25  0.30  0.35  0.40  0.45  0.50
    worst  90.0  90.0  70.0  70.0  60.0  55.0  55.0  60.0  45.0  45.0  s
    frac   0.55  0.60  0.65  0.70  0.75  0.80 *0.85  0.90  0.95  0.99
    worst  40.0  40.0  45.0  45.0  45.0  45.0  40.0  40.0  40.0  40.0  s
    absolute arm alone: 35.0 s (SAFETY-42 §3.3, reproduced in the same runs)

⚠️ NO CLIFF ANYWHERE: 0.45-0.99 is flat within one control cycle, and it is not
monotone - §3.3's own mechanism (c). ⚠️ "Zero false namings" is NOT achievable
at any fraction and the record says so; what is zero is false ALARMS - 90.0 s
worst against a 185 s hold, so nothing comes within 95 s of notifying anybody.
N=1 control 0.0 s at every fraction in all 2640 one-inverter runs; flapping
0.0 s in all 840.

DRIVEN. Part 8b pinned the SUPPRESSED behaviour and said "if it ever goes red
because the alarm fired, read the card that made it fire". It now grades BOTH
rules on one run - `share_frac=None` is SAFETY-42's rule exactly:

    OPT-09 weights, absolute arm alone   named on   0/200
    OPT-09 weights, the shipped rule     named on 200/200

POSITIVE CONTROL. Same fixture, same weights, same 125 W share, one legitimate
fact changed: a pack at its own 10 % cut-out is named on 0/200 (account() still
gates it), and a unit delivering HALF its share is named on 0/200.

N=1 BYTE-IDENTICAL, asserted: `plan()` handed a `weights` mapping that raises on
any read still answers identically at a roster of one, and the same mapping
raises at a roster of two; `delinquent()` answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.

MUTATION, run on fresh :ro trees: M12 (drop the relative arm), M13 (frac -> 1.0)
and M14 (frac -> 0.05) are all red on named checks. The constant cannot move in
either direction unseen.

⚠️ CFG-07 gets ONE CONTROL CYCLE WORSE and is deliberately not fixed here: the
worst legitimate episode goes 35.0 -> 40.0 s, so the schema's `int(30,900)`
false-tripping band widens from [30,35] to [30,40]. CFG-07 should be re-derived
against 40.0 s.

No option value, no schema bound and no default changed. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 03:14:16 +02:00
adminandClaude Opus 5 57775d4e06 TEST-21: delivery_hold_s's floor re-derived, and 30.0 s did not reproduce
No code and no option changes. What changes is a number this repository claimed
about a shipped safety constant.

`delivery_hold_s: 185` shipped resting on "30.0 s, the worst continuous
legitimate episode at the shipped threshold across all 2640 runs", plus a lag
sweep giving 45.0 s and 90.0 s - from a harness `DECISIONS/SAFETY-42` §1
deliberately did NOT commit, because it imports `plantsim` and `frame_check` out
of admin/goodwe. So the floor under a safety constant could not be re-run. That
is PROC-01's shape, with eleven instances in three days.

The harness is committed now, in admin/goodwe as `sim/delivery_window.py`, and
NOT here. `plantsim` and `frame_check` are that repo's; restating them in this
tree is CI-08's coupling, which has broken three times, and the alternative
fate - skipped for want of its imports - is a green that establishes nothing.
There the dependency runs one way: THIS repo's `control`, `arbiter` and the
shipped `delivery.delinquent()` are imported live out of whichever checkout is
under test. Its `--selftest` is now a step in that repo's `checks.yml`, so a
change here that moves `saturation_w`, `max_w`, `delivery_hold_s` or the shape
of `delinquent()` goes red there instead of leaving a stale number here.

    cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
        python3 -B delivery_window.py --selftest --campaign --blag

⚠️ THE HEADLINE DID NOT REPRODUCE: 35.0 s, not 30.0 s. Recorded as measured. The
harness was not adjusted until it agreed - the one correction made during the
rebuild was made AGAINST the doc's number (§1 records it) and made every figure
LARGER.

WHAT REPRODUCED - and it is the part the compatibility promise rests on:
  * the N=1 control arm is 0.0 s at EVERY threshold in EVERY run. §3.2's algebra
    measured, not argued.
  * the flapping family is 0.0 s everywhere in all 840 runs - the family that
    defeated SAFETY-30's site window still cannot touch this check.
  * 45.0 s at twice A's lag, to the cycle.

WHAT DID NOT:
  * the headline, by one control cycle (30.0 -> 35.0 s); the reversal family by
    two (20.0 -> 30.0 s).
  * the NON-MONOTONE curve. §3.3 finding 3 read 55.0 s at 250 W against 40.0 s
    at 100 W as a property of the reduction. This rebuild's curve is monotone.
    The mechanism is real and is still the reason not to read either table as a
    sensitivity curve, but it is an ARGUMENT now, not a measurement, and it is
    written down as weaker.
  * the low-threshold rows, badly (40.0 -> 120.0 s at 100 W). Nothing ships
    there, but that gap is the honest measure of how much of the original table
    was a property of axes nobody wrote down. §3.3 fixes every axis COUNT and
    RANGE and no individual value; the rebuild prints its choices above its own
    table so the caveat travels with the number.
  * the four-times-slower row moved the OTHER way: 75.0 s, not 90.0 s.

185 s IS NOT CHANGED. It is 5.29x the re-measured worst (the record claimed
6.2x), 2.47x the four-times-slower case, 6.2x the firmware's LINK_STALE_S veto
and 4.9x inside the billing quarter. All four bounds clear and none narrowly,
and the ordering of the argument is unchanged. Moving a shipped safety constant
because a margin went from 6.2x to 5.3x while every bound holds would re-open
SAFETY-41's four-attempt false-trip regime for nothing.

⚠️ ONE CONSEQUENCE AN OPERATOR CAN HIT. At 30.0 s the rig's floor coincided with
the firmware's veto and a reader could not tell which was binding. At 35.0 s the
rig's is strictly the binding one - and it is ABOVE the schema's own
`int(30,900)` minimum. `delivery_hold_s: 30` is therefore below a MEASURED
legitimate episode and would report on ordinary saturation. Both translations
now say so. Raising the schema floor is a change to a shipped option's contract
and is left to its own card rather than smuggled in here.

⚠️ THE B-LAG ROWS ARE RELABELLED THROUGHOUT. Inverter B does not physically
exist (~2026-09-12); `plantsim.INVERTER_B` is A COPIED, `measured=False`. Every
two-inverter figure in this record is two copies of A, and the lag sweep is a
SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, not a measurement of it. The old
wording - "MEASURED, not felt" - overstated exactly that, and "the RAMP does not
move it at all" was too strong: halving it is worth 0 s at A's lag and +5 s at
twice it.

Touched: DECISIONS/SAFETY-42 §1 and §3.3, app/delivery.py's provenance comment,
config.yaml's `delivery_hold_s` block, both translations, CHANGELOG Unreleased.
26/26 suites pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 21:47:08 +02:00
Claude Opus 5 21f22b393a UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02
checks / unit (pull_request) Successful in 6m20s
UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is
LIVE, so all three describe the running install. No behaviour, no schema and no
default moves here: config.yaml, translations/ and every module under app/ are
byte-identical to 6e7b233.

⚠️ AC 3 - "the visible count drops to ~15" - IS WITHDRAWN AS UNSATISFIABLE, and
the arithmetic is the reason rather than the effort. options: and schema: both
declare all 71 keys, so the set HA's toggle can hide is EMPTY - it hides a key
only when it is ?-optional AND absent from options:, and nothing is absent. But
the deeper fact is that no manifest could have met it: Supervisor's own
UiOptions marks 59 of the 71 REQUIRED, and the frontend's own filter -
`schema.filter(e => e.name in opts || e.required)`, transcribed out of the
bundle in the release image - shows a required field unconditionally. So 59 is
the floor with options: EMPTIED ALTOGETHER. The target was ~4x below anything
reachable.

⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH.
A key absent from options: has no default for the Supervisor to deep-merge into
an existing box's stored blob. That is exactly the delivery_hold_s mechanism
this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to
its options: default of 185 instead of becoming exit 78. Hiding a key is
precisely the edit that removes that. It would also promote the 27 unpinned
in-code literal fallbacks the new check ledgers into the ACTUAL source of the
defaults on a live install - the CFG-03 defect the same card's AC 4 exists to
police, made load-bearing by its AC 3.

⚠️ THE COUNT WAS PRINTED AND NEVER GRADED, WHICH IS HOW THIS PASSED REVIEW.
supervisor_shape_probe.py section F has printed "the Configuration tab renders
71 fields" since the day it was written and exited 0 through UX-01's whole
review while displaying the number that contradicted the criterion it was
evidence for. Three assertions replace the bare print, every number computed
from the manifest and from Supervisor's own `required` flag rather than typed,
including an ISOLATED COUNTERFACTUAL that empties options: and nothing else.
MEASURED RED: delete one key (price_feed_url) from options: and two of them
fail - `Hideable: ['price_feed_url']`, `70 of 71 fields render` - exit 1.

--- AC 4: the AST check, built --------------------------------------------

AC 4's first clause is VACUOUS over an empty set (see above) and its second -
"the table changed goes red" - already works six times over. The third, the one
with the content, was never written: the only literal scan in the repository is
test_validate.py's regex over eight maintenance_* keys, which is a grep and
predates the card.

test_defaults_pin.py walks ast.Call nodes over app/*.py. It:

  - DISCOVERS the six *_OPTION_DEFAULTS mirrors from the tree rather than
    naming them. Every existing pin names its own table by hand, so a SEVENTH
    table added tomorrow is pinned by nobody and nothing says so;
  - grades all 30 mirrored keys against config.yaml, and requires that no
    module carries a literal fallback for any of them;
  - FREEZES the CFG-03 backlog at exactly today's 27 (module, key) pairs, by
    pair and not by count - a count goes green again the moment one site is
    deleted and another added. Line numbers are deliberately out of the key: a
    ledger that reddens on an unrelated reformat is one people re-bless without
    reading. It can only shrink;
  - grades every remaining literal AGAINST config.yaml, which is the half that
    catches CFG-04's actual failure - two copies of one number, one moves.

⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for:
main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10.
It cannot reach an operator - the deep-merge guarantees the key is present - and
the only paths that hand over a partial dict are sim/inject.py and this repo's
fixtures. Recorded as the single permitted divergence with its reason rather
than corrected; changing it alters the control loop's target and belongs to a
card that grades the loop. A second divergence goes red.

⭐ THE RED DIRECTION WAS RUN, TWICE OVER. Part 5 feeds the scanner a synthetic
module containing a deliberate violation of each rule and REQUIRES it to be
caught, in-process, through the same function the graded parts call - so a
scanner that goes blind stops working there too - and the site total is graded
against a floor. And a literal was planted in the REAL tree:

    main.py:290  float(opts.get("maintenance_charge_w", 2000))
    -> FAIL no literal fallback survives for any of the 30 keys a mirror
       already answers for                                          exit 1

    main.py:290  opts.get("p1_host", "x"), opts.get("slew_w", 999)
    -> FAIL no NEW module/key pair has started carrying a literal fallback
            [('main.py', 'p1_host')]
       FAIL every remaining literal still AGREES with config.yaml
            main.py  slew_w: code 999 vs config.yaml 0               exit 1

Both plants removed.

--- AC 5: CFG-04 is closed, and not by this card's predecessor -------------

fef6ef2 touched config.yaml, translations/, test_translations.py and the probe -
no defaults code at all - and on 0.7.0 the max_w 2000 -> 2500 triple mutation
survives 21 files, 0 RED. CONFIRMED CLOSED on release/1.0 by re-running the same
triple mutation on a throwaway copy of 6e7b233: 26 files, RED at
test_selfconfig.py, exit 1 -

  1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches
  the rail collapsing to the 2000 default under an explicit 400 W slew

git log -L 310,325:test_selfconfig.py attributes that check to bcee235,
"UX-02: pin both halves of the max_w fix, because neither was graded". The AC is
met BY UX-02 and ships with the pending 0.9.0 deploy. Nothing was re-done.

--- the 8 per-entry translations: they stay --------------------------------

Measured negative and not a defect in them: UiOptions gives inverters type
schema WITH multiple, the frontend sends that case to ha-selector-object, which
labels fields `fields[name].label || name`, and the converter sets neither label
nor translation_key. The Supervisor tab CANNOT be made to render them. They stay
because UX-02's ingress panel is the surface that can, because they cost
nothing, and because deleting them removes the only thing that would catch a
per-entry key added without a translation - which is how SAFETY-32's REQUIRED
bms_soc_floor would have joined the roster unlabelled. A written decision, not a
change; translations/ is untouched.

tools/supervisor_selfapi_probe.py stays red on its hardcoded len(_sub) == 7.
That is TOOL-04, already filed, and it is not re-filed or fixed here.

--- evidence ---------------------------------------------------------------

  positive control  test_defaults_pin.py's mirror floor moved 6 -> 7 inside the
                    harness container: "### RED: test_defaults_pin.py", exit 1
  suite             27 files, ALL GREEN, exit 0 (goodwe-addon-checks:1, the
                    checkout mounted read-only)
  shape probe       all cases passed, exit 0, on
                    ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0

Nothing here touched hardware, the live Home Assistant at 192.168.2.158, or
sim/. Both probe runs were throwaway containers over a read-only mount.

DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md carries all four
decisions, the frontend transcription, and the two counterfactual transcripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:29:48 +02:00
glenn schrooyenandClaude Opus 5 8e3734f5ea PKG-12: the live options WERE checked, and the changelog said they were not
checks / unit (pull_request) Successful in 6m17s
Review approved the bump and, in doing so, dated one of its own paragraphs.
When this section was written the pre-deploy check had not been run and the
changelog said so, correctly. The owner then exported the reference install's
options and the check cleared, which makes the shipped sentence false in the
release notes an operator reads at the hardware.

Replaced with what was measured, twice:

  stored blob as-is                          exit 0, CLEARED
  effective post-upgrade config              exit 0, CLEARED
    (the 70 stored keys merged OVER 0.9.0's
     71 config.yaml defaults)

  max_w 5000, slew_w 400, rule-10 ceiling 555.6 W.

⚠️ AND THE SECOND CHECK IS NOT CEREMONY. The stored blob has 70 keys, not 71:
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so a box running 0.7.0 has
never stored it, and it is REQUIRED in the schema with no trailing `?` - the
exact shape that becomes exit 78. It resolves to its `options:` default of 185
because the Supervisor sets `App.options = deepmerge(defaults, stored)`, which
`tools/supervisor_selfapi_probe.py` §D pins against a real Supervisor image.
Review re-derived that independently rather than taking the reasoning on trust.

"Your saved settings are valid" and "what the box will actually run is valid"
are different questions, and only the second one is the one being asked before
a deploy.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 00:47:11 +02:00
glenn schrooyenandClaude Opus 5 86bdf49332 PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship
checks / unit (pull_request) Successful in 6m14s
⚠️ MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK, AND THAT DISTINCTION IS
CHECKED RATHER THAN ASSERTED. PKG-07 was minor because a config that started on
0.7.0 could be REFUSED on 0.8.0. Nothing of that kind is true here:

  - `options:` and `schema:` hold 71 top-level keys on both 06661c5 and HEAD.
    The one schema addition is a PER-ENTRY key, `capacity_kwh: float(0.1,200)?`,
    and the trailing `?` makes it OPTIONAL.
  - An unmodified 0.8.0-era config validates clean on 0.9.0. Measured through
    the shipped validator on both the flat single-inverter shape the reference
    install runs and an N=2 roster with no `capacity_kwh` on any entry: 0
    problems each.
  - OPT-09's two new refusals fire only on configs that USE the new key - an
    unparseable value, or the key stated on one entry and left empty on another.
    Neither can reach a config written before the key existed.

PKG-07's first draft justified its bump with an invented compatibility break and
review caught it. This one states what was measured, and the measurement says
existing installs upgrade untouched.

Contents since 0.8.0 (`06661c5`): UX-02 (`3d29993`), OPT-09 (`fdb3b69`),
PKG-09 (`fcaa724`).

⚠️ THE VERSION WENT UNBUMPED FOR THE THIRD TIME IN THREE DAYS - PKG-06 six
merges, PKG-07 six, this one five - and this time PKG-09's own tool caught it
during PKG-09's review rather than a person. The changelog carries OPT-09's
disclosed N=2 detection regression and points at the pre-deploy check.

⚠️ STILL NOT DONE, AND STILL NOT DOABLE FROM HERE: the live options have not
been checked against this version's validator. The gate reports exit 2 - NOT
CLEARED - rather than clearing, which is correct. Export the options from the
Supervisor and re-run before `ha apps update`.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 00:18:36 +02:00
glenn schrooyenandClaude Opus 5 cab267c4f3 OPT-09: the 6500 W window is a bound the ratings never let the site reach
checks / unit (pull_request) Successful in 6m10s
Round-2 non-blocking findings, both fixed.

⚠️ THE WINDOW FIGURE WAS ARITHMETIC NOBODY HAD RUN. `min_w * (w_A + w_B) / w_B`
= 6500 W is correct about `_fill()`'s PROPORTIONAL regime and is not a
reachable steady state: the healthy pack's own 5000 W rating binds first.

SWEPT on part 8b's own fixture, reproduced rather than transcribed:

    load   equal   OPT-09   site target   A      B
    1000   199/200   0/200       1080    997     83
    2000   199/200   0/200       2170   2003    167
    3000   199/200   0/200       3250   3000    250
    4000   199/200   4/200       4340   4006    334
    4500   199/200   8/200       4890   4514    376
    4900   199/200  41/200       5320   4911    409   <- largest suppressed
    5000   199/200 195/200       8000   5000   3000   <- A at its rating
    6000   199/200 195/200       8000   5000   3000

So the alarm returns BELOW the formula bound, not above it, and the largest
site target reached with the wedged unit still under the 500 W threshold is
5320 W - not 6500. The equal-weights "suppressed below 1000 W" bound is
unreachable for the same reason: the equal arm names the wedged unit 199/200 at
EVERY load swept, 1000 W included.

The formula and its check are kept, because the mechanism they state is right
and it is the mechanism that generalises to other ratings. What was wrong was
the sentence around them, in four places, implying the site passes through the
bound. Each now carries the sweep and says the rating binds first.

⚠️ THIS NUMBER CAME FROM THE REVIEW, NOT FROM THE BRANCH, and was adopted in
good faith by the implementer. The reviewer found it in round 2 and said so.
That is worth recording: a figure arriving from a trusted reviewer got less
scrutiny than one written by the author, and PROC-01's eight fabricated numbers
in two days is now nine. The rule that catches it is the same either way - run
it before you ship it.

Also: the DECISIONS header's base sha, stale for the third time (464d398 ->
3368231 -> 3d29993, moved by two rebases and caught by review each time). It now
records that history, and the header is why `test_translations.py`'s counts are
computed rather than typed.

SAFETY-43 is now named by id where the follow-up is referenced, so a reader can
follow the thread from the disclosure to its closure.

Suite: 25 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 23:26:22 +02:00
glenn schrooyenandClaude Opus 5 8d83ef0dfb OPT-09: the weights DO move on a wedged unit, and that narrows SAFETY-42
checks / unit (pull_request) Successful in 6m11s
B1, the blocking review finding. §5 of the decision said:

    "A wedged unit reads a healthy SoC, so no envelope closes and no weight
     moves - OPT-09 cannot see it at all."

The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. A weight is a function of a pack's ENERGY HEADROOM, not
of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's - the ordinary case this card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.

So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit. That is a DETECTION REGRESSION at
N=2. It is disclosed here, not fixed: making delinquent() relative to the ask
rather than absolute is SAFETY-42's code and a design change, filed as its own
card.

1. THE CORRECTION, in the three places that carried the claim

   * DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam
     accurately, quotes the sentence it is replacing, carries the measured
     rows and the window arithmetic, and names the residual in the same voice
     as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not
     exist". Also drops "they meet and do not overlap" from the opening.
   * app/packshare.py - a new section in the module docstring, immediately
     after "weights are a comfort choice for stability and not for delivery",
     which is where the over-read starts.
   * CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the
     operator-facing sentence: a wedged inverter with less energy headroom
     than its sibling can now go unreported at ordinary house loads.

   test_packshare.py part 8's closing check and its "what this file cannot
   see" list repeated the same false clause; both now say the ENVELOPE cannot
   reach a wedged unit and point at part 8b for what the WEIGHTS do to it.

2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks

   The reviewer's probe, reproduced: real compute(), real plan(), real
   packshare.weights(), real delivery.account(), real delivery.delinquent();
   the only model is the plant (A obeys within one cycle inside its rating,
   B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against
   its own 10 % floor, so envelope() does not close and account() explains
   nothing - a SAFETY-42 true positive by that card's own definition, which
   a PRECONDITION check asserts before anything is concluded from it.

       load 1500 W, B wedged at 15 %, equal weights  named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 1500 W, B wedged at 15 %, OPT-09 weights named on   0/200 cycles,
         site target +1630 W, shares a+1505, b+125,  grid -5 W, named=(nobody)
       load 6000 W, B wedged at 15 %, equal weights  named on 199/200 cycles
       load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles

       suppression window: equal weights, site target < 1000 W; OPT-09
       weights, site target < 6500 W of a 9600 W site

   THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that
   the weighted arm names NOBODY where the equal arm names the wedged unit.
   A check asserting the alarm still fires would fail, and papering over it
   would be worse than the original defect. The 6000 W row is there so the
   part is not pinning "the weighted arm never names anybody": past the
   healthy pack's own rating, _fill()'s last-resort pass hands the wedged
   unit 3000 W and both arms name it. The window is real and it has an edge.

   MUTATION - packshare.weights() keeps its shape and forces every weight to
   1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree
   with python3 -B, not reasoned about:

       load 1500 W, B wedged at 15 %, equal weights  named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
         site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
       load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
         site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b]
       suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W

     FAIL  AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...]
     FAIL  ...and the arithmetic behind it, stated as a window [...]
     FAIL  AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...]

   test_packshare.py exits 1. Restored: exits 0.

3. THREE PROC-01 PROSE DEFECTS

   * DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @
     464d398". The branch was rebased; git merge-base HEAD 3368231 is
     3368231. Corrected, and it says why the old number was there.
   * Same file:13 cited main.py:2272 for "resolution = resolve(...)".
     git show 3368231:goodwe_controller/app/main.py | grep -n says 2422.
     Corrected. The sibling citation arbiter.py:535 is right and is left.
   * DOCS.md:174 said "Since 0.7.x the split is driven by two separate
     things", which tells an operator the fix already shipped. config.yaml
     is 0.8.0 and the live install is 0.7.0, where the split is 50/50 - this
     card's own premise. Now "from 0.8.0 onwards", with an explicit warning
     that 0.7.0 and earlier are a fixed 50/50.

NOT TOUCHED, deliberately: delinquent()'s absolute threshold (SAFETY-42's
code, its own card), and app/main.py:2699's double-logged scope refusal (N4,
filed separately).

INVERTER B DOES NOT EXIST (expected ~2026-09-12). Every two-unit figure above
is TWO COPIES OF A through a plant modelled in test_packshare.py; B's 4600 W /
20.48 kWh / 10 % floor are from the datasheet and the serial. Nothing here was
measured on hardware, and the added disclaimers say so in each place.

Verified with the real harness per .gitea/workflows/checks.yml
(goodwe-addon-checks:1): COLLECTED 24 files, ALLGREEN. Harness positive
control run first - test_validate.py deliberately broken, harness reported
"FAILED: test_validate.py" and exited 1; restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:55:20 +02:00
glenn schrooyenandClaude Opus 5 f8e501393f OPT-09: the weights parameter nothing ever passed, and what it cannot do
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:

  * a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
    `bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
    pack is not asked to discharge and its share goes to the sibling;
  * SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
    `_fill()`'s last-resort pass will override the moment the sibling saturates.

Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.

Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.

SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.

Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.

⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:55:20 +02:00
glenn schrooyenandClaude Opus 5 fdaf3fb54f UX-02: the validator's refusals, next to the field, before the save
checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.

WHAT IT ADDS
  app/configui.py     the field model, built from `GET /addons/self/info`'s own
                      `schema` + `translations`. No option key, no label, no
                      rule of its own. UX-01's `Group · ` label prefix becomes a
                      real section heading, in whichever language resolved.
  app/selfconfig.py   the Supervisor conversation and the write gate.
  app/config_page.py  the page. Its longest function decides which HTML input a
                      type deserves; it contains no rule.
  app/web.py          five routes. Still the only file a reviewer has to read to
                      answer "does anything here reach the inverter".

THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
  1. A blob the SHIPPED validator refuses is never written - not warned about,
     not written. An invalid blob plus a restart is exit 78, and the panel is
     gone with the add-on that serves it.
  2. A blob that moved underneath is refused, not clobbered. Read hands out a
     sha256 of the canonical blob, save hands it back, and the stored blob is
     re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
     applied to configuration - "a blob reaches the Supervisor only if it was
     derived from the blob stored right now" - with the honest weakening stated
     in the decision record: a second writer is DETECTED, not made
     inexpressible.
  3. Every message is the validator's own, byte for byte. test_selfconfig.py
     part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
     under app/ to show validate.py is the only one that differs, and serves the
     real /config/validate route out of that copy in a subprocess. The new
     sentence arrives, filed under the key it names.
  4. Nothing on the surface commands an inverter or clears a latch. Saving does
     not restart: applying costs a control interruption - the wind-down commands
     0 W on every inverter - so that is a separate, confirmed button that says
     so first.

MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
  - the three self-paths are permitted, and `/addons/self/info` is admitted by
    the `default` role too while `options` and `restart` rest on the bypass
    ALONE. The probe's first draft claimed the role admitted none of the three
    and went red.
  - `/addons/self/options/validate` is NOT reachable from inside an add-on, so
    the only validator this panel can call is ours - which is the one with the
    cross-field rules.
  - a POST that omits a key does not preserve it: the key reverts to
    config.yaml's default. Hence whole-blob read-modify-write.
  - the Configuration tab CAN be removed, and doing so drops 0-of-71 options
    through and takes the add-on down. The card said it could not be; it stays
    by decision, which is a better reason.

DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.

Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

---

⚠️ REBASED ONTO release/1.0 @ 3368231, AND HISTORY DELIBERATELY FLATTENED.
The original branch carried `653874d` - an ORPHANED first attempt at PKG-07
whose commit message justified the version bump with a claim that was FALSE
(that `slew_w: 0` used to mean "no limit"; the schema was `int(50,5000)` from
the repo's first commit, so 0 was never legal). That commit's CONTENT was
superseded when release/1.0 was merged in, and the merge resolved correctly -
one 0.8.0 section, no fabricated text. But the retracted claim would have
entered release/1.0's history as a commit message with no retraction attached
to it, where PKG-07's own merged commit is the thing that retracts it.

So the branch is replayed as a single commit on top of the corrected PKG-07
rather than merged with the bad one trailing behind. The tree is byte-identical
to the reviewed branch tip; only the ancestry changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 21:38:31 +02:00
glenn schrooyenandClaude Opus 5 06661c5e17 PKG-07: bump 0.7.0 -> 0.8.0, because 0.7.0's own default is now a refused config
checks / unit (pull_request) Successful in 6m6s
Six merges landed behind an unbumped 0.7.0 - CI-06, SAFETY-38, SAFETY-39,
SAFETY-37, SAFETY-13, SAFETY-42. PKG-06 recorded this exact failure YESTERDAY
(bbc903b, 2026-09-05 19:23): the Supervisor keys the built image by `version:`,
so `ha apps update` against an unchanged version is a silent no-op and the box
keeps running the old tree. Catching it by hand a second time is the argument
for the automated check PKG-06's follow-up still owes.

MINOR rather than patch, and the reason is sharper than a semantic change:
SAFETY-39's validator rule 10 refuses an explicit `slew_w` above
SLEW_RAIL_FRAC x the site rail - 555.6 W on a 5000 W rail - and 0.7.0's own
SHIPPED DEFAULT was `slew_w: 1000` under schema `int(50,5000)`. An install that
never touched the key is refused on 0.8.0. Refusal is SystemExit(78); the
add-on does not start.

⚠️ AN EARLIER DRAFT OF THIS COMMIT JUSTIFIED THE BUMP BY CLAIMING `slew_w: 0`
USED TO MEAN "no limit" AND NOW MEANS "follow the rail". THAT WAS FALSE AND IS
REMOVED. The schema was `int(50,5000)` from the repo's first commit through
0.7.0, so 0 was never a legal value and never meant anything; and the old
`control.py` had no zero case - at slew_w=0 it pinned the command to its
previous value, freezing it, which is the opposite of "no limit". The widening
to `int(0,5000)` is a NEW capability, not a changed meaning. The bump is right;
the story told for it was invented - PROC-01's defect appearing in the very
commit that complains about process.

⚠️ AND THEN A SECOND ONE, IN THE SAME COMMIT: the text said PKG-06 was "three
days ago" when the two commits are about a day apart (2026-09-05 19:23 ->
2026-09-06 20:03). Caught in round 2 and corrected. Two invented facts in one
documentation-only change is the measurement PROC-01 wants, not an anecdote:
both were plausible, neither was load-bearing, and both would have shipped.

⚠️ THE PRE-DEPLOY CHECK THIS IMPLIES IS NOT DONE AND CANNOT BE DONE FROM HERE.
Whether the LIVE options survive rule 10 depends on the live `slew_w`, and the
options blob is only reachable through the Supervisor API, which needs a token
this session does not have; the HA REST API does not expose it and no entity
carries it. On record `slew_w` was set to 400 during the SAFETY-39 interim fix,
and 400 is below 555.6 - but that is a written record, not a reading, and the
value has been hand-edited since. Read the options and check them BEFORE
`ha apps update`. Exit 78 also takes the ingress panel down, because the add-on
serving it is not running.

The contents table now carries each merge subject's WHOLE claim. Two rows had
been trimmed to their flattering halves - SAFETY-39's "`gain` is the constant
that is still wrong" and SAFETY-42's "the discriminator the card asked for does
not exist" - and both are restored.

Separately, `## Unreleased` was a lie twice over: two headings by that name, one
spanning ~3000 lines across three releases, one from the 0.4.x era, and every
word under both is shipped and running. Retitled, not re-sorted; the split is
PKG-08.

Reviewed twice. Round 1 REJECTED for an empty PR - built in a clone a concurrent
agent was using, and "pushed" reported from the command's echo rather than from
the remote ref - and for the fabricated slew_w story. Round 2 APPROVED with the
date as a non-blocking finding, fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 20:16:39 +02:00
Claude 090a8ecc35 SAFETY-42 round 2: pin the two thresholds at a config that is not the shipped one
checks / unit (pull_request) Successful in 6m7s
THE BLOCKER. At the `delinquent()` call site, `self.tuning.saturation_w` ->
`250.0` (-19 B) was a survivor of all 23 suites. It is not an equivalent
mutant: two copies of inverter A, B wedged, `saturation_w: 1800` (inside its
own schema int(100,2000)) and a load of 1600 W put B's shortfall strictly
between the literal and the configured value - head names nobody, the mutant
names B. At the shipped 500 both name it, which is exactly why every fixture
in part 4 was blind: their gaps are all above both thresholds.

It matters beyond a mutation score. `delivery.py`'s MIN_W_IS_SATURATION_W note
and the `delivery_hold_s` block in config.yaml both promise an operator, in
shipped prose, that there is no fourth copy of a threshold to keep in step and
no way for the two to drift - raise `saturation_w` and this check moves with
you. A literal at the call site removes exactly that promise, silently, and
the promise is why the design was chosen over a `delivery_w` key.

ASKING THE SAME QUESTION OF EVERY OTHER NUMBER THIS CHECK READS FOUND A SECOND
SURVIVOR OF ALL 23 SUITES: `bms_floor=inv.bms_soc_floor` -> `10.0` (-13 B),
invisible because config.yaml's own example states 10 % and every fixture in
this file used it. Two arms bracket it: a pack at 15 % against its own 25 %
cut-out is cold and must stay unnamed (the literal names an empty pack), and a
pack at 8 % against its own 5 % cut-out is wedged and must be named (the
literal explains the fault away). One arm on each side leaves no constant at
all.

`delivery_hold_s` and `is_live()`'s window were ALREADY pinned - the fixtures
had to move them to run - and `LINK_STALE_S` has no option behind it, so
"pinned across a changed config" is not available for it and is not claimed.
DECISIONS section 9 enumerates all of it with the deltas and which check kills
which.

Verified in the CI image, tree mounted :ro, `python3 -B`, `__pycache__` purged
and counted before every run (TEST-15):
  R5 saturation_w -> 250.0                    -19 B  killed by part 4b(a)
  R6 bms_soc_floor -> 10.0                    -13 B  killed by part 4b(b), both arms
  R7 arm(self.delivery_cfg) -> DeliveryConfig()  -1 B  killed (already)
  R8 is_live(now) -> is_live(now, 3600.0)      +8 B  killed (already)
  R9 pack cut-out -> conjoined site floor    +105 B  killed twice
  head: 23/23 test files PASS, no bytecode written during the run.

No shipped behaviour changes: the only edits to app/ are comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 19:25:45 +02:00
glenn schrooyenandClaude Opus 5 706e625d99 SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
A unit standing at a non-zero share and producing nothing is seen by NOTHING.
SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a
unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the
meter in direction. Circulating power is exactly zero when one unit does
nothing. The only trace left is `reason == "saturated-freeze"`, which
clamp.py:93-96 documents as the alarm an operator learns to ignore. It is
HW-01's shape, which ran 6 h 25 m with all three layers spectators.

⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE
MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while
|grid error| > X, both derived". Run on the two-master rig it fails in BOTH
directions, and no (N, X) fixes either:

  FALSE POSITIVE  `plantsim.Plant.limit()` returns 0 W for a discharge command
      into a pack at its own floor, and a wedged unit produces 0 W too. Load
      2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE
      IDENTICAL ON ALL 120 in grid, site target, every share and every measured
      watt. Both saturated-freeze on 116 of them, both hold |grid error| over
      300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The
      predicate's inputs do not differ, so no threshold on them can differ. And
      "a cold pack at its floor must not alarm" is the card's own AC 2.

  FALSE NEGATIVE  the standing grid error is what a HEALTHY SIBLING ERASES.
      Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop
      is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing
      at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run
      on the exact fault the card describes.

WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable
because of a reduction that only exists at N>=2:

    a unit is named while it is short by more than `saturation_w` with NO
    ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units
    asked to discharge.

THE ACCOUNT keeps the cold pack out and is the only thing that can: the two
cases are identical in the power domain, so the discriminator has to be a
non-power fact, and there is exactly one - the pack's own SoC against its own
`bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than
transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with
`peak_soc_floor`: that floor bounds a money-saving discharge only and using it
would explain away a wedged unit anywhere between the two.

THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see
hits every unit; one box that stopped listening does not - and it is what makes
this INERT AT N=1, by algebra rather than by a roster guard.

IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping
the wedged unit from `live` genuinely recovers a site the shipped path does not:
at load 1500 W, kept in the split the site settles at 800 W with a 700 W error
and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the
sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What
refuses it is that the only re-allocation this add-on can express is
all-or-nothing: a unit derated to 50 % is short by more than saturation_w with
nothing to explain it, so this detector names it too, and at load 3000 W
dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE.
A partial re-allocation is what would make acting safe and the allocator cannot
express it. That is a card.

control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it.

NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so
there is no fourth CFG-03 copy and it cannot drift from the number the control
law itself calls "not following". `delivery_hold_s: 185` comes off 2640
closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over
the whole schema range, across load reversals, a flapping load and ASYMMETRIC
SPLITS - a family SAFETY-30's campaign did not have and the one that dominates
here) plus 1890 runs at a roster of ONE as the control:

    threshold   reversals   flapping   weighted   N=1 control
     * 500 W      20.0 s      0.0 s      30.0 s      0.0 s

The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping
column - the family that defeated SAFETY-30's site window - is zero too, because
a common stimulus makes both units short at once and the subset stops being
proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W):
lowering the threshold can turn a proper subset into the whole roster and BREAK
the run.

⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY
EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter
figure above is two copies of A. Sweeping a different second unit: the worst
legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and scales
with its LAG - 45.0 s at twice A's measured 10 s, 90.0 s at four times it.
185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x
LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is
SAFETY-02's site hold.

SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per
unit, re-raised on the first cycle of every process, cleared by the Start press.
No flat mirror: a rollback lands on a version that drives one actuator, where
this check is inert.

⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a
version bump before it ships. Additive and defaulted, so an existing options blob
stays valid.

STATED GAPS: charge shares are not graded (no configured charge ceiling exists,
so a constant-voltage taper cannot be explained and arming it would fire on every
maintenance charge); every unit wedged at once is not caught (the subset is not
proper and no instrument here separates that from a plant that cannot).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 19:25:45 +02:00
glenn schrooyenandClaude Opus 5 f156fadac0 SAFETY-13: the bound came from the wrong half of a two-sided measurement
checks / unit (pull_request) Successful in 5m34s
NOTES.md 5.10 measured the 1.78 kW step BOTH ways - "Up: ~18 s from step to
deadband. Down: ~23 s" (NOTES.md:1550). SAFETY-39 takes 18 correctly: its
bound is a REFUSAL and the looser number refuses less. This branch reused
PLANT_SETTLE_S for the opposite job - how long the plant may still be the
EXPLANATION for a divergence - where the conservative half is the longer one.
ceil(23/2) = 12, not 9, and being short there does not refuse anything, it
accuses.

The false positive is real and is measured here. One inverter, one compute(),
one plant, no second writer anywhere in the harness: the command falls through
zero while the pack is still coasting the old way, unasked_power() calls our
own power "unasked", the counter reaches 9 while it is still coasting, and the
law emits "freeze-released". At the plant's own measured 18 s/23 s shape, over
272 load-step pairs, the 9-cycle bound fires in 62 and the 12-cycle bound in 2.

  PLANT_SETTLE_DOWN_S = 23.0 is added beside PLANT_SETTLE_S = 18.0, because
  the plant's settling time is a PAIR and each bound has to say which half it
  means and why.
  SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) = 12. It is no
  longer the reciprocal of SLEW_RAIL_FRAC; that was a coincidence of both
  reading the same half, and test_control.py now asserts the inequality.
  _SAT_COUNT_CAP moves with it, 10 -> 13. config.yaml accepts
  saturation_cycles as int(1,10), so the whole accepted range is now strictly
  BELOW the bound and the degenerate configuration this branch used to
  document no longer exists - pinned against config.yaml's own schema line.

Three shipped statements were falsified by that measurement and are rewritten,
not renumbered - at 12 the residual is smaller, not zero:

  unasked_power()'s docstring said anything outside the interval "was put
  there by something other than this loop". It was our own power: eight cycles
  after a +1990 W command the command is 0 and the pack still reads +522.3 W.
  control.py said "SO THE BOUND BELONGS TO (b) AND ONLY TO (b)". It fired in
  case (a).
  DOCS.md told an operator "if you see freeze-released, look for the second
  writer" - on a single-inverter install, hunting something that does not
  exist. Replaced by a section that gives the innocent causes first (a large
  load or PV swing; a plant slower than the one measured, 8/320 scenarios at
  23 s rising to 268/320 at 50 s) and the second writer only when the state
  STANDS.

Round 1's own results are kept and re-graded, not weakened. The release/1.0
sweep had to widen to reach the new bound (3780 cases): 3267 bit-identical,
432 releases, 27 command changes - all still charge starts from prev_w == 0 -
and 81 differing in the saturation counter alone, which nothing downstream can
read. The card's case releases on the twelfth saturated cycle instead of the
ninth and settles at the same -1190 W against the same frozen integrator.

Also corrected, because they were claims and did not reproduce: the coverage
table's "output freeze" row was 11, and the mutation form its own note
describes gives 6 on this tree, 6 on the first SAFETY-13 commit and 2 on
release/1.0; the detector row was left at 11 and is 15; the frozen = False
footnote was left at 10 and is 13. And section 6 reported 107 625 as "released
states" when it is the number of states SWEPT - 67 500 of them release, and
both are counted now.

Nothing here reached the inverter. 22/22 test files pass in goodwe-addon-ci,
tree rsynced to a plain path, mounted :ro, python3 -B, __pycache__ purged and
printed first (0 dirs / 0 .pyc; the worktree carries none).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 18:32:03 +02:00
adminandClaude Opus 5 6c4565659b SAFETY-13: the output freeze gets an exit, and only where it needs one
checks / unit (pull_request) Successful in 5m30s
While the loop is frozen the command's magnitude may fall but never rise. At
prev_w == 0 there is no magnitude left to fall, so a charge cannot START. That
refusal is deliberate and it is kept: the inverter reporting power we did not
command means another writer is on the actuator, which is the §1.5 non-goal.
What it did not have was an END - nothing cleared it but the other writer
leaving.

Reproduced on release/1.0 @ c8dcb38, at the live install's shape (max_w 5000,
slew_w 400, target_grid_w -10), with another writer holding the pack at +700 W.
The route is not the obvious one: entering from an export, the slew limit moves
the command off zero before the counter reaches 3. It is entered from the
DEADBAND - a site resting at its target counts saturated cycles while nothing is
wrong - and 0 W is then a fixed point: a 2 kW export arriving afterwards leaves
the command at exactly 0.0 W on every subsequent cycle.

And prev_w == 0 is not where it stops. The same tuning pins at -800 W against
its own integrator standing at -2388 W, with the same absence of an exit. So the
change is written against the output freeze, not against a special case of it.

THE DECISION IS WHICH FREEZES GET AN EXIT, NOT HOW LONG THE BLOCK LASTS.

  - A divergence a LIMIT could have produced - the inverter doing less than the
    command, in the command's own direction - clears itself, because the command
    is pinned at something the inverter can reach. No exit. release/1.0
    unchanged, and asserted so: a curtailed inverter is never released over 30
    cycles.
  - A divergence a limit could NOT have produced - overshooting the command, or
    the opposite sign - cannot clear itself, because clearing it needs the
    inverter to track OUR command. That one releases.

unasked_power() is that predicate and it is physical, not statistical: a limit
can only make the inverter do less, in the command's own direction, so
everything a limit can produce lies in the interval spanned by 0 and prev_w
widened by saturation_w. At prev_w == 0 it IS the saturation test, which is
exactly why the zero case is the one that strands.

The bound is not a new number. SETTLE_CYCLES = ceil(PLANT_SETTLE_S /
LOOP_TICK_S) = 9, SAFETY-39's two measured constants and the reciprocal of
SLEW_RAIL_FRAC: a divergence still standing after the plant's own settling time
is not this plant catching up. _SAT_COUNT_CAP is written as SETTLE_CYCLES + 1;
its value is unchanged at 10.

RELEASING THE OUTPUT FREEZE DOES NOT UNFREEZE THE LOOP, which is what makes this
bounded. The integrator freeze is untouched, so a released cycle can only move
the command toward an accumulator that stopped accumulating, by at most one slew
step, and never past it - swept over 107 625 released states. On the run above
the command starts on the ninth saturated cycle and settles at -1190 W against a
frozen integrator of -1194 W, not at the 5000 W rail.

Graded against a transcription of release/1.0 over the 3024-case sweep, which
was NOT taught the new rule: 2592 cases bit-identical, 432 releases, and 27 of
those change the command - every one a charge from prev_w == 0 that release/1.0
refused to start.

SAFETY-04's deliberate assertion is kept rather than inverted: its fixture sits
below the bound, so it still asserts the block, and the release is asserted
beside it on the other side of the bound.

Coverage audit re-measured, same mutation form, __pycache__ purged: output
freeze 2 -> 11 (2 reproduced on a pristine c8dcb38 first), the exit 4,
unasked_power 6.

saturation_cycles at 9 or 10 removes the block entirely, because the freeze
cannot engage before its own exit is due. Not refused at startup; asserted in
the tests and written in DOCS.md.

Nothing was measured on two inverters: inverter B does not physically exist
(NOTES.md §1.1b) and sim/plantsim.py:216 INVERTER_B is INVERTER_A copied with
measured=False. The N=2 escalation is stated from the code (actual_w is
site_batt_w, the SUM) and is labelled as such.

22/22 test files pass in goodwe-addon-ci, mounted :ro, bytecode purged first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 13:56:26 +02:00
glenn schrooyenandClaude Opus 5 8e241a3243 SAFETY-37: correct a wall-clock tick and a false size claim
checks / unit (pull_request) Successful in 5m32s
Two blocking findings from review, both in prose, both corrected at every
site.

1. "trips on tick 7" was not what the fixture prints. It prints 11, five
   runs out of five, and the developer's own report says 11. ⚠️ But the
   deeper problem is that the number should never have been quoted: the
   tick is counted on the WALL CLOCK inside cycle() (hold_s 2 s, ticks
   0.20 s apart), so it is load-dependent - 7 is what a box slow enough
   to stretch each tick to ~0.29 s would print - and nothing pins it.
   The DECISIONS transcript now says 11 because it is a transcript of a
   run; the CHANGELOG stops quoting a tick at all and states the
   checkable thing instead: it trips without the gate and never with it,
   which part 3 grades.

2. ⚠️ SURVIVOR 2's SIZE CLAIM WAS FALSE FOR ONE OF THE TWO CONSUMERS THIS
   CARD EXISTS TO PROTECT. It read "one contributing at most 250 W to a
   loop whose reporting floor is 700 W" at three sites. That bounds the
   COMMAND. Once the register is frozen its REPORTED value is unrelated
   to the command - which is exactly HW-01's shape: commanded ~0 W,
   register stuck on the last large number.

   Measured in review: a unit frozen at -1200 W beside a healthy +900 W
   sibling yields circulating_w = 1800 W against the 700 W floor, 2.6x
   OVER, with no gate armed.

   So the scoping is TRUE for the per-unit sign check and FALSE for
   circulation. Corrected to say exactly that, and the circulation
   consumer's exposure below MOVE_W is now recorded as a KNOWN GAP rather
   than a covered one.

   It survived because part 9's S2 check asserts only `not stale` and
   part 4 pairs the frozen unit with a RESTING sibling
   (circulating_w([A_FROZEN_W, 0.0]) == 0.0) - neither grades the size
   sentence.

No behaviour change. Full suite: 22 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 12:35:27 +02:00
glenn schrooyenandClaude Opus 5 6864109a01 SAFETY-37: a stale reading is None, and arrival cannot be what says so
checks / unit (pull_request) Successful in 5m34s
The architect pass made SAFETY-37, TEL-06 and TEL-08 one systemic problem -
"a stale reading holds its last value and reads healthy" - and this builds the
single policy. Both consumers the card names are gated by one change, and
neither consumer needed a line: `SignWatchdog.evaluate` already CLEARS its
window on a None measurement and `circulating_w` already returns None for a
roster with a missing term. Neither could ever receive one. The gate is the
piece that was missing BETWEEN the shipped read and the shipped checks.

⚠️ ONE CRITERION IN THE CARD COULD NOT BE IMPLEMENTED AS WRITTEN, AND SAYING SO
IS THE MAIN CONTENT. The card says "only arrival separates them". At this layer
arrival cannot: `read_inputs()` polls Home Assistant's state cache about once a
second and HA answers 200 with a number whether or not the publisher behind the
entity is alive, so both members of the pair arrive identically for ever.
test_freshness.py part 1a BUILDS the arrival-age gate the card asks for and
grades it failing - the age is 0.0 s at every instant of both runs, a threshold
that can never fire, which is TEL-06's own defect one layer up. The half of the
rule that survives is honoured absolutely: nothing here reads a Home Assistant
timestamp, and the check asserts the module contains no `last_updated`.

So the criterion became: a reading is stale when it has not moved through a
change it was OBLIGED to follow.

    STALE  <=>  unchanged >= 35 s
                AND |standing| >= 250 W
                AND |standing - standing when the reading last changed| >= 250 W

Latched until a different value arrives. `Inverter.batt` becomes None - never a
held number - which is a missing input, which §11.1 rule 1 answers with 0 W.

WHY A CONJUNCTION AND NOT A THRESHOLD, MEASURED 2026-09-06. Replaying the
committed 30-day capture of this house's own 1 W power entity (485,841 samples
out of HA's recorder, where a row exists only on a CHANGE, so the gap between
rows IS the unchanged duration every consumer saw): the longest legitimate
unchanged run is 263.2 s, held at -899.0 W. A false-positive-free threshold
would have to exceed that, and 263 s is 3.5x too slow for a consumer that trips
at 75 s. There is no admissible threshold. Every one of those long runs is
under an UNMOVING load, which is what the travel term keys on.

THE BOUND, derived at both ends and graded against the shipped config.yaml.
Floor 17.0 s = 6.0 plant settle (NOTES §5.9, measured 2026-08-22) + 10.0
ESPHome publish (firmware/goodwe-master.yaml:181) + 1.0 read period
(run_control's asyncio.sleep(1)). Ceiling 75.0 s = min(circulation_hold_s 75,
unit_sign_disagree_s 85). Band [34.0, 37.5]; 35 is a CHOICE inside a DERIVED
band and the check says so - the first draft claimed the band held one integer
and went red on its own first run. MOVE_W is the LARGEST admissible value,
unit_sign_disagree_w, because below it the consumer cannot speak at all.

SoC is STATED, NOT WIRED, and BOUNDS carries it with wired=False and the reason.
Its obligation term is energy through the pack, not command travel - a rested
pack holds a percent for hours under a 0 W command - and K needs a usable
capacity nobody has measured. That is TEL-08's; the record says so.

⚠️ BEHAVIOUR CHANGES AT THE SHIPPED ROSTER OF ONE, and it is named rather than
hidden: a FROZEN register now stops the site where it used to read as a healthy
measurement for ever. That is HW-01, which was a one-inverter site. A healthy
install is unchanged, asserted over sixty reads of a dithering register under a
reversing command. A false verdict fails toward 0 W behind the existing
stale_input_s hold and clears itself on the register's next move.

⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE ARE INVERTER A with different
numbers passed in; inverter B does not physically exist (~2026-09-12).

MUTATION: 16 mutations against a purged, :ro, python3 -B tree, fingerprinted
before and after every revert. One survivor, and it is the finding - dropping
the magnitude term left every check green because no fixture separated "the
command travelled 250 W" from "the command ended up above 250 W". Closed, and
recorded as MUTATION.md habit 17. A second process defect is habit 18: a
mutation run that timed out between the edit and the revert left the module
mutated on disk and nothing announced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 11:53:26 +02:00
adminandClaude Opus 5 5d7e275df4 SAFETY-39: slew_w is a ratio to the rail, and gain is what is still wrong
checks / unit (pull_request) Successful in 5m16s
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where
"capping how far the loop can jump ahead of the inverter" was true because the
rail itself bounded the excursion. The live install went to max_w 5000 and the
constant did not move, so the loop crosses its whole output range in 10 s
against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system
2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five
2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from
3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved.

REPRODUCED FIRST, and one half of it is not reproducible at all. On
plantsim.Plant and on an independent in-repo model: the setpoint stepping a full
1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from
export into import. The -5154 W figure is NOT reproducible in either model,
because both clamp the plant at its rating - so the card's AC "battery no longer
exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met.

THE DELIVERABLE IS THE RELATIONSHIP.
  SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9
  slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as
  integrator_max_w = 0 already means follow max_w. It is the shipped default:
  222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site.
  validate.py rule 10 refuses an explicit value faster than that, graded against
  sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a
  second inverter moves the bound with no operator action.

VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the
refusal names the rail, the crossing time, the measurement and what to do.
Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating
all still start.

GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51
export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250)
and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the
OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W
rail in under three ticks whatever the slew is. Two control-law constants moving
in one release on a running house makes the next measurement uninterpretable.

⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND
SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the
enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS
this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant
did - both stated in the record rather than left to be discovered.

Nothing reached the inverter. The live install still runs the interim slew_w 400
applied by hand on 2026-09-05, which rule 10 accepts unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 01:10:15 +02:00
glenn schrooyenandClaude Opus 5 d5b9317d57 SAFETY-38: correct the survivor count, which was stated four ways
checks / unit (pull_request) Successful in 5m11s
Review found the summary lines miscounted, and one of the miscounts filed
a KILL among the survivors. The authoritative tables in §7e and §7f were
right throughout; only the sentences a reader actually relies on were
wrong.

Arithmetic, from §7f's own ledger:
  14 measured = 10 surviving
              +  1 already caught  (C3 allocator.py:162, red in test_alloc.py)
              +  3 caught by round 4 (C6 clamp.py:253, C7 peakguard.py:493,
                                      B12 arbiter.py:264)
Survivors by file: TWO in allocator.py (C1 :154, C2 :160), three in
arbiter.py (C4a/C4b :344, C5 :392), two in tariff.py (C8, C9), two in
arbitrage.py (B17, B18), one in main.py (B19). Two, not three, in
allocator.py - C3 is the kill.

Before: DECISIONS §6c said "Nine" while listing eleven; the CHANGELOG said
"11 SURVIVORS" while listing eleven. Both counted C3 as a survivor.

⚠️ KEPT VISIBLE RATHER THAN QUIETLY FIXED. This is this card's own
recurring defect in prose rather than in code - a claim wider than the
measurement beneath it - and it is the second false statement this ticket
has put into its own decision record. The correction says so at the line,
because a document whose whole value is that it can be trusted should
record when it could not be.

No app/ change; no test change. 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 23:10:30 +02:00