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
912from __future__ import annotations
1013
14+ import argparse
1115import os
1216import sys
13- import argparse
1417from datetime import datetime , timezone
1518from pathlib import Path
1619from urllib .parse import urlencode
1720
1821import pandas as pd
22+ import requests
1923from dotenv import load_dotenv
24+ from eic_codes import EIC_BY_AREA # control area EICs per zone
2025
2126VALID_ZONES = ("SE3" , "SE4" , "FI" )
2227
2328API_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
2932def 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
4145def 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
5755def 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+
100122if __name__ == "__main__" :
101123 raise SystemExit (main ())
0 commit comments