TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so #81

Merged
admin merged 1 commits from TARIFF-07 into release/1.0 2026-09-13 02:55:00 +02:00
Owner

What this does

Withdraws engie_dynamic, totalenergies_dynamic and eneco_eddy from PROVIDERS and from every surface that listed them, and replaces the silence a site on one of those ids has been sitting in with a named refusal.

⚠️ BACKWARD-INCOMPATIBLE, AND THAT IS THE POINT

A config that is legal on the current version is refused on the next one. price_provider set to any of the three now exits SystemExit(78) (EX_CONFIG) at startup. Same shape as 0.8.0's slew_w rule, and the same instruction: 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.

No version number is picked here. The right one depends on whether the current image has already been built and pulled — a fact about the live box this branch cannot observe. The ## Unreleased section says so. Whoever deploys decides.

The endpoints were re-verified before anything was deleted

Not taken on report. Checked by hand on 2026-09-13:

id endpoint observed
engie_dynamic https://api.engie.be/dynamic/be 404, body {"message":"no Route matched with those values","request_id":"..."} — a Kong gateway with no route
totalenergies_dynamic https://api.totalenergies.be/prices/dynamic TLS failure. Server presents CN=pge-apigee-dev.direct-energie.com; api.totalenergies.be is not in the SAN list, so the connection closes before any request is sent
eneco_eddy https://api.eneco.be/eddy/prices 404, content-length: 1, server: Microsoft-IIS/10.0

All three failed. Had one answered, this branch would have stopped and asked.

Why the migration is a refusal and not a fallback

§11.4 turns a failed fetch into a silent fall back to the static profile. Every fetch these three ever made raised SourceUnavailable. So a site configured on one has been running on static_profile — or on nothing at all, where none was set — since the day the id was entered, and nothing on its status page has ever differed from a working provider that 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". The refusal names the id, the endpoint, what that endpoint answers, which tariff has really been in force (and it says NOTHING AT ALL where static_profile is empty, because that site is in a worse state), and what to set instead.

No alias: these were retail B x spot + A tariffs with per-contract coefficients behind a customer login, generic_dayahead reads wholesale, and CREG/VREG publish no index to read the coefficients from. An alias would have to invent the numbers a battery is priced against.

⚠️ config.yaml's schema deliberately keeps all three ids — read this before "tidying" it

price_provider is a list(off|...) enum and the Supervisor validates the options file against it before the add-on starts. Delete the retired ids from the enum and an existing config naming one becomes schema-invalid: opaque expected one of ..., add-on never starts, validate.py never runs, the refusal is never seen — on exactly the sites it exists for.

So PROVIDERS is two and the enum stays six. The cost is that the dropdown still offers three dead ids to a new operator; that is the cheaper failure, because choosing one is refused by name with the full explanation. Both translations label them withdrawn. RETIRED_PROVIDERS is the list, the schema check is derived (PROVIDERS | RETIRED_PROVIDERS | {"off"}, not typed), and register() refuses to re-register a retired id so the refusal cannot be silently taken away.

PriceProduct.REALTIME survives with no shipped declarer

eneco_eddy was its sole declarer, so _WIDE, Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's 2.5 now have none. Kept on purpose — it is the AC2 seam, and the first genuine imbalance product would otherwise have to rebuild it. A reviewer finding a mechanism with zero callers would reasonably delete it, so the intent is recorded in DECISIONS/TARIFF-07-withdrawing-three-plugins.md and pinned by a check that goes red if a shipped plugin ever declares REALTIME (at which point the right fix is to edit the record, not the seam).

False claims corrected

  • "Belgian imbalance product" about eneco_eddy — DOCS.md, app/price_forecast.py, app/price_history.py, app/static_tariff.py, DECISIONS/FCST-04, and twice in CHANGELOG.md. Eneco's dynamic product is Zon & Wind Dynamisch, indexed to Belpex/EPEX day-ahead. The two released CHANGELOG entries 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 needed now cite Belgian day-ahead, 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 x BELPEX-H - 1,188 on the same card as its consumption formula (identical on the January and September 2026 cards) and ENGIE publishes (B x eSpot_15) - A. Corrected to what is true: it is the same two-coefficient shape with its own coefficients and its own VAT treatment (injection VAT-exempt, consumption at 6%), so the scalar is a simplification entered by hand, not a reflection of the market.
  • _points_from_json() read a {"data": [...]} shape no feed anybody here has ever reached publishes — invented alongside these plugins and graded only against fixtures written from the same guess. Deleted. It survives only as row_shape_body(), a negative stimulus proving a row document is a failure for generic_dayahead.

