Skip to content

Commit 0be5e7b

Browse files
feat(imbalance): add real HTTP GET and save raw A85 XML; keep parquet stub
1 parent ca53c0e commit 0be5e7b

1 file changed

Lines changed: 55 additions & 33 deletions

File tree

src/fetch_imbalance_entsoe.py

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,68 @@
11
#!/usr/bin/env python3
2-
"""Fetch Nordic imbalance prices (ENTSO-E REST) to Parquet — dry-run first.
2+
"""Fetch Nordic imbalance prices (ENTSO-E REST, A85) to Parquet.
33
4-
This step adds:
5-
- --dry-run: build and print the ENTSO-E URL (no network), still writes an
6-
empty Parquet with expected columns so downstream scripts don’t break.
4+
This step enables REAL HTTP GET and saving the raw XML.
5+
Parsing to a tidy DataFrame comes next.
6+
7+
Usage (UTC dates, end exclusive):
8+
python src/fetch_imbalance_entsoe.py --zone SE3 --start 2025-08-15 --end 2025-08-17 --out data/SE3_imbalance_api.parquet --dry-run
9+
python src/fetch_imbalance_entsoe.py --zone SE3 --start 2025-08-15 --end 2025-08-17 --out data/SE3_imbalance_api.parquet
710
"""
811

912
from __future__ import annotations
1013

14+
import argparse
1115
import os
1216
import sys
13-
import argparse
1417
from datetime import datetime, timezone
1518
from pathlib import Path
1619
from urllib.parse import urlencode
1720

1821
import pandas as pd
22+
import requests
1923
from dotenv import load_dotenv
24+
from eic_codes import EIC_BY_AREA # control area EICs per zone
2025

2126
VALID_ZONES = ("SE3", "SE4", "FI")
2227

2328
API_BASE = "https://web-api.tp.entsoe.eu/api"
24-
# NB: Codes here are indicative for imbalance prices; we’ll verify next step.
25-
DOC_IMBALANCE_PRICE = "A46" # Imbalance price document
26-
PROC_REALIZED = "A16" # Realised
29+
DOC_IMBALANCE_PRICE = "A85" # Imbalance Prices
2730

2831

2932
def parse_args() -> argparse.Namespace:
3033
p = argparse.ArgumentParser(
31-
description="Fetch ENTSO-E imbalance prices and save to Parquet."
34+
description="Fetch ENTSO-E imbalance prices (A85). Saves raw XML; parquet is stub for now."
3235
)
3336
p.add_argument("--zone", required=True, choices=VALID_ZONES, help="SE3 | SE4 | FI")
34-
p.add_argument("--start", required=True, help="YYYY-MM-DD")
35-
p.add_argument("--end", required=True, help="YYYY-MM-DD (exclusive)")
37+
p.add_argument("--start", required=True, help="YYYY-MM-DD (UTC)")
38+
p.add_argument("--end", required=True, help="YYYY-MM-DD (UTC, exclusive)")
3639
p.add_argument("--out", required=True, type=Path, help="Output Parquet path")
40+
p.add_argument("--raw-xml-out", type=Path, help="Optional path to save raw XML")
3741
p.add_argument("--dry-run", action="store_true", help="Print URL only; no network")
3842
return p.parse_args()
3943

4044

4145
def ymd_to_period(s: str) -> str:
42-
"""YYYY-MM-DD -> ENTSO-E period string YYYYMMDDHHMM (00:00)."""
43-
dt = datetime.fromisoformat(s).replace(tzinfo=timezone.utc, hour=0, minute=0)
46+
"""YYYY-MM-DD -> ENTSO-E period string YYYYMMDDHHMM (00:00 UTC)."""
47+
dt = datetime.fromisoformat(s).replace(tzinfo=timezone.utc, hour=0, minute=0, second=0, microsecond=0)
4448
return dt.strftime("%Y%m%d%H%M")
4549

4650

47-
def eic_for_zone(zone: str) -> str:
48-
# Try to import from local eic_codes if available; otherwise placeholder.
49-
try:
50-
# Expect a dict like EIC_BY_ZONE = {"SE3": "...", ...}
51-
from eic_codes import EIC_BY_ZONE # type: ignore
52-
return EIC_BY_ZONE[zone]
53-
except Exception:
54-
return f"{zone}_EIC"
51+
def _mask(tok: str) -> str:
52+
return (tok[:6] + "..." + tok[-4:]) if tok and len(tok) > 10 else "***"
5553

5654

5755
def main() -> int:
5856
args = parse_args()
5957

58+
# Token
6059
load_dotenv()
6160
token = os.getenv("ENTSOE_TOKEN") or ""
6261
if not token:
6362
print("ERROR: ENTSOE_TOKEN not found in environment or .env", file=sys.stderr)
6463
return 2
6564

66-
# Validate dates (UTC, end exclusive)
65+
# Dates (UTC, end exclusive)
6766
try:
6867
start_dt = datetime.fromisoformat(args.start).replace(tzinfo=timezone.utc)
6968
end_dt = datetime.fromisoformat(args.end).replace(tzinfo=timezone.utc)
@@ -74,28 +73,51 @@ def main() -> int:
7473
print("ERROR: end must be after start", file=sys.stderr)
7574
return 2
7675

77-
# Build URL (no request yet)
76+
# Build URL for A85 using control area EIC
77+
area_eic = EIC_BY_AREA.get(args.zone, {}).get("CONTROL_AREA") or EIC_BY_AREA.get(args.zone, {}).get("BIDDING_ZONE") or f"{args.zone}_EIC"
7878
params = {
7979
"securityToken": token,
80-
"documentType": DOC_IMBALANCE_PRICE,
81-
"processType": PROC_REALIZED,
82-
# Parameter name may vary per dataset; we’ll verify in the next step.
83-
"outBiddingZone": eic_for_zone(args.zone),
80+
"documentType": DOC_IMBALANCE_PRICE, # A85
81+
"controlArea_Domain": area_eic, # control area filter
8482
"periodStart": ymd_to_period(args.start),
8583
"periodEnd": ymd_to_period(args.end),
8684
}
8785
url = f"{API_BASE}?{urlencode(params)}"
8886

8987
if args.dry_run:
90-
print(f"DRY-RUN URL: {url}")
88+
print(f"DRY-RUN URL: {url.replace(token, _mask(token))}")
89+
# stub parquet to keep pipeline happy
90+
_write_empty_parquet(args.out)
91+
print(f"OK (dry-run): wrote {args.out}")
92+
return 0
9193

92-
# Write an empty parquet with expected columns for now
93-
df = pd.DataFrame(columns=["ts_utc", "zone", "imbalance_price_eur_mwh"])
94-
args.out.parent.mkdir(parents=True, exist_ok=True)
95-
df.to_parquet(args.out)
96-
print(f"OK (dry-run): wrote {args.out}")
94+
# --- REAL FETCH (no parsing yet) ---
95+
try:
96+
r = requests.get(url, timeout=45)
97+
except Exception as exc:
98+
print(f"ERROR: request failed: {exc}", file=sys.stderr)
99+
return 2
100+
101+
if r.status_code != 200:
102+
print(f"ERROR: HTTP {r.status_code}\n{r.text[:1000]}", file=sys.stderr)
103+
return 2
104+
105+
raw_xml_path = args.raw_xml_out or Path(f"data/imbalance_A85_{args.zone}_{args.start}_{args.end}.xml")
106+
raw_xml_path.parent.mkdir(parents=True, exist_ok=True)
107+
raw_xml_path.write_text(r.text, encoding="utf-8")
108+
print(f"Saved raw XML: {raw_xml_path}")
109+
110+
# still write stub parquet (parser comes next)
111+
_write_empty_parquet(args.out)
112+
print(f"OK (fetch-only): wrote empty parquet {args.out}")
97113
return 0
98114

99115

116+
def _write_empty_parquet(out_path: Path) -> None:
117+
df = pd.DataFrame(columns=["ts_utc", "zone", "imbalance_price_eur_mwh"])
118+
out_path.parent.mkdir(parents=True, exist_ok=True)
119+
df.to_parquet(out_path)
120+
121+
100122
if __name__ == "__main__":
101123
raise SystemExit(main())

0 commit comments

Comments
 (0)