detquantlib 5.5.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.5.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.5.0 → detquantlib-5.6.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.6.0}/LICENSE.txt +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/README.md +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/assets/wind_turbine.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/converters/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/converters/definitions.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/converters/energy.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/converters/helpers.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/converters/price.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/data/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/data/databases/detdatabase.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/data/databases/helpers.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/data/databases/snowflake_client.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/data/entsoe/entsoe.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/data/sftp/sftp.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/dates/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/dates/dates.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/figures/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/figures/plotly_figures.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/forecasting/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/forecasting/forecasting.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/outputs/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/outputs/outputs_interface.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/stats/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/stats/data_analysis.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/tradable_products/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/tradable_products/tradable_products.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/utils/__init__.py +0 -0
- {detquantlib-5.5.0 → detquantlib-5.6.0}/detquantlib/utils/logging.py +0 -0
- {detquantlib-5.5.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
|
|
@@ -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
|
|
File without changes
|