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`.**
+71 -6
View File
@@ -1765,12 +1765,50 @@ hour the feed did not print at all.
**What you can actually see on the status page.** *Price history* (below) says
what is held. *Arbitrage* carries the strategy's own sentence, which names the
firm/provisional split whenever that split is what refused a trade. *Tariff*
does **not** report the fill: it is the static-profile or Home Assistant
price-entity sentence, regenerated on the 30 s tick. The provider plan's own
line — `dynamic_dayahead from generic_dayahead, 48 h, provisional` — goes to the
**add-on log** at every refresh, alongside `§8.3: 33 of 48 hours carried forward
from yesterday's profile and flagged provisional; 15 published`.
firm/provisional split whenever that split is what refused a trade. And
**Tariff** on the status page reads `provisional` through the morning, because
since TARIFF-11 that row carries the provider plan itself:
```
Tariff: no static profile (recomputed this tick); provider plan fetched 412 s
ago for a 48 h horizon: dynamic_dayahead from generic_dayahead,
60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing,
provisional
```
⚠️ **The two halves of that row are not computed at the same moment, and the
row says so.** Everything before the semicolon is the static-profile or Home
Assistant price-entity sentence, regenerated on the **30 s tick** from options
alone — no network. Everything after it is the **background refresh's**
resolution, which runs every 1800 s and is served for up to 5400 s before it is
retired; `fetched N s ago` is the age of the fetch that produced it and is the
only figure in the row that tells you whether the feed is still answering. On a
site with a provider configured and no usable plan the row says that too,
rather than falling silently back to the static half.
Read the plan half as five separate facts: which *product* the feed publishes
and which *plugin* read it, how many **points** it carries and how many **whole
hours** those points cover, how many of the points are **firm** (published for
their own interval) against **carried** (filled from yesterday under §8.3), the
**spacing** of the points in seconds — `900+3600` is the normal mixed steady
state, quarter-hourly firm points beside hourly carried ones — and `provisional`
whenever any point is carried.
⚠️ **Points and hours are two different numbers and this row used to print one
of them under the other's name.** `words` was `f"… {len(points)} h"`, so a
33-hour plan of quarter-hourly points logged `dynamic_dayahead from
generic_dayahead, 132 h`. It is the same conflation the *Price history* row
carried before FCST-04 and the same one `at()` carried before TARIFF-14, and it
is **not a constant factor**: measured over a 40-hour soak the overstatement
ranged **1.79× to 5.33×**, because the carried points are hourly while the firm
ones are quarter-hourly, so the ratio moves with the mix as the day goes on.
The same sentence is in the **add-on log** at every refresh, alongside `§8.3:
33 of 48 hours carried forward from yesterday's profile and flagged
provisional; the feed published 60 points over the other 15 hours`.
The §8.3 refusal an operator reads when a trade is declined counts in the same
two units — `§8.3: only 1 of 96 points carry a published price - 1 of 24 hours`
— where it used to say `only 1 of 96 hours` about a 24-hour day.
The plan is also now cut to the horizon you asked for, which means **hours that
have already elapsed are no longer in it**. That is a real change to arbitrage:
@@ -1951,6 +1989,33 @@ only thing that decides whether a request leaves the machine is
`price_provider`: with it `off`, the refresh resolves the static profile in
memory and opens no connection.
**The *Arbitrage* status row says which of three things is true** — trading is
off, trading is on and this cycle reached no decision, or a decision was
reached — and it stamps every decision with its age. Until TARIFF-11 it said
one of two, because the first two rendered identically. They have three
different remedies:
| the row opens with | what is true | what to do |
|---|---|---|
| **off** — *"arbitrage_enabled is false, so nothing trades"* | Trading is not armed. Nothing will happen, and nothing is wrong | Arm it, if you meant to |
| **ON (arbitrage_enabled), but NO DECISION has been reached yet** | Trading **is** armed, and no cycle has run as far as the arbitrage step. The row then names the control loop's own reason, usually *inputs missing* | Fix the input the loop names |
| **decided N s ago:** … | The strategy answered this cycle, and `N` is how old that answer is | Read the sentence that follows — it is the strategy's own |
| **no decision this cycle (reason); the last one was taken N s ago:** … | Trading is armed, a decision stands from an earlier cycle, and **this** cycle stopped before reaching the step | As row 2 — the standing decision is history, not a verdict on now |
A fifth rendering — *"…no decision this cycle …, and none stands from an
earlier one"* — covers the case where trading was armed after a cycle had
already run without it.
⚠️ **The second row used to render exactly like the first one**, naming
`arbitrage_enabled` on a site where `arbitrage_enabled` is `true`. A cycle with
a missing input returns after roughly `stale_input_s` (15 s by default) without
reaching the arbitrage step at all, so on an armed site the row reported *off*
at startup and, once one decision had been taken, went on showing that decision
for up to 15 s after the site stopped deciding anything. The age on the third
row is printed **always**, including when it is zero: a row that reports
staleness only once it is stale cannot be told from one that never reports
it.
### Site
| option | meaning |
+160 -10
View File
@@ -218,6 +218,106 @@ def pack_energy_of(inv):
inv.capacity_kwh, inv.max_w)
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
provider: bool, max_age_s: float) -> str:
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan.
THE DEFECT THIS PREVENTS (TARIFF-11). `origin`, `product`, the horizon,
the firm/carried split and the spacing appeared in NO status row. The row
was `static_status()`'s sentence alone, 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 samples of a 40-hour soak on a site whose
feed was answering. The only external evidence a plan existed at all was
one `_LOG.info` line, and that line carried the point<->hour mislabel
`TariffPlan.words` is fixed for.
⚠️ TWO SOURCES, TWO CLOCKS, AND THE ROW SAYS SO IN WORDS. These sentences
are NOT computed at the same moment and presenting them as though they
were would be a new version of the defect being fixed:
`tick_words` static_status() / the Home Assistant price entities,
regenerated on THIS tick, every 30 s, I/O-free;
`plan` the background refresh's resolution, up to
TARIFF_REFRESH_S old before it is retried and up to
TARIFF_MAX_AGE_S (5400 s since TARIFF-12) old before
`fresh_tariff_plan()` retires it.
So the plan half is stamped with its OWN age in seconds, measured from
`tariff_at` - the moment of the fetch that produced it, which TARIFF-12
deliberately does not move on a kept plan.
⚠️ AND `age_s` IS THE CALLER'S MEASUREMENT, NOT THIS FUNCTION'S. A clock
read in here would be a second clock disagreeing with the one that decided
the plan was fresh enough to pass at all.
⚠️ "NO PLAN" IS SAID OUT LOUD ON A SITE THAT CONFIGURED A PROVIDER. A
dynamic site whose refresh has never completed, or whose plan has aged
past `max_age_s`, is the exact state the soak measured; rendering it as
the bare static sentence is how it stayed invisible for 802 samples.
"""
if plan is None:
if not provider:
return tick_words
return (f"{tick_words} (recomputed this tick); NO provider plan - "
f"none has been resolved yet, or the last one is older than "
f"{max_age_s:.0f} s")
return (f"{tick_words} (recomputed this tick); provider plan fetched "
f"{age_s:.0f} s ago for a {horizon_h:.0f} h horizon: {plan.words}")
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
why: str) -> str:
"""The *Arbitrage* status row: THREE states, which used to render as two.
THE DEFECT THIS PREVENTS (TARIFF-11). The row was
`"off (arbitrage_enabled)" if self.arb is None else self.arb.explain()`,
under a comment asserting that `self.arb is None` if and only if
`arbitrage.enabled(self.o)` was False. IT IS NOT: `cycle()` returns at the
input hold - inputs missing, `bad_for < stale_input_s`, default 15 s -
BEFORE it reaches `self.arb = None`. Driven: `arbitrage_enabled: true`
plus a sensor answering `None` gives `arbitrage.enabled(opts) = True`,
`self.arb = None` (its `__init__` value, untouched) and a row reading
`off (arbitrage_enabled)` - NAMING AN OPTION THAT IS ON. Past the first
completed cycle the same early return leaves the PREVIOUS decision
standing instead, which the row then rendered as a current one.
THE THREE FACTS, and they are three because they have three remedies:
trading is off the operator has not armed it. Nothing is
wrong and nothing will happen.
no decision this cycle it is armed, and the cycle did not get as far
as the arbitrage step. `why` is the control
loop's own reason - usually "inputs missing".
a decision the strategy's own sentence, with the age of
the cycle that produced it.
⚠️ `on` IS READ FROM THE OPTIONS, NOT INFERRED FROM `decision is None`.
Inferring it is the whole defect: the option is the only thing that
answers "is trading armed", and it is available on every render.
⚠️ THE AGE IS ALWAYS PRINTED, INCLUDING WHEN IT IS ZERO. A row that
reports staleness only once it is stale cannot be told, on any single
render, from one that never reports it - and the bound TARIFF-10's
comment claimed did not exist is ~15 s of it.
"""
if not on:
return "off - `arbitrage_enabled` is false, so nothing trades"
if decided_at is None:
return (f"ON (`arbitrage_enabled`), but NO DECISION has been reached "
f"yet - no cycle has run as far as the arbitrage step. The "
f"loop's own reason: {why or 'none recorded'}")
age = (now - decided_at).total_seconds()
stale = cycle_at is not None and decided_at < cycle_at
head = (f"no decision this cycle ({why or 'no reason recorded'}); the "
f"last one was taken {age:.0f} s ago: " if stale else
f"decided {age:.0f} s ago: ")
if decision is None:
return (f"ON (`arbitrage_enabled`), no decision this cycle "
f"({why or 'no reason recorded'}), and none stands from an "
f"earlier one")
return head + decision.explain()
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db",
@@ -796,6 +896,19 @@ class Controller:
self.tariff_plan = None
self.tariff_at = None
self.arb = None
# TARIFF-11. WHEN the standing arbitrage decision was taken, or None if
# this controller has never reached the arbitrage step. ⚠️ `self.arb is
# None` DOES NOT MEAN "TRADING IS OFF" AND NEVER DID - see
# `arbitrage_row()`. It also means "cycle() returned before the step
# ran", and at that point `self.arb` is either this attribute's initial
# None (startup) or the PREVIOUS cycle's decision (an input hold), and
# the status row rendered all three identically.
self.arb_at = None
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
# is the exact, derived statement of "the latest cycle did not get as
# far as the arbitrage step", which is the fact the *Arbitrage* row
# used to render identically to "trading is switched off".
self.cycle_at = None
self._arb_action = None
self.inputs_bad_since = None
# SAFETY-03. What the loop's OWN bounds did last cycle - a
@@ -1565,6 +1678,24 @@ class Controller:
self.cheap, self.tariff_words = static_status(
o, datetime.now(timezone.utc))
# ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND
# THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the
# 30 s I/O-free tick; `fresh_tariff_plan()` is an attribute read and an
# age comparison against TARIFF_MAX_AGE_S - still no fetch on this path,
# which is the rule the whole branch above exists to keep. What the row
# gains is the plan's SHAPE (origin, product, horizon, the firm/carried
# split, the spacing) and what it must never gain is the suggestion
# that the two halves were computed at the same instant, so the plan
# half carries its own age in seconds. See `tariff_plan_row()`.
_tick = datetime.now(timezone.utc)
_standing = self.fresh_tariff_plan(_tick)
self.tariff_words = tariff_plan_row(
_standing, self.tariff_words,
age_s=(None if _standing is None
else (_tick - self.tariff_at).total_seconds()),
horizon_h=float(price_option(o, "price_horizon_h")),
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
if self.inputs_ok:
if self.inputs_bad_since is not None:
self.log_event("inputs recovered")
@@ -1918,6 +2049,11 @@ class Controller:
method's only job is to state honestly what each strategy wants.
"""
now = datetime.now(timezone.utc)
# ⚠️ BEFORE THE FIRST `return` OF THIS METHOD, ON PURPOSE (TARIFF-11).
# It stamps that a cycle RAN, which is a different fact from any of the
# decisions the cycle may or may not reach, and the status row needs
# the two separately.
self.cycle_at = now
# ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration
# drains and refills ONE pack and grades ONE coulomb counter, so each
@@ -2577,7 +2713,13 @@ class Controller:
# `frozenset()` would be main.py asserting a forecast is healthy when
# there is not one, which is exactly what static_tariff's `uncertainty`
# argument has no default in order to prevent.
self.arb = None
# ⚠️ TARIFF-11: BOTH ATTRIBUTES, AND `arb_at` IS WHAT MAKES THE ROW
# HONEST. Reaching this line is the only proof that this cycle got as
# far as the arbitrage step; the early return at the input hold above
# leaves BOTH standing at whatever the last completed cycle left, and
# `arb_at` is then the age the status row reports rather than a
# decision presented as current.
self.arb, self.arb_at = None, now
if arbitrage.enabled(self.o):
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
@@ -4481,15 +4623,23 @@ class Controller:
# every refusal it can make already carries a reason a human can
# act on, and "Arbitrage: no" would throw all of them away.
#
# ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE, which is worth
# saying because that card is about the two switches being
# different. `self.arb` is None if and only if
# `arbitrage.enabled(self.o)` was False, so `off (arbitrage_enabled)`
# still names the option that is actually off - and on a site with a
# provider configured it is now the row that says the prices
# arriving on the *Price history* row above are moving nothing.
("Arbitrage", "off (arbitrage_enabled)" if self.arb is None
else self.arb.explain()),
# ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A
# COMMENT THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. The
# claim was that `self.arb is None` **if and only if**
# `arbitrage.enabled(self.o)` was False. Only the forward direction
# holds: `cycle()` returns at the input hold (`bad_for <
# stale_input_s`, default 15 s) BEFORE it reaches `self.arb = None`
# at all, so on an armed site `self.arb` is `__init__`'s None at
# startup and the PREVIOUS cycle's decision during a later hold -
# and the row rendered `off (arbitrage_enabled)` on a site whose
# `arbitrage_enabled` is `true`. `arbitrage_row()` reads the option
# instead of inferring it, and separates "trading is off" from "no
# decision was reached this cycle"; `cycle_at` and `arb_at` are what
# make the second one an observation rather than a guess.
("Arbitrage", arbitrage_row(
on=arbitrage.enabled(self.o), decision=self.arb,
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.reason)),
]
return {
"banner": banner, "level": level, "rows": rows, "checks": checks,
+12 -2
View File
@@ -422,9 +422,19 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
_audit(out, published)
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
if carried:
# ⚠️ THREE QUANTITIES, THREE NAMES (TARIFF-11). This line read
# "%d of %d hours ... ; %d published" with `carried` (a count of
# carried HOURS - the fill emits exactly one point per unpublished
# hour) over `len(out)` (a count of POINTS, four per hour wherever the
# feed printed) and `len(out) - carried` (POINTS again) under the same
# noun. On the Belgian quarter-hourly feed that reads "33 of 141
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
# feed's is the POINT; nothing derived from one answers for the other.
total_h = len({_utc_hour(p.start) for p in out})
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
"profile and flagged provisional; %d published",
carried, len(out), len(out) - carried)
"profile and flagged provisional; the feed published %d "
"points over the other %d hours",
carried, total_h, len(out) - carried, total_h - carried)
product = read.match(series=lambda r: r.product,
no_data=lambda _r: yesterday.product)
+138 -9
View File
@@ -771,6 +771,63 @@ class TariffPlan:
series=lambda r: any(p.provisional for p in r.points),
no_data=lambda _r: True)
# -- the plan's SHAPE, in the two units it actually has (TARIFF-11) ------
# ⚠️ POINTS AND HOURS ARE TWO NUMBERS AND THIS PLAN HAD ONLY EVER
# REPORTED ONE OF THEM, UNDER THE OTHER'S NAME. `words` read
# `f"... {len(self.read.points)} h"`, which counts POINTS: on the
# quarter-hourly Belgian feed (15-minute MTU, 2025-10-01) a 33-hour plan
# logged `dynamic_dayahead from generic_dayahead, 132 h`. It is the same
# conflation TARIFF-14 removed from `at()` and FCST-04 removed from the
# *Price history* row, left standing in the prose. ⚠️ AND THE ERROR IS
# NOT A FLAT 4x: a 40 h soak measured 1.79x .. 5.33x, because §8.3's
# carried points are HOURLY while the firm ones are quarter-hourly, so
# the ratio moves with the firm/carried mix as the day goes on. Nothing
# derived from ONE of these numbers can answer for the other.
@property
def n_points(self) -> int:
"""How many points the series carries. A `no_data` read carries none."""
return self.read.match(series=lambda r: len(r.points),
no_data=lambda _r: 0)
@property
def hours(self) -> int:
"""How many distinct WHOLE CLOCK HOURS those points touch.
Same rule as `price_history._held()`, deliberately - the *Tariff* and
*Price history* rows describe the same feed and an operator reading
`132 points over 33 h` beside `96 points over 24 h` must be able to
compare them. A point is attributed to the UTC hour its own start
falls in; hours the feed never printed are simply not in the set, so
this counts what is PRICED rather than what was asked for.
"""
return self.read.match(series=lambda r: _whole_hours(r.points),
no_data=lambda _r: 0)
@property
def n_firm(self) -> int:
"""Points carrying a published price for their own interval (§8.3)."""
return len(self.firm_points())
@property
def n_carried(self) -> int:
"""Points filled from yesterday's profile, or from §11.4's fallback."""
return self.n_points - self.n_firm
@property
def spacings_s(self) -> tuple:
"""The distinct gaps between consecutive points, ascending, in seconds.
⚠️ MIXED IS THE NORMAL STEADY STATE, NOT AN EDGE CASE. FCST-04's
carry-forward emits HOURLY carried points beside QUARTER-HOURLY firm
ones and the soak measured `[900, 3600]` in 70 % of samples - so a
surface reporting one global spacing would be wrong about the usual
case. `price._cover_table()` is where that rule is enforced; this
only reports it.
"""
return self.read.match(
series=lambda r: _spacings(r.points), no_data=lambda _r: ())
def price_at(self, instant: datetime) -> float:
"""EUR/kWh now. Raises `Absence` when there is no price - never 0.0."""
return self.read.match(
@@ -926,20 +983,30 @@ class TariffPlan:
pts = self.read.points
firm = tuple(p for p in pts if not p.provisional)
if len(firm) < 2:
# ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF
# IT (TARIFF-11). This sentence read "only 1 of 96 hours carry a
# published price" about 96 quarter-hourly points, which are 24
# hours - the point<->hour conflation TARIFF-14 removed from
# `at()`, still standing here, and this one reaches an OPERATOR
# through main.py's *Arbitrage* row and not only the log.
return Arbitrage.refuse(
f"§8.3: only {len(firm)} of {len(pts)} hours carry a published "
f"price - the rest are provisional (origin {self.origin!r}), "
f"and no irreversible action may be taken on a provisional "
f"price. A buy hour and a sell hour must BOTH be firm before a "
f"cycle can be committed. Load shifting, self-consumption and "
f"peak shaving continue")
f"§8.3: only {len(firm)} of {len(pts)} points carry a "
f"published price - {_whole_hours(firm)} of "
f"{_whole_hours(pts)} hours - and the rest are provisional "
f"(origin {self.origin!r}), so no irreversible action may be "
f"taken on them. A buy interval and a sell interval must BOTH "
f"be firm before a cycle can be committed. Load shifting, "
f"self-consumption and peak shaving continue")
spread = round(max(p.eur_per_kwh for p in firm)
- min(p.eur_per_kwh for p in firm), 6)
if spread == 0.0:
# ⚠️ POINTS AND HOURS, SEPARATELY, for the §8.3 rung's reason -
# this sentence counted points and called them "firm hours" too.
return Arbitrage.refuse(
f"the tariff is flat - every one of the {len(firm)} firm hours "
f"costs {firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
f"the tariff is flat - every one of the {len(firm)} firm "
f"points, spanning {_whole_hours(firm)} hours, costs "
f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
f"selling high is arithmetically impossible. §9.2: a fully "
f"fixed product is self-consumption only")
@@ -969,15 +1036,77 @@ class TariffPlan:
@property
def words(self) -> str:
"""The plan's whole shape in one sentence - LOG *AND* STATUS PAGE.
⚠️ THIS STRING IS OPERATOR-FACING SINCE TARIFF-11, which is a reason
for its wording to matter MORE rather than less. It used to reach only
`_LOG.info("tariff refreshed: %s")`; main.py's *Tariff* row now carries
it, age-stamped, so every number in it is read by somebody who cannot
see the object.
⚠️ EVERY QUANTITY IS NAMED IN ITS OWN UNIT. `{n} points over {h} h` is
FCST-04's *Price history* wording on purpose - the two rows describe
the same feed and have to be comparable - and the firm/carried split
and the spacing are here because they were the two facts the soak
found an operator could not learn at all: the plan was `provisional`
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
surface said either.
"""
if not self.has_prices:
return f"no tariff - {self.read.reason}"
tag = {"provider": self.read.provider, "static": "static_profile",
"fallback": "static_profile (feed down)"}.get(self.origin,
self.origin)
return (f"{self.product.value} from {tag}, {len(self.read.points)} h"
gaps = self.spacings_s
return (f"{self.product.value} from {tag}, "
f"{self.n_points} points over {self.hours} h, "
f"{self.n_firm} firm / {self.n_carried} carried"
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
if gaps else "")
+ (", provisional" if self.provisional else ""))
def _whole_hours(points) -> int:
"""How many distinct whole CLOCK HOURS a point list touches. Never raises.
THE DEFECT THIS PREVENTS (TARIFF-11). Three sentences in this module
reported `len(points)` under the name "hours": `TariffPlan.words`, §8.3's
firm-pair refusal and the flat-tariff refusal. On the hourly series that
was the only resolution any fixture carried before TARIFF-08 the two
numbers are identical, which is why it shipped; on the Belgian
quarter-hourly feed (15-minute MTU, 2025-10-01) `words` read
`dynamic_dayahead from generic_dayahead, 132 h` for 33 hours and the
refusal read `only 1 of 96 hours` for 24.
⚠️ IT IS NOT `n / 4`, AND A CONSTANT FACTOR IS THE WRONG FIX. §8.3's
carry-forward emits ONE HOURLY point per unpublished hour beside the
feed's four-per-hour firm ones, so the measured overstatement over a
40-hour soak ranged 1.79x .. 5.33x as the mix moved. The hour count has
to be COUNTED.
⚠️ IT COUNTS PRICED HOURS, NOT THE HORIZON. FCST-04 leaves an hour out
of the series when neither the feed nor yesterday can price it, so this
is at most the horizon and is smaller whenever the plan has holes - which
is the honest direction for a figure an operator reads as "how far ahead
do I have prices".
"""
return len({p.start.astimezone(timezone.utc)
.replace(minute=0, second=0, microsecond=0) for p in points})
def _spacings(points) -> tuple:
"""Distinct gaps between consecutive points, ascending, in whole seconds.
Sorted by START, not by list order: `price._cover_table()` establishes
that order for `at()` rather than assuming it, and a surface that reported
the gaps of an unsorted list would print negative spacings about a series
that answers correctly.
"""
starts = sorted(p.start.astimezone(timezone.utc) for p in points)
return tuple(sorted({int((b - a).total_seconds())
for a, b in zip(starts, starts[1:])}))
def _absent(reason):
raise Absence(
f"this plan observed no prices ({reason}). A number invented here is "
+699 -1
View File
@@ -51,6 +51,7 @@ import io
import json
import logging
import os
import re
import shutil
import sys
import tempfile
@@ -79,7 +80,7 @@ from app import price as _price
from app import arbitrage as _arb
from app import main as _main
from app.p1 import make_sample, split_signed
from app.static_tariff import TariffPlan
from app.static_tariff import TariffPlan, static_status
from app.validate import validate_startup_config
fails = []
@@ -3767,6 +3768,703 @@ check("the id is offered by config.yaml's schema, so a site configured on it "
"reaches these messages rather than a Supervisor schema error",
"custom_formula" in _schema_ids)
# =========================================================================== #
# 12. TARIFF-11 - THE RENDERED SURFACE, GRADED AS A SURFACE
# =========================================================================== #
#
# WHAT THIS SECTION IS FOR, and it is the only thing on the card that is not a
# one-line fix. Four defects shipped together and every one of them survived 29
# green suites for the SAME reason: **nothing in this repository graded the
# strings a human is shown.** Every existing tariff check inspects the
# `TariffPlan` OBJECT - `plan.read.points`, `plan.firm_points()`, `verdict.kind`
# - and all four defects were in the SENTENCES built out of that object:
#
# 1. `TariffPlan.words` counted POINTS and called them HOURS, so a 33-hour
# plan logged `dynamic_dayahead from generic_dayahead, 132 h`;
# 2. §8.3's and the flat rung's refusals did the same, and those reach an
# operator through main.py's *Arbitrage* row, not only the log;
# 3. the plan reached NO status row at all - `rows["Tariff"]` was
# `static_status()`'s own sentence and read `'no static profile'` at all
# 802 samples of a 40-hour soak on a site whose feed was answering;
# 4. `rows["Arbitrage"]` rendered "trading is off" and "no decision was
# reached this cycle" as the SAME string, naming an option that was on.
#
# ⚠️ SO THIS SECTION GRADES `status_payload()["rows"]` AND THE REFUSAL
# SENTENCES, NOT THE OBJECTS BEHIND THEM. It renders through the shipped
# `Controller`, parses each number OUT OF THE ROW BY ITS OWN SLOT, and compares
# it against a figure this file computed from the FIXTURE'S CONSTRUCTION
# PARAMETERS - never from the property the row was built out of. That is
# MUTATION.md habit 4: if both sides of a comparison come from the same
# expression, the check is decoration.
#
# ⚠️ AND IT IS DELIBERATELY NOT A SUBSTRING TEST OVER A SENTENCE THIS FILE ALSO
# WROTE. Three checks in this chain turned out to be graded by something they
# did not own - a docstring, a deleted plugin's text, the HEADING above a table
# whose ROWS had been emptied (MUTATION.md, TARIFF-15 M13). `"132 points" in
# row` is that mistake again: it is satisfied by the number appearing anywhere,
# including in the other number's slot, which habit 14 shows is a ZERO-BYTE
# edit away. Every NUMBER below is asserted as a parsed slot against an integer
# derived here; every other claim is an INEQUALITY between two renderings that
# must not be equal. The ONE substring test is the rung-attribution needle
# (`"the tariff is flat"`, `"carry a published price"`) that TEST-16 already
# owns and grades on - it asks WHICH RUNG spoke, not what this file wrote.
_T11_NOW = datetime(2026, 1, 14, 0, 0, tzinfo=timezone.utc)
_T11_BASE = dict(_T10_BASE, meter_entity="sensor.grid")
class _T11Hass(_T10Hass):
"""`_T10Hass`, with ONE reading switchable to `None`.
⚠️ THE `None` IS THE WHOLE STIMULUS FOR DEFECT 4. `inputs_ok` is False the
moment any pack's SoC does not answer, and `cycle()` then returns at the
input hold (`bad_for < stale_input_s`, 15 s by default) BEFORE it reaches
`self.arb = None`. A fixture whose sensors always answer cannot reach the
row this section is about.
"""
def __init__(self, soc=55.0):
self.soc = soc
async def number(self, entity, invert=False):
return {"sensor.grid": 0.0, "sensor.soc": self.soc,
"sensor.batt": 0.0}.get(entity)
def _t11_points(firm_h: int, carried_h: int, base=None) -> list:
"""`firm_h` QUARTER-HOURLY firm hours, then `carried_h` HOURLY carried ones.
FCST-04's real mixed shape, and the reason a single spacing is the wrong
model: the feed prints four points an hour and §8.3's carry-forward emits
exactly ONE point per unpublished hour, so a normal weekday-morning plan is
900 s at the front and 3600 s at the back. The soak measured that mix in
70 % of samples.
⚠️ THE PRICES ASCEND AND THE TWO BANDS DO NOT OVERLAP, so no check below
can pass by reading the wrong point: a mutation that grades a carried point
as firm moves a price as well as a count.
"""
t0 = base or _T11_NOW
pts = [PricePoint(t0 + timedelta(hours=h, minutes=15 * q),
round(0.100 + 0.001 * (4 * h + q), 5), False)
for h in range(firm_h) for q in range(4)]
pts += [PricePoint(t0 + timedelta(hours=firm_h + h),
round(0.500 + 0.001 * h, 5), True)
for h in range(carried_h)]
return pts
def _t11_plan(firm_h, carried_h, *, origin="provider",
provider="generic_dayahead", product=PriceProduct.DAY_AHEAD):
return TariffPlan(
PriceRead.series(_t11_points(firm_h, carried_h), product, provider,
_T11_NOW), None, origin)
def _t11_want(firm_h: int, carried_h: int) -> dict:
"""What a row about `_t11_points(firm_h, carried_h)` MUST say.
⚠️ COMPUTED FROM THE FIXTURE'S TWO CONSTRUCTION PARAMETERS AND FROM
NOTHING ELSE (habit 4). Not `len(plan.read.points)`, not `plan.hours`, not
`len(plan.firm_points())` - those are the expressions the row is built out
of, and a check that compares a rendered number against the property that
rendered it compares a thing against itself. `4 * firm_h` and
`firm_h + carried_h` are this file's own arithmetic over the shape it
asked for.
The spacing rule, stated rather than re-counted: consecutive quarters are
900 s apart, the last quarter of the last firm hour is 900 s before the
first carried hour, and consecutive carried hours are 3600 s apart - so
3600 appears only when there are at least TWO carried hours.
"""
gaps = ([900] if firm_h else []) + ([3600] if carried_h >= 2 else [])
return {"points": 4 * firm_h + carried_h, "hours": firm_h + carried_h,
"firm": 4 * firm_h, "carried": carried_h,
"spacings": gaps or [3600]}
class _NoRows(dict):
"""What a failed render hands back: a dict of `_Raised`, never a crash.
⚠️ FCST-06's LESSON, IN THE OPERAND RATHER THAN IN THE CONDITION. A
mutation that makes a render raise must report as ONE named red per check,
not as a traceback that takes the rest of the section with it and grades
none of it - a red COUNT is only a lower bound until the run is seen to
reach the end of its section.
"""
def __init__(self, e):
super().__init__()
self.e = e
def __missing__(self, key):
return _Raised(self.e)
class _NoCtl:
"""A controller a failed render never built. Every attribute is `_Raised`."""
def __init__(self, e):
self._e = e
def __getattr__(self, name):
return _Raised(self._e)
def _t11_render(over=None, *, plan=None, plan_age_s=0.0, soc=55.0, cycles=1,
age_arb_by_s=None, then_soc_none=False):
"""Drive the SHIPPED controller and hand back what a human would read.
Returns `(rows, ctl)` - `rows` is `dict(status_payload()["rows"])`, i.e.
the actual HTTP surface, and `ctl` is there so a check can ask the loop for
a figure to grade the row AGAINST (its own `reason`, its own `arb_at`).
⚠️ `read_inputs()` THEN `cycle()`, IN THAT ORDER, because that is what
`run_control()` does. A harness that called only `cycle()` would leave
every reading at `None` and grade an input-hold row it had manufactured
itself.
"""
opts = dict(_T11_BASE, **(over or {}))
tmp = tempfile.mkdtemp(prefix="tariff11-")
_tmpdirs_t10.append(tmp)
async def _go():
stop = asyncio.Event()
ctl = _main.Controller(opts, _T11Hass(soc), _T10Store(), _T10Pub(),
stop=stop,
peak_db_path=os.path.join(tmp, "t11.db"),
price_history_path=os.path.join(tmp, "ph.json"))
if plan is not None:
ctl.tariff_plan = plan
ctl.tariff_at = (datetime.now(timezone.utc)
- timedelta(seconds=plan_age_s))
for _ in range(cycles):
await ctl.read_inputs()
await ctl.cycle()
if then_soc_none:
# ⚠️ THE REAL STIMULUS FOR THE SECOND HALF OF DEFECT 4, and it is
# a control-flow one: a healthy cycle takes a decision, the SoC
# then stops answering, and the NEXT cycle returns at the input
# hold before it reaches the arbitrage step at all. `self.arb`
# still holds the previous decision - the row rendered it as a
# current one, with nothing saying the site had stopped deciding.
ctl.hass.soc = None
await ctl.read_inputs()
await ctl.cycle()
if age_arb_by_s is not None and ctl.arb_at is not None:
# ⚠️ THE CLOCK IS MOVED, NOT SLEPT THROUGH. The property under test
# is "the row reports the age of the decision", and a real 15 s
# wait would put a quarter of a minute into every run for a number
# the check can set exactly.
ctl.arb_at -= timedelta(seconds=age_arb_by_s)
return dict(ctl.status_payload()["rows"]), ctl
try:
return asyncio.run(_go())
except Exception as e: # noqa: BLE001
return _NoRows(e), _NoCtl(e)
# -- 12a. the instrument first: a parser that can tell right from wrong ------
# ⚠️ CALIBRATED BEFORE ANYTHING IS GRADED WITH IT (habit 1, and MUTATION.md's
# eleventh instance of habit 4 was in a measuring instrument rather than in a
# check). A slot parser that returns `None` for everything makes every row
# below vacuous; one that matches a number wherever it appears reads a
# transposition as a pass. So it is driven against a row that is RIGHT and a
# row that is WRONG IN EXACTLY THE WAY THE DEFECT WAS, and both answers are
# asserted.
_T11_SLOTS = {
"points": r"(\d+) points over \d+ h",
"hours": r"\d+ points over (\d+) h",
"firm": r"(\d+) firm / \d+ carried",
"carried": r"\d+ firm / (\d+) carried",
"horizon": r"for a (\d+) h horizon",
"age_s": r"fetched (\d+) s ago",
"spacings": r"([\d+]+) s spacing",
# ⚠️ THE *Arbitrage* ROW'S OWN AGE SLOT, and it is deliberately the same
# pattern for "decided N s ago" and "the last one was taken N s ago" -
# WHICH of the two sentences was rendered is graded by inequality below,
# and this only has to read the number out of whichever one it is.
"arb_age_s": r"(\d+) s ago",
# ⚠️ THE REFUSAL SENTENCES GET SLOTS TOO, for the rows' reason: `"1 of 96"
# in reason` is satisfied by that pair standing in the HOUR clause, which
# is the transposition the defect actually was.
"r83_firm_pts": r"only (\d+) of \d+ points carry a published price",
"r83_all_pts": r"only \d+ of (\d+) points carry a published price",
"r83_firm_h": r"published price - (\d+) of \d+ hours",
"r83_all_h": r"published price - \d+ of (\d+) hours",
"flat_pts": r"every one of the (\d+) firm points, spanning \d+ hours",
"flat_h": r"every one of the \d+ firm points, spanning (\d+) hours",
}
def _slot(row, name):
"""The number in ONE NAMED SLOT of a rendered row, or `_Raised`.
⚠️ BY SLOT, NEVER BY PRESENCE. `"33" in row` is true of a row that prints
33 in the POINT slot, which is a zero-byte transposition away and is
exactly MUTATION.md habit 14's finding. A slot that does not match is a
FAILED derivation and reports as one; it is not a miss that passes.
"""
def _get():
if not isinstance(row, str):
raise TypeError(f"no row to read: {row!r}")
m = re.search(_T11_SLOTS[name], row)
if m is None:
raise ValueError(f"no {name!r} slot in {row[:90]!r}")
raw = m.group(1)
return ([int(x) for x in raw.split("+")] if name == "spacings"
else int(raw))
return derived(_get)
_T11_GOOD = ("x (recomputed this tick); provider plan fetched 7 s ago for a "
"48 h horizon: dynamic_dayahead from generic_dayahead, "
"132 points over 33 h, 36 firm / 96 carried, 900+3600 s spacing")
_T11_SHIPPED_DEFECT = ("x (recomputed this tick); provider plan fetched 7 s "
"ago for a 48 h horizon: dynamic_dayahead from "
"generic_dayahead, 132 points over 132 h, "
"96 firm / 36 carried, 900+3600 s spacing")
# The slots divide into the ones a *Tariff*/​*Arbitrage* ROW carries and the
# ones a REFUSAL sentence carries; each group is calibrated against its own
# well-formed example, because a slot asked of the wrong kind of sentence is
# correctly absent and would make this check red for the wrong reason.
_T11_ROW_SLOTS = ("points", "hours", "firm", "carried", "horizon", "age_s",
"spacings", "arb_age_s")
_T11_REF_SLOTS = ("r83_firm_pts", "r83_all_pts", "r83_firm_h", "r83_all_h",
"flat_pts", "flat_h")
_T11_GOOD_REF = ("§8.3: only 1 of 96 points carry a published price - 1 of 24 "
"hours - and the rest are provisional; the tariff is flat - "
"every one of the 96 firm points, spanning 24 hours, costs")
print(f" slot parser on a correct row -> "
f"{ {k: _slot(_T11_GOOD, k) for k in _T11_ROW_SLOTS} }")
print(f" slot parser on a refusal -> "
f"{ {k: _slot(_T11_GOOD_REF, k) for k in _T11_REF_SLOTS} }")
check("the slot parser reads every field of a well-formed row and of a "
"well-formed refusal - at a single unreadable slot every check below "
"this line would be graded on a failed derivation rather than on the "
"sentence",
all(not isinstance(_slot(_T11_GOOD, k), _Raised) for k in _T11_ROW_SLOTS)
and all(not isinstance(_slot(_T11_GOOD_REF, k), _Raised)
for k in _T11_REF_SLOTS)
and sorted(_T11_ROW_SLOTS + _T11_REF_SLOTS) == sorted(_T11_SLOTS)
and _slot(_T11_GOOD, "points") == 132
and _slot(_T11_GOOD, "hours") == 33)
check("...and it TELLS THE TWO DEFECTS APART from the correct row: the shipped "
"point<->hour conflation (132 h for 33 h) and a firm/carried "
"transposition both move a slot, so the parser is not merely finding "
"numbers somewhere in a sentence",
_slot(_T11_SHIPPED_DEFECT, "hours") == 132
and _slot(_T11_SHIPPED_DEFECT, "firm") == 96
and _slot(_T11_SHIPPED_DEFECT, "points")
== _slot(_T11_GOOD, "points"))
check("...and a missing slot is a FAILED derivation, not a silent pass - the "
"difference between 'the row does not say that' and 'the row says "
"nothing', which is why `derived()` exists in this file",
isinstance(_slot("nothing useful here", "points"), _Raised))
# -- 12b. the fixture SET, and the degeneracy it is built to avoid ------------
# ⚠️ ONE ARRANGEMENT CANNOT SEPARATE FIVE NUMBERS (habit 10). On the shipped
# healthy plan - an all-firm quarter-hourly day - `points` and `firm` are the
# SAME INTEGER and `carried` is 0, so a row that printed the point count in the
# firm slot would pass. On an hourly static profile `points` and `hours` are
# the same integer, which is precisely why the defect shipped: every fixture
# that existed before TARIFF-08 was hourly. So the fixtures are a SET, and the
# table below is COMPUTED and PRINTED - a pair of fields no row separates shows
# up as a named survivor and takes this check red, rather than being absent
# from a list nobody re-derived.
_T11_SHAPES = (
("all-firm quarter-hourly, the live healthy plan", 33, 0),
("mixed: 9 published hours, 24 carried - the weekday morning", 9, 24),
("§11.4 fallback: nothing published, 12 hourly carried", 0, 12),
("late morning: 6 published hours, 3 carried", 6, 3),
)
_T11_FIELDS = ("points", "hours", "firm", "carried", "horizon")
_T11_TABLE = [(lbl, dict(_t11_want(f, c), horizon=48))
for lbl, f, c in _T11_SHAPES]
print(" fixture set (points / hours / firm / carried / horizon):")
for _lbl, _w in _T11_TABLE:
print(f" {_w['points']:>4} {_w['hours']:>4} {_w['firm']:>4} "
f"{_w['carried']:>4} {_w['horizon']:>4} {_lbl}")
_T11_UNSEP = sorted(
f"{a}=={b}" for i, a in enumerate(_T11_FIELDS) for b in _T11_FIELDS[i + 1:]
if not any(w[a] != w[b] for _lbl, w in _T11_TABLE))
print(f" field pairs NO fixture separates: {_T11_UNSEP or 'none'}")
check(f"every pair of the five numbers the *Tariff* row prints is told apart "
f"by at least one fixture - unseparated pairs {_T11_UNSEP}. A pair that "
f"is equal in every fixture is one the checks below cannot tell "
f"transposed, which is a ZERO-BYTE edit (habit 14)",
_T11_UNSEP == [])
check("...and the set contains BOTH the degenerate hourly arrangement (points "
"== hours, where the defect was invisible for three cards) and the "
"quarter-hourly one where they differ 4x, so the row is pinned on the "
"shape that hid it as well as on the shape that exposes it",
any(w["points"] == w["hours"] for _l, w in _T11_TABLE)
and any(w["points"] == 4 * w["hours"] for _l, w in _T11_TABLE))
# -- 12c. THE *Tariff* ROW ITSELF ---------------------------------------------
_T11_ROWS = [(lbl, f, c,
derived(lambda f=f, c=c: _t11_render(
{"price_provider": "generic_dayahead"},
plan=_t11_plan(f, c))[0]["Tariff"]))
for lbl, f, c in _T11_SHAPES]
for _lbl, _f, _c, _row in _T11_ROWS:
print(f" Tariff row | {_lbl}")
print(f" {_row}")
_T11_BAD = [f"{lbl}: {k} slot reads {_slot(row, k)!r}, fixture says "
f"{_t11_want(f, c)[k]!r}"
for lbl, f, c, row in _T11_ROWS
for k in ("points", "hours", "firm", "carried", "spacings")
if _slot(row, k) != _t11_want(f, c)[k]]
check(f"AC: EVERY number the rendered *Tariff* row prints matches what this "
f"file computed from the fixture's own two parameters, in its OWN slot, "
f"across all four shapes - disagreements {_T11_BAD}. This is the check "
f"that did not exist: `words` said `132 h` about 33 hours for three "
f"cards and no suite could see it, because no suite read the sentence",
_T11_BAD == [])
check(f"...and the HORIZON reaches the row as hours, from the option rather "
f"than from the plan - "
f"{[_slot(r, 'horizon') for _l, _f, _c, r in _T11_ROWS]} against "
f"`price_horizon_h` = "
f"{price_option(dict(_T11_BASE), 'price_horizon_h')}. The soak measured "
f"48.0 at every sample and `words` never said 48",
all(_slot(r, "horizon")
== int(float(price_option(dict(_T11_BASE), "price_horizon_h")))
for _l, _f, _c, r in _T11_ROWS))
# ⚠️ ORIGIN AND PRODUCT ARE GRADED BY DISAGREEMENT, NOT BY A SUBSTRING. Both
# are enum-ish labels, so `"provider" in row` is satisfied by the word
# "provider" this row already contains twice for other reasons. Two plans over
# the IDENTICAL point list, differing in exactly one of the two fields, must
# render differently - which is false of any row that drops the field.
# ⚠️ AND THE FIXTURE VARIES `origin` **ALONE**. The first draft of this pair
# gave the fallback plan `provider="static_profile"` as well - which is what a
# real §11.4 plan carries - so the two rows differed for TWO reasons and the
# mutation that drops `origin` from the sentence entirely (tag <- read.provider,
# -190 B) was a SURVIVOR of the whole suite. That is MUTATION.md habit 4 in the
# check written to close this card: a difference-claim whose two sides differ in
# more than the thing it names cannot grade that thing. The provider string is
# held EQUAL below, as its own precondition check.
_T11_ID_PTS = (9, 24)
_T11_PLAN_PROV = _t11_plan(*_T11_ID_PTS, origin="provider")
_T11_PLAN_FALL = _t11_plan(*_T11_ID_PTS, origin="fallback")
_T11_AS_PROVIDER = derived(lambda: _t11_render(
{"price_provider": "generic_dayahead"},
plan=_T11_PLAN_PROV)[0]["Tariff"])
_T11_AS_FALLBACK = derived(lambda: _t11_render(
{"price_provider": "generic_dayahead"},
plan=_T11_PLAN_FALL)[0]["Tariff"])
check(f"the origin pair differs in `origin` AND IN NOTHING ELSE (habit 1): "
f"provider strings {_T11_PLAN_PROV.read.provider!r} == "
f"{_T11_PLAN_FALL.read.provider!r}, products equal, point lists equal, "
f"origins {_T11_PLAN_PROV.origin!r} != {_T11_PLAN_FALL.origin!r}. Give "
f"the fallback its own provider string and the row below differs for a "
f"second reason, which is how the -190 B mutation survived the first "
f"draft",
_T11_PLAN_PROV.read.provider == _T11_PLAN_FALL.read.provider
and _T11_PLAN_PROV.product == _T11_PLAN_FALL.product
and _T11_PLAN_PROV.origin != _T11_PLAN_FALL.origin
and [p.canonical() for p in _T11_PLAN_PROV.read.points]
== [p.canonical() for p in _T11_PLAN_FALL.read.points])
_T11_AS_REALTIME = derived(lambda: _t11_render(
{"price_provider": "generic_dayahead"},
plan=_t11_plan(*_T11_ID_PTS, product=PriceProduct.REALTIME))[0]["Tariff"])
print(f" origin='provider' -> {_T11_AS_PROVIDER[-88:]!r}")
print(f" origin='fallback' -> {_T11_AS_FALLBACK[-88:]!r}")
print(f" product=REALTIME -> {_T11_AS_REALTIME[-88:]!r}")
check("AC: the plan's ORIGIN reaches the row - two plans over the IDENTICAL "
"point list, one observed from the feed and one §11.4 standing in for a "
"dead one, render DIFFERENTLY. Every count in them is equal, so a row "
"that dropped `origin` could not tell an operator that the prices on "
"screen are the static profile rather than the market",
_T11_AS_PROVIDER != _T11_AS_FALLBACK
and all(_slot(_T11_AS_PROVIDER, k) == _slot(_T11_AS_FALLBACK, k)
for k in ("points", "hours", "firm", "carried")))
check("...and so does the PRODUCT, on the same terms - §9.2's product is what "
"the optimiser widens its thresholds on, and it was on no surface",
_T11_AS_REALTIME != _T11_AS_PROVIDER
and all(_slot(_T11_AS_REALTIME, k) == _slot(_T11_AS_PROVIDER, k)
for k in ("points", "hours", "firm", "carried")))
# ⚠️ THE TWO TARIFF SOURCES ARE NOT COMPUTED AT THE SAME MOMENT AND THE ROW
# MAY NOT IMPLY THEY WERE. `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, and the age is graded against a number this file SET rather than
# against one the row supplied.
_T11_FRESH = derived(lambda: _t11_render(
{"price_provider": "generic_dayahead"},
plan=_t11_plan(9, 24), plan_age_s=0.0)[0]["Tariff"])
_T11_OLD = derived(lambda: _t11_render(
{"price_provider": "generic_dayahead"},
plan=_t11_plan(9, 24), plan_age_s=4000.0)[0]["Tariff"])
print(f" plan 0 s old -> age slot {_slot(_T11_FRESH, 'age_s')!r}")
print(f" plan 4000 s old -> age slot {_slot(_T11_OLD, 'age_s')!r}")
check(f"AC/trap: the row does NOT present the 30 s tick's sentence and a "
f"4000-second-old plan as one measurement - the plan half is stamped "
f"with its own age, and the age MOVES with `tariff_at`: "
f"{_slot(_T11_FRESH, 'age_s')} s against {_slot(_T11_OLD, 'age_s')} s "
f"for the SAME plan object. A constant, or an age taken from the render "
f"clock, gives the same number twice",
isinstance(_slot(_T11_OLD, "age_s"), int)
and 4000 <= _slot(_T11_OLD, "age_s") <= 4010
and _slot(_T11_FRESH, "age_s") <= 5)
check(f"...and the two halves are still BOTH there, in order: the tick's "
f"static sentence for this provider-only site - "
f"{static_status(dict(_T11_BASE), _T11_NOW)[1]!r} - leads, and the plan "
f"follows it. Replacing the row wholesale would lose the cheap-window "
f"evidence TARIFF-03 put there",
_T11_FRESH.startswith(static_status(dict(_T11_BASE), _T11_NOW)[1]))
# ⚠️ AND THE STATE THE SOAK ACTUALLY MEASURED: a provider configured, and no
# plan. 802 samples of `'no static profile'`, which is the row a site with no
# provider prints too - so the surface could not tell "trading on a live feed"
# from "no tariff machinery at all".
_T11_NOPLAN = derived(lambda: _t11_render(
{"price_provider": "generic_dayahead"}, plan=None)[0]["Tariff"])
_T11_NOPROV = derived(lambda: _t11_render({}, plan=None)[0]["Tariff"])
print(f" provider set, no plan -> {_T11_NOPLAN!r}")
print(f" no provider at all -> {_T11_NOPROV!r}")
check(f"AC: a site with a `price_provider` and NO usable plan says so, and "
f"reads differently from a site that configured no provider at all. "
f"Both printed {_T11_NOPROV!r} before this card - all 802 soak samples, "
f"on an install whose feed was answering",
_T11_NOPLAN != _T11_NOPROV
and str(int(_main.TARIFF_MAX_AGE_S)) in _T11_NOPLAN)
# -- 12d. THE *Arbitrage* ROW: THREE FACTS THAT RENDERED AS TWO ---------------
# ⚠️ THE STIMULUS IS A DRIVEN CYCLE WITH A SENSOR ANSWERING `None`, not a
# hand-set attribute. The defect is a control-flow one - `cycle()` returns at
# the input hold BEFORE `self.arb = None` - and a fixture that assigned
# `ctl.arb = None` itself would have reproduced the symptom without ever
# touching the path that causes it.
_T11_OFF = derived(lambda: _t11_render({}, soc=None))
_T11_ON_HELD = derived(lambda: _t11_render({"arbitrage_enabled": True},
soc=None))
_T11_ON_OK = derived(lambda: _t11_render({"arbitrage_enabled": True},
soc=55.0))
_T11_ON_STALE = derived(lambda: _t11_render({"arbitrage_enabled": True},
soc=55.0, age_arb_by_s=13))
_T11_ON_DROPPED = derived(lambda: _t11_render({"arbitrage_enabled": True},
soc=55.0, then_soc_none=True))
print(f" trading off, inputs missing -> {_T11_OFF[0]['Arbitrage']!r}")
print(f" trading ON, inputs missing -> {_T11_ON_HELD[0]['Arbitrage']!r}")
print(f" trading ON, inputs fine -> {_T11_ON_OK[0]['Arbitrage']!r}")
print(f" trading ON, decision 13 s old-> {_T11_ON_STALE[0]['Arbitrage']!r}")
print(f" trading ON, SoC then dropped -> {_T11_ON_DROPPED[0]['Arbitrage']!r}")
check(f"the stimulus is the one the card names, and it is asserted rather "
f"than assumed (habit 1): `arbitrage.enabled(opts)` is "
f"{_arb.enabled(_T11_ON_HELD[1].o)} while `controller.arb` is "
f"{_T11_ON_HELD[1].arb!r}. If a later edit makes `cycle()` reach the "
f"arbitrage step under an input hold, this goes red rather than the "
f"rows below going quietly vacuous",
_arb.enabled(_T11_ON_HELD[1].o) is True
and _T11_ON_HELD[1].arb is None
and not _T11_ON_HELD[1].inputs_ok)
check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
f"and no decision was reached this cycle' RENDER DIFFERENTLY. They are "
f"different facts with different remedies and this row printed "
f"`off (arbitrage_enabled)` for both - naming an option that is `true` "
f"in the options blob",
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
f"({_T11_ON_HELD[1].reason!r}), so the operator is told which input is "
f"missing rather than being sent to look at a switch that is already on. "
f"The reason comes from main.py, not from this file",
_T11_ON_HELD[1].reason
and _T11_ON_HELD[1].reason in _T11_ON_HELD[0]["Arbitrage"])
check(f"...and a cycle that DID reach the step renders the strategy's own "
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
f"the one thing about it that was already right",
_T11_ON_OK[1].arb is not None
and _T11_ON_OK[1].arb.explain() in _T11_ON_OK[0]["Arbitrage"]
and _T11_ON_OK[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
check(f"...and all THREE states are pairwise distinct, so no pair of them can "
f"be collapsed by a later edit without a named red here",
len({_T11_OFF[0]["Arbitrage"], _T11_ON_HELD[0]["Arbitrage"],
_T11_ON_OK[0]["Arbitrage"]}) == 3)
_T11_AGE = _slot(_T11_ON_STALE[0]["Arbitrage"], "arb_age_s")
check(f"AC: a decision the CURRENT cycle did not take is rendered as one - "
f"age slot {_T11_AGE} s against the 13 s this file moved `arb_at` back "
f"by. The card's second half: during an input hold the row went on "
f"showing the last decision, up to `stale_input_s` (15 s) stale, with "
f"nothing on the page saying so",
isinstance(_T11_AGE, int) and 13 <= _T11_AGE <= 18
and _T11_ON_STALE[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
# ⚠️ AND THE SAME CLAIM ON THE STIMULUS THAT ACTUALLY PRODUCES IT. The row
# above moves `arb_at` by hand so the AGE can be graded against an exact
# number; this one drives the control-flow path itself - one healthy cycle,
# then a sensor that stops answering - and asserts that the shipped controller
# reaches the state the card describes, rather than that this file can simulate
# it. Without this row a mutation that never sets `cycle_at` would still pass,
# because the hand-aged fixture would go on rendering `stale`.
# ⚠️ `derived()`, NOT A BARE COMPARISON, AND THE BATTERY IS WHY. The mutation
# that never stamps `cycle_at` leaves it at `__init__`'s None, and
# `datetime < None` is a TypeError - in the f-string OPERAND as much as in the
# condition. Driven, the first draft of this check produced a TRACEBACK and
# NO NAMED RED, grading none of the checks after it: FCST-06's finding, in the
# check written for this card. "It answered nothing" and "it broke on the way
# to answering" must not grade the same.
_T11_DROP_STALE = derived(
lambda: _T11_ON_DROPPED[1].arb_at < _T11_ON_DROPPED[1].cycle_at)
check(f"DRIVEN, no clock touched: a decision stands "
f"({_T11_ON_DROPPED[1].arb is not None}) while the LATEST cycle returned "
f"at the input hold without reaching the arbitrage step "
f"(arb_at < cycle_at = {_T11_DROP_STALE!r}), and the row says so instead "
f"of presenting the standing decision as this cycle's",
_T11_ON_DROPPED[1].arb is not None
and not _T11_ON_DROPPED[1].inputs_ok
and _T11_DROP_STALE is True
and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
check(f"...and it names the reason the cycle stopped "
f"({_T11_ON_DROPPED[1].reason!r}) beside the age of the decision it is "
f"still showing, so 'the strategy decided this' and 'the strategy last "
f"decided this' are not one sentence",
_T11_ON_DROPPED[1].reason in _T11_ON_DROPPED[0]["Arbitrage"]
and isinstance(_slot(_T11_ON_DROPPED[0]["Arbitrage"], "arb_age_s"), int))
check("...and `main.py`'s 'if and only if' is graded as BEHAVIOUR rather than "
"as prose: a comment is not a check, and three findings in this chain "
"were graded by a sentence their author also wrote. What makes the "
"comment true is that the row reads the OPTION - so with the option on "
"and no decision taken, the row does not equal the option-off row, which "
"is the row above",
_arb.enabled(_T11_ON_HELD[1].o)
and _T11_ON_HELD[1].arb is None
and _T11_ON_HELD[0]["Arbitrage"] != _T11_OFF[0]["Arbitrage"])
# -- 12e. the two REFUSAL sentences, which reach this same row ----------------
# ⚠️ THESE ARE OPERATOR-FACING THROUGH `self.arb.explain()`, WHICH IS WHY THEY
# ARE HERE AND NOT ONLY IN test_static_tariff.py. `§8.3: only 1 of 96 hours
# carry a published price` is 96 POINTS, which is 24 hours - the same
# conflation TARIFF-14 removed from `at()`, left standing in the prose that
# describes it.
_T11_A9 = {"peak_guard_active": False, "min_spread_eur_kwh": 0.0,
"uncertainty": frozenset()}
_T11_ONEFIRM = TariffPlan(
PriceRead.series(
[PricePoint(_T11_NOW + timedelta(minutes=15 * i),
round(0.10 + 0.001 * i, 5), i != 0) for i in range(96)],
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
_T11_FLAT = TariffPlan(
PriceRead.series(
[PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False)
for i in range(96)],
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(**_T11_A9).reason)
_rflat = derived(lambda: _T11_FLAT.arbitrage(**_T11_A9).reason)
print(f" §8.3 refusal : {_r83[:118]}")
print(f" flat refusal : {_rflat[:118]}")
# 96 quarter-hourly points span 24 hours; ONE firm point spans one of them.
# Both figures are this file's arithmetic over the fixture it built.
# The fixture is 96 points at 15-minute spacing, built four lines above, so it
# spans 96 // 4 = 24 hours and its single firm point occupies ONE of them.
# Both expectations are this file's arithmetic over the shape it asked for.
_T11_R83 = {k: _slot(_r83, k) for k in
("r83_firm_pts", "r83_all_pts", "r83_firm_h", "r83_all_h")}
print(f" §8.3 slots : {_T11_R83}")
print(f" flat slots : "
f"{ {k: _slot(_rflat, k) for k in ('flat_pts', 'flat_h')} }")
check(f"AC: §8.3's refusal reports POINTS and HOURS as two quantities, each in "
f"its OWN slot - {_T11_R83} over a fixture of 96 quarter-hourly points "
f"spanning 24 hours, of which one is firm. It read `only 1 of 96 hours` "
f"about a 24-hour day, and this sentence reaches the status page",
_T11_R83["r83_firm_pts"] == 1 and _T11_R83["r83_all_pts"] == 96
and _T11_R83["r83_firm_h"] == 1 and _T11_R83["r83_all_h"] == 96 // 4)
check(f"...and the two clauses are not the same pair twice: the POINT clause "
f"and the HOUR clause disagree, which is exactly what the shipped "
f"sentence could not express",
_T11_R83["r83_all_pts"] != _T11_R83["r83_all_h"])
check(f"...and the flat-tariff refusal does the same - it counted the same 96 "
f"points and called them `firm hours`: slots "
f"{_slot(_rflat, 'flat_pts')} points, {_slot(_rflat, 'flat_h')} hours",
_slot(_rflat, "flat_pts") == 96
and _slot(_rflat, "flat_h") == 96 // 4)
check("...and neither sentence lost what TEST-16 put in it: §8.3's refusal is "
"still attributable to the FIRM-PAIR rung by its own words, and does not "
"degrade into the flat rung's sentence about a series whose prices "
"differ by 0.095 EUR/kWh",
"§8.3" in _r83 and "carry a published price" in _r83
and "the tariff is flat" not in _r83
and "the tariff is flat" in _rflat)
# -- 12f. END TO END, against the COMMITTED capture --------------------------
# ⚠️ EVERY FIXTURE ABOVE IS SYNTHETIC, AND habit 19 IS WHY THIS ROW EXISTS.
# The shapes above are this file's own arithmetic; they cannot tell whether the
# SHIPPED refresh puts a real plan on the row. This drives `run_tariff_refresh`
# against `scenarios/energy-charts-be-price-*.json` - bytes captured from
# api.energy-charts.info - and grades the row against the CAPTURE'S OWN point
# count, which neither main.py nor static_tariff.py shaped.
_T11_N_CAP = len(_CAPTURE["price"])
def _t11_e2e():
tmp = tempfile.mkdtemp(prefix="tariff11-e2e-")
_tmpdirs_t10.append(tmp)
opts = dict(_T11_BASE, price_provider="generic_dayahead")
async def _get(url, timeout_s):
return _restamped_body(datetime.now(timezone.utc))
real = _price.https_get
_price.https_get = _get
try:
async def _go():
stop = asyncio.Event()
ctl = _main.Controller(opts, _T11Hass(), _T10Store(), _T10Pub(),
stop=stop,
peak_db_path=os.path.join(tmp, "e.db"),
price_history_path=os.path.join(tmp, "p.json"))
task = asyncio.create_task(ctl.run_tariff_refresh())
for _ in range(250): # ~5 s ceiling, habit 5
if ctl.tariff_at is not None:
break
await asyncio.sleep(0.02)
ctl.stopping = True
stop.set()
await asyncio.wait_for(task, timeout=5)
await ctl.read_inputs()
await ctl.cycle()
return dict(ctl.status_payload()["rows"])
return asyncio.run(_go())
finally:
_price.https_get = real
_T11_E2E = derived(_t11_e2e)
_T11_E2E_ROW = "" if isinstance(_T11_E2E, _Raised) else _T11_E2E["Tariff"]
print(f" end to end, {_T11_N_CAP} captured prices at 900 s:")
print(f" {_T11_E2E_ROW}")
check(f"AC, DRIVEN THROUGH THE SHIPPED REFRESH: the *Tariff* row reports the "
f"{_T11_N_CAP} points the COMMITTED capture publishes, over "
f"{_T11_N_CAP // 4} hours, on a quarter-hourly feed. Both numbers come "
f"from the captured document's own length - not from the plan the code "
f"under test built out of it (habit 19)",
_slot(_T11_E2E_ROW, "points") == _T11_N_CAP
and _slot(_T11_E2E_ROW, "hours") == _T11_N_CAP // 4
and _slot(_T11_E2E_ROW, "points") != _slot(_T11_E2E_ROW, "hours"))
check(f"...and the row an operator reads names the SPACING that makes those "
f"two numbers differ - {_slot(_T11_E2E_ROW, 'spacings')!r} s. The plan "
f"carried mixed 900/3600 s for 70 % of a 40-hour soak and no surface "
f"said so",
_slot(_T11_E2E_ROW, "spacings") == [900])
check("...and this is the same wording FCST-04's *Price history* row already "
"used, so the two rows describing the same feed can be compared rather "
"than being two conventions on one page",
"points over" in _T11_E2E_ROW
and "points over" in ("" if isinstance(_T11_E2E, _Raised)
else _T11_E2E["Price history"]))
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))