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

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

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

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

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

WHAT IT IS. A display and guard fix:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-13 16:24:56 +02:00
co-authored by Claude Opus 5
parent 7796f168a9
commit 9c65ac2cf8
15 changed files with 671 additions and 58 deletions
+106
View File
@@ -25,6 +25,7 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| _this branch_ | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving |
| _this branch_ | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface |
| _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest |
| _this branch_ | TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real Flemish figure **⚠️ CHANGED DEFAULT** |
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
@@ -131,6 +132,111 @@ figure is correct. This is a product concern rather than a site one: the add-on
is sold to clients, and a Walloon or Brussels client could not express their
own tariff.
### TARIFF-18 - `price_taxes_eur_kwh` stops shipping at half the real figure
⚠️ **A SHIPPED DEFAULT CHANGED: `price_taxes_eur_kwh` 0.06 -> 0.11736 EUR/kWh.**
Same shape as 0.8.0's `slew_w` rule, and the same instruction follows — **read
the live options before `ha addons update`**, this time to see whether
`price_taxes_profile` is set, which is the one shape the move could have
refused. The "who is affected" section below is the part that matters, and it
is shorter on consequences than a changed default usually is.
The old `0.06` had **no recorded provenance anywhere**: not a card, not a date,
not a component list. It is roughly **half** the Flemish figure. The new default
is the Eneco residential card for Flanders, September 2026, incl. 6 % VAT, and
its rows are now **in the code** (`app/price.py`'s
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, re-added by `test_price.py` section 1b):
| component | c€/kWh |
|---|---|
| Fluvius distribution (**Antwerpen**) | 5,35 |
| Bijzondere accijns, *"Alle verbruik"* — no volume bands | 4,876 |
| Bijdrage groene stroom en WKK Vlaanderen | 1,51 |
| Energiebijdrage — **0 EUR/MWh since 2026-08-01**, merged into the accijns | 0 |
| **total** | **11,736 c€/kWh = 0.11736** |
⚠️ **IT IS PER-DSO, AND THE DEFAULT IS NOT AN AVERAGE.** Only the distribution
row moves: across the eight Fluvius DSOs it runs 5,28 c€/kWh
(Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about **0.117 to
0.131**. The default is a real DSO's real row, near the **low** end, and DOCS
tells an operator to look up their own. A **Walloon or Brussels** site should
not use this scalar at all — its distribution tariff has a day and a night band
and belongs in TARIFF-17's `price_taxes_profile`.
⚠️ **THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE AND CANNOT.**
This scalar is a **LEVEL** component: an additive constant cancels exactly in
`max − min` and leaves the argmin where it was. TARIFF-17 shipped the machinery
and the proof — six flat scalars spanning and exceeding the whole per-DSO range
give the **identical** cheapest hour and the **identical** spread to the last
digit (`test_price.py` 16b, printed on every run), and none of that changes
here. No trade happens that did not happen before *because of the level*. What
does change is two things, and nothing else:
* **every number an operator reads** — the all-in prices on the status page,
`custom_formula`'s startup preview and the recorded price history. At half
the true non-energy component they made the add-on look considerably better
than it is; and
* **§17's negative-price guard** (`arbitrage.py`), the one rung that tests an
**absolute** all-in price rather than a difference. It fires when
`spot < −(markup + taxes)`, so its boundary moves **−0.09000 -> −0.14736
EUR/kWh of spot**. A −0.11868 EUR/kWh spot hour used to read **−0.02868**
all-in and the discharge was refused as paid-for disposal; at the real tax
level it reads **+0.02868** and discharges, because that hour genuinely
paid.
`test_arbitrage.py` section 2c drives both sides through the shipped
`decide()`, and pins the boundary from below as well.
⚠️ **AND THAT ASYMMETRY IS WHY THE DEFAULT SITS AT THE BOTTOM OF THE BAND.** Set
too **high**, the figure carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal — §17's own case. Set too **low**, it only
declines a discharge that would have paid. One default standing in for eight
DSOs belongs at the bottom.
#### Who is affected
* **A site that set `price_taxes_eur_kwh` itself: nothing changes.** Its value
is kept and is never silently replaced.
* **A site whose stored options blob already carries the key: nothing changes
either, and this is most existing installs.** The Supervisor sets
`App.options = deepmerge(config.yaml defaults, stored options)`
(`tools/supervisor_selfapi_probe.py` §D), so a moved default reaches only
keys the blob does **not** carry — and the reference install's export carries
**70 of the 71**, the one absentee being a key added after its last save.
Such a site goes on running at `0.06` and reading numbers that are about half
its real all-in price until somebody opens the Configuration tab. ⚠️ **That
is a real limit of this card and it is not fixed here**: the change reaches a
**fresh install**, and an existing one only when its options are re-saved.
⚠️ **NOT re-measured on this branch** — the 70-of-71 figure is 0.9.0's, and
PKG-09's `tools/predeploy_check.py --options <live blob>` against the live
options is what would confirm it before deploying.
* ⚠️ **A site with `price_taxes_profile` SET would have been refused at startup,
and is deliberately not.** TARIFF-17 made a taxes period table beside a
**non-default** `price_taxes_eur_kwh` a `SystemExit(78)` refusal, comparing
against the shipped default precisely because the Supervisor writes the key
whether it was typed or not. Moving the default turns every stored `0.06`
into "non-default" **without anybody editing anything**, so a working Walloon
or Brussels site would have stopped starting on the upgrade over an inert
number the table overrides anyway. `price.py` now carries
`SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,)` and `validate.py` treats a
superseded default as *untouched* as well. **The rung is not weakened**: a
figure that is neither the shipped nor a superseded default — ORES's 0.1198,
say — is still refused, by name, and the refusal now says which of the two
figures it will accept (`test_validate.py`, four new checks plus the
difference precondition).
Nothing hardcoded `0.06` in that rung — it read `PRICE_OPTION_DEFAULTS` — which
is why the interaction is a *behaviour* change rather than a drift, and why it
had to be decided rather than noticed.
`config.yaml` and `PRICE_OPTION_DEFAULTS` moved together and CFG-03's tripwire
(`test_price.py` section 1, `test_defaults_pin.py`) stayed green by agreeing,
not by being widened. ⚠️ One check did have to be **rewritten rather than
re-pointed**: `test_price.py` section 0 used to justify its own grading
constants by observing that the shipped pair was degenerate — `0.06` is exactly
twice `0.03`. TARIFF-18 ends that coincidence, so the sentence is no longer true
and is not kept; the file now asserts per-figure that neither shipped default is
what it grades with, which is the property that was actually load-bearing.
### TARIFF-13 - the coverage figure beside the peak, and which quarter gets it
**The capacity-tariff status row's `N quarter(s) banked` is read from the
@@ -85,11 +85,14 @@ refusal: the card's own point must land inside 0.02–1.00 EUR/kWh.
⚠️ **Its floor is applied to the ENERGY figure and not to the all-in one, and
that asymmetry was found by a mutation rather than by reasoning.** The shipped
`price_taxes_eur_kwh` is 0.06 — three times the floor — so a card read a
`price_taxes_eur_kwh` was 0.06 — three times the floor — so a card read a
hundred times too small comes out at 0.0016 EUR/kWh of energy, is carried over
0.02 by the flat tax addend alone, and passes a band applied to the total. The
hundredfold error is exactly what the band exists for. Graded from both
directions in `test_price.py` 15e/15i and driven red as M10.
directions in `test_price.py` 15e/15i and driven red as M10. *(Annotated
2026-09-13: TARIFF-18 raised the default to 0.11736, nearly six times the
floor. The asymmetry is unchanged in kind and larger in degree — the flat
addend that carries a hundredfold-small card over 0.02 got bigger.)*
## 4. Fixed coefficients. No expression evaluation of any kind
@@ -155,4 +158,9 @@ granularity it was handed. Graded.
* **The hour-varying network charge that bites Wallonia.** Separate card.
* **Raising `price_taxes_eur_kwh` from 0.06 to ~0.12.** Separate card, and an
owner's decision. Note §3b: the band's floor is deliberately insensitive to
whatever that number becomes.
whatever that number becomes. *(Annotated 2026-09-13: that card is
**TARIFF-18** and it shipped on this branch at **0.11736** — the Eneco
residential card for Flanders, September 2026, incl. 6 % VAT, summed from
Fluvius Antwerpen distribution 5,35 + bijzondere accijns 4,876 + bijdrage
groene stroom en WKK 1,51 c€/kWh. §3b held: the floor is still on the energy
figure and nothing in this module needed a new number.)*
+89 -7
View File
@@ -1371,7 +1371,7 @@ 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` | Levies and VAT, EUR/kWh. Same rule |
| `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 |
| `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 |
@@ -1891,10 +1891,11 @@ size:
| **ORDER** | **varies** by hour, block or day-type | **Yes, and worst.** It changes *which hour is cheapest* |
Measured on the real arithmetic (`test_price.py` section 16b, printed on every
run): with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00 EUR/kWh,
the same 24-hour feed is cheapest at **13:00 in all five cases** and its spread
is **0.1500 EUR/kWh in all five cases**. That is the LEVEL bucket doing
nothing, and it is not a rounding artefact — it is arithmetic.
run): with a flat taxes figure of 0.00, 0.0739, 0.11736 (the shipped default),
0.1198, 0.1327 or 1.00 EUR/kWh, the same 24-hour feed is cheapest at **13:00 in
all six cases** and its spread is **0.1500 EUR/kWh in all six cases**. That is
the LEVEL bucket doing nothing, and it is not a rounding artefact — it is
arithmetic.
Three consequences worth having in mind:
@@ -1925,6 +1926,83 @@ Three consequences worth having in mind:
is the only ORDER component the add-on has. Anything added next belongs in one
of the three, and saying which is part of adding it.
### What `price_taxes_eur_kwh` is made of, and why yours is different (TARIFF-18)
**It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a
Flemish residential bill — everything on your invoice that is *not* the energy
itself — read off the Eneco residential card for Flanders, **September 2026**,
**including 6 % VAT**:
| component | c€/kWh |
|---|---|
| Fluvius distribution (**Antwerpen**) | 5,35 |
| Bijzondere accijns, *"Alle verbruik"* — no volume bands | 4,876 |
| Bijdrage groene stroom en WKK Vlaanderen | 1,51 |
| Energiebijdrage | **0** |
| **total** | **11,736 c€/kWh = 0.11736 €/kWh** |
The rows are in the code, in `app/price.py`'s
`FLEMISH_TAXES_COMPONENTS_EUR_KWH`, and `test_price.py` section 1b re-adds them
and refuses a default that is not their sum. It replaces a shipped `0.06`,
which was roughly **half** the true figure and had no recorded provenance at
all.
⚠️ **`energiebijdrage` is zero and that is not a mistake.** *"Vanaf 1 augustus
2026 bedraagt de energiebijdrage 0 EUR/MWh"* — it was merged into the
bijzondere accijns. If you are re-deriving this total from a guide written
before that date you will find a separate energiebijdrage line; adding it would
count it twice. It is carried as a **zero row** rather than left out for
exactly that reason.
⚠️ **THIS IS A PER-DSO FIGURE AND YOURS IS PROBABLY NOT 0.11736.** Only the
distribution row moves. Across the eight Fluvius DSOs it runs **5,28 c€/kWh**
(Midden-Vlaanderen) to **6,70** (West), so the Flemish all-in total runs about
**0.117 to 0.131 €/kWh** — a 1,42 c€/kWh spread, which on 4 000 kWh a year is
about €57. The other two rows are Flanders-wide and do not move. **Look up your
own distribution row, add the same accijns and groenestroombijdrage, and type
the total.**
⚠️ **A WALLOON OR BRUSSELS SITE SHOULD NOT USE THIS SCALAR AT ALL.** Their
distribution tariff has a **day and a night band** — `Enkelvoudig | Dag |
Nacht | Uitsl. nacht` on the same card, where Fluvius prints a single rate —
and a flat figure flattens exactly the component that is anti-correlated with
what the battery wants to do. Use **`price_taxes_profile`** (the next section);
it is the only ORDER-bucket component the add-on has.
⚠️ **THIS IS A DISPLAY AND GUARD FIX. IT DOES NOT IMPROVE ARBITRAGE, AND IT
CANNOT.** `price_taxes_eur_kwh` is a **LEVEL** component — added identically to
every hour — so it **cancels exactly** in `max − min` and leaves the argmin
where it was. It does not change which hour is cheapest, it does not change the
spread, and it does not make any trade happen that did not happen before. See
*The three buckets* above, where six flat scalars spanning and exceeding the
whole per-DSO range give the identical cheapest hour and the identical spread
to the last digit. What raising it **does** change is exactly two things:
* **every figure an operator reads** — the all-in prices on the status page,
`custom_formula`'s startup preview and the recorded price history. At half
the true non-energy component they made the add-on look considerably better
than it is.
* **§17's negative-price guard** (`arbitrage.py`), which is the one rung that
tests an **absolute** all-in price rather than a difference. The guard fires
when `spot < −(markup + taxes)`, so its boundary moved from **−0.09000** to
**−0.14736 €/kWh** of spot. A spot hour at −0.11868 €/kWh used to read
**−0.02868 €/kWh** all-in and the discharge was refused as paid-for disposal;
at the real tax level it reads **+0.02868** and the discharge is allowed,
because the operator really does pay those levies and that hour really was
profitable (`test_arbitrage.py` section 2c drives both, and pins the boundary
from below as well).
⚠️ **Which is why the default sits near the LOW end of the per-DSO band rather
than in the middle of it.** The two error directions are not symmetric. Set too
**high**, the figure carries a genuinely negative hour up above zero and the
add-on exports into paid-for disposal — §17's own regression case. Set too
**low**, it only declines a discharge that would have paid. Where one default
has to stand in for eight DSOs, it belongs at the bottom.
**If you set this yourself, nothing above affects you.** A site with its own
`price_taxes_eur_kwh` keeps it; the default only answers where the option is
absent from the stored options blob.
### Levies and VAT by time of day (`price_taxes_profile`)
**Leave this empty unless your network tariff really does have a day and a
@@ -1979,8 +2057,12 @@ second set of 23- and 25-hour-day bugs.
- ⚠️ **Set this *or* `price_taxes_eur_kwh`, not both.** The table wins when it
is set, so a scalar left at a non-default value beside it would be a number
you typed that decides nothing. That is refused at startup rather than
silently resolved; leave `price_taxes_eur_kwh` at its shipped `0.06` when you
fill this in.
silently resolved; leave `price_taxes_eur_kwh` at its shipped `0.11736` when
you fill this in. ⚠️ **A scalar still sitting on the superseded `0.06` is
also accepted**, and deliberately so — TARIFF-18 moved that default, and your
stored options blob carries the old number whether you typed it or not, so
refusing it would have stopped working Walloon and Brussels sites on an
upgrade over a figure the table overrides anyway.
- A **malformed table is refused at startup**, not at the next fetch. It is
read on the ~30 s planning path, and a table that first fails at 03:00 is a
site that stops pricing at 03:00 — which §11.4 turns into a quiet fallback
+5 -2
View File
@@ -831,13 +831,16 @@ document (`' 216.080 -> 0.30123'`), and M13 is red.
**M10 is `+0 B` and it is a real defect, not a synthetic one.** It came out of
writing the mutation rather than out of review: `implausible_all_in()`'s floor
was applied to the all-in figure, and `price_taxes_eur_kwh` ships at 0.06 —
was applied to the all-in figure, and `price_taxes_eur_kwh` shipped at 0.06 —
**three times the 0.02 floor** — so a card read a hundred times too small
(0.0016 EUR/kWh of energy) is carried over the floor by the flat tax addend
alone and passes. The hundredfold error is exactly what that band exists for.
The floor is applied to the **energy** figure now and the ceiling to the all-in
one, both directions are graded, and the trap is stated as a check in its own
right so the asymmetry cannot be "tidied up" later.
right so the asymmetry cannot be "tidied up" later. *(Annotated 2026-09-13:
TARIFF-18 raised that default to 0.11736, **nearly six times** the floor. The
addend that does the carrying got BIGGER, so the trap is wider than when M10
was measured and the asymmetry is more load-bearing rather than less.)*
**M15 is the one the framework CANNOT catch, and the reds say which half does.**
Declaring `ALL_IN` makes `assemble()` add nothing, and section 3's all-in check
+10 -7
View File
@@ -520,13 +520,16 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
⚠️ THE LOW END IS GRADED ON THE ENERGY COMPONENT AND THE HIGH END ON THE
ALL-IN FIGURE, AND THAT ASYMMETRY IS THE CHECK RATHER THAN AN OVERSIGHT.
`price_taxes_eur_kwh` ships at 0.06, which is three times the low bound -
so a card read a HUNDRED times too small comes out at 0.0016 EUR/kWh of
energy, is carried over the floor by the flat tax addend alone, and passes
a band applied to the all-in total. The hundredfold error is precisely
what this exists to catch, so the floor is applied where it can still see
it. The ceiling is applied to the all-in figure because that is the number
the site is really billed at, and taxes only push it up.
`price_taxes_eur_kwh` ships at 0.11736 (TARIFF-18; it was 0.06 before, and
the argument was already decisive at that figure), which is nearly SIX
times the low bound - so a card read a HUNDRED times too small comes out at
0.0016 EUR/kWh of energy, is carried over the floor by the flat tax addend
alone, and passes a band applied to the all-in total. The hundredfold error
is precisely what this exists to catch, so the floor is applied where it
can still see it. ⚠️ Raising the default made this trap WIDER, not
narrower: the addend that does the carrying got bigger. The ceiling is
applied to the all-in figure because that is the number the site is really
billed at, and taxes only push it up.
⚠️ AND THE LIMIT, WITH THE CLAIM. This grades the CARD'S OWN POINT - one
price, at the monthly index the card assumed - and not every hour of every
+66 -1
View File
@@ -818,6 +818,69 @@ def plan_is_provisional(read: PriceRead) -> bool:
# Options: what a plugin may see, and what it may not
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
# TARIFF-18: what `price_taxes_eur_kwh` IS MADE OF
# --------------------------------------------------------------------------- #
# ⚠️ THE DEFECT THIS TABLE PREVENTS IS THE ONE THE SHIPPED 0.06 WAS: A NUMBER
# NOBODY COULD SOURCE. 0.06 EUR/kWh is roughly HALF the real Flemish non-energy
# figure, and because no reader could see what it was made of there was nothing
# to argue with. Every row below is one line of the Eneco residential card for
# Flanders, September 2026, INCLUDING 6 % VAT, converted from the c EUR/kWh the
# card prints to the EUR/kWh this option takes. `price_taxes_eur_kwh`'s default
# is their sum, and test_price.py section 1b re-adds them and refuses a default
# that is not exactly that.
#
# ⚠️ THIS IS A PER-DSO FIGURE, AND IT IS THE DISTRIBUTION ROW THAT MOVES. Across
# the eight Fluvius DSOs that row runs 5,28 c EUR/kWh (Midden-Vlaanderen) to
# 6,70 (West), so the all-in Flemish total runs about 0.117 to 0.131 EUR/kWh.
# A site outside Antwerpen should type its own total; the other two rows are
# Flanders-wide and do not move with the DSO.
#
# ⚠️ AND THE DEFAULT SITS NEAR THE LOW END OF THAT BAND ON PURPOSE, BECAUSE THE
# TWO ERROR DIRECTIONS ARE NOT SYMMETRIC. This scalar is a LEVEL component: it
# cancels exactly in `max - min` and cannot move which hour is cheapest
# (test_price.py 16b measures that as a family). The one DECISION it reaches is
# §17's negative-price guard in arbitrage.py, which tests the ABSOLUTE all-in
# price of the sell hour. A figure set too HIGH carries a genuinely negative
# hour up above zero, and the add-on exports into paid-for disposal - the exact
# case §17 exists to refuse. A figure set too LOW only declines a trade that
# would have paid. So where a default must stand in for eight different DSOs,
# it belongs at the bottom of the band and not in the middle of it.
#
# ⚠️ `energiebijdrage` IS ZERO, AND IT IS A ROW HERE RATHER THAN AN ABSENCE.
# "Vanaf 1 augustus 2026 bedraagt de energiebijdrage 0 EUR/MWh" - it was merged
# into the bijzondere accijns. Anyone re-deriving this total from a guide
# written before that date will find a separate energiebijdrage line and add it
# on top of an accijns that already contains it. The zero row is here so that
# the double-count is visible as a decision rather than as an omission.
FLEMISH_TAXES_COMPONENTS_EUR_KWH = (
("Fluvius distribution, Antwerpen", 0.0535),
("Bijzondere accijns, 'Alle verbruik' - no volume bands", 0.04876),
("Bijdrage groene stroom en WKK Vlaanderen", 0.0151),
("Energiebijdrage - 0 EUR/MWh since 2026-08-01, inside the accijns", 0.0),
)
# ⚠️ TARIFF-18, AND IT IS NOT HOUSEKEEPING - IT DECIDES WHICH CONFIGS START.
# TARIFF-17's rung in validate.py refuses a site that sets `price_taxes_profile`
# AND leaves `price_taxes_eur_kwh` at something other than the shipped default,
# because the table wins and the scalar would decide nothing. It compares
# against the DEFAULT rather than against presence, because the Supervisor sets
# `App.options = deepmerge(config.yaml defaults, stored options)` and a saved
# blob therefore carries every schema key whether the operator typed it or not
# (measured: the reference install's export is 70 of 71 keys - CHANGELOG 0.9.0,
# `tools/supervisor_selfapi_probe.py` §D).
#
# So moving the default turns a previously ACCEPTED configuration into
# `SystemExit(78)`. A site that stored 0.06 - which is every install that has
# ever saved its configuration - and that also carries a taxes table would stop
# starting on the upgrade, over a number it never typed and that decides
# nothing. That refusal would be TRUE and USELESS: the rung's subject is a
# number the OPERATOR typed, and a superseded shipped default is not one. It
# still reads as "untouched" here, and the rung's sentence names both figures.
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) # TARIFF-02 .. TARIFF-17
# ⚠️ CFG-03, AND HOW THIS TICKET AVOIDS WIDENING IT. Option defaults in this
# add-on already live in three places - config.yaml, main.py's `o.get(k, X)`
# fallbacks, and a dataclass default - held apart only by a tripwire in
@@ -836,7 +899,9 @@ def plan_is_provisional(read: PriceRead) -> bool:
PRICE_OPTION_DEFAULTS = {
"price_provider": "off",
"price_markup_eur_kwh": 0.03, # §14 tariffs.energy_price.markup_eur_kwh
"price_taxes_eur_kwh": 0.06, # §14 tariffs.energy_price.taxes_eur_kwh
# TARIFF-18: the sum of FLEMISH_TAXES_COMPONENTS_EUR_KWH above, to the
# card's own last digit. §14 tariffs.energy_price.taxes_eur_kwh.
"price_taxes_eur_kwh": 0.11736,
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "",
+29 -5
View File
@@ -330,7 +330,9 @@ from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
from .peakguard import PEAK_OPTION_DEFAULTS
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS
from .price import (
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
from .static_tariff import (
ProfileInvalid, TAXES_PROFILE_OPTION, parse_static_profile,
parse_taxes_profile)
@@ -1444,13 +1446,32 @@ def _price_problems(opts: dict) -> list[str]:
# the price-entity/static_profile pair above. The table decides when it
# is set, so a non-default scalar left beside it is a number the
# operator typed that changes nothing, on the quantity §11.4 makes
# hardest to notice. It is compared against the SHIPPED DEFAULT rather
# hardest to notice. It is compared against the SHIPPED DEFAULTS rather
# than against presence, because the Supervisor writes every schema key
# into the options file whether the operator touched it or not.
# into the options file whether the operator touched it or not - so
# "the operator did not type this" has to be read off the VALUE.
#
# ⚠️ TARIFF-18: "UNTOUCHED" IS A SET, NOT ONE NUMBER, AND THE DEFECT
# THAT MAKES IT ONE IS AN UPGRADE THAT STOPS A BATTERY CONTROLLER
# STARTING. The Supervisor sets `App.options = deepmerge(config.yaml
# defaults, stored options)`, so a saved blob carries every schema key
# whether the operator typed it or not - the reference install's export
# is 70 of 71. When TARIFF-18 moved this default 0.06 -> 0.11736, every
# such blob went from "equal to the shipped default" to "not equal to
# it" without anybody editing anything, and a site that also carries a
# taxes table would have met `SystemExit(78)` on the upgrade. The
# sentence below is about a number the OPERATOR TYPED; a superseded
# shipped default is not one, so it still reads as untouched. The set
# is in price.py beside the default it supersedes, because the two only
# ever move together.
scalar = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
if scalar is not None and abs(scalar - shipped) > 1e-12:
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
superseded = ", ".join(
str(u) for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
if scalar is not None \
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
problems.append(
f"{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is "
f"{scalar} rather than its shipped {shipped}. THE TABLE WINS: "
@@ -1458,7 +1479,10 @@ def _price_problems(opts: dict) -> list[str]:
f"the scalar, so the number you typed there would decide "
f"nothing and nothing would say so. Set price_taxes_eur_kwh "
f"back to {shipped}, or clear {TAXES_PROFILE_OPTION} to keep "
f"the flat figure.")
f"the flat figure. (A scalar still sitting on a SUPERSEDED "
f"default - {superseded} - is accepted and NOT refused: "
f"that is a number this add-on wrote into your options "
f"blob, not one you typed.)")
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
+27 -2
View File
@@ -291,7 +291,29 @@ options:
# price_markup_eur_kwh is ignored under it, with a line in the log saying so.
price_provider: "off"
price_markup_eur_kwh: 0.03
price_taxes_eur_kwh: 0.06
# TARIFF-18. The all-in Flemish non-energy figure, EUR/kWh, incl. 6 % VAT.
# Its components are named in app/price.py's FLEMISH_TAXES_COMPONENTS_EUR_KWH
# and test_price.py section 1b re-adds them:
#
# Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
# Bijzondere accijns, "Alle verbruik" 4,876
# Bijdrage groene stroom en WKK Vlaanderen 1,51
# Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
# ------
# 11,736 c EUR/kWh = 0.11736
#
# ⚠️ THIS IS PER-DSO AND YOURS IS PROBABLY DIFFERENT. Only the distribution
# row moves: across the eight Fluvius DSOs it runs 5,28 c EUR/kWh
# (Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about 0.117
# to 0.131. Antwerpen is near the LOW end, which is the direction this
# default should err in - it is inert on which hour is cheapest, but §17's
# negative-price guard reads the ABSOLUTE all-in price, and a figure set too
# high would carry a genuinely negative hour above zero.
#
# ⚠️ WALLONIA AND BRUSSELS DO NOT BELONG IN THIS SCALAR AT ALL. Their
# distribution tariff has a day and a night band; use price_taxes_profile.
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
@@ -359,7 +381,10 @@ options:
#
# ⚠️ SET THIS *OR* price_taxes_eur_kwh, NOT BOTH. Two writers of one quantity,
# one of which silently wins, is refused at startup: leave the scalar at its
# shipped default when you fill this in.
# shipped default when you fill this in. A scalar still sitting on the
# SUPERSEDED 0.06 is accepted too (TARIFF-18 moved that default, and your
# stored options carry the old number whether you typed it or not) - the
# refusal is about a figure you chose, not about one this add-on wrote.
price_taxes_profile: ""
# TARIFF-15. YOUR OWN TARIFF CARD, as two fixed coefficients. Used only when
+118
View File
@@ -527,6 +527,124 @@ check(f"...while THE SAME MAGNITUDES with the sign flipped all DISCHARGE, so "
# header. Nothing in app/ changed - these rungs were correct all along.
# --------------------------------------------------------------------------- #
print()
print("2c. TARIFF-18 - where the \u00a717 boundary sits in SPOT terms, and the "
"shipped default that decides it")
# ⚠️ WHAT THIS GRADES AND WHAT IT DOES NOT CLAIM. `price_taxes_eur_kwh` is a
# LEVEL component: it is added identically to every hour, so it cancels exactly
# in `max - min` and cannot move which hour is cheapest. test_price.py 16b
# measures that as a FAMILY - five flat scalars spanning and exceeding a real
# per-DSO range, identical argmin and identical spread to the last digit - and
# nothing here re-litigates it. Raising the shipped default 0.06 -> 0.11736 is
# therefore NOT an arbitrage-correctness fix, and this section does not say it
# is.
#
# What it IS: §17's guard is the one rung in this module that reads an
# ABSOLUTE price rather than a difference, so it is the one decision a level
# component can reach. The boundary is `spot < -(markup + taxes)`, and moving
# the taxes default moves it. Below, driven through the shipped `decide()`.
#
# ⚠️ AND THE TWO ERROR DIRECTIONS ARE NOT SYMMETRIC, WHICH IS WHY THE DEFAULT
# SITS AT THE BOTTOM OF THE PER-DSO BAND RATHER THAN IN THE MIDDLE. A taxes
# figure set too HIGH carries a genuinely negative hour up above zero and the
# add-on exports into paid-for disposal - §17's own regression case. A figure
# set too LOW only declines a discharge that would have paid. Where one default
# must stand in for eight Fluvius DSOs, it belongs at the low end.
_T18_MARKUP = PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"]
_T18_TAXES = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
_T18_OLD_TAXES = 0.06 # what config.yaml shipped before TARIFF-18
# ⚠️ THE PRECONDITION FIRST (MUTATION.md habit 1). Every comparison below is
# "the old level and the new level answer differently". If the two levels were
# ever the same number, all of it would be one figure graded against itself -
# DEV-02's `(-5000, 5000)` against `(-5000, 5000)`, one card over.
check(f"the shipped taxes default {_T18_TAXES} and the superseded "
f"{_T18_OLD_TAXES} are DIFFERENT numbers, and differ by more than the "
f"0.01 EUR/kWh step this fixture's spot series is built on",
abs(_T18_TAXES - _T18_OLD_TAXES) > 0.01)
# The §17 boundary, computed HERE from the two option values rather than read
# off arbitrage.py - habit 2. all-in = spot + markup + taxes, so the guard
# fires exactly when spot < -(markup + taxes).
_T18_B_NEW = round(-(_T18_MARKUP + _T18_TAXES), 6)
_T18_B_OLD = round(-(_T18_MARKUP + _T18_OLD_TAXES), 6)
_T18_PROBE = round((_T18_B_NEW + _T18_B_OLD) / 2, 6)
check(f"the guard's spot-price boundary moves {_T18_B_OLD:+.5f} -> "
f"{_T18_B_NEW:+.5f} EUR/kWh, and the probe {_T18_PROBE:+.5f} lies "
f"STRICTLY BETWEEN them - so one level answers each way and the probe is "
f"not on either boundary",
_T18_B_NEW < _T18_PROBE < _T18_B_OLD)
# A whole-day-negative spot series whose DEAREST hour is the probe. Same shape
# as section 2's: the case a guard written "not all prices are negative" lets
# through, in SPOT rather than all-in terms.
_T18_SPOT = [round(-0.40 + 0.010 * i, 6) for i in range(24)]
_T18_SPOT[DEAR_H] = _T18_PROBE
check(f"the spot day's dearest hour is slot {DEAR_H} at {_T18_PROBE:+.5f} and "
f"every other hour is below it, so the hour the guard is asked about is "
f"the one the probe was chosen for",
_T18_SPOT.index(max(_T18_SPOT)) == DEAR_H
and _T18_SPOT.count(max(_T18_SPOT)) == 1
and all(p < 0 for p in _T18_SPOT))
def _t18_all_in(taxes):
"""spot + markup + taxes, added here, by this file's own arithmetic."""
return [round(p + _T18_MARKUP + taxes, 6) for p in _T18_SPOT]
_T18_OLD_SERIES = _t18_all_in(_T18_OLD_TAXES)
_T18_NEW_SERIES = _t18_all_in(_T18_TAXES)
print(f" dearest hour all-in: old level {_T18_OLD_SERIES[DEAR_H]:+.5f}, "
f"shipped level {_T18_NEW_SERIES[DEAR_H]:+.5f} EUR/kWh")
# ⚠️ THE LEVEL BUCKET RESTATED ON THIS FIXTURE, BEFORE ANYTHING IS CONCLUDED
# FROM IT. If the two arms differed in spread or in argmin, the verdicts below
# could be attributed to that instead, and this section would be claiming an
# arbitrage effect it must not claim.
check(f"the two levels leave the SPREAD identical to the last digit "
f"({round(max(_T18_OLD_SERIES) - min(_T18_OLD_SERIES), 6)} EUR/kWh) and "
f"the cheapest hour in the same slot - so nothing below is an optimiser "
f"effect",
round(max(_T18_OLD_SERIES) - min(_T18_OLD_SERIES), 6)
== round(max(_T18_NEW_SERIES) - min(_T18_NEW_SERIES), 6)
and _T18_OLD_SERIES.index(min(_T18_OLD_SERIES))
== _T18_NEW_SERIES.index(min(_T18_NEW_SERIES)))
_T18_SELL_OLD = act(plan_of(_T18_OLD_SERIES), DEAR_H, thresh=_shipped,
unc=NO_WEATHER)
_T18_SELL_NEW = act(plan_of(_T18_NEW_SERIES), DEAR_H, thresh=_shipped,
unc=NO_WEATHER)
check(f"at the SUPERSEDED 0.06 the same spot hour is "
f"{_T18_OLD_SERIES[DEAR_H]:+.5f} all-in, so \u00a717 REFUSES the discharge "
f"- and the reason names \u00a717 and that price, not merely that nothing "
f"happened",
_T18_SELL_OLD.action is None
and "§17" in _T18_SELL_OLD.reason
and f"{_T18_OLD_SERIES[DEAR_H]:+.4f}" in _T18_SELL_OLD.reason)
check(f"...and at the SHIPPED {_T18_TAXES} the SAME spot hour is "
f"{_T18_NEW_SERIES[DEAR_H]:+.5f} all-in and the battery DISCHARGES. That "
f"is the whole of what this card changes in a decision: the operator "
f"really does pay those levies, so this hour was never paid-for disposal "
f"and the old default was declining a trade that paid",
_T18_SELL_NEW.action == DISCHARGE)
# ⚠️ AND THE OTHER SIDE OF THE SHIPPED BOUNDARY, or the pair above grades
# "bigger taxes discharge more" rather than "the boundary is at -(markup +
# taxes)". One step below the shipped boundary is still refused.
_T18_BELOW = list(_T18_SPOT)
_T18_BELOW[DEAR_H] = round(_T18_B_NEW - 0.001, 6)
_T18_SELL_BELOW = act(
plan_of([round(p + _T18_MARKUP + _T18_TAXES, 6) for p in _T18_BELOW]),
DEAR_H, thresh=_shipped, unc=NO_WEATHER)
check(f"a spot hour at {_T18_BELOW[DEAR_H]:+.5f} - one tenth of a cent BELOW "
f"the shipped boundary - is still refused by \u00a717, so the boundary is "
f"where -(markup + taxes) puts it and not merely somewhere lower than "
f"before",
_T18_SELL_BELOW.action is None and "§17" in _T18_SELL_BELOW.reason)
# --------------------------------------------------------------------------- #
print()
print("3. AC3 - peak shaving outranks arbitrage, through both mechanisms")
+125 -21
View File
@@ -23,10 +23,15 @@ TWO RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
- markup and taxes must DIFFER and both be NON-ZERO, or "added markup
twice" and "added markup and taxes once each" produce the same number
and the double-counting check grades nothing. The shipped defaults are
0.03 and 0.06 and 0.06 IS EXACTLY TWICE 0.03, so this file deliberately
uses 0.031 / 0.057 for the arithmetic and asserts the non-degeneracy
conditions as checks in their own right rather than assuming them.
and the double-counting check grades nothing. This file uses 0.031 /
0.057 for the arithmetic and asserts the non-degeneracy conditions as
checks in their own right rather than assuming them. ⚠️ Until TARIFF-18
the shipped pair was 0.03 and 0.06 - taxes EXACTLY twice the markup,
the one degeneracy that collapses "added the markup twice" into "added
both" - and that coincidence was the stated reason for the separate
constants. TARIFF-18 moved taxes to 0.11736 and ENDED it; the constants
stay, because a fixture that IS the shipped default cannot show a
reader which of the two a check really consumed.
- the raw price must be non-zero, or an all-in figure equals markup+taxes
and a plugin that dropped the energy component entirely still passes.
- a fixture must carry BOTH a provisional and a non-provisional point, or
@@ -62,7 +67,9 @@ from app.price import (
ALL_IN, Absence, BANNED_IN_PLUGIN_CODE, Component, FrameworkOption,
RETIRED_PROVIDERS,
MAX_PLAUSIBLE_EUR_KWH, code_leaks,
PRICE_OPTION_DEFAULTS, PROVIDERS, PluginOptions, PricePoint, PriceProduct,
FLEMISH_TAXES_COMPONENTS_EUR_KWH, PRICE_OPTION_DEFAULTS,
PROVIDERS, PluginOptions, PricePoint, PriceProduct,
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH,
PriceProvider, PriceRead, ProviderCapabilities, RawPoint, SourceUnavailable,
arbitrage_allowed, assemble, brussels, brussels_date_range,
brussels_offset, check_egress, dated_feed_url,
@@ -169,12 +176,14 @@ def derived(fn):
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
# ⚠️ NOT the shipped 0.03 / 0.06. See this module's docstring: 0.06 is exactly
# 2 x 0.03, so under the shipped pair "the framework added the markup twice and
# no taxes" and "the framework added one markup and one taxes" differ by
# 0.03 - but several OTHER confusions collapse. These two are co-prime-ish in
# the only sense that matters here, and the three degeneracy conditions are
# asserted below rather than assumed.
# ⚠️ NOT the shipped pair, and deliberately so. See this module's docstring.
# Until TARIFF-18 the shipped pair was 0.03 / 0.06, where taxes are exactly
# 2 x the markup and several confusions collapse; TARIFF-18 moved taxes to
# 0.11736 and ended that particular degeneracy. These constants are kept
# because the reason outlives it: a fixture EQUAL to the shipped default cannot
# distinguish a check that read the fixture from one that read the default.
# Section 0's fourth check is what pins that, per figure, and the three
# degeneracy conditions are asserted below rather than assumed.
MARKUP = 0.031
TAXES = 0.057
@@ -203,13 +212,24 @@ check("...and differ, so 'added the wrong one' is visible",
check("...and markup twice != markup plus taxes, so double-counting the markup "
"is distinguishable from correctly adding both",
abs(2 * MARKUP - (MARKUP + TAXES)) > 1e-9)
check("...and the shipped defaults are NOT what this file grades with, because "
f"config.yaml's {PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} is exactly "
f"twice its {PRICE_OPTION_DEFAULTS['price_markup_eur_kwh']}",
abs(PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
- 2 * PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"]) < 1e-9
and (MARKUP, TAXES) != (PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"],
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]))
# ⚠️ TARIFF-18 CHANGED THE ARGUMENT THIS CHECK MAKES, AND THE CHANGE IS
# RECORDED RATHER THAN QUIETLY DROPPED. Until TARIFF-18 the shipped pair was
# (0.03, 0.06) - taxes EXACTLY twice the markup - which is the one degeneracy
# that makes "added the markup twice" indistinguishable from "added both", and
# this check named that coincidence as the reason the file grades with its own
# constants. Raising the taxes default to 0.11736 ENDS the coincidence, so that
# sentence is no longer true and is not kept. What the file needs from the
# shipped pair is weaker and is now asserted directly: each shipped figure must
# DIFFER from the constant this file grades with, or a check that read the
# shipped default instead of the fixture would pass by coincidence. Asserted
# per-figure and not as a tuple - a tuple comparison is satisfied by one of the
# two matching.
check(f"...and NEITHER shipped default is what this file grades with: shipped "
f"({PRICE_OPTION_DEFAULTS['price_markup_eur_kwh']}, "
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']}) against this file's "
f"({MARKUP}, {TAXES})",
MARKUP != PRICE_OPTION_DEFAULTS["price_markup_eur_kwh"]
and TAXES != PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
# --------------------------------------------------------------------------- #
@@ -300,6 +320,89 @@ check("...and no retired id is ALSO a live plugin, which would silently take "
set(RETIRED_PROVIDERS) & set(PROVIDERS) == set())
# --------------------------------------------------------------------------- #
print()
print("1b. TARIFF-18 - the shipped taxes default is a SUM OF NAMED CARD ROWS")
# ⚠️ THE DEFECT THIS SECTION PREVENTS IS THE ONE THE SHIPPED 0.06 WAS: A NUMBER
# WITH NO PROVENANCE. 0.06 EUR/kWh is roughly HALF the real Flemish figure, and
# nobody could argue with it because there was nothing written down to argue
# against - no rows, no card, no date. Section 1 above pins the default against
# config.yaml, which catches DRIFT between the two copies and says nothing
# whatever about whether the number is right. This section grades the number.
#
# ⚠️ AND IT GRADES IT FROM TWO INDEPENDENT SIDES, because the module's rows and
# the module's default are both in app/price.py and an edit that moved BOTH
# would satisfy a check that only re-added the rows (MUTATION.md: "where did
# each side of this comparison come from"). So: the default is re-added from
# the shipped rows, AND the total is compared against the card's own figures
# TYPED HERE, in the units the card prints them in.
print(" app/price.py's rows, c EUR/kWh incl. 6 % VAT:")
for _name, _v in FLEMISH_TAXES_COMPONENTS_EUR_KWH:
print(f" {_v * 100:>7.3f} {_name}")
_T18_SUM = sum(v for _n, v in FLEMISH_TAXES_COMPONENTS_EUR_KWH)
_T18_DEFAULT = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
print(f" {_T18_SUM * 100:>7.3f} = the shipped default {_T18_DEFAULT}")
check(f"the {len(FLEMISH_TAXES_COMPONENTS_EUR_KWH)} rows re-add to the shipped "
f"default: {round(_T18_SUM, 6)} against {_T18_DEFAULT}",
abs(_T18_SUM - _T18_DEFAULT) < 1e-12)
# Side two: the Eneco residential card for Flanders, September 2026, in the
# c EUR/kWh it prints. Typed here, so an edit inside app/price.py cannot move
# both sides of a comparison at once.
_T18_CARD_C = (5.35 # Fluvius distribution, Antwerpen
+ 4.876 # bijzondere accijns, "Alle verbruik"
+ 1.51 # bijdrage groene stroom en WKK Vlaanderen
+ 0.0) # energiebijdrage, 0 EUR/MWh since 2026-08-01
check(f"...and the card itself, typed in this file in c EUR/kWh, totals "
f"{_T18_CARD_C:.3f} c = {_T18_CARD_C / 100:.5f} EUR/kWh, which IS the "
f"shipped default to the card's own last digit",
abs(_T18_CARD_C / 100.0 - _T18_DEFAULT) < 1e-9)
# ⚠️ THE ZERO ROW IS A TRAP MARKER AND IS ASSERTED AS ONE. "Vanaf 1 augustus
# 2026 bedraagt de energiebijdrage 0 EUR/MWh" - it was merged into the
# bijzondere accijns. Anybody re-deriving this total from a guide written
# before that date finds a separate energiebijdrage line and adds it to an
# accijns that already contains it. Deleting the row is the edit that lets that
# happen silently, so the row's PRESENCE is graded and not only the sum.
_T18_NAMES = [n.lower() for n, _v in FLEMISH_TAXES_COMPONENTS_EUR_KWH]
check("the energiebijdrage is carried as a row worth ZERO rather than left "
"out, so the next person to re-derive this total finds it already "
"accounted for instead of adding it a second time",
any("energiebijdrage" in n for n in _T18_NAMES)
and all(v == 0.0 for n, v in FLEMISH_TAXES_COMPONENTS_EUR_KWH
if "energiebijdrage" in n.lower()))
check("...and the three rows that are NOT zero are distinct non-zero figures, "
"so the sum above is not being carried by one row with the others inert",
len({v for _n, v in FLEMISH_TAXES_COMPONENTS_EUR_KWH if v}) == 3)
# ⚠️ THE PER-DSO BAND, AND THE DIRECTION THE DEFAULT ERRS IN. Only the
# distribution row moves between Fluvius DSOs - 5,28 c EUR/kWh in
# Midden-Vlaanderen to 6,70 in West - so the all-in Flemish total runs about
# 0.117 to 0.131 EUR/kWh. The default must be INSIDE that band and in its lower
# half: this scalar is inert on which hour is cheapest (section 16b) but §17's
# negative-price guard reads the ABSOLUTE all-in price, and a figure set too
# high carries a genuinely negative hour above zero. test_arbitrage.py 2c
# drives that boundary through the shipped decision.
_T18_LO, _T18_HI = 0.117, 0.131
check(f"the default {_T18_DEFAULT} is inside the Flemish per-DSO band "
f"{_T18_LO}-{_T18_HI} EUR/kWh, and in its LOWER HALF, which is the "
f"direction \u00a717's guard makes safe",
_T18_LO <= _T18_DEFAULT <= (_T18_LO + _T18_HI) / 2)
# ⚠️ READ DEFENSIVELY ON PURPOSE. Emptying the superseded tuple must produce a
# NAMED red, not an IndexError inside this f-string - a crash takes the file
# red with nothing on the record saying which claim broke, which is MUTATION.md
# reporting rule 3 in the negative.
_T18_OLD = (SUPERSEDED_TAXES_DEFAULTS_EUR_KWH[0]
if SUPERSEDED_TAXES_DEFAULTS_EUR_KWH else None)
check(f"...and it is no longer the superseded {_T18_OLD}, which sat at "
f"{(_T18_OLD / _T18_DEFAULT) if _T18_OLD else 0:.0%} of the Flemish "
f"figure - about half of what the operator actually pays",
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH == (0.06,)
and all(abs(_T18_DEFAULT - u) > 1e-9
for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH))
# --------------------------------------------------------------------------- #
print()
print("2. every shipped plugin emits the exact canonical schema (AC1)")
@@ -3735,9 +3838,10 @@ check("...and a formula that is not JSON at all is refused with the shape it "
why(validate_startup_config(
vbase(price_provider="custom_formula", price_formula="0.102*spot+1"),
-5000, 5000), "not valid JSON"))
check("...and a card outside the plausible band is refused too, WITH THE "
"SHIPPED taxes in place - the flat 0.06 must not be able to carry a "
"hundredfold error over the floor",
check(f"...and a card outside the plausible band is refused too, WITH THE "
f"SHIPPED taxes in place - the flat "
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} must not be able to "
f"carry a hundredfold error over the floor",
why(validate_startup_config(
vbase(price_provider="custom_formula",
price_formula=json.dumps(_both_sides)),
+8 -2
View File
@@ -1139,12 +1139,18 @@ check("...and OPT-04's four, TARIFF-15's one and TARIFF-17's one are additions "
# 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
# is this card's first acceptance criterion, and a count cannot grade it.
# ⚠️ THE LITERAL 0.11736 IS TARIFF-18's, AND IT IS TYPED HERE ON PURPOSE. The
# claim is "the scalar is still its own key, holding its own real number", and
# reading that number out of the dict under test would compare it against
# itself. TARIFF-18 raised it from 0.06; if it moves again this line goes red
# and whoever moved it comes here and says which card.
check("...and `price_taxes_eur_kwh` is still its own key beside the table, so "
"TARIFF-17 widened the framework rather than renaming the scalar",
"TARIFF-17 widened the framework rather than renaming the scalar - and "
"it still holds TARIFF-18's Flemish figure",
isinstance(_defaults, list)
and "price_taxes_eur_kwh" in _defaults
and "price_taxes_profile" in _defaults
and _price.PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] == 0.06
and _price.PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] == 0.11736
and _price.PRICE_OPTION_DEFAULTS["price_taxes_profile"] == "")
check("...and every one of them is still in config.yaml, which stays the "
"source of truth",
+10
View File
@@ -147,6 +147,16 @@ DERIVED = {
# where the ratio came from - a citation alone would leave an operator
# reading "0" on the tab with no idea it is not "off".
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
# TARIFF-18. `price_taxes_eur_kwh` joins the table for `slew_w`'s reason:
# it stopped being a figure somebody picked and became one SUMMED FROM
# NAMED CARD ROWS. Three needles, because three different things about it
# can go wrong on the Configuration tab and only the first is provenance:
# where the number came from, that the operator's own DSO gives a
# different one, and that changing it moves no trade - which is the claim
# an operator is most likely to assume the opposite of when a number
# doubles.
"price_taxes_eur_kwh": ("TARIFF-18", "YOUR OWN DSO",
"MOVES NO TRADING DECISION"),
}
_uncited = []
for key, needles in sorted(DERIVED.items()):
+63 -4
View File
@@ -18,6 +18,8 @@ import asyncio
import sys
from app.maintenance import MAINT_OPTION_DEFAULTS, MaintConfig
from app.price import (
PRICE_OPTION_DEFAULTS, SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
from app.control import LOOP_TICK_S, PLANT_SETTLE_S, SLEW_RAIL_FRAC
from app.validate import (
ACTUATOR_WRITE_KEYS, FIRMWARE_INGEST_VETO_S, ConfigRefused,
@@ -1378,6 +1380,8 @@ print("TARIFF-17 - the taxes period table is refused HERE, at startup, and not "
# healthy fixed-tariff day. That is `static_profile`'s argument, one option
# over, and this is the same rule.
_T17_DAG, _T17_NACHT = 0.1327, 0.0739
_T18_SHIPPED_TAXES = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
_T18_SUPERSEDED = tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
_T17_OK = ('{"periods":['
'{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",'
f'"eur_per_kwh":{_T17_DAG}}},'
@@ -1427,11 +1431,66 @@ check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
and why(_T17_P_BOTH, "THE TABLE WINS"))
check("...and the SHIPPED scalar beside the table is NOT refused, or every "
"Walloon site would have to clear a key it never touched. The refusal is "
"about a value the operator chose, and 0.06 is the one they did not",
validate_startup_config(base(price_taxes_profile=_T17_OK,
price_taxes_eur_kwh=0.06),
-5000, 5000) == []
"about a value the operator chose, and the shipped default is the one "
"they did not",
validate_startup_config(
base(price_taxes_profile=_T17_OK,
price_taxes_eur_kwh=_T18_SHIPPED_TAXES),
-5000, 5000) == []
and len(_T17_P_BOTH) == 1)
# ⚠️ TARIFF-18 - "UNTOUCHED" IS A SET, AND THIS IS THE HALF THAT DECIDES
# WHETHER AN UPGRADE STOPS A RUNNING SITE. The Supervisor sets `App.options =
# deepmerge(config.yaml defaults, stored options)`, so a saved blob carries
# every schema key whether the operator typed it or not - the reference
# install's export is 70 of 71 (CHANGELOG 0.9.0). When TARIFF-18 moved this
# default 0.06 -> 0.11736, every such blob went from "equal to the shipped
# default" to "not equal to it" WITHOUT ANYBODY EDITING ANYTHING. A site that
# also carries a taxes table would have met `SystemExit(78)` on the upgrade,
# over a number it never typed and that decides nothing. So a SUPERSEDED
# default still reads as untouched.
#
# ⚠️ AND THE PRECONDITION FIRST (MUTATION.md habit 1): if the shipped default
# and the superseded one were ever the same number, the two checks below would
# be one check written twice, and dropping the superseded set entirely would
# leave both green.
check(f"the shipped taxes default {_T18_SHIPPED_TAXES} and the superseded "
f"{_T18_SUPERSEDED} are DIFFERENT numbers, so 'the shipped one is "
f"accepted' and 'the superseded one is accepted' are two claims",
_T18_SHIPPED_TAXES not in _T18_SUPERSEDED and len(_T18_SUPERSEDED) >= 1)
check(f"...and the superseded set is non-empty ({_T18_SUPERSEDED}), or the "
f"loop below quantifies over nothing and passes on a tree that dropped "
f"the set entirely - UX-01 AC 4's vacuous clause, one file over",
len(_T18_SUPERSEDED) >= 1)
for _old in _T18_SUPERSEDED:
check(f"...and a blob that still carries the SUPERSEDED default {_old} "
f"beside the table STARTS. That value was written into the options "
f"file by this add-on, not typed by the operator, so refusing it "
f"would stop a working site on an upgrade over an inert number",
validate_startup_config(base(price_taxes_profile=_T17_OK,
price_taxes_eur_kwh=_old),
-5000, 5000) == [])
# ⚠️ AND THE RUNG IS STILL A RUNG. A superseded default is not a licence for
# any value: 0.1198 is ORES's enkelvoudig rate, a number an operator plausibly
# types, and it is still refused beside the table. Without this line the set
# above could be widened to "anything" and nothing would report.
check("...while a value that is NEITHER the shipped default NOR a superseded "
"one is still REFUSED beside the table, naming it - the set of "
"'untouched' figures widened by exactly the old defaults and by nothing "
"else",
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
"0.1198")
and 0.1198 not in _T18_SUPERSEDED
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
# both which figure to return to and that the old default is not the problem.
check("...and the refusal names the SHIPPED default to return to AND says the "
"superseded default is accepted, so the sentence is actionable on both "
"sides of the upgrade",
bool(_T18_SUPERSEDED)
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}")
and why(_T17_P_BOTH, "SUPERSEDED")
and all(why(_T17_P_BOTH, str(u)) for u in _T18_SUPERSEDED))
check("...and the same non-default scalar WITHOUT the table still starts, "
"which is what makes the line above about the PAIR rather than about "
"0.1198 being disliked",
+2 -2
View File
@@ -148,8 +148,8 @@ configuration:
name: "Tariff · Supplier markup (EUR/kWh)"
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under manual_csv, whose figures are all-in, it is ignored, and logged as ignored, never applied twice."
price_taxes_eur_kwh:
name: "Tariff · Levies and VAT (EUR/kWh)"
description: "Taxes and levies per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them."
name: "Tariff · Distribution, levies and VAT (EUR/kWh)"
description: "Everything on your bill that is not the energy itself - distribution, levies, excise and VAT - per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them. ⚠️ THIS NUMBER IS NOT A PREFERENCE AND IT IS NOT YOUR INVERTER'S. It ships at 0.11736, DERIVED in TARIFF-18 from the Eneco residential card for Flanders of September 2026 including 6% VAT: Fluvius distribution for Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. The energiebijdrage is 0 since 1 August 2026 - it was merged into the excise, so a figure copied from an older guide would count it twice. ⚠️ CHECK IT AGAINST YOUR OWN DSO. Only the distribution row moves: across the eight Fluvius DSOs it runs from 5,28 c/kWh in Midden-Vlaanderen to 6,70 in West-Vlaanderen, so the Flemish total runs from about 0.117 to 0.131. In Wallonia or Brussels the distribution tariff has a separate day and night rate and does not belong in this field at all - use the levies-by-time-of-day option below instead. ⚠️ CHANGING IT MOVES NO TRADING DECISION: the same amount is added to every hour, so it cancels out of both the cheapest hour and the spread. What it does move is every price you are shown, and the rule that refuses to discharge into an hour whose all-in price is below zero."
export_price_eur_kwh:
name: "Tariff · Injection price (EUR/kWh)"
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
+2 -2
View File
@@ -128,8 +128,8 @@ configuration:
name: "Tarief · Marge van de leverancier (EUR/kWh)"
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij manual_csv, waarvan de bedragen alles inbegrepen zijn, wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
price_taxes_eur_kwh:
name: "Tarief · Heffingen en btw (EUR/kWh)"
description: "Belastingen en heffingen per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat."
name: "Tarief · Distributie, heffingen en btw (EUR/kWh)"
description: "Alles op uw factuur dat niet de energie zelf is - distributie, heffingen, accijns en btw - per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat. ⚠️ DIT GETAL IS GEEN VOORKEUR EN HET IS NIET DAT VAN UW OMVORMER. Het staat standaard op 0.11736, AFGELEID in TARIFF-18 van de Eneco-kaart voor residentiele klanten in Vlaanderen van september 2026, inclusief 6% btw: Fluvius-distributie voor Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. De energiebijdrage is sinds 1 augustus 2026 nul - ze is opgegaan in de accijns, dus een bedrag uit een oudere gids telt ze dubbel. ⚠️ CONTROLEER HET BIJ UW EIGEN DISTRIBUTIENETBEHEERDER. Alleen de distributieregel verschilt: over de acht Fluvius-netgebieden loopt die van 5,28 c/kWh in Midden-Vlaanderen tot 6,70 in West-Vlaanderen, zodat het Vlaamse totaal ongeveer van 0.117 tot 0.131 loopt. In Wallonie en Brussel heeft het distributietarief een apart dag- en nachttarief en hoort het hier helemaal niet thuis - gebruik dan de optie voor heffingen per tijdstip hieronder. ⚠️ DIT WIJZIGEN VERANDERT GEEN ENKELE HANDELSBESLISSING: hetzelfde bedrag komt bij elk uur, dus het valt weg uit zowel het goedkoopste uur als het prijsverschil. Wat het wel verandert is elke prijs die u te zien krijgt, en de regel die weigert te ontladen in een uur waarvan de totaalprijs onder nul ligt."
export_price_eur_kwh:
name: "Tarief · Injectievergoeding (EUR/kWh)"
description: "Wat u krijgt voor een kWh die u op het net zet, alles inbegrepen. Eén getal en geen reeks: geen enkele leverancier hier publiceert een dynamische injectiecurve. In België ligt dit vaak dicht bij nul, wat op zich al sterk pleit voor eigenverbruik in plaats van injectie. De waarde mag negatief zijn - op een zomerse middag betaalt u soms om te injecteren."