TARIFF-03: static day/night and fully-fixed tariffs, first-class
checks / unit (pull_request) Successful in 2m43s
checks / unit (pull_request) Successful in 2m43s
§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
This commit is contained in:
co-authored by
Claude Opus 5
parent
91ea676af4
commit
303525cfc5
@@ -583,11 +583,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 |
|
||||
|---|---|
|
||||
@@ -599,6 +603,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
|
||||
@@ -667,10 +672,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user