File size: 9,163 Bytes
5d7f266 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | """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
|