TARIFF-17: a price component that varies by hour has somewhere to live
checks / unit (pull_request) Successful in 7m25s
checks / unit (pull_request) Successful in 7m25s
`price.py` modelled the whole non-energy part of a Belgian bill as ONE flat scalar. This adds `price_taxes_profile`, which carries the TAXES component as a period table, and makes `assemble()` evaluate it at each point's own instant. ⚠️ THE FLAT SCALAR WAS NOT A WRONG NUMBER, AND THIS IS NOT A CORRECTION OF ONE. The optimiser reads exactly two quantities off a series - the spread `max - min` over the firm points, and which hour is cheapest - and an additive constant identical in every hour cancels out of the first and leaves the second where it was. It is provably inert. Measured, and printed by test_price.py 16b 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. What was missing is anywhere for an HOUR-VARYING non-energy component to live. This is a missing capability, not a bad constant. Belgium has one. Fluvius distribution is a single rate - its tariff-card row reads `Normaal | Uitsl. nacht`, where exclusive night is a separate physical circuit and not a night register - so nothing changes for the reference site in Flanders. The Walloon DSOs and Sibelga are on the same card with four columns, `Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real money (c/kWh): ORES 13,27 / 7,39; AIESH 15,17 / 8,21; Tecteo RESA 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090. ⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO. The cheap network hours are the cheap spot hours, so flattening them does not shift the curve - it UNDERSTATES the spread and declines cycles that would have paid. Same feed, same markup, the only difference being the taxes argument: flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500 ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788 The regression check grades WHICH HOUR IS CHEAPEST, not the spread: an implementation that reads the table once and adds one number to every point is the LEVEL bucket wearing a period table - right magnitude, wrong shape - and a spread assertion passes on it. Measured: hoisting the lookup out of `assemble()`'s loop (+17 B) is red on six named checks in test_price.py and GREEN in the other 28 files. ONE GRAMMAR, NOT TWO. `static_tariff.py` already shipped the periods, the day selectors, the tiling rule and the DST handling, so `parse_taxes_profile()` is the same parser with the option name and the "what this number means" clause as arguments. A second tiling rule would be a second set of 23- and 25-hour-day bugs. test_static_tariff.py section 10 pins that two ways: by parsing every module under `app/` and asserting the grammar is defined in exactly one of them, and by deriving each of thirteen refusal sentences for the new option from the old option's own sentence rather than transcribing them. `Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee are untouched. The new key joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and a plugin that names it is refused at `register()`. It reaches both plugins that take taxes - `custom_formula` ({ENERGY, MARKUP}) and `generic_dayahead` ({ENERGY}) - and not `manual_csv`, which is all-in: the table is routed through `capabilities().adds`, exactly as the scalar is. ⚠️ 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 indistinguishable from a healthy fixed-tariff day. validate.py refuses three things: a table that does not tile the day; a table set beside a NON-DEFAULT `price_taxes_eur_kwh`, because the table wins and the scalar would then be a number the operator typed that decides nothing; and a whole card typed in CENTS, which is how these figures are printed. That last rung is not a restatement of the parser's +-10 EUR/kWh bound - ORES's 13,27 trips the parser, but Sibelga's entire card, 9,39 and 7,10, is under it and would otherwise have started as a 9.39 EUR/kWh levy. Both rungs are graded by the REASON each gives, and mutating them together (TEST-16) moves three rows. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and the scalar path is unchanged arithmetically rather than merely in intent: test_price.py 16f reproduces the pre-TARIFF-17 expression and compares 12 capability/scalar combinations point for point with `==`. 13 mutations, every one red on a named check, ledger in test_price.py below section 16k with byte deltas and the one equivalent mutant named. 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:
co-authored by
Claude Opus 5
parent
c518007caf
commit
2fac0aaf06
@@ -24,6 +24,7 @@ if it has, it needs 0.10.0. Whoever deploys decides.
|
||||
| _this branch_ | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price |
|
||||
| _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 |
|
||||
|
||||
⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT
|
||||
ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and
|
||||
@@ -59,6 +60,77 @@ 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-17 - a price component that varies by hour has somewhere to live
|
||||
|
||||
**`price_taxes_profile`: the taxes component as a period table, alongside the
|
||||
flat `price_taxes_eur_kwh`.** ⚠️ **The flat scalar was not a wrong number, and
|
||||
this is not a correction of one.** The optimiser reads exactly two things off a
|
||||
series — the spread `max − min` over the firm points, and which hour is
|
||||
cheapest — and an additive constant identical in every hour cancels out of the
|
||||
first and leaves the second where it was. It is *provably* inert. What was
|
||||
missing was anywhere for an **hour-varying** non-energy component to live.
|
||||
|
||||
Belgium has one. Fluvius distribution is a single rate, and its row on the
|
||||
tariff card reads `Normaal | Uitsl. nacht` — *exclusive night* being a separate
|
||||
physical circuit, not a night register — so for the reference site in Flanders
|
||||
nothing here changes. The Walloon DSOs and Sibelga are on the same card with
|
||||
four columns, `Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real
|
||||
money (c€/kWh): ORES `13,27 / 7,39`, AIESH `15,17 / 8,21`, Tecteo RESA
|
||||
`12,20 / 7,01`, Sibelga `9,39 / 7,10`, ORES IMPACT 2026 `16,578 / 5,090`.
|
||||
|
||||
⚠️ **AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO.** The
|
||||
cheap network hours *are* the cheap spot hours, so flattening them does not
|
||||
shift the curve — it **understates the spread** and declines cycles that would
|
||||
have paid. Measured on one ordinary Belgian winter weekday, same feed, same
|
||||
markup, the only difference being the taxes argument:
|
||||
|
||||
```
|
||||
flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500
|
||||
ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788
|
||||
```
|
||||
|
||||
Five different flat figures spanning and exceeding the table's own range give
|
||||
**the same hour and the same spread to the last digit**; the table moves the
|
||||
hour a battery would buy in from 13:00 to 03:00. That is the regression
|
||||
`test_price.py` section 16 grades, and it grades **the hour**, not the spread:
|
||||
an implementation that reads the table once and adds one number to every point
|
||||
produces the right magnitude and the wrong shape, and a spread assertion passes
|
||||
on it. `DOCS.md` now names the three buckets — LEVEL, SPREAD, ORDER — so the
|
||||
next person adding a component knows which one it is in.
|
||||
|
||||
**The change is contained.** `static_tariff.py` already shipped the grammar —
|
||||
periods tiling the day, gaps and overlaps refused, day types, DST — so it is
|
||||
**the same parser**, taking the option name and the "what this number means"
|
||||
clause as arguments. There is no second tiling rule, because a second one would
|
||||
be a second set of 23- and 25-hour-day bugs; `test_static_tariff.py` section 10
|
||||
pins that by parsing every module under `app/` and asserting the grammar is
|
||||
defined in exactly one of them, and by deriving each of thirteen refusal
|
||||
sentences for the new option from the old option's own sentence.
|
||||
`Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee
|
||||
are untouched: the new key joins `FRAMEWORK_KEYS`, `BANNED_IN_PLUGIN_CODE` is
|
||||
derived from that set, and a plugin naming it is refused at `register()`.
|
||||
|
||||
⚠️ **A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH.** It is
|
||||
read on the ~30 s planning path through `assemble()`, 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 indistinguishable from a healthy fixed-tariff day. `validate.py`
|
||||
parses it at startup, and refuses three things: a table that does not tile the
|
||||
day (the same sentence `static_profile` gives, naming this key); a table set
|
||||
*beside* a non-default `price_taxes_eur_kwh`, because the table wins and the
|
||||
scalar would then be a number the operator typed that decides nothing; and a
|
||||
whole card typed **in cents**, which is how these figures are printed. That
|
||||
last rung is not a restatement of the parser's ±10 EUR/kWh bound — ORES's
|
||||
`13,27` trips the parser, but Sibelga's entire card, `9,39` and `7,10`, is
|
||||
under it, and would otherwise have started as a 9.39 EUR/kWh levy.
|
||||
|
||||
**Nothing changes for a site that does not set it.** The option ships `""`, the
|
||||
scalar path is unchanged arithmetically — `test_price.py` 16f reproduces the
|
||||
pre-TARIFF-17 expression and compares 12 capability/scalar combinations point
|
||||
for point with `==` — and the reference site is in Flanders, where the flat
|
||||
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-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
|
||||
|
||||
Reference in New Issue
Block a user