detquantlib 5.4.0__tar.gz → 5.6.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.4.0 → detquantlib-5.6.0}/PKG-INFO +1 -1
- detquantlib-5.6.0/detquantlib/assets/__init__.py +4 -0
- detquantlib-5.6.0/detquantlib/assets/battery.py +397 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/databases/snowflake_client.py +144 -10
- {detquantlib-5.4.0 → detquantlib-5.6.0}/pyproject.toml +1 -1
- detquantlib-5.4.0/detquantlib/assets/__init__.py +0 -4
- detquantlib-5.4.0/detquantlib/assets/battery.py +0 -174
- {detquantlib-5.4.0 → detquantlib-5.6.0}/LICENSE.txt +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/README.md +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/assets/wind_turbine.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/definitions.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/energy.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/helpers.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/price.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/databases/detdatabase.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/databases/helpers.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/entsoe/entsoe.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/sftp/sftp.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/dates/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/dates/dates.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/figures/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/figures/plotly_figures.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/forecasting/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/forecasting/forecasting.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/outputs/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/outputs/outputs_interface.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/stats/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/stats/data_analysis.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/tradable_products/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/tradable_products/tradable_products.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/utils/__init__.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/utils/logging.py +0 -0
- {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/utils/utils.py +0 -0
|
@@ -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
|
|
@@ -4,24 +4,22 @@ Client for working with a Snowflake database from Python.
|
|
|
4
4
|
This module provides a configuration dataclass and a client class that wrap
|
|
5
5
|
``snowflake-connector-python`` with a small, safe API for interacting with Snowflake. Data is
|
|
6
6
|
exchanged as pandas DataFrames.
|
|
7
|
-
|
|
8
|
-
Example:
|
|
9
|
-
>>> with SnowflakeClient(SnowflakeConfig.from_env()) as client:
|
|
10
|
-
... df = client.query("SELECT CURRENT_VERSION() AS version")
|
|
11
7
|
"""
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
# Python built-in packages
|
|
15
10
|
import logging
|
|
16
11
|
import os
|
|
17
12
|
import re
|
|
18
13
|
from dataclasses import dataclass, field
|
|
14
|
+
from types import TracebackType
|
|
19
15
|
from typing import Any, Iterator, Optional, Sequence, Union
|
|
20
16
|
|
|
17
|
+
# Third-party packages
|
|
21
18
|
import pandas as pd
|
|
22
19
|
import snowflake.connector
|
|
23
20
|
from snowflake.connector import SnowflakeConnection
|
|
24
21
|
from snowflake.connector.errors import Error as SnowflakeError
|
|
22
|
+
from snowflake.connector.pandas_tools import write_pandas
|
|
25
23
|
|
|
26
24
|
logger = logging.getLogger(__name__)
|
|
27
25
|
|
|
@@ -108,10 +106,27 @@ class SnowflakeClient:
|
|
|
108
106
|
Attributes:
|
|
109
107
|
config: The connection settings used by this client.
|
|
110
108
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
Examples:
|
|
110
|
+
Example 1: Loading a table
|
|
111
|
+
>>> with SnowflakeClient() as client:
|
|
114
112
|
... customers = client.load_table("CUSTOMERS", columns=["ID", "NAME"], limit=100)
|
|
113
|
+
|
|
114
|
+
Example 2: Standard query method
|
|
115
|
+
>>> with SnowflakeClient() as client:
|
|
116
|
+
... df = client.query("SELECT * FROM orders WHERE status = %s", ("SHIPPED",))
|
|
117
|
+
|
|
118
|
+
Example 3: Batch query method to stream a large table in chunks
|
|
119
|
+
>>> with SnowflakeClient(SnowflakeConfig.from_env()) as client:
|
|
120
|
+
... for chunk in client.query_batches("SELECT * FROM BIG_TABLE"):
|
|
121
|
+
... process(chunk)
|
|
122
|
+
|
|
123
|
+
Example 4: Writing a DataFrame to a table, creating the table if needed
|
|
124
|
+
>>> with SnowflakeClient() as client:
|
|
125
|
+
... client.write_table(df, "DB.SCHEMA.ORDERS", auto_create_table=True)
|
|
126
|
+
|
|
127
|
+
Example 5: Running a statement that does not return data
|
|
128
|
+
>>> with SnowflakeClient() as client:
|
|
129
|
+
... client.execute("DELETE FROM ORDERS WHERE STATUS = %s", ("CANCELLED",))
|
|
115
130
|
"""
|
|
116
131
|
|
|
117
132
|
def __init__(self, config: Optional[SnowflakeConfig] = None) -> None:
|
|
@@ -228,7 +243,12 @@ class SnowflakeClient:
|
|
|
228
243
|
self.connect()
|
|
229
244
|
return self
|
|
230
245
|
|
|
231
|
-
def __exit__(
|
|
246
|
+
def __exit__(
|
|
247
|
+
self,
|
|
248
|
+
exc_type: Optional[type[BaseException]],
|
|
249
|
+
exc: Optional[BaseException],
|
|
250
|
+
tb: Optional[TracebackType],
|
|
251
|
+
) -> None:
|
|
232
252
|
"""
|
|
233
253
|
Closes the connection when leaving a ``with`` block.
|
|
234
254
|
|
|
@@ -359,6 +379,120 @@ class SnowflakeClient:
|
|
|
359
379
|
cols = [d[0] for d in cur.description]
|
|
360
380
|
return pd.DataFrame(cur.fetchall(), columns=cols)
|
|
361
381
|
|
|
382
|
+
# ---------------------------------------------------------------------------------------------
|
|
383
|
+
# Writing data
|
|
384
|
+
# ---------------------------------------------------------------------------------------------
|
|
385
|
+
def write_table(
|
|
386
|
+
self,
|
|
387
|
+
df: pd.DataFrame,
|
|
388
|
+
table: str,
|
|
389
|
+
overwrite: bool = False,
|
|
390
|
+
auto_create_table: bool = False,
|
|
391
|
+
chunk_size: Optional[int] = None,
|
|
392
|
+
) -> int:
|
|
393
|
+
"""
|
|
394
|
+
Writes a DataFrame to a table.
|
|
395
|
+
|
|
396
|
+
Data is uploaded in bulk with the connector's ``write_pandas`` (staged Parquet files loaded
|
|
397
|
+
with ``COPY INTO``), which is much faster than row-by-row inserts. Table and column names
|
|
398
|
+
are treated as unquoted identifiers, so they are case-insensitive and stored in uppercase,
|
|
399
|
+
consistent with ``load_table()``.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
df: The data to write. Column names must be valid unquoted identifiers and, unless the
|
|
403
|
+
table is created by this call, must match the table's columns.
|
|
404
|
+
table: Table name, optionally qualified, e.g. ``"SCHEMA.TABLE"`` or
|
|
405
|
+
``"DB.SCHEMA.TABLE"``. Omitted parts default to the session's current database and
|
|
406
|
+
schema.
|
|
407
|
+
overwrite: If ``True``, the existing rows are replaced by ``df``. If ``False``, ``df``
|
|
408
|
+
is appended. Combined with ``auto_create_table``, the table itself is recreated
|
|
409
|
+
with column types inferred from ``df``.
|
|
410
|
+
auto_create_table: If ``True``, the table is created if it does not exist, with column
|
|
411
|
+
types inferred from ``df``.
|
|
412
|
+
chunk_size: Number of rows per uploaded file. If ``None``, the whole DataFrame is
|
|
413
|
+
uploaded at once.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
The number of rows written. If ``df`` is empty, nothing is written, the table is left
|
|
417
|
+
unchanged and ``0`` is returned.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
ValueError: If the table name has more than three parts, or if the table name or a
|
|
421
|
+
column name is not a valid identifier.
|
|
422
|
+
RuntimeError: If Snowflake reports that the upload did not complete.
|
|
423
|
+
SnowflakeError: If the write fails. The error is logged before being re-raised.
|
|
424
|
+
"""
|
|
425
|
+
parts = [self._check_identifier(p) for p in table.split(".")]
|
|
426
|
+
if len(parts) > 3:
|
|
427
|
+
raise ValueError(f"Invalid table name: {table!r}. Expected at most DB.SCHEMA.TABLE.")
|
|
428
|
+
for col in df.columns:
|
|
429
|
+
self._check_identifier(str(col))
|
|
430
|
+
|
|
431
|
+
if df.empty:
|
|
432
|
+
logger.warning("DataFrame is empty; nothing written to %s", table)
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
# Pad the qualifier on the left, so "T" -> (None, None), "S.T" -> (None, S), etc.
|
|
436
|
+
*qualifier, table_name = parts
|
|
437
|
+
database, schema = ([None, None] + qualifier)[-2:]
|
|
438
|
+
|
|
439
|
+
conn = self.connect()
|
|
440
|
+
try:
|
|
441
|
+
success, n_chunks, n_rows, _ = write_pandas(
|
|
442
|
+
conn,
|
|
443
|
+
df,
|
|
444
|
+
table_name,
|
|
445
|
+
database=database,
|
|
446
|
+
schema=schema,
|
|
447
|
+
chunk_size=chunk_size,
|
|
448
|
+
quote_identifiers=False,
|
|
449
|
+
auto_create_table=auto_create_table,
|
|
450
|
+
overwrite=overwrite,
|
|
451
|
+
use_logical_type=True,
|
|
452
|
+
)
|
|
453
|
+
except SnowflakeError as e:
|
|
454
|
+
logger.error("Write to %s failed: %s", table, e)
|
|
455
|
+
raise
|
|
456
|
+
if not success:
|
|
457
|
+
raise RuntimeError(f"Write to {table} did not complete successfully.")
|
|
458
|
+
logger.info("Wrote %d rows to %s in %d chunk(s)", n_rows, table, n_chunks)
|
|
459
|
+
return n_rows
|
|
460
|
+
|
|
461
|
+
def execute(
|
|
462
|
+
self,
|
|
463
|
+
sql: str,
|
|
464
|
+
params: Optional[Union[Sequence[Any], dict[str, Any]]] = None,
|
|
465
|
+
) -> Optional[int]:
|
|
466
|
+
"""
|
|
467
|
+
Runs a SQL statement that does not return data.
|
|
468
|
+
|
|
469
|
+
Intended for statements such as ``INSERT``, ``UPDATE``, ``DELETE``, ``MERGE``,
|
|
470
|
+
``CREATE TABLE`` or ``USE SCHEMA``. Always pass user-supplied values through ``params``, as
|
|
471
|
+
in ``query()``. Changes are committed automatically, as the connection runs in Snowflake's
|
|
472
|
+
default autocommit mode.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
sql: The SQL statement to execute.
|
|
476
|
+
params: Values bound to the placeholders in ``sql``, as in ``query()``.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
The number of rows affected, as reported by Snowflake, or ``None`` if unavailable. For
|
|
480
|
+
statements that do not modify rows (e.g. DDL), the value is not meaningful.
|
|
481
|
+
|
|
482
|
+
Raises:
|
|
483
|
+
SnowflakeError: If the statement fails. The error is logged before being re-raised.
|
|
484
|
+
"""
|
|
485
|
+
conn = self.connect()
|
|
486
|
+
try:
|
|
487
|
+
with conn.cursor() as cur:
|
|
488
|
+
cur.execute(sql, params)
|
|
489
|
+
rowcount = cur.rowcount
|
|
490
|
+
except SnowflakeError as e:
|
|
491
|
+
logger.error("Statement failed: %s", e)
|
|
492
|
+
raise
|
|
493
|
+
logger.info("Statement affected %s rows", rowcount)
|
|
494
|
+
return rowcount
|
|
495
|
+
|
|
362
496
|
@staticmethod
|
|
363
497
|
def _check_identifier(name: str) -> str:
|
|
364
498
|
"""
|
|
@@ -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
|
|
File without changes
|