TARIFF-16: the injection leg stops being one number
checks / unit (pull_request) Successful in 7m42s

`price.py` modelled what a site is PAID for export as ONE scalar. The market
publishes a line, and the cards say so in their own words: Eneco Zon & Wind
Dynamisch `0,1 x BELPEX-H - 1,188` c EUR/kWh - "het injectietarief is dynamisch
en kan per uur wijzigen", identical on the January and the September 2026 cards;
ENGIE Dynamic `(B x eSpot_15) - A`; Aspiravi `0,07 x Belpex - 2`.
`price_export_formula` carries that line with its OWN coefficients.

⚠️ THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT.
`arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment
says so, citing §9.3's near-zero Belgian injection price as the reason the
economically sound trade on this market is charge-cheap-to-displace-expensive-
IMPORT. Both hours it compares are all-in CONSUMPTION prices. No trade this
add-on takes today is decided by an injection price and nothing here changes
one. What it changes is what an operator is told, and what a future
export-aware strategy rests on: OPT-06 and any export logic would otherwise
inherit a scalar that cannot represent the market, and the 6% VAT asymmetry
would be invisible in the arithmetic. test_price.py 17h parses every module
under app/ and goes red the day something calls an injection reader, so that
sentence is graded rather than attested.

FOUR PROPERTIES A SCALAR CANNOT HOLD. Its own factor (Eneco 0,102 consumption
against 0,100 injection; Aspiravi 0,07 against ~0,10 - not the consumption B
with a sign flip). A negative offset, which `price_markup_eur_kwh: float(0,1)`
cannot express at all. VAT-exemption - "injectietarieven zijn vrijgesteld van
btw" - against 6% on consumption, representable only because `vat_pct` is PER
SPEC. And a price that goes below zero with no floor, under 11,88 EUR/MWh of
BELPEX-H, which is the middle of a sunny day.

⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, AND THAT NEEDED A DIFFERENT BAND
RATHER THAN A LOWER FLOOR. `implausible_all_in()` floors the consumption leg at
+0.02 EUR/kWh of energy; applied to injection that refuses the real market in
the hours it matters most. Measured rather than argued: 17c puts ONE spec
through BOTH bands and prints that the consumption band refuses -0.03008
EUR/kWh while the injection band accepts it. The floor was not decoration - it
is what catches a card read a hundred times too small, the one unit error the
card self-check cannot see because it cancels out of a comparison between the
two sides - so the bound moves OFF the price and ONTO the coefficients, where
it is index-free and sign-free: a pass-through band (Eneco's injection leg is
1.00x, Aspiravi's 0.70x; the band is 0.10-10x) and an offset magnitude bound
(0.20 EUR/kWh against published 0.01188 and 0.02). Two rungs that can both
answer a hundredfold error, so each is graded on its REASON, each gets a
stimulus the other cannot reach with the precondition asserted as its own
check, and they are mutated TOGETHER (TEST-16).

⚠️ THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT
THAT IS STATED RATHER THAN WORKED AROUND. `price_formula` can require a `card`
block because a Belgian card prints the consumption formula, the reference
index AND the resulting price. Eneco's INJECTION formula is printed with no
price beside it. Requiring a block would make the published line inexpressible;
inventing the estimate would grade the coefficients against arithmetic done
with the same coefficients. So the block is OPTIONAL on this leg and unchanged
on the other, the self-check runs at the same tolerance wherever a printed
estimate exists, and validate.py says at startup - "its coefficients are
UNGRADED" - when it does not, naming the two bounds that ran instead. This is
TARIFF-15 demonstrating two tariffs end to end rather than three, one card
later. 17e also MEASURES the size of that gap: a factor of 0.070 where the
contract says 0.100 - 30% of every export payment - passes both bounds, because
0.70x is a pass-through somebody really publishes.

ONE PARSER, NOT TWO. `parse_export_formula()` is `parse_price_formula()` with
its OPTION NAME as an argument - TARIFF-17's move on `parse_static_profile()`,
exactly - and everything that differs between the legs is a string in one
`_LEGS` table. A second copy would be a second unit table, a second NaN rule, a
second unknown-key rule and a second `to_resolution()`, diverging first on the
23- and 25-hour days. test_price.py 17b pins it two ways: all 32 grammar names
are defined in exactly ONE module under app/, with the scope COMPUTED by
parsing every file rather than listed; and twelve refusal sentences for the new
option are DERIVED from the old option's own sentence rather than transcribed.
The four sentences that deliberately differ are named, with what differs, and
asserted to differ.

NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "";
`export_price_eur_kwh()`'s body is byte-identical, pinned on the AST rather
than on the source text; `export_price_at()` falls through to it for every
index value. 17f compares seven option dicts x five index values against the
pre-TARIFF-16 expression with `==`.

`price_export_formula` joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived
from that set, and `code_leaks()` refuses a plugin that names it with nothing
else edited.

ALSO CORRECTED HERE: config.yaml's comment beside `export_price_eur_kwh` still
read "no shipped supplier publishes a dynamic injection curve". TARIFF-07
struck that claim from app/price.py and DOCS.md and did not reach config.yaml,
where it has been shipping since.

20 mutations, every one red on a named check, ledger in test_price.py below
section 17 - including a ZERO-byte one (the two legs transposed in the
VAT-asymmetry banner: no decision moves, only the sentence) and a ONE-byte one
(`card_required=True` on the injection leg). Three run over all 29 files and
red on test_price.py alone. 29/29 green.

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 18:41:06 +02:00
co-authored by Claude Opus 5
parent 4dcd9b0fc6
commit 995da777ce
11 changed files with 1884 additions and 116 deletions
+125 -22
View File
@@ -1372,7 +1372,8 @@ does, and only after you have read why.
| `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` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
| `export_price_eur_kwh` | The **injection** price. A separate scalar, not a series — see below |
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
| `price_csv_path` | Where `manual_csv` reads its file |
| `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 |
@@ -1691,10 +1692,13 @@ 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
The **injection** leg is a separate card, and it has its own option:
`price_export_formula` (TARIFF-16). 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.
so it is a second per-contract formula rather than a second copy of this one —
the same parser, its own coefficients. `export_price_eur_kwh` remains the flat
injection price for a site that is paid one, and **no strategy reads either**;
see *Injection is priced separately* above.
#### `manual_csv` — the offline fallback
@@ -1845,25 +1849,122 @@ logged and reported on the status row, never raised. A cache that cannot answer
is not allowed to be a controller that cannot regulate, and that applies to the
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.
Negative values are accepted — you can be paid to *not* export at midday in
summer.
### Injection is priced separately
⚠️ 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.**
There are two ways to say what you are paid for an exported kWh, and which one
you want depends on your contract:
**The old Home Assistant price sensors.** `price_now_entity` and
| your contract | what to set |
|---|---|
| a flat injection price | `export_price_eur_kwh` — one number, EUR/kWh. This is the default and what every existing install uses |
| a price that moves with the market | `price_export_formula` — your card's injection line as `factor × index + offset`, in exactly the shape `price_formula` uses for consumption |
Negative values are accepted on both — you can be charged to export at midday in
summer, and the add-on says so rather than flooring it.
#### ⚠️ What the add-on does *not* do with an injection price
**No strategy in this add-on reads one.** Not the scalar, not the formula.
`arbitrage.py` prices the sell side as **displaced import** — both hours it
compares are all-in *consumption* prices — and its own comment gives the reason:
§9.3's near-zero Belgian injection price is exactly why the economically sound
trade on this market is *charge cheap to displace expensive import*, not *charge
cheap to export dear*. So setting `price_export_formula` changes **no decision
the add-on takes today**. It changes what you are shown at startup, and it is
the shape any future export-aware strategy would read.
That is the argument for doing it now rather than later: OPT-06 and any export
logic would otherwise inherit a scalar that cannot represent the market, and the
6% VAT asymmetry between the two legs would be invisible in the arithmetic.
`test_price.py` 17h parses every module under `app/` and goes red the day
something calls an injection reader, so this paragraph is graded rather than
attested.
#### Why a scalar was not enough
Belgian suppliers publish the injection leg as a formula, on the same card as
the consumption one. The reason given here until TARIFF-07 — *"no supported
supplier publishes a dynamic injection tariff"* — was **false**, and what they
publish has four properties one number cannot hold:
```
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c/kWh
"Het injectietarief is dynamisch en kan per uur wijzigen"
- identical on the January and the September 2026 cards
ENGIE Dynamic (B x eSpot_15) - A
Aspiravi (non-dynamic) 0,07 x Belpex - 2
```
1. **Its own factor.** Eneco uses `0,102` on consumption and `0,100` on
injection; Aspiravi `0,07` against about `0,10` on its consumption leg. It is
**not** the consumption `B` with a sign flip.
2. **A negative offset** — `− 1,188`, `− 2`. Note that
`price_markup_eur_kwh: float(0,1)` cannot express a negative markup at all,
so the offset has no home on the consumption side either.
3. **VAT-exempt.** *"Injectietarieven zijn vrijgesteld van btw"*, while
consumption is at 6%. `vat_pct` is **per leg**, so that six-point asymmetry
is expressible; a single scalar carries no VAT field at all.
4. **It goes negative, with no floor.** Eneco's injection price is below zero
for any BELPEX-H under **11,88 EUR/MWh** — and a PV household exports
precisely in the hours most likely to be negative. **You can be charged to
export.**
#### `price_export_formula`, field by field
Same grammar as `price_formula` — literally the same parser, with its option
name as an argument, so there is no second unit table and no second card rule to
diverge from the first. Eneco's published injection line, typed out:
```json
{"index":"epex_be","resolution":"hourly","factor":0.1,"offset":-1.188,
"units":"c_eur_per_kwh","vat_pct":0}
```
⚠️ **`vat_pct` is 0 here and 6 on the consumption leg.** That is the one field
people copy across from the other formula and get wrong.
⚠️ **The `card` block is optional on this leg and required on the other**, and
that is a statement about the cards rather than a relaxed rule. `price_formula`
can demand one because a Belgian card prints the consumption formula, the
reference index it assumed *and* the resulting price — so the add-on can
recompute that price and refuse to start unless your numbers reproduce it.
**Eneco's injection formula is printed with no price beside it.** There is
nothing to grade against on that card. Requiring a block would make the market's
own published line inexpressible; inventing the estimate would grade your
coefficients against arithmetic done with your coefficients, which is not a
check. So:
* if **your** card prints an injection estimate, fill the block in and it is
graded at the same tolerance as the consumption leg — the add-on refuses to
start if your numbers do not reproduce it;
* if it does not, the add-on **says so at startup** — *"its coefficients are
UNGRADED"* — and names what it checked instead.
What it checks instead, always, on both paths:
* **pass-through**: `factor`, converted to a share of the wholesale price, must
be between 0.10× and 10×. Eneco's injection leg is `1.00×` and Aspiravi's
`0.70×`; a coefficient typed a hundred times out reads `0.01×` or `100×`.
* **offset magnitude**: `|offset|` must be at most 0.20 EUR/kWh. The published
ones are `−0.01188` and `−0.02`.
Both bounds are on the **coefficients**, not on the price, which is why they can
bound a leg whose price legitimately goes below zero. `price_formula`'s
plausibility band is deliberately **not** reused here: its floor is `+0.02
EUR/kWh` of energy, and applying that to injection would refuse the real market
in exactly the hours where knowing the number matters most.
⚠️ **And the gap those bounds leave, with its size.** They bound the *shape* of
the coefficients, never their truth. With no printed estimate, a `factor` of
`0.070` where your contract says `0.100` — 30% of every export payment — passes
both, because `0.70×` is a pass-through somebody really publishes. Only a
printed estimate can catch a wrong-contract pair, and on Eneco's card there is
not one. That is why the startup line says the leg is ungraded instead of
implying it was checked.
### The old Home Assistant price sensors
`price_now_entity` and
`price_avg_entity` still work and are unchanged: the add-on compares them and
uses the result as a cheap-window hint for the maintenance charge. They are
**not compatible with `price_provider`, and not with `static_profile` either**
@@ -2073,7 +2174,9 @@ second set of 23- and 25-hour-day bugs.
is untouched — the table is routed through the same `capabilities().adds`
rule as the scalar, not bolted on.
- It does **not** apply to `static_profile`, whose prices are already all-in,
nor to `export_price_eur_kwh`.
nor to `export_price_eur_kwh` or `price_export_formula`. Those are the
injection leg, and nothing is added to what you are *paid* for export:
`price_taxes_eur_kwh` is a consumption component.
### Static day/night and fully-fixed tariffs (`static_profile`)