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,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,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__ = []
@@ -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)