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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user