TARIFF-11: the plan's account of itself, corrected and put on the page #84

Merged
admin merged 2 commits from TARIFF-11 into release/1.0 2026-09-13 14:07:08 +02:00
Owner

Four defects of one shape: the add-on's account of itself was wrong, always in the direction that flattered it. They shipped together because nothing in this repository graded the strings a human is shown — every tariff check inspects the TariffPlan object, and all four were in the sentences built out of it.

Reproduced before the fix, driven through the shipped Controller against the committed energy-charts capture; the harness and its transcript are at /home/homelab/.cache/t11-mut/.

1. TariffPlan.words counted points and called them hours

Measured before: dynamic_dayahead from generic_dayahead, 192 h for 192 quarter-hourly points = 48 hours. Belgium moved to a 15-minute MTU on 2025-10-01, so on the real feed it was wrong every time it was printed — and not by a constant factor: across a 40-hour soak the overstatement ranged 1.79× to 5.33×, because §8.3's carried points are hourly while the firm ones are quarter-hourly.

After: dynamic_dayahead from generic_dayahead, 192 points over 48 h, 192 firm / 0 carried, 900 s spacing. N points over H h is FCST-04's Price history wording on purpose, so the two rows describing the same feed can be compared.

2. The §8.3 and flat-tariff refusals did the same — and they reach an operator

Before: §8.3: only 1 of 96 hours carry a published price about 96 points, which are 24 hours; the flat rung counted the same points and called them firm hours. ⚠️ This is exactly the point≠hour conflation TARIFF-14 removed from at(), left standing in the prose that describes it, and it reaches the status page through self.arb.explain().

After: §8.3: only 1 of 96 points carry a published price - 1 of 24 hours - and the rest are provisional … and the tariff is flat - every one of the 96 firm points, spanning 24 hours, costs ….

The same conflation in price_forecast.py's §8.3 carry-forward log line — %d of %d hours with a carried-hour count over a point count — is corrected in the same commit, because DOCS quotes that line verbatim.

3. The plan reached no HTTP surface at all

Before, driven: rows["Tariff"] == 'no static profile' on a site whose feed had just answered with a 192-point plan. origin, product, the horizon and the firm/carried split were in no row — the soak read 'no static profile' at all 802 samples.

After:

no static profile (recomputed this tick); provider plan fetched 0 s ago for a
48 h horizon: dynamic_dayahead from generic_dayahead, 192 points over 48 h,
192 firm / 0 carried, 900 s spacing

⚠️ The two Tariff sources are kept distinguishable. static_status() runs on the 30 s I/O-free tick; the provider plan comes off the 1800 s refresh and is served for up to TARIFF_MAX_AGE_S (5400 s since TARIFF-12). So the plan half carries its own age, measured from tariff_at — which TARIFF-12 deliberately does not move on a kept plan — and the check drives the same plan object at 0 s and at 4000 s and asserts the two rows differ. A provider site with no usable plan says so, instead of falling silently back to the static sentence.

⚠️ FCST-04's Tariff↔DOCS pin is honoured, not routed around. test_price_history.py pins the Tariff row and DOCS as a PAIR; wiring the plan in took it red, and DOCS.md now carries the sentence it had been overclaiming, with the row's five facts and the two-clock warning written out.

4. main.py:4486-4489's "if and only if" was false

cycle() returns at the input hold — inputs missing, bad_for < stale_input_s, 15 s by default — before it reaches self.arb = None. Driven, arbitrage_enabled: true plus a sensor answering None:

before:  arbitrage.enabled(opts)=True  self.arb=None  row='off (arbitrage_enabled)'
after:   row='ON (`arbitrage_enabled`), but NO DECISION has been reached yet - no
              cycle has run as far as the arbitrage step. The loop's own reason:
              inputs missing 0s'

and the second half, after a healthy cycle whose SoC then drops out:

before:  row == the previous cycle's decision, rendered as current
after:   'no decision this cycle (inputs missing 0s); the last one was taken 0 s
          ago: arbitrage idle - NOT COVERED: …'

