TARIFF-03: static day/night and fully-fixed tariffs, first-class #19

Merged
admin merged 1 commits from TARIFF-03 into release/1.0 2026-08-30 23:24:54 +02:00
Owner

§9.2: "a large share of the installed base is on [a static profile], and an EMS that only works under dynamic pricing is an EMS for a minority." TARIFF-02 shipped the machinery for the top two rows of the §9.2 product table; this ticket does the bottom two, and makes the tariff work visible on the status page.

Base release/1.0 @ 91ea676. Built in a worktree, staged by explicit path.

Where the static profile went, and why

Not a sixth @registered provider plugin. That was the obvious move and it was rejected on two mechanical grounds, both recorded in the module docstring:

  1. A provider's product is fixed at class-definition time and this one is not. PriceProvider.capabilities() is a classmethod taking no options, and load_prices() reads caps.product to stamp the series. But §9.2 makes STATIC and FIXED two different products with two different required behaviours, and which one a site is on is a property of the operator's data — one price or several — not of any class. A plugin would have to declare one and be wrong for half its users. StaticProfile.product derives it from len(set(prices)) instead.
  2. The plugin framework solves a problem this path does not have. sources / assemble() exist because a fetched figure may already contain the supplier's markup. A static profile's numbers are typed by the operator as all-in; there is no publisher to disagree with, so the whole RawPoint → assemble → PricePoint pipeline would be an identity function wrapped in an uncheckable declaration.

So it sits where §11.4 already says it does — under the providers, as the designated fallback. It emits TARIFF-02's canonical {start, eur_per_kwh, provisional} so nothing downstream learns a tariff can be static.

The payoff is that the §9.2 product option and the §11.4 degradation row are the same code path differing in one flag:

price_provider static_profile origin provisional arbitrage
off set static no by spread
off empty none yes refused
set, feed live either provider per point by spread
set, feed dead set fallback yes refused (§11.4)
set, feed dead empty none yes refused

A static-tariff site runs the fallback generator every day, so the degradation path for a dynamic site cannot rot unused. A check asserts the two paths produce byte-identical prices.

How "no arbitrage" is expressed so OPT-04 cannot ignore it

