FCST-03: yesterday's prices, flagged as yesterday's, and unable to move anything
checks / unit (pull_request) Successful in 2m43s

§8.3 fills the hours a day-ahead feed has not published yet with yesterday's
profile and flags them provisional, and forbids any irreversible action on such
a price. The whole risk of this ticket is one line - a carried point and a
published point are three fields and a bool, so `provisional=y.provisional`
copies yesterday's False onto today's guess and produces stale data presented as
current. That is SAFETY-18's frozen-but-fresh meter one layer up.

So the flag is never passed through. Carried points come from a constructor with
no provisional parameter; published points pass through BY IDENTITY, so no copy
exists that could lose one; an audit re-derives the expected flag for every
emitted point and raises rather than repairing; and the check walks the module's
own AST so the pass-through idiom cannot come back looking ordinary. A published
point may itself be provisional (eneco_eddy's imbalance print is), so the rule
for a published hour is "whatever the feed said", never False.

The fill keys on the Brussels wall clock rather than t-24h, so the 23- and
25-hour days are right; a late publication rebuilds the series, moving the
number and the flag together.

AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside
it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is
provisional" would have switched arbitrage off every morning on every dynamic
site - and refusal 5 is new: the spread must clear a threshold widened by the
widest active uncertainty source (max, never a product). The permitting variant
now refuses to be constructed around a provisional point at all, and the
cheap-window flag became per-hour for the same reason.

The irreversible action is a value carrying the hours it commits, derived from
maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain
whose window reaches into a carried hour, and the phase machine stays idle. It
is the price-justified entry point only - a gate that blocked on no_data would
stop the monthly cycle on every default install.

Zero new config options; TEST-14's signature scan ported to cover both Gate's
and Arbitrage's constructors, closing _Refused.reason's open hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
glenn schrooyen
2026-08-30 23:54:07 +02:00
co-authored by Claude Opus 5
parent cf5094c608
commit dd40c56ab4
6 changed files with 1668 additions and 48 deletions
+63
View File
@@ -2,6 +2,69 @@
## Unreleased
**Provisional prices, and what may not rest on one (FCST-03).**
Day-ahead prices arrive once a day, around 14–16 h CET. Until they do, §8.3 says
the unknown hours use yesterday's profile and are flagged `provisional: true` —
and **no irreversible action may be taken on a provisional price.**
`app/price_forecast.py` does the fill, and the whole module is shaped by one
defect: a carried-forward point and a published one are the same three fields
and a bool, so the natural line `PricePoint(..., provisional=y.provisional)`
copies yesterday's `False` onto today's guess and produces stale data presented
as current. That is the meter-side SAFETY-18 failure one layer up — the reading
arrives on time, looks fresh, and is wrong.
**So the flag is never passed through.** Carried points are built by a
constructor that has no `provisional` parameter at all (the literal `True` lives
in one place, and there is nothing for a caller to forget or copy); published
points are passed through **by identity**, so there is no copy that could lose a
flag; and an audit re-derives the expected flag for every emitted point from the
set of hours the feed actually published and *raises* rather than repairing one.
A published point may itself be provisional — `eneco_eddy`'s current-quarter
imbalance print is — so the audit's rule for a published hour is "whatever the
feed said", never "False".
**The fill keys on the Brussels wall clock, not on `t − 24 h`.** A 23-hour day
has no 02:00 and a 25-hour day has two, so a fixed 24-hour subtraction shifts
every carried hour by one across a transition and misprices a whole day with
nothing to show for it. Re-running the fill after a late publication replaces
the carried hours with the real print: the number and the flag move together
because the series is rebuilt, never patched.
**Arbitrage now drops provisional hours instead of refusing the whole day.**
The refusal that read "the plan is provisional" would have switched arbitrage off
every morning on every dynamic site once carry-forward filling landed — a
part-firm series is the *normal* state before the print. What §8.3 forbids is
acting **on** a provisional price, so the provisional hours are dropped and the
spread, the cheapest hour and the dearest hour are computed over the published
ones only; fewer than two firm hours is still a refusal, which is where the
§11.4 fallback (wholly provisional) lands. The permitting variant now refuses to
be *constructed* around a provisional point at all. The cheap-window flag became
per-hour for the same reason, so a site's maintenance charge floor does not go
dark every morning.
**Real-time / imbalance products get measurably wider thresholds.** One widening
path, driven by a set of active uncertainty sources: the product's own (§9.2 row
2, derived from the declared `PriceProduct` and never from a plugin id) and
§11.4's "no weather data" row, which the spec widens by a different trigger and
which is the same mechanism. The combination rule is `max`, not a product — two
independent doubts must not compound into a threshold nothing can clear. The
caller supplies the base round-trip cost threshold, so OPT-04 still owns what
the number *is*; this owns only how much wider it gets and why, and the allowed
verdict reports the threshold it cleared.
**An irreversible action is a value, not a judgement call at each call site.**
`Irreversible` carries the hours an action commits — the maintenance drain's
window is derived from `maintenance.DRAIN_TIMEOUT_MIN` rather than typed twice —
and the gate looks at exactly those hours, blocking on a provisional one, on an
hour the series does not cover, and on no price data at all: three different
reasons, none of them an answer. ⚠️ It is the entry point for a start being
*justified by a price*; the calendar path main.py runs today is untouched,
because a gate that blocked on `no_data` would stop the monthly cycle on every
default install — §12's opening sentence.
**Zero new options.** No `config.yaml` key, no new defaults dict, no `o.get(k,
X)` in main.py: the module reads no options at all, so CFG-03 did not widen.
**Static day/night and fully-fixed tariffs, first-class (TARIFF-03).**
§9.2 puts a large share of the Belgian installed base on a static profile, and
an EMS that only works under dynamic pricing is an EMS for a minority. A new