DOCS tells a Belgian dynamic customer what to do today

Provider table is two rows. 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 x spot + A: no multiplier, and no negative offset — price_markup_eur_kwh: float(0,1) could not hold one anyway. The formula provider is referenced as planned and is a separate card; it is not implemented here.

Mutations

All nine red. Harness at a card-specific path (/home/homelab/.cache/t07-mut/, backups inside it), rebuilding the tree and purging __pycache__ per MUTATION.md.

mutation delta red on
M1 delete the retired branch from _price_problems() -2785 B 7 checks
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 — the trap above -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 are +0 B, the case MUTATION.md flags. The harness rebuilds from source and purges, and stale bytecode manufactures survivors rather than reds, so these are sound; M2's grep confirmed the branch had moved on disk. ⚠️ M5 and M7's counts are lower bounds — both end in a traceback with later checks ungraded (FCST-06's rule). M7's traceback is itself the point: without the guard the re-registration succeeds and pollutes PROVIDERS.

⚠️ One check had to be rebuilt rather than deleted: test_price.py 4b proved code_leaks()'s docstring strip is load-bearing using 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. The stimulus is constructed in the test file now; the shipped set is asserted clean separately.

Checks

29/29 suites green. Baseline at 8d09b30 was also 29/29, verified before any edit.

Not done, deliberately

  • No version number picked — owner's decision, depends on the live box.
  • Formula provider not implemented — separate card, referenced as planned.
  • Released CHANGELOG history not rewritten — annotated in place.
  • Nothing deployed. No ha addons update, no writes to /addons/, no contact with 192.168.2.158 or 192.168.2.221.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## What this does Withdraws `engie_dynamic`, `totalenergies_dynamic` and `eneco_eddy` from `PROVIDERS` and from every surface that listed them, and replaces the silence a site on one of those ids has been sitting in with a named refusal. ## ⚠️ BACKWARD-INCOMPATIBLE, AND THAT IS THE POINT **A config that is legal on the current version is refused on the next one.** `price_provider` set to any of the three now exits `SystemExit(78)` (`EX_CONFIG`) at startup. Same shape as 0.8.0's `slew_w` rule, and the same instruction: **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. **No version number is picked here.** The right one depends on whether the current image has already been built and pulled — a fact about the live box this branch cannot observe. The `## Unreleased` section says so. Whoever deploys decides. ## The endpoints were re-verified before anything was deleted Not taken on report. Checked by hand on 2026-09-13: | id | endpoint | observed | |---|---|---| | `engie_dynamic` | `https://api.engie.be/dynamic/be` | **404**, body `{"message":"no Route matched with those values","request_id":"..."}` — a Kong gateway with no route | | `totalenergies_dynamic` | `https://api.totalenergies.be/prices/dynamic` | **TLS failure.** Server presents `CN=pge-apigee-dev.direct-energie.com`; `api.totalenergies.be` is not in the SAN list, so the connection closes before any request is sent | | `eneco_eddy` | `https://api.eneco.be/eddy/prices` | **404**, `content-length: 1`, `server: Microsoft-IIS/10.0` | All three failed. Had one answered, this branch would have stopped and asked. ## Why the migration is a refusal and not a fallback §11.4 turns a failed fetch into a **silent** fall back to the static profile. Every fetch these three ever made raised `SourceUnavailable`. So a site configured on one has been running on `static_profile` — **or on nothing at all, where none was set** — since the day the id was entered, and **nothing on its status page has ever differed from a working provider that 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"*. The refusal names the id, the endpoint, what that endpoint answers, which tariff has really been in force (and it says **NOTHING AT ALL** where `static_profile` is empty, because that site is in a worse state), and what to set instead. No alias: these were retail `B x spot + A` tariffs with per-contract coefficients behind a customer login, `generic_dayahead` reads wholesale, and CREG/VREG publish no index to read the coefficients from. An alias would have to invent the numbers a battery is priced against. ## ⚠️ `config.yaml`'s schema deliberately keeps all three ids — read this before "tidying" it `price_provider` is a `list(off|...)` enum and **the Supervisor validates the options file against it before the add-on starts.** Delete the retired ids from the enum and an existing config naming one becomes schema-invalid: opaque `expected one of ...`, add-on never starts, `validate.py` never runs, **the refusal is never seen — on exactly the sites it exists for.** So `PROVIDERS` is two and the enum stays six. The cost is that the dropdown still offers three dead ids to a new operator; that is the cheaper failure, because choosing one is refused by name with the full explanation. Both translations label them withdrawn. `RETIRED_PROVIDERS` is the list, the schema check is derived (`PROVIDERS | RETIRED_PROVIDERS | {"off"}`, not typed), and `register()` refuses to re-register a retired id so the refusal cannot be silently taken away. ## `PriceProduct.REALTIME` survives with no shipped declarer `eneco_eddy` was its sole declarer, so `_WIDE`, `Uncertainty.REALTIME_PRODUCT` and `WIDEN_FACTOR`'s 2.5 now have none. **Kept on purpose** — it is the AC2 seam, and the first genuine imbalance product would otherwise have to rebuild it. A reviewer finding a mechanism with zero callers would reasonably delete it, so the intent is recorded in `DECISIONS/TARIFF-07-withdrawing-three-plugins.md` **and pinned by a check** that goes red if a shipped plugin ever declares `REALTIME` (at which point the right fix is to edit the record, not the seam). ## False claims corrected * **"Belgian imbalance product"** about `eneco_eddy` — `DOCS.md`, `app/price_forecast.py`, `app/price_history.py`, `app/static_tariff.py`, `DECISIONS/FCST-04`, and twice in `CHANGELOG.md`. Eneco's dynamic product is *Zon & Wind Dynamisch*, indexed to Belpex/EPEX **day-ahead**. The two released CHANGELOG entries 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 needed now cite **Belgian day-ahead**, 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 x BELPEX-H - 1,188` on the same card as its consumption formula (identical on the January and September 2026 cards) and ENGIE publishes `(B x eSpot_15) - A`. Corrected to what is true: it is the **same two-coefficient shape with its own coefficients and its own VAT treatment** (injection VAT-exempt, consumption at 6%), so the scalar is a **simplification entered by hand, not a reflection of the market**. * `_points_from_json()` read a `{"data": [...]}` shape **no feed anybody here has ever reached publishes** — invented alongside these plugins and graded only against fixtures written from the same guess. Deleted. It survives only as `row_shape_body()`, a negative stimulus proving a row document is a *failure* for `generic_dayahead`. ## DOCS tells a Belgian dynamic customer what to do today Provider table is two rows. 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 x spot + A`**: no multiplier, and no negative offset — `price_markup_eur_kwh: float(0,1)` could not hold one anyway. The formula provider is referenced as **planned** and is a separate card; it is not implemented here. ## Mutations All nine red. Harness at a card-specific path (`/home/homelab/.cache/t07-mut/`, backups inside it), rebuilding the tree and purging `__pycache__` per `MUTATION.md`. | | mutation | delta | red on | |---|---|---|---| | M1 | delete the retired branch from `_price_problems()` | -2785 B | 7 checks | | 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 — the trap above | -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 are +0 B**, the case `MUTATION.md` flags. The harness rebuilds from source and purges, and stale bytecode manufactures *survivors* rather than reds, so these are sound; M2's grep confirmed the branch had moved on disk. ⚠️ **M5 and M7's counts are lower bounds** — both end in a traceback with later checks ungraded (FCST-06's rule). M7's traceback is itself the point: without the guard the re-registration succeeds and pollutes `PROVIDERS`. ⚠️ One check had to be **rebuilt rather than deleted**: `test_price.py` 4b proved `code_leaks()`'s docstring strip is load-bearing using 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**. The stimulus is constructed in the test file now; the shipped set is asserted clean separately. ## Checks **29/29 suites green.** Baseline at `8d09b30` was also 29/29, verified before any edit. ## Not done, deliberately * No version number picked — owner's decision, depends on the live box. * Formula provider not implemented — separate card, referenced as planned. * Released CHANGELOG history not rewritten — annotated in place. * **Nothing deployed.** No `ha addons update`, no writes to `/addons/`, no contact with 192.168.2.158 or 192.168.2.221. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-13 02:47:09 +02:00
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
admin merged commit aade2c79fd into release/1.0 2026-09-13 02:55:00 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#81