eu-price-forecast / code /forecast.py
x2q's picture
Initial release 2026-11-03
5d7f266 verified
Raw History Blame Contribute Delete
9.16 kB
"""Scoring: from price history + the live weather forecast to the KV value of one zone. Pure pandas/numpy - the models are applied by
train_daily.py and arrive here as arrays, so everything in this file is unit-tested without LightGBM, the network or the Spark.
What is published (KV `eu-forecast-<zone>`), and what is not:
* ONLY forecast days. The settled prices stay in `eu-prices-<zone>` (each zone's licensed source); /api/nordic puts the two
together, so this key never carries a price that a data licence might attribute to somebody else.
* every day from the first unpublished day to h = MAX_H, `type: 'forecast'`, with `horizon` (= h) and P10/P90 in `min_eur_mwh` /
`max_eur_mwh`, the Nordic feed's own field names, on all 24 hours or on none - and ONLY the horizons the publish gate lets this zone
publish (publish_gate.json: at least 5 % better than the naive level+shape method out of sample).
* `trainedThrough`, `model`, and `accuracy` - the backtest's error of THIS zone for exactly the horizons published, so a page can state
what the forecast is worth instead of a general claim.
Horizon. h = days from the last known price day to the target day, plus one: with tomorrow's auction published, the day after
tomorrow is h = 2; before it is published, tomorrow is h = 2 (the information state is the same: one unknown day).
"""
from datetime import timedelta
import numpy as np
import pandas as pd
import features as F
import recipe
from zones import ZONES
MAX_H = recipe.H_MAX
MIN_HOURS = 12 # a day counts as published with at least this many priced hours (like the price jobs)
MODEL_NAME = recipe.MODEL_NAME
def last_known_day(p):
"""The newest date with at least MIN_HOURS priced hours, or None."""
n = p.dropna().groupby(p.dropna().index.normalize()).size()
n = n[n >= MIN_HOURS]
return n.index.max().date() if len(n) else None
def live_weather_local(live, zone):
"""live()'s frame (UTC, raw Open-Meteo names) -> the zone's local clock with the model's column names."""
f = live[zone].rename(columns={v: k for k, v in F.RAW_VARS.items()})[F.WX]
return F.to_local(f, ZONES[zone][1])
def live_regional_local(live, zone):
reg = F.regional({z: f.rename(columns={v: k for k, v in F.RAW_VARS.items()})[F.WX] for z, f in live.items()})
return F.to_local(reg, ZONES[zone][1])
def target_days(last_known, today, days_ahead=7, max_h=MAX_H):
"""[(date, h)] for every date after the last known day, up to `days_ahead` after today and at most max_h."""
out = []
d = last_known + timedelta(days=1)
while d <= today + timedelta(days=days_ahead):
h = (d - last_known).days + 1
if 2 <= h <= max_h:
out.append((d, h))
d += timedelta(days=1)
return out
def build_rows(zone, p, wx, reg, days, eu, today, days_ahead=7, max_h=MAX_H):
"""The feature rows of every forecast day of one zone (24 hours per day, h = 2 .. max_h), with `t` (the hour), `date` (its day) and
`zone`; an empty frame when the zone's prices are stale (older than yesterday: a forecast built on them would be a week old)."""
lk = last_known_day(p)
if lk is None or lk < today - timedelta(days=1):
return pd.DataFrame()
parts = []
for d, h in target_days(lk, today, days_ahead, max_h):
idx = pd.date_range(pd.Timestamp(d), periods=24, freq="h")
X = F.make_features(zone, idx, h, p, wx, reg, days, eu)
X["t"] = idx
X["date"] = pd.Timestamp(d)
parts.append(X)
if not parts:
return pd.DataFrame()
out = pd.concat(parts, ignore_index=True)
out["zone"] = zone
return out
def offset_arrays(cal, kind, zone, hs):
"""Per-row band offsets: (lo, hi) arrays for the horizons `hs` (one per row), NaN where the zone has no calibration of this kind."""
import gate as G
hs = np.asarray(hs)
lo, hi = np.full(len(hs), np.nan), np.full(len(hs), np.nan)
for h in np.unique(hs):
o = G.offsets(cal, kind, zone, int(h))
if o is not None:
m = hs == h
lo[m], hi[m] = o["lo"], o["hi"]
return lo, hi
def band_edges(point, q_lo, q_hi, off_lo, off_hi):
"""P10 / P90 arrays for rows whose central estimate is `point`, or None when there is nothing to base a band on.
`q_lo`, `q_hi` the quantile models' own P10 / P90 predictions (EUR/MWh, absolute) - the report's section-7 recipe - or None
`off_lo/off_hi` per-row offsets from calibration_eu.json: the 1.2 x rolling 10th / 90th percentile of the edge's own miss, ADDED to the
quantile prediction (kind 'quantile'), or to the central estimate when q_lo / q_hi are None (kind 'residual')
Both edges are widened, never narrowed, to contain the central estimate (a mean of two models can sit outside a quantile model's edges),
so min <= price <= max always holds. An hour without an offset or with a non-finite edge has NaN edges: build_days then publishes the
day without any band (a band on some hours only is not a band, and the API drops such a day)."""
point = np.asarray(point, dtype=float)
off_lo, off_hi = np.asarray(off_lo, dtype=float), np.asarray(off_hi, dtype=float)
if not (np.isfinite(off_lo).any() and np.isfinite(off_hi).any()):
return None
if q_lo is not None and q_hi is not None:
lo, hi = np.asarray(q_lo, dtype=float) + off_lo, np.asarray(q_hi, dtype=float) + off_hi
else:
lo, hi = point + off_lo, point + off_hi
return np.minimum(lo, point), np.maximum(hi, point)
def build_days(rows, point, edges, allowed, level_corr=None, shadow_corr=None):
"""The `days` list from a zone's rows and the central estimate `point` (array aligned to rows).
allowed the horizons to publish (set); a day at another horizon is left out
edges (lo, hi) arrays aligned to rows, or None (no band)
level_corr (zone, h, date) -> EUR/MWh shifted onto the published numbers (0 unless EU_FORECAST_LAMBDA is set; shadow otherwise)
A day whose estimate has a non-finite hour is dropped, not patched. Each day also carries `uncorrected` and, with shadow_corr, `shadow`
(the archive's, stripped by payload())."""
point = np.asarray(point, dtype=float)
out = []
if rows.empty:
return out
rows = rows.reset_index(drop=True) # positions in `rows` are positions in point / edges
zone = rows["zone"].iloc[0]
for (date, h), g in rows.groupby(["date", "h"], sort=True):
h = int(h)
if h not in allowed or len(g) != 24:
continue
pos = g.index.to_numpy()[np.argsort(g["hour"].to_numpy(), kind="stable")]
y = point[pos]
if not np.isfinite(y).all():
continue
raw = y.copy()
d = date.date() if hasattr(date, "date") else date
if level_corr is not None:
y = y + float(level_corr(zone, h, d))
lo = hi = None
if edges is not None:
lo, hi = np.asarray(edges[0], dtype=float)[pos], np.asarray(edges[1], dtype=float)[pos]
if level_corr is not None:
lo, hi = lo + (y - raw), hi + (y - raw)
if not (np.isfinite(lo).all() and np.isfinite(hi).all()):
lo = hi = None
rows24 = []
for hr in range(24):
r = {"hour": hr, "eur_mwh": round(float(y[hr]), 2)}
if lo is not None:
r["min_eur_mwh"] = round(float(min(lo[hr], y[hr])), 2)
r["max_eur_mwh"] = round(float(max(hi[hr], y[hr])), 2)
rows24.append(r)
day = {"date": d.isoformat(), "type": "forecast", "horizon": h, "prices": rows24,
"uncorrected": [round(float(v), 2) for v in raw]}
if shadow_corr is not None:
day["shadow"] = [round(float(v) + float(shadow_corr(zone, h, d)), 2) for v in raw]
out.append(day)
return out
def payload(zone, days, today, trained_through, generated_at, accuracy=None, recipe_name=None, fuel=None):
"""The KV value. `generated` is the zone-independent run date; `accuracy` is the backtest block for this zone (gate.accuracy_block)."""
v = {"zone": zone, "generated": today.isoformat(), "generatedAt": generated_at,
"trainedThrough": trained_through.isoformat() if trained_through else None,
"unit": "EUR/MWh", "model": MODEL_NAME,
"days": [{k: x for k, x in d.items() if k not in ("uncorrected", "shadow")} for d in days]}
if recipe_name:
v["recipe"] = recipe_name
if fuel is not None:
v["fuel"] = bool(fuel)
if accuracy:
v["accuracy"] = accuracy
return v
def archive_payload(pl, days=None):
"""What is kept for monitoring (forecast-v3-archive pattern): the run as published, without the accuracy block, and - when
the unstripped `days` are passed - with each day's `uncorrected` model numbers and `shadow` numbers, so the error of the MODEL (not of the
corrected forecast) can be measured from the archive later."""
out = {k: v for k, v in pl.items() if k != "accuracy"}
if days is not None:
out["days"] = days
return out