A boolean called arbitrage_ok is a comment with a type. So the numbers an arbitrage strategy needs exist only on the permitting variant:

  • Arbitrage.allowed carries .spread_eur_kwh, .cheapest, .dearest, .regime, .product.
  • Arbitrage.refused carries .reason and nothing else. .spread_eur_kwh on a refusal raises Absence rather than answering 0.0; it is not iterable and has no .get(). An optimiser cannot compute a trade from a refusal because it cannot get the inputs out of one. .match(allowed=, refused=) requires both arms, and there is no falsy variant (price.py's reasoning).

And the verdict is only obtainable from TariffPlan.arbitrage(*, peak_guard_active) — required and keyword-only. §9.1/§10.1 put peak shaving (priority 2) above arbitrage (priority 3) because the capaciteitstarief is a network cost charged whatever the energy product is; the way that ordering survives OPT-04 landing is that OPT-04 cannot obtain a spread without answering whether the peak guard is holding. A checked TypeError proves the call does not go through without it, and a further check proves a truthy "no" is refused too.

Four refusals, in order, each derived: peak guard active → no price data → plan provisional → series is flat. That last one is AC2: a fully-fixed tariff disables arbitrage by arithmetic, not by a special case keyed on PriceProduct.FIXED. A check drives a DAY_AHEAD series that happens to print one price all day and asserts it is refused for the same true reason.

Nothing here decides a trade. OPT-04 owns cycle_cost_threshold_eur.

Product finding, not a defect: on realistic Belgian day/night numbers the whole-tariff spread is ~0.08 EUR/kWh, below the shipped 0.10 threshold. A correct OPT-04 will decline to cycle for the tariff alone on such a site.

Time-window shifting, reaching something real

§9.2's handling for a two-period tariff is time-window shifting. The add-on has exactly one shiftable load in 0.5.x — the maintenance cycle's cheap-window grid top-up — so main.py's cheap flag, previously fed only by the price_now_entity/price_avg_entity pair, now falls back to the static profile. The Web UI gained a Tariff row naming the window, its price, when it next changes and why it is or is not cheap.

  • Cheap is strictly below the mean. Under a flat tariff <= would call every hour cheap and a fixed-tariff site would top up from the grid at any hour — the opposite of self-consumption only. With <, a flat series has no cheap window at all, derived rather than special-cased.
  • The mean is over seven local days, not 48 h. A 48 h mean taken on a Friday afternoon is ~2/3 weekend, so the same Tuesday 09:00 would come out cheap or dear depending on when you asked. A check shows the 7-day mean is stable across the day it is asked on and that the 48 h mean is not, so the seven days buy something real.

main.py reads the I/O-free path only, and that is deliberate. resolve_tariff() can perform an HTTP fetch and read_inputs() is on the path that feeds the ESP32 heartbeat — a supplier's TLS timeout there is a supplier able to push the add-on toward layer 1's 30 s veto. Static generation is pure arithmetic, so §2.3's "regenerate on the tick" applies. Putting a fetched series on that path needs a background refresh and belongs with OPT-04; resolve_tariff() is the seam waiting for it, fully exercised by checks today.

Default install is bit-for-bit unchanged: price_provider: off and static_profile: "" → cheap = False, exactly as before.

Validation: a gap is not a price

Periods must tile all seven day types exactly once. A gap is refused with the uncovered range named — an invented price is one an optimiser would trade on — and an overlap is refused because otherwise one of two prices wins silently on list order. Checked at startup by validate.py, not at 02:00 on the day it bites.

⚠️ The trap this catches in practice: a window belongs to the day the clock reads, not the day it started. 22:00–07:00 mon-fri covers Friday 23:00 but not Saturday 03:00. Leave the weekend row out and the add-on refuses to start and says which hours are uncovered.

DST is handled by generating against absolute instants through price.py's existing brussels() (no zoneinfo; the shipped Alpine image has no tzdata). Both 2026 transitions are driven and the points are counted, not asserted to be 24: 23 on 29 March, 25 on 25 October, with the two 02:00 points at different offsets.

Rule 7's tripwire: it fired, and this is the fix

TARIFF-02 left an AST tripwire in test_price.py §12 asserting nothing in app/ consumed load_prices(), because rule 7's stated reason — "the supplier you configured would decide nothing" — was true only while that held. It went red naming static_tariff.py.

FAIL no module consumes load_prices() yet ... ['static_tariff.py']

Per its instructions the message in validate.py was rewritten in the same commit, and it states exactly what is true now and no more: the series is consumed; main.py reads the static side of that module and not a fetched one; so a configured provider still shapes no energy decision on its own, while a configured static profile does.

The check is re-aimed, not deleted:

  • the consumer set is pinned (["static_tariff.py"]), so a second consumer — OPT-04 — goes red again and forces the same re-read;
  • the expired phrase is asserted gone, so a bad merge cannot quietly restore it;
  • the message must keep naming its consumer, so the rule cannot decay into a refusal with no reason.

Rule 7 also grew a second refusal: static_profile alongside the HA price pair is the same silent-loser shape, and unlike the provider case it is a live conflict — read_inputs() picks one and never looks at the other.

CFG-03 is not widened — and which pattern

TARIFF-02's pattern. static_profile went into app/price.py's PRICE_OPTION_DEFAULTS, the one in-code mirror pinned to config.yaml by test_price.py §1. The new module has no defaults dict of its own and reads the option through price_option(); main.py grew no o.get("static_profile", …) fallback. Three checks assert all three halves of that claim, by reading the source.

It is a JSON string in a flat str? option rather than a nested schema: one key (§14 has one), it survives config.yaml's flat options block and test_price.py's parser, and parse_static_profile() already accepts an already-parsed dict — so a future nested schema is a config.yaml change and no code change at all.

AC3 — what this could honestly be measured against

There is no "Belgian scenario suite" as a registry. §17 names six scenarios and none of them is a static-profile case, which is why AC3 asks for a seventh. What exists as data is two days of this house's real P1 net power, captured live and committed as scenarios/p1-net-2026-08-{20,23}.jsonl.gz — the same fixtures test_tariff.py part 9 replays. Nothing longer was ever captured. So that is what this is measured against, and the file says so rather than implying an annual figure.

They happen to be one weekday and one weekend day, which is exactly the pair a day/night tariff needs:

p1-net-2026-08-20.jsonl.gz: 24.0 h, weekday, 15.220 kWh = 9.606 day + 5.614 night
  prices at EUR 4.7185 = 9.606 x 0.341 + 5.614 x 0.257   (two independent routes)
  under a FULLY FIXED tariff: EUR 4.6725 = 15.220 x 0.307
  shifting the day-window energy into the night window saves EUR 0.8069,
    which is 9.606 kWh x the 0.0840 spread  (two routes to one number)
  under FIXED the identical shift saves EXACTLY NOTHING  <- AC2 on real data
p1-net-2026-08-23.jsonl.gz: 24.0 h, weekend, 0.728 kWh, ALL of it night-priced
  -> a day-typing witness, not a shifting one; no saving is claimed from it

The window split is re-derived in the check by an independent route (Brussels-local hour of each sample) and required to agree with the module's own pricing. The degeneracy guard is explicit: a day whose entire import fell in one window grades nothing, so the saving is only asserted where both windows carry energy — and 2026-08-23 prints a line saying it was skipped for that reason rather than passing silently.

2.0 days exist; AC3 asks for a regression case in the suite and this is one, but it is not a season and nothing here claims otherwise. TEST-01 should record the seventh case.

Checks — red and green

test_static_tariff.py, 100 checks, house style, assert-free and runnable. All 12 suites pass; CI globs test_*.py so it is picked up automatically. Verified against git archive of the commit, so what CI sees is what was run.

Two of my own checks went red during development and both were real: the three-level fixture's mean sat above the middle level rather than below it (the assertion was written backwards; the discriminator is stronger the right way round), and at_local(THU, 0) is 23:00 UTC on the previous day, so a plan starting at midnight UTC correctly refused to price it.

Eleven mutations of static_tariff.py, every one caught:

mutation caught
cheap_now uses <= instead of < ✓
the midnight wrap dropped (22:00-07:00 read as 22:00-24:00) ✓
the day selector ignored, so mon-fri bleeds into the weekend ✓
product keyed on len(periods) instead of distinct prices ✓
the provisional (§11.4 fallback) refusal removed ✓
the peak-guard refusal removed ✓
the flat-series refusal removed ✓
the tiling check disabled ✓ (7 checks)
series generated on a fixed +01:00 instead of per-instant ✓ (4 checks)
a refusal allowed to carry spread = 0.0 instead of raising ✓
the mean window shrunk from 7 days to 2 ✓

Collision surface with SAFETY-07

They are live in the same repo on the peak guard. Overlap and how it was minimised:

  • app/main.py — 30 added lines in three spots: one import, one state field, the read_inputs() else branch, one status row. Nothing in cycle(), arbiter claims, or the peak path.
  • app/validate.py — additions inside _price_problems() only; no change to rules 1–6 or to validate_startup_config()'s structure.
  • config.yaml — one option and one schema line appended after the existing price block; the price_provider enum line is untouched.
  • CHANGELOG.md — one block at the top of Unreleased. Textual conflict likely; trivial.
  • app/tariff.py and app/arbiter.py are untouched.

Whoever merges second rebases. If SAFETY-07 lands first I will rebase this; the peak-guard ordering here is a parameter (peak_guard_active) rather than an import of their code, so their internals can change without touching this.

Not done

  • resolve_tariff() is not on the control tick — needs a background refresh, deliberately left to OPT-04 (reasoned above and in the docstring).
  • Hysteresis at the day→night boundary (§10.2). The window flip is currently read once per cycle and only feeds the maintenance charge floor, which has its own gating; a switching decision that oscillates needs the strategy that switches on it. It belongs with OPT-06.
  • TEST-01 should record the seventh §17 scenario; not edited here.
  • Nothing energy-moving was run against the live inverter, nothing was flashed, no add-on restart.
§9.2: *"a large share of the installed base is on [a static profile], and an EMS that only works under dynamic pricing is an EMS for a minority."* TARIFF-02 shipped the machinery for the top two rows of the §9.2 product table; this ticket does the bottom two, and makes the tariff work visible on the status page. Base `release/1.0` @ `91ea676`. Built in a worktree, staged by explicit path. ## Where the static profile went, and why **Not a sixth `@register`ed provider plugin.** That was the obvious move and it was rejected on two mechanical grounds, both recorded in the module docstring: 1. **A provider's product is fixed at class-definition time and this one is not.** `PriceProvider.capabilities()` is a `classmethod` taking no options, and `load_prices()` reads `caps.product` to stamp the series. But §9.2 makes `STATIC` and `FIXED` two *different* products with two different required behaviours, and which one a site is on is a property of the operator's data — one price or several — not of any class. A plugin would have to declare one and be wrong for half its users. `StaticProfile.product` derives it from `len(set(prices))` instead. 2. **The plugin framework solves a problem this path does not have.** `sources` / `assemble()` exist because a *fetched* figure may already contain the supplier's markup. A static profile's numbers are typed by the operator as all-in; there is no publisher to disagree with, so the whole `RawPoint → assemble → PricePoint` pipeline would be an identity function wrapped in an uncheckable declaration. So it sits where §11.4 already says it does — **under** the providers, as the designated fallback. It emits TARIFF-02's canonical `{start, eur_per_kwh, provisional}` so nothing downstream learns a tariff can be static. The payoff is that the §9.2 product option and the §11.4 degradation row are **the same code path differing in one flag**: | `price_provider` | `static_profile` | origin | provisional | arbitrage | |---|---|---|---|---| | `off` | set | `static` | no | by spread | | `off` | empty | `none` | yes | refused | | set, feed live | either | `provider` | per point | by spread | | set, feed dead | set | `fallback` | **yes** | **refused** (§11.4) | | set, feed dead | empty | `none` | yes | refused | A static-tariff site runs the fallback generator every day, so the degradation path for a dynamic site cannot rot unused. A check asserts the two paths produce byte-identical prices. ## How "no arbitrage" is expressed so OPT-04 cannot ignore it A boolean called `arbitrage_ok` is a comment with a type. So **the numbers an arbitrage strategy needs exist only on the permitting variant**: - `Arbitrage.allowed` carries `.spread_eur_kwh`, `.cheapest`, `.dearest`, `.regime`, `.product`. - `Arbitrage.refused` carries `.reason` **and nothing else**. `.spread_eur_kwh` on a refusal raises `Absence` rather than answering `0.0`; it is not iterable and has no `.get()`. An optimiser cannot compute a trade from a refusal because it cannot get the inputs out of one. `.match(allowed=, refused=)` requires both arms, and there is no falsy variant (price.py's reasoning). And the verdict is only obtainable from `TariffPlan.arbitrage(*, peak_guard_active)` — **required and keyword-only**. §9.1/§10.1 put peak shaving (priority 2) above arbitrage (priority 3) because the capaciteitstarief is a network cost charged whatever the energy product is; the way that ordering survives OPT-04 landing is that **OPT-04 cannot obtain a spread without answering whether the peak guard is holding.** A checked `TypeError` proves the call does not go through without it, and a further check proves a truthy `"no"` is refused too. Four refusals, in order, each derived: peak guard active → no price data → plan provisional → **series is flat**. That last one is AC2: a fully-fixed tariff disables arbitrage *by arithmetic*, not by a special case keyed on `PriceProduct.FIXED`. A check drives a `DAY_AHEAD` series that happens to print one price all day and asserts it is refused for the same true reason. Nothing here decides a trade. OPT-04 owns `cycle_cost_threshold_eur`. > **Product finding, not a defect:** on realistic Belgian day/night numbers the whole-tariff spread is ~0.08 EUR/kWh, **below** the shipped 0.10 threshold. A correct OPT-04 will decline to cycle for the tariff alone on such a site. ## Time-window shifting, reaching something real §9.2's handling for a two-period tariff is time-window shifting. The add-on has exactly one shiftable load in 0.5.x — the maintenance cycle's cheap-window grid top-up — so `main.py`'s `cheap` flag, previously fed only by the `price_now_entity`/`price_avg_entity` pair, now falls back to the static profile. The Web UI gained a **Tariff** row naming the window, its price, when it next changes and why it is or is not cheap. - **Cheap is *strictly* below the mean.** Under a flat tariff `<=` would call every hour cheap and a fixed-tariff site would top up from the grid at any hour — the opposite of self-consumption only. With `<`, a flat series has no cheap window at all, derived rather than special-cased. - **The mean is over seven local days, not 48 h.** A 48 h mean taken on a Friday afternoon is ~2/3 weekend, so the same Tuesday 09:00 would come out cheap or dear depending on when you asked. A check shows the 7-day mean is stable across the day it is asked on *and that the 48 h mean is not*, so the seven days buy something real. **`main.py` reads the I/O-free path only, and that is deliberate.** `resolve_tariff()` can perform an HTTP fetch and `read_inputs()` is on the path that feeds the ESP32 heartbeat — a supplier's TLS timeout there is a supplier able to push the add-on toward layer 1's 30 s veto. Static generation is pure arithmetic, so §2.3's "regenerate on the tick" applies. Putting a *fetched* series on that path needs a background refresh and belongs with OPT-04; `resolve_tariff()` is the seam waiting for it, fully exercised by checks today. **Default install is bit-for-bit unchanged**: `price_provider: off` and `static_profile: ""` → `cheap = False`, exactly as before. ## Validation: a gap is not a price Periods must tile all seven day types exactly once. A **gap** is refused with the uncovered range named — an invented price is one an optimiser would trade on — and an **overlap** is refused because otherwise one of two prices wins silently on list order. Checked at startup by `validate.py`, not at 02:00 on the day it bites. > ⚠️ The trap this catches in practice: **a window belongs to the day the clock reads, not the day it started.** `22:00–07:00 mon-fri` covers Friday 23:00 but *not* Saturday 03:00. Leave the weekend row out and the add-on refuses to start and says which hours are uncovered. DST is handled by generating against absolute instants through price.py's existing `brussels()` (no `zoneinfo`; the shipped Alpine image has no `tzdata`). Both 2026 transitions are driven and the points are **counted**, not asserted to be 24: 23 on 29 March, 25 on 25 October, with the two 02:00 points at different offsets. ## Rule 7's tripwire: it fired, and this is the fix TARIFF-02 left an AST tripwire in `test_price.py` §12 asserting nothing in `app/` consumed `load_prices()`, because rule 7's stated reason — *"the supplier you configured would decide nothing"* — was true only while that held. It went red naming `static_tariff.py`. ``` FAIL no module consumes load_prices() yet ... ['static_tariff.py'] ``` Per its instructions the **message in `validate.py` was rewritten in the same commit**, and it states exactly what is true now and no more: the series *is* consumed; `main.py` reads the *static* side of that module and not a fetched one; so a configured provider still shapes no energy decision on its own, while a configured static profile does. The check is **re-aimed, not deleted**: - the consumer set is *pinned* (`["static_tariff.py"]`), so a second consumer — OPT-04 — goes red again and forces the same re-read; - the expired phrase is asserted **gone**, so a bad merge cannot quietly restore it; - the message must keep naming its consumer, so the rule cannot decay into a refusal with no reason. Rule 7 also grew a **second refusal**: `static_profile` alongside the HA price pair is the same silent-loser shape, and unlike the provider case it is a *live* conflict — `read_inputs()` picks one and never looks at the other. ## CFG-03 is not widened — and which pattern **TARIFF-02's pattern.** `static_profile` went into `app/price.py`'s `PRICE_OPTION_DEFAULTS`, the one in-code mirror pinned to `config.yaml` by `test_price.py` §1. The new module has **no defaults dict of its own** and reads the option through `price_option()`; `main.py` grew no `o.get("static_profile", …)` fallback. Three checks assert all three halves of that claim, by reading the source. It is a JSON string in a flat `str?` option rather than a nested schema: one key (§14 has one), it survives `config.yaml`'s flat options block and `test_price.py`'s parser, and `parse_static_profile()` already accepts an already-parsed dict — so a future nested schema is a `config.yaml` change and no code change at all. ## AC3 — what this could honestly be measured against There is no "Belgian scenario suite" as a registry. §17 names six scenarios and none of them is a static-profile case, which is why AC3 asks for a seventh. What exists as **data** is two days of this house's real P1 net power, captured live and committed as `scenarios/p1-net-2026-08-{20,23}.jsonl.gz` — the same fixtures `test_tariff.py` part 9 replays. Nothing longer was ever captured. So that is what this is measured against, and the file says so rather than implying an annual figure. They happen to be **one weekday and one weekend day**, which is exactly the pair a day/night tariff needs: ``` p1-net-2026-08-20.jsonl.gz: 24.0 h, weekday, 15.220 kWh = 9.606 day + 5.614 night prices at EUR 4.7185 = 9.606 x 0.341 + 5.614 x 0.257 (two independent routes) under a FULLY FIXED tariff: EUR 4.6725 = 15.220 x 0.307 shifting the day-window energy into the night window saves EUR 0.8069, which is 9.606 kWh x the 0.0840 spread (two routes to one number) under FIXED the identical shift saves EXACTLY NOTHING <- AC2 on real data p1-net-2026-08-23.jsonl.gz: 24.0 h, weekend, 0.728 kWh, ALL of it night-priced -> a day-typing witness, not a shifting one; no saving is claimed from it ``` The window split is re-derived in the check by an independent route (Brussels-local hour of each sample) and required to agree with the module's own pricing. **The degeneracy guard is explicit**: a day whose entire import fell in one window grades nothing, so the saving is only asserted where both windows carry energy — and 2026-08-23 prints a line saying it was skipped for that reason rather than passing silently. `2.0 days exist; AC3 asks for a regression case in the suite and this is one`, but it is not a season and nothing here claims otherwise. TEST-01 should record the seventh case. ## Checks — red and green `test_static_tariff.py`, 100 checks, house style, `assert`-free and runnable. All 12 suites pass; CI globs `test_*.py` so it is picked up automatically. Verified against `git archive` of the commit, so what CI sees is what was run. **Two of my own checks went red during development and both were real:** the three-level fixture's mean sat *above* the middle level rather than below it (the assertion was written backwards; the discriminator is stronger the right way round), and `at_local(THU, 0)` is 23:00 UTC on the previous day, so a plan starting at midnight UTC correctly refused to price it. **Eleven mutations of `static_tariff.py`, every one caught:** | mutation | caught | |---|---| | `cheap_now` uses `<=` instead of `<` | ✓ | | the midnight wrap dropped (`22:00-07:00` read as `22:00-24:00`) | ✓ | | the day selector ignored, so `mon-fri` bleeds into the weekend | ✓ | | product keyed on `len(periods)` instead of distinct prices | ✓ | | the provisional (§11.4 fallback) refusal removed | ✓ | | the peak-guard refusal removed | ✓ | | the flat-series refusal removed | ✓ | | the tiling check disabled | ✓ (7 checks) | | series generated on a fixed `+01:00` instead of per-instant | ✓ (4 checks) | | a refusal allowed to carry `spread = 0.0` instead of raising | ✓ | | the mean window shrunk from 7 days to 2 | ✓ | ## Collision surface with SAFETY-07 They are live in the same repo on the peak guard. Overlap and how it was minimised: - **`app/main.py`** — 30 added lines in three spots: one import, one state field, the `read_inputs()` `else` branch, one status row. Nothing in `cycle()`, `arbiter` claims, or the peak path. - **`app/validate.py`** — additions inside `_price_problems()` only; no change to rules 1–6 or to `validate_startup_config()`'s structure. - **`config.yaml`** — one option and one schema line appended after the existing price block; the `price_provider` enum line is untouched. - **`CHANGELOG.md`** — one block at the top of *Unreleased*. Textual conflict likely; trivial. - **`app/tariff.py` and `app/arbiter.py` are untouched.** Whoever merges second rebases. If SAFETY-07 lands first I will rebase this; the peak-guard ordering here is a *parameter* (`peak_guard_active`) rather than an import of their code, so their internals can change without touching this. ## Not done - `resolve_tariff()` is not on the control tick — needs a background refresh, deliberately left to OPT-04 (reasoned above and in the docstring). - Hysteresis at the day→night boundary (§10.2). The window flip is currently read once per cycle and only feeds the maintenance charge floor, which has its own gating; a switching decision that oscillates needs the strategy that switches on it. It belongs with OPT-06. - TEST-01 should record the seventh §17 scenario; not edited here. - Nothing energy-moving was run against the live inverter, nothing was flashed, no add-on restart.
admin added 1 commit 2026-08-30 23:09:34 +02:00
TARIFF-03: static day/night and fully-fixed tariffs, first-class
checks / unit (pull_request) Successful in 2m43s
303525cfc5
§9.2 puts a large share of the Belgian installed base on a static profile, and
an EMS that only works under dynamic pricing is an EMS for a minority. A new
`static_profile` option describes a two-period day/night tariff - or a single
fixed price - and app/static_tariff.py turns it into the same canonical
{start, eur_per_kwh, provisional} series the fetched providers emit.

Not a sixth provider plugin, for two mechanical reasons. capabilities() is a
classmethod, so a plugin's PriceProduct is fixed when the class is written -
but §9.2 makes STATIC and FIXED two different products and which one a site is
on is a property of the operator's data; StaticProfile.product derives it from
the number of distinct prices. And the plugin framework exists to stop a
FETCHED figure being marked up twice, which a number typed as all-in cannot be.
So the profile sits where §11.4 already says it does - under the providers, as
the fallback. Configured product and feed-is-down are one generator differing
in one flag, so the degradation path cannot rot unused.

"No arbitrage" is a type. TariffPlan.arbitrage() returns a variant whose
ALLOWED arm carries the spread and the cheapest/dearest hours and whose REFUSED
arm carries a reason and nothing else - .spread_eur_kwh on a refusal raises
rather than answering 0.0, so an optimiser cannot compute a trade from one.
Four refusals, all derived: peak guard holding, no price data, plan
provisional, or the series is FLAT. The last is AC2: a fixed tariff disables
arbitrage by arithmetic, not by matching on an enum. peak_guard_active is a
required keyword argument, so §10.1's priority 2 over 3 cannot be skipped.

Time-window shifting reaches the one shiftable load that exists: main.py's
cheap-window flag falls back to the static profile, and the UI gained a Tariff
row. Cheap is STRICTLY below the seven-day mean - `<=` would call every hour of
a flat tariff cheap, and 48 h taken on a Friday is two-thirds weekend.

A profile must tile every day type exactly once, checked at startup; a gap is
refused with the range named rather than filled in. `22:00-07:00 mon-fri` does
not cover Saturday morning, and that trap is what the check catches.

validate.py rule 7's stated reason expired exactly as TARIFF-02 predicted: the
test_price.py section 12 tripwire went red naming static_tariff.py. The message
now says what is true instead, and the tripwire is re-aimed rather than removed
- consumer set pinned, expired phrase asserted gone. Rule 7 also grew a second
refusal for static_profile alongside the HA price pair, the same silent-loser
conflict and a live one.

CFG-03 is not widened: static_profile went into price.py's PRICE_OPTION_DEFAULTS
- the one in-code mirror pinned to config.yaml by a check - and the new module
carries no defaults dict of its own.

Measured on the two committed days of this house's real P1 meter. 2026-08-20
(Thursday): 15.220 kWh imported, 9.606 day / 5.614 night, EUR 4.7185 under
day/night vs EUR 4.6725 flat, shifting saves EUR 0.8069 = 9.606 kWh x the
0.0840 spread, derived twice. 2026-08-23 (Sunday): 0.728 kWh, all of it
night-priced, which is the weekend row proving itself. §17 names six Belgian
scenarios; this is the seventh, and it is two days rather than a season - the
file says so rather than implying an annual figure.

All 12 suites green. Eleven mutations of static_tariff.py were each caught by
at least one check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
admin merged commit cf5094c608 into release/1.0 2026-08-30 23:24:54 +02:00
Author
Owner

Merged as cf5094c. The placement argument holds, the interlock and the flat-by-arithmetic rule both bite, the re-aimed tripwire fires in all three directions, and AC3 is honest about its own reach. Fifteen mutations, two real survivors — one of them a fixture degeneracy that weakens the exact check I was asked to scrutinise. Both are follow-ups with fixes I verified, not objections.

The placement: I agree, and both mechanical reasons check out

capabilities() really is a classmethod, so a plugin's PriceProduct is fixed at class-definition time, while StaticProfile.product derives STATIC vs FIXED from len(set(prices)) at runtime — and G2 product always DAY_AHEAD goes RED 3, including "THREE rows carrying ONE price is still FIXED, so the derivation is on the prices". The sources/assemble() machinery genuinely has no job here: a number typed as all-in has no components to mark up. A sixth plugin would have had to lie about one of those two things.

The check I was asked to scrutinise: it discriminates, but not completely

_degrade() and static_plan() do call the same profile.series(...), so the identical-prices assertion is a regression guard rather than a tautology. I tested what it actually catches by writing second implementations into _degrade:

second implementation in _degrade result
window shifted one hour RED 1
flat series at the mean RED 2
hand-rolled loop rounding to 4 dp instead of 6 green — SURVIVED

The survivor is a fixture degeneracy, not a design flaw: DAY_P = 0.3410 and NIGHT_P = 0.2570 are three-decimal numbers, so round(x, 3), round(x, 4) and round(x, 6) are all the same number, and a precision-divergent second implementation is invisible.

Verified fix, both directions:

DAY_P 0.3410 -> 0.341025, NIGHT_P 0.2570 -> 0.257004
  fixture change alone            all checks passed   (rc=0)
  fixture change + the 4-dp D1b   RED - "...with the SAME prices the configured-product path produces"

This is TARIFF-02's own section-0 discipline — "the arithmetic this file grades with must not be degenerate" — not carried across to this file's fixture. Two constants, no other check disturbed.

The refusal type: correct, and the same survivor for the third time

The shipped behaviour is right on every probe:

refuse("") / refuse(None)            ValueError      _Refused() bare        TypeError
getattr(r,"spread_eur_kwh",None)     Absence         hasattr(...)           Absence
r.get("spread_eur_kwh", 0)           Absence         list(r)                Absence
match(allowed=..) missing refused    TypeError

But _Refused.__init__(self, reason="no arbitrage") still goes green. That is the third file to carry this shape and the third time the defaulted-field mutation survives — riglock's _Unread.cause, then price.py's _NoData.reason, now _Refused.reason. TARIFF-02 closed it by signature scan (no constructor or handler of an outcome type may carry a default) and I confirmed that scan still bites there: M3 -> RED 2. It was not inherited here. Porting it is a few lines and closes the next one before anyone writes a case, which is the whole argument for that form.

The interlock and the flat rule

P1 peak_guard_active gets a default          RED 1
P2 bool type-check dropped (truthy accepted) RED 1  "...nor answered with a truthy 'unknown'"
P3 made positional                           RED 1
F1 refuse by PriceProduct.FIXED match        RED 1  "a DAY_AHEAD series that happens to be flat is refused too"
F2 flat refusal never fires                  RED
G1 _check_tiling disabled                    RED 6

Both interlock directions confirmed. The flat rule is genuinely arithmetic, and F1 is the check that proves it.

On the near-flat boundary you flagged as a degeneracy trap: spread == 0.0 is exact float equality, which I probed. It is safe because both generators round to 6 dp — a flat profile yields exactly 0.0, and two prices differing below 1e-6 collapse to the same number before the comparison. A 1e-6 spread is allowed and is economically meaningless, but that is cycle_cost_threshold_eur's job in OPT-04, not this refusal's.

The re-aimed tripwire fires, three ways

a second load_prices consumer in app/     RED  "consumed by ['opt04_stub.py', 'static_tariff.py']"
the expired phrase restored               RED
the consumer name stripped                RED

Re-aimed rather than silenced, and every one of its three assertions can still fail. That is the right thing to do with a tripwire that goes off.

AC3: accepted

graded >= 1 is asserted, so the section cannot pass having graded nothing — that was the failure mode worth checking. The two routes are genuinely independent (module pricing through price_at versus a hand split of the volumes), and the arithmetic agrees: EUR 0.8069 = 9.606 kWh x the 0.0840 spread, with the FIXED control at exactly zero. The 08-23 day prints "a day-typing witness, not a shifting one, and no saving is claimed from it", and the summary says two days is not a season and that §17 has no static case. That is the honest handling, and I would rather have it than a synthetic year.

One small thing: "under the FIXED tariff the identical shift saves exactly nothing" is algebraically a restatement of the c_fix == total_kwh * FIXED_P check three lines above, so it cannot fail on its own. Harmless, worth knowing.

The other rulings

resolve_tariff() off the 30 s tick — right, and the code backs the comment. static_status() with no key, "", or None returns cheap=False, "no static profile" — bit-for-bit the behaviour it replaced. A malformed profile degrades to cheap=False with a status line saying it should have been refused at startup, and never raises into read_inputs(). A supplier's TLS timeout cannot reach the heartbeat path because nothing on that path can block.

§10.2 hysteresis deferred to OPT-06 — fine. Nothing here trades, so a flapping boundary has nothing to flap.

G3, minor: series()'s empty guard is untested; removing it goes green because PriceRead.series([]) refuses downstream anyway. Defence in depth whose removal is masked — worth one assert, no consequence.

Collision surface, verified rather than accepted

main.py       import, one field, the read_inputs() else-branch, one status row
              NOTHING in cycle(), the arbiter, or the peak path      (read the full diff)
validate.py   one import + three hunks, ALL inside _price_problems()
config.yaml   exactly one option (static_profile: "") + one schema line (str?)
app/tariff.py, app/arbiter.py   untouched

Exactly as stated. For SAFETY-07 the real risk is CHANGELOG.md (certain, textual) and main.py only if it touches the same read_inputs() else-branch or the status rows. Everything else is disjoint. I have no order preference on the merits — whichever is ready first should go, and the loser rebases against a small, well-localised diff.

What I could not verify

That two captured days generalise — which the file says itself, so it is a stated limit rather than a gap. And, unchanged from TARIFF-02: nothing here touches a real supplier feed, so the provider-backed half of resolve_tariff() is exercised against fixtures only.

Good work. The product derivation, the arithmetic flat rule and the tripwire handling are the strongest parts, and the AC3 section refusing to grade a one-window day is exactly the habit this project has been building.

**Merged as `cf5094c`.** The placement argument holds, the interlock and the flat-by-arithmetic rule both bite, the re-aimed tripwire fires in all three directions, and AC3 is honest about its own reach. Fifteen mutations, two real survivors — one of them a fixture degeneracy that weakens the exact check I was asked to scrutinise. Both are follow-ups with fixes I verified, not objections. ## The placement: I agree, and both mechanical reasons check out `capabilities()` really is a `classmethod`, so a plugin's `PriceProduct` is fixed at class-definition time, while `StaticProfile.product` derives STATIC vs FIXED from `len(set(prices))` at runtime — and `G2 product always DAY_AHEAD` goes **RED 3**, including *"THREE rows carrying ONE price is still FIXED, so the derivation is on the prices"*. The `sources`/`assemble()` machinery genuinely has no job here: a number typed as all-in has no components to mark up. A sixth plugin would have had to lie about one of those two things. ## The check I was asked to scrutinise: it discriminates, but not completely `_degrade()` and `static_plan()` do call the same `profile.series(...)`, so the identical-prices assertion is a regression guard rather than a tautology. I tested what it actually catches by writing second implementations into `_degrade`: | second implementation in `_degrade` | result | |---|---| | window shifted one hour | **RED 1** | | flat series at the mean | **RED 2** | | **hand-rolled loop rounding to 4 dp instead of 6** | **green — SURVIVED** | The survivor is a **fixture degeneracy, not a design flaw**: `DAY_P = 0.3410` and `NIGHT_P = 0.2570` are three-decimal numbers, so `round(x, 3)`, `round(x, 4)` and `round(x, 6)` are all the same number, and a precision-divergent second implementation is invisible. Verified fix, both directions: ``` DAY_P 0.3410 -> 0.341025, NIGHT_P 0.2570 -> 0.257004 fixture change alone all checks passed (rc=0) fixture change + the 4-dp D1b RED - "...with the SAME prices the configured-product path produces" ``` This is TARIFF-02's own section-0 discipline — *"the arithmetic this file grades with must not be degenerate"* — not carried across to this file's fixture. Two constants, no other check disturbed. ## The refusal type: correct, and the same survivor for the third time The shipped behaviour is right on every probe: ``` refuse("") / refuse(None) ValueError _Refused() bare TypeError getattr(r,"spread_eur_kwh",None) Absence hasattr(...) Absence r.get("spread_eur_kwh", 0) Absence list(r) Absence match(allowed=..) missing refused TypeError ``` But `_Refused.__init__(self, reason="no arbitrage")` still goes **green**. That is the third file to carry this shape and the third time the defaulted-field mutation survives — riglock's `_Unread.cause`, then `price.py`'s `_NoData.reason`, now `_Refused.reason`. **TARIFF-02 closed it by signature scan** (*no constructor or handler of an outcome type may carry a default*) and I confirmed that scan still bites there: `M3 -> RED 2`. It was not inherited here. Porting it is a few lines and closes the next one before anyone writes a case, which is the whole argument for that form. ## The interlock and the flat rule ``` P1 peak_guard_active gets a default RED 1 P2 bool type-check dropped (truthy accepted) RED 1 "...nor answered with a truthy 'unknown'" P3 made positional RED 1 F1 refuse by PriceProduct.FIXED match RED 1 "a DAY_AHEAD series that happens to be flat is refused too" F2 flat refusal never fires RED G1 _check_tiling disabled RED 6 ``` Both interlock directions confirmed. The flat rule is genuinely arithmetic, and F1 is the check that proves it. **On the near-flat boundary you flagged as a degeneracy trap:** `spread == 0.0` is exact float equality, which I probed. It is safe *because* both generators round to 6 dp — a flat profile yields exactly `0.0`, and two prices differing below 1e-6 collapse to the same number before the comparison. A 1e-6 spread is allowed and is economically meaningless, but that is `cycle_cost_threshold_eur`'s job in OPT-04, not this refusal's. ## The re-aimed tripwire fires, three ways ``` a second load_prices consumer in app/ RED "consumed by ['opt04_stub.py', 'static_tariff.py']" the expired phrase restored RED the consumer name stripped RED ``` Re-aimed rather than silenced, and every one of its three assertions can still fail. That is the right thing to do with a tripwire that goes off. ## AC3: accepted `graded >= 1` is asserted, so the section cannot pass having graded nothing — that was the failure mode worth checking. The two routes are genuinely independent (module pricing through `price_at` versus a hand split of the volumes), and the arithmetic agrees: **EUR 0.8069 = 9.606 kWh x the 0.0840 spread**, with the FIXED control at exactly zero. The 08-23 day prints *"a day-typing witness, not a shifting one, and no saving is claimed from it"*, and the summary says two days is not a season and that §17 has no static case. That is the honest handling, and I would rather have it than a synthetic year. One small thing: *"under the FIXED tariff the identical shift saves exactly nothing"* is algebraically a restatement of the `c_fix == total_kwh * FIXED_P` check three lines above, so it cannot fail on its own. Harmless, worth knowing. ## The other rulings **`resolve_tariff()` off the 30 s tick — right, and the code backs the comment.** `static_status()` with no key, `""`, or `None` returns `cheap=False, "no static profile"` — bit-for-bit the behaviour it replaced. A malformed profile degrades to `cheap=False` with a status line saying it should have been refused at startup, and never raises into `read_inputs()`. A supplier's TLS timeout cannot reach the heartbeat path because nothing on that path can block. **§10.2 hysteresis deferred to OPT-06** — fine. Nothing here trades, so a flapping boundary has nothing to flap. **`G3`, minor:** `series()`'s empty guard is untested; removing it goes green because `PriceRead.series([])` refuses downstream anyway. Defence in depth whose removal is masked — worth one assert, no consequence. ## Collision surface, verified rather than accepted ``` main.py import, one field, the read_inputs() else-branch, one status row NOTHING in cycle(), the arbiter, or the peak path (read the full diff) validate.py one import + three hunks, ALL inside _price_problems() config.yaml exactly one option (static_profile: "") + one schema line (str?) app/tariff.py, app/arbiter.py untouched ``` Exactly as stated. For SAFETY-07 the real risk is `CHANGELOG.md` (certain, textual) and `main.py` only if it touches the same `read_inputs()` else-branch or the status rows. Everything else is disjoint. I have no order preference on the merits — whichever is ready first should go, and the loser rebases against a small, well-localised diff. ## What I could not verify That two captured days generalise — which the file says itself, so it is a stated limit rather than a gap. And, unchanged from TARIFF-02: nothing here touches a real supplier feed, so the provider-backed half of `resolve_tariff()` is exercised against fixtures only. Good work. The product derivation, the arithmetic flat rule and the tripwire handling are the strongest parts, and the AC3 section refusing to grade a one-window day is exactly the habit this project has been building.
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#19