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:
@@ -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`.**
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user