TARIFF-17: a price component that varies by hour has somewhere to live #85

Merged
admin merged 1 commits from TARIFF-17 into release/1.0 2026-09-13 15:40:18 +02:00
Owner

The gap

price.py modelled the whole non-energy part of a Belgian bill as one flat scalar.

⚠️ That scalar was not a wrong number, and this is not a correction of one. The optimiser reads exactly two quantities off a price series — the spread max − min over the firm points, and which hour is cheapest (min(firm, key=…)). An additive constant identical in every hour cancels exactly 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:

flat taxes 0.0000  -> cheapest hour 13, spread 0.1500
flat taxes 0.0739  -> cheapest hour 13, spread 0.1500
flat taxes 0.1198  -> cheapest hour 13, spread 0.1500
flat taxes 0.1327  -> cheapest hour 13, spread 0.1500
flat taxes 1.0000  -> cheapest hour 13, spread 0.1500

What was missing is anywhere for an hour-varying component to live. This is a missing capability, not a bad constant.

The three buckets

bucket what it looks like reaches the decision?
LEVEL additive, identical every hour No — provably
SPREAD multiplicative on spot Yes. Scales the spread against a fixed cycle_cost_threshold_eur
ORDER varies by hour, block or day-type Yes, and worst. Changes which hour is cheapest

