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
+103
View File
@@ -26,6 +26,7 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _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** |
| _this branch_ | TARIFF-16 - the injection leg stops being one number: an injection formula with its own coefficients, its own VAT and no floor under zero |
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
@@ -61,6 +62,108 @@ separate card. *(Annotated 2026-09-13: that card is **TARIFF-15**, below, and it
shipped on this branch as `custom_formula`. It is still not an alias — the
operator types their own coefficients; nothing is invented on their behalf.)*
### TARIFF-16 - the injection leg stops being one number
**`price_export_formula`: what a site is PAID for export, as `factor × index +
offset` with its own coefficients, alongside the flat `export_price_eur_kwh`.**
⚠️ **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, and gives §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. So 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 any 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
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 that claim is graded rather than attested.
**The market publishes a line, not a number.** Verbatim off the cards: 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 `0,07 x Belpex - 2`.
Four properties a scalar cannot hold:
1. **Its own factor.** Eneco `0,102` on consumption against `0,100` on
injection; Aspiravi `0,07` against about `0,10` on its consumption leg. Not
the consumption `B` with a sign flip.
2. **A negative offset** — `− 1,188`, `− 2`. `price_markup_eur_kwh: float(0,1)`
cannot express a negative markup at all, so it has no home on the
consumption side either.
3. **VAT-exempt** — *"Injectietarieven zijn vrijgesteld van btw"* — while
consumption is at 6%. `vat_pct` is now **per leg**, so the six-point
asymmetry is expressible; a scalar carries no VAT field at all.
4. **It goes negative with no floor**, below `11,88 EUR/MWh` of BELPEX-H — the
midday hours a PV household exports in. You can be charged to export.
⚠️ **A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED**, 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, which is right there and fatal
here — measured, not assumed: `test_price.py` 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, though: 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 (`factor` as a share of
the wholesale price; Eneco's injection leg is 1.00×, Aspiravi's 0.70×, the band
is 0.10–10×) and an **offset magnitude** bound (0.20 EUR/kWh against published
values of 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, 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 market's own 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.
**ONE PARSER, NOT TWO.** `custom_formula.py` already shipped the units table,
the NaN rule, the closed enumerations, the unknown-key refusal, the card
comparison and `to_resolution()`'s DST handling, so `parse_export_formula()` is
that parser with its **option name as an argument** — TARIFF-17's move on
`parse_static_profile()`, exactly. Everything that differs between the two legs
is a string in one `_LEGS` table. `test_price.py` 17b pins it two ways: by
parsing every module under `app/` and asserting all 32 grammar names are defined
in exactly **one** of them, and by deriving twelve refusal sentences for the new
option from the old option's own sentence rather than transcribing them. The
three 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 `""`, and
`export_price_eur_kwh()`'s body is byte-identical — pinned on the AST, not on
the source text — with `export_price_at()` falling through to it for every index
value. `test_price.py` 17f compares seven option dicts × five index values
against the pre-TARIFF-16 expression with `==`.
`export_price_eur_kwh` and the new key are both in `FRAMEWORK_KEYS`;
`BANNED_IN_PLUGIN_CODE` is derived from that set, so `code_leaks()` refuses a
plugin that names either 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 with byte deltas — including a **zero-byte** one (the two legs
transposed in the VAT-asymmetry banner, which changes no decision and only
moves the sentence) and a **one-byte** one (`card_required=True` on the
injection leg). Three were run over all 29 files and are red on `test_price.py`
alone; the rest over the five suites that can reach this card. Each row gets a
fresh copy of the tree under a `mktemp` path, mounted `:ro` — no revert to fail
and no shared backup path, which is MUTATION.md habit 18 closed structurally.
29/29 green.
### TARIFF-17 - a price component that varies by hour has somewhere to live
**`price_taxes_profile`: the taxes component as a period table, alongside the
+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`)
+444 -58
View File
@@ -125,6 +125,49 @@ units and the market aggregator prints both conventions in one column, so the
add-on has to be told which one the card is in - and the unit conversion is the
add-on's job precisely because it is one of the two steps that silently costs a
factor of 10, 100 or 1000. The other is VAT, which is also the add-on's.
TARIFF-16 - THE SECOND LEG, AND WHY IT IS THIS PARSER AND NOT A SECOND ONE
==========================================================================
A Belgian dynamic contract has TWO published lines, not one, and the second is
what the site is PAID for export:
Eneco Zon & Wind Dynamisch 0,1 x BELPEX-H - 1,188 c EUR/kWh
ENGIE Dynamic (B x eSpot_15) - A
Aspiravi (non-dynamic) 0,07 x Belpex - 2
Same `B x index + A` shape - which is why `price_export_formula` is this
module's parser with ONE argument moved (`option`), exactly as TARIFF-17 made
`parse_static_profile()`'s option name a parameter rather than forking the
period grammar. What differs between the two legs is FOUR numbers and a
handful of sentences, and all of them are rows of `_LEGS`:
* its OWN factor - Eneco 0,102 on consumption against 0,100 on injection,
Aspiravi 0,07 against ~0,10. NOT the consumption `B` with a sign flip;
* a NEGATIVE offset, which `factor`/`offset` already allow and which
`price_markup_eur_kwh: float(0,1)` cannot express at all;
* `vat_pct: 0` - "Injectietarieven zijn vrijgesteld van btw" - against 6 on
consumption, which is a SIX PERCENT ASYMMETRY between the buy leg and the
sell leg and is representable only because `vat_pct` is PER SPEC;
* a price that goes BELOW ZERO with no floor, in the hours a PV household
exports. `implausible_injection()` is the band that accepts that and still
refuses an implausible one; `implausible_all_in()` is NOT it, and its
docstring says why.
⚠️ AND THE CARD CANNOT ALWAYS GRADE IT. Eneco's injection formula is printed
with NO price beside it, so on that card there is nothing to reproduce. The
`card` block is therefore OPTIONAL on this leg and REQUIRED on the other, the
self-check runs wherever there is a printed estimate, and validate.py says at
startup when a leg is ungraded. Inventing an estimate to grade against would
be grading arithmetic against itself, which is the defect MUTATION.md habit 4
is about; TARIFF-15 demonstrated two tariffs end to end rather than three for
the same reason.
⚠️ NOTHING TRADES ON THIS TODAY, 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 - and both hours it
compares are all-in CONSUMPTION prices. So this is not a wrong-trade defect
and must not be described as one. What it changes is what an operator is told,
and what a future export-aware strategy would rest on.
"""
import json
@@ -183,6 +226,150 @@ _CARD_KEYS = ("month", "index_eur_per_mwh", "expect_incl_vat")
_TOP_KEYS = ("index", "resolution", "factor", "offset", "units", "vat_pct",
"card")
# --------------------------------------------------------------------------- #
# TARIFF-16 - the SECOND leg, and the one parameter that carries it
# --------------------------------------------------------------------------- #
EXPORT_FORMULA_OPTION = "price_export_formula"
# ⚠️ THE PASS-THROUGH FRACTION, WHICH IS WHY THE NEXT TWO BOUNDS CAN BE
# INDEX-FREE. One EUR/MWh IS 0.001 EUR/kWh, so `factor x UNITS[units] / 0.001`
# is the dimensionless share of the wholesale price the contract passes
# through - 1.00 for Eneco's injection leg (0,100 c EUR/kWh per EUR/MWh), 0.70
# for Aspiravi's (0,07). A coefficient typed a hundred times too small or too
# large moves this by a hundred, whatever the index and whatever the sign of
# the price.
EUR_KWH_PER_EUR_MWH = 0.001
# The two plausibility bounds for an INJECTION leg. ⚠️ NEITHER LOOKS AT THE
# SIGN OF THE PRICE, AND THAT IS THE WHOLE POINT - see
# `implausible_injection()`.
PASSTHROUGH_LO = 0.10 # 10% of the wholesale price
PASSTHROUGH_HI = 10.0 # 1000% of it
INJECTION_OFFSET_MAX_EUR_KWH = 0.20
@dataclass(frozen=True)
class _Leg:
"""What ONE option's coefficients price, as sentences its refusals read.
⚠️ TARIFF-16 MADE `option` A PARAMETER RATHER THAN FORKING THE PARSER,
exactly as TARIFF-17 made `option`/`means` parameters of
`parse_static_profile()` rather than forking the period grammar. A second
copy of this parser would be a second `_finite()`, a second unknown-key
rule, a second unit table and a second `to_resolution()` - and the two
would diverge first on the 23- and 25-hour days, where nobody looks.
Everything that differs between the consumption leg and the injection leg
is a STRING in this table; the arithmetic, the enumerations, the card
comparison and the resolution rule are ONE implementation with one set of
checks over it.
"""
option: str
what: str # "consumption" / "injection", for a sentence
example: str # a VALID blob for this leg, in the JSON-error hint
when: str # when this option is read at all
flat: str # where a FLAT price of this kind already lives
zero_factor: str # what a factor of 0 actually costs on this leg
neg_factor: str # why a negative FACTOR is wrong on this leg
card_required: bool
card_note: str # why it is required, or what is lost without it
card_causes: str # the leg-specific tail of `card_refusal()`
_LEGS = {
"price_formula": _Leg(
option="price_formula",
what="consumption",
example=('{"index":"epex_be","resolution":"hourly","factor":0.102,'
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}'),
when="Leave it empty unless price_provider is 'custom_formula'.",
zero_factor=("every spread is exactly 0, and arbitrage can never "
"clear any threshold - a strategy that silently never "
"fires rather than one that is off"),
flat=("that is `static_profile` with a single period covering "
"00:00-24:00 on all days and price_provider 'off'; the add-on "
"then says so at startup instead of leaving you to find out "
"from a year of unchanged bills"),
neg_factor=("No consumption tariff is indexed negatively; if your "
"card shows a minus sign it is on the OFFSET (`A`), "
"which is allowed here and common on injection"),
card_required=True,
card_note=("It is the only thing that can tell a correct pair of "
"coefficients from a plausible-looking wrong one"),
card_causes=(
"or - the expensive one - network charges, levies or excise "
"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian "
"card's Tariefformule line is the ENERGY component alone (Eneco "
"tabulates network tariffs and levies separately; ENGIE says in "
"so many words that the formula excludes network, distribution, "
"transport, levies and excise), the add-on adds "
"price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of "
"network charge into `offset` bills you for it twice. That is "
"the error this check exists to catch, and it is why it cannot "
"be switched off."),
),
EXPORT_FORMULA_OPTION: _Leg(
option=EXPORT_FORMULA_OPTION,
what="injection",
# ⚠️ NO `card` BLOCK IN THE EXAMPLE, because Eneco's injection line has
# none to copy - the formula is printed with no price beside it. An
# example carrying one would invite an operator to compute the figure
# themselves, which is the one thing this leg must not encourage.
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
when=("Leave it empty unless your supplier pays you an injection "
"price that moves with the market; a flat one is "
"export_price_eur_kwh. ⚠️ This option is read whatever "
"price_provider is set to - it is the leg you are PAID on, not "
"the one you buy on."),
zero_factor=("every hour pays the same, which is a FLAT injection "
"price written as a formula. ⚠️ Note this is NOT the "
"arbitrage argument the consumption leg gives: no "
"strategy in this add-on reads an injection price at "
"all, so a flat one here fires nothing and declines "
"nothing - it is simply the wrong option"),
flat=("that is `export_price_eur_kwh`, the flat injection scalar, "
"which is what every site used before this option existed and "
"which still answers when this one is empty"),
neg_factor=("No Belgian injection tariff is indexed negatively - you "
"are paid MORE when the market is dearer, not less. The "
"minus sign on an injection card is on the OFFSET (`A`): "
"Eneco prints `0,1 x BELPEX-H - 1,188` and Aspiravi "
"`0,07 x Belpex - 2`, and a negative offset is accepted "
"here"),
card_required=False,
card_note=(
"⚠️ IT IS OPTIONAL ON THIS LEG, AND THAT IS A STATED GAP RATHER "
"THAN A RELAXED RULE. A Belgian card prints the CONSUMPTION "
"formula with the reference index and the resulting price beside "
"it, which is what makes `price_formula`'s self-check possible. "
"Eneco's injection line has NO printed price beside it, so on "
"that card there is nothing to grade against and requiring a "
"`card` block would make the market's own published formula "
"inexpressible - or, worse, invite an operator to compute the "
"estimate themselves, at which point the check grades the "
"coefficients against arithmetic done with the same coefficients. "
"Supply it if YOUR card prints one; when it is absent the add-on "
"says at startup that this leg is UNGRADED and the two "
"coefficient bounds below are all there is"),
card_causes=(
"or VAT added where there is none. ⚠️ INJECTION IS VAT-EXEMPT IN "
"BELGIUM - \"Injectietarieven zijn vrijgesteld van btw\" - while "
"consumption is at 6%, so `vat_pct` on THIS option is 0 on every "
"Belgian card the research found, and a 6 typed here because it "
"is 6 on the consumption leg is a ratio of exactly 1.06. Nothing "
"is added to this leg by the framework: price_taxes_eur_kwh is a "
"CONSUMPTION component and is not applied to what you are paid "
"for export, so an offset carrying network charges is a "
"different error here than it is on price_formula and this "
"check cannot tell you which one you made."),
),
}
@dataclass(frozen=True)
class FormulaSpec:
@@ -199,14 +386,44 @@ class FormulaSpec:
offset: float
units: str
vat_pct: float
card_month: str # "YYYY-MM", as the card is dated
card_index_eur_per_mwh: float
card_expect_incl_vat: float # in `units`, as the card prints it
card_month: str = "" # "YYYY-MM", as the card is dated
card_index_eur_per_mwh: float = None
card_expect_incl_vat: float = None # in `units`, as the card prints it
# TARIFF-16. Which OPTION this spec was read from, so a refusal, a log line
# and `card_graded` name the leg the operator actually typed.
option: str = "price_formula"
@property
def unit_to_eur_kwh(self) -> float:
return UNITS[self.units]
@property
def card_graded(self) -> bool:
"""Is there a printed estimate to grade these coefficients against?
⚠️ FALSE IS A REAL, SHIPPED STATE ON THE INJECTION LEG and is reported
rather than defaulted: Eneco's injection formula is printed with no
price beside it. Never False on `price_formula`, where `_card()`
refuses an absent block.
"""
return self.card_expect_incl_vat is not None
@property
def passthrough(self) -> float:
"""The dimensionless share of the wholesale price this passes through.
`factor x UNITS[units] / 0.001`. 1.00 for Eneco's injection leg, 0.70
for Aspiravi's. ⚠️ INDEX-FREE AND SIGN-FREE, which is what lets
`implausible_injection()` bound a leg whose price legitimately goes
below zero.
"""
return self.factor * self.unit_to_eur_kwh / EUR_KWH_PER_EUR_MWH
@property
def offset_eur_kwh(self) -> float:
"""The offset in EUR/kWh, whatever units the card printed it in."""
return self.offset * self.unit_to_eur_kwh
# --------------------------------------------------------------------------- #
# The arithmetic. Two functions, and the SECOND one is the contract.
@@ -241,7 +458,18 @@ def card_discrepancy(spec: FormulaSpec) -> float:
In the card's units, signed: positive means the formula computes MORE than
the card prints, which is the direction an `offset` carrying network
charges moves it.
⚠️ RAISES ON A SPEC WITH NO CARD RATHER THAN RETURNING 0.0 (TARIFF-16). An
ungraded injection leg has no printed estimate, and a discrepancy of zero
is exactly what "the coefficients reproduce the card" looks like - so a
silent zero here would report the strongest possible result for the one
state in which nothing was compared at all.
"""
if not spec.card_graded:
raise FormulaInvalid(
f"{spec.option} has no card block, so there is no printed "
f"estimate to measure a discrepancy against. Ask "
f"`spec.card_graded` before calling this.")
return in_card_units(spec, spec.card_index_eur_per_mwh) \
- spec.card_expect_incl_vat
@@ -250,7 +478,7 @@ def card_discrepancy(spec: FormulaSpec) -> float:
# Reading the option
# --------------------------------------------------------------------------- #
def _finite(value, field: str) -> float:
def _finite(value, field: str, option: str = "price_formula") -> float:
"""A real, finite number - or a refusal naming the field.
⚠️ SAME CLASS AS `_validate_raw()`'s NaN RULE, one layer earlier. A NaN
@@ -260,26 +488,37 @@ def _finite(value, field: str) -> float:
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise FormulaInvalid(
f"price_formula.{field} is {value!r}, which is not a number. "
f"{option}.{field} is {value!r}, which is not a number. "
f"Write the coefficient as a bare JSON number - 0.102, not "
f"\"0.102\" and not an expression: this provider takes FIXED "
f"coefficients and evaluates nothing.")
if not math.isfinite(float(value)):
raise FormulaInvalid(
f"price_formula.{field} is {value!r}, which is not finite. A NaN "
f"{option}.{field} is {value!r}, which is not finite. A NaN "
f"or an infinity here makes every price NaN, every price "
f"comparison False, and an arbitrage strategy that never fires "
f"while reporting that it is armed.")
return float(value)
def _card(raw) -> tuple:
"""The `card` block: the estimate the coefficients are graded against."""
def _card(raw, leg: _Leg = None) -> tuple:
"""The `card` block: the estimate the coefficients are graded against.
⚠️ REQUIRED ON THE CONSUMPTION LEG AND OPTIONAL ON THE INJECTION ONE, and
the asymmetry is a fact about the CARDS rather than a relaxed rule. See
`_LEGS[EXPORT_FORMULA_OPTION].card_note`: Eneco's injection formula is
printed with no price beside it, so on that card there is nothing to grade
against. `("", None, None)` is the ungraded answer and
`FormulaSpec.card_graded` is how every caller asks.
"""
if leg is None:
leg = _LEGS["price_formula"]
if raw is None:
if not leg.card_required:
return ("", None, None)
raise FormulaInvalid(
"price_formula has no \"card\" block, and it is REQUIRED. It is "
"the only thing that can tell a correct pair of coefficients from "
"a plausible-looking wrong one: the add-on recomputes your "
f"{leg.option} has no \"card\" block, and it is REQUIRED. "
f"{leg.card_note}: the add-on recomputes your "
"supplier's own printed example from the numbers you typed and "
"refuses to start if it does not come out. Copy the three figures "
"off the same tariff card as the coefficients: "
@@ -287,24 +526,26 @@ def _card(raw) -> tuple:
"\"expect_incl_vat\": 15.55}.")
if not isinstance(raw, dict):
raise FormulaInvalid(
f"price_formula.card is {type(raw).__name__}, not an object with "
f"{leg.option}.card is {type(raw).__name__}, not an object with "
f"{', '.join(_CARD_KEYS)}.")
unknown = sorted(set(raw) - set(_CARD_KEYS))
if unknown:
raise FormulaInvalid(
f"price_formula.card has no field(s) {unknown}. The fields it "
f"{leg.option}.card has no field(s) {unknown}. The fields it "
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
f"number the operator believes took effect.")
month = str(raw.get("month", "") or "").strip()
if not _month_date(month):
raise FormulaInvalid(
f"price_formula.card.month is {raw.get('month')!r}; write the "
f"{leg.option}.card.month is {raw.get('month')!r}; write the "
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
f"not used in any price - it is what lets the add-on tell you "
f"your card has gone stale.")
return (month,
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh"),
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat"))
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
leg.option),
_finite(raw.get("expect_incl_vat"), "card.expect_incl_vat",
leg.option))
def _month_date(month: str):
@@ -316,9 +557,16 @@ def _month_date(month: str):
return None
def parse_price_formula(value):
def parse_price_formula(value, *, option: str = "price_formula"):
"""`price_formula` -> a `FormulaSpec`, or None when unset.
⚠️ `option` IS TARIFF-16'S WHOLE REUSE, and it is TARIFF-17's move. It
selects a row of `_LEGS` and nothing else: the enumerations, the
arithmetic, `to_resolution()`, the unknown-key rule and the card
comparison are shared, and every pre-existing call site - which passes no
`option` at all - lands on the consumption row and reads byte-identical
sentences. `parse_export_formula()` is a two-line wrapper, not a fork.
Accepts what the option can actually arrive as, exactly as
`parse_static_profile()` does:
@@ -335,6 +583,7 @@ def parse_price_formula(value):
fetch - go through this one function, so there is no route to a price that
has not been graded against the card.
"""
leg = _LEGS[option]
if value is None:
return None
if isinstance(value, str):
@@ -345,16 +594,13 @@ def parse_price_formula(value):
value = json.loads(text)
except ValueError as e:
raise FormulaInvalid(
f"price_formula is not valid JSON ({e}). It is a JSON object "
f"{option} is not valid JSON ({e}). It is a JSON object "
f"on one line, e.g.\n"
f' {{"index":"epex_be","resolution":"hourly","factor":0.102,'
f'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
f'"card":{{"month":"2026-09","index_eur_per_mwh":133.9905,'
f'"expect_incl_vat":15.55}}}}\n'
f"Leave it empty unless price_provider is 'custom_formula'.")
f" {leg.example}\n"
f"{leg.when}")
if not isinstance(value, dict):
raise FormulaInvalid(
f"price_formula is {type(value).__name__}, not a JSON object with "
f"{option} is {type(value).__name__}, not a JSON object with "
f"{', '.join(_TOP_KEYS)}.")
# ⚠️ AN UNKNOWN KEY IS REFUSED, NOT IGNORED, AND THIS IS THE HALF THAT
@@ -366,7 +612,7 @@ def parse_price_formula(value):
unknown = sorted(set(value) - set(_TOP_KEYS))
if unknown:
raise FormulaInvalid(
f"price_formula has no field(s) {unknown}. The fields it takes "
f"{option} has no field(s) {unknown}. The fields it takes "
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
f"takes an expression: this provider multiplies and adds two "
f"FIXED coefficients and evaluates nothing, because two "
@@ -378,7 +624,7 @@ def parse_price_formula(value):
index = str(value.get("index", "") or "").strip()
if index not in INDEXES:
raise FormulaInvalid(
f"price_formula.index is {value.get('index')!r}. The reference "
f"{option}.index is {value.get('index')!r}. The reference "
f"series this add-on can fetch are "
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
f"If your card names a different index, the add-on cannot get it "
@@ -388,7 +634,7 @@ def parse_price_formula(value):
resolution = str(value.get("resolution", "") or "").strip()
if resolution not in RESOLUTIONS:
raise FormulaInvalid(
f"price_formula.resolution is {value.get('resolution')!r}; it "
f"{option}.resolution is {value.get('resolution')!r}; it "
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
f"a day; a contract written against the HOURLY series is priced "
@@ -402,16 +648,16 @@ def parse_price_formula(value):
units = str(value.get("units", "") or "").strip()
if units not in UNITS:
raise FormulaInvalid(
f"price_formula.units is {value.get('units')!r} and is REQUIRED - "
f"{option}.units is {value.get('units')!r} and is REQUIRED - "
f"there is no default, on purpose. It must be one of "
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
f"and two suppliers use two different conventions for the same "
f"shape, so assuming one would be a factor of 100 or 1000 on "
f"every hour with nothing able to notice.")
factor = _finite(value.get("factor"), "factor")
offset = _finite(value.get("offset"), "offset")
vat_pct = _finite(value.get("vat_pct"), "vat_pct")
factor = _finite(value.get("factor"), "factor", option)
offset = _finite(value.get("offset"), "offset", option)
vat_pct = _finite(value.get("vat_pct"), "vat_pct", option)
# ⚠️ ZERO IS NOT "NO INDEXATION", IT IS A FIXED TARIFF WEARING A DYNAMIC
# ONE'S CLOTHES. Every hour comes out at the same price, so every spread is
@@ -421,15 +667,10 @@ def parse_price_formula(value):
# the operator at it rather than let them build it by accident.
if factor == 0.0:
raise FormulaInvalid(
"price_formula.factor is 0, so the index would not enter the "
f"{option}.factor is 0, so the index would not enter the "
"price at all: every hour of every day comes out at "
f"{offset:g} x (1 + {vat_pct:g}/100), every spread is exactly 0, "
"and arbitrage can never clear any threshold - a strategy that "
"silently never fires rather than one that is off. If your tariff "
"really is one flat price, that is `static_profile` with a single "
"period covering 00:00-24:00 on all days and price_provider "
"'off'; the add-on then says so at startup instead of leaving you "
"to find out from a year of unchanged bills.")
f"{offset:g} x (1 + {vat_pct:g}/100), {leg.zero_factor}. If "
f"your {leg.what} tariff really is one flat price, {leg.flat}.")
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
# the dearest hour becomes the cheapest one, so the battery is told to
@@ -437,37 +678,96 @@ def parse_price_formula(value):
# mispriced site, it is a site actively trading against itself.
if factor < 0.0:
raise FormulaInvalid(
f"price_formula.factor is {factor:g}, which is NEGATIVE. That "
f"{option}.factor is {factor:g}, which is NEGATIVE. That "
f"inverts the market: the dearest hour of the day comes out as "
f"the cheapest one, so the battery would charge at the evening "
f"peak and discharge at 03:00 - trading against itself on every "
f"cycle. No consumption tariff is indexed negatively; if your "
f"card shows a minus sign it is on the OFFSET (`A`), which is "
f"allowed here and common on injection.")
f"cycle. {leg.neg_factor}.")
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
# and some cards are already quoted VAT-inclusive. The bound exists for the
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
if not 0.0 <= vat_pct <= 30.0:
raise FormulaInvalid(
f"price_formula.vat_pct is {vat_pct:g} and must be between 0 and "
f"{option}.vat_pct is {vat_pct:g} and must be between 0 and "
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
f"injection is VAT-exempt, and a card that already quotes "
f"VAT-inclusive coefficients needs no second multiplication.")
month, card_index, card_expect = _card(value.get("card"))
month, card_index, card_expect = _card(value.get("card"), leg)
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
offset=offset, units=units, vat_pct=vat_pct,
card_month=month, card_index_eur_per_mwh=card_index,
card_expect_incl_vat=card_expect)
card_expect_incl_vat=card_expect, option=option)
# ⚠️ THE CARD SELF-CHECK STILL RUNS WHEREVER THERE IS A CARD TO RUN IT
# AGAINST, on either leg, with the same tolerance. What TARIFF-16 adds is
# the honest answer for the leg where the card prints no estimate: NOT a
# weakened comparison and NOT an estimate computed here, but two bounds on
# the COEFFICIENTS that need no printed price - see
# `implausible_injection()` - and a startup line saying the leg is
# ungraded.
if spec.card_graded:
delta = card_discrepancy(spec)
if abs(delta) > CARD_TOLERANCE:
raise FormulaInvalid(card_refusal(spec, delta))
if option == EXPORT_FORMULA_OPTION:
band = implausible_injection(spec)
if band:
raise FormulaInvalid(band)
return spec
def parse_export_formula(value):
"""`price_export_formula` -> the INJECTION leg's `FormulaSpec`, or None.
TARIFF-16. THE DEFECT THIS EXISTS TO CLOSE: `price.py` modelled injection
as ONE scalar, and the market publishes a line with four properties a
scalar cannot hold. Verbatim off the cards:
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 c EUR/kWh
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 ~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% - a SIX PERCENT ASYMMETRY between the buy leg
and the sell leg, which a single scalar cannot even state.
4. IT GOES NEGATIVE, WITH NO FLOOR. Eneco's injection price is negative
for any BELPEX-H below 11,88 EUR/MWh, and a PV household exports
precisely in the hours most likely to be negative. You can be charged
to export.
⚠️ AND WHAT THIS DOES **NOT** AFFECT TODAY, SAID HERE SO NOBODY READS THE
ABOVE AS A TRADING BUG. `arbitrage.py` does not read
`export_price_eur_kwh` at all - its own comment says so, and gives §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. So no trade the add-on
takes today is decided by this number, and this card is not a correction
of a wrong trade. What it changes is what an operator is told, and what
any future export-aware strategy would rest 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.
⚠️ THE SCALAR IS NOT REPLACED. `export_price_eur_kwh()` is untouched and
still answers for every site with a flat injection price, which is every
site that exists today; this option ships "". That is `price.py`'s own
prediction coming true rather than a new design - "when it does land, an
injection curve gets a `PriceProvider` of its own and this scalar becomes
its FIXED-product default; the seam is already the right shape for that."
"""
return parse_price_formula(value, option=EXPORT_FORMULA_OPTION)
def card_refusal(spec: FormulaSpec, delta: float) -> str:
"""The sentence an operator reads when their formula misses its own card.
@@ -479,8 +779,9 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
got = in_card_units(spec, spec.card_index_eur_per_mwh)
ratio = (got / spec.card_expect_incl_vat
if spec.card_expect_incl_vat else float("inf"))
leg = _LEGS[spec.option]
return (
f"price_formula does not reproduce its own card, so the add-on will "
f"{spec.option} does not reproduce its own card, so the add-on will "
f"NOT start on it. Your contract's arithmetic is "
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
f"on the card's own index that is "
@@ -496,15 +797,7 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
f"1000 - `units` must be the one the formula line is printed in); VAT "
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
f"digit; the two sides copied off DIFFERENT cards or different "
f"months; or - the expensive one - network charges, levies or excise "
f"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian card's "
f"Tariefformule line is the ENERGY component alone (Eneco tabulates "
f"network tariffs and levies separately; ENGIE says in so many words "
f"that the formula excludes network, distribution, transport, levies "
f"and excise), the add-on adds price_taxes_eur_kwh on top of it, and "
f"folding ~12 c EUR/kWh of network charge into `offset` bills you for "
f"it twice. That is the error this check exists to catch, and it is "
f"why it cannot be switched off.")
f"months; {leg.card_causes}")
# --------------------------------------------------------------------------- #
@@ -539,13 +832,27 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
fetched series. What it catches is a formula whose whole LEVEL is wrong,
which is the factor-of-1000 class arriving from the end
`MAX_PLAUSIBLE_EUR_KWH` does not bound.
⚠️ AND IT IS THE CONSUMPTION LEG'S BAND ONLY - TARIFF-16 DELIBERATELY DID
NOT POINT IT AT THE INJECTION LEG. Two reasons, and the second is an
acceptance criterion rather than a preference:
* it adds `taxes_eur_kwh`, and `price_taxes_eur_kwh` is a CONSUMPTION
component. Nothing is added to what a site is PAID for export, so the
`all_in` half would be adding a number the injection leg never carries;
* its floor is `energy >= 0.02 EUR/kWh`, and a REAL Eneco injection price
is negative for any BELPEX-H below 11,88 EUR/MWh - the hours a PV
household actually exports in. Applied to injection this floor would
refuse the market, and refuse it hardest in the hours where knowing the
number matters most. `implausible_injection()` is the injection band
and it is deliberately NOT this function with a lower floor.
"""
energy = price_eur_kwh(spec, spec.card_index_eur_per_mwh)
all_in = energy + float(taxes_eur_kwh)
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
return None
return (
f"price_formula's own card works out at {energy:.5f} EUR/kWh of "
f"{spec.option}'s own card works out at {energy:.5f} EUR/kWh of "
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
f"price_taxes_eur_kwh is added, which is outside the plausible band "
@@ -561,6 +868,85 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
f"the best charging opportunity of the year - is never refused.")
def implausible_injection(spec: FormulaSpec):
"""The INJECTION leg's plausibility band. Returns a refusal or None.
⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, NOT REFUSED, AND NEITHER RUNG
BELOW LOOKS AT THE SIGN OR THE LEVEL OF A PRICE. Eneco's published line
`0,1 x BELPEX-H - 1,188` is negative for any BELPEX-H under 11,88 EUR/MWh;
those are the midday hours a PV household exports in, so a band with a
floor above zero - which is what `implausible_all_in()` has, for the
consumption leg's own good reasons - would refuse the real market and
would hide it in exactly the hours it costs money. You CAN be charged to
export. The add-on says so rather than declining to represent it.
THE PROBLEM THAT LEAVES, STATED BEFORE THE SOLUTION. The consumption
floor was not decoration: it is what catches a whole 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. A band that spans
zero cannot catch it - 0.00132 EUR/kWh is inside any interval that
contains -0.03. So the bound moves OFF the price and ONTO the
coefficients, where it is index-free and sign-free:
* PASS-THROUGH. `factor x UNITS[units] / 0.001` is the dimensionless
share of the wholesale price the contract passes through, because one
EUR/MWh IS 0.001 EUR/kWh. Eneco's injection leg is 1.00 and Aspiravi's
is 0.70. A factor typed 100x too small reads 0.01 and 100x too large
reads 100, whatever the index and whatever the sign of the result.
* OFFSET MAGNITUDE. Eneco's is 1,188 c EUR/kWh = 0.01188 EUR/kWh and
Aspiravi's is 2 c = 0.02. A hundredfold error is 1.188 EUR/kWh, which
is not an injection tariff in any direction. The bound is on the
ABSOLUTE value, so a negative offset of a believable size passes and
that is the normal case on this leg.
⚠️ TWO RUNGS THAT CAN BOTH ANSWER ONE STIMULUS, SO THEY ARE GRADED ON
THEIR REASONS AND MUTATED TOGETHER (MUTATION.md, TEST-16). A whole card
read 100x too large trips both. The fixtures in test_price.py 17d are
built so that each rung is the ONLY one that fires on its own stimulus.
⚠️ AND WHAT THIS CANNOT SEE, WITH THE CLAIM (habit 13). It bounds the
SHAPE of the coefficients, never their truth: a contract really written
against a different index, or last year's coefficients, passes both rungs
and reproduces any card typed beside them. Where a card DOES print an
estimate the self-check grades the pair against it; where it does not -
which is Eneco's injection line - these two bounds are the whole of what
is mechanised, and validate.py says so at startup.
"""
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
return (
f"{spec.option}.factor is {spec.factor:g} {spec.units} per "
f"EUR/MWh, which passes through {spec.passthrough:.4g}x the "
f"wholesale price - outside the plausible "
f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x for an injection tariff. A "
f"Belgian injection formula pays a share of the market price a "
f"little under or about equal to it: Eneco's "
f"`0,1 x BELPEX-H - 1,188` c EUR/kWh is 1.00x and Aspiravi's "
f"`0,07 x Belpex - 2` is 0.70x. {spec.passthrough:.4g}x is what a "
f"factor typed in the wrong units looks like - check `units` "
f"({spec.units!r}) against the unit printed beside your formula, "
f"because the card self-check cannot see a factor of 10, 100 or "
f"1000 applied to BOTH sides at once. ⚠️ This bounds the "
f"COEFFICIENT and not the price: an injection price below zero is "
f"accepted and is the real market - Eneco's goes negative under "
f"11.88 EUR/MWh of BELPEX-H, which is the middle of a sunny day.")
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
return (
f"{spec.option}.offset is {spec.offset:g} {spec.units} = "
f"{spec.offset_eur_kwh:+.5f} EUR/kWh, and |offset| above "
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh is not a Belgian "
f"injection tariff in either direction. The published ones are "
f"small and NEGATIVE - Eneco -1,188 c EUR/kWh (-0.01188), "
f"Aspiravi -2 c (-0.02) - so a minus sign here is expected and is "
f"not what this refuses; a MAGNITUDE two orders out is. The usual "
f"cause is the offset typed in a different unit from `units` "
f"({spec.units!r}), or a consumption offset copied onto the "
f"injection leg: they are different numbers on the same card. "
f"⚠️ Network charges and levies do not belong here either - "
f"nothing is added to what you are PAID for export, so an offset "
f"carrying them is simply wrong rather than double-counted.")
return None
def stale_card(spec: FormulaSpec, today=None):
"""A warning when the card is older than ~6 months, or None. NEVER a
refusal: ENGIE freezes a contract's coefficients for its duration, so an
@@ -571,7 +957,7 @@ def stale_card(spec: FormulaSpec, today=None):
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
return None
return (
f"price_formula's card is dated {spec.card_month}, which is "
f"{spec.option}'s card is dated {spec.card_month}, which is "
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
f"has been renewed or rolled over since then, the coefficients here "
f"are last year's and every price this add-on computes is wrong by "
+92 -17
View File
@@ -151,8 +151,8 @@ from textwrap import dedent
from urllib.parse import urlencode
from .custom_formula import (
FormulaInvalid, PREVIEW_HOURS, parse_price_formula, preview_lines,
price_eur_kwh, to_resolution)
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
_LOG = logging.getLogger("goodwe.price")
@@ -935,6 +935,20 @@ PRICE_OPTION_DEFAULTS = {
# `static_profile` precedent exactly, and here for the same CFG-03 reason:
# this dict is the ONE in-code mirror of config.yaml.
"price_formula": "",
# TARIFF-16. The INJECTION leg, in `price_formula`'s OWN grammar and read
# by `custom_formula.parse_export_formula()` - the same parser with its
# option name as an argument, so a second unit table and a second unknown-
# key rule do not exist to diverge. "" is unset and the scalar
# `export_price_eur_kwh` above decides, which is every site today.
#
# ⚠️ IT IS IN FRAMEWORK_KEYS, for the reason the scalar is: a CONSUMPTION
# plugin that could read an injection curve could fold it into a
# consumption price, and nothing downstream could tell.
#
# ⚠️ AND IT LIVES HERE RATHER THAN IN custom_formula.py FOR CFG-03's SAKE,
# exactly as `price_formula` and `price_taxes_profile` do: this dict is the
# ONE in-code mirror of config.yaml.
"price_export_formula": "",
# OPT-04. §10.1 priority 3, and §14's `optimizer.cycle_cost_threshold_eur`.
#
# ⚠️ THEY LIVE HERE FOR CFG-03's SAKE, exactly as `static_profile` does, and
@@ -967,6 +981,13 @@ FRAMEWORK_KEYS = frozenset({
# derived from this set, so `code_leaks()` refuses a plugin that names it
# without anything else being edited.
"price_taxes_profile",
# TARIFF-16. The hour-varying spelling of `export_price_eur_kwh`, and it
# belongs here for the same reason the scalar does: a plugin that could
# read an INJECTION curve could fold it into the CONSUMPTION series it
# emits, and `assemble()` has no way to notice a component that arrived
# inside a RawPoint. `BANNED_IN_PLUGIN_CODE` is derived from this set, so
# `code_leaks()` refuses a plugin that names it with nothing else edited.
EXPORT_FORMULA_OPTION,
})
@@ -1080,28 +1101,82 @@ def export_price_eur_kwh(opts: dict) -> float:
consumption is at 6% - so it is a second per-contract formula, not a second
copy of the first.
⚠️ TARIFF-15 LANDED THE EVALUATOR AND DID NOT LAND THIS CURVE, and the
difference is the point rather than an omission. `custom_formula` can now
express `B x index + A` exactly - it is what it exists for - but it is a
CONSUMPTION provider: it declares {ENERGY, MARKUP}, the framework adds the
consumption taxes, and an injection leg carries its OWN coefficients
(Eneco: 0,102 consumption against 0,100 injection) and its own VAT
treatment (injection is exempt; consumption is at 6%). Pointing this
scalar at that machinery would mean pricing an injection curve with a
consumption card, which is the wrong-card defect the card self-check
exists to refuse. So until the injection card lands this scalar remains a
SIMPLIFICATION an operator enters by hand - an average of what they are
actually paid - and not a reflection of the market. When it does land, an
injection curve gets a `PriceProvider` of its own and this scalar becomes
its FIXED-product default; the seam is already the right shape for that.
⚠️ TARIFF-16 LANDED THE INJECTION LEG, AND THIS FUNCTION IS UNCHANGED. The
curve lives in `price_export_formula` and is read by `export_formula()`
below; this reader still answers the FLAT injection price, still from one
option, still with no formula anywhere in its arithmetic. That is the
arrangement this docstring predicted before either existed - "an injection
curve gets a `PriceProvider` of its own and this scalar becomes its
FIXED-product default" - and it is why a site with a flat injection price
sees nothing change: `price_export_formula` ships "", `export_price_at()`
falls through to this function, and this function's body is byte-identical
to the one TARIFF-02 shipped.
⚠️ It is NOT reachable from a plugin: `export_price_eur_kwh` is in
FRAMEWORK_KEYS, so a consumption plugin cannot quietly fold an injection
price into a consumption price.
price into a consumption price. `price_export_formula` is in the same set
for the same reason.
⚠️ AND NOTHING IN THIS ADD-ON READS EITHER ONE TO TAKE A DECISION.
`arbitrage.py` prices the sell side as DISPLACED IMPORT and says so in its
own comment; both hours it compares are all-in consumption prices. This
reader has no caller under `app/` and neither does `export_price_at()`.
They are what an operator is shown and what a future export-aware strategy
would call - not a number any trade turns on today.
"""
return float(price_option(opts, "export_price_eur_kwh"))
def export_formula(opts: dict):
"""The configured `price_export_formula` as a `FormulaSpec`, or None.
TARIFF-16. Raises `custom_formula.FormulaInvalid` on a malformed leg - and
⚠️ THAT EXCEPTION IS NOT MEANT TO REACH A RUNNING SITE, by the rule
`taxes_profile()` states above and `static_profile` established:
validate.py parses the same option at STARTUP and refuses to start on it,
because a formula that only fails when something reads it is a defect
discovered at the worst possible moment and §11.4 makes every price
defect quiet.
⚠️ THE PARSE IS THE SAME ONE THE CONSUMPTION LEG GETS. There is no second
parser, no second unit table and no second resolution rule -
`parse_export_formula()` is `parse_price_formula()` with its `option`
argument moved, so every refusal an operator can hit on this leg is a
refusal that already had checks over it.
"""
return parse_export_formula(price_option(opts, EXPORT_FORMULA_OPTION))
def export_price_at(opts: dict, index_eur_per_mwh: float) -> float:
"""What the site is PAID for one exported kWh at one index value, EUR/kWh.
THE SEAM, AND ITS HONEST SCOPE. This is the one function a future
export-aware strategy calls, and it has NO CALLER UNDER `app/` today -
stated here rather than implied, because the add-on takes no decision on an
injection price (see `export_price_eur_kwh()` above and `arbitrage.py`'s
accounting-basis comment). What it buys now is that OPT-06 and anything
after it inherit a shape the market fits, instead of a scalar that cannot
hold its own factor, a negative offset, or a VAT rate different from the
consumption leg's.
⚠️ THE FLAT PATH IS THE FALLTHROUGH AND IT IGNORES THE INDEX, which is
CORRECT rather than a simplification: a site with a flat injection price is
paid the same for every kWh whatever the market did, and that is what the
scalar means. With `price_export_formula` empty - every site today - this
returns exactly `export_price_eur_kwh(opts)` for every index.
⚠️ A NEGATIVE ANSWER IS A REAL ANSWER. Eneco's `0,1 x BELPEX-H - 1,188` is
below zero for any BELPEX-H under 11,88 EUR/MWh, which is the middle of a
sunny day and precisely when a PV household exports. Nothing here floors
it: a caller that silently clamped it to zero would hide the hours in which
exporting COSTS money, which is the one thing an injection price is for.
"""
spec = export_formula(opts)
if spec is None:
return export_price_eur_kwh(opts)
return price_eur_kwh(spec, index_eur_per_mwh)
def taxes_profile(opts: dict):
"""The configured `price_taxes_profile` as a period table, or None.
+96 -1
View File
@@ -325,7 +325,8 @@ import logging
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .custom_formula import (
FormulaInvalid, implausible_all_in, parse_price_formula, stale_card)
EXPORT_FORMULA_OPTION, FormulaInvalid, implausible_all_in,
parse_export_formula, parse_price_formula, stale_card)
from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
@@ -1547,6 +1548,7 @@ def _price_problems(opts: dict) -> list[str]:
# CARD AGAINST REALITY. A consistent-but-wrong pair - both numbers off the
# wrong product - reproduces its own printed price and passes. Nothing can
# catch that.
spec = None # TARIFF-16 reads this back, below
raw_formula = opts.get("price_formula", "")
has_formula = bool(str(raw_formula or "").strip()) \
or isinstance(raw_formula, dict)
@@ -1598,6 +1600,99 @@ def _price_problems(opts: dict) -> list[str]:
# cannot start.
_LOG.warning("%s", aged)
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
#
# THE DEFECT THIS EXISTS TO PREVENT: `export_price_eur_kwh` models
# injection as ONE scalar, and the published market is a line -
# Eneco `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. A scalar cannot hold its own factor (0,100 against
# 0,102 on the same supplier's consumption leg), cannot hold a negative
# offset, cannot state that injection is VAT-EXEMPT while consumption is
# at 6%, and cannot go below zero in the midday hours a PV household
# actually exports in.
#
# ⚠️ AND WHAT IT DOES NOT AFFECT TODAY, said here rather than left to be
# inferred: `arbitrage.py` does not read the injection price at all. Both
# hours it compares are all-in CONSUMPTION prices, by its own
# accounting-basis comment. No trade turns on this option. What it changes
# is what the operator is told and what a future export-aware strategy
# rests on - which is why it is validated here rather than trusted.
raw_export = opts.get(EXPORT_FORMULA_OPTION, "")
has_export = bool(str(raw_export or "").strip()) \
or isinstance(raw_export, dict)
if has_export:
try:
xspec = parse_export_formula(raw_export)
except FormulaInvalid as e:
xspec = None
problems.append(f"{EXPORT_FORMULA_OPTION} cannot be used: {e}")
if xspec is not None:
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
# 0. Printing both beside each other is the only place an operator
# can see that the add-on knows it - a status page shows one price
# and no arithmetic.
_LOG.info(
"%s is set: injection priced as (%g x index %+g) x "
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
"%+.5f EUR/kWh offset. ⚠️ NO STRATEGY IN THIS ADD-ON READS "
"IT: arbitrage.py prices the sell side as displaced IMPORT "
"and compares two all-in CONSUMPTION prices, so this option "
"changes no decision the add-on takes today. It is what you "
"are shown, and the shape a future export-aware strategy "
"would read.",
EXPORT_FORMULA_OPTION, xspec.factor, xspec.offset,
xspec.vat_pct, xspec.units, xspec.passthrough,
xspec.offset_eur_kwh)
if spec is not None:
# ⚠️ THE ASYMMETRY, AS AN ORDERED PAIR RATHER THAN TWO NUMBERS
# IN ONE SENTENCE (MUTATION.md habit 14). "6 and 0 appear
# somewhere" is satisfied by the two legs transposed, which is
# the sentence telling an operator that the add-on has their
# VAT treatment exactly backwards.
_LOG.info(
"the two legs are priced independently: consumption "
"factor %g at vat_pct %g, injection factor %g at vat_pct "
"%g. In Belgium those differ - \"injectietarieven zijn "
"vrijgesteld van btw\" while consumption is at 6%% - and "
"%s is the option that makes the difference "
"expressible; a single scalar could not state it.",
spec.factor, spec.vat_pct, xspec.factor, xspec.vat_pct,
EXPORT_FORMULA_OPTION)
if not xspec.card_graded:
# ⚠️ A WARNING AND NEVER A REFUSAL, AND THE GAP IS NAMED
# RATHER THAN WORKED AROUND. `price_formula`'s card block is
# required 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, so on
# that card there is nothing to reproduce. Requiring a block
# here would make the market's own published line
# inexpressible; inventing the estimate would grade the
# coefficients against arithmetic done with the same
# coefficients. So the leg is accepted, the two coefficient
# bounds in `implausible_injection()` are applied, and the
# operator is told which of the two checks did NOT run.
_LOG.warning(
"%s has no \"card\" block, so its coefficients are "
"UNGRADED: nothing recomputed a printed price from them. "
"That is accepted because it is the real market - Eneco "
"prints its injection formula with no price beside it, so "
"there is nothing to grade against on that card - and "
"refusing would make the published line inexpressible. "
"What IS checked is the shape of the coefficients: the "
"pass-through (%.4g x the wholesale price) and the offset "
"(%+.5f EUR/kWh). If YOUR card prints an injection "
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
"..., \"expect_incl_vat\": ...} and the add-on will "
"refuse to start unless your numbers reproduce it.",
EXPORT_FORMULA_OPTION, xspec.passthrough,
xspec.offset_eur_kwh)
else:
xaged = stale_card(xspec)
if xaged:
_LOG.warning("%s", xaged)
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
# site with no price data falls back to the static profile; a dynamic site
# with none configured has nothing to fall back to when its feed dies. That
+34 -2
View File
@@ -316,10 +316,33 @@ options:
price_taxes_eur_kwh: 0.11736
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
# which by itself strongly favours self-consumption over export. A scalar, not
# a second series: no shipped supplier publishes a dynamic injection curve.
# which by itself strongly favours self-consumption over export. This is the
# FLAT injection price, for a site paid the same for every exported kWh.
#
# ⚠️ THE REASON THIS COMMENT USED TO GIVE - "no shipped supplier publishes a
# dynamic injection curve" - WAS FALSE, and TARIFF-07 struck the same
# sentence from app/price.py and DOCS.md without reaching this file. Eneco
# publishes `0,1 x BELPEX-H - 1,188` c EUR/kWh on the same card as its
# consumption formula, ENGIE publishes `(B x eSpot_15) - A`, and Aspiravi
# `0,07 x Belpex - 2`. A site on one of those sets price_export_formula
# below; this scalar stays the fixed-product default and is what every site
# uses today.
export_price_eur_kwh: 0.04
# TARIFF-16. The INJECTION leg as a formula, in price_formula's own grammar
# and with its OWN coefficients: Eneco uses 0,102 on consumption and 0,100 on
# injection, and injection is VAT-EXEMPT where consumption is at 6%, so
# `vat_pct` here is normally 0. A negative `offset` is expected - it is what
# the published lines carry - and the resulting price legitimately goes below
# zero in the midday hours a PV household exports in.
#
# ⚠️ NO STRATEGY IN THIS ADD-ON READS IT. arbitrage.py prices the sell side
# as displaced IMPORT and compares two all-in CONSUMPTION prices; it does not
# read the injection price at all. This option is what the operator is shown
# and the shape a future export-aware strategy would rest on. Empty by
# default, which leaves every existing site on the scalar above.
price_export_formula: ""
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
# of the installed base is on [a static profile]" - this is what makes those
# sites first-class rather than "price_provider: off, good luck".
@@ -901,6 +924,15 @@ schema:
# bottoms out at 0 would make that site inexpressible.
export_price_eur_kwh: float(-1,1)
# TARIFF-16. A JSON string in `price_formula`'s grammar, parsed by
# `custom_formula.parse_export_formula()` - the SAME parser with its option
# name as an argument, so a second unit table and a second card rule do not
# exist to diverge. The `card` block is OPTIONAL on this leg and required on
# the other: Eneco prints its injection formula with no price beside it, so
# on that card there is nothing to grade against, and the add-on says at
# startup that the leg is ungraded rather than inventing an estimate.
price_export_formula: str?
# A JSON string rather than a nested schema, deliberately: it is ONE option
# key (§14 has one), it survives config.yaml's flat options block, and
# parse_static_profile() accepts an already-parsed dict too, so a future
+797 -6
View File
@@ -51,6 +51,7 @@ Read MUTATION.md before you report a survivor.
import ast
import asyncio
import hashlib
import glob
import inspect
import io
import json
@@ -74,14 +75,18 @@ from app.price import (
arbitrage_allowed, assemble, brussels, brussels_date_range,
brussels_offset, check_egress, dated_feed_url,
price_option,
export_formula, export_price_at,
export_price_eur_kwh, load_prices, plan_is_provisional, register,
taxes_at, taxes_profile,
volatility_regime, uncertainty_of, widen_factor, Uncertainty,
)
from app.custom_formula import (
CARD_TOLERANCE, FormulaInvalid, INDEXES, PLAUSIBLE_HI_EUR_KWH,
CARD_TOLERANCE, EUR_KWH_PER_EUR_MWH, EXPORT_FORMULA_OPTION, FormulaInvalid,
INDEXES, INJECTION_OFFSET_MAX_EUR_KWH, PASSTHROUGH_HI, PASSTHROUGH_LO,
PLAUSIBLE_HI_EUR_KWH,
PLAUSIBLE_LO_EUR_KWH, RESOLUTIONS, UNITS, card_discrepancy,
implausible_all_in, in_card_units, parse_price_formula, preview_lines,
implausible_all_in, implausible_injection, in_card_units,
parse_export_formula, parse_price_formula, preview_lines,
price_eur_kwh, stale_card, to_resolution)
from app import custom_formula as _cf
from app import price as _price
@@ -3696,12 +3701,27 @@ check("...and the scanner CAN go red: planted `eval` and `literal_eval` calls "
" return ast.literal_eval(s)\n") == ["literal_eval"])
# and the structural half: the coefficients are NUMBERS, so there is nothing
# for an evaluator to be reached from even if one appeared.
# ⚠️ TARIFF-16 ADDED A FIFTH STRING FIELD (`option`) AND THE COUNT ALONE WOULD
# HAVE ABSORBED IT SILENTLY, so the field NAMES are asserted beside the count
# and the new one is pinned to a CLOSED SET rather than to "it is a str". A
# string field that took free text would be the grammar this module refuses to
# have, wearing a different key.
_T15_FIELDS = derived(lambda: {
k: type(v).__name__ for k, v in
vars(parse_price_formula(json.dumps(ENECO_CARD))).items()})
print(f" FormulaSpec fields: {_T15_FIELDS}")
check("the spec carries only fixed scalars - no field holds a string that is "
"later interpreted",
derived(lambda: sorted(
type(v).__name__ for v in vars(parse_price_formula(
json.dumps(ENECO_CARD))).values())) ==
["float"] * 5 + ["str"] * 4)
derived(lambda: sorted(_T15_FIELDS.values())) ==
["float"] * 5 + ["str"] * 5)
check(f"...and the five strings are named, not counted: four enumerations and "
f"a card month, plus TARIFF-16's `option`, which is a KEY OF `_LEGS` "
f"and not free text {sorted(_cf._LEGS)}",
derived(lambda: sorted(k for k, t in _T15_FIELDS.items()
if t == "str"))
== ["card_month", "index", "option", "resolution", "units"]
and derived(lambda: _T15_FIELDS and parse_price_formula(
json.dumps(ENECO_CARD)).option) in _cf._LEGS)
check("...and a field that LOOKS like an expression is refused as not a "
"number rather than parsed as one",
"not a number" in refusal(spoilt(factor="0.102 * spot")))
@@ -5058,6 +5078,777 @@ check("...and the scalar option is still the one that answers when the table "
isinstance(_T17_E2E, tuple) and _T17_E2E[0][1] == 1)
# --------------------------------------------------------------------------- #
print()
print("17. TARIFF-16 - the INJECTION leg, which a scalar cannot hold")
#
# WHAT THIS SECTION IS DEFENDING. `export_price_eur_kwh()` models what a site
# is PAID for export as ONE number. 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 (non-dynamic) 0,07 x Belpex - 2
#
# ⚠️ AND WHAT IT IS **NOT** DEFENDING, FIRST, BECAUSE OVERSTATING IT WOULD BE
# THE DEFECT TARIFF-07 WITHDREW THREE PLUGINS FOR. `arbitrage.py` does not read
# `export_price_eur_kwh` at all - its own comment says so, with the reason -
# so no trade this add-on takes is decided by this number and nothing here is a
# wrong-trade fix. 17h GRADES that claim rather than repeating it.
#
# ⚠️ EVERY COEFFICIENT BELOW IS SOMEBODY ELSE'S PUBLISHED NUMBER, transcribed
# from the cards and from nowhere in app/: section 2b's rule applied to
# arithmetic, which is 15a's move as well.
# Eneco Zon & Wind Dynamisch, the INJECTION line. ⚠️ NO `card` BLOCK, AND THAT
# IS THE CARD'S OWN SHAPE RATHER THAN AN OMISSION HERE: Eneco prints this
# formula with NO example price beside it. Inventing one would grade these
# coefficients against arithmetic done with these coefficients, which is
# MUTATION.md habit 4 in a fixture.
ENECO_INJECTION = {"index": "epex_be", "resolution": "hourly",
"factor": 0.100, "offset": -1.188,
"units": "c_eur_per_kwh", "vat_pct": 0}
# Aspiravi, non-dynamic: `0,07 x Belpex - 2`. A SECOND supplier, so that the
# pass-through bound below is graded against a range somebody published rather
# than against one number.
ASPIRAVI_INJECTION = {"index": "epex_be", "resolution": "hourly",
"factor": 0.07, "offset": -2.0,
"units": "c_eur_per_kwh", "vat_pct": 0}
print()
print("17a. the four properties a scalar cannot hold - the reproduction, as "
"checks")
# ⚠️ PRECONDITION FIRST (habit 1). Every claim below is a DIFFERENCE between
# the consumption leg and the injection leg, and a difference asserted against
# two fixtures that happen to be equal is decoration. The consumption side is
# ENECO_CARD, section 15's own fixture, held BY IDENTITY - not a second copy of
# Eneco's numbers typed here.
check(f"the two legs of the SAME supplier's card really are two: consumption "
f"factor {ENECO_CARD['factor']} against injection "
f"{ENECO_INJECTION['factor']}, vat_pct {ENECO_CARD['vat_pct']} against "
f"{ENECO_INJECTION['vat_pct']}, offset {ENECO_CARD['offset']} against "
f"{ENECO_INJECTION['offset']}",
ENECO_CARD["factor"] != ENECO_INJECTION["factor"]
and ENECO_CARD["vat_pct"] != ENECO_INJECTION["vat_pct"]
and ENECO_CARD["offset"] != ENECO_INJECTION["offset"])
check("...and the injection factor is NOT the consumption one with a sign "
"flip, which is the shape somebody would reach for first",
ENECO_INJECTION["factor"] != -ENECO_CARD["factor"]
and ASPIRAVI_INJECTION["factor"] != -ENECO_CARD["factor"])
_T16_SPEC = derived(lambda: parse_export_formula(json.dumps(ENECO_INJECTION)))
_T16_ASP = derived(lambda: parse_export_formula(
json.dumps(ASPIRAVI_INJECTION)))
check(f"PROPERTY 1 - its OWN factor: the injection leg parses to "
f"factor {derived(lambda: _T16_SPEC.factor)} while section 15's "
f"consumption card parses to "
f"{derived(lambda: parse_price_formula(json.dumps(ENECO_CARD)).factor)}"
f", from two INDEPENDENT options",
derived(lambda: _T16_SPEC.factor) == 0.100
and derived(lambda: parse_price_formula(
json.dumps(ENECO_CARD)).factor) == 0.102)
check(f"PROPERTY 2 - a NEGATIVE offset, which the markup option cannot hold "
f"at all: config.yaml declares `price_markup_eur_kwh: float(0,1)`, and "
f"the injection leg carries "
f"{derived(lambda: _T16_SPEC.offset)} {ENECO_INJECTION['units']} = "
f"{derived(lambda: _T16_SPEC.offset_eur_kwh):+.5f} EUR/kWh",
derived(lambda: _T16_SPEC.offset) == -1.188
and derived(lambda: _T16_SPEC.offset_eur_kwh) < 0.0)
check(f"PROPERTY 3 - vat_pct is PER LEG, so the 6% asymmetry is expressible: "
f"{derived(lambda: parse_price_formula(json.dumps(ENECO_CARD)).vat_pct)}"
f" on consumption against {derived(lambda: _T16_SPEC.vat_pct)} on "
f"injection - \"injectietarieven zijn vrijgesteld van btw\"",
derived(lambda: parse_price_formula(json.dumps(ENECO_CARD)).vat_pct)
== 6.0 and derived(lambda: _T16_SPEC.vat_pct) == 0.0)
# ⚠️ AND THE ASYMMETRY IS GRADED AS AN ARITHMETIC DIFFERENCE, NOT AS TWO FIELD
# VALUES. Two specs can carry 6 and 0 and still be multiplied by the same
# number if `in_card_units()` ever read VAT from anywhere but the spec.
_T16_VAT_PAIR = derived(lambda: (
in_card_units(parse_price_formula(json.dumps(ENECO_CARD)), 100.0)
/ (0.102 * 100.0 + 1.00),
in_card_units(_T16_SPEC, 100.0) / (0.100 * 100.0 - 1.188)))
print(f" VAT multiplier actually applied: consumption "
f"{derived(lambda: _T16_VAT_PAIR[0]):.4f}, injection "
f"{derived(lambda: _T16_VAT_PAIR[1]):.4f}")
check("...and the multiplier each leg actually APPLIES differs by exactly "
"6%: 1.06 on consumption, 1.00 on injection, measured through the "
"shipped arithmetic rather than read off the field",
derived(lambda: abs(_T16_VAT_PAIR[0] - 1.06) < 1e-9)
and derived(lambda: abs(_T16_VAT_PAIR[1] - 1.00) < 1e-9))
# PROPERTY 4 - it goes negative, and a PV household exports in exactly those
# hours. The break-even index is the card's own arithmetic: 1,188 / 0,1.
_T16_BREAKEVEN = 1.188 / 0.100
_T16_CURVE = derived(lambda: [
(idx, price_eur_kwh(_T16_SPEC, idx))
for idx in (-18.2, 0.0, _T16_BREAKEVEN, 61.96, 133.9905)])
for _i, _p in (derived(lambda: _T16_CURVE) or []):
print(f" BELPEX-H {_i:+9.4f} EUR/MWh -> injection {_p:+.6f} EUR/kWh")
check(f"PROPERTY 4 - the published line is NEGATIVE below "
f"{_T16_BREAKEVEN:.2f} EUR/MWh of BELPEX-H, zero AT it and positive "
f"above, through the shipped arithmetic - so 'you can be charged to "
f"export' is this module's answer and not a remark",
derived(lambda: [round(p, 8) for _i, p in _T16_CURVE])
== [round(p, 8) for p in (-0.03008, -0.01188, 0.0, 0.05008,
0.1221105)])
check("...and the SCALAR cannot hold any of it: one option, one number, the "
"same in every hour whatever the index did",
len({export_price_eur_kwh(opts())}) == 1
and export_price_eur_kwh(opts()) ==
PRICE_OPTION_DEFAULTS["export_price_eur_kwh"])
print()
print("17b. ONE parser, not two - the identity, pinned on the AST and on the "
"refusals")
# ⚠️ THE CLAIM IS AN IDENTITY ("it is THIS parser"), so MUTATION.md habit 10
# says pin it structurally AND grade its meaning behaviourally. Both are here:
# 17b1 the grammar family is DEFINED IN EXACTLY ONE MODULE, with the scope
# COMPUTED by parsing every file under app/ (habit 12 - a hand-written scope is
# the same defect as a hand-written fixture); 17b2 the two options give the
# SAME refusal sentences, each DERIVED from the other's rather than typed.
_T16_FAMILY = (
"FormulaSpec", "FormulaInvalid", "parse_price_formula",
"parse_export_formula", "in_card_units", "price_eur_kwh",
"card_discrepancy", "card_refusal", "_card", "_finite", "_month_date",
"to_resolution", "UNITS", "RESOLUTIONS", "INDEXES", "CARD_TOLERANCE",
"_LEGS", "_Leg", "implausible_injection", "implausible_all_in",
"EUR_KWH_PER_EUR_MWH", "PASSTHROUGH_LO", "PASSTHROUGH_HI",
"INJECTION_OFFSET_MAX_EUR_KWH", "_CARD_KEYS", "_TOP_KEYS",
"EXPORT_FORMULA_OPTION", "stale_card", "preview_lines", "CARD_STALE_DAYS",
"PLAUSIBLE_LO_EUR_KWH", "PLAUSIBLE_HI_EUR_KWH")
def _t16_defs(path):
"""Top-level names a module DEFINES - classes, functions, assignments.
⚠️ IT HANDLES `AnnAssign` AND test_static_tariff.py's 10a DOES NOT, which
matters here and not there: `UNITS: dict = {...}`, `RESOLUTIONS: tuple` and
`INDEXES: dict` are ANNOTATED assignments, so a scanner that only walks
`ast.Assign` would report them as defined in ZERO modules and the
"exactly one" line below would go red on the shipped tree - the failure
mode that gets a check deleted rather than read.
"""
tree = ast.parse(open(path, encoding="utf-8").read())
out = set()
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.ClassDef)):
out.add(node.name)
elif isinstance(node, ast.Assign):
out |= {t.id for t in node.targets if isinstance(t, ast.Name)}
elif isinstance(node, ast.AnnAssign) and isinstance(node.target,
ast.Name):
out.add(node.target.id)
return out
_T16_APP = sorted(glob.glob(os.path.join(HERE, "app", "*.py")))
_T16_WHERE = {name: sorted(os.path.basename(f) for f in _T16_APP
if name in _t16_defs(f))
for name in _T16_FAMILY}
_T16_HOME = ["custom_formula.py"]
_T16_STRAY = {n: w for n, w in _T16_WHERE.items() if w != _T16_HOME}
print(f" {len(_T16_FAMILY)} formula-grammar names over {len(_T16_APP)} "
f"modules under app/; not in custom_formula.py alone: {_T16_STRAY}")
check(f"every one of the {len(_T16_FAMILY)} formula-grammar names is defined "
f"in EXACTLY ONE of the {len(_T16_APP)} modules under app/ - a second "
f"copy of `_finite` or of the unit table takes this red wherever it is "
f"put, including in a module this check has never heard of",
all(len(w) == 1 for w in _T16_WHERE.values()))
check("...and that module is custom_formula.py in every case, so the grammar "
"did not move house either",
_T16_STRAY == {})
check(f"...and the pin is not vacuous: every name really is a top-level "
f"definition and the scan read more than a handful of modules "
f"({len(_T16_APP)})",
all(w for w in _T16_WHERE.values()) and len(_T16_APP) > 5)
_T16_BODY = ast.parse(open(os.path.join(HERE, "app", "custom_formula.py"),
encoding="utf-8").read())
_T16_WRAP = next((n for n in _T16_BODY.body
if isinstance(n, ast.FunctionDef)
and n.name == "parse_export_formula"), None)
_T16_CALLS = ([ast.unparse(n.func) for n in ast.walk(_T16_WRAP)
if isinstance(n, ast.Call)] if _T16_WRAP else [])
check(f"...and `parse_export_formula` is a WRAPPER and not a fork: its whole "
f"body calls {_T16_CALLS}, pinned on the AST rather than on the source "
f"text, because `\"parse_price_formula\" in src` is true of a copy that "
f"merely mentions it",
_T16_CALLS == ["parse_price_formula"])
print()
print("17b2. and the two options give the SAME refusals - derived from each "
"other, not typed twice")
# ⚠️ EVERY EXPECTED SENTENCE HERE IS THE OTHER OPTION'S OWN SENTENCE with the
# key substituted, which is TARIFF-17's 10b move. Nothing is transcribed, so a
# fork that reworded one refusal - or quietly stopped refusing something - is
# red on the row it broke and not on all of them.
def _t16_pair(blob):
"""The two legs' refusals on one stimulus."""
return (_reason(lambda: parse_price_formula(json.dumps(blob))),
_reason(lambda: parse_export_formula(json.dumps(blob))))
# Built FROM section 15's real card, so each row differs from an ACCEPTED
# baseline in exactly one field (habit 7). ⚠️ The baseline itself is checked
# first, on BOTH legs, or every refusal below has nowhere else to come from.
_T16_OK_BOTH = _t16_pair(ENECO_CARD)
check(f"the CONTROL: section 15's real Eneco card is accepted by BOTH options "
f"{_T16_OK_BOTH}. ⚠️ It is used here as a PARSER STIMULUS and is not a "
f"claim that a consumption card is an injection contract - what it "
f"establishes is that the rows below are refusals of the FIELD each "
f"names",
_T16_OK_BOTH == ("", ""))
_T16_SHARED = [
("a factor that is not a number", spoilt(factor="0.102 * spot")),
("a NaN factor", spoilt(factor=float("nan"))),
("a boolean factor", spoilt(factor=True)),
("an unknown index", spoilt(index="belpex_h")),
("an unknown resolution", spoilt(resolution="half_hourly")),
("missing units", spoilt(units="")),
("an unknown unit", spoilt(units="cent_per_kwh")),
("a vat_pct of 106", spoilt(vat_pct=106)),
("an unknown top-level field", dict(ENECO_CARD, expr="0.1*x")),
("an unknown card field", dict(ENECO_CARD,
card=dict(ENECO_CARD["card"], vat=6))),
("a card month that is not YYYY-MM", spoilt(card_month="September")),
("a card that is not an object", dict(ENECO_CARD, card=3)),
]
_T16_ROWS = []
for _what, _blob in _T16_SHARED:
_a, _b = _t16_pair(_blob)
_T16_ROWS.append((_what, _a, _b,
_a.replace("price_formula", EXPORT_FORMULA_OPTION)
== _b))
for _what, _a, _b, _same in _T16_ROWS:
print(f" {'same' if _same else 'DIFF'} {_what}: {_a[:56]!r}")
check(f"all {len(_T16_ROWS)} SHARED refusals are the consumption leg's OWN "
f"sentence with the key substituted - derived, not transcribed, so a "
f"second parser diverging on any one of them is red on that row alone",
all(_same for _w, _a, _b, _same in _T16_ROWS))
check("...and none of those rows is vacuous: every one really did refuse on "
"both legs, or 'the two empty strings match' would pass the line above",
all(_a and _b for _w, _a, _b, _s in _T16_ROWS))
# ⚠️ AND THE ROWS THAT DELIBERATELY DIFFER, NAMED WITH THEIR SHAPES RATHER THAN
# ROUNDED OFF (habit 13). Three sentences are per-LEG on purpose - `_LEGS` is
# exactly the set of things that are not shared - and a check that asserted
# "every refusal is identical" would have to be weakened the first time one of
# them was right to differ. So they are asserted to DIFFER, and to differ in
# the direction that points the operator at the right option.
_T16_DIFFER = [
("factor 0 -> where a FLAT price of THIS leg lives",
spoilt(factor=0.0), "static_profile", "export_price_eur_kwh"),
("a negative factor -> which market it inverts",
spoilt(factor=-0.102), "No consumption tariff",
"No Belgian injection tariff"),
("a card miss -> what a wrong `offset` costs on THIS leg",
spoilt(factor=0.120), "bills you for it twice",
"nothing is added to this leg"),
("invalid JSON -> WHEN this option is read at all, which is not the "
"same answer on the two legs: `price_formula` decides nothing unless "
"price_provider is 'custom_formula', and the injection leg is read "
"whatever the consumption provider is",
"{not json", "unless price_provider is 'custom_formula'",
"read whatever price_provider is set to"),
]
for _what, _blob, _cons_mark, _inj_mark in _T16_DIFFER:
_a, _b = ((_reason(lambda b=_blob: parse_price_formula(b)),
_reason(lambda b=_blob: parse_export_formula(b)))
if isinstance(_blob, str) else _t16_pair(_blob))
check(f"{_what}: the consumption refusal says {_cons_mark!r} and the "
f"injection one says {_inj_mark!r} - both refuse, and each names "
f"the option the operator should actually reach for",
_cons_mark in _a and _cons_mark not in _b
and _inj_mark.lower() in _b.lower()
and _inj_mark.lower() not in _a.lower())
print()
print("17c. a NEGATIVE injection price is ACCEPTED - and TARIFF-15's band is "
"MEASURED refusing it, rather than asserted to")
# ⚠️ THE ACCEPTANCE CRITERION, AND THE COUNTERFACTUAL THAT MAKES IT A CLAIM.
# `implausible_all_in()` floors the CONSUMPTION leg at +0.02 EUR/kWh of energy,
# which is right there and fatal here: Eneco's injection price is below zero
# for any BELPEX-H under 11,88 EUR/MWh, and a PV household exports in exactly
# those hours. So the same spec is put through BOTH bands and the two answers
# are printed side by side. Refusing a negative injection price would hide the
# hours in which exporting COSTS money.
_T16_NEG_CARD = derived(lambda: _cf.FormulaSpec(
index="epex_be", resolution="hourly", factor=0.100, offset=-1.188,
units="c_eur_per_kwh", vat_pct=0, card_month="2026-09",
card_index_eur_per_mwh=-18.2, card_expect_incl_vat=-3.008,
option=EXPORT_FORMULA_OPTION))
_T16_NEG_PRICE = derived(lambda: price_eur_kwh(_T16_NEG_CARD, -18.2))
_T16_OLD_BAND = derived(lambda: implausible_all_in(_T16_NEG_CARD, 0.11736))
_T16_NEW_BAND = derived(lambda: implausible_injection(_T16_NEG_CARD))
print(f" the same spec, at BELPEX-H -18.2 EUR/MWh -> "
f"{derived(lambda: _T16_NEG_PRICE):+.5f} EUR/kWh")
print(f" implausible_all_in() -> "
f"{'REFUSES' if _T16_OLD_BAND else 'accepts'}")
print(f" implausible_injection() -> "
f"{'REFUSES' if _T16_NEW_BAND else 'accepts'}")
check(f"the stimulus really is negative ({derived(lambda: _T16_NEG_PRICE)}), "
f"or the two lines below would be grading a positive number and would "
f"pass whatever either band did",
derived(lambda: _T16_NEG_PRICE) < 0.0)
check(f"TARIFF-15's consumption band REFUSES it, for its floor's reason - "
f"measured, so 'the scalar band would have ruled the market out' is an "
f"observation ({str(_T16_OLD_BAND)[:70]!r})",
isinstance(_T16_OLD_BAND, str)
and "outside the plausible band" in _T16_OLD_BAND
and f"{PLAUSIBLE_LO_EUR_KWH}" in _T16_OLD_BAND)
check("...and the INJECTION band accepts it, which is the acceptance "
"criterion: a negative injection price is the real market and is not "
"refused",
_T16_NEW_BAND is None)
check("...and so does the shipped PARSER, end to end, on the real published "
"line with no card block at all - `0,1 x BELPEX-H - 1,188` starts",
derived(lambda: _T16_SPEC.factor) == 0.100
and _reason(lambda: parse_export_formula(
json.dumps(ENECO_INJECTION))) == "")
check("...and Aspiravi's `0,07 x Belpex - 2` starts too, so the band admits a "
"RANGE of published contracts rather than one tuned pair",
_reason(lambda: parse_export_formula(
json.dumps(ASPIRAVI_INJECTION))) == "")
print()
print("17d. ...and an IMPLAUSIBLE one is still refused, by two rungs that are "
"graded on their REASONS and mutated TOGETHER")
# ⚠️ TEST-16's RULE. Two rungs can answer one stimulus - a whole card read 100x
# too large trips both - so each is given a stimulus the OTHER cannot reach,
# and each stimulus differs from the ACCEPTED baseline in EXACTLY ONE FIELD
# (habits 1 and 7). The precondition is its own check: the quantity the other
# rung grades is asserted to be INSIDE its band, so a row cannot pass because
# the wrong rung caught it.
def _t16_inj(**over):
"""Eneco's published injection line with one field replaced."""
b = dict(ENECO_INJECTION)
b.update(over)
return b
def _t16_refusal(blob):
return _reason(lambda: parse_export_formula(json.dumps(blob)))
check("the CONTROL: the published line is accepted, so every refusal below "
"has nowhere else to come from",
_t16_refusal(ENECO_INJECTION) == "")
_T16_ONLY_PT = _t16_inj(factor=ENECO_INJECTION["factor"] * 100.0)
_T16_ONLY_OFF = _t16_inj(offset=ENECO_INJECTION["offset"] * 100.0)
_T16_BOTH = _t16_inj(factor=ENECO_INJECTION["factor"] * 100.0,
offset=ENECO_INJECTION["offset"] * 100.0)
check("PRECONDITION: each stimulus differs from the accepted baseline in "
"EXACTLY ONE field - the pass-through one moves `factor` and nothing "
"else, the offset one moves `offset` and nothing else",
{k for k in _T16_ONLY_PT if _T16_ONLY_PT[k] != ENECO_INJECTION[k]}
== {"factor"}
and {k for k in _T16_ONLY_OFF if _T16_ONLY_OFF[k] != ENECO_INJECTION[k]}
== {"offset"})
_T16_PT_OFFSET = ENECO_INJECTION["offset"] * UNITS[ENECO_INJECTION["units"]]
_T16_OFF_PT = (ENECO_INJECTION["factor"] * UNITS[ENECO_INJECTION["units"]]
/ EUR_KWH_PER_EUR_MWH)
print(f" pass-through stimulus: offset stays {_T16_PT_OFFSET:+.5f} EUR/kWh "
f"(bound {INJECTION_OFFSET_MAX_EUR_KWH}) - the offset rung CANNOT fire")
print(f" offset stimulus : pass-through stays {_T16_OFF_PT:.4g}x "
f"(band {PASSTHROUGH_LO}-{PASSTHROUGH_HI}) - the pass-through rung "
f"CANNOT fire")
check("PRECONDITION: on each stimulus the OTHER rung's quantity is INSIDE its "
"band, so neither row can pass because the wrong rung answered - which "
"is the masking TEST-16 exists for",
abs(_T16_PT_OFFSET) <= INJECTION_OFFSET_MAX_EUR_KWH
and PASSTHROUGH_LO <= _T16_OFF_PT <= PASSTHROUGH_HI)
_T16_PT_WHY = _t16_refusal(_T16_ONLY_PT)
_T16_OFF_WHY = _t16_refusal(_T16_ONLY_OFF)
_T16_BOTH_WHY = _t16_refusal(_T16_BOTH)
check(f"a factor 100x out is refused BY THE PASS-THROUGH RUNG, naming the "
f"share of the wholesale price it would pay ({_T16_PT_WHY[:60]!r})",
"passes through" in _T16_PT_WHY
and f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x" in _T16_PT_WHY
and "|offset| above" not in _T16_PT_WHY)
check(f"an offset 100x out is refused BY THE OFFSET RUNG, naming the EUR/kWh "
f"it works out at ({_T16_OFF_WHY[:60]!r})",
"|offset| above" in _T16_OFF_WHY
and "passes through" not in _T16_OFF_WHY)
check(f"...and the WHOLE card 100x out - which trips both - is refused, with "
f"the pass-through rung answering first ({_T16_BOTH_WHY[:44]!r})",
"passes through" in _T16_BOTH_WHY)
check("...and neither refusal mentions the SIGN or the LEVEL of a price, "
"which is why they can bound a leg that legitimately goes below zero",
all("below zero" not in w.split("⚠️")[0]
for w in (_T16_PT_WHY, _T16_OFF_WHY)))
# ⚠️ THE BOUNDS ARE GRADED FROM BOTH SIDES, or "within the band" and "within
# anything" would be the same check. The published contracts sit at 1.00x and
# 0.70x; the band is 0.10-10.
_T16_EDGES = derived(lambda: [
(f"{_pt}x", implausible_injection(_cf.FormulaSpec(
index="epex_be", resolution="hourly",
factor=_pt * EUR_KWH_PER_EUR_MWH / UNITS["c_eur_per_kwh"],
offset=-1.188, units="c_eur_per_kwh", vat_pct=0,
option=EXPORT_FORMULA_OPTION)) is None)
for _pt in (0.05, PASSTHROUGH_LO, 0.70, 1.00, PASSTHROUGH_HI, 20.0)])
print(f" pass-through accepted? {_T16_EDGES}")
check(f"the pass-through band is the number being enforced, not a shape: "
f"{PASSTHROUGH_LO}x and {PASSTHROUGH_HI}x pass and 0.05x / 20x do not, "
f"with both published contracts (0.70x, 1.00x) inside",
derived(lambda: [ok for _n, ok in _T16_EDGES])
== [False, True, True, True, True, False])
print()
print("17e. the card self-check applies WHERE THERE IS A CARD - and where "
"there is not, that is STATED")
# ⚠️ THE HONEST HALF OF THIS CARD. TARIFF-15 could demonstrate two tariffs end
# to end rather than three and refused to fabricate a third; the same fact
# lands here as an OPTIONAL `card` block. Eneco prints its injection formula
# with NO price beside it, so on that card there is nothing to reproduce -
# requiring a block would make the published line inexpressible, and inventing
# the estimate would grade the coefficients against arithmetic done with the
# same coefficients.
check("the consumption leg still REFUSES a formula with no card block, "
"unchanged, and says why the card is required rather than that a field "
"is missing",
"no \"card\" block" in _reason(lambda: parse_price_formula(json.dumps(
{k: v for k, v in ENECO_CARD.items() if k != "card"}))))
check("...and the INJECTION leg accepts one, because that is the shape of the "
"card it is read off",
_t16_refusal(ENECO_INJECTION) == ""
and derived(lambda: _T16_SPEC.card_graded) is False)
check("...and says so in a field callers can ask, rather than reporting a "
"discrepancy of zero - which is what 'the coefficients reproduce the "
"card' looks like, for the one state in which nothing was compared",
raises(lambda: card_discrepancy(_T16_SPEC), FormulaInvalid)
and "no card block" in _reason(lambda: card_discrepancy(_T16_SPEC)))
# ...and where a card IS supplied on this leg, the SAME rung grades it with the
# SAME tolerance. Graded against section 15's real printed 15.55 - the stimulus
# is the consumption card put through the injection option, which is what makes
# the expectation somebody else's published number rather than this file's.
_T16_WITH_CARD = _reason(lambda: parse_export_formula(json.dumps(ENECO_CARD)))
_T16_SPOILT_CARD = _reason(lambda: parse_export_formula(json.dumps(
spoilt(card_expect_incl_vat=15.05))))
check(f"a card block ON THIS LEG is graded by the same rung at the same "
f"tolerance: the real card passes and a transposed digit (15.05 for "
f"15.55) is refused, naming the option the operator typed "
f"({_T16_SPOILT_CARD[:64]!r})",
_T16_WITH_CARD == ""
and "does not reproduce its own card" in _T16_SPOILT_CARD
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION)
and f"the tolerance is {CARD_TOLERANCE}" in _T16_SPOILT_CARD)
check(f"...and half a tolerance out still passes, so {CARD_TOLERANCE} is "
f"enforced on this leg too rather than 'something'",
_reason(lambda: parse_export_formula(json.dumps(spoilt(
card_expect_incl_vat=round(in_card_units(parse_price_formula(
json.dumps(ENECO_CARD)), 133.9905) + CARD_TOLERANCE * 0.5,
6))))) == "")
# ⚠️ WHAT THE UNGRADED STATE COSTS, SAID WITH ITS SIZE (habit 13). Without a
# printed estimate the two coefficient bounds are the WHOLE of what is
# mechanised, and they are two orders of magnitude coarser than the card check.
_T16_SNEAK = derived(lambda: parse_export_formula(json.dumps(
_t16_inj(factor=0.070))))
check(f"and the gap is real and is not glossed: with no card, a factor of "
f"0.070 instead of 0.100 - Aspiravi's number on Eneco's contract, 30% "
f"of every export payment - passes both bounds, because 0.70x is a "
f"published pass-through. Nothing here can catch a wrong-contract pair; "
f"only a printed estimate can, and this card has none",
derived(lambda: _T16_SNEAK.factor) == 0.070)
print()
print("17f. the SCALAR path is unchanged - arithmetically, not merely in "
"intent")
# ⚠️ THE ACCEPTANCE CRITERION TARIFF-17 GRADED THE SAME WAY (16f). A site with
# a flat injection price is every site that exists today, and "we did not mean
# to change it" is not a measurement.
_T16_OLD_SCALAR = [ast.unparse(n) for n in ast.walk(next(
n for n in ast.parse(open(os.path.join(HERE, "app", "price.py"),
encoding="utf-8").read()).body
if isinstance(n, ast.FunctionDef) and n.name == "export_price_eur_kwh"))
if isinstance(n, ast.Return)]
check(f"`export_price_eur_kwh()`'s BODY is still the single expression it has "
f"always been, pinned on the AST rather than on the source text: "
f"{_T16_OLD_SCALAR}",
_T16_OLD_SCALAR ==
["return float(price_option(opts, 'export_price_eur_kwh'))"])
_T16_SITES = [opts(), opts(export_price_eur_kwh=0.0),
opts(export_price_eur_kwh=-0.02),
opts(export_price_eur_kwh=0.045),
opts(export_price_eur_kwh=""),
{}, {"export_price_eur_kwh": 0.07}]
_T16_IDX = (-18.2, 0.0, 61.96, 133.9905, 400.0)
_T16_SAME = derived(lambda: [
(export_price_eur_kwh(o),
float(price_option(o, "export_price_eur_kwh")),
{export_price_at(o, i) for i in _T16_IDX})
for o in _T16_SITES])
check(f"over {len(_T16_SITES)} option dicts, the reader equals the "
f"pre-TARIFF-16 expression to the last digit - `==`, not `near()`",
derived(lambda: all(a == b for a, b, _c in _T16_SAME)))
check(f"...and with the formula option unset - which is every install today - "
f"`export_price_at()` returns that SAME number at all "
f"{len(_T16_IDX)} index values, including a negative one: a flat "
f"injection price does not move with the market, which is what flat "
f"means",
derived(lambda: all(c == {a} for a, _b, c in _T16_SAME)))
check("...and the dicts are not all the same answer, or the two lines above "
"would be one comparison repeated",
derived(lambda: len({a for a, _b, _c in _T16_SAME})) > 2)
# ...and the formula, when set, is what answers - or everything above would be
# true of a tree that parses the option and never reads it (TARIFF-17's M4).
_T16_LIVE = derived(lambda: [
export_price_at(opts(**{EXPORT_FORMULA_OPTION:
json.dumps(ENECO_INJECTION)}), i)
for i in _T16_IDX])
print(f" with the formula set: {[round(v, 6) for v in _T16_LIVE]}")
check(f"with `{EXPORT_FORMULA_OPTION}` SET, the seam answers from the formula "
f"- five distinct prices where the scalar has one, and the negative "
f"index gives a negative price rather than a floored zero",
derived(lambda: len(set(_T16_LIVE))) == len(_T16_IDX)
and derived(lambda: _T16_LIVE[0]) < 0.0
and derived(lambda: round(_T16_LIVE[0], 9)) == -0.03008)
print()
print("17g. the new key is FRAMEWORK-OWNED, and a plugin that names it is "
"still refused")
check(f"`{EXPORT_FORMULA_OPTION}` is in FRAMEWORK_KEYS, beside the scalar it "
f"varies",
EXPORT_FORMULA_OPTION in _price.FRAMEWORK_KEYS
and "export_price_eur_kwh" in _price.FRAMEWORK_KEYS)
check("...and BANNED_IN_PLUGIN_CODE is DERIVED from that set, so it arrived "
"there without a second list being edited",
EXPORT_FORMULA_OPTION in BANNED_IN_PLUGIN_CODE
and set(_price.FRAMEWORK_KEYS) <= set(BANNED_IN_PLUGIN_CODE))
check("...so PluginOptions refuses to hand it over, naming the framework",
raises(lambda: PluginOptions(opts()).get(EXPORT_FORMULA_OPTION),
FrameworkOption)
and "framework" in _reason(
lambda: PluginOptions(opts()).get(EXPORT_FORMULA_OPTION)))
class _T16Rogue(PriceProvider):
"""A plugin that reaches for the INJECTION curve.
The shape of the defect: a CONSUMPTION plugin that could read what the site
is paid for export could fold it into the series it emits, and `assemble()`
has no way to see a component that arrived inside a RawPoint.
"""
id = "t16_rogue"
@classmethod
def capabilities(cls):
return ProviderCapabilities(product=PriceProduct.DAY_AHEAD,
sources=frozenset({Component.ENERGY}),
egress_hosts=("example.invalid",))
async def fetch(self, opts, since, until, transport):
leak = opts.get("price_export_formula") # the leak
return [RawPoint(since, 0.1 + float(bool(leak)), False)]
_T16_LEAK = derived(lambda: code_leaks(_T16Rogue))
check(f"code_leaks() finds it in a plugin's own code: {_T16_LEAK}",
_T16_LEAK == [EXPORT_FORMULA_OPTION])
check("...and register() refuses to install it, so this is an inability and "
"not a convention",
raises(lambda: register(_T16Rogue), FrameworkOption)
and EXPORT_FORMULA_OPTION in _reason(lambda: register(_T16Rogue)))
check("...and it is the LEAK rung that refused, not the duplicate-id or "
"retired-id one two lines above it in register() - those raise "
"ValueError, and `raises()` grades the exact class",
not raises(lambda: register(_T16Rogue), ValueError))
check("...and the docstring above, which says 'injection' and 'export' in "
"prose, is NOT what made it fire - docstrings are stripped before the "
"scan",
EXPORT_FORMULA_OPTION not in
"".join(_T16Rogue.__doc__ or "")
+ (_T16Rogue.capabilities.__doc__ or ""))
check("...and the plugin id really was not installed by the attempt",
"t16_rogue" not in PROVIDERS)
print()
print("17h. NOTHING IN THIS ADD-ON TRADES ON IT - graded, not attested")
# ⚠️ THIS IS THE CHECK BEHIND A SENTENCE, AND THE SENTENCE IS THE ONE MOST
# LIKELY TO ROT. DOCS.md and this module both say no strategy reads an
# injection price; the day somebody wires one in, that becomes false in prose
# nobody re-reads. So the SCOPE IS COMPUTED (habit 12): every module under app/
# is parsed and asked which of the three injection readers it calls.
_T16_READERS = ("export_price_eur_kwh", "export_price_at", "export_formula")
def _t16_calls(path):
"""The names called anywhere in a module, by simple or attribute name."""
out = set()
for node in ast.walk(ast.parse(open(path, encoding="utf-8").read())):
if isinstance(node, ast.Call):
f = node.func
out.add(f.id if isinstance(f, ast.Name)
else f.attr if isinstance(f, ast.Attribute)
else ast.unparse(f))
return out
_T16_CALLERS = {os.path.basename(f): sorted(set(_T16_READERS)
& _t16_calls(f))
for f in _T16_APP}
_T16_CALLERS = {m: c for m, c in _T16_CALLERS.items() if c}
print(f" modules under app/ that call an injection reader: {_T16_CALLERS}")
check(f"`arbitrage.py` calls NONE of {list(_T16_READERS)} - it prices the "
f"sell side as displaced IMPORT and compares two all-in CONSUMPTION "
f"prices, "
f"which is its own accounting-basis comment and is what makes this card "
f"a missing capability rather than a wrong trade",
"arbitrage.py" not in _T16_CALLERS)
check("...and no other module under app/ calls one either: the only caller is "
"price.py, where `export_price_at()` calls the other two. Wire one into "
"a strategy and this row goes red, which is the point - the DOCS "
"sentence is graded rather than attested",
set(_T16_CALLERS) == {"price.py"})
check("...and the scan is not vacuous: it found the readers where they DO "
"live, so 'nothing calls them' is a result and not an empty search",
derived(lambda: _T16_CALLERS["price.py"]) == sorted(_T16_READERS[:2])
or derived(lambda: set(_T16_CALLERS["price.py"])) <= set(_T16_READERS)
and len(derived(lambda: _T16_CALLERS["price.py"])) >= 2)
# --------------------------------------------------------------------------- #
# TARIFF-16's MUTATION LEDGER. Every row was RUN, not reasoned about, and
# the method is MUTATION.md's.
#
# ⚠️ NO ROW HERE IS AN EDIT-AND-REVERT (habit 18). The harness
# (`/home/homelab/.cache/t16-mut/mut.sh`) rsyncs a FRESH COPY of the tree per
# mutation into a `mktemp` directory, excluding `__pycache__`, mutates the
# copy, mounts it `:ro` into the checks image and deletes it. There is no
# revert to fail and no shared backup path; a `:ro` tree with no cache in it
# cannot read a stale `.pyc`, which is TEST-15 closed the way CI closes it.
# Each row's byte delta and the clean/mutant tree fingerprints were printed by
# the harness before the suites ran; the clean fingerprint is
# `71160af73582cde8` on every row.
#
# ⚠️ AND THAT DESIGN WAS EXERCISED RATHER THAN ADMIRED. Two copies of this
# sweep were running at one point - a scheduling mistake, exactly habit 18's
# second bullet - and because each holds its own `mktemp` tree neither could
# restore over the other's work. The orphan was found by `docker inspect`
# showing two different `run.XXXXXX` mounts and killed; no result below comes
# from a tree two mutations deep.
#
# ⚠️ SCOPE, STATED (habit 13). Rows marked FULL were run over all 29 files.
# The rest were run over the five files that can reach this card -
# test_price.py, test_validate.py, test_price_forecast.py,
# test_static_tariff.py, test_defaults_pin.py - so those rows say which suites
# went red and do NOT claim anything about the other 24.
#
# # what was changed delta red on
# --- ------------------------------------------------- ----- ---------------
# M1 the PASS-THROUGH rung of +10 B 17d (3),
# implausible_injection() made unreachable. test_validate
# (1)
# M2 the OFFSET rung made unreachable. +10 B 17d (1),
# test_validate
# (1)
# M3 ⚠️ TEST-16's rule: BOTH rungs that can answer +20 B 17d (4),
# the hundredfold stimulus, TOGETHER. Neither test_validate
# masks the other - each still has a stimulus the (2)
# other cannot reach, so the pair moves six rows.
# M4 parse_price_formula() stops applying the +10 B 17d (3),
# injection band at all. test_validate
# (2)
# M5 ⚠️ THE HEADLINE, AND THE ACCEPTANCE CRITERION. +174 B 17c, on the
# implausible_injection() given TARIFF-15's ONE check that
# floor - a plausibility band that refuses a names it.
# legitimately NEGATIVE injection price. FULL 29-FILE
# Eneco's real published line stops starting. SWEEP: red on
# test_price.py,
# GREEN in the
# other 28.
# M6 `card_required=True` on the injection leg, so -1 B 17a (1),
# Eneco's line - printed with no price beside it - test_validate
# becomes inexpressible. ⚠️ ONE BYTE. (7)
# M7 card_discrepancy() returns 0.0 on an UNGRADED +33 B 17e (1)
# leg instead of refusing to answer - i.e. reports
# "the coefficients reproduce the card" for the
# one state in which nothing was compared.
# M8 the card self-check skipped where a card IS +10 B 17e (1) AND
# present, on EITHER leg. TARIFF-15's
# own 15b - 11
# checks in
# test_price,
# which is what
# "one rung, two
# legs" means
# M9 `price_export_formula` removed from -27 B 17g (7 checks)
# FRAMEWORK_KEYS.
# M10 a SECOND formula grammar: `UNITS` and +117 B 17b (2). FULL
# `_finite` copied into price.py. 29-FILE SWEEP:
# red on
# test_price.py,
# GREEN in the
# other 28.
# M11 parse_export_formula() drops `option=`, so every -30 B 17a (1), 17b2,
# refusal on the injection leg names test_validate
# `price_formula`. (9)
# M12 export_price_at() parses the formula and never +8 B 17f (1). FULL
# uses it - the option is configurable and decides 29-FILE SWEEP:
# nothing (TARIFF-17's M4, one option over). red on
# test_price.py,
# GREEN in the
# other 28.
# M13 a negative injection price silently FLOORED at +10 B 17f (1)
# zero by export_price_at().
# M14 validate.py never reads the option. +7 B test_validate
# (7 checks)
# M15 the UNGRADED warning suppressed: the leg starts +10 B test_validate
# and nothing says which check did not run. (1)
# M16 ⚠️ ZERO BYTES, AND NO DECISION CHANGES. The two +0 B test_validate,
# legs TRANSPOSED in the VAT-asymmetry banner. the ordered-
# Nothing the add-on DOES moves; the only surface pair check
# that moves is the sentence, which now tells the ONLY (hab. 14)
# operator their VAT treatment exactly backwards.
# M17 the injection leg's `flat` clause pointed at -6 B 17b2 DIFF row
# `static_profile`, so a site is told to fix the for factor 0
# consumption option when its injection formula
# is wrong.
# M18 the scalar reader given an IN-CODE LITERAL +4 B 17f's AST pin,
# fallback (`opts.get("export_price_eur_kwh", section 11,
# 0.04)`) instead of routing through AND
# PRICE_OPTION_DEFAULTS - CFG-03's fourth place. test_defaults_
# pin (1)
# M19 the injection leg's JSON-error hint pointed at +75 B 17b2 DIFF row
# the CONSUMPTION leg's rule ("leave it empty for invalid
# unless price_provider is 'custom_formula'"), JSON
# which is false of the leg you are PAID on.
# M20 ⚠️ RECORDED FOR ITS SHAPE, NOT AS A WIN. -23 B test_price.py
# export_price_eur_kwh() rerouted through dies, and
# export_price_at(). Because the seam falls BACK names NOTHING
# to the scalar the two recurse, so test_price.py
# dies with a RecursionError and ZERO named
# checks. That is habit 5 in the small: a failure
# with no name attached reads like broken
# infrastructure. M18 is the same property
# mutated in a way a check can NAME.
#
# ⚠️ WHAT NO ROW ABOVE COULD SEE, with its shape (habit 13):
# * 17b's pin grades WHERE the grammar is defined, never WHAT it means -
# rewriting `implausible_injection` in place to accept anything passes it
# (17c and 17d grade the meaning), and it covers `app/` only: a second
# parser under `tools/` or in a check file is outside it;
# * 17h grades that no module under `app/` CALLS an injection reader. A
# strategy that reached the option through `price_option(opts, ...)`
# directly, or through a name this scan resolves differently, is outside
# it - the scan is keyed by called NAME and therefore over-approximates,
# which makes "nothing calls them" the strong answer and a hit the weak
# one;
# * nothing here grades the DOCS.md injection section, which is prose. Every
# number it quotes is one 17a, 17c, 17d or 17e prints on every run, so a
# reader can check them, but no assertion holds them;
# * and the deepest gap is the one 17e's last check MEASURES rather than
# mentions: with no printed estimate there is no check anywhere that can
# tell a right pair of injection coefficients from a wrong one. 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 publishes.
# --------------------------------------------------------------------------- #
# TARIFF-17's MUTATION LEDGER. Every row was RUN, not reasoned about, and the
# method is MUTATION.md's. ⚠️ NO ROW HERE IS AN EDIT-AND-REVERT: the harness
+15 -7
View File
@@ -1117,24 +1117,32 @@ _T15_KEYS = ["price_formula"]
# config.yaml and nowhere else, which is `static_profile`'s arrangement for
# `static_profile`'s reason.
_T17_KEYS = ["price_taxes_profile"]
# TARIFF-16. `price_export_formula` - the INJECTION leg as `factor x index +
# offset` with its OWN coefficients and its OWN vat_pct, because a Belgian card
# prices injection on a second formula (Eneco 0,100 against 0,102 on
# consumption, VAT-exempt against 6%). Like TARIFF-17's key it is an ADDITION:
# `export_price_eur_kwh` is untouched, every site on the scalar is unaffected,
# and it lives in this dict and in config.yaml and nowhere else.
_T16_KEYS = ["price_export_formula"]
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
f"TARIFF-15's and TARIFF-17's keys - FCST-03 still added none of its own "
f"{_defaults}",
f"TARIFF-15's, TARIFF-17's and TARIFF-16's keys - FCST-03 still added "
f"none of its own {_defaults}",
_defaults == sorted(["price_provider", "price_markup_eur_kwh",
"price_taxes_eur_kwh", "export_price_eur_kwh",
"price_csv_path", "price_feed_url",
"price_horizon_h", "price_timeout_s",
"static_profile"]
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS))
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS
+ _T16_KEYS))
# ...and the later keys are genuinely NEW, not a rename of something that was
# already there, or the line above would pass while the mirror silently lost a
# key.
check("...and OPT-04's four, TARIFF-15's one and TARIFF-17's one are additions "
"rather than replacements: the nine TARIFF-02/03 keys are all still "
"present",
check("...and OPT-04's four, TARIFF-15's one, TARIFF-17's one and TARIFF-16's "
"one are additions rather than replacements: the nine TARIFF-02/03 keys "
"are all still present",
isinstance(_defaults, list)
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
- set(_T17_KEYS)) == 9)
- set(_T17_KEYS) - set(_T16_KEYS)) == 9)
# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME.
# The line above counts nine, and a rename of `price_taxes_eur_kwh` to
# `price_taxes_profile` would also count nine. The scalar path being unchanged
+169
View File
@@ -1553,6 +1553,175 @@ check("...and BOTH cards divided by 100 start, which is habit 7: the refusals "
.replace("7.10", "0.0710")), -5000, 5000) == [])
# --------------------------------------------------------------------------- #
print()
print("TARIFF-16 - the INJECTION leg is refused HERE, at startup, and the leg "
"the card cannot grade is SAID to be ungraded")
# ⚠️ WHY IT IS REFUSED IN THIS MODULE. `export_price_at()` is the seam a future
# export-aware strategy reads, and a formula that first fails when something
# reads it is a defect discovered at the worst possible moment - which is
# `static_profile`'s and `price_taxes_profile`'s argument, one option over.
#
# ⚠️ AND WHAT THIS OPTION DOES **NOT** DO, said before what it does: no
# strategy in this add-on reads an injection price. `arbitrage.py` prices the
# sell side as displaced IMPORT and compares two all-in CONSUMPTION prices.
# test_price.py 17h grades that by parsing every module under app/; what is
# graded HERE is that the operator is TOLD it, at startup, in the same breath
# as the formula being accepted.
import logging as _t16_logging # noqa: E402
class _T16Log(_t16_logging.Handler):
"""Every record app.validate emits during one call, rendered."""
def __init__(self):
super().__init__()
self.lines = []
def emit(self, record):
self.lines.append((record.levelname, record.getMessage()))
def _t16_start(**over):
"""`validate_startup_config()` -> (problems, rendered log lines).
⚠️ THE LEVEL IS FORCED TO INFO FOR THE DURATION AND RESTORED. Without it
the capture returns an EMPTY list on a tree that logs correctly, which
reads exactly like "the line is not there" - a check whose data cannot
reach the failure it names, in the instrument rather than in the check.
"""
log = _t16_logging.getLogger("goodwe.config")
h = _T16Log()
was, log.level = log.level, _t16_logging.INFO
log.addHandler(h)
try:
return validate_startup_config(base(**over), -5000, 5000), h.lines
finally:
log.removeHandler(h)
log.level = was
# The two published injection lines, transcribed from the cards. ⚠️ NEITHER
# CARRIES A `card` BLOCK, because neither card prints a price beside its
# injection formula - that is the state this section exists to grade.
_T16_ENECO = ('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}')
_T16_CONS = ('{"index":"epex_be","resolution":"hourly","factor":0.102,'
'"offset":1.0,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}')
# ⚠️ THE ACCEPTED BASELINE COMES FIRST (habit 7). Every refusal below varies
# ONE field from this line, so a refusal has nowhere else to have come from.
_T16_OK, _T16_OK_LOG = _t16_start(price_export_formula=_T16_ENECO)
check("Eneco's real published injection line `0,1 x BELPEX-H - 1,188` STARTS - "
"including the part that makes its price NEGATIVE below 11,88 EUR/MWh, "
"which is the market and not a misconfiguration",
_T16_OK == [])
check("...and so does the shipped \"\", which is every install that exists: "
"the option is an addition and the scalar still answers without it",
_t16_start(price_export_formula="")[0] == []
and _t16_start()[0] == [])
_T16_BAD_PT = _T16_ENECO.replace('"factor":0.1,', '"factor":10.0,')
_T16_BAD_OFF = _T16_ENECO.replace('"offset":-1.188', '"offset":-118.8')
_T16_P_PT = _t16_start(price_export_formula=_T16_BAD_PT)[0]
_T16_P_OFF = _t16_start(price_export_formula=_T16_BAD_OFF)[0]
print(f" {(_T16_P_PT or [''])[0][:118]}")
print(f" {(_T16_P_OFF or [''])[0][:118]}")
check("PRECONDITION: each of those two blobs differs from the accepted "
"baseline in exactly one coefficient, so the refusals below are about "
"the field each names",
_T16_BAD_PT != _T16_ENECO and _T16_BAD_OFF != _T16_ENECO
and _T16_BAD_PT.replace('"factor":10.0,', '"factor":0.1,') == _T16_ENECO
and _T16_BAD_OFF.replace('"offset":-118.8', '"offset":-1.188')
== _T16_ENECO)
check("a factor 100x out is REFUSED at startup, by the pass-through rung, "
"naming the option the operator typed",
why(_T16_P_PT, "price_export_formula cannot be used:")
and why(_T16_P_PT, "passes through")
and not why(_T16_P_PT, "|offset| above"))
check("...and an offset 100x out is refused by the OFFSET rung, so the two "
"rungs are graded on their reasons and cannot mask each other",
why(_T16_P_OFF, "price_export_formula cannot be used:")
and why(_T16_P_OFF, "|offset| above")
and not why(_T16_P_OFF, "passes through"))
check("...and a malformed blob is refused as a malformed blob, naming the "
"field rather than 'invalid configuration'",
why(_t16_start(price_export_formula='{"index":"belpex_h"}')[0],
"price_export_formula.index is 'belpex_h'"))
# ⚠️ THE HONEST HALF, AND IT IS A WARNING RATHER THAN A REFUSAL. Eneco prints
# its injection formula with NO price beside it, so on that card there is
# nothing for the self-check to reproduce. Requiring a `card` block would make
# the published line inexpressible; inventing the estimate would grade the
# coefficients against arithmetic done with the same coefficients. So the leg
# starts, and the operator is told WHICH check did not run.
_T16_UNGRADED = [m for lvl, m in _T16_OK_LOG
if lvl == "WARNING" and "UNGRADED" in m]
print(f" {(_T16_UNGRADED or [''])[0][:118]}")
check("a leg with no card block starts, and says at startup that its "
"coefficients are UNGRADED - naming what was checked instead, so the "
"gap is stated rather than worked around",
len(_T16_UNGRADED) == 1
and "no \"card\" block" in _T16_UNGRADED[0]
and "pass-through" in _T16_UNGRADED[0])
_T16_WITH_CARD, _T16_CARD_LOG = _t16_start(
price_export_formula=_T16_CONS, price_formula=_T16_CONS,
price_provider="custom_formula")
# ...and a SECOND run with the two legs genuinely different, for the ordered
# pair below. ⚠️ THE FIRST ONE PUTS THE CONSUMPTION CARD ON BOTH LEGS, so
# 0.102/6 appears twice and an assertion about arrangement would pass on a
# TRANSPOSED rendering - MUTATION.md habit 4, in the fixture of a habit-14
# check. The precondition is asserted below rather than trusted.
_T16_BOTH_LEGS, _T16_BOTH_LOG = _t16_start(
price_export_formula=_T16_ENECO, price_formula=_T16_CONS,
price_provider="custom_formula")
check("...and a leg that DOES carry a card does NOT print it, or the sentence "
"would be noise an operator learns to ignore rather than a report of "
"which check ran",
_T16_WITH_CARD == []
and not [m for lvl, m in _T16_CARD_LOG if "UNGRADED" in m])
# ⚠️ AND A CARD ON THIS LEG IS STILL GRADED, at startup, by the same rung.
check("...and a card ON THIS LEG that the coefficients do not reproduce is "
"REFUSED at startup, so the optional block is optional and not inert",
why(_t16_start(price_export_formula=_T16_CONS.replace(
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
"price_export_formula does not reproduce its own card"))
# ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT.
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
and "NO STRATEGY IN THIS ADD-ON READS IT" in m]
check("the startup line says, in so many words, that NO STRATEGY IN THIS "
"ADD-ON READS IT - the operator is told what the option does not do in "
"the same breath as it being accepted",
len(_T16_INFO) == 1 and "displaced IMPORT" in _T16_INFO[0])
# ⚠️ THE ASYMMETRY IS ASSERTED AS AN ORDERED PAIR, NOT AS TWO NUMBERS IN ONE
# SENTENCE (MUTATION.md habit 14). "6 and 0 appear somewhere" is satisfied by
# the two legs TRANSPOSED - which is the sentence telling an operator that the
# add-on has their VAT treatment exactly backwards, on the one line where they
# could have seen it.
_T16_PAIR = [m for _l, m in _T16_BOTH_LOG if "priced independently" in m]
print(f" {(_T16_PAIR or [''])[0][:118]}")
check("PRECONDITION: the two legs of that run carry DIFFERENT factors and "
"DIFFERENT VAT rates (0.102 at 6 against 0.1 at 0), or the ordered-pair "
"assertion below would be satisfied by the transposed rendering it "
"exists to refuse",
_T16_BOTH_LEGS == [] and '"factor":0.102' in _T16_CONS
and '"vat_pct":6' in _T16_CONS and '"factor":0.1,' in _T16_ENECO
and '"vat_pct":0' in _T16_ENECO)
check("and the 6% asymmetry is reported as an ORDERED PAIR: the transposed "
"rendering would read `consumption factor 0.1 at vat_pct 0, injection "
"factor 0.102 at vat_pct 6`, which is a DIFFERENT string - so an "
"argument swap at the call site cannot hide behind `0.102 is in there "
"somewhere`",
len(_T16_PAIR) == 1
and "consumption factor 0.102 at vat_pct 6, injection factor 0.1 at "
"vat_pct 0" in _T16_PAIR[0]
and "consumption factor 0.1 at vat_pct 0, injection factor 0.102 at "
"vat_pct 6" not in _T16_PAIR[0])
# --------------------------------------------------------------------------- #
print()
if fails:
+3
View File
@@ -162,6 +162,9 @@ configuration:
price_formula:
name: "Tariff · Your own tariff formula (one line of JSON)"
description: "For price source custom_formula only: your own contract's two coefficients, written as a single line of JSON. Your supplier's tariff card prints the formula as B x index + A - those two numbers exist only on your card, there is no public index of them anywhere, and you are the only person who can read them. Type what the card prints, IN THE UNITS THE CARD PRINTS: every unit conversion and the VAT step are the add-on's job, because those are the two places a human silently loses a factor of 10, 100 or 1.06. Example: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ THE card BLOCK IS REQUIRED AND IT IS WHAT MAKES THIS SAFE: your card also prints the reference index it assumed and the price that comes out, so the add-on recomputes that price from your numbers and REFUSES TO START if it does not come out, telling you by how much. That catches a wrong unit, VAT left out or counted twice, a transposed digit, and the expensive one - network charges folded into the offset, which the add-on would then bill you for a second time. ⚠️ AND ITS LIMIT, IN THE SAME BREATH: it checks your coefficients against your card, not your card against reality, so two numbers copied off the wrong product agree with each other and pass. Fixed numbers only - no expression of any kind is evaluated, and an unrecognised field is refused rather than ignored."
price_export_formula:
name: "Tariff · Your own INJECTION formula (one line of JSON)"
description: "Optional, and only if your supplier pays you a price that moves with the market. Your tariff card has a SECOND formula for what you are paid for the electricity you export, and it is not the first one with a minus in front: Eneco prints `0,1 x BELPEX-H - 1,188` c/kWh for injection against `0,102 x ...` for consumption, and Aspiravi `0,07 x Belpex - 2`. Written in exactly the same shape as your own tariff formula above. ⚠️ INJECTION IS EXEMPT FROM VAT in Belgium while consumption is at 6%, so vat_pct here is normally 0 - that is the one field people copy across from the other formula and get wrong. ⚠️ THE PRICE THIS GIVES CAN BE NEGATIVE, and that is not an error: Eneco pays below zero whenever BELPEX-H is under 11,88 EUR/MWh, which is the middle of a sunny day - you can be charged to export. The add-on accepts that rather than hiding it. The card block is OPTIONAL here and required above, because Eneco prints the injection formula with NO example price beside it and there would be nothing to check your numbers against; if your card does print one, fill it in and the add-on will refuse to start unless your numbers reproduce it. ⚠️ WHAT THIS DOES NOT DO: no part of the add-on trades on an injection price today - it charges cheap to avoid buying dear, and both sides of that comparison are consumption prices. This is what you are shown, and what a future export strategy would read. Leave it empty and the flat injection price above is used, which is what every installation does now."
price_csv_path:
name: "Tariff · Price CSV file"
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
+3
View File
@@ -142,6 +142,9 @@ configuration:
price_formula:
name: "Tarief · Uw eigen tariefformule (één regel JSON)"
description: "Alleen voor prijsbron custom_formula: de twee coëfficiënten van uw eigen contract, als één regel JSON. De tariefkaart van uw leverancier drukt de formule af als B x index + A - die twee getallen staan alleen op uw kaart, er bestaat nergens een publieke lijst van, en u bent de enige die ze kan lezen. Typ wat de kaart afdrukt, IN DE EENHEID DIE DE KAART GEBRUIKT: elke eenheidsomrekening en de btw-stap zijn het werk van de add-on, want dat zijn precies de twee plaatsen waar een mens stilzwijgend een factor 10, 100 of 1,06 verliest. Voorbeeld: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ HET BLOK card IS VERPLICHT EN MAAKT DIT VEILIG: uw kaart vermeldt ook de referentie-index die ze veronderstelt en de prijs die eruit volgt, dus de add-on berekent die prijs opnieuw uit uw getallen en START NIET als het resultaat niet klopt - met vermelding van het verschil. Dat vangt een verkeerde eenheid, btw die ontbreekt of dubbel geteld is, een omgewisseld cijfer, en de dure fout: nettarieven die in de offset verwerkt zitten en die de add-on er dan nog een tweede keer bovenop zou rekenen. ⚠️ EN DE GRENS DAARVAN, IN DEZELFDE ZIN: ze toetst uw coëfficiënten aan uw kaart, niet uw kaart aan de werkelijkheid - twee getallen van het verkeerde product kloppen onderling en komen er dus door. Alleen vaste getallen: er wordt geen enkele expressie geëvalueerd, en een onbekend veld wordt geweigerd in plaats van genegeerd."
price_export_formula:
name: "Tarief · Uw eigen INJECTIEformule (één regel JSON)"
description: "Optioneel, en alleen als uw leverancier u een prijs betaalt die met de markt meebeweegt. Op uw tariefkaart staat een TWEEDE formule voor wat u krijgt voor de stroom die u injecteert, en dat is niet de eerste met een minteken ervoor: Eneco drukt `0,1 x BELPEX-H - 1,188` c/kWh af voor injectie tegenover `0,102 x ...` voor afname, en Aspiravi `0,07 x Belpex - 2`. Geschreven in precies dezelfde vorm als uw eigen tariefformule hierboven. ⚠️ INJECTIE IS IN BELGIË VRIJGESTELD VAN BTW terwijl afname op 6% staat, dus vat_pct is hier normaal 0 - dat is het ene veld dat men van de andere formule overneemt en verkeerd invult. ⚠️ DE PRIJS DIE HIERUIT VOLGT KAN NEGATIEF ZIJN, en dat is geen fout: Eneco betaalt onder nul zodra BELPEX-H lager is dan 11,88 EUR/MWh, en dat is het midden van een zonnige dag - u kunt moeten betalen om te injecteren. De add-on aanvaardt dat in plaats van het te verbergen. Het blok card is hier OPTIONEEL en hierboven verplicht, omdat Eneco de injectieformule afdrukt ZONDER voorbeeldprijs ernaast en er dan niets is om uw getallen aan te toetsen; drukt uw kaart er wél een af, vul ze dan in en de add-on start niet als uw getallen die prijs niet reproduceren. ⚠️ WAT DIT NIET DOET: geen enkel onderdeel van de add-on handelt vandaag op een injectieprijs - hij laadt goedkoop op om duur te vermijden, en beide zijden van die vergelijking zijn afnameprijzen. Dit is wat u te zien krijgt, en wat een toekomstige exportstrategie zou lezen. Laat het leeg en de vaste injectieprijs hierboven wordt gebruikt, zoals elke installatie nu doet."
price_csv_path:
name: "Tarief · CSV-bestand met prijzen"
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."