#!/usr/bin/env python3 """Month-ahead forecast transfer capacity (ENTSO-E A61 / contract type A03). This is the congestion feature v3 actually uses, and it replaces a first attempt that misread the data. That attempt parsed A78 transmission unavailability, whose Available_Period quantity turns out to be per ASSET, not per border: a record reading 0 MW for "S Kristian PT4 Transformator" means that one transformer is out, not that the Skagerrak link is dead. Taking a min across overlapping records therefore produced an implausible median availability of 0.00 on DK1-NO2, which is what gave the misparse away. Month-ahead NTC is the right signal instead: - it is the capacity the market can actually use across the border, already aggregated over assets by the TSOs; - it is published a MONTH ahead, so it is unambiguously available at every horizon this model forecasts (h = 2..9). Week-ahead (A02) carries slightly fresher numbers but only covers 7 days, which would leak at h = 8, 9. Daily resolution, curveType A03 (variable-sized blocks): a Point appears only when the value changes, so values are forward-filled to the next change. Both directions per border — export capacity out of DK and import capacity in constrain prices differently. """ import os, re, time, urllib.request, urllib.parse from datetime import date, timedelta import pandas as pd OUT = os.path.dirname(os.path.abspath(__file__)) TOKEN = os.environ.get("ENTSOE_TOKEN", "a3f638e6-3312-4ebb-96c3-2b588516e41e") EIC = { "DK1": "10YDK-1--------W", "DK2": "10YDK-2--------M", "DELU": "10Y1001A1001A82H", "NO2": "10YNO-2--------T", "SE3": "10Y1001A1001A46L", "SE4": "10Y1001A1001A47J", "NL": "10YNL----------L", } BORDERS = [("dk1_delu", "DK1", "DELU"), ("dk1_no2", "DK1", "NO2"), ("dk1_se3", "DK1", "SE3"), ("dk1_nl", "DK1", "NL"), ("dk2_delu", "DK2", "DELU"), ("dk2_se4", "DK2", "SE4")] START, END = date(2024, 3, 1), date(2026, 8, 2) def fetch(a, b, s, e): url = "https://web-api.tp.entsoe.eu/api?" + urllib.parse.urlencode({ "securityToken": TOKEN, "documentType": "A61", "contract_MarketAgreement.Type": "A03", "in_Domain": EIC[a], "out_Domain": EIC[b], "periodStart": s.strftime("%Y%m%d0000"), "periodEnd": e.strftime("%Y%m%d0000"), }) for attempt in range(4): try: with urllib.request.urlopen(url, timeout=120) as r: return r.read().decode("utf-8", "replace") except urllib.error.HTTPError as ex: if ex.code == 400: return "" # no data for this window if attempt == 3: raise time.sleep(15 * (attempt + 1)) except Exception: if attempt == 3: raise time.sleep(15 * (attempt + 1)) return "" def parse(xml): """-> list of (date, mw). curveType A03: a point holds until the next one.""" rows = [] for per in re.finditer(r"(.*?)", xml, re.S): body = per.group(1) ti = re.search(r"([^<]+)\s*([^<]+)", body) if not ti: continue t0 = pd.Timestamp(ti.group(1)).tz_convert("UTC") t1 = pd.Timestamp(ti.group(2)).tz_convert("UTC") pts = sorted((int(p), float(q)) for p, q in re.findall(r"(\d+)\s*([\d.]+)", body)) for i, (pos, qty) in enumerate(pts): d0 = t0 + pd.Timedelta(days=pos - 1) d1 = (t0 + pd.Timedelta(days=pts[i + 1][0] - 1)) if i + 1 < len(pts) else t1 for d in pd.date_range(d0, d1, freq="D", inclusive="left"): rows.append((d.date(), qty)) return rows def main(): frames = [] for name, a, b in BORDERS: for direction, (x, y) in (("export", (a, b)), ("import", (b, a))): rows, cur = [], START while cur < END: nxt = min(cur + timedelta(days=180), END) xml = fetch(x, y, cur, nxt) if xml: rows += parse(xml) print(f"{name} {direction} {cur}..{nxt}: {len(rows)} cum", flush=True) cur = nxt time.sleep(2) if not rows: print(f" {name} {direction}: no data", flush=True) continue df = (pd.DataFrame(rows, columns=["date", "mw"]) .drop_duplicates("date", keep="last").sort_values("date")) df["border"] = name df["direction"] = direction frames.append(df) print(f" {name} {direction}: {len(df)} days, " f"{df.mw.min():.0f}-{df.mw.max():.0f} MW", flush=True) full = pd.concat(frames, ignore_index=True) full.to_parquet(f"{OUT}/ntc_daily.parquet", index=False) print(f"\nsaved ntc_daily.parquet: {len(full)} rows") print(full.groupby(["border", "direction"]).mw.agg(["size", "min", "max", "nunique"]).to_string()) print("NTC DONE", flush=True) if __name__ == "__main__": main()