Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b59f5ce31b | ||
|
|
0618194d9c | ||
|
|
587751b115 |
@@ -1,5 +1,105 @@
|
|||||||
# Changelog
|
# 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 few
|
||||||
|
Brussels days in `/data/price_history.json`, and `resolve_tariff()` now fills
|
||||||
|
an unpublished hour of the horizon from yesterday's same-wall-clock hour,
|
||||||
|
flagged provisional. ⚠️ **"An" unpublished hour, not "every" one:** the fill
|
||||||
|
can carry only an hour yesterday actually published, so a partial yesterday
|
||||||
|
leaves the local hours it never held **out of the series** - §11.4 for those
|
||||||
|
hours, and asking for one raises rather than answering. Correct behaviour,
|
||||||
|
and the wording is now the behaviour.
|
||||||
|
* ⚠️ **A sub-hourly feed keeps every print.** `eneco_eddy` is the imbalance
|
||||||
|
product and settles per **quarter**; the fill walks whole hours to decide
|
||||||
|
what needs carrying, and that is not a rule about what a published hour may
|
||||||
|
contain. Every published point inside the horizon passes through by identity,
|
||||||
|
and a carried point is added only for a whole hour with no published coverage
|
||||||
|
at all. The first draft keyed the published set by hour - last point wins -
|
||||||
|
and a 96-point quarter-hourly feed reached the optimiser as 15 points with
|
||||||
|
its spread down from 0.143 to 0.014 EUR/kWh, on an all-firm, fully-published
|
||||||
|
series.
|
||||||
|
* The horizon is **whole hours at both ends**: `since` is floored to the hour
|
||||||
|
that contains it, and so is `until`. Asked for 48 hours at 08:17, the plan is
|
||||||
|
48 hours long and not 49.
|
||||||
|
* `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 (including a `version` of `1.0`, which
|
||||||
|
`== 1`), a **day record that is not a record**, a malformed point - each its own
|
||||||
|
state with its own reason, 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.
|
||||||
|
`.words` never raises, because `Controller.__init__` reads it unguarded and a
|
||||||
|
status line that throws is an add-on that does not start.
|
||||||
|
|
||||||
|
⚠️ **AND WHERE THERE IS NO `/data`, NOTHING IS RECORDED AT ALL** - not even in
|
||||||
|
memory. An in-memory yesterday answers for one run and is gone on the next
|
||||||
|
restart, which is a cache that forgets without saying so; the honest pair is
|
||||||
|
`unavailable` on the status row and no prices carried.
|
||||||
|
|
||||||
|
⚠️ **"YESTERDAY" IS A LOCAL DATE, AND THE ARITHMETIC IS ON THE DATE.**
|
||||||
|
`brussels()` renders at a fixed offset, so subtracting `timedelta(days=1)` from
|
||||||
|
it subtracts 24 **absolute** hours - and the Brussels day before a
|
||||||
|
spring-forward is 23 hours long. Between 00:00 and 00:59 on the morning after
|
||||||
|
the March transition that lands on the day *before* yesterday. **No option was
|
||||||
|
added and `config.yaml` is untouched.**
|
||||||
|
|
||||||
## 0.9.0
|
## 0.9.0
|
||||||
|
|
||||||
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
|
⚠️ **MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK - AND THAT DISTINCTION IS THE
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# FCST-04 — what "yesterday" is, and what the fill is allowed to reach
|
||||||
|
|
||||||
|
Four 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 — and stated at the reach it actually has:
|
||||||
|
|
||||||
|
- **This one reaches control.** 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. That is the verdict the optimiser reads: `arbitrage.py`
|
||||||
|
calls `plan.arbitrage(...)` on `fresh_tariff_plan()`, and it is the only path
|
||||||
|
by which a provider plan moves energy. The §11.4 static fallback refuses on
|
||||||
|
the same rung, for the same reason.
|
||||||
|
- **This one does not, and saying so is the point.** `TariffPlan.cheap_now()`
|
||||||
|
also returns False on a fully-carried series, because `firm_points()` is
|
||||||
|
empty — but it has **no production caller on the provider plan**.
|
||||||
|
`self.cheap` comes from `static_status()`, which builds its *own* static plan
|
||||||
|
and calls `cheap_now()` on that. So it is a property of the type, checked in
|
||||||
|
`test_static_tariff.py`, and it is not evidence about the shipped control
|
||||||
|
path. The conclusion below holds on the first bullet alone.
|
||||||
|
|
||||||
|
The two paths therefore 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` parses every shipped module under `app/`, `tools/`,
|
||||||
|
`scenarios/` and `estop/` and fails if any of them **uses the name**
|
||||||
|
`_remember` — an attribute, a bare name, a `def`, or the string constant a
|
||||||
|
`getattr` would need. By name rather than by substring, so a comment that
|
||||||
|
merely mentions the rule does not fail it and a call in `tools/` cannot pass
|
||||||
|
unseen.
|
||||||
|
- and it pins the order two ways: the **line numbers** of the two calls in
|
||||||
|
`carry()`, and — the one that matters — the **behaviour**. A feed carrying
|
||||||
|
yesterday alongside today, into an empty store, must fill the whole horizon;
|
||||||
|
reversed, the lookup finds nothing and the plan is only the hours the feed
|
||||||
|
published. Driven, so a reversal is red.
|
||||||
|
|
||||||
|
⚠️ **The first version of that ordering check graded nothing, and this file
|
||||||
|
said it did.** It compared positions in `ast.walk`, which is breadth-first:
|
||||||
|
`self._remember(...)` is a statement and `self.yesterday(...)` is an *argument*
|
||||||
|
of `fill_from_yesterday(...)`, one level deeper, so `_remember` came out first
|
||||||
|
whichever statement was written first. Measured — shipped and reversed both
|
||||||
|
scored the same. A decorative check that a document cites as a guarantee is
|
||||||
|
worse than no check, which is why the correction is recorded here rather than
|
||||||
|
quietly made.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
⚠️ **And section 1's identity check now grades identity.** It used to compare
|
||||||
|
`eur_per_kwh` between `_before` and `_after`, which come from two *separate*
|
||||||
|
`resolve_tariff()` calls — each parsing the feed body into its own objects, so
|
||||||
|
`is` could never have held between them however the fill were written. FCST-03's
|
||||||
|
whole anti-copy argument is `filled.points[i] is fetched.points[j]`, and floats
|
||||||
|
cannot grade it. The value comparison is kept and now says what it is (the fill
|
||||||
|
moved the window, it did not touch a price); identity is graded on a read the
|
||||||
|
file constructs and still holds a reference to.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. A sub-hourly feed passes through whole; the fill only decides which hours are missing.
|
||||||
|
|
||||||
|
`eneco_eddy` is the Belgian imbalance product and settles **per quarter**, and
|
||||||
|
`price.py`'s `_validate_raw()` accepts any strictly-increasing spacing — so a
|
||||||
|
published hour may legitimately hold four points.
|
||||||
|
|
||||||
|
FCST-04's first caller keyed the published set as `{_utc_hour(p.start): p for p
|
||||||
|
in r.points}`. It reads perfectly ordinary and it keeps the **last** point of
|
||||||
|
each hour. Measured on a 96-point quarter-hourly feed — all firm, fully
|
||||||
|
published, nothing to carry: the plan reached the optimiser as **15 points**,
|
||||||
|
the arbitrage spread fell from **0.143 to 0.014 EUR/kWh**, and the cheapest
|
||||||
|
"hour" became a `:45` point whose `at()` window runs into the next hour. That is
|
||||||
|
not the windowing effect above; it is a feed losing 84 % of its own prints, and
|
||||||
|
no `provisional` flag would ever have reported it.
|
||||||
|
|
||||||
|
**The alternative was rejected:** refusing sub-hourly feeds at `_validate_raw()`
|
||||||
|
would have made the count correct by breaking the one supported product that
|
||||||
|
legitimately publishes per quarter.
|
||||||
|
|
||||||
|
**What was done instead** keeps FCST-03's existing contract rather than weakening
|
||||||
|
it: *every* published point inside the window passes through **by identity**,
|
||||||
|
and a carried point is added only for a whole hour with **no** published
|
||||||
|
coverage at all. A carried hour is still a single hourly point whatever the
|
||||||
|
feed's own spacing is, because yesterday's profile is only known per wall-clock
|
||||||
|
hour — and carried hours are provisional, so they are out of the trade set
|
||||||
|
anyway.
|
||||||
|
|
||||||
|
⚠️ **`_audit()` got stricter, not looser, and that was the condition on doing
|
||||||
|
this at all.** The old post-condition refused any repeated `_utc_hour` — a count
|
||||||
|
that cannot express four points in one hour. It is replaced by two rules that
|
||||||
|
**subsume** it:
|
||||||
|
|
||||||
|
- a **published** point must *be* one of that hour's published points, by
|
||||||
|
identity, and may be emitted only once;
|
||||||
|
- a **carried** point may stand only in an hour that does not appear in the
|
||||||
|
published set at all, must be flagged `True`, and may not repeat.
|
||||||
|
|
||||||
|
So a duplicated carried hour — the DST mispricing the count existed to catch —
|
||||||
|
still raises; so does the same published point emitted twice; and a carried
|
||||||
|
point sharing an hour with a real print now raises too, which the count never
|
||||||
|
looked at. Each of those three is driven by its own mutation in
|
||||||
|
`test_price_forecast.py`.
|
||||||
@@ -1424,6 +1424,70 @@ 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
|
and arbitrage is disabled, per the degradation matrix. It never falls back to a
|
||||||
price of zero, which would read as free electricity.
|
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 an 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** — `provisional: true` on every carried hour,
|
||||||
|
which is what the optimiser reads. 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**.
|
||||||
|
|
||||||
|
**"An unpublished hour", not "every unpublished hour"** — the fill can only
|
||||||
|
carry an hour yesterday actually published. If yesterday's record is itself
|
||||||
|
partial (a first day, an outage, a feed that only ever serves the rest of the
|
||||||
|
day), the local hours it never held are **left out of the series**: §11.4
|
||||||
|
applies to those hours, and asking the plan for one raises rather than
|
||||||
|
answering. An hour with no price is not a free hour, and the fill does not
|
||||||
|
invent a neighbour, a mean or a zero for it.
|
||||||
|
|
||||||
|
**A quarter-hourly feed keeps all four of its prints.** `eneco_eddy` settles
|
||||||
|
per quarter, and the fill decides *which whole hours need carrying* — never what
|
||||||
|
a published hour may contain. Every point the provider published inside the
|
||||||
|
horizon is passed through untouched; a carried point is added only for a whole
|
||||||
|
hour the feed did not print at all.
|
||||||
|
|
||||||
|
**What you can actually see on the status page.** *Price history* (below) says
|
||||||
|
what is held. *Arbitrage* carries the strategy's own sentence, which names the
|
||||||
|
firm/provisional split whenever that split is what refused a trade. *Tariff*
|
||||||
|
does **not** report the fill: it is the static-profile or Home Assistant
|
||||||
|
price-entity sentence, regenerated on the 30 s tick. The provider plan's own
|
||||||
|
line — `dynamic_dayahead from generic_dayahead, 48 h, provisional` — goes to the
|
||||||
|
**add-on log** at every refresh, alongside `§8.3: 33 of 48 hours carried forward
|
||||||
|
from yesterday's profile and flagged provisional; 15 published`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The same applies to a file that is *partly* broken: a day record this build
|
||||||
|
cannot read is dropped, the row says how many were dropped and why, and the rest
|
||||||
|
of the file still loads. And where there is no `/data` at all the store records
|
||||||
|
**nothing** — not even in memory. An in-memory yesterday would answer for one
|
||||||
|
run and vanish on the next restart, which is a cache that forgets without saying
|
||||||
|
so; `unavailable` and *no prices carried* are the honest pair.
|
||||||
|
|
||||||
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
|
**Injection is priced separately** (`export_price_eur_kwh`). In Belgium it is
|
||||||
often near zero, which by itself strongly favours self-consumption over export.
|
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
|
It is a single number rather than a curve because no supported supplier
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ from . import peakguard
|
|||||||
from . import packshare
|
from . import packshare
|
||||||
from .p1 import P1Ingest, build_source, is_enabled
|
from .p1 import P1Ingest, build_source, is_enabled
|
||||||
from .price import Uncertainty, price_option
|
from .price import Uncertainty, price_option
|
||||||
|
from .price_history import PriceHistory
|
||||||
from .static_tariff import resolve_tariff, static_status
|
from .static_tariff import resolve_tariff, static_status
|
||||||
from .tariff import PeakStore, PeakTracker
|
from .tariff import PeakStore, PeakTracker
|
||||||
from .store import migrate_maint_keys, migrate_unit_keys
|
from .store import migrate_maint_keys, migrate_unit_keys
|
||||||
@@ -165,7 +166,8 @@ def pack_energy_of(inv):
|
|||||||
|
|
||||||
class Controller:
|
class Controller:
|
||||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
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.o = opts
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.store = store
|
self.store = store
|
||||||
@@ -582,6 +584,42 @@ class Controller:
|
|||||||
self.peak.restore(self.p1.averager)
|
self.peak.restore(self.p1.averager)
|
||||||
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
|
_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: CARRYING a
|
||||||
|
# price widens what the optimiser can SEE, never what it may act on.
|
||||||
|
#
|
||||||
|
# ⚠️ AND THE CAVEAT THAT BELONGS WITH THAT SENTENCE, because this is
|
||||||
|
# the one place a maintainer reads it on its own. THE OTHER HALF OF
|
||||||
|
# THIS CHANGE DOES MOVE ENERGY, and it is the windowing rather than 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 sit in the series as the day's cheapest hour, and no
|
||||||
|
# cycle can buy in an hour that is past - so the trade was quietly
|
||||||
|
# never taken. `resolve_tariff()`'s docstring, DOCS.md and the CHANGELOG
|
||||||
|
# all carry this beside the guarantee; it may not appear here without it.
|
||||||
|
#
|
||||||
|
# ⚠️ A BROKEN OR ABSENT STORE IS NOT AN ERROR HERE. It answers `no_data`,
|
||||||
|
# the fill carries nothing, and the plan is the pre-FCST-04 one minus
|
||||||
|
# the elapsed hours - short, but honest and still refusing on the same
|
||||||
|
# rules. It must not be an error in the other sense either: a day
|
||||||
|
# record this build cannot read is DROPPED by `PriceHistory._load()`
|
||||||
|
# and reported in `.words`, because both the constructor above and the
|
||||||
|
# `.words` read below are unguarded, and a cache that raises here is an
|
||||||
|
# add-on that does not start.
|
||||||
|
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
|
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
|
||||||
# design (peakguard.py holds no history between calls), so the only
|
# design (peakguard.py holds no history between calls), so the only
|
||||||
# state here is what an EDGE needs: what the last cycle concluded, so
|
# state here is what an EDGE needs: what the last cycle concluded, so
|
||||||
@@ -3455,7 +3493,8 @@ class Controller:
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
try:
|
try:
|
||||||
plan = await resolve_tariff(
|
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
|
self.tariff_plan, self.tariff_at = plan, now
|
||||||
_LOG.info("tariff refreshed: %s", plan.words)
|
_LOG.info("tariff refreshed: %s", plan.words)
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
@@ -4304,6 +4343,13 @@ class Controller:
|
|||||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||||
("Cheap window", "yes" if self.cheap else "no"),
|
("Cheap window", "yes" if self.cheap else "no"),
|
||||||
("Tariff", self.tariff_words),
|
("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 -
|
# 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
|
# every refusal it can make already carries a reason a human can
|
||||||
# act on, and "Arbitrage: no" would throw all of them away.
|
# act on, and "Arbitrage: no" would throw all of them away.
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ So the flag is not passed through anywhere in this file:
|
|||||||
the object the provider produced is the object in the output series - so
|
the object the provider produced is the object in the output series - so
|
||||||
there is no copy that could lose or gain a flag. `filled.points[i] is
|
there is no copy that could lose or gain a flag. `filled.points[i] is
|
||||||
fetched.points[j]` is a checkable property, and test_price_forecast.py
|
fetched.points[j]` is a checkable property, and test_price_forecast.py
|
||||||
checks it.
|
checks it. EVERY published point in the window is passed through, not one
|
||||||
|
per hour: see the sub-hourly note below.
|
||||||
* `_audit()` RE-DERIVES the expected flag for every output point from the set
|
* `_audit()` RE-DERIVES the expected flag for every output point from the set
|
||||||
of hours the feed actually published, and RAISES if any point disagrees.
|
of hours the feed actually published, and RAISES if any point disagrees.
|
||||||
It is a post-condition, not a filter: it never fixes a flag, because a
|
It is a post-condition, not a filter: it never fixes a flag, because a
|
||||||
@@ -90,6 +91,24 @@ which is the row the spec actually writes for "no price data". If NOTHING can
|
|||||||
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
|
be produced, the result is `PriceRead.no_data(reason)` and not an empty series,
|
||||||
because an empty series is not constructible on purpose (price.py trap 3).
|
because an empty series is not constructible on purpose (price.py trap 3).
|
||||||
|
|
||||||
|
⚠️ A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR, and the hourly walk
|
||||||
|
below is about WHICH HOURS NEED FILLING - never about what a published hour is
|
||||||
|
allowed to contain. `eneco_eddy` is the Belgian imbalance product and settles
|
||||||
|
per QUARTER, `_validate_raw()` accepts any strictly-increasing spacing, and a
|
||||||
|
published hour may therefore hold four points. So EVERY published point inside
|
||||||
|
the window is passed through, in order, and a carried point is added only for a
|
||||||
|
whole hour with NO published coverage at all.
|
||||||
|
|
||||||
|
The alternative was measured rather than reasoned about, because FCST-04's
|
||||||
|
first caller shipped it: keying the published set as `{_utc_hour(p.start): p
|
||||||
|
for p in r.points}` reads perfectly ordinary and keeps the LAST point of each
|
||||||
|
hour, dropping the other three. On a 96-point quarter-hourly feed - all firm,
|
||||||
|
fully published, nothing to carry - the series came out 15 points long, the
|
||||||
|
arbitrage spread collapsed from 0.143 to 0.014 EUR/kWh, and the cheapest
|
||||||
|
"hour" became a `:45` point whose `at()` window runs into the next hour. A feed
|
||||||
|
losing 84 % of its own prints is not a windowing effect and is not something a
|
||||||
|
provisional flag would ever have reported.
|
||||||
|
|
||||||
|
|
||||||
AC2: THE IRREVERSIBLE-ACTION GATE
|
AC2: THE IRREVERSIBLE-ACTION GATE
|
||||||
=================================
|
=================================
|
||||||
@@ -156,6 +175,25 @@ def _utc_hour(instant: datetime) -> datetime:
|
|||||||
return u.replace(minute=0, second=0, microsecond=0)
|
return u.replace(minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _published_by_hour(read: PriceRead) -> dict:
|
||||||
|
"""UTC hour -> the provider's OWN points that start inside it, in order.
|
||||||
|
|
||||||
|
⚠️ A TUPLE PER HOUR, NOT A POINT, AND THAT IS THE WHOLE OF THE SUB-HOURLY
|
||||||
|
RULE. `{_utc_hour(p.start): p for p in r.points}` reads identically, is one
|
||||||
|
character shorter, and silently keeps one point per hour - the last one
|
||||||
|
written. The mapping answers two DIFFERENT questions here: "does this hour
|
||||||
|
need filling at all?" is `u in published`, and "what does the output carry
|
||||||
|
for it?" is all of `published[u]`. Collapsing them is how a quarter-hourly
|
||||||
|
feed loses three quarters of its prints.
|
||||||
|
"""
|
||||||
|
out: dict = {}
|
||||||
|
read.match(
|
||||||
|
series=lambda r: [out.setdefault(_utc_hour(p.start), []).append(p)
|
||||||
|
for p in r.points],
|
||||||
|
no_data=lambda _r: [])
|
||||||
|
return {u: tuple(ps) for u, ps in out.items()}
|
||||||
|
|
||||||
|
|
||||||
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
||||||
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
|
"""A point filled from yesterday's profile. §8.3: ALWAYS provisional.
|
||||||
|
|
||||||
@@ -172,37 +210,69 @@ def _carried(start_utc: datetime, eur_per_kwh: float) -> PricePoint:
|
|||||||
def _audit(points, published) -> None:
|
def _audit(points, published) -> None:
|
||||||
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
|
"""Post-condition: every flag matches the point's TRUE provenance (AC1).
|
||||||
|
|
||||||
`published` maps a UTC hour to the point the provider produced for it.
|
`published` maps a UTC hour to the TUPLE of points the provider produced
|
||||||
|
for it (`_published_by_hour()`). An hour ABSENT from that mapping is an hour
|
||||||
|
the feed did not print, and a carried point is the only thing that may
|
||||||
|
stand in it.
|
||||||
|
|
||||||
|
The rule, in the two forms an output point can take:
|
||||||
|
|
||||||
|
* PUBLISHED - the point must BE one of `published[u]`, by identity, and
|
||||||
|
may be emitted only once. Its flag is whatever the feed said, never
|
||||||
|
forced to False: an imbalance print is published AND provisional (§9.2,
|
||||||
|
TARIFF-02), and a check that "corrected" it would be a bug wearing a
|
||||||
|
guarantee.
|
||||||
|
* CARRIED - the hour must not appear in `published` AT ALL, the flag must
|
||||||
|
be exactly True, and that hour may hold no second point.
|
||||||
|
|
||||||
|
⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHY IT IS STRICTER. This
|
||||||
|
audit began as a `seen` set: any repeated `_utc_hour` raised, because a
|
||||||
|
duplicated hour is how a DST day gets mispriced. A bare hour count cannot
|
||||||
|
express a quarter-hourly feed, where four distinct published points share
|
||||||
|
one hour legitimately - so the count was replaced by the two rules above,
|
||||||
|
which subsume it. A repeated CARRIED hour still raises; so does the same
|
||||||
|
published point emitted twice; and a carried point may no longer share an
|
||||||
|
hour with a published one, which the count never checked at all.
|
||||||
|
|
||||||
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
|
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
|
||||||
provenance is one whose provenance means nothing; the only safe response to
|
provenance is one whose provenance means nothing; the only safe response to
|
||||||
"this point's flag disagrees with where it came from" is to refuse to emit
|
"this point's flag disagrees with where it came from" is to refuse to emit
|
||||||
the series at all.
|
the series at all.
|
||||||
"""
|
"""
|
||||||
seen = set()
|
carried_hours = set()
|
||||||
|
emitted = set()
|
||||||
for p in points:
|
for p in points:
|
||||||
u = _utc_hour(p.start)
|
u = _utc_hour(p.start)
|
||||||
if u in seen:
|
|
||||||
raise ValueError(f"the filled series repeats {u.isoformat()}; a "
|
|
||||||
f"duplicated hour is how a DST day gets mispriced")
|
|
||||||
seen.add(u)
|
|
||||||
src = published.get(u)
|
src = published.get(u)
|
||||||
prov = Provenance.CARRIED if src is None else Provenance.PUBLISHED
|
|
||||||
if src is None:
|
if src is None:
|
||||||
|
if u in carried_hours:
|
||||||
|
raise ValueError(
|
||||||
|
f"the filled series repeats the carried hour "
|
||||||
|
f"{u.isoformat()}; a duplicated hour is how a DST day gets "
|
||||||
|
f"mispriced")
|
||||||
|
carried_hours.add(u)
|
||||||
if p.provisional is not True:
|
if p.provisional is not True:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{u.isoformat()} is {prov.value} and is flagged "
|
f"{u.isoformat()} is {Provenance.CARRIED.value} and is "
|
||||||
f"provisional={p.provisional!r}. §8.3: an hour filled from "
|
f"flagged provisional={p.provisional!r}. §8.3: an hour "
|
||||||
f"yesterday's profile is provisional whether or not the "
|
f"filled from yesterday's profile is provisional whether "
|
||||||
f"number turns out to be right - the flag is a claim about "
|
f"or not the number turns out to be right - the flag is a "
|
||||||
f"PROVENANCE, not about accuracy.")
|
f"claim about PROVENANCE, not about accuracy.")
|
||||||
else:
|
else:
|
||||||
if p is not src:
|
if not any(p is q for q in src):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{u.isoformat()} is {prov.value} but the series carries a "
|
f"{u.isoformat()} is {Provenance.PUBLISHED.value} but the "
|
||||||
f"COPY of that point rather than the point itself. A copy "
|
f"series carries a COPY of that point rather than the "
|
||||||
f"is where the provisional flag gets lost; published points "
|
f"point itself. A copy is where the provisional flag gets "
|
||||||
f"pass through by identity.")
|
f"lost; published points pass through by identity.")
|
||||||
|
if id(p) in emitted:
|
||||||
|
raise ValueError(
|
||||||
|
f"the filled series emits the published point at "
|
||||||
|
f"{p.start.isoformat()} TWICE. The provider's own points "
|
||||||
|
f"are strictly increasing (`_validate_raw`), so a repeat "
|
||||||
|
f"is never a second print - it is a duplicated hour, which "
|
||||||
|
f"is how a DST day gets mispriced")
|
||||||
|
emitted.add(id(p))
|
||||||
|
|
||||||
|
|
||||||
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||||
@@ -217,11 +287,21 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
|||||||
|
|
||||||
Returns a `PriceRead`:
|
Returns a `PriceRead`:
|
||||||
|
|
||||||
* a series, whose points are the provider's own objects for the hours it
|
* a series, carrying the provider's own objects - ALL of them, which on
|
||||||
published and `_carried()` points for the rest, ordered by instant;
|
a quarter-hourly feed is four inside one hour - for the hours it
|
||||||
|
published, and one `_carried()` point for each remaining whole hour,
|
||||||
|
ordered by instant;
|
||||||
* `no_data` when neither source can produce a single hour - never an
|
* `no_data` when neither source can produce a single hour - never an
|
||||||
empty series, which is not constructible (price.py trap 3).
|
empty series, which is not constructible (price.py trap 3).
|
||||||
|
|
||||||
|
⚠️ THE WINDOW IS WHOLE HOURS AT BOTH ENDS. `since` is floored to the hour
|
||||||
|
that CONTAINS it, because the current hour is still a tradeable hour; and
|
||||||
|
so is `until`, which is the half FCST-04's first draft left out. With
|
||||||
|
`since` floored and `until` compared raw, a 48 h horizon asked for at 08:17
|
||||||
|
emitted 49 hours - the hour of `until` was still `< until` - and the count
|
||||||
|
was one too many for every `now` that is not exactly on the hour, which is
|
||||||
|
every `now` a running controller has.
|
||||||
|
|
||||||
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
|
⚠️ IDEMPOTENT AND RE-RUNNABLE, WHICH IS THE §5.4 LATE-PUBLICATION CONTRACT.
|
||||||
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
|
Day-ahead publication at 14-16 h CET can be late or fail, so the fetcher
|
||||||
retries and this runs again against the fresher read. Published hours always
|
retries and this runs again against the fresher read. Published hours always
|
||||||
@@ -234,9 +314,7 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
|||||||
raise ValueError(f"{name} must be tz-aware; a naive instant is "
|
raise ValueError(f"{name} must be tz-aware; a naive instant is "
|
||||||
f"ambiguous for one hour every October")
|
f"ambiguous for one hour every October")
|
||||||
|
|
||||||
published = read.match(
|
published = _published_by_hour(read)
|
||||||
series=lambda r: {_utc_hour(p.start): p for p in r.points},
|
|
||||||
no_data=lambda _r: {})
|
|
||||||
|
|
||||||
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
|
# Yesterday's profile, keyed by BRUSSELS WALL-CLOCK HOUR. First point wins
|
||||||
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
|
# when yesterday itself was a 25-hour day and printed local hour 2 twice.
|
||||||
@@ -248,11 +326,12 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
|||||||
|
|
||||||
out = []
|
out = []
|
||||||
u = _utc_hour(since)
|
u = _utc_hour(since)
|
||||||
end = until.astimezone(timezone.utc)
|
end = _utc_hour(until)
|
||||||
while u < end:
|
while u < end:
|
||||||
if u in published:
|
got = published.get(u)
|
||||||
out.append(published[u]) # BY IDENTITY. No copy.
|
if got:
|
||||||
else:
|
out.extend(got) # BY IDENTITY, and ALL of them. No copy,
|
||||||
|
else: # and no decimation to one an hour.
|
||||||
src = by_local_hour.get(brussels(u).hour)
|
src = by_local_hour.get(brussels(u).hour)
|
||||||
if src is not None:
|
if src is not None:
|
||||||
out.append(_carried(u, src.eur_per_kwh))
|
out.append(_carried(u, src.eur_per_kwh))
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
"""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.
|
||||||
|
|
||||||
|
⚠️ AND THAT RULE IS CARRIED OUT IN `_remember()`, NOT ONLY IN `_save()`. FCST-04
|
||||||
|
first shipped it half-done: `_save()` short-circuited on `not self.ok` while
|
||||||
|
`_remember()` filled `self._days` anyway, so a store with no durable volume
|
||||||
|
answered `words` = "unavailable" and `yesterday()` = A SERIES - an in-memory
|
||||||
|
yesterday that works all day and is gone on the next restart, which is exactly
|
||||||
|
the silently-forgetting cache this rule exists to refuse. A store that cannot
|
||||||
|
persist now records nothing, and both of its answers say the same thing.
|
||||||
|
|
||||||
|
|
||||||
|
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` PARSES every shipped module under `app/`, `tools/`,
|
||||||
|
`scenarios/` and `estop/` and fails if any of them uses the NAME - an
|
||||||
|
attribute, a bare name, a def, or the string constant a `getattr` needs. By
|
||||||
|
name rather than by substring, so a comment about the rule does not fail it
|
||||||
|
and a call from `tools/` cannot pass unseen.
|
||||||
|
|
||||||
|
⚠️ AND THE ORDER IS GRADED BY DRIVING IT, NOT BY READING THE SOURCE. The
|
||||||
|
first version of that check compared positions in `ast.walk`, which is
|
||||||
|
breadth-first: `self._remember(...)` is a statement and `self.yesterday(...)`
|
||||||
|
is an ARGUMENT of the fill, one level deeper, so `_remember` came out first
|
||||||
|
whichever statement was written first - shipped and reversed scored the
|
||||||
|
same. What grades it now is the behaviour: a feed carrying yesterday
|
||||||
|
alongside today, into an EMPTY store, must fill the whole horizon, and
|
||||||
|
reversed it fills nothing.
|
||||||
|
|
||||||
|
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 the SORT-ORDER cap keeps. 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.
|
||||||
|
#
|
||||||
|
# ⚠️ IT IS A CAP, NOT THE RETENTION RULE, AND THREE HAS NO SLACK. The steady
|
||||||
|
# state is already {yesterday, today, tomorrow}; a feed that publishes to D+2,
|
||||||
|
# or ONE stray point in the wrong year - which sorts LAST and is therefore
|
||||||
|
# immortal under a newest-N cap - pushes yesterday out of a three-slot store
|
||||||
|
# and keeps it out, permanently and silently. So `_prune()` PINS the two days a
|
||||||
|
# lookup can actually ask for on top of the cap; see its docstring. The store
|
||||||
|
# is then bounded at KEEP_DAYS + 2 records rather than at KEEP_DAYS.
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def _yesterday_of(instant: datetime) -> str:
|
||||||
|
"""The Brussels date of the day BEFORE `instant`'s Brussels day.
|
||||||
|
|
||||||
|
⚠️ THE ARITHMETIC IS ON THE LOCAL DATE, NOT ON THE INSTANT, AND THAT IS A
|
||||||
|
DST DEFECT THIS FILE SHIPPED. `brussels()` renders at the FIXED offset in
|
||||||
|
force at that instant, so `brussels(stamp) - timedelta(days=1)` subtracts 24
|
||||||
|
ABSOLUTE hours from a wall clock - and the Brussels day before a
|
||||||
|
spring-forward is 23 hours long. Measured at 2026-03-30T00:30+02:00, the
|
||||||
|
first hour of the morning after the March transition: it returned
|
||||||
|
2026-03-28, so the fill carried a TWO-DAY-OLD profile flagged as yesterday's
|
||||||
|
(or, with only the last three days held, answered `no_data` naming a date
|
||||||
|
that is not yesterday).
|
||||||
|
|
||||||
|
A calendar day minus one day is `date` arithmetic, and a `date` has no
|
||||||
|
offset to lose. The 25-hour October day is right for the same reason.
|
||||||
|
"""
|
||||||
|
return (brussels(instant).date() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _n_points(rec) -> int:
|
||||||
|
"""How many points a day record holds. 0 for anything that is not one.
|
||||||
|
|
||||||
|
⚠️ TOLERANT, AND IT IS THE SECOND LINE OF DEFENCE, NOT THE FIRST. `_load()`
|
||||||
|
drops a malformed day record and says so, so this should never see one. It
|
||||||
|
is written this way because `words` is read from `Controller.__init__` and
|
||||||
|
from every status render: a status LINE that raises takes the add-on down,
|
||||||
|
and "the cache is broken" must never be able to mean "the controller does
|
||||||
|
not start". That is exactly what FCST-04 shipped - `{"days": {"2026-01-14":
|
||||||
|
"oops"}}` gave `ok=True`, `last_error=None`, and an `AttributeError` out of
|
||||||
|
`words`, `yesterday()` and `resolve_tariff()` alike, with no self-heal
|
||||||
|
because `_remember()` raised before it could rewrite the file.
|
||||||
|
"""
|
||||||
|
if not isinstance(rec, dict):
|
||||||
|
return 0
|
||||||
|
pts = rec.get("points")
|
||||||
|
return len(pts) if isinstance(pts, (list, tuple)) else 0
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
found = blob.get("version") if isinstance(blob, dict) else None
|
||||||
|
# ⚠️ THE TYPE IS PART OF THE VERSION. `1.0 == 1` in Python, so a float
|
||||||
|
# written by some other tool would pass a bare `!=` and be read as
|
||||||
|
# schema 1 - a version check that cannot tell 1 from 1.0 is one that
|
||||||
|
# would not notice a writer this build has never met.
|
||||||
|
if not isinstance(blob, dict) or isinstance(found, bool) \
|
||||||
|
or not isinstance(found, int) or found != SCHEMA:
|
||||||
|
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 = self._usable_days(days)
|
||||||
|
|
||||||
|
def _usable_days(self, days: dict) -> dict:
|
||||||
|
"""The day records that are shaped like day records. The rest are dropped.
|
||||||
|
|
||||||
|
⚠️ THE ASYMMETRY THAT PRODUCED THE DEFECT: `_parse_points()` below is
|
||||||
|
carefully defensive about every field of a POINT, and `_load()` was not
|
||||||
|
defensive about the record holding them at all. `{"version": 1, "days":
|
||||||
|
{"2026-01-14": "oops"}}` therefore loaded as `ok=True`,
|
||||||
|
`last_error=None` - and then `rec.get(...)` raised `AttributeError` out
|
||||||
|
of `words`, `yesterday()` and `resolve_tariff()`. `PriceHistory(...)`
|
||||||
|
and `.words` are both unguarded in `Controller.__init__`, so THE ADD-ON
|
||||||
|
DID NOT START; and it never self-healed, because `_remember()` raised on
|
||||||
|
the same record before it could reach `_save()`.
|
||||||
|
|
||||||
|
A broken cache is a short horizon. It is not a controller that will not
|
||||||
|
regulate a battery, and §6's rule - absent machinery must not disable
|
||||||
|
the loop - does not stop applying because the machinery is a JSON file.
|
||||||
|
|
||||||
|
Dropped, not repaired: an unusable record has nothing in it to keep, and
|
||||||
|
the next `_remember()` writes a fresh one under that date.
|
||||||
|
"""
|
||||||
|
kept, dropped = {}, []
|
||||||
|
for key, rec in days.items():
|
||||||
|
if not isinstance(key, str) or not isinstance(rec, dict) \
|
||||||
|
or not isinstance(rec.get("points"), list):
|
||||||
|
dropped.append(str(key))
|
||||||
|
continue
|
||||||
|
kept[key] = rec
|
||||||
|
if dropped:
|
||||||
|
# Recorded, not silent: `yesterday()` and `words` both report
|
||||||
|
# `last_error`, and a cache that quietly discards half a file reads
|
||||||
|
# to an operator exactly like a normal first day.
|
||||||
|
self.last_error = (f"{len(dropped)} unusable day record(s) dropped "
|
||||||
|
f"({', '.join(sorted(dropped))})")
|
||||||
|
_LOG.warning("price history at %s: %s - the controller starts "
|
||||||
|
"anyway and the horizon is as short as the feed makes "
|
||||||
|
"it", self.path, self.last_error)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
if not self.ok:
|
||||||
|
return
|
||||||
|
# ⚠️ THE TEMP FILE IS REMOVED ON EVERY FAILING PATH. A write that dies
|
||||||
|
# between `mkstemp` and `os.replace` used to leave its temp behind, and
|
||||||
|
# the refresh task runs 48 times a day: a persistent write failure -
|
||||||
|
# a full volume being the likeliest cause - leaked 48 inodes a day onto
|
||||||
|
# the volume that was already the problem.
|
||||||
|
tmp = None
|
||||||
|
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)
|
||||||
|
tmp = None # it IS the file now; do not unlink
|
||||||
|
except OSError as err:
|
||||||
|
self.last_error = f"could not write ({err})"
|
||||||
|
_LOG.error("could not persist price history: %s", err)
|
||||||
|
finally:
|
||||||
|
if tmp is not None:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError: # nothing further to try, and a
|
||||||
|
pass # failed cleanup is not a fault
|
||||||
|
|
||||||
|
# -- 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, including how that order is graded.
|
||||||
|
|
||||||
|
Returns whatever `fill_from_yesterday()` returns: a series over the
|
||||||
|
whole hours of [since, until), carrying EVERY point the provider
|
||||||
|
published inside it - by identity, and four of them in one hour on a
|
||||||
|
quarter-hourly feed - plus one carried point for each remaining whole
|
||||||
|
hour FOR WHICH YESTERDAY HELD THAT WALL-CLOCK HOUR. An hour neither
|
||||||
|
source can price is left out (§11.4 for that hour), and `no_data` comes
|
||||||
|
back when that is every 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.
|
||||||
|
"""
|
||||||
|
if not self.ok:
|
||||||
|
# ⚠️ A STORE THAT CANNOT PERSIST RECORDS NOTHING. See the module
|
||||||
|
# docstring's third warning: filling `self._days` here would give a
|
||||||
|
# site with no /data an in-memory yesterday that answers a series
|
||||||
|
# all day and is gone on the next restart - a cache that forgets
|
||||||
|
# without saying so, which is the one thing that rule refuses. The
|
||||||
|
# honest degraded state is `words` = "unavailable" and `yesterday()`
|
||||||
|
# = `no_data`, and those two must agree.
|
||||||
|
return
|
||||||
|
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.
|
||||||
|
raw = rec.get("points")
|
||||||
|
held = {q["start"]: q for q in (raw if isinstance(raw, list) else ())
|
||||||
|
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:
|
||||||
|
"""Drop records no lookup can reach. A sort-order cap, plus two pins.
|
||||||
|
|
||||||
|
⚠️ NOTHING IS DELETED BY ARITHMETIC AGAINST THE CLOCK. 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.
|
||||||
|
Deleting on a bad clock is unrecoverable; holding a few days too many
|
||||||
|
is not, so the cap is `sorted(...)[-KEEP_DAYS:]` and stays that way.
|
||||||
|
|
||||||
|
⚠️ AND A CAP ALONE IS DEFEATED BY ONE POINT IN THE WRONG YEAR. Sorting
|
||||||
|
is lexicographic on the date string, so a stray far-future record sorts
|
||||||
|
LAST and is immortal - it consumes a slot for good, and with the steady
|
||||||
|
state already {yesterday, today, tomorrow} filling all three, yesterday
|
||||||
|
is what falls out. A feed that publishes to D+2 does the same thing
|
||||||
|
without any stray at all. So the two days `yesterday()` can ACTUALLY be
|
||||||
|
asked for are pinned on top of the cap.
|
||||||
|
|
||||||
|
`stamp` is READ here, which it was not before: the pin is the only use
|
||||||
|
of it, and it is additive. Pinning can only RETAIN a record, never
|
||||||
|
delete one, so a jumped `stamp` pins two dates that are not held and
|
||||||
|
changes nothing - the WITNESS-03 property is preserved by construction
|
||||||
|
rather than by remembering to check it.
|
||||||
|
"""
|
||||||
|
if len(self._days) <= KEEP_DAYS:
|
||||||
|
return
|
||||||
|
keep = set(sorted(self._days)[-KEEP_DAYS:])
|
||||||
|
keep |= {_bdate(stamp), _yesterday_of(stamp)}
|
||||||
|
for day in [d for d in self._days if d not in keep]:
|
||||||
|
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 = _yesterday_of(stamp)
|
||||||
|
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.
|
||||||
|
|
||||||
|
⚠️ THIS MUST NOT RAISE, EVER. It is read once from `Controller.__init__`
|
||||||
|
and again on every status render, and neither call site guards it - so
|
||||||
|
an exception here is an add-on that does not start. It went out that
|
||||||
|
way: a day record that was a string instead of a dict raised
|
||||||
|
`AttributeError` from the `len(...)` below. `_load()` now drops such a
|
||||||
|
record and `_n_points()` is total, so this has two independent reasons
|
||||||
|
not to fail on a broken file.
|
||||||
|
"""
|
||||||
|
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"
|
||||||
|
held = "; ".join(f"{d} ({_n_points(self._days[d])} h)"
|
||||||
|
for d in sorted(self._days, key=str))
|
||||||
|
# A file that PARTLY loaded says so, or the dropped days read as days
|
||||||
|
# that were simply never written.
|
||||||
|
return held + (f" [{self.last_error}]" if self.last_error else "")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -1019,7 +1019,7 @@ def static_status(opts: dict, now: datetime):
|
|||||||
|
|
||||||
|
|
||||||
async def resolve_tariff(opts: dict, since: datetime, until: 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`.
|
"""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
|
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
|
||||||
@@ -1033,9 +1033,42 @@ 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 + profile -> the CONFIGURED product (§9.2 rows 3-4)
|
||||||
price_provider off + none -> no prices at all (§11.4, nothing to
|
price_provider off + none -> no prices at all (§11.4, nothing to
|
||||||
fall back 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 + profile -> §11.4 FALLBACK, provisional, no arbitrage
|
||||||
provider no_data + none -> no prices at all
|
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 FILL IS PER WHOLE HOUR AND ITS COVERAGE IS CONDITIONAL. Every point
|
||||||
|
the provider published inside the window passes through BY IDENTITY - four
|
||||||
|
of them in one hour on a quarter-hourly product like `eneco_eddy` - and a
|
||||||
|
carried point is added only for a whole hour the feed did not print at all
|
||||||
|
AND yesterday did. An hour neither source can price is left out, which is
|
||||||
|
§11.4 for that hour: `plan.price_at()` raises rather than answering, and an
|
||||||
|
hour with no price is not a free one.
|
||||||
|
|
||||||
|
⚠️ 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)
|
stamp = now or datetime.now(timezone.utc)
|
||||||
pid = str(price_option(opts, "price_provider") or "off").strip()
|
pid = str(price_option(opts, "price_provider") or "off").strip()
|
||||||
@@ -1044,6 +1077,20 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
|||||||
|
|
||||||
read = await load_prices(opts, since, until, transport)
|
read = await load_prices(opts, since, until, transport)
|
||||||
return read.match(
|
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"),
|
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
|
||||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||||
|
|
||||||
|
|||||||
@@ -272,16 +272,19 @@ check("the provenance audit REFUSES a carried hour flagged non-provisional - "
|
|||||||
"the 'stale but plausible' point, which is SAFETY-18's frozen-but-fresh "
|
"the 'stale but plausible' point, which is SAFETY-18's frozen-but-fresh "
|
||||||
"meter one layer up",
|
"meter one layer up",
|
||||||
raises(lambda: _fc._audit([_stale_copy], {}), ValueError))
|
raises(lambda: _fc._audit([_stale_copy], {}), ValueError))
|
||||||
|
# ⚠️ `published` MAPS AN HOUR TO A TUPLE, not to a point (FCST-04 H1). An hour
|
||||||
|
# may legitimately hold four published points - see the quarter-hourly block
|
||||||
|
# below - so the audit takes the whole set and checks membership by identity.
|
||||||
check("...and refuses a published hour that arrived as a COPY rather than as "
|
check("...and refuses a published hour that arrived as a COPY rather than as "
|
||||||
"the published object, which is where a flag gets lost",
|
"the published object, which is where a flag gets lost",
|
||||||
raises(lambda: _fc._audit(
|
raises(lambda: _fc._audit(
|
||||||
[PricePoint(start=TODAY_PART.points[0].start,
|
[PricePoint(start=TODAY_PART.points[0].start,
|
||||||
eur_per_kwh=TODAY_PART.points[0].eur_per_kwh,
|
eur_per_kwh=TODAY_PART.points[0].eur_per_kwh,
|
||||||
provisional=TODAY_PART.points[0].provisional)],
|
provisional=TODAY_PART.points[0].provisional)],
|
||||||
{D0: TODAY_PART.points[0]}), ValueError))
|
{D0: (TODAY_PART.points[0],)}), ValueError))
|
||||||
check("...and never repairs a flag: the audit's only outcomes are silence and "
|
check("...and never repairs a flag: the audit's only outcomes are silence and "
|
||||||
"an exception, so a series it passed was already correct",
|
"an exception, so a series it passed was already correct",
|
||||||
_fc._audit(list(FILLED.points), {u: TODAY_PART.points[i]
|
_fc._audit(list(FILLED.points), {u: (TODAY_PART.points[i],)
|
||||||
for i, u in enumerate(DAY[:PUBLISHED_N])})
|
for i, u in enumerate(DAY[:PUBLISHED_N])})
|
||||||
is None)
|
is None)
|
||||||
|
|
||||||
@@ -298,6 +301,81 @@ check("a PUBLISHED point that the feed itself flagged provisional keeps that "
|
|||||||
and all(p.provisional for p in _eddy_filled.points[:2])
|
and all(p.provisional for p in _eddy_filled.points[:2])
|
||||||
and _eddy_filled.points[0] is _eddy.points[0])
|
and _eddy_filled.points[0] is _eddy.points[0])
|
||||||
|
|
||||||
|
# ⚠️ AND A SUB-HOURLY FEED IS NOT DECIMATED TO ONE POINT AN HOUR (FCST-04 H1).
|
||||||
|
# `eneco_eddy` settles per QUARTER and `_validate_raw()` accepts any
|
||||||
|
# strictly-increasing spacing, so a published hour can hold four points. The
|
||||||
|
# fill walks WHOLE HOURS to decide what needs carrying; that must not become a
|
||||||
|
# rule about what a published hour may contain. Measured on FCST-04's first
|
||||||
|
# draft, which keyed the published set `{_utc_hour(p.start): p ...}`: a
|
||||||
|
# 96-point feed came out 15 points long and its arbitrage spread fell from
|
||||||
|
# 0.143 to 0.014 EUR/kWh, on an ALL-FIRM, fully-published series.
|
||||||
|
_QN = 16 # four hours of quarters
|
||||||
|
_QUARTERS = PriceRead.series(
|
||||||
|
[PricePoint(start=brussels(D0 + timedelta(minutes=15 * n)),
|
||||||
|
eur_per_kwh=round(0.100000 + 0.011300 * n, 6),
|
||||||
|
provisional=False)
|
||||||
|
for n in range(_QN)],
|
||||||
|
PriceProduct.REALTIME, "eneco_eddy", D0)
|
||||||
|
_QHOURS = {_fc._utc_hour(p.start) for p in _QUARTERS.points}
|
||||||
|
check(f"the quarter-hourly fixture really is sub-hourly - {_QN} points across "
|
||||||
|
f"{len(_QHOURS)} hours - or every check below passes for an hourly feed "
|
||||||
|
f"too and grades nothing",
|
||||||
|
len(_QUARTERS.points) == _QN and len(_QHOURS) == _QN // 4)
|
||||||
|
_qfill = derived(lambda: fill_from_yesterday(_QUARTERS, yesterday=YESTERDAY,
|
||||||
|
since=D0, until=D1, stamp=D0))
|
||||||
|
_qfirm = derived(lambda: [p for p in _qfill.points if not p.provisional])
|
||||||
|
check(f"every one of the {_QN} published points survives the fill BY IDENTITY "
|
||||||
|
f"({len(_qfirm)} kept). One-per-hour keying would leave {_QN // 4}, and "
|
||||||
|
f"the spread over the hours the feed actually published would collapse "
|
||||||
|
f"with them",
|
||||||
|
len(_qfirm) == _QN
|
||||||
|
and all(any(p is q for q in _QUARTERS.points) for p in _qfirm))
|
||||||
|
check(f"...and the {len(DAY) - len(_QHOURS)} whole hours it published NOTHING "
|
||||||
|
f"for are carried, ONE point each: a carried hour is hourly whatever the "
|
||||||
|
f"feed's spacing is, because yesterday's profile is only known per "
|
||||||
|
f"wall-clock hour",
|
||||||
|
_qfill.kind == "series"
|
||||||
|
and len([p for p in _qfill.points if p.provisional])
|
||||||
|
== len(DAY) - len(_QHOURS))
|
||||||
|
check("...and the filled series is still ordered by instant across the two "
|
||||||
|
"kinds, so `at()` and `window()` are not reading a shuffled list",
|
||||||
|
_qfill.kind == "series"
|
||||||
|
and [p.start.astimezone(UTC) for p in _qfill.points]
|
||||||
|
== sorted(p.start.astimezone(UTC) for p in _qfill.points))
|
||||||
|
|
||||||
|
_PUB_Q = _fc._published_by_hour(_QUARTERS)
|
||||||
|
# ⚠️ `derived`, NOT A RAW CALL. Under the decimating mutation this check
|
||||||
|
# exists for, `_audit` RAISES here - and a harness that dies on the mutation it
|
||||||
|
# is grading reports a traceback instead of a FAIL, which MUTATION.md forbids.
|
||||||
|
check("the audit ACCEPTS four published points inside one hour, which is the "
|
||||||
|
"case the repeated-hour COUNT it replaced could not express",
|
||||||
|
derived(lambda: _fc._audit(list(_QUARTERS.points), _PUB_Q)) is None)
|
||||||
|
check("...and still REFUSES a repeated CARRIED hour, which is the DST "
|
||||||
|
"mispricing that count existed to catch",
|
||||||
|
raises(lambda: _fc._audit([_fc._carried(D0, 0.11),
|
||||||
|
_fc._carried(D0, 0.12)], {}), ValueError))
|
||||||
|
check("...and refuses the SAME published point emitted twice - the provider's "
|
||||||
|
"points are strictly increasing, so a repeat is never a second print",
|
||||||
|
raises(lambda: _fc._audit([_QUARTERS.points[0], _QUARTERS.points[0]],
|
||||||
|
_PUB_Q), ValueError))
|
||||||
|
check("...and refuses a CARRIED point standing in an hour the feed DID "
|
||||||
|
"publish, which the old count never looked at",
|
||||||
|
raises(lambda: _fc._audit([_fc._carried(D0, 0.11)], _PUB_Q), ValueError))
|
||||||
|
|
||||||
|
# ⚠️ THE HORIZON IS WHOLE HOURS AT BOTH ENDS (FCST-04 L8). `since` is floored
|
||||||
|
# and `until` was not, so a 48 h request made at any instant off the hour came
|
||||||
|
# back 49 hours long. Graded at an OFF-THE-HOUR instant, because on the hour
|
||||||
|
# the two implementations agree and the check would grade nothing.
|
||||||
|
_off = D0 + timedelta(hours=8, minutes=17, seconds=43)
|
||||||
|
for _h in (1, 12, 48):
|
||||||
|
_n = derived(lambda h=_h: len(fill_from_yesterday(
|
||||||
|
TODAY_PART, yesterday=YESTERDAY, since=_off,
|
||||||
|
until=_off + timedelta(hours=h), stamp=_off).points))
|
||||||
|
check(f"a {_h} h horizon asked for at {_off.isoformat()} - 17 min 43 s "
|
||||||
|
f"past the hour, the shape a running controller always has - is "
|
||||||
|
f"{_h} points long, not {_h + 1}",
|
||||||
|
_n == _h)
|
||||||
|
|
||||||
# The structural half: the constructor has nothing to pass, and the file is
|
# The structural half: the constructor has nothing to pass, and the file is
|
||||||
# scanned so the pass-through idiom cannot come back looking ordinary.
|
# scanned so the pass-through idiom cannot come back looking ordinary.
|
||||||
check("_carried() has NO `provisional` parameter, so a caller has nothing to "
|
check("_carried() has NO `provisional` parameter, so a caller has nothing to "
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,7 @@ from app.price import (
|
|||||||
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
|
Absence, PricePoint, PriceProduct, PriceRead, arbitrage_allowed, brussels,
|
||||||
plan_is_provisional, required_spread, uncertainty_of,
|
plan_is_provisional, required_spread, uncertainty_of,
|
||||||
)
|
)
|
||||||
|
from app.price_history import NoHistory
|
||||||
from app.static_tariff import (
|
from app.static_tariff import (
|
||||||
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
|
MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan,
|
||||||
parse_static_profile, resolve_tariff, static_plan, static_status,
|
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_feed_url="https://api.energy-charts.info/price?bzn=BE",
|
||||||
price_timeout_s=1, price_markup_eur_kwh=0.03,
|
price_timeout_s=1, price_markup_eur_kwh=0.03,
|
||||||
price_taxes_eur_kwh=0.06)
|
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",
|
check("a dead feed falls back to the static profile rather than to nothing",
|
||||||
_fb.origin == "fallback" and _fb.has_prices)
|
_fb.origin == "fallback" and _fb.has_prices)
|
||||||
check("...with the SAME prices the configured-product path produces, because "
|
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)))
|
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",
|
_fb_none = run(resolve_tariff(opts(None, price_provider="generic_dayahead",
|
||||||
price_timeout_s=1),
|
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 "
|
check("a dead feed with NO static_profile has nothing to fall back to, and "
|
||||||
"says that rather than inventing a tariff",
|
"says that rather than inventing a tariff",
|
||||||
_fb_none.origin == "none" and not _fb_none.has_prices)
|
_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),
|
_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",
|
check("a live feed is used in preference to the static profile",
|
||||||
_live_plan.origin == "provider"
|
_live_plan.origin == "provider"
|
||||||
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
and _live_plan.product is PriceProduct.DAY_AHEAD)
|
||||||
|
|||||||
Reference in New Issue
Block a user