Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a11597e0b3 | ||
|
|
610725bed9 | ||
|
|
b4a3801f68 | ||
|
|
a991438e40 | ||
|
|
068b83f57c | ||
|
|
ac9854886b | ||
|
|
aade2c79fd | ||
|
|
f620f7cf80 | ||
|
|
8d09b30f16 | ||
|
|
028c3e53fb | ||
|
|
a6e64fb66e | ||
|
|
435e1b6f7c | ||
|
|
f5ea39784d | ||
|
|
7f2c4e20e1 | ||
|
|
559f0eb022 | ||
|
|
c81289527f | ||
|
|
e6c45a61e7 | ||
|
|
88c59a9918 | ||
|
|
17b7b447c4 | ||
|
|
4257e38e5a | ||
|
|
7a5281e97f | ||
|
|
6eba18968c | ||
|
|
958f5cd218 |
@@ -15,6 +15,746 @@ if it has, it needs 0.10.0. Whoever deploys decides.
|
||||
| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
|
||||
| _this branch_ | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
|
||||
| _this branch_ | FCST-06 - "cannot persist" stops meaning "has no /data" and starts meaning what it says |
|
||||
| _this branch_ | TARIFF-08 - the day-ahead feed is read in the shape it actually publishes, and the fixtures stop agreeing with the parser |
|
||||
| _this branch_ | TARIFF-09 - the day-ahead feed is asked for the horizon it was given, so tomorrow arrives firm instead of carried |
|
||||
| _this branch_ | TARIFF-10 - configuring a price provider is what fetches prices; trading stays its own switch |
|
||||
| _this branch_ | TARIFF-12 - a supplier outage stops deleting the plan on its first failed fetch, and TARIFF_MAX_AGE_S starts governing |
|
||||
| _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-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving |
|
||||
| _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
|
||||
`eneco_eddy`. `price_provider` set to any of those three is now **refused at
|
||||
startup** — `SystemExit(78)` (`EX_CONFIG`), the add-on does not start — where
|
||||
it was accepted before. That is as backward-incompatible as it gets, and it is
|
||||
**the point of the change** rather than a side effect of it: see the next
|
||||
paragraph for why starting was the worse outcome. Same shape as 0.8.0's
|
||||
`slew_w` rule, and the same instruction follows — **read the live options and
|
||||
check `price_provider` against the withdrawn list BEFORE `ha addons update`.**
|
||||
That check is NOT performed by this branch and cannot be.
|
||||
|
||||
⚠️ **THE REFUSAL IS THE MIGRATION, BECAUSE THERE WAS NOTHING TO MIGRATE.** All
|
||||
three pointed at endpoints that **do not exist and never did** — verified by
|
||||
hand on 2026-09-13: a 404 from a gateway with no route, a TLS hostname the
|
||||
certificate does not cover, and a 404 with an empty body. Every fetch any of
|
||||
them ever made failed, and §11.4 turns a failed fetch into a **silent** fall
|
||||
back to the static profile. So a site configured on one of these has been
|
||||
running on `static_profile` — **or on nothing at all, where none was set** —
|
||||
since the day the id was entered, with nothing on its status page ever
|
||||
differing from a working provider that never found a cheap hour. The refusal
|
||||
names the id, the endpoint, what that endpoint actually answers, which tariff
|
||||
has really been in force, and what to set instead. **Starting quietly is what
|
||||
hid this; it is not an option that was on the table.**
|
||||
|
||||
⚠️ **THERE IS NO ALIAS AND THERE WILL NOT BE ONE.** These were retail tariffs
|
||||
of the form `B × spot + A` with **per-contract** coefficients that live behind
|
||||
the customer's supplier login; CREG's tariff-card pages 404 and VREG was
|
||||
unreachable, so there is no public index to read them from. An alias to
|
||||
`generic_dayahead` — which reads *wholesale* — would have to invent the numbers
|
||||
a battery is priced against. A formula provider is the planned answer and is a
|
||||
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-13 - the coverage figure beside the peak, and which quarter gets it
|
||||
|
||||
**The capacity-tariff status row's `N quarter(s) banked` is read from the
|
||||
database instead of from process memory.** It was an `int` on `PeakTracker`,
|
||||
starting at 0 and counted up inside `sync()`, printed next to a peak read from
|
||||
SQLite. A 40-hour soak caught what that means at a restart, on both instances:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
```
|
||||
|
||||
The peak is durable and was never wrong. The **count** is what says how much of
|
||||
the billing month that peak covers, and 365 W over 120 quarters and 365 W over
|
||||
0 quarters are different claims about the same number — so mid-month, after
|
||||
every restart, the page made the weaker claim while showing the stronger one's
|
||||
value. TARIFF-01's rule that an unreadable store must not read as a zero peak,
|
||||
one column over. Two more defects in the same variable, neither in the ticket:
|
||||
it was **never scoped to a month** (on 1 October the row still showed
|
||||
September's tally beside October's empty month), and it **missed the block
|
||||
`restore()` banks** from the checkpoint at startup.
|
||||
|
||||
⚠️ **AN UNREADABLE STORE NOW SAYS SO IN THAT CLAUSE.** Where the database will
|
||||
not answer a count, the row reads `an unreadable number of quarter(s) banked`.
|
||||
`0` was never available; `None` was rejected too, because `None quarter(s)
|
||||
banked` reads as the same zero. `0 quarter(s) banked` stays true and reachable —
|
||||
it is what a month nothing has been written to says.
|
||||
|
||||
⚠️ **AND A TIED PEAK'S ATTRIBUTED QUARTER IS NOW DECIDED: the EARLIEST wins.**
|
||||
The wattage is what the utility bills, so this changes no bill and no guard —
|
||||
`peak_w` is still only ever raised and SAFETY-07 reads the same watts. What it
|
||||
changes is the answer to "when did my monthly peak happen", which an operator
|
||||
goes and investigates; it now stops moving. The two readers of that record
|
||||
**did** disagree: the `monthly_peak` upsert's strict `>` kept whichever tied
|
||||
block was *written* first, while `recompute_month()` has always kept the
|
||||
earliest — the same by accident only while blocks arrive in time order, and
|
||||
measured to diverge (09:45 against 09:00) when they do not.
|
||||
|
||||
⚠️ **THE SOAK'S DRIFTING QUARTER WAS NOT A TIE, AND IS LEFT ALONE.** Driven,
|
||||
exactly equal quarters never re-attributed at all. What moved the timestamp was
|
||||
a genuine new maximum **below the row's whole-watt resolution** — 365.0000001 W
|
||||
is a new peak to the database and the same `365 W` to the reader. That is the
|
||||
instrument being right, and printing more decimals to explain it would put
|
||||
noise in the one row an operator reads when the guard engages.
|
||||
|
||||
Reasoning in `DECISIONS/TARIFF-13-the-coverage-of-a-peak.md`; parts 11 and 12
|
||||
of `test_tariff.py` drive a real restart — a second `Controller` against the
|
||||
same file — and pin both decisions.
|
||||
### 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`.**
|
||||
A Belgian dynamic contract prices energy as `B × index + A`; the two
|
||||
coefficients live on the customer's own tariff card and **nowhere else**.
|
||||
There is no machine-readable source for them anywhere — CREG's consumer
|
||||
tariff-card pages 404, CREG Scan is a JavaScript wizard, the annual report
|
||||
carries the supplier list without per-product coefficients, VREG was
|
||||
unreachable, and every coefficient found in research came off a supplier's own
|
||||
PDF, one image-only. So there is nothing to scrape and a per-supplier plugin
|
||||
would have no source for its numbers — which is precisely why TARIFF-07
|
||||
withdrew three of them. **The customer is the only party who can see their own
|
||||
coefficients.** They type them, once:
|
||||
|
||||
```json
|
||||
{"index": "epex_be", "resolution": "hourly",
|
||||
"factor": 0.102, "offset": 1.00,
|
||||
"units": "c_eur_per_kwh", "vat_pct": 6,
|
||||
"card": {"month": "2026-09", "index_eur_per_mwh": 133.9905,
|
||||
"expect_incl_vat": 15.55}}
|
||||
```
|
||||
|
||||
`price = (factor × index_eur_per_mwh + offset) × (1 + vat_pct/100)`, in
|
||||
`units`, **converted to EUR/kWh by the add-on**. The operator types what the
|
||||
card prints, in the card's own units; every unit conversion and the VAT
|
||||
multiplication are the add-on's job, because those are the two steps that
|
||||
silently cost a factor of 10, 100 or 1.06.
|
||||
|
||||
**The index is `generic_dayahead`'s, not a second one** — same EPEX BE feed,
|
||||
same Brussels-calendar range request, same columnar parser, same declared
|
||||
egress hosts. The URL decision is now one function both plugins call, so a
|
||||
short-horizon or override bug cannot exist in one and not the other.
|
||||
`resolution: quarter_hourly` emits the feed's 96 points as they are;
|
||||
`hourly` averages each hour's members. Neither is `_Series.at()`'s hourly
|
||||
assumption returning: it decides how many points are **emitted**, and the
|
||||
window each one covers is still derived from its neighbours (TARIFF-14).
|
||||
|
||||
⚠️ **It declares `{ENERGY, MARKUP}`, not `ALL_IN`, and that is the whole safety
|
||||
argument.** A Belgian card's `Tariefformule` line is the **energy component
|
||||
only** — Eneco tabulates network tariffs in one table and levies in a second,
|
||||
ENGIE states that the formula excludes network, distribution, transport, grid
|
||||
levies and excise. Declaring `ALL_IN` would lie on behalf of every operator who
|
||||
typed exactly what their card says, and would drop `price_taxes_eur_kwh` from
|
||||
every hour. Measured: 0.2125 → 0.1555 EUR/kWh, silently.
|
||||
|
||||
⚠️ **THE CARD IS THE GRADER, AND IT IS WHY THIS IS SAFE TO SHIP.** The
|
||||
framework already mechanises that a *plugin* cannot double-count
|
||||
(`PluginOptions`, `FRAMEWORK_KEYS`, `code_leaks()`). This provider introduces a
|
||||
risk none of those can see, because it is **config, not code**: the *operator*
|
||||
double-counting, by folding network charges into `offset` while
|
||||
`price_taxes_eur_kwh` carries them too. So:
|
||||
|
||||
> At startup `validate.py` computes
|
||||
> `(factor × card.index_eur_per_mwh + offset) × (1 + vat_pct/100)` and
|
||||
> **refuses to start** unless it equals `card.expect_incl_vat` to within
|
||||
> `0.01`, naming the discrepancy, the ratio and the likely causes.
|
||||
|
||||
An inflated `offset` **cannot pass**: the printed estimate is the energy
|
||||
component alone, so ~12 c€/kWh of network charge moves the computed 15.55 to
|
||||
28.27 and the add-on stops. The same check catches VAT omitted, VAT applied
|
||||
twice, a transposed digit, and a unit mismatch between the coefficients and the
|
||||
printed price.
|
||||
|
||||
⚠️ **And the limit, in the same sentence as the claim: it grades the
|
||||
coefficients against the card, not the card against reality.** A
|
||||
consistent-but-wrong pair — both numbers off the wrong product — reproduces its
|
||||
own printed price perfectly and passes. Nothing can catch that. A **second**
|
||||
refusal covers the one unit error the first cannot see, the one applied to both
|
||||
sides at once: the card's own point must land inside 0.02–1.00 EUR/kWh. Its
|
||||
floor is applied to the **energy** figure rather than the all-in one, because
|
||||
the shipped 0.06 of taxes is three times that floor and would carry a
|
||||
hundredfold error over it on its own — measured, and driven red by a mutation.
|
||||
|
||||
**The market's own arithmetic, reproduced to the digit the supplier prints.**
|
||||
Both cards that were readable print the formula, the index they assumed *and*
|
||||
the resulting price:
|
||||
|
||||
| | computed by the shipped code | the card prints |
|
||||
|---|---|---|
|
||||
| Eneco `(0.102 × 133.9905 + 1) × 1.06` | **15.5471** | **15,55** |
|
||||
| ENGIE `(0.1 × 61.96 + 0.9) × 1.06` | **7.5218** | **7,522** |
|
||||
|
||||
⚠️ **No expression evaluation of any kind, and a check that fails if one ever
|
||||
appears.** `price_formula` takes numbers; there is no field that accepts an
|
||||
expression and an **unrecognised field is refused rather than ignored**, so
|
||||
typing `"expr"` is told it does not exist instead of quietly pricing the site
|
||||
on the fields beside it. Two reasons, and the second decides it:
|
||||
|
||||
* the string is settable over HTTP — the add-on's own ingress page posts it to
|
||||
`/addons/self/options` — so `eval` on it is RCE in a root container holding
|
||||
`SUPERVISOR_TOKEN`, with `homeassistant_api` and `hassio_api`, driving an
|
||||
inverter over RS485;
|
||||
* **an expression is unverifiable.** Two coefficients can be graded against the
|
||||
card's printed price; an arbitrary expression cannot be graded against
|
||||
anything, so it would make the invisible-wrong-coefficient problem *strictly
|
||||
worse* — the exact failure this provider exists to prevent.
|
||||
|
||||
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
|
||||
`B × index + A`. Future shapes become **named fields** (`clamp_lo`,
|
||||
`factor_negative`, `offset_bands`), each individually refusable and each
|
||||
gradeable against a card. Never a grammar. `test_price.py` 15f walks
|
||||
`app/custom_formula.py`'s AST for `eval`/`exec`/`compile`/`__import__`/
|
||||
`literal_eval` and exercises the scanner's **red** direction on every run.
|
||||
|
||||
**Refused at startup, not at 03:00.** `card` absent or failing by more than
|
||||
0.01; `units` absent (no default, on purpose — the same convention is printed
|
||||
two ways by two suppliers); `factor == 0`, which makes every spread 0 and
|
||||
arbitrage silently never fire (the message points at `static_profile`, which is
|
||||
the first-class way to say "one price"); `factor < 0`, which inverts the market
|
||||
so the battery charges at the evening peak; `vat_pct` outside 0–30 (**0 stays
|
||||
legitimate** — injection is VAT-exempt and some cards are already VAT-inclusive);
|
||||
a non-finite or non-numeric coefficient; an unknown field; an unknown `index` or
|
||||
`resolution`; and the plausibility band above. A `card.month` older than about
|
||||
six months **warns and does not refuse** — ENGIE freezes coefficients for a
|
||||
contract's duration, so an old card is often exactly right.
|
||||
|
||||
⚠️ **Negative output prices are NOT refused.** They are legal on EPEX and they
|
||||
are the best charging hours of the year (§17 keeps the regression case). The
|
||||
card check bounds the formula's *magnitude* without constraining the sign of
|
||||
any hour.
|
||||
|
||||
**A startup preview, so a factor of 1000 is visible in one line.** The first
|
||||
time the feed answers, the add-on logs the next 24 h as
|
||||
`index EUR/MWh -> all-in EUR/kWh` with the min, median, max and the day's
|
||||
spread. It is a new framework hook (`PriceProvider.startup_preview()`, default
|
||||
silent) rather than a line in `load_prices()`, because only the plugin knows
|
||||
what went *in* to its own arithmetic; it is emitted once per process and it
|
||||
cannot stop a fetch — a preview that raises is caught and logged.
|
||||
|
||||
**Surfaces.** `config.yaml` gains `price_formula` (options **and** schema) and
|
||||
`custom_formula` joins the `price_provider` enum through the **derived** set
|
||||
`PROVIDERS | RETIRED_PROVIDERS | {"off"}` rather than a hand-typed list; both
|
||||
translations describe the option and the new id. `DOCS.md` replaces the
|
||||
"planned" sentence with the shape, a worked example from a real card, the
|
||||
startup transcript, and the limit of the check stated in the same sentence as
|
||||
the claim.
|
||||
|
||||
**Not in scope, and each is a card of its own:** the injection leg's own
|
||||
coefficients (Eneco uses 0,102 on consumption against 0,100 on injection, and
|
||||
injection is VAT-exempt where consumption is at 6 %); the hour-varying network
|
||||
charge that bites Wallonia; raising the `price_taxes_eur_kwh` default from 0.06
|
||||
to ~0.12.
|
||||
|
||||
### TARIFF-07 - three plugins that never fetched a price, withdrawn
|
||||
|
||||
**`engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` are gone from
|
||||
`PROVIDERS` and from every surface that listed them.** They did not stop
|
||||
working. **They never worked.** Verified by hand on 2026-09-13, before anything
|
||||
was deleted:
|
||||
|
||||
| id | endpoint | observed |
|
||||
|---|---|---|
|
||||
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **404**, Kong `no Route matched with those values` |
|
||||
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure** — cert is `CN=pge-apigee-dev.direct-energie.com`, host not in the SAN list |
|
||||
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **404**, empty body |
|
||||
|
||||
They are also **structurally impossible**, not merely unfinished. ENGIE's
|
||||
`(B × eSpot_15) − A` coefficients are per-contract and sit behind the
|
||||
klantenzone login, so no public unauthenticated endpoint could return a given
|
||||
customer's price; there is no regulator index to read coefficients from; and
|
||||
**"Eddy" is not an Eneco product at all** — Eneco's dynamic product is *Zon &
|
||||
Wind Dynamisch*, indexed to Belpex/EPEX **day-ahead**, not imbalance.
|
||||
|
||||
**The schema keeps all three ids on purpose, and that is the whole reason the
|
||||
refusal is reachable.** `price_provider` is a `list(off|…)` enum and the
|
||||
Supervisor validates the options file against it *before the add-on runs*. Drop
|
||||
a retired id from the enum and an existing config naming it becomes
|
||||
schema-invalid: the operator gets an opaque `expected one of ...`, the add-on
|
||||
never starts, and `validate.py`'s message is never seen — on exactly the sites
|
||||
that need it. `PROVIDERS` is two; the enum stays six. `test_price.py` asserts
|
||||
the schema offers exactly `PROVIDERS | RETIRED_PROVIDERS | {"off"}`, derived
|
||||
rather than typed, and `register()` refuses to re-register a retired id so the
|
||||
refusal cannot be taken away by an `@register` somebody adds back.
|
||||
|
||||
**`PriceProduct.REALTIME` and the widening path survive with no shipped
|
||||
declarer.** `eneco_eddy` was their sole declarer, so `_WIDE`,
|
||||
`Uncertainty.REALTIME_PRODUCT` and `WIDEN_FACTOR`'s 2.5 now have none. They are
|
||||
kept: it is the AC2 seam — declare `REALTIME`, get the wide regime, edit no
|
||||
framework code — and the first real imbalance product would otherwise have to
|
||||
rebuild it. ⚠️ **No shipped plugin declares `PriceProduct.REALTIME` until one
|
||||
does**, `test_price.py` section 5 pins that so the emptiness reads as intent
|
||||
rather than a dead branch, and section 10 keeps the path live from a class
|
||||
declared outside `price.py`. See
|
||||
`DECISIONS/TARIFF-07-withdrawing-three-plugins.md`.
|
||||
|
||||
⚠️ **FOUR FALSE CLAIMS ARE CORRECTED, AND THEY ARE WHY THIS CARD EXISTS.**
|
||||
|
||||
* `eneco_eddy` was called a "Belgian imbalance product" in `DOCS.md`,
|
||||
`app/price_forecast.py`, `app/price_history.py`, `app/static_tariff.py`,
|
||||
`DECISIONS/FCST-04-the-day-that-is-carried.md` and twice in this file. It is
|
||||
**false** — Eneco's dynamic product is day-ahead-indexed. The two historical
|
||||
entries here are **annotated in place** rather than rewritten; the record of
|
||||
what the project believed is part of the record.
|
||||
* The quarter-hourly examples those surfaces used `eneco_eddy` for are now
|
||||
**Belgian day-ahead itself**, which really does settle per quarter since the
|
||||
15-minute MTU of 2025-10-01 — `generic_dayahead` reads 96 points a day. The
|
||||
mechanism was always right; only the example was invented.
|
||||
* `export_price_eur_kwh`'s docstring claimed *"no shipped supplier publishes a
|
||||
dynamic injection curve"*. **Also false.** Eneco publishes
|
||||
`0,1 × BELPEX-H − 1,188` on the same card as its consumption formula,
|
||||
identical on the January and September 2026 cards, and ENGIE publishes
|
||||
`(B × eSpot_15) − A`. What is true: an injection curve is the **same
|
||||
two-coefficient shape** with **its own** coefficients and its own VAT
|
||||
treatment — injection is VAT-exempt, consumption is at 6% — so the scalar is
|
||||
a **simplification an operator enters by hand, not a reflection of the
|
||||
market**. `DOCS.md` says the same.
|
||||
* `_points_from_json()` is deleted with the plugins it served. It read a
|
||||
`{"data": [...]}` document **no feed anybody here has ever reached
|
||||
publishes** — invented alongside these three, and graded only against
|
||||
fixtures written from the same guess. TARIFF-08 found that loop on the
|
||||
response side for `generic_dayahead`; this is the rest of it.
|
||||
|
||||
**`DOCS.md` now answers the question a Belgian dynamic customer actually has.**
|
||||
The provider table is two rows. The new prose is explicit that
|
||||
`generic_dayahead` gives wholesale EPEX BE plus flat markup and taxes scalars,
|
||||
which **approximates** a retail dynamic tariff and **cannot express
|
||||
`B × spot + A`** — it has no multiplier, and it cannot represent a negative
|
||||
offset, which `price_markup_eur_kwh: float(0,1)` could not hold in any case. A
|
||||
formula provider is named as the planned answer and is a separate card.
|
||||
*(Annotated 2026-09-13: TARIFF-15 replaced that sentence. The table is three
|
||||
rows and the prose points at `custom_formula`.)*
|
||||
|
||||
⚠️ **One check had to be rebuilt rather than deleted.** `test_price.py` 4b
|
||||
proved `code_leaks()`'s docstring strip is load-bearing by finding a shipped
|
||||
docstring a raw scan false-positives on — and that docstring was
|
||||
`engie_dynamic`'s. With it gone the check would have **passed on a tree with
|
||||
the strip deleted**: a check that had quietly stopped being one. The stimulus
|
||||
is constructed in the test file now, and the shipped set asserted clean
|
||||
separately.
|
||||
|
||||
Nine mutations driven, all red; the table is in the decision record. Two carry
|
||||
a **+0 B** delta and two end in a traceback whose red count is therefore a
|
||||
lower bound — both flagged there, per `MUTATION.md`.
|
||||
|
||||
### TARIFF-10 - a configured provider is fetched, whether or not you trade
|
||||
|
||||
**`amain()` started the background tariff refresh only when
|
||||
`arbitrage_enabled` was on, and that option ships `false`.**
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a price
|
||||
provider configured **nothing was ever fetched**: no plan resolved,
|
||||
`/data/price_history.json` never created, the *Price history* status row
|
||||
reading `empty` for the life of the install, and TARIFF-09's short-horizon
|
||||
warning unable to fire because there was no fetch to warn about.
|
||||
|
||||
Measured over a 40 h soak of two instances side by side on the same feed:
|
||||
|
||||
| | shipped default + provider | same, arbitrage armed |
|
||||
|---|---|---|
|
||||
| feed requests | **0** | fetching |
|
||||
| `/data/price_history.json` | `present: false` on **all 802 samples** | written |
|
||||
| resolved plan | none | 132 points |
|
||||
|
||||
⚠️ **`DOCS.md` has described the fixed behaviour since the commit that wrote
|
||||
the gate.** OPT-04 (`bdd7f18`) added the refresh task, the
|
||||
`arbitrage.enabled()` guard on it, and the sentence *"turning a provider on
|
||||
still fetches, validates and publishes prices and changes no charging
|
||||
decision"* — all in one change. `config.yaml` has said the same since
|
||||
TARIFF-02 (`852fc7c`). The documentation and the code have therefore disagreed
|
||||
for as long as both existed, and nothing compared them. `config.yaml` and both translations carried the same
|
||||
claim, plus a second one that had gone stale in the other direction (*"nothing
|
||||
consumes the price series yet"*, untrue since OPT-04). All four now say what
|
||||
the add-on does.
|
||||
|
||||
**The gate is `tariff_refresh_wanted()`, and it is a STRICT WIDENING.**
|
||||
|
||||
```
|
||||
provider_configured(opts) or arbitrage.enabled(opts)
|
||||
```
|
||||
|
||||
Every configuration that started the refresh before still starts it. The second
|
||||
term is not redundant and is not there for symmetry: `price_provider: off` with
|
||||
a `static_profile` and `arbitrage_enabled: true` is a configuration
|
||||
`validate.py` explicitly **accepts**, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 EUR/kWh day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Gating the refresh on the provider alone would
|
||||
leave that site's `tariff_plan` at `None` forever and answer *"no tariff plan
|
||||
has been resolved yet"* on every cycle - silently disarming a strategy the UI
|
||||
says is running.
|
||||
|
||||
⚠️ **Nothing acquires a periodic HTTPS call from this.** With `price_provider`
|
||||
off, `resolve_tariff()` answers out of the static profile without opening a
|
||||
socket; and the true shipped default - no provider, no trading - starts **no
|
||||
task at all**. What decides whether a request leaves the machine is
|
||||
`price_provider`, and only that.
|
||||
|
||||
⚠️ **AND NOTHING TRADES THAT DID NOT TRADE BEFORE.** `cycle()`'s
|
||||
`if arbitrage.enabled(self.o):` is untouched. Graded directly rather than
|
||||
inferred from the gate having moved: `Controller.cycle()` is driven on a
|
||||
resolved provider plan whose cheapest firm hour is now, with the peak guard
|
||||
asserted clear, and `arbitrage_enabled` is the single option that moves between
|
||||
the rows.
|
||||
|
||||
| `arbitrage_enabled` | cheapest hour is now | commanded |
|
||||
|---|---|---|
|
||||
| `false` | yes | **+180 W** (`loop -> 180 W`) |
|
||||
| `false` | no | **+180 W** (`loop -> 180 W`) |
|
||||
| `true` | yes | -5000 W (`arbitrage -> -5000 W`) |
|
||||
| `true` | no | +5000 W (`arbitrage -> 5000 W`) |
|
||||
|
||||
**`TariffPlan.cheap_now()` is deliberately NOT wired, and that is now a
|
||||
decision rather than an inheritance.** A provider-backed plan exists on every
|
||||
configured site from this commit, so *Cheap window* - which feeds the
|
||||
maintenance cycle's grid top-up, i.e. the one place a price reaches something
|
||||
that **moves energy** without trading being armed - could now read it. It does
|
||||
not: `self.cheap` still comes from `static_status()`, and on a provider-only
|
||||
site (`static_profile` empty) that is `False` on every cycle, so no paid grid
|
||||
top-up is ever forced. Wiring it would take that from *never* to *sometimes*.
|
||||
`DECISIONS/TARIFF-10-fetching-is-not-trading.md` §4 carries the argument and
|
||||
the list of what adopting it would take.
|
||||
|
||||
**The check that would have caught this** is `test_price.py` section 13, and it
|
||||
grades **reachability from a configuration**, not the shape of an `if`. The
|
||||
guards in `amain()` are *evaluated* against real option dicts, whatever they are
|
||||
spelled as; the call graph under `app/` is walked to see whether the tasks those
|
||||
guards start can arrive at `resolve_tariff()`, wherever it has moved to; and
|
||||
then the fetch is **driven** with a counting transport, because a graph is an
|
||||
argument and a counted request is an observation. The shipped defect - the gate
|
||||
back at `arbitrage.enabled(opts)`, **-4 B** - is red on seven named checks.
|
||||
|
||||
### TARIFF-12 - a returned degradation is not a refresh
|
||||
|
||||
**`run_tariff_refresh()` promised in its own docstring that "a failure does not
|
||||
clear the plan", and enforced it with `except Exception` - a branch a supplier
|
||||
outage never reaches.** `load_prices()` states the reason in its own words: it
|
||||
"RETURNS, NEVER RAISES, for every failure a site can actually have", because a
|
||||
degradation arriving as a traceback into the planning tick would stop the
|
||||
controller regulating over an expired certificate. So a dead feed **returns** a
|
||||
§11.4 plan, the `try` succeeds, and the assignment below it overwrote the good
|
||||
plan unconditionally. With `static_profile` unset - the shipped default, and the
|
||||
normal state of a dynamic-tariff site - `_degrade()` returns
|
||||
`TariffPlan(no_data, None, "none")`: **a complete 48-hour plan replaced by
|
||||
nothing.**
|
||||
|
||||
Measured in a 40-hour soak, `static_profile` empty, a 90-minute outage driven:
|
||||
|
||||
| Brussels | refresh | plan |
|
||||
|---|---|---|
|
||||
| 17:26:12 | 55 | `dynamic_dayahead from generic_dayahead, 141 h, provisional` |
|
||||
| **17:56:12** | **56** | **`no tariff - generic_dayahead: ClientConnectorError: Cannot connect to host`** |
|
||||
| 19:26:12 | 59 | restored, one refresh after the feed returned |
|
||||
|
||||
`origin: none`, `n_points: 0` for all 30 outage samples. The plan was gone ~27
|
||||
minutes after the last good fetch, **at the first failure** - so
|
||||
`TARIFF_MAX_AGE_S` (5400 s) never governed anything, and there was no
|
||||
serve-stale window at all: a transient blip cost exactly what a long outage did.
|
||||
|
||||
**The question is now asked once, after the attempt, from the evidence that
|
||||
exists** - FCST-06's answer, one module over. The evidence is
|
||||
`TariffPlan.from_feed` (did this resolution observe the configured feed) and the
|
||||
shipped `fresh_tariff_plan()` (is an observation still current). A degradation
|
||||
replaces a standing observation only once that observation has **aged out**, so
|
||||
`TARIFF_MAX_AGE_S` is now the serve-stale window it reads like.
|
||||
|
||||
**A §11.4 static fallback does not replace a fresh provider plan either**,
|
||||
although it carries real prices. Every point `_degrade()` builds is
|
||||
`provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses and
|
||||
`cheap_now()` is False - everything a consumer may act on is already off - while
|
||||
a day-ahead price for a given hour does not change once published. The fallback
|
||||
is less actionable and no more current; it takes over when the observation
|
||||
retires, which is what §11.4 is for. The rule is **not** symmetrical: a standing
|
||||
plan that is itself a degradation is replaced by the next one, or an outage
|
||||
would freeze the operator's static profile at the window it was first generated
|
||||
for.
|
||||
|
||||
**`tariff_at` does not move on a kept plan.** It is the moment of the fetch that
|
||||
produced the plan standing, `fresh_tariff_plan()` judges the age from it, and an
|
||||
unmoved timestamp is the only thing that tells a kept plan from a freshly
|
||||
fetched one. Re-stamping it would be the refresh asserting the feed answered
|
||||
when it did not - the "freeze at its last value and look current forever" hazard
|
||||
the same docstring names.
|
||||
|
||||
**What did NOT change.** The persisted price history still does not back the
|
||||
plan during an outage: FCST-03 exists to stop stale prices being acted on, and
|
||||
"recent" is not "current". Nothing about the control loop, the arbiter, or the
|
||||
30 s tick - `cycle()` still reads two attributes and awaits no fetch.
|
||||
|
||||
`test_arbitrage.py` section 8b drives the whole chain with **nothing stubbed but
|
||||
the socket**: the transport raises the way aiohttp does, `load_prices()` returns
|
||||
`no_data`, `_degrade()` builds the §11.4 plan, and the shipped
|
||||
`run_tariff_refresh()` decides. A check that patched `resolve_tariff` to *raise*
|
||||
would have passed against the defect. Eleven mutations, eleven reds, no
|
||||
survivors - see `DECISIONS/TARIFF-12-a-degradation-is-not-a-refresh.md`.
|
||||
|
||||
**Merged after TARIFF-10, which widened *which installs run this task*, and the
|
||||
two configurations it newly keeps alive are graded rather than reasoned about.**
|
||||
A provider with trading disarmed is the case this guard was designed for and is
|
||||
covered by every driven check here, because nothing in `run_tariff_refresh()`
|
||||
reads a trading option at all - asserted on the AST symbols rather than on the
|
||||
source text, after two drafts of that check went red on a correct tree for
|
||||
saying "arbitrage" in a docstring and in a log line. `price_provider: off` + a
|
||||
`static_profile` resolves to the CONFIGURED product (`origin: "static"`), so
|
||||
`standing.from_feed` is false on every refresh and the guard never latches: the
|
||||
plan is regenerated for the current window exactly as it always was. Both are
|
||||
driven, and a mutation that makes the guard depend on `arbitrage.enabled()` is
|
||||
red on five named checks.
|
||||
|
||||
|
||||
### TARIFF-14 - the trade was decided against the wrong price
|
||||
|
||||
**On the only price feed that works, `_Series.at()` returned the wrong point,
|
||||
and the error grew with depth into the series.** It asked
|
||||
`s <= u < s + 1 h` of every point **in list order** and returned the **first**
|
||||
match. On the hourly shape every fixture in this repository carried before
|
||||
TARIFF-08 that is right by accident; Belgium moved to a **15-minute MTU on
|
||||
2025-10-01** and the live day-ahead feed returns **96 points at 900 s**, where
|
||||
it is wrong twice over. Measured against the shipped code at `5059e28`:
|
||||
|
||||
```
|
||||
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
|
||||
|
||||
ask + 0min -> 02:00 0.10
|
||||
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
|
||||
ask +45min -> 02:00 0.10 <-- WRONG
|
||||
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
|
||||
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
|
||||
```
|
||||
|
||||
Inside the first hour the `:00` point wins because its hour has not run out.
|
||||
**From the second hour on the answer is not even the top of the hour** - the
|
||||
first point still inside its own hour wins - so the error compounds.
|
||||
|
||||
**What it cost is not "a slightly wrong price".** `arbitrage._act()` tests
|
||||
`here.start == v.cheapest.start` / `== v.dearest.start` to decide whether this
|
||||
interval is the buy one or the sell one. That identity almost never held, so
|
||||
**the strategy idled through exactly the interval it exists to trade**,
|
||||
reporting *"this hour is neither the buy hour nor the sell hour"* - and read
|
||||
§8.3's `here.provisional` and §17's `price < 0` off the same wrong point.
|
||||
`static_tariff.py` emits hourly points, so **static sites were never affected**;
|
||||
this was reachable only down the dynamic path TARIFF-08/09 made work.
|
||||
|
||||
**The rule now, in one sentence: a point covers from its own start until the
|
||||
next point's start, and never more than one hour.**
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| 900 s | `at(02:15)` -> the 02:00 point | -> the 02:15 point |
|
||||
| 3600 s | `at(02:30)` -> the 02:00 point | **unchanged** |
|
||||
| **mixed** `[900, 3600]` | each point one hour wide | **each point takes its own gap** |
|
||||
|
||||
The mixed case is not an edge case: FCST-04's carry-forward emits **hourly
|
||||
carried points beside quarter-hourly firm ones**, and the soak measured spacing
|
||||
`[900, 3600]` in **70 % of samples**. A window derived from one global spacing
|
||||
would be wrong in the normal steady state.
|
||||
|
||||
**The one-hour cap is load-bearing.** FCST-04 leaves an hour *out* of the
|
||||
series when nothing published it and yesterday has no value either, and
|
||||
`at()` raising `Absence` for that hour is what sends the caller to the static
|
||||
profile (§11.4). Uncapped, "until the next point's start" would stretch the
|
||||
point before a three-hour hole across the whole hole and answer every instant
|
||||
in it with a stale price - filling holes, introduced by the fix for a different
|
||||
defect.
|
||||
|
||||
**The last point's window is a decision and it is written down.**
|
||||
`DECISIONS/TARIFF-14-the-window-a-point-covers.md` §2 argues it: the last point
|
||||
is given **the same span as the point before it** (one hour when it is the only
|
||||
point). A flat one-hour tail would answer **00:30 tomorrow with today's 23:45
|
||||
price**, which is reaching for a neighbour across a day boundary. The cost of
|
||||
the choice - a mixed series ending at the firm/carried seam loses its last 45
|
||||
minutes to `Absence` - is stated there rather than buried, and the direction is
|
||||
the safe one.
|
||||
|
||||
**`at()` now reads a point's neighbours, so it would depend on the series being
|
||||
sorted - and it establishes that itself** rather than trusting the three
|
||||
producers that happen to guarantee it today. The lookup index is sorted by UTC
|
||||
start once, at construction; a series handed its 96 points in reverse order
|
||||
answers identically, which is a check. `covers()` shared the defect exactly
|
||||
(it has no rule of its own - it asks `at()`) and is fixed by the same change;
|
||||
`window()` did not (it grades a point's **start** against a range and asks
|
||||
nothing about duration), and its docstring now says so.
|
||||
|
||||
**Nothing about §9.3 or a point's `start` changed.** `PricePoint` still has
|
||||
three fields; the span is derived from the neighbours, not stored.
|
||||
|
||||
Graded by `test_price.py` section 14 - 900 s, 3600 s, **mixed**, holes, the
|
||||
tail, caller order, `covers()`/`window()` - and end to end by
|
||||
`test_arbitrage.py` section 10, which drives `decide()` over a 96-point
|
||||
quarter-hourly day whose cheapest and dearest quarters are deliberately **not**
|
||||
on a whole hour. Eight mutations, all red, including a **zero-byte** one on the
|
||||
tail rule; the table with byte deltas and the checks that were NOT reached is
|
||||
in the decision note §6.
|
||||
|
||||
|
||||
### TARIFF-09 - the feed is asked for the range the caller wanted
|
||||
|
||||
**`generic_dayahead` took `since` and `until` and requested the bare feed URL,
|
||||
which answers one Brussels day.** Everything past today was then filled from
|
||||
yesterday's profile by FCST-04, flagged provisional, and §8.3 drops provisional
|
||||
hours from the trade set - so **the optimiser never saw tomorrow as firm, at any
|
||||
hour of any day.** Measured on `4257e38` at 2026-09-10 09:00 CEST over the
|
||||
shipped 48 h horizon, live feed, yesterday seeded:
|
||||
|
||||
| | 48 h horizon, one-day feed |
|
||||
|---|---|
|
||||
| firm | **15 whole hours** (60 quarter-hour points) |
|
||||
| carried, provisional | **33 hours** - 24 on D+1, 9 on D+2 |
|
||||
| permitted cycle | buy 11:45, sell 19:45 - **both on 2026-09-10** |
|
||||
| firm local days | `[2026-09-10]` |
|
||||
|
||||
The ceiling was structural, not incidental: with a one-day window, at hour *H*
|
||||
of the local day a 48 h horizon is at most **24−H hours firm and 24+H carried**.
|
||||
At 23:00 that is **1 firm hour and 47 carried**, and the remaining firm spread
|
||||
(0.0464 EUR/kWh) no longer clears the default 0.10 round-trip threshold at all -
|
||||
so arbitrage was refused outright. This is **not** what FCST-04's fill is for:
|
||||
that covers the window until the ~13:00-14:00 CET auction, after which a healthy
|
||||
feed publishes tomorrow in full.
|
||||
|
||||
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
|
||||
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns 192 quarter-hours across two
|
||||
local days, committed unmodified beside TARIFF-08's captures. The plugin simply
|
||||
never asked.
|
||||
|
||||
**The range is now derived from `since`/`until` on the BRUSSELS calendar**, as
|
||||
two inclusive dates. Not the UTC calendar: 22:30 UTC in summer is already
|
||||
tomorrow in Belgium, so a UTC-derived start asks for the day the controller has
|
||||
just left and drops the day it is standing in, for the last one or two hours of
|
||||
every day. That is `price_history._bdate()`'s lesson one layer up, with a worse
|
||||
consequence - `_bdate()` misfiles an hour, this never fetches it. `until` is
|
||||
exclusive, so a horizon ending exactly at local midnight asks for one day fewer
|
||||
than the naive reading gives, and an inverted window collapses to a single day
|
||||
rather than a backwards range.
|
||||
|
||||
Same code path, same window, a feed serving both days:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| firm hours of 48 | 15 | **39** |
|
||||
| carried, provisional | 33 | 9 |
|
||||
| firm local days | 1 | **2** |
|
||||
| permitted cycle | one calendar day | **buys on one day, sells on the next** |
|
||||
|
||||
The 9 remaining carried hours are the tail of the horizon that runs into a third
|
||||
Brussels day, which no day-ahead market publishes. FCST-04 still fills them and
|
||||
still flags them; what changed is what is firm, never what may be acted on.
|
||||
|
||||
**Before publication nothing regresses, and that is graded rather than
|
||||
assumed.** Asking for tomorrow before the auction returns fewer days than
|
||||
requested - measured, at 09:46 CEST the two-day request came back byte-identical
|
||||
to the one-day body - and that stays a `series` of what exists, with FCST-04
|
||||
filling the remainder. A `no_data` there would switch a working site's arbitrage
|
||||
off every morning.
|
||||
|
||||
⚠️ **`price_feed_url` is the EXACT URL and the plugin appends nothing to it, so
|
||||
an overriding site keeps the short horizon.** That is a decision, not an
|
||||
oversight: `start`/`end` are this feed's spelling, the provider declares a second
|
||||
host whose parameters are different, and rewriting a URL an operator typed is
|
||||
how a request nobody intended gets made. **It is not silent** - the plugin logs a
|
||||
`WARNING` at fetch time naming the consequence and the `start..end` it would
|
||||
otherwise have asked for. See
|
||||
`DECISIONS/TARIFF-09-the-range-and-the-override.md`.
|
||||
|
||||
The plugin contract - `fetch(self, opts, since, until, transport)` hands every
|
||||
plugin the range - is still **not** enforced, because three shipped plugins point
|
||||
at endpoints nobody has verified and a rule obliging them to pass a range would
|
||||
be satisfied by an invented parameter name. Which plugins consume the range is
|
||||
pinned as a literal in `test_price.py` instead, so the next supplier has to
|
||||
decide rather than inherit the silence.
|
||||
|
||||
### FCST-06 - the second way to be a store that cannot persist
|
||||
|
||||
@@ -72,6 +812,91 @@ EISDIR) and a volume that moves out from under an open store (`mkstemp`,
|
||||
ENOENT) - because the rule is enforced on the outcome and one cause would grade
|
||||
a coincidence. See `DECISIONS/FCST-06-cannot-persist-is-one-question.md`.
|
||||
|
||||
### TARIFF-08 - no network price provider had ever parsed a live response
|
||||
|
||||
**`generic_dayahead` could not read one byte of `api.energy-charts.info`, and
|
||||
had never been able to.** `_points_from_json()` asks for `doc["data"]`, a list
|
||||
of `{start, price_eur_mwh}` objects. That document does not exist. Fetched live
|
||||
on 2026-09-10, the feed returns **columnar** parallel arrays and no `data` key
|
||||
at all:
|
||||
|
||||
```
|
||||
top-level keys: ['deprecated', 'license_info', 'price', 'unit', 'unix_seconds']
|
||||
unix_seconds: 96 UTC epoch seconds, first 1788991200, spacing 900 s
|
||||
price: 96 floats, first 216.08
|
||||
unit: 'EUR / MWh'
|
||||
```
|
||||
|
||||
Driving the shipped parser against that body: `SourceUnavailable: the feed
|
||||
carried no 'data' list`. Since `generic_dayahead` is the only shipped provider
|
||||
whose endpoint anybody has reached, **dynamic pricing did not work at all**.
|
||||
|
||||
⚠️ **AND §11.4 MADE THAT SILENT, WHICH IS WHY IT LASTED.** A parse error becomes
|
||||
`no_data`, becomes the static profile, becomes arbitrage off - the *correct*
|
||||
degradation, and it says nothing whatsoever about the cause. A site would have
|
||||
sat on the fallback indefinitely while the status page reported only that the
|
||||
feed was down.
|
||||
|
||||
⚠️ **THE SUITE COULD NOT SEE IT BECAUSE EVERY FIXTURE WAS BUILT FROM THE
|
||||
PARSER'S OWN ASSUMPTION.** `test_price.py`, `test_price_history.py` and
|
||||
`test_static_tariff.py` each built `json.dumps({"data": [{"start": ...,
|
||||
"price_eur_mwh": ...}]})` - a shape invented here, published by nobody. The
|
||||
checks graded the parser against the parser's guess: a closed loop that cannot
|
||||
notice the guess is wrong, and no amount of coverage inside it would have. The
|
||||
loop is broken by **bytes nobody in this repository wrote**: two unmodified
|
||||
response bodies are committed under `scenarios/`, one quarter-hourly
|
||||
(2026-09-10, 96 points at 900 s) and one hourly (2025-09-10, 24 points at
|
||||
3600 s - the same endpoint before Belgium's 15-minute MTU), with URL, retrieval
|
||||
time, licence-as-returned and a sha256 recorded beside them in
|
||||
`energy-charts-be-price-captures.json` and **re-derived** by the replay, so a
|
||||
fixture edited to make a check pass stops being a capture. The remaining
|
||||
invented fixtures are renamed `synthetic_row_feed` at every call site.
|
||||
|
||||
**The columnar document is read by its own function, `_points_from_columnar_json()`.**
|
||||
Not by teaching the row parser to sniff for both: a parser that decides which
|
||||
document it received is a parser that can decide wrong, and a wrong-but-
|
||||
parseable response then becomes a price. Two shapes, two functions, and each
|
||||
plugin names the one its feed publishes. Feeding the row shape to
|
||||
`generic_dayahead` is now a graded `no_data`, so the sniff cannot come back
|
||||
quietly.
|
||||
|
||||
⚠️ **UNEQUAL ARRAYS ARE REFUSED RATHER THAN ZIPPED.** `zip()` stops at the
|
||||
shorter list and says nothing, so a truncated `price` array would have produced
|
||||
a **real price series with its tail missing** - and the tail of a Belgian day is
|
||||
the evening peak. That is worse than no series: `no_data` switches arbitrage
|
||||
off, whereas a short series is traded on as though complete, and nothing
|
||||
downstream can tell them apart. Refused in both directions, with the two lengths
|
||||
in the reason.
|
||||
|
||||
⚠️ **A UNIT CHANGE IS REFUSED FOR A REASON `_validate_raw()` CANNOT COVER.** The
|
||||
plugin converts with a fixed `0.001`; if the feed ever published EUR/kWh under
|
||||
the same keys, every price would come out a **thousand times too small** and
|
||||
`MAX_PLAUSIBLE_EUR_KWH` would not fire, because it only bounds prices that are
|
||||
too large. DATA-02's fetcher already refused on this field; the shipped path
|
||||
does now too.
|
||||
|
||||
**`unix_seconds` are UTC instants, said so explicitly**
|
||||
(`datetime.fromtimestamp(t, timezone.utc)`); §9.3's rendering at the Brussels
|
||||
offset stays where it already lived, in `assemble()`, rather than being copied
|
||||
into the plugin where a mutation could not tell it from a no-op. A published
|
||||
`null` is dropped and logged with a count - a missing quarter is not a free
|
||||
quarter - while an all-null body is `no_data` named by this parser rather than
|
||||
by the boundary validator.
|
||||
|
||||
**End to end against the live feed, 2026-09-10:** `resolve_tariff()` returns a
|
||||
`TariffPlan` with `origin='provider'`, **96 quarter-hourly points**, spacing
|
||||
`[900]` s and no decimation, first point `2026-09-10T00:00:00+02:00` at 0.30608
|
||||
EUR/kWh all-in. FCST-04's H1 fix was never theoretical - 900 s is the shape of
|
||||
the real feed.
|
||||
|
||||
⚠️ **WHAT THIS DOES NOT FIX.** `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy` are untouched: their URLs are unverified, no real body can be
|
||||
captured for them, and withdrawing them is TARIFF-07's open decision. Their
|
||||
fixtures are now named `synthetic_row_feed` so nothing reads them as evidence
|
||||
that those feeds parse. And `generic_dayahead` still ignores `since`/`until`
|
||||
and asks for no date range, so the default URL returns one published day - a
|
||||
full 48 h horizon is reached only through §8.3's carry-forward fill.
|
||||
|
||||
### FCST-05 - what FCST-04's own contract still did not hold
|
||||
|
||||
**A single non-UTF-8 byte in `/data/price_history.json` stopped the add-on from
|
||||
@@ -182,7 +1007,12 @@ spread it would have created was never compared against anything.
|
||||
leaves the local hours it never held **out of the series** - §11.4 for those
|
||||
hours, and asking for one raises rather than answering. Correct behaviour,
|
||||
and the wording is now the behaviour.
|
||||
* ⚠️ **A sub-hourly feed keeps every print.** `eneco_eddy` is the imbalance
|
||||
* ⚠️ **A sub-hourly feed keeps every print.** [⚠️ **The claim that follows is
|
||||
FALSE and TARIFF-07 corrected it: `eneco_eddy` was not an imbalance product,
|
||||
was not an Eneco product at all, and its endpoint never existed. The real
|
||||
quarter-hourly feed is Belgian DAY-AHEAD since the 15-minute MTU of
|
||||
2025-10-01, which is what `generic_dayahead` reads. The behaviour described
|
||||
below is unchanged.**] `eneco_eddy` is the imbalance
|
||||
product and settles per **quarter**; the fill walks whole hours to decide
|
||||
what needs carrying, and that is not a rule about what a published hour may
|
||||
contain. Every published point inside the horizon passes through by identity,
|
||||
@@ -3398,6 +4228,10 @@ somebody else's tariff. What is mechanised is reachability; what stays human is
|
||||
truthfulness.
|
||||
|
||||
**The product type is declared, not inferred from the plugin's name.**
|
||||
[⚠️ **FALSE, corrected by TARIFF-07: `eneco_eddy` was never a real-time /
|
||||
imbalance product — Eneco's dynamic product is day-ahead-indexed — and the
|
||||
plugin has been withdrawn. The declaration mechanism described here is
|
||||
unchanged and is now exercised only from outside `price.py`.**]
|
||||
`eneco_eddy` is a real-time/imbalance product and gets widened arbitrage
|
||||
thresholds; that reaches a consumer through `capabilities().product` and
|
||||
`volatility_regime()`, never through a name comparison. `test_price.py` asserts
|
||||
|
||||
@@ -75,8 +75,9 @@ Every point still carries `provisional=True`, so the **flag never lies** — wha
|
||||
decays is the number, behind a flag that is telling the truth.
|
||||
|
||||
⚠️ **No value-level check can catch that**, and claiming one would be worse than
|
||||
claiming none. A carried point and a published-provisional point (`eneco_eddy`'s
|
||||
current quarter, §9.2) are the same three fields carrying the same flag. The
|
||||
claiming none. A carried point and a published-provisional point (a real-time
|
||||
product's current interval, §9.2) are the same three fields carrying the same
|
||||
flag. The
|
||||
difference is provenance, and provenance is not in the value.
|
||||
|
||||
The defence is therefore an **ordering**, not a validation:
|
||||
@@ -139,8 +140,16 @@ file constructs and still holds a reference to.
|
||||
|
||||
## 4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
|
||||
|
||||
`eneco_eddy` is the Belgian imbalance product and settles **per quarter**, and
|
||||
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing — so a
|
||||
⚠️ **This section originally said `eneco_eddy` is "the Belgian imbalance
|
||||
product". That was false and [[TARIFF-07]] withdrew the plugin: Eneco's dynamic
|
||||
product is "Zon & Wind Dynamisch" and is indexed to Belpex/EPEX **day-ahead**,
|
||||
not to imbalance, and `api.eneco.be/eddy/prices` never existed. The mechanism
|
||||
below is unaffected, because the real quarter-hourly feed is the one still
|
||||
shipped.**
|
||||
|
||||
Belgian **day-ahead** settles per **quarter** — the 15-minute MTU took effect
|
||||
on 2025-10-01, so `generic_dayahead` reads 96 points a day rather than 24 — and
|
||||
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing, so a
|
||||
published hour may legitimately hold four points.
|
||||
|
||||
FCST-04's first caller keyed the published set as `{_utc_hour(p.start): p for p
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# TARIFF-07 — three plugins that never fetched a price, and the one mechanism they leave behind
|
||||
|
||||
**Status: DECIDED 2026-09-13 — withdraw `engie_dynamic`,
|
||||
`totalenergies_dynamic` and `eneco_eddy`. Keep `PriceProduct.REALTIME` and the
|
||||
widening machinery. Refuse a retired id by name rather than aliasing it.**
|
||||
|
||||
Filed with TARIFF-07. Follows [[TARIFF-08]], which found the same class of
|
||||
defect one layer down — a parser graded against its own guess — and left the
|
||||
question of these three open.
|
||||
|
||||
---
|
||||
|
||||
## 1. The endpoints, verified rather than taken on report
|
||||
|
||||
The card said the three endpoints were dead. They were re-checked by hand on
|
||||
**2026-09-13** before anything was deleted, because "the endpoint is dead" is
|
||||
exactly the kind of claim this repository keeps finding to be second-hand:
|
||||
|
||||
| id | endpoint | observed |
|
||||
|---|---|---|
|
||||
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **HTTP 404**, body `{"message":"no Route matched with those values","request_id":"..."}` — a Kong gateway with no route configured |
|
||||
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure**. The handshake completes and the server presents `CN=pge-apigee-dev.direct-energie.com`; `api.totalenergies.be` is not in the SAN list, so the connection is closed before any request is sent |
|
||||
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **HTTP 404**, `content-length: 1`, `server: Microsoft-IIS/10.0` — an empty body from a live host that does not serve this path |
|
||||
|
||||
All three failed. Had any one of them answered, this card would have stopped
|
||||
and asked, because an endpoint that answers changes the decision from
|
||||
"withdraw" to "fix the parser".
|
||||
|
||||
## 2. Why this is not "unfinished", and why no replacement can be written
|
||||
|
||||
An unfinished plugin has a right endpoint and a wrong parser. These have
|
||||
neither, and the reason is structural:
|
||||
|
||||
* **ENGIE's dynamic price is `(B × eSpot_15) − A`, and `B` and `A` are
|
||||
PER-CONTRACT.** They live behind the customer's klantenzone login. No
|
||||
unauthenticated public URL can return *a given customer's* price, so the
|
||||
endpoint this plugin wanted cannot exist for anybody to build.
|
||||
* **There is no regulator index to read the coefficients from.** CREG's
|
||||
tariff-card pages 404; VREG was unreachable. Every coefficient found in
|
||||
research came from a supplier's own PDF, which is a document, not a feed.
|
||||
* **"Eddy" is not an Eneco product at all.** Eneco's dynamic product is
|
||||
**"Zon & Wind Dynamisch"**, and it is indexed to **Belpex/EPEX day-ahead** —
|
||||
not to imbalance. The plugin was wrong about the product, the product type
|
||||
and the URL simultaneously.
|
||||
|
||||
So the three were not withdrawn for being broken. They were withdrawn for
|
||||
describing tariffs that no public endpoint can serve.
|
||||
|
||||
## 3. What a site configured on one of these has actually been running
|
||||
|
||||
⚠️ **This is the half that decides what the migration is.** §11.4 turns a
|
||||
failed fetch into a silent fall back: static profile, plan provisional, no
|
||||
arbitrage. That is the *correct* degradation, and it says nothing at all about
|
||||
the cause. Every fetch these three ever made raised `SourceUnavailable`.
|
||||
|
||||
Therefore a site configured on one of them has been running on
|
||||
`static_profile` — **or on nothing at all, where no profile was set** — since
|
||||
the day the id was entered, and **nothing on its status page has ever differed
|
||||
from a working provider that simply never found a cheap hour**.
|
||||
|
||||
The migration is therefore **not** "keep their prices working". There were no
|
||||
prices. It is *"tell them their prices never worked"*, which is why the answer
|
||||
is a refusal and not a fallback.
|
||||
|
||||
### Why not a silent alias to `generic_dayahead`
|
||||
|
||||
It is the friendlier-looking option and it is wrong. These published **retail**
|
||||
prices; `generic_dayahead` reads **wholesale**. Aliasing needs the site's own
|
||||
`B` and `A`, which §2 has just established nobody has. An alias would have to
|
||||
**invent the numbers a battery is priced against** — the same defect as a
|
||||
parser graded against its own guess, one layer further from anything that
|
||||
reports. The scalar `price_markup_eur_kwh` cannot stand in for `B` either: it
|
||||
is an addend, not a multiplier, and it is `float(0,1)` so it cannot even hold a
|
||||
negative `A`.
|
||||
|
||||
## 4. ⚠️ The `config.yaml` schema is deliberately WIDER than the registry
|
||||
|
||||
`price_provider` is a `list(off|…)` enum, and the Supervisor validates the
|
||||
options file against it **before the add-on starts**.
|
||||
|
||||
So the obvious edit — delete the three ids from the enum along with the classes
|
||||
— has a consequence that runs exactly opposite to this card's purpose:
|
||||
|
||||
```
|
||||
operator's config says price_provider: eneco_eddy
|
||||
|
|
||||
+-- id NOT in the schema enum
|
||||
|
|
||||
+-- Supervisor rejects the options file
|
||||
|
|
||||
+-- add-on never starts
|
||||
|
|
||||
+-- validate.py never runs
|
||||
|
|
||||
+-- the named refusal is NEVER SEEN
|
||||
```
|
||||
|
||||
The operator gets an opaque `expected one of ...` from a layer they did not
|
||||
configure, on **exactly the sites the withdrawal exists to inform**.
|
||||
|
||||
**Decision: keep all three ids in the schema enum.** `PROVIDERS` shrinks to
|
||||
two; the enum stays at six. The cost is that the dropdown still offers three
|
||||
dead ids to a *new* operator — and that is the cheaper failure, because
|
||||
choosing one is refused at the next startup **by name**, with the endpoint, the
|
||||
observation and the alternative, which is strictly more than a schema
|
||||
rejection would say. Both translations label them withdrawn in the option
|
||||
description.
|
||||
|
||||
`app/price.py`'s `RETIRED_PROVIDERS` is the list. `test_price.py` section 1
|
||||
asserts the schema offers exactly `PROVIDERS | RETIRED_PROVIDERS | {"off"}` —
|
||||
derived, not typed — and `register()` refuses to re-register a retired id, so
|
||||
the registry and the retired table cannot both claim one and the refusal cannot
|
||||
be taken away by an `@register` somebody adds back.
|
||||
|
||||
## 5. ⚠️ `PriceProduct.REALTIME` and the widening path are KEPT with no shipped declarer
|
||||
|
||||
`eneco_eddy` was the **sole** declarer of `PriceProduct.REALTIME`. Deleting it
|
||||
orphans:
|
||||
|
||||
* `_WIDE = frozenset({PriceProduct.REALTIME})`
|
||||
* `Uncertainty.REALTIME_PRODUCT`
|
||||
* `WIDEN_FACTOR[Uncertainty.REALTIME_PRODUCT] = 2.5`
|
||||
|
||||
**They stay.** The reasoning, stated here so the next reader finds intent
|
||||
rather than an apparent dead branch:
|
||||
|
||||
1. A reviewer finding a mechanism with **zero callers** would reasonably delete
|
||||
it. That is good instinct applied to a missing fact, and this file is the
|
||||
fact.
|
||||
2. It is the **AC2 seam**. The whole point of `capabilities().product` is that
|
||||
a new supplier is a new class and nothing else: declare `REALTIME`, get the
|
||||
wide regime, edit no framework code. Deleting the enum member would mean the
|
||||
first genuine imbalance product has to **rebuild** that seam — and rebuilding
|
||||
it is precisely the "editing the optimiser to add a supplier" that AC2
|
||||
forbids.
|
||||
3. It costs nothing. Two enum members and a dict entry.
|
||||
|
||||
**⚠️ NO SHIPPED PLUGIN DECLARES `PriceProduct.REALTIME` UNTIL ONE DOES.** Both
|
||||
shipped plugins (`generic_dayahead`, `manual_csv`) are day-ahead. §9.2's row 2
|
||||
is unoccupied. That is not the same as it having no mechanism, and the
|
||||
difference is what this section exists to record.
|
||||
|
||||
It is **pinned, not merely documented**. `test_price.py` section 5 asserts:
|
||||
|
||||
* that **no shipped plugin** declares `REALTIME` — so if one ever does, the
|
||||
check goes red and the right fix is to **edit this record**, not to delete
|
||||
the seam;
|
||||
* that the widening still works **from a declaration alone** —
|
||||
`volatility_regime`, `uncertainty_of` and `widen_factor` are graded on a
|
||||
`ProviderCapabilities` the test file builds;
|
||||
* and section 10 registers `_NewSupplier`, a class defined **outside
|
||||
`price.py`** that declares `REALTIME` and is asserted to land in the wide
|
||||
regime. That class is now the only live declarer in the tree, which makes the
|
||||
seam something this suite exercises end to end rather than a constant it
|
||||
reads.
|
||||
|
||||
## 6. What else went, and one thing that did not
|
||||
|
||||
`_points_from_json()` went with the three. It read a `{"data": [{...}]}`
|
||||
row-per-point document that **no feed anybody here has ever reached
|
||||
publishes** — the shape was invented alongside these plugins, and the only
|
||||
bodies ever fed to it were fixtures written from the same guess. TARIFF-08
|
||||
found that loop on the response side for `generic_dayahead`; this is the rest
|
||||
of it. `needs_credentials` went too: the three were its only setters and
|
||||
nothing read it.
|
||||
|
||||
The shape survives in **one** place and in one direction: `row_shape_body()` in
|
||||
`test_price.py`, feeding `generic_dayahead` so that section 9 grades a row
|
||||
document as a **failure** for it rather than a second dialect it might sniff.
|
||||
It is named for what it is.
|
||||
|
||||
⚠️ **One check had to be rebuilt rather than deleted, and saying so matters.**
|
||||
`test_price.py` section 4b proved that `code_leaks()`'s docstring strip is
|
||||
load-bearing by finding a shipped docstring that a raw text scan would
|
||||
false-positive on. The docstring that did that was **`engie_dynamic`'s** — it
|
||||
spelled `price_markup_eur_kwh` in full. With it gone, no shipped docstring
|
||||
spells a full framework key, so the check would have **passed on a tree with
|
||||
the strip deleted**: a check that had quietly stopped being one, which is the
|
||||
third failure mode in `MUTATION.md`. The stimulus is now constructed in the
|
||||
test file (`_DocumentedPlugin`), and the shipped set is asserted clean
|
||||
separately.
|
||||
|
||||
## 7. Mutations driven
|
||||
|
||||
Every check added or rebuilt here was driven red. Harness:
|
||||
`/home/homelab/.cache/t07-mut/`, which rebuilds the tree from the worktree and
|
||||
purges `__pycache__` per `MUTATION.md`'s rule.
|
||||
|
||||
| | mutation | Δ | red on |
|
||||
|---|---|---|---|
|
||||
| M1 | delete the retired branch from `_price_problems()` | −2785 B | 7 checks in `test_price.py` |
|
||||
| M2 | move the retired branch **after** the unknown-id branch | **+0 B** | the same 7 |
|
||||
| M3 | make `been_running` constant (drop the empty-`static_profile` case) | −212 B | 2 |
|
||||
| M4 | drop the "WHAT TO DO" half of the refusal | −874 B | 1 |
|
||||
| M5 | `RETIRED_PROVIDERS = {}` | −1099 B | 5, then a traceback |
|
||||
| M6 | drop the three ids from `config.yaml`'s enum — §4's trap | −47 B | 2 |
|
||||
| M7 | delete `register()`'s retired guard | −1101 B | 2, then a traceback |
|
||||
| M8 | `_WIDE = frozenset()` | −23 B | 3 + 4 across two suites |
|
||||
| M9 | `WIDEN_FACTOR[REALTIME_PRODUCT]` 2.5 → 1.5 | **+0 B** | 1 + 1 |
|
||||
|
||||
⚠️ **M2 and M9 have a byte delta of zero**, which `MUTATION.md` names as the
|
||||
risky case. The harness rebuilds the tree from source on every run and purges
|
||||
`__pycache__`, so there is no inherited cache to read; and stale bytecode
|
||||
manufactures **survivors**, never reds, so a red from a `+0 B` mutation is not
|
||||
at risk from that defect. M2's grep confirmed the branch had moved on disk.
|
||||
|
||||
⚠️ **M5 and M7's red counts are LOWER BOUNDS.** Both runs ended in a traceback
|
||||
with checks after it ungraded — FCST-06's rule: read the output, not the count.
|
||||
M7's traceback is itself informative: with the guard gone the re-registration
|
||||
**succeeds**, `PROVIDERS` is polluted with a class that has no fixture, and the
|
||||
suite dies downstream. That is the failure the guard prevents, arriving loudly.
|
||||
|
||||
## 8. What this card did not do
|
||||
|
||||
* **It did not pick a version number.** The change is backward-incompatible — a
|
||||
config legal on the current version is refused on the next — and the
|
||||
CHANGELOG says so prominently, in the shape PKG-07 established for `slew_w`.
|
||||
Which number it belongs to depends on whether the current image has already
|
||||
been built and pulled, which is a fact about the live box that this branch
|
||||
cannot observe. Whoever deploys decides.
|
||||
* **It did not implement the formula provider.** `B × spot + A` with per-site
|
||||
coefficients is the real answer for a Belgian dynamic customer and is a
|
||||
separate card. `DOCS.md` references it as planned and is explicit about what
|
||||
`generic_dayahead` can and cannot express in the meantime — in particular
|
||||
that it cannot represent a negative offset, and that
|
||||
`price_markup_eur_kwh: float(0,1)` could not hold one anyway.
|
||||
* **It did not rewrite released CHANGELOG history.** The two historical entries
|
||||
that called `eneco_eddy` an imbalance product are annotated in place with a
|
||||
correction naming this card, rather than silently edited. The record of what
|
||||
the project believed is part of the record.
|
||||
@@ -0,0 +1,141 @@
|
||||
# TARIFF-09 — asking the feed for the horizon, and what `price_feed_url` means
|
||||
|
||||
Two decisions. The first had no defensible alternative once it was measured;
|
||||
the second did, and the alternative is argued below rather than dismissed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The measurement this card exists for
|
||||
|
||||
`generic_dayahead.fetch()` accepted `since` and `until` and requested the bare
|
||||
`https://api.energy-charts.info/price?bzn=BE`. That URL names no range, and the
|
||||
feed answers **one Brussels day**.
|
||||
|
||||
Measured on the merged tree (`4257e38`) at 2026-09-10 09:00 CEST, 48 h horizon,
|
||||
live feed, yesterday seeded through the shipped read path:
|
||||
|
||||
```
|
||||
60 firm points (15 whole hours) + 33 carried hours of 48 requested
|
||||
carried: 24 h on 2026-09-11, 9 h on 2026-09-12
|
||||
arbitrage(0.10): allowed, spread 0.4645, buy 11:45 and sell 19:45 -- BOTH on 2026-09-10
|
||||
firm local days = [2026-09-10]
|
||||
```
|
||||
|
||||
Everything past today was carry-forward, flagged provisional, and §8.3 drops
|
||||
provisional hours from the trade set. **The optimiser never saw tomorrow as
|
||||
firm, at any hour.** The ceiling was structural rather than incidental: with a
|
||||
one-day window, at hour *H* of the local day a 48 h horizon is at most
|
||||
**24−H hours firm and 24+H carried** — 15/33 at 09:00, and **1/47 at 23:00**,
|
||||
where the remaining firm spread (0.0464 EUR/kWh) no longer even clears the
|
||||
default 0.10 round-trip threshold and arbitrage is refused outright.
|
||||
|
||||
That is not what FCST-04's fill was built for. FCST-04 covers the
|
||||
pre-publication window until the ~13:00–14:00 CET auction, after which a
|
||||
healthy feed hands over tomorrow in full.
|
||||
|
||||
**The data exists and the endpoint serves it.** Verified 2026-09-10T07:45:49Z:
|
||||
`?bzn=BE&start=2026-09-09&end=2026-09-10` returns **192 quarter-hours across
|
||||
two local days**, committed unmodified as
|
||||
`scenarios/energy-charts-be-price-2026-09-09_2026-09-10-twoday.json`. The
|
||||
plugin simply never asked.
|
||||
|
||||
After the change, same code path, same window, a feed serving both days:
|
||||
**39 firm hours and 9 carried**, firm across **two** Brussels days, and the
|
||||
permitted cycle buys on one day and sells on the other. Graded in
|
||||
`test_price_history.py` section 1c against both feed states.
|
||||
|
||||
---
|
||||
|
||||
## 1. The range is derived on the **Brussels** calendar, from `since`/`until`
|
||||
|
||||
`since` and `until` are **instants**; the API takes **dates**. Those are not
|
||||
the same question, and the conversion is where the day gets lost.
|
||||
|
||||
`brussels_date_range()` returns two inclusive `date`s:
|
||||
|
||||
* the first is `brussels(since).date()`. **Not `since.date()`, and not the UTC
|
||||
date.** 22:30 UTC in summer is already tomorrow in Belgium, so a UTC-derived
|
||||
start asks for the day the controller has just left and drops the day it is
|
||||
standing in — for the last one or two hours of *every* day. This is
|
||||
`price_history._bdate()`'s lesson one layer up, with a worse consequence:
|
||||
`_bdate()` misfiles an hour, this never fetches it.
|
||||
* the last is `brussels(until).date()`, **stepped back a day when `until`
|
||||
falls exactly on local midnight**, because `until` is exclusive and that
|
||||
instant is the first tick of a day nobody asked for. Without it every
|
||||
whole-hour horizon requests one extra day — harmless on this feed, but it
|
||||
makes the request a claim the caller never made.
|
||||
* an empty or inverted window collapses to a single day rather than producing
|
||||
a backwards range, which no feed has a defined answer for.
|
||||
|
||||
Both ends are graded, and both are driven red by mutation — including the two
|
||||
that swap `brussels()` for the UTC date (`test_price.py` section 2c).
|
||||
|
||||
---
|
||||
|
||||
## 2. `price_feed_url` is the **exact URL**, used as given. The plugin appends
|
||||
nothing.
|
||||
|
||||
The alternative — merge our `start`/`end` into whatever query string the
|
||||
operator supplied — was considered and rejected. Three reasons, in the order
|
||||
they decided it:
|
||||
|
||||
1. **`start`/`end` are energy-charts' spelling, and this provider declares a
|
||||
second host.** `generic_dayahead.capabilities().egress_hosts` is
|
||||
`("api.energy-charts.info", "web-api.tp.entsoe.eu")`. That second host's
|
||||
day-ahead date parameters have not been verified by this card and are not
|
||||
guessed at here — but they are not `start`/`end`. So an override to the
|
||||
*other declared host* — a case the declaration itself anticipates — is
|
||||
exactly where merging appends two parameters that host ignores, producing
|
||||
the short horizon anyway, with a URL nobody typed as the receipt.
|
||||
2. **Merging means re-serialising a URL somebody else wrote.** Parsing and
|
||||
rebuilding reorders parameters, collapses duplicates and re-escapes values.
|
||||
"Override the feed" is what the option already says it means, and an
|
||||
override that is silently rewritten is not one.
|
||||
3. **Blind appending is not on the table at all.** `dated_feed_url()` refuses
|
||||
to add a query string to a base that already has one, so
|
||||
`?bzn=BE?bzn=BE&start=…` cannot be produced by this module even by a later
|
||||
edit that folds the parameters back into the constant.
|
||||
|
||||
⚠️ **The cost is real and it is therefore NOT a doc footnote.** An overriding
|
||||
operator gets exactly the short horizon this card exists to fix. So the plugin
|
||||
emits a `WARNING` on the shipped `goodwe.price` logger at **fetch time**, naming
|
||||
the consequence — one Brussels day, the rest carried and flagged, §8.3 dropping
|
||||
them from the trade set — and naming the `start..end` it *would* have asked
|
||||
for. §11.4 is what makes a price defect quiet; an operator who has taken the
|
||||
short horizon has to be able to find out from the log that they did.
|
||||
|
||||
The warning is graded, with a control that the default path stays silent, and
|
||||
a mutation that downgrades it to `debug()` takes that check red.
|
||||
|
||||
**Fetch time rather than startup**, because that is where the URL actually in
|
||||
use is known. The trade-off is one line per refresh (the tariff task, not the
|
||||
30 s control tick). If the status page ever grows a price-source row, this is
|
||||
the sentence it should carry.
|
||||
|
||||
---
|
||||
|
||||
## 3. The plugin contract: `fetch(self, opts, since, until, transport)`
|
||||
|
||||
The signature promises every plugin the range, and until this card **none of
|
||||
the four networked plugins used it**. The obvious follow-up is to enforce the
|
||||
promise. It is **not** enforced, and the reason is TARIFF-08's:
|
||||
|
||||
Three of the five shipped plugins point at endpoints nobody has ever verified
|
||||
(TARIFF-07 is the open decision on withdrawing them). A rule obliging them to
|
||||
pass a date range would be satisfied by an **invented parameter name** — which
|
||||
is the parser-graded-against-its-own-guess defect moved from the response to
|
||||
the request, and it would ship looking like a guarantee.
|
||||
|
||||
What exists instead is a **roster pin** in `test_price.py` section 2c: which
|
||||
plugins reference their `since`/`until` is asserted as a literal, derived from
|
||||
the registry and the AST. A sixth supplier, or a change to any of the three,
|
||||
has to edit that line and make the decision rather than inherit the silence.
|
||||
It is named as a source-shape pin, not as evidence that any feed parses;
|
||||
`generic_dayahead`'s behaviour is graded separately by the URL it really
|
||||
requests.
|
||||
|
||||
**Recommendation for whoever picks up TARIFF-07:** enforce it at the moment
|
||||
those three endpoints are either verified or withdrawn, not before. If they are
|
||||
withdrawn, `generic_dayahead` and `manual_csv` are the whole roster and the
|
||||
rule is one line — `manual_csv` reads a whole file and is windowed downstream,
|
||||
so it would be the single documented exemption.
|
||||
@@ -0,0 +1,277 @@
|
||||
# TARIFF-10 — fetching a price and trading on one are two decisions
|
||||
|
||||
**Status: DECIDED 2026-09-12 — Option A. If a price provider is configured,
|
||||
prices are fetched. Trading stays a separate switch (`arbitrage_enabled`), and
|
||||
this card moves nothing that moves energy.**
|
||||
|
||||
Filed with TARIFF-10. Follows [[TARIFF-09]] and [[FCST-04]]; §4 settles the
|
||||
limitation FCST-04 recorded and did not decide.
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect, and why it was invisible
|
||||
|
||||
`app/main.py`'s `amain()` read:
|
||||
|
||||
```
|
||||
if arbitrage.enabled(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
```
|
||||
|
||||
`run_tariff_refresh` is the **only** caller of `resolve_tariff()`, which is the
|
||||
**only** caller of `price_history.carry()`. `arbitrage_enabled` ships `false`.
|
||||
|
||||
So on a shipped install with a provider configured:
|
||||
|
||||
* no HTTPS request was ever made to the feed;
|
||||
* no plan was ever resolved, so `self.tariff_plan` stayed `None` forever;
|
||||
* `/data/price_history.json` was never created;
|
||||
* the *Price history* status row read `empty` for the life of the install;
|
||||
* TARIFF-09's short-horizon warning could not fire, because it is logged on a
|
||||
fetch and there was no fetch.
|
||||
|
||||
Measured, not inferred — a 40 h soak, two instances, one feed:
|
||||
|
||||
| | shipped default + provider | same, arbitrage armed |
|
||||
|---|---|---|
|
||||
| feed requests | **0** | fetching |
|
||||
| `/data/price_history.json` | `present: false`, **802/802 samples** | written |
|
||||
| resolved plan | none | 132 points |
|
||||
|
||||
⚠️ **AND FOUR SURFACES ALREADY DESCRIBED THE FIXED BEHAVIOUR.** `DOCS.md`
|
||||
§*Energy price providers*: *"With `arbitrage_enabled: false`, which is the
|
||||
shipped default, turning a provider on still fetches, validates and publishes
|
||||
prices and changes no charging decision."* `config.yaml` and both translations
|
||||
said the same, and additionally carried a claim that had gone stale in the other
|
||||
direction — *"nothing consumes the price series yet"*, untrue since OPT-04
|
||||
shipped the consumer. The code was the only surface that disagreed with all of
|
||||
them, and no check compared the two.
|
||||
|
||||
The dating is worth being exact about, because it says this was never a
|
||||
regression. `git log -S` puts the refresh task, its `arbitrage.enabled()` guard
|
||||
**and** the DOCS sentence in the *same commit* — OPT-04, `bdd7f18`;
|
||||
`config.yaml`'s version of the claim goes back further, to TARIFF-02,
|
||||
`852fc7c`. The prose and the code disagreed from the moment both existed.
|
||||
|
||||
## 2. Why a check on the `if` would not have caught it
|
||||
|
||||
The `if` was exactly what somebody meant to write, and its comment argued for
|
||||
it honestly:
|
||||
|
||||
> ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on reads the
|
||||
> resolved plan … so on a default install this task would be network traffic in
|
||||
> aid of an attribute nobody reads.
|
||||
|
||||
That was true when OPT-04 wrote it and stopped being true when FCST-04 gave the
|
||||
plan a *store* and a *status row*. A pin on the expression would have gone red
|
||||
on the fix and green on the defect. What was wrong is the **set of
|
||||
configurations from which a fetch is reachable**, so that is what
|
||||
`test_price.py` section 13 grades:
|
||||
|
||||
1. the guards on every `asyncio.create_task` in `amain()` are **evaluated**
|
||||
against real option dicts — whatever the guard is spelled as;
|
||||
2. the call graph under `app/` is walked to see whether the coroutines those
|
||||
guards start can arrive at `resolve_tariff()` — wherever it has moved to;
|
||||
3. and then the fetch is **driven**, with a counting transport, a real
|
||||
`Controller` and a real store path, because a reachability graph is an
|
||||
argument and a counted request is an observation.
|
||||
|
||||
⚠️ **Step 3 grades the gate only because it starts the task the way `amain()`
|
||||
would.** The driver asks `_fetchers(opts)` — the evaluated guard — before it
|
||||
calls the coroutine. Measured: with the shipped defect restored **and** that one
|
||||
line defeated (`if False and "run_tariff_refresh" not in names:`), the four
|
||||
driven rows go **green** while only the structural rows stay red. Without it
|
||||
they are decoration, which is MUTATION.md's habit 4 in a harness.
|
||||
|
||||
What the instrument **cannot** see is printed on every run: a coroutine started
|
||||
by anything but `create_task`; a guard that is not an enclosing `if` in
|
||||
`amain()`; anything outside `app/`; and — because the graph is keyed by **name**
|
||||
— two same-named functions as one node, which makes every *reachable* answer an
|
||||
**upper** bound and every *not reachable* answer the strong one.
|
||||
|
||||
## 3. The gate, and why it is an `or`
|
||||
|
||||
```
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
```
|
||||
|
||||
The first term is what this card adds. **The second term is the old gate, kept
|
||||
verbatim, and it is not there for symmetry.**
|
||||
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration `validate.py` explicitly **accepts** (rule 7 refuses only the
|
||||
no-prices-at-all case), and it **trades**. Measured on this branch:
|
||||
|
||||
```
|
||||
opts: price_provider off, arbitrage_enabled true,
|
||||
cycle_cost_threshold_eur 0.05,
|
||||
static_profile 0.20 night / 0.284 day
|
||||
plan : static_profile from static_profile, 48 h, 48 points
|
||||
WITH the plan -> ARBITRAGE_CHARGE -5000.0 W
|
||||
"cheapest firm hour at 0.2000 EUR/kWh, 0.0840 below the
|
||||
dearest and clear of the 0.0750 this cycle had to cover"
|
||||
WITHOUT the plan -> None "no tariff plan has been resolved yet"
|
||||
```
|
||||
|
||||
Gating the refresh on the provider **alone** — which is the literal reading of
|
||||
the ticket's second acceptance criterion — would leave that site's
|
||||
`tariff_plan` at `None` forever and answer *"no tariff plan has been resolved
|
||||
yet"* on every cycle. That is a strategy the UI says is running and that cannot
|
||||
fire: the exact state `validate.py`'s OPT-04 rules exist to refuse, arrived at
|
||||
from inside the code instead of from the configuration.
|
||||
|
||||
So the gate is a **strict widening**: every configuration that started the task
|
||||
before still starts it, and `test_price.py` grades that as its own row rather
|
||||
than leaving it to prose.
|
||||
|
||||
⚠️ **The AC's stated harm is still fully met, and it is worth being exact about
|
||||
this rather than quietly declaring victory.** The criterion reads *"with
|
||||
`price_provider: off`: no fetch task at all, whatever `arbitrage_enabled` says
|
||||
— an operator with no provider must not acquire a periodic HTTPS call."* The
|
||||
letter of it is not met in one configuration (provider off, trading armed),
|
||||
because that configuration keeps the task it already had. The harm it names is:
|
||||
|
||||
* with `price_provider` off, `resolve_tariff()` returns `static_plan()` — pure
|
||||
arithmetic, **no socket**. Driven and counted: **0 requests**.
|
||||
* the true shipped default — no provider, no trading — starts **no task at
|
||||
all**.
|
||||
|
||||
Nobody *acquires* a periodic HTTPS call from this card, and the only thing that
|
||||
decides whether a request leaves the machine is `price_provider`. **If the
|
||||
owner wants the literal reading instead, it is a one-token change** (drop the
|
||||
`or` term) **and it costs the site in the measurement above its trading.** That
|
||||
is the trade, stated rather than chosen silently.
|
||||
|
||||
## 4. `TariffPlan.cheap_now()` — available, and deliberately NOT consumed
|
||||
|
||||
**Decision: do not wire it in this card. `self.cheap` stays on
|
||||
`static_status()`.**
|
||||
|
||||
FCST-04 recorded that `cheap_now()` has no production caller on the provider
|
||||
plan: `main.read_inputs()` assigns `self.cheap, self.tariff_words =
|
||||
static_status(o, now)`, which builds its **own** static plan and calls
|
||||
`cheap_now()` on that. Under Option A a provider-backed plan now exists on every
|
||||
configured site, so the limitation stops being structural and becomes a choice.
|
||||
|
||||
**It is not cosmetic, and that is the whole reason for the decision.**
|
||||
`self.cheap` is read in `cycle()` by the maintenance cycle's cheap-window grid
|
||||
top-up — the add-on's one shiftable load, and the one place a *price* reaches
|
||||
something that **moves energy** on an install that has not armed trading.
|
||||
|
||||
⚠️ **And the direction it would move is not "a slightly different window".**
|
||||
`static_status()` returns `(False, "no static profile")` whenever
|
||||
`static_profile` is empty, so today a provider-only site — a plausible shape
|
||||
for a dynamic-tariff install, and the shape this card makes work — has
|
||||
`self.cheap = False` on **every** cycle and never forces a paid grid top-up at
|
||||
all. Wiring `cheap_now()` would take that from *never* to *sometimes*. That is
|
||||
a strictly larger behaviour change than this card's headline, and it belongs to
|
||||
whoever owns the maintenance cycle rather than to the ticket that made it
|
||||
reachable.
|
||||
|
||||
Three further reasons, in the order they would bite:
|
||||
|
||||
1. **Two sources, one attribute, and the loser is silent.** `validate.py`
|
||||
already refuses `static_profile` alongside the Home Assistant price
|
||||
entities *because* `read_inputs()` picks one and the other disappears. A
|
||||
provider plan would be a third writer of the same attribute with no rule
|
||||
saying which wins.
|
||||
2. **The freshness question is not answered for this consumer.** The plan
|
||||
retires after `TARIFF_MAX_AGE_S` and `fresh_tariff_plan()` returns `None`.
|
||||
Arbitrage has an answer for that (refuse). The maintenance top-up has none:
|
||||
"the plan expired" would have to mean *not cheap*, and *not cheap* on this
|
||||
path means **wait for sun**, which is a real decision nobody has taken.
|
||||
3. **The comparison basis changes, and so does the answer on a bad feed.**
|
||||
`cheap_now()` is strictly-below-the-mean-of-the-**firm**-hours, and it is
|
||||
`False` when THIS hour is provisional or is not in the series at all
|
||||
(FCST-03's rule: this hour decides, not the whole plan). So a feed that
|
||||
published nothing, or an hour the §8.3 fill could not cover, reads *not
|
||||
cheap* where a `static_profile` reads whatever the clock says. Defensible
|
||||
either way — and it is a change to the shipped maintenance cycle that needs
|
||||
its own measurement rather than a side effect of this one.
|
||||
|
||||
**What adopting it would take**, so the next card does not start from scratch:
|
||||
|
||||
* a rule for which source wins when both a provider plan and a
|
||||
`static_profile` are configured, enforced in `validate.py` the way the
|
||||
entity/profile conflict already is;
|
||||
* a decision on what an **expired** or **absent** plan means for the grid
|
||||
top-up, stated as a default rather than left to `None`;
|
||||
* `read_inputs()` splitting `self.cheap` from `self.tariff_words`, which are
|
||||
currently one assignment from one function;
|
||||
* a check that the maintenance top-up actually fires and does not fire on the
|
||||
provider plan — driven through `cycle()`, not asserted on the attribute;
|
||||
* and the *Tariff* status row, which FCST-04 pinned as a **pair** with
|
||||
`DOCS.md` in `test_price_history.py` §9: it does not report the provider
|
||||
plan, DOCS does not claim it does, and moving one without the other is
|
||||
already red.
|
||||
|
||||
## 5. The *Arbitrage* status row, re-read and left alone
|
||||
|
||||
`main.status_payload()` renders `("Arbitrage", "off (arbitrage_enabled)" if
|
||||
self.arb is None else self.arb.explain())`. `self.arb` is assigned only inside
|
||||
`if arbitrage.enabled(self.o):` and `arbitrage.decide()` never returns `None`,
|
||||
so `self.arb is None` **iff** the option is off. The string still names the
|
||||
option that is actually off, and under Option A it gains a job it did not have:
|
||||
on a site with a provider configured it is the row that tells an operator the
|
||||
prices arriving on the *Price history* row above are moving nothing. Unchanged,
|
||||
with a note at the call site saying it was checked.
|
||||
|
||||
## 6. What this card did not do
|
||||
|
||||
* It did not touch `run_tariff_refresh()`'s body (TARIFF-12 is there).
|
||||
* It did not change `cycle()`'s `if arbitrage.enabled(self.o):`, the sizing,
|
||||
the thresholds, the SoC bounds or the peak interlock.
|
||||
* It did not wire the provider plan into the *Tariff* status row or into
|
||||
`self.cheap` — §4.
|
||||
* It did not pick a version number (PKG-06).
|
||||
|
||||
## 7. Mutation ledger
|
||||
|
||||
Every mutation applied to a **fresh `cp -a`** of the clean worktree rather than
|
||||
reverted (habit 18: there is no revert to verify and no shared scratch path),
|
||||
`__pycache__` purged, tree mounted `:ro`, run under `python3 -B`.
|
||||
|
||||
| id | mutation | Δ bytes | result |
|
||||
|---|---|---|---|
|
||||
| M1 | the shipped defect: gate back to `arbitrage.enabled(opts)` | **-4 B** | **RED**, 7 named checks |
|
||||
| M2 | drop the `or arbitrage.enabled(opts)` term | -27 B | **RED**, 2 named checks (strict-widening + the static-profile row) |
|
||||
| M3 | `tariff_refresh_wanted` -> always `True` | +8 B | **RED**, the AC2 reachability row |
|
||||
| M4 | `provider_configured`: `"off"` counts as configured | -6 B | **RED**, the AC2 reachability row |
|
||||
| M5 | `cycle()`'s trading gate -> `if True or arbitrage.enabled(self.o)` | +8 B | **RED**, both AC3 rows (`-5000 W` with trading off) |
|
||||
| M6 | instrument: `_reaches()` always `True` | **+0 B** | **RED**, 4 checks incl. its own calibration row |
|
||||
| M7 | instrument: `_task_sites()` returns `[]` | **+0 B** | **RED**, 10 checks incl. "it found 0 sites" |
|
||||
| M8 | fixture: 5 minutes of telegrams instead of a whole quarter | **+0 B** | **RED**, the peak-guard precondition and both armed rows |
|
||||
| M9 | M1 **plus** the harness's guard-driven start defeated | -4 B | the four DRIVEN rows go **GREEN** — see §2 |
|
||||
| M10 | `provider_id()` drops `.strip()` | -8 B | **survivor**, then CLOSED — see below |
|
||||
| M11 | `provider_configured()` -> `return True` | -32 B | **RED**, 4 checks |
|
||||
| M12 | DOCS.md's sentence reverted to the old, vaguer claim | +0 B (docs) | **RED**, the AC4 pairing row (`fetches = True, DOCS says = False`) |
|
||||
| M13 | the Dutch translation left behind while English moved | +0 B (docs) | **RED**, the both-languages row |
|
||||
|
||||
⚠️ **M10 was a real survivor and is recorded as one.** Dropping `.strip()` left
|
||||
`test_price.py`, `test_price_history.py`, `test_static_tariff.py` and
|
||||
`test_validate.py` all green: nothing in the repository fed a `price_provider`
|
||||
with surrounding whitespace, and the `.strip()` predates this card — it was
|
||||
carried into `provider_id()` from the three call sites it replaced. The
|
||||
Supervisor's `list(off|…)` schema cannot produce `" off "`, but
|
||||
`validate_startup_config()` is also `sim/inject.py`'s entry point and takes any
|
||||
dict at all. Closed in the same commit by the padded-id pair in section 13;
|
||||
re-run as **M10b**, red on two named checks.
|
||||
|
||||
⚠️ **M8 is the fixture defect this card walked into and then fenced.** The
|
||||
first draft fed five minutes of telegrams. Whether that **covers** the current
|
||||
billing quarter depends on where the wall clock sits inside it, so the peak
|
||||
guard was blind and engaged on some runs and not others — and `decide()` refuses
|
||||
outright while the guard is holding (§9.1 over §10.1). On those runs the armed
|
||||
baseline did not trade, and "trading off never trades" would have been graded
|
||||
on a fixture in which nothing could trade either way. It is now 900 s of
|
||||
telegrams, and the guard's clear state is a **check** rather than an assumption.
|
||||
|
||||
## 8. What is still open
|
||||
|
||||
* The literal reading of AC2 (§3). One token, and it costs a measured trading
|
||||
configuration its plan. **Owner's call.**
|
||||
* `cheap_now()` (§4). Owner's call, with the adoption list above.
|
||||
* The *Tariff* status row still shows the static-profile sentence on a
|
||||
provider-backed site; the provider plan's own line goes to the log. Pinned as
|
||||
a pair with `DOCS.md` by FCST-04 and untouched here.
|
||||
@@ -0,0 +1,252 @@
|
||||
# TARIFF-12 — a returned degradation is not a refresh
|
||||
|
||||
Three decisions. The first is what the ticket put up for judgement; the second
|
||||
and third are what fall out of it and would otherwise be decided by accident.
|
||||
|
||||
---
|
||||
|
||||
## 0. What was actually wrong, stated once
|
||||
|
||||
`run_tariff_refresh()`'s docstring:
|
||||
|
||||
> ⚠️ **AND A FAILURE DOES NOT CLEAR THE PLAN.** … Zeroing the plan here would
|
||||
> replace a coarse tariff with no tariff, which is the opposite of what §11.4
|
||||
> asks for.
|
||||
|
||||
The guarantee was real. The condition it was enforced on was not:
|
||||
|
||||
```python
|
||||
try:
|
||||
plan = await resolve_tariff(...)
|
||||
self.tariff_plan, self.tariff_at = plan, now # unconditional
|
||||
except Exception as err:
|
||||
_LOG.warning("tariff refresh failed (%s); keeping the plan ...")
|
||||
```
|
||||
|
||||
`load_prices()` says in its own docstring that it **"RETURNS, NEVER RAISES, for
|
||||
every failure a site can actually have"**, because a degradation arriving as a
|
||||
traceback into the planning tick would stop the controller regulating over an
|
||||
expired certificate. So a supplier outage — the case the docstring names first —
|
||||
is a **return**: the `try` succeeds, `_degrade()` hands back a §11.4 plan, and
|
||||
the good plan is overwritten. The `except` is reachable only by something
|
||||
nobody expected, and nothing in the shipped path produces it.
|
||||
|
||||
This is the fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's
|
||||
HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and now
|
||||
`except Exception` only, for a path that never raises. **A guarantee enforced on
|
||||
the condition someone thought of, rather than the condition that occurs.**
|
||||
|
||||
Reproduced before the fix, driving the shipped chain with nothing stubbed but
|
||||
the socket — 48 h horizon, `static_profile` empty, one good fetch then a
|
||||
`ClientConnectorError` 1800 s later, `TARIFF_MAX_AGE_S` = 5400 s:
|
||||
|
||||
```
|
||||
after a good fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
||||
after the failed fetch origin='none' n_points=0 'no tariff - generic_dayahead: ClientConnectorError: ...'
|
||||
tariff_at moved: True
|
||||
```
|
||||
|
||||
and after it:
|
||||
|
||||
```
|
||||
after the failed fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
||||
tariff_at moved: False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. The question is asked ONCE, after the attempt, from the evidence that exists
|
||||
|
||||
FCST-06's answer, one module over, and for the same reason: **the enumeration
|
||||
of failure conditions cannot be completed.** There is no pre-check that a feed
|
||||
will answer which is not itself a guess, and adding one would be a second
|
||||
symptom test beside the `except`. So the predicate is an **outcome**, read off
|
||||
the object the attempt produced:
|
||||
|
||||
```python
|
||||
standing = self.fresh_tariff_plan(now)
|
||||
keep = (not plan.from_feed
|
||||
and standing is not None and standing.from_feed)
|
||||
```
|
||||
|
||||
Two facts, both of which exist by the time the question is asked:
|
||||
|
||||
* **`plan.from_feed`** — did *this* resolution observe the configured provider?
|
||||
`TariffPlan.origin` already names the four ways a plan is obtained; this
|
||||
collapses them to the one question a caller holding a previous plan has to
|
||||
ask. It lives on `TariffPlan` rather than as an `origin == "provider"` at the
|
||||
call site for `fresh_tariff_plan()`'s own stated reason — *a check has to be
|
||||
able to assert on the shipped expression rather than on a transcription that
|
||||
drifts* — and because `origin`'s vocabulary belongs to `static_tariff.py`.
|
||||
* **`fresh_tariff_plan(now)`** — is a standing observation still current. It
|
||||
already existed, it is already the one place the age is judged, and it is
|
||||
**reused, not transcribed**. `test_arbitrage.py` 8b pins that reuse on the
|
||||
AST; a copy of the comparison behaves identically until the day the two
|
||||
drift, which is a claim about the code's shape, so a shape check is what
|
||||
grades it (FCST-06's precedent for the one source check among driven ones).
|
||||
|
||||
### What was NOT done
|
||||
|
||||
* **Not `except Exception` widened, or a second `if` beside it.** That is the
|
||||
defect's own shape — a place where the rule is spelt twice, so a fifth cause
|
||||
needs a fifth branch. The `except` stays, relabelled *"not the outage path"*,
|
||||
keeps the plan on the same terms, and is now honestly the
|
||||
something-nobody-expected branch.
|
||||
* **Not the persisted price store as a cache.** Out of scope by the product
|
||||
owner's call, and it is the right call: FCST-03 exists precisely to stop
|
||||
stale prices being acted on, and "recent" is not "current". Keeping the last
|
||||
**fetched** plan until it ages out is a different promise from serving the
|
||||
store, and that one needs its own ticket.
|
||||
* **Not a `no_data`-only guard.** `not plan.has_prices` is the tempting
|
||||
spelling and it is wrong — see decision 2. It leaves the §11.4 fallback free
|
||||
to replace a fresh observation, and it is a 1-byte edit from the shipped
|
||||
line, which is why it is in the mutation table rather than in a comment.
|
||||
|
||||
---
|
||||
|
||||
## 2. A §11.4 static fallback does NOT replace a fresh provider plan
|
||||
|
||||
The ticket asked this directly, because the fallback is *not* nothing: it is
|
||||
the operator's own configured profile, with real prices, for the requested
|
||||
window. The answer is still no, and the reason is not that it is worse data —
|
||||
it is that **nothing may be acted on it, and it is no more current.**
|
||||
|
||||
* Every point `_degrade()` builds carries `provisional=True`. So
|
||||
`firm_points()` is empty, `arbitrage()` finds fewer than two firm hours and
|
||||
refuses, and `cheap_now()` is False. Both of the two things `TariffPlan`
|
||||
exposes for a consumer to act on are already off.
|
||||
* A day-ahead price for a given hour **does not change once published**. A plan
|
||||
fetched 30 minutes ago is exactly as true about 19:00 as one fetched now; at
|
||||
most `TARIFF_MAX_AGE_S` of its horizon has elapsed. The observation is not
|
||||
stale in any sense the fallback is fresher than.
|
||||
|
||||
So replacing the observation with the fallback trades a plan with firm hours
|
||||
for a plan with none, and buys no currency with it. The fallback takes over
|
||||
when the observation **retires** — which is exactly the row §11.4 describes,
|
||||
arrived at when there is no longer an observation to prefer.
|
||||
|
||||
### And the rule is deliberately NOT symmetrical
|
||||
|
||||
`standing.from_feed` is a term in its own right. A standing plan that is itself
|
||||
a degradation **is** replaced by the next degradation, and its timestamp moves.
|
||||
Without that term:
|
||||
|
||||
* an outage on a static-profile site would freeze the operator's profile at the
|
||||
window it was first generated for, and the guard would be *"never overwrite"*
|
||||
wearing a bound's name;
|
||||
* and on a `price_provider: off` install — the shipped default — the plan would
|
||||
flip between kept and re-derived every `TARIFF_MAX_AGE_S`, for no behavioural
|
||||
gain, changing which reason the status page prints on a two-hour cycle.
|
||||
|
||||
Each of the three terms is the only false one in some check in 8b, which is
|
||||
MUTATION.md habit 17's test for whether a suite can tell an `AND` from an `OR`.
|
||||
|
||||
---
|
||||
|
||||
## 3. `tariff_at` does not move on a kept plan
|
||||
|
||||
The docstring's other half is that a plan must not *"freeze at its last value
|
||||
and look current forever"*. `tariff_at` is what makes that work: it is the
|
||||
moment of the fetch that produced the plan standing, and `fresh_tariff_plan()`
|
||||
judges the age from it.
|
||||
|
||||
Re-stamping it on a kept plan would be this task asserting the feed answered
|
||||
when it did not. The plan would be re-certified as current at every refresh, an
|
||||
outage of any length would never retire it, and `TARIFF_MAX_AGE_S` would be
|
||||
**exactly as dead as it was before this card** — reachable only by the refresh
|
||||
task itself dying. Leaving it alone is what makes the bound govern: the plan is
|
||||
served for the age bound after its own fetch, and no longer.
|
||||
|
||||
It is also the whole of **"distinguishable from a freshly-fetched one"**. A
|
||||
kept plan and a fetched plan are the same object shape; what separates them is
|
||||
that one's timestamp advanced and the other's did not, and every consumer that
|
||||
asks how old the plan is already reads that number. The kept case additionally
|
||||
logs at WARNING, naming the degradation's own reason, the fetch it is keeping,
|
||||
its age, and the deadline it retires at.
|
||||
|
||||
⚠️ **What this does not give the operator, and why it is not here.** The status
|
||||
page has no row for "the last refresh failed" — the banner/level not moving
|
||||
when the plan is lost is separately carded, and the ticket rules it out of
|
||||
scope. This card's change makes that surface *more* truthful, not less: during
|
||||
the first `TARIFF_MAX_AGE_S` of an outage there is now a real plan behind the
|
||||
Arbitrage row instead of a no-plan refusal, so the row says something true for
|
||||
longer. The no-plan state is entered later by up to 5400 s; it is not removed,
|
||||
and the 30-minute lag reported on that row is untouched by anything here.
|
||||
|
||||
---
|
||||
|
||||
## 4. Merged after TARIFF-10 — what the wider gate changed, and what it did not
|
||||
|
||||
TARIFF-10 landed on `release/1.0` while this card was open. It changed **which
|
||||
installs run this task** — `if arbitrage.enabled(opts)` became
|
||||
`tariff_refresh_wanted(opts)`, i.e. `provider_configured(opts) or
|
||||
arbitrage.enabled(opts)` — while this card changed **what the task does on a
|
||||
failed fetch**. Git auto-merged the two hunks, which is exactly when the
|
||||
interaction needs reading rather than trusting.
|
||||
|
||||
**Nothing in the decisions above changed.** Two configurations are newly
|
||||
reachable, and both are now driven in `test_arbitrage.py` 8b rather than
|
||||
reasoned about:
|
||||
|
||||
* **A provider configured with trading DISARMED** — TARIFF-10's headline case.
|
||||
This is precisely the configuration the guard was designed for: the plans are
|
||||
`origin == "provider"`, and a dead feed must not delete one that is still
|
||||
inside `TARIFF_MAX_AGE_S`. It needs no second fixture, because **nothing on
|
||||
this path reads `arbitrage_enabled` at all**, and that is asserted rather
|
||||
than assumed — over the AST's symbols and the non-docstring string constants,
|
||||
because two earlier drafts of that check were **red against a correct tree**:
|
||||
the first matched the docstring paragraph that *says* `arbitrage_enabled` is
|
||||
not read, the second matched the log line's "arbitrage, where it is armed".
|
||||
A substring test over a function grades its prose as much as its behaviour,
|
||||
which is habit 4 in a third costume. A fixture that set the option and
|
||||
asserted no difference would have been the other defect — comparing a thing
|
||||
against itself.
|
||||
* **`price_provider: off` + a `static_profile`** — the configuration TARIFF-10's
|
||||
`or` exists to protect, and the one where a latching guard would do real harm.
|
||||
`resolve_tariff()` returns `static_plan(provisional=False)`, whose origin is
|
||||
`"static"`: the CONFIGURED product, not a §11.4 row. So `standing.from_feed`
|
||||
is false on every refresh, `keep` is false on every refresh, and the plan is
|
||||
regenerated for the current window every 30 minutes exactly as before this
|
||||
card. **Driven, twice in a row**, because a guard that latched here would
|
||||
freeze a static site's plan at its first window for the life of the install —
|
||||
and mutation M3, which drops the `standing.from_feed` term, is red on that
|
||||
check as well as on the fallback one.
|
||||
|
||||
Two further facts settle the rest of the interaction:
|
||||
|
||||
* **This decision is strictly downstream of every side effect.** The §8.3 carry
|
||||
into `price_history` happens inside `resolve_tariff()`, on the `series`
|
||||
branch, before `keep` is computed — so which of two plans is kept can neither
|
||||
add nor lose a recorded day, and TARIFF-10's *Price history* row is
|
||||
unaffected either way.
|
||||
* **The kept-plan log line was reworded.** It said "arbitrage stops with it",
|
||||
which after TARIFF-10 is false on a site that fetches without trading; it now
|
||||
says "arbitrage, **where it is armed**, stops with it".
|
||||
|
||||
All eleven mutations were **re-driven on the merged tree** rather than carried
|
||||
over — the surrounding code changed, so a red against the old base is not
|
||||
evidence about the new one. Eleven red, no survivors, every revert
|
||||
fingerprinted. The added member is M11: making the guard conditional on
|
||||
`arbitrage.enabled(self.o)` (+86 B), red on five named checks.
|
||||
|
||||
⚠️ **And one process note that belongs in this file.** This card's mutation
|
||||
harness was written to a shared scratchpad path and was **overwritten mid-run
|
||||
by another agent's harness for a different card**, pointing at a different
|
||||
worktree. That is MUTATION.md habit 18's second bullet almost verbatim. It was
|
||||
caught because the harness fingerprints the tree after every revert and the
|
||||
runs are what proved this worktree was never left mutated; the harness now
|
||||
lives at a card-specific path. **A harness that edits source must not share a
|
||||
name, a directory or a backup path with another one.**
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
* **Nothing on the 30 s tick.** `cycle()` still reads two attributes and awaits
|
||||
no fetch; the AST pin in `test_arbitrage.py` section 8 is unchanged and green.
|
||||
* **Nothing about the prices themselves.** No series is invented, extended,
|
||||
re-stamped or carried. The only question this card answers is *which of two
|
||||
plans the controller holds*.
|
||||
* **A `price_provider: off` install is bit-for-bit unaffected.** Its standing
|
||||
plan is never `origin == "provider"`, so `keep` is false on every refresh it
|
||||
will ever run, and the assignment happens exactly as it did before.
|
||||
@@ -0,0 +1,225 @@
|
||||
# TARIFF-13 — the coverage of a peak, and which quarter gets the credit
|
||||
|
||||
Two decisions. §1 is the defect the ticket was raised on; §2 is the smaller
|
||||
question it asked to be settled deliberately rather than left as an accident.
|
||||
§3 is what this branch deliberately did **not** do, and why.
|
||||
|
||||
---
|
||||
|
||||
## 0. What was actually wrong, stated once
|
||||
|
||||
`main.py`'s capacity-tariff status row prints a peak read from SQLite and a
|
||||
coverage figure read from **process memory**:
|
||||
|
||||
```python
|
||||
_pk = self.peak.peak(when) # SQLite, durable
|
||||
...
|
||||
+ f"; {self.peak.recorded} quarter(s) banked" # an int on the tracker
|
||||
```
|
||||
|
||||
`PeakTracker.recorded` was `self.recorded = 0` in `__init__`, incremented once
|
||||
per block inside `sync()`. A 40-hour soak caught what that means at a driven
|
||||
restart, on both instances independently:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
```
|
||||
|
||||
Reproduced here before anything was changed, through the real `Controller`, the
|
||||
real `P1Ingest`, and a **real restart** — a second `Controller` object against
|
||||
the same file on disk, never a re-read of the first:
|
||||
|
||||
```
|
||||
before: 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 12 quarter(s) banked
|
||||
after : 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
|
||||
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
|
||||
peak survives the restart: True
|
||||
count survives the restart: False (12 -> 0, store held 12 rows)
|
||||
```
|
||||
|
||||
**TARIFF-01's argument, one column over.** That module's whole design is that
|
||||
an unreadable store must not read as a zero peak, *because a zero peak is
|
||||
disarming*. A coverage figure of zero is disarming in exactly the same way and
|
||||
about the same number: the count is what says how much of the billing month the
|
||||
watt figure actually covers, and 365 W over 120 quarters and 365 W over 0
|
||||
quarters are different claims about the same 365 W. After a restart the page
|
||||
made the weaker claim while displaying the stronger one's value. It did not
|
||||
under-report the peak — it under-reported **its own evidence**.
|
||||
|
||||
Two further defects fell out of the same variable once it was driven, neither
|
||||
of them in the ticket:
|
||||
|
||||
* **it was never scoped to a month.** It counted every block the *process* had
|
||||
banked, so on 1 October the row read September's tally beside October's "no
|
||||
completed quarter recorded yet". Measured, on the shipped tree:
|
||||
`2026-10: no completed quarter recorded yet - 0 % of the 3500 W soft limit;
|
||||
1 quarter(s) banked`.
|
||||
* **it missed the block `restore()` banks.** A stale block closed from the
|
||||
checkpoint goes into `quarter_blocks` without passing through `sync()`, so
|
||||
the counter never saw it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decision: the count is READ FROM THE TIER THE PEAK IS READ FROM
|
||||
|
||||
The acceptance criterion allowed either route — make the count durable, or stop
|
||||
printing a number that silently resets. **Durable**, because the count is the
|
||||
useful half of the row and removing it would have cost the operator the one
|
||||
thing that says whether the peak means anything yet.
|
||||
|
||||
`PeakStore.banked_count(month)` asks the database `SELECT COUNT(*) FROM
|
||||
quarter_blocks WHERE month=?`, and `PeakTracker.recorded` is a property over it
|
||||
rather than an attribute. Three consequences, and all three are the defects
|
||||
above:
|
||||
|
||||
* a restart re-reads the same rows, so the figure survives it;
|
||||
* the count is per **month**, so it agrees with the peak it is printed beside;
|
||||
* a block banked by `restore()` is a row like any other and is counted.
|
||||
|
||||
Three things this route deliberately does **not** do:
|
||||
|
||||
* **it does not count `len(blocks_in(month))`.** `blocks_in()` returns `[]` on a
|
||||
read error — safe there only because no caller reads its emptiness as a peak,
|
||||
which its own docstring says — so taking its length would rebuild the
|
||||
disarming zero one call further out. `banked_count()` returns **`None`** when
|
||||
the database will not answer.
|
||||
* **it does not count `monthly_peak`.** That tier holds one row per *month* and
|
||||
would answer `1` for every month ever measured — wrong by exactly the amount
|
||||
that makes it look plausible. The two tables are proven equal on the **peak**
|
||||
by `recompute_month()`; they were never equal on the **count**.
|
||||
* **it does not remove the dedupe.** `_seen` is still what stops `cycle()`'s
|
||||
once-a-second `sync()` re-banking a block that `P1Ingest.blocks` keeps for a
|
||||
day, and `sync()`'s return value is still the number newly banked. What moved
|
||||
is only where the *status page* reads its figure. ⚠️ The check that graded the
|
||||
dedupe used to assert `recorded == 1` — the dedupe and the figure reporting it
|
||||
were the same variable. Counting rows in `quarter_blocks` cannot see a
|
||||
re-bank at all (it is a PRIMARY KEY no-op), so that check is now graded on
|
||||
`sync()`'s **return**, with the store's tally as the second half.
|
||||
|
||||
### ⚠️ The unreadable case renders a WORD, not a number
|
||||
|
||||
`banked_count()` returns `int | None`; `recorded_in()` renders the `None` as
|
||||
the module constant `COUNT_UNREADABLE = "an unreadable number of"`, so the
|
||||
sentence reads
|
||||
|
||||
```
|
||||
... ; an unreadable number of quarter(s) banked
|
||||
```
|
||||
|
||||
`0` was never available. `None` was rejected too: `f"{None} quarter(s) banked"`
|
||||
renders **"None quarter(s) banked"**, which an operator reads as the same zero.
|
||||
This is the `PeakReading` trade — no `peak_w_or(default)`, ever — applied to the
|
||||
coverage figure: a caller that formats it gets a true sentence in both states,
|
||||
and a caller that does arithmetic on it gets a `TypeError`, which is loud.
|
||||
|
||||
"0 quarter(s) banked" remains a **true and reachable** sentence: it is what a
|
||||
month nothing has been written to says, and part 11 asserts that the two states
|
||||
are spelled differently.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision: at EQUAL wattage, the EARLIEST quarter keeps the attribution
|
||||
|
||||
**First, not most recent.** The reasoning, in the order it matters:
|
||||
|
||||
1. **It is not a money question.** The utility bills the wattage; the timestamp
|
||||
is not part of the bill. So the decision is settled entirely on what the
|
||||
status row is *for*.
|
||||
2. **The row answers "when did my monthly peak happen".** An operator reading it
|
||||
goes and looks at one event — what was running, what the weather was. A
|
||||
stable answer is the one that can be investigated; "most recent wins"
|
||||
guarantees the answer moves every time the wattage recurs, and an operator
|
||||
who looked yesterday is sent somewhere else today for no reason.
|
||||
3. **It is what one reader already did unconditionally.** `recompute_month()`
|
||||
is `ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1` — earliest wins.
|
||||
Choosing "most recent" would have meant changing both readers; choosing
|
||||
"earliest" meant making the other one say out loud what it was already doing
|
||||
by accident.
|
||||
|
||||
### ⚠️ And the two readers DID diverge, which is why this is a code change
|
||||
|
||||
The `monthly_peak` upsert was `WHERE excluded.peak_w > monthly_peak.peak_w` —
|
||||
strictly greater, so an exact tie never wrote and the row kept whichever tied
|
||||
block was **written first**. That coincides with *earliest* only while blocks
|
||||
arrive in time order. Fed four equal 365 W blocks newest-first, measured on the
|
||||
shipped tree:
|
||||
|
||||
```
|
||||
ascending arrival monthly_peak -> 09:00:00 recompute_month -> 09:00:00 agree=True
|
||||
DESCENDING arrival monthly_peak -> 09:45:00 recompute_month -> 09:00:00 agree=False
|
||||
```
|
||||
|
||||
That is a divergence in the derived record that part 8 of `test_tariff.py`
|
||||
exists to prove cannot happen — latent, because nothing in the shipped path
|
||||
banks out of order today. The upsert now carries the tie term explicitly:
|
||||
|
||||
```sql
|
||||
WHERE excluded.peak_w > monthly_peak.peak_w
|
||||
OR (excluded.peak_w = monthly_peak.peak_w
|
||||
AND excluded.quarter_start < monthly_peak.quarter_start)
|
||||
```
|
||||
|
||||
**`peak_w` is untouched.** The `>` term is exactly as it was, no tie can move
|
||||
the billed number, and SAFETY-07's guard reads the same watt figure it read
|
||||
before. The second term moves only the timestamp.
|
||||
|
||||
### ⚠️ AND THE SOAK'S DRIFT WAS NOT A TIE AT ALL
|
||||
|
||||
The ticket's reading — "ties at the same wattage are re-attributed to the most
|
||||
recent matching quarter" — is **false**, and driving it is what showed that.
|
||||
Six exactly-equal 365.0 W quarters on the shipped tree moved the attribution
|
||||
**zero** times, because `>` is strict.
|
||||
|
||||
What the soak saw was a genuine new maximum **smaller than the row's own
|
||||
resolution**. The row renders `{peak_w:.0f}` — whole watts — so 365.0000001 W is
|
||||
a new peak to the database and the same `365 W` to the reader:
|
||||
|
||||
```
|
||||
q0 fed 365.0 -> renders 365 W, attributed 09:00:00
|
||||
q1 fed 365.0000001 -> renders 365 W, attributed 09:15:00
|
||||
q2 fed 365.0000002 -> renders 365 W, attributed 09:30:00
|
||||
```
|
||||
|
||||
**Decision: left alone, deliberately.** It is the instrument behaving correctly
|
||||
— the peak really did rise — and the alternative, printing more decimal places,
|
||||
puts noise in the one row an operator reads at the moment the guard engages, to
|
||||
explain a movement that is real. Both cases are pinned in part 12 so that
|
||||
neither can be changed by accident, and the distinction is recorded here so the
|
||||
next reader does not re-diagnose it as a tie.
|
||||
|
||||
---
|
||||
|
||||
## 3. What this branch did NOT do, and who owns it
|
||||
|
||||
**`main.py`'s status row was not touched.** TARIFF-11 holds `main.py`'s status
|
||||
rows on a concurrent branch, and the standing instruction is to stop rather than
|
||||
race there. Everything above is in `app/tariff.py`, and the row picks the fix up
|
||||
without editing, because `self.peak.recorded` is an attribute access and is now
|
||||
a property.
|
||||
|
||||
**The residual, stated with its scope.** `recorded` answers for
|
||||
`datetime.now()`. The peak beside it follows `checks(when=...)`. The shipped
|
||||
install has **exactly one** call site — `self.checks()` with no argument, at
|
||||
`main.py:4359` — where the two are the same month, so **the live page is correct
|
||||
and durable**. They part company only under the `when=` affordance TEST-17 added
|
||||
for the suite, and the mismatch there is strictly smaller than the one it
|
||||
replaces (this month's tally, rather than a process-wide total across all
|
||||
months).
|
||||
|
||||
`PeakTracker.recorded_in(when)` is the scoped form and is shipped. Moving the
|
||||
row onto it is:
|
||||
|
||||
```python
|
||||
- + f"; {self.peak.recorded} quarter(s) banked"
|
||||
+ + f"; {self.peak.recorded_in(when)} quarter(s) banked"
|
||||
```
|
||||
|
||||
one token, on one line, inside the block TARIFF-11 owns. `test_tariff.py`
|
||||
part 10 **pins the present behaviour** — graded against a row count taken
|
||||
straight from `quarter_blocks`, not against the property that produces it — so
|
||||
the gap goes red if it changes and cannot be quietly forgotten.
|
||||
@@ -0,0 +1,349 @@
|
||||
# TARIFF-14 — how long one price point covers, and what the last one does
|
||||
|
||||
One defect and one genuinely open decision. The defect had no defensible
|
||||
alternative once it was measured; the decision — the **last** point's window —
|
||||
did, and the alternative is argued below rather than dismissed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The measurement this card exists for
|
||||
|
||||
`_Series.at()` asked `s <= u < s + 1 h` of every point **in list order** and
|
||||
returned the **first** match. Run against the shipped code at `5059e28` on a
|
||||
quarter-hourly series:
|
||||
|
||||
```
|
||||
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
|
||||
|
||||
ask + 0min -> 02:00 0.10
|
||||
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
|
||||
ask +30min -> 02:00 0.10 <-- WRONG
|
||||
ask +45min -> 02:00 0.10 <-- WRONG
|
||||
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
|
||||
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
|
||||
```
|
||||
|
||||
**Two failure modes, not one.** Inside the first hour the `:00` point wins
|
||||
because its hour has not run out. From the second hour on the answer is not
|
||||
even the top of the hour — the first point still inside its own hour wins — so
|
||||
**the error compounds with depth into the series**.
|
||||
|
||||
Same code, same run, after the fix:
|
||||
|
||||
```
|
||||
ask + 0min -> 02:00 0.10 ask +60min -> 03:00 0.14
|
||||
ask +15min -> 02:15 0.11 ask +75min -> 03:15 0.15
|
||||
ask +30min -> 02:30 0.12 ask +90min -> 03:30 0.16
|
||||
ask +45min -> 02:45 0.13 ask+120min -> 04:00 0.18
|
||||
```
|
||||
|
||||
An hourly series is **unchanged at every instant**, before and after — which is
|
||||
the whole reason nothing caught this. Every price fixture in this repository
|
||||
before TARIFF-08 was the invented hourly row shape, `static_tariff.py` emits
|
||||
hourly points, and the DST checks count points on the 23- and 25-hour days
|
||||
without ever asking `at()` for a quarter-hour. **The suite had been exercising
|
||||
the one resolution on which the broken rule and the right one agree.**
|
||||
|
||||
Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
|
||||
returns **96 points at 900 s**, so this is reachable only down the dynamic path
|
||||
TARIFF-08/09 just made work. Static sites are immune.
|
||||
|
||||
## 0.1 What it cost, which is not "a slightly wrong price"
|
||||
|
||||
`arbitrage._act()` calls `_point_at(plan, now)` → `series.at(now)` and then
|
||||
tests `here.start == v.cheapest.start` / `== v.dearest.start` to decide whether
|
||||
this interval is the buy one or the sell one. That is an **identity test
|
||||
between the matched point and the point the verdict was built from**, and on a
|
||||
quarter-hourly series it almost never matched — so the strategy idled through
|
||||
exactly the interval it exists to trade, reporting *"this hour is neither the
|
||||
buy hour nor the sell hour"*. It also read `here.provisional` (§8.3) and §17's
|
||||
`price < 0` off that same wrong point.
|
||||
|
||||
---
|
||||
|
||||
## 1. The rule
|
||||
|
||||
> **A point covers from its own start until the next point's start, and never
|
||||
> more than one hour.**
|
||||
|
||||
* **900 s** → each point covers its own quarter.
|
||||
* **3600 s** → each point covers its own hour. Identical to what shipped.
|
||||
* **MIXED** → **each point takes its own gap.** FCST-04's carry-forward emits
|
||||
hourly carried points beside quarter-hourly firm ones, and the soak measured
|
||||
spacing `[900, 3600]` in **70 % of samples** — so a window derived from one
|
||||
global spacing would be wrong in the **normal steady state**, not in an edge
|
||||
case.
|
||||
|
||||
### 1.1 Why the one-hour cap is not decoration
|
||||
|
||||
FCST-04 **leaves holes on purpose**: an hour with no published price and no
|
||||
yesterday value is left *out* of the series, and `price_forecast.py`'s module
|
||||
note names `at()` raising `Absence` for it as the behaviour that sends the
|
||||
caller to the static profile (§11.4 for that hour).
|
||||
|
||||
Uncapped, "until the next point's start" stretches the point **before** a
|
||||
three-hour hole across the whole hole and answers every instant in it with a
|
||||
stale price. Filling holes is the defect this module exists to refuse, and it
|
||||
would have been introduced **by the fix for a different one**. Graded in
|
||||
`test_price.py` 14d; the mutation that removes the cap is red there.
|
||||
|
||||
---
|
||||
|
||||
## 2. The decision: what the **last** point covers
|
||||
|
||||
There is no next start, and §9.3 says nothing about how wide the final interval
|
||||
is — a `PricePoint` has **three fields** and must not learn a fourth, because
|
||||
that is the wire format every store, replay, fixture and captured body in this
|
||||
repository is written against. So the tail is a **choice**, and both candidates
|
||||
are defensible.
|
||||
|
||||
**Chosen: the last point is given the same span as the point before it** (one
|
||||
hour when it is the only point).
|
||||
|
||||
| | flat one hour | the span in front of it (**chosen**) |
|
||||
|---|---|---|
|
||||
| 96-point 900 s day, `at(23:50)` | answers 23:45 | answers 23:45 |
|
||||
| the same day, `at(00:30)` **tomorrow** | **answers with today's 23:45 price** | `Absence` |
|
||||
| 24-point hourly day, `at(23:50)` | answers 23:00 | answers 23:00 |
|
||||
| mixed series ending at the firm/carried seam | answers for an hour | `Absence` after 15 min |
|
||||
|
||||
**The second row is why.** A flat one-hour tail reaches across a day boundary
|
||||
and prices tomorrow morning with yesterday's last print — which is
|
||||
"reaching for a neighbouring one", the single thing the `Absence` message has
|
||||
always refused to do. The chosen rule never extends a point past a span the
|
||||
series itself demonstrates.
|
||||
|
||||
### 2.1 The cost of the choice, stated rather than buried
|
||||
|
||||
On a **mixed** series whose last point is a carried **hourly** one sitting
|
||||
behind quarter-hourly firm ones, the tail is **900 s**, and the last 45 minutes
|
||||
of the horizon raise `Absence` for an hour something did carry a value for.
|
||||
|
||||
That is accepted, not tolerated, and the direction is the safe one:
|
||||
|
||||
* the caller falls back to the static profile (§11.4) instead of trading on a
|
||||
span the series never demonstrated;
|
||||
* the point is **carried**, so §8.3 forbids any irreversible action resting on
|
||||
it regardless;
|
||||
* it is at the far end of a 48 h horizon, where nothing is decided at 30 s.
|
||||
|
||||
Under-covering costs a missed decision. Over-covering prices a real interval
|
||||
with a number that does not describe it. Those are not symmetric.
|
||||
|
||||
### 2.2 The degenerate case
|
||||
|
||||
A series of **one** point has no gap in front of it to copy and gets **one
|
||||
hour** — the shipped behaviour, kept deliberately. `test_price.py` 14e states
|
||||
it as its own check so it cannot decay into an accident.
|
||||
|
||||
---
|
||||
|
||||
## 3. Does `at()` now depend on sortedness? Yes — and it establishes it itself
|
||||
|
||||
The old rule read one point at a time and did not care about order. The new one
|
||||
reads a point's **neighbours**, so it *would* depend on the series being
|
||||
ascending.
|
||||
|
||||
It does not, because **the lookup index is sorted by UTC start at construction**
|
||||
(`_cover_table()`, called once from `_Series.__init__`). No caller's list order
|
||||
can reach `at()`.
|
||||
|
||||
That matters because the three things that "guarantee" order today each
|
||||
guarantee it somewhere else:
|
||||
|
||||
| | what it guarantees | where |
|
||||
|---|---|---|
|
||||
| `_validate_raw()` | strictly increasing starts | the `load_prices()` path only |
|
||||
| `price_history._parse_points()` | re-sorts by instant | the store read path only |
|
||||
| FCST-04's fill | emits ascending | that one producer only |
|
||||
| `PriceRead.series()` | **nothing** | every other caller, including tests |
|
||||
|
||||
Three producers agreeing today is not an invariant; it is a coincidence with
|
||||
good references. `test_price.py` 14f drives a series handed its 96 points in
|
||||
**reverse** order and asserts `at()` answers identically at every one of them —
|
||||
red the moment the sort is removed.
|
||||
|
||||
**Duplicated starts** (refused by `_validate_raw()`, constructible by hand) give
|
||||
the earlier list entry a zero-width window and the later one the real span. One
|
||||
of the pair becomes unreachable through `at()`; **nothing raises**, at
|
||||
construction or on the 30 s control path. Stated as a check rather than left to
|
||||
be discovered.
|
||||
|
||||
---
|
||||
|
||||
## 4. `covers()` shared it. `window()` did not
|
||||
|
||||
* **`covers()`** has no window rule of its own — it calls `at()` — so it shared
|
||||
the defect exactly and the same change fixes it. It is kept as a delegation
|
||||
for that reason: a second copy of the rule is a second place for it to be
|
||||
wrong.
|
||||
* **`window(since, until)`** grades a point's **start** against a half-open
|
||||
range and never asks how long a point lasts, so it carries no resolution
|
||||
assumption at all. It is **not** a coverage query — a point that starts before
|
||||
`since` and runs into it is absent from its result by design. That is now
|
||||
said in its docstring and pinned by a check, because the next reader will ask.
|
||||
It has **no callers** in `app/`.
|
||||
|
||||
---
|
||||
|
||||
## 5. What this card does NOT fix, with its shape
|
||||
|
||||
`price_forecast._walk()` — §8.3's irreversible-action gate — iterates
|
||||
`committed_hours()`, which steps in **whole hours**, and asks `series.at(u)` at
|
||||
each. On a quarter-hourly series that now reliably returns the hour's `:00`
|
||||
point (it previously returned something earlier and arbitrary), so the gate
|
||||
reads **one point per hour** and never looks at the `:15`/`:30`/`:45` flags.
|
||||
|
||||
A partially-provisional hour would therefore be gated on its first quarter
|
||||
alone. **Two things keep it off every shipped install, and both are
|
||||
circumstantial rather than structural:** `price_permits()`/`begin_phase()` have
|
||||
**no caller in `app/main.py`** — the §12 price-justified entry point is built
|
||||
and not yet wired — and FCST-04 carries **whole** hours while
|
||||
`generic_dayahead` publishes whole days, so the four quarters of an hour are
|
||||
firm or carried together anyway. Both are facts about the *callers and
|
||||
producers*, not properties of the *gate*, so this is written down here rather
|
||||
than left for the first sub-hourly feed or the first wiring-up to find. Fixing it means teaching
|
||||
`committed_hours()` about resolution, which is a data-model question this card
|
||||
deliberately does not open.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mutations
|
||||
|
||||
Eight, each purged before the run (TEST-15), each reverted with the tree
|
||||
fingerprint checked afterwards (MUTATION.md habit 18), backup at a `mktemp`
|
||||
path per invocation.
|
||||
|
||||
⚠️ **Re-driven in full on the MERGED tree** after TARIFF-10 landed on
|
||||
`release/1.0` (base `88c59a9` -> `559f0eb`). All eight give the same verdicts
|
||||
and the same red-check counts as before the merge, which is the answer that
|
||||
matters: TARIFF-10 added 650 lines of checks to `test_price.py` and none of
|
||||
them moves under any of these edits, because they grade `resolve_tariff()`'s
|
||||
*reachability from a configuration* and not the lookup. Clean `app/price.py` on
|
||||
the merged tree = **`90d8ca31f7c0109d`, 85953 B** (pre-merge it was
|
||||
`30d1a56f0117e571`, 83742 B; the difference is TARIFF-10's `provider_id()` /
|
||||
`provider_configured()`, which this card does not touch).
|
||||
|
||||
| | mutation | Δ | red in |
|
||||
|---|---|---|---|
|
||||
| M1 | `at()` back to the shipped fixed one-hour scan over `self.points` | +68 B | **24 named checks**: `test_price.py` 14b ×4, 14c ×3, 14d, 14e ×3, 14f, 14g; `test_arbitrage.py` 10b ×6, 10c ×3, 10d ×2 |
|
||||
| M2 | drop the one-hour cap | −68 B | `test_price.py` 14d |
|
||||
| M3 | flat one-hour tail instead of the span in front | **+0 B** | `test_price.py` 14e ×3, 14g — 4 checks |
|
||||
| M4 | trust the caller's list order — drop the sort | −61 B | `test_price.py` 14f |
|
||||
| M5 | `covers()` grows its own hour-wide rule | +95 B | `test_price.py` 14e, 14g |
|
||||
| M6 | `window()` becomes a coverage query | −37 B | `test_price.py` 14g |
|
||||
| M7 | the window's end becomes inclusive | +1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14e ×4, 14f ×2, 14g — 16 checks |
|
||||
| M8 | the window's start becomes exclusive | −1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14f ×2, 14g, 14h, plus the pre-existing section 6 check — 14 checks |
|
||||
|
||||
**M3 is the zero-byte one and it is the tail decision itself** — the case
|
||||
MUTATION.md names as the risky size, caught by the check written for it.
|
||||
|
||||
**What the arbitrage file does NOT see, with sizes.** M2, M4, M5, M6 and M8
|
||||
leave `test_arbitrage.py` green, and that is honest rather than a gap to round
|
||||
off: they are `at()`-level properties (holes, caller order, `covers()`,
|
||||
`window()`, the start boundary) that no arbitrage fixture reaches, because the
|
||||
probes are seven minutes into a slot and the plans have no holes. The one that
|
||||
matters end to end — M1, the shipped rule — is red in **both** files, on the
|
||||
named check `THE PROPERTY THE DEFECT BROKE`.
|
||||
|
||||
---
|
||||
|
||||
## 7. TARIFF-10 landed on the base while this was open — what it changes here
|
||||
|
||||
Base moved `88c59a9` → `559f0eb` mid-card. The question is not whether the
|
||||
branches merge; it is whether TARIFF-10 makes any sentence above **false**.
|
||||
|
||||
**In `app/price.py` the two do not meet.** TARIFF-10 added `provider_id()` and
|
||||
`provider_configured()` beside `price_option()` and routed `load_prices()`
|
||||
through them. That is the option-reading layer; it touches nothing in
|
||||
`_Series`, `at()`, `covers()`, `window()` or `_cover_table()`. Git merged the
|
||||
file without a conflict, and the merged `at()` is byte-identical to the one the
|
||||
mutations above were driven against.
|
||||
|
||||
**Behaviourally it widens who has a series at all, and that cuts one way only.**
|
||||
`run_tariff_refresh()` now runs on installs with `arbitrage_enabled` **off**, so
|
||||
a provider-configured site has a standing plan — and therefore a `_Series` —
|
||||
where before it had none. **It does not add a new `at()` consumer to the 30 s
|
||||
tick**, which is the claim worth checking rather than assuming:
|
||||
|
||||
| caller of `at()`/`covers()` | reached by a non-trading install? |
|
||||
|---|---|
|
||||
| `arbitrage._point_at()` (`arbitrage.py:300`) | no — trading only |
|
||||
| `static_tariff.TariffPlan.price_at()` / `.words` `here` (`:750`, `:819`) | on the tick these are reached through `static_status()`, which builds a **`static_plan`** — hourly points, immune. `main.py` calls only `plan.words` on the fetched plan, which counts points and never looks one up |
|
||||
| `price_forecast._walk()` (`:562`, `:568`) | **`price_permits()` and `begin_phase()` have no caller in `app/main.py` at all** — §12's price-justified entry point is built and not yet wired |
|
||||
|
||||
So TARIFF-10 neither creates nor closes any exposure this card is about. What it
|
||||
does do is make the defect's reach larger the moment such a site arms trading,
|
||||
because the series it would trade against now exists on every configured
|
||||
install rather than only on armed ones. That strengthens the case for the fix
|
||||
and changes nothing in its reasoning.
|
||||
|
||||
**In `test_price.py` both cards appended a section 13.** Nothing is dropped:
|
||||
TARIFF-10's stays **13**, this card's is renumbered **14** (which is also this
|
||||
card's number), and the temp-directory teardown TARIFF-10 put at the end of the
|
||||
file stays at the end of the file. The two sections share no fixture, no option
|
||||
dict and no helper name; TARIFF-10's grades `resolve_tariff()`'s *reachability
|
||||
from a configuration* and drives real fetches with a counting transport, and
|
||||
**none of its checks moves under any of the eight mutations above** — measured,
|
||||
not assumed.
|
||||
|
||||
---
|
||||
|
||||
## 8. TARIFF-12 landed too — a plan may now be 90 minutes old when it is asked
|
||||
|
||||
Base moved `559f0eb` → `a6e64fb` while this was open. TARIFF-12 stops a
|
||||
returned §11.4 degradation from overwriting a standing plan that is still
|
||||
inside `TARIFF_MAX_AGE_S`, so **a served plan can now be up to
|
||||
`3 × TARIFF_REFRESH_S = 5400 s` old** where before it was at most one refresh
|
||||
interval. `at()` is what answers *"what does it cost right now"* out of that
|
||||
older plan, so the question is fair and the answer is written out rather than
|
||||
assumed.
|
||||
|
||||
**Per-point span: unaffected, structurally.** Every window is derived from the
|
||||
neighbouring points' **absolute `start` instants**. Those are `datetime`s inside
|
||||
a frozen dataclass inside a tuple — they cannot move, and `_cover_table()` runs
|
||||
once at construction over data that cannot change afterwards. A plan's *age* is
|
||||
a property of `fetched_at` / `tariff_at`, never of a window. **The index cannot
|
||||
go stale**, which is a small positive under TARIFF-12 rather than a risk: a kept
|
||||
plan now lives longer, and there is nothing in it that needed recomputing.
|
||||
|
||||
**The one-hour cap: unaffected.** It bites only where a gap exceeds an hour,
|
||||
i.e. at a hole FCST-04 left. Walking further into a series crosses more windows;
|
||||
each is still resolved on its own neighbours.
|
||||
|
||||
**The tail: reachable for the first time, and the choice made in §2 is the one
|
||||
that survives it.** This is the half worth spelling out.
|
||||
|
||||
* At the **shipped 48 h horizon** the tail is unreachable by staleness. A plan
|
||||
is served for at most 90 minutes, so `now` is at most 90 minutes into a
|
||||
~48 h series — the last point is still about 46½ hours away.
|
||||
* But `price_horizon_h` is `int(1,72)` in `config.yaml`, so an operator **can**
|
||||
configure a **1 h** horizon. A 90-minute serve-stale window against a 1 h
|
||||
series puts `now` genuinely past the end. That is not hypothetical; it is a
|
||||
supported configuration meeting a supported outage.
|
||||
* Under the **chosen** rule a quarter-hourly 1 h series ends at `:45` and that
|
||||
point's window closes at the top of the next hour: `at()` raises `Absence`,
|
||||
`_point_at()` returns None, `_act()` idles with its "no price for this
|
||||
instant" reason, and §11.4 sends the site to the static profile.
|
||||
* Under the **rejected** flat one-hour tail the same stale plan would have gone
|
||||
on trading against the `:45` print for **up to 45 minutes after the interval
|
||||
it describes had ended** — pricing an outage with a number for a quarter that
|
||||
is over.
|
||||
|
||||
So TARIFF-12 does not weaken the tail decision. It is the first thing that makes
|
||||
it reachable on a shipped configuration, and it independently selects the same
|
||||
answer. ⚠️ **Note which direction that runs**: the more stale a plan is allowed
|
||||
to be, the more the tail rule is doing real work, so it should be re-read if
|
||||
`TARIFF_MAX_AGE_S` is ever widened again.
|
||||
|
||||
**`TariffPlan.from_feed` cannot produce a series this code has not seen.** It is
|
||||
a pure predicate on `origin` (`== "provider"`), and TARIFF-12's decision keeps or
|
||||
replaces a **whole plan object**. It never splices two reads into one series, so
|
||||
it cannot hand `at()` an unsorted or duplicated point list — the case §3 covers
|
||||
defensively and that nothing shipped produces.
|
||||
|
||||
**Re-graded on this base.** All 29 files green. `app/price.py` is byte-identical
|
||||
to the TARIFF-10 merge (`90d8ca31f7c0109d`, 85953 B) because TARIFF-12 does not
|
||||
touch it. **M1** re-driven: +68 B, red on the same **24 named checks** across
|
||||
both files. **M3** re-driven: **+0 B**, red on the same 4. This branch
|
||||
contributes **zero diff** to `app/main.py` and `app/static_tariff.py`.
|
||||
@@ -0,0 +1,158 @@
|
||||
# TARIFF-15 — the operator's own tariff card, and why the card has to grade it
|
||||
|
||||
**Status: DECIDED 2026-09-13 — ship `custom_formula` with FIXED coefficients and
|
||||
a REQUIRED `card` block whose printed price the add-on reproduces at startup or
|
||||
refuses to run. No expression evaluation, ever. Declare `{ENERGY, MARKUP}`,
|
||||
never `ALL_IN`.**
|
||||
|
||||
Filed with TARIFF-15. Follows [[TARIFF-07]], which withdrew three per-supplier
|
||||
plugins and named a formula provider as the answer it could not give.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a formula rather than more plugins
|
||||
|
||||
TARIFF-07 established the negative half: a per-supplier plugin has no source
|
||||
for its numbers. This card establishes the positive one.
|
||||
|
||||
* **There is no machine-readable source of Belgian tariff coefficients
|
||||
anywhere.** CREG's consumer tariff-card pages 404. CREG Scan is a
|
||||
JavaScript-only wizard. The annual report carries the supplier list and no
|
||||
per-product coefficients. VREG was unreachable. Every coefficient found in
|
||||
research came from a supplier's **own PDF**, one of them image-only.
|
||||
* So there is nothing to scrape, and fourteen plugins would still have no
|
||||
source for their numbers — they would be fourteen copies of the invention
|
||||
TARIFF-07 deleted three of.
|
||||
* **The customer is the only party who can see their own coefficients**, and
|
||||
the card they read them from **is the only artefact that can grade them**.
|
||||
That second clause is the whole design; everything below follows from it.
|
||||
|
||||
## 2. The shape is the market's published arithmetic
|
||||
|
||||
Both suppliers whose 2026 cards were readable print three things on one line:
|
||||
the formula, the reference index it assumes, **and the resulting price**.
|
||||
|
||||
| | computed | the card prints |
|
||||
|---|---|---|
|
||||
| Eneco `(0.102 × 133.9905 + 1) × 1.06` | **15.5471** | **15,55** |
|
||||
| ENGIE `(0.1 × 61.96 + 0.9) × 1.06` | **7.5218** | **7,522** |
|
||||
|
||||
Two independent suppliers, reproduced to the last printed digit, through the
|
||||
shipped code (`test_price.py` 15a runs both, and then runs both again end to
|
||||
end through `load_prices()`).
|
||||
|
||||
That third column is not a nicety. It is the only externally-published number
|
||||
in this whole feature, and it is what makes the design safe.
|
||||
|
||||
## 3. The double-counting risk, and which half of it is new
|
||||
|
||||
`price.py`'s trap 1 already mechanises that a **plugin** cannot double-count:
|
||||
`PluginOptions` refuses the framework keys, `code_leaks()` refuses a plugin
|
||||
whose *code* mentions them, `assemble()` adds the complement exactly once.
|
||||
|
||||
`custom_formula` introduces a risk none of those can see, **because it is not
|
||||
in code**: the **operator** double-counting, by folding their network charges
|
||||
into `offset` while `price_taxes_eur_kwh` also carries them. `code_leaks()`
|
||||
scans code. This is config, typed by a person reading a PDF.
|
||||
|
||||
It is a real reading of a real card, not a hypothetical. A Belgian card's
|
||||
`Tariefformule` line is the **energy component alone** — Eneco tabulates
|
||||
network tariffs in one table and levies in a second; ENGIE states in so many
|
||||
words that the formula excludes network, distribution, transport, grid levies
|
||||
and excise. An operator who wants "the price I actually pay" and adds ~12
|
||||
c€/kWh to `offset` has done something entirely reasonable and is now billed for
|
||||
network charges twice, on every hour, with §11.4 making the whole class of
|
||||
price defect invisible.
|
||||
|
||||
**Decision: the card grades it.** At startup `validate.py` recomputes the
|
||||
card's own printed price from the operator's coefficients and refuses to start
|
||||
unless the two agree to within `0.01` — which is the last digit a card prints.
|
||||
An inflated `offset` cannot pass, because the printed estimate is by
|
||||
construction the energy component: 15.55 becomes 28.27 and the add-on stops and
|
||||
says why.
|
||||
|
||||
**And the limit, stated in the same sentence as the claim**, exactly as
|
||||
`price.py` states trap 1's: *it grades the coefficients against the card, not
|
||||
the card against reality.* A consistent-but-wrong pair — both numbers off the
|
||||
wrong product, or off a card the contract has since rolled over from — agrees
|
||||
with itself perfectly and passes. Nothing here or anywhere else can catch that.
|
||||
|
||||
### 3b. The one unit error the card check cannot see
|
||||
|
||||
A factor applied to **both** sides — coefficients *and* printed price — cancels
|
||||
out of a comparison between them. That is a second quantity, so it is a second
|
||||
refusal: the card's own point must land inside 0.02–1.00 EUR/kWh.
|
||||
|
||||
⚠️ **Its floor is applied to the ENERGY figure and not to the all-in one, and
|
||||
that asymmetry was found by a mutation rather than by reasoning.** The shipped
|
||||
`price_taxes_eur_kwh` is 0.06 — three times the floor — so a card read a
|
||||
hundred times too small comes out at 0.0016 EUR/kWh of energy, is carried over
|
||||
0.02 by the flat tax addend alone, and passes a band applied to the total. The
|
||||
hundredfold error is exactly what the band exists for. Graded from both
|
||||
directions in `test_price.py` 15e/15i and driven red as M10.
|
||||
|
||||
## 4. Fixed coefficients. No expression evaluation of any kind
|
||||
|
||||
**Decided: never a grammar.** Two arguments, and the second is the one that
|
||||
decides it.
|
||||
|
||||
* **Security.** The add-on serves `POST config/save` → `POST
|
||||
/addons/self/options` on its own ingress page, so `price_formula` is
|
||||
**settable over HTTP**. `eval` on it is remote code execution in a root
|
||||
container holding `SUPERVISOR_TOKEN`, with `homeassistant_api` and
|
||||
`hassio_api`, driving an inverter over RS485. `eval(s, {"__builtins__": {}})`
|
||||
is not a sandbox and this repository is not going to pretend otherwise.
|
||||
* **⚠️ AND THE DECIDING ARGUMENT IS NOT SECURITY: AN EXPRESSION IS
|
||||
UNVERIFIABLE.** Two coefficients can be graded against the card's printed
|
||||
estimate. An arbitrary expression cannot be graded against **anything** — so
|
||||
it makes the invisible-wrong-coefficient problem *strictly worse*, which is
|
||||
the exact failure this provider exists to prevent. An expression would buy
|
||||
the operator freedom to be wrong in a way nothing could report.
|
||||
|
||||
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
|
||||
`B × index + A`, so an expression buys nothing the market needs either.
|
||||
|
||||
**Future shapes become NAMED FIELDS** — `clamp_lo`, `factor_negative`,
|
||||
`offset_bands` — each individually refusable and each individually gradeable
|
||||
against a card. `parse_price_formula()` therefore **refuses an unknown key**
|
||||
rather than ignoring it: an operator who types `"expr"` is told it does not
|
||||
exist, instead of being priced on the fields they also typed with the one they
|
||||
believed in dropped in silence.
|
||||
|
||||
`test_price.py` 15f walks the module's AST for `eval`, `exec`, `compile`,
|
||||
`__import__` and `literal_eval`, and exercises the scanner's **red** direction
|
||||
on every run so it cannot become a scanner that scans nothing.
|
||||
|
||||
## 5. `{ENERGY, MARKUP}`, not `ALL_IN`
|
||||
|
||||
The contract's formula **is** the supplier's markup — that is what `B` and `A`
|
||||
are — but it is not the levies. Declaring `ALL_IN` would lie on behalf of every
|
||||
operator who typed exactly what their card says and would drop
|
||||
`price_taxes_eur_kwh` from every hour: measured at 0.2125 → 0.1555 EUR/kWh.
|
||||
`price_markup_eur_kwh` is therefore **ignored** under this provider, and logged
|
||||
as ignored, which is the framework's existing rule rather than a new one.
|
||||
|
||||
## 6. What was reused rather than rebuilt
|
||||
|
||||
The index path is `generic_dayahead`'s: same EPEX BE feed, same
|
||||
Brussels-calendar range, same columnar parser, same declared egress hosts. The
|
||||
override/range decision moved into one shared `dayahead_feed_url()` that both
|
||||
plugins call, because two copies of that rule would be two places for the
|
||||
short-horizon defect TARIFF-09 fixed to come back in only one of them.
|
||||
|
||||
`resolution: hourly` averages each hour's members. ⚠️ **That is not
|
||||
`_Series.at()`'s hourly assumption returning (TARIFF-14):** it decides how many
|
||||
points are *emitted*; the window each point covers is still derived from its
|
||||
neighbours in `_cover_table()`. A feed that is already hourly averages one
|
||||
member per hour — the identity — so the option cannot silently assume the
|
||||
granularity it was handed. Graded.
|
||||
|
||||
## 7. What this card did NOT do
|
||||
|
||||
* **The injection leg.** Eneco uses `0,102` on consumption and `0,100` on
|
||||
injection, and injection is VAT-exempt where consumption is at 6 % — a second
|
||||
per-contract formula, not a second copy of this one. Separate card.
|
||||
* **The hour-varying network charge that bites Wallonia.** Separate card.
|
||||
* **Raising `price_taxes_eur_kwh` from 0.06 to ~0.12.** Separate card, and an
|
||||
owner's decision. Note §3b: the band's floor is deliberately insensitive to
|
||||
whatever that number becomes.
|
||||
+415
-25
@@ -116,7 +116,7 @@ whole rendered list, in both languages.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **All 71 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||||
| **All 72 options are always shown** | The frontend keeps a field when `name in options \|\| required`, and every key is in `options:`. There is no "advanced" fold |
|
||||
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
|
||||
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
|
||||
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
|
||||
@@ -1346,24 +1346,39 @@ does, and only after you have read why.
|
||||
> release ships the five provider plugins, the canonical all-in format they
|
||||
> emit, the config that selects one, and — new in OPT-04 — a price-arbitrage
|
||||
> strategy that trades on them. With `arbitrage_enabled: false`, which is the
|
||||
> shipped default, turning a provider on still fetches, validates and publishes
|
||||
> prices and changes no charging decision.
|
||||
> shipped default, **selecting a provider is what starts the fetch**: the feed
|
||||
> is read every 30 minutes, validated, and the day recorded in
|
||||
> `/data/price_history.json`, which the *Price history* status row reports and
|
||||
> the add-on log names at every refresh. No charging decision changes.
|
||||
>
|
||||
> ⚠️ **That has been the documented behaviour since OPT-04 and was not the
|
||||
> actual behaviour until TARIFF-10.** The background refresh was started only
|
||||
> when `arbitrage_enabled` was on — and it ships `false` — so an install with a
|
||||
> provider configured fetched nothing at all, never created the store, and read
|
||||
> *Price history: empty* for as long as it ran. If you configured a provider and saw an empty
|
||||
> Price history row, that is what you were looking at. Trading is unchanged and
|
||||
> is still `arbitrage_enabled` alone.
|
||||
>
|
||||
> `static_profile` below needs no network at all, so its cheap window is
|
||||
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||||
> whether or not arbitrage is armed.
|
||||
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
|
||||
> that window.** A provider-backed plan now exists on every configured site,
|
||||
> but *Cheap window* and the maintenance cycle's grid top-up read
|
||||
> `static_profile` and nothing else — a deliberate limit, recorded with what it
|
||||
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `price_provider` | `off` (default) or one of the five plugins below |
|
||||
| `price_provider` | `off` (default) or one of the three plugins below |
|
||||
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
|
||||
| `price_taxes_eur_kwh` | Levies and VAT, EUR/kWh. Same rule |
|
||||
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
|
||||
| `price_csv_path` | Where `manual_csv` reads its file |
|
||||
| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted |
|
||||
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
|
||||
| `price_horizon_h` | How far ahead to ask for, hours |
|
||||
| `price_timeout_s` | Per-request timeout |
|
||||
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
|
||||
| `price_formula` | For `custom_formula` only: your own contract's two coefficients, off your own tariff card. Empty by default — see below |
|
||||
|
||||
**Which plugin sources what.** This is the one thing to get right, because
|
||||
adding your markup to a price that already contains it makes every future
|
||||
@@ -1372,11 +1387,86 @@ arbitrage decision wrong by that amount, in a direction nothing reports.
|
||||
| plugin | product | the published figure contains | the add-on adds |
|
||||
|---|---|---|---|
|
||||
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
|
||||
| `engie_dynamic` | day-ahead | energy + Engie's markup | taxes |
|
||||
| `totalenergies_dynamic` | day-ahead | energy + TotalEnergies' markup | taxes |
|
||||
| `eneco_eddy` | **real-time / imbalance** | everything, all-in | nothing |
|
||||
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
|
||||
| `manual_csv` | day-ahead | everything, all-in | nothing |
|
||||
|
||||
⚠️ **Three plugins were withdrawn in TARIFF-07, and if your configuration names
|
||||
one of them, read this.** `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy` are gone. They did not stop working — **they never worked.** Each
|
||||
pointed at an endpoint that does not exist and never did:
|
||||
|
||||
| id | its endpoint | what that endpoint actually answers |
|
||||
|---|---|---|
|
||||
| `engie_dynamic` | `https://api.engie.be/dynamic/be` | **404**, gateway error `no Route matched with those values` |
|
||||
| `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure** — that hostname is not in the server's certificate |
|
||||
| `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **404**, empty body |
|
||||
|
||||
Every fetch any of them ever made failed, and a failed fetch falls back to the
|
||||
static tariff **silently** — so a site set to one of these has been running on
|
||||
`static_profile`, or on nothing at all, since the day it was configured, with
|
||||
nothing on the status page saying so. The add-on now **refuses to start** on one
|
||||
of these ids and tells you all of the above by name. That is deliberate: a
|
||||
silent fallback is what hid this for as long as it lasted.
|
||||
|
||||
There is no automatic migration and there cannot be one. These were *retail*
|
||||
tariffs of the form `B x spot + A` with **per-contract** coefficients that live
|
||||
behind your supplier's customer login; there is no public index to read them
|
||||
from. Any alias would have to invent the numbers your battery is priced against.
|
||||
(`eneco_eddy` was doubly wrong: **"Eddy" is not an Eneco product at all** —
|
||||
Eneco's dynamic product is *Zon & Wind Dynamisch*, and it is indexed to
|
||||
Belpex/EPEX **day-ahead**, not to imbalance.)
|
||||
|
||||
#### So what should a Belgian dynamic-tariff customer do today?
|
||||
|
||||
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
|
||||
close.** It reads the wholesale EPEX BE day-ahead curve and adds your
|
||||
`price_markup_eur_kwh` and `price_taxes_eur_kwh` as **flat** scalars. Your real
|
||||
Belgian dynamic contract is a **formula**:
|
||||
|
||||
```
|
||||
consumption: price = B x spot + A <- B and A are in YOUR contract
|
||||
```
|
||||
|
||||
`generic_dayahead` cannot express that. Two gaps, and the second is the
|
||||
harder one — **both are closed by `custom_formula`**, which is what the
|
||||
rest of this section is about:
|
||||
|
||||
* **No multiplier.** `B` is typically slightly above 1 (it scales the wholesale
|
||||
price before anything is added). A flat markup cannot scale anything, so the
|
||||
*shape* of your curve is right but its *amplitude* is not — the peaks and
|
||||
troughs the optimiser trades against are compressed relative to your bill.
|
||||
* **No negative offset.** `A` is frequently **negative** on injection and can be
|
||||
on consumption. `price_markup_eur_kwh` is `float(0,1)` in the schema — it
|
||||
**cannot hold a negative number at all**, so that contract shape is currently
|
||||
inexpressible rather than merely approximate.
|
||||
|
||||
What that means in practice: arbitrage decisions made on `generic_dayahead` are
|
||||
made against a curve that is *correlated* with your bill, not equal to it. For
|
||||
deciding **which hours** are cheap that is usually good enough; for deciding
|
||||
**whether a cycle pays for itself** — which is what `cycle_cost_threshold_eur`
|
||||
compares against — it is an approximation, and one that errs by however far `B`
|
||||
is from 1.
|
||||
|
||||
**`custom_formula` is the answer to exactly this, and it shipped in TARIFF-15.**
|
||||
You type your contract's two coefficients once, off your own tariff card, and
|
||||
the add-on evaluates `B x index + A` against the same EPEX BE day-ahead curve
|
||||
`generic_dayahead` reads. Both gaps above close: `B` is a real multiplier, and
|
||||
`A` may be negative. It is documented in full in **[Your own tariff formula
|
||||
(`custom_formula`)](#your-own-tariff-formula-custom_formula)** below.
|
||||
|
||||
So there are three honest options rather than two:
|
||||
|
||||
1. **`custom_formula`** — your contract's own arithmetic, if you can read the
|
||||
two coefficients off your tariff card. This is the one to use.
|
||||
2. **`generic_dayahead`** with your markup and taxes set as well as you can —
|
||||
accepting the approximation above; or
|
||||
3. **`manual_csv`**, where you compute the all-in prices yourself from your own
|
||||
contract's formula and hand the add-on the answers. Nothing is approximated,
|
||||
but nothing is automatic either.
|
||||
|
||||
If you are on a **fixed** or **day/night** tariff — most Belgian households —
|
||||
neither of those applies: use `static_profile` and leave `price_provider: off`.
|
||||
|
||||
You cannot get this wrong by editing the markup: each plugin *declares* what its
|
||||
feed contains and the add-on adds only the rest. Set a markup under a plugin
|
||||
that already carries one and it is **ignored, with a line in the log saying so**
|
||||
@@ -1390,12 +1480,225 @@ whether a plugin described its supplier's feed truthfully — that is a claim
|
||||
about somebody else's tariff, and no code can verify it. If you write a plugin,
|
||||
the `sources` declaration is the part a reviewer has to read.
|
||||
|
||||
`eneco_eddy` is a real-time/imbalance product rather than a day-ahead one. It is
|
||||
flagged as high-volatility, which widens arbitrage thresholds once the
|
||||
optimiser lands. That comes from the plugin's own declaration, not from its
|
||||
name.
|
||||
**Real-time / imbalance products still have a place in the machinery, but no
|
||||
plugin declares one.** A plugin that declares `PriceProduct.REALTIME` is flagged
|
||||
high-volatility and gets widened arbitrage thresholds; that comes from the
|
||||
plugin's own declaration, never from its name. All three shipped plugins are
|
||||
day-ahead, so nothing exercises that path on a real install today — it is kept
|
||||
on purpose so the first genuine imbalance product does not have to rebuild it.
|
||||
See `DECISIONS/TARIFF-07-withdrawing-three-plugins.md`.
|
||||
|
||||
**`manual_csv` — the offline fallback.** It needs no internet, no supplier
|
||||
**`generic_dayahead` and the shape of the public feed.** It reads Fraunhofer
|
||||
ISE's Energy-Charts API (`https://api.energy-charts.info/price?bzn=BE`, no key
|
||||
and no registration), which publishes a *columnar* document — parallel
|
||||
`unix_seconds` and `price` arrays, in EUR/MWh, with the unit and the licence
|
||||
stated in the response. Two things follow that are worth knowing before you
|
||||
point `price_feed_url` somewhere else:
|
||||
|
||||
* The timestamps are **UTC instants**. They are rendered at the Brussels offset
|
||||
that applies to each one, so the hour that happens twice every October is two
|
||||
points rather than one overwritten one.
|
||||
* Belgium moved to a **15-minute market time unit** on 2025-10-01, and the feed
|
||||
followed. A published day is **96 points, not 24**. Nothing in the add-on
|
||||
assumes either spacing, and nothing aggregates the quarters away.
|
||||
* The request **names the days it wants**, derived from the horizon on the
|
||||
**Brussels** calendar — `?bzn=BE&start=2026-09-09&end=2026-09-10`, two
|
||||
inclusive local dates. Without that the feed answers a single day, and
|
||||
everything past today would be carried forward from yesterday's profile and
|
||||
flagged provisional — which §8.3 excludes from arbitrage, so tomorrow would
|
||||
never be tradeable at any hour. The calendar is Brussels rather than UTC
|
||||
because 22:30 UTC in summer is already tomorrow in Belgium.
|
||||
|
||||
**What you get before the auction publishes.** The day-ahead auction clears at
|
||||
about 13:00–14:00 CET. Ask for tomorrow before that and the feed returns fewer
|
||||
days than requested — that is normal, not a failure. The add-on keeps the
|
||||
series of what exists and fills the remaining hours from yesterday's profile,
|
||||
flagged provisional; arbitrage runs on the firm hours and ignores the flagged
|
||||
ones. So a dynamic site's plan is part firm and part carried every morning and
|
||||
fully firm once tomorrow is published, and neither state is an error.
|
||||
|
||||
⚠️ **If you set `price_feed_url`, you keep the short horizon.** The override is
|
||||
sent exactly as you wrote it and the add-on adds no `start`/`end` to it, because
|
||||
those are this feed's parameter names and an override may point somewhere with
|
||||
different ones — rewriting a URL you typed is how a request nobody intended gets
|
||||
made. The consequence is a **`WARNING` in the log on every price refresh**,
|
||||
naming the range that would otherwise have been requested. If you only wanted a
|
||||
different host and not a different horizon, the fix is to clear the option.
|
||||
|
||||
If the arrays ever come back different lengths, or the unit stops saying
|
||||
EUR/MWh, the fetch is refused outright rather than trusted in part: a price
|
||||
series missing its last hours is a series missing the evening peak, and it
|
||||
would be traded on as though it were complete. A refused fetch is the §11.4
|
||||
row — static profile, provisional plan, no arbitrage — and the reason is in the
|
||||
log.
|
||||
|
||||
**A refused fetch does not take the plan you already have.** The last plan that
|
||||
actually came from the feed keeps standing until it is 90 minutes old (three
|
||||
missed refreshes), and only then does the §11.4 row above take over. The
|
||||
add-on logs a `WARNING` naming the failure, the fetch it is keeping, its age
|
||||
and the moment it retires; the plan's own age is **not** reset by the failed
|
||||
refresh, so a kept plan still expires on schedule and is never presented as a
|
||||
current one. One failed fetch is a blip, not a lost tariff.
|
||||
|
||||
#### Your own tariff formula (`custom_formula`)
|
||||
|
||||
**What it is for.** A Belgian dynamic contract prices energy as a formula —
|
||||
your supplier's tariff card prints it as `Tariefformule` — and the two
|
||||
coefficients in it belong to *your contract*. There is no machine-readable
|
||||
source for them anywhere: CREG's consumer tariff-card pages return 404, CREG
|
||||
Scan is a JavaScript wizard, the annual report lists suppliers without
|
||||
per-product coefficients, and VREG was unreachable. Every coefficient found in
|
||||
research came off a supplier's own PDF, one of them image-only. **You are the
|
||||
only party who can see your own coefficients**, so you type them, once.
|
||||
|
||||
Set `price_provider: custom_formula` and fill in `price_formula` with one line
|
||||
of JSON:
|
||||
|
||||
```json
|
||||
{"index": "epex_be", "resolution": "hourly",
|
||||
"factor": 0.102, "offset": 1.00,
|
||||
"units": "c_eur_per_kwh", "vat_pct": 6,
|
||||
"card": {"month": "2026-09", "index_eur_per_mwh": 133.9905,
|
||||
"expect_incl_vat": 15.55}}
|
||||
```
|
||||
|
||||
The contract it expresses is:
|
||||
|
||||
```
|
||||
price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct / 100)
|
||||
```
|
||||
|
||||
expressed in `units`, and **converted to EUR/kWh by the add-on**. You type what
|
||||
your card prints, in the card's own units; every unit conversion and the VAT
|
||||
multiplication are the add-on's job, because those are exactly the two steps
|
||||
where a human silently loses a factor of 10, 100 or 1.06.
|
||||
|
||||
| field | what it is |
|
||||
|---|---|
|
||||
| `index` | the reference series. `epex_be` — the EPEX BE day-ahead curve in EUR/MWh, the same feed `generic_dayahead` reads |
|
||||
| `resolution` | `quarter_hourly` (the 96 points the feed publishes) or `hourly` (each hour's quarters averaged into one point). Your card says which series your contract is written against |
|
||||
| `factor` | `B` — the multiplier on the index. Must be a number, and positive |
|
||||
| `offset` | `A` — the fixed part, in the same units. **May be negative** |
|
||||
| `units` | `c_eur_per_kwh`, `eur_per_kwh` or `eur_per_mwh`. **Required, with no default** |
|
||||
| `vat_pct` | the VAT percentage. `6` for Belgian residential electricity — not `0.06` and not `106`. `0` is legitimate |
|
||||
| `card` | the month the card is dated, the reference index it assumed, and the price it prints. **Required** — see below |
|
||||
|
||||
##### A worked example, from a real card
|
||||
|
||||
Eneco's September 2026 card prints the formula, the reference index it assumed
|
||||
*and* the resulting price. With `factor: 0.102`, `offset: 1.00`, `vat_pct: 6`
|
||||
and the card's own index of `133.9905` EUR/MWh:
|
||||
|
||||
```
|
||||
(0.102 x 133.9905 + 1.00) x 1.06 = 15.5471 c€/kWh the card prints 15,55
|
||||
```
|
||||
|
||||
ENGIE's card, in the same shape with its own numbers:
|
||||
|
||||
```
|
||||
(0.100 x 61.96 + 0.90) x 1.06 = 7.5218 c€/kWh the card prints 7,522
|
||||
```
|
||||
|
||||
Both reproduce to the last digit the card prints, through the shipped code —
|
||||
`test_price.py` section 15a runs exactly these two.
|
||||
|
||||
##### ⚠️ The `card` block is required, and it is the safety mechanism
|
||||
|
||||
Because your card prints the index it assumed **and** the price that comes out,
|
||||
the add-on can check your coefficients against your supplier's own worked
|
||||
example. At startup it recomputes that printed price from the numbers you
|
||||
typed and **refuses to start** unless the two agree to within `0.01` — naming
|
||||
the discrepancy, the ratio, and the likely causes.
|
||||
|
||||
That catches, in one check: VAT left out, VAT applied twice, a transposed
|
||||
digit, and a **unit mismatch** between your coefficients and the price you
|
||||
typed beside them (a factor of 10, 100 or 1000).
|
||||
|
||||
And it catches the expensive one. **Your card's formula line is the energy
|
||||
component only.** Eneco tabulates network tariffs in one table and levies in a
|
||||
second; ENGIE states that the formula excludes network, distribution,
|
||||
transport, grid levies and excise. The add-on therefore declares that
|
||||
`custom_formula` supplies *energy and markup*, and adds `price_taxes_eur_kwh`
|
||||
on top — once. If you fold your network charges into `offset`, you are billed
|
||||
for them twice, and nothing on the status page would ever show it. It cannot
|
||||
happen: ~12 c€/kWh of network charge moves the computed 15.55 to about 27, and
|
||||
the add-on refuses to start and says so.
|
||||
|
||||
⚠️ **And the limit of that check, in the same breath as the claim: it grades
|
||||
your coefficients against your card, not your card against reality.** Two
|
||||
numbers copied off the wrong product — or off a card your contract has since
|
||||
rolled over from — agree with each other perfectly and pass. Nothing can catch
|
||||
that. What is mechanised is that your coefficients and your card's printed
|
||||
price *agree*; whether the card is the right card is yours to know. The add-on
|
||||
warns (and does not refuse) when `card.month` is more than about six months
|
||||
old, because ENGIE freezes coefficients for a contract's duration and an old
|
||||
card is often exactly right.
|
||||
|
||||
One more check runs beside it: the card's own point must land inside the range
|
||||
a Belgian retail tariff can occupy (0.02–1.00 EUR/kWh). That exists because a
|
||||
unit error applied to **both** sides at once cancels out of the comparison
|
||||
above and cannot cancel out of this one. It bounds the card's own point, **not
|
||||
every hour** — a live hour may legitimately fall outside it, and a negative
|
||||
day-ahead hour, which is the best charging opportunity of the year, is never
|
||||
refused.
|
||||
|
||||
##### ⚠️ Fixed coefficients. No expression is evaluated, ever
|
||||
|
||||
`price_formula` takes numbers. There is no field that accepts an expression,
|
||||
and an unrecognised field is **refused** rather than ignored, so typing
|
||||
`"expr": "..."` tells you it does not exist instead of quietly pricing you on
|
||||
the other fields.
|
||||
|
||||
Two reasons, and the second is the decisive one:
|
||||
|
||||
* This string is settable over HTTP — the add-on's own ingress configuration
|
||||
page posts it to the Supervisor — so evaluating it would be remote code
|
||||
execution inside a root container that holds a Supervisor token and drives
|
||||
an inverter. (`eval` with an emptied `__builtins__` is not a sandbox.)
|
||||
* **An expression could not be graded against anything.** Two coefficients can
|
||||
be checked against your card's printed price; an arbitrary expression
|
||||
cannot, so it would make the invisible-wrong-coefficient problem strictly
|
||||
worse — which is the exact failure this provider exists to prevent.
|
||||
|
||||
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
|
||||
`B x index + A`, so an expression would buy nothing the market needs. If a
|
||||
future contract needs a different shape, it gets a **named field**
|
||||
(`clamp_lo`, `factor_negative`, `offset_bands`) that can be refused on its
|
||||
own. Never a grammar.
|
||||
|
||||
##### What you see at startup
|
||||
|
||||
The first time the feed answers, the add-on logs a preview of the next 24
|
||||
hours as `index EUR/MWh -> all-in EUR/kWh`, followed by the minimum, median,
|
||||
maximum and the day's spread. It is there so that a formula off by a factor of
|
||||
1000 is visible in one line rather than in a month of bills:
|
||||
|
||||
```
|
||||
custom_formula preview - the next 24 h of YOUR tariff, index EUR/MWh -> all-in
|
||||
EUR/kWh, computed as (0.102 x index + 1) x (1 + 6/100) c_eur_per_kwh, converted
|
||||
to EUR/kWh and with price_taxes_eur_kwh added by the framework:
|
||||
2026-09-10T00:00:00+02:00 216.080 -> 0.30423
|
||||
2026-09-10T00:15:00+02:00 216.080 -> 0.30423
|
||||
... 72 further point(s) not listed; the figures below cover ALL 96
|
||||
min 0.17277 / median 0.28031 / max 0.67499 EUR/kWh, spread 0.50222 EUR/kWh
|
||||
over 96 point(s). ⚠️ If those are a thousand times what your bill says,
|
||||
`units` is wrong; if the spread is 0, `factor` is.
|
||||
```
|
||||
|
||||
(That transcript is the shipped code run against the committed
|
||||
`scenarios/energy-charts-be-price-2026-09-10-quarterhourly.json` capture with
|
||||
the default markup and taxes, not an illustration.)
|
||||
|
||||
##### What it does not do
|
||||
|
||||
The **injection** leg is a separate card. Eneco uses `0,102` on consumption and
|
||||
`0,100` on injection, and injection is VAT-exempt where consumption is at 6 %,
|
||||
so it is a second per-contract formula rather than a second copy of this one.
|
||||
`export_price_eur_kwh` is still the scalar you enter by hand.
|
||||
|
||||
#### `manual_csv` — the offline fallback
|
||||
|
||||
It needs no internet, no supplier
|
||||
account and no HTTP stack at all, and it is the supported way to run this
|
||||
add-on price-aware on a site with no outbound connectivity. Write a file and
|
||||
point `price_csv_path` at it:
|
||||
@@ -1453,20 +1756,59 @@ applies to those hours, and asking the plan for one raises rather than
|
||||
answering. An hour with no price is not a free hour, and the fill does not
|
||||
invent a neighbour, a mean or a zero for it.
|
||||
|
||||
**A quarter-hourly feed keeps all four of its prints.** `eneco_eddy` settles
|
||||
per quarter, and the fill decides *which whole hours need carrying* — never what
|
||||
a published hour may contain. Every point the provider published inside the
|
||||
**A quarter-hourly feed keeps all four of its prints.** Belgian day-ahead
|
||||
settles per quarter — the 15-minute MTU took effect on 2025-10-01, so
|
||||
`generic_dayahead` reads 96 points a day — and the fill decides *which whole
|
||||
hours need carrying*, never what a published hour may contain. Every point the provider published inside the
|
||||
horizon is passed through untouched; a carried point is added only for a whole
|
||||
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:
|
||||
@@ -1505,8 +1847,21 @@ volume being full exactly as it applies to the volume being absent.
|
||||
|
||||
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
|
||||
often near zero, which by itself strongly favours self-consumption over export.
|
||||
It is a single number rather than a curve because no supported supplier
|
||||
publishes a dynamic injection tariff; negative values are accepted.
|
||||
Negative values are accepted — you can be paid to *not* export at midday in
|
||||
summer.
|
||||
|
||||
⚠️ It is a single number rather than a curve, and the reason given here until
|
||||
TARIFF-07 — *"no supported supplier publishes a dynamic injection tariff"* — was
|
||||
**false**. Suppliers do. Eneco publishes `0,1 x BELPEX-H − 1,188` on the same
|
||||
tariff card as its consumption formula (identical on the January and the
|
||||
September 2026 cards), and ENGIE publishes one in the same shape,
|
||||
`(B x eSpot_15) − A`. What is true is narrower: an injection curve is the **same
|
||||
two-coefficient formula** as the consumption curve but with **its own**
|
||||
coefficients and **its own VAT treatment** — injection is VAT-exempt where
|
||||
consumption is at 6% — so it is a second per-contract formula rather than a copy
|
||||
of the first, and the add-on cannot evaluate either one yet. Until the formula
|
||||
provider ships, **this scalar is a simplification you enter by hand — an average
|
||||
of what you are actually paid — and not a reflection of the market.**
|
||||
|
||||
**The old Home Assistant price sensors.** `price_now_entity` and
|
||||
`price_avg_entity` still work and are unchanged: the add-on compares them and
|
||||
@@ -1626,6 +1981,41 @@ towards the firmware's 30 s failsafe. The plan refreshes every 30 minutes and
|
||||
retires itself if the refresh stops answering, at which point arbitrage stops
|
||||
with it.
|
||||
|
||||
⚠️ **That refresh is not gated on this switch** (TARIFF-10). It runs when a
|
||||
`price_provider` is configured *or* when arbitrage is armed, so turning
|
||||
arbitrage off does not turn the price feed off — and a site with no provider
|
||||
that arms arbitrage on a `static_profile` keeps the plan it has always had. The
|
||||
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 |
|
||||
|
||||
@@ -695,6 +695,84 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
that two of it cannot silently overwrite each other's work. A fixed path
|
||||
in a tool that edits source is a shared mutable global with a filename.
|
||||
|
||||
19. ⚠️ **A FIXTURE IN A SHAPE NOBODY PUBLISHES GRADES THE PARSER AGAINST THE
|
||||
PARSER — FOURTEENTH INSTANCE OF HABIT 4, AND THE FIRST WHERE THE FIXTURE
|
||||
AND THE CODE UNDER TEST SHARED A *SHAPE* RATHER THAN A NUMBER.** TARIFF-08.
|
||||
`price.py`'s `_points_from_json()` read `doc["data"]` as a list of
|
||||
`{start, price_eur_mwh}` objects. `api.energy-charts.info` publishes
|
||||
parallel `unix_seconds` / `price` arrays and has no `data` key at all, so
|
||||
`generic_dayahead` raised `SourceUnavailable` on **every live fetch it ever
|
||||
made** — and the suite was green, because all three price suites built
|
||||
their bodies with `json.dumps({"data": [...]})`. The fixture was derived
|
||||
from the parser's guess, so no mutation of the parser and no amount of
|
||||
coverage inside that loop could ever have reached the failure. §11.4 then
|
||||
turned it into a *correct* silent degradation: parse error -> `no_data` ->
|
||||
static profile -> arbitrage off, with nothing naming the cause.
|
||||
|
||||
Habit 2 with a network on the end of it: **the reference has to be data the
|
||||
code under test did not shape.** For a parser that means BYTES, captured
|
||||
from the source and committed unmodified, with the URL, the retrieval time
|
||||
and a checksum beside them - `scenarios/energy-charts-be-price-*.json` and
|
||||
its `-captures.json` provenance, re-derived by the replay so an edited
|
||||
fixture stops being a capture. Two granularities, because the same endpoint
|
||||
answers hourly before 2025-10-01 and quarter-hourly after it and a fixture
|
||||
at one spacing cannot grade an assumption about the other.
|
||||
|
||||
⚠️ **AND THE SURVIVING SYNTHETIC FIXTURES ARE NAMED FOR WHAT THEY ARE.**
|
||||
Three shipped plugins point at endpoints nobody has verified (TARIFF-07),
|
||||
so no capture exists for them; their fixture builder is called
|
||||
`synthetic_row_feed` at every call site, because the next reader's default
|
||||
assumption will otherwise be that a green check means the feed parses.
|
||||
|
||||
⚠️ **ONE OF THIS CARD'S OWN NEW LINES WAS DECORATION AND THE BATTERY SAID
|
||||
SO.** The columnar parser first rendered its instants with `brussels()`
|
||||
before returning them. `assemble()` already renders every plugin's points,
|
||||
so removing the parser's call changed nothing observable - **M4 came back a
|
||||
survivor on a `:ro`, purged tree**, which is not the direction stale
|
||||
bytecode fabricates. The line was removed rather than fenced with a check:
|
||||
a second copy of a rule that already has an owner is indistinguishable from
|
||||
a no-op, and the mutation that proves the rendering is real belongs on
|
||||
`assemble()`, where it is now aimed.
|
||||
|
||||
20. ⚠️ **A FIXTURE GRADES THE RESPONSE. NOTHING ABOUT THE REQUEST IS REACHABLE
|
||||
FROM ONE, AND THE TRANSPORT HAD BEEN RECORDING IT ALL ALONG.** TARIFF-09,
|
||||
and it is habit 19's closed loop with the other half still shut.
|
||||
`generic_dayahead.fetch()` took `since` and `until` and asked for the bare
|
||||
`?bzn=BE`, which answers ONE Brussels day - so §8.3 dropped every hour past
|
||||
today from the trade set as provisional and the optimiser never saw
|
||||
tomorrow as firm, at any hour of any day. Measured at 09:00: **15 firm
|
||||
hours and 33 carried of 48**, both ends of the permitted cycle inside one
|
||||
calendar day; at 23:00, **1 and 47**, with the firm spread no longer
|
||||
clearing the round-trip cost.
|
||||
|
||||
TARIFF-08 had just fixed the RESPONSE side of this same plugin by
|
||||
committing real bytes. It could not have caught this: a captured body is
|
||||
the answer to a URL, and **no property of any body says which URL was
|
||||
asked for**. `canned()` in `test_price.py` had recorded `(url, timeout_s)`
|
||||
on every call since TARIFF-02 and not one check had ever asserted on the
|
||||
first element - so the harness *knew* the plugin was ignoring its arguments
|
||||
and nothing asked it. The fix is one line of habit: **where a plugin takes
|
||||
arguments that shape a request, assert on the REQUEST off the injected
|
||||
transport, not only on what comes back.** Section 2c does, and the
|
||||
shipped-defect mutation (restore the bare URL, `-173 B`) is red on six
|
||||
named checks in `test_price.py` and two in `test_price_history.py`.
|
||||
|
||||
⚠️ **AND A MUTATION THAT TURNS A READ INTO `no_data` CRASHES A SUITE
|
||||
INSTEAD OF NAMING A CHECK, UNLESS THE CHECK EXPECTS IT.** `_NoData.points`
|
||||
raises `Absence` by design, so `derived(...) < derived(...)` over two
|
||||
absent reads is a `TypeError` and `[p for p in plan.read.points]` is a
|
||||
traceback - habit 5's shape with an exception in place of a hang. The
|
||||
mutation that matters here is the "defensive" one somebody writes after
|
||||
reading this card: *refuse when the feed returned fewer days than we asked
|
||||
for*. It is exactly what a healthy feed does every morning before the
|
||||
~13:00-14:00 CET auction, and switching arbitrage off for it would be a
|
||||
worse defect than the one being fixed. It is red BY NAME in
|
||||
`test_price.py` (33 checks, the AC's own line among them) and takes
|
||||
`test_price_history.py` down at **FCST-04's section 1**, whose `_before`
|
||||
read is unguarded - a red, but an unnamed one. That is recorded rather than
|
||||
retrofitted: guarding another card's section is a change to what its
|
||||
checks report, and it belongs to whoever next touches it.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
@@ -702,6 +780,89 @@ static routing pin catches. The *bound* variant — right actuator, wrong
|
||||
numbers — is a different defect with a different fix, and it was absent. Both
|
||||
are in the table now.
|
||||
|
||||
## TARIFF-15 — the card-graded formula, mutation by mutation
|
||||
|
||||
Twenty-two mutations, one per check the card added, **run** rather than
|
||||
reasoned about. Harness: `/home/homelab/.cache/t15-mut/run.py`, backups beside
|
||||
it rather than in a shared scratchpad — two agents' harnesses collided in one
|
||||
this week. Each run restores from backup, purges `__pycache__` **on the host**
|
||||
first, and grades through the CI image with the tree bind-mounted `:ro`, so the
|
||||
interpreter cannot write a cache into the tree under test at all (TEST-15's
|
||||
structural immunity, borrowed).
|
||||
|
||||
**No survivors.** Two entries are `+0 B` and are called out as such, per the
|
||||
reporting rule.
|
||||
|
||||
| | mutation | file | Δ | red |
|
||||
|---|---|---|---|---|
|
||||
| M1 | `CARD_TOLERANCE` widened to 100 — the self-check stops checking | `custom_formula.py` | +1 B | **8** |
|
||||
| M2 | `CARD_TOLERANCE` tightened to 0 — the other direction, so the tolerance is a number and not a shape | `custom_formula.py` | −1 B | **16** |
|
||||
| M3 | `units` becomes optional | `custom_formula.py` | +10 B | 1 |
|
||||
| M4 | the `factor == 0` rung removed | `custom_formula.py` | −8 B | 1 |
|
||||
| M5 | the `factor < 0` rung removed | `custom_formula.py` | −7 B | 1 |
|
||||
| M6 | the `vat_pct` band widened to ±1000 | `custom_formula.py` | +6 B | 2 |
|
||||
| M7 | the non-finite rung removed from `_finite()` | `custom_formula.py` | −26 B | 2 |
|
||||
| M8 | the non-numeric rung removed from `_finite()` | `custom_formula.py` | −16 B | 4 |
|
||||
| M9 | an unknown top-level field ignored instead of refused | `custom_formula.py` | −2 B | 1 |
|
||||
| M10 | the plausibility **floor** applied to the all-in figure instead of the energy one | `custom_formula.py` | **+0 B** | 2 |
|
||||
| M11 | `hourly` stops averaging and emits the feed's own points | `custom_formula.py` | +12 B | 3 |
|
||||
| M12 | the hourly bucket takes the FIRST quarter instead of the mean | `custom_formula.py` | −11 B | 1 |
|
||||
| M13 | the preview drops the index column from each row | `custom_formula.py` | −13 B | 1 |
|
||||
| M14 | the preview is emitted on every refresh rather than once | `price.py` | −8 B | 1 |
|
||||
| M15 | `custom_formula` declares `ALL_IN` — the double-counting lie | `price.py` | −41 B | **10** |
|
||||
| M16 | a stale card never warns | `custom_formula.py` | −8 B | 1 |
|
||||
| M17 | a stale card REFUSES instead of warning | `validate.py` | −3 B | 1 |
|
||||
| M18 | the empty-formula refusal removed from the startup gate | `validate.py` | −38 B | 1 |
|
||||
| M19 | the index scaled to EUR/kWh before the formula — the factor-1000 | `price.py` | +2 B | **7** |
|
||||
| M20 | an `eval` call planted in `app/custom_formula.py` | `custom_formula.py` | +14 B | 1 |
|
||||
| M21 | `custom_formula` stops passing the caller's range to the feed | `price.py` | **+0 B** | 1 |
|
||||
| M22 | **PAIRED**: M1 *and* the plausibility band removed, together | `custom_formula.py` | −60 B | **12** |
|
||||
|
||||
### The four that are worth reading rather than counting
|
||||
|
||||
**M13 was a SURVIVOR of the whole suite on the first attempt, at −13 B.** The
|
||||
check read `"index EUR/MWh -> all-in EUR/kWh" in log` — which is the preview's
|
||||
**heading**. Deleting the index column from every **row** left the heading
|
||||
saying it prints something it no longer printed, and nothing went red. That is
|
||||
this file's third failure mode arriving in the check written to close it: the
|
||||
assertion ran, the sentence was true, and the data could not reach the failure
|
||||
it named. It is graded on a rendered ROW now, built here from the captured
|
||||
document (`' 216.080 -> 0.30123'`), and M13 is red.
|
||||
|
||||
**M10 is `+0 B` and it is a real defect, not a synthetic one.** It came out of
|
||||
writing the mutation rather than out of review: `implausible_all_in()`'s floor
|
||||
was applied to the all-in figure, and `price_taxes_eur_kwh` ships at 0.06 —
|
||||
**three times the 0.02 floor** — so a card read a hundred times too small
|
||||
(0.0016 EUR/kWh of energy) is carried over the floor by the flat tax addend
|
||||
alone and passes. The hundredfold error is exactly what that band exists for.
|
||||
The floor is applied to the **energy** figure now and the ceiling to the all-in
|
||||
one, both directions are graded, and the trap is stated as a check in its own
|
||||
right so the asymmetry cannot be "tidied up" later.
|
||||
|
||||
**M15 is the one the framework CANNOT catch, and the reds say which half does.**
|
||||
Declaring `ALL_IN` makes `assemble()` add nothing, and section 3's all-in check
|
||||
is *derived from the declaration* — so it passes on the lie, exactly as
|
||||
`price.py`'s trap 1 says no code can catch a misdeclaration. What goes red is
|
||||
the **docstring/declaration agreement** check, the card reproductions, and the
|
||||
`sources` pin: ten reds, none of them from the arithmetic. That is the
|
||||
guarantee at its true size — the two claims are held to agree, and whether
|
||||
either is true about somebody else's tariff remains human.
|
||||
|
||||
**M22 is the paired run (TEST-16).** The card self-check and the plausibility
|
||||
band both answer a unit error, so each could be masking the other. Weakened
|
||||
**together** they produce twelve reds rather than the eight M1 alone produces,
|
||||
and the band's own checks stay red — so neither rung is doing the other's work.
|
||||
|
||||
### What this battery does NOT establish
|
||||
|
||||
It does not establish that a card is the right card. Every mutation above moves
|
||||
the relationship between the coefficients and the printed price; **none of them
|
||||
can move the relationship between the printed price and the operator's actual
|
||||
contract**, because nothing in this repository observes that. A
|
||||
consistent-but-wrong pair passes every one of these checks, by construction,
|
||||
and `DECISIONS/TARIFF-15-the-card-is-the-grader.md` §3 says so in the same
|
||||
sentence as the claim.
|
||||
|
||||
## Where each surface stands
|
||||
|
||||
* **Local and dev-harness runs — the exposed one.** This is where FCST-03 hit
|
||||
@@ -752,6 +913,7 @@ commit and PR bodies.
|
||||
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
|
||||
| **MAINT-05 round 3**, `migrate_maint_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **was a survivor — now CLOSED** | measured against head `65cddf3` under the purge, tree mounted `:ro`, `19/19 PASS`. It stamps the schema marker on a migration that LOST state, defeating the older `lost` guard as well as the new `misdirected` one, because every check that reached the marker did so on a store whose `kept` list was empty. Closed by `test_maintenance.py` **9d**; the mutation is now red on three named checks. |
|
||||
| **MAINT-05 round 3**, `migrate_unit_keys()` marker gate `if rep.ok:` → `if rep.ok or rep.kept:` (`+12 B`) | **found here — SAFETY-02's half, now CLOSED** | the SAME mutation one function up, on the LATCH migration rather than the history one. Measured a survivor against this branch under the purge, `:ro`, `19/19 PASS`, then closed by `test_inverter.py` part 10's new fixture — a store with `unit/inverter-a/sign_reason` already present (so `kept` is non-empty) whose `sign_tripped: true` write is dropped. Shipped: marker withheld. Mutant: marker stamped on a **disarmed latch**, and the next start skips the migration that would have re-armed it. |
|
||||
| **TARIFF-09** 11 mutations over `app/price.py` and the two-day capture, all red, no survivors | **not at risk** | direction, and method: every mutation was applied to a FRESH `cp -a` of the clean worktree rather than reverted, so there is no revert to verify (habit 18) and no shared scratch path two runs could collide on. Each ran through `tools/mutation_run.sh`, which purges and fingerprints. Byte deltas recorded on the PR; one is **+0 B** - a single price digit edited inside the committed capture, the risky delta - and the re-derived sha256 catches it. |
|
||||
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
|
||||
|
||||
**Nothing here says a past result is wrong.** The two survivors re-run above
|
||||
|
||||
@@ -24,7 +24,7 @@ since the status page shipped; this file has five more chances to get it wrong.
|
||||
|
||||
⚠️ AND IT IS BUILT FOR A PHONE FIRST. This is the screen someone stands in
|
||||
front of the inverter with: one column, big touch targets, sections that start
|
||||
collapsed so 71 options do not arrive as one wall, and the refusal block pinned
|
||||
collapsed so 72 options do not arrive as one wall, and the refusal block pinned
|
||||
where it cannot be scrolled past.
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
"""TARIFF-15 - the customer's own tariff card, as two fixed coefficients.
|
||||
|
||||
WHAT THIS IS FOR, in one sentence: a Belgian dynamic contract prices energy as
|
||||
`B x index + A` with coefficients that exist in exactly one place - the tariff
|
||||
card in the customer's klantenzone - so the customer types those two numbers
|
||||
and the add-on does every unit conversion and the VAT multiplication itself.
|
||||
|
||||
WHY A FORMULA AND NOT FOURTEEN MORE PLUGINS (§9.2, TARIFF-07)
|
||||
=============================================================
|
||||
There is NO machine-readable source of Belgian tariff coefficients anywhere.
|
||||
CREG's consumer tariff-card pages 404, CREG Scan is a JS-only wizard, the
|
||||
annual report carries the supplier list and no per-product coefficients, and
|
||||
VREG was unreachable. Every coefficient found in research came off a supplier's
|
||||
own PDF, one of them image-only. So there is nothing to scrape and a
|
||||
per-supplier plugin would have no source for its numbers - fourteen plugins
|
||||
would be fourteen copies of the same invention, which is the defect TARIFF-07
|
||||
withdrew three plugins for. THE CUSTOMER IS THE ONLY PARTY WHO CAN SEE THEIR
|
||||
OWN COEFFICIENTS, and the card they read them from is the only artefact that
|
||||
can grade them.
|
||||
|
||||
THE SHAPE IS THE MARKET'S PUBLISHED ARITHMETIC, NOT OUR APPROXIMATION
|
||||
====================================================================
|
||||
Both suppliers whose 2026 cards were readable print the formula, the reference
|
||||
index they assumed AND the resulting price, so the arithmetic below is
|
||||
reproducible against something they published rather than against our own
|
||||
guess:
|
||||
|
||||
Eneco (0.102 x 133.9905 + 1.00) x 1.06 = 15.5471 card prints 15,55
|
||||
ENGIE (0.100 x 61.96 + 0.90) x 1.06 = 7.5218 card prints 7,522
|
||||
|
||||
Two independent suppliers, reproduced to the last printed digit, in
|
||||
c EUR/kWh. test_price.py section 13 runs both through the SHIPPED code.
|
||||
|
||||
⚠️ THE DOUBLE-COUNTING MECHANISM, AND WHY THIS DESIGN IS SAFE AT ALL
|
||||
====================================================================
|
||||
This provider declares `sources = {ENERGY, MARKUP}` and NOT `ALL_IN`. A Belgian
|
||||
card's `Tariefformule` line is THE ENERGY COMPONENT ONLY: Eneco puts network
|
||||
tariffs in one table and levies in a second, and ENGIE states in so many words
|
||||
that the formula "does not include fixed network/distribution costs, transport,
|
||||
grid levies, or excise duties". Declaring ALL_IN would lie on behalf of every
|
||||
user who typed what their card says, and would silently drop ~12 c EUR/kWh.
|
||||
|
||||
price.py already mechanises that a PLUGIN cannot double-count (`PluginOptions`,
|
||||
`FRAMEWORK_KEYS`, `code_leaks()`). This provider introduces a risk none of those
|
||||
can see, because it is not in the code: THE OPERATOR double-counting, by folding
|
||||
their network charges into `offset` while `price_taxes_eur_kwh` carries them as
|
||||
well. `code_leaks()` scans code. This is config.
|
||||
|
||||
So the card is the grader:
|
||||
|
||||
At startup validate.py computes
|
||||
`(factor x card.index_eur_per_mwh + offset) x (1 + vat_pct/100)` and
|
||||
REFUSES TO START unless it equals `card.expect_incl_vat` to within
|
||||
CARD_TOLERANCE, naming the discrepancy.
|
||||
|
||||
An `offset` inflated by network charges CANNOT PASS: the card's printed estimate
|
||||
is by construction the energy component alone, so adding ~12 c EUR/kWh moves
|
||||
15.55 to ~27 and the add-on refuses. The same check catches VAT omitted, VAT
|
||||
applied twice, a transposed digit, and a UNIT MISMATCH BETWEEN THE TWO SIDES -
|
||||
coefficients read off the formula line while the expected price is typed in
|
||||
another convention, which is the factor-10/100/1000 class as it actually
|
||||
happens.
|
||||
|
||||
⚠️ THE ONE UNIT ERROR THE CARD CHECK CANNOT SEE IS THE ONE APPLIED TO BOTH
|
||||
SIDES AT ONCE, and saying so is the difference between a guard and a slogan:
|
||||
scaling the coefficients AND the expected price by the same 100 cancels out of
|
||||
a comparison between them. That is what `implausible_all_in()` is for, and it
|
||||
is a SEPARATE refusal on a SEPARATE quantity - the card's own point, all-in,
|
||||
against the band a Belgian retail tariff can occupy.
|
||||
|
||||
⚠️ AND THE LIMIT, IN THE SAME BREATH AS THE CLAIM, exactly as price.py's trap 1
|
||||
states its own: THE CHECK GRADES THE COEFFICIENTS AGAINST THE CARD, NOT THE CARD
|
||||
AGAINST REALITY. A consistent-but-wrong pair - both numbers copied off the wrong
|
||||
product, or off last year's card - reproduces its own printed price perfectly
|
||||
and passes. Nothing here or anywhere else can catch that; what is mechanised is
|
||||
that the two coefficients and the printed estimate AGREE, which is the whole of
|
||||
the unit/VAT/decimal-point class and none of the "wrong contract" class.
|
||||
|
||||
⚠️ FIXED COEFFICIENTS. NO EXPRESSION EVALUATION OF ANY KIND.
|
||||
============================================================
|
||||
All 24 surveyed Belgian products, both injection legs and aWATTar AT fit
|
||||
`B x index + A`, so an expression buys nothing the market needs. Against that:
|
||||
|
||||
* the add-on serves `POST config/save` -> `POST /addons/self/options` on its
|
||||
own ingress page, so this string is SETTABLE OVER HTTP. `eval` on it is
|
||||
remote code execution in a root container holding SUPERVISOR_TOKEN, with
|
||||
`homeassistant_api` and `hassio_api`, driving an inverter over RS485.
|
||||
(`eval(s, {"__builtins__": {}})` is not a sandbox and never was.)
|
||||
* ⚠️ AND THE DECIDING ARGUMENT IS NOT SECURITY - AN EXPRESSION IS
|
||||
UNVERIFIABLE. Two coefficients can be graded against the card's printed
|
||||
estimate. An arbitrary expression cannot be graded against anything, so it
|
||||
makes the invisible-wrong-coefficient problem STRICTLY WORSE, which is the
|
||||
exact failure this provider exists to prevent.
|
||||
|
||||
Future shapes become NAMED FIELDS - `clamp_lo`, `factor_negative`,
|
||||
`offset_bands` - each individually refusable and each individually gradeable
|
||||
against a card. Never a grammar. `parse_price_formula()` therefore REFUSES AN
|
||||
UNKNOWN KEY rather than ignoring it, so a user who types `"expr"` is told it
|
||||
does not exist instead of being silently priced on the fields they also typed.
|
||||
test_price.py section 13f walks this module's AST and fails if `eval`, `exec`,
|
||||
`compile`, `__import__` or `literal_eval` ever appears in it.
|
||||
|
||||
WHERE THE NUMBERS COME FROM AND WHERE THEY GO
|
||||
=============================================
|
||||
`index` names the reference series. `epex_be` is the EPEX BE day-ahead curve in
|
||||
EUR/MWh, which is what `generic_dayahead` already fetches - same feed, same
|
||||
`brussels_date_range()` request, same columnar parser, same declared egress
|
||||
hosts. No new parser and no new failure mode.
|
||||
|
||||
`resolution` says which series the contract is written against:
|
||||
|
||||
* `quarter_hourly` - the 96 points the feed publishes since Belgium's
|
||||
15-minute MTU (2025-10-01), emitted as they are;
|
||||
* `hourly` - the four quarters of each hour averaged into one point.
|
||||
|
||||
⚠️ NEITHER IS AN HOURLY ASSUMPTION IN `_Series.at()`'s SENSE (TARIFF-14). The
|
||||
hourly option changes how many POINTS are emitted and their spacing; the window
|
||||
each point covers is still derived from its neighbours by `_cover_table()`, and
|
||||
nothing here tells anything downstream how long a point lasts. A feed that is
|
||||
already hourly averages one member per hour, which is the identity - so the
|
||||
option cannot silently assume the granularity it was handed.
|
||||
|
||||
`units` is NOT bureaucracy. Two suppliers publish the same shape in different
|
||||
units and the market aggregator prints both conventions in one column, so the
|
||||
add-on has to be told which one the card is in - and the unit conversion is the
|
||||
add-on's job precisely because it is one of the two steps that silently costs a
|
||||
factor of 10, 100 or 1000. The other is VAT, which is also the add-on's.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
|
||||
class FormulaInvalid(Exception):
|
||||
"""`price_formula` cannot be used. Raised by the parser, refused by
|
||||
validate.py at startup - never at 03:00 on a fetch."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The enumerations. Every one of these is a CLOSED set, and that is the point:
|
||||
# an unknown value is refused by name rather than guessed at.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# unit -> how many EUR/kWh one of it is. ⚠️ THE WHOLE FACTOR-1000 CLASS LIVES
|
||||
# HERE, so the table is tiny, explicit, and has no "default".
|
||||
UNITS: dict = {
|
||||
"c_eur_per_kwh": 0.01, # eurocent per kWh - Eneco and ENGIE print this
|
||||
"eur_per_kwh": 1.0, # some aggregators print this
|
||||
"eur_per_mwh": 0.001, # the wholesale convention, occasionally reused
|
||||
}
|
||||
|
||||
RESOLUTIONS: tuple = ("hourly", "quarter_hourly")
|
||||
|
||||
# index id -> the human name of the series it refers to. One entry today; a
|
||||
# second index is a new row here plus a fetch path, not a new grammar.
|
||||
INDEXES: dict = {
|
||||
"epex_be": "EPEX BE day-ahead, EUR/MWh",
|
||||
}
|
||||
|
||||
# ⚠️ 0.01 IN THE CARD'S OWN UNITS, WHICH IS THE LAST DIGIT BOTH REAL CARDS
|
||||
# PRINT. Eneco prints 15,55 against a computed 15.5471 (0.0029 out) and ENGIE
|
||||
# prints 7,522 against a computed 7.5218 (0.0002 out), so the tolerance has to
|
||||
# absorb the card's own rounding and nothing more. Every error class this check
|
||||
# exists for - a factor of 10, of 100, of 1000, a missing or doubled 1.06, a
|
||||
# transposed digit, ~12 c EUR/kWh of network charge folded into `offset` - is
|
||||
# at least two orders of magnitude bigger than this.
|
||||
CARD_TOLERANCE = 0.01
|
||||
|
||||
# The all-in plausibility band, EUR/kWh. `MAX_PLAUSIBLE_EUR_KWH` in price.py
|
||||
# bounds only prices that are too LARGE; this is the other end, and it is the
|
||||
# one the factor-1000 error falls off.
|
||||
PLAUSIBLE_LO_EUR_KWH = 0.02
|
||||
PLAUSIBLE_HI_EUR_KWH = 1.00
|
||||
|
||||
# ~6 months. ⚠️ A WARNING, NEVER A REFUSAL: ENGIE freezes a contract's
|
||||
# coefficients for its duration, so a card two years old can be exactly right
|
||||
# and a site refused over one would be a site that cannot start.
|
||||
CARD_STALE_DAYS = 183
|
||||
|
||||
_CARD_KEYS = ("month", "index_eur_per_mwh", "expect_incl_vat")
|
||||
_TOP_KEYS = ("index", "resolution", "factor", "offset", "units", "vat_pct",
|
||||
"card")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormulaSpec:
|
||||
"""One contract's published arithmetic. SEVEN numbers and no code.
|
||||
|
||||
⚠️ EVERY FIELD IS A FIXED, NAMED SCALAR. There is deliberately no field
|
||||
that holds an expression, a callable, a lambda or a template, and there is
|
||||
no place to put one: `parse_price_formula()` refuses an unrecognised key.
|
||||
"""
|
||||
|
||||
index: str
|
||||
resolution: str
|
||||
factor: float
|
||||
offset: float
|
||||
units: str
|
||||
vat_pct: float
|
||||
card_month: str # "YYYY-MM", as the card is dated
|
||||
card_index_eur_per_mwh: float
|
||||
card_expect_incl_vat: float # in `units`, as the card prints it
|
||||
|
||||
@property
|
||||
def unit_to_eur_kwh(self) -> float:
|
||||
return UNITS[self.units]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The arithmetic. Two functions, and the SECOND one is the contract.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def in_card_units(spec: FormulaSpec, index_eur_per_mwh: float) -> float:
|
||||
"""`(factor x index + offset) x (1 + vat_pct/100)`, in the card's units.
|
||||
|
||||
This is the line the card prints, spelled exactly as the card spells it,
|
||||
and it is the only place the coefficients are combined. The card
|
||||
self-check and the price path both go through here, so a mutation to it
|
||||
cannot move the price without also moving the self-check.
|
||||
"""
|
||||
return ((spec.factor * float(index_eur_per_mwh) + spec.offset)
|
||||
* (1.0 + spec.vat_pct / 100.0))
|
||||
|
||||
|
||||
def price_eur_kwh(spec: FormulaSpec, index_eur_per_mwh: float) -> float:
|
||||
"""The energy+markup price for one index value, in EUR/kWh.
|
||||
|
||||
⚠️ THE UNIT CONVERSION IS THE ADD-ON'S JOB AND IT HAPPENS HERE, ONCE. The
|
||||
user types what their card prints, in the card's own units; every step
|
||||
from there to EUR/kWh is code, because those steps are exactly the ones
|
||||
that silently cost a factor of 10, 100 or 1000 when a human does them.
|
||||
"""
|
||||
return in_card_units(spec, index_eur_per_mwh) * spec.unit_to_eur_kwh
|
||||
|
||||
|
||||
def card_discrepancy(spec: FormulaSpec) -> float:
|
||||
"""How far the coefficients land from the card's own printed estimate.
|
||||
|
||||
In the card's units, signed: positive means the formula computes MORE than
|
||||
the card prints, which is the direction an `offset` carrying network
|
||||
charges moves it.
|
||||
"""
|
||||
return in_card_units(spec, spec.card_index_eur_per_mwh) \
|
||||
- spec.card_expect_incl_vat
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Reading the option
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _finite(value, field: str) -> float:
|
||||
"""A real, finite number - or a refusal naming the field.
|
||||
|
||||
⚠️ SAME CLASS AS `_validate_raw()`'s NaN RULE, one layer earlier. A NaN
|
||||
factor makes every price NaN, every comparison False, and an arbitrage
|
||||
strategy that silently never fires; `True` is an `int` in Python and would
|
||||
price the whole day at the index.
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.{field} is {value!r}, which is not a number. "
|
||||
f"Write the coefficient as a bare JSON number - 0.102, not "
|
||||
f"\"0.102\" and not an expression: this provider takes FIXED "
|
||||
f"coefficients and evaluates nothing.")
|
||||
if not math.isfinite(float(value)):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.{field} is {value!r}, which is not finite. A NaN "
|
||||
f"or an infinity here makes every price NaN, every price "
|
||||
f"comparison False, and an arbitrage strategy that never fires "
|
||||
f"while reporting that it is armed.")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _card(raw) -> tuple:
|
||||
"""The `card` block: the estimate the coefficients are graded against."""
|
||||
if raw is None:
|
||||
raise FormulaInvalid(
|
||||
"price_formula has no \"card\" block, and it is REQUIRED. It is "
|
||||
"the only thing that can tell a correct pair of coefficients from "
|
||||
"a plausible-looking wrong one: the add-on recomputes your "
|
||||
"supplier's own printed example from the numbers you typed and "
|
||||
"refuses to start if it does not come out. Copy the three figures "
|
||||
"off the same tariff card as the coefficients: "
|
||||
"{\"month\": \"2026-09\", \"index_eur_per_mwh\": 133.9905, "
|
||||
"\"expect_incl_vat\": 15.55}.")
|
||||
if not isinstance(raw, dict):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.card is {type(raw).__name__}, not an object with "
|
||||
f"{', '.join(_CARD_KEYS)}.")
|
||||
unknown = sorted(set(raw) - set(_CARD_KEYS))
|
||||
if unknown:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.card has no field(s) {unknown}. The fields it "
|
||||
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
|
||||
f"number the operator believes took effect.")
|
||||
month = str(raw.get("month", "") or "").strip()
|
||||
if not _month_date(month):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.card.month is {raw.get('month')!r}; write the "
|
||||
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
|
||||
f"not used in any price - it is what lets the add-on tell you "
|
||||
f"your card has gone stale.")
|
||||
return (month,
|
||||
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh"),
|
||||
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat"))
|
||||
|
||||
|
||||
def _month_date(month: str):
|
||||
"""`"2026-09"` -> the first of that month, or None if it is not one."""
|
||||
try:
|
||||
year, mon = str(month).split("-")
|
||||
return date(int(year), int(mon), 1)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_price_formula(value):
|
||||
"""`price_formula` -> a `FormulaSpec`, or None when unset.
|
||||
|
||||
Accepts what the option can actually arrive as, exactly as
|
||||
`parse_static_profile()` does:
|
||||
|
||||
* `None` / `""` / `"null"` - unset.
|
||||
* a JSON string - what config.yaml's flat `str?` option
|
||||
carries on a Supervisor install.
|
||||
* a dict - already parsed, from a future nested
|
||||
schema, from sim/inject.py, or a check.
|
||||
|
||||
Raises `FormulaInvalid`; never returns a half-built spec.
|
||||
|
||||
⚠️ THE CARD SELF-CHECK IS PART OF PARSING, not a separate step a caller
|
||||
may forget. Both callers - validate.py's startup gate and the plugin's own
|
||||
fetch - go through this one function, so there is no route to a price that
|
||||
has not been graded against the card.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text or text.lower() in ("null", "none", "off"):
|
||||
return None
|
||||
try:
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula is not valid JSON ({e}). It is a JSON object "
|
||||
f"on one line, e.g.\n"
|
||||
f' {{"index":"epex_be","resolution":"hourly","factor":0.102,'
|
||||
f'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
|
||||
f'"card":{{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
||||
f'"expect_incl_vat":15.55}}}}\n'
|
||||
f"Leave it empty unless price_provider is 'custom_formula'.")
|
||||
if not isinstance(value, dict):
|
||||
raise FormulaInvalid(
|
||||
f"price_formula is {type(value).__name__}, not a JSON object with "
|
||||
f"{', '.join(_TOP_KEYS)}.")
|
||||
|
||||
# ⚠️ AN UNKNOWN KEY IS REFUSED, NOT IGNORED, AND THIS IS THE HALF THAT
|
||||
# KEEPS THE "NEVER A GRAMMAR" PROMISE HONEST. A future shape is a NAMED
|
||||
# field - clamp_lo, factor_negative, offset_bands - added here and
|
||||
# refusable on its own. Ignoring unknown keys would mean a user who typed
|
||||
# "expr" or "formula" gets priced on the fields they also typed, with the
|
||||
# one they believed in dropped in silence.
|
||||
unknown = sorted(set(value) - set(_TOP_KEYS))
|
||||
if unknown:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula has no field(s) {unknown}. The fields it takes "
|
||||
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
|
||||
f"takes an expression: this provider multiplies and adds two "
|
||||
f"FIXED coefficients and evaluates nothing, because two "
|
||||
f"coefficients can be graded against your card's printed price "
|
||||
f"and an arbitrary expression cannot be graded against anything. "
|
||||
f"If your contract needs a shape this cannot express, it needs a "
|
||||
f"new named field here - say which shape.")
|
||||
|
||||
index = str(value.get("index", "") or "").strip()
|
||||
if index not in INDEXES:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.index is {value.get('index')!r}. The reference "
|
||||
f"series this add-on can fetch are "
|
||||
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
|
||||
f"If your card names a different index, the add-on cannot get it "
|
||||
f"and guessing which series your supplier meant would price your "
|
||||
f"battery against the wrong curve.")
|
||||
|
||||
resolution = str(value.get("resolution", "") or "").strip()
|
||||
if resolution not in RESOLUTIONS:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.resolution is {value.get('resolution')!r}; it "
|
||||
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
|
||||
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
|
||||
f"a day; a contract written against the HOURLY series is priced "
|
||||
f"on the mean of each hour's quarters. Which one your contract "
|
||||
f"uses is on your card and cannot be inferred from the feed.")
|
||||
|
||||
# ⚠️ `units` IS REQUIRED AND HAS NO DEFAULT. A default here would be the
|
||||
# factor-1000 class with a friendly face: the add-on would silently assume
|
||||
# a convention the card never stated, and the card self-check would then
|
||||
# be graded in units nobody chose.
|
||||
units = str(value.get("units", "") or "").strip()
|
||||
if units not in UNITS:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.units is {value.get('units')!r} and is REQUIRED - "
|
||||
f"there is no default, on purpose. It must be one of "
|
||||
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
|
||||
f"and two suppliers use two different conventions for the same "
|
||||
f"shape, so assuming one would be a factor of 100 or 1000 on "
|
||||
f"every hour with nothing able to notice.")
|
||||
|
||||
factor = _finite(value.get("factor"), "factor")
|
||||
offset = _finite(value.get("offset"), "offset")
|
||||
vat_pct = _finite(value.get("vat_pct"), "vat_pct")
|
||||
|
||||
# ⚠️ ZERO IS NOT "NO INDEXATION", IT IS A FIXED TARIFF WEARING A DYNAMIC
|
||||
# ONE'S CLOTHES. Every hour comes out at the same price, so every spread is
|
||||
# 0, arbitrage can never clear any threshold, and a strategy that never
|
||||
# fires is indistinguishable from one that is switched off (§14's warning
|
||||
# box). The add-on already has a first-class way to say "one price": point
|
||||
# the operator at it rather than let them build it by accident.
|
||||
if factor == 0.0:
|
||||
raise FormulaInvalid(
|
||||
"price_formula.factor is 0, so the index would not enter the "
|
||||
"price at all: every hour of every day comes out at "
|
||||
f"{offset:g} x (1 + {vat_pct:g}/100), every spread is exactly 0, "
|
||||
"and arbitrage can never clear any threshold - a strategy that "
|
||||
"silently never fires rather than one that is off. If your tariff "
|
||||
"really is one flat price, that is `static_profile` with a single "
|
||||
"period covering 00:00-24:00 on all days and price_provider "
|
||||
"'off'; the add-on then says so at startup instead of leaving you "
|
||||
"to find out from a year of unchanged bills.")
|
||||
|
||||
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
|
||||
# the dearest hour becomes the cheapest one, so the battery is told to
|
||||
# charge into the evening peak and to discharge at 03:00. That is not a
|
||||
# mispriced site, it is a site actively trading against itself.
|
||||
if factor < 0.0:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.factor is {factor:g}, which is NEGATIVE. That "
|
||||
f"inverts the market: the dearest hour of the day comes out as "
|
||||
f"the cheapest one, so the battery would charge at the evening "
|
||||
f"peak and discharge at 03:00 - trading against itself on every "
|
||||
f"cycle. No consumption tariff is indexed negatively; if your "
|
||||
f"card shows a minus sign it is on the OFFSET (`A`), which is "
|
||||
f"allowed here and common on injection.")
|
||||
|
||||
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
|
||||
# and some cards are already quoted VAT-inclusive. The bound exists for the
|
||||
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
|
||||
if not 0.0 <= vat_pct <= 30.0:
|
||||
raise FormulaInvalid(
|
||||
f"price_formula.vat_pct is {vat_pct:g} and must be between 0 and "
|
||||
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
|
||||
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
|
||||
f"injection is VAT-exempt, and a card that already quotes "
|
||||
f"VAT-inclusive coefficients needs no second multiplication.")
|
||||
|
||||
month, card_index, card_expect = _card(value.get("card"))
|
||||
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
|
||||
offset=offset, units=units, vat_pct=vat_pct,
|
||||
card_month=month, card_index_eur_per_mwh=card_index,
|
||||
card_expect_incl_vat=card_expect)
|
||||
|
||||
delta = card_discrepancy(spec)
|
||||
if abs(delta) > CARD_TOLERANCE:
|
||||
raise FormulaInvalid(card_refusal(spec, delta))
|
||||
return spec
|
||||
|
||||
|
||||
def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
||||
"""The sentence an operator reads when their formula misses its own card.
|
||||
|
||||
⚠️ IT NAMES THE DISCREPANCY AND THE SIX WAYS IT HAPPENS. "Invalid
|
||||
configuration" would be true and useless: every one of these errors looks
|
||||
exactly like a working tariff from the status page, which is why the check
|
||||
exists at all.
|
||||
"""
|
||||
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
||||
ratio = (got / spec.card_expect_incl_vat
|
||||
if spec.card_expect_incl_vat else float("inf"))
|
||||
return (
|
||||
f"price_formula does not reproduce its own card, so the add-on will "
|
||||
f"NOT start on it. Your contract's arithmetic is "
|
||||
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
|
||||
f"on the card's own index that is "
|
||||
f"({spec.factor:g} x {spec.card_index_eur_per_mwh:g} + "
|
||||
f"{spec.offset:g}) x (1 + {spec.vat_pct:g}/100) = {got:.4f} "
|
||||
f"{spec.units}, but card.expect_incl_vat says "
|
||||
f"{spec.card_expect_incl_vat:g} - OUT BY {delta:+.4f} {spec.units} "
|
||||
f"(ratio {ratio:.4g}; the tolerance is {CARD_TOLERANCE}, which is the "
|
||||
f"last digit a card prints). One of the two sides is wrong and the "
|
||||
f"add-on cannot tell which, so it refuses rather than price a battery "
|
||||
f"on it. The usual causes, in the order they happen: the coefficients "
|
||||
f"and the expected price are in DIFFERENT units (ratio 10, 100 or "
|
||||
f"1000 - `units` must be the one the formula line is printed in); VAT "
|
||||
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
|
||||
f"digit; the two sides copied off DIFFERENT cards or different "
|
||||
f"months; or - the expensive one - network charges, levies or excise "
|
||||
f"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian card's "
|
||||
f"Tariefformule line is the ENERGY component alone (Eneco tabulates "
|
||||
f"network tariffs and levies separately; ENGIE says in so many words "
|
||||
f"that the formula excludes network, distribution, transport, levies "
|
||||
f"and excise), the add-on adds price_taxes_eur_kwh on top of it, and "
|
||||
f"folding ~12 c EUR/kWh of network charge into `offset` bills you for "
|
||||
f"it twice. That is the error this check exists to catch, and it is "
|
||||
f"why it cannot be switched off.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The two questions the PLUGIN cannot ask, because they need framework numbers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
|
||||
"""The card's own point, if its LEVEL cannot be a Belgian retail price.
|
||||
|
||||
Returns a refusal sentence or None. ⚠️ CALLED FROM validate.py AND FROM
|
||||
NOWHERE ELSE, because it reads a FRAMEWORK-owned number: a plugin that
|
||||
could see `taxes` could add it a second time, which is price.py's trap 1.
|
||||
|
||||
⚠️ THE LOW END IS GRADED ON THE ENERGY COMPONENT AND THE HIGH END ON THE
|
||||
ALL-IN FIGURE, AND THAT ASYMMETRY IS THE CHECK RATHER THAN AN OVERSIGHT.
|
||||
`price_taxes_eur_kwh` ships at 0.06, which is three times the low bound -
|
||||
so a card read a HUNDRED times too small comes out at 0.0016 EUR/kWh of
|
||||
energy, is carried over the floor by the flat tax addend alone, and passes
|
||||
a band applied to the all-in total. The hundredfold error is precisely
|
||||
what this exists to catch, so the floor is applied where it can still see
|
||||
it. The ceiling is applied to the all-in figure because that is the number
|
||||
the site is really billed at, and taxes only push it up.
|
||||
|
||||
⚠️ AND THE LIMIT, WITH THE CLAIM. This grades the CARD'S OWN POINT - one
|
||||
price, at the monthly index the card assumed - and not every hour of every
|
||||
day. A live EPEX hour can and should fall outside this band (a negative
|
||||
hour is the best charging opportunity of the year, and §17 keeps a
|
||||
regression case for it), so the band is deliberately NOT applied to the
|
||||
fetched series. What it catches is a formula whose whole LEVEL is wrong,
|
||||
which is the factor-of-1000 class arriving from the end
|
||||
`MAX_PLAUSIBLE_EUR_KWH` does not bound.
|
||||
"""
|
||||
energy = price_eur_kwh(spec, spec.card_index_eur_per_mwh)
|
||||
all_in = energy + float(taxes_eur_kwh)
|
||||
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
|
||||
return None
|
||||
return (
|
||||
f"price_formula's own card works out at {energy:.5f} EUR/kWh of "
|
||||
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
|
||||
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
|
||||
f"price_taxes_eur_kwh is added, which is outside the plausible band "
|
||||
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh for a Belgian "
|
||||
f"retail tariff. The card self-check passed, so the coefficients and "
|
||||
f"the printed price AGREE - they simply agree on a number no Belgian "
|
||||
f"bill carries, which is what a whole card read in the wrong units "
|
||||
f"looks like (a factor of 10, 100 or 1000 applied to BOTH sides at "
|
||||
f"once cancels out of that check and cannot cancel out of this one). "
|
||||
f"Check `units` against the unit printed beside your formula. "
|
||||
f"⚠️ This bounds the CARD'S OWN POINT, not every hour: a live hour may "
|
||||
f"legitimately fall outside the band, and a negative day-ahead hour - "
|
||||
f"the best charging opportunity of the year - is never refused.")
|
||||
|
||||
|
||||
def stale_card(spec: FormulaSpec, today=None):
|
||||
"""A warning when the card is older than ~6 months, or None. NEVER a
|
||||
refusal: ENGIE freezes a contract's coefficients for its duration, so an
|
||||
old card can be exactly right and refusing one would stop the add-on on a
|
||||
correctly configured site."""
|
||||
dated = _month_date(spec.card_month)
|
||||
now = today or datetime.now(timezone.utc).date()
|
||||
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
|
||||
return None
|
||||
return (
|
||||
f"price_formula's card is dated {spec.card_month}, which is "
|
||||
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
|
||||
f"has been renewed or rolled over since then, the coefficients here "
|
||||
f"are last year's and every price this add-on computes is wrong by "
|
||||
f"however far they moved - and the card self-check cannot see it, "
|
||||
f"because it grades your coefficients against the card you typed, not "
|
||||
f"the card against your current contract. Not refused: ENGIE freezes "
|
||||
f"coefficients for the contract's duration, so an old card is often "
|
||||
f"exactly right. Worth re-reading your klantenzone once.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Resolution
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def to_resolution(pairs, resolution: str) -> list:
|
||||
"""`[(instant, index EUR/MWh)]` -> the series the CONTRACT is written on.
|
||||
|
||||
`quarter_hourly` hands the feed's own points back, ascending.
|
||||
`hourly` averages each hour's members into one point AT THE TOP OF THE
|
||||
HOUR.
|
||||
|
||||
⚠️ THE BUCKET IS A UTC HOUR AND THAT IS EXACT FOR BELGIUM, not an
|
||||
approximation: CET and CEST are both WHOLE-hour offsets, so a Brussels hour
|
||||
and a UTC hour start at the same instant on every day of the year including
|
||||
both transition days. Keying by a naive local hour is what collides the two
|
||||
02:00s in October; keying by the UTC instant cannot.
|
||||
|
||||
⚠️ AND THIS IS NOT `_Series.at()`'s HOURLY ASSUMPTION COMING BACK
|
||||
(TARIFF-14). It decides how many points are EMITTED, not how long one
|
||||
covers - the coverage window is still derived from the neighbours in
|
||||
`_cover_table()`. A feed that already publishes hourly points averages one
|
||||
member per hour, i.e. the identity, so this cannot silently assume the
|
||||
granularity it was handed.
|
||||
|
||||
⚠️ AN INCOMPLETE HOUR IS AVERAGED OVER WHAT IS THERE rather than dropped or
|
||||
zero-filled. A horizon that starts at 09:20 has two quarters in its first
|
||||
hour; their mean is the best statement available about that hour, and
|
||||
dropping it would leave a hole `at()` answers with `Absence` for an hour
|
||||
the feed did publish.
|
||||
"""
|
||||
ordered = sorted(pairs, key=lambda r: r[0].astimezone(timezone.utc))
|
||||
if resolution == "quarter_hourly":
|
||||
return list(ordered)
|
||||
if resolution != "hourly":
|
||||
raise FormulaInvalid(f"unknown resolution {resolution!r}")
|
||||
buckets: dict = {}
|
||||
for start, value in ordered:
|
||||
key = start.astimezone(timezone.utc).replace(
|
||||
minute=0, second=0, microsecond=0)
|
||||
buckets.setdefault(key, []).append(value)
|
||||
return [(k, sum(v) / len(v)) for k, v in sorted(buckets.items())]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The startup preview
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
PREVIEW_HOURS = 24
|
||||
PREVIEW_MAX_ROWS = 24
|
||||
|
||||
|
||||
def preview_lines(spec: FormulaSpec, rows) -> list:
|
||||
"""`[(instant, index EUR/MWh, all-in EUR/kWh)]` -> lines for the log.
|
||||
|
||||
THE DEFECT THIS EXISTS TO MAKE VISIBLE: every error this module refuses is
|
||||
one the operator cannot see from the status page, and the two numbers that
|
||||
would have shown it - the index that went IN and the price that came OUT -
|
||||
are never printed beside each other anywhere else. A factor of 1000 is
|
||||
obvious in one line of `61.96 -> 0.13522` and invisible in a plan.
|
||||
|
||||
⚠️ IT PRINTS A BOUNDED NUMBER OF ROWS AND SAYS SO. At `quarter_hourly` the
|
||||
next 24 h is 96 points; the cap is a row COUNT rather than "every fourth
|
||||
point", because sampling every fourth point would assume the granularity
|
||||
TARIFF-14 spent a card removing the assumption of.
|
||||
"""
|
||||
if not rows:
|
||||
return []
|
||||
shown = rows[:PREVIEW_MAX_ROWS]
|
||||
prices = sorted(r[2] for r in rows)
|
||||
mid = len(prices) // 2
|
||||
median = (prices[mid] if len(prices) % 2
|
||||
else (prices[mid - 1] + prices[mid]) / 2.0)
|
||||
out = [
|
||||
f"custom_formula preview - the next {PREVIEW_HOURS} h of YOUR tariff, "
|
||||
f"index EUR/MWh -> all-in EUR/kWh, computed as "
|
||||
f"({spec.factor:g} x index + {spec.offset:g}) x "
|
||||
f"(1 + {spec.vat_pct:g}/100) {spec.units}, converted to EUR/kWh and "
|
||||
f"with price_taxes_eur_kwh added by the framework:"]
|
||||
for start, index, all_in in shown:
|
||||
out.append(f" {start.isoformat()} {index:9.3f} -> {all_in:9.5f}")
|
||||
if len(rows) > len(shown):
|
||||
out.append(f" ... {len(rows) - len(shown)} further point(s) not "
|
||||
f"listed; the figures below cover ALL {len(rows)}")
|
||||
out.append(
|
||||
f" min {prices[0]:.5f} / median {median:.5f} / max {prices[-1]:.5f} "
|
||||
f"EUR/kWh, spread {prices[-1] - prices[0]:.5f} EUR/kWh over "
|
||||
f"{len(rows)} point(s). ⚠️ If those are a thousand times what your "
|
||||
f"bill says, `units` is wrong; if the spread is 0, `factor` is.")
|
||||
return out
|
||||
+308
-18
@@ -67,7 +67,7 @@ from .mqtt import (MqttPublisher, announced_units, unit_slugs,
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, price_option
|
||||
from .price import Uncertainty, price_option, provider_configured
|
||||
from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
@@ -138,6 +138,60 @@ TARIFF_REFRESH_S = 1800.0
|
||||
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
def tariff_refresh_wanted(opts: dict) -> bool:
|
||||
"""TARIFF-10. Does this install need the background tariff refresh at all?
|
||||
|
||||
THE DEFECT THIS PREVENTS, and it shipped: the gate below used to be
|
||||
`arbitrage.enabled(opts)` alone. That option defaults to `false`, and
|
||||
`run_tariff_refresh` is the only caller of `resolve_tariff()`, which is the
|
||||
only caller of `price_history.carry()` - so on a shipped install with a
|
||||
provider configured NOTHING was ever fetched, no plan was ever resolved,
|
||||
`/data/price_history.json` was never created, the *Price history* row read
|
||||
`empty` forever, and TARIFF-09's short-horizon warning could not fire
|
||||
because there was no fetch to warn about. Measured over a 40 h soak of two
|
||||
instances side by side: zero feed requests and `present: false` across all
|
||||
802 samples on the shipped default, against a resolved plan from the same
|
||||
feed on the instance with arbitrage armed. ⚠️ AND `DOCS.md` HAS
|
||||
DESCRIBED THE BEHAVIOUR BELOW SINCE THE COMMIT THAT WROTE THE GATE:
|
||||
OPT-04 (bdd7f18) added the refresh task, the `arbitrage.enabled()` guard on
|
||||
it, and the sentence saying a configured provider still fetches, all in one
|
||||
change. config.yaml has said it since TARIFF-02 (852fc7c).
|
||||
|
||||
Two terms, and they answer two different questions:
|
||||
|
||||
provider_configured(opts) -> "the operator asked for a price feed", so
|
||||
fetch it, validate it and record the day
|
||||
(§8.3). This is the term TARIFF-10 ADDS.
|
||||
arbitrage.enabled(opts) -> "the operator armed trading". This term is
|
||||
what the gate USED to be, kept verbatim.
|
||||
|
||||
⚠️ IT IS AN `or`, SO THE GATE IS A STRICT WIDENING. Every configuration
|
||||
that started this task before still starts it; none that did not start it
|
||||
stops anything. That matters because the second term is not redundant:
|
||||
`price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
configuration validate.py explicitly ACCEPTS, and it trades - measured, at
|
||||
`cycle_cost_threshold_eur: 0.05` on a 0.20/0.284 day/night profile,
|
||||
`ARBITRAGE_CHARGE -5000 W`. Dropping that term to make the gate read
|
||||
`provider_configured(opts)` alone would leave `self.tariff_plan` at None on
|
||||
that site forever and `decide()` would answer "no tariff plan has been
|
||||
resolved yet" on every cycle - silently disarming a strategy the UI says is
|
||||
running, which is the exact failure validate.py's OPT-04 rules exist to
|
||||
refuse.
|
||||
|
||||
⚠️ AND NO CONFIGURATION ACQUIRES A PERIODIC HTTPS CALL FROM THIS TICKET.
|
||||
With `price_provider` off, `resolve_tariff()` returns `static_plan()` - pure
|
||||
arithmetic, no socket - so the second term starts a task that fetches
|
||||
nothing. The true shipped default (no provider, no arbitrage) starts no
|
||||
task at all. What is fetched is decided by the FIRST term only.
|
||||
|
||||
⚠️ THIS IS NOT A TRADING SWITCH. It decides whether prices are LOOKED UP.
|
||||
Whether they move energy is `arbitrage.enabled()` at the one call site in
|
||||
`cycle()`, and TARIFF-10 did not touch it: `arbitrage_enabled: false` still
|
||||
means no trade, ever, on any configuration.
|
||||
"""
|
||||
return provider_configured(opts) or arbitrage.enabled(opts)
|
||||
|
||||
|
||||
def load_options() -> dict:
|
||||
try:
|
||||
with open(OPTIONS_PATH, "r", encoding="utf-8") as fh:
|
||||
@@ -164,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",
|
||||
@@ -742,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
|
||||
@@ -1511,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")
|
||||
@@ -1864,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
|
||||
@@ -2523,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)
|
||||
@@ -3480,13 +3676,64 @@ class Controller:
|
||||
raise out of here and take the task down, because then the plan freezes
|
||||
at its last value and looks current forever. That is what
|
||||
`fresh_tariff_plan()`'s age bound is the backstop for, and this is the
|
||||
half that keeps the task alive so the bound rarely matters.
|
||||
half that keeps the task alive. ⚠️ THE BOUND IS NO LONGER A BACKSTOP
|
||||
ONLY: since TARIFF-12 it is also the serve-stale window an outage runs
|
||||
on, and it is consulted below on every refresh.
|
||||
|
||||
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN. `resolve_tariff()` already has
|
||||
a §11.4 answer for a dead feed - degrade to the static profile, mark it
|
||||
provisional, arbitrage refuses - and that answer is better than the
|
||||
absence of one. Zeroing the plan here would replace a coarse tariff with
|
||||
no tariff, which is the opposite of what §11.4 asks for.
|
||||
⚠️ AND A FAILURE DOES NOT CLEAR THE PLAN - WHICH IS A CLAIM ABOUT WHAT
|
||||
`resolve_tariff()` RETURNS, NOT ABOUT WHAT IT RAISES (TARIFF-12). This
|
||||
paragraph was enforced by the `except` below alone, and the `except`
|
||||
below is the one path a supplier outage never takes. `load_prices()`
|
||||
says so in its own words - it "RETURNS, NEVER RAISES, for every failure
|
||||
a site can actually have" - because a degradation arriving as a
|
||||
traceback into the planning tick would stop the controller regulating
|
||||
over an expired certificate. So a dead feed is a RETURN: the `try`
|
||||
succeeds, `_degrade()` hands back a §11.4 plan, and the assignment used
|
||||
to overwrite the good one unconditionally. Measured in a 40 h soak with
|
||||
`static_profile` unset (the shipped default, and the normal state of a
|
||||
dynamic-tariff site): the FIRST failed fetch replaced a complete 48 h
|
||||
plan with `TariffPlan(no_data, None, "none")` - 0 points - 27 minutes
|
||||
after the last good one, and `TARIFF_MAX_AGE_S` governed nothing. A
|
||||
transient blip cost the same as a 90-minute outage.
|
||||
|
||||
⚠️ SO THE QUESTION IS ASKED ONCE, AFTER THE ATTEMPT, FROM THE EVIDENCE
|
||||
THAT EXISTS - FCST-06's answer, in this file. The evidence is
|
||||
`plan.from_feed` (did this attempt observe the feed) and
|
||||
`fresh_tariff_plan(now)` (is an observation still current). A
|
||||
degradation replaces a standing observation only once that observation
|
||||
has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it
|
||||
reads like rather than a bound nothing could reach.
|
||||
|
||||
⚠️ AND A §11.4 STATIC FALLBACK DOES NOT REPLACE A FRESH PROVIDER PLAN
|
||||
EITHER, although it carries real prices. Every point `_degrade()`
|
||||
builds is `provisional=True`, so `firm_points()` is empty,
|
||||
`arbitrage()` refuses and `cheap_now()` is False - everything a
|
||||
consumer may act on is already off. A day-ahead price for a given hour
|
||||
does not change once published, so a plan fetched half an hour ago is
|
||||
exactly as true about that hour as one fetched now. The fallback is
|
||||
less actionable and no more current; it takes over when the observation
|
||||
retires, which is what §11.4 is for. The reverse is not symmetrical: a
|
||||
standing plan that is ITSELF a degradation is replaced by a fresh one,
|
||||
or an outage would freeze the operator's static profile at the window
|
||||
it was generated for.
|
||||
|
||||
⚠️ AND NOTHING HERE READS `arbitrage_enabled`, WHICH TARIFF-10 MAKES
|
||||
WORTH SAYING. Since that card this task also runs on a site with a
|
||||
provider configured and trading DISARMED, so "keep the plan" is a
|
||||
decision about what the add-on has LOOKED UP, not about what it may
|
||||
trade on. It is also strictly downstream of `resolve_tariff()`: the
|
||||
§8.3 carry into `price_history` happens on the `series` branch inside
|
||||
that call, before this decision exists, so which of two plans is kept
|
||||
cannot add or lose a recorded day.
|
||||
|
||||
⚠️ AND `tariff_at` DOES NOT MOVE ON A KEPT PLAN. It is the moment the
|
||||
fetch that produced the standing plan happened, and
|
||||
`fresh_tariff_plan()` judges the age from it - re-stamping it here
|
||||
would be this task asserting the feed answered when it did not, and the
|
||||
plan would "freeze at its last value and look current forever", which
|
||||
is the hazard the paragraph above this one names. An unmoved
|
||||
`tariff_at` is also the only thing that tells a kept plan from a
|
||||
freshly fetched one, so it is what the check asserts.
|
||||
"""
|
||||
horizon = float(price_option(self.o, "price_horizon_h"))
|
||||
while not self.stopping:
|
||||
@@ -3495,11 +3742,33 @@ class Controller:
|
||||
plan = await resolve_tariff(
|
||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||
history=self.price_history)
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
standing = self.fresh_tariff_plan(now)
|
||||
# Three terms, and each of them is the only false one in some
|
||||
# check: the feed answered (replace), nothing current stands
|
||||
# (take the degradation - this is what makes the age bound
|
||||
# govern), or what stands is itself a degradation (replace).
|
||||
keep = (not plan.from_feed
|
||||
and standing is not None and standing.from_feed)
|
||||
if keep:
|
||||
_LOG.warning(
|
||||
"tariff refresh degraded (%s); KEEPING the plan "
|
||||
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
|
||||
"so it retires %.0f s after that fetch; arbitrage, "
|
||||
"where it is armed, stops with it. A kept plan is "
|
||||
"never presented as a current one.", plan.words,
|
||||
self.tariff_at,
|
||||
(now - self.tariff_at).total_seconds(),
|
||||
TARIFF_MAX_AGE_S)
|
||||
else:
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
except Exception as err: # noqa: BLE001
|
||||
# ⚠️ NOT THE OUTAGE PATH - see the docstring. `load_prices()`
|
||||
# returns on every failure a site can have, so what reaches
|
||||
# here is something nobody expected, and the plan is kept for
|
||||
# the same reason and on the same terms.
|
||||
_LOG.warning(
|
||||
"tariff refresh failed (%s); keeping the plan resolved at "
|
||||
"tariff refresh raised (%s); keeping the plan resolved at "
|
||||
"%s. It retires on its own after %.0f s and arbitrage "
|
||||
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
@@ -4353,8 +4622,24 @@ class Controller:
|
||||
# OPT-04. The strategy's own sentence, not a re-derivation of it -
|
||||
# every refusal it can make already carries a reason a human can
|
||||
# act on, and "Arbitrage: no" would throw all of them away.
|
||||
("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,
|
||||
@@ -4605,11 +4890,16 @@ async def amain() -> None:
|
||||
# ESP32 heartbeat. A fetch on that path is a supplier's TLS timeout able
|
||||
# to push this add-on towards layer 1's 30 s veto. See TARIFF_REFRESH_S.
|
||||
#
|
||||
# ⚠️ STARTED ONLY WHEN ARBITRAGE IS ARMED. Nothing else in the add-on
|
||||
# reads the resolved plan - `cheap` still comes from static_status() on
|
||||
# the tick, which is pure - so on a default install this task would be
|
||||
# network traffic in aid of an attribute nobody reads.
|
||||
if arbitrage.enabled(opts):
|
||||
# ⚠️ STARTED WHEN A PROVIDER IS CONFIGURED, *OR* WHEN TRADING IS ARMED
|
||||
# - TARIFF-10, and it used to be the second term alone. `arbitrage_
|
||||
# enabled` ships `false`, so a site that had selected a price feed
|
||||
# fetched nothing at all; the whole argument, the measurement and what
|
||||
# the `or` is protecting are in `tariff_refresh_wanted()` above.
|
||||
#
|
||||
# ⚠️ IT DOES NOT ARM ANYTHING. Fetching a price and trading on one are
|
||||
# two decisions; this is the first, and `cycle()`'s
|
||||
# `if arbitrage.enabled(self.o):` is still the only gate on the second.
|
||||
if tariff_refresh_wanted(opts):
|
||||
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
|
||||
|
||||
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
|
||||
|
||||
+849
-148
File diff suppressed because it is too large
Load Diff
@@ -71,12 +71,19 @@ So the flag is not passed through anywhere in this file:
|
||||
the pass-through idiom cannot come back in a later edit and pass review by
|
||||
looking ordinary.
|
||||
|
||||
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". `eneco_eddy` is an imbalance product whose
|
||||
current-quarter print is genuinely provisional and says so per point (§9.2,
|
||||
TARIFF-02). Such a point is published AND provisional, and the audit's rule for
|
||||
a published point is "the flag is whatever the feed said", never "False". A
|
||||
provenance check that forced published points to False would be a bug that
|
||||
looks like a guarantee.
|
||||
⚠️ "PUBLISHED" DOES NOT MEAN "FIRM". §9.3's canonical format carries
|
||||
`provisional` PER POINT, not per series, precisely because a real-time /
|
||||
imbalance product's current-interval print is settled after the fact and is
|
||||
genuinely provisional while it is published. Such a point is published AND
|
||||
provisional, and the audit's rule for a published point is "the flag is
|
||||
whatever the feed said", never "False". A provenance check that forced
|
||||
published points to False would be a bug that looks like a guarantee.
|
||||
|
||||
⚠️ NO SHIPPED PLUGIN EMITS ONE TODAY (TARIFF-07 withdrew the only declarer of
|
||||
`PriceProduct.REALTIME`), and that is exactly why this paragraph stays. The
|
||||
rule is a property of the FORMAT, not of any supplier; weakening it to
|
||||
"published implies False" while nothing exercises it is how the first real
|
||||
imbalance feed would arrive mispriced and flagged firm.
|
||||
|
||||
|
||||
THE FILL, AND WHY IT KEYS ON THE WALL CLOCK
|
||||
@@ -104,8 +111,9 @@ because an empty series is not constructible on purpose (price.py trap 3).
|
||||
|
||||
⚠️ A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR, and the hourly walk
|
||||
below is about WHICH HOURS NEED FILLING - never about what a published hour is
|
||||
allowed to contain. `eneco_eddy` is the Belgian imbalance product and settles
|
||||
per QUARTER, `_validate_raw()` accepts any strictly-increasing spacing, and a
|
||||
allowed to contain. BELGIAN DAY-AHEAD ITSELF SETTLES PER QUARTER since the
|
||||
15-minute MTU took effect on 2025-10-01 - `generic_dayahead` reads 96 points a
|
||||
day, not 24 - `_validate_raw()` accepts any strictly-increasing spacing, and a
|
||||
published hour may therefore hold four points. So EVERY published point inside
|
||||
the window is passed through, in order, and a carried point is added only for a
|
||||
whole hour with NO published coverage at all.
|
||||
@@ -171,7 +179,7 @@ class Provenance(Enum):
|
||||
flag" and "the others".
|
||||
"""
|
||||
PUBLISHED = "published" # the provider printed this hour. Flag: the
|
||||
# feed's own - an imbalance print may be
|
||||
# feed's own - a real-time print may be
|
||||
# published AND provisional (§9.2).
|
||||
CARRIED = "carried" # yesterday's same-wall-clock hour (§8.3).
|
||||
# Flag: ALWAYS True, by construction.
|
||||
@@ -414,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)
|
||||
|
||||
@@ -107,7 +107,8 @@ THE FOUR RULES THIS FILE IS SHAPED BY
|
||||
|
||||
⚠️ NO VALUE-LEVEL CHECK CAN CATCH THAT, and saying so is more useful than a
|
||||
guarantee that would not hold. A carried point and a published-provisional
|
||||
point (`eneco_eddy`'s current quarter, §9.2) are the same three fields with
|
||||
point (a real-time product's current interval, §9.2) are the same three
|
||||
fields with
|
||||
the same flag; the difference is provenance, and provenance is not in the
|
||||
value. So the mechanism is ORDERING, not validation: `carry()` is the only
|
||||
public entry point, it remembers the argument and returns the fill, and the
|
||||
|
||||
@@ -13,7 +13,7 @@ is an EMS for a minority." The §9.2 product table maps four products onto four
|
||||
EMS behaviours, and TARIFF-02 shipped the machinery for the top two only:
|
||||
|
||||
Dynamic day-ahead hourly EPEX BE + markup full arbitrage
|
||||
Real-time/imbalance e.g. Eneco Eddy Flex arbitrage, wider thresholds
|
||||
Real-time/imbalance no shipped plugin (TARIFF-07) arbitrage, wider thresh.
|
||||
Static day/night two-period tariff TIME-WINDOW SHIFTING <- here
|
||||
Fully fixed single price SELF-CONSUMPTION ONLY <- here
|
||||
|
||||
@@ -207,7 +207,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
required_spread, uncertainty_of, widen_factor,
|
||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||
@@ -728,6 +728,33 @@ class TariffPlan:
|
||||
self.origin = origin
|
||||
|
||||
# -- derived facts -------------------------------------------------------
|
||||
@property
|
||||
def from_feed(self) -> bool:
|
||||
"""Is this series an OBSERVATION of the configured provider's feed?
|
||||
|
||||
TARIFF-12. `origin` names four ways a plan is obtained; this collapses
|
||||
them to the one question a caller holding a PREVIOUS plan has to ask
|
||||
about a new one - did this resolution see the feed, or is it standing
|
||||
in for one that did not? Only `"provider"` saw it. `"fallback"` and
|
||||
`"none"` are the two §11.4 rows, and `"static"` is a site with no
|
||||
feed to observe at all, where the question does not arise.
|
||||
|
||||
⚠️ IT IS NOT `has_prices`, AND THE DIFFERENCE IS THE WHOLE POINT. A
|
||||
§11.4 fallback HAS prices - the operator's own static profile - but
|
||||
every point `_degrade()` builds carries `provisional=True`, so
|
||||
`firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is
|
||||
False. It is a real tariff and it is not an observation, and a caller
|
||||
deciding whether to REPLACE an observation with it needs the second
|
||||
fact, not the first.
|
||||
|
||||
⚠️ AND IT IS A PROPERTY HERE RATHER THAN AN `origin == "provider"` AT
|
||||
THE CALL SITE, for `main.fresh_tariff_plan()`'s stated reason: a check
|
||||
has to be able to assert on the shipped expression rather than on a
|
||||
transcription that drifts, and `origin`'s vocabulary belongs to this
|
||||
module.
|
||||
"""
|
||||
return self.origin == "provider"
|
||||
|
||||
@property
|
||||
def has_prices(self) -> bool:
|
||||
return self.read.match(series=lambda _r: True, no_data=lambda _r: False)
|
||||
@@ -744,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(
|
||||
@@ -899,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")
|
||||
|
||||
@@ -942,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 "
|
||||
@@ -1055,7 +1211,8 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
|
||||
⚠️ THE FILL IS PER WHOLE HOUR AND ITS COVERAGE IS CONDITIONAL. Every point
|
||||
the provider published inside the window passes through BY IDENTITY - four
|
||||
of them in one hour on a quarter-hourly product like `eneco_eddy` - and a
|
||||
of them in one hour on the quarter-hourly day-ahead feed `generic_dayahead`
|
||||
reads (Belgium's 15-minute MTU, 2025-10-01) - and a
|
||||
carried point is added only for a whole hour the feed did not print at all
|
||||
AND yesterday did. An hour neither source can price is left out, which is
|
||||
§11.4 for that hour: `plan.price_at()` raises rather than answering, and an
|
||||
@@ -1071,8 +1228,7 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
missing and loses the elapsed ones it should never have had.
|
||||
"""
|
||||
stamp = now or datetime.now(timezone.utc)
|
||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||
if pid in ("", "off"):
|
||||
if not provider_configured(opts):
|
||||
return static_plan(opts, since, until, provisional=False, now=stamp)
|
||||
|
||||
read = await load_prices(opts, since, until, transport)
|
||||
|
||||
@@ -132,6 +132,18 @@ OBSERVED = "observed" # the store answered and there is a peak
|
||||
NO_RECORD = "no_record" # the store answered and this month has none yet
|
||||
NOT_OBSERVED = "not_observed" # the store did not answer. NOT a zero peak.
|
||||
|
||||
# TARIFF-13. What the COVERAGE figure reads when the store cannot count.
|
||||
#
|
||||
# ⚠️ NOT 0, AND NOT `None`. This is the reading-state argument of TARIFF-01
|
||||
# applied to the number printed BESIDE the peak rather than to the peak. A
|
||||
# coverage figure of 0 says "this month has been measured over no quarters at
|
||||
# all", which is the strongest possible claim that the peak next to it means
|
||||
# nothing - and `f"{None} quarter(s) banked"` renders "None quarter(s) banked",
|
||||
# which an operator reads as that same zero. The one honest answer to "how many
|
||||
# quarters have I banked" when the table will not answer is that it is not
|
||||
# known, and it has to SAY so in the sentence the operator reads.
|
||||
COUNT_UNREADABLE = "an unreadable number of"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# local time, without a hard dependency on the tz database
|
||||
@@ -388,6 +400,23 @@ class PeakStore:
|
||||
" per_phase_json=excluded.per_phase_json"
|
||||
" WHERE excluded.offtake_avg_w > quarter_blocks.offtake_avg_w",
|
||||
(start, month, w, pp_json))
|
||||
# TARIFF-13. THE TIE RULE, DECIDED RATHER THAN EMERGENT:
|
||||
# at EQUAL wattage the EARLIEST quarter keeps the attribution.
|
||||
# See DECISIONS/TARIFF-13-the-coverage-of-a-peak.md §2.
|
||||
#
|
||||
# `peak_w` itself is still only ever RAISED - the `>` term is
|
||||
# untouched and no tie can move the billed number - so SAFETY-07
|
||||
# reads exactly the watt figure it read before. The second term
|
||||
# moves only the TIMESTAMP the status page attributes it to.
|
||||
#
|
||||
# ⚠️ WITHOUT THE SECOND TERM THE TWO READERS DISAGREE, measured:
|
||||
# `>` alone keeps whichever tied block was WRITTEN FIRST, while
|
||||
# `recompute_month()`'s `ORDER BY offtake_avg_w DESC,
|
||||
# quarter_start ASC` keeps the EARLIEST. Those coincide only
|
||||
# while blocks arrive in time order. Fed four equal 365 W blocks
|
||||
# newest-first, `monthly_peak` answered 09:45 and the recompute
|
||||
# answered 09:00 - a divergence in the derived record part 8 of
|
||||
# test_tariff.py exists to prove cannot happen.
|
||||
self._db.execute(
|
||||
"INSERT INTO monthly_peak(month, peak_w, quarter_start, per_phase_json)"
|
||||
" VALUES(?,?,?,?)"
|
||||
@@ -395,7 +424,9 @@ class PeakStore:
|
||||
" peak_w=excluded.peak_w,"
|
||||
" quarter_start=excluded.quarter_start,"
|
||||
" per_phase_json=excluded.per_phase_json"
|
||||
" WHERE excluded.peak_w > monthly_peak.peak_w",
|
||||
" WHERE excluded.peak_w > monthly_peak.peak_w"
|
||||
" OR (excluded.peak_w = monthly_peak.peak_w"
|
||||
" AND excluded.quarter_start < monthly_peak.quarter_start)",
|
||||
(month, w, start, pp_json))
|
||||
return True
|
||||
except sqlite3.Error as err:
|
||||
@@ -449,6 +480,49 @@ class PeakStore:
|
||||
quarter_start=datetime.fromisoformat(row[1]),
|
||||
per_phase_w=pp)
|
||||
|
||||
def banked_count(self, month: str) -> int | None:
|
||||
"""How many quarters this month has banked. `None` when unknowable.
|
||||
|
||||
TARIFF-13. THE DEFECT THIS PREVENTS: the status page printed a coverage
|
||||
figure held in PROCESS MEMORY - `PeakTracker.recorded`, incremented once
|
||||
per block banked by THIS process - beside a peak read from SQLite. The
|
||||
peak outlives a restart and the counter does not, so a driven restart
|
||||
mid-month took a real reading of
|
||||
|
||||
2026-09: peak 365 W ... ; 120 quarter(s) banked
|
||||
|
||||
to the same peak, the same attributed quarter and `0 quarter(s) banked`,
|
||||
on both instances independently. The count is what tells an operator how
|
||||
much of the billing month the number beside it actually covers: 365 W
|
||||
over 120 quarters and 365 W over 0 quarters are different claims about
|
||||
the same watt figure, and after a restart the page made the weaker claim
|
||||
while displaying the stronger one's value. Reading the count from the
|
||||
same durable tier the peak comes from is what makes the two agree.
|
||||
|
||||
⚠️ `None`, NOT 0, and NOT `len(blocks_in(month))`. `blocks_in()` returns
|
||||
`[]` on a read error - safe there only because no caller reads its
|
||||
emptiness as a peak (§ its own docstring) - so counting its length would
|
||||
rebuild the disarming zero one call further out. This asks the database
|
||||
for a COUNT and refuses to answer when the database refuses.
|
||||
|
||||
⚠️ Counts `quarter_blocks`, the tier with one row per closed block, and
|
||||
NOT `monthly_peak`, which has one row per MONTH and would answer 1 for
|
||||
every month that has ever been measured. The two tables are proven equal
|
||||
on the PEAK by `recompute_month()`; they were never equal on the COUNT
|
||||
and are not meant to be.
|
||||
"""
|
||||
if self._db is None:
|
||||
return None
|
||||
try:
|
||||
row = self._db.execute(
|
||||
"SELECT COUNT(*) FROM quarter_blocks WHERE month=?",
|
||||
(month,)).fetchone()
|
||||
except sqlite3.Error as err:
|
||||
self.last_error = str(err)
|
||||
_LOG.error("could not count quarters for %s (%s)", month, err)
|
||||
return None
|
||||
return int(row[0])
|
||||
|
||||
def blocks_in(self, month: str) -> list:
|
||||
"""(quarter_start, offtake_avg_w) for a month, oldest first. [] on error
|
||||
is safe here ONLY because no caller reads emptiness as a peak - AC 3's
|
||||
@@ -539,11 +613,59 @@ class PeakTracker:
|
||||
self.cap_w = float(cap_w)
|
||||
self.policy = policy
|
||||
self.checkpoint_s = float(checkpoint_s)
|
||||
self.recorded = 0
|
||||
self.restore_state = "not attempted"
|
||||
self._seen: set = set()
|
||||
self._last_ckpt = 0.0
|
||||
|
||||
# -- coverage -----------------------------------------------------------
|
||||
#
|
||||
# TARIFF-13. `recorded` USED TO BE AN `int` ATTRIBUTE ON THIS OBJECT,
|
||||
# starting at 0 and incremented in `sync()`. It is a property over the store
|
||||
# now, and the three things that changes are all defects it was carrying:
|
||||
#
|
||||
# * it RESET TO 0 ON EVERY RESTART, beside a peak that did not - see
|
||||
# `PeakStore.banked_count()` for the driven reading;
|
||||
# * it was NOT SCOPED TO A MONTH. It counted every block this process had
|
||||
# banked, so on 1 October the row still read September's tally beside
|
||||
# October's "no completed quarter recorded yet";
|
||||
# * it MISSED the block `restore()` banks. A stale block closed from the
|
||||
# checkpoint goes into `quarter_blocks` and never went through `sync()`,
|
||||
# so the counter never saw it. Counting the table cannot miss it.
|
||||
#
|
||||
# ⚠️ REMOVING THE COUNTER DOES NOT REMOVE THE DEDUPE. `_seen` is still what
|
||||
# stops `cycle()`'s once-a-second `sync()` re-banking a closed quarter that
|
||||
# `P1Ingest.blocks` keeps for a DAY, and `sync()`'s RETURN value is still
|
||||
# the count of blocks newly banked on that call. What changed is only where
|
||||
# the STATUS PAGE's figure is read from. The check that grades the dedupe
|
||||
# must therefore grade `sync()`'s return, not this property, which now
|
||||
# answers 1 from the database however many times the block was offered.
|
||||
|
||||
def recorded_in(self, when: datetime | None = None):
|
||||
"""Quarters banked in `when`'s billing month - `int`, or the sentinel.
|
||||
|
||||
⚠️ The return type is `int | str` on purpose, and the `str` is
|
||||
`COUNT_UNREADABLE`. A caller that formats this into a sentence gets a
|
||||
true sentence in both cases; a caller that wants to do arithmetic on it
|
||||
gets a TypeError, which is loud. That is the same trade `PeakReading`
|
||||
makes by having no `peak_w_or(default)`.
|
||||
"""
|
||||
n = self.store.banked_count(month_key(when or datetime.now(timezone.utc)))
|
||||
return COUNT_UNREADABLE if n is None else n
|
||||
|
||||
@property
|
||||
def recorded(self):
|
||||
"""Quarters banked in the month being billed TODAY.
|
||||
|
||||
⚠️ This is the live status page's reading, where the peak beside it is
|
||||
also read for `datetime.now()`. `checks(when=X)` for an X in another
|
||||
month is a test affordance (TEST-17); the row scoped that way gets this
|
||||
month's count beside that month's peak, which is strictly better than
|
||||
the process-wide counter it replaces but is still a mismatch.
|
||||
`recorded_in(when)` is the scoped form the row should read once
|
||||
`main.py`'s status rows are free to change - TARIFF-11 holds them.
|
||||
"""
|
||||
return self.recorded_in()
|
||||
|
||||
# -- startup ------------------------------------------------------------
|
||||
def restore(self, averager, now: datetime | None = None) -> str:
|
||||
"""Put the open block back. Returns which of the three cases happened."""
|
||||
@@ -600,7 +722,6 @@ class PeakTracker:
|
||||
continue
|
||||
if self.store.record_block(block, self.policy):
|
||||
self._seen.add(key)
|
||||
self.recorded += 1
|
||||
n += 1
|
||||
if n:
|
||||
# A block just closed, so the checkpoint's block is gone: clearing it
|
||||
|
||||
@@ -118,8 +118,14 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
It is warned about below, not refused.
|
||||
|
||||
7. A price configuration that cannot mean what it says. [(a)]
|
||||
Three cases, all in `_price_problems()`: an unknown `price_provider` id
|
||||
(refused rather than defaulted to 'off', per the §14 warning box);
|
||||
Four cases, all in `_price_problems()`: an unknown `price_provider` id
|
||||
(refused rather than defaulted to 'off', per the §14 warning box); a
|
||||
RETIRED id - one this add-on really did ship and has been accepting ever
|
||||
since, whose plugin never once fetched a price because its endpoint does
|
||||
not exist (TARIFF-07), which is case (a) in its purest form: §11.4's
|
||||
silent fall back to the static profile IS the layer the operator cannot
|
||||
see, and it has been overriding their chosen source since the day they
|
||||
set it;
|
||||
`price_provider` set alongside `price_now_entity`/`price_avg_entity`,
|
||||
which is TWO PRICE SOURCES ON ONE SITE where the Home Assistant pair
|
||||
silently wins; and `manual_csv` with no `price_csv_path`, which turns the
|
||||
@@ -318,11 +324,13 @@ import logging
|
||||
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .custom_formula import (
|
||||
FormulaInvalid, implausible_all_in, parse_price_formula, stale_card)
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
|
||||
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
from .watchdog import SIGN_OPTION_DEFAULTS
|
||||
|
||||
@@ -1242,17 +1250,74 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
without a price feed is an add-on that a Belgian fixed-tariff site - a large
|
||||
share of the installed base, per §9.2 - cannot run at all.
|
||||
|
||||
Four refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
||||
two notices), and every refusal is still case (a) from this module's
|
||||
docstring: a source the operator SET being overridden by a layer they cannot
|
||||
see. The additions are `static_profile` alongside the Home Assistant price
|
||||
pair - the same silent-loser shape as provider-plus-pair, and a LIVE one -
|
||||
and a `static_profile` that does not tile the day, which would leave hours
|
||||
with no price at all.
|
||||
Five refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
||||
two notices; TARIFF-07 added the retired-id one; TARIFF-15 added three -
|
||||
a `custom_formula` with no formula, a formula that does not parse or does
|
||||
not reproduce its own card, and one whose card lands outside the plausible
|
||||
band - plus two notices, a formula nothing reads and a stale card), and
|
||||
every refusal is still case (a) from this module's docstring: a source the
|
||||
operator SET being overridden by a layer they cannot see. The additions are `static_profile`
|
||||
alongside the Home Assistant price pair - the same silent-loser shape as
|
||||
provider-plus-pair, and a LIVE one - and a `static_profile` that does not
|
||||
tile the day, which would leave hours with no price at all.
|
||||
|
||||
⚠️ THE RETIRED-ID BRANCH IS NOT A FRIENDLIER SPELLING OF THE UNKNOWN-ID
|
||||
ONE, AND MUST NOT COLLAPSE INTO IT. They answer different questions. An
|
||||
unknown id is a TYPO: the operator meant something else and the fix is to
|
||||
spell it right. A retired id is a CHOICE THE ADD-ON ACCEPTED - it was in
|
||||
the schema, it was in the dropdown, it resolved to a class, and every
|
||||
startup since said nothing. What that operator needs is not "this id is
|
||||
wrong" but "this id has never once returned a price, and here is what your
|
||||
site has actually been running instead" - because §11.4 made the failure
|
||||
silent and NOTHING on their status page has ever distinguished it from a
|
||||
working provider that never finds a cheap hour. The retired branch is
|
||||
therefore FIRST, and it names the id, the endpoint, the observation, the
|
||||
tariff that has really been in force, and the move to make.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
|
||||
|
||||
if pid in RETIRED_PROVIDERS:
|
||||
# ⚠️ BEFORE the unknown-id branch, and the order is load-bearing: a
|
||||
# retired id is ALSO not in PROVIDERS, so the generic message would
|
||||
# win on a reordering and the operator would be told they mistyped
|
||||
# something they chose on purpose.
|
||||
# ⚠️ AND THE SECOND SENTENCE CHANGES WITH `static_profile`, because
|
||||
# "you have been on the static profile" is FALSE on a site that never
|
||||
# set one. §11.4 falls back to the static profile; with no profile to
|
||||
# fall back to, the site has had no tariff at all - a worse state, and
|
||||
# one this message must not paper over.
|
||||
been_running = (
|
||||
f"the static day/night tariff in static_profile"
|
||||
if str(opts.get("static_profile", "") or "").strip()
|
||||
else "NOTHING AT ALL - static_profile is empty too, so there was "
|
||||
"no tariff to fall back to either")
|
||||
problems.append(
|
||||
f"price_provider is {pid!r}, which WAS a shipped plugin and has "
|
||||
f"been WITHDRAWN (TARIFF-07). This is not a typo and you did not "
|
||||
f"mistype it - the add-on offered this id, accepted it, and has "
|
||||
f"said nothing at every startup since. "
|
||||
f"⚠️ IT HAS NEVER FETCHED A PRICE, HERE OR ANYWHERE: "
|
||||
f"{RETIRED_PROVIDERS[pid]}. Every fetch it ever made ended in "
|
||||
f"SourceUnavailable, and §11.4 turns that into a SILENT fall back "
|
||||
f"- so what this site has actually been running since the day the "
|
||||
f"id was set is {been_running}. Nothing you could see would have "
|
||||
f"differed from a working provider that simply never found a cheap "
|
||||
f"hour, which is why you are being told now rather than left on "
|
||||
f"it. WHAT TO DO: set price_provider to 'generic_dayahead' for the "
|
||||
f"wholesale EPEX BE day-ahead curve, to which the add-on adds your "
|
||||
f"price_markup_eur_kwh and price_taxes_eur_kwh - that APPROXIMATES "
|
||||
f"a Belgian dynamic tariff and cannot reproduce your supplier's "
|
||||
f"B x spot + A formula, so the level will be off even when the "
|
||||
f"shape is right; or 'manual_csv' to enter all-in prices you "
|
||||
f"compute yourself; or 'off' to run on static_profile "
|
||||
f"deliberately. There is deliberately NO alias to your old "
|
||||
f"supplier: aliasing would need your contract's own two "
|
||||
f"coefficients, which live behind your klantenzone login and are "
|
||||
f"in no public index, so the add-on would have to invent the "
|
||||
f"numbers it prices your battery against.")
|
||||
return problems # nothing below can be judged against a dead id
|
||||
|
||||
if pid != "off" and pid not in PROVIDERS:
|
||||
problems.append(
|
||||
f"price_provider is {pid!r}, which is not a shipped plugin. The "
|
||||
@@ -1355,6 +1420,85 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
"for this product. Peak shaving, reserve management and the "
|
||||
"maintenance cycle are unaffected.", profile.prices[0])
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
#
|
||||
# THE DEFECT THIS EXISTS TO PREVENT, and it is not one `code_leaks()` can
|
||||
# see because it is not in code: an operator folds their NETWORK CHARGES
|
||||
# into `offset` while `price_taxes_eur_kwh` carries them as well, and the
|
||||
# site is billed against a price ~12 c EUR/kWh high on every hour for as
|
||||
# long as nobody re-reads the configuration. §11.4 makes every price
|
||||
# defect quiet, so nothing on the status page would ever differ.
|
||||
#
|
||||
# The card is what grades it. A Belgian tariff card prints the formula,
|
||||
# the reference index it assumed AND the resulting price, so the add-on
|
||||
# recomputes that printed price from the numbers the operator typed and
|
||||
# refuses to start unless the two agree to within CARD_TOLERANCE. An
|
||||
# inflated `offset` cannot pass - the printed estimate is the energy
|
||||
# component alone, so ~12 c EUR/kWh moves 15.55 to ~27. The same check
|
||||
# catches VAT omitted, VAT applied twice, a transposed digit, and a unit
|
||||
# MISMATCH between the coefficients and the printed estimate. The one unit
|
||||
# error it cannot see is the one applied to BOTH sides at once, because
|
||||
# that cancels out of a comparison between them - which is why
|
||||
# `implausible_all_in()` below is a second refusal on a second quantity
|
||||
# rather than a restatement of this one.
|
||||
#
|
||||
# ⚠️ AND ITS LIMIT, IN THE SAME SENTENCE AS THE CLAIM, exactly as price.py
|
||||
# states trap 1's: IT GRADES THE COEFFICIENTS AGAINST THE CARD, NOT THE
|
||||
# CARD AGAINST REALITY. A consistent-but-wrong pair - both numbers off the
|
||||
# wrong product - reproduces its own printed price and passes. Nothing can
|
||||
# catch that.
|
||||
raw_formula = opts.get("price_formula", "")
|
||||
has_formula = bool(str(raw_formula or "").strip()) \
|
||||
or isinstance(raw_formula, dict)
|
||||
if pid == "custom_formula" and not has_formula:
|
||||
problems.append(
|
||||
"price_provider is 'custom_formula' but price_formula is empty, "
|
||||
"so the add-on has no coefficients to price anything with. Every "
|
||||
"fetch would return no-price-data and §11.4 would fall back "
|
||||
"silently - which is the shape of defect this provider exists to "
|
||||
"remove, arrived at by leaving one field blank. Copy the "
|
||||
"Tariefformule line off your supplier's tariff card, e.g. "
|
||||
"{\"index\":\"epex_be\",\"resolution\":\"hourly\","
|
||||
"\"factor\":0.102,\"offset\":1.00,"
|
||||
"\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,"
|
||||
"\"card\":{\"month\":\"2026-09\","
|
||||
"\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}} "
|
||||
"- see DOCS.md. Or set price_provider to 'generic_dayahead' for "
|
||||
"the wholesale curve with a flat markup.")
|
||||
elif has_formula:
|
||||
try:
|
||||
spec = parse_price_formula(raw_formula)
|
||||
except FormulaInvalid as e:
|
||||
spec = None
|
||||
problems.append(f"price_formula cannot be used: {e}")
|
||||
if spec is not None and pid != "custom_formula":
|
||||
# ⚠️ A WARNING AND NOT A REFUSAL. This is not two price sources
|
||||
# fighting over one flag - it is one that is simply not wired to
|
||||
# anything - so the §14 warning box does not apply. But an option
|
||||
# an operator filled in and that decides nothing is exactly the
|
||||
# silent loser this module's case (a) is about, so it is said out
|
||||
# loud rather than left to be discovered from a bill.
|
||||
_LOG.warning(
|
||||
"price_formula is filled in but price_provider is %r, so the "
|
||||
"coefficients decide NOTHING - only 'custom_formula' reads "
|
||||
"them. Set price_provider to 'custom_formula' to use them, or "
|
||||
"clear price_formula so the configuration says what the "
|
||||
"add-on is doing.", pid)
|
||||
if spec is not None and pid == "custom_formula":
|
||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
band = implausible_all_in(spec, taxes if taxes is not None else 0.0)
|
||||
if band:
|
||||
problems.append(band)
|
||||
aged = stale_card(spec)
|
||||
if aged:
|
||||
# ⚠️ WARN, NEVER REFUSE. ENGIE freezes a contract's
|
||||
# coefficients for its duration, so a two-year-old card can be
|
||||
# exactly right and a site refused over one is a site that
|
||||
# cannot start.
|
||||
_LOG.warning("%s", aged)
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
# with none configured has nothing to fall back to when its feed dies. That
|
||||
|
||||
@@ -265,9 +265,17 @@ options:
|
||||
# TARIFF - what energy costs, and whether to trade on it
|
||||
# ==========================================================================
|
||||
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
||||
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
||||
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
||||
# validates and publishes prices and changes no energy decision.
|
||||
# static profile, plan provisional, NO ARBITRAGE.
|
||||
#
|
||||
# ⚠️ SELECTING A PROVIDER IS WHAT STARTS THE FETCH, AND IT IS NOT
|
||||
# `arbitrage_enabled` (TARIFF-10). With a provider set and trading off - the
|
||||
# shipped default - the add-on fetches the feed every 30 minutes, validates
|
||||
# it, records the day in /data/price_history.json and reports what it holds
|
||||
# on the *Price history* status row. It changes NO energy decision: the cheap
|
||||
# window the maintenance cycle reads still comes from `static_profile`, and
|
||||
# §10.1's arbitrage is gated on `arbitrage_enabled` alone. Until TARIFF-10
|
||||
# the fetch was gated on `arbitrage_enabled` too, so this option did nothing
|
||||
# whatsoever on a shipped install - the store was never even created.
|
||||
#
|
||||
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
|
||||
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
|
||||
@@ -276,9 +284,11 @@ options:
|
||||
#
|
||||
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
|
||||
# the FRAMEWORK, only for the components the chosen plugin declares its feed
|
||||
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
|
||||
# and manual_csv publish a figure that already carries the markup, so setting
|
||||
# it under those is ignored - and logged as ignored, never applied twice.
|
||||
# does not already contain. manual_csv carries an all-in figure, so setting
|
||||
# either under it is ignored - and logged as ignored, never applied twice.
|
||||
# `custom_formula` (TARIFF-15) carries energy AND markup - your contract's
|
||||
# formula IS the markup - so it takes price_taxes_eur_kwh only, and your
|
||||
# price_markup_eur_kwh is ignored under it, with a line in the log saying so.
|
||||
price_provider: "off"
|
||||
price_markup_eur_kwh: 0.03
|
||||
price_taxes_eur_kwh: 0.06
|
||||
@@ -317,7 +327,63 @@ options:
|
||||
# to fall back to and a dead feed leaves the site with no tariff at all.
|
||||
# validate.py warns about exactly that.
|
||||
static_profile: ""
|
||||
|
||||
# TARIFF-15. YOUR OWN TARIFF CARD, as two fixed coefficients. Used only when
|
||||
# `price_provider: custom_formula`; "" is unset.
|
||||
#
|
||||
# A Belgian dynamic contract prices energy as `B x index + A`, and the two
|
||||
# coefficients exist in exactly ONE place - the tariff card in your
|
||||
# klantenzone. There is no machine-readable source for them anywhere (CREG's
|
||||
# tariff-card pages 404, CREG Scan is a JS wizard, VREG was unreachable), so
|
||||
# no plugin and no scraper can ever know YOUR numbers. You are the only party
|
||||
# who can see them.
|
||||
#
|
||||
# A JSON object, on one line. You type what your card prints, IN THE CARD'S
|
||||
# OWN UNITS; every unit conversion and the VAT multiplication are the
|
||||
# add-on's job, because those are exactly the two steps that silently cost a
|
||||
# factor of 10, 100 or 1.06.
|
||||
#
|
||||
# {"index":"epex_be","resolution":"hourly","factor":0.102,"offset":1.00,
|
||||
# "units":"c_eur_per_kwh","vat_pct":6,
|
||||
# "card":{"month":"2026-09","index_eur_per_mwh":133.9905,
|
||||
# "expect_incl_vat":15.55}}
|
||||
#
|
||||
# price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100), in
|
||||
# `units`, converted to EUR/kWh by the add-on.
|
||||
#
|
||||
# ⚠️ THE `card` BLOCK IS REQUIRED AND IT IS THE SAFETY MECHANISM. Your card
|
||||
# prints the formula, the reference index it assumed AND the resulting price.
|
||||
# At startup the add-on recomputes that printed price from the numbers you
|
||||
# typed and REFUSES TO START unless the two agree to within 0.01 - naming the
|
||||
# discrepancy. That catches VAT left out or applied twice, a transposed
|
||||
# digit, a unit MISMATCH between the coefficients and the printed price
|
||||
# (10x/100x/1000x), and the expensive one: NETWORK CHARGES FOLDED INTO
|
||||
# `offset`. A unit error applied to BOTH sides at once cancels out of that
|
||||
# comparison; the add-on refuses that separately, by checking the card's own
|
||||
# point against the band a Belgian retail tariff can occupy. Your card's formula line is the ENERGY component
|
||||
# alone; the add-on adds price_taxes_eur_kwh on top, so folding ~12 c/kWh of
|
||||
# network charge into `offset` would bill you for it twice - and it cannot
|
||||
# pass, because it moves the computed 15.55 to about 27.
|
||||
#
|
||||
# ⚠️ AND THE LIMIT OF THAT CHECK, IN THE SAME BREATH: it grades your
|
||||
# COEFFICIENTS AGAINST YOUR CARD, not your card against reality. Two numbers
|
||||
# copied off the wrong product agree with each other perfectly and pass.
|
||||
#
|
||||
# ⚠️ FIXED COEFFICIENTS ONLY - the add-on evaluates NO expression of any kind
|
||||
# and there is no field that takes one. An unknown key is refused rather than
|
||||
# ignored. See DOCS.md for why (it is not only security: two coefficients can
|
||||
# be graded against your card and an arbitrary expression cannot be graded
|
||||
# against anything).
|
||||
price_formula: ""
|
||||
price_csv_path: "/share/goodwe_prices.csv"
|
||||
# ⚠️ AN OVERRIDE IS THE EXACT URL (TARIFF-09). The plugin sends it verbatim
|
||||
# and appends nothing - including the `start`/`end` date range it derives from
|
||||
# the horizon for its own feed - so an override gets whatever range that URL
|
||||
# answers, which for a bare day-ahead endpoint is ONE Brussels day. Every hour
|
||||
# past it is then carried from yesterday and flagged provisional, and §8.3
|
||||
# keeps provisional hours out of the trade set. The plugin logs a WARNING
|
||||
# naming that consequence on every fetch that uses an override; clearing this
|
||||
# option is what restores the full horizon.
|
||||
price_feed_url: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
@@ -344,6 +410,12 @@ options:
|
||||
# configuration: this is the first strategy in the add-on that moves energy
|
||||
# against the meter on purpose, so it is armed deliberately or not at all.
|
||||
#
|
||||
# ⚠️ IT IS NOT WHAT TURNS THE PRICE FEED ON (TARIFF-10). `price_provider`
|
||||
# above decides whether prices are FETCHED; this decides whether they move
|
||||
# energy. Leaving this off on a site with a provider configured is a
|
||||
# supported, documented state: prices are fetched, validated and recorded,
|
||||
# and nothing trades.
|
||||
#
|
||||
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
|
||||
# combinations that cannot work rather than letting them look armed. With
|
||||
# `price_provider: off` and no `static_profile` there is no series to trade
|
||||
@@ -736,9 +808,32 @@ schema:
|
||||
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
|
||||
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
|
||||
# checks reach validate_startup_config() with a dict the Supervisor never saw.
|
||||
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
|
||||
# else, which is AC2.
|
||||
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
# Adding a plugin means adding it here and to PROVIDERS - and nowhere else,
|
||||
# which is AC2.
|
||||
#
|
||||
# ⚠️ THREE OF THESE FIVE IDS NO LONGER RESOLVE TO A PLUGIN, AND LEAVING THEM
|
||||
# HERE IS A DECISION (TARIFF-07). engie_dynamic, totalenergies_dynamic and
|
||||
# eneco_eddy were withdrawn - their endpoints do not exist and never did - and
|
||||
# the obvious edit is to delete them from this line too. DO NOT. The Supervisor
|
||||
# validates the options file against this schema BEFORE the add-on starts, so
|
||||
# an id that is not listed here makes an EXISTING config schema-invalid: the
|
||||
# operator gets an opaque "expected one of ..." from the Supervisor, the add-on
|
||||
# never runs, and validate.py's retired-id refusal - the message that tells
|
||||
# them their prices never worked and what to do instead - IS NEVER REACHED, on
|
||||
# exactly the sites that need it. Schema tightness would cost the only thing
|
||||
# this withdrawal is FOR.
|
||||
#
|
||||
# The cost of keeping them is that the dropdown still offers three dead ids to
|
||||
# a NEW operator. That is the cheaper failure: choosing one is refused at the
|
||||
# next startup by name, with the endpoint, the observation and the alternative
|
||||
# - which is strictly more than a typo-shaped schema rejection would say. Both
|
||||
# translations label them withdrawn in the option description as well.
|
||||
#
|
||||
# app/price.py's RETIRED_PROVIDERS is the list, test_price.py section 1 asserts
|
||||
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
|
||||
# typed - and register() refuses to re-register a retired id, so the two
|
||||
# cannot both claim one.
|
||||
price_provider: list(off|generic_dayahead|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
|
||||
@@ -752,6 +847,11 @@ schema:
|
||||
# parse_static_profile() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
static_profile: str?
|
||||
# TARIFF-15. A JSON string for the same reason `static_profile` is one: it is
|
||||
# ONE option key, it survives config.yaml's flat options block, and
|
||||
# parse_price_formula() accepts an already-parsed dict too, so a future
|
||||
# nested schema is a config.yaml change and no code change at all.
|
||||
price_formula: str?
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_horizon_h: int(1,72)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1757455200,1757458800,1757462400,1757466000,1757469600,1757473200,1757476800,1757480400,1757484000,1757487600,1757491200,1757494800,1757498400,1757502000,1757505600,1757509200,1757512800,1757516400,1757520000,1757523600,1757527200,1757530800,1757534400,1757538000],"price":[73.0,61.31,64.32,62.49,67.0,64.48,94.52,149.26,132.43,91.61,84.8,79.61,69.23,66.75,68.91,69.31,80.29,66.91,105.2,161.05,116.73,84.2,64.1,42.41],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788904800,1788905700,1788906600,1788907500,1788908400,1788909300,1788910200,1788911100,1788912000,1788912900,1788913800,1788914700,1788915600,1788916500,1788917400,1788918300,1788919200,1788920100,1788921000,1788921900,1788922800,1788923700,1788924600,1788925500,1788926400,1788927300,1788928200,1788929100,1788930000,1788930900,1788931800,1788932700,1788933600,1788934500,1788935400,1788936300,1788937200,1788938100,1788939000,1788939900,1788940800,1788941700,1788942600,1788943500,1788944400,1788945300,1788946200,1788947100,1788948000,1788948900,1788949800,1788950700,1788951600,1788952500,1788953400,1788954300,1788955200,1788956100,1788957000,1788957900,1788958800,1788959700,1788960600,1788961500,1788962400,1788963300,1788964200,1788965100,1788966000,1788966900,1788967800,1788968700,1788969600,1788970500,1788971400,1788972300,1788973200,1788974100,1788975000,1788975900,1788976800,1788977700,1788978600,1788979500,1788980400,1788981300,1788982200,1788983100,1788984000,1788984900,1788985800,1788986700,1788987600,1788988500,1788989400,1788990300,1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[150.93,151.41,150.99,147.59,153.25,147.26,144.04,141.53,147.79,146.53,143.55,139.93,145.58,139.56,139.11,138.06,138.92,138.52,138.04,138.65,137.29,139.03,144.59,150.34,154.12,174.5,178.43,186.62,199.35,200.21,200.11,197.43,220.07,202.0,196.01,174.03,198.32,167.49,152.54,142.05,161.5,145.45,128.61,100.85,139.18,115.83,94.41,80.39,111.25,89.61,84.99,74.38,80.53,70.58,62.27,57.1,62.24,64.95,68.99,71.19,66.9,87.32,109.0,123.12,68.48,125.53,155.99,161.9,138.77,160.57,167.35,173.0,142.2,172.83,196.19,211.26,205.85,218.77,217.08,222.0,210.98,208.46,208.88,210.02,209.77,202.61,200.27,191.49,194.39,195.04,197.26,190.92,188.39,198.05,203.05,191.84,216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1 @@
|
||||
{"license_info":"CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de","unix_seconds":[1788991200,1788992100,1788993000,1788993900,1788994800,1788995700,1788996600,1788997500,1788998400,1788999300,1789000200,1789001100,1789002000,1789002900,1789003800,1789004700,1789005600,1789006500,1789007400,1789008300,1789009200,1789010100,1789011000,1789011900,1789012800,1789013700,1789014600,1789015500,1789016400,1789017300,1789018200,1789019100,1789020000,1789020900,1789021800,1789022700,1789023600,1789024500,1789025400,1789026300,1789027200,1789028100,1789029000,1789029900,1789030800,1789031700,1789032600,1789033500,1789034400,1789035300,1789036200,1789037100,1789038000,1789038900,1789039800,1789040700,1789041600,1789042500,1789043400,1789044300,1789045200,1789046100,1789047000,1789047900,1789048800,1789049700,1789050600,1789051500,1789052400,1789053300,1789054200,1789055100,1789056000,1789056900,1789057800,1789058700,1789059600,1789060500,1789061400,1789062300,1789063200,1789064100,1789065000,1789065900,1789066800,1789067700,1789068600,1789069500,1789070400,1789071300,1789072200,1789073100,1789074000,1789074900,1789075800,1789076700],"price":[216.08,216.08,214.92,200.0,223.37,201.43,196.32,190.0,199.53,194.83,180.53,172.23,179.88,175.77,167.1,173.89,163.29,165.99,171.41,178.73,170.27,177.37,187.71,201.07,193.62,207.61,218.27,226.77,227.17,234.05,236.99,229.94,248.93,227.5,216.08,195.67,215.99,195.01,177.1,155.09,174.91,152.71,139.59,123.83,140.77,123.19,114.96,94.5,102.77,96.63,94.57,116.34,114.63,126.82,129.89,123.95,105.0,119.11,121.0,136.79,123.41,135.01,135.0,156.68,146.88,152.16,162.02,181.78,154.76,184.28,194.3,215.76,197.23,234.15,253.03,296.58,345.32,389.16,482.99,559.0,551.11,446.41,358.97,295.9,271.39,268.06,241.43,238.01,239.7,226.41,223.05,211.56,218.21,210.61,197.78,171.79],"unit":"EUR / MWh","deprecated":false}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"card": "TARIFF-08, extended by TARIFF-09",
|
||||
"what": "UNMODIFIED response bodies from the public EPEX BE day-ahead feed, captured so the SHIPPED parser can be replayed against what the source really publishes",
|
||||
"why": "every price fixture in the suite was built from a `{\"data\": [{\"start\": ..., \"price_eur_mwh\": ...}]}` document that this feed has never published. The suite therefore graded `_points_from_json` against its own assumption - a closed loop that could not notice the assumption was wrong - and §11.4 turned the resulting failure into a silent fall back to the static profile. A captured real body is what breaks that loop, so these files are bytes, not a reconstruction: nothing here is pretty-printed, re-keyed or trimmed. TARIFF-09 adds a body from a request that names a DATE RANGE, for the same reason one layer out: the shipped plugin took `since`/`until` and asked for the bare default URL, so the feed answered ONE Brussels day and every hour past today was carried forward and dropped from the trade set as provisional. A two-day body captured from the range request is what makes \"asking for the range gets the range\" checkable against something the plugin did not shape.",
|
||||
"source": "Energy-Charts API (Fraunhofer ISE), api.energy-charts.info",
|
||||
"upstream": "Bundesnetzagentur | SMARD.de",
|
||||
"licence": "CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/)",
|
||||
"licence_note": "the BE bidding zone is in the API's CC BY 4.0 list, so redistribution is permitted with attribution. DATA-02 established this against the API's own OpenAPI document before committing `epex-be-dayahead-2025-09-01_2026-09-01.json`; both bodies below carry the same `license_info` string, recorded per file and asserted by the replay test.",
|
||||
"attribution": "Data licensed CC BY 4.0 by Bundesnetzagentur | SMARD.de, retrieved via the Energy-Charts API (Fraunhofer ISE), https://energy-charts.info. Published without changes.",
|
||||
"retrieved_utc": "2026-09-10T06:35:00+00:00 (TARIFF-08's two), 2026-09-10T07:45:49+00:00 (TARIFF-09's)",
|
||||
"retrieved_with": "curl, by hand; no code in `app/` fetches these",
|
||||
"shape": "COLUMNAR: parallel `unix_seconds` (UTC epoch seconds) and `price` arrays, plus `unit`, `license_info` and `deprecated`. There is no `data` key and no per-point object.",
|
||||
"replayed_by": "test_price.py sections 2b (TARIFF-08) and 2c (TARIFF-09)",
|
||||
"files": {
|
||||
"energy-charts-be-price-2026-09-10-quarterhourly.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"note": "the SHIPPED default URL of `generic_dayahead`, verbatim and with no parameters, so the replay drives the bytes the add-on's own request produces",
|
||||
"covers_local_day": "2026-09-10 (Europe/Brussels)",
|
||||
"granularity_s": 900,
|
||||
"intervals": 96,
|
||||
"bytes": 1877,
|
||||
"sha256": "176309d0020ad208320797385ef68b6a98ac248218539416cddf6579dd060487",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
],
|
||||
"also_returned_verbatim_by": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-10&end=2026-09-11",
|
||||
"retrieved_utc": "2026-09-10T07:46:55+00:00",
|
||||
"note": "TARIFF-09's PRE-PUBLICATION case, and it needed no second file because the answer was BYTE-IDENTICAL to the bare-URL capture above - the same 1877 bytes and the same sha256. Asked for two days at 09:46 CEST, before the ~13:00-14:00 CET auction publishes 2026-09-11, the feed returned the ONE day that exists. That is the short answer this card requires to stay a `series` of what exists rather than becoming `no_data`, and the replay re-derives the identity from the bytes rather than restating it."
|
||||
}
|
||||
},
|
||||
"energy-charts-be-price-2025-09-10-hourly.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2025-09-10&end=2025-09-10",
|
||||
"note": "a day BEFORE Belgium's 15-minute MTU (2025-10-01), so the same endpoint answers HOURLY. Kept because the parser must not assume either spacing, and a fixture at only one granularity cannot grade that",
|
||||
"covers_local_day": "2025-09-10 (Europe/Brussels)",
|
||||
"granularity_s": 3600,
|
||||
"intervals": 24,
|
||||
"bytes": 572,
|
||||
"sha256": "03fe41690810df224611f4016899f856457f67a3c12c7654da17b1331ff2eff4",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
]
|
||||
},
|
||||
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json": {
|
||||
"url": "https://api.energy-charts.info/price?bzn=BE&start=2026-09-09&end=2026-09-10",
|
||||
"retrieved_utc": "2026-09-10T07:45:49+00:00",
|
||||
"note": "TARIFF-09. A RANGE request, and the counterfactual the ticket is built on: `start`/`end` are INCLUSIVE Brussels calendar dates and this one answers 192 quarter-hours across TWO local days, where the bare `?bzn=BE` of the same endpoint answers 96 across one. The shipped `generic_dayahead` now derives these two dates from the `since`/`until` it is handed.",
|
||||
"covers_local_days": [
|
||||
"2026-09-09",
|
||||
"2026-09-10"
|
||||
],
|
||||
"granularity_s": 900,
|
||||
"intervals": 192,
|
||||
"bytes": 3579,
|
||||
"sha256": "4d83f5efb4294a4a682562eac619bf35e45c95edde957bfa94c402e1777cc9b3",
|
||||
"unit_as_returned": "EUR / MWh",
|
||||
"licence_as_returned": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated_as_returned": false,
|
||||
"top_level_keys": [
|
||||
"deprecated",
|
||||
"license_info",
|
||||
"price",
|
||||
"unit",
|
||||
"unix_seconds"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,9 @@ from app.price import (
|
||||
PRICE_OPTION_DEFAULTS, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, WIDEN_FACTOR, brussels, required_spread, widen_factor,
|
||||
)
|
||||
from app.static_tariff import TariffPlan, parse_static_profile, static_plan
|
||||
from app.static_tariff import (
|
||||
TariffPlan, parse_static_profile, resolve_tariff, static_plan,
|
||||
)
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
UTC = timezone.utc
|
||||
@@ -1284,6 +1286,323 @@ check("main.py reads every arbitrage option through app/arbitrage.py or "
|
||||
"arbitrage_soc_ceiling")))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("8b. TARIFF-12 - a RETURNED degradation must not clear a fresh plan")
|
||||
|
||||
# ⚠️ THE STIMULUS IS A RETURN, NOT A RAISE, AND THAT IS THE WHOLE CHECK.
|
||||
# `run_tariff_refresh()` guarded the plan with `except Exception` alone, and
|
||||
# `load_prices()` states in its own docstring that it "RETURNS, NEVER RAISES,
|
||||
# for every failure a site can actually have" - so the one case the guard was
|
||||
# written for, a supplier outage, never reached it. Everything below drives the
|
||||
# SHIPPED chain and stubs nothing but the socket: the transport raises the way
|
||||
# aiohttp does, `load_prices()` turns that into a `no_data` read, `_degrade()`
|
||||
# turns THAT into a §11.4 plan, and `run_tariff_refresh()` decides what to do
|
||||
# with it. A check that patched `resolve_tariff` to raise would have passed
|
||||
# against the defective code.
|
||||
import asyncio # noqa: E402
|
||||
from app import price as _price # noqa: E402
|
||||
from app.price_history import NoHistory # noqa: E402
|
||||
|
||||
|
||||
class ClientConnectorError(Exception):
|
||||
"""aiohttp's own class name, so the §11.4 reason this drives is the
|
||||
sentence the 40 h soak recorded at refresh 56, verbatim."""
|
||||
|
||||
|
||||
_T12_FEED_UP = {"up": True}
|
||||
|
||||
|
||||
async def _t12_transport(url, timeout_s):
|
||||
"""The socket, and nothing else, is what this replaces."""
|
||||
if not _T12_FEED_UP["up"]:
|
||||
raise ClientConnectorError(
|
||||
"Cannot connect to host api.energy-charts.info:443 ssl:default")
|
||||
t0 = datetime.now(UTC).replace(minute=0, second=0, microsecond=0)
|
||||
return json.dumps({
|
||||
"unix_seconds": [int((t0 + timedelta(hours=i)).timestamp())
|
||||
for i in range(48)],
|
||||
"price": [80.0 + 40.0 * (i % 6) for i in range(48)],
|
||||
"unit": "EUR / MWh"}).encode()
|
||||
|
||||
|
||||
class _T12Controller:
|
||||
"""Just enough Controller to run the SHIPPED refresh coroutine.
|
||||
|
||||
Both methods are bound off the shipped class rather than copied, for
|
||||
`fresh_tariff_plan()`'s own stated reason: a transcription drifts.
|
||||
"""
|
||||
run_tariff_refresh = _main.Controller.run_tariff_refresh
|
||||
fresh_tariff_plan = _main.Controller.fresh_tariff_plan
|
||||
|
||||
def __init__(self, opts):
|
||||
self.o = opts
|
||||
self.tariff_plan = None
|
||||
self.tariff_at = None
|
||||
self.price_history = NoHistory()
|
||||
self.stop_event = asyncio.Event()
|
||||
self.stop_event.set() # so the inter-refresh wait returns
|
||||
self._ticks = 0
|
||||
|
||||
@property
|
||||
def stopping(self):
|
||||
self._ticks -= 1
|
||||
return self._ticks < 0
|
||||
|
||||
def refresh_once(self):
|
||||
self._ticks = 1
|
||||
asyncio.run(self.run_tariff_refresh())
|
||||
return self
|
||||
|
||||
|
||||
def _t12_opts(profile=""):
|
||||
# `price_timeout_s` is read with a plain `.get()` by every plugin, so it
|
||||
# has to be present - an options dict that relies on price_option()'s
|
||||
# defaults for it fetches with `float(None)` and this section would grade
|
||||
# a TypeError instead of an outage.
|
||||
return {"price_provider": "generic_dayahead", "price_timeout_s": 10,
|
||||
"price_horizon_h": 48, "static_profile": profile}
|
||||
|
||||
|
||||
_T12_HORIZON_H = 48
|
||||
_T12_STALE_S = _main.TARIFF_MAX_AGE_S
|
||||
_t12_saved_transport = _price.https_get
|
||||
_price.https_get = _t12_transport
|
||||
|
||||
# -- the accepted baseline (habit 7): one good fetch, varied one thing at a
|
||||
# -- time from here.
|
||||
_T12_FEED_UP["up"] = True
|
||||
_t12_a = derived(lambda: _T12Controller(_t12_opts()).refresh_once())
|
||||
_t12_good = derived(lambda: _t12_a.tariff_plan)
|
||||
check(f"BASELINE: a fetch that succeeds stores an OBSERVATION - "
|
||||
f"{getattr(_t12_good, 'words', _t12_good)!r}",
|
||||
getattr(_t12_good, "origin", None) == "provider"
|
||||
and _t12_good.from_feed is True
|
||||
and len(_t12_good.read.points) == _T12_HORIZON_H)
|
||||
|
||||
# -- the stimulus, graded before it is used on anything (habit 6/7) ----------
|
||||
_T12_FEED_UP["up"] = False
|
||||
_t12_now = datetime.now(UTC)
|
||||
_t12_deg = derived(lambda: asyncio.run(resolve_tariff(
|
||||
_t12_opts(), _t12_now, _t12_now + timedelta(hours=_T12_HORIZON_H),
|
||||
now=_t12_now, history=NoHistory())))
|
||||
check("STIMULUS: a dead feed makes resolve_tariff() RETURN a degraded plan - "
|
||||
"it does not raise, so `except Exception` is not what was protecting "
|
||||
"the plan and the defect was never on that branch",
|
||||
isinstance(_t12_deg, TariffPlan))
|
||||
check(f"...and the returned plan is the §11.4 no-profile row the soak saw - "
|
||||
f"{getattr(_t12_deg, 'words', _t12_deg)!r}",
|
||||
isinstance(_t12_deg, TariffPlan) and _t12_deg.origin == "none"
|
||||
and _t12_deg.from_feed is False and _t12_deg.has_prices is False
|
||||
and "ClientConnectorError" in _t12_deg.words)
|
||||
|
||||
# -- AC1: the degradation must not replace a plan still inside the bound -----
|
||||
# ⚠️ 1800 s IS STATED HERE, NOT DERIVED FROM THE MODULE BEING GRADED - section
|
||||
# 8's own lesson. A probe at `TARIFF_MAX_AGE_S - 1` moves with a mutation of
|
||||
# the constant; half an hour is inside any reading of a 5400 s bound, and it is
|
||||
# the interval the soak actually ran at.
|
||||
_t12_b = derived(lambda: _T12Controller(_t12_opts()))
|
||||
_t12_b.tariff_plan, _t12_b.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
_t12_at_before = _t12_b.tariff_at
|
||||
check(f"PRECONDITION: the standing plan is 1800 s old and TARIFF_MAX_AGE_S is "
|
||||
f"{_T12_STALE_S:.0f} s, so it IS fresh when the failed refresh runs - "
|
||||
f"without this the next check grades an expired plan",
|
||||
1800 < _T12_STALE_S
|
||||
and _t12_b.fresh_tariff_plan(datetime.now(UTC)) is _t12_good)
|
||||
_t12_b.refresh_once()
|
||||
check("a RETURNED degradation does not replace a plan still inside "
|
||||
"TARIFF_MAX_AGE_S - the 48 h series is still there, by identity, and "
|
||||
"not a 0-point `origin: none` read",
|
||||
_t12_b.tariff_plan is _t12_good
|
||||
and _t12_b.tariff_plan.has_prices is True
|
||||
and len(_t12_b.tariff_plan.read.points) == _T12_HORIZON_H)
|
||||
check("...and `tariff_at` did NOT move, so the kept plan still ages out from "
|
||||
"the fetch that produced it and is distinguishable from a freshly "
|
||||
"fetched one. Re-stamping it here is how a plan freezes at its last "
|
||||
"value and looks current forever",
|
||||
_t12_b.tariff_at == _t12_at_before)
|
||||
check("...and the two timestamps a successful refresh and a kept plan produce "
|
||||
"really are different values, or the check above compares a number "
|
||||
"against itself",
|
||||
_t12_a.tariff_at != _t12_at_before)
|
||||
|
||||
# -- AC3: TARIFF_MAX_AGE_S governs - the bound is where the module says ------
|
||||
_t12_c = derived(lambda: _T12Controller(_t12_opts()))
|
||||
_t12_c.tariff_plan, _t12_c.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=_T12_STALE_S + 5)
|
||||
_t12_c.refresh_once()
|
||||
check(f"a plan that has aged PAST {_T12_STALE_S:.0f} s IS replaced by the "
|
||||
f"degradation - the plan is served stale for the age bound and no "
|
||||
f"longer, so TARIFF_MAX_AGE_S governs something rather than being "
|
||||
f"unreachable",
|
||||
_t12_c.tariff_plan is not _t12_good
|
||||
and _t12_c.tariff_plan.from_feed is False
|
||||
and _t12_c.tariff_plan.has_prices is False)
|
||||
_t12_d = derived(lambda: _T12Controller(_t12_opts()))
|
||||
_t12_d.tariff_plan, _t12_d.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=_T12_STALE_S - 60)
|
||||
_t12_d.refresh_once()
|
||||
check("...and one minute SHORT of the bound it is still kept, so the line "
|
||||
"above is a bound rather than 'the degradation always wins'",
|
||||
_t12_d.tariff_plan is _t12_good)
|
||||
_t12_e = derived(lambda: _T12Controller(_t12_opts()).refresh_once())
|
||||
check("a degradation with NO plan standing at all is stored - §11.4 still "
|
||||
"reaches an install that has never had a good fetch, and the guard is "
|
||||
"not 'never overwrite'",
|
||||
_t12_e.tariff_plan is not None
|
||||
and _t12_e.tariff_plan.from_feed is False
|
||||
and _t12_e.tariff_at is not None)
|
||||
|
||||
# -- the §11.4 STATIC FALLBACK, which is the judgement this card had to make -
|
||||
_t12_f = derived(lambda: _T12Controller(_t12_opts(STATIC_BE)))
|
||||
_t12_fb = derived(lambda: asyncio.run(resolve_tariff(
|
||||
_t12_opts(STATIC_BE), _t12_now,
|
||||
_t12_now + timedelta(hours=_T12_HORIZON_H), now=_t12_now,
|
||||
history=NoHistory())))
|
||||
check(f"PRECONDITION: with a static_profile configured the SAME dead feed "
|
||||
f"degrades to a §11.4 FALLBACK carrying real prices, not to the "
|
||||
f"no-profile row - {getattr(_t12_fb, 'words', _t12_fb)!r}",
|
||||
isinstance(_t12_fb, TariffPlan) and _t12_fb.origin == "fallback"
|
||||
and _t12_fb.has_prices is True and _t12_fb.from_feed is False)
|
||||
check("...and every one of its points is provisional, so firm_points() is "
|
||||
"empty, arbitrage() refuses and cheap_now() is False - it is a real "
|
||||
"tariff and nothing may be acted on it. That is why it does not replace "
|
||||
"a fresh observation",
|
||||
isinstance(_t12_fb, TariffPlan) and _t12_fb.provisional is True
|
||||
and _t12_fb.firm_points() == ()
|
||||
and _t12_fb.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=NO_WEATHER).kind == "refused")
|
||||
_t12_f.tariff_plan, _t12_f.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
_t12_f_at = _t12_f.tariff_at
|
||||
_t12_f.refresh_once()
|
||||
check("a §11.4 static FALLBACK does not replace a fresh provider plan either, "
|
||||
"although it has prices - a day-ahead price for an hour does not change "
|
||||
"once published, so the 30-minute-old observation is no less true and "
|
||||
"is the only one of the two that carries a firm hour",
|
||||
_t12_f.tariff_plan is _t12_good and _t12_f.tariff_at == _t12_f_at)
|
||||
|
||||
# -- ...and the rule is NOT symmetrical: a degradation replaces a degradation.
|
||||
_t12_g = derived(lambda: _T12Controller(_t12_opts(STATIC_BE)))
|
||||
_t12_g.tariff_plan, _t12_g.tariff_at = _t12_fb, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
_t12_g_at = _t12_g.tariff_at
|
||||
_t12_g.refresh_once()
|
||||
check("a standing plan that is ITSELF a §11.4 fallback IS replaced by the "
|
||||
"next one, and its timestamp moves - otherwise an outage would freeze "
|
||||
"the operator's static profile at the window it was first generated "
|
||||
"for, and the guard would be 'never overwrite' wearing a bound's name",
|
||||
_t12_g.tariff_plan is not _t12_fb and _t12_g.tariff_plan.origin ==
|
||||
"fallback" and _t12_g.tariff_at != _t12_g_at)
|
||||
|
||||
# -- ...and a recovered feed replaces a kept plan, which is the third term ---
|
||||
_T12_FEED_UP["up"] = True
|
||||
_t12_h = derived(lambda: _T12Controller(_t12_opts()))
|
||||
_t12_h.tariff_plan, _t12_h.tariff_at = _t12_good, datetime.now(UTC) - \
|
||||
timedelta(seconds=1800)
|
||||
_t12_h_at = _t12_h.tariff_at
|
||||
_t12_h.refresh_once()
|
||||
check("when the feed answers again the observation replaces the kept plan and "
|
||||
"`tariff_at` moves - the guard keeps a plan against a DEGRADATION, not "
|
||||
"against a fetch that worked",
|
||||
_t12_h.tariff_plan is not _t12_good
|
||||
and _t12_h.tariff_plan.from_feed is True
|
||||
and _t12_h.tariff_at != _t12_h_at)
|
||||
|
||||
# -- TARIFF-10 widened WHICH INSTALLS RUN THIS TASK, so the configurations it
|
||||
# -- newly keeps alive are graded here rather than reasoned about.
|
||||
# ⚠️ `price_provider: off` + a `static_profile` + `arbitrage_enabled: true` is a
|
||||
# configuration validate.py ACCEPTS and TARIFF-10's `or` exists to protect. On
|
||||
# it `resolve_tariff()` returns `static_plan(provisional=False)` - the
|
||||
# CONFIGURED product, origin "static" - so `standing.from_feed` is false on
|
||||
# every refresh and this card's guard must never fire. Confirmed by driving it,
|
||||
# not by reading the origin table: a guard that quietly latched here would
|
||||
# freeze a static site's plan at its first window for the life of the install.
|
||||
_t12_i = derived(lambda: _T12Controller(
|
||||
{"price_provider": "off", "price_timeout_s": 10, "price_horizon_h": 48,
|
||||
"static_profile": STATIC_BE}).refresh_once())
|
||||
_t12_i_plan, _t12_i_at = _t12_i.tariff_plan, _t12_i.tariff_at
|
||||
_t12_i.refresh_once()
|
||||
check("PRECONDITION: a `price_provider: off` + static_profile site resolves to "
|
||||
"the CONFIGURED product, not to a §11.4 row - origin 'static', prices, "
|
||||
"and from_feed false because there is no feed to have observed",
|
||||
getattr(_t12_i_plan, "origin", None) == "static"
|
||||
and _t12_i_plan.has_prices is True and _t12_i_plan.from_feed is False)
|
||||
check("...and TARIFF-10 now keeps this task alive on exactly that site, where "
|
||||
"this card's guard must NEVER latch: the second refresh replaces the "
|
||||
"first and the timestamp moves, so the plan is regenerated for the "
|
||||
"current window every 30 minutes as it always was",
|
||||
_t12_i.tariff_plan is not _t12_i_plan
|
||||
and _t12_i.tariff_plan.origin == "static"
|
||||
and _t12_i.tariff_at != _t12_i_at)
|
||||
|
||||
# ⚠️ AND THE OTHER CONFIGURATION TARIFF-10 ADDED - a provider configured with
|
||||
# trading DISARMED - is graded by every driven check above without a second
|
||||
# fixture, because nothing on this path reads `arbitrage_enabled` at all. That
|
||||
# is a claim about the code rather than about a behaviour, so it is asserted as
|
||||
# one (habit 4's exception, FCST-06's precedent): a fixture that set the option
|
||||
# and asserted no difference would be comparing a thing against itself.
|
||||
_T12_TRADING_OPTS = ("arbitrage_enabled", "cycle_cost_threshold_eur",
|
||||
"arbitrage_soc_floor", "arbitrage_soc_ceiling")
|
||||
# ⚠️ AND IT IS WHAT THE FUNCTION READS THAT IS SCANNED, NOT ITS TEXT. Two
|
||||
# earlier drafts of this check were red against a CORRECT tree: the first
|
||||
# because the docstring paragraph that SAYS `arbitrage_enabled` is not read is
|
||||
# itself an occurrence of the string, the second because the log line says
|
||||
# "arbitrage, where it is armed, stops with it" - which is a sentence, not a
|
||||
# read. A substring test over a function grades its prose as much as its
|
||||
# behaviour (habit 4). So the symbols are taken from the AST, and the string
|
||||
# constants are taken separately and with the docstring dropped: naming
|
||||
# arbitrage in a message is fine, READING a trading option is the thing claimed
|
||||
# not to happen.
|
||||
_t12_syms = derived(lambda: {getattr(n, "id", None) or getattr(n, "attr", None)
|
||||
for n in ast.walk(_refresh)
|
||||
if isinstance(n, (ast.Name, ast.Attribute))})
|
||||
_t12_consts = derived(lambda: {c.value for b in _refresh.body[1:]
|
||||
for c in ast.walk(b)
|
||||
if isinstance(c, ast.Constant)
|
||||
and isinstance(c.value, str)})
|
||||
check("run_tariff_refresh() READS no trading option and calls nothing in "
|
||||
"app/arbitrage.py, so keeping a plan is a decision about what the add-on "
|
||||
"has LOOKED UP - TARIFF-10's own distinction, and why the checks above "
|
||||
"cover a provider-configured site whether or not arbitrage is armed",
|
||||
isinstance(_t12_syms, set)
|
||||
and not any("arbitrage" in (n or "") for n in _t12_syms)
|
||||
and not (_t12_consts & set(_T12_TRADING_OPTS))
|
||||
# ...and both scanners can see what IS there, or they grade nothing: the
|
||||
# one option this task does read, and a name it really does call.
|
||||
and "price_horizon_h" in _t12_consts
|
||||
and "fresh_tariff_plan" in _t12_syms
|
||||
# ...and the docstring really was dropped, or the first draft's defect is
|
||||
# back and the constant set is grading prose again.
|
||||
and not any("RETURNS, NEVER RAISES" in c for c in _t12_consts))
|
||||
|
||||
# -- the vocabulary, graded on its own so a mutation of it has a named home --
|
||||
check("TariffPlan.from_feed is true of exactly the observation - a §11.4 "
|
||||
"fallback, a no-data read and a CONFIGURED static profile are all false, "
|
||||
"and it is not a synonym for has_prices",
|
||||
_t12_good.from_feed is True and _t12_fb.from_feed is False
|
||||
and _t12_deg.from_feed is False
|
||||
and static_plan({"static_profile": STATIC_BE}, D0,
|
||||
D0 + timedelta(days=2), now=D0).from_feed is False
|
||||
and static_plan({"static_profile": STATIC_BE}, D0,
|
||||
D0 + timedelta(days=2), now=D0).has_prices is True)
|
||||
|
||||
# ⚠️ A SHAPE CLAIM, AND IT IS ALLOWED TO BE ONE (FCST-06's precedent). "The age
|
||||
# is judged in ONE place" is a claim about the code, not about a behaviour: a
|
||||
# transcribed copy of the same comparison behaves identically until the day the
|
||||
# two drift. Every other check in this section is driven.
|
||||
check("run_tariff_refresh() asks fresh_tariff_plan() rather than re-deriving "
|
||||
"the age bound - one place judges it, which is what that method's own "
|
||||
"docstring is for",
|
||||
_refresh is not None
|
||||
and {n.func.attr for n in ast.walk(_refresh)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)}
|
||||
>= {"fresh_tariff_plan"})
|
||||
|
||||
_price.https_get = _t12_saved_transport
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("9. the startup gate - the configurations that cannot work are refused")
|
||||
@@ -1376,6 +1695,312 @@ check("soc_bounds() returns the shipped pair, floor first",
|
||||
float(PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("10. TARIFF-14 - the QUARTER-HOURLY day, end to end through decide()")
|
||||
# ⚠️ THIS IS THE PROPERTY THE DEFECT ACTUALLY BROKE, WHICH IS WHY IT IS GRADED
|
||||
# HERE AND NOT ONLY ON `at()`. `_act()` decides whether this interval is the
|
||||
# buy one or the sell one with `here.start == v.cheapest.start` - an IDENTITY
|
||||
# test between the point `_point_at()` matched and the point the verdict was
|
||||
# built from. `_Series.at()` used to return the first point whose start was
|
||||
# within one hour BEFORE the instant, in list order, so on a 900 s series that
|
||||
# identity almost never held: the strategy idled through exactly the interval
|
||||
# it exists to trade, and read `here.provisional` and §17's `price < 0` off a
|
||||
# point that was not the one being traded.
|
||||
#
|
||||
# Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
|
||||
# returns 96 points at 900 s. `static_tariff.py` emits HOURLY points, so every
|
||||
# fixture above this line - and every static site - is immune; this is
|
||||
# reachable only down the dynamic path TARIFF-08/09 made work.
|
||||
|
||||
QD0 = datetime(2026, 1, 15, 0, 0, tzinfo=timezone(timedelta(hours=1)))
|
||||
QUARTER = timedelta(minutes=15)
|
||||
|
||||
|
||||
def qplan_of(prices, *, provisional=False, start=QD0, gaps=None):
|
||||
"""A TariffPlan over `prices` spaced by `gaps` seconds (900 s by default).
|
||||
|
||||
`gaps` is what makes the MIXED fixture below expressible: FCST-04's
|
||||
carry-forward emits HOURLY carried points beside QUARTER-HOURLY firm ones,
|
||||
and the soak measured spacing [900, 3600] in 70 % of samples.
|
||||
"""
|
||||
g = [900] * (len(prices) - 1) if gaps is None else list(gaps)
|
||||
flags = ([bool(provisional)] * len(prices)
|
||||
if isinstance(provisional, bool) else list(provisional))
|
||||
pts, t = [], start
|
||||
for i, (p, f) in enumerate(zip(prices, flags)):
|
||||
pts.append(PricePoint(start=brussels(t), eur_per_kwh=round(float(p), 6),
|
||||
provisional=bool(f)))
|
||||
if i < len(g):
|
||||
t += timedelta(seconds=g[i])
|
||||
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", start),
|
||||
None, "provider")
|
||||
|
||||
|
||||
def qprobe(i, *, start=QD0, gaps=None):
|
||||
"""An instant 7 minutes into slot `i` - inside every window, never on a
|
||||
boundary, and NOT the midpoint of an hour either, so an implementation that
|
||||
answered per hour and one that answers per quarter differ here."""
|
||||
g = [900] * i if gaps is None else list(gaps)[:i]
|
||||
return start + timedelta(seconds=sum(g)) + timedelta(minutes=7)
|
||||
|
||||
|
||||
def shipped_at(series, u):
|
||||
"""THE RULE TARIFF-14 REPLACED, kept here as the COUNTERFACTUAL ONLY.
|
||||
|
||||
The first point whose start is within one hour before `u`, in list order.
|
||||
Nothing below is graded against it; it exists so that the fixtures can be
|
||||
shown to SEPARATE the two rules. A check whose data cannot reach the
|
||||
failure it names is decoration (MUTATION.md), and on an hourly series the
|
||||
two rules agree at every instant - which is why 29 files stayed green.
|
||||
"""
|
||||
uu = u.astimezone(UTC)
|
||||
for p in series.points:
|
||||
s = p.start.astimezone(UTC)
|
||||
if s <= uu < s + timedelta(hours=1):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
# The hourly day interpolated across its quarters, with the two extremes
|
||||
# planted OFF the hour boundary on purpose.
|
||||
QBASE = []
|
||||
for _i in range(24):
|
||||
_a, _b = DAY[_i], DAY[(_i + 1) % 24]
|
||||
for _q in range(4):
|
||||
QBASE.append(round(_a + (_b - _a) * _q / 4 + 0.000001 * (_i * 4 + _q),
|
||||
6))
|
||||
Q_CHEAP_I = 4 * 5 + 3 # 05:45 - a :45 quarter, not a whole hour
|
||||
Q_DEAR_I = 4 * 19 + 1 # 19:15 - a :15 quarter, not a whole hour
|
||||
QDAY = list(QBASE)
|
||||
QDAY[Q_CHEAP_I] = round(min(QBASE) - 0.030, 6)
|
||||
QDAY[Q_DEAR_I] = round(max(QBASE) + 0.030, 6)
|
||||
QPLAN = qplan_of(QDAY)
|
||||
QPTS = QPLAN.read.points
|
||||
|
||||
print("10a. the fixture is not degenerate, and it SEPARATES the two rules")
|
||||
_qgaps = sorted({int((b.start - a.start).total_seconds())
|
||||
for a, b in zip(QPTS, QPTS[1:])})
|
||||
check(f"the day is 96 points at {_qgaps} s - the shape the live feed has "
|
||||
f"returned since Belgium's 2025-10-01 move to a 15-minute MTU",
|
||||
len(QPTS) == 96 and _qgaps == [900])
|
||||
check(f"the cheapest quarter is slot {Q_CHEAP_I} and the dearest slot "
|
||||
f"{Q_DEAR_I} - neither first, neither last, {abs(Q_CHEAP_I - Q_DEAR_I)} "
|
||||
f"slots apart, and NEITHER ON A WHOLE HOUR, without which this section "
|
||||
f"would grade nothing an hourly fixture does not already grade",
|
||||
QDAY.index(min(QDAY)) == Q_CHEAP_I and QDAY.index(max(QDAY)) == Q_DEAR_I
|
||||
and Q_CHEAP_I % 4 != 0 and Q_DEAR_I % 4 != 0
|
||||
and Q_CHEAP_I not in (0, 95) and Q_DEAR_I not in (0, 95)
|
||||
and abs(Q_CHEAP_I - Q_DEAR_I) > 1)
|
||||
check("...and all 96 prices are distinct, so 'the cheapest quarter' names one "
|
||||
"quarter and no tie-break is what any check below is really grading",
|
||||
len(set(QDAY)) == 96)
|
||||
check(f"...and its spread is {round(max(QDAY) - min(QDAY), 6):.4f} EUR/kWh, "
|
||||
f"clear of the widened threshold, so what follows grades the strategy "
|
||||
f"and not the spread rung",
|
||||
round(max(QDAY) - min(QDAY), 6) > required_spread(_shipped, NO_WEATHER))
|
||||
# ⚠️ HABIT 4, AND THE ONLY REASON THIS SECTION IS WORTH ITS LINES. State, as a
|
||||
# check, that the shipped rule and the fixed one give DIFFERENT points at the
|
||||
# two probes that matter. Without it, every check below could be passing on a
|
||||
# fixture the defect could not have changed.
|
||||
_cf_buy = derived(lambda: shipped_at(QPLAN.read, qprobe(Q_CHEAP_I)))
|
||||
_cf_sell = derived(lambda: shipped_at(QPLAN.read, qprobe(Q_DEAR_I)))
|
||||
check(f"the shipped rule and this one give DIFFERENT points at both probes - "
|
||||
f"buy: {derived(lambda: _cf_buy.eur_per_kwh)} instead of "
|
||||
f"{QDAY[Q_CHEAP_I]}, sell: {derived(lambda: _cf_sell.eur_per_kwh)} "
|
||||
f"instead of {QDAY[Q_DEAR_I]} - so the fixture can reach the failure it "
|
||||
f"names",
|
||||
_cf_buy is not QPTS[Q_CHEAP_I] and _cf_sell is not QPTS[Q_DEAR_I]
|
||||
and _cf_buy is not None and _cf_sell is not None)
|
||||
|
||||
print()
|
||||
print("10b. the buy/sell identity test matches the actual extremum interval")
|
||||
_qv = QPLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
check("the verdict's cheapest and dearest are the two QUARTERS, not the hours "
|
||||
"containing them - `static_tariff` reduces over every firm point and "
|
||||
"carries no resolution assumption of its own",
|
||||
derived(lambda: _qv.cheapest) is QPTS[Q_CHEAP_I]
|
||||
and derived(lambda: _qv.dearest) is QPTS[Q_DEAR_I])
|
||||
_pa_buy = derived(lambda: _arb._point_at(QPLAN, qprobe(Q_CHEAP_I)))
|
||||
_pa_sell = derived(lambda: _arb._point_at(QPLAN, qprobe(Q_DEAR_I)))
|
||||
check("THE PROPERTY THE DEFECT BROKE: at the buy probe `_point_at()` returns "
|
||||
"the verdict's OWN cheapest point, by identity - which is what makes "
|
||||
"`_act()`'s `here.start == v.cheapest.start` true, and what made it "
|
||||
"false on every quarter-hourly series the add-on has ever fetched",
|
||||
_pa_buy is derived(lambda: _qv.cheapest))
|
||||
check("...and the same at the sell probe, against the verdict's dearest",
|
||||
_pa_sell is derived(lambda: _qv.dearest))
|
||||
_qbuy = decide(QPLAN, qprobe(Q_CHEAP_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
_qsell = decide(QPLAN, qprobe(Q_DEAR_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
check("...so decide() CHARGES in the cheapest quarter and DISCHARGES in the "
|
||||
"dearest one, which is the trade the strategy exists to make and the "
|
||||
"one it slept through",
|
||||
_qbuy.action == CHARGE and _qsell.action == DISCHARGE)
|
||||
check(f"...at THAT QUARTER's price and not at the price of the hour "
|
||||
f"containing it (buy {_qbuy.price_now_eur_kwh}, sell "
|
||||
f"{_qsell.price_now_eur_kwh})",
|
||||
near(_qbuy.price_now_eur_kwh, QDAY[Q_CHEAP_I])
|
||||
and near(_qsell.price_now_eur_kwh, QDAY[Q_DEAR_I]))
|
||||
check("...and both reasons quote that quarter's price, so the operator's log "
|
||||
"names the interval that was actually traded",
|
||||
f"{QDAY[Q_CHEAP_I]:.4f}" in _qbuy.reason
|
||||
and f"{QDAY[Q_DEAR_I]:.4f}" in _qsell.reason)
|
||||
# The negative arm. Without it "it acted" could be "it acts everywhere".
|
||||
_QOFF_I = Q_CHEAP_I + 2
|
||||
check(f"the off-peak probe's own price differs from its hour's :00 price "
|
||||
f"({QDAY[_QOFF_I]} vs {QDAY[4 * (_QOFF_I // 4)]}), so the check below "
|
||||
f"can tell which point the decision read",
|
||||
QDAY[_QOFF_I] != QDAY[4 * (_QOFF_I // 4)]
|
||||
and _QOFF_I not in (Q_CHEAP_I, Q_DEAR_I))
|
||||
_qoff = decide(QPLAN, qprobe(_QOFF_I), peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0)
|
||||
check("a quarter that is neither extremum still idles, for the reason that "
|
||||
"names the right rule, and reports ITS OWN price - not the price of the "
|
||||
"quarter the shipped scan would have matched",
|
||||
_qoff.action is None and _qoff.reason == _arb.IDLE_OFF_PEAK_HOUR
|
||||
and near(_qoff.price_now_eur_kwh, QDAY[_QOFF_I]))
|
||||
|
||||
print()
|
||||
print("10c. §17 and §8.3 are evaluated on the QUARTER being traded")
|
||||
# ⚠️ §17 AT 900 s. The whole day is below zero and the dearest QUARTER is the
|
||||
# least negative one - section 2's shape, at the resolution the live feed
|
||||
# actually publishes. Under the shipped scan this probe never reached the
|
||||
# guard at all: the identity test failed first and the decision idled as
|
||||
# "neither the buy hour nor the sell hour", so §17 was not so much wrong as
|
||||
# never consulted.
|
||||
_shift = round(max(QDAY) + 0.020, 6)
|
||||
QNEG = [round(p - _shift, 6) for p in QDAY]
|
||||
QNEG_PLAN = qplan_of(QNEG)
|
||||
check(f"the negative day's dearest quarter is slot {Q_DEAR_I} and it is BELOW "
|
||||
f"ZERO ({max(QNEG):+.4f}), and its spread still clears the widened "
|
||||
f"threshold - so the refusal below is §17's and not the spread rung's",
|
||||
QNEG.index(max(QNEG)) == Q_DEAR_I and max(QNEG) < 0
|
||||
and round(max(QNEG) - min(QNEG), 6) > required_spread(_shipped,
|
||||
NO_WEATHER))
|
||||
_qneg_sell = decide(QNEG_PLAN, qprobe(Q_DEAR_I), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER, charge_w=MAX_W, discharge_w=MAX_W,
|
||||
soc_floor_pct=20.0, soc_ceiling_pct=95.0)
|
||||
check("selling into a negative-price QUARTER is refused, and the reason names "
|
||||
"§17 and THAT QUARTER's price",
|
||||
_qneg_sell.action is None and "§17" in _qneg_sell.reason
|
||||
and f"{max(QNEG):+.4f}" in _qneg_sell.reason)
|
||||
# ...and the other side, so the refusal is about the sign and nothing else.
|
||||
QPOS = list(QNEG)
|
||||
QPOS[Q_DEAR_I] = round(-max(QNEG), 6)
|
||||
check("...while the SAME quarter-hourly series with that ONE price's SIGN "
|
||||
"FLIPPED discharges instead",
|
||||
decide(qplan_of(QPOS), qprobe(Q_DEAR_I), peak_guard_active=False,
|
||||
soc=50.0, min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == DISCHARGE
|
||||
and sum(1 for a, b in zip(QNEG, QPOS) if a != b) == 1)
|
||||
# §8.3's flag, read off the covering point. `_act`'s own provisional guard
|
||||
# stays unreachable here for section 4's reason - the extrema are firm by
|
||||
# `_firm_or_die` and starts are unique - and THAT ARGUMENT IS EXACTLY WHAT THE
|
||||
# DEFECT BROKE at 900 s, because the point `at()` returned was not the
|
||||
# verdict's point at all. It is restated here at the resolution that matters.
|
||||
check("the point matched for the buy quarter IS the verdict's own cheapest "
|
||||
"point and is FIRM, so `_act`'s §8.3 guard is unreachable at 900 s for "
|
||||
"the same construction section 4 states at 3600 s",
|
||||
_pa_buy is derived(lambda: _qv.cheapest)
|
||||
and derived(lambda: _pa_buy.provisional) is False
|
||||
and len({p.start for p in QPTS}) == len(QPTS))
|
||||
|
||||
print()
|
||||
print("10d. the MIXED series FCST-04 actually emits, traded end to end")
|
||||
# ⚠️ 70 % OF SOAK SAMPLES, NOT AN EDGE CASE. The carry-forward emits HOURLY
|
||||
# carried points beside QUARTER-HOURLY firm ones, so a window derived from one
|
||||
# global spacing would be wrong in the NORMAL steady state. Here: 12 carried
|
||||
# hourly points for the morning, then firm quarters for the rest of the day.
|
||||
MIX_N_H, MIX_N_Q = 12, 48
|
||||
MIX_GAPS = [3600] * MIX_N_H + [900] * (MIX_N_Q - 1)
|
||||
MIX_PRICES = [QDAY[4 * i] for i in range(MIX_N_H)] + QDAY[4 * MIX_N_H:]
|
||||
MIX_FLAGS = [True] * MIX_N_H + [False] * MIX_N_Q
|
||||
MIX_PLAN = qplan_of(MIX_PRICES, provisional=MIX_FLAGS, gaps=MIX_GAPS)
|
||||
MIX_PTS = MIX_PLAN.read.points
|
||||
_mix_gaps = sorted({int((b.start - a.start).total_seconds())
|
||||
for a, b in zip(MIX_PTS, MIX_PTS[1:])})
|
||||
check(f"the mixed plan really is mixed - spacing {_mix_gaps} s, "
|
||||
f"{MIX_N_H} carried and {MIX_N_Q} firm points, so neither half is what "
|
||||
f"the other half's window rule would give",
|
||||
_mix_gaps == [900, 3600]
|
||||
and sum(1 for p in MIX_PTS if p.provisional) == MIX_N_H
|
||||
and sum(1 for p in MIX_PTS if not p.provisional) == MIX_N_Q)
|
||||
_mix_cheap_i = MIX_N_H + (MIX_PRICES[MIX_N_H:].index(min(MIX_PRICES[MIX_N_H:])))
|
||||
_mix_dear_i = MIX_N_H + (MIX_PRICES[MIX_N_H:].index(max(MIX_PRICES[MIX_N_H:])))
|
||||
check(f"...and its firm half's extremes are quarters {_mix_cheap_i} and "
|
||||
f"{_mix_dear_i}, neither on a whole hour, so the identity test below is "
|
||||
f"about the quarter and not about the hour",
|
||||
(_mix_cheap_i - MIX_N_H) % 4 != 0 and (_mix_dear_i - MIX_N_H) % 4 != 0)
|
||||
_mv = MIX_PLAN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=_shipped,
|
||||
uncertainty=NO_WEATHER)
|
||||
_mix_buy_t = qprobe(_mix_cheap_i, gaps=MIX_GAPS)
|
||||
_mix_sell_t = qprobe(_mix_dear_i, gaps=MIX_GAPS)
|
||||
check("the verdict over a MIXED series still names firm quarters, and "
|
||||
"`_point_at()` matches them by identity at their own probes",
|
||||
derived(lambda: _mv.cheapest) is MIX_PTS[_mix_cheap_i]
|
||||
and derived(lambda: _mv.dearest) is MIX_PTS[_mix_dear_i]
|
||||
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_buy_t))
|
||||
is MIX_PTS[_mix_cheap_i]
|
||||
and derived(lambda: _arb._point_at(MIX_PLAN, _mix_sell_t))
|
||||
is MIX_PTS[_mix_dear_i])
|
||||
check("...so the mixed day CHARGES and DISCHARGES in its firm quarters, at "
|
||||
"their own prices",
|
||||
decide(MIX_PLAN, _mix_buy_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == CHARGE
|
||||
and decide(MIX_PLAN, _mix_sell_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action == DISCHARGE)
|
||||
# An instant inside a CARRIED hour of the same series. The covering point is
|
||||
# the carried one; the shipped scan answered a point from the FIRM block
|
||||
# earlier in the list, which is §8.3's flag read off the wrong point.
|
||||
_mix_carried_t = QD0 + timedelta(hours=MIX_N_H - 1, minutes=37)
|
||||
check("an instant inside a CARRIED hour of the mixed series matches the "
|
||||
"CARRIED point, whose provisional flag is what §8.3 reads - and the "
|
||||
"strategy does nothing there, because a carried point is not in the "
|
||||
"firm trade set",
|
||||
derived(lambda: _arb._point_at(MIX_PLAN, _mix_carried_t))
|
||||
is MIX_PTS[MIX_N_H - 1]
|
||||
and MIX_PTS[MIX_N_H - 1].provisional is True
|
||||
and decide(MIX_PLAN, _mix_carried_t, peak_guard_active=False, soc=50.0,
|
||||
min_spread_eur_kwh=_shipped, uncertainty=NO_WEATHER,
|
||||
charge_w=MAX_W, discharge_w=MAX_W, soc_floor_pct=20.0,
|
||||
soc_ceiling_pct=95.0).action is None)
|
||||
|
||||
print()
|
||||
print("10e. the HOURLY day is unchanged, which is the regression half")
|
||||
# ⚠️ A REGRESSION GUARD, NOT A WITNESS. On an hourly series the shipped rule
|
||||
# and this one agree at every instant, which is precisely why 29 files stayed
|
||||
# green through the defect. It is here so that a fix which got 900 s right by
|
||||
# breaking 3600 s cannot pass.
|
||||
_h_buy = act(PLAN, CHEAP_H, thresh=_shipped, unc=NO_WEATHER)
|
||||
_h_sell = act(PLAN, DEAR_H, thresh=_shipped, unc=NO_WEATHER)
|
||||
check("the hourly fixture still charges in its cheapest hour and discharges "
|
||||
"in its dearest, at those hours' prices",
|
||||
_h_buy.action == CHARGE and _h_sell.action == DISCHARGE
|
||||
and near(_h_buy.price_now_eur_kwh, DAY[CHEAP_H])
|
||||
and near(_h_sell.price_now_eur_kwh, DAY[DEAR_H]))
|
||||
check("...and a probe 59 minutes into an hourly slot still matches that slot, "
|
||||
"so the hourly window did not shrink under the fix",
|
||||
derived(lambda: _arb._point_at(
|
||||
PLAN, D0 + timedelta(hours=CHEAP_H, minutes=59)))
|
||||
is derived(lambda: PLAN.read.points[CHEAP_H]))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -7,7 +7,7 @@ WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
|
||||
check, not a grep."
|
||||
|
||||
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
|
||||
71 keys, so "every schema key absent from options" quantifies over the empty set
|
||||
72 keys, so "every schema key absent from options" quantifies over the empty set
|
||||
and is true of any tree, including one with no defaults at all. Clause two -
|
||||
"the table changed goes red" - does work, six times over, once per mirror in its
|
||||
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
|
||||
|
||||
+3203
-80
File diff suppressed because it is too large
Load Diff
@@ -289,22 +289,29 @@ check("...and never repairs a flag: the audit's only outcomes are silence and "
|
||||
for i, u in enumerate(DAY[:PUBLISHED_N])})
|
||||
is None)
|
||||
|
||||
# ⚠️ A PUBLISHED POINT MAY BE PROVISIONAL. eneco_eddy's current-quarter print
|
||||
# is settled after the fact and says so (§9.2). "Published" is not "firm".
|
||||
_eddy = series_of(DAY[:2], lambda u: t_price(brussels(u).hour),
|
||||
PriceProduct.REALTIME, "eneco_eddy", provisional=True)
|
||||
# ⚠️ A PUBLISHED POINT MAY BE PROVISIONAL. A real-time / imbalance product's
|
||||
# current interval is settled after the fact and says so per point (§9.2).
|
||||
# "Published" is not "firm", and §9.3 carries the flag PER POINT for exactly
|
||||
# this. No shipped plugin declares REALTIME since TARIFF-07, so the series is
|
||||
# declared here - which is the only place the case can come from now, and is
|
||||
# why this stimulus did not go with the plugin.
|
||||
_rt = series_of(DAY[:2], lambda u: t_price(brussels(u).hour),
|
||||
PriceProduct.REALTIME, "_realtime_not_shipped",
|
||||
provisional=True)
|
||||
_eddy_filled = derived(lambda: fill_from_yesterday(
|
||||
_eddy, yesterday=YESTERDAY, since=D0, until=D1, stamp=D0))
|
||||
_rt, yesterday=YESTERDAY, since=D0, until=D1, stamp=D0))
|
||||
check("a PUBLISHED point that the feed itself flagged provisional keeps that "
|
||||
"flag - the audit checks provenance, and does not force published hours "
|
||||
"to False",
|
||||
_eddy_filled.kind == "series"
|
||||
and all(p.provisional for p in _eddy_filled.points[:2])
|
||||
and _eddy_filled.points[0] is _eddy.points[0])
|
||||
and _eddy_filled.points[0] is _rt.points[0])
|
||||
|
||||
# ⚠️ AND A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR (FCST-04 H1).
|
||||
# `eneco_eddy` settles per QUARTER and `_validate_raw()` accepts any
|
||||
# strictly-increasing spacing, so a published hour can hold four points. The
|
||||
# BELGIAN DAY-AHEAD ITSELF SETTLES PER QUARTER - the 15-minute MTU of
|
||||
# 2025-10-01, which `generic_dayahead` reads as 96 points a day - and
|
||||
# `_validate_raw()` accepts any strictly-increasing spacing, so a published
|
||||
# hour can hold four points. The
|
||||
# fill walks WHOLE HOURS to decide what needs carrying; that must not become a
|
||||
# rule about what a published hour may contain. Measured on FCST-04's first
|
||||
# draft, which keyed the published set `{_utc_hour(p.start): p ...}`: a
|
||||
@@ -316,7 +323,7 @@ _QUARTERS = PriceRead.series(
|
||||
eur_per_kwh=round(0.100000 + 0.011300 * n, 6),
|
||||
provisional=False)
|
||||
for n in range(_QN)],
|
||||
PriceProduct.REALTIME, "eneco_eddy", D0)
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", D0)
|
||||
_QHOURS = {_fc._utc_hour(p.start) for p in _QUARTERS.points}
|
||||
check(f"the quarter-hourly fixture really is sub-hourly - {_QN} points across "
|
||||
f"{len(_QHOURS)} hours - or every check below passes for an hourly feed "
|
||||
@@ -942,13 +949,20 @@ _normal_named_eddy = derived(lambda: min_accepted_spread(
|
||||
PriceProduct.DAY_AHEAD, frozenset(), BASE, provider="eneco_eddy"))
|
||||
check("the widening follows the DECLARED PriceProduct even when the provider "
|
||||
"is called 'generic_dayahead', and does NOT follow the name "
|
||||
"'eneco_eddy' when the declared product is day-ahead - so a sixth "
|
||||
"supplier is widened by its declaration and not by an id in a list",
|
||||
"'eneco_eddy' when the declared product is day-ahead - so a supplier is "
|
||||
"widened by its declaration and not by an id in a list",
|
||||
near(_wide_named_dayahead, _wide, 1e-9)
|
||||
and near(_normal_named_eddy, _normal, 1e-9))
|
||||
check("...and the shipped plugin §9.2 names IS the one that declares REALTIME, "
|
||||
"so the mechanism is pointed at the real product",
|
||||
PROVIDERS["eneco_eddy"].capabilities().product is PriceProduct.REALTIME
|
||||
# ⚠️ 'eneco_eddy' ABOVE IS NOW A RETIRED ID AND THE CHECK IS STRONGER FOR IT.
|
||||
# It was chosen because it was the one plugin that really did declare REALTIME,
|
||||
# so the check read "the name does not override the declaration". It declares
|
||||
# nothing now, which makes the string a pure name with no product behind it -
|
||||
# exactly the input an id-sniffing implementation would still widen.
|
||||
check("...and NO shipped plugin declares REALTIME since TARIFF-07, so the "
|
||||
"widening path is reached by declaration alone - kept deliberately, see "
|
||||
"DECISIONS/TARIFF-07-withdrawing-three-plugins.md",
|
||||
[p for p, c in PROVIDERS.items()
|
||||
if c.capabilities().product is PriceProduct.REALTIME] == []
|
||||
and uncertainty_of(PriceProduct.REALTIME)
|
||||
== frozenset({Uncertainty.REALTIME_PRODUCT})
|
||||
and uncertainty_of(PriceProduct.DAY_AHEAD) == frozenset())
|
||||
@@ -1087,19 +1101,27 @@ _cfg = derived(lambda: open(os.path.join(HERE, "config.yaml"),
|
||||
# why, as this line does.
|
||||
_OPT04_KEYS = ["arbitrage_enabled", "cycle_cost_threshold_eur",
|
||||
"arbitrage_soc_floor", "arbitrage_soc_ceiling"]
|
||||
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's and OPT-04's "
|
||||
f"keys - FCST-03 still added none of its own {_defaults}",
|
||||
# TARIFF-15 added the fifth, and came here to say so as this comment instructs:
|
||||
# `price_formula` is the `custom_formula` provider's only option - one JSON
|
||||
# string carrying one contract's coefficients - and it lives in this dict and
|
||||
# in config.yaml and nowhere else, which is the arrangement `static_profile`
|
||||
# already has for the same reason.
|
||||
_T15_KEYS = ["price_formula"]
|
||||
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's "
|
||||
f"and TARIFF-15's keys - FCST-03 still added none of its own "
|
||||
f"{_defaults}",
|
||||
_defaults == sorted(["price_provider", "price_markup_eur_kwh",
|
||||
"price_taxes_eur_kwh", "export_price_eur_kwh",
|
||||
"price_csv_path", "price_feed_url",
|
||||
"price_horizon_h", "price_timeout_s",
|
||||
"static_profile"] + _OPT04_KEYS))
|
||||
# ...and the four are genuinely NEW, not a rename of something that was already
|
||||
# there, or the line above would pass while the mirror silently lost a key.
|
||||
check("...and OPT-04's four keys are additions rather than replacements: the "
|
||||
"nine TARIFF-02/03 keys are all still present",
|
||||
"static_profile"] + _OPT04_KEYS + _T15_KEYS))
|
||||
# ...and the later keys are genuinely NEW, not a rename of something that was
|
||||
# already there, or the line above would pass while the mirror silently lost a
|
||||
# key.
|
||||
check("...and OPT-04's four and TARIFF-15's one are additions rather than "
|
||||
"replacements: the nine TARIFF-02/03 keys are all still present",
|
||||
isinstance(_defaults, list)
|
||||
and len(set(_defaults) - set(_OPT04_KEYS)) == 9)
|
||||
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)) == 9)
|
||||
check("...and every one of them is still in config.yaml, which stays the "
|
||||
"source of truth",
|
||||
isinstance(_cfg, str)
|
||||
|
||||
@@ -288,9 +288,25 @@ def allin(mwh):
|
||||
|
||||
|
||||
def feed_body(instants, table):
|
||||
return json.dumps({"data": [
|
||||
{"start": u.isoformat(), "price_eur_mwh": table[brussels(u).hour]}
|
||||
for u in instants]}).encode()
|
||||
"""A `generic_dayahead` body in the COLUMNAR shape the real feed publishes.
|
||||
|
||||
⚠️ TARIFF-08. This used to build `{"data": [{"start": ...,
|
||||
"price_eur_mwh": ...}]}` - a shape `api.energy-charts.info` has never
|
||||
published and that the shipped parser was nevertheless written to expect.
|
||||
Every price fixture in the repository fed that invented shape, so the
|
||||
suite graded the parser against the parser's own assumption while the live
|
||||
plugin raised `SourceUnavailable` on every fetch. Real captured bodies are
|
||||
replayed in test_price.py section 2b; the fixtures here are SYNTHETIC
|
||||
prices in the REAL shape, because this file is grading the fill and the
|
||||
history, not the parse.
|
||||
"""
|
||||
return json.dumps({
|
||||
"unix_seconds": [int(u.timestamp()) for u in instants],
|
||||
"price": [table[brussels(u).hour] for u in instants],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
|
||||
|
||||
def transport_for(body):
|
||||
@@ -299,8 +315,12 @@ def transport_for(body):
|
||||
return _t
|
||||
|
||||
|
||||
# ⚠️ NO `price_feed_url` (TARIFF-09). An override is used VERBATIM and the
|
||||
# plugin appends no date range to it, so a CFG that sets one - even to the
|
||||
# feed's own address - would exercise the override path in every check in this
|
||||
# file and never the shipped default. The override has its own grading in
|
||||
# test_price.py section 2c.
|
||||
CFG = {"price_provider": "generic_dayahead",
|
||||
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"price_timeout_s": 1,
|
||||
"price_markup_eur_kwh": MARKUP,
|
||||
"price_taxes_eur_kwh": TAXES,
|
||||
@@ -501,6 +521,220 @@ check("...and the plan is still a provider plan, not a §11.4 fallback: the "
|
||||
_after.origin == "provider" and _after.product is PriceProduct.DAY_AHEAD)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("1c. TARIFF-09 - the feed is now ASKED for the horizon, and tomorrow "
|
||||
"comes back FIRM")
|
||||
#
|
||||
# ⚠️ WHAT SECTION 1 COULD NOT SEE, AND WHY. Everything above grades the FILL
|
||||
# against a feed that published one day, and it was right to: FCST-04 covers
|
||||
# the window between midnight and the ~13:00-14:00 CET auction. But
|
||||
# `generic_dayahead.fetch()` accepted `since`/`until` and requested the bare
|
||||
# `?bzn=BE`, which answers one Brussels day AT EVERY HOUR OF EVERY DAY - so the
|
||||
# one-day feed was not the pre-publication state, it was the ONLY state, and
|
||||
# §8.3 dropped every hour past today from the trade set as provisional. The
|
||||
# optimiser never saw tomorrow as firm.
|
||||
#
|
||||
# Measured on the merged tree (4257e38) at 2026-09-10T09:00+02:00 over a 48 h
|
||||
# horizon, live feed, yesterday seeded: 15 firm whole hours and 33 carried,
|
||||
# and BOTH ends of the permitted cycle inside 2026-09-10. At 23:00 it was 1
|
||||
# firm and 47 carried, and the firm spread no longer cleared the round-trip
|
||||
# cost at all. The ceiling was structural - at hour H a one-day window gives at
|
||||
# most 24-H firm hours - which is why no amount of carrying could fix it.
|
||||
#
|
||||
# The two plans below differ in ONE thing: what the feed answered. The code
|
||||
# path, the window, the store and the instant are identical, so the difference
|
||||
# is the horizon and nothing else (habit 7).
|
||||
|
||||
TOMORROW_D = "2026-01-16"
|
||||
TOMORROW_H = bday_hours(TOMORROW_D)
|
||||
|
||||
# ⚠️ TOMORROW'S PRICES MUST NOT OVERLAP TODAY'S OR YESTERDAY'S, or a point's
|
||||
# day of origin is not recoverable from its number and every claim below is
|
||||
# about a coincidence. Asserted, not assumed.
|
||||
TOMORROW_MWH = {h: 300.0 + h for h in range(24)}
|
||||
TOMORROW_MWH[18] = 900.0 # the dear evening TOMORROW: the global peak of
|
||||
# the 48 h window, and it is on the second day
|
||||
|
||||
PRICE_AT = {u: TODAY_MWH[brussels(u).hour] for u in TODAY_H}
|
||||
PRICE_AT.update({u: TOMORROW_MWH[brussels(u).hour] for u in TOMORROW_H})
|
||||
|
||||
|
||||
def body_at(instants):
|
||||
"""A columnar body priced PER INSTANT, so two days can differ at the same
|
||||
local hour. `feed_body()` keys on the local hour alone and cannot."""
|
||||
return json.dumps({
|
||||
"unix_seconds": [int(u.timestamp()) for u in instants],
|
||||
"price": [PRICE_AT[u] for u in instants],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
|
||||
|
||||
def recording(body):
|
||||
"""A transport that serves bytes and remembers the URL it was asked for."""
|
||||
seen = []
|
||||
|
||||
async def _t(url, timeout_s):
|
||||
seen.append(url)
|
||||
return body
|
||||
_t.seen = seen
|
||||
return _t
|
||||
|
||||
|
||||
_vals_today = {TODAY_MWH[h] for h in range(24)}
|
||||
_vals_tmrw = {TOMORROW_MWH[h] for h in range(24)}
|
||||
_vals_yday = {YESTERDAY_MWH[h] for h in range(24)}
|
||||
check(f"the three days' price tables share no value at all "
|
||||
f"({len(_vals_today)}/{len(_vals_tmrw)}/{len(_vals_yday)} distinct), so "
|
||||
f"which day a point came from is recoverable from the point",
|
||||
not (_vals_today & _vals_tmrw) and not (_vals_tmrw & _vals_yday)
|
||||
and not (_vals_today & _vals_yday))
|
||||
check(f"...and both fixture days are 24 Brussels hours long "
|
||||
f"({len(TODAY_H)}/{len(TOMORROW_H)}), found by search rather than by "
|
||||
f"adding 24 UTC hours",
|
||||
len(TODAY_H) == 24 and len(TOMORROW_H) == 24)
|
||||
|
||||
# THE TWO FEED STATES, one code path.
|
||||
_t_two = recording(body_at(TODAY_H + TOMORROW_H))
|
||||
_h_two = store()
|
||||
_ph.PriceHistory._remember(_h_two, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
_pub = run(resolve_tariff(CFG, NOW, _until, transport=_t_two, now=NOW,
|
||||
history=_h_two))
|
||||
|
||||
_t_one = recording(body_at(TODAY_H))
|
||||
_h_one = store()
|
||||
_ph.PriceHistory._remember(_h_one, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
_pre = run(resolve_tariff(CFG, NOW, _until, transport=_t_one, now=NOW,
|
||||
history=_h_one))
|
||||
|
||||
# ⚠️ THE REQUEST, WHICH IS THE THING THIS CARD CHANGED. Both calls ask for the
|
||||
# same three Brussels days; only the answer differs. The expected dates are
|
||||
# spelled out here rather than derived from the module that builds them.
|
||||
check(f"the shipped plugin asks the feed for the Brussels days the horizon "
|
||||
f"touches, on both calls -> {_t_two.seen}",
|
||||
_t_two.seen == _t_one.seen
|
||||
== ["https://api.energy-charts.info/price?bzn=BE"
|
||||
"&start=2026-01-15&end=2026-01-17"])
|
||||
check("...and it is no longer the bare URL that answers a single day, which "
|
||||
"is what made the one-day state permanent rather than pre-publication",
|
||||
"start=" in _t_two.seen[0] and "end=" in _t_two.seen[0])
|
||||
|
||||
|
||||
def split(plan):
|
||||
"""(firm, carried), and ([], []) for a plan that observed no series.
|
||||
|
||||
⚠️ THE EMPTY ANSWER IS DELIBERATE. A mutation that turns the short
|
||||
pre-publication answer into `no_data` makes `plan.read.points` raise
|
||||
`Absence` by design, and an unguarded read here would take this file down
|
||||
with a traceback rather than reporting a named FAIL - "the suite is red"
|
||||
and "the check that watches this is red" are different claims
|
||||
(MUTATION.md, reporting a mutation, point 3). With this guard the counts
|
||||
below go red BY NAME, which is what a mutation report can cite.
|
||||
"""
|
||||
if not plan.has_prices:
|
||||
return [], []
|
||||
firm = [p for p in plan.read.points if not p.provisional]
|
||||
carried = [p for p in plan.read.points if p.provisional]
|
||||
return firm, carried
|
||||
|
||||
|
||||
def local_days(points):
|
||||
return sorted({brussels(p.start).strftime("%Y-%m-%d") for p in points})
|
||||
|
||||
|
||||
_f2, _c2 = split(_pub)
|
||||
_f1, _c1 = split(_pre)
|
||||
|
||||
# The expected counts are re-derived from the fixture by set arithmetic: the
|
||||
# horizon hours the feed printed, versus the horizon hours it did not.
|
||||
_window = [SINCE_HOUR + timedelta(hours=n) for n in range(HORIZON_H)]
|
||||
_two_pub_utc = {u.astimezone(UTC) for u in TODAY_H + TOMORROW_H}
|
||||
_one_pub_utc = {u.astimezone(UTC) for u in TODAY_H}
|
||||
_want_f2 = len([u for u in _window if u.astimezone(UTC) in _two_pub_utc])
|
||||
_want_f1 = len([u for u in _window if u.astimezone(UTC) in _one_pub_utc])
|
||||
check(f"WITH TOMORROW PUBLISHED the 48 h plan is {len(_f2)} firm hours and "
|
||||
f"{len(_c2)} carried; with only today, {len(_f1)} firm and {len(_c1)} "
|
||||
f"carried. Re-derived from the fixture: {_want_f2} and {_want_f1}",
|
||||
len(_f2) == _want_f2 and len(_f1) == _want_f1
|
||||
and len(_f2) + len(_c2) == HORIZON_H
|
||||
and len(_f1) + len(_c1) == HORIZON_H
|
||||
and len(_f2) > len(_f1))
|
||||
check(f"...and the firm hours now span TWO Brussels local days "
|
||||
f"{local_days(_f2)}, where the one-day answer spans exactly one "
|
||||
f"{local_days(_f1)}. This is the acceptance criterion, and the ONE thing "
|
||||
f"no amount of carry-forward could ever produce: a carried hour is "
|
||||
f"provisional by construction",
|
||||
len(local_days(_f2)) == 2 and len(local_days(_f1)) == 1
|
||||
and local_days(_f2) == [TODAY_D, TOMORROW_D])
|
||||
|
||||
# ⚠️ THE TAIL IS STILL CARRIED, WHICH IS FCST-04 STILL DOING ITS JOB. A 48 h
|
||||
# horizon asked for at 09:17 runs 9 hours into a THIRD Brussels day, and no
|
||||
# day-ahead market publishes that far. The fix widens what is firm; it does not
|
||||
# claim the market publishes more than it does.
|
||||
check(f"FCST-04 still fills the remainder: the {len(_c2)} hours past the "
|
||||
f"market's own horizon are carried from yesterday and flagged, on a "
|
||||
f"third Brussels day {local_days(_c2)} - so the plan still covers all "
|
||||
f"{HORIZON_H} hours",
|
||||
len(_c2) > 0 and local_days(_c2) == ["2026-01-17"]
|
||||
and all(p.provisional for p in _c2))
|
||||
check("...and the plan is still a provider plan reporting itself provisional, "
|
||||
"not a §11.4 fallback",
|
||||
_pub.origin == "provider" and _pub.provisional)
|
||||
|
||||
# ⚠️ THE PRE-PUBLICATION ANSWER IS A SERIES OF WHAT EXISTS, NOT `no_data`.
|
||||
# Asking for tomorrow before the auction returns fewer days than requested.
|
||||
# A `no_data` there would switch a working site's arbitrage off every morning.
|
||||
check(f"the short answer stays a SERIES of what exists - {_pre.read.kind}, "
|
||||
f"{len(_pre.read.points)} hours, origin {_pre.origin!r} - and is never "
|
||||
f"no_data and never a truncated series presented as complete "
|
||||
f"({len(_c1)} of its hours are flagged provisional)",
|
||||
_pre.read.kind == "series" and _pre.origin == "provider"
|
||||
and len(_pre.read.points) == HORIZON_H and len(_c1) > 0)
|
||||
|
||||
# --- the trade set, which is where the whole thing lands ------------------- #
|
||||
_A9 = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
|
||||
uncertainty=frozenset())
|
||||
_v2 = _pub.arbitrage(**_A9)
|
||||
_v1 = _pre.arbitrage(**_A9)
|
||||
check("both plans still ALLOW a trade, so the two verdicts below are compared "
|
||||
"against each other and not against a refusal",
|
||||
_v2.kind == "allowed" and _v1.kind == "allowed")
|
||||
_v2 = _v2 if _v2.kind == "allowed" else None
|
||||
_v1 = _v1 if _v1.kind == "allowed" else None
|
||||
|
||||
# ⚠️ NON-DEGENERACY, DERIVED. The global cheapest firm hour must be on TODAY
|
||||
# and the global dearest on TOMORROW, or "the trade set spans two days" is a
|
||||
# property of the arithmetic rather than of the fix.
|
||||
def _day_of(v, end):
|
||||
"""The Brussels day of one end of a permitting verdict, or "" for a
|
||||
refusal - so a mutation that turns the verdict into a refusal is a named
|
||||
FAIL below rather than an `Absence` traceback."""
|
||||
return "" if v is None else brussels(getattr(v, end).start).strftime(
|
||||
"%Y-%m-%d")
|
||||
|
||||
|
||||
_lo2, _hi2 = _day_of(_v2, "cheapest"), _day_of(_v2, "dearest")
|
||||
_lo1, _hi1 = _day_of(_v1, "cheapest"), _day_of(_v1, "dearest")
|
||||
check(f"WITH TOMORROW FIRM the trade set contains hours from BOTH days: "
|
||||
f"buy on {_lo2}, sell on {_hi2}",
|
||||
sorted({_lo2, _hi2}) == [TODAY_D, TOMORROW_D])
|
||||
check(f"...where the one-day answer's buy AND sell are both inside {_lo1} - "
|
||||
f"a trade set that is a subset of ONE calendar day, structurally, which "
|
||||
f"is the defect this card is about",
|
||||
_lo1 == _hi1 == TODAY_D)
|
||||
_s2 = None if _v2 is None else _v2.spread_eur_kwh
|
||||
_s1 = None if _v1 is None else _v1.spread_eur_kwh
|
||||
check(f"...and the cross-day spread is strictly larger ({_s2} vs {_s1} "
|
||||
f"EUR/kWh), so the second day is not merely visible but PRICED",
|
||||
_s2 is not None and _s1 is not None and _s2 > _s1)
|
||||
check("...and both ends of the permitted cycle are FIRM, which "
|
||||
"`_firm_or_die()` asserts at construction and §8.3 requires",
|
||||
_v2 is not None and not _v2.cheapest.provisional
|
||||
and not _v2.dearest.provisional)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2. AC2 - the spread stopped being taken over hours that have gone")
|
||||
@@ -545,9 +779,10 @@ check("the carried hours are NOT in the trade set - the verdict's cheapest and "
|
||||
print()
|
||||
print("2b. a QUARTER-HOURLY feed keeps every print, and its spread with them")
|
||||
|
||||
# ⚠️ THE DEFECT THIS SECTION EXISTS FOR. `eneco_eddy` is the Belgian imbalance
|
||||
# product and settles per QUARTER; `_validate_raw()` accepts any
|
||||
# strictly-increasing spacing, so a published hour may hold four points.
|
||||
# ⚠️ THE DEFECT THIS SECTION EXISTS FOR. Belgian DAY-AHEAD settles per QUARTER
|
||||
# - the 15-minute MTU took effect on 2025-10-01, so `generic_dayahead` reads 96
|
||||
# points a day - and `_validate_raw()` accepts any strictly-increasing spacing,
|
||||
# so a published hour may hold four points.
|
||||
# FCST-04's first draft keyed the published set `{_utc_hour(p.start): p ...}` -
|
||||
# LAST POINT OF THE HOUR WINS, the other three dropped - and on an all-firm,
|
||||
# fully-published 96-point feed the plan came out 15 points long with its
|
||||
@@ -559,14 +794,24 @@ for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)):
|
||||
_qu = TODAY_H[0] + timedelta(minutes=_QSTEP_MIN * _n)
|
||||
# A spiky within-the-hour profile, so decimation is visible in the SPREAD
|
||||
# and not only in a count.
|
||||
_qrows.append({"start": _qu.isoformat(),
|
||||
"price_eur_mwh": 100.0 + 40.0 * (_n % 4) + brussels(_qu).hour})
|
||||
_qbody = json.dumps({"data": _qrows}).encode()
|
||||
_qrows.append((_qu,
|
||||
100.0 + 40.0 * (_n % 4) + brussels(_qu).hour))
|
||||
# ⚠️ COLUMNAR, like the real feed - see feed_body(). And 900 s spacing is not a
|
||||
# hypothetical here: Belgium moved to a 15-minute MTU on 2025-10-01 and
|
||||
# `api.energy-charts.info` has answered 96 points a day ever since, which is
|
||||
# exactly what test_price.py section 2b replays from a captured body.
|
||||
_qbody = json.dumps({
|
||||
"unix_seconds": [int(_u.timestamp()) for _u, _v in _qrows],
|
||||
"price": [_v for _u, _v in _qrows],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
check(f"the fixture really is sub-hourly - {len(_qrows)} points across "
|
||||
f"{len(TODAY_H)} Brussels hours - or every check below passes for an "
|
||||
f"hourly feed too and grades nothing",
|
||||
len(_qrows) == 4 * len(TODAY_H)
|
||||
and len({_r["start"] for _r in _qrows}) == len(_qrows))
|
||||
and len({_r[0] for _r in _qrows}) == len(_qrows))
|
||||
|
||||
_q_before = run(resolve_tariff(CFG, NOW, _until, transport=transport_for(_qbody),
|
||||
now=NOW, history=NoHistory()))
|
||||
|
||||
@@ -743,8 +743,10 @@ def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ⚠️ NO `price_feed_url` (TARIFF-09): an override is used verbatim and the
|
||||
# plugin appends no date range to it, so setting one - even to the feed's own
|
||||
# address - exercises the override path rather than the shipped default.
|
||||
_cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
|
||||
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
|
||||
price_timeout_s=1, price_markup_eur_kwh=0.03,
|
||||
price_taxes_eur_kwh=0.06)
|
||||
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead,
|
||||
@@ -778,9 +780,20 @@ check("a dead feed with NO static_profile has nothing to fall back to, and "
|
||||
_fb_none.origin == "none" and not _fb_none.has_prices)
|
||||
|
||||
# A live feed still wins over the profile.
|
||||
_body = json.dumps({"data": [
|
||||
{"start": (THU + timedelta(hours=i)).isoformat(), "price_eur_mwh": 40 + i}
|
||||
for i in range(24)]}).encode()
|
||||
# ⚠️ COLUMNAR (TARIFF-08). `api.energy-charts.info` publishes parallel
|
||||
# `unix_seconds`/`price` arrays and no `data` key; the `{"data": [...]}` body
|
||||
# this used to build was invented by the parser it was grading, which is how a
|
||||
# provider that could never parse a live response shipped green. Real captured
|
||||
# bodies are replayed in test_price.py section 2b - the prices here are
|
||||
# synthetic, the SHAPE is not.
|
||||
_body = json.dumps({
|
||||
"unix_seconds": [int((THU + timedelta(hours=i)).timestamp())
|
||||
for i in range(24)],
|
||||
"price": [40 + i for i in range(24)],
|
||||
"unit": "EUR / MWh",
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) "
|
||||
"from Bundesnetzagentur | SMARD.de",
|
||||
"deprecated": False}).encode()
|
||||
|
||||
|
||||
async def _live(url, timeout_s):
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
python3 test_tariff.py
|
||||
|
||||
Ten parts. What each is for, and - where it matters - what it is deliberately
|
||||
NOT allowed to be tested at:
|
||||
Twelve parts. What each is for, and - where it matters - what it is
|
||||
deliberately NOT allowed to be tested at:
|
||||
|
||||
1. THE BLOCK ARITHMETIC, against a worked example written out segment by
|
||||
segment. The fixture is chosen to be non-degenerate on three axes at once:
|
||||
@@ -33,6 +33,17 @@ NOT allowed to be tested at:
|
||||
proven equal to the tier it derives from, and proven never to be lowered.
|
||||
9. AC 3, ON THE REPLAY DATA THAT ACTUALLY EXISTS. See the honesty note below.
|
||||
10. THE WIRING, through the real `Controller` and its real status page.
|
||||
11. TARIFF-13: THE COVERAGE FIGURE PRINTED BESIDE THE PEAK, across a REAL
|
||||
restart - a second `Controller` against the same file. The peak was
|
||||
always durable; the count of banked quarters was an `int` on the
|
||||
tracker and reset to 0, so the page asserted "0 quarter(s) banked"
|
||||
for a month it had measured 120 of. An unreadable store must not
|
||||
produce a coverage figure of 0 either - part 5's argument, one
|
||||
column over.
|
||||
12. TARIFF-13: WHICH QUARTER A TIED PEAK IS ATTRIBUTED TO, decided
|
||||
(earliest wins) rather than left emergent, and pinned on BOTH
|
||||
readers with a newest-first fixture - ascending arrival cannot tell
|
||||
"the earliest quarter" from "the first row written".
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
⚠️ PART 9 IS A TWO-DAY MEASUREMENT AND SAYS SO. THE 30-DAY ANSWER IS ELSEWHERE
|
||||
@@ -106,9 +117,9 @@ from pathlib import Path
|
||||
from app.main import Controller
|
||||
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
|
||||
from app.tariff import (
|
||||
BILLING_TZ, CHECKPOINT_S, NO_RECORD, NOT_OBSERVED, POLICY_MAX,
|
||||
POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _EUTimezone, billed_w,
|
||||
month_key,
|
||||
BILLING_TZ, CHECKPOINT_S, COUNT_UNREADABLE, NO_RECORD, NOT_OBSERVED,
|
||||
POLICY_MAX, POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _ClosedBlock,
|
||||
_EUTimezone, billed_w, month_key,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -932,18 +943,28 @@ check("the closed quarter reached SQLite through the tracker the cycle calls",
|
||||
|
||||
# ⚠️ `cycle()` calls sync() ONCE A SECOND against a `blocks` list that keeps a
|
||||
# closed quarter for a DAY. Without a dedupe that is ~86 000 re-banks of the
|
||||
# same row per block, and `recorded` - the number the status page prints as
|
||||
# "quarters banked" - would be a count of loop iterations rather than of
|
||||
# quarters. The database stays correct either way, which is exactly why this
|
||||
# needs its own check instead of being caught by the peak.
|
||||
# same row per block, and a coverage figure derived from the number of times a
|
||||
# block was OFFERED would be a count of loop iterations rather than of quarters.
|
||||
# The database stays correct either way, which is exactly why this needs its own
|
||||
# check instead of being caught by the peak.
|
||||
#
|
||||
# ⚠️ GRADED ON `sync()`'s RETURN, and on the STORE's tally for the month the
|
||||
# quarter is in - TARIFF-13. It used to be graded on `PeakTracker.recorded`,
|
||||
# which was then an `int` incremented inside this very loop, so the dedupe and
|
||||
# the figure that reported it were the same variable. They are not any more:
|
||||
# `recorded_in()` counts rows in `quarter_blocks`, where re-banking the same
|
||||
# `quarter_start` is a PRIMARY KEY no-op and could never have shown up as a
|
||||
# second quarter. A check written against it alone would now pass however many
|
||||
# times sync() re-banked, which is why the return value is the load-bearing
|
||||
# half of this assertion and is asserted first.
|
||||
again = c.peak.sync(c.p1)
|
||||
third = c.peak.sync(c.p1)
|
||||
print(f" sync() called three times over the same closed quarter: "
|
||||
f"{n}, {again}, {third} newly banked; tracker reports "
|
||||
f"{c.peak.recorded} quarter(s)")
|
||||
f"{n}, {again}, {third} newly banked; the store holds "
|
||||
f"{c.peak.recorded_in(B)} quarter(s) for {month_key(B)}")
|
||||
check("...and syncing the same quarter again banks nothing new, so the count "
|
||||
"the status page shows is quarters and not cycles",
|
||||
again == 0 and third == 0 and c.peak.recorded == 1)
|
||||
again == 0 and third == 0 and c.peak.recorded_in(B) == 1)
|
||||
check("...and the peak is unchanged by the repeat",
|
||||
near(c.peak.peak(B).peak_w, 4998.0, 1e-6))
|
||||
|
||||
@@ -957,11 +978,33 @@ peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r
|
||||
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
|
||||
check("the status page carries exactly one capacity-tariff peak row",
|
||||
len(peak_rows) == 1)
|
||||
check("...naming the peak, the soft limit and the quarters banked, all derived "
|
||||
check("...naming the peak, the soft limit and a coverage figure, all derived "
|
||||
"from the controller that was actually built",
|
||||
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
|
||||
and f"{SHIPPED_CAP_W:.0f} W soft limit" in peak_rows[0]["text"]
|
||||
and "1 quarter(s) banked" in peak_rows[0]["text"])
|
||||
and "quarter(s) banked" in peak_rows[0]["text"])
|
||||
|
||||
# ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH,
|
||||
# NOT TO `when` - TARIFF-13, and it is a KNOWN GAP stated rather than rounded
|
||||
# off. `main.py`'s row reads `self.peak.recorded`, which answers for
|
||||
# `datetime.now()`; the peak beside it follows `when`. The two agree on the
|
||||
# ONLY call site a shipped install has - `checks()` with no argument, at
|
||||
# main.py:4359 - which is why the live page is correct and durable; they part
|
||||
# company only under this suite's `when=` affordance. `PeakTracker` already
|
||||
# carries the scoped form, `recorded_in(when)`, and moving the row onto it is a
|
||||
# one-token edit inside main.py's status-row block, which TARIFF-11 holds. This
|
||||
# check PINS the present behaviour so the gap cannot be forgotten, and part 11
|
||||
# is the authority on the figure itself.
|
||||
_live_month = month_key(datetime.now(timezone.utc))
|
||||
_live_rows = len(c.peak.store.blocks_in(_live_month))
|
||||
print(f" the row read at {month_key(B)} carries the tally for "
|
||||
f"{_live_month} ({_live_rows} quarter(s)), which is this fixture's "
|
||||
f"known scoping gap")
|
||||
check("the row's coverage figure is the LIVE month's tally - graded against a "
|
||||
"row count taken straight from quarter_blocks, so this pins the gap "
|
||||
"rather than restating the property that produces it",
|
||||
bool(peak_rows)
|
||||
and f"; {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
|
||||
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
|
||||
f"on a {SHIPPED_CAP_W:.0f} W limit",
|
||||
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
|
||||
@@ -1149,6 +1192,308 @@ check(f"...and the same IMPORT grader over main.py finds {_main_imports}, so "
|
||||
bool(_main_imports))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("11. TARIFF-13: what the instrument says about its OWN COVERAGE, "
|
||||
"across a restart")
|
||||
|
||||
# THE DEFECT THIS PART PREVENTS. The status row prints a peak read from SQLite
|
||||
# beside a count of banked quarters, and the count used to be an `int` attribute
|
||||
# on `PeakTracker` that started at 0 in `__init__`. A 40-hour soak caught what
|
||||
# that means at a driven restart, on both instances independently:
|
||||
#
|
||||
# before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
# - 10 % of the 3500 W soft limit; 120 quarter(s) banked
|
||||
# after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
|
||||
# - 10 % of the 3500 W soft limit; 0 quarter(s) banked
|
||||
#
|
||||
# The peak and its attributed quarter persist; the coverage beside them does
|
||||
# not. §9.1's peak is what the utility bills, but the COUNT is what says how
|
||||
# much of the billing month the figure covers - 365 W over 120 quarters and
|
||||
# 365 W over 0 quarters are different claims about the same watt figure, and
|
||||
# after a restart the page made the weaker claim while showing the stronger
|
||||
# one's value. TARIFF-01's argument is that an unreadable store must not read as
|
||||
# a zero peak because a zero peak is DISARMING; a zero COVERAGE beside a live
|
||||
# peak is the same error one column over.
|
||||
#
|
||||
# ⚠️ THE RESTART HERE IS A REAL ONE - a second `Controller` against the same
|
||||
# file on disk, never a re-read of the first object. 11b asserts that the second
|
||||
# object has banked nothing itself, so the number it prints cannot have come
|
||||
# from process memory. Without that precondition this whole part is satisfied by
|
||||
# a counter that was simply never reset.
|
||||
|
||||
_T13 = TMP / "t13-restart.db"
|
||||
|
||||
# The fixture lives in the month being billed TODAY, and the row is read with NO
|
||||
# `when` argument - the LIVE status page, the surface that misinformed. That
|
||||
# also makes the check stable on every day of the year, which a fixed calendar
|
||||
# date is not (TEST-17, one part up, is the same lesson).
|
||||
_now13 = datetime.now(timezone.utc)
|
||||
_mon13 = month_key(_now13)
|
||||
# Anchor on the first 900 s boundary of the billing month, so all N quarters are
|
||||
# inside `_mon13` however close to a month edge the suite happens to run.
|
||||
_ms = datetime(int(_mon13[:4]), int(_mon13[5:]), 1, tzinfo=timezone.utc)
|
||||
_q0 = datetime.fromtimestamp(
|
||||
(int(_ms.timestamp()) // QUARTER_S + 1) * QUARTER_S, timezone.utc)
|
||||
|
||||
_N13 = 6 # > 1, so "count == rows" cannot be satisfied by 0 == 0
|
||||
_W13 = 1234.0
|
||||
|
||||
c13 = built(_T13)
|
||||
for _q in range(_N13):
|
||||
_base = _q0 + timedelta(seconds=QUARTER_S * _q)
|
||||
for _i in range(0, QUARTER_S + 1, 5):
|
||||
c13.p1.submit(sample(_base + timedelta(seconds=_i), _W13))
|
||||
c13.peak.sync(c13.p1)
|
||||
|
||||
# Ground truth from the OTHER tier, which the tracker did not supply - the count
|
||||
# under test must be graded against something that is not itself (habit 2).
|
||||
_truth_before = len(c13.peak.store.blocks_in(_mon13))
|
||||
_row_before = _peak_rows(c13.checks())[0]["text"]
|
||||
print(" before restart: " + _row_before)
|
||||
check(f"precondition: the fixture banked {_N13} quarters in {_mon13}, so a "
|
||||
f"count of 0 and a count of the truth are different numbers",
|
||||
_truth_before == _N13 and _N13 > 1)
|
||||
check(f"...and the row says so before the restart",
|
||||
f"{_N13} quarter(s) banked" in _row_before)
|
||||
|
||||
c13.peak.store.close()
|
||||
|
||||
# THE RESTART.
|
||||
c13b = built(_T13)
|
||||
_row_after = _peak_rows(c13b.checks())[0]["text"]
|
||||
_truth_after = len(c13b.peak.store.blocks_in(_mon13))
|
||||
print(" after restart: " + _row_after)
|
||||
print(f" rows in quarter_blocks: {_truth_before} before, "
|
||||
f"{_truth_after} after; the restarted tracker has banked "
|
||||
f"{len(c13b.peak._seen)} block(s) of its own through sync()")
|
||||
|
||||
check("precondition: the second Controller is a REAL restart - it shares no "
|
||||
"object with the first and has banked nothing through sync(), so any "
|
||||
"count it prints came off the disk and not out of memory",
|
||||
c13b.peak is not c13.peak and len(c13b.peak._seen) == 0)
|
||||
check("the peak and its attributed quarter survive the restart - the durable "
|
||||
"half, asserted so that the coverage claim below is not graded against a "
|
||||
"reading that had already collapsed",
|
||||
_row_before.split(" - ")[0] == _row_after.split(" - ")[0]
|
||||
and f"{_W13:.0f} W" in _row_after)
|
||||
check("...and the COUNT BESIDE IT DOES NOT COLLAPSE TO ZERO, which is the "
|
||||
"defect: a coverage figure that resets while the value beside it "
|
||||
"persists actively misinforms",
|
||||
"; 0 quarter(s) banked" not in _row_after)
|
||||
check(f"...and it is the store's own tally, {_truth_after}, not a "
|
||||
f"process-lifetime counter - graded against a COUNT OF ROWS in the "
|
||||
f"other tier rather than against the property under test",
|
||||
f"{_truth_after} quarter(s) banked" in _row_after and _truth_after >= _N13)
|
||||
|
||||
# ⚠️ The restarted run banks one MORE than it drove, and that is correct: the
|
||||
# checkpointed open block was stale, so `restore()` closed and banked it. The
|
||||
# in-memory counter never saw that block at all - it only ever counted what went
|
||||
# through `sync()` - so this is a third way the old figure under-read, and it is
|
||||
# asserted rather than tolerated. Driven at a chosen `now` so it cannot depend
|
||||
# on when the suite runs.
|
||||
_t13s = fresh_store("t13-stale")
|
||||
_tr13 = PeakTracker(_t13s, cap_w=SHIPPED_CAP_W)
|
||||
_av13 = QuarterAverager(phases=1)
|
||||
_sb = _q0 + timedelta(seconds=QUARTER_S * 40)
|
||||
for _i in range(0, 600, 5):
|
||||
list(_av13.add(sample(_sb + timedelta(seconds=_i), 2000.0)))
|
||||
_t13s.save_partial(_av13.snapshot())
|
||||
_before_stale = _tr13.recorded_in(_sb)
|
||||
_state = _tr13.restore(QuarterAverager(phases=1),
|
||||
now=_sb + timedelta(seconds=QUARTER_S * 3))
|
||||
print(f" stale-block restore: {_state}")
|
||||
print(f" quarters banked in {month_key(_sb)}: {_before_stale} -> "
|
||||
f"{_tr13.recorded_in(_sb)}")
|
||||
check("a block closed by restore() rather than by sync() is counted too - the "
|
||||
"old counter incremented only in sync() and missed it entirely",
|
||||
_before_stale == 0 and _tr13.recorded_in(_sb) == 1
|
||||
and "banked" in _state)
|
||||
|
||||
# --- the count is scoped to the MONTH it is printed beside --------------------
|
||||
# ⚠️ TWO MONTHS AT DIFFERENT COUNTS. Equal counts would be satisfied by a
|
||||
# cross-month total, by a constant, and by reading either month for the other.
|
||||
_t13m = fresh_store("t13-months")
|
||||
_MA = datetime(2026, 4, 6, 9, 0, tzinfo=timezone.utc) # 2026-04
|
||||
_MB = datetime(2026, 5, 6, 9, 0, tzinfo=timezone.utc) # 2026-05
|
||||
_trm = PeakTracker(_t13m, cap_w=SHIPPED_CAP_W)
|
||||
for _m, _k in ((_MA, 3), (_MB, 5)):
|
||||
for _q in range(_k):
|
||||
_t13m.record_block(_ClosedBlock(
|
||||
start=_m + timedelta(seconds=QUARTER_S * _q),
|
||||
offtake_avg_w=900.0 + _q, per_phase_offtake_avg_w=None))
|
||||
print(f" banked by month: {month_key(_MA)}={_trm.recorded_in(_MA)}, "
|
||||
f"{month_key(_MB)}={_trm.recorded_in(_MB)}, "
|
||||
f"a month never written={_trm.recorded_in(datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc))}")
|
||||
check("precondition: the two months were given DIFFERENT numbers of quarters, "
|
||||
"so a count that answered either one for the other would be visible here",
|
||||
month_key(_MA) != month_key(_MB))
|
||||
check("each month reports ITS OWN quarters and not the pair's total - the old "
|
||||
"counter was per PROCESS, so on 1 October the row still showed "
|
||||
"September's tally beside October's empty month",
|
||||
_trm.recorded_in(_MA) == 3 and _trm.recorded_in(_MB) == 5)
|
||||
check("...and a month with no quarters reports 0, which is TRUE of a month "
|
||||
"nothing was written to - the sentinel below is for a store that will "
|
||||
"not answer, and the two states must not be spelled the same way",
|
||||
_trm.recorded_in(datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc)) == 0)
|
||||
|
||||
# --- and a store that cannot answer does NOT report zero ----------------------
|
||||
# The other half of part 5's argument, applied to the coverage figure. `0
|
||||
# quarter(s) banked` and `an unreadable number of quarter(s) banked` are
|
||||
# opposite states: the first says the month has been measured over nothing, the
|
||||
# second says the instrument cannot say. Spelling them the same way is exactly
|
||||
# what this card was raised about.
|
||||
_t13u = PeakStore(str(TMP / "no-such-dir" / "nope13.db"))
|
||||
_tru = PeakTracker(_t13u, cap_w=SHIPPED_CAP_W)
|
||||
print(f" unreadable store: banked_count -> {_t13u.banked_count(_mon13)!r}, "
|
||||
f"rendered -> {_tru.recorded_in(_now13)!r}")
|
||||
check("an unreadable store answers None to a COUNT, never 0 - the store-level "
|
||||
"half",
|
||||
_t13u.banked_count(_mon13) is None)
|
||||
check("...and the rendered figure says it is unreadable instead of naming a "
|
||||
"number, so the sentence cannot read as a measured zero",
|
||||
_tru.recorded_in(_now13) == COUNT_UNREADABLE
|
||||
and f"{_tru.recorded_in(_now13)} quarter(s) banked"
|
||||
== "an unreadable number of quarter(s) banked")
|
||||
check("...and that sentence is not merely NON-zero by accident: the string a "
|
||||
"count of 0 would produce is different text",
|
||||
f"{_tru.recorded_in(_now13)} quarter(s) banked"
|
||||
!= f"{0} quarter(s) banked")
|
||||
|
||||
# ⚠️ A STORE THAT IS OPEN AND WHOSE COUNT QUERY FAILS IS A SECOND CODE PATH, and
|
||||
# the checks above cannot reach it - a handle that never opened short-circuits
|
||||
# on `self._db is None` before any SQL runs, so `banked_count()`'s
|
||||
# `except sqlite3.Error` arm was executed by nothing. Found by this card's own
|
||||
# mutation battery, not by review: returning 0 instead of None from that arm
|
||||
# survived the whole suite at -3 B. It is the same third-path argument part 5
|
||||
# makes for `peak()`, and it is the arm a half-corrupt database on a real
|
||||
# install actually takes.
|
||||
_t13b = fresh_store("t13-broken")
|
||||
for _q in range(4):
|
||||
_t13b.record_block(_ClosedBlock(start=_q0 + timedelta(seconds=QUARTER_S * _q),
|
||||
offtake_avg_w=800.0 + _q,
|
||||
per_phase_offtake_avg_w=None))
|
||||
_trb = PeakTracker(_t13b, cap_w=SHIPPED_CAP_W)
|
||||
_count_ok = _t13b.banked_count(_mon13)
|
||||
_t13b._db.execute("DROP TABLE quarter_blocks")
|
||||
_t13b._db.commit()
|
||||
_count_broken = _t13b.banked_count(_mon13)
|
||||
print(f" open store, count query FAILS: {_count_ok} -> {_count_broken!r}, "
|
||||
f"rendered -> {_trb.recorded_in(_now13)!r}")
|
||||
check(f"precondition: before the table was dropped the same call answered "
|
||||
f"{_count_ok}, so 'it returns nothing now' is a change and not the "
|
||||
f"store's resting state",
|
||||
_count_ok == 4)
|
||||
check("a store whose COUNT QUERY fails answers None too - a different code "
|
||||
"path from a handle that never opened, and the one a half-corrupt "
|
||||
"database takes",
|
||||
_count_broken is None)
|
||||
check("...and it renders as unreadable rather than as 0, so a half-corrupt "
|
||||
"database cannot print a coverage figure that reads like a measured "
|
||||
"month with nothing in it",
|
||||
_trb.recorded_in(_now13) == COUNT_UNREADABLE)
|
||||
|
||||
# --- the count reads the BLOCK tier, not the month tier -----------------------
|
||||
# `monthly_peak` holds one row per MONTH, so counting it answers 1 for every
|
||||
# month that has ever been measured - a figure that is wrong by exactly the
|
||||
# amount that makes it look plausible.
|
||||
check("the coverage figure counts quarter_blocks and not monthly_peak, which "
|
||||
"would answer 1 for every measured month however many quarters it holds",
|
||||
_trm.recorded_in(_MB) == 5
|
||||
and _t13m.peak(_MB).is_observed and _t13m.recompute_month(month_key(_MB)).is_observed)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("12. TARIFF-13: which quarter a TIED peak is attributed to")
|
||||
|
||||
# THE DECISION, recorded in DECISIONS/TARIFF-13-the-coverage-of-a-peak.md §2:
|
||||
# at equal wattage the EARLIEST quarter keeps the attribution. The wattage is
|
||||
# what is billed, so this is not a money question - it is the answer to "when
|
||||
# did my monthly peak happen", and an operator investigating that goes and looks
|
||||
# at one event. A stable answer is the useful one; "most recent wins" guarantees
|
||||
# the answer moves every time the wattage recurs.
|
||||
#
|
||||
# ⚠️ AND THE SOAK'S DRIFT WAS NOT A TIE. The 40-hour run showed 365 W attributed
|
||||
# to three different quarters with no restart and no new peak, and the obvious
|
||||
# reading - "ties re-attribute to the most recent" - is false, measured: the
|
||||
# upsert's term is strictly `>`, so an exact tie never wrote. What moved was a
|
||||
# genuine new maximum SMALLER THAN THE ROW'S OWN RESOLUTION. The row renders
|
||||
# `{peak_w:.0f}`, whole watts; 365.0000001 W is a new peak to the database and
|
||||
# the same "365 W" to the reader. Both cases are pinned below, because the fix
|
||||
# for one is not the fix for the other.
|
||||
|
||||
_t13t = fresh_store("t13-ties")
|
||||
_TB = datetime(2026, 3, 9, 9, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _bank(store, q, w):
|
||||
store.record_block(_ClosedBlock(start=_TB + timedelta(seconds=QUARTER_S * q),
|
||||
offtake_avg_w=w, per_phase_offtake_avg_w=None))
|
||||
|
||||
|
||||
def _qt(n):
|
||||
return _TB + timedelta(seconds=QUARTER_S * n)
|
||||
|
||||
|
||||
for _q in (0, 1, 2, 3):
|
||||
_bank(_t13t, _q, 365.0)
|
||||
_asc = _t13t.peak(_TB)
|
||||
print(f" four EXACTLY equal 365 W quarters, oldest first -> attributed "
|
||||
f"{_asc.quarter_start.isoformat()}")
|
||||
check("an exact tie keeps the EARLIEST quarter: the attribution does not move "
|
||||
"when the same wattage recurs",
|
||||
_asc.quarter_start == _qt(0) and near(_asc.peak_w, 365.0))
|
||||
|
||||
# ⚠️ FED NEWEST-FIRST, which is what separates "the earliest quarter" from
|
||||
# "whichever row was written first". Ascending arrival cannot tell those apart -
|
||||
# they are the same row - so an ascending-only fixture grades neither.
|
||||
_t13r = fresh_store("t13-ties-rev")
|
||||
for _q in (3, 2, 1, 0):
|
||||
_bank(_t13r, _q, 365.0)
|
||||
_desc = _t13r.peak(_TB)
|
||||
_desc_rc = _t13r.recompute_month(month_key(_TB))
|
||||
print(f" the same four fed NEWEST first -> monthly_peak attributed "
|
||||
f"{_desc.quarter_start.isoformat()}, recompute_month attributed "
|
||||
f"{_desc_rc.quarter_start.isoformat()}")
|
||||
check("...and it is the earliest QUARTER, not the first row WRITTEN - fed "
|
||||
"newest-first the answer is still the oldest quarter",
|
||||
_desc.quarter_start == _qt(0))
|
||||
check("...and BOTH readers give it, so the derived record has not diverged "
|
||||
"from the tier it derives from on the tie rule either (part 8's "
|
||||
"invariant, at equal wattage)",
|
||||
_desc_rc.quarter_start == _desc.quarter_start
|
||||
and near(_desc_rc.peak_w, _desc.peak_w))
|
||||
|
||||
# The other direction, and the reason the tie rule may never touch `peak_w`: a
|
||||
# STRICTLY HIGHER quarter must still take the attribution, whenever it arrives.
|
||||
_t13h = fresh_store("t13-higher")
|
||||
for _q, _w in ((0, 365.0), (1, 366.0), (2, 365.0)):
|
||||
_bank(_t13h, _q, _w)
|
||||
_hi = _t13h.peak(_TB)
|
||||
print(f" 365, then 366, then 365 -> peak {_hi.peak_w:.0f} W at "
|
||||
f"{_hi.quarter_start.isoformat()}")
|
||||
check("a strictly higher quarter still takes both the peak and the "
|
||||
"attribution - the tie rule moves the TIMESTAMP and never the watts",
|
||||
near(_hi.peak_w, 366.0) and _hi.quarter_start == _qt(1))
|
||||
|
||||
# And the mechanism the soak actually saw: an increase below the row's own
|
||||
# resolution. Not a tie, and no tie rule can address it.
|
||||
_t13d = fresh_store("t13-subdisplay")
|
||||
_seen13 = []
|
||||
for _q, _w in enumerate((365.0, 365.0000001, 365.0000002)):
|
||||
_bank(_t13d, _q, _w)
|
||||
_seen13.append(_t13d.peak(_TB).quarter_start)
|
||||
print(f" 365.0, 365.0000001, 365.0000002 W all render "
|
||||
f"{_t13d.peak(_TB).peak_w:.0f} W and attribute to "
|
||||
f"{[s.time().isoformat() for s in _seen13]}")
|
||||
check("an increase BELOW the row's whole-watt resolution is a real new maximum "
|
||||
"and does move the attribution - so the soak's drifting quarter was a "
|
||||
"rounded display, not a tie, and is left alone deliberately",
|
||||
len(set(_seen13)) == 3
|
||||
and len({f"{w:.0f}" for w in (365.0, 365.0000001, 365.0000002)}) == 1)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
|
||||
@@ -362,7 +362,7 @@ print("\n7. no behaviour and no value moved")
|
||||
# test_peakguard.py's. This line is the cheap one that says the reorder did not
|
||||
# lose or duplicate a key on the way.
|
||||
check("no option was lost, gained or duplicated by the reordering: the two "
|
||||
"halves of config.yaml hold the same 71 keys, once each",
|
||||
"halves of config.yaml hold the same 72 keys, once each",
|
||||
sorted(_opt_order) == sorted(ORDER) and len(set(ORDER)) == len(ORDER))
|
||||
check("...and `inverters: []` is still shipped in the options half, which is "
|
||||
"what Supervisor 2026.08.0 refuses every pre-0.6.0 options blob without "
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# ⚠️ WHAT THIS FILE IS FOR, AND WHAT HOME ASSISTANT ACTUALLY DOES WITH IT.
|
||||
# Without a `translations/` directory the Configuration tab renders the raw
|
||||
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 71
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-ONE are
|
||||
# The frontend keeps a field when `e.name in options || e.required`, and all 72
|
||||
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-TWO are
|
||||
# rendered, always, in `schema:` order, with no description. This file is what
|
||||
# turns that into sentences.
|
||||
#
|
||||
@@ -143,10 +143,10 @@ configuration:
|
||||
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
|
||||
price_provider:
|
||||
name: "Tariff · Dynamic price source"
|
||||
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ Switching it on today fetches, checks and publishes prices and changes no energy decision - nothing consumes the price series yet. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
|
||||
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH - not the trading switch further down. With a source selected the add-on fetches the feed every half hour, checks it, records the day and shows what it holds on the Price history row, whether or not price trading is armed. On its own it still moves no energy: trading is the separate switch, and the cheap window the monthly maintenance cycle uses comes from the fixed or day/night tariff below. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed. ⚠️ THREE IDS IN THIS LIST ARE WITHDRAWN AND WILL NOT START: engie_dynamic, totalenergies_dynamic and eneco_eddy. Their endpoints do not exist and never did, so those plugins never once fetched a price - a site set to one of them has silently been running on the fixed or day/night tariff below, or on nothing, since the day it was set. They are still offered here only so that such a site gets that explanation at startup instead of an unreadable schema error. Choose generic_dayahead for the wholesale curve with a flat markup, custom_formula to enter your own contract's B x index + A coefficients off your tariff card, manual_csv for a file you write yourself, or off."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tariff · Supplier markup (EUR/kWh)"
|
||||
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under engie_dynamic, totalenergies_dynamic, eneco_eddy and manual_csv it is ignored, and logged as ignored, never applied twice."
|
||||
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under manual_csv, whose figures are all-in, it is ignored, and logged as ignored, never applied twice."
|
||||
price_taxes_eur_kwh:
|
||||
name: "Tariff · Levies and VAT (EUR/kWh)"
|
||||
description: "Taxes and levies per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them."
|
||||
@@ -156,12 +156,15 @@ configuration:
|
||||
static_profile:
|
||||
name: "Tariff · Fixed or day/night tariff (one line of JSON)"
|
||||
description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative."
|
||||
price_formula:
|
||||
name: "Tariff · Your own tariff formula (one line of JSON)"
|
||||
description: "For price source custom_formula only: your own contract's two coefficients, written as a single line of JSON. Your supplier's tariff card prints the formula as B x index + A - those two numbers exist only on your card, there is no public index of them anywhere, and you are the only person who can read them. Type what the card prints, IN THE UNITS THE CARD PRINTS: every unit conversion and the VAT step are the add-on's job, because those are the two places a human silently loses a factor of 10, 100 or 1.06. Example: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ THE card BLOCK IS REQUIRED AND IT IS WHAT MAKES THIS SAFE: your card also prints the reference index it assumed and the price that comes out, so the add-on recomputes that price from your numbers and REFUSES TO START if it does not come out, telling you by how much. That catches a wrong unit, VAT left out or counted twice, a transposed digit, and the expensive one - network charges folded into the offset, which the add-on would then bill you for a second time. ⚠️ AND ITS LIMIT, IN THE SAME BREATH: it checks your coefficients against your card, not your card against reality, so two numbers copied off the wrong product agree with each other and pass. Fixed numbers only - no expression of any kind is evaluated, and an unrecognised field is refused rather than ignored."
|
||||
price_csv_path:
|
||||
name: "Tariff · Price CSV file"
|
||||
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
|
||||
price_feed_url:
|
||||
name: "Tariff · Price feed URL override"
|
||||
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere."
|
||||
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere. WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN: the add-on adds nothing to it, not even the date range it normally asks its own feed for, so an override gets whatever horizon that URL returns - often a single day, with the rest of the plan carried over from yesterday and excluded from price arbitrage. A warning in the log says so on every price refresh. Clear this option to get the full horizon back."
|
||||
price_horizon_h:
|
||||
name: "Tariff · How far ahead to fetch prices (hours)"
|
||||
description: "How many hours of prices to ask the source for."
|
||||
@@ -176,7 +179,7 @@ configuration:
|
||||
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
|
||||
arbitrage_enabled:
|
||||
name: "Tariff · Trade on the price spread"
|
||||
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
|
||||
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. ⚠️ LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF. A price source selected above is fetched, checked and recorded either way; this switch decides only whether the add-on ACTS on those prices. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
|
||||
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
|
||||
|
||||
@@ -123,10 +123,10 @@ configuration:
|
||||
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
|
||||
price_provider:
|
||||
name: "Tarief · Bron van dynamische prijzen"
|
||||
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ Dit vandaag aanzetten haalt prijzen op, controleert en publiceert ze, en verandert geen enkele energiebeslissing - er is nog niets dat de prijsreeks gebruikt. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten."
|
||||
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ DIT AANZETTEN IS WAT HET OPHALEN VAN PRIJZEN START - niet de handelsschakelaar verderop. Met een bron ingesteld haalt de add-on de feed elk half uur op, controleert die, bewaart de dag en toont op de rij Prijsgeschiedenis wat er bewaard is, of prijshandel nu aan staat of niet. Op zichzelf verplaatst dat nog geen energie: handelen is de aparte schakelaar, en het goedkope venster dat de maandelijkse onderhoudscyclus gebruikt komt uit het vaste of dag/nacht-tarief hieronder. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten. ⚠️ DRIE IDS IN DEZE LIJST ZIJN INGETROKKEN EN STARTEN NIET MEER: engie_dynamic, totalenergies_dynamic en eneco_eddy. Hun endpoints bestaan niet en hebben nooit bestaan, dus die plugins hebben nooit één prijs opgehaald - een installatie die op zo'n bron stond, draait sindsdien stilzwijgend op het vaste of dag/nacht-tarief hieronder, of op niets. Ze staan hier alleen nog zodat zo'n installatie bij het opstarten die uitleg krijgt in plaats van een onleesbare schemafout. Kies generic_dayahead voor de groothandelscurve met een vaste marge, custom_formula om de coëfficiënten B x index + A van uw eigen contract van uw tariefkaart over te typen, manual_csv voor een bestand dat u zelf schrijft, of off."
|
||||
price_markup_eur_kwh:
|
||||
name: "Tarief · Marge van de leverancier (EUR/kWh)"
|
||||
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij engie_dynamic, totalenergies_dynamic, eneco_eddy en manual_csv wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
|
||||
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij manual_csv, waarvan de bedragen alles inbegrepen zijn, wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
|
||||
price_taxes_eur_kwh:
|
||||
name: "Tarief · Heffingen en btw (EUR/kWh)"
|
||||
description: "Belastingen en heffingen per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat."
|
||||
@@ -136,12 +136,15 @@ configuration:
|
||||
static_profile:
|
||||
name: "Tarief · Vast of dag/nacht-tarief (één regel JSON)"
|
||||
description: "Uw vaste of dag/nacht-contract, geschreven als één regel JSON. AANBEVOLEN ZELFS BIJ EEN DYNAMISCH CONTRACT: hierop valt de add-on terug wanneer de prijsfeed uitvalt, en staat dit leeg dan blijft de installatie bij een dode feed helemaal zonder tarief achter. De periodes moeten elk dagtype precies één keer bedekken, zonder gat en zonder overlap - een gat wordt bij het opstarten geweigerd in plaats van opgevuld, want een verzonnen prijs is er één waarop een strategie zou handelen. Een volledig vast tarief is één periode van 00:00 tot 24:00 op alle dagen. De prijzen zijn ALLES INBEGREPEN; de marge en heffingen hierboven worden er niet bij opgeteld. Let op: een venster van 22:00 tot 07:00 voor ma-vr dekt zaterdagochtend NIET, dus een regel voor het weekend is noodzakelijk en niet decoratief."
|
||||
price_formula:
|
||||
name: "Tarief · Uw eigen tariefformule (één regel JSON)"
|
||||
description: "Alleen voor prijsbron custom_formula: de twee coëfficiënten van uw eigen contract, als één regel JSON. De tariefkaart van uw leverancier drukt de formule af als B x index + A - die twee getallen staan alleen op uw kaart, er bestaat nergens een publieke lijst van, en u bent de enige die ze kan lezen. Typ wat de kaart afdrukt, IN DE EENHEID DIE DE KAART GEBRUIKT: elke eenheidsomrekening en de btw-stap zijn het werk van de add-on, want dat zijn precies de twee plaatsen waar een mens stilzwijgend een factor 10, 100 of 1,06 verliest. Voorbeeld: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ HET BLOK card IS VERPLICHT EN MAAKT DIT VEILIG: uw kaart vermeldt ook de referentie-index die ze veronderstelt en de prijs die eruit volgt, dus de add-on berekent die prijs opnieuw uit uw getallen en START NIET als het resultaat niet klopt - met vermelding van het verschil. Dat vangt een verkeerde eenheid, btw die ontbreekt of dubbel geteld is, een omgewisseld cijfer, en de dure fout: nettarieven die in de offset verwerkt zitten en die de add-on er dan nog een tweede keer bovenop zou rekenen. ⚠️ EN DE GRENS DAARVAN, IN DEZELFDE ZIN: ze toetst uw coëfficiënten aan uw kaart, niet uw kaart aan de werkelijkheid - twee getallen van het verkeerde product kloppen onderling en komen er dus door. Alleen vaste getallen: er wordt geen enkele expressie geëvalueerd, en een onbekend veld wordt geweigerd in plaats van genegeerd."
|
||||
price_csv_path:
|
||||
name: "Tarief · CSV-bestand met prijzen"
|
||||
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
|
||||
price_feed_url:
|
||||
name: "Tarief · Afwijkende URL voor de prijsfeed"
|
||||
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen."
|
||||
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen. WAT U HIER TYPT WORDT LETTERLIJK VERSTUURD: de add-on voegt er niets aan toe, ook niet het datumbereik dat ze normaal aan haar eigen feed vraagt. Een afwijkende URL levert dus de horizon op die die URL zelf teruggeeft - vaak één dag, waarna de rest van het plan uit de prijzen van gisteren wordt overgenomen en buiten de prijsarbitrage valt. Bij elke prijsvernieuwing verschijnt daarover een waarschuwing in het logboek. Maak dit veld leeg om de volledige horizon terug te krijgen."
|
||||
price_horizon_h:
|
||||
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
|
||||
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
|
||||
@@ -156,7 +159,7 @@ configuration:
|
||||
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
|
||||
arbitrage_enabled:
|
||||
name: "Tarief · Handelen op het prijsverschil"
|
||||
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
|
||||
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. ⚠️ DIT UIT LATEN STAAN ZET DE PRIJSFEED NIET UIT. Een prijsbron die hierboven gekozen is, wordt hoe dan ook opgehaald, gecontroleerd en bewaard; deze schakelaar bepaalt alleen of de add-on op die prijzen HANDELT. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
|
||||
cycle_cost_threshold_eur:
|
||||
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
|
||||
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."
|
||||
|
||||
Reference in New Issue
Block a user