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 +13 -0
- entsoe/_http.py +201 -0
- entsoe/_mappings.py +145 -0
- entsoe/_xml.py +172 -0
- entsoe/client.py +67 -0
- entsoe/exceptions.py +30 -0
- entsoe/namespaces/__init__.py +15 -0
- entsoe/namespaces/_base.py +45 -0
- entsoe/namespaces/balancing.py +62 -0
- entsoe/namespaces/generation.py +128 -0
- entsoe/namespaces/load.py +62 -0
- entsoe/namespaces/prices.py +40 -0
- entsoe/namespaces/transmission.py +93 -0
- entsoe/py.typed +0 -0
- python_entsoe-0.1.0.dist-info/METADATA +17 -0
- python_entsoe-0.1.0.dist-info/RECORD +17 -0
- python_entsoe-0.1.0.dist-info/WHEEL +4 -0
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,,
|