TARIFF-18: price_taxes_eur_kwh stops shipping at half the real figure
checks / unit (pull_request) Successful in 7m48s

`price_taxes_eur_kwh` shipped at 0.06 EUR/kWh with no recorded provenance -
no card, no date, no component list - and roughly HALF the true Flemish
all-in non-energy figure. It now ships at 0.11736, and the rows it is made
of are in the code rather than in this message:

  Fluvius distribution (Antwerpen)              5,35   c EUR/kWh
  Bijzondere accijns, "Alle verbruik"           4,876
  Bijdrage groene stroom en WKK Vlaanderen      1,51
  Energiebijdrage (0 EUR/MWh since 2026-08-01)  0
                                               ------
                                               11,736 c EUR/kWh = 0.11736

Eneco residential card for Flanders, September 2026, incl. 6 % VAT.
`app/price.py`'s FLEMISH_TAXES_COMPONENTS_EUR_KWH carries the rows and
test_price.py section 1b re-adds them - and also compares the total against
the card's own figures typed in the test file, so an edit that moved the
rows and the default together is still caught.

WHAT THIS IS NOT. It is not an arbitrage-correctness fix and it cannot be.
This scalar is a LEVEL component: an additive constant cancels exactly in
`max - min` and leaves the argmin where it was. TARIFF-17 shipped that proof
and it is unchanged - test_price.py 16b drives six flat scalars spanning and
exceeding the whole per-DSO range and gets the identical cheapest hour and
the identical spread to the last digit. DOCS and the CHANGELOG say so in
those words.

WHAT IT IS. A display and guard fix:

  * every number an operator reads, which at half the true value made the
    add-on look considerably better than it is; and
  * §17's negative-price guard in arbitrage.py - the one rung that tests an
    ABSOLUTE all-in price rather than a difference. It fires when
    `spot < -(markup + taxes)`, so its boundary moves -0.09 -> -0.14736
    EUR/kWh of spot. test_arbitrage.py section 2c drives both sides through
    the shipped decide() and pins the boundary from below as well.

The default is Antwerpen's real row, near the LOW end of the 0.117-0.131
per-DSO band, and that is deliberate: the error directions are not
symmetric. Too high carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal - §17's own case. Too low only
declines a discharge that would have paid.

