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
+80 -9
View File
@@ -644,11 +644,15 @@ floor of 60 s that figure is 60 W and only the margin is left.
### Energy price providers
> **Nothing in 0.5.x acts on a price.** This release ships the *plumbing*: the
> five provider plugins, the canonical all-in format they emit, and the config
> that selects one. The arbitrage strategy that would use it is a later ticket.
> Turning a provider on today fetches, validates and publishes prices and
> changes no charging decision. That is deliberate — the seam lands first.
> **No *fetched* price moves energy in 0.5.x.** This release ships the
> *plumbing*: the five provider plugins, the canonical all-in format they emit,
> and the config that selects one. Turning a provider on fetches, validates and
> publishes prices and changes no charging decision; the arbitrage strategy that
> would use it is a later ticket.
>
> The one exception is `static_profile` below. A static day/night tariff needs
> no network, so its cheap window is computed on every cycle and does reach the
> maintenance cycle's grid top-up.
| option | meaning |
|---|---|
@@ -660,6 +664,7 @@ floor of 60 s that figure is 60 W and only the margin is left.
| `price_feed_url` | Override the plugin's default feed. Only a host the plugin itself declares is accepted |
| `price_horizon_h` | How far ahead to ask for, hours |
| `price_timeout_s` | Per-request timeout |
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
**Which plugin sources what.** This is the one thing to get right, because
adding your markup to a price that already contains it makes every future
@@ -728,10 +733,76 @@ publishes a dynamic injection tariff; negative values are accepted.
**The old Home Assistant price sensors.** `price_now_entity` and
`price_avg_entity` still work and are unchanged: the add-on compares them and
uses the result as a cheap-window hint for the maintenance charge. They are
**not compatible with `price_provider`** — configure both and the add-on
refuses to start, naming the conflict, because the Home Assistant pair would
silently keep deciding while the status page named your supplier. Use one or
the other.
**not compatible with `price_provider`, and not with `static_profile` either**
— configure two of them and the add-on refuses to start, naming the conflict,
because the add-on reads exactly one and the loser is silent. Use one.
### Static day/night and fully-fixed tariffs (`static_profile`)
Most Belgian households are **not** on a dynamic contract, and an EMS that only
works under hourly pricing is an EMS for a minority. `static_profile` is how a
two-period day/night tariff or a single fixed price becomes a first-class
configuration rather than "turn the price feature off and hope".
It is one option, holding a JSON object, on one line:
```json
{"periods": [
{"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
{"name":"night", "from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
{"name":"night", "from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
```
A **fully fixed** tariff is one period covering the whole week:
```json
{"periods": [
{"name":"fixed","from":"00:00","to":"24:00","days":"all","eur_per_kwh":0.31}]}
```
- `eur_per_kwh` is **all-in** — energy *plus* your supplier's markup *plus*
taxes — exactly like `manual_csv`. `price_markup_eur_kwh` and
`price_taxes_eur_kwh` are **not** added to it.
- `days` takes `mon`…`sun`, ranges (`mon-fri`, `fri-mon`), comma lists
(`sat,sun`) and the words `weekday`, `weekend`, `all`.
- Times are **local Belgian time** and windows are half-open: `07:00`–`22:00`
includes 07:00 and excludes 22:00. A window may cross midnight.
- The clock changes twice a year and the add-on handles it. It generates prices
against absolute instants, so a spring-forward day is 23 hours and an
autumn-back day is 25.
> ⚠️ **A window belongs to the day the clock reads, not the day it started.**
> `22:00`–`07:00` on `mon-fri` covers Friday 23:00 but **not** Saturday 03:00 —
> Saturday 03:00 is Saturday's. That is why the example above has a third row
> for the weekend. Leave it out and the add-on **refuses to start**, naming the
> uncovered hours.
**Every day type must be covered exactly once.** The periods have to tile all
seven days with no gap and no overlap, and the add-on checks that at startup. A
gap is refused rather than filled in, because a made-up price is one the
optimiser would trade on; an overlap is refused because otherwise one of the two
prices silently wins on the order you typed them in.
**What it changes today.** The add-on works out which window you are in and
whether that window is *cheap* — strictly below the tariff's seven-day average.
The Web UI shows both on a new **Tariff** row, and the maintenance cycle's
cheap-window grid top-up uses it, which is exactly the "shift load into the
cheap window" behaviour a day/night tariff asks for. Nothing else moves energy
on a price yet.
Under a **fully fixed** tariff every hour equals the average, so there is *no*
cheap window by construction and the add-on runs self-consumption only — which
is the correct behaviour for that product, not a limitation. Peak shaving, the
capacity-tariff tracker, the reserve floor and the maintenance schedule are all
unaffected: the capacity tariff is a network cost charged whatever your energy
contract is.
**Recommended even on a dynamic contract.** When a price feed is unreachable the
add-on falls back to your static profile, marks the plan provisional and
disables arbitrage. With `static_profile` empty there is nothing to fall back
to, and the site simply has no tariff until the feed returns. It is not required
— the add-on warns rather than refusing — but filling it in is what makes that
fallback work.
### Site