FCST-04: the carry-forward fill reaches the plan, and the day it carries survives a restart
checks / unit (pull_request) Successful in 7m11s

FCST-03 shipped `fill_from_yesterday()` complete and checked, and shipped the
consumers written for it - `TariffPlan.firm_points()`, `cheap_now()`'s refusal
of a provisional hour, and `arbitrage()`'s refusal 3, which drops provisional
hours from the trade set rather than refusing the whole series. It shipped NO
CALLER. Nothing in `app/` named the fill, so no series ever contained a carried
point and every one of those consumers was exercising its all-firm branch.

⚠️ THE DEFECT THAT PRODUCED WAS NOT AN ERROR ANYWHERE. `load_prices()` returns
what the feed published and nothing widened it to the requested horizon, so on
a dynamic site between midnight and the 14-16 h CET publication the plan
covered the rest of today and nothing more - measured on the fixture here, 24 h
against a 48 h request. `arbitrage()` then took `max - min` over that truncated
window and reported a spread. The number was real, the window was half the one
configured, and nothing said so: tomorrow evening's peak, the hour a morning
charge would be sold into, was not in the series to be compared against
anything. A missing hour is not a wrong price - it is an hour the optimiser
never learns exists.

WHAT THIS ADDS. `app/price_history.py` keeps the PUBLISHED prices of the last
KEEP_DAYS Brussels days in /data/price_history.json; `resolve_tariff()` gains a
required keyword-only `history` and fills every unpublished hour of the horizon
from yesterday's same-wall-clock hour, flagged. Persistence rather than a
variable on the refresh task, because the window the fill exists for is
midnight -> publication and an add-on restarted at 08:00 would otherwise spend
all of it on the truncated series. Not in state.json: the refresh runs every
1800 s and that file holds the maintenance history and SAFETY-02's latch.

⚠️ ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR.
Carried hours are provisional, so §8.3 keeps them out of the trade set: the
fill widens what the optimiser can SEE, never what it may DO, and
test_price_history.py checks that so it cannot be overclaimed later. What does
change a trade is that the filled series is windowed to [since, until), so
hours that have ALREADY ELAPSED are no longer in the spread. Before, a cheap
night that had gone sat in the series as the day's cheapest hour - and a cycle
cannot buy in an hour that is past, so the trade was quietly never taken.
Section 2 grades this by finding the cheapest firm hour with and without the
fill and showing it MOVES, from 02:00 UTC to 08:00 UTC on the fixture.

⚠️ A DEAD FEED IS STILL §11.4, NOT YESTERDAY. The fill runs only on a `series`
read. The two are indistinguishable to the optimiser - both 100 % provisional,
so arbitrage refuses on the firm-pair rung and `cheap_now()` is False - so
preferring yesterday's feed over the `static_profile` an operator configured
FOR that case would change a documented degradation for a difference only the
status page can see. That, and two more, in
DECISIONS/FCST-04-the-day-that-is-carried.md.

⚠️ THE ANTI-DECAY RULE IS AN ORDERING, NOT A VALIDATION, and saying so is more
useful than a guarantee that would not hold. Feeding the filled series back in
is how this rots - day 2 carries day 1's guesses, day 3 carries day 2's carry
of them, behind a `provisional` flag that stays TRUE while the NUMBER decays.
No value-level check can catch it: a carried point and `eneco_eddy`'s
published-provisional quarter are the same three fields with the same flag. So
`carry()` is the only public entry, it records BEFORE it fills, the filled
series exists only as a return value the object never sees again, and
test_price_history.py walks app/ for any other module naming `_remember` and
reads `carry()`'s AST to pin the order.

EVIDENCE. 29/29 test files pass in the CI image. Eight mutations driven against
app/price_history.py and app/static_tariff.py, __pycache__ purged before each
run per MUTATION.md/TEST-15: newest-record lookup (5 red), recording the filled
series (1), replace-instead-of-merge (10), UTC-date keying (10), removing the
fill call entirely (12), a lost price field defaulting to 0.0 (2), prune by
clock arithmetic (1), a degraded reason that stops naming its cause (1). No
survivors; baseline and revert both green. Three of those first crashed the
harness instead of reporting a FAIL - "it was refused" and "it broke on the way
to being refused" must not grade the same - so `why()` and `_priced()` were
added and the run repeated.

