python-entsoe 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
entsoe/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """ENTSO-E Transparency Platform API client library."""
2
+
3
+ from .client import Client
4
+ from .exceptions import ENTSOEError, InvalidParameterError, NoDataError, RateLimitError
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = [
8
+ "Client",
9
+ "ENTSOEError",
10
+ "InvalidParameterError",
11
+ "NoDataError",
12
+ "RateLimitError",
13
+ ]
entsoe/_http.py ADDED
@@ -0,0 +1,201 @@
1
+ """Low-level HTTP client for the ENTSO-E Transparency Platform API.
2
+
3
+ Handles:
4
+ - Request construction with proper parameter formatting
5
+ - Automatic year-splitting for long date ranges
6
+ - Retry with exponential backoff on rate limits (429)
7
+ - Response validation
8
+ """
9
+
10
+ import io
11
+ import logging
12
+ import time
13
+ import zipfile
14
+ from datetime import timedelta
15
+
16
+ import pandas as pd
17
+ import requests
18
+
19
+ from .exceptions import ENTSOEError, InvalidParameterError, RateLimitError
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ BASE_URL = "https://web-api.tp.entsoe.eu/api"
24
+
25
+ # ENTSO-E API limits requests to ~1 year
26
+ MAX_REQUEST_RANGE = timedelta(days=365)
27
+
28
+
29
+ def _format_timestamp(ts: pd.Timestamp) -> str:
30
+ """Format a tz-aware Timestamp to ENTSO-E API format (YYYYMMDDHHmm in UTC)."""
31
+ utc = ts.tz_convert("UTC")
32
+ return utc.strftime("%Y%m%d%H%M")
33
+
34
+
35
+ def _validate_timestamps(
36
+ start: pd.Timestamp, end: pd.Timestamp
37
+ ) -> tuple[pd.Timestamp, pd.Timestamp]:
38
+ """Validate and normalize start/end timestamps."""
39
+ if start.tzinfo is None:
40
+ raise InvalidParameterError(
41
+ "start timestamp must be timezone-aware. "
42
+ "Example: pd.Timestamp('2024-01-01', tz='Europe/Paris')"
43
+ )
44
+ if end.tzinfo is None:
45
+ raise InvalidParameterError(
46
+ "end timestamp must be timezone-aware. "
47
+ "Example: pd.Timestamp('2024-01-07', tz='Europe/Paris')"
48
+ )
49
+ if start >= end:
50
+ raise InvalidParameterError("start must be before end.")
51
+ return start, end
52
+
53
+
54
+ def _split_years(
55
+ start: pd.Timestamp, end: pd.Timestamp
56
+ ) -> list[tuple[pd.Timestamp, pd.Timestamp]]:
57
+ """Split a date range into chunks that don't exceed the API's max range.
58
+
59
+ Returns a list of (start, end) tuples.
60
+ """
61
+ chunks = []
62
+ current = start
63
+ while current < end:
64
+ chunk_end = min(current + MAX_REQUEST_RANGE, end)
65
+ chunks.append((current, chunk_end))
66
+ current = chunk_end
67
+ return chunks
68
+
69
+
70
+ def _extract_xml(response: requests.Response) -> str:
71
+ """Extract XML from a response, handling ZIP-compressed responses.
72
+
73
+ Some ENTSO-E endpoints (imbalance prices/volumes, unavailability)
74
+ return ZIP archives containing one or more XML files.
75
+ """
76
+ content = response.content
77
+ if content[:2] == b"PK": # ZIP magic bytes
78
+ with zipfile.ZipFile(io.BytesIO(content)) as zf:
79
+ xml_parts = [zf.read(name).decode("utf-8") for name in zf.namelist()]
80
+ if len(xml_parts) == 1:
81
+ return xml_parts[0]
82
+ # Multiple XMLs in ZIP: merge by returning as a list marker
83
+ # We wrap them so the query() method can handle them like multi-chunk
84
+ return xml_parts
85
+ return response.text
86
+
87
+
88
+ class HttpClient:
89
+ """Low-level HTTP client for the ENTSO-E API."""
90
+
91
+ def __init__(
92
+ self,
93
+ api_key: str,
94
+ session: requests.Session | None = None,
95
+ max_retries: int = 3,
96
+ base_delay: float = 1.0,
97
+ ):
98
+ self.api_key = api_key
99
+ self.session = session or requests.Session()
100
+ self.session.headers.update({"User-Agent": "entsoe-library/0.1.0"})
101
+ self.max_retries = max_retries
102
+ self.base_delay = base_delay
103
+
104
+ def query(
105
+ self,
106
+ params: dict,
107
+ start: pd.Timestamp,
108
+ end: pd.Timestamp,
109
+ ) -> str | list[str]:
110
+ """Execute a query against the ENTSO-E API.
111
+
112
+ Automatically splits requests spanning more than 1 year and
113
+ handles ZIP-compressed responses (multiple XMLs inside).
114
+
115
+ Args:
116
+ params: API parameters (documentType, processType, etc.).
117
+ Do NOT include securityToken, periodStart, or periodEnd.
118
+ start: Period start (tz-aware).
119
+ end: Period end (tz-aware).
120
+
121
+ Returns:
122
+ A single XML string, or a list of XML strings when the
123
+ response spans multiple chunks or contains a multi-file ZIP.
124
+
125
+ Raises:
126
+ ENTSOEError: On API errors.
127
+ RateLimitError: When rate limit retries are exhausted.
128
+ """
129
+ start, end = _validate_timestamps(start, end)
130
+ chunks = _split_years(start, end)
131
+
132
+ # Collect all XML strings, flattening any lists from ZIP responses
133
+ xml_parts: list[str] = []
134
+ for chunk_start, chunk_end in chunks:
135
+ result = self._single_request(params, chunk_start, chunk_end)
136
+ if isinstance(result, list):
137
+ xml_parts.extend(result)
138
+ else:
139
+ xml_parts.append(result)
140
+
141
+ if len(xml_parts) == 1:
142
+ return xml_parts[0]
143
+ return xml_parts
144
+
145
+ def query_raw(
146
+ self,
147
+ params: dict,
148
+ start: pd.Timestamp,
149
+ end: pd.Timestamp,
150
+ ) -> list[str]:
151
+ """Execute a query and return a list of XML response strings (one per chunk)."""
152
+ start, end = _validate_timestamps(start, end)
153
+ chunks = _split_years(start, end)
154
+ return [self._single_request(params, cs, ce) for cs, ce in chunks]
155
+
156
+ def _single_request(
157
+ self,
158
+ params: dict,
159
+ start: pd.Timestamp,
160
+ end: pd.Timestamp,
161
+ ) -> str:
162
+ """Execute a single API request with retry logic."""
163
+ request_params = {
164
+ "securityToken": self.api_key,
165
+ "periodStart": _format_timestamp(start),
166
+ "periodEnd": _format_timestamp(end),
167
+ **params,
168
+ }
169
+
170
+ for attempt in range(self.max_retries + 1):
171
+ logger.debug(
172
+ "ENTSO-E request: %s (attempt %d/%d)",
173
+ request_params.get("documentType", "?"),
174
+ attempt + 1,
175
+ self.max_retries + 1,
176
+ )
177
+
178
+ response = self.session.get(BASE_URL, params=request_params)
179
+
180
+ if response.status_code == 429:
181
+ if attempt < self.max_retries:
182
+ delay = self.base_delay * (2**attempt)
183
+ logger.warning("Rate limited. Retrying in %.1fs...", delay)
184
+ time.sleep(delay)
185
+ continue
186
+ raise RateLimitError()
187
+
188
+ if response.status_code == 401:
189
+ raise ENTSOEError(
190
+ "Unauthorized. Check your ENTSOE_API_KEY.", status_code=401
191
+ )
192
+
193
+ if response.status_code != 200:
194
+ raise ENTSOEError(
195
+ f"API returned HTTP {response.status_code}: {response.text[:500]}",
196
+ status_code=response.status_code,
197
+ )
198
+
199
+ return _extract_xml(response)
200
+
201
+ raise ENTSOEError("Max retries exceeded.")
entsoe/_mappings.py ADDED
@@ -0,0 +1,145 @@
1
+ """ENTSO-E area codes, document types, and PSR type mappings."""
2
+
3
+ # Country code (ISO 3166-1 alpha-2) → ENTSO-E EIC area code
4
+ AREA_CODES: dict[str, str] = {
5
+ "AL": "10YAL-KESH-----5",
6
+ "AT": "10YAT-APG------L",
7
+ "BA": "10YBA-JPCC-----D",
8
+ "BE": "10YBE----------2",
9
+ "BG": "10YCA-BULGARIA-R",
10
+ "CH": "10YCH-SWISSGRIDZ",
11
+ "CZ": "10YCZ-CEPS-----N",
12
+ "DE": "10Y1001A1001A83F",
13
+ "DE_LU": "10Y1001A1001A82H",
14
+ "DE_AT_LU": "10Y1001A1001A63L",
15
+ "DK": "10Y1001A1001A65H",
16
+ "DK_1": "10YDK-1--------W",
17
+ "DK_2": "10YDK-2--------M",
18
+ "EE": "10Y1001A1001A39I",
19
+ "ES": "10YES-REE------0",
20
+ "FI": "10YFI-1--------U",
21
+ "FR": "10YFR-RTE------C",
22
+ "GB": "10YGB----------A",
23
+ "GR": "10YGR-HTSO-----Y",
24
+ "HR": "10YHR-HEP------M",
25
+ "HU": "10YHU-MAVIR----U",
26
+ "IE": "10YIE-1001A00010",
27
+ "IE_SEM": "10Y1001A1001A59C",
28
+ "IT": "10YIT-GRTN-----B",
29
+ "IT_NORTH": "10Y1001A1001A73I",
30
+ "IT_CNOR": "10Y1001A1001A70O",
31
+ "IT_CSUD": "10Y1001A1001A71M",
32
+ "IT_SUD": "10Y1001A1001A788",
33
+ "IT_SICI": "10Y1001A1001A74G",
34
+ "IT_SARD": "10Y1001A1001A75E",
35
+ "LT": "10YLT-1001A0008Q",
36
+ "LU": "10YLU-CEGEDEL-NQ",
37
+ "LV": "10YLV-1001A00074",
38
+ "ME": "10YCS-CG-TSO---S",
39
+ "MK": "10YMK-MEPSO----8",
40
+ "MT": "10Y1001A1001A93C",
41
+ "NL": "10YNL----------L",
42
+ "NO": "10YNO-0--------C",
43
+ "NO_1": "10YNO-1--------2",
44
+ "NO_2": "10YNO-2--------T",
45
+ "NO_3": "10YNO-3--------J",
46
+ "NO_4": "10YNO-4--------9",
47
+ "NO_5": "10Y1001A1001A48H",
48
+ "PL": "10YPL-AREA-----S",
49
+ "PT": "10YPT-REN------W",
50
+ "RO": "10YRO-TEL------P",
51
+ "RS": "10YCS-SERBIATSOV",
52
+ "SE": "10YSE-1--------K",
53
+ "SE_1": "10Y1001A1001A44P",
54
+ "SE_2": "10Y1001A1001A45N",
55
+ "SE_3": "10Y1001A1001A46L",
56
+ "SE_4": "10Y1001A1001A47J",
57
+ "SI": "10YSI-ELES-----O",
58
+ "SK": "10YSK-SEPS-----K",
59
+ "TR": "10YTR-TEIAS----W",
60
+ "UA": "10Y1001C--00003F",
61
+ "UK": "10Y1001A1001A92E",
62
+ "XK": "10Y1001C--00100H",
63
+ }
64
+
65
+ # Document type codes used in API requests
66
+ DOCUMENT_TYPES: dict[str, str] = {
67
+ # Load
68
+ "actual_load": "A65",
69
+ "load_forecast": "A65",
70
+ # Prices
71
+ "day_ahead_prices": "A44",
72
+ # Generation
73
+ "actual_generation_per_type": "A75",
74
+ "actual_generation_per_plant": "A73",
75
+ "generation_forecast_wind_solar": "A69",
76
+ "generation_forecast": "A71",
77
+ "installed_capacity": "A68",
78
+ "installed_capacity_per_unit": "A71",
79
+ # Transmission
80
+ "physical_crossborder_flows": "A11",
81
+ "scheduled_exchanges": "A09",
82
+ "net_transfer_capacity": "A61",
83
+ # Balancing
84
+ "imbalance_prices": "A85",
85
+ "imbalance_volumes": "A86",
86
+ "contracted_reserve_prices": "A89",
87
+ "activated_balancing_energy_prices": "A84",
88
+ }
89
+
90
+ # Process type codes
91
+ PROCESS_TYPES: dict[str, str] = {
92
+ "realised": "A16",
93
+ "day_ahead": "A01",
94
+ "intraday_total": "A18",
95
+ "week_ahead": "A31",
96
+ "month_ahead": "A32",
97
+ "year_ahead": "A33",
98
+ }
99
+
100
+ # PSR (Power System Resource) type codes
101
+ PSR_TYPES: dict[str, str] = {
102
+ "B01": "Biomass",
103
+ "B02": "Fossil Brown coal/Lignite",
104
+ "B03": "Fossil Coal-derived gas",
105
+ "B04": "Fossil Gas",
106
+ "B05": "Fossil Hard coal",
107
+ "B06": "Fossil Oil",
108
+ "B07": "Fossil Oil shale",
109
+ "B08": "Fossil Peat",
110
+ "B09": "Geothermal",
111
+ "B10": "Hydro Pumped Storage",
112
+ "B11": "Hydro Run-of-river and poundage",
113
+ "B12": "Hydro Water Reservoir",
114
+ "B13": "Marine",
115
+ "B14": "Nuclear",
116
+ "B15": "Other renewable",
117
+ "B16": "Solar",
118
+ "B17": "Waste",
119
+ "B18": "Wind Offshore",
120
+ "B19": "Wind Onshore",
121
+ "B20": "Other",
122
+ }
123
+
124
+
125
+ def lookup_area(country: str) -> str:
126
+ """Resolve a country code to its ENTSO-E EIC area code.
127
+
128
+ Args:
129
+ country: ISO country code (e.g., "FR", "DE_LU").
130
+
131
+ Returns:
132
+ The EIC area code string.
133
+
134
+ Raises:
135
+ InvalidParameterError: If the country code is not recognized.
136
+ """
137
+ from .exceptions import InvalidParameterError
138
+
139
+ code = country.upper().strip()
140
+ if code not in AREA_CODES:
141
+ raise InvalidParameterError(
142
+ f"Unknown country code: '{country}'. "
143
+ f"Available: {', '.join(sorted(AREA_CODES.keys()))}"
144
+ )
145
+ return AREA_CODES[code]
entsoe/_xml.py ADDED
@@ -0,0 +1,172 @@
1
+ """XML parsing utilities for ENTSO-E API responses.
2
+
3
+ Parses TimeSeries XML documents into pandas DataFrames.
4
+ Handles the various document types returned by the API:
5
+ - GL_MarketDocument (load, generation)
6
+ - Publication_MarketDocument (prices)
7
+ - TransmissionNetwork_MarketDocument (transmission)
8
+ - Balancing_MarketDocument (balancing)
9
+
10
+ All share the TimeSeries > Period > Point structure.
11
+ """
12
+
13
+ import re
14
+ import xml.etree.ElementTree as ET
15
+ from datetime import timedelta
16
+
17
+ import pandas as pd
18
+
19
+ from .exceptions import NoDataError
20
+
21
+
22
+ def _strip_ns(tag: str) -> str:
23
+ """Remove XML namespace prefix from a tag name."""
24
+ return re.sub(r"\{[^}]+\}", "", tag)
25
+
26
+
27
+ def _find(element: ET.Element, local_name: str) -> ET.Element | None:
28
+ """Find a child element by local name, ignoring namespace."""
29
+ for child in element:
30
+ if _strip_ns(child.tag) == local_name:
31
+ return child
32
+ return None
33
+
34
+
35
+ def _findall(element: ET.Element, local_name: str) -> list[ET.Element]:
36
+ """Find all child elements by local name, ignoring namespace."""
37
+ return [child for child in element if _strip_ns(child.tag) == local_name]
38
+
39
+
40
+ def _find_text(element: ET.Element, local_name: str) -> str | None:
41
+ """Find a child element's text by local name, ignoring namespace."""
42
+ child = _find(element, local_name)
43
+ return child.text if child is not None else None
44
+
45
+
46
+ def _parse_resolution(resolution: str) -> timedelta:
47
+ """Parse ISO 8601 duration to timedelta.
48
+
49
+ Common values: PT15M, PT30M, PT60M, P1Y
50
+ """
51
+ match = re.match(r"PT?(\d+)([MHDY])", resolution)
52
+ if not match:
53
+ raise ValueError(f"Cannot parse resolution: {resolution}")
54
+ value, unit = int(match.group(1)), match.group(2)
55
+ if unit == "M":
56
+ return timedelta(minutes=value)
57
+ if unit == "H":
58
+ return timedelta(hours=value)
59
+ if unit == "D":
60
+ return timedelta(days=value)
61
+ if unit == "Y":
62
+ return timedelta(days=365 * value)
63
+ raise ValueError(f"Unknown resolution unit: {unit}")
64
+
65
+
66
+ def _extract_point_value(point: ET.Element) -> float | None:
67
+ """Extract the numeric value from a Point element.
68
+
69
+ Handles <quantity> (load/generation), <price.amount> (day-ahead prices),
70
+ and <imbalance_Price.amount> (balancing).
71
+ """
72
+ for tag in ("quantity", "price.amount", "imbalance_Price.amount"):
73
+ text = _find_text(point, tag)
74
+ if text is not None:
75
+ return float(text)
76
+ return None
77
+
78
+
79
+ def parse_timeseries(xml_text: str) -> pd.DataFrame:
80
+ """Parse an ENTSO-E XML response into a DataFrame.
81
+
82
+ Handles multiple TimeSeries (e.g., generation per type) and multiple
83
+ Periods within each TimeSeries (e.g., multi-day responses).
84
+
85
+ Returns a DataFrame with columns:
86
+ - timestamp: tz-aware UTC datetime
87
+ - value: numeric value (MW or EUR/MWh depending on query)
88
+ - Plus any metadata columns (psr_type, etc.) if multiple TimeSeries present.
89
+
90
+ Raises:
91
+ NoDataError: If the response contains no TimeSeries data.
92
+ """
93
+ root = ET.fromstring(xml_text)
94
+ timeseries_list = _findall(root, "TimeSeries")
95
+
96
+ if not timeseries_list:
97
+ # Check for reason/text in error responses
98
+ reason = _find(root, "Reason")
99
+ if reason is not None:
100
+ reason_text = _find_text(reason, "text")
101
+ raise NoDataError(reason_text or "No data available.")
102
+ raise NoDataError()
103
+
104
+ rows: list[dict] = []
105
+
106
+ for ts in timeseries_list:
107
+ # Extract TimeSeries metadata
108
+ ts_meta: dict = {}
109
+
110
+ # PSR type (generation fuel type)
111
+ mkt_psr = _find(ts, "MktPSRType")
112
+ if mkt_psr is not None:
113
+ psr_code = _find_text(mkt_psr, "psrType")
114
+ if psr_code:
115
+ ts_meta["psr_type"] = psr_code
116
+
117
+ # In/Out domain (useful for transmission)
118
+ in_domain = _find(ts, "in_Domain.mRID")
119
+ if in_domain is not None and in_domain.text:
120
+ ts_meta["in_domain"] = in_domain.text
121
+ out_domain = _find(ts, "out_Domain.mRID")
122
+ if out_domain is not None and out_domain.text:
123
+ ts_meta["out_domain"] = out_domain.text
124
+
125
+ # Currency and unit
126
+ currency = _find_text(ts, "currency_Unit.name")
127
+ if currency:
128
+ ts_meta["currency"] = currency
129
+ price_unit = _find_text(ts, "price_Measure_Unit.name")
130
+ if price_unit:
131
+ ts_meta["price_unit"] = price_unit
132
+ quantity_unit = _find_text(ts, "quantity_Measure_Unit.name")
133
+ if quantity_unit:
134
+ ts_meta["quantity_unit"] = quantity_unit
135
+
136
+ # Process each Period
137
+ for period in _findall(ts, "Period"):
138
+ time_interval = _find(period, "timeInterval")
139
+ if time_interval is None:
140
+ continue
141
+
142
+ start_text = _find_text(time_interval, "start")
143
+ if start_text is None:
144
+ continue
145
+
146
+ resolution_text = _find_text(period, "resolution")
147
+ if resolution_text is None:
148
+ continue
149
+
150
+ period_start = pd.Timestamp(start_text)
151
+ resolution = _parse_resolution(resolution_text)
152
+
153
+ for point in _findall(period, "Point"):
154
+ position_text = _find_text(point, "position")
155
+ if position_text is None:
156
+ continue
157
+
158
+ position = int(position_text)
159
+ value = _extract_point_value(point)
160
+
161
+ timestamp = period_start + resolution * (position - 1)
162
+
163
+ row = {"timestamp": timestamp, "value": value, **ts_meta}
164
+ rows.append(row)
165
+
166
+ if not rows:
167
+ raise NoDataError()
168
+
169
+ df = pd.DataFrame(rows)
170
+ df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
171
+ df = df.sort_values("timestamp").reset_index(drop=True)
172
+ return df
entsoe/client.py ADDED
@@ -0,0 +1,67 @@
1
+ """Main Client class for the ENTSO-E Transparency Platform API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from ._http import HttpClient
8
+ from .namespaces import (
9
+ BalancingNamespace,
10
+ GenerationNamespace,
11
+ LoadNamespace,
12
+ PricesNamespace,
13
+ TransmissionNamespace,
14
+ )
15
+
16
+
17
+ class Client:
18
+ """ENTSO-E Transparency Platform API client.
19
+
20
+ Provides typed, namespace-organized access to European electricity market data.
21
+
22
+ Usage::
23
+
24
+ from entsoe import Client
25
+ import pandas as pd
26
+
27
+ client = Client() # reads ENTSOE_API_KEY from env
28
+
29
+ start = pd.Timestamp("2024-01-01", tz="Europe/Paris")
30
+ end = pd.Timestamp("2024-01-07", tz="Europe/Paris")
31
+
32
+ df = client.load.actual(start, end, country="FR")
33
+ df = client.prices.day_ahead(start, end, country="FR")
34
+ df = client.generation.actual(start, end, country="FR")
35
+
36
+ Namespaces:
37
+ load: Actual load and load forecast.
38
+ prices: Day-ahead market prices.
39
+ generation: Actual generation, forecasts, installed capacity.
40
+ transmission: Cross-border flows and scheduled exchanges.
41
+ balancing: Imbalance prices and volumes.
42
+ """
43
+
44
+ def __init__(self, api_key: str | None = None) -> None:
45
+ """Initialize the client.
46
+
47
+ Args:
48
+ api_key: ENTSO-E API key. If not provided, reads from
49
+ the ``ENTSOE_API_KEY`` environment variable.
50
+
51
+ Raises:
52
+ ValueError: If no API key is found.
53
+ """
54
+ resolved_key = api_key or os.environ.get("ENTSOE_API_KEY")
55
+ if not resolved_key:
56
+ raise ValueError(
57
+ "API key required. Pass api_key= or set the ENTSOE_API_KEY "
58
+ "environment variable."
59
+ )
60
+
61
+ http = HttpClient(api_key=resolved_key)
62
+
63
+ self.load = LoadNamespace(http)
64
+ self.prices = PricesNamespace(http)
65
+ self.generation = GenerationNamespace(http)
66
+ self.transmission = TransmissionNamespace(http)
67
+ self.balancing = BalancingNamespace(http)
entsoe/exceptions.py ADDED
@@ -0,0 +1,30 @@
1
+ """Exception hierarchy for the ENTSO-E client."""
2
+
3
+
4
+ class ENTSOEError(Exception):
5
+ """Base exception for all ENTSO-E API errors."""
6
+
7
+ def __init__(self, message: str, status_code: int | None = None):
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+
11
+
12
+ class NoDataError(ENTSOEError):
13
+ """Raised when the API returns no data for the requested period/parameters."""
14
+
15
+ def __init__(self, message: str = "No data available for the requested parameters."):
16
+ super().__init__(message)
17
+
18
+
19
+ class InvalidParameterError(ENTSOEError):
20
+ """Raised when invalid parameters are passed (bad country code, naive timestamps, etc.)."""
21
+
22
+ def __init__(self, message: str):
23
+ super().__init__(message)
24
+
25
+
26
+ class RateLimitError(ENTSOEError):
27
+ """Raised when rate limit is exceeded and all retries are exhausted."""
28
+
29
+ def __init__(self, message: str = "Rate limit exceeded. Retries exhausted."):
30
+ super().__init__(message, status_code=429)
@@ -0,0 +1,15 @@
1
+ """Domain namespace classes for the ENTSO-E client."""
2
+
3
+ from .balancing import BalancingNamespace
4
+ from .generation import GenerationNamespace
5
+ from .load import LoadNamespace
6
+ from .prices import PricesNamespace
7
+ from .transmission import TransmissionNamespace
8
+
9
+ __all__ = [
10
+ "BalancingNamespace",
11
+ "GenerationNamespace",
12
+ "LoadNamespace",
13
+ "PricesNamespace",
14
+ "TransmissionNamespace",
15
+ ]
@@ -0,0 +1,45 @@
1
+ """Base namespace class shared by all domain namespaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import pandas as pd
8
+
9
+ from .._mappings import lookup_area
10
+ from .._xml import parse_timeseries
11
+
12
+ if TYPE_CHECKING:
13
+ from .._http import HttpClient
14
+
15
+
16
+ class BaseNamespace:
17
+ """Base class for domain namespaces.
18
+
19
+ Holds a reference to the shared HttpClient and provides
20
+ common helper methods for building queries and parsing responses.
21
+ """
22
+
23
+ def __init__(self, http: HttpClient) -> None:
24
+ self._http = http
25
+
26
+ def _query(
27
+ self,
28
+ params: dict,
29
+ start: pd.Timestamp,
30
+ end: pd.Timestamp,
31
+ ) -> pd.DataFrame:
32
+ """Execute a query and parse the XML response into a DataFrame."""
33
+ result = self._http.query(params, start, end)
34
+
35
+ # Handle multi-chunk responses (year-splitting)
36
+ if isinstance(result, list):
37
+ dfs = [parse_timeseries(xml) for xml in result]
38
+ return pd.concat(dfs, ignore_index=True).sort_values("timestamp").reset_index(drop=True)
39
+
40
+ return parse_timeseries(result)
41
+
42
+ @staticmethod
43
+ def _area(country: str) -> str:
44
+ """Resolve country code to EIC area code."""
45
+ return lookup_area(country)
@@ -0,0 +1,62 @@
1
+ """Balancing namespace — imbalance price and volume queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+ from ._base import BaseNamespace
8
+
9
+
10
+ class BalancingNamespace(BaseNamespace):
11
+ """Access balancing market data.
12
+
13
+ Methods:
14
+ imbalance_prices: System imbalance prices.
15
+ imbalance_volumes: System imbalance volumes.
16
+ """
17
+
18
+ def imbalance_prices(
19
+ self,
20
+ start: pd.Timestamp,
21
+ end: pd.Timestamp,
22
+ country: str,
23
+ ) -> pd.DataFrame:
24
+ """Query imbalance prices.
25
+
26
+ Args:
27
+ start: Period start (tz-aware).
28
+ end: Period end (tz-aware).
29
+ country: Country code (e.g., "FR", "DE").
30
+
31
+ Returns:
32
+ DataFrame with columns: timestamp, value (EUR/MWh).
33
+ """
34
+ area = self._area(country)
35
+ params = {
36
+ "documentType": "A85",
37
+ "controlArea_Domain": area,
38
+ }
39
+ return self._query(params, start, end)
40
+
41
+ def imbalance_volumes(
42
+ self,
43
+ start: pd.Timestamp,
44
+ end: pd.Timestamp,
45
+ country: str,
46
+ ) -> pd.DataFrame:
47
+ """Query imbalance volumes.
48
+
49
+ Args:
50
+ start: Period start (tz-aware).
51
+ end: Period end (tz-aware).
52
+ country: Country code (e.g., "FR", "DE").
53
+
54
+ Returns:
55
+ DataFrame with columns: timestamp, value (MW).
56
+ """
57
+ area = self._area(country)
58
+ params = {
59
+ "documentType": "A86",
60
+ "controlArea_Domain": area,
61
+ }
62
+ return self._query(params, start, end)
@@ -0,0 +1,128 @@
1
+ """Generation namespace — actual generation, forecasts, and capacity queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+ from ._base import BaseNamespace
8
+
9
+
10
+ class GenerationNamespace(BaseNamespace):
11
+ """Access electricity generation data.
12
+
13
+ Methods:
14
+ actual: Actual generation output per type.
15
+ forecast: Day-ahead generation forecast (wind/solar).
16
+ installed_capacity: Installed generation capacity per type.
17
+ per_plant: Actual generation per production unit.
18
+ """
19
+
20
+ def actual(
21
+ self,
22
+ start: pd.Timestamp,
23
+ end: pd.Timestamp,
24
+ country: str,
25
+ psr_type: str | None = None,
26
+ ) -> pd.DataFrame:
27
+ """Query actual generation output per type.
28
+
29
+ Args:
30
+ start: Period start (tz-aware).
31
+ end: Period end (tz-aware).
32
+ country: Country code (e.g., "FR", "DE").
33
+ psr_type: Optional PSR type code (e.g., "B16" for Solar,
34
+ "B19" for Wind Onshore). If None, returns all types.
35
+
36
+ Returns:
37
+ DataFrame with columns: timestamp, value (MW), psr_type.
38
+ """
39
+ params = {
40
+ "documentType": "A75",
41
+ "processType": "A16",
42
+ "in_Domain": self._area(country),
43
+ }
44
+ if psr_type:
45
+ params["psrType"] = psr_type
46
+ return self._query(params, start, end)
47
+
48
+ def forecast(
49
+ self,
50
+ start: pd.Timestamp,
51
+ end: pd.Timestamp,
52
+ country: str,
53
+ psr_type: str | None = None,
54
+ ) -> pd.DataFrame:
55
+ """Query day-ahead generation forecast (wind and solar).
56
+
57
+ Args:
58
+ start: Period start (tz-aware).
59
+ end: Period end (tz-aware).
60
+ country: Country code (e.g., "FR", "DE").
61
+ psr_type: Optional PSR type code to filter by.
62
+
63
+ Returns:
64
+ DataFrame with columns: timestamp, value (MW), psr_type.
65
+ """
66
+ params = {
67
+ "documentType": "A69",
68
+ "processType": "A01",
69
+ "in_Domain": self._area(country),
70
+ }
71
+ if psr_type:
72
+ params["psrType"] = psr_type
73
+ return self._query(params, start, end)
74
+
75
+ def installed_capacity(
76
+ self,
77
+ start: pd.Timestamp,
78
+ end: pd.Timestamp,
79
+ country: str,
80
+ psr_type: str | None = None,
81
+ ) -> pd.DataFrame:
82
+ """Query installed generation capacity per type.
83
+
84
+ Args:
85
+ start: Period start (tz-aware).
86
+ end: Period end (tz-aware).
87
+ country: Country code (e.g., "FR", "DE").
88
+ psr_type: Optional PSR type code to filter by.
89
+
90
+ Returns:
91
+ DataFrame with columns: timestamp, value (MW), psr_type.
92
+ """
93
+ params = {
94
+ "documentType": "A68",
95
+ "processType": "A33",
96
+ "in_Domain": self._area(country),
97
+ }
98
+ if psr_type:
99
+ params["psrType"] = psr_type
100
+ return self._query(params, start, end)
101
+
102
+ def per_plant(
103
+ self,
104
+ start: pd.Timestamp,
105
+ end: pd.Timestamp,
106
+ country: str,
107
+ psr_type: str | None = None,
108
+ ) -> pd.DataFrame:
109
+ """Query actual generation per production unit.
110
+
111
+ Args:
112
+ start: Period start (tz-aware).
113
+ end: Period end (tz-aware).
114
+ country: Country code (e.g., "FR", "DE").
115
+ psr_type: Optional PSR type code to filter by.
116
+
117
+ Returns:
118
+ DataFrame with columns: timestamp, value (MW), psr_type,
119
+ and additional unit identifiers.
120
+ """
121
+ params = {
122
+ "documentType": "A73",
123
+ "processType": "A16",
124
+ "in_Domain": self._area(country),
125
+ }
126
+ if psr_type:
127
+ params["psrType"] = psr_type
128
+ return self._query(params, start, end)
@@ -0,0 +1,62 @@
1
+ """Load namespace — actual load and load forecast queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+ from ._base import BaseNamespace
8
+
9
+
10
+ class LoadNamespace(BaseNamespace):
11
+ """Access electricity load data.
12
+
13
+ Methods:
14
+ actual: Actual total system load.
15
+ forecast: Day-ahead load forecast.
16
+ """
17
+
18
+ def actual(
19
+ self,
20
+ start: pd.Timestamp,
21
+ end: pd.Timestamp,
22
+ country: str,
23
+ ) -> pd.DataFrame:
24
+ """Query actual total system load.
25
+
26
+ Args:
27
+ start: Period start (tz-aware).
28
+ end: Period end (tz-aware).
29
+ country: Country code (e.g., "FR", "DE").
30
+
31
+ Returns:
32
+ DataFrame with columns: timestamp, value (MW).
33
+ """
34
+ params = {
35
+ "documentType": "A65",
36
+ "processType": "A16",
37
+ "outBiddingZone_Domain": self._area(country),
38
+ }
39
+ return self._query(params, start, end)
40
+
41
+ def forecast(
42
+ self,
43
+ start: pd.Timestamp,
44
+ end: pd.Timestamp,
45
+ country: str,
46
+ ) -> pd.DataFrame:
47
+ """Query day-ahead load forecast.
48
+
49
+ Args:
50
+ start: Period start (tz-aware).
51
+ end: Period end (tz-aware).
52
+ country: Country code (e.g., "FR", "DE").
53
+
54
+ Returns:
55
+ DataFrame with columns: timestamp, value (MW).
56
+ """
57
+ params = {
58
+ "documentType": "A65",
59
+ "processType": "A01",
60
+ "outBiddingZone_Domain": self._area(country),
61
+ }
62
+ return self._query(params, start, end)
@@ -0,0 +1,40 @@
1
+ """Prices namespace — day-ahead price queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+ from ._base import BaseNamespace
8
+
9
+
10
+ class PricesNamespace(BaseNamespace):
11
+ """Access electricity price data.
12
+
13
+ Methods:
14
+ day_ahead: Day-ahead market prices.
15
+ """
16
+
17
+ def day_ahead(
18
+ self,
19
+ start: pd.Timestamp,
20
+ end: pd.Timestamp,
21
+ country: str,
22
+ ) -> pd.DataFrame:
23
+ """Query day-ahead electricity prices.
24
+
25
+ Args:
26
+ start: Period start (tz-aware).
27
+ end: Period end (tz-aware).
28
+ country: Country code (e.g., "FR", "DE").
29
+
30
+ Returns:
31
+ DataFrame with columns: timestamp, value (EUR/MWh),
32
+ currency, price_unit.
33
+ """
34
+ area = self._area(country)
35
+ params = {
36
+ "documentType": "A44",
37
+ "in_Domain": area,
38
+ "out_Domain": area,
39
+ }
40
+ return self._query(params, start, end)
@@ -0,0 +1,93 @@
1
+ """Transmission namespace — cross-border flow and exchange queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+
7
+ from ._base import BaseNamespace
8
+
9
+
10
+ class TransmissionNamespace(BaseNamespace):
11
+ """Access cross-border transmission data.
12
+
13
+ Methods:
14
+ crossborder_flows: Physical cross-border flows between two areas.
15
+ scheduled_exchanges: Scheduled commercial exchanges between two areas.
16
+ net_transfer_capacity: Net transfer capacity between two areas.
17
+ """
18
+
19
+ def crossborder_flows(
20
+ self,
21
+ start: pd.Timestamp,
22
+ end: pd.Timestamp,
23
+ country_from: str,
24
+ country_to: str,
25
+ ) -> pd.DataFrame:
26
+ """Query physical cross-border flows.
27
+
28
+ Args:
29
+ start: Period start (tz-aware).
30
+ end: Period end (tz-aware).
31
+ country_from: Exporting country code (e.g., "FR").
32
+ country_to: Importing country code (e.g., "DE").
33
+
34
+ Returns:
35
+ DataFrame with columns: timestamp, value (MW).
36
+ """
37
+ params = {
38
+ "documentType": "A11",
39
+ "in_Domain": self._area(country_to),
40
+ "out_Domain": self._area(country_from),
41
+ }
42
+ return self._query(params, start, end)
43
+
44
+ def scheduled_exchanges(
45
+ self,
46
+ start: pd.Timestamp,
47
+ end: pd.Timestamp,
48
+ country_from: str,
49
+ country_to: str,
50
+ ) -> pd.DataFrame:
51
+ """Query scheduled commercial exchanges (day-ahead).
52
+
53
+ Args:
54
+ start: Period start (tz-aware).
55
+ end: Period end (tz-aware).
56
+ country_from: Exporting country code (e.g., "FR").
57
+ country_to: Importing country code (e.g., "DE").
58
+
59
+ Returns:
60
+ DataFrame with columns: timestamp, value (MW).
61
+ """
62
+ params = {
63
+ "documentType": "A09",
64
+ "in_Domain": self._area(country_to),
65
+ "out_Domain": self._area(country_from),
66
+ }
67
+ return self._query(params, start, end)
68
+
69
+ def net_transfer_capacity(
70
+ self,
71
+ start: pd.Timestamp,
72
+ end: pd.Timestamp,
73
+ country_from: str,
74
+ country_to: str,
75
+ ) -> pd.DataFrame:
76
+ """Query day-ahead net transfer capacity.
77
+
78
+ Args:
79
+ start: Period start (tz-aware).
80
+ end: Period end (tz-aware).
81
+ country_from: Exporting country code (e.g., "FR").
82
+ country_to: Importing country code (e.g., "DE").
83
+
84
+ Returns:
85
+ DataFrame with columns: timestamp, value (MW).
86
+ """
87
+ params = {
88
+ "documentType": "A61",
89
+ "contract_MarketAgreement.Type": "A01",
90
+ "in_Domain": self._area(country_to),
91
+ "out_Domain": self._area(country_from),
92
+ }
93
+ return self._query(params, start, end)
entsoe/py.typed ADDED
File without changes
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-entsoe
3
+ Version: 0.1.0
4
+ Summary: Python client for the ENTSO-E Transparency Platform API
5
+ Project-URL: Repository, https://github.com/jsulopzs/python-entsoe
6
+ Author-email: jsulopzs <jesus.lopez@datons.com>
7
+ License-Expression: MIT
8
+ Keywords: api,electricity,energy,entsoe,transparency
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.13
16
+ Requires-Dist: pandas>=2.0
17
+ Requires-Dist: requests>=2.28
@@ -0,0 +1,17 @@
1
+ entsoe/__init__.py,sha256=98RqhEtaMPIEBetDsEnIsbFzgYMp0iqNQsB5ZZ_gYKo,312
2
+ entsoe/_http.py,sha256=1Hj04bE-J6-V1ZnQpXpXYynId40hXft-MZxhHlg7GYk,6572
3
+ entsoe/_mappings.py,sha256=v0CQRBpUCu-YWRvYsfsd5ktXMlDBfftlp6Z1nCOnNVE,4174
4
+ entsoe/_xml.py,sha256=6GAAXovbQ5WCv6Twz-Yf32rKzOuQeZsSCta2N_2bfm4,5775
5
+ entsoe/client.py,sha256=prIAYq0uJCldMt7ky24-gdx1ptx9xc9s9Aac3J8KnIk,2048
6
+ entsoe/exceptions.py,sha256=N9BmlzoQY1EIaGXw2x-OhSthTi_ktei9LwbDRYtbYfY,981
7
+ entsoe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ entsoe/namespaces/__init__.py,sha256=ZVd80s5OVxegLo3A6nI64tHlBA1Iwe2ejqQilGNod5E,399
9
+ entsoe/namespaces/_base.py,sha256=zVeo8XN714oQ7WCcZVIzutnN-P7hvqdh6akuxOSwKcs,1259
10
+ entsoe/namespaces/balancing.py,sha256=llCg6IJH6tqr0m_sDivR797nj7rKUdJIPrNPOd1lN2A,1580
11
+ entsoe/namespaces/generation.py,sha256=HlLwGvwqbTRv15NEPTKILbNG6O3SkVn_OUARFKKIBu4,3831
12
+ entsoe/namespaces/load.py,sha256=-pzP4nbcQ8ASa86tzKkJsSBEAXQDn4Su_DiHn6yiU2g,1578
13
+ entsoe/namespaces/prices.py,sha256=c5Maoix9YJ5t9k5r4T86igcdObovBKBKYN7El1rj_r4,959
14
+ entsoe/namespaces/transmission.py,sha256=OBh58UTwGahts3BOaer1OazzT8uUDmumpeHUpLtGJZE,2809
15
+ python_entsoe-0.1.0.dist-info/METADATA,sha256=RMm6m_Et8NCvnUQtT2X4AhmC8QypiI9JxBTBEKf41eQ,683
16
+ python_entsoe-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
17
+ python_entsoe-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any