Commit Graph
107 Commits
Author SHA1 Message Date
adminandClaude Opus 5.5 be57b87c34 UX-13 P4: the setup wizard for installers
checks / unit (pull_request) Successful in 10m22s
infra-pin / fresh (pull_request) Successful in 1s
The Setup tab draws the Installer view's page model one step at a time,
from each field's `wizard` step in app/uilayout.py: meter (with the kettle
test), inverters and batteries, solar (optional), prices, peak limit
(battery modes: "comes in a later update", OPT-11), check and start.
It reuses P2's controls and saves through /config/validate and
/config/save. No route was added.

- First run: /config/model says `setup_needed` when the stored options name
  no power request entity, or on a new install (no `setup_done` and no
  `auto` in the Store, control not started). The page opens #setup only
  when opened with no route. The wizard's save writes `setup_done`.
- Kettle test: GET ha/entities?entity= reads one entity now, in W. The
  change decides: up keeps meter_invert off, down turns it on; a net or
  import/export meter that goes down blocks the step with what to check.
- Last step: validate.setup_checks() beside the refusals: the meter reads,
  each power request entity exists in HA (bad if not, SAFETY-77), battery
  levels and power with direction, solar sign, prices. Saves with
  shadow_mode on by default, then a confirmed restart naming the 0 W.
- P2 test finding (a): a stale save lists only the keys the other surface
  moved (the blob handed out on open, by fingerprint, against stored now).
- test_setup.py; tools/ux13_wizard_blobs.py makes the 88 blobs the wizard
  can save; all accepted by Supervisor 2026.08.0 AppOptions (offline).
- DECISIONS/UX-13-setup-wizard.md; DOCS "Setup, for a new install".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 05:31:18 +02:00
glenn schrooyenandClaude Opus 5.5 06aa09e28c SAFETY-73 review: a refusal needs 22 s of evidence, and the cut-out warning is for giving only
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 10m11s
- REFUSED_SPAN_S: the 12 cycles of evidence must also span (SETTLE_CYCLES-1) *
  LOOP_TICK_S = 22 s. At a 1 s meter, lagged healthy readings refused both
  units 10-16 times an hour after load steps (site held 0 W). 2 s and slower
  ticks are unchanged.
- REFUSES_ABOVE_CUT_OUT only for a refusal to give power: a full battery that
  stops taking charge is always above its cut-out level.
- test_delivery: its wedged fixture (0.2 s cycles) no longer reaches a refusal;
  the release/1.0 checks are restored and a span check added.
- test_refusal_units: lagged-reading and full-battery plants, two new checks,
  U14/U15 in the mutation table. Stale "> 500 W" wording fixed. DOCS/DECISIONS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 02:16:24 +02:00
glenn schrooyenandClaude Opus 5.5 016ed5a5a3 SAFETY-73: a battery that refuses, per inverter, with two or more
checks / unit (pull_request) Successful in 10m1s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-52's hold applied per unit at N>1 (owner, 2026-09-27, option R).
A unit that gives < 100 W for 12 cycles in one direction while asked
> 200 W, or trips its stale-reading gate on a ~0 W reading, gets a
P_SAFETY `refused` unit limit closing that direction, so the allocator
hands its share to the others within their own limits. Probe: a +-200 W
pin every 15 min, one unit at a time, only while the site asks 200 W that
way. All units refusing: the site law holds 0 W. Exits reseed the site
target from the wire. INFO plus one WARNING (REFUSES_ABOVE_CUT_OUT) above
the configured cut-out; SAFETY-42/43 no longer report a refusing unit.
N=1 unchanged.

test_refusal_units.py: cases a/b/c on flat and jitter registers against
the old behaviour, probes, recovery, exits; mutations U1-U13 all red.
TEST-19 no-fault campaign: 0 engagements in 5280 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:13:26 +02:00
adminandClaude Opus 5.5 3c0f2b9669 SAFETY-52 review: a refusal is under 100 W, and any delivery ends it
checks / unit (pull_request) Successful in 9m22s
infra-pin / fresh (pull_request) Successful in 1s
- Engage (and seed from a stale stop) below REFUSED_BELOW_W, the probe's
  own 100 W answer, not below saturation_w: a battery derated to 100-499 W
  was held at 0 W ~93 % of the time.
- A reading of 100 W or more in the held direction ends the refusal, probe
  or not: at a 1 s meter SETTLE_CYCLES is 12 s, shorter than a healthy
  reading's lag, and that held a healthy step at 0 W until the next probe.
- test_refusal part 5b and a seed test; DOCS states the N>1 limit; the
  DECISIONS record carries the traced cause of the replay's extra trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:11:42 +02:00
glenn schrooyenandClaude Opus 5.5 1f97c7652f SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Failing after 9m32s
At the BMS cut-out the saturation freeze held the last request for hours
(+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid
error changed sign. compute() now carries a refused direction: after
SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction
at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go
on charge, an answered probe, stop/start or another writer. A stale-reading
trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's
~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A).

