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.
- pydpeet/__init__.py +56 -0
- pydpeet/citations/__init__.py +7 -0
- pydpeet/citations/citeme.py +397 -0
- pydpeet/citations/html_writer.py +137 -0
- pydpeet/dev_utils/__init__.py +7 -0
- pydpeet/dev_utils/generate_inits/__init__.py +7 -0
- pydpeet/dev_utils/generate_inits/generate_inits.py +327 -0
- pydpeet/io/__init__.py +7 -0
- pydpeet/io/configs/__init__.py +7 -0
- pydpeet/io/configs/config.py +227 -0
- pydpeet/io/configs/const.py +1 -0
- pydpeet/io/convert.py +594 -0
- pydpeet/io/device/__init__.py +7 -0
- pydpeet/io/device/arbin_4_23_PV090331/__init__.py +7 -0
- pydpeet/io/device/arbin_4_23_PV090331/formatter.py +22 -0
- pydpeet/io/device/arbin_4_23_PV090331/mapper.py +18 -0
- pydpeet/io/device/arbin_4_23_PV090331/reader.py +68 -0
- pydpeet/io/device/arbin_8_00_PV221201/__init__.py +7 -0
- pydpeet/io/device/arbin_8_00_PV221201/formatter.py +26 -0
- pydpeet/io/device/arbin_8_00_PV221201/mapper.py +18 -0
- pydpeet/io/device/arbin_8_00_PV221201/reader.py +66 -0
- pydpeet/io/device/basytec_6_3_1_0/__init__.py +7 -0
- pydpeet/io/device/basytec_6_3_1_0/formatter.py +18 -0
- pydpeet/io/device/basytec_6_3_1_0/mapper.py +18 -0
- pydpeet/io/device/basytec_6_3_1_0/reader.py +54 -0
- pydpeet/io/device/digatron_4_20_6_236/__init__.py +7 -0
- pydpeet/io/device/digatron_4_20_6_236/formatter.py +27 -0
- pydpeet/io/device/digatron_4_20_6_236/mapper.py +18 -0
- pydpeet/io/device/digatron_4_20_6_236/reader.py +45 -0
- pydpeet/io/device/digatron_eis_4_20_6_236/__init__.py +7 -0
- pydpeet/io/device/digatron_eis_4_20_6_236/formatter.py +39 -0
- pydpeet/io/device/digatron_eis_4_20_6_236/mapper.py +18 -0
- pydpeet/io/device/digatron_eis_4_20_6_236/reader.py +42 -0
- pydpeet/io/device/neware_8_0_0_516/__init__.py +7 -0
- pydpeet/io/device/neware_8_0_0_516/formatter.py +31 -0
- pydpeet/io/device/neware_8_0_0_516/mapper.py +18 -0
- pydpeet/io/device/neware_8_0_0_516/reader.py +397 -0
- pydpeet/io/device/parstat_2_63_3/__init__.py +7 -0
- pydpeet/io/device/parstat_2_63_3/formatter.py +34 -0
- pydpeet/io/device/parstat_2_63_3/mapper.py +18 -0
- pydpeet/io/device/parstat_2_63_3/reader.py +34 -0
- pydpeet/io/device/safion_1_9/__init__.py +7 -0
- pydpeet/io/device/safion_1_9/formatter.py +25 -0
- pydpeet/io/device/safion_1_9/mapper.py +18 -0
- pydpeet/io/device/safion_1_9/reader.py +89 -0
- pydpeet/io/device/zahner/__init__.py +7 -0
- pydpeet/io/device/zahner/formatter.py +73 -0
- pydpeet/io/device/zahner/mapper.py +34 -0
- pydpeet/io/device/zahner/reader.py +46 -0
- pydpeet/io/device/zahner_new/__init__.py +7 -0
- pydpeet/io/device/zahner_new/formatter.py +72 -0
- pydpeet/io/device/zahner_new/mapper.py +50 -0
- pydpeet/io/device/zahner_new/reader.py +46 -0
- pydpeet/io/map.py +69 -0
- pydpeet/io/read.py +50 -0
- pydpeet/io/utils/__init__.py +7 -0
- pydpeet/io/utils/ext_path.py +34 -0
- pydpeet/io/utils/formatter_utils.py +429 -0
- pydpeet/io/utils/load_custom_module.py +33 -0
- pydpeet/io/utils/timing.py +36 -0
- pydpeet/io/write.py +99 -0
- pydpeet/process/__init__.py +7 -0
- pydpeet/process/analyze/__init__.py +7 -0
- pydpeet/process/analyze/average.py +297 -0
- pydpeet/process/analyze/capacity.py +208 -0
- pydpeet/process/analyze/configs/__init__.py +7 -0
- pydpeet/process/analyze/configs/battery_config.py +38 -0
- pydpeet/process/analyze/configs/ocv_config.py +177 -0
- pydpeet/process/analyze/configs/step_analyzer_config.py +178 -0
- pydpeet/process/analyze/cycle.py +63 -0
- pydpeet/process/analyze/efficiency.py +182 -0
- pydpeet/process/analyze/energy.py +51 -0
- pydpeet/process/analyze/extract/__init__.py +7 -0
- pydpeet/process/analyze/extract/dva_ica.py +224 -0
- pydpeet/process/analyze/extract/ocv.py +196 -0
- pydpeet/process/analyze/power.py +32 -0
- pydpeet/process/analyze/resistance.py +87 -0
- pydpeet/process/analyze/soc.py +418 -0
- pydpeet/process/analyze/soh.py +72 -0
- pydpeet/process/analyze/utils.py +122 -0
- pydpeet/process/merge/__init__.py +7 -0
- pydpeet/process/merge/series.py +274 -0
- pydpeet/process/sequence/__init__.py +7 -0
- pydpeet/process/sequence/configs/__init__.py +7 -0
- pydpeet/process/sequence/configs/config.py +17 -0
- pydpeet/process/sequence/step_analyzer.py +440 -0
- pydpeet/process/sequence/utils/__init__.py +7 -0
- pydpeet/process/sequence/utils/annotate/__init__.py +7 -0
- pydpeet/process/sequence/utils/annotate/annotate_primitives.py +419 -0
- pydpeet/process/sequence/utils/configs/CONFIG_Fallback.py +231 -0
- pydpeet/process/sequence/utils/configs/CONFIG_preprocessing.py +243 -0
- pydpeet/process/sequence/utils/configs/__init__.py +7 -0
- pydpeet/process/sequence/utils/console_prints/__init__.py +7 -0
- pydpeet/process/sequence/utils/console_prints/log_time.py +30 -0
- pydpeet/process/sequence/utils/postprocessing/__init__.py +7 -0
- pydpeet/process/sequence/utils/postprocessing/df_primitives_correction.py +166 -0
- pydpeet/process/sequence/utils/postprocessing/filter_df.py +168 -0
- pydpeet/process/sequence/utils/postprocessing/generate_instructions.py +297 -0
- pydpeet/process/sequence/utils/preprocessing/__init__.py +7 -0
- pydpeet/process/sequence/utils/preprocessing/calculate_thresholds.py +27 -0
- pydpeet/process/sequence/utils/processing/__init__.py +7 -0
- pydpeet/process/sequence/utils/processing/analyze_segments.py +368 -0
- pydpeet/process/sequence/utils/processing/attempt_to_merge_neighboring_segments.py +122 -0
- pydpeet/process/sequence/utils/processing/check_CV_results.py +226 -0
- pydpeet/process/sequence/utils/processing/check_power_zero_watt_segments.py +85 -0
- pydpeet/process/sequence/utils/processing/check_zero_length.py +117 -0
- pydpeet/process/sequence/utils/processing/split_in_segments.py +97 -0
- pydpeet/process/sequence/utils/processing/supress_smaller_segments.py +99 -0
- pydpeet/process/sequence/utils/processing/widen_constant_segments.py +185 -0
- pydpeet/process/sequence/utils/visualize/__init__.py +7 -0
- pydpeet/process/sequence/utils/visualize/visualize_data.py +288 -0
- pydpeet/res/__init__.py +7 -0
- pydpeet/settings.py +0 -0
- pydpeet/utils/__init__.py +7 -0
- pydpeet/utils/logging_style.py +28 -0
- pydpeet/version.py +0 -0
- pydpeet-0.2.0.dist-info/METADATA +85 -0
- pydpeet-0.2.0.dist-info/RECORD +122 -0
- pydpeet-0.2.0.dist-info/WHEEL +5 -0
- pydpeet-0.2.0.dist-info/licenses/AUTHORS.md +0 -0
- pydpeet-0.2.0.dist-info/licenses/LICENCE.md +27 -0
- 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)
|