No option added, config.yaml untouched, and the CHANGELOG entry deliberately
picks no version: PKG-07's rule, and only the deployer knows whether 0.9.0's
image has been pulled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-09 19:37:45 +02:00
co-authored by Claude Opus 5
parent 587751b115
commit 0618194d9c
8 changed files with 1449 additions and 7 deletions
+70
View File
@@ -1,5 +1,75 @@
# Changelog
## Unreleased
⚠️ **THIS HAS NO VERSION AND CANNOT REACH AN INSTALL UNTIL IT GETS ONE.**
PKG-06's and PKG-07's rule: the Supervisor keys the built image by `version:`,
so `ha apps update` is a silent no-op on an unbumped change. This branch
deliberately does **not** pick the number, because the right one depends on a
fact the branch cannot observe - whether 0.9.0's image has already been built
and pulled. If it has not, this belongs inside 0.9.0's section before it ships;
if it has, it needs 0.10.0. Whoever deploys decides.
| | |
|---|---|
| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
**FCST-03 shipped the fill and no caller.** `price_forecast.fill_from_yesterday()`
was complete and checked, and so were the consumers written for it -
`TariffPlan.firm_points()`, `cheap_now()`'s refusal of a provisional hour, and
`arbitrage()`'s refusal 3, which drops provisional hours from the trade set
rather than refusing the whole series. Nothing in `app/` called the fill, so no
series ever contained a carried point and every one of those consumers was
exercising its all-firm branch.
⚠️ **THE DEFECT THAT PRODUCED WAS NOT AN ERROR ANYWHERE.** `load_prices()`
returns what the feed published and nothing widened it to the requested horizon,
so on a dynamic site between midnight and the 14-16 h CET day-ahead publication
the plan covered the rest of today and **nothing more** - typically 10-24 of the
48 hours `price_horizon_h` asked for. `arbitrage()` then took `max - min` over
that truncated window and reported a spread. The number was real, the window was
half the one configured, and **nothing said so**: tomorrow evening's peak, the
hour a morning charge would be sold into, was not in the series at all, so the
spread it would have created was never compared against anything.
**What changes:**
* `app/price_history.py` (new) keeps the **published** prices of the last
`KEEP_DAYS` Brussels days in `/data/price_history.json`, and
`resolve_tariff()` now fills every unpublished hour of the horizon from
yesterday's same-wall-clock hour, flagged provisional.
* `resolve_tariff(history=...)` is **required and keyword-only**. A caller with
no durable volume passes the explicit `NoHistory()`. A default of `None`
meaning "skip the fill" would hand a caller who forgot the argument exactly
the truncated horizon this card removes, silently.
* A new status row, **Price history**, names the days held and their hour
counts.
⚠️ **ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR.**
Carried hours are provisional, so §8.3 keeps them out of the trade set: the fill
widens what the optimiser can **see**, never what it may **do**. What does
change a trade is that the filled series is windowed to `[since, until)`, so
**hours that have already elapsed are no longer in the spread**. Before, a cheap
night that had already gone sat in the series as the day's cheapest hour - and a
cycle cannot buy in an hour that is past, so the trade was quietly never taken.
`test_price_history.py` section 2 grades this by finding the cheapest firm hour
with and without the fill and showing it **moves**.
⚠️ **A DEAD FEED IS STILL §11.4, NOT YESTERDAY.** The fill runs only on a
`series` read; a feed that published nothing still degrades to the configured
`static_profile`. The two are indistinguishable to the optimiser - both are
100 % provisional, so arbitrage refuses on the firm-pair rung and `cheap_now()`
is False - so preferring yesterday would change a documented degradation for a
difference only the status page can see.
`DECISIONS/FCST-04-the-day-that-is-carried.md` carries that and two more.
⚠️ **A BROKEN CACHE IS NOT A BROKEN CONTROLLER.** No `/data`, an unreadable
file, a schema this build does not know, a malformed point - four states, four
reasons, none of them a price and none of them an empty series. In every one the
plan is still a real provider series and the controller still regulates; the
horizon is just as short as it was before this card. **No option was added and
`config.yaml` is untouched.**
## 0.9.0
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
@@ -0,0 +1,98 @@
# FCST-04 — what "yesterday" is, and what the fill is allowed to reach
Three decisions, recorded because each had a defensible alternative that a later
reader would otherwise re-open from scratch.
---
## 1. The fill runs on a `series` read only. A dead feed still goes to §11.4.
`fill_from_yesterday()` accepts a `no_data` read and will happily build a
100 %-carried series from yesterday alone. `resolve_tariff()` does not let it:
a feed that published nothing still falls to `_degrade()` and §11.4's own row —
*"No price data → fall back to the static profile, mark the plan provisional,
no arbitrage."*
**Why not prefer yesterday's real prices to a coarse day/night profile?**
Because it buys nothing the optimiser can use, and it silently replaces a
fallback the operator configured.
Measured, not assumed: a fully-carried series is 100 % provisional, so
`TariffPlan.arbitrage()` finds fewer than two firm hours and refuses on §8.3's
firm-pair rung, and `cheap_now()` returns False because `firm_points()` is
empty. The §11.4 static fallback produces *exactly* those two answers. The two
paths differ only in which numbers the status page shows.
So the choice is between two identical control behaviours, and the tie-breaker
is that `static_profile` is a documented option an operator fills in **for this
case**. Overriding it for a cosmetic difference is a behaviour change nobody
asked for.
⚠️ **The consequence, stated so it is not discovered later as a bug:**
`fill_from_yesterday()`'s `no_data` branch is not reached from production.
It stays checked in `test_price_forecast.py`. Turning it on is a product
decision — it would want a rule about how old a carried profile may be before it
stops being offered at all — and not a wiring one.
---
## 2. "Yesterday" is a date, not the newest record.
`newest()` is the obvious accessor and it is the one that decays. An add-on down
from Friday to Monday would answer Friday's prices to a fill whose only
vocabulary is `provisional: true` — a flag that says "this is a guess", never
"this guess is three days old". Nothing downstream can tell the difference and
nothing reports it.
So `yesterday(stamp)` computes the Brussels date before `stamp`'s Brussels date
and looks up **that** record. Anything else is `no_data` with a reason naming
both the date wanted and the dates held.
The cost is real and accepted: after an outage of more than a day, the first
morning back has no fill. That is the honest state — there is no yesterday —
and the horizon is short for one morning rather than wrong for several.
---
## 3. What is recorded is the provider's read, and the mechanism is ordering.
Feeding the filled series back into the store is how this rots: day 2 carries
day 1's guesses, day 3 carries day 2's carry of them, and after a week of a dead
feed the "yesterday" being carried is a fossil of the last real publication.
Every point still carries `provisional=True`, so the **flag never lies** — what
decays is the number, behind a flag that is telling the truth.
⚠️ **No value-level check can catch that**, and claiming one would be worse than
claiming none. A carried point and a published-provisional point (`eneco_eddy`'s
current quarter, §9.2) are the same three fields carrying the same flag. The
difference is provenance, and provenance is not in the value.
The defence is therefore an **ordering**, not a validation:
- `carry()` is the only public entry point. It records its argument, then
returns the fill.
- The filled series exists only as that return value. The object never sees it
again.
- `_remember()` is private and called from exactly one place.
- `test_price_history.py` walks `app/` and fails if any other module names
`_remember`, and reads `carry()`'s AST to confirm the record happens before
the lookup — so a later edit that reverses them is red rather than plausible.
That ordering has a second benefit worth keeping: a feed that returns yesterday
alongside today refreshes yesterday's record from live data *before* it is
consumed as the profile.
---
## What this ticket does not claim
It does not make a trade possible that yesterday's prices justify. Carried hours
are dropped from the trade set by §8.3, and that is not a limitation to be fixed
later — it is the rule.
The one change that **does** move energy came from the windowing, not the
carrying: the filled series covers `[since, until)`, so hours that have already
elapsed are no longer in the spread. A cheap night that has gone used to be the
series' cheapest hour, and a cycle cannot buy in an hour that is past — so the
trade was silently never taken. `test_price_history.py` section 2 grades that by
finding the cheapest firm hour with and without the fill and showing it moves.
+34
View File
@@ -1424,6 +1424,40 @@ distinct state from "prices, all of them zero": the plan is marked provisional
and arbitrage is disabled, per the degradation matrix. It never falls back to a
price of zero, which would read as free electricity.
#### The hours the feed has not published yet (FCST-04)
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
Before that, the feed is serving today and nothing else — so a `price_horizon_h`
of 48 asks for 48 hours and gets back the ten or fifteen that are left in the
day. Until FCST-04 those other hours were simply **absent** from the plan: not
priced badly, not flagged, just missing, and nothing said so.
Now the add-on keeps the **published prices of the last few days** in
`/data/price_history.json` and fills every unpublished hour of the horizon with
yesterday's price for the same Brussels wall-clock hour, flagged
`provisional: true`. Two things follow, and the second one matters more:
- **The plan says it is guessing.** *Tariff* on the status page reads
`provisional` through the morning, because half its hours are. Before, a
half-length plan looked completely settled.
- **Nothing may trade on a carried hour.** §8.3 forbids acting on a provisional
price, so arbitrage drops those hours from the trade set entirely — it needs a
buy hour and a sell hour that were *both* published. Carrying a price forward
widens what the optimiser can **see**; it never widens what it may **do**.
The plan is also now cut to the horizon you asked for, which means **hours that
have already elapsed are no longer in it**. That is a real change to arbitrage:
a cheap night that has already gone used to sit in the series as the cheapest
hour of the day, and a cycle can never buy in an hour that is past — so the
trade was quietly never taken. The spread is now taken over hours still ahead.
A new status row, **Price history**, says which days are held and how many hours
each has. If it reads `empty` or `unavailable`, nothing is carried, the horizon
is as short as the feed makes it, and every other guarantee is unchanged — a
cache that cannot answer is not allowed to be a controller that cannot regulate.
The store is rebuilt by the next successful fetch; on a brand-new install the
first fill is possible the day after the first refresh.
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
often near zero, which by itself strongly favours self-consumption over export.
It is a single number rather than a curve because no supported supplier
+33 -2
View File
@@ -68,6 +68,7 @@ from . import peakguard
from . import packshare
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
from .store import migrate_maint_keys, migrate_unit_keys
@@ -165,7 +166,8 @@ def pack_energy_of(inv):
class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db"):
peak_db_path: str = "/data/tariff.db",
price_history_path: str = "/data/price_history.json"):
self.o = opts
self.hass = hass
self.store = store
@@ -582,6 +584,27 @@ class Controller:
self.peak.restore(self.p1.averager)
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
# FCST-04. The §8.3 carry-forward store: the published prices of the
# last few Brussels days, so the hours a day-ahead feed has not printed
# yet can be filled from yesterday's profile and FLAGGED, instead of
# being absent from the series entirely.
#
# ⚠️ IT IS AN INSTRUMENT'S BACKING FILE, NOT A STRATEGY. It commands
# nothing and claims nothing; what it changes is how many hours the
# plan covers. Before this, a dynamic site between midnight and the
# 14-16 h CET publication had a series covering only the rest of today
# - typically 10-24 of the 48 hours `price_horizon_h` asked for - and
# `TariffPlan.arbitrage()` took its spread over that truncated window
# without anything reporting that it had. Every carried hour is
# provisional, so refusal 3 drops it from the trade set: this widens
# what the optimiser can SEE, never what it may act on.
#
# ⚠️ A BROKEN OR ABSENT STORE IS NOT AN ERROR HERE. It answers `no_data`,
# the fill carries nothing, and the plan is exactly the pre-FCST-04
# one - short, but honest and still refusing on the same rules.
self.price_history = PriceHistory(price_history_path)
_LOG.info("§8.3 price history: %s", self.price_history.words)
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
# design (peakguard.py holds no history between calls), so the only
# state here is what an EDGE needs: what the last cycle concluded, so
@@ -3455,7 +3478,8 @@ class Controller:
now = datetime.now(timezone.utc)
try:
plan = await resolve_tariff(
self.o, now, now + timedelta(hours=horizon), now=now)
self.o, now, now + timedelta(hours=horizon), now=now,
history=self.price_history)
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
except Exception as err: # noqa: BLE001
@@ -4304,6 +4328,13 @@ class Controller:
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
# between a 48-hour plan and a 12-hour one on a weekday morning.
# It is its own row rather than a clause of "Tariff" because it
# answers a different question: "Tariff" says what the plan IS, and
# this says what the plan could still be filled from if the feed is
# late again tomorrow.
("Price history", self.price_history.words),
# OPT-04. The strategy's own sentence, not a re-derivation of it -
# every refusal it can make already carries a reason a human can
# act on, and "Arbitrage: no" would throw all of them away.
+433
View File
@@ -0,0 +1,433 @@
"""FCST-04 - the day the carry-forward carries, kept somewhere a restart cannot reach.
WHAT WAS MISSING, AND WHY IT WAS INVISIBLE. FCST-03 shipped
`price_forecast.fill_from_yesterday()` complete and checked, and shipped the
consumers that were written to expect it - `TariffPlan.firm_points()`,
`cheap_now()`'s refusal of a provisional hour, and `arbitrage()`'s refusal 3,
which drops provisional hours from the trade set instead of refusing the whole
series. Every one of those was live. The FILL ITSELF HAD NO CALLER: nothing in
`app/` referenced `fill_from_yesterday`, so no series ever contained a carried
point and every one of those consumers was exercising its all-firm branch.
The defect that produced is not an error anywhere. `load_prices()` returns
whatever the feed published and nothing widens it to the requested horizon, so
on a dynamic site between midnight and the 14-16 h CET day-ahead publication
the series covers the rest of TODAY and nothing more - typically 10-24 of the
48 hours `price_horizon_h` asked for. `TariffPlan.arbitrage()` then computes
`max - min` over that truncated window and reports a spread. The number is
real, the window is half the one the operator configured, and NOTHING SAYS SO:
tomorrow evening's peak - the hour a morning charge would be sold into - is
simply not in the series, so the spread it would have created cannot clear a
threshold it was never compared against. A missing hour is not a wrong price;
it is an hour the optimiser never learns exists.
So this module is the half FCST-03 left out, and it is deliberately the SMALL
half: the fill's arithmetic, its DST rule and its provenance audit all stay in
`price_forecast.py`, untouched. What lives here is the one thing that
arithmetic needs and could not have: A YESTERDAY THAT SURVIVES A RESTART.
WHY PERSISTENCE, AND NOT A VARIABLE ON THE REFRESH TASK
======================================================
An in-memory "the last full day I saw" costs nothing and is wrong exactly when
it matters. The window the fill exists for is midnight -> publication; an
add-on restarted at 08:00 - a config change, an HA update, a power cut - would
come up with no yesterday at all and spend that whole window on the truncated
series, which is the pre-FCST-03 behaviour arriving silently on the mornings
after an operator touched something. The fill's own value is a function of
surviving a restart, so the day it carries has to.
⚠️ AND NOT IN `/data/state.json`. That file holds the maintenance history and
SAFETY-02's sign latch, and `Store.set()` rewrites the whole of it on every
write. The tariff refresh runs every `TARIFF_REFRESH_S` (1800 s), so folding a
price cache into it would put 48 extra whole-file rewrites a day through the
file whose corruption reads as "no maintenance ever ran" and "never tripped".
The cache is worth having; it is not worth putting anywhere near that. Separate
file, separate blast radius.
⚠️ AND THE DIRECTORY IS NOT CREATED, which is `tariff.PeakStore`'s rule and its
reason applies unchanged: `/data` is mounted by the Supervisor and always
exists on a real install, so `makedirs` here could only ever succeed where the
durable volume is MISSING - manufacturing a cache on the container's ephemeral
layer that writes fine, reads fine, and is thrown away on the next restart.
That is a yesterday that silently forgets, which is worse than one that says it
cannot answer.
THE FOUR RULES THIS FILE IS SHAPED BY
=====================================
1. IT RECORDS WHAT THE PROVIDER PUBLISHED, NEVER WHAT THE FILL PRODUCED.
Feeding a filled series back in is the defect that makes the whole mechanism
rot: day 2 carries day 1's guesses, day 3 carries day 2's carry of them, and
after a week of a dead feed the "yesterday" being carried forward is a
fossil of the last real publication with nothing anywhere reporting its age.
Every point still carries `provisional=True`, so the flag never lies - what
decays is the NUMBER, silently, behind a flag that is telling the truth.
⚠️ NO VALUE-LEVEL CHECK CAN CATCH THAT, and saying so is more useful than a
guarantee that would not hold. A carried point and a published-provisional
point (`eneco_eddy`'s current quarter, §9.2) are the same three fields with
the same flag; the difference is provenance, and provenance is not in the
value. So the mechanism is ORDERING, not validation: `carry()` is the only
public entry point, it remembers the argument and returns the fill, and the
filled series exists only as a return value that `carry()` itself never
sees again. `_remember()` is private, is called from exactly one place, and
`test_price_history.py` walks the tree and fails if any module outside this
file names it.
2. "YESTERDAY" MEANS YESTERDAY, NOT "THE NEWEST DAY I HAVE".
`newest()` is the natural accessor and it is the one that decays: an add-on
down from Friday to Monday would answer Friday's prices to a fill that flags
them as one day old. The lookup is BY DATE - the Brussels day before the
Brussels day of the instant asked about - and a record for any other date is
NO_RECORD with a reason naming both dates. A three-day-old profile is not a
worse yesterday; it is not a yesterday.
3. A STORE THAT CANNOT ANSWER SAYS SO, IN THE TYPE.
`yesterday()` returns a `PriceRead`: a `series` when a full record exists,
`no_data(reason)` for every other outcome - no file, no record for that
date, an unreadable file, a record that parses to nothing. There is no
empty-series answer and no `.get(..., [])`-shaped accessor, for
`price.py`'s reason: an empty series is how "the cache is broken" gets to
wear the shape of "yesterday was free". `fill_from_yesterday()` takes a
`PriceRead` and handles `no_data` on its own, so the degraded case needs no
branch at the call site - it fills nothing and the horizon stays short,
which is exactly the behaviour that shipped before this ticket.
4. HOURS ACCUMULATE WITHIN A DAY; THEY DO NOT REPLACE IT.
A feed that serves only the REMAINING hours of the day (a legitimate shape -
nothing in the plugin contract promises a whole day) would, under
replace-the-record, overwrite a complete 24-hour profile with the two points
left at 22:00 and hand the next morning a yesterday with two hours in it. So
a record is MERGED by instant: a later read updates the hours it carries and
leaves the rest, and a revised price for an hour already held wins because
it is the newer statement about the same hour.
⚠️ KEYED BY INSTANT, NOT BY LOCAL HOUR, AND THAT IS THE DST DECISION. Storing
`hour -> price` would collapse the two 02:00s of a 25-hour October day into
one, inventing a second and different collapsing rule beside the one
`fill_from_yesterday()` already documents ("the first of its two 02:00 points
is the one used, deterministically"). Storing the instants keeps both, in
order, and lets the fill's own rule be the only one in the system.
WHAT THIS DOES NOT DO, ON PURPOSE
=================================
It does not extend the fill to a feed that published NOTHING. `resolve_tariff()`
applies `carry()` only on a `series` read; a `no_data` read still falls to
`_degrade()` and §11.4's row - "No price data -> fall back to the static
profile, mark the plan provisional, no arbitrage" - which is the fallback the
operator configured `static_profile` FOR. Preferring yesterday's feed over it
would change a documented degradation without being asked, and it would buy
nothing measurable: a fully-carried series is 100 % provisional, so
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()` is
False, exactly as on the §11.4 fallback. The two differ only in which numbers
the status page shows. `fill_from_yesterday()` handles a `no_data` read anyway,
and that branch is therefore not reached from production - it stays checked,
and turning it on is a product decision, not a wiring one. See
DECISIONS/FCST-04-the-day-that-is-carried.md.
"""
import json
import logging
import os
import tempfile
from datetime import datetime, timedelta, timezone
from .price import PricePoint, PriceProduct, PriceRead, brussels
from .price_forecast import fill_from_yesterday
_LOG = logging.getLogger("goodwe.price.history")
# How many Brussels days of published prices are kept. The fill needs exactly
# one - yesterday - and rule 2 refuses anything older, so this is not a horizon:
# it is the number of days a record may sit unused before pruning removes it,
# and it is small because a bigger cache would only hold days no lookup can ask
# for. Three covers today, yesterday, and the day a fetch spanning midnight
# writes ahead of itself.
KEEP_DAYS = 3
SCHEMA = 1
def _bdate(instant: datetime) -> str:
"""The Brussels calendar date of an instant, as `YYYY-MM-DD`.
⚠️ BRUSSELS, NOT UTC. 23:30 UTC in July is already tomorrow in Belgium, and
a cache keyed on the UTC date would file the last 90-120 minutes of every
day under the wrong one - so a lookup on the following morning would find a
"yesterday" missing its own evening hours, which are the expensive ones.
"""
return brussels(instant).strftime("%Y-%m-%d")
class PriceHistory:
"""The published price series of the last few Brussels days, in /data.
⚠️ `ok` IS ABOUT THE FILE, NOT ABOUT THE DATA. A healthy store with no
record for yesterday is `ok` and answers `no_data` - a normal state on a
site's first day, and a different thing from a store that could not be
opened. Both are visible: `ok`/`last_error` describe the file, and
`yesterday()`'s reason describes the answer.
"""
def __init__(self, path: str = "/data/price_history.json"):
self.path = path
self.ok = False
self.last_error: str | None = None
self._days: dict = {}
self._open()
# -- the file ------------------------------------------------------------
def _open(self) -> None:
parent = os.path.dirname(self.path)
# See the module note: NOT created. A cache on the ephemeral layer is
# one that forgets without saying so.
if parent and not os.path.isdir(parent):
self.last_error = f"no durable volume at {parent}"
# INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong.
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
"to carry and the horizon stays as short as the feed",
self.last_error)
return
self.ok = True
self._load()
def _load(self) -> None:
try:
with open(self.path, "r", encoding="utf-8") as fh:
blob = json.load(fh)
except FileNotFoundError:
_LOG.info("no price history yet at %s - the first refresh writes "
"one, and the fill has nothing to carry until the day "
"after that", self.path)
return
except (json.JSONDecodeError, OSError) as err:
# A corrupt cache must not stop the controller, and must not be
# silently re-created either: the record is discarded, the reason
# is kept, and `yesterday()` reports it rather than answering
# "no record", which would read as a normal first day.
self.last_error = f"unreadable ({err})"
_LOG.warning("price history at %s is %s - starting from empty. "
"§8.3 carry-forward has nothing to carry until the "
"next refresh has written a full day",
self.path, self.last_error)
return
if not isinstance(blob, dict) or blob.get("version") != SCHEMA:
found = blob.get("version") if isinstance(blob, dict) else None
self.last_error = f"schema {found!r}, not {SCHEMA}"
_LOG.warning("price history at %s has %s - discarding it rather "
"than guessing what its points mean",
self.path, self.last_error)
return
days = blob.get("days")
if not isinstance(days, dict):
self.last_error = "no `days` object"
_LOG.warning("price history at %s has %s", self.path, self.last_error)
return
self._days = days
def _save(self) -> None:
if not self.ok:
return
try:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path))
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump({"version": SCHEMA, "days": self._days}, fh, indent=1)
# Atomic: a power cut mid-write must not leave a truncated file
# that parses as an empty day.
os.replace(tmp, self.path)
except OSError as err:
self.last_error = f"could not write ({err})"
_LOG.error("could not persist price history: %s", err)
# -- the one public entry point -----------------------------------------
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
stamp: datetime) -> PriceRead:
"""Remember what this read published, then §8.3-fill the missing hours.
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
recorded FIRST, so a feed that hands back yesterday alongside today
refreshes yesterday's record from live data before it is consumed as
the profile; and the filled series - the one thing that must never be
recorded - exists only as this method's return value, which this object
never sees again. See rule 1.
Returns whatever `fill_from_yesterday()` returns: a series over
[since, until) whose published hours are the provider's own point
objects and whose remaining hours are carried and flagged, or
`no_data` when neither source can produce a single hour.
"""
self._remember(read, stamp)
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
since=since, until=until, stamp=stamp)
# -- the halves, private because rule 1 is an ordering ------------------
def _remember(self, read: PriceRead, stamp: datetime) -> None:
"""File a provider read's points under their own Brussels days.
⚠️ PRIVATE, AND CALLED FROM EXACTLY ONE PLACE. A caller that can reach
this can record a filled series, which is the decay in rule 1. There is
no value-level defence against that - a carried point and a published
provisional point are the same three fields - so the defence is that
nothing outside this file names it, and `test_price_history.py` checks
the tree for that rather than trusting the underscore.
"""
points = read.match(series=lambda r: r.points, no_data=lambda _r: ())
if not points:
return
product = read.match(series=lambda r: r.product.value,
no_data=lambda _r: None)
provider = read.match(series=lambda r: r.provider,
no_data=lambda _r: None)
touched = set()
for p in points:
day = _bdate(p.start)
rec = self._days.setdefault(
day, {"product": product, "provider": provider, "points": []})
# Rule 4: MERGE by instant. A later read updates the hours it
# carries and leaves the rest; a revised price for an hour already
# held wins, because it is the newer statement about that hour.
held = {q["start"]: q for q in rec.get("points", [])
if isinstance(q, dict) and "start" in q}
held[p.start.isoformat()] = p.canonical()
rec["points"] = sorted(held.values(), key=lambda q: q["start"])
rec["product"], rec["provider"] = product, provider
touched.add(day)
self._prune(stamp)
self._save()
_LOG.debug("price history: %d point(s) filed under %s; %d day(s) held",
len(points), ", ".join(sorted(touched)), len(self._days))
def _prune(self, stamp: datetime) -> None:
"""Keep the newest KEEP_DAYS records. Older ones can never be a lookup.
Pruning is by SORT ORDER, not by arithmetic against `stamp`: a clock
that jumped - the failure mode WITNESS-03 spent a session on - would
otherwise delete every record in one pass and leave the fill with
nothing on the morning after, which is the one morning it is for.
"""
if len(self._days) <= KEEP_DAYS:
return
for day in sorted(self._days)[:-KEEP_DAYS]:
del self._days[day]
def yesterday(self, stamp: datetime) -> PriceRead:
"""The published series of the Brussels day BEFORE `stamp`'s, or no_data.
⚠️ THE DATE IS COMPUTED, NOT LOOKED UP. See rule 2: `newest()` would
answer Friday's prices to a Monday fill, and the fill would flag them as
one day old because that is the only thing its flag can say.
"""
if stamp.tzinfo is None or stamp.utcoffset() is None:
raise ValueError(
f"yesterday({stamp!r}): a naive datetime is not an instant, "
f"and 'which Brussels day was yesterday' is exactly the "
f"question a wall-clock reading cannot answer.")
want = _bdate(brussels(stamp) - timedelta(days=1))
if self.last_error and not self._days:
return PriceRead.no_data(
f"the price history at {self.path} is {self.last_error}, so "
f"§8.3 has no profile to carry forward for {want}; the plan "
f"covers only the hours the feed published")
rec = self._days.get(want)
if rec is None:
have = ", ".join(sorted(self._days)) or "no days at all"
return PriceRead.no_data(
f"no published prices recorded for {want} (held: {have}), so "
f"§8.3 has no profile to carry forward. A record for another "
f"day is not a yesterday")
points, bad = _parse_points(rec.get("points"))
if not points:
why = bad or "it is empty"
return PriceRead.no_data(
f"the {want} record holds no usable point ({why}), and an "
f"empty series is not a free day")
try:
product = PriceProduct(rec.get("product"))
except ValueError:
return PriceRead.no_data(
f"the {want} record names product {rec.get('product')!r}, "
f"which is not a PriceProduct. The optimiser switches on that "
f"enum, so a record that cannot name one cannot be a series")
if bad:
_LOG.warning("price history: %s in the %s record were dropped; "
"%d point(s) carried forward", bad, want, len(points))
return PriceRead.series(points, product,
str(rec.get("provider") or "price_history"),
stamp)
# -- observability -------------------------------------------------------
@property
def words(self) -> str:
"""One line for the status page. Says what is held, or why nothing is."""
if not self.ok:
return f"unavailable - {self.last_error}"
if not self._days:
why = f" - {self.last_error}" if self.last_error else ""
return f"empty{why} - nothing to carry forward yet"
return "; ".join(f"{d} ({len(self._days[d].get('points') or ())} h)"
for d in sorted(self._days))
def _parse_points(raw) -> tuple:
"""Canonical dicts -> PricePoints, and a phrase naming what was dropped.
⚠️ A MALFORMED POINT IS DROPPED, NOT DEFAULTED, and the drop is reported.
`float(row.get("eur_per_kwh", 0))` is the shape of the whole family of
defects this repo has been removing: a cache that lost a field would answer
"that hour was free" to a fill that flags it as an ordinary guess.
"""
if not isinstance(raw, list):
return (), "the `points` field is not a list"
out, dropped = [], 0
for row in raw:
if not isinstance(row, dict):
dropped += 1
continue
try:
start = datetime.fromisoformat(row["start"])
value = float(row["eur_per_kwh"])
flag = row["provisional"]
except (KeyError, TypeError, ValueError):
dropped += 1
continue
if start.tzinfo is None or start.utcoffset() is None \
or not isinstance(flag, bool):
dropped += 1
continue
out.append(PricePoint(start=start, eur_per_kwh=value, provisional=flag))
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
class NoHistory:
"""The explicit "this caller keeps no history" - a NAME, not a `None`.
⚠️ IT EXISTS SO THE PARAMETER CAN BE REQUIRED. `history=None` meaning "skip
the fill" is the shape this repo keeps removing: a caller who forgets the
argument gets the truncated-horizon behaviour of the bug this ticket is
about, silently, and the code reads as though the fill is happening. With
no default there is nothing to forget, and a caller with no durable volume
- a check, a replay harness - has to say `NoHistory()` and be visible in
the diff.
It carries out the ordering faithfully: it records nothing and carries
nothing, so `carry()` returns the provider's read unchanged and the plan is
exactly what it was before FCST-04.
"""
ok = False
last_error = "no history is kept by this caller"
words = "not kept"
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
return read
def yesterday(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data(
"this caller keeps no price history (NoHistory), so §8.3 has no "
"profile to carry forward")
+41 -2
View File
@@ -1019,7 +1019,7 @@ def static_status(opts: dict, now: datetime):
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
transport=None, now=None) -> TariffPlan:
transport=None, now=None, *, history) -> TariffPlan:
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
@@ -1033,9 +1033,34 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
price_provider off + profile -> the CONFIGURED product (§9.2 rows 3-4)
price_provider off + none -> no prices at all (§11.4, nothing to
fall back to)
provider series -> the feed (§9.2 rows 1-2)
provider series -> the feed, §8.3-FILLED (§9.2 rows 1-2)
provider no_data + profile -> §11.4 FALLBACK, provisional, no arbitrage
provider no_data + none -> no prices at all
⚠️ `history` IS REQUIRED, KEYWORD-ONLY, AND HAS NO DEFAULT (FCST-04). It is
the §8.3 carry-forward store - `price_history.PriceHistory`, or the explicit
`price_history.NoHistory()` for a caller that keeps none. A default of
`None` meaning "skip the fill" would give a caller who forgot the argument
the TRUNCATED HORIZON this ticket exists to remove, silently, while the code
read as though the fill were happening. There is nothing to forget now.
⚠️ AND THE FILL RUNS ONLY ON A `series` READ. A feed that published nothing
still falls to `_degrade()` and §11.4's own row - the static profile the
operator configured FOR that case - rather than to yesterday's feed. The two
are indistinguishable to the optimiser (both are 100 % provisional, so
`arbitrage()` finds fewer than two firm hours and refuses, and `cheap_now()`
is False), so preferring yesterday would change a documented degradation for
a difference only the status page can see. See price_history.py's closing
note and DECISIONS/FCST-04-the-day-that-is-carried.md.
⚠️ THE FILLED SERIES IS WINDOWED TO [since, until), AND THAT IS A CHANGE.
`load_prices()` returns whatever the feed sent, past hours included, and
`TariffPlan.arbitrage()` took its spread over all of it - so an hour that
had already gone could be the cheapest hour of a cycle nothing can now buy
in. `fill_from_yesterday()` walks the requested horizon and emits exactly
that, so the spread is over hours that are still ahead. Both directions of
the difference are real: the window gains the unpublished hours it was
missing and loses the elapsed ones it should never have had.
"""
stamp = now or datetime.now(timezone.utc)
pid = str(price_option(opts, "price_provider") or "off").strip()
@@ -1044,6 +1069,20 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
read = await load_prices(opts, since, until, transport)
return read.match(
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
"""§8.3: record what the feed published, carry yesterday into the gaps.
The fill can itself return `no_data` - a feed whose points all fall outside
the horizon, with nothing in the history to fill it from - and that is the
§11.4 row arrived at one step later, so it degrades exactly as a failed
fetch does rather than being special-cased here.
"""
filled = history.carry(read, since=since, until=until, stamp=stamp)
return filled.match(
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
no_data=lambda r: _degrade(opts, r, since, until, stamp))
+734
View File
@@ -0,0 +1,734 @@
"""Runnable check for FCST-04 - the day the fill carries. `python3 test_price_history.py`
WHAT THIS FILE IS DEFENDING. FCST-03 shipped `fill_from_yesterday()` and the
consumers written for it, and shipped NO CALLER. This file grades the caller:
that a partially-published day-ahead feed now produces a plan covering the whole
requested horizon, that the day it carries survives a restart, and that the
cache cannot decay by being fed its own output.
AC1 THE HOLE IS CLOSED. `resolve_tariff()` on a feed that has published 10
of 48 hours returns a series covering 48, the 38 unpublished hours are
provisional and the 10 published ones are the provider's own objects,
unchanged and unflagged. Graded against the same call with
`NoHistory()`, which is the pre-FCST-04 behaviour, so the difference is
MEASURED rather than described.
AC2 THE WINDOW STOPPED INCLUDING THE PAST, which is the half of this change
that moves energy. `load_prices()` returns whatever the feed sent, so at
09:00 a spread was taken over hours that had already elapsed and the
"cheapest hour" could be one no cycle can buy in - the trade is then
silently never taken. The filled series is windowed to [since, until),
so the firm cheapest hour is a hour still ahead. Graded by finding the
cheapest firm hour both ways and showing it MOVES.
AC3 THE CACHE CANNOT DECAY. What is recorded is the provider's read and
never the filled series; if it were, day 2 would carry day 1's guesses
and day 3 would carry day 2's carry of them, behind a `provisional` flag
that stays true while the NUMBER rots. Graded by driving `carry()` twice
and reading the file back.
AC4 A STORE THAT CANNOT ANSWER SAYS SO. No file, no record for yesterday, an
unreadable file, a record whose points are malformed - four different
reasons, none of them a price, none of them an empty series, and the
controller keeps its pre-FCST-04 plan through all four.
THE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
* EVERY VERDICT IS DERIVED. The carried/published split is re-derived here
from the set of hours the fixture published, by different arithmetic than
app/ uses. The horizon growth is a subtraction of two measured counts, not
a constant. The cheapest-hour move is found by looking, not asserted.
* NO STIMULUS IS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE:
- YESTERDAY AND TODAY MUST PRICE EVERY LOCAL HOUR DIFFERENTLY, or a fill
that carried the wrong day is invisible. Asserted first, as a check.
- THE PUBLISHED WINDOW MUST BE A STRICT SUBSET OF THE HORIZON, or "the
fill widened it" and "the fill did nothing" are the same observation.
- THE ELAPSED HOURS MUST CONTAIN THE CHEAPEST HOUR OF THE WHOLE FEED, or
AC2 grades nothing: the windowed and unwindowed cheapest hour would be
the same point either way. This is also the realistic shape of the
danger - a cheap night that has already gone, holding a battery out of
a trade all morning.
- THE MERGE FIXTURE MUST REVISE A PRICE TO A DIFFERENT NUMBER, or merge
and replace are indistinguishable.
- THE PRUNE FIXTURE MUST HOLD MORE DAYS THAN KEEP_DAYS, or pruning is not
exercised at all.
* A FAILED READ IS NOT AN OBSERVATION. Every degraded store is graded on the
TYPE it answers with (`no_data` and its reason), never on a falsy value,
and the malformed-point case is checked for the specific defect - a price
defaulted to 0.0 - rather than for a count.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. See MUTATION.md and TEST-15.
"""
import ast
import asyncio
import inspect
import json
import os
import shutil
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from app import price_history as _ph
from app.price import (
PricePoint, PriceProduct, PriceRead, brussels,
)
from app.price_history import KEEP_DAYS, NoHistory, PriceHistory, SCHEMA
from app.static_tariff import resolve_tariff
UTC = timezone.utc
HERE = os.path.dirname(os.path.abspath(__file__))
fails = []
_tmpdirs = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def why(read):
"""A read's reason, or "" when it is a series.
⚠️ NOT `read.reason`. On a `_Series` that attribute RAISES `Absence` by
design, so a mutation that makes a refusing accessor answer a series would
take this file down with a traceback instead of reporting a FAIL - and
MUTATION.md's rule is that "it was refused" and "it broke on the way to
being refused" must not grade the same.
"""
return read.match(series=lambda _r: "", no_data=lambda r: r.reason)
def raises(fn, exc):
try:
fn()
except exc:
return True
except Exception:
return False
return False
def run(coro):
return asyncio.run(coro)
def tmpdir():
d = tempfile.mkdtemp(prefix="fcst04-")
_tmpdirs.append(d)
return d
def store():
"""A history on a fresh directory that looks durable."""
return PriceHistory(os.path.join(tmpdir(), "price_history.json"))
# --------------------------------------------------------------------------- #
# The fixtures.
#
# ⚠️ A DAY IS A BRUSSELS DAY, BUILT BY SEARCH. The first draft of this file made
# a day 24 UTC hours long and every count in it came out one short: in January
# Belgium is UTC+1, so 23:00 UTC already belongs to tomorrow, and a "24-hour
# day" built by adding hours to midnight UTC is 23 hours of one Brussels day
# plus one hour of the next. That is the same class of error the module is
# keyed to avoid, so the fixture must not contain it - and it is found by
# LOOKING at each instant's Brussels date rather than by adding an offset a
# helper would have to be right about.
#
# ⚠️ THE INSTANT IS 09:00 BRUSSELS ON A WEEKDAY, WHICH IS THE WHOLE POINT. The
# day-ahead auction publishes at 14-16 h CET, so at this instant a healthy feed
# is serving TODAY IN FULL and nothing of tomorrow - the window this ticket
# exists for. A fixture at 20:00 would have both days and would grade nothing.
# --------------------------------------------------------------------------- #
TODAY_D = "2026-01-15" # a Thursday; Belgium on CET
YDAY_D = "2026-01-14"
HORIZON_H = 48
def bday_hours(bdate: str) -> list:
"""The UTC instants whose BRUSSELS date is `bdate`, one per local hour."""
probe = datetime.fromisoformat(bdate + "T00:00:00+00:00") - timedelta(hours=3)
out = []
for n in range(30):
u = probe + timedelta(hours=n)
if brussels(u).strftime("%Y-%m-%d") == bdate:
out.append(u)
return out
TODAY_H = bday_hours(TODAY_D)
YDAY_H = bday_hours(YDAY_D)
def at_local(bdate: str, hh: int) -> datetime:
"""The UTC instant whose Brussels wall clock reads hh:00 on `bdate`."""
return next(u for u in bday_hours(bdate) if brussels(u).hour == hh)
NOW = at_local(TODAY_D, 9)
# Today's feed, in EUR/MWh, keyed by BRUSSELS local hour and all distinct.
# ⚠️ THE CHEAPEST HOUR IS 03:00 LOCAL - ALREADY ELAPSED AT 09:00 - and that is
# AC2's stimulus, asserted below rather than assumed.
TODAY_MWH = {h: 120.0 + h for h in range(24)}
TODAY_MWH[3] = 5.0 # the cheap night that has already gone
TODAY_MWH[19] = 260.0 # the dear evening still ahead
# Yesterday's published prices, DIFFERENT AT EVERY LOCAL HOUR from today's, so
# a fill that carried the wrong day is visible.
YESTERDAY_MWH = {h: 400.0 + 3 * h for h in range(24)}
MARKUP, TAXES = 0.03, 0.06
def allin(mwh):
"""EUR/MWh as published -> the all-in EUR/kWh `assemble()` produces."""
return round(mwh * 0.001 + MARKUP + TAXES, 6)
def feed_body(instants, table):
return json.dumps({"data": [
{"start": u.isoformat(), "price_eur_mwh": table[brussels(u).hour]}
for u in instants]}).encode()
def transport_for(body):
async def _t(url, timeout_s):
return body
return _t
CFG = {"price_provider": "generic_dayahead",
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
"price_timeout_s": 1,
"price_markup_eur_kwh": MARKUP,
"price_taxes_eur_kwh": TAXES,
"static_profile": ""}
def series_of(instants, table, product=PriceProduct.DAY_AHEAD,
provider="generic_dayahead", stamp=None):
"""A PriceRead.series built directly - the shape `load_prices()` returns."""
return PriceRead.series(
[PricePoint(start=brussels(u),
eur_per_kwh=allin(table[brussels(u).hour]),
provisional=False)
for u in instants],
product, provider, stamp or NOW)
# --------------------------------------------------------------------------- #
print("0. the arithmetic this file grades with must not be degenerate")
check(f"a Brussels day is 24 hours long in the fixture helper, on both fixture "
f"days ({len(TODAY_H)}, {len(YDAY_H)}) - a 23-hour 'day' built by adding "
f"to midnight UTC is what made every count below come out one short",
len(TODAY_H) == 24 and len(YDAY_H) == 24
and {brussels(u).hour for u in TODAY_H} == set(range(24)))
# The feed serves TODAY IN FULL - which is what a day-ahead feed does - so the
# hours it publishes that are still AHEAD are a strict subset of the horizon.
_ahead = [u for u in TODAY_H if u >= NOW]
check(f"at 09:00 the hours the feed published that have NOT elapsed are a "
f"STRICT SUBSET of the {HORIZON_H} h horizon ({len(_ahead)} of "
f"{HORIZON_H}), so 'the fill widened the plan' and 'the fill did "
f"nothing' are different observations",
0 < len(_ahead) < HORIZON_H)
check("yesterday prices EVERY local hour differently from today, so a fill "
"that carried the wrong day is visible in the numbers",
all(TODAY_MWH[h] != YESTERDAY_MWH[h] for h in range(24)))
check("...and no fixture price is zero, so 'dropped the price' is visible too",
all(TODAY_MWH.values()) and all(YESTERDAY_MWH.values()))
_cheapest_all = min(TODAY_H, key=lambda u: TODAY_MWH[brussels(u).hour])
_cheapest_ahead = min(_ahead, key=lambda u: TODAY_MWH[brussels(u).hour])
check(f"AC2's stimulus is real: the cheapest hour of the whole published day is "
f"{brussels(_cheapest_all).hour:02d}:00 local, which has already elapsed "
f"at 09:00, and the cheapest hour still AHEAD is a different one "
f"({brussels(_cheapest_ahead).hour:02d}:00). Without this the windowed "
f"and unwindowed answers would be the same point and AC2 would grade "
f"nothing",
_cheapest_all != _cheapest_ahead and _cheapest_all < NOW
and _cheapest_ahead >= NOW)
check(f"KEEP_DAYS is {KEEP_DAYS}, and the prune fixture below files more days "
f"than that, so pruning is exercised rather than skipped", KEEP_DAYS < 6)
# --------------------------------------------------------------------------- #
print()
print("1. AC1 - the hole: a partially-published feed now covers the horizon")
# ⚠️ THE FEED PUBLISHES TODAY IN FULL, WHICH IS WHAT A DAY-AHEAD FEED DOES.
# The partial publication this ticket is about is that TOMORROW is missing, not
# that this morning is - and the elapsed hours the feed is still serving are
# exactly what AC2 is about, so they have to be in the body.
_body_today = feed_body(TODAY_H, TODAY_MWH)
_until = NOW + timedelta(hours=HORIZON_H)
# The pre-FCST-04 behaviour, by the shipped code path: NoHistory() records
# nothing and carries nothing, so this IS the old plan.
_before = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
history=NoHistory()))
# And the same call with a history that already holds yesterday.
_h1 = store()
_h1_seed = series_of(YDAY_H, YESTERDAY_MWH)
_ph.PriceHistory._remember(_h1, _h1_seed, NOW) # seeding, not a caller
_after = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
history=_h1))
_n_before = len(_before.read.points)
_n_after = len(_after.read.points)
check(f"the plan's horizon GREW, measured: {_n_before} h before FCST-04, "
f"{_n_after} h after, against a {HORIZON_H} h request",
_n_after > _n_before and _n_after == HORIZON_H)
check(f"...and before the change it was exactly what the feed sent "
f"({len(TODAY_H)} h of one day), which is the defect from both ends: the "
f"{HORIZON_H - len(_ahead)} hours past the feed's own horizon were not "
f"short-priced but ABSENT, and the {len(TODAY_H) - len(_ahead)} hours "
f"that had already elapsed were in the series anyway",
_n_before == len(TODAY_H))
# The split, re-derived here from the fixture rather than read off the module.
_published_instants = {u.astimezone(UTC) for u in _ahead}
_got_carried = {p.start.astimezone(UTC) for p in _after.read.points
if p.provisional}
_got_firm = {p.start.astimezone(UTC) for p in _after.read.points
if not p.provisional}
check("AC1: exactly the hours the feed published are FIRM, and exactly the "
"hours it did not are PROVISIONAL - the flag is a claim about "
"provenance, re-derived here from the fixture",
_got_firm == _published_instants
and _got_carried == {p.start.astimezone(UTC)
for p in _after.read.points} - _published_instants
and len(_got_carried) == HORIZON_H - len(_ahead))
_before_by_start = {p.start.astimezone(UTC): p for p in _before.read.points}
check("...and every published point is the PROVIDER'S OWN OBJECT, passed "
"through by identity - a copy is where the flag gets lost (FCST-03)",
all(p.eur_per_kwh == _before_by_start[p.start.astimezone(UTC)].eur_per_kwh
for p in _after.read.points if not p.provisional))
# The carried numbers are yesterday's, at the same BRUSSELS wall-clock hour.
# ⚠️ THE LOOKUP IS BUILT BY SEARCH over yesterday's own instants, not by
# subtracting 24 h from today's - a fill that subtracted a fixed day is exactly
# what this is meant to catch, and a helper that subtracts one cannot catch it.
_Y_BY_LOCAL = {}
for _u in YDAY_H:
_Y_BY_LOCAL.setdefault(brussels(_u).hour,
allin(YESTERDAY_MWH[brussels(_u).hour]))
_carried_ok = [_Y_BY_LOCAL.get(brussels(p.start).hour) is not None
and abs(p.eur_per_kwh
- _Y_BY_LOCAL[brussels(p.start).hour]) < 1e-9
for p in _after.read.points if p.provisional]
check(f"...and every carried number is YESTERDAY's price for the same BRUSSELS "
f"wall-clock hour, not today's and not a neighbour's "
f"({sum(_carried_ok)}/{len(_carried_ok)})",
_carried_ok and all(_carried_ok))
check("the plan reports itself PROVISIONAL now, which it did not before - the "
"old plan claimed full firmness over a horizon it covered half of",
_after.provisional and not _before.provisional)
check("...and the plan is still a provider plan, not a §11.4 fallback: the "
"fill does not degrade anything",
_after.origin == "provider" and _after.product is PriceProduct.DAY_AHEAD)
# --------------------------------------------------------------------------- #
print()
print("2. AC2 - the spread stopped being taken over hours that have gone")
_A = dict(peak_guard_active=False, min_spread_eur_kwh=0.0,
uncertainty=frozenset())
_v_before = _before.arbitrage(**_A)
_v_after = _after.arbitrage(**_A)
check("both plans still ALLOW a trade, so this section compares two verdicts "
"rather than a verdict and a refusal",
_v_before.kind == "allowed" and _v_after.kind == "allowed")
_cheap_before = _v_before.cheapest.start.astimezone(UTC)
_cheap_after = _v_after.cheapest.start.astimezone(UTC)
check(f"AC2: the cheapest FIRM hour MOVED from {_cheap_before.isoformat()} - "
f"already elapsed at {NOW.isoformat()}, so no cycle could ever buy in it "
f"- to {_cheap_after.isoformat()}, which is still ahead",
_cheap_before < NOW <= _cheap_after and _cheap_before != _cheap_after)
check("...and the new one is the cheapest hour the feed published that has NOT "
"gone, derived from the fixture rather than from the module",
_cheap_after == _cheapest_ahead.astimezone(UTC))
check("...and no point of the filled series starts before the requested "
"`since` hour: the window is [since, until) and the past is not in it",
min(p.start.astimezone(UTC) for p in _after.read.points)
>= NOW.replace(minute=0, second=0, microsecond=0))
# ⚠️ THE HONEST LIMIT OF THIS TICKET, checked so it cannot be overclaimed
# later: the CARRIED hours buy no trade. §8.3 forbids acting on a provisional
# price and refusal 3 drops them from the trade set, so what widened is what
# the optimiser can SEE, and the trade above came from the WINDOWING, not from
# yesterday's numbers.
_firm_after = [p for p in _after.read.points if not p.provisional]
check("the carried hours are NOT in the trade set - the verdict's cheapest and "
"dearest are both firm, so this ticket widened what the optimiser sees "
"and not what it may act on",
not _v_after.cheapest.provisional and not _v_after.dearest.provisional
and len(_firm_after) == len(_ahead))
# --------------------------------------------------------------------------- #
print()
print("3. AC3 - the cache records the FEED, never its own output")
_h2 = store()
_read2 = series_of(TODAY_H, TODAY_MWH)
_filled2 = _h2.carry(_read2, since=NOW, until=_until, stamp=NOW)
# Round two, as the refresh task does every 1800 s: the same partial feed.
_filled2b = _h2.carry(_read2, since=NOW, until=_until, stamp=NOW)
_disk = json.load(open(_h2.path, encoding="utf-8"))
_today_key = brussels(NOW).strftime("%Y-%m-%d")
_recorded = {p["start"] for p in _disk["days"][_today_key]["points"]}
_recorded_utc = {datetime.fromisoformat(s).astimezone(UTC) for s in _recorded}
check(f"AC3: after two `carry()` rounds the record for {_today_key} holds "
f"EXACTLY the {len(TODAY_H)} hours the feed published "
f"({len(_recorded_utc)} held) - not one of the "
f"{HORIZON_H - len(_ahead)} hours the fill invented",
_recorded_utc == {u.astimezone(UTC) for u in TODAY_H})
check("...and no recorded point is flagged provisional, because a carried "
"point never reached the file - the flag would still read true while the "
"NUMBER decayed, which is the failure this rule is about",
not any(p["provisional"] for p in _disk["days"][_today_key]["points"]))
def _canon(read):
return [p.canonical() for p in read.match(series=lambda r: r.points,
no_data=lambda _r: ())]
check(f"...and the fill is idempotent across the two rounds, which is §5.4's "
f"late-publication contract - the refresh task runs it again every "
f"1800 s against whatever the feed says by then "
f"({len(_canon(_filled2))} h both times)",
_canon(_filled2) and _canon(_filled2) == _canon(_filled2b))
# The ordering is the mechanism, so the mechanism is checked in the tree.
_APP = os.path.join(HERE, "app")
_leaks = []
for name in sorted(os.listdir(_APP)):
if not name.endswith(".py") or name == "price_history.py":
continue
src = open(os.path.join(_APP, name), encoding="utf-8").read()
if "_remember" in src:
_leaks.append(name)
check(f"AC3's mechanism is an ORDERING, not a validation - a carried point and "
f"a published-provisional point are the same three fields - so nothing "
f"outside app/price_history.py may name `_remember` {_leaks or 'none do'}",
not _leaks)
_carry_src = inspect.getsource(PriceHistory.carry)
_tree = ast.parse(_carry_src.strip())
_calls = [n.func.attr for n in ast.walk(_tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)]
check(f"...and `carry()` records BEFORE it fills, so a feed carrying yesterday "
f"alongside today refreshes the profile before consuming it {_calls}",
_calls.index("_remember") < _calls.index("yesterday"))
# --------------------------------------------------------------------------- #
print()
print("4. rule 2 - 'yesterday' is a DATE, not 'the newest day I have'")
_h3 = store()
_ph.PriceHistory._remember(
_h3, series_of(bday_hours("2026-01-12"), YESTERDAY_MWH), NOW)
_y3 = _h3.yesterday(NOW)
check("a store holding only the day before LAST answers no_data, not those "
"prices - `newest()` is the accessor that decays, and an add-on down "
"over a weekend is when it would",
_y3.kind == "no_data")
check(f"...and the reason names BOTH the date wanted and the dates held, so an "
f"operator can see which it is: {why(_y3)!r}",
"2026-01-14" in why(_y3) and "2026-01-12" in why(_y3))
_h4 = store()
_ph.PriceHistory._remember(
_h4, series_of(YDAY_H, YESTERDAY_MWH), NOW)
_y4 = _h4.yesterday(NOW)
check("...while the day BEFORE this one answers a series, so the refusal above "
"is about the date and not about the store being broken",
_y4.kind == "series" and len(_y4.points) == 24)
check("a naive instant is refused rather than assumed to be UTC - 'which "
"Brussels day was yesterday' is the question a wall clock cannot answer",
raises(lambda: _h4.yesterday(datetime(2026, 1, 15, 9)), ValueError))
# The Brussels-date rule, on the fixture a UTC-date rule gets wrong. A feed
# serving the 24 UTC hours of a July day spans TWO Brussels days - Belgium is
# on CEST, so 22:00 and 23:00 UTC are already 00:00 and 01:00 of tomorrow - and
# a UTC-keyed cache would file all 24 under one date. The evening hours are the
# expensive ones, so filing them under the wrong day is the costly direction.
_h5 = store()
_jul_utc = [datetime(2026, 7, 14, h, tzinfo=UTC) for h in range(24)]
_split = {brussels(u).strftime("%Y-%m-%d") for u in _jul_utc}
check(f"the July fixture really does straddle two Brussels days {sorted(_split)}"
f", or the next check passes for a UTC-keyed cache as well",
len(_split) == 2)
_ph.PriceHistory._remember(
_h5, series_of(_jul_utc, YESTERDAY_MWH), _jul_utc[-1])
_disk5 = json.load(open(_h5.path, encoding="utf-8"))
_counts = {d: len(_disk5["days"][d]["points"]) for d in sorted(_disk5["days"])}
check(f"...and the record is keyed on the BRUSSELS date, so those 24 hours are "
f"filed under both of them {_counts} rather than all under one",
set(_counts) == _split and sum(_counts.values()) == 24
and all(_counts.values()))
# --------------------------------------------------------------------------- #
print()
print("5. AC4 - four degraded stores, four reasons, no prices")
_missing = PriceHistory(os.path.join(tmpdir(), "gone", "price_history.json"))
check("no durable volume: the store is NOT ok, and it does not create the "
"directory - a cache on the ephemeral layer reads fine and is thrown "
"away on the next restart (tariff.PeakStore's rule)",
not _missing.ok and "no durable volume" in (_missing.last_error or "")
and not os.path.isdir(os.path.dirname(_missing.path)))
_d6 = tmpdir()
_p6 = os.path.join(_d6, "price_history.json")
open(_p6, "w", encoding="utf-8").write("{ this is not json")
_h6 = PriceHistory(_p6)
check("an unreadable file: the reason is kept and reported, and the record is "
"NOT silently re-created as an empty one that reads like a first day",
"unreadable" in (_h6.last_error or "")
and _h6.yesterday(NOW).kind == "no_data"
and "unreadable" in why(_h6.yesterday(NOW)))
_p7 = os.path.join(tmpdir(), "price_history.json")
json.dump({"version": SCHEMA + 99, "days": {}}, open(_p7, "w", encoding="utf-8"))
_h7 = PriceHistory(_p7)
check("a file from a schema this build does not know is discarded rather than "
"guessed at - its points might mean something else",
"schema" in (_h7.last_error or "")
and _h7.yesterday(NOW).kind == "no_data")
# A record whose points are malformed. THE DEFECT IS A DEFAULTED PRICE.
_p8 = os.path.join(tmpdir(), "price_history.json")
_good = {"start": brussels(at_local(YDAY_D, 5)).isoformat(),
"eur_per_kwh": allin(YESTERDAY_MWH[5]), "provisional": False}
json.dump({"version": SCHEMA, "days": {"2026-01-14": {
"product": "dynamic_dayahead", "provider": "generic_dayahead",
"points": [_good,
{"start": "2026-01-14T06:00:00+01:00"}, # no price
{"start": "2026-01-14 07:00:00", "eur_per_kwh": 0.2,
"provisional": False}, # naive
{"eur_per_kwh": 0.3, "provisional": False}, # no instant
"not even a dict"]}}},
open(_p8, "w", encoding="utf-8"))
_y8 = PriceHistory(_p8).yesterday(NOW)
check("a malformed point is DROPPED, not defaulted: the four broken rows are "
"gone and the one good row survives",
_y8.kind == "series" and len(_y8.points) == 1)
check("...and specifically no point came back priced 0.0 - "
"`float(row.get('eur_per_kwh', 0))` is how a lost field becomes 'that "
"hour was free'",
all(p.eur_per_kwh != 0.0 for p in _y8.points)
and abs(_y8.points[0].eur_per_kwh - allin(YESTERDAY_MWH[5])) < 1e-9)
_p9 = os.path.join(tmpdir(), "price_history.json")
json.dump({"version": SCHEMA, "days": {"2026-01-14": {
"product": "no_such_product", "provider": "x",
"points": [_good]}}}, open(_p9, "w", encoding="utf-8"))
check("a record naming a product that is not a PriceProduct is no_data, not a "
"series with a string in the field the optimiser switches on",
PriceHistory(_p9).yesterday(NOW).kind == "no_data")
# ...and every one of those leaves the CONTROLLER regulating.
#
# ⚠️ THE PLAN IS NOT BIT-FOR-BIT THE PRE-FCST-04 ONE, and the first draft of
# this check claimed it was. A broken cache still goes through the fill, which
# WINDOWS to [since, until) whether or not it has anything to carry - so the
# plan is the feed's own hours MINUS the elapsed ones. That is the AC2 half of
# this change, and it is correct with no cache at all: an hour that has gone is
# not a cheap hour. What must survive a broken cache is that the plan is a real
# provider series with no invented points and no §11.4 degradation.
for _label, _hist in (("no durable volume", _missing),
("unreadable file", _h6),
("wrong schema", _h7)):
_plan = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
history=_hist))
check(f"...and with {_label} the plan is a real provider series over the "
f"{len(_ahead)} published hours still ahead, with NOTHING carried "
f"and no §11.4 degradation. A broken cache is not a broken "
f"controller - it is a short horizon, which is where this started",
_plan.origin == "provider"
and len(_plan.read.points) == len(_ahead)
and not any(p.provisional for p in _plan.read.points))
# --------------------------------------------------------------------------- #
print()
print("6. rule 4 - hours accumulate within a day; they do not replace it")
_h10 = store()
_ph.PriceHistory._remember(_h10, series_of(TODAY_H, TODAY_MWH), NOW)
# A feed that serves only the hours left in the day - a legitimate shape, and
# the one that turns replace-the-record into a two-hour yesterday.
_REVISED = dict(TODAY_MWH)
_REVISED[22], _REVISED[23] = 999.0, 998.0
check("the revision really changes the numbers, or merge and replace are the "
"same observation",
_REVISED[22] != TODAY_MWH[22] and _REVISED[23] != TODAY_MWH[23])
_ph.PriceHistory._remember(
_h10, series_of([at_local(TODAY_D, 22), at_local(TODAY_D, 23)], _REVISED),
NOW)
_back = _h10.yesterday(NOW + timedelta(days=1))
check(f"a two-hour read did not overwrite a 24-hour day: "
f"{len(_back.points) if _back.kind == 'series' else _back.kind} hours "
f"held. Under replace, the next morning's 'yesterday' would have been "
f"two hours long",
_back.kind == "series" and len(_back.points) == 24)
# ⚠️ KEYED BY BRUSSELS HOUR, because that is what the fixture tables are keyed
# by. Keying this by UTC hour reads the wrong row of the table in winter and
# grades the module against a price nobody put there.
_by_hour = {brussels(p.start).hour: p.eur_per_kwh for p in _back.points}
def _priced(h, want):
"""True only if hour `h` is present AND carries `want`. A MISSING hour is
False, never a KeyError: under `replace` the hours simply are not there, and
a harness that dies on the mutation it is grading reports nothing at all."""
return h in _by_hour and abs(_by_hour[h] - want) < 1e-9
check("...and the two revised hours carry the NEW price, because a later read "
"is the newer statement about the same hour",
_priced(22, allin(999.0)) and _priced(23, allin(998.0)))
check("...while every untouched hour kept its original price",
all(_priced(h, allin(TODAY_MWH[h])) for h in range(22)))
# --------------------------------------------------------------------------- #
print()
print("7. the restart this whole module exists for")
_d11 = tmpdir()
_p11 = os.path.join(_d11, "price_history.json")
_first = PriceHistory(_p11)
_ph.PriceHistory._remember(
_first, series_of(YDAY_H, YESTERDAY_MWH), NOW)
del _first
_second = PriceHistory(_p11) # a brand-new add-on process
_y11 = _second.yesterday(NOW)
check("yesterday survives a restart, which is the ONLY reason this is a file "
"and not a variable on the refresh task: the window the fill exists for "
"is midnight -> publication, and an add-on restarted at 08:00 would "
"otherwise spend all of it on the truncated series",
_y11.kind == "series" and len(_y11.points) == 24)
check("...with the prices intact through the JSON round trip, to six decimals",
all(abs(p.eur_per_kwh
- allin(YESTERDAY_MWH[brussels(p.start).hour])) < 1e-9
for p in _y11.points))
_plan11 = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
history=_second))
check("...and the restarted controller's very first refresh already covers the "
"whole horizon",
len(_plan11.read.points) == HORIZON_H)
# --------------------------------------------------------------------------- #
print()
print("8. pruning keeps yesterday, and a jumped clock does not empty the store")
_h12 = store()
for back in range(6, 0, -1):
_ph.PriceHistory._remember(
_h12,
series_of(bday_hours(
(datetime.fromisoformat(TODAY_D) - timedelta(days=back)
).strftime("%Y-%m-%d")), YESTERDAY_MWH),
NOW)
_held = sorted(json.load(open(_h12.path, encoding="utf-8"))["days"])
check(f"six days filed, {KEEP_DAYS} held {_held} - a bigger cache would only "
f"hold days rule 2 refuses to look at",
len(_held) == KEEP_DAYS)
check("...and yesterday is one of them, which is the only day that matters",
_h12.yesterday(NOW).kind == "series")
_h13 = store()
_ph.PriceHistory._remember(
_h13, series_of(YDAY_H, YESTERDAY_MWH), NOW)
_ph.PriceHistory._prune(_h13, NOW + timedelta(days=4000))
check("pruning is by SORT ORDER, not by arithmetic against the clock: a clock "
"that jumped four thousand days does not delete the record, which would "
"empty the store on exactly the morning the fill is for",
_h13.yesterday(NOW).kind == "series")
# --------------------------------------------------------------------------- #
print()
print("9. the parameter cannot be forgotten, and main.py does not forget it")
_sig = inspect.signature(resolve_tariff)
_hp = _sig.parameters.get("history")
check("`resolve_tariff(history=...)` is KEYWORD-ONLY and has NO DEFAULT - a "
"default of None meaning 'skip the fill' would hand a caller who forgot "
"it the truncated horizon this ticket removes, silently",
_hp is not None and _hp.kind is inspect.Parameter.KEYWORD_ONLY
and _hp.default is inspect.Parameter.empty)
check("...so calling it without one is a TypeError, not a quiet regression",
raises(lambda: run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today),
now=NOW)), TypeError))
_MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read()
_main_tree = ast.parse(_MAIN)
_refresh = next(n for n in ast.walk(_main_tree)
if isinstance(n, ast.AsyncFunctionDef)
and n.name == "run_tariff_refresh")
_kw = [k.arg for n in ast.walk(_refresh) if isinstance(n, ast.Call)
for k in n.keywords]
check(f"the shipped refresh task passes a history {_kw} - the wiring is what "
f"this ticket IS, so it is checked in the tree rather than described",
"history" in _kw)
check("...and it is the CONTROLLER'S store, not a NoHistory() smuggled in to "
"make the call typecheck",
"history=self.price_history" in _MAIN
and "NoHistory" not in _MAIN)
# NoHistory is inert, and says so by identity rather than by producing a series.
_nh_read = series_of(TODAY_H, TODAY_MWH)
check("NoHistory() carries nothing and returns the read BY IDENTITY, so the "
"explicit 'I keep no history' is exactly the old behaviour and not a "
"second implementation of it",
NoHistory().carry(_nh_read, since=NOW, until=_until, stamp=NOW)
is _nh_read
and NoHistory().yesterday(NOW).kind == "no_data")
# --------------------------------------------------------------------------- #
for d in _tmpdirs:
shutil.rmtree(d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")
+6 -3
View File
@@ -69,6 +69,7 @@ from app.price import (
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
plan_is_provisional, required_spread, uncertainty_of,
)
from app.price_history import NoHistory
from app.static_tariff import (
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
parse_static_profile, resolve_tariff, static_plan, static_status,
@@ -746,7 +747,8 @@ _cfg = opts(TWO_PERIOD, price_provider="generic_dayahead",
price_feed_url="https://api.energy-charts.info/price?bzn=BE",
price_timeout_s=1, price_markup_eur_kwh=0.03,
price_taxes_eur_kwh=0.06)
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead))
_fb = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1), transport=_dead,
history=NoHistory()))
check("a dead feed falls back to the static profile rather than to nothing",
_fb.origin == "fallback" and _fb.has_prices)
check("...with the SAME prices the configured-product path produces, because "
@@ -769,7 +771,8 @@ check("...and the provisional plan has no cheap window either: §8.3 forbids an
not any(_fb.cheap_now(at_local(THU, h)) for h in range(24)))
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
price_timeout_s=1),
THU, THU + timedelta(days=1), transport=_dead))
THU, THU + timedelta(days=1), transport=_dead,
history=NoHistory()))
check("a dead feed with NO static_profile has nothing to fall back to, and "
"says that rather than inventing a tariff",
_fb_none.origin == "none" and not _fb_none.has_prices)
@@ -785,7 +788,7 @@ async def _live(url, timeout_s):
_live_plan = run(resolve_tariff(_cfg, THU, THU + timedelta(days=1),
transport=_live))
transport=_live, history=NoHistory()))
check("a live feed is used in preference to the static profile",
_live_plan.origin == "provider"
and _live_plan.product is PriceProduct.DAY_AHEAD)