PyDPEET 0.2.0__py3-none-any.whl

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 (122) hide show
  1. pydpeet/__init__.py +56 -0
  2. pydpeet/citations/__init__.py +7 -0
  3. pydpeet/citations/citeme.py +397 -0
  4. pydpeet/citations/html_writer.py +137 -0
  5. pydpeet/dev_utils/__init__.py +7 -0
  6. pydpeet/dev_utils/generate_inits/__init__.py +7 -0
  7. pydpeet/dev_utils/generate_inits/generate_inits.py +327 -0
  8. pydpeet/io/__init__.py +7 -0
  9. pydpeet/io/configs/__init__.py +7 -0
  10. pydpeet/io/configs/config.py +227 -0
  11. pydpeet/io/configs/const.py +1 -0
  12. pydpeet/io/convert.py +594 -0
  13. pydpeet/io/device/__init__.py +7 -0
  14. pydpeet/io/device/arbin_4_23_PV090331/__init__.py +7 -0
  15. pydpeet/io/device/arbin_4_23_PV090331/formatter.py +22 -0
  16. pydpeet/io/device/arbin_4_23_PV090331/mapper.py +18 -0
  17. pydpeet/io/device/arbin_4_23_PV090331/reader.py +68 -0
  18. pydpeet/io/device/arbin_8_00_PV221201/__init__.py +7 -0
  19. pydpeet/io/device/arbin_8_00_PV221201/formatter.py +26 -0
  20. pydpeet/io/device/arbin_8_00_PV221201/mapper.py +18 -0
  21. pydpeet/io/device/arbin_8_00_PV221201/reader.py +66 -0
  22. pydpeet/io/device/basytec_6_3_1_0/__init__.py +7 -0
  23. pydpeet/io/device/basytec_6_3_1_0/formatter.py +18 -0
  24. pydpeet/io/device/basytec_6_3_1_0/mapper.py +18 -0
  25. pydpeet/io/device/basytec_6_3_1_0/reader.py +54 -0
  26. pydpeet/io/device/digatron_4_20_6_236/__init__.py +7 -0
  27. pydpeet/io/device/digatron_4_20_6_236/formatter.py +27 -0
  28. pydpeet/io/device/digatron_4_20_6_236/mapper.py +18 -0
  29. pydpeet/io/device/digatron_4_20_6_236/reader.py +45 -0
  30. pydpeet/io/device/digatron_eis_4_20_6_236/__init__.py +7 -0
  31. pydpeet/io/device/digatron_eis_4_20_6_236/formatter.py +39 -0
  32. pydpeet/io/device/digatron_eis_4_20_6_236/mapper.py +18 -0
  33. pydpeet/io/device/digatron_eis_4_20_6_236/reader.py +42 -0
  34. pydpeet/io/device/neware_8_0_0_516/__init__.py +7 -0
  35. pydpeet/io/device/neware_8_0_0_516/formatter.py +31 -0
  36. pydpeet/io/device/neware_8_0_0_516/mapper.py +18 -0
  37. pydpeet/io/device/neware_8_0_0_516/reader.py +397 -0
  38. pydpeet/io/device/parstat_2_63_3/__init__.py +7 -0
  39. pydpeet/io/device/parstat_2_63_3/formatter.py +34 -0
  40. pydpeet/io/device/parstat_2_63_3/mapper.py +18 -0
  41. pydpeet/io/device/parstat_2_63_3/reader.py +34 -0
  42. pydpeet/io/device/safion_1_9/__init__.py +7 -0
  43. pydpeet/io/device/safion_1_9/formatter.py +25 -0
  44. pydpeet/io/device/safion_1_9/mapper.py +18 -0
  45. pydpeet/io/device/safion_1_9/reader.py +89 -0
  46. pydpeet/io/device/zahner/__init__.py +7 -0
  47. pydpeet/io/device/zahner/formatter.py +73 -0
  48. pydpeet/io/device/zahner/mapper.py +34 -0
  49. pydpeet/io/device/zahner/reader.py +46 -0
  50. pydpeet/io/device/zahner_new/__init__.py +7 -0
  51. pydpeet/io/device/zahner_new/formatter.py +72 -0
  52. pydpeet/io/device/zahner_new/mapper.py +50 -0
  53. pydpeet/io/device/zahner_new/reader.py +46 -0
  54. pydpeet/io/map.py +69 -0
  55. pydpeet/io/read.py +50 -0
  56. pydpeet/io/utils/__init__.py +7 -0
  57. pydpeet/io/utils/ext_path.py +34 -0
  58. pydpeet/io/utils/formatter_utils.py +429 -0
  59. pydpeet/io/utils/load_custom_module.py +33 -0
  60. pydpeet/io/utils/timing.py +36 -0
  61. pydpeet/io/write.py +99 -0
  62. pydpeet/process/__init__.py +7 -0
  63. pydpeet/process/analyze/__init__.py +7 -0
  64. pydpeet/process/analyze/average.py +297 -0
  65. pydpeet/process/analyze/capacity.py +208 -0
  66. pydpeet/process/analyze/configs/__init__.py +7 -0
  67. pydpeet/process/analyze/configs/battery_config.py +38 -0
  68. pydpeet/process/analyze/configs/ocv_config.py +177 -0
  69. pydpeet/process/analyze/configs/step_analyzer_config.py +178 -0
  70. pydpeet/process/analyze/cycle.py +63 -0
  71. pydpeet/process/analyze/efficiency.py +182 -0
  72. pydpeet/process/analyze/energy.py +51 -0
  73. pydpeet/process/analyze/extract/__init__.py +7 -0
  74. pydpeet/process/analyze/extract/dva_ica.py +224 -0
  75. pydpeet/process/analyze/extract/ocv.py +196 -0
  76. pydpeet/process/analyze/power.py +32 -0
  77. pydpeet/process/analyze/resistance.py +87 -0
  78. pydpeet/process/analyze/soc.py +418 -0
  79. pydpeet/process/analyze/soh.py +72 -0
  80. pydpeet/process/analyze/utils.py +122 -0
  81. pydpeet/process/merge/__init__.py +7 -0
  82. pydpeet/process/merge/series.py +274 -0
  83. pydpeet/process/sequence/__init__.py +7 -0
  84. pydpeet/process/sequence/configs/__init__.py +7 -0
  85. pydpeet/process/sequence/configs/config.py +17 -0
  86. pydpeet/process/sequence/step_analyzer.py +440 -0
  87. pydpeet/process/sequence/utils/__init__.py +7 -0
  88. pydpeet/process/sequence/utils/annotate/__init__.py +7 -0
  89. pydpeet/process/sequence/utils/annotate/annotate_primitives.py +419 -0
  90. pydpeet/process/sequence/utils/configs/CONFIG_Fallback.py +231 -0
  91. pydpeet/process/sequence/utils/configs/CONFIG_preprocessing.py +243 -0
  92. pydpeet/process/sequence/utils/configs/__init__.py +7 -0
  93. pydpeet/process/sequence/utils/console_prints/__init__.py +7 -0
  94. pydpeet/process/sequence/utils/console_prints/log_time.py +30 -0
  95. pydpeet/process/sequence/utils/postprocessing/__init__.py +7 -0
  96. pydpeet/process/sequence/utils/postprocessing/df_primitives_correction.py +166 -0
  97. pydpeet/process/sequence/utils/postprocessing/filter_df.py +168 -0
  98. pydpeet/process/sequence/utils/postprocessing/generate_instructions.py +297 -0
  99. pydpeet/process/sequence/utils/preprocessing/__init__.py +7 -0
  100. pydpeet/process/sequence/utils/preprocessing/calculate_thresholds.py +27 -0
  101. pydpeet/process/sequence/utils/processing/__init__.py +7 -0
  102. pydpeet/process/sequence/utils/processing/analyze_segments.py +368 -0
  103. pydpeet/process/sequence/utils/processing/attempt_to_merge_neighboring_segments.py +122 -0
  104. pydpeet/process/sequence/utils/processing/check_CV_results.py +226 -0
  105. pydpeet/process/sequence/utils/processing/check_power_zero_watt_segments.py +85 -0
  106. pydpeet/process/sequence/utils/processing/check_zero_length.py +117 -0
  107. pydpeet/process/sequence/utils/processing/split_in_segments.py +97 -0
  108. pydpeet/process/sequence/utils/processing/supress_smaller_segments.py +99 -0
  109. pydpeet/process/sequence/utils/processing/widen_constant_segments.py +185 -0
  110. pydpeet/process/sequence/utils/visualize/__init__.py +7 -0
  111. pydpeet/process/sequence/utils/visualize/visualize_data.py +288 -0
  112. pydpeet/res/__init__.py +7 -0
  113. pydpeet/settings.py +0 -0
  114. pydpeet/utils/__init__.py +7 -0
  115. pydpeet/utils/logging_style.py +28 -0
  116. pydpeet/version.py +0 -0
  117. pydpeet-0.2.0.dist-info/METADATA +85 -0
  118. pydpeet-0.2.0.dist-info/RECORD +122 -0
  119. pydpeet-0.2.0.dist-info/WHEEL +5 -0
  120. pydpeet-0.2.0.dist-info/licenses/AUTHORS.md +0 -0
  121. pydpeet-0.2.0.dist-info/licenses/LICENCE.md +27 -0
  122. pydpeet-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,418 @@