DOCS.md now has this table, where each existing component sits, and why an ORDER-bucket regression must grade the argmin and not the spread. (custom_formula's price is (factor × index + offset) × (1 + VAT), so its offset is LEVEL and its factor is SPREAD — the spread is proportional to factor, which is why 0.100 → 0.10812 is exactly +8.1 % on any feed.)

Why Belgium needs the ORDER bucket

Fluvius distribution is a single rate — its tariff-card row reads Normaal | Uitsl. nacht, where exclusive night is a separate physical circuit, 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 (c€/kWh):

DSO enkelvoudig dag nacht gap
ORES 11,98 13,27 7,39 5,88
AIESH 13,64 15,17 8,21 6,96
Tecteo RESA 11,06 12,20 7,01 5,19
Sibelga (Brussels) 9,39 9,39 7,10 2,29
ORES IMPACT (2026) — 16,578 5,090 11,49

⚠️ 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.

The reproduction

Same feed, same markup, the only argument that differs being taxes:

flat 0.1198 (ORES enkelvoudig) : cheapest hour 13, spread 0.1500
ORES dag 0.1327 / nacht 0.0739 : cheapest hour 03, spread 0.1788

and driven through the shipped decision rather than through assemble()'s output — static_tariff.arbitrage().cheapest moves 13:00 → 03:00 on the same feed, at the shipped cycle_cost_threshold_eur, both arms allowed.

⚠️ The fixture differs in exactly one way, and that is asserted as its own precondition (16a): same caps object, same RawPoint list, same markup, by identity. And the flat arm is not one scalar but the whole family (16b) — five figures spanning and exceeding the table's own range, all giving the same hour and the same spread — so what 16c measures cannot be a level effect.

The change

  • price_taxes_profile: the TAXES component as a period table. assemble() takes a scalar or a table and evaluates it at each point's own instant.
  • One grammar, not two. static_tariff.py already shipped the periods, the day selectors, the tiling rule and the DST handling. parse_taxes_profile() is parse_static_profile() with the option name and the "what this number means" clause as default arguments — so every pre-existing call site and every pre-existing sentence is byte-identical. A second tiling rule would be a second set of 23- and 25-hour-day bugs.
  • The new key joins FRAMEWORK_KEYS. BANNED_IN_PLUGIN_CODE is derived from that set, so code_leaks() refuses a plugin that names it with no second list edited. Component.TAXES semantics and the double-counting guarantee are untouched.
  • Both plugins that take taxes get it, and the one that does not, does not: custom_formula ({ENERGY, MARKUP}) and generic_dayahead ({ENERGY}) both move 13 → 03; manual_csv (all-in) stays at 13. The table is routed through capabilities().adds, exactly as the scalar is.
  • The scalar path is unchanged arithmetically, not merely in intent. 16f reproduces the pre-TARIFF-17 expression and compares 12 capability/scalar combinations point for point with ==.

A malformed table, and where it is refused

⚠️ It is read on the ~30 s planning path, so it is refused at startup by validate.py, not at fetch time — 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. Three refusals:

  1. a table that does not tile the day — the same sentence static_profile gives, naming this key;
  2. the table beside a non-default price_taxes_eur_kwh — the table wins, so the scalar would be a number the operator typed that decides nothing. The shipped 0.06 beside the table is accepted, or every Walloon site would have to clear a key it never touched;
  3. a whole card typed in cents. ⚠️ 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.

If it somehow gets past startup, load_prices() returns a §11.4 no-data read naming the key and logs an error — it does not silently fall back to the scalar, and it does not raise into the tick.

Mutation ledger — 13 mutations, every one red on a named check

⚠️ No row is an edit-and-revert. The harness rsyncs a fresh copy of the tree per mutation (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 — MUTATION.md habit 18 closed structurally; and a :ro tree with no cache cannot read a stale .pyc — TEST-15 closed the way CI closes it.

# mutation delta red on
M1 assemble() hoists the table lookup out of the loop +17 B 16c/16d/16e/16k. Full 29-file sweep: red on test_price.py, GREEN in the other 28
M2 taxes_at() returns periods[0] instead of the point's band +17 B 16c/16d/16e/16k
M3 key removed from FRAMEWORK_KEYS −27 B 16g (5 checks)
M4 load_prices() parses the table and never applies it +10 B 16k only — every other check stays green while no site can configure one
M5 validate.py never reads the option −32 B test_validate (6)
M6 two-writers refusal made unreachable −1 B test_validate (2)
M7 cents rung threshold 1.0 → 1000.0 +3 B the Sibelga line only; the ORES line passes for the parser's reason
M8 ⚠️ both cents rungs together (TEST-16) +3/+3 B test_validate (3), test_price, test_static_tariff
M9 a second grammar — _check_tiling copied into price.py +86 B test_static_tariff 10a (2)
M10 parse_taxes_profile() drops option= −61 B test_static_tariff 10b, test_price 16j
M11 StaticProfile.at() matches on the UTC clock +15 B 16e, 16i, test_static_tariff (5)
M12 _price() ignores its means argument −26 B test_static_tariff 10b
M13 assemble() adds markup ignoring caps.adds −42 B 16f + section 3 (6)

⚠️ What none of it can see, with shapes (habit 13): passing p.start (UTC) instead of local to taxes_at() is an equivalent mutant, not a survivor — StaticProfile.at() calls brussels() on whatever it is given, so no fixture can separate them. 10a grades where the grammar is defined and never what it means, and covers app/ only. Nothing grades DOCS.md's bucket table, which is prose — the numbers it quotes are the ones 16b and 16c print on every run.

⚠️ M10 is green in test_validate.py and that is named rather than rounded off: that file's own price_taxes_profile cannot be used: prefix supplies the key, so it cannot see the inner sentence naming the wrong one. 10b and 16j are what do.

Checks

29/29 green. New: test_price.py section 16 (16a–16k), test_static_tariff.py section 10 (10a–10b), a TARIFF-17 section in test_validate.py. test_price_forecast.py's option-mirror drift pin was updated as its own comment instructs, plus a check that price_taxes_eur_kwh is still its own key beside the table — a count of nine cannot tell an addition from a rename.

Not deployed. No ha addons update, no writes to /addons/, no contact with 192.168.2.158 or the inverter.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## The gap `price.py` modelled the whole non-energy part of a Belgian bill as **one flat scalar**. ⚠️ **That scalar was not a wrong number, and this is not a correction of one.** The optimiser reads exactly two quantities off a price series — the **spread** `max − min` over the firm points, and **which hour is cheapest** (`min(firm, key=…)`). An additive constant identical in every hour cancels exactly 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: ``` flat taxes 0.0000 -> cheapest hour 13, spread 0.1500 flat taxes 0.0739 -> cheapest hour 13, spread 0.1500 flat taxes 0.1198 -> cheapest hour 13, spread 0.1500 flat taxes 0.1327 -> cheapest hour 13, spread 0.1500 flat taxes 1.0000 -> cheapest hour 13, spread 0.1500 ``` What was missing is anywhere for an **hour-varying** component to live. This is a **missing capability**, not a bad constant. ## The three buckets | bucket | what it looks like | reaches the decision? | |---|---|---| | **LEVEL** | additive, identical every hour | **No — provably** | | **SPREAD** | multiplicative on spot | **Yes.** Scales the spread against a fixed `cycle_cost_threshold_eur` | | **ORDER** | varies by hour, block or day-type | **Yes, and worst.** Changes *which hour is cheapest* | `DOCS.md` now has this table, where each existing component sits, and why an ORDER-bucket regression must grade the **argmin** and not the spread. (`custom_formula`'s price is `(factor × index + offset) × (1 + VAT)`, so its `offset` is LEVEL and its `factor` is SPREAD — the spread is *proportional* to `factor`, which is why 0.100 → 0.10812 is exactly +8.1 % on any feed.) ## Why Belgium needs the ORDER bucket Fluvius distribution is a single rate — its tariff-card row reads `Normaal | Uitsl. nacht`, where *exclusive night* is a **separate physical circuit**, 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` (c€/kWh): | DSO | enkelvoudig | dag | nacht | gap | |---|---|---|---|---| | ORES | 11,98 | 13,27 | 7,39 | **5,88** | | AIESH | 13,64 | 15,17 | 8,21 | **6,96** | | Tecteo RESA | 11,06 | 12,20 | 7,01 | 5,19 | | Sibelga (Brussels) | 9,39 | 9,39 | 7,10 | 2,29 | | ORES IMPACT (2026) | — | 16,578 | 5,090 | **11,49** | ⚠️ **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. ## The reproduction Same feed, same markup, the only argument that differs being `taxes`: ``` flat 0.1198 (ORES enkelvoudig) : cheapest hour 13, spread 0.1500 ORES dag 0.1327 / nacht 0.0739 : cheapest hour 03, spread 0.1788 ``` and driven through the shipped decision rather than through `assemble()`'s output — `static_tariff.arbitrage().cheapest` moves **13:00 → 03:00** on the same feed, at the shipped `cycle_cost_threshold_eur`, both arms allowed. ⚠️ **The fixture differs in exactly one way, and that is asserted as its own precondition** (16a): same `caps` object, same `RawPoint` list, same markup, by identity. And the flat arm is not one scalar but the whole **family** (16b) — five figures spanning and exceeding the table's own range, all giving the same hour and the same spread — so what 16c measures cannot be a level effect. ## The change - **`price_taxes_profile`**: the TAXES component as a period table. `assemble()` takes a scalar *or* a table and evaluates it at **each point's own instant**. - **One grammar, not two.** `static_tariff.py` already shipped the periods, the day selectors, the tiling rule and the DST handling. `parse_taxes_profile()` is `parse_static_profile()` with the option name and the "what this number means" clause as **default arguments** — so every pre-existing call site and every pre-existing sentence is byte-identical. A second tiling rule would be a second set of 23- and 25-hour-day bugs. - **The new key joins `FRAMEWORK_KEYS`.** `BANNED_IN_PLUGIN_CODE` is derived from that set, so `code_leaks()` refuses a plugin that names it with no second list edited. `Component.TAXES` semantics and the double-counting guarantee are untouched. - **Both plugins that take taxes get it**, and the one that does not, does not: `custom_formula` (`{ENERGY, MARKUP}`) and `generic_dayahead` (`{ENERGY}`) both move 13 → 03; `manual_csv` (all-in) stays at 13. The table is routed through `capabilities().adds`, exactly as the scalar is. - **The scalar path is unchanged arithmetically, not merely in intent.** 16f reproduces the pre-TARIFF-17 expression and compares 12 capability/scalar combinations point for point with `==`. ## A malformed table, and where it is refused ⚠️ It is read on the ~30 s planning path, so it is refused at **startup** by `validate.py`, not at fetch time — 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. Three refusals: 1. **a table that does not tile the day** — the same sentence `static_profile` gives, naming this key; 2. **the table beside a non-default `price_taxes_eur_kwh`** — the table wins, so the scalar would be a number the operator typed that decides nothing. The *shipped* 0.06 beside the table is accepted, or every Walloon site would have to clear a key it never touched; 3. **a whole card typed in cents.** ⚠️ 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. If it somehow gets past startup, `load_prices()` returns a §11.4 no-data read naming the key and logs an error — it does **not** silently fall back to the scalar, and it does not raise into the tick. ## Mutation ledger — 13 mutations, every one red on a named check ⚠️ **No row is an edit-and-revert.** The harness rsyncs a fresh copy of the tree per mutation (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 — MUTATION.md habit 18 closed structurally; and a `:ro` tree with no cache cannot read a stale `.pyc` — TEST-15 closed the way CI closes it. | # | mutation | delta | red on | |---|---|---|---| | M1 | `assemble()` hoists the table lookup out of the loop | +17 B | 16c/16d/16e/16k. **Full 29-file sweep: red on `test_price.py`, GREEN in the other 28** | | M2 | `taxes_at()` returns `periods[0]` instead of the point's band | +17 B | 16c/16d/16e/16k | | M3 | key removed from `FRAMEWORK_KEYS` | −27 B | 16g (5 checks) | | M4 | `load_prices()` parses the table and never applies it | +10 B | **16k only** — every other check stays green while no site can configure one | | M5 | `validate.py` never reads the option | −32 B | `test_validate` (6) | | M6 | two-writers refusal made unreachable | −1 B | `test_validate` (2) | | M7 | cents rung threshold 1.0 → 1000.0 | +3 B | the **Sibelga** line only; the ORES line passes for the *parser's* reason | | M8 | ⚠️ **both** cents rungs together (TEST-16) | +3/+3 B | `test_validate` (3), `test_price`, `test_static_tariff` | | M9 | a second grammar — `_check_tiling` copied into `price.py` | +86 B | `test_static_tariff` 10a (2) | | M10 | `parse_taxes_profile()` drops `option=` | −61 B | `test_static_tariff` 10b, `test_price` 16j | | M11 | `StaticProfile.at()` matches on the UTC clock | +15 B | 16e, 16i, `test_static_tariff` (5) | | M12 | `_price()` ignores its `means` argument | −26 B | `test_static_tariff` 10b | | M13 | `assemble()` adds markup ignoring `caps.adds` | −42 B | 16f + section 3 (6) | ⚠️ **What none of it can see, with shapes** (habit 13): passing `p.start` (UTC) instead of `local` to `taxes_at()` is an **equivalent** mutant, not a survivor — `StaticProfile.at()` calls `brussels()` on whatever it is given, so no fixture can separate them. 10a grades *where* the grammar is defined and never *what it means*, and covers `app/` only. Nothing grades `DOCS.md`'s bucket table, which is prose — the numbers it quotes are the ones 16b and 16c print on every run. ⚠️ **M10 is green in `test_validate.py`** and that is named rather than rounded off: that file's own `price_taxes_profile cannot be used:` prefix supplies the key, so it cannot see the inner sentence naming the wrong one. 10b and 16j are what do. ## Checks **29/29 green.** New: `test_price.py` section 16 (16a–16k), `test_static_tariff.py` section 10 (10a–10b), a TARIFF-17 section in `test_validate.py`. `test_price_forecast.py`'s option-mirror drift pin was updated as its own comment instructs, plus a check that `price_taxes_eur_kwh` is still its own key beside the table — a count of nine cannot tell an addition from a rename. **Not deployed.** No `ha addons update`, no writes to `/addons/`, no contact with 192.168.2.158 or the inverter. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-13 15:32:15 +02:00
`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
admin merged commit 7796f168a9 into release/1.0 2026-09-13 15:40:18 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#85