The row reads the option instead of inferring it from a missing decision; cycle_at (stamped before cycle()'s first return) and arb_at are what make "this cycle reached no decision" an observation rather than a guess. The comment now states what is true.

The deliverable: a check that reads the RENDERED SURFACE

test_price.py section 12. It renders through the shipped Controller.status_payload() and grades rows["Tariff"] and rows["Arbitrage"].

  • Parsed by slot, never by presence. "132 points" in row is satisfied by the number appearing in the other number's slot — MUTATION.md habit 14's zero-byte edit. Each number is pulled out of its own slot and compared against a figure computed from the fixture's two construction parameters, never from the property the row was built out of (habit 4).
  • The instrument is calibrated first (habit 1): the slot parser is driven against a correct row and against a row wrong in exactly the way the defect was, and a missing slot is a failed derivation rather than a silent pass.
  • The fixtures are a SET (habit 10) of four plan shapes, and the check computes and prints the table of field pairs no fixture separates — none. It deliberately contains both the degenerate hourly arrangement (points == hours, where the defect hid for three cards) and the quarter-hourly one where they differ 4×.
  • origin and product are graded by disagreement, not by substring: two plans over the identical point list differing in one field must render differently.
  • The Arbitrage row's three states are driven, with a sensor answering None and — for the stale case — by a real second cycle rather than a hand-set attribute, and graded by inequality: two facts that must not render identically.
  • End to end against scenarios/energy-charts-be-price-*.json, graded against the captured document's own length (habit 19).

Mutations

Every check driven. Harness /home/homelab/.cache/t11-mut/run.py: each mutation applied to a fresh cp -a of the clean worktree (never edit-and-revert — habit 18), __pycache__ purged on the host, tree bind-mounted :ro so no cache can be written into it, mutated line asserted on disk, byte delta recorded, full 29-file run each.

16 mutations, run rather than reasoned about, no survivors. M2 is +0 B
and is called out as such, per MUTATION.md's reporting rule. Harness
/home/homelab/.cache/t11-mut/run.py: each mutation on a fresh cp -a of the
clean worktree rather than edit-and-revert (habit 18), __pycache__ purged,
tree bind-mounted :ro so no cache can be written into it, the mutated line
asserted on disk, full 29-file run each.

mutation file Δ red
M1 the SHIPPED DEFECT restored: words prints the point count in the hour slot static_tariff.py +3 B 2
M2 firm and carried TRANSPOSED - habit 14's zero-byte edit, both numbers still in the sentence static_tariff.py +0 B 1
M3 hours counts POINTS - the defect one level down from words static_tariff.py -9 B 2
M4 _whole_hours stops truncating to the hour, so every quarter is its own 'hour' static_tariff.py -10 B 5
M5 the plan is dropped from the Tariff row - the shipped defect 3 main.py -133 B 8
M6 the plan's age is a constant, so a 5400 s old plan reads as current main.py -10 B 1
M7 the Arbitrage row goes back to INFERRING 'off' from a missing decision - the shipped defect 4 main.py +33 B 4
M8 a stale decision renders exactly like a fresh one main.py -144 B 2
M9 cycle_at is never stamped, so nothing can tell 'this cycle decided' from 'a previous one did' main.py -28 B 2
M10 arb_at is never stamped either - the row loses the age it reports main.py -18 B 4
M11 §8.3's refusal says 'hours' where it means points - the shipped defect 2, verbatim static_tariff.py -1 B 1
M12 the flat refusal drops the hour figure and goes back to calling points hours static_tariff.py -38 B 1
M13 a provider site with no plan falls silently back to the static sentence - the state the soak measured 802 times main.py -166 B 1
M14 only the FIRST spacing is reported, so a mixed 900/3600 plan reads as uniformly quarter-hourly static_tariff.py -23 B 1
M15 origin stops reaching the sentence: a §11.4 fallback reads as an observation of the feed static_tariff.py -190 B 1
M16 PAIRED: M1 and M3 together - the two rungs that answer 'how many hours' weakened at once static_tariff.py -6 B 2

⚠️ TWO OF THEM WERE FOUND BY THE BATTERY IN THE CHECK ITSELF, which is why
the battery is not optional:

  • M15 was a SURVIVOR of the whole suite at −190 B on the first draft. The
    origin pair gave the §11.4 plan provider="static_profile" as well as
    origin="fallback" — what a real one carries — so the two rows differed for
    two reasons and dropping origin from the sentence entirely changed
    nothing the check could see. Habit 4, in the check written to close this
    card. The provider string is now held equal and that equality is its own
    precondition check.
  • M9 produced a TRACEBACK and NO NAMED RED. cycle_at unstamped is
    __init__'s None, and datetime < None raised inside the check's
    f-string operand — FCST-06's finding, in the operand rather than in the
    condition, grading none of the checks after it. It goes through derived()
    now and M9 is red on two named checks.

Not in this card

  • TARIFF-09's price_feed_url override warning still predicts the wrong degraded state (app/price.py:1574): on a cold store the unpublished hours are absent, not "carried forward and flagged provisional". That was item 3 of the original card and is not among the four this ticket names; absence is the worse state and the warning names the milder one. Left for its own card.
  • Nothing in app/tariff.py or the capacity-peak state (TARIFF-13's).
  • No behaviour changed: no threshold, refusal rung, claim or setpoint moved. Every edit is a sentence, a derived counter behind one, or where a sentence is rendered.

All 29 test files green.


🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

Four defects of one shape: **the add-on's account of itself was wrong, always in the direction that flattered it.** They shipped together because nothing in this repository graded the strings a human is shown — every tariff check inspects the `TariffPlan` *object*, and all four were in the *sentences* built out of it. Reproduced before the fix, driven through the shipped `Controller` against the committed `energy-charts` capture; the harness and its transcript are at `/home/homelab/.cache/t11-mut/`. ## 1. `TariffPlan.words` counted points and called them hours Measured before: `dynamic_dayahead from generic_dayahead, 192 h` for **192 quarter-hourly points = 48 hours**. Belgium moved to a 15-minute MTU on 2025-10-01, so on the real feed it was wrong every time it was printed — and **not by a constant factor**: across a 40-hour soak the overstatement ranged **1.79× to 5.33×**, because §8.3's carried points are hourly while the firm ones are quarter-hourly. After: `dynamic_dayahead from generic_dayahead, 192 points over 48 h, 192 firm / 0 carried, 900 s spacing`. `N points over H h` is FCST-04's *Price history* wording on purpose, so the two rows describing the same feed can be compared. ## 2. The §8.3 and flat-tariff refusals did the same — and they reach an operator Before: `§8.3: only 1 of 96 hours carry a published price` about 96 points, which are 24 hours; the flat rung counted the same points and called them `firm hours`. ⚠️ This is exactly the point≠hour conflation TARIFF-14 removed from `at()`, left standing in the prose that describes it, and it reaches the status page through `self.arb.explain()`. After: `§8.3: only 1 of 96 points carry a published price - 1 of 24 hours - and the rest are provisional …` and `the tariff is flat - every one of the 96 firm points, spanning 24 hours, costs …`. The same conflation in `price_forecast.py`'s §8.3 carry-forward log line — `%d of %d hours` with a carried-**hour** count over a **point** count — is corrected in the same commit, because DOCS quotes that line verbatim. ## 3. The plan reached no HTTP surface at all Before, driven: `rows["Tariff"] == 'no static profile'` on a site whose feed had just answered with a 192-point plan. `origin`, `product`, the horizon and the firm/carried split were in **no** row — the soak read `'no static profile'` at all 802 samples. After: ``` no static profile (recomputed this tick); provider plan fetched 0 s ago for a 48 h horizon: dynamic_dayahead from generic_dayahead, 192 points over 48 h, 192 firm / 0 carried, 900 s spacing ``` ⚠️ **The two Tariff sources are kept distinguishable.** `static_status()` runs on the 30 s I/O-free tick; the provider plan comes off the 1800 s refresh and is served for up to `TARIFF_MAX_AGE_S` (5400 s since TARIFF-12). So the plan half carries **its own age**, measured from `tariff_at` — which TARIFF-12 deliberately does not move on a kept plan — and the check drives the same plan object at 0 s and at 4000 s and asserts the two rows differ. A provider site with no usable plan says so, instead of falling silently back to the static sentence. ⚠️ **FCST-04's Tariff↔DOCS pin is honoured, not routed around.** `test_price_history.py` pins the *Tariff* row and DOCS as a PAIR; wiring the plan in took it red, and DOCS.md now carries the sentence it had been overclaiming, with the row's five facts and the two-clock warning written out. ## 4. `main.py:4486-4489`'s "if and only if" was false `cycle()` returns at the input hold — inputs missing, `bad_for < stale_input_s`, 15 s by default — **before** it reaches `self.arb = None`. Driven, `arbitrage_enabled: true` plus a sensor answering `None`: ``` before: arbitrage.enabled(opts)=True self.arb=None row='off (arbitrage_enabled)' after: row='ON (`arbitrage_enabled`), but NO DECISION has been reached yet - no cycle has run as far as the arbitrage step. The loop's own reason: inputs missing 0s' ``` and the second half, after a healthy cycle whose SoC then drops out: ``` before: row == the previous cycle's decision, rendered as current after: 'no decision this cycle (inputs missing 0s); the last one was taken 0 s ago: arbitrage idle - NOT COVERED: …' ``` The row reads the **option** instead of inferring it from a missing decision; `cycle_at` (stamped before `cycle()`'s first return) and `arb_at` are what make "this cycle reached no decision" an observation rather than a guess. The comment now states what is true. ## The deliverable: a check that reads the RENDERED SURFACE `test_price.py` section 12. It renders through the shipped `Controller.status_payload()` and grades `rows["Tariff"]` and `rows["Arbitrage"]`. * **Parsed by slot, never by presence.** `"132 points" in row` is satisfied by the number appearing in the *other* number's slot — MUTATION.md habit 14's zero-byte edit. Each number is pulled out of its own slot and compared against a figure computed from the **fixture's two construction parameters**, never from the property the row was built out of (habit 4). * **The instrument is calibrated first** (habit 1): the slot parser is driven against a correct row *and* against a row wrong in exactly the way the defect was, and a missing slot is a failed derivation rather than a silent pass. * **The fixtures are a SET** (habit 10) of four plan shapes, and the check **computes and prints** the table of field pairs no fixture separates — `none`. It deliberately contains both the degenerate hourly arrangement (points == hours, where the defect hid for three cards) and the quarter-hourly one where they differ 4×. * **`origin` and `product` are graded by disagreement**, not by substring: two plans over the *identical* point list differing in one field must render differently. * **The *Arbitrage* row's three states are driven**, with a sensor answering `None` and — for the stale case — by a real second cycle rather than a hand-set attribute, and graded by **inequality**: two facts that must not render identically. * **End to end** against `scenarios/energy-charts-be-price-*.json`, graded against the **captured document's own length** (habit 19). ## Mutations Every check driven. Harness `/home/homelab/.cache/t11-mut/run.py`: each mutation applied to a fresh `cp -a` of the clean worktree (never edit-and-revert — habit 18), `__pycache__` purged on the host, tree bind-mounted `:ro` so no cache can be written into it, mutated line asserted on disk, byte delta recorded, full 29-file run each. **16 mutations, run rather than reasoned about, no survivors.** M2 is `+0 B` and is called out as such, per MUTATION.md's reporting rule. Harness `/home/homelab/.cache/t11-mut/run.py`: each mutation on a fresh `cp -a` of the clean worktree rather than edit-and-revert (habit 18), `__pycache__` purged, tree bind-mounted `:ro` so no cache can be written into it, the mutated line asserted on disk, full 29-file run each. | | mutation | file | Δ | red | |---|---|---|---|---| | M1 | the SHIPPED DEFECT restored: `words` prints the point count in the hour slot | `static_tariff.py` | +3 B | **2** | | M2 | firm and carried TRANSPOSED - habit 14's zero-byte edit, both numbers still in the sentence | `static_tariff.py` | +0 B | **1** | | M3 | `hours` counts POINTS - the defect one level down from `words` | `static_tariff.py` | -9 B | **2** | | M4 | `_whole_hours` stops truncating to the hour, so every quarter is its own 'hour' | `static_tariff.py` | -10 B | **5** | | M5 | the plan is dropped from the *Tariff* row - the shipped defect 3 | `main.py` | -133 B | **8** | | M6 | the plan's age is a constant, so a 5400 s old plan reads as current | `main.py` | -10 B | **1** | | M7 | the *Arbitrage* row goes back to INFERRING 'off' from a missing decision - the shipped defect 4 | `main.py` | +33 B | **4** | | M8 | a stale decision renders exactly like a fresh one | `main.py` | -144 B | **2** | | M9 | `cycle_at` is never stamped, so nothing can tell 'this cycle decided' from 'a previous one did' | `main.py` | -28 B | **2** | | M10 | `arb_at` is never stamped either - the row loses the age it reports | `main.py` | -18 B | **4** | | M11 | §8.3's refusal says 'hours' where it means points - the shipped defect 2, verbatim | `static_tariff.py` | -1 B | **1** | | M12 | the flat refusal drops the hour figure and goes back to calling points hours | `static_tariff.py` | -38 B | **1** | | M13 | a provider site with no plan falls silently back to the static sentence - the state the soak measured 802 times | `main.py` | -166 B | **1** | | M14 | only the FIRST spacing is reported, so a mixed 900/3600 plan reads as uniformly quarter-hourly | `static_tariff.py` | -23 B | **1** | | M15 | `origin` stops reaching the sentence: a §11.4 fallback reads as an observation of the feed | `static_tariff.py` | -190 B | **1** | | M16 | PAIRED: M1 and M3 together - the two rungs that answer 'how many hours' weakened at once | `static_tariff.py` | -6 B | **2** | ⚠️ **TWO OF THEM WERE FOUND BY THE BATTERY IN THE CHECK ITSELF**, which is why the battery is not optional: * **M15 was a SURVIVOR of the whole suite at −190 B on the first draft.** The origin pair gave the §11.4 plan `provider="static_profile"` as well as `origin="fallback"` — what a real one carries — so the two rows differed for **two** reasons and dropping `origin` from the sentence entirely changed nothing the check could see. Habit 4, in the check written to close this card. The provider string is now held **equal** and that equality is its own precondition check. * **M9 produced a TRACEBACK and NO NAMED RED.** `cycle_at` unstamped is `__init__`'s `None`, and `datetime < None` raised inside the check's **f-string operand** — FCST-06's finding, in the operand rather than in the condition, grading none of the checks after it. It goes through `derived()` now and M9 is red on two named checks. ## Not in this card * **TARIFF-09's `price_feed_url` override warning still predicts the wrong degraded state** (`app/price.py:1574`): on a cold store the unpublished hours are **absent**, not "carried forward and flagged provisional". That was item 3 of the original card and is not among the four this ticket names; absence is the worse state and the warning names the milder one. Left for its own card. * Nothing in `app/tariff.py` or the capacity-peak state (TARIFF-13's). * No behaviour changed: no threshold, refusal rung, claim or setpoint moved. Every edit is a sentence, a derived counter behind one, or where a sentence is rendered. All 29 test files green. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 2 commits 2026-09-13 13:59:14 +02:00
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
Merge remote-tracking branch 'origin/release/1.0' into TARIFF-11
checks / unit (pull_request) Successful in 7m20s
a11597e0b3
# Conflicts:
#	goodwe_controller/CHANGELOG.md
admin merged commit c518007caf into release/1.0 2026-09-13 14:07:08 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#84