1
+ import gc
2
+ import logging
3
+ from enum import Enum
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from numba import njit
8
+
9
+ from pydpeet.process.analyze.capacity import add_capacity
10
+ from pydpeet.process.analyze.configs.battery_config import BatteryConfig
11
+ from pydpeet.process.analyze.utils import (
12
+ StepTimer,
13
+ precompute_block_arrays_soc_methods,
14
+ )
15
+
16
+
17
+ class SocMethod(Enum):
18
+ WITHOUT_RESET = "withoutReset"
19
+ WITH_RESET_WHEN_FULL = "withResetWhenFull"
20
+ WITH_RESET_WHEN_EMPTY = "withResetWhenEmpty"
21
+ WITH_RESET_WHEN_FULL_AND_EMPTY = "withResetWhenFullAndEmpty"
22
+
23
+
24
+ # Map method names to integers expected by numba function
25
+ SOC_METHOD_MAP = {
26
+ "WITHOUT_RESET": 0,
27
+ "WITH_RESET_WHEN_FULL": 1,
28
+ "WITH_RESET_WHEN_EMPTY": 2,
29
+ "WITH_RESET_WHEN_FULL_AND_EMPTY": 3,
30
+ }
31
+
32
+
33
+ # numba compiled multi-method function: compute SOC arrays for all methods in one call, writing into preallocated output
34
+ @njit(cache=True)
35
+ def _compute_soc_multi_methods_out(
36
+ delta_soc: list[float],
37
+ current: list[float],
38
+ abs_current: list[float],
39
+ voltage: list[float],
40
+ soc_start: float,
41
+ lower_soc: float,
42
+ upper_soc: float,
43
+ threshold_current: float,
44
+ max_voltage: float,
45
+ min_voltage: float,
46
+ method_ints: list[int],
47
+ socs_out: list[int, float],
48
+ reset_buf: list[float],
49
+ ) -> None:
50
+ """
51
+ Compute State of Charge (SOC) arrays for all methods in one call, writing into preallocated output.
52
+
53
+ Params:
54
+ - delta_soc (n): array of delta SOC values
55
+ - current (n): array of current values
56
+ - abs_current (n): array of absolute current values
57
+ - voltage (n): array of voltage values
58
+ - SOC_start (float): starting SOC value
59
+ - lower_soc (float): lowerSOC value for reset
60
+ - upper_soc (float): upperSOC value for reset
61
+ - threshold_current (float): current threshold value for reset
62
+ - max_voltage (float): maximum voltage value for reset
63
+ - min_voltage (float): minimum voltage value for reset
64
+ - method_ints (nm): array of integers representing SOC methods (0: without reset, 1: with reset when full, 2: with reset when empty, 3: with reset when full and empty)
65
+ - socs_out (nm, n): preallocated array to writeSOC values into
66
+ - reset_buf (n): preallocated array to store reset points
67
+ """
68
+ n = len(delta_soc)
69
+ nm = len(method_ints)
70
+
71
+ for m in range(nm):
72
+ method_int = method_ints[m]
73
+
74
+ # Use reset_buf as the reset_points storage (preallocated in Python)
75
+ # initialize reset_buf to sentinel -1.0 for this method
76
+ for ii in range(n):
77
+ reset_buf[ii] = -1.0
78
+
79
+ charge_sign = 1.0
80
+
81
+ # Method flags
82
+ is_full_method = method_int == 1 or method_int == 3
83
+ is_empty_method = method_int == 2 or method_int == 3
84
+
85
+ # Minimum run length for reset
86
+ min_run_length = 3
87
+
88
+ # detect runs for resets
89
+ i = 0
90
+ while i < n:
91
+ # Skip NaN points
92
+ if np.isnan(voltage[i]) or np.isnan(abs_current[i]):
93
+ i += 1
94
+ continue
95
+
96
+ # Full-Rest Runs
97
+ if is_full_method and (voltage[i] >= max_voltage and abs_current[i] < threshold_current):
98
+ start = i
99
+ while (
100
+ i + 1 < n
101
+ and not np.isnan(voltage[i + 1])
102
+ and not np.isnan(abs_current[i + 1])
103
+ and voltage[i + 1] >= max_voltage
104
+ and abs_current[i + 1] < threshold_current
105
+ ):
106
+ i += 1
107
+ run_length = i - start + 1
108
+ if run_length >= min_run_length:
109
+ reset_buf[i] = upper_soc
110
+ i += 1
111
+ continue
112
+
113
+ # Empty-Rest Runs
114
+ if is_empty_method and (voltage[i] <= min_voltage and abs_current[i] < threshold_current):
115
+ start = i
116
+ while (
117
+ i + 1 < n
118
+ and not np.isnan(voltage[i + 1])
119
+ and not np.isnan(abs_current[i + 1])
120
+ and voltage[i + 1] <= min_voltage
121
+ and abs_current[i + 1] < threshold_current
122
+ ):
123
+ i += 1
124
+ run_length = i - start + 1
125
+ if run_length >= min_run_length and reset_buf[i] < 0.0:
126
+ reset_buf[i] = lower_soc
127
+ i += 1
128
+ continue
129
+
130
+ i += 1
131
+
132
+ # Transitions: mark previous index on current direction change after limit
133
+ for i in range(1, n):
134
+ if np.isnan(voltage[i - 1]) or np.isnan(current[i]):
135
+ continue
136
+ prev_v = voltage[i - 1]
137
+ if is_full_method and prev_v >= max_voltage and current[i] * charge_sign < 0.0:
138
+ idx = i - 1
139
+ if reset_buf[idx] < 0.0:
140
+ reset_buf[idx] = upper_soc
141
+ if is_empty_method and prev_v <= min_voltage and current[i] * charge_sign > 0.0:
142
+ idx = i - 1
143
+ if reset_buf[idx] < 0.0:
144
+ reset_buf[idx] = lower_soc
145
+
146
+ # accumulate SOC and write into the output row
147
+ # start value
148
+ # read previous value into prev variable for faster local use
149
+ if reset_buf[0] >= 0.0:
150
+ prev = reset_buf[0]
151
+ else:
152
+ prev = soc_start
153
+ socs_out[m, 0] = prev
154
+ first_reset = False
155
+
156
+ for i in range(1, n):
157
+ d = delta_soc[i]
158
+ if np.isnan(d):
159
+ d = 0.0
160
+ cur = prev + d
161
+ if reset_buf[i] >= 0.0:
162
+ if not first_reset:
163
+ difference = reset_buf[i] - cur
164
+ # shift previous values in output row
165
+ for j in range(i):
166
+ socs_out[m, j] = socs_out[m, j] + difference
167
+ first_reset = True
168
+ cur = reset_buf[i]
169
+ socs_out[m, i] = cur
170
+ prev = cur
171
+
172
+ # function writes into socs_out and uses/reset_buf (both preallocated), returns None
173
+ return
174
+
175
+
176
+ def add_soc(
177
+ df: pd.DataFrame,
178
+ df_primitives: pd.DataFrame,
179
+ neware_bool: bool = True,
180
+ standard_method: SocMethod = None,
181
+ methods: list[SocMethod] = None,
182
+ config: BatteryConfig = None,
183
+ lower_soc_for_voltage: float = 0,
184
+ upper_soc_for_voltage: float = 1,
185
+ lower_voltage_for_soc: float = 0,
186
+ upper_voltage_for_soc: float = 0,
187
+ verbose: bool = True,
188
+ restart_for_testindex: bool = True,
189
+ ) -> pd.DataFrame:
190
+ """
191
+ Computes the Soc (State of Charge) for a battery cell, from the given dataframe. It therefore integrates the current over time,
192
+ using the trapezoid rule. As a capacity reference value, the first calculated capacity value is used, which is updated once the
193
+ soc reaches a point with a new calculated capacity value.
194
+
195
+ The resulting DataFrame has additional 'SOC_<method_name>' columns.
196
+
197
+ Parameters:
198
+ df (pandas.DataFrame): Input DataFrame containing battery test data
199
+ standard_method (SocMethod, optional): Standard SOC method to use if no other methods are provided
200
+ methods (list[SocMethod], optional): List of additional SOC methods to use
201
+ config (BatteryConfig, optional): Configuration object containing battery test parameters
202
+ lower_soc_for_voltage (float, optional): Lower SOC value for voltage bounds (default: 0)
203
+ upper_soc_for_voltage (float, optional): UpperSOC value for voltage bounds (default: 1)
204
+ lower_voltage_for_soc (float, optional): Lower voltage bound for SOC computation (default: 0)
205
+ upper_voltage_for_soc (float, optional): Upper voltage bound forSOC computation (default: 0)
206
+ verbose (bool, optional): If True, print debug messages. Defaults to False.
207
+ restart_for_testindex (bool, optional): If True, restart the computation if a new TestIndex block is encountered. Defaults to True.
208
+
209
+ Returns:
210
+ pandas.DataFrame: DataFrame with added 'SOC_<method_name>' columns
211
+ """
212
+ if methods is None:
213
+ methods = [standard_method] if standard_method is not None else []
214
+ if not methods:
215
+ raise ValueError("No SOC methods supplied (methods or standard_method).")
216
+ # Required columns check
217
+ for col in ["Test_Time[s]", "Current[A]", "Voltage[V]"]:
218
+ if col not in df.columns:
219
+ raise ValueError(f"Column: {col} is missing! Can't proceed")
220
+
221
+ # Add capacity if missing
222
+ if "Capacity[Ah]" not in df.columns:
223
+ # TODO: Assumed stack level for now
224
+ logging.warning("Column 'Capacity[Ah]' missing, adding with function add_capacity.", stacklevel=2)
225
+ with StepTimer(verbose) as st:
226
+ if df_primitives is None:
227
+ logging.info("df_primitives is None, please provide a valid df_primitives for add_capacity function")
228
+ else:
229
+ df = add_capacity(df, df_primitives, neware_bool, config=config, verbose=verbose)
230
+ st.log("added Capacity[Ah] column")
231
+
232
+ # Copy df so it can be safely modified
233
+ df_mod = df.copy()
234
+
235
+ # Config
236
+ if config is None:
237
+ raise ValueError("config must be provided")
238
+ c_ref = config.c_ref
239
+ soc_start = config.soc_start
240
+ max_voltage = upper_voltage_for_soc or config.max_voltage
241
+ min_voltage = lower_voltage_for_soc or config.min_voltage
242
+ threshold_current = config.threshold_current
243
+ voltage_intervall = config.voltage_intervall
244
+ lower_soc = lower_soc_for_voltage or 0
245
+ upper_soc = upper_soc_for_voltage or 1
246
+
247
+ logging.info(f"Starting SOC computation on dataframe of size {len(df_mod)}...")
248
+
249
+ # precompile numba functions to be faster with first big block
250
+ with StepTimer(verbose) as st:
251
+ warmup_numba()
252
+ st.log("warmed up numba")
253
+
254
+ logging.info("Pre-creating SOC columns...")
255
+ # Pre-create SOC columns
256
+ with StepTimer(verbose) as st:
257
+ for m in methods:
258
+ colname = "SOC_" + (m.name if hasattr(m, "name") else str(m))
259
+ if colname not in df_mod.columns:
260
+ df_mod[colname] = np.nan
261
+ st.log(f"created column {colname}")
262
+
263
+ # Helper to adjust voltages
264
+ def _adj_voltages(max_v, min_v, intervall):
265
+ return max_v * (1 - intervall), min_v * (1 + intervall)
266
+
267
+ # Either process per TestIndex block or the whole df
268
+ if "TestIndex" in df_mod.columns and restart_for_testindex:
269
+ unique_indices = df_mod.loc[df_mod["TestIndex"] >= 0, "TestIndex"].dropna().unique()
270
+ for idx in unique_indices:
271
+ logging.info(f"Processing TestIndex {idx} with {df_mod['TestIndex'].eq(idx).sum()} rows...")
272
+ block_mask = df_mod["TestIndex"] == idx
273
+ block = df_mod.loc[block_mask]
274
+
275
+ if block.empty:
276
+ continue
277
+ # ensure deterministic order for precomputation
278
+ block_sorted = block.sort_values("Test_Time[s]") # keep original index values
279
+
280
+ # Precompute arrays on the sorted block
281
+ with StepTimer(verbose) as st:
282
+ if block_sorted["Capacity[Ah]"].notna().any():
283
+ capacity_values = block_sorted["Capacity[Ah]"].values.astype(np.float64)
284
+ else:
285
+ # pass an array of NaNs with same length (numba-friendly)
286
+ capacity_values = np.full(len(block_sorted), np.nan, dtype=np.float64)
287
+
288
+ delta_soc, current_arr, abs_current, voltage_arr, c_ref_as = precompute_block_arrays_soc_methods(
289
+ block_sorted["Test_Time[s]"].values,
290
+ block_sorted["Current[A]"].values,
291
+ block_sorted["Voltage[V]"].values,
292
+ capacity_values,
293
+ c_ref,
294
+ )
295
+ st.log("precomputed block arrays")
296
+
297
+ # Using sorted block length for allocations (important!)
298
+ max_voltage_adj, min_voltage_adj = _adj_voltages(max_voltage, min_voltage, voltage_intervall)
299
+ method_ints = np.array([SOC_METHOD_MAP[m.name] for m in methods], dtype=np.int64)
300
+
301
+ n_points = len(block_sorted) # <<< use block_sorted, not block
302
+ socs_matrix = np.empty((len(methods), n_points), dtype=np.float64)
303
+ reset_buf = np.empty(n_points, dtype=np.float64)
304
+
305
+ with StepTimer(verbose) as st:
306
+ _compute_soc_multi_methods_out(
307
+ delta_soc,
308
+ current_arr,
309
+ abs_current,
310
+ voltage_arr,
311
+ soc_start,
312
+ lower_soc,
313
+ upper_soc,
314
+ threshold_current,
315
+ max_voltage_adj,
316
+ min_voltage_adj,
317
+ method_ints,
318
+ socs_matrix,
319
+ reset_buf,
320
+ )
321
+ st.log("computed SOC values")
322
+
323
+ # sanity check
324
+ assert socs_matrix.shape[1] == len(block_sorted), "SOC length mismatch vs block_sorted!"
325
+
326
+ # Assign back using the sorted block's index so the row mapping is exact
327
+ for idx_method, method in enumerate(methods):
328
+ colname = "SOC_" + (method.name if hasattr(method, "name") else str(method))
329
+ df_mod.loc[block_sorted.index, colname] = socs_matrix[idx_method]
330
+
331
+ del socs_matrix, reset_buf
332
+ gc.collect()
333
+
334
+ else:
335
+ logging.info(f"Processing whole dataframe ({len(df_mod)} rows)...")
336
+
337
+ with StepTimer(verbose) as st:
338
+ delta_soc, current_arr, abs_current, voltage_arr, c_ref_as = precompute_block_arrays_soc_methods(
339
+ df_mod["Test_Time[s]"].values,
340
+ df_mod["Current[A]"].values,
341
+ df_mod["Voltage[V]"].values,
342
+ df_mod["Capacity[Ah]"].values,
343
+ c_ref,
344
+ )
345
+ st.log("precomputed arrays")
346
+
347
+ max_voltage_adj, min_voltage_adj = _adj_voltages(max_voltage, min_voltage, voltage_intervall)
348
+ method_ints = np.array([SOC_METHOD_MAP[m.name] for m in methods], dtype=np.int64)
349
+
350
+ n_points = len(df_mod)
351
+ socs_matrix = np.empty((len(methods), n_points), dtype=np.float64)
352
+ reset_buf = np.empty(n_points, dtype=np.float64)
353
+
354
+ with StepTimer(verbose) as st:
355
+ _compute_soc_multi_methods_out(
356
+ delta_soc,
357
+ current_arr,
358
+ abs_current,
359
+ voltage_arr,
360
+ soc_start,
361
+ lower_soc,
362
+ upper_soc,
363
+ threshold_current,
364
+ max_voltage_adj,
365
+ min_voltage_adj,
366
+ method_ints,
367
+ socs_matrix,
368
+ reset_buf,
369
+ )
370
+ st.log("computed SOC values")
371
+
372
+ for idx_method, method in enumerate(methods):
373
+ colname = "SOC_" + (method.name if hasattr(method, "name") else str(method))
374
+ df_mod[colname] = socs_matrix[idx_method]
375
+
376
+ del socs_matrix, reset_buf
377
+ gc.collect()
378
+
379
+ # Rename standard SOC column
380
+ if standard_method is not None:
381
+ std_name = standard_method.name if hasattr(standard_method, "name") else str(standard_method)
382
+ if "SOC_" + std_name in df_mod.columns:
383
+ # overwrite existing "SOC" if present, avoiding duplicate column labels
384
+ df_mod["SOC"] = df_mod.pop("SOC_" + std_name)
385
+
386
+ return df_mod
387
+
388
+
389
+ def warmup_numba():
390
+ """
391
+ Warm up numba by calling the expensive functions with dummy data.
392
+
393
+ This ensures that subsequent calls to these functions are faster.
394
+ """
395
+ n = 10
396
+ t = np.zeros(n, dtype=np.float64)
397
+ c = np.zeros(n, dtype=np.float64)
398
+ v = np.zeros(n, dtype=np.float64)
399
+ cap = np.ones(n, dtype=np.float64)
400
+ precompute_block_arrays_soc_methods(t, c, v, cap, 1.0)
401
+ dummy_methods = np.array([0], dtype=np.int64)
402
+ socs_out = np.zeros((1, n), dtype=np.float64)
403
+ reset_buf = np.zeros(n, dtype=np.float64)
404
+ _compute_soc_multi_methods_out(
405
+ np.zeros(n),
406
+ np.zeros(n),
407
+ np.zeros(n),
408
+ np.zeros(n),
409
+ 0.0,
410
+ 0.0,
411
+ 1.0,
412
+ 0.01,
413
+ 4.2,
414
+ 3.0,
415
+ dummy_methods,
416
+ socs_out,
417
+ reset_buf,
418
+ )
@@ -0,0 +1,72 @@
1
+ import inspect
2
+ import logging
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from pydpeet.process.analyze.capacity import add_capacity
8
+ from pydpeet.process.analyze.configs.battery_config import BatteryConfig
9
+ from pydpeet.process.analyze.utils import StepTimer
10
+
11
+
12
+ def add_soh(
13
+ df: pd.DataFrame,
14
+ neware_bool: bool = True,
15
+ df_primitives: pd.DataFrame = None,
16
+ config: BatteryConfig = None,
17
+ verbose: bool = True,
18
+ ) -> pd.DataFrame:
19
+ """
20
+ Calculate State of Health (SOH) for the given battery test data.
21
+
22
+ The State of Health is calculated as the ratio of the current capacity to the reference capacity (C_ref).
23
+
24
+ Parameters:
25
+ df (pandas.DataFrame): Input DataFrame containing battery test data
26
+ df_primitives (pandas.DataFrame, optional): DataFrame containing primitive battery test data
27
+ config (BatteryConfig, optional): Configuration object containing battery test parameters
28
+
29
+ Returns:
30
+ pandas.DataFrame: DataFrame with added 'SOH' column
31
+ """
32
+ if config is None:
33
+ func_name = inspect.currentframe().f_code.co_name
34
+ raise ValueError(f"config is None, please provide a valid config for {func_name}")
35
+
36
+ df_mod = df.copy()
37
+ logging.info(f"Starting SOH computation on dataframe of size {len(df_mod)}...")
38
+
39
+ if "Capacity[Ah]" not in df_mod.columns:
40
+ logging.info("Capacity[Ah] not found, adding Capacity column with add_capacity function...")
41
+ with StepTimer(verbose) as st:
42
+ if df_primitives is None:
43
+ logging.info("df_primitives is None, please provide a valid df_primitives for add_capacity function")
44
+ else:
45
+ df_mod = add_capacity(df_mod, neware_bool, df_primitives, config, verbose=verbose)
46
+ st.log("added capacity column to df")
47
+ first_valid_idx = df_mod["Capacity[Ah]"].first_valid_index()
48
+ if first_valid_idx is None:
49
+ logging.warning("No valid capacity values found — returning DataFrame with empty SOH column")
50
+ df_mod["SOH"] = np.nan # or whatever column you intend to fill
51
+ return df_mod
52
+
53
+ c_ref = config.c_ref
54
+
55
+ if c_ref is None:
56
+ logging.info("c_ref is None, attempting to use first valid capacity value as reference")
57
+
58
+ first_valid_idx = df_mod["Capacity[Ah]"].first_valid_index()
59
+
60
+ if first_valid_idx is None:
61
+ logging.warning("No valid capacity values found — returning DataFrame with empty SOH column")
62
+ df_mod["SOH"] = np.nan # or whatever column you intend to fill
63
+ return df_mod
64
+ else:
65
+ c_ref = df_mod.at[first_valid_idx, "Capacity[Ah]"]
66
+ logging.info(f"Using first valid capacity value ({c_ref:.4f} Ah) as reference")
67
+
68
+ with StepTimer(verbose) as st:
69
+ df_mod["SOH"] = df_mod["Capacity[Ah]"].dropna() / c_ref
70
+ st.log("computed SOH values")
71
+
72
+ return df_mod
@@ -0,0 +1,122 @@
1
+ import logging
2
+ import time
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ from numba import njit
7
+
8
+
9
+ class StepTimer:
10
+ """Helper to log elapsed time for sub-steps inside a method."""
11
+
12
+ def __init__(self, verbose=True, indent=" "):
13
+ self.verbose = verbose
14
+ self.indent = indent
15
+ self.start = None
16
+
17
+ def __enter__(self):
18
+ self.start = time.perf_counter()
19
+ return self
20
+
21
+ def __exit__(self, exc_type, exc_val, exc_tb):
22
+ pass
23
+
24
+ def log(self, msg: str):
25
+ if self.verbose:
26
+ elapsed = time.perf_counter() - self.start
27
+ logging.info(f"{self.indent}{elapsed:0.4f}s {msg}")
28
+
29
+
30
+ def _check_columns(df: pd.DataFrame, required_columns: list[str]) -> None:
31
+ """Checks if the required columns are present in the DataFrame, allowing for 'Segment_' prefixes."""
32
+ columns = set(df.columns)
33
+ mapped_columns = {col.replace("Segment_", "") for col in columns}
34
+
35
+ missing_columns = [col for col in required_columns if col not in columns and col not in mapped_columns]
36
+ if missing_columns:
37
+ raise KeyError(f"The following required columns are missing: {', '.join(missing_columns)}")
38
+
39
+ return None
40
+
41
+
42
+ # Precompute arrays that are independent of chosen SOC reset method
43
+
44
+
45
+ @njit(cache=True)
46
+ def precompute_block_arrays_soc_methods(time, current, voltage, capacity_values, c_ref):
47
+ """
48
+ Numba-optimized version of precompute_block_arrays_soc_methods.
49
+ Returns: (delta_soc, current_arr, abs_current, voltage_arr, c_ref_as)
50
+ """
51
+ n = len(time)
52
+
53
+ # Ensure arrays are float64 contiguous
54
+ delta_t = np.zeros(n, dtype=np.float64)
55
+ if n > 1:
56
+ for i in range(1, n):
57
+ if np.isnan(time[i]) or np.isnan(time[i - 1]):
58
+ delta_t[i] = 0.0
59
+ else:
60
+ delta_t[i] = time[i] - time[i - 1]
61
+
62
+ # delta_Q = current * delta_t
63
+ delta_Q = np.zeros(n, dtype=np.float64)
64
+ for i in range(n):
65
+ if np.isnan(current[i]):
66
+ delta_Q[i] = 0.0
67
+ else:
68
+ delta_Q[i] = current[i] * delta_t[i]
69
+
70
+ # c_ref_as forward-fill
71
+ c_ref_as = np.empty(n, dtype=np.float64)
72
+ local = c_ref if c_ref is not None else 1e-9
73
+ for i in range(n):
74
+ value = capacity_values[i]
75
+ if not np.isnan(value):
76
+ local = value
77
+ if local <= 0.0 or np.isnan(local):
78
+ local = 1e-9
79
+ c_ref_as[i] = local * 3600.0 # convert Ah → As
80
+
81
+ # delta_soc = delta_Q / c_ref_as
82
+ delta_soc = np.zeros(n, dtype=np.float64)
83
+ for i in range(n):
84
+ if c_ref_as[i] != 0.0:
85
+ delta_soc[i] = delta_Q[i] / c_ref_as[i]
86
+ else:
87
+ delta_soc[i] = 0.0
88
+
89
+ # abs_current
90
+ abs_current = np.empty(n, dtype=np.float64)
91
+ for i in range(n):
92
+ ci = current[i]
93
+ if np.isnan(ci):
94
+ abs_current[i] = 0.0
95
+ else:
96
+ abs_current[i] = ci if ci >= 0.0 else -ci
97
+
98
+ # voltage_arr
99
+ voltage_arr = np.empty(n, dtype=np.float64)
100
+ for i in range(n):
101
+ voltage_arr[i] = voltage[i] if not np.isnan(voltage[i]) else 0.0
102
+
103
+ return delta_soc, current, abs_current, voltage_arr, c_ref_as
104
+
105
+
106
+ def drop_duplicate_testtime(df: pd.DataFrame, keep: str | bool = "first") -> pd.DataFrame:
107
+ """
108
+ Drop duplicate rows based on Test_Time[s].
109
+ keep='first' keeps the first occurrence,
110
+ keep='last' keeps the last,
111
+ keep=False removes all duplicates entirely.
112
+ """
113
+ if "Test_Time[s]" not in df.columns:
114
+ raise ValueError("No 'Test_Time[s]' column in DataFrame")
115
+
116
+ n_before = len(df)
117
+ df_clean = df.drop_duplicates(subset=["Test_Time[s]"], keep=keep).copy()
118
+ n_after = len(df_clean)
119
+
120
+ logging.warning(f"Dropped {n_before - n_after} duplicate rows (kept={keep}).")
121
+
122
+ return df_clean.sort_values("Test_Time[s]").reset_index(drop=True)
@@ -0,0 +1,7 @@
1
+ """
2
+ Auto-generated __init__ file.
3
+ Created: 2026-03-06 15:11:49
4
+ """
5
+
6
+ # Restrictive package init: start with no public API
7
+ __all__ = []