TARIFF-17's INTERACTION, WHICH HAD TO BE DECIDED. Nothing in that rung
hardcoded 0.06; it read PRICE_OPTION_DEFAULTS, so moving the default moves
what it refuses. The Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`, so a saved blob carries the key whether it was
typed or not - every stored 0.06 became "non-default" with nobody editing
anything, and a working Walloon or Brussels site with a taxes table would
have met SystemExit(78) on the upgrade over an inert number. price.py now
carries SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) and validate.py reads a
superseded default as untouched too. The rung is NOT weakened: 0.1198 beside
a table is still refused by name, and the refusal now names both figures.

config.yaml and PRICE_OPTION_DEFAULTS moved together; CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing.
test_price.py section 0's fourth check was REWRITTEN rather than re-pointed:
it used to justify the file's own grading constants by observing that the
shipped pair was degenerate (0.06 is exactly twice 0.03). TARIFF-18 ends
that coincidence, so the sentence is no longer true and is not kept.

29/29 test files green. Mutation ledger on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-13 16:24:56 +02:00
co-authored by Claude Opus 5
parent 7796f168a9
commit 9c65ac2cf8
15 changed files with 671 additions and 58 deletions
+106
View File
@@ -25,6 +25,7 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _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 |
| _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
| _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** |
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
@@ -131,6 +132,111 @@ figure is correct. This is a product concern rather than a site one: the add-on
is sold to clients, and a Walloon or Brussels client could not express their
own tariff.
### TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real figure
⚠️ **A SHIPPED DEFAULT CHANGED: `price_taxes_eur_kwh` 0.06 -> 0.11736 EUR/kWh.**
Same shape as 0.8.0's `slew_w` rule, and the same instruction follows — **read
the live options before `ha addons update`**, this time to see whether
`price_taxes_profile` is set, which is the one shape the move could have
refused. The "who is affected" section below is the part that matters, and it
is shorter on consequences than a changed default usually is.
The old `0.06` had **no recorded provenance anywhere**: not a card, not a date,
not a component list. It is roughly **half** the Flemish figure. The new default
is the Eneco residential card for Flanders, September 2026, incl. 6 % VAT, and
its rows are now **in the code** (`app/price.py`'s
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, re-added by `test_price.py` section 1b):
| component | c€/kWh |
|---|---|
| Fluvius distribution (**Antwerpen**) | 5,35 |
| Bijzondere accijns, *"Alle verbruik"* — no volume bands | 4,876 |
| Bijdrage groene stroom en WKK Vlaanderen | 1,51 |
| Energiebijdrage — **0 EUR/MWh since 2026-08-01**, merged into the accijns | 0 |
| **total** | **11,736 c€/kWh = 0.11736** |
⚠️ **IT IS PER-DSO, AND THE DEFAULT IS NOT AN AVERAGE.** Only the distribution
row moves: across the eight Fluvius DSOs it runs 5,28 c€/kWh
(Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about **0.117 to
0.131**. The default is a real DSO's real row, near the **low** end, and DOCS
tells an operator to look up their own. A **Walloon or Brussels** site should
not use this scalar at all — its distribution tariff has a day and a night band
and belongs in TARIFF-17's `price_taxes_profile`.
⚠️ **THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE AND CANNOT.**
This scalar is a **LEVEL** component: an additive constant cancels exactly in
`max − min` and leaves the argmin where it was. TARIFF-17 shipped the machinery
and the proof — six flat scalars spanning and exceeding the whole per-DSO range
give the **identical** cheapest hour and the **identical** spread to the last
digit (`test_price.py` 16b, printed on every run), and none of that changes
here. No trade happens that did not happen before *because of the level*. What
does change is two things, and nothing else:
* **every number an operator reads** — the all-in prices on the status page,
`custom_formula`'s startup preview and the recorded price history. At half
the true non-energy component they made the add-on look considerably better
than it is; and
* **§17's negative-price guard** (`arbitrage.py`), the one rung that tests an
**absolute** all-in price rather than a difference. It fires when
`spot < −(markup + taxes)`, so its boundary moves **−0.09000 -> −0.14736
EUR/kWh of spot**. A −0.11868 EUR/kWh spot hour used to read **−0.02868**
all-in and the discharge was refused as paid-for disposal; at the real tax
level it reads **+0.02868** and discharges, because that hour genuinely
paid.
`test_arbitrage.py` section 2c drives both sides through the shipped
`decide()`, and pins the boundary from below as well.
⚠️ **AND THAT ASYMMETRY IS WHY THE DEFAULT SITS AT THE BOTTOM OF THE BAND.** Set
too **high**, the figure carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal — §17's own case. Set too **low**, it only
declines a discharge that would have paid. One default standing in for eight
DSOs belongs at the bottom.
#### Who is affected
* **A site that set `price_taxes_eur_kwh` itself: nothing changes.** Its value
is kept and is never silently replaced.
* **A site whose stored options blob already carries the key: nothing changes
either, and this is most existing installs.** The Supervisor sets
`App.options = deepmerge(config.yaml defaults, stored options)`
(`tools/supervisor_selfapi_probe.py` §D), so a moved default reaches only
keys the blob does **not** carry — and the reference install's export carries
**70 of the 71**, the one absentee being a key added after its last save.
Such a site goes on running at `0.06` and reading numbers that are about half
its real all-in price until somebody opens the Configuration tab. ⚠️ **That
is a real limit of this card and it is not fixed here**: the change reaches a
**fresh install**, and an existing one only when its options are re-saved.
⚠️ **NOT re-measured on this branch** — the 70-of-71 figure is 0.9.0's, and
PKG-09's `tools/predeploy_check.py --options <live blob>` against the live
options is what would confirm it before deploying.
* ⚠️ **A site with `price_taxes_profile` SET would have been refused at startup,
and is deliberately not.** TARIFF-17 made a taxes period table beside a
**non-default** `price_taxes_eur_kwh` a `SystemExit(78)` refusal, comparing
against the shipped default precisely because the Supervisor writes the key
whether it was typed or not. Moving the default turns every stored `0.06`
into "non-default" **without anybody editing anything**, so a working Walloon
or Brussels site would have stopped starting on the upgrade over an inert
number the table overrides anyway. `price.py` now carries
`SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)` and `validate.py` treats a
superseded default as *untouched* as well. **The rung is not weakened**: a
figure that is neither the shipped nor a superseded default — ORES's 0.1198,
say — is still refused, by name, and the refusal now says which of the two
figures it will accept (`test_validate.py`, four new checks plus the
difference precondition).
Nothing hardcoded `0.06` in that rung — it read `PRICE_OPTION_DEFAULTS` — which
is why the interaction is a *behaviour* change rather than a drift, and why it
had to be decided rather than noticed.
`config.yaml` and `PRICE_OPTION_DEFAULTS` moved together and CFG-03's tripwire
(`test_price.py` section 1, `test_defaults_pin.py`) stayed green by agreeing,
not by being widened. ⚠️ One check did have to be **rewritten rather than
re-pointed**: `test_price.py` section 0 used to justify its own grading
constants by observing that the shipped pair was degenerate — `0.06` is exactly
twice `0.03`. TARIFF-18 ends that coincidence, so the sentence is no longer true
and is not kept; the file now asserts per-figure that neither shipped default is
what it grades with, which is the property that was actually load-bearing.
### 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