gb-price-estimate / code /fetch_mid.py
x2q's picture
Initial release 2026-11-03
e49f88c verified
Raw History Blame Contribute Delete
3.66 kB
#!/usr/bin/env python3
"""Great Britain's Elexon Market Index Price (MID) as an hourly history, cached in one parquet: price_gb.parquet (t = the hour on the London wall clock,
naive; eur_mwh = GBP/MWh - the column keeps the EU code's name, the unit is pounds).
The hours are exactly those the live job writes to KV `eu-prices-gb` (scripts/eu_prices/elexon_parse.py: the volume-weighted half-hour price, an
hour counts only when all its half hours are priced, hourly mean, London clock), so the model is trained on the number the page shows.
Elexon allows at most 7 days per request; this pulls 6 local days per request with a pause (a few hundred requests for four years, once). A later run
re-pulls only the last REFRESH_DAYS and merges. Licence: Contains BMRS data (c) Elexon Limited copyright and database right 2026 - the history is a
training input; nothing here is published except the model's own estimate.
python3 fetch_mid.py [--since 2023-01-01]
"""
import argparse
import os
import sys
import time
from datetime import date, datetime, timedelta, timezone
import pandas as pd
DIR = os.path.dirname(os.path.abspath(__file__))
for p in (DIR, os.path.join(DIR, "..", "..", "eu_prices"), os.path.join(DIR, "..", "eu")): # repo layout; flat on the Spark
sys.path.insert(0, p)
import elexon_parse as X # noqa: E402
import build_elexon_prices as BE # noqa: E402
TZ = "Europe/London"
START = date(2023, 1, 1)
CHUNK_DAYS = 6
REFRESH_DAYS = 10
PAUSE = 1.0
DATA = os.environ.get("GB_DATA_DIR", DIR) # where the caches live (the Spark: the job's own directory)
PATH = os.path.join(DATA, "price_gb.parquet")
def pull(since, until):
"""Hourly Series (naive London clock) for the local days since..until inclusive."""
rows, cur = {}, since
while cur <= until:
end = min(cur + timedelta(days=CHUNK_DAYS - 1), until)
dates = [(cur + timedelta(days=i)).isoformat() for i in range((end - cur).days + 1)]
a, b = BE.interval(dates, TZ)
slots = X.parse_mid(BE.fetch_mid(a, b))
for d in X.build_days(slots, TZ, dates, min_hours=1):
for p in d["prices"]:
if p["eur_mwh"] is not None:
rows[pd.Timestamp(d["date"]) + pd.Timedelta(hours=p["hour"])] = p["eur_mwh"]
cur = end + timedelta(days=1)
time.sleep(PAUSE)
return pd.Series(rows, dtype=float).sort_index()
def update(out=PATH, today=None, start=START, refresh=REFRESH_DAYS):
"""Create or top up the cache; returns the hourly Series."""
today = today or datetime.now(timezone.utc).date()
if os.path.exists(out):
old = pd.read_parquet(out)
old = old.set_index(pd.to_datetime(old["t"]))["eur_mwh"]
since = max(old.index.max().date() - timedelta(days=refresh), start)
new = pull(since, today)
merged = pd.concat([old[old.index < pd.Timestamp(since)], new])
else:
merged = pull(start, today)
merged = merged[~merged.index.duplicated(keep="last")].sort_index()
merged.rename("eur_mwh").rename_axis("t").reset_index().to_parquet(out, index=False)
return merged
def load(path=PATH):
df = pd.read_parquet(path)
s = df.set_index(pd.to_datetime(df["t"]))["eur_mwh"].sort_index()
return s[~s.index.duplicated(keep="last")]
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--since", default=START.isoformat())
a = ap.parse_args()
s = update(start=date.fromisoformat(a.since))
print(f"gb: {len(s):,} hours, {s.index.min():%Y-%m-%d} -> {s.index.max():%Y-%m-%d}, mean {s.mean():.1f} GBP/MWh", flush=True)