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.
Files changed (36) hide show
  1. {detquantlib-5.4.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.4.0 → detquantlib-5.6.0}/detquantlib/data/databases/snowflake_client.py +144 -10
  5. {detquantlib-5.4.0 → detquantlib-5.6.0}/pyproject.toml +1 -1
  6. detquantlib-5.4.0/detquantlib/assets/__init__.py +0 -4
  7. detquantlib-5.4.0/detquantlib/assets/battery.py +0 -174
  8. {detquantlib-5.4.0 → detquantlib-5.6.0}/LICENSE.txt +0 -0
  9. {detquantlib-5.4.0 → detquantlib-5.6.0}/README.md +0 -0
  10. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/__init__.py +0 -0
  11. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/assets/wind_turbine.py +0 -0
  12. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/__init__.py +0 -0
  13. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/definitions.py +0 -0
  14. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/energy.py +0 -0
  15. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/helpers.py +0 -0
  16. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/converters/price.py +0 -0
  17. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/__init__.py +0 -0
  18. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/databases/detdatabase.py +0 -0
  19. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/databases/helpers.py +0 -0
  20. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/entsoe/entsoe.py +0 -0
  21. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/data/sftp/sftp.py +0 -0
  22. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/dates/__init__.py +0 -0
  23. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/dates/dates.py +0 -0
  24. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/figures/__init__.py +0 -0
  25. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/figures/plotly_figures.py +0 -0
  26. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/forecasting/__init__.py +0 -0
  27. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/forecasting/forecasting.py +0 -0
  28. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/outputs/__init__.py +0 -0
  29. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/outputs/outputs_interface.py +0 -0
  30. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/stats/__init__.py +0 -0
  31. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/stats/data_analysis.py +0 -0
  32. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/tradable_products/__init__.py +0 -0
  33. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/tradable_products/tradable_products.py +0 -0
  34. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/utils/__init__.py +0 -0
  35. {detquantlib-5.4.0 → detquantlib-5.6.0}/detquantlib/utils/logging.py +0 -0
  36. {detquantlib-5.4.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.4.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
@@ -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
- from __future__ import annotations
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
- Example:
112
- >>> with SnowflakeClient(SnowflakeConfig.from_env()) as client:
113
- ... df = client.query("SELECT * FROM orders WHERE status = %s", ("SHIPPED",))
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__(self, exc_type, exc, tb) -> None:
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,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "detquantlib"
3
- version = "5.4.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