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,297 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from pydpeet.process.analyze.capacity import add_charge_throughput
|
|
7
|
+
from pydpeet.process.analyze.utils import (
|
|
8
|
+
StepTimer,
|
|
9
|
+
_check_columns,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def calculate_average_temperature(
|
|
14
|
+
df: pd.DataFrame,
|
|
15
|
+
verbose: bool = True,
|
|
16
|
+
) -> float:
|
|
17
|
+
if "Temperature[°C]" not in df.columns:
|
|
18
|
+
raise ValueError("Temperature[°C] column not found in DataFrame.")
|
|
19
|
+
|
|
20
|
+
average_temperature = df["Temperature[°C]"].mean()
|
|
21
|
+
|
|
22
|
+
return average_temperature
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def calculate_average_voltage(
|
|
26
|
+
df: pd.DataFrame,
|
|
27
|
+
verbose: bool = True,
|
|
28
|
+
) -> float:
|
|
29
|
+
if "Voltage[V]" not in df.columns:
|
|
30
|
+
raise ValueError("Voltage[V] column not found in DataFrame.")
|
|
31
|
+
|
|
32
|
+
average_voltage = df["Voltage[V]"].mean()
|
|
33
|
+
|
|
34
|
+
return average_voltage
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def calculate_average_loading_voltage(
|
|
38
|
+
df: pd.DataFrame,
|
|
39
|
+
verbose: bool = True,
|
|
40
|
+
) -> float:
|
|
41
|
+
if "Voltage[V]" not in df.columns:
|
|
42
|
+
raise ValueError("Voltage[V] column not found in DataFrame.")
|
|
43
|
+
|
|
44
|
+
positive_charges = df[df["Voltage[V]"].diff() > 0]["Voltage[V]"]
|
|
45
|
+
average_charge = positive_charges.mean()
|
|
46
|
+
|
|
47
|
+
return average_charge
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def calculate_average_charge(df: pd.DataFrame) -> float:
|
|
51
|
+
"""
|
|
52
|
+
Calculate the average charge current [A] over all charging phases (Current > 0) efficiently.
|
|
53
|
+
"""
|
|
54
|
+
if "Current[A]" not in df.columns:
|
|
55
|
+
return np.nan
|
|
56
|
+
|
|
57
|
+
currents = df["Current[A]"].to_numpy()
|
|
58
|
+
positive = currents[currents > 0]
|
|
59
|
+
|
|
60
|
+
if positive.size == 0:
|
|
61
|
+
return np.nan
|
|
62
|
+
|
|
63
|
+
return positive.mean()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def calculate_average_discharge(df: pd.DataFrame) -> float:
|
|
67
|
+
"""
|
|
68
|
+
Calculate the average discharge current [A] over all discharging phases (Current < 0) efficiently.
|
|
69
|
+
"""
|
|
70
|
+
if "Current[A]" not in df.columns:
|
|
71
|
+
return np.nan
|
|
72
|
+
|
|
73
|
+
currents = df["Current[A]"].to_numpy()
|
|
74
|
+
negative = currents[currents < 0]
|
|
75
|
+
|
|
76
|
+
if negative.size == 0:
|
|
77
|
+
return np.nan
|
|
78
|
+
|
|
79
|
+
return negative.mean()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def calculate_soh_loss(
|
|
83
|
+
df: pd.DataFrame,
|
|
84
|
+
verbose: bool = True,
|
|
85
|
+
) -> float:
|
|
86
|
+
"""
|
|
87
|
+
Calculate the total SOH loss for a dataset.
|
|
88
|
+
Returns a single scalar value:
|
|
89
|
+
soh_loss = 1 - min(valid SOH)
|
|
90
|
+
"""
|
|
91
|
+
_check_columns(df, ["SOH"])
|
|
92
|
+
|
|
93
|
+
soh = df["SOH"].astype(float).dropna()
|
|
94
|
+
|
|
95
|
+
if soh.empty:
|
|
96
|
+
if verbose:
|
|
97
|
+
logging.warning("No valid SOH values found.")
|
|
98
|
+
|
|
99
|
+
return np.nan
|
|
100
|
+
|
|
101
|
+
with StepTimer(verbose) as st:
|
|
102
|
+
soh_loss = 1 - soh.min()
|
|
103
|
+
st.log("computed SOH loss")
|
|
104
|
+
|
|
105
|
+
return soh_loss
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def calculate_soh_loss_per_cycle(
|
|
109
|
+
df: pd.DataFrame,
|
|
110
|
+
verbose: bool = True,
|
|
111
|
+
) -> float:
|
|
112
|
+
"""
|
|
113
|
+
Calculate the SOH loss per equivalent full cycle.
|
|
114
|
+
Returns a single scalar value:
|
|
115
|
+
soh_loss_per_cycle = (1 - min(SOH)) / max(EquivalentFullCycles)
|
|
116
|
+
"""
|
|
117
|
+
_check_columns(df, ["SOH", "EquivalentFullCycles"])
|
|
118
|
+
|
|
119
|
+
soh_loss = calculate_soh_loss(df, verbose=False)
|
|
120
|
+
cycles = df["EquivalentFullCycles"].astype(float).dropna()
|
|
121
|
+
|
|
122
|
+
if cycles.empty:
|
|
123
|
+
if verbose:
|
|
124
|
+
logging.warning("No valid EquivalentFullCycles values found. returning np.nan")
|
|
125
|
+
|
|
126
|
+
return np.nan
|
|
127
|
+
|
|
128
|
+
max_cycles = cycles.max()
|
|
129
|
+
if max_cycles <= 0:
|
|
130
|
+
if verbose:
|
|
131
|
+
logging.warning("Max EquivalentFullCycles <= 0, cannot compute loss per cycle. returning np.nan")
|
|
132
|
+
|
|
133
|
+
return np.nan
|
|
134
|
+
|
|
135
|
+
with StepTimer(verbose) as st:
|
|
136
|
+
soh_loss_per_cycle = soh_loss / max_cycles
|
|
137
|
+
st.log("computed SOH loss per cycle")
|
|
138
|
+
|
|
139
|
+
return soh_loss_per_cycle
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def add_soh_loss_over_charging(
|
|
143
|
+
df: pd.DataFrame,
|
|
144
|
+
verbose: bool = True,
|
|
145
|
+
) -> pd.DataFrame:
|
|
146
|
+
"""
|
|
147
|
+
Calculate SOH loss per unit of charging current.
|
|
148
|
+
|
|
149
|
+
Parameters:
|
|
150
|
+
df (pd.DataFrame): Must contain 'SOH' and 'ChargingCurrent' columns.
|
|
151
|
+
verbose (bool): If True, prints intermediate steps.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
pd.DataFrame: Original df with a new column 'SohLossPerAh'.
|
|
155
|
+
"""
|
|
156
|
+
df = df.sort_index().copy()
|
|
157
|
+
soh_indices = df.index[~df["SOH"].isna()]
|
|
158
|
+
df["SohLossPerAh"] = np.nan
|
|
159
|
+
|
|
160
|
+
for i in range(1, len(soh_indices)):
|
|
161
|
+
start_idx = soh_indices[i - 1]
|
|
162
|
+
end_idx = soh_indices[i]
|
|
163
|
+
|
|
164
|
+
soh_diff = df.at[end_idx, "SOH"] - df.at[start_idx, "SOH"]
|
|
165
|
+
|
|
166
|
+
# Slice the dataframe between two SOH measurements
|
|
167
|
+
df_slice = df.loc[start_idx:end_idx]
|
|
168
|
+
|
|
169
|
+
# Calculate average charging current (make sure function exists)
|
|
170
|
+
avg_current = calculate_average_positive_chargeThroughput(df_slice)
|
|
171
|
+
|
|
172
|
+
# Compute SOH loss per Ah
|
|
173
|
+
loss_per_current = soh_diff / avg_current if avg_current != 0 else np.nan
|
|
174
|
+
df.at[end_idx, "SohLossPerAh"] = loss_per_current
|
|
175
|
+
|
|
176
|
+
if verbose:
|
|
177
|
+
logging.info(
|
|
178
|
+
f"From index {start_idx} to {end_idx}: SOH diff = {soh_diff}, "
|
|
179
|
+
f"Avg current = {avg_current}, Loss/current = {loss_per_current}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return df
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def calculate_total_charge(
|
|
186
|
+
df: pd.DataFrame,
|
|
187
|
+
verbose: bool = True,
|
|
188
|
+
) -> float:
|
|
189
|
+
"""
|
|
190
|
+
Calculate the total charge (in Ah) from the ChargeThroughput column.
|
|
191
|
+
|
|
192
|
+
Parameters:
|
|
193
|
+
-----------
|
|
194
|
+
df : pandas.DataFrame
|
|
195
|
+
Input DataFrame containing 'ChargeThroughput[Ah]' column
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
-------
|
|
199
|
+
float
|
|
200
|
+
Total charge (in Ah)
|
|
201
|
+
"""
|
|
202
|
+
if "ChargeThroughput[Ah]" not in df.columns:
|
|
203
|
+
if verbose:
|
|
204
|
+
logging.info("ChargeThroughput column missing, computing via add_charge_throughput...")
|
|
205
|
+
|
|
206
|
+
df = add_charge_throughput(df, verbose=verbose)
|
|
207
|
+
|
|
208
|
+
delta_load = df["ChargeThroughput[Ah]"].diff().fillna(0)
|
|
209
|
+
total_charge = delta_load[delta_load > 0].sum()
|
|
210
|
+
|
|
211
|
+
if verbose:
|
|
212
|
+
logging.info(f"Total charge computed: {total_charge:.4f} Ah")
|
|
213
|
+
|
|
214
|
+
return total_charge
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def calculate_total_discharge(
|
|
218
|
+
df: pd.DataFrame,
|
|
219
|
+
verbose: bool = True,
|
|
220
|
+
) -> float:
|
|
221
|
+
"""
|
|
222
|
+
Calculate the total discharge (in Ah) from the ChargeThroughput column.
|
|
223
|
+
|
|
224
|
+
Parameters:
|
|
225
|
+
df (pandas.DataFrame): Input DataFrame containing 'ChargeThroughput[Ah]' column
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
float: Total discharge (in Ah)
|
|
229
|
+
|
|
230
|
+
Notes:
|
|
231
|
+
The total discharge is calculated as the sum of negative differences in the ChargeThroughput column.
|
|
232
|
+
"""
|
|
233
|
+
if "ChargeThroughput[Ah]" not in df.columns:
|
|
234
|
+
logging.info("ChargeThroughput column missing, computing via add_charge_throughput...")
|
|
235
|
+
df = add_charge_throughput(df, verbose=verbose)
|
|
236
|
+
|
|
237
|
+
delta_load = df["ChargeThroughput[Ah]"].diff().fillna(0)
|
|
238
|
+
total_discharge = -delta_load[delta_load < 0].sum()
|
|
239
|
+
|
|
240
|
+
if verbose:
|
|
241
|
+
logging.info(f"Total discharge computed: {total_discharge:.4f} Ah")
|
|
242
|
+
|
|
243
|
+
return total_discharge
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def calculate_average_positive_chargeThroughput(
|
|
247
|
+
df: pd.DataFrame,
|
|
248
|
+
verbose: bool = True,
|
|
249
|
+
) -> float:
|
|
250
|
+
"""
|
|
251
|
+
Calculate the average charge (in Ah) from the ChargeThroughput column.
|
|
252
|
+
|
|
253
|
+
Parameters:
|
|
254
|
+
df (pandas.DataFrame): Input DataFrame containing 'ChargeThroughput[Ah]' column
|
|
255
|
+
verbose (bool, optional): If True, print debug messages
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
float: Average charge (in Ah)
|
|
259
|
+
|
|
260
|
+
Notes:
|
|
261
|
+
The average charge is calculated as the mean of only positive values in the ChargeThroughput column.
|
|
262
|
+
"""
|
|
263
|
+
if "ChargeThroughput[Ah]" not in df.columns:
|
|
264
|
+
logging.info("ChargeThroughput column missing, computing via add_charge_throughput...")
|
|
265
|
+
df = add_charge_throughput(df, verbose=verbose)
|
|
266
|
+
|
|
267
|
+
positive_charges = df[df["ChargeThroughput[Ah]"] > 0]["ChargeThroughput[Ah]"]
|
|
268
|
+
average_charge = positive_charges.mean()
|
|
269
|
+
|
|
270
|
+
return average_charge
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def calculate_average_negative_charge_throughput(
|
|
274
|
+
df: pd.DataFrame,
|
|
275
|
+
verbose: bool = True,
|
|
276
|
+
) -> float:
|
|
277
|
+
"""
|
|
278
|
+
Calculate the average discharge (in Ah) from the ChargeThroughput column.
|
|
279
|
+
|
|
280
|
+
Parameters:
|
|
281
|
+
df (pandas.DataFrame): Input DataFrame containing 'ChargeThroughput[Ah]' column
|
|
282
|
+
verbose (bool, optional): If True, print debug messages
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
float: Average discharge (in Ah)
|
|
286
|
+
|
|
287
|
+
Notes:
|
|
288
|
+
The average discharge is calculated as the mean of only negative values in the ChargeThroughput column.
|
|
289
|
+
"""
|
|
290
|
+
if "ChargeThroughput[Ah]" not in df.columns:
|
|
291
|
+
logging.info("ChargeThroughput column missing, computing via add_charge_throughput...")
|
|
292
|
+
df = add_charge_throughput(df, verbose=verbose)
|
|
293
|
+
|
|
294
|
+
negative_charges = df[df["ChargeThroughput[Ah]"] < 0]["ChargeThroughput[Ah]"]
|
|
295
|
+
average_discharge = negative_charges.mean()
|
|
296
|
+
|
|
297
|
+
return average_discharge
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from scipy import integrate
|
|
7
|
+
|
|
8
|
+
from pydpeet.process.analyze.configs.battery_config import BatteryConfig
|
|
9
|
+
from pydpeet.process.analyze.configs.step_analyzer_config import SEGMENT_SEQUENCE_CONFIG
|
|
10
|
+
from pydpeet.process.analyze.utils import (
|
|
11
|
+
StepTimer,
|
|
12
|
+
_check_columns,
|
|
13
|
+
)
|
|
14
|
+
from pydpeet.process.sequence.step_analyzer import extract_sequence_overview
|
|
15
|
+
from pydpeet.process.sequence.utils.postprocessing.filter_df import filter_and_split_df_by_blocks
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_capacity(
|
|
19
|
+
df: pd.DataFrame,
|
|
20
|
+
df_primitives: pd.DataFrame,
|
|
21
|
+
neware_bool: bool = True,
|
|
22
|
+
config: BatteryConfig = None,
|
|
23
|
+
verbose: bool = True,
|
|
24
|
+
) -> pd.DataFrame:
|
|
25
|
+
"""
|
|
26
|
+
Compute the capacity of a battery cell from its discharge data.
|
|
27
|
+
|
|
28
|
+
The capacity computation is a multistep process. First, the discharge blocks
|
|
29
|
+
are filtered from the data. Then, the blocks with a full discharge (from max to min)
|
|
30
|
+
are searched. For each of these blocks, the cumulative capacity is computed by
|
|
31
|
+
integrating the absolute current over time.
|
|
32
|
+
|
|
33
|
+
The resulting DataFrame has an additional 'Capacity[Ah]' column.
|
|
34
|
+
|
|
35
|
+
Parameters:
|
|
36
|
+
df (pandas.DataFrame): Input DataFrame containing battery test data
|
|
37
|
+
config (BatteryConfig, optional): Configuration object containing battery test parameters
|
|
38
|
+
verbose (bool, optional): If True, print debug messages. Defaults to False.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
pandas.DataFrame: DataFrame with added 'Capacity[Ah]' column
|
|
42
|
+
:param THRESHOLD_DICT: threshold dictionary for neware params
|
|
43
|
+
"""
|
|
44
|
+
# Check if the required columns are present
|
|
45
|
+
required_cols = ["Test_Time[s]", "Current[A]", "Voltage[V]"]
|
|
46
|
+
_check_columns(df, required_cols)
|
|
47
|
+
|
|
48
|
+
if config is None:
|
|
49
|
+
func_name = inspect.currentframe().f_code.co_name
|
|
50
|
+
raise ValueError(f"config is None, please provide a valid config for {func_name}!")
|
|
51
|
+
|
|
52
|
+
minimal_current = config.minimal_current_for_capacity
|
|
53
|
+
maximal_current = config.maximal_current_for_capacity
|
|
54
|
+
|
|
55
|
+
df_mod = df.copy()
|
|
56
|
+
max_voltage = config.max_voltage
|
|
57
|
+
min_voltage = config.min_voltage
|
|
58
|
+
voltage_intervall = config.voltage_intervall
|
|
59
|
+
|
|
60
|
+
logging.info(f"Starting capacity computation on dataframe of size {len(df_mod)}...")
|
|
61
|
+
|
|
62
|
+
# Step 2: Segments and sequences
|
|
63
|
+
with StepTimer(verbose) as st:
|
|
64
|
+
df_segments_and_sequences = extract_sequence_overview(
|
|
65
|
+
df_primitives, SEGMENT_SEQUENCE_CONFIG=SEGMENT_SEQUENCE_CONFIG
|
|
66
|
+
)
|
|
67
|
+
st.log("computed segments and sequences")
|
|
68
|
+
|
|
69
|
+
if neware_bool:
|
|
70
|
+
# Step 3: Filter discharge blocks
|
|
71
|
+
rules = [
|
|
72
|
+
"CC_Discharge_after_CC_Charge",
|
|
73
|
+
"CC_Discharge_after_CCCV_Charge",
|
|
74
|
+
"CC_Discharge_after_CV_Charge",
|
|
75
|
+
"CC_Discharge_after_CCCV_Charge_with_Pause",
|
|
76
|
+
"CC_Discharge_after_CC_Charge_with_Pause",
|
|
77
|
+
"CC_Discharge_after_CV_Charge_with_Pause",
|
|
78
|
+
]
|
|
79
|
+
with StepTimer(verbose) as st:
|
|
80
|
+
dfs_per_block, df_filtered = filter_and_split_df_by_blocks(
|
|
81
|
+
df_segments_and_sequences=df_segments_and_sequences,
|
|
82
|
+
df_primitives=df_primitives,
|
|
83
|
+
rules=rules,
|
|
84
|
+
combine_op="or",
|
|
85
|
+
also_return_filtered_df=True,
|
|
86
|
+
)
|
|
87
|
+
st.log("filtered initial discharge blocks")
|
|
88
|
+
|
|
89
|
+
rules = ["CC_Discharge"]
|
|
90
|
+
with StepTimer(verbose) as st:
|
|
91
|
+
dfs_per_block, df_filtered = filter_and_split_df_by_blocks(
|
|
92
|
+
df_segments_and_sequences=df_segments_and_sequences,
|
|
93
|
+
df_primitives=df_primitives,
|
|
94
|
+
rules=rules,
|
|
95
|
+
combine_op="or",
|
|
96
|
+
also_return_filtered_df=True,
|
|
97
|
+
)
|
|
98
|
+
st.log("filtered CC_Discharge blocks")
|
|
99
|
+
|
|
100
|
+
discharge_dfs = []
|
|
101
|
+
# search for the blocks with a full discharge (from max to min)
|
|
102
|
+
for i, block in enumerate(dfs_per_block):
|
|
103
|
+
avg_current = block["Current[A]"].mean()
|
|
104
|
+
voltage_range = (block["Voltage[V]"].max(), block["Voltage[V]"].min())
|
|
105
|
+
|
|
106
|
+
if not (minimal_current < avg_current < maximal_current) or not (
|
|
107
|
+
voltage_range[0] >= max_voltage * (1 - voltage_intervall)
|
|
108
|
+
and voltage_range[1] <= min_voltage * (1 + voltage_intervall)
|
|
109
|
+
):
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
block = block.copy()
|
|
113
|
+
time_diff = block["Test_Time[s]"].diff() / 3600
|
|
114
|
+
block["Capacity[Ah]"] = (block["Current[A]"] * time_diff).cumsum()
|
|
115
|
+
|
|
116
|
+
if len(block) > 0:
|
|
117
|
+
time_seconds = block["Test_Time[s]"].values
|
|
118
|
+
current = block["Current[A]"].values
|
|
119
|
+
with StepTimer(verbose) as st:
|
|
120
|
+
capacity_ah = integrate.cumulative_trapezoid(abs(current), time_seconds, initial=0) / 3600
|
|
121
|
+
st.log(f"computed cumulative capacity for block {i}")
|
|
122
|
+
block["Capacity[Ah]"] = float("NaN")
|
|
123
|
+
block.loc[block.index[-1], "Capacity[Ah]"] = capacity_ah[-1]
|
|
124
|
+
|
|
125
|
+
discharge_dfs.append(block)
|
|
126
|
+
|
|
127
|
+
if len(discharge_dfs) > 0:
|
|
128
|
+
for block in discharge_dfs:
|
|
129
|
+
df_mod.loc[block.index[0] : block.index[-1], "Capacity[Ah]"] = block["Capacity[Ah]"]
|
|
130
|
+
else:
|
|
131
|
+
logging.info("No valid discharge blocks found, returning DataFrame with Capacity as nans")
|
|
132
|
+
df_mod["Capacity[Ah]"] = np.full(len(df_mod), np.nan, dtype=np.float64)
|
|
133
|
+
|
|
134
|
+
return df_mod
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def add_charge_throughput(
|
|
138
|
+
df: pd.DataFrame,
|
|
139
|
+
calculate_tests_individually: bool = False,
|
|
140
|
+
verbose: bool = True,
|
|
141
|
+
) -> pd.DataFrame:
|
|
142
|
+
"""
|
|
143
|
+
Calculate charge throughput and absolute charge throughput from a given DataFrame.
|
|
144
|
+
|
|
145
|
+
Parameters:
|
|
146
|
+
df (pandas.DataFrame): Input DataFrame containing 'Test_Time[s]' and 'Current[A]' columns
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
pandas.DataFrame: DataFrame with added 'ChargeThroughput[Ah]' and 'AbsoluteChargeThroughput[Ah]' columns
|
|
150
|
+
|
|
151
|
+
Notes:
|
|
152
|
+
The 'ChargeThroughput[Ah]' column represents the cumulative charge (in Ah) with sign (i.e., loaded + / unloaded -)
|
|
153
|
+
The 'AbsoluteChargeThroughput[Ah]' column represents the cumulative absolute charge (in Ah)
|
|
154
|
+
"""
|
|
155
|
+
time_col = "Test_Time[s]"
|
|
156
|
+
current_col = "Current[A]"
|
|
157
|
+
testindex_col = "TestIndex"
|
|
158
|
+
|
|
159
|
+
# quick checks
|
|
160
|
+
if time_col not in df.columns or current_col not in df.columns:
|
|
161
|
+
raise KeyError(f"Missing required columns: {time_col}, {current_col}")
|
|
162
|
+
|
|
163
|
+
n = len(df)
|
|
164
|
+
if n == 0:
|
|
165
|
+
if verbose:
|
|
166
|
+
logging.info("Empty DataFrame, returning.")
|
|
167
|
+
return df.copy()
|
|
168
|
+
|
|
169
|
+
time = df[time_col].to_numpy(dtype=float)
|
|
170
|
+
current = df[current_col].to_numpy(dtype=float)
|
|
171
|
+
|
|
172
|
+
charge_throughput = np.full(n, np.nan, dtype=float)
|
|
173
|
+
abs_charge_throughput = np.full(n, np.nan, dtype=float)
|
|
174
|
+
|
|
175
|
+
def _process_slice(start_slice: int, end_slice: int):
|
|
176
|
+
slice_time = time[start_slice:end_slice]
|
|
177
|
+
slice_current = current[start_slice:end_slice]
|
|
178
|
+
|
|
179
|
+
mask = ~np.isnan(slice_time) & ~np.isnan(slice_current)
|
|
180
|
+
if np.sum(mask) <= 1:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
charge = integrate.cumulative_trapezoid(slice_current[mask], slice_time[mask], initial=0) / 3600.0
|
|
184
|
+
abs_charge = integrate.cumulative_trapezoid(np.abs(slice_current[mask]), slice_time[mask], initial=0) / 3600.0
|
|
185
|
+
|
|
186
|
+
valid_indices = np.nonzero(mask)[0] + start_slice
|
|
187
|
+
charge_throughput[valid_indices] = charge
|
|
188
|
+
abs_charge_throughput[valid_indices] = abs_charge
|
|
189
|
+
|
|
190
|
+
with StepTimer(verbose) as st:
|
|
191
|
+
if calculate_tests_individually and (testindex_col in df.columns):
|
|
192
|
+
testvalues = df[testindex_col].to_numpy()
|
|
193
|
+
starts = np.nonzero(np.concatenate(([True], testvalues[1:] != testvalues[:-1])))[0]
|
|
194
|
+
starts = np.append(starts, n)
|
|
195
|
+
for i in range(len(starts) - 1):
|
|
196
|
+
start = int(starts[i])
|
|
197
|
+
end = int(starts[i + 1])
|
|
198
|
+
_process_slice(start, end)
|
|
199
|
+
st.log("Processed charge throughput per individual test blocks")
|
|
200
|
+
else:
|
|
201
|
+
_process_slice(0, n)
|
|
202
|
+
st.log("Processed charge throughput for whole DataFrame")
|
|
203
|
+
|
|
204
|
+
df_mod = df.copy()
|
|
205
|
+
df_mod["ChargeThroughput[Ah]"] = charge_throughput
|
|
206
|
+
df_mod["AbsoluteChargeThroughput[Ah]"] = abs_charge_throughput
|
|
207
|
+
|
|
208
|
+
return df_mod
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class BatteryConfig:
|
|
6
|
+
cell_name: str = "Default"
|
|
7
|
+
c_ref: float = None
|
|
8
|
+
soc_start: float = 0
|
|
9
|
+
max_voltage: float = 4.2
|
|
10
|
+
min_voltage: float = 2.5
|
|
11
|
+
threshold_current: float = 0.075
|
|
12
|
+
voltage_intervall: float = 0.01
|
|
13
|
+
# ----- values for capacity calculation -----
|
|
14
|
+
minimal_current_for_capacity: float = -1.2
|
|
15
|
+
maximal_current_for_capacity: float = -0.8
|
|
16
|
+
# ----- values for internal resistance calculation -----
|
|
17
|
+
min_current_diff: float = 1
|
|
18
|
+
max_time_diff: float = 0.5
|
|
19
|
+
min_voltage_diff: float = 0
|
|
20
|
+
# ignores negative values in internal resistance calculation,
|
|
21
|
+
# should only appear in Neware Cells because of a bug
|
|
22
|
+
ignore_negative_resistance_values: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
battery_config_default = BatteryConfig()
|
|
26
|
+
|
|
27
|
+
# Configs for different Cells
|
|
28
|
+
lgm50lt_nmc_4800 = BatteryConfig(
|
|
29
|
+
c_ref=4.8,
|
|
30
|
+
max_voltage=4.2,
|
|
31
|
+
min_voltage=2.5,
|
|
32
|
+
min_current_diff=1,
|
|
33
|
+
max_time_diff=0.5,
|
|
34
|
+
min_voltage_diff=0,
|
|
35
|
+
ignore_negative_resistance_values=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
hakadi_nmc_1500 = BatteryConfig(c_ref=1.5, max_voltage=3.6, min_voltage=2.0)
|