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
This commit is contained in:
2026-09-13 13:51:05 +02:00
co-authored by Claude Opus 5
parent 068b83f57c
commit 610725bed9
6 changed files with 1170 additions and 28 deletions
+90
View File
@@ -22,6 +22,7 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded |
| _this branch_ | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** |
| _this branch_ | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price |
| _this branch_ | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface |
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
@@ -57,6 +58,95 @@ separate card. *(Annotated 2026-09-13: that card is **TARIFF-15**, below, and it
shipped on this branch as `custom_formula`. It is still not an alias — the
operator types their own coefficients; nothing is invented on their behalf.)*
### TARIFF-11 - the plan's own account of itself, corrected and put on the page
**Four defects, one shape: every one of them made the add-on look better than
it was.** 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.
**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 on the real feed this was wrong every time it was printed, and
⚠️ **not by a constant factor**: measured 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 and the ratio moves with the mix
through the day. It now reads
```
dynamic_dayahead from generic_dayahead, 132 points over 33 h,
132 firm / 0 carried, 900 s spacing
```
— the same plan, with each number in its own unit. On the weekday morning that
plan is partly carried and reads `60 points over 33 h, 36 firm / 24 carried,
900+3600 s spacing, provisional` instead, which is the state the soak found an
operator could learn nothing about. The `N points over H h` wording is
FCST-04's *Price history* wording on purpose, so the two rows describing the
same feed can be compared rather than being two conventions on one page.
**2. The §8.3 and flat-tariff refusals did the same, and they reach an
operator.** `§8.3: only 1 of 96 hours carry a published price` is 96 points,
which is 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 is not confined
to a log line, because `self.arb.explain()` puts it on the status page. Both
now print the two quantities separately: `only 1 of 96 points carry a published
price - 1 of 24 hours`. 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.
**3. The plan reached no HTTP surface at all.** `origin`, `product`, the
firm/carried split and the spacing appeared in **no** status row. `rows["Tariff"]`
was `static_status()`'s sentence, computed on the 30 s I/O-free tick from its
own static plan, and it **never saw the provider plan** — it read `'no static
profile'` at all **802** soak samples on a site whose feed was answering. So an
operator could not tell a working dynamic tariff from no tariff machinery at
all, and could not learn that the plan was `provisional` for 76 % of the soak
or mixed 900/3600 s for 70 %. The *Tariff* row now carries the plan.
⚠️ **The two halves of that row are not computed at the same moment and the row
says so.** `static_status()` runs on the 30 s tick; the provider plan comes off
the 1800 s background refresh and is served for up to `TARIFF_MAX_AGE_S`
(5400 s since TARIFF-12). So the plan half is stamped with **its own age**,
measured from `tariff_at` — which TARIFF-12 deliberately does not move on a
kept plan. A provider site with no usable plan says that too, rather than
falling silently back to the static sentence.
**4. `main.py`'s "if and only if" was false, and the row named an option that
was on.** TARIFF-10's comment asserted that `self.arb is None` **iff**
`arbitrage.enabled(self.o)` 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` gives `arbitrage.enabled(opts) = True`, `self.arb = None` and
a row reading `off (arbitrage_enabled)`. Past the first completed cycle the
same early return leaves the **previous** decision standing, which the row
rendered as a current one, up to ~15 s stale. The row now distinguishes three
facts with three remedies — trading is off; trading is on and this cycle
reached no decision; a decision, with its age — and it reads the **option**
rather than inferring it from a missing decision. The comment states what is
true.
⚠️ **THE DELIVERABLE IS THE CHECK, NOT THE FIXES.** All four survived 29 green
suites because no check in this repository had ever read a rendered row.
`test_price.py` section 12 renders through the shipped `Controller.status_payload()`,
parses each number out of the row **by its own slot**, and compares it against
a figure computed from the fixture's own construction parameters — never from
the property the row was built out of (MUTATION.md habit 4). It is deliberately
**not** a substring test over a sentence the same file wrote: `"132 points" in
row` is satisfied by the number appearing in the *other* number's slot, which
habit 14 shows is a zero-byte edit away. The fixtures are a **set** of four
plan shapes, and the check computes and prints the table of field pairs no
fixture separates — `none` — so a pair that became degenerate would go red
rather than quietly vacuous. The *Arbitrage* row's three states are driven
through real cycles with a sensor answering `None`, and graded by
**inequality**: two facts that must not render identically.
**Nothing about what the battery decides changes.** No threshold, no refusal
rung, no claim, no setpoint. Every edit is to a sentence, to a derived counter
behind one, or to where a sentence is rendered.
### TARIFF-15 - your own tariff card, as two coefficients
**New price provider `custom_formula`, and one new option, `price_formula`.**