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.
- {detquantlib-5.5.0 → detquantlib-5.7.0}/PKG-INFO +16 -4
- {detquantlib-5.5.0 → detquantlib-5.7.0}/README.md +15 -3
- detquantlib-5.7.0/detquantlib/assets/__init__.py +4 -0
- detquantlib-5.7.0/detquantlib/assets/battery.py +397 -0
- {detquantlib-5.5.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.5.0 → detquantlib-5.7.0}/pyproject.toml +1 -1
- detquantlib-5.5.0/detquantlib/assets/__init__.py +0 -4
- detquantlib-5.5.0/detquantlib/assets/battery.py +0 -174
- {detquantlib-5.5.0 → detquantlib-5.7.0}/LICENSE.txt +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/assets/wind_turbine.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/definitions.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/energy.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/helpers.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/converters/price.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/databases/detdatabase.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/databases/helpers.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/databases/snowflake_client.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/entsoe/entsoe.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/data/sftp/sftp.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/dates/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/dates/dates.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/figures/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/figures/plotly_figures.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/forecasting/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/forecasting/forecasting.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/outputs/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/outputs/outputs_interface.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/stats/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/stats/data_analysis.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/tradable_products/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/tradable_products/tradable_products.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/utils/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.7.0}/detquantlib/utils/logging.py +0 -0
- {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.
|
|
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`
|
|
@@ -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,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
|
|
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
|