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