Files
goodwe-addon/goodwe_controller/DECISIONS/TARIFF-15-the-card-is-the-grader.md
T
glenn schrooyenandClaude Opus 5 9c65ac2cf8
checks / unit (pull_request) Successful in 7m48s
TARIFF-18: price_taxes_eur_kwh stops shipping at half the real figure
`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
2026-09-13 16:24:56 +02:00

167 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` was 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. *(Annotated
2026-09-13: TARIFF-18 raised the default to 0.11736, nearly six times the
floor. The asymmetry is unchanged in kind and larger in degree — the flat
addend that carries a hundredfold-small card over 0.02 got bigger.)*
## 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. *(Annotated 2026-09-13: that card is
**TARIFF-18** and it shipped on this branch at **0.11736** — the Eneco
residential card for Flanders, September 2026, incl. 6 % VAT, summed from
Fluvius Antwerpen distribution 5,35 + bijzondere accijns 4,876 + bijdrage
groene stroom en WKK 1,51 c€/kWh. §3b held: the floor is still on the energy
figure and nothing in this module needed a new number.)*