FCST-04: the carry-forward stops decimating a quarter-hourly feed, and a broken cache stops stopping the add-on
checks / unit (pull_request) Successful in 7m22s
checks / unit (pull_request) Successful in 7m22s
Rework of the FCST-04 branch against review. Every finding below was proven by a runnable script before it was touched and re-run after; every check added or repaired was driven red by a mutation, `__pycache__` purged first (MUTATION.md, TEST-15). 29/29 test files green. ⚠️ H1 - A SUB-HOURLY FEED WAS DECIMATED TO ONE POINT AN HOUR, AND THE SPREAD WENT WITH IT. `fill_from_yesterday()` keyed the published set as `{_utc_hour(p.start): p for p in r.points}` - LAST POINT OF THE HOUR WINS, the other three dropped. `eneco_eddy` is the Belgian imbalance product and settles per QUARTER, and `_validate_raw()` accepts any strictly-increasing spacing, so this is a shape the product supports today. 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 one. Not the windowing effect this card is about; a feed losing 84 % of its own prints, with no flag anywhere reporting it. The fill now decides only WHICH WHOLE HOURS NEED CARRYING: 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. Same feed, same fixture: 60 of 60 firm points kept, spread 0.134 over the hours still ahead. ⚠️ AND `_audit()` GOT STRICTER, NOT LOOSER - that was the condition on doing it at all. The old post-condition refused any repeated `_utc_hour`, a count that cannot express four points in one hour. Two rules replace it and 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 absent from the published set, must be flagged True, and may not repeat. So the duplicated carried hour it existed to catch still raises, the same published point emitted twice now raises, and a carried point sharing an hour with a real print now raises too - which the count never looked at. Three mutations, three reds. ⚠️ H2 - A DAY RECORD THAT WAS NOT A RECORD STOPPED THE ADD-ON FROM STARTING. `_load()` validated `version` and that `days` is a dict, never that each VALUE is one. `{"version": 1, "days": {"2026-01-14": "oops"}}` loaded as ok=True, last_error=None, and then `rec.get(...)` raised AttributeError out of `words`, `yesterday()` and `resolve_tariff()` alike. `PriceHistory(...)` and `.words` are both unguarded in `Controller.__init__`, so the controller did not come up - and it never self-healed, because `_remember()` raised on the same record before it could reach `_save()`. That falsifies the CHANGELOG's "A BROKEN CACHE IS NOT A BROKEN CONTROLLER" in the one direction that matters. The asymmetry that produced it is visible in the file: `_parse_points()` is carefully defensive about every field of a POINT and `_load()` was not defensive about the record holding them. Unusable records are now dropped and REPORTED (`words` says how many and why), and `words` is total by construction as a second line. M3 - `yesterday()` WAS OFF BY ONE DAY FOR 00:00-00:59 AFTER SPRING-FORWARD. `brussels()` renders at the FIXED offset for that instant, so `brussels(stamp) - timedelta(days=1)` subtracts 24 ABSOLUTE hours - and the Brussels day before the March transition is 23 hours long. At 2026-03-30T00:30+02:00 it returned 2026-03-28's prices as "yesterday", or `no_data` naming a date that is not yesterday. The arithmetic is now on the local DATE, which has no offset to lose, and both transitions have a check - the module's headline claim had none. M4 - WITH NO /data THE STORE STILL FILLED, IN MEMORY. `_save()` short-circuited on `not ok` while `_remember()` populated `self._days` anyway: `words` said "unavailable" and `yesterday()` answered A SERIES that is gone on the next restart. The module docstring rejects exactly that ("a yesterday that silently forgets") as its reason for not calling makedirs, so the code now matches the docstring rather than contradicting it. M5 - THE PRUNE MUTATION SURVIVED BECAUSE THE STIMULUS WAS DEGENERATE. §8's clock-jump check seeded ONE day, so `if len(self._days) <= KEEP_DAYS: return` fired and the body it is named after never ran: pruning by arithmetic against the clock - which empties the store on exactly the morning the fill is for - passed the whole 29-file suite. The fixture is now written to a file and reopened (`_load()` does not prune, which is how a store legitimately holds more than the cap) with KEEP_DAYS + 2 days, and the mutant is red. M6 - THE AST ORDERING CHECK COULD NOT CATCH A REVERSAL, AND TWO DOCUMENTS CITED IT AS A GUARANTEE. `_calls.index("_remember") < _calls.index("yesterday")` grades `ast.walk` BFS DEPTH: `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 both scored True. It is now line numbers AND, the half 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 published hours. Reversing the two statements is red on both. L14/L9 - KEEP_DAYS = 3 IS THE STEADY STATE EXACTLY, so a newest-N cap has no slack: a feed publishing to D+2, or ONE stray point in the wrong year - which sorts LAST and can never age out - pushes yesterday out for good. `_prune()` now PINS the two days a lookup can actually ask for on top of the cap. Pinning is additive, so it can only RETAIN and the WITNESS-03 property (nothing is deleted by clock arithmetic) is preserved by construction; `stamp` stops being a dead parameter; the store is bounded at KEEP_DAYS + 2. L8 - the horizon end was not floored while `since` was, so a 48 h request made at any instant off the hour returned 49 points. §1's count was green only because its NOW was exactly :00:00, a shape production never has; the fixture now sits at :17:43. L10 - a failed `_save()` leaked its temp file; at 48 writes a day a persistent write failure leaked 48 inodes a day onto the volume that was already the problem. L15 - `{"version": 1.0}` passed, since `1.0 == 1`. L16 - the `_remember` tree walk scanned `app/*.py` only and matched a bare string; it now parses `app/`, `tools/`, `scenarios/` and `estop/` and looks for the NAME, so a comment does not fail it and a call in `tools/` cannot pass unseen. CLAIM-ACCURACY DEFECTS, WHICH THIS REPO TREATS AS REAL: * L7. DOCS said "*Tariff* on the status page reads `provisional` through the morning". FALSE - `tariff_words` is assigned only from the HA price-entity branch and from `static_status()`, both on the 30 s tick, and the plan's own sentence reaches `_LOG.info`. The doc now says what an operator can see (the Price history row, the Arbitrage row's own refusal sentence, the refresh log line) and a check pins the PAIRING - wiring the plan into the row is fine, correcting the doc is fine, doing one without the other is red. * L11. "fills EVERY unpublished hour" is conditional: a partial yesterday leaves the local hours it never held out of the series (§11.4 for those hours, and `at()` raises rather than answering). Correct behaviour, overclaimed wording, and untested - all three fixed. * L12. DECISIONS §1 leaned on `TariffPlan.cheap_now()`, which has NO PRODUCTION CALLER on the provider plan (`self.cheap` comes from `static_status()`, which builds its own). The conclusion holds on `arbitrage()`'s firm-pair refusal alone, which does reach control; the evidence is now stated at the reach it has. * L13. §1's "passed through by identity" check compared `eur_per_kwh` between two SEPARATE `resolve_tariff()` calls, where `is` could never have held. The value comparison is kept and now says what it grades; identity is graded on a read the file constructs and still holds. * L17. `PriceHistory.words` and the new status row had no check at all - and `words` is one of the two things H2 crashed. Both now have one. * L18. main.py's inline note carried the "widens what the optimiser can SEE, never what it may act on" guarantee without the windowing caveat that the CHANGELOG, DOCS and `resolve_tariff()`'s docstring all carry. It is the one place a maintainer reads it unqualified. EVIDENCE. All eight reviewer proofs re-run: p1/p1b return 29 March, p4 keeps 60 of 60 firm points at spread 0.134, p5 starts and reports, p6 D leaves no temp, p6 E refuses 1.0, p6 F records nothing, p7 returns 48 for 48. p2's script still scores True/True because it re-implements the OLD algorithm - the reversal is now driven as a mutation instead, and is red. p3's script always showed the shipped code correct; what was wrong was the fixture, and its mutant is now red. Twenty mutations driven, no survivors, tree fingerprinted and `__pycache__` purged before each: decimation (2 suites red), audit x3, unfloored `until` (2), unvalidated day records (7 checks), instant-subtraction yesterday, in-memory fill on a volume-less store, clock-arithmetic prune, cap without the pin, leaked temp, loose schema type, empty `words` (5), reversed `carry()` (2), a `_remember` leak in tools/ spelled two ways, a neighbour-filling fallback, a copy instead of identity (2), the DOCS claim in both directions, and the caveat removed from main.py. Baseline and the unmutated tree both green. No option added, config.yaml untouched, and the CHANGELOG entry still picks no version: PKG-07's rule, and only the deployer knows whether 0.9.0's image has been pulled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
co-authored by
Claude Opus 5
parent
0618194d9c
commit
b59f5ce31b
@@ -34,10 +34,26 @@ spread it would have created was never compared against anything.
|
||||
|
||||
**What changes:**
|
||||
|
||||
* `app/price_history.py` (new) keeps the **published** prices of the last
|
||||
`KEEP_DAYS` Brussels days in `/data/price_history.json`, and
|
||||
`resolve_tariff()` now fills every unpublished hour of the horizon from
|
||||
yesterday's same-wall-clock hour, flagged provisional.
|
||||
* `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
|
||||
@@ -64,11 +80,25 @@ difference only the status page can see.
|
||||
`DECISIONS/FCST-04-the-day-that-is-carried.md` carries that and two more.
|
||||
|
||||
⚠️ **A BROKEN CACHE IS NOT A BROKEN CONTROLLER.** No `/data`, an unreadable
|
||||
file, a schema this build does not know, a malformed point - four states, four
|
||||
reasons, none of them a price and none of them an empty series. In every one the
|
||||
plan is still a real provider series and the controller still regulates; the
|
||||
horizon is just as short as it was before this card. **No option was added and
|
||||
`config.yaml` is untouched.**
|
||||
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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FCST-04 — what "yesterday" is, and what the fill is allowed to reach
|
||||
|
||||
Three decisions, recorded because each had a defensible alternative that a later
|
||||
Four decisions, recorded because each had a defensible alternative that a later
|
||||
reader would otherwise re-open from scratch.
|
||||
|
||||
---
|
||||
@@ -17,11 +17,23 @@ no arbitrage."*
|
||||
Because it buys nothing the optimiser can use, and it silently replaces a
|
||||
fallback the operator configured.
|
||||
|
||||
Measured, not assumed: a fully-carried series is 100 % provisional, so
|
||||
`TariffPlan.arbitrage()` finds fewer than two firm hours and refuses on §8.3's
|
||||
firm-pair rung, and `cheap_now()` returns False because `firm_points()` is
|
||||
empty. The §11.4 static fallback produces *exactly* those two answers. The two
|
||||
paths differ only in which numbers the status page shows.
|
||||
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
|
||||
@@ -74,9 +86,26 @@ The defence is therefore an **ordering**, not a validation:
|
||||
- The filled series exists only as that return value. The object never sees it
|
||||
again.
|
||||
- `_remember()` is private and called from exactly one place.
|
||||
- `test_price_history.py` walks `app/` and fails if any other module names
|
||||
`_remember`, and reads `carry()`'s AST to confirm the record happens before
|
||||
the lookup — so a later edit that reverses them is red rather than plausible.
|
||||
- `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
|
||||
@@ -96,3 +125,57 @@ 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`.
|
||||
|
||||
@@ -1433,18 +1433,41 @@ day. Until FCST-04 those other hours were simply **absent** from the plan: not
|
||||
priced badly, not flagged, just missing, and nothing said so.
|
||||
|
||||
Now the add-on keeps the **published prices of the last few days** in
|
||||
`/data/price_history.json` and fills every unpublished hour of the horizon with
|
||||
`/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.** *Tariff* on the status page reads
|
||||
`provisional` through the morning, because half its hours are. Before, a
|
||||
half-length plan looked completely settled.
|
||||
- **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
|
||||
@@ -1458,6 +1481,13 @@ 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
|
||||
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
|
||||
|
||||
@@ -596,12 +596,27 @@ class Controller:
|
||||
# - typically 10-24 of the 48 hours `price_horizon_h` asked for - and
|
||||
# `TariffPlan.arbitrage()` took its spread over that truncated window
|
||||
# without anything reporting that it had. Every carried hour is
|
||||
# provisional, so refusal 3 drops it from the trade set: this widens
|
||||
# what the optimiser can SEE, never what it may act on.
|
||||
# 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 exactly the pre-FCST-04
|
||||
# one - short, but honest and still refusing on the same rules.
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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,
|
||||
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
|
||||
=================================
|
||||
@@ -156,6 +175,25 @@ def _utc_hour(instant: datetime) -> datetime:
|
||||
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:
|
||||
"""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:
|
||||
"""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
|
||||
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
|
||||
the series at all.
|
||||
"""
|
||||
seen = set()
|
||||
carried_hours = set()
|
||||
emitted = set()
|
||||
for p in points:
|
||||
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)
|
||||
prov = Provenance.CARRIED if src is None else Provenance.PUBLISHED
|
||||
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:
|
||||
raise ValueError(
|
||||
f"{u.isoformat()} is {prov.value} and is flagged "
|
||||
f"provisional={p.provisional!r}. §8.3: an hour filled from "
|
||||
f"yesterday's profile is provisional whether or not the "
|
||||
f"number turns out to be right - the flag is a claim about "
|
||||
f"PROVENANCE, not about accuracy.")
|
||||
f"{u.isoformat()} is {Provenance.CARRIED.value} and is "
|
||||
f"flagged provisional={p.provisional!r}. §8.3: an hour "
|
||||
f"filled from yesterday's profile is provisional whether "
|
||||
f"or not the number turns out to be right - the flag is a "
|
||||
f"claim about PROVENANCE, not about accuracy.")
|
||||
else:
|
||||
if p is not src:
|
||||
if not any(p is q for q in src):
|
||||
raise ValueError(
|
||||
f"{u.isoformat()} is {prov.value} but the series carries a "
|
||||
f"COPY of that point rather than the point itself. A copy "
|
||||
f"is where the provisional flag gets lost; published points "
|
||||
f"pass through by identity.")
|
||||
f"{u.isoformat()} is {Provenance.PUBLISHED.value} but the "
|
||||
f"series carries a COPY of that point rather than the "
|
||||
f"point itself. A copy is where the provisional flag gets "
|
||||
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,
|
||||
@@ -217,11 +287,21 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
|
||||
Returns a `PriceRead`:
|
||||
|
||||
* a series, whose points are the provider's own objects for the hours it
|
||||
published and `_carried()` points for the rest, ordered by instant;
|
||||
* a series, carrying the provider's own objects - ALL of them, which on
|
||||
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
|
||||
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.
|
||||
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
|
||||
@@ -234,9 +314,7 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
raise ValueError(f"{name} must be tz-aware; a naive instant is "
|
||||
f"ambiguous for one hour every October")
|
||||
|
||||
published = read.match(
|
||||
series=lambda r: {_utc_hour(p.start): p for p in r.points},
|
||||
no_data=lambda _r: {})
|
||||
published = _published_by_hour(read)
|
||||
|
||||
# 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.
|
||||
@@ -248,11 +326,12 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
|
||||
out = []
|
||||
u = _utc_hour(since)
|
||||
end = until.astimezone(timezone.utc)
|
||||
end = _utc_hour(until)
|
||||
while u < end:
|
||||
if u in published:
|
||||
out.append(published[u]) # BY IDENTITY. No copy.
|
||||
else:
|
||||
got = published.get(u)
|
||||
if got:
|
||||
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)
|
||||
if src is not None:
|
||||
out.append(_carried(u, src.eur_per_kwh))
|
||||
|
||||
@@ -53,6 +53,14 @@ 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
|
||||
=====================================
|
||||
@@ -73,8 +81,20 @@ THE FOUR RULES THIS FILE IS SHAPED BY
|
||||
public entry point, it remembers the argument and returns the fill, and the
|
||||
filled series exists only as a return value that `carry()` itself never
|
||||
sees again. `_remember()` is private, is called from exactly one place, and
|
||||
`test_price_history.py` walks the tree and fails if any module outside this
|
||||
file names it.
|
||||
`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
|
||||
@@ -139,12 +159,20 @@ from .price_forecast import fill_from_yesterday
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price.history")
|
||||
|
||||
# How many Brussels days of published prices are kept. The fill needs exactly
|
||||
# one - yesterday - and rule 2 refuses anything older, so this is not a horizon:
|
||||
# it is the number of days a record may sit unused before pruning removes it,
|
||||
# and it is small because a bigger cache would only hold days no lookup can ask
|
||||
# for. Three covers today, yesterday, and the day a fetch spanning midnight
|
||||
# writes ahead of itself.
|
||||
# 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
|
||||
@@ -161,6 +189,44 @@ def _bdate(instant: datetime) -> str:
|
||||
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.
|
||||
|
||||
@@ -214,8 +280,13 @@ class PriceHistory:
|
||||
"next refresh has written a full day",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
if not isinstance(blob, dict) or blob.get("version") != SCHEMA:
|
||||
found = blob.get("version") if isinstance(blob, dict) else None
|
||||
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",
|
||||
@@ -226,11 +297,55 @@ class PriceHistory:
|
||||
self.last_error = "no `days` object"
|
||||
_LOG.warning("price history at %s has %s", self.path, self.last_error)
|
||||
return
|
||||
self._days = days
|
||||
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:
|
||||
@@ -238,9 +353,16 @@ class PriceHistory:
|
||||
# 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,
|
||||
@@ -252,12 +374,15 @@ class PriceHistory:
|
||||
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.
|
||||
never sees again. See rule 1, including how that order is graded.
|
||||
|
||||
Returns whatever `fill_from_yesterday()` returns: a series over
|
||||
[since, until) whose published hours are the provider's own point
|
||||
objects and whose remaining hours are carried and flagged, or
|
||||
`no_data` when neither source can produce a single hour.
|
||||
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),
|
||||
@@ -274,6 +399,15 @@ class PriceHistory:
|
||||
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
|
||||
@@ -290,7 +424,8 @@ class PriceHistory:
|
||||
# Rule 4: MERGE by instant. A later read updates the hours it
|
||||
# carries and leaves the rest; a revised price for an hour already
|
||||
# held wins, because it is the newer statement about that hour.
|
||||
held = {q["start"]: q for q in rec.get("points", [])
|
||||
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"])
|
||||
@@ -303,16 +438,34 @@ class PriceHistory:
|
||||
len(points), ", ".join(sorted(touched)), len(self._days))
|
||||
|
||||
def _prune(self, stamp: datetime) -> None:
|
||||
"""Keep the newest KEEP_DAYS records. Older ones can never be a lookup.
|
||||
"""Drop records no lookup can reach. A sort-order cap, plus two pins.
|
||||
|
||||
Pruning is by SORT ORDER, not by arithmetic against `stamp`: a clock
|
||||
that jumped - the failure mode WITNESS-03 spent a session on - would
|
||||
⚠️ 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
|
||||
for day in sorted(self._days)[:-KEEP_DAYS]:
|
||||
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:
|
||||
@@ -327,7 +480,7 @@ class PriceHistory:
|
||||
f"yesterday({stamp!r}): a naive datetime is not an instant, "
|
||||
f"and 'which Brussels day was yesterday' is exactly the "
|
||||
f"question a wall-clock reading cannot answer.")
|
||||
want = _bdate(brussels(stamp) - timedelta(days=1))
|
||||
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 "
|
||||
@@ -363,14 +516,26 @@ class PriceHistory:
|
||||
# -- observability -------------------------------------------------------
|
||||
@property
|
||||
def words(self) -> str:
|
||||
"""One line for the status page. Says what is held, or why nothing is."""
|
||||
"""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"
|
||||
return "; ".join(f"{d} ({len(self._days[d].get('points') or ())} h)"
|
||||
for d in sorted(self._days))
|
||||
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:
|
||||
|
||||
@@ -1053,6 +1053,14 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
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
|
||||
|
||||
@@ -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 "
|
||||
"meter one layer up",
|
||||
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 "
|
||||
"the published object, which is where a flag gets lost",
|
||||
raises(lambda: _fc._audit(
|
||||
[PricePoint(start=TODAY_PART.points[0].start,
|
||||
eur_per_kwh=TODAY_PART.points[0].eur_per_kwh,
|
||||
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 "
|
||||
"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])})
|
||||
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 _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
|
||||
# scanned so the pass-through idiom cannot come back looking ordinary.
|
||||
check("_carried() has NO `provisional` parameter, so a caller has nothing to "
|
||||
|
||||
@@ -25,9 +25,14 @@ cache cannot decay by being fed its own output.
|
||||
that stays true while the NUMBER rots. Graded by driving `carry()` twice
|
||||
and reading the file back.
|
||||
AC4 A STORE THAT CANNOT ANSWER SAYS SO. No file, no record for yesterday, an
|
||||
unreadable file, a record whose points are malformed - four different
|
||||
unreadable file, a schema this build does not know, A DAY RECORD THAT IS
|
||||
NOT A RECORD, a record whose points are malformed - six different
|
||||
reasons, none of them a price, none of them an empty series, and the
|
||||
controller keeps its pre-FCST-04 plan through all four.
|
||||
controller keeps its pre-FCST-04 plan through all of them. ⚠️ THE
|
||||
CRASHING ONE IS THE POINT: `{"days": {"2026-01-14": "oops"}}` loaded as
|
||||
healthy and then raised `AttributeError` out of every accessor, and both
|
||||
`PriceHistory(...)` and `.words` are unguarded in `Controller.__init__`
|
||||
- so the add-on did not start, and never self-healed.
|
||||
|
||||
THE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
|
||||
@@ -49,13 +54,33 @@ THE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
|
||||
- THE MERGE FIXTURE MUST REVISE A PRICE TO A DIFFERENT NUMBER, or merge
|
||||
and replace are indistinguishable.
|
||||
- THE PRUNE FIXTURE MUST HOLD MORE DAYS THAN KEEP_DAYS, or pruning is not
|
||||
exercised at all.
|
||||
exercised at all. ⚠️ THIS FILE BROKE ITS OWN RULE AND IT COST A LIVE
|
||||
MUTATION: §8's clock-jump check seeded ONE day, `_prune`'s early
|
||||
return fired, and the body it is named after never ran. The mutant that
|
||||
pruned by arithmetic against the clock - which empties the store on
|
||||
exactly the morning the fill is for - survived the whole 29-file suite.
|
||||
The fixture is now WRITTEN TO A FILE and reopened, because `_load()`
|
||||
does not prune and that is how a store legitimately holds more days
|
||||
than the cap.
|
||||
- NOW MUST NOT BE ON AN HOUR BOUNDARY. `datetime.now()` never is, and a
|
||||
horizon count taken at exactly :00:00 was green for a fill that emitted
|
||||
HORIZON + 1 hours at every other instant.
|
||||
- A SUB-HOURLY FIXTURE MUST REALLY BE SUB-HOURLY, or "every published
|
||||
print survives" passes for an hourly feed and grades nothing.
|
||||
|
||||
* A FAILED READ IS NOT AN OBSERVATION. Every degraded store is graded on the
|
||||
TYPE it answers with (`no_data` and its reason), never on a falsy value,
|
||||
and the malformed-point case is checked for the specific defect - a price
|
||||
defaulted to 0.0 - rather than for a count.
|
||||
|
||||
* AND A STRUCTURAL CHECK MUST BE ABLE TO FAIL. §3's ordering check compared
|
||||
`ast.walk` positions, which grade NESTING DEPTH: `self._remember(...)` is a
|
||||
statement and `self.yesterday(...)` is an argument of the fill, so
|
||||
`_remember` came out first whichever statement was written first - measured
|
||||
by reversing them and watching the check stay green, while DECISIONS §3 and
|
||||
the module docstring both cited it as making a reversal "red rather than
|
||||
plausible". It is now line numbers AND the behaviour itself.
|
||||
|
||||
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
|
||||
`tools/mutation_run.sh`, which does. See MUTATION.md and TEST-15.
|
||||
"""
|
||||
@@ -72,7 +97,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import price_history as _ph
|
||||
from app.price import (
|
||||
PricePoint, PriceProduct, PriceRead, brussels,
|
||||
Absence, PricePoint, PriceProduct, PriceRead, brussels,
|
||||
)
|
||||
from app.price_history import KEEP_DAYS, NoHistory, PriceHistory, SCHEMA
|
||||
from app.static_tariff import resolve_tariff
|
||||
@@ -111,6 +136,42 @@ def raises(fn, exc):
|
||||
return False
|
||||
|
||||
|
||||
class _Raised:
|
||||
"""What a check saw instead of a value. Equals nothing, is falsy, and is
|
||||
NOT a default."""
|
||||
|
||||
def __init__(self, e):
|
||||
self.e = e
|
||||
|
||||
def __eq__(self, other):
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return id(self)
|
||||
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f"<raised {type(self.e).__name__}: {self.e}>"
|
||||
|
||||
|
||||
def derived(fn):
|
||||
"""A value a check grades. An exception becomes `_Raised`.
|
||||
|
||||
⚠️ NOT A DEFAULT, AND NOT A BARE CALL EITHER. MUTATION.md's rule is that
|
||||
"it was refused" and "it broke on the way to being refused" must not grade
|
||||
the same - and the accessors below are exactly the ones the unvalidated
|
||||
day record used to raise from, so a raw call here would take the harness
|
||||
down on the mutation it is meant to catch and report nothing at all.
|
||||
Copied from test_price_forecast.py.
|
||||
"""
|
||||
try:
|
||||
return fn()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _Raised(e)
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
@@ -169,13 +230,30 @@ def at_local(bdate: str, hh: int) -> datetime:
|
||||
return next(u for u in bday_hours(bdate) if brussels(u).hour == hh)
|
||||
|
||||
|
||||
NOW = at_local(TODAY_D, 9)
|
||||
# ⚠️ AND IT IS NOT ON THE HOUR. A controller's `now` is
|
||||
# `datetime.now(timezone.utc)`; it is :17:43 past as often as anything else and
|
||||
# it is never :00:00. The first draft of this file used the exact hour, and
|
||||
# §1's `_n_after == HORIZON_H` was green only because of it: `since` was
|
||||
# floored to its hour while `until` was compared raw, so a 48 h request made at
|
||||
# any other instant came back 49 hours long. A fixture has to have the shape
|
||||
# production has, or a count grades an alignment the running system never gets.
|
||||
NOW = at_local(TODAY_D, 9) + timedelta(minutes=17, seconds=43)
|
||||
|
||||
# Where the WINDOW starts: the whole hour that contains NOW. The hour a
|
||||
# controller is standing in is still a tradeable hour, so the fill emits from
|
||||
# here and not from NOW - and every count below is derived against this rather
|
||||
# than against the instant.
|
||||
SINCE_HOUR = NOW.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
# Today's feed, in EUR/MWh, keyed by BRUSSELS local hour and all distinct.
|
||||
# ⚠️ THE CHEAPEST HOUR IS 03:00 LOCAL - ALREADY ELAPSED AT 09:00 - and that is
|
||||
# AC2's stimulus, asserted below rather than assumed.
|
||||
TODAY_MWH = {h: 120.0 + h for h in range(24)}
|
||||
TODAY_MWH[3] = 5.0 # the cheap night that has already gone
|
||||
TODAY_MWH[9] = 250.0 # the hour NOW falls in - dear, so the
|
||||
# cheapest hour AHEAD is strictly
|
||||
# later than NOW and "it moved to an
|
||||
# hour still ahead" is exact
|
||||
TODAY_MWH[19] = 260.0 # the dear evening still ahead
|
||||
|
||||
# Yesterday's published prices, DIFFERENT AT EVERY LOCAL HOUR from today's, so
|
||||
@@ -230,9 +308,16 @@ check(f"a Brussels day is 24 hours long in the fixture helper, on both fixture "
|
||||
len(TODAY_H) == 24 and len(YDAY_H) == 24
|
||||
and {brussels(u).hour for u in TODAY_H} == set(range(24)))
|
||||
|
||||
check(f"NOW is {NOW.isoformat()} - deliberately NOT on an hour boundary. A "
|
||||
f"horizon count taken at exactly :00:00 is green for a fill that emits "
|
||||
f"HORIZON + 1 hours at every instant a controller actually runs at",
|
||||
(NOW.minute, NOW.second, NOW.microsecond) != (0, 0, 0))
|
||||
|
||||
# The feed serves TODAY IN FULL - which is what a day-ahead feed does - so the
|
||||
# hours it publishes that are still AHEAD are a strict subset of the horizon.
|
||||
_ahead = [u for u in TODAY_H if u >= NOW]
|
||||
# ⚠️ AGAINST SINCE_HOUR, NOT NOW: the 09:00 point started before NOW and its
|
||||
# hour still contains NOW, so it is a published hour of the window.
|
||||
_ahead = [u for u in TODAY_H if u >= SINCE_HOUR]
|
||||
check(f"at 09:00 the hours the feed published that have NOT elapsed are a "
|
||||
f"STRICT SUBSET of the {HORIZON_H} h horizon ({len(_ahead)} of "
|
||||
f"{HORIZON_H}), so 'the fill widened the plan' and 'the fill did "
|
||||
@@ -254,8 +339,8 @@ check(f"AC2's stimulus is real: the cheapest hour of the whole published day is
|
||||
f"({brussels(_cheapest_ahead).hour:02d}:00). Without this the windowed "
|
||||
f"and unwindowed answers would be the same point and AC2 would grade "
|
||||
f"nothing",
|
||||
_cheapest_all != _cheapest_ahead and _cheapest_all < NOW
|
||||
and _cheapest_ahead >= NOW)
|
||||
_cheapest_all != _cheapest_ahead and _cheapest_all < SINCE_HOUR
|
||||
and _cheapest_ahead > NOW)
|
||||
|
||||
check(f"KEEP_DAYS is {KEEP_DAYS}, and the prune fixture below files more days "
|
||||
f"than that, so pruning is exercised rather than skipped", KEEP_DAYS < 6)
|
||||
@@ -314,11 +399,33 @@ check("AC1: exactly the hours the feed published are FIRM, and exactly the "
|
||||
and len(_got_carried) == HORIZON_H - len(_ahead))
|
||||
|
||||
_before_by_start = {p.start.astimezone(UTC): p for p in _before.read.points}
|
||||
check("...and every published point is the PROVIDER'S OWN OBJECT, passed "
|
||||
"through by identity - a copy is where the flag gets lost (FCST-03)",
|
||||
# ⚠️ THIS IS A VALUE CHECK AND SAYS SO. `_before` and `_after` come from two
|
||||
# separate `resolve_tariff()` calls, each of which parsed the feed body into
|
||||
# its OWN objects, so `is` could never hold between them however the fill were
|
||||
# written - a check claiming identity here would be claiming a property its
|
||||
# own fixture makes untestable. What it does grade is that the fill moved the
|
||||
# WINDOW and did not touch a PRICE.
|
||||
check("...and every published point carries the same NUMBER as the unfilled "
|
||||
"plan's for that instant: the fill changed which hours are in the "
|
||||
"series, not what any of them costs",
|
||||
all(p.eur_per_kwh == _before_by_start[p.start.astimezone(UTC)].eur_per_kwh
|
||||
for p in _after.read.points if not p.provisional))
|
||||
|
||||
# ...and identity itself, graded on a read this file constructed and still
|
||||
# holds a reference to. FCST-03's whole anti-copy argument is
|
||||
# `filled.points[i] is fetched.points[j]`, and floats cannot grade it.
|
||||
_h1b = store()
|
||||
_ph.PriceHistory._remember(_h1b, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
_read_id = series_of(TODAY_H, TODAY_MWH)
|
||||
_filled_id = _h1b.carry(_read_id, since=NOW, until=_until, stamp=NOW)
|
||||
_firm_id = [p for p in _filled_id.points if not p.provisional]
|
||||
check(f"...and IDENTITY, graded as identity: all {len(_firm_id)} firm points "
|
||||
f"of a filled series ARE the provider's own objects (`is`), not copies "
|
||||
f"carrying the same numbers. A copy is where the provisional flag gets "
|
||||
f"lost (FCST-03)",
|
||||
len(_firm_id) == len(_ahead)
|
||||
and all(any(p is q for q in _read_id.points) for p in _firm_id))
|
||||
|
||||
# The carried numbers are yesterday's, at the same BRUSSELS wall-clock hour.
|
||||
# ⚠️ THE LOOKUP IS BUILT BY SEARCH over yesterday's own instants, not by
|
||||
# subtracting 24 h from today's - a fill that subtracted a fixed day is exactly
|
||||
@@ -336,6 +443,36 @@ check(f"...and every carried number is YESTERDAY's price for the same BRUSSELS "
|
||||
f"({sum(_carried_ok)}/{len(_carried_ok)})",
|
||||
_carried_ok and all(_carried_ok))
|
||||
|
||||
# ⚠️ "FILLS EVERY UNPUBLISHED HOUR" IS CONDITIONAL, AND THIS IS THE CONDITION.
|
||||
# A yesterday that is itself partial fills only the local hours it HOLDS; the
|
||||
# rest are left out, which is §11.4 for those hours and emphatically not a
|
||||
# zero. The behaviour is right and the wording was not - the CHANGELOG and DOCS
|
||||
# said "every", unqualified, and nothing here exercised it.
|
||||
_hp = store()
|
||||
_PART_LOCAL = list(range(12)) # yesterday published 00:00..11:00 only
|
||||
_ph.PriceHistory._remember(
|
||||
_hp, series_of([at_local(YDAY_D, h) for h in _PART_LOCAL], YESTERDAY_MWH),
|
||||
NOW)
|
||||
_plan_part = run(resolve_tariff(CFG, NOW, _until,
|
||||
transport=transport_for(_body_today), now=NOW,
|
||||
history=_hp))
|
||||
_window_hours = [SINCE_HOUR + timedelta(hours=n) for n in range(HORIZON_H)]
|
||||
_ahead_utc = {u.astimezone(UTC) for u in _ahead}
|
||||
_expect_covered = {u for u in _window_hours
|
||||
if u in _ahead_utc or brussels(u).hour in _PART_LOCAL}
|
||||
_got_covered = {p.start.astimezone(UTC) for p in _plan_part.read.points}
|
||||
check(f"a PARTIAL yesterday fills only the local hours it holds: "
|
||||
f"{len(_got_covered)} of the {HORIZON_H} horizon hours are covered, "
|
||||
f"re-derived from the fixture as {len(_expect_covered)}. The hours "
|
||||
f"yesterday never published stay ABSENT",
|
||||
_got_covered == _expect_covered and 0 < len(_got_covered) < HORIZON_H)
|
||||
_gap = sorted(set(_window_hours) - _expect_covered)[0]
|
||||
check(f"...and an uncovered hour ({_gap.isoformat()}) raises `Absence` rather "
|
||||
f"than answering a price. §11.4 applies to that hour: a hole in the "
|
||||
f"series is not a free hour, and this is why the wording had to stop "
|
||||
f"saying 'every'",
|
||||
raises(lambda: _plan_part.read.at(_gap), Absence))
|
||||
|
||||
check("the plan reports itself PROVISIONAL now, which it did not before - the "
|
||||
"old plan claimed full firmness over a horizon it covered half of",
|
||||
_after.provisional and not _before.provisional)
|
||||
@@ -370,8 +507,7 @@ check("...and the new one is the cheapest hour the feed published that has NOT "
|
||||
|
||||
check("...and no point of the filled series starts before the requested "
|
||||
"`since` hour: the window is [since, until) and the past is not in it",
|
||||
min(p.start.astimezone(UTC) for p in _after.read.points)
|
||||
>= NOW.replace(minute=0, second=0, microsecond=0))
|
||||
min(p.start.astimezone(UTC) for p in _after.read.points) >= SINCE_HOUR)
|
||||
|
||||
# ⚠️ THE HONEST LIMIT OF THIS TICKET, checked so it cannot be overclaimed
|
||||
# later: the CARRIED hours buy no trade. §8.3 forbids acting on a provisional
|
||||
@@ -386,6 +522,76 @@ check("the carried hours are NOT in the trade set - the verdict's cheapest and "
|
||||
and len(_firm_after) == len(_ahead))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("2b. a QUARTER-HOURLY feed keeps every print, and its spread with them")
|
||||
|
||||
# ⚠️ THE DEFECT THIS SECTION EXISTS FOR. `eneco_eddy` is the Belgian imbalance
|
||||
# product and settles per QUARTER; `_validate_raw()` accepts any
|
||||
# strictly-increasing spacing, so a published hour may hold four points.
|
||||
# FCST-04's first draft keyed the published set `{_utc_hour(p.start): p ...}` -
|
||||
# LAST POINT OF THE HOUR WINS, the other three dropped - and on an all-firm,
|
||||
# fully-published 96-point feed the plan came out 15 points long with its
|
||||
# arbitrage spread down from 0.143 to 0.014 EUR/kWh. That is not the windowing
|
||||
# effect AC2 is about; it is prints being thrown away, and no flag reports it.
|
||||
_QSTEP_MIN = 15
|
||||
_qrows = []
|
||||
for _n in range(len(TODAY_H) * (60 // _QSTEP_MIN)):
|
||||
_qu = TODAY_H[0] + timedelta(minutes=_QSTEP_MIN * _n)
|
||||
# A spiky within-the-hour profile, so decimation is visible in the SPREAD
|
||||
# and not only in a count.
|
||||
_qrows.append({"start": _qu.isoformat(),
|
||||
"price_eur_mwh": 100.0 + 40.0 * (_n % 4) + brussels(_qu).hour})
|
||||
_qbody = json.dumps({"data": _qrows}).encode()
|
||||
check(f"the fixture really is sub-hourly - {len(_qrows)} points across "
|
||||
f"{len(TODAY_H)} Brussels hours - or every check below passes for an "
|
||||
f"hourly feed too and grades nothing",
|
||||
len(_qrows) == 4 * len(TODAY_H)
|
||||
and len({_r["start"] for _r in _qrows}) == len(_qrows))
|
||||
|
||||
_q_before = run(resolve_tariff(CFG, NOW, _until, transport=transport_for(_qbody),
|
||||
now=NOW, history=NoHistory()))
|
||||
_hq = store()
|
||||
_ph.PriceHistory._remember(_hq, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
_q_after = run(resolve_tariff(CFG, NOW, _until, transport=transport_for(_qbody),
|
||||
now=NOW, history=_hq))
|
||||
# What the feed published INSIDE the window, derived from the unfilled plan.
|
||||
_q_win = [p for p in _q_before.read.points
|
||||
if p.start.astimezone(UTC) >= SINCE_HOUR]
|
||||
_q_firm = [p for p in _q_after.read.points if not p.provisional]
|
||||
check(f"every print the window contains survives the fill: {len(_q_firm)} firm "
|
||||
f"points against the {len(_q_win)} the feed published in that window. "
|
||||
f"One-per-hour keying leaves {len(_q_win) // 4}",
|
||||
len(_q_win) > 0 and len(_q_firm) == len(_q_win))
|
||||
check("...and they are the SAME INSTANTS AND PRICES the feed published, so "
|
||||
"the count is not being made up by four hourly points wearing "
|
||||
"quarter-hourly starts",
|
||||
{(p.start.astimezone(UTC), p.eur_per_kwh) for p in _q_firm}
|
||||
== {(p.start.astimezone(UTC), p.eur_per_kwh) for p in _q_win})
|
||||
check("...and no two of them share a whole hour by accident: the fixture puts "
|
||||
"four distinct prices inside every published hour, which is what the "
|
||||
"decimated version could not represent",
|
||||
len({p.start.astimezone(UTC).replace(minute=0) for p in _q_firm})
|
||||
== len(_q_firm) // 4 > 0)
|
||||
|
||||
_q_spread_want = round(max(p.eur_per_kwh for p in _q_win)
|
||||
- min(p.eur_per_kwh for p in _q_win), 6)
|
||||
_qv = _q_after.arbitrage(**_A)
|
||||
_q_spread_got = _qv.match(allowed=lambda v: v.spread_eur_kwh,
|
||||
refused=lambda _v: None)
|
||||
check(f"...and the spread is the fixture's own max - min over that window "
|
||||
f"({_q_spread_want} EUR/kWh, measured {_q_spread_got}), not the "
|
||||
f"flattened figure a decimated series produces",
|
||||
_qv.kind == "allowed" and _q_spread_got is not None
|
||||
and abs(_q_spread_got - _q_spread_want) < 1e-9)
|
||||
check("...and the cheapest firm point is one the FEED published, at its own "
|
||||
"sub-hourly instant - not a re-derived hourly one",
|
||||
_qv.kind == "allowed"
|
||||
and any(_qv.cheapest is q for q in _q_after.read.points)
|
||||
and any(_qv.cheapest.start.astimezone(UTC) == q.start.astimezone(UTC)
|
||||
for q in _q_win))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("3. AC3 - the cache records the FEED, never its own output")
|
||||
@@ -423,26 +629,114 @@ check(f"...and the fill is idempotent across the two rounds, which is §5.4's "
|
||||
_canon(_filled2) and _canon(_filled2) == _canon(_filled2b))
|
||||
|
||||
# The ordering is the mechanism, so the mechanism is checked in the tree.
|
||||
#
|
||||
# ⚠️ EVERY SHIPPED PYTHON FILE, AND BY NAME RATHER THAN BY SUBSTRING. The first
|
||||
# draft scanned `app/*.py` only - not `tools/`, not `scenarios/`, not the
|
||||
# repo's `estop/` - and matched the bare string, so a module that merely
|
||||
# MENTIONED `_remember` in a comment failed the check while a real call from
|
||||
# `tools/` passed it unseen. This parses each file and looks for the name being
|
||||
# USED: an attribute, a bare name, a def, or a string constant, which is how
|
||||
# `getattr` reaches a private method.
|
||||
_APP = os.path.join(HERE, "app")
|
||||
_leaks = []
|
||||
for name in sorted(os.listdir(_APP)):
|
||||
if not name.endswith(".py") or name == "price_history.py":
|
||||
continue
|
||||
src = open(os.path.join(_APP, name), encoding="utf-8").read()
|
||||
if "_remember" in src:
|
||||
_leaks.append(name)
|
||||
|
||||
|
||||
def _uses_remember(path):
|
||||
"""Does this module USE the name `_remember`? Prose does not count."""
|
||||
try:
|
||||
tree = ast.parse(open(path, encoding="utf-8").read())
|
||||
except (OSError, SyntaxError):
|
||||
# ⚠️ NOT False. A file this scan cannot read is a file it has not
|
||||
# cleared, and "unreadable" must never grade the same as "clean".
|
||||
return True
|
||||
for n in ast.walk(tree):
|
||||
if isinstance(n, ast.Attribute) and n.attr == "_remember":
|
||||
return True
|
||||
if isinstance(n, ast.Name) and n.id == "_remember":
|
||||
return True
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) \
|
||||
and n.name == "_remember":
|
||||
return True
|
||||
if isinstance(n, ast.Constant) and n.value == "_remember":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_SHIPPED = []
|
||||
for _root in (_APP, os.path.join(HERE, "tools"), os.path.join(HERE, "scenarios"),
|
||||
os.path.join(os.path.dirname(HERE), "estop")):
|
||||
for _dirpath, _dirnames, _filenames in os.walk(_root):
|
||||
for _f in sorted(_filenames):
|
||||
if _f.endswith(".py") and _f != "price_history.py":
|
||||
_SHIPPED.append(os.path.join(_dirpath, _f))
|
||||
check(f"the scan has a roster to walk ({len(_SHIPPED)} shipped modules across "
|
||||
f"app/, tools/, scenarios/ and estop/), and it reaches beyond app/ - an "
|
||||
f"empty or app-only glob passes for a leak in any other directory",
|
||||
len(_SHIPPED) > 10
|
||||
and any(os.sep + "tools" + os.sep in p for p in _SHIPPED))
|
||||
_leaks = [os.path.relpath(p, HERE) for p in _SHIPPED if _uses_remember(p)]
|
||||
check(f"AC3's mechanism is an ORDERING, not a validation - a carried point and "
|
||||
f"a published-provisional point are the same three fields - so nothing "
|
||||
f"outside app/price_history.py may name `_remember` {_leaks or 'none do'}",
|
||||
not _leaks)
|
||||
|
||||
_probe = os.path.join(tmpdir(), "probe.py")
|
||||
open(_probe, "w", encoding="utf-8").write("def f(h):\n return h._remember(1)\n")
|
||||
_probe_ok = os.path.join(tmpdir(), "probe_ok.py")
|
||||
open(_probe_ok, "w", encoding="utf-8").write(
|
||||
'"""A docstring about _remember."""\n# and a comment about _remember\nX = 1\n')
|
||||
check("...and that scan FAILS on a real call and PASSES on a file that only "
|
||||
"talks about the rule, so it is a check and not a decoration",
|
||||
_uses_remember(_probe) and not _uses_remember(_probe_ok))
|
||||
|
||||
# ⚠️ `ast.walk` IS BREADTH-FIRST, SO A LIST OF CALL NAMES GRADES NESTING DEPTH
|
||||
# AND NOT SOURCE ORDER. The first draft of this check was
|
||||
# `_calls.index("_remember") < _calls.index("yesterday")`: `self._remember(...)`
|
||||
# is a statement while `self.yesterday(...)` is an ARGUMENT of
|
||||
# `fill_from_yesterday(...)`, one level deeper, so `_remember` was emitted
|
||||
# first whichever statement came first. Measured by writing the reversal out
|
||||
# and grading it: shipped and reversed both scored True, while DECISIONS §3 and
|
||||
# price_history.py's docstring both cited the check as making a reversal "red
|
||||
# rather than plausible". Line numbers here, and the behaviour itself below.
|
||||
_carry_src = inspect.getsource(PriceHistory.carry)
|
||||
_tree = ast.parse(_carry_src.strip())
|
||||
_calls = [n.func.attr for n in ast.walk(_tree)
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)]
|
||||
check(f"...and `carry()` records BEFORE it fills, so a feed carrying yesterday "
|
||||
f"alongside today refreshes the profile before consuming it {_calls}",
|
||||
_calls.index("_remember") < _calls.index("yesterday"))
|
||||
_lines = {}
|
||||
for _n in ast.walk(_tree):
|
||||
if isinstance(_n, ast.Call) and isinstance(_n.func, ast.Attribute):
|
||||
_lines[_n.func.attr] = min(_lines.get(_n.func.attr, 10 ** 9), _n.lineno)
|
||||
check(f"`carry()` names `_remember` on an EARLIER LINE than `yesterday` "
|
||||
f"{_lines} - source order, which is what 'it records before it fills' "
|
||||
f"actually means",
|
||||
"_remember" in _lines and "yesterday" in _lines
|
||||
and _lines["_remember"] < _lines["yesterday"])
|
||||
|
||||
# ...and the BEHAVIOUR the ordering exists for, driven rather than read. A feed
|
||||
# that hands back yesterday alongside today must refresh yesterday's record
|
||||
# BEFORE it is consumed as the profile. Into an EMPTY store that is the whole
|
||||
# difference: record-then-fill covers the horizon, fill-then-record covers
|
||||
# nothing, because at the moment of the fill there is no yesterday yet.
|
||||
_h_ord = store() # empty: no record from an earlier day
|
||||
_two_day = PriceRead.series(
|
||||
[PricePoint(start=brussels(u),
|
||||
eur_per_kwh=allin(YESTERDAY_MWH[brussels(u).hour]),
|
||||
provisional=False) for u in YDAY_H]
|
||||
+ [PricePoint(start=brussels(u),
|
||||
eur_per_kwh=allin(TODAY_MWH[brussels(u).hour]),
|
||||
provisional=False) for u in TODAY_H],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", NOW)
|
||||
_ord_filled = _h_ord.carry(_two_day, since=NOW, until=_until, stamp=NOW)
|
||||
_ord_n = _ord_filled.match(series=lambda r: len(r.points), no_data=lambda _r: 0)
|
||||
check(f"...and the ordering is DRIVEN, not only read: a feed carrying "
|
||||
f"YESTERDAY alongside today, into an empty store, fills the whole "
|
||||
f"{HORIZON_H} h horizon ({_ord_n} h) because the record was written "
|
||||
f"before the lookup. Reversed, the lookup finds nothing and the plan is "
|
||||
f"the {len(_ahead)} published hours the window keeps",
|
||||
_ord_n == HORIZON_H)
|
||||
check("...and the hours it carried carry YESTERDAY's numbers - the record that "
|
||||
"same call had just written - not today's",
|
||||
_ord_filled.kind == "series"
|
||||
and all(abs(p.eur_per_kwh
|
||||
- allin(YESTERDAY_MWH[brussels(p.start).hour])) < 1e-9
|
||||
for p in _ord_filled.points if p.provisional))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -496,7 +790,72 @@ check(f"...and the record is keyed on the BRUSSELS date, so those 24 hours are "
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. AC4 - four degraded stores, four reasons, no prices")
|
||||
print("4b. the two mornings a year when 'yesterday' is a DATE and not 24 hours")
|
||||
|
||||
# ⚠️ `brussels()` RETURNS A FIXED-OFFSET DATETIME, so `brussels(stamp) -
|
||||
# timedelta(days=1)` subtracts 24 ABSOLUTE HOURS from a wall clock. The
|
||||
# Brussels day before a spring-forward is 23 hours long, so between 00:00 and
|
||||
# 00:59 on the morning after it that lands on the day BEFORE yesterday: the
|
||||
# fill then carries a two-day-old profile behind a flag whose only vocabulary
|
||||
# is "this is a guess", or - once pruning has dropped that day - answers
|
||||
# `no_data` naming a date that is not yesterday. Measured at
|
||||
# 2026-03-30T00:30+02:00 on the shipped branch: it returned 2026-03-28's
|
||||
# prices. DST is this module's headline claim and it had NO CHECK.
|
||||
for _label, (_dm2, _dm1, _d0) in (
|
||||
("spring forward - yesterday was 23 h long",
|
||||
("2026-03-28", "2026-03-29", "2026-03-30")),
|
||||
("fall back - yesterday was 25 h long",
|
||||
("2026-10-24", "2026-10-25", "2026-10-26"))):
|
||||
_lens = [len(bday_hours(_d)) for _d in (_dm2, _dm1, _d0)]
|
||||
check(f"{_label}: the fixture days are {_lens} hours long, so one of them "
|
||||
f"really is not 24 and a DATE subtraction and an INSTANT subtraction "
|
||||
f"can be told apart at all",
|
||||
_lens[1] != 24 and _lens[0] == 24 and _lens[2] == 24)
|
||||
_hd = store()
|
||||
_tag = {}
|
||||
for _i, _d in enumerate((_dm2, _dm1, _d0)):
|
||||
# One price band per DAY, so the answer names which day it came from.
|
||||
_tag[_d] = 0.100000 * (_i + 1)
|
||||
_ph.PriceHistory._remember(
|
||||
_hd,
|
||||
PriceRead.series(
|
||||
[PricePoint(start=brussels(u),
|
||||
eur_per_kwh=round(_tag[_d] + 0.000100
|
||||
* brussels(u).hour, 6),
|
||||
provisional=False)
|
||||
for u in bday_hours(_d)],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", at_local(_d, 12)),
|
||||
at_local(_d, 12))
|
||||
check(f"{_label}: all three days are held, so the check below is about the "
|
||||
f"DATE arithmetic and not about a record having been pruned away",
|
||||
sorted(_hd._days) == sorted((_dm2, _dm1, _d0)))
|
||||
_stamp_dst = at_local(_d0, 0) + timedelta(minutes=30)
|
||||
_yd = _hd.yesterday(_stamp_dst)
|
||||
_band = _yd.match(
|
||||
series=lambda r: round(min(p.eur_per_kwh for p in r.points), 4),
|
||||
no_data=lambda _r: None)
|
||||
check(f"{_label}: at 00:30 local on {_d0}, yesterday() answers {_dm1}'s "
|
||||
f"prices (band {_band}, wanted {round(_tag[_dm1], 4)}) and not "
|
||||
f"{_dm2}'s (band {round(_tag[_dm2], 4)}) {why(_yd)!r}",
|
||||
_yd.kind == "series" and _band == round(_tag[_dm1], 4))
|
||||
check(f"{_label}: ...and it holds {_lens[1]} points, the length that "
|
||||
f"Brussels day actually was - a 24-slot answer would be the wrong "
|
||||
f"day wearing the right date",
|
||||
_yd.kind == "series" and len(_yd.points) == _lens[1])
|
||||
# The control, one hour later: an implementation that is right only at
|
||||
# 01:30 is the one being caught, so both are graded.
|
||||
_yd2 = _hd.yesterday(at_local(_d0, 1) + timedelta(minutes=30))
|
||||
check(f"{_label}: ...and at 01:30 local it is still {_dm1} - the defect "
|
||||
f"shows only in the first local hour, which is why an hourly refresh "
|
||||
f"could hit it and never be noticed",
|
||||
_yd2.kind == "series"
|
||||
and round(min(p.eur_per_kwh for p in _yd2.points), 4)
|
||||
== round(_tag[_dm1], 4))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. AC4 - degraded stores, one reason each, no prices")
|
||||
|
||||
_missing = PriceHistory(os.path.join(tmpdir(), "gone", "price_history.json"))
|
||||
check("no durable volume: the store is NOT ok, and it does not create the "
|
||||
@@ -505,6 +864,28 @@ check("no durable volume: the store is NOT ok, and it does not create the "
|
||||
not _missing.ok and "no durable volume" in (_missing.last_error or "")
|
||||
and not os.path.isdir(os.path.dirname(_missing.path)))
|
||||
|
||||
# ⚠️ AND IT DOES NOT FILL IN MEMORY EITHER. FCST-04 shipped this half-done:
|
||||
# `_save()` short-circuited on `not ok` while `_remember()` populated
|
||||
# `self._days` anyway, so a site with no /data answered `words` = "unavailable"
|
||||
# and `yesterday()` = A SERIES - an in-memory profile that works all day and is
|
||||
# gone on the next restart. price_history.py's own third warning rejects
|
||||
# exactly that ("a yesterday that silently forgets") as its reason for not
|
||||
# calling makedirs, so the code and the docstring have to agree.
|
||||
_carried_nowhere = _missing.carry(series_of(TODAY_H, TODAY_MWH),
|
||||
since=NOW, until=_until, stamp=NOW)
|
||||
check("a store with no durable volume records NOTHING, not even in memory: "
|
||||
"after a full carry it still holds no day, `yesterday()` still answers "
|
||||
"no_data, and `words` still says unavailable. A cache that forgets on "
|
||||
"restart without saying so is worse than one that says it cannot answer",
|
||||
_missing._days == {}
|
||||
and _missing.yesterday(NOW + timedelta(days=1)).kind == "no_data"
|
||||
and _missing.words.startswith("unavailable"))
|
||||
check("...and the carry still returns a real series - the fill windows the "
|
||||
"feed and carries nothing, which is the pre-FCST-04 horizon and not a "
|
||||
"degradation",
|
||||
_carried_nowhere.kind == "series"
|
||||
and len(_carried_nowhere.points) == len(_ahead))
|
||||
|
||||
_d6 = tmpdir()
|
||||
_p6 = os.path.join(_d6, "price_history.json")
|
||||
open(_p6, "w", encoding="utf-8").write("{ this is not json")
|
||||
@@ -554,6 +935,127 @@ check("a record naming a product that is not a PriceProduct is no_data, not a "
|
||||
"series with a string in the field the optimiser switches on",
|
||||
PriceHistory(_p9).yesterday(NOW).kind == "no_data")
|
||||
|
||||
# ⚠️ AND THE ONE THAT STOPPED THE ADD-ON FROM STARTING. `_load()` validated
|
||||
# `version` and that `days` is a dict, and never that each VALUE is one. With
|
||||
# `{"version": 1, "days": {"2026-01-14": "oops"}}` the store reported ok=True
|
||||
# and last_error=None, and then `rec.get(...)` raised `AttributeError` out of
|
||||
# `words`, `yesterday()` and `resolve_tariff()` alike. `PriceHistory(...)` and
|
||||
# `.words` are both unguarded in `Controller.__init__`, so the controller did
|
||||
# not come up - and it never self-healed, because `_remember()` raised on the
|
||||
# same record before it could reach `_save()`. The asymmetry that produced it
|
||||
# is visible in the file: `_parse_points()` is carefully defensive about every
|
||||
# field of a POINT, and `_load()` was not defensive about the record at all.
|
||||
_p14 = os.path.join(tmpdir(), "price_history.json")
|
||||
json.dump({"version": SCHEMA, "days": {
|
||||
"2026-01-14": "oops",
|
||||
"2026-01-13": {"product": "dynamic_dayahead", "provider": "generic_dayahead",
|
||||
"points": [_good]}}},
|
||||
open(_p14, "w", encoding="utf-8"))
|
||||
_h14 = PriceHistory(_p14)
|
||||
check("a day record that is not a dict is DROPPED and REPORTED, and the rest "
|
||||
"of the file still loads - one bad record is not a bad file",
|
||||
_h14.ok and "unusable day record" in (_h14.last_error or "")
|
||||
and sorted(_h14._days) == ["2026-01-13"])
|
||||
_w14 = derived(lambda: _h14.words)
|
||||
_y14 = derived(lambda: _h14.yesterday(NOW).kind)
|
||||
_plan14 = derived(lambda: run(resolve_tariff(
|
||||
CFG, NOW, _until, transport=transport_for(_body_today),
|
||||
now=NOW, history=_h14)).origin)
|
||||
check(f"...and NOTHING raises on it: not `words` ({_w14!r}), not `yesterday()` "
|
||||
f"({_y14!r}), not the shipped `resolve_tariff()` path ({_plan14!r}). A "
|
||||
f"broken cache is a short horizon, not a controller that will not start",
|
||||
isinstance(_w14, str) and _y14 == "no_data" and _plan14 == "provider")
|
||||
for _shape in ("oops", [1, 2, 3], 7, None, {"points": "not a list"}):
|
||||
_pX = os.path.join(tmpdir(), "price_history.json")
|
||||
json.dump({"version": SCHEMA, "days": {"2026-01-14": _shape}},
|
||||
open(_pX, "w", encoding="utf-8"))
|
||||
_hX = PriceHistory(_pX)
|
||||
_wX = derived(lambda h=_hX: h.words)
|
||||
_yX = derived(lambda h=_hX: h.yesterday(NOW).kind)
|
||||
check(f"...and the same holds for a day record of {type(_shape).__name__} "
|
||||
f"{_shape!r}: dropped, reported, and every accessor answers "
|
||||
f"({_wX!r}, {_yX!r})",
|
||||
_hX._days == {} and "unusable day record" in (_hX.last_error or "")
|
||||
and isinstance(_wX, str) and _yX == "no_data")
|
||||
|
||||
_pF = os.path.join(tmpdir(), "price_history.json")
|
||||
json.dump({"version": float(SCHEMA), "days": {}},
|
||||
open(_pF, "w", encoding="utf-8"))
|
||||
check(f"a `version` of {float(SCHEMA)!r} is refused although it EQUALS "
|
||||
f"{SCHEMA} - a schema check that cannot tell 1 from 1.0 is one that "
|
||||
f"would not notice a writer this build has never met",
|
||||
"schema" in (PriceHistory(_pF).last_error or ""))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5b. `words` - the status row, and one of the two things a bad record "
|
||||
"used to crash")
|
||||
|
||||
def words_of(h):
|
||||
return derived(lambda: h.words)
|
||||
|
||||
|
||||
_w_missing = words_of(_missing)
|
||||
check(f"no durable volume: the row says UNAVAILABLE and names the reason "
|
||||
f"{_w_missing!r}",
|
||||
isinstance(_w_missing, str) and _w_missing.startswith("unavailable")
|
||||
and "no durable volume" in _w_missing)
|
||||
_h_words = store()
|
||||
check("a healthy but EMPTY store says something different, because it is a "
|
||||
"different state and a normal one on a site's first day",
|
||||
isinstance(words_of(_h_words), str)
|
||||
and words_of(_h_words).startswith("empty")
|
||||
and "nothing to carry forward" in words_of(_h_words))
|
||||
_ph.PriceHistory._remember(_h_words, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
check(f"...and a store holding a day names the DATE and the HOUR COUNT "
|
||||
f"{words_of(_h_words)!r} - 'a cache exists' and 'the cache holds "
|
||||
f"yesterday, all 24 hours of it' are different facts, and only the "
|
||||
f"second says the horizon will be full tomorrow morning",
|
||||
words_of(_h_words) == f"{YDAY_D} (24 h)")
|
||||
check(f"a file that only PARTLY loaded says so in the row {_w14!r}, or the "
|
||||
f"days it dropped read as days that were simply never written",
|
||||
isinstance(_w14, str) and (_h14.last_error or "") in _w14
|
||||
and "2026-01-13" in _w14)
|
||||
check("NoHistory() has a sentence of its own rather than an empty cell",
|
||||
NoHistory().words == "not kept")
|
||||
check("...and `words` never raises, whatever the file holds. It is read once "
|
||||
"from Controller.__init__ and again on every status render, and neither "
|
||||
"call site guards it - an exception here is an add-on that does not start",
|
||||
all(isinstance(words_of(h), str)
|
||||
for h in (_missing, _h6, _h7, _h14, _h_words)))
|
||||
|
||||
# ⚠️ AND A FAILED WRITE LEAVES NOTHING BEHIND. `_save()` writes to a `mkstemp`
|
||||
# and renames - atomic, so a power cut cannot leave a truncated file that
|
||||
# parses as an empty day - and a failure BETWEEN the two used to leave the temp
|
||||
# in place. The refresh task writes 48 times a day, so a persistent write
|
||||
# failure leaked 48 inodes a day onto the volume whose being full is the
|
||||
# likeliest reason the write failed at all.
|
||||
_dleak = tmpdir()
|
||||
_pleak = os.path.join(_dleak, "price_history.json")
|
||||
_hleak = PriceHistory(_pleak)
|
||||
check("the leak fixture starts clean, or 'nothing left behind' is not a "
|
||||
"measurement",
|
||||
_hleak.ok and os.listdir(_dleak) == [])
|
||||
os.mkdir(_pleak) # a DIRECTORY where the file goes: the temp is
|
||||
# written fine and `os.replace` cannot complete
|
||||
_ph.PriceHistory._remember(_hleak, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
check(f"a write that cannot complete SAYS so rather than passing quietly "
|
||||
f"{(_hleak.last_error or '')[:48]!r}",
|
||||
"could not write" in (_hleak.last_error or ""))
|
||||
_left = sorted(n for n in os.listdir(_dleak) if n != "price_history.json")
|
||||
check(f"...and leaves NOTHING behind {_left} - at 48 writes a day a leaked "
|
||||
f"temp per write fills the volume that was already the problem",
|
||||
_left == [])
|
||||
_dok = tmpdir()
|
||||
_hok = PriceHistory(os.path.join(_dok, "price_history.json"))
|
||||
_ph.PriceHistory._remember(_hok, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
check(f"...and a SUCCESSFUL write leaves exactly the one file "
|
||||
f"{sorted(os.listdir(_dok))}, so the cleanup did not start deleting the "
|
||||
f"cache it just wrote",
|
||||
sorted(os.listdir(_dok)) == ["price_history.json"]
|
||||
and _hok.last_error is None)
|
||||
|
||||
# ...and every one of those leaves the CONTROLLER regulating.
|
||||
#
|
||||
# ⚠️ THE PLAN IS NOT BIT-FOR-BIT THE PRE-FCST-04 ONE, and the first draft of
|
||||
@@ -671,14 +1173,88 @@ check(f"six days filed, {KEEP_DAYS} held {_held} - a bigger cache would only "
|
||||
check("...and yesterday is one of them, which is the only day that matters",
|
||||
_h12.yesterday(NOW).kind == "series")
|
||||
|
||||
_h13 = store()
|
||||
_ph.PriceHistory._remember(
|
||||
_h13, series_of(YDAY_H, YESTERDAY_MWH), NOW)
|
||||
# ⚠️ THE STIMULUS HAS TO REACH `_prune`'s BODY, AND THE FIRST DRAFT DID NOT.
|
||||
# It seeded ONE day and then pruned with a jumped stamp: `if len(self._days) <=
|
||||
# KEEP_DAYS: return` fired, the body never ran, and the check named after that
|
||||
# body was green for an implementation that pruned by clock arithmetic.
|
||||
# Measured - the body was replaced with "delete every day older than stamp
|
||||
# minus KEEP_DAYS days", the guard left in place, and the whole 29-file suite
|
||||
# stayed green while the mutant emptied the store on a jumped clock.
|
||||
#
|
||||
# A store holding more days than the cap is a REAL state, not a contrivance:
|
||||
# `_load()` does not prune, so this is exactly what a restart sees after a file
|
||||
# written by a build with a larger cap, or after a feed that spanned several
|
||||
# days. So the fixture is written, opened, and only then pruned.
|
||||
_FILED = KEEP_DAYS + 2
|
||||
_p13 = os.path.join(tmpdir(), "price_history.json")
|
||||
_days13 = {}
|
||||
for _back in range(_FILED - 1, -1, -1):
|
||||
_d13 = (datetime.fromisoformat(TODAY_D)
|
||||
- timedelta(days=_back)).strftime("%Y-%m-%d")
|
||||
_days13[_d13] = {
|
||||
"product": PriceProduct.DAY_AHEAD.value,
|
||||
"provider": "generic_dayahead",
|
||||
"points": [PricePoint(start=brussels(u),
|
||||
eur_per_kwh=allin(YESTERDAY_MWH[brussels(u).hour]),
|
||||
provisional=False).canonical()
|
||||
for u in bday_hours(_d13)]}
|
||||
json.dump({"version": SCHEMA, "days": _days13},
|
||||
open(_p13, "w", encoding="utf-8"))
|
||||
_h13 = PriceHistory(_p13)
|
||||
check(f"the prune fixture holds {len(_h13._days)} days, MORE than KEEP_DAYS "
|
||||
f"({KEEP_DAYS}), so the early return cannot swallow the stimulus - this "
|
||||
f"file's own rule, and the one its first draft broke",
|
||||
len(_h13._days) == _FILED > KEEP_DAYS)
|
||||
_ph.PriceHistory._prune(_h13, NOW + timedelta(days=4000))
|
||||
check("pruning is by SORT ORDER, not by arithmetic against the clock: a clock "
|
||||
"that jumped four thousand days does not delete the record, which would "
|
||||
"empty the store on exactly the morning the fill is for",
|
||||
_h13.yesterday(NOW).kind == "series")
|
||||
check(f"pruning does not delete by ARITHMETIC AGAINST THE CLOCK: a clock that "
|
||||
f"jumped four thousand days keeps the newest {KEEP_DAYS} of the "
|
||||
f"{_FILED} records {sorted(_h13._days)} instead of deleting all of them, "
|
||||
f"which would empty the store on exactly the morning the fill is for",
|
||||
len(_h13._days) == KEEP_DAYS and _h13.yesterday(NOW).kind == "series")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("8b. the two days a lookup can ask for are PINNED, so a far-off record "
|
||||
"cannot evict yesterday")
|
||||
|
||||
# ⚠️ KEEP_DAYS = 3 IS THE STEADY STATE EXACTLY - {yesterday, today, tomorrow} -
|
||||
# so a newest-N cap has NO SLACK. A feed that publishes to D+2 makes four days
|
||||
# and the cap drops the oldest, which is yesterday: the only day the fill can
|
||||
# use. One stray point in the wrong year does the same thing permanently,
|
||||
# because a lexicographic sort puts it LAST and nothing can ever age it out.
|
||||
_h15 = store()
|
||||
_SPAN_D = (YDAY_D, TODAY_D, "2026-01-16", "2026-01-17")
|
||||
_span = [u for _d in _SPAN_D for u in bday_hours(_d)]
|
||||
check(f"the spanning fixture really covers {len(_SPAN_D)} Brussels days, more "
|
||||
f"than KEEP_DAYS ({KEEP_DAYS}), or the cap is not under pressure and "
|
||||
f"this grades nothing",
|
||||
len(_SPAN_D) > KEEP_DAYS
|
||||
and len({brussels(u).strftime("%Y-%m-%d") for u in _span}) == len(_SPAN_D))
|
||||
_ph.PriceHistory._remember(_h15, series_of(_span, YESTERDAY_MWH), NOW)
|
||||
check(f"a feed spanning {len(_SPAN_D)} days does NOT evict yesterday: "
|
||||
f"{sorted(_h15._days)} held, and {YDAY_D} is one of them. Under the "
|
||||
f"newest-{KEEP_DAYS} cap alone it is the first record dropped, and the "
|
||||
f"fill loses its only usable day for good",
|
||||
YDAY_D in _h15._days and _h15.yesterday(NOW).kind == "series")
|
||||
|
||||
_h16 = store()
|
||||
for _d16 in (YDAY_D, TODAY_D, "2026-01-16"):
|
||||
_ph.PriceHistory._remember(_h16, series_of(bday_hours(_d16), TODAY_MWH), NOW)
|
||||
_STRAY_D = "2099-07-01"
|
||||
_ph.PriceHistory._remember(
|
||||
_h16, series_of(bday_hours(_STRAY_D)[:1], TODAY_MWH), NOW)
|
||||
check(f"the stray fixture is really far future and really sorts last "
|
||||
f"{sorted(_h16._days)[-1]!r}, or 'it can never age out' is not what is "
|
||||
f"being graded",
|
||||
sorted(_h16._days)[-1] == _STRAY_D)
|
||||
check(f"...and ONE stray point in {_STRAY_D} does not push yesterday out "
|
||||
f"either. It is not deleted - nothing is deleted by arithmetic here - "
|
||||
f"it simply cannot take yesterday's slot",
|
||||
YDAY_D in _h16._days and _h16.yesterday(NOW).kind == "series")
|
||||
check(f"...while the store stays BOUNDED at KEEP_DAYS + 2 = {KEEP_DAYS + 2} "
|
||||
f"records ({len(_h16._days)} held), so the pin is a pin and not a leak",
|
||||
len(_h16._days) <= KEEP_DAYS + 2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -708,11 +1284,57 @@ _kw = [k.arg for n in ast.walk(_refresh) if isinstance(n, ast.Call)
|
||||
check(f"the shipped refresh task passes a history {_kw} - the wiring is what "
|
||||
f"this ticket IS, so it is checked in the tree rather than described",
|
||||
"history" in _kw)
|
||||
check("the FCST-04 note in main.py carries the WINDOWING caveat beside the "
|
||||
"'widens what the optimiser can SEE, never what it may act on' "
|
||||
"guarantee. That note is the one place a maintainer reads that sentence "
|
||||
"unqualified, and the CHANGELOG, DOCS and resolve_tariff()'s docstring "
|
||||
"all carry the pair - so it may not appear here on its own",
|
||||
"never what it may act on" in _MAIN and "ALREADY ELAPSED" in _MAIN)
|
||||
|
||||
check("...and it is the CONTROLLER'S store, not a NoHistory() smuggled in to "
|
||||
"make the call typecheck",
|
||||
"history=self.price_history" in _MAIN
|
||||
and "NoHistory" not in _MAIN)
|
||||
|
||||
check("...and `words` reaches the STATUS PAGE as its own row, so what the fill "
|
||||
"has to carry from is a fact an operator can read rather than one that "
|
||||
"only exists in a log line",
|
||||
'("Price history", self.price_history.words)' in _MAIN)
|
||||
_rows_fn = next(n for n in ast.walk(_main_tree)
|
||||
if isinstance(n, ast.FunctionDef)
|
||||
and n.name == "status_payload")
|
||||
_row_labels = [e.elts[0].value for e in ast.walk(_rows_fn)
|
||||
if isinstance(e, ast.Tuple) and len(e.elts) == 2
|
||||
and isinstance(e.elts[0], ast.Constant)
|
||||
and isinstance(e.elts[0].value, str)]
|
||||
check(f"...and it is in the ROW LIST rather than in a comment about one "
|
||||
f"{[r for r in _row_labels if 'rice' in r or 'ariff' in r]}",
|
||||
"Price history" in _row_labels)
|
||||
|
||||
# ⚠️ AND WHAT THE STATUS PAGE DOES **NOT** SAY, PINNED AS A PAIR. DOCS.md
|
||||
# claimed "*Tariff* on the status page reads `provisional` through the
|
||||
# morning". It does not and never did: `self.tariff_words` is assigned only
|
||||
# from the Home Assistant price-entity branch and from `static_status()`, both
|
||||
# on the 30 s tick, and the provider plan's own sentence reaches `_LOG.info`
|
||||
# and nothing else. This grades the PAIRING rather than the current state - so
|
||||
# wiring the plan into that row is fine and correcting the doc is fine, and
|
||||
# doing one without the other is red.
|
||||
_MAIN_LINES = _MAIN.splitlines()
|
||||
_tw_lines = sorted({n.lineno for n in ast.walk(_main_tree)
|
||||
if isinstance(n, ast.Assign)
|
||||
for t in n.targets
|
||||
for a in ast.walk(t)
|
||||
if isinstance(a, ast.Attribute) and a.attr == "tariff_words"})
|
||||
_row_from_plan = any("plan" in _MAIN_LINES[i - 1] for i in _tw_lines)
|
||||
_DOCS = open(os.path.join(HERE, "DOCS.md"), encoding="utf-8").read()
|
||||
_docs_claims = "**Tariff** on the status page reads `provisional`" in _DOCS
|
||||
check(f"the *Tariff* row and what DOCS says about it AGREE: fed from the "
|
||||
f"provider plan = {_row_from_plan} (assigned on lines {_tw_lines}), DOCS "
|
||||
f"claims it reads `provisional` through the morning = {_docs_claims}. It "
|
||||
f"shipped as False against True - an overclaim in a doc is a defect "
|
||||
f"here, and this is the pair that has to move together",
|
||||
len(_tw_lines) >= 2 and _row_from_plan == _docs_claims)
|
||||
|
||||
# NoHistory is inert, and says so by identity rather than by producing a series.
|
||||
_nh_read = series_of(TODAY_H, TODAY_MWH)
|
||||
check("NoHistory() carries nothing and returns the read BY IDENTITY, so the "
|
||||
|
||||
Reference in New Issue
Block a user