test_refusal.py drives the real Controller and gate; tools/replay_refusal.py
replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined
where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what
SAFETY-58/59 must take into account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:43:27 +02:00
adminandClaude Opus 5.5 3c298af3d8 UX-11 review: restored stops log their figures to events, the quiet-meter WARNING is rate-limited
checks / unit (pull_request) Successful in 9m7s
infra-pin / fresh (pull_request) Successful in 1s
- After a restart, each restored stop (direction per inverter and site,
  delivery, circulation) also writes its saved reason to the action log.
  log_level defaults to info and events starts empty, so the DEBUG
  evidence alone left the figures only in state.json.
- A flapping meter (ha_signed under a flat load, 42-97 s between changes)
  logs at most one WARNING/INFO pair per 10 minutes, with the episodes in
  between at DEBUG.
- LONG_LINE_OK refuses any key whose text is a safety reason.
- test_watchdog keeps the byte pin beside the meaning pin, since the text
  did not change.
- DOCS says the DEBUG line needs log_level debug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:21:05 +02:00
glenn schrooyenandClaude Opus 5.5 29837dfaa7 UX-11: safety log lines give one plain sentence and the code, the figures go to DEBUG
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
- Every WARNING/ERROR line that reported a safety reason (direction, delivery,
  stuck meter, stale reading, peak guard, circulation, over-rating) now ends in
  the reason's code (messages.reason_code()). The reason itself, figures and
  limits included, is on a DEBUG line right after it and in the action log.
- The reasons and state.json are unchanged, so an old state file loads and
  shows the same way: every saved reason opens with its code.
- test_messages part 6 checks every WARNING+ line at 25 words as logged; nine
  non-safety families are listed with a reason and capped. The byte pin in
  test_watchdog now pins the meaning, in order.
- The meter logs one WARNING per quiet episode, and INFO when back, so the red
  row's "the add-on log shows why" holds. The Why line past the hold names
  the missing reading. Action log: "25 engagements", "Shadow mode off: ...".

No trigger, latch, threshold, log level, notification id, cadence, row colour
or banner order changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 13:34:30 +02:00
adminandClaude Opus 5.5 e0b578756f UX-10 review: the line as logged gets the red-alarm shape, clamp lines at 30 words, plain HA 400
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
Part 6 checked the quoted reason alone, so title + reason + tails logged
as 4+ sentences. It now also checks the whole line (at most 3 sentences,
none over 30 words); 25 lines failed and are rewritten (one-sentence
wrappers, two-sentence circulation and peak reasons, STALE_READING's
fixed limits on a DEBUG line). The clamp lines for one inverter are
pinned at 30 words with OUTPUT_CLAMP and the numbers kept. Home
Assistant's bare '400: Bad Request' while it loads becomes one plain
line; still WARNING, throttled, a failed write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:08:38 +02:00
glenn schrooyenandClaude Opus 5.5 23a3a3f5b5 UX-10: the last plain-language leftovers: log lines, two table rows, 'Why: Other', red at 0 s
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
- test_messages.py part 6: every dynamic part of a WARNING+ line is classified
  as data or as a built text, and every built text is produced by the real
  code and checked. This is the gap the 2d scan had: it counted `%s` as one
  word, so reasons built by loop_clamp_note() and friends were never seen.
- Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept):
  OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak
  guard (codes now PEAK_GUARD*), the migration report, price feed, price
  history and gate reasons. Plurals via messages.times()/count().
- Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff
  "one every 15 minutes or every hour", "not final yet" dropped.
- Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO,
  aiohttp access log only at log level debug, settings warnings once per
  start, one plain line per Home Assistant outage (hass.Outage).
- INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE",
  "NOT_OBSERVED", "did NOT land", "AND".
- Display bugs: WHY_WINNER covers every winner and safety reason
  (main.why_key(), part 7 finds them in the code); the meter row is amber
  "waiting for the first reading" inside its limit after a start; the
  missing-reading age is counted when the page asks.
- STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section).

No change to safety behaviour, alarm levels, notification ids, triggers,
cadence or the banner cascade. One row colour changes (meter at start),
justified in DECISIONS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:58:43 +02:00
Claude review 569f16aa91 UX-08 2d review: action log reachable in status JSON, engaged peak row pinned, imported keys expanded
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m54s
- status_payload() also returns the action log as `events`. The page
  does not render it. Some entries (a dropped inverter's reason, the peak
  guard's projection, unplaced watts) are written nowhere else, and DOCS
  still points installers at the action log.
- test_peakguard pins the engaged row's ordered pair (the battery's share,
  then the limit).
- test_messages expands setting keys named by constants imported from
  another app module, so the shown-text 30-word scan counts them.
- p1.py: 'A meter message could not be read'. DOCS glossary: control
  board in plain words, with the hardware in an installer note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:29:14 +02:00
adminandClaude Opus 5.5 6a95447248 UX-08 2d: plain log lines, the rest of the status page, and DOCS
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m55s
Every WARNING and ERROR line is one plain line of at most 25 words, with
no card ids, capitals or semicolons; the reasoning of the ten longest is
on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0).

