TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so
checks / unit (pull_request) Successful in 7m12s

engie_dynamic, totalenergies_dynamic and eneco_eddy are removed from PROVIDERS
and from every surface that listed them. They did not stop working - they never
worked. Re-verified by hand on 2026-09-13 before deleting anything: 404 with
Kong's "no Route matched with those values"; a TLS handshake that fails because
api.totalenergies.be is absent from the certificate's SAN list; 404 with an
empty body. All three are also structurally impossible - the coefficients are
per-contract and behind a customer login, there is no regulator index, and
"Eddy" is not an Eneco product at all.

⚠️ BACKWARD-INCOMPATIBLE. A config legal on the current version is refused on
the next. That is the point of the change: §11.4 turned every failed fetch into
a silent fall back, so a site on one of these ids has been running on
static_profile - or on nothing - since the day it was set, with nothing on its
status page ever differing from a working provider that never found a cheap
hour. validate.py now refuses a retired id with a message naming the id, the
endpoint, what that endpoint answers, which tariff has really been in force,
and what to set instead. No version number is picked here; that depends on a
fact about the live box.

⚠️ config.yaml's price_provider enum deliberately KEEPS all three ids. The
Supervisor validates the options file against that enum before the add-on
starts, so removing them would make an existing config schema-invalid and the
refusal above would never be reached 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.

PriceProduct.REALTIME, _WIDE, Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's
2.5 are KEPT with no shipped declarer - it is the AC2 seam, and a reviewer
finding a mechanism with zero callers would reasonably delete it. Recorded in
DECISIONS/TARIFF-07-withdrawing-three-plugins.md and pinned by a check.

False claims corrected: every "Belgian imbalance product" claim about
eneco_eddy (DOCS.md, price_forecast.py, price_history.py, static_tariff.py,
FCST-04, CHANGELOG - the two released entries annotated in place rather than
rewritten); the quarter-hourly examples now cite Belgian day-ahead, which
really does settle per quarter since the 15-minute MTU; and
export_price_eur_kwh's "no shipped supplier publishes a dynamic injection
curve", which is false - Eneco publishes 0,1 x BELPEX-H - 1,188 and ENGIE
(B x eSpot_15) - A, same two-coefficient shape with its own coefficients and
its own VAT treatment, so the scalar is a simplification and not the market.

DOCS.md's provider table is two rows and now says honestly what a Belgian
dynamic customer should do today, including that generic_dayahead cannot
express B x spot + A and that price_markup_eur_kwh: float(0,1) could not hold a
negative offset. The formula provider is referenced as planned, not built.

_points_from_json() went with the plugins it served: it read a shape no feed
anybody here has ever reached publishes. The one place it survives is a
negative stimulus, named row_shape_body().

Nine mutations driven, all red; table in the decision record. 29/29 suites
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-13 02:46:15 +02:00
co-authored by Claude Opus 5
parent 8d09b30f16
commit f620f7cf80
15 changed files with 1045 additions and 329 deletions
+132 -1
View File
@@ -20,6 +20,128 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _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** |
⚠️ **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.
### 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.
⚠️ **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
@@ -596,7 +718,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,
@@ -3812,6 +3939,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