Merge release/1.0 (TARIFF-03) into SAFETY-07
checks / unit (pull_request) Successful in 3m12s

The anticipated conflict again, and only it: both cards prepend to CHANGELOG.md's
Unreleased section. Both entries kept, SAFETY-07 first. app/main.py and DOCS.md
auto-merged, so the overlap claim on #18 still holds - config.yaml and
validate.py remain untouched by this card even after TARIFF-02 and TARIFF-03
widened both.

Full suite green on the merge result, test_price.py and test_static_tariff.py
included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
glenn schrooyen
2026-08-30 23:43:35 +02:00
co-authored by Claude Opus 5
9 changed files with 2174 additions and 50 deletions
+94
View File
@@ -69,6 +69,100 @@ COVERED — running it there would have blinded it permanently and stopped every
legacy install from charging, which is what the first cut did until
`test_clamp.py`'s export case caught it.
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
§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 — as JSON, and `app/static_tariff.py` turns it into the same
canonical `{start, eur_per_kwh, provisional}` series the fetched providers emit,
so nothing downstream learns that a tariff can be static.
**It is not a sixth provider plugin, and the reason is mechanical.**
`PriceProvider.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 with two different required behaviours, and which one a site is on is a
property of the operator's data, not of any class. `StaticProfile.product`
derives it from the number of distinct prices instead. The second reason: the
plugin framework exists to stop a *fetched* figure being marked up twice, and a
number the operator typed as all-in has no publisher to disagree with.
**So the profile sits where §11.4 already says it does — under the providers, as
the fallback.** The configured-product path and the feed-is-down path are the
same generator differing in one flag: `provisional`. A static-tariff site runs
that path every day, so the degradation path for a dynamic site cannot rot
unused.
**"No arbitrage" is a type, not a boolean somebody has to remember to check.**
`TariffPlan.arbitrage()` returns a variant: the *allowed* arm carries the
spread and the cheapest and dearest hours, and the *refused* arm carries a
reason and nothing else — `.spread_eur_kwh` on a refusal raises rather than
answering 0.0. An optimiser cannot compute a trade from a refusal because it
cannot get the inputs out of one. Four refusals, all derived: the peak guard is
holding, there is no price data, the plan is provisional, or **the series is
flat**. That last one is AC2 — a fully-fixed tariff disables arbitrage by
arithmetic rather than by matching on an enum, and a dynamic feed that happens
to print one price all day is refused for the same true reason.
**Peak shaving outranks arbitrage, expressed so OPT-04 cannot skip it.**
`arbitrage()` takes `peak_guard_active` as a required keyword argument, so the
spread is not obtainable without first answering whether §10.1's priority 2 is
holding. §9.1's capacity tariff is a network cost charged whatever the energy
product is.
**Time-window shifting reaches the one shiftable load that exists.** main.py's
cheap-window flag — previously fed only by the `price_now_entity` /
`price_avg_entity` pair — now falls back to the static profile, and the Web UI
gained a **Tariff** row naming the window, the price, when it next changes and
why it is or is not cheap. The rule is *strictly* below the seven-day mean:
under a flat tariff `<=` would call every hour cheap and a fixed-tariff site
would top up from the grid at any hour, which is the opposite of
self-consumption only. Seven days rather than 48 hours because a 48 h mean taken
on a Friday is two-thirds weekend, and the same Tuesday 09:00 would come out
cheap or dear depending on when you asked.
**A profile must tile every day type exactly once, checked at startup.** A gap
is refused with the uncovered range named rather than filled in — 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. The trap this catches
in practice: `22:00–07:00 mon-fri` does **not** cover Saturday morning, because
a window applies on the day the clock reads.
**validate.py rule 7's stated reason expired, exactly as TARIFF-02 predicted.**
That ticket left a tripwire in `test_price.py` section 12 asserting that nothing
in `app/` consumed `load_prices()`, with instructions to fix the refusal's
wording in the same commit when it went red. It went red naming
`static_tariff.py`. The message now says what is true instead — the series *is*
consumed, but main.py acts on the static side of it and not on a fetched one —
and the tripwire is re-aimed rather than removed: the consumer set is pinned, so
a second consumer (OPT-04) goes red again, and the expired phrase is asserted
gone so a bad merge cannot restore it. Rule 7 also grew a second refusal:
`static_profile` alongside the Home Assistant price pair is the same
silent-loser conflict, and a live one.
**No new place for an option default (CFG-03 is not widened).**
`static_profile` was added to `app/price.py`'s `PRICE_OPTION_DEFAULTS` — the one
in-code mirror TARIFF-02 created and pinned to `config.yaml` by a check — rather
than to a defaults dict in the new module, which has none. main.py grew no
`o.get("static_profile", …)` fallback. That is TARIFF-02's pattern, stated
here so a reviewer does not have to infer it.
**Measured, on the data that exists.** `test_static_tariff.py` replays the two
committed days of this house's real P1 meter (`scenarios/p1-net-2026-08-{20,23}`)
priced under a Belgian day/night profile. 2026-08-20 is a Thursday: 15.220 kWh
imported, 9.606 in the day window and 5.614 in the night one, EUR 4.7185 under
day/night against EUR 4.6725 flat, and shifting the day-window energy into the
night window saves EUR 0.8069 — which is 9.606 kWh x the 0.0840 spread, derived
twice and required to agree. 2026-08-23 is a Sunday: 0.728 kWh, **all** of it
night-priced, which is the weekend row proving itself on real data. §17 names
six Belgian scenarios and a static-profile case is not among them; this is the
seventh, and it is two captured days rather than a season — the file says so
rather than implying an annual figure.
Also worth knowing before OPT-04: on realistic Belgian day/night numbers the
whole-tariff spread is about 0.08 EUR/kWh, **below** the shipped
`cycle_cost_threshold_eur` of 0.10. A correct optimiser will decline to cycle
for the tariff alone on such a site. That is a product finding, not a defect.
**Price provider plugins, and one canonical all-in price format (TARIFF-02).**
`app/price.py` ships five providers - `generic_dayahead` (wholesale EPEX BE),
`engie_dynamic`, `totalenergies_dynamic`, `eneco_eddy` and the offline