The status page: the month-peak and peak-guard rows, the stale-reading
row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the
Stopped banner and the line under the buttons, which now shows the plain
hint of each event instead of the raw action log. The Supervisor question
at start logs one line with a real reason.

Refusals are counted as shown (labels expanded, long cases driven), seven
were shortened, the card and parse errors lost their internals, a blank
value reads "empty", and the panel no longer files inverters.max_w under
the flat max_w. Option names lose "(JSON)", the NL texts are checked too.

DOCS gets a glossary (control board, isolator, Developer Tools) and card
ids move from headings to a "For installers" line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 08:01:16 +02:00
goodwe-review c84d5fd14f UX-08 2c review: unit settings anchor to inverters, plainer texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s
- messages.setting() for one inverter in the list reads
  “Inverter rating” for inverter-b (inverters.max_w), so the config panel
  files per-unit reasons under `inverters`, where the value is changed.
- The tariff card and plausibility reasons name
  “Distribution, levies and VAT” (price_taxes_eur_kwh) again.
- The wrong-direction reasons say "safety stop", the sign_disagree_w
  option says what it protects, and the roster, price-entity,
  integrator_max_w and capacity_kwh texts read more plainly.
- NL: stuurprint -> stuurmodule, "De piekbeperking".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 06:51:57 +02:00
glenn schrooyenandClaude Opus 5.5 2c516a032c UX-08 2c: short refusals, error texts and option texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s
Validator refusals name the setting by its label and key, say what is
wrong and what to change, and keep the rejected value and limit. Option
names and descriptions are short in en and nl; citations moved to DOCS
"For installers" and the test_translations citation check now pins the
instruction on the tab and the record in DOCS. No rule, key, schema,
default or exit code changed. Allowlist 167 -> 43 (log lines only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 05:55:45 +02:00
adminandClaude Opus 5.5 00e3ee9de8 UX-09: Details link for current HA, "would" in shadow mode, plain terms
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 8m50s
- Details link: /config/app/<slug>/documentation on HA 2026.2+ (the old
  /hassio/addon path is a 404 from 2026.5), /hassio/addon on 2026.1 and
  older. Slug from GET /addons/self/info, version from GET /info, hostname
  as fallback.
- Shadow mode: the over-rating alarm and row say what would have been sent,
  the peak alarm no longer says the battery is giving all it can.
- "controller" -> "the add-on" / "control". Settings named by their labels;
  four labels changed to match (Peak reserve, Battery level entity, Solar
  production ..., Reversed-sign solar entities).
- Shadow banner and inverter link notes moved to messages.py and checked.
- DOCS Notifications and Status page rewritten for home owners, with a
  "For installers" part each. Allowlist 168 -> 167.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 04:10:39 +02:00
glenn schrooyenandClaude Opus 5.5 571b884315 UX-08 phase 2b review: keep over-rating evidence, pin latch clearing, plainer rows
checks / unit (pull_request) Successful in 8m48s
infra-pin / fresh (pull_request) Successful in 1s
- During a bound episode an earlier over-rating count stays on the page (amber,
  under its own title); test_clamp pins it.
- test_messages pins that every latch row says how it clears.
- pv_entities description no longer claims the page names the entity (en, nl);
  allowlist hash updated for that option-desc line.
- Plainer texts: multi-inverter direction banner, "fine now" after an episode,
  and the e-stop, peak-forecast and peak-guard-waiting rows move to messages.py.
- TEL-12 DECISIONS points to UX-08 item 3 for the retired row rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:10:25 +02:00
adminandClaude Opus 5.5 d017fc67bf UX-08 phase 2b: short, plain status rows and page banners
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Failing after 1s
Every Commissioning row and every page banner is now worded in
app/messages.py (row_*, banner_*): one sentence of at most 15 words, a red
row up to three sentences so a latch still says how it clears, banners at
most 12 words. No row names a Home Assistant entity: sensors by their
setting, solar sources by their place in the list. Reason codes and limits
stay on the log line and event list; rows keep the numbers a reader acts on.

UX-07 item 1: ClampWatchdog counts over-rating and integrator-bound
engagements separately, and after an episode each count shows under its own
alarm's title instead of both under "output clamp".

No row colour, visibility or banner order changed. Rows carry an "id" so the
tests pin meaning rather than wording. The row and banner kinds leave the
allowlist (200 -> 168, cap equal). DOCS.md gains "Status page"; choices are in
DECISIONS/UX-08-short-notifications.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 02:12:10 +02:00
adminandClaude Opus 5.5 7b65a3e69a UX-08 review: tighter notification texts, and two meaning fixes
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m48s
- not delivering: the body said control keeps running and then "start
  control"; clearing it takes stop then start, and it now says so.
- peak reserve: the "reserve not set" branch is really soc_floor None,
  i.e. an inverter's cut-out level missing; it now names that. The title
  is true on all three branches ("Battery stopped limiting the peak").
- no semicolon run-ons in bodies (STYLE.md + test); loop-bound, circulating,
  meter-frozen and multi-unit over-rating bodies rewritten to match.
- allowlist cap is now exact, so a shrink cannot leave room to regrow.
- DOCS names the settings behind "peak reserve" and "cut-out level";
  DECISIONS no longer states the HA anchor behaviour as fact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:18:55 +02:00
glenn schrooyenandClaude Opus 5.5 50ccea3dd7 UX-08 phase 2a: short, plain notifications, and a ratchet for the rest
checks / unit (pull_request) Successful in 8m54s
infra-pin / fresh (pull_request) Successful in 1s
Every HA notification the add-on raises now has a title of at most 5
everyday words and a one-sentence body; a red alarm may use three so no
action is lost. The texts live in app/messages.py, the rule in STYLE.md,
and the detail that moved out in DOCS.md "Notifications", which each body
links to. No notification_id, trigger or cadence changed.

test_messages.py enforces the rule on every notification, fails any
notification worded outside app/messages.py, and ratchets the kinds not yet
rewritten (rows, banners, refusals, logs, options) through a shrinking
allowlist of 200. Six tests re-pinned from prose to meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 01:00:54 +02:00
glenn schrooyenandClaude Opus 5.5 55a6d5dcdb SAFETY-40 review: integrator-bound WARNING gets its own notification id
checks / unit (pull_request) Successful in 8m46s
infra-pin / fresh (pull_request) Successful in 1s
A later anti-windup notice could replace a write-path BUG SIGNAL notice
under the shared goodwe_output_clamp id (escalation ignores the budget).
The WARNING now uses goodwe_integrator_bound and points to DOCS; the
cadence sentence in DOCS is corrected. Tests pin both ids (red on fd14569).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:08:44 +02:00
glenn schrooyenandClaude Opus 5.5 fd14569a71 SAFETY-40: the integrator bound is not a BUG SIGNAL; numbers from the clamp tick
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Successful in 1s
The clamp alarm gave `i_clamped` (normal anti-windup) the over-rating BUG
SIGNAL text, printed `Decision.i_w` as "the loop asked for", and printed
`self.target` (after slew/freeze) as "was clamped to".

- clamp.loop_clamp_note(): i_clamped says "the loop's integrator reached its
  N W anti-windup bound" with this tick's command; clamped keeps BUG SIGNAL
  with the requested command and the clamp's own result.
- Decision carries i_bound_w / requested_w / clamped_w, set at the clamp.
- i_clamped still counts and notifies (same id, cadence, SAFETY-41
  escalation) but as a WARNING, amber row, plain title. A bug has its own
  notification budget and its text is not overwritten within an episode.
- test_clamp.py part 9: red on the old code per defect; 5 mutations killed.
- DOCS.md alarm section (and its stale 30 s cadence), DECISIONS/SAFETY-40.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-26 22:37:01 +02:00
admin 16c796fa30 Merge pull request 'TEL-14: PV sign declared per entity (pv_invert_entities)' (#124) from TEL-14-pv-sign into release/1.0 2026-09-25 01:30:33 +02:00
glenn schrooyenandClaude Opus 5.5 d71f7dbd35 Merge release/1.0 (43b2740) into DOC-5-watchdog to sync base
checks / unit (pull_request) Successful in 8m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:08:15 +02:00
glenn schrooyenandClaude Opus 5.5 7559d21eda TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive
for consumption and negative for production. `pv_invert_entities` lists the
ones to flip; the flip is applied per member inside pv.total(), before the sum,
so the MQTT `pv` row and the status page carry the corrected figure. Default []
is byte-identical to 0.12.0.

Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0):
a parallel list validates the 0.12.0 blob unchanged and renders as its own
translated repeatable field; dicts on pv_entities refuse 0.12.0's stored
strings; a prefix is invisible syntax; a global bool cannot describe a mixed
roster. Status page marks flipped entities and cautions per entity below
-50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged.

Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:25:24 +02:00
admin 3e38bafcee Merge pull request 'SAFETY-62/SAFETY-60: operator tools refuse input they cannot read' (#120) from SAFETY-62-refuse-unread-input into release/1.0 2026-09-25 00:25:22 +02:00
glenn schrooyenandClaude Opus 5.5 02a822af98 DOC-5: tell installers to turn on the Supervisor Watchdog
checks / unit (pull_request) Successful in 8m41s
Since SAFETY-61 a dead control loop writes 0 W and exits 70. The Supervisor
restarts a stopped add-on only when the per-install Watchdog toggle is on,
and it defaults to off. One step in DOCS.md "Before you start", with a
pointer to the DECISIONS file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:20:52 +02:00
glenn schrooyenandClaude Opus 5.5 b06f1fad5f SAFETY-62 review: DOCS exit 3 at the command block (R1); recognise the auto store key (R2)
checks / unit (pull_request) Successful in 8m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-25 00:02:33 +02:00
glenn schrooyenandClaude Opus 5.5 f4f72211a9 SAFETY-60/62: operator tools refuse input they cannot read
checks / unit (pull_request) Successful in 8m31s
predeploy_check.py and rehearse_roster.py now assert a positive property of
their input (a JSON object, most of whose top-level keys are known option or
store keys) and refuse anything else with the shape named. The `ha apps info
--raw-json` wrapper, a truncated or list-wrapped state file and a wrapped
config no longer produce "CLEARED" / "NO FINDINGS" with exit 0.

Also: a clean --no-live-entities rehearsal exits 3, not 0; alloc_sim.py
exits 1 when split-invariance FAILS; decode_bus_capture.py takes several
paths and exits 1 on a file that is mostly not frames.

Graded by the new test_tool_inputs.py; DOCS.md says to pass .data.options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:27:50 +02:00
adminandClaude Opus 5.5 29ca9b5157 DOC-1: operator docs name the real P1 entity and a roster that starts
checks / unit (pull_request) Successful in 8m8s
- DOCS.md / config.yaml: the watchdog's P1 age entity is
  sensor.goodwe_p1_sample_age (device "GoodWe" + "P1 sample age", as
  app/mqtt.py announces and the live install recorded), not
  sensor.p1_sample_age_s.
- DOCS.md: the inverters: example and per-entry table carry the required
  bms_soc_floor (and capacity_kwh); config.yaml's schema comment says four
  required keys, not three.
- Field guide: marked v0.1.0/outdated; slew_w row no longer recommends the
  1000 W that SAFETY-39 now refuses. HTML regenerated; the PDF is NOT
  rebuilt (no Chrome here). README/DOCS pointers fixed.
- test_docs.py: the documented roster must pass app/validate.py and the
  schema's per-entry keys; the documented entity id must equal the id
  composed from app/mqtt.py; the wrong id may appear in no shipped doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 23:24:50 +02:00
adminandClaude Opus 5 0ef9565071 TEL-12: several PV inverters, and one absent member is an absent TOTAL
checks / unit (pull_request) Successful in 7m59s
0.11.0 shipped `pv_entity` as a single string. The reference site's owner
reported within minutes of it going live that they have several PV inverters
and could configure one - which is ordinary for a multi-string roof, not a
property of one house. `pv_entities` is the roster and the row carries the
SITE total.

⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`,
and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL
ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no
irradiance" conclusion the channel exists to TEST. So `pv.total()` answers
`None` the moment any configured entity is absent: the topic carries HA's own
`None`, the entity goes to `unknown`, the recorder records a GAP. The
alternative (the partial sum beside a contributor count) was implemented and
run as mutation N1b; it loses because the MQTT row is read COLD a week later by
an attribution that would believe it, while the qualifying count would live in a
second entity with its own history. The partial information is not thrown away -
the STATUS PAGE names every entity that reported, with its watts, and every one
that did not, with its reason, because that surface is read LIVE by a human who
wants the diagnostic.

⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is
`is None`, never falsiness - a roster that blanked the site on a zero would
report an absence on every clear evening.

THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list:
tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an
0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and
deepmerge's list strategy is `override` so a `[]` default does not rescue it -
the live add-on would not start. `match(.*)?` does accept both shapes and is
rejected for a different, measured reason: it validates nothing (a string, a
list and the integer 417 all pass) and renders as a plain text box. So
`pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for
`inverters:`, with a status caution saying which key won - a union would
double-count the inverter the old key still names.

STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than
replaced and now grades five claims over three document sets. The control hash
is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the
roster control document is byte-identical between the two trees. 2/5 is a claim
TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly
as 0.11.0 does" is a real cross-tree identity over control AND telemetry.

⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit
24: a discriminator defined as "the baseline lacks this feature" dies on the
release that ships the feature, and dies as a false refusal.

Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B
(`sum` -> `max`, the same three characters) and was run on a fresh copy with
`cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO
contributions in two units with absence driven at EVERY position - a member
contributing 0 W could go absent without moving the sum of the rest, which is
habit 23 with a coinciding value in the arithmetic rather than in the plant.

31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0,
so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG
entry is wanted and deliberately not written - 11b forbids `## Unreleased` and
the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the
material it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 22:13:25 +02:00
glenn schrooyenandClaude Opus 5 07ba3433ed TEL-11: PV production stops being invisible, and stays out of the control law
checks / unit (pull_request) Successful in 8m6s
OPT-10 measured that this site's pack reaches 90 % SoC on 2 days of 16 and that
20.5 kWh of surplus left the site while the pack had room - 12.6 kWh of it with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which
is the only software defect) and guessing between them is how an optimiser gets
tuned to solve a cloud problem. This card adds the channel. It does not do the
attribution.

AN HA ENTITY, NOT A REGISTER, AND THAT IS A PRODUCT DECISION. This add-on's
RS485 bus carries the BATTERY inverter it drives; a household's PV inverter is
normally a different box from a different vendor reporting into Home Assistant
through its own integration. `pv_entity` takes whatever `sensor.*` the site's
own PV integration provides, any brand HA can read.

POSITIVE IS PRODUCTION, AND THERE IS NO `pv_invert`. The convention is derived
rather than assumed: `meter_entity` is positive when IMPORTING and `batt_entity`
is positive when DISCHARGING (DOCS.md, Sources), both of which are "power
arriving at the house's AC bus", and production arrives there too. Those two
keys carry invert flags because they are BIPOLAR and are READ BY THE CONTROL
PATH - `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor, so a reversed sensor hands the site a plausible number
it acts on. Production is unipolar and is read by nothing: a reversed sensor
announces itself as -4 kW at noon in the very history this card creates, and a
flag would only let an installer hide it. No negative-value alarm either - a PV
inverter's own standby draw makes a small negative reading at night ordinary, so
any threshold would be a number nobody measured.

ABSENCE IS NEVER ZERO, AND "DROPPED" IS NOT ABSENT. `publish()` SKIPS a None,
which is right for `circulation` and exactly wrong here: a skipped row leaves
Home Assistant holding the last number forever and the recorder draws a FLAT
LINE through the outage, which is indistinguishable from a measurement and
manufactures the "there was no surplus" conclusion this card exists to TEST. So
the absence is published, with HA's own payload: `mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric branch,
so the entity's state becomes `unknown` and the recorder stores a GAP. Read out
of home-assistant/core `dev` on 2026-09-24 and quoted at the constant.
`ABSENT_AS_UNKNOWN` is a one-key set and the suite asserts `circulation: None`
is STILL dropped.

THE UNIT IS READ OFF THE ENTITY. `mW`, `W`, `kW`, `MW`, `GW`, `TW` - HA's own
`UnitOfPower`, every factor an exact power of ten. A sensor with no unit, or a
unit outside that table, is REFUSED by name and the status page says which.
`read_inputs()`'s `abs(raw) < 50` kW-or-W guess is safe for a quarter-hour
demand forecast and is NOT safe here: 30 W of dusk is an ordinary reading and
that heuristic would publish it as 30 kW.

GENUINELY OPTIONAL, AND NOT A SECOND `price_now_entity`. validate.py does not
mention the key - it cannot conflict with anything, because nothing else reads
it. The MQTT row lives in a new `OPTIONAL_SENSORS` list whose `include` argument
DEFAULTS TO EMPTY, so every caller that has never heard of `pv_entity` -
test_mqtt.py's fixtures, tools/rehearse_roster.py, anything written next year -
announces exactly what it announced before. Unset: no state call at all, no
entity, no topic, no status row, no page row. Set and failing: a CAUTION
(`ok=False, warn=True`), never a `bad` row, so a telemetry channel can never put
a battery site into NOT READY.

IT MUST NOT BECOME A CONTROL INPUT, AND THAT IS GRADED TWICE. `test_pv.py`
part 6 drives 40 cycles of the real `cycle()` and asserts the commands are
bit-identical across unset / no reading / 0 W / 8 kW, with a COMPUTED census
(33 modules under app/) showing the material is mentioned in exactly three:
pv.py, main.py, mqtt.py. `tools/tel11_pv_trace.py` is the cross-tree half -
`safety56_behaviour_trace.py`'s pattern with a DIFFERENT control clause, because
this card's claim is that nothing differs rather than that only sentences do: it
REFUSES unless the two `app/` fingerprints differ and unless exactly one side
reacts to the option. Measured on 41 stimulus cells:

    control   release/1.0 unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      unset  846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    control   TEL-11      set    846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
    telemetry release/1.0 unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      unset  a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
    telemetry TEL-11      set    4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f

UNSET is byte-identical in BOTH documents, which is stronger than "the control
path is untouched": an install that does not set the option sees no change of
any kind, announcement and status rows included.

⚠️ AND THE CARD'S CENTRAL MUTATION WAS A SURVIVOR ON THE FIRST BATTERY.
`self.grid -= self.pv_w` in `read_inputs()` - +106 B, on disk, purged, genuinely
running - came back GREEN, because the fixture drove a 6 kW export over 12
cycles, which puts the loop AGAINST ITS RAIL: a command moving `slew_w` every
cycle produces the same ramp for -6000 W and for -14 000 W of apparent error.
MUTATION.md habit 23 with a saturated ACTUATOR in place of a coinciding value,
and it is in that file now as the habit's second instance. The fixture now
drives a 600 W import the loop SETTLES on, against a responsive meter, and asks
habit 23's own question as its own check: shift the METER by the same 8 kW and
confirm the commands really would have moved.

Evidence: 31/31 suites green in the CI image, checks.yml's exact command, purged
tree, `:ro`. Ten mutations, NO survivors, each on its own `cp -a` of the tree so
habit 18's revert hazard is closed by construction - table in
`DECISIONS/TEL-11-pv-production.md` §7 with byte deltas and sha256s.

⚠️ THIS IS A RUNTIME CHANGE BEHIND AN UNCHANGED VERSION, AND
`tools/predeploy_check.py` WILL REFUSE IT. `app/`, `config.yaml` and
`translations/` are all in its `RUNTIME` tuple and `config.yaml` still reads
0.10.1, so the gate reports a NO-OP deploy until a version is cut. That is PKG's
call and this card does not take it.

No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading
that is not config.yaml's `version:`, and opening one would mean taking that
same packaging decision. The operator account is in DOCS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 15:35:07 +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
glenn schrooyenandClaude Opus 5 a6af332048 SAFETY-48: what the lead cap actually does to a frozen integrator, and what the peak figure depends on
checks / unit (pull_request) Successful in 7m55s
Four review findings, none of them in executable code. The control law, the
tests and the instruments' arithmetic are untouched; every number below was
re-measured against the committed 16-day capture before it was written down.

R1 - app/control.py carried a justification the decision record and MUTATION.md
habit 22 had already retracted, and it was false about the code it sits in. It
said an unconditional placement "would also pull the integrator toward a
command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The
118 was measured against a different pair; on the pair the sentence names the
two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow
form that shipped DOES move a saturation-frozen integrator: it runs before the
freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap
changes `i_w` on 1095, and on 679 of those it moves it further out on the side
it was already on - putting back magnitude the freeze had just let fall, which
the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid
+4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at
-176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal
(the freeze clipped on 75 512 ticks, the slew bound on none of them), and that
is stated too. It is also where SAFETY-13's settle values went: gating the cap
on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part
4(b) does not come back - that one moved for the winding reason.

R3 - the record read its own sensitivity table backwards. `fit_plant()` picks
lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while
the tool's own printed fidelity measure (export against the recorded 29.43 kWh)
prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s
release/1.0's worst quarter is ALREADY 3434 W and this card changes it by
exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a
carries the table; the export reduction now leads every summary and the peak is
presented as lag-dependent. The form-A rejection is strengthened rather than
weakened: form A is worse on the billed quarter at every lag where release/1.0
produces a peak at all, and at every top-N cut of the quarters.

R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads
with the export reduction, says the figures are simulated, and says plainly
that no capacity-tariff improvement is promised.

R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and
at the slowest slews it is slightly WORSE" while printing a monotonically
falling column. That sentence is TRUE on release/1.0 (reproduced:
958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law
(777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its
record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out
of the law it imported and narrates from the column it just printed. Both
branches of that narration were driven.

R5 - three counts in the L table were decoration and are re-driven in the CI
image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the
function names exchanged and 5+4 with the branch bodies exchanged (two
mutations wearing one name, now listed separately), L6 is 6+3. The separable-
state count is grid-dependent and three sweeps disagree, so all three are
quoted with their grids and the load-bearing claim is WHERE they separate.

R7 - "a large import answered too weakly" is not what the aggregate shows. Form
A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers
8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm
is a tail redistribution inside quarters that already net to offtake. The
conclusion stands; the sentence is narrowed to it.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 02:37:01 +02:00
glenn schrooyenandClaude Opus 5 03ef61fa3b SAFETY-48: the slew step ceiling reaches the integrator, and what it bounds is the LEAD
`compute()` freezes the integrator with an asymmetric rule and the comment
beside it states the requirement outright - "the requirement is that the
INTEGRATOR stop accumulating, not only the command" - but `frozen` is computed
from ONE signal, `abs(prev_w - actual_w) > saturation_w`, and slew-limiting
never enters it. The integrator moves `gain * error` a cycle and the command
moves at most `slew_w`, so the integrator outruns the command whenever
`|error| > slew_w / gain`: 667 W at the live shape, against a measured median
grid error of 1114 W at the onset of a recorded episode. SAFETY-39's retune
made it MORE frequent, not less - a smaller step is a lower threshold.

⚠️ THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, WHICH IS WHY IT IS
NOT WHAT SHIPPED. Reusing the asymmetric freeze on a carried slew flag was
built from release/1.0's own source and measured over 16.00 days / 256 252
meter instants of the live install's recorder: export 39.61 -> 34.43 kWh, and
the worst quarter-hour average OFFTAKE 3739 -> 3935 W with the mean quarter
+2.73 W, in five of six plant and tuning variants. A frozen integrator stops at
`prev + gain*error`, which undershoots by (1 - gain); for an export that is
free and for an import it is offtake, for as long as the ramp lasts. The card
said "do not invent a second rule"; the measurement says to, and both sentences
are recorded.

WHAT SHIPS BOUNDS THE LEAD AT ONE SLEW STEP, inside the branch where the slew
actually bound. Same 16 days, same ticks, same disturbance, same plant, same
seed: export 39.61 -> 34.07 kWh, wasted throughput 4.67 -> 3.19 kWh, battery
sign reversals 2992 -> 2770, median recovery 232 -> 199 s, p99 849 -> 850 s,
max 901 -> 898 s, unrecovered 53 -> 31, worst |i_w - prev_w| 9200 -> 800 W -
and the worst 15-minute average offtake 3739 -> 3434 W, i.e. the billed
quantity IMPROVES. `i_w = slewed` (a cap of zero) is better again at 32.02 kWh
and is refused: it is the velocity form this module's docstring says `i_w=None`
collapses to.

TWO COMMITTED NUMBERS MOVED, DELIBERATELY, WITH THE REASON BESIDE THEM.
SAFETY-13's released command settles at -800 W and -1200 W where release/1.0
settled at -1190 W and -2390 W, because there is less wound integrator to
settle onto; AC 4's property - it stops at the frozen integrator, not the rail
- is what is asserted and the second is now graded against `i_w` rather than a
literal. And SAFETY-39's "the slew does not own the over-absorption" is now
FALSE - INVERTED, on the independent first-order plant test_control.py writes
itself: 1005/1109/1285/1330 W rising with a slower slew becomes 729/550/424/326
falling. Two plant models that share nothing agree on the sign.

Evidence: `tools/replay_recorded_loop.py` plus the read-only capture it made
(scenarios/loop-2026-09-02_2026-09-17). The "before" law is the shipped file
with the block removed by a named substitution - never a transcription - and a
missing anchor is a REFUSAL carrying "NOTHING HAS BEEN MEASURED". The
instrument prints its own fidelity first: it reproduces 39.6 kWh of export
where the install exported 29.4, i.e. it is ~35 % pessimistic about the loop's
tracking, so only the delta is claimed.

30/30 suites green, including test_control.py's exhaustive i_w=None
equivalence sweep against the transcribed release/1.0 law, untouched: the cap
moves `i_w` and not `target`, and that sweep compares (target_w, sat_count,
frozen, reason).

Six law mutations, one instrument battery. L4 - the cap applied
unconditionally, -92 B - was a SURVIVOR of all thirty files: it is
bit-identical on the shipped configuration (0 of 256 246 ticks) and separable
on 2050 of 257 400 swept states, all at `integrator_max_w` above `max_w`, which
part 4 now drives. L5 and L6 are ZERO BYTES.

AND TWO COMMENTS IN THIS CARD WERE INVENTED AND ARE CORRECTED IN PLACE. The
one-sided spelling was justified by a two-sided form that would "drag a
trailing integrator" - it does not, and the two agree on 205 920 of 205 920
swept states; and a "118 of 256 252 ticks" figure had been measured against a
different pair. MUTATION.md gains habits 21 and 22 for both shapes.

NOT DEPLOYED, NOT VERSIONED. The live install still runs release/1.0's law.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-19 02:36:28 +02:00
glenn schrooyenandClaude Opus 5 b548525d10 TEL-10: the loop's integrator stops being invisible
`self.i_w` has been a separate quantity from the command since SAFETY-04 and
has left the process nowhere at all - no entity, no MQTT attribute, no log
line. The investigation behind SAFETY-48 could only RECONSTRUCT it from the
grid trace plus a replay; the reconstruction reproduced about a third of the
engagements it was checked against and was discarded, so the card it was
gathered for had to be argued from a measurement nobody could stand behind.
It could not have been made to work either: `i_w` depends on every cycle
before it, including the ones where the arbiter overrode the loop and
`main.py` re-seeded `self.i_w = self.target`, and that re-seed is invisible in
the grid trace.

THREE SITE ROWS, APPENDED AFTER release/1.0's EIGHT. `loop_i` is
`Decision.i_w` - the accumulator after the cycle, before the output clamp, the
slew limit and quantisation. `loop_lead` is `decision.i_w - prev_w` against the
command `compute()` was HANDED, which is not a subtraction of the other two
published rows: `Setpoint` is the arbiter's output and is this cycle's, so on
any cycle the arbiter overrode the loop the two differ - and that is exactly
the cycle on which the integrator was re-seeded. `loop_reason` is
`compute()`'s own word; the arbiter's `explain()` prints the winning claim's
NAME and has never carried the claim's reason, so `slew-limited` appears in
neither the `Why` row nor `Controller status`. Measured on the same cycle:
arbiter `'loop -> -5000 W'`, loop `'i-clamped'`.

NOTHING IN THE CONTROL PATH MOVED. `app/control.py` is not in this diff.
`compute()`, its returned `Decision` and every constant it reads are identical
to release/1.0, and all nine of release/1.0's retained discovery messages are
byte-identical and still FIRST in the announcement.

Evidence: 30/30 suites green in the CI image. Nine mutations, ONE survivor,
reported as one: `decision.i_w - self.target` for the lead is +2 B and green
because `cycle()` has not reassigned `self.target` at that line - the name is
kept for the coupling, not for a behaviour it buys, and M2b (+8 B) is the
mutation that does change the value and is red.

AND TWO OF THIS CARD'S OWN CHECKS COULD NOT REACH THE FAILURE THEY NAMED.
"the published integrator reaches its bound" was GREEN under the card's named
mutation (publish `prev_w`, -3 B), because the command reaches the bound too -
twelve cycles later. It asserts the LEAD TIME now and is red on 5. "a stopped
controller publishes not-running" was GREEN under deleting that branch's
assignments (-45 B, -67 B), because the fixture STARTED stopped and `__init__`
writes the same values; it stops the controller part-way through a run that
has already wound the integrator to -5000 W. Both are MUTATION.md habit 4,
found by the battery rather than by review.

No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## `
heading that is not config.yaml's `version:`, and opening one would mean
taking a packaging decision this card does not get to take. The operator
account is in DOCS.md and the release note belongs to the next PKG ticket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-18 23:36:23 +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
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
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
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
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