detquantlib 5.5.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.
Files changed (37) hide show
  1. {detquantlib-5.5.0 → detquantlib-5.7.0}/PKG-INFO +16 -4
  2. {detquantlib-5.5.0 → detquantlib-5.7.0}/README.md +15 -3
  3. detquantlib-5.7.0/detquantlib/assets/__init__.py +4 -0
  4. detquantlib-5.7.0/detquantlib/assets/battery.py +397 -0
  5. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/__init__.py +3 -0
  6. detquantlib-5.7.0/detquantlib/data/tennet/tennet_client.py +417 -0
  7. {detquantlib-5.5.0 → detquantlib-5.7.0}/pyproject.toml +1 -1
  8. detquantlib-5.5.0/detquantlib/assets/__init__.py +0 -4
  9. detquantlib-5.5.0/detquantlib/assets/battery.py +0 -174
  10. {detquantlib-5.5.0 → detquantlib-5.7.0}/LICENSE.txt +0 -0
  11. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/__init__.py +0 -0
  12. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/assets/wind_turbine.py +0 -0
  13. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/__init__.py +0 -0
  14. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/definitions.py +0 -0
  15. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/energy.py +0 -0
  16. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/helpers.py +0 -0
  17. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/price.py +0 -0
  18. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/databases/detdatabase.py +0 -0
  19. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/databases/helpers.py +0 -0
  20. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/databases/snowflake_client.py +0 -0
  21. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/entsoe/entsoe.py +0 -0
  22. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/sftp/sftp.py +0 -0
  23. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/dates/__init__.py +0 -0
  24. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/dates/dates.py +0 -0
  25. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/figures/__init__.py +0 -0
  26. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/figures/plotly_figures.py +0 -0
  27. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/forecasting/__init__.py +0 -0
  28. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/forecasting/forecasting.py +0 -0
  29. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/outputs/__init__.py +0 -0
  30. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/outputs/outputs_interface.py +0 -0
  31. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/stats/__init__.py +0 -0
  32. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/stats/data_analysis.py +0 -0
  33. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/tradable_products/__init__.py +0 -0
  34. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/tradable_products/tradable_products.py +0 -0
  35. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/utils/__init__.py +0 -0
  36. {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/utils/logging.py +0 -0
  37. {detquantlib-5.5.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.5.0
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
- - `Battery`:
87
- - `from detquantlib.assets import Battery`
88
- - `from detquantlib.assets.battery import Battery`
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
- - `Battery`:
58
- - `from detquantlib.assets import Battery`
59
- - `from detquantlib.assets.battery import Battery`
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`
@@ -0,0 +1,4 @@
1
+ from .battery import BatteryState
2
+ from .wind_turbine import WindTurbine, WindTurbineModels
3
+
4
+ __all__ = ["BatteryState", "WindTurbine", "WindTurbineModels"]
@@ -0,0 +1,397 @@
1
+ # Python built-in packages
2
+ import json
3
+ import logging
4
+ import os
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from functools import cached_property
8
+ from typing import Literal
9
+
10
+ # Third-party packages
11
+ import numpy as np
12
+ import pandas as pd
13
+ import redis
14
+
15
+ # Internal modules
16
+ from detquantlib.data import DetDatabase
17
+ from detquantlib.utils import list_to_str
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class BatteryState:
22
+ """
23
+ A class containing the characteristics and current state of a battery.
24
+
25
+ Attributes:
26
+ capacity_mwh: Capacity (MWh)
27
+ power_mw: Power (MW)
28
+ soc_mwh: State of charge (MWh)
29
+ dod_mwh: Depth of discharge (MWh)
30
+ rte: Roundtrip efficiency (%)
31
+ site_node_ids: Site node IDs of the physical batteries aggregated into this group. An
32
+ entry is None when the source system doesn't report a node ID.
33
+ """
34
+
35
+ capacity_mwh: float
36
+ power_mw: float
37
+ soc_mwh: float
38
+ dod_mwh: float
39
+ rte: float
40
+ site_node_ids: list
41
+
42
+ @cached_property
43
+ def cp_ratio(self) -> float:
44
+ """Calculates the battery's capacity:power ratio."""
45
+ return round(self.capacity_mwh / self.power_mw)
46
+
47
+ def __post_init__(self) -> None:
48
+ self._validate_obj()
49
+
50
+ def _validate_obj(self) -> None:
51
+ """Validates the values of the object's attributes."""
52
+ errors: list[str] = []
53
+
54
+ # Type checks (bool is a subclass of int, so exclude it explicitly)
55
+ for name in ("capacity_mwh", "power_mw", "soc_mwh", "dod_mwh", "rte"):
56
+ value = getattr(self, name)
57
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
58
+ errors.append(f"{name} must be a float, got {type(value).__name__}.")
59
+
60
+ # Skip range checks if types are wrong
61
+ if errors:
62
+ raise ValueError("\n".join(errors))
63
+
64
+ # Range checks
65
+ if not self.capacity_mwh >= 0:
66
+ errors.append(f"capacity_mwh must be >= 0, got {self.capacity_mwh}.")
67
+ if not self.power_mw >= 0:
68
+ errors.append(f"power_mw must be >= 0, got {self.power_mw}.")
69
+ if not self.soc_mwh >= 0:
70
+ errors.append(f"soc_mwh must be >= 0, got {self.soc_mwh}.")
71
+ if not self.dod_mwh >= 0:
72
+ errors.append(f"dod_mwh must be >= 0, got {self.dod_mwh}.")
73
+ if not 0 <= self.rte <= 1:
74
+ errors.append(f"rte must be in [0, 1], got {self.rte}.")
75
+ if self.soc_mwh > self.capacity_mwh:
76
+ raise ValueError(
77
+ f"State of charge ({self.soc_mwh} MWh) must be lower than battery capacity "
78
+ f"({self.capacity_mwh} MWh)."
79
+ )
80
+ if self.soc_mwh < self.dod_mwh:
81
+ logging.warning(
82
+ f"State of charge ({self.soc_mwh} MWh) is lower than depth of discharge "
83
+ f"({self.dod_mwh} MWh)."
84
+ )
85
+
86
+ if errors:
87
+ raise ValueError("\n".join(errors))
88
+
89
+ @staticmethod
90
+ def build_from_redis(
91
+ strategy_id: uuid.UUID,
92
+ ems: Literal["eneris", "smartbox"],
93
+ rte: float,
94
+ dod_ratio: float = 0.0,
95
+ cp_ratio: int | None = None,
96
+ steering_margin_power: float = 0.0,
97
+ steering_margin_capacity: float = 0.0,
98
+ ) -> "BatteryState | list[BatteryState]":
99
+ """
100
+ Loads live battery states from Redis and builds the corresponding battery object(s).
101
+
102
+ Args:
103
+ strategy_id: Strategy ID
104
+ ems: Energy management system (can take value 'eneris' or 'smartbox')
105
+ rte: Batteries roundtrip efficiency
106
+ dod_ratio: Batteries depth of discharge (ratio)
107
+ cp_ratio: Desired capacity-to-power ratio. If provided, only batteries matching that
108
+ ratio will be retained. If None, all batteries will be retained.
109
+ steering_margin_capacity: Steering margin (ratio) to be applied to the BESS capacity
110
+ steering_margin_power: Steering margin (ratio) to be applied to the BESS power
111
+
112
+ Returns:
113
+ Battery object (if input argument 'cp_ratio' is specified), or list of battery
114
+ objects otherwise
115
+
116
+ Raises:
117
+ ValueError: Raises an error if the input 'ems' is not valid.
118
+ ValueError: Raises an error if the Redis cache is empty.
119
+ ValueError: Raises an error if the capacity:power ratio is not found.
120
+ """
121
+ # Validate inputs
122
+ _ems_eneris = "eneris"
123
+ _ems_smartbox = "smartbox"
124
+ _ems_accepted_values = [_ems_eneris, _ems_smartbox]
125
+ if ems not in _ems_accepted_values:
126
+ raise ValueError(
127
+ f"Invalid ems value '{ems}'. Accepted values: {list_to_str(_ems_accepted_values)}."
128
+ )
129
+
130
+ # Get battery serial numbers
131
+ serial_numbers = BatteryState.load_serial_map(strategy_id)
132
+
133
+ # Connect to redis cache
134
+ r = redis.Redis(
135
+ host=os.environ["DET_REDIS_HOST"],
136
+ port=int(os.environ["DET_REDIS_PORT"]),
137
+ password=os.environ["DET_REDIS_PASSWORD"],
138
+ ssl=True,
139
+ db=int(os.environ["DET_REDIS_DB"]),
140
+ )
141
+
142
+ # Loop over cache data
143
+ battery_characteristics = []
144
+ missing_batteries = []
145
+ for key in r.scan_iter("*"):
146
+ # Parse data
147
+ raw = r.hget(key, "live")
148
+ data = json.loads(raw)
149
+
150
+ # Temporary code to distinguish between Eneris and SmartBox batteries
151
+ is_smartbox = "powermeter" in data.keys()
152
+
153
+ if (
154
+ ems == _ems_smartbox
155
+ and is_smartbox
156
+ and str(data.get("serial")).upper() in serial_numbers
157
+ ):
158
+ missing_keys = BatteryState.find_missing_battery_keys_smartbox(data)
159
+ if missing_keys:
160
+ # Skip battery if info cannot be found
161
+ missing_batteries.append(
162
+ dict(
163
+ site_node_id=None,
164
+ error_msg=f"Serial {data['serial']}: missing or null keys: {missing_keys}",
165
+ )
166
+ )
167
+ continue
168
+
169
+ capacity_mwh = (
170
+ data["batteries"]["charge_capacity"] + data["batteries"]["discharge_capacity"]
171
+ ) / 1000
172
+ power_mw = data["batteries"]["rated_power"] / 1000
173
+ soc_mwh = data["batteries"]["discharge_capacity"] / 1000
174
+ timestamp = data["ts"]
175
+ site_node_id = None
176
+
177
+ # Calculate depth of discharge
178
+ dod_mwh = dod_ratio * capacity_mwh
179
+
180
+ # Store characteristics
181
+ battery_characteristics.append(
182
+ dict(
183
+ capacity_mwh=capacity_mwh,
184
+ power_mw=power_mw,
185
+ soc_mwh=soc_mwh,
186
+ dod_mwh=dod_mwh,
187
+ rte=rte,
188
+ timestamp=timestamp,
189
+ site_node_id=site_node_id,
190
+ )
191
+ )
192
+
193
+ elif ems == _ems_eneris and not is_smartbox:
194
+ missing_keys = BatteryState.find_missing_battery_keys_eniris(data)
195
+ if missing_keys:
196
+ # Skip battery if info cannot be found
197
+ missing_batteries.append(
198
+ dict(
199
+ site_node_id=data.get("siteNodeId", None),
200
+ error_msg=f"Missing keys: {missing_keys}",
201
+ )
202
+ )
203
+ continue
204
+
205
+ storage = data["data"]["state"]["storage"]
206
+
207
+ # Convert settings
208
+ capacity_mwh = storage["energy_capacity_Wh"] / 1e6
209
+ soc_mwh = storage["energy_stored_Wh"] / 1e6
210
+ charge_rate_mw = storage["max_charge_power_W"] / 1e6
211
+ discharge_rate_mw = storage["max_discharge_power_W"] / 1e6
212
+
213
+ # Set (dis)charge rate
214
+ power_mw = max(charge_rate_mw, -discharge_rate_mw)
215
+
216
+ # Calculate depth of discharge
217
+ dod_mwh = dod_ratio * capacity_mwh
218
+
219
+ # Get BESS group data
220
+ timestamp = data["time"]
221
+ site_node_id = str(data["siteNodeId"])
222
+
223
+ # Store characteristics
224
+ battery_characteristics.append(
225
+ dict(
226
+ capacity_mwh=capacity_mwh,
227
+ power_mw=power_mw,
228
+ soc_mwh=soc_mwh,
229
+ dod_mwh=dod_mwh,
230
+ rte=rte,
231
+ timestamp=timestamp,
232
+ site_node_id=site_node_id,
233
+ )
234
+ )
235
+
236
+ nr_miss = len(missing_batteries)
237
+ if nr_miss > 0:
238
+ nr_found = len(battery_characteristics)
239
+ msg = (
240
+ f"Failed to retrieve the battery storage data of {nr_miss}/{nr_found + nr_miss} "
241
+ f"batteries. Details:"
242
+ )
243
+ for mb in missing_batteries:
244
+ msg += (
245
+ f"\n- Site node ID: {mb['site_node_id']}. Error message: '{mb['error_msg']}'."
246
+ )
247
+ logging.warning(msg)
248
+
249
+ # Convert to dataframe
250
+ df_bess = pd.DataFrame(battery_characteristics)
251
+ if df_bess.empty:
252
+ raise ValueError("Empty Redis cache. No batteries found.")
253
+
254
+ # Calculate capacity:power ratio
255
+ df_bess["cp_ratio"] = (df_bess["capacity_mwh"] / df_bess["power_mw"]).round(0)
256
+ df_bess.sort_values("cp_ratio", ignore_index=True, inplace=True)
257
+
258
+ # Calculate aggregated VPP's
259
+ df_vpp = (
260
+ df_bess[
261
+ [
262
+ "capacity_mwh",
263
+ "power_mw",
264
+ "soc_mwh",
265
+ "dod_mwh",
266
+ "rte",
267
+ "site_node_id",
268
+ "cp_ratio",
269
+ ]
270
+ ]
271
+ .groupby("cp_ratio", as_index=False)
272
+ .agg(
273
+ {
274
+ "capacity_mwh": "sum",
275
+ "power_mw": "sum",
276
+ "soc_mwh": "sum",
277
+ "dod_mwh": "sum",
278
+ "rte": "mean",
279
+ "site_node_id": list,
280
+ }
281
+ )
282
+ .reset_index(drop=True)
283
+ # The aggregated column holds every node ID in the group, hence the plural field name
284
+ .rename(columns={"site_node_id": "site_node_ids"})
285
+ )
286
+
287
+ df_vpp.drop(columns="cp_ratio", inplace=True)
288
+
289
+ # Round to avoid machine precision issues caused by 'group by'
290
+ df_vpp = df_vpp.round(9)
291
+
292
+ # Add margin to available steerable batteries
293
+ df_vpp["power_mw"] *= 1 - steering_margin_power
294
+ df_vpp["capacity_mwh"] *= 1 - steering_margin_capacity
295
+ df_vpp["soc_mwh"] = np.clip(
296
+ a=df_vpp["soc_mwh"], a_min=df_vpp["dod_mwh"], a_max=df_vpp["capacity_mwh"]
297
+ )
298
+
299
+ # Create battery objects
300
+ batteries = [BatteryState(**df_vpp.loc[i, :].to_dict()) for i in df_vpp.index]
301
+
302
+ # Filter on desired capacity:power ratio
303
+ if cp_ratio is not None:
304
+ batteries = next((b for b in batteries if b.cp_ratio == cp_ratio), None)
305
+ if batteries is None:
306
+ raise ValueError(f"Capacity:power ratio of {cp_ratio} not found.")
307
+
308
+ return batteries
309
+
310
+ @staticmethod
311
+ def load_serial_map(strategy_id: uuid.UUID) -> list[str]:
312
+ """Loads the serial number of all active batteries linked to a given strategy."""
313
+ query = (
314
+ f"SELECT SerialNumber FROM [META].[StrategySerialMap] "
315
+ f"WHERE StrategyId = '{strategy_id}' "
316
+ f"AND Active = 1"
317
+ )
318
+ serial_numbers = DetDatabase("det-algo-trading").query_db(query)
319
+ serial_numbers = serial_numbers["SerialNumber"].str.upper().to_list()
320
+ return serial_numbers
321
+
322
+ @staticmethod
323
+ def find_missing_battery_keys_eniris(data: dict) -> list[str]:
324
+ """
325
+ Checks that a live Eniris payload contains all required keys, including
326
+ nested ones, and returns the list of keys that are missing.
327
+
328
+ Expected structure:
329
+ - Top level: "data", "siteNodeId", "time"
330
+ - data["data"]: "state"
331
+ - data["data"]["state"]: "storage"
332
+ - data["data"]["state"]["storage"]: "energy_capacity_Wh",
333
+ "energy_stored_Wh", "max_charge_power_W", "max_discharge_power_W"
334
+
335
+ Args:
336
+ data: Raw battery payload
337
+
338
+ Returns:
339
+ list: Names of the required keys that are missing (empty if all present)
340
+ """
341
+ # Each entry maps a path of parent keys to the keys expected there.
342
+ required_key_paths = [
343
+ ([], ["data", "siteNodeId", "time"]),
344
+ (["data"], ["state"]),
345
+ (["data", "state"], ["storage"]),
346
+ (
347
+ ["data", "state", "storage"],
348
+ [
349
+ "energy_capacity_Wh",
350
+ "energy_stored_Wh",
351
+ "max_charge_power_W",
352
+ "max_discharge_power_W",
353
+ ],
354
+ ),
355
+ ]
356
+
357
+ missing_keys = []
358
+ for path, keys in required_key_paths:
359
+ # Walk down to the sub-dict at `path`, stop if a parent is missing or is not a dict
360
+ node = data
361
+ for parent in path:
362
+ node = node.get(parent) if isinstance(node, dict) else None
363
+ if node is None:
364
+ break
365
+ if not isinstance(node, dict):
366
+ continue
367
+ missing_keys.extend(key for key in keys if key not in node)
368
+
369
+ return missing_keys
370
+
371
+ @staticmethod
372
+ def find_missing_battery_keys_smartbox(data: dict) -> list[str]:
373
+ """
374
+ Checks that a live SmartBox payload contains all required keys with
375
+ non-null values, and returns the list of keys that are missing or null.
376
+
377
+ Expected structure:
378
+ - Top level: "serial", "ts", "batteries"
379
+ - data["batteries"]: "charge_capacity", "discharge_capacity", "rated_power"
380
+
381
+ Args:
382
+ data: Raw battery payload
383
+
384
+ Returns:
385
+ list: Names of the required keys that are missing or null (empty if all present)
386
+ """
387
+ missing_keys = [key for key in ["serial", "ts", "batteries"] if data.get(key) is None]
388
+
389
+ batteries = data.get("batteries")
390
+ if isinstance(batteries, dict):
391
+ missing_keys.extend(
392
+ key
393
+ for key in ["charge_capacity", "discharge_capacity", "rated_power"]
394
+ if batteries.get(key) is None
395
+ )
396
+
397
+ return missing_keys
@@ -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("_")
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "detquantlib"
3
- version = "5.5.0"
3
+ version = "5.7.0"
4
4
  description = "An internal library containing functions and classes that can be used across Quant models."
5
5
  authors = ["Dynamic Energy Trading"]
6
6
  readme = "README.md"
@@ -1,4 +0,0 @@
1
- from .battery import Battery
2
- from .wind_turbine import WindTurbine, WindTurbineModels
3
-
4
- __all__ = ["Battery", "WindTurbine", "WindTurbineModels"]
@@ -1,174 +0,0 @@
1
- # Python built-in packages
2
- import json
3
- import os
4
-
5
- # Third-party packages
6
- import pandas as pd
7
- import redis
8
-
9
- # Internal modules
10
- from detquantlib.utils import list_to_str
11
-
12
-
13
- class Battery:
14
- """A class containing the characteristics of a battery."""
15
-
16
- def __init__(
17
- self,
18
- capacity_mwh: float = None,
19
- power_mw: float = None,
20
- state_of_charge_mwh: float = None,
21
- roundtrip_eff: float = None,
22
- group_id: int = None,
23
- ):
24
- """
25
- Constructor method.
26
-
27
- Args:
28
- capacity_mwh: Battery capacity in MWh
29
- power_mw: Battery power in MW
30
- state_of_charge_mwh: Current state of charge in MWh
31
- roundtrip_eff: Roundtrip efficiency
32
- group_id: Group ID (used to differentiate batteries with different characteristics
33
- """
34
- # Assign inputs
35
- self.capacity_mwh = capacity_mwh
36
- self.power_mw = power_mw
37
- self.state_of_charge_mwh = state_of_charge_mwh
38
- self.roundtrip_eff = roundtrip_eff
39
- self.group_id = group_id
40
-
41
- # Calculate power-to-capacity ratio
42
- if self.capacity_mwh and self.power_mw:
43
- self.capacity_power_ratio = round(self.capacity_mwh / self.power_mw, 0)
44
-
45
- @staticmethod
46
- def build_from_redis() -> (list["Battery"], str):
47
- """
48
- Builds Battery objects from the individual battery units found in the redis cache.
49
-
50
- Returns:
51
- Tuple:
52
- - List of Battery objects
53
- - Message listing all the batteries whose information could not be retrieved
54
- from the redis cache
55
- """
56
- # Load individual battery units
57
- battery_units, missing_units = Battery.load_battery_units_from_redis()
58
-
59
- # Print missing battery units
60
- msg_missing_units = ""
61
- nr_found, nr_miss = len(battery_units), len(missing_units)
62
- if nr_miss > 0:
63
- msg_missing_units = (
64
- f"Failed to retrieve the battery storage data of {nr_miss}/{nr_found + nr_miss} "
65
- f"batteries. Details:"
66
- )
67
- for mb in missing_units:
68
- msg_missing_units += (
69
- f"\n- Site node ID: {mb['site_node_id']}. Error message: '{mb['error_msg']}'."
70
- )
71
-
72
- if nr_found == 0:
73
- return list(), msg_missing_units
74
-
75
- # Set battery groups based on capacity:power ratio
76
- battery_units = pd.DataFrame(battery_units)
77
- battery_units.sort_values("cp_ratio", ignore_index=True, inplace=True)
78
- battery_units["cp_group"] = battery_units["cp_ratio"].round(0)
79
-
80
- # Calculate aggregated virtual batteries
81
- agg_units = (
82
- battery_units[["capacity_mwh", "power_mw", "state_of_charge_mwh", "cp_group"]]
83
- .groupby("cp_group", as_index=False)
84
- .agg({"capacity_mwh": "sum", "power_mw": "sum", "state_of_charge_mwh": "sum"})
85
- .reset_index(drop=True)
86
- )
87
- agg_units["group_id"] = agg_units["cp_group"].rank(method="dense")
88
- agg_units.drop(columns="cp_group", inplace=True)
89
-
90
- # Round to avoid machine precision issues caused by 'group by'
91
- agg_units = agg_units.round(9)
92
-
93
- # Create battery objects
94
- batteries = [Battery(**agg_units.loc[i, :].to_dict()) for i in agg_units.index]
95
-
96
- return batteries, msg_missing_units
97
-
98
- @staticmethod
99
- def load_battery_units_from_redis() -> (list[dict], list[dict]):
100
- """
101
- Loads individual battery units from the redis cache.
102
-
103
- Returns:
104
- Tuple:
105
- - Individual battery units whose information could be found
106
- - Individual battery units whose information could not be found
107
-
108
- Raises:
109
- ConnectionError: Raises an error if environment variables are not defined.
110
- """
111
- # Check mandatory environment variables to connect to Redis cache
112
- mandatory_vars = ["DET_REDIS_HOST", "DET_REDIS_PORT", "DET_REDIS_PASSWORD", "DET_REDIS_DB"]
113
- for mv in mandatory_vars:
114
- if mv not in os.environ:
115
- raise ConnectionError(
116
- f"Environment variable '{mv}' not found. Connection to Redis cache requires "
117
- f"the following environment variables: {list_to_str(mandatory_vars)}."
118
- )
119
-
120
- # Connect to redis cache
121
- r = redis.Redis(
122
- host=os.environ["DET_REDIS_HOST"],
123
- port=int(os.environ["DET_REDIS_PORT"]),
124
- password=os.environ["DET_REDIS_PASSWORD"],
125
- ssl=True,
126
- db=int(os.environ["DET_REDIS_DB"]),
127
- )
128
-
129
- # Loop over cache data
130
- battery_units = []
131
- missing_units = []
132
- for key in r.scan_iter("*"):
133
- # Parse data
134
- raw = r.get(key)
135
- data = json.loads(raw)
136
-
137
- try:
138
- storage = data["data"]["state"]["storage"]
139
- except KeyError:
140
- # Skip battery if info cannot be found
141
- missing_units.append(
142
- dict(site_node_id=data["siteNodeId"], error_msg=data["data"].get("errors", ""))
143
- )
144
- continue
145
-
146
- # Convert settings
147
- capacity_mwh = storage["energy_capacity_Wh"] / 1e6
148
- state_of_charge_mwh = storage["energy_stored_Wh"] / 1e6
149
- charge_rate_mw = storage["max_charge_power_W"] / 1e6
150
- discharge_rate_mw = storage["max_discharge_power_W"] / 1e6
151
-
152
- # Set (dis)charge rate
153
- power_mw = max(charge_rate_mw, -discharge_rate_mw)
154
-
155
- # Calculate capacity:power ratio
156
- cp_ratio = capacity_mwh / power_mw
157
-
158
- # Get BESS group data
159
- timestamp = data["time"]
160
- site_node_id = data["siteNodeId"]
161
-
162
- # Store characteristics
163
- battery_units.append(
164
- dict(
165
- capacity_mwh=capacity_mwh,
166
- power_mw=power_mw,
167
- state_of_charge_mwh=state_of_charge_mwh,
168
- cp_ratio=cp_ratio,
169
- timestamp=timestamp,
170
- site_node_id=site_node_id,
171
- )
172
- )
173
-
174
- return battery_units, missing_units
File without changes