detquantlib 5.6.0__tar.gz → 5.7.0__tar.gz
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.
- {detquantlib-5.6.0 → detquantlib-5.7.0}/PKG-INFO +16 -4
- {detquantlib-5.6.0 → detquantlib-5.7.0}/README.md +15 -3
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/data/__init__.py +3 -0
- detquantlib-5.7.0/detquantlib/data/tennet/tennet_client.py +417 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/pyproject.toml +1 -1
- {detquantlib-5.6.0 → detquantlib-5.7.0}/LICENSE.txt +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/assets/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/assets/battery.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/assets/wind_turbine.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/converters/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/converters/definitions.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/converters/energy.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/converters/helpers.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/converters/price.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/data/databases/detdatabase.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/data/databases/helpers.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/data/databases/snowflake_client.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/data/entsoe/entsoe.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/data/sftp/sftp.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/dates/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/dates/dates.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/figures/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/figures/plotly_figures.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/forecasting/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/forecasting/forecasting.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/outputs/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/outputs/outputs_interface.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/stats/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/stats/data_analysis.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/tradable_products/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/tradable_products/tradable_products.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/utils/__init__.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/utils/logging.py +0 -0
- {detquantlib-5.6.0 → detquantlib-5.7.0}/detquantlib/utils/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: detquantlib
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.7.0
|
|
4
4
|
Summary: An internal library containing functions and classes that can be used across Quant models.
|
|
5
5
|
License-File: LICENSE.txt
|
|
6
6
|
Author: Dynamic Energy Trading
|
|
@@ -83,9 +83,9 @@ poetry run python docs.py
|
|
|
83
83
|
|
|
84
84
|
Classes:
|
|
85
85
|
|
|
86
|
-
- `
|
|
87
|
-
- `from detquantlib.assets import
|
|
88
|
-
- `from detquantlib.assets.battery import
|
|
86
|
+
- `BatteryState`:
|
|
87
|
+
- `from detquantlib.assets import BatteryState`
|
|
88
|
+
- `from detquantlib.assets.battery import BatteryState`
|
|
89
89
|
- `DetDatabase`:
|
|
90
90
|
- `from detquantlib.data import DetDatabase`
|
|
91
91
|
- `from detquantlib.data.databases.detdatabase import DetDatabase`
|
|
@@ -104,6 +104,18 @@ Classes:
|
|
|
104
104
|
- `Sftp`:
|
|
105
105
|
- `from detquantlib.data import Sftp`
|
|
106
106
|
- `from detquantlib.data.sftp.sftp import Sftp`
|
|
107
|
+
- `SnowflakeClient`:
|
|
108
|
+
- `from detquantlib.data import SnowflakeClient`
|
|
109
|
+
- `from detquantlib.data.databases.snowflake_client import SnowflakeClient`
|
|
110
|
+
- `SnowflakeConfig`:
|
|
111
|
+
- `from detquantlib.data import SnowflakeConfig`
|
|
112
|
+
- `from detquantlib.data.databases.snowflake_client import SnowflakeConfig`
|
|
113
|
+
- `TennetAPIError`:
|
|
114
|
+
- `from detquantlib.data import TennetAPIError`
|
|
115
|
+
- `from detquantlib.data.tennet.tennet_client import TennetAPIError`
|
|
116
|
+
- `TennetClient`:
|
|
117
|
+
- `from detquantlib.data import TennetClient`
|
|
118
|
+
- `from detquantlib.data.tennet.tennet_client import TennetClient`
|
|
107
119
|
- `WindTurbine`:
|
|
108
120
|
- `from detquantlib.assets import WindTurbine`
|
|
109
121
|
- `from detquantlib.assets.wind_turbine import WindTurbine`
|
|
@@ -54,9 +54,9 @@ poetry run python docs.py
|
|
|
54
54
|
|
|
55
55
|
Classes:
|
|
56
56
|
|
|
57
|
-
- `
|
|
58
|
-
- `from detquantlib.assets import
|
|
59
|
-
- `from detquantlib.assets.battery import
|
|
57
|
+
- `BatteryState`:
|
|
58
|
+
- `from detquantlib.assets import BatteryState`
|
|
59
|
+
- `from detquantlib.assets.battery import BatteryState`
|
|
60
60
|
- `DetDatabase`:
|
|
61
61
|
- `from detquantlib.data import DetDatabase`
|
|
62
62
|
- `from detquantlib.data.databases.detdatabase import DetDatabase`
|
|
@@ -75,6 +75,18 @@ Classes:
|
|
|
75
75
|
- `Sftp`:
|
|
76
76
|
- `from detquantlib.data import Sftp`
|
|
77
77
|
- `from detquantlib.data.sftp.sftp import Sftp`
|
|
78
|
+
- `SnowflakeClient`:
|
|
79
|
+
- `from detquantlib.data import SnowflakeClient`
|
|
80
|
+
- `from detquantlib.data.databases.snowflake_client import SnowflakeClient`
|
|
81
|
+
- `SnowflakeConfig`:
|
|
82
|
+
- `from detquantlib.data import SnowflakeConfig`
|
|
83
|
+
- `from detquantlib.data.databases.snowflake_client import SnowflakeConfig`
|
|
84
|
+
- `TennetAPIError`:
|
|
85
|
+
- `from detquantlib.data import TennetAPIError`
|
|
86
|
+
- `from detquantlib.data.tennet.tennet_client import TennetAPIError`
|
|
87
|
+
- `TennetClient`:
|
|
88
|
+
- `from detquantlib.data import TennetClient`
|
|
89
|
+
- `from detquantlib.data.tennet.tennet_client import TennetClient`
|
|
78
90
|
- `WindTurbine`:
|
|
79
91
|
- `from detquantlib.assets import WindTurbine`
|
|
80
92
|
- `from detquantlib.assets.wind_turbine import WindTurbine`
|
|
@@ -3,6 +3,7 @@ from .databases.helpers import *
|
|
|
3
3
|
from .databases.snowflake_client import SnowflakeClient, SnowflakeConfig
|
|
4
4
|
from .entsoe.entsoe import Entsoe
|
|
5
5
|
from .sftp.sftp import Sftp
|
|
6
|
+
from .tennet.tennet_client import TennetAPIError, TennetClient
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
8
9
|
"DetDatabase",
|
|
@@ -12,4 +13,6 @@ __all__ = [
|
|
|
12
13
|
"list_to_sql_columns",
|
|
13
14
|
"SnowflakeClient",
|
|
14
15
|
"SnowflakeConfig",
|
|
16
|
+
"TennetClient",
|
|
17
|
+
"TennetAPIError",
|
|
15
18
|
]
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# Python built-in packages
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from math import ceil
|
|
8
|
+
from typing import Optional, Union
|
|
9
|
+
|
|
10
|
+
# Third-party packages
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
# Custom data type
|
|
15
|
+
DateLike = Union[str, date, datetime, pd.Timestamp]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TennetAPIError(Exception):
|
|
19
|
+
"""Raised when the TenneT API returns an error response."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Initialise the error.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
message: Human-readable error message.
|
|
27
|
+
status_code: HTTP status code of the failed response, if any.
|
|
28
|
+
"""
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TennetClient:
|
|
34
|
+
"""
|
|
35
|
+
Thin wrapper around the TenneT publications API (https://developer.tennet.eu).
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
base_url: Base URL used for all requests (production or acceptance).
|
|
39
|
+
timeout: Request timeout in seconds.
|
|
40
|
+
max_retries: Number of retries on HTTP 429 / 5xx responses and connection errors.
|
|
41
|
+
backoff: Base back-off in seconds used when no ``Retry-After`` header is returned.
|
|
42
|
+
session: The ``requests.Session`` used for all requests.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> client = TennetClient(api_key="...") # or set TENNET_API_KEY
|
|
46
|
+
>>> prices = client.get_settlement_prices("2026-10-01", "2026-10-02")
|
|
47
|
+
>>> delta = client.get_balance_delta_high_res("2026-10-14 10:00", "2026-10-14 12:00")
|
|
48
|
+
>>> latest = client.get_balance_delta_high_res_latest()
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
BASE_URL = "https://api.tennet.eu/publications/v1/"
|
|
52
|
+
ACC_BASE_URL = "https://api.acc.tennet.eu/publications/v1/"
|
|
53
|
+
LOCAL_TZ = "Europe/Amsterdam"
|
|
54
|
+
DATE_FORMAT_UTC = "%Y-%m-%dT%H:%M:%SZ"
|
|
55
|
+
|
|
56
|
+
# Maximum period TenneT accepts in a single balance delta high res request.
|
|
57
|
+
BALANCE_DELTA_HIGH_RES_MAX_PERIOD = pd.Timedelta(hours=4)
|
|
58
|
+
|
|
59
|
+
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
api_key: Optional[str] = None,
|
|
64
|
+
*,
|
|
65
|
+
acceptance: bool = False,
|
|
66
|
+
timeout: float = 30.0,
|
|
67
|
+
max_retries: int = 3,
|
|
68
|
+
backoff: float = 2.0,
|
|
69
|
+
session: Optional[requests.Session] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Initialise the client.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
api_key: API key from the TenneT developer portal. Falls back to the
|
|
76
|
+
``TENNET_API_KEY`` environment variable.
|
|
77
|
+
acceptance: Use the acceptance (test) environment instead of production.
|
|
78
|
+
timeout: Request timeout in seconds.
|
|
79
|
+
max_retries: How many times to retry on HTTP 429 / 5xx responses.
|
|
80
|
+
backoff: Base back-off in seconds (exponential) when no ``Retry-After``
|
|
81
|
+
header is returned.
|
|
82
|
+
session: Optional pre-configured ``requests.Session`` (handy for testing or
|
|
83
|
+
proxies).
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If no API key is provided or found in the environment.
|
|
87
|
+
"""
|
|
88
|
+
api_key = api_key or os.environ.get("TENNET_API_KEY")
|
|
89
|
+
if not api_key:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"No API key provided. Pass `api_key` or set the TENNET_API_KEY "
|
|
92
|
+
"environment variable."
|
|
93
|
+
)
|
|
94
|
+
self.base_url = self.ACC_BASE_URL if acceptance else self.BASE_URL
|
|
95
|
+
self.timeout = timeout
|
|
96
|
+
self.max_retries = max_retries
|
|
97
|
+
self.backoff = backoff
|
|
98
|
+
self.session = session or requests.Session()
|
|
99
|
+
self.session.headers.update(
|
|
100
|
+
{"apikey": api_key, "Accept": "text/csv", "User-Agent": "TennetClient/1.0"}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------------------------
|
|
104
|
+
# Public
|
|
105
|
+
# ---------------------------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def get_settlement_prices(
|
|
108
|
+
self, start: DateLike, end: DateLike, *, chunk_size: Optional[pd.Timedelta] = None
|
|
109
|
+
) -> pd.DataFrame:
|
|
110
|
+
"""
|
|
111
|
+
Get imbalance settlement prices per 15-minute ISP.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
start: Start of the period. Naive values are interpreted as Europe/Amsterdam.
|
|
115
|
+
end: End of the period. Naive values are interpreted as Europe/Amsterdam.
|
|
116
|
+
chunk_size: Optional maximum period per request; longer ranges are split.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Settlement prices.
|
|
120
|
+
"""
|
|
121
|
+
return self._query_range(
|
|
122
|
+
endpoint="settlement-prices",
|
|
123
|
+
start=start,
|
|
124
|
+
end=end,
|
|
125
|
+
resolution=pd.Timedelta(minutes=15),
|
|
126
|
+
chunk_size=chunk_size,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def get_balance_delta_high_res(
|
|
130
|
+
self,
|
|
131
|
+
start: DateLike,
|
|
132
|
+
end: DateLike,
|
|
133
|
+
*,
|
|
134
|
+
chunk_size: Optional[pd.Timedelta] = BALANCE_DELTA_HIGH_RES_MAX_PERIOD,
|
|
135
|
+
) -> pd.DataFrame:
|
|
136
|
+
"""
|
|
137
|
+
Get the balance delta at 12-second resolution.
|
|
138
|
+
|
|
139
|
+
TenneT accepts at most 4 hours per request, so longer ranges are split into
|
|
140
|
+
``chunk_size`` windows (default 4 hours) and concatenated.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
start: Start of the period. Naive values are interpreted as Europe/Amsterdam.
|
|
144
|
+
end: End of the period. Naive values are interpreted as Europe/Amsterdam.
|
|
145
|
+
chunk_size: Maximum period per request. Pass ``None`` to send a single request.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Balance delta at 12-second resolution.
|
|
149
|
+
"""
|
|
150
|
+
return self._query_range(
|
|
151
|
+
endpoint="balance-delta-high-res",
|
|
152
|
+
start=start,
|
|
153
|
+
end=end,
|
|
154
|
+
resolution=pd.Timedelta(seconds=12),
|
|
155
|
+
chunk_size=chunk_size,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def get_balance_delta_high_res_latest(self) -> pd.DataFrame:
|
|
159
|
+
"""
|
|
160
|
+
Get the most recent balance delta high res publication (roughly the last 30 min).
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Most recent balance delta at 12-second resolution.
|
|
164
|
+
"""
|
|
165
|
+
return self._parse_csv(
|
|
166
|
+
text=self._request(endpoint="balance-delta-high-res/latest"),
|
|
167
|
+
resolution=pd.Timedelta(seconds=12),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------------------------
|
|
171
|
+
# Internal
|
|
172
|
+
# ---------------------------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def _query_range(
|
|
175
|
+
self,
|
|
176
|
+
endpoint: str,
|
|
177
|
+
start: DateLike,
|
|
178
|
+
end: DateLike,
|
|
179
|
+
resolution: pd.Timedelta,
|
|
180
|
+
chunk_size: Optional[pd.Timedelta],
|
|
181
|
+
) -> pd.DataFrame:
|
|
182
|
+
"""
|
|
183
|
+
Query a date-ranged endpoint, splitting the range into chunks if needed.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
endpoint: Endpoint path relative to the base URL.
|
|
187
|
+
start: Start of the period.
|
|
188
|
+
end: End of the period.
|
|
189
|
+
resolution: Length of one interval, used to rebuild timestamps from ISPs.
|
|
190
|
+
chunk_size: Maximum period per request, or ``None`` for a single request.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Concatenated DataFrame.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ValueError: If ``start`` is not before ``end``, or ``chunk_size`` is not positive.
|
|
197
|
+
"""
|
|
198
|
+
start_ts, end_ts = self._to_utc(start), self._to_utc(end)
|
|
199
|
+
if start_ts >= end_ts:
|
|
200
|
+
raise ValueError(f"`start` ({start_ts}) must be before `end` ({end_ts}).")
|
|
201
|
+
if chunk_size is not None and chunk_size <= pd.Timedelta(0):
|
|
202
|
+
raise ValueError(f"`chunk_size` ({chunk_size}) must be positive.")
|
|
203
|
+
|
|
204
|
+
# Set batch windows
|
|
205
|
+
chunk_size = end_ts - start_ts if chunk_size is None else chunk_size
|
|
206
|
+
windows = [
|
|
207
|
+
(start_ts + chunk_size * t, min(start_ts + chunk_size * (t + 1), end_ts))
|
|
208
|
+
for t in range(ceil((end_ts - start_ts) / chunk_size))
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
# Query in batches
|
|
212
|
+
frames = []
|
|
213
|
+
for w_start, w_end in windows:
|
|
214
|
+
params = {
|
|
215
|
+
"date_from": w_start.strftime(self.DATE_FORMAT_UTC),
|
|
216
|
+
"date_to": w_end.strftime(self.DATE_FORMAT_UTC),
|
|
217
|
+
}
|
|
218
|
+
frames.append(self._parse_csv(self._request(endpoint, params), resolution))
|
|
219
|
+
|
|
220
|
+
frames = [f for f in frames if not f.empty]
|
|
221
|
+
if not frames:
|
|
222
|
+
return self._empty_frame()
|
|
223
|
+
df = pd.concat(frames)
|
|
224
|
+
df = df.sort_values(["timeinterval_start", "timeinterval_end"], ignore_index=True)
|
|
225
|
+
return df
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def _to_utc(cls, value: DateLike) -> pd.Timestamp:
|
|
229
|
+
"""
|
|
230
|
+
Convert a date-like value to a UTC timestamp.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
value: Date-like value. Naive values are interpreted as Europe/Amsterdam,
|
|
234
|
+
timezone-aware values are converted.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Timezone-aware UTC timestamp.
|
|
238
|
+
"""
|
|
239
|
+
ts = pd.Timestamp(value)
|
|
240
|
+
if ts.tzinfo is None:
|
|
241
|
+
ts = ts.tz_localize(cls.LOCAL_TZ, ambiguous="raise", nonexistent="shift_forward")
|
|
242
|
+
ts = ts.tz_convert("UTC")
|
|
243
|
+
return ts
|
|
244
|
+
|
|
245
|
+
def _request(self, endpoint: str, params: Optional[dict[str, str]] = None) -> str:
|
|
246
|
+
"""
|
|
247
|
+
Perform a GET request with retries.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
endpoint: Endpoint path relative to the base URL.
|
|
251
|
+
params: Optional query parameters.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
The response body as text.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
TennetAPIError: If the request fails or the API returns an error status.
|
|
258
|
+
"""
|
|
259
|
+
url = self.base_url + endpoint
|
|
260
|
+
for attempt in range(self.max_retries + 1):
|
|
261
|
+
try:
|
|
262
|
+
resp = self.session.get(url, params=params, timeout=self.timeout)
|
|
263
|
+
except requests.RequestException as exc:
|
|
264
|
+
if attempt >= self.max_retries:
|
|
265
|
+
raise TennetAPIError(f"Request to {url} failed: {exc}") from exc
|
|
266
|
+
time.sleep(self.backoff * 2**attempt)
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
if resp.status_code in self._RETRY_STATUS and attempt < self.max_retries:
|
|
270
|
+
time.sleep(self._retry_delay(resp, attempt))
|
|
271
|
+
continue
|
|
272
|
+
if resp.status_code >= 400:
|
|
273
|
+
raise TennetAPIError(
|
|
274
|
+
f"TenneT API error {resp.status_code} for {url}: {resp.text[:500]}",
|
|
275
|
+
status_code=resp.status_code,
|
|
276
|
+
)
|
|
277
|
+
return resp.text
|
|
278
|
+
raise TennetAPIError(f"Giving up on {url} after {self.max_retries} retries.")
|
|
279
|
+
|
|
280
|
+
def _retry_delay(self, resp: requests.Response, attempt: int) -> float:
|
|
281
|
+
"""
|
|
282
|
+
Compute how long to wait before retrying.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
resp: The response that triggered the retry.
|
|
286
|
+
attempt: Zero-based attempt number.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Delay in seconds, taken from ``Retry-After`` or exponential back-off.
|
|
290
|
+
"""
|
|
291
|
+
retry_after = (resp.headers or {}).get("Retry-After")
|
|
292
|
+
try:
|
|
293
|
+
return float(retry_after)
|
|
294
|
+
except (TypeError, ValueError):
|
|
295
|
+
return self.backoff * 2**attempt
|
|
296
|
+
|
|
297
|
+
@classmethod
|
|
298
|
+
def _parse_csv(cls, text: str, resolution: pd.Timedelta) -> pd.DataFrame:
|
|
299
|
+
"""
|
|
300
|
+
Parse a TenneT CSV response into a DataFrame.
|
|
301
|
+
|
|
302
|
+
Interval columns (``timeinterval_start/end_utc`` or ``_loc``) are converted to
|
|
303
|
+
Europe/Amsterdam timestamps and renamed to ``timeinterval_start`` / ``timeinterval_end``.
|
|
304
|
+
UTC columns take precedence: if both are present, the local ones are dropped. Rows are
|
|
305
|
+
then sorted chronologically.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
text: CSV response body.
|
|
309
|
+
resolution: Length of one interval, used to disambiguate local timestamps via ISPs.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
DataFrame with snake_case columns and Europe/Amsterdam interval timestamps.
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
TennetAPIError: If only one column of a start/end interval pair is present.
|
|
316
|
+
"""
|
|
317
|
+
if not text or not text.strip():
|
|
318
|
+
return cls._empty_frame()
|
|
319
|
+
df = pd.read_csv(StringIO(text))
|
|
320
|
+
if df.empty:
|
|
321
|
+
return cls._empty_frame()
|
|
322
|
+
|
|
323
|
+
# Convert column names to snake case
|
|
324
|
+
df.columns = [cls._snake(c) for c in df.columns]
|
|
325
|
+
|
|
326
|
+
# Find complete start/end interval column pairs per suffix
|
|
327
|
+
cols = ["timeinterval_start", "timeinterval_end"]
|
|
328
|
+
pairs = {}
|
|
329
|
+
for sfx in ["utc", "loc"]:
|
|
330
|
+
pair = {f"{c}_{sfx}": c for c in cols}
|
|
331
|
+
n_present = sum(c in df.columns for c in pair.keys())
|
|
332
|
+
if n_present == 1:
|
|
333
|
+
raise TennetAPIError(
|
|
334
|
+
f"Incomplete interval columns in TenneT response: {list(pair.keys())}."
|
|
335
|
+
)
|
|
336
|
+
if n_present == 2:
|
|
337
|
+
pairs[sfx] = pair
|
|
338
|
+
|
|
339
|
+
# Convert interval columns to Europe/Amsterdam timestamps (UTC is unambiguous, so it is
|
|
340
|
+
# preferred over local)
|
|
341
|
+
if "utc" in pairs:
|
|
342
|
+
df = df.drop(columns=list(pairs.get("loc", {}).keys())).rename(columns=pairs["utc"])
|
|
343
|
+
for c in cols:
|
|
344
|
+
df[c] = pd.to_datetime(df[c], utc=True).dt.tz_convert(cls.LOCAL_TZ)
|
|
345
|
+
elif "loc" in pairs:
|
|
346
|
+
df = df.rename(columns=pairs["loc"])
|
|
347
|
+
df[cols[0]], df[cols[1]] = cls._localize_intervals(df, resolution)
|
|
348
|
+
|
|
349
|
+
# Sort chronologically
|
|
350
|
+
if pairs:
|
|
351
|
+
df = df.sort_values(cols, ignore_index=True)
|
|
352
|
+
|
|
353
|
+
return df
|
|
354
|
+
|
|
355
|
+
@classmethod
|
|
356
|
+
def _localize_intervals(
|
|
357
|
+
cls, df: pd.DataFrame, resolution: pd.Timedelta
|
|
358
|
+
) -> tuple[pd.Series, pd.Series]:
|
|
359
|
+
"""
|
|
360
|
+
Convert local (Europe/Amsterdam) interval columns to timezone-aware timestamps.
|
|
361
|
+
|
|
362
|
+
Local wall-clock times are ambiguous in the repeated hour when DST ends, so:
|
|
363
|
+
- Start: rebuilt from the delivery date + ISP number (adding a Timedelta to an aware
|
|
364
|
+
timestamp is absolute-time arithmetic, hence DST-safe), then checked against the
|
|
365
|
+
wall-clock start times in the response.
|
|
366
|
+
- End: converted from the response values. For an ambiguous wall-clock time, the
|
|
367
|
+
earliest reading that falls after the start is used.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
df: DataFrame with naive ``timeinterval_start``, ``timeinterval_end`` and ``isp``
|
|
371
|
+
columns.
|
|
372
|
+
resolution: Length of one ISP.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Timezone-aware start and end timestamps.
|
|
376
|
+
|
|
377
|
+
Raises:
|
|
378
|
+
TennetAPIError: If the ``isp`` column is missing, if the ISP-based start times don't
|
|
379
|
+
match the response (e.g. an inconsistent ``resolution``), or if an end time is not
|
|
380
|
+
after its start time.
|
|
381
|
+
"""
|
|
382
|
+
if "isp" not in df.columns:
|
|
383
|
+
raise TennetAPIError(
|
|
384
|
+
"Cannot parse local date columns without column 'isp', because local "
|
|
385
|
+
"timestamps are ambiguous around DST."
|
|
386
|
+
)
|
|
387
|
+
start_naive = pd.to_datetime(df["timeinterval_start"])
|
|
388
|
+
end_naive = pd.to_datetime(df["timeinterval_end"])
|
|
389
|
+
|
|
390
|
+
# Start: delivery date + ISP number, cross-checked with the response
|
|
391
|
+
day = start_naive.dt.normalize().dt.tz_localize(cls.LOCAL_TZ)
|
|
392
|
+
start = day + (df["isp"].astype(int) - 1) * resolution
|
|
393
|
+
if not (start.dt.tz_localize(None) == start_naive).all():
|
|
394
|
+
raise TennetAPIError(
|
|
395
|
+
f"Input resolution '{resolution}' and ISP numbers are inconsistent with TenneT "
|
|
396
|
+
f"response start timestamps."
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# End: localize both readings of ambiguous times (DST is the earlier one in absolute
|
|
400
|
+
# time), and keep the earliest that falls after the start
|
|
401
|
+
end_dst = end_naive.dt.tz_localize(cls.LOCAL_TZ, ambiguous=True)
|
|
402
|
+
end_std = end_naive.dt.tz_localize(cls.LOCAL_TZ, ambiguous=False)
|
|
403
|
+
end = end_dst.where(end_dst > start, end_std)
|
|
404
|
+
if not (end > start).all():
|
|
405
|
+
raise TennetAPIError("TenneT response contains intervals that end before they start.")
|
|
406
|
+
|
|
407
|
+
return start, end
|
|
408
|
+
|
|
409
|
+
@classmethod
|
|
410
|
+
def _empty_frame(cls) -> pd.DataFrame:
|
|
411
|
+
"""Return an empty DataFrame, used when TenneT returns no data."""
|
|
412
|
+
return pd.DataFrame()
|
|
413
|
+
|
|
414
|
+
@staticmethod
|
|
415
|
+
def _snake(name: str) -> str:
|
|
416
|
+
"""Convert a column name to snake_case."""
|
|
417
|
+
return re.sub(r"[^0-9a-z]+", "_", name.strip().lower()).strip("_")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|