steer-materials 0.1.15__py3-none-any.whl → 0.1.17__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.
@@ -1,1188 +0,0 @@
1
- from steer_core.DataManager import DataManager
2
- from steer_core.Constants.Units import *
3
- from steer_core.Decorators.Electrochemical import calculate_half_cell_curves_properties
4
- from steer_core.Mixins.Data import DataMixin
5
- from steer_core.Mixins.Serializer import SerializerMixin
6
-
7
- from steer_materials.Base import _Material
8
-
9
- import pandas as pd
10
- import numpy as np
11
- import plotly.express as px
12
- from plotly import graph_objects as go
13
- from typing import List, Tuple, Union, Optional
14
- from copy import deepcopy
15
-
16
-
17
- class _ActiveMaterial(_Material, DataMixin):
18
-
19
- def __init__(
20
- self,
21
- name: str,
22
- reference: str,
23
- specific_cost: float,
24
- density: float,
25
- half_cell_curves: Union[List[pd.DataFrame], pd.DataFrame],
26
- color: Optional[str] = "#2c2c2c",
27
- voltage_cutoff: Optional[float] = None,
28
- extrapolation_window: Optional[float] = 0.4,
29
- reversible_capacity_scaling: Optional[float] = 1.0,
30
- irreversible_capacity_scaling: Optional[float] = 1.0,
31
- ) -> None:
32
- """
33
- Initialize an object that represents an active material.
34
-
35
- Parameters
36
- ----------
37
- name : str
38
- Name of the material.
39
- reference : str
40
- Reference electrode for the material, e.g., 'Li/Li+'.
41
- specific_cost : float
42
- Specific cost of the material in $/kg.
43
- density : float
44
- Density of the material in g/cm^3.
45
- half_cell_curves : Union[List[pd.DataFrame], pd.DataFrame]
46
- Half cell curves for the material, either as a list of pandas DataFrames or a single DataFrame.
47
- color : str
48
- Color of the material, used for plotting.
49
- voltage_cutoff : Optional[float]
50
- The voltage cutoff for the half cell curves in V. This is the maximum voltage (for CathodeMaterial) or minimum voltage (for AnodeMaterial)
51
- at which the half cell curve will be calculated.
52
- extrapolation_window : Optional[float]
53
- The extrapolation window in V. This is the amount of voltage below the maximum voltage (for CathodeMaterial) or above the minimum voltage (for AnodeMaterial)
54
- of the half cell curves that will be used for extrapolation. This allows for estimation of voltage profiles over a voltage window
55
- reversible_capacity_scaling : Optional[float]
56
- Scaling factor for the reversible capacity of the material. Default is 1.0 (no scaling).
57
- irreversible_capacity_scaling : Optional[float]
58
- Scaling factor for the irreversible capacity of the material. Default is 1.0 (no scaling).
59
- """
60
- super().__init__(
61
- name=name, density=density, specific_cost=specific_cost, color=color
62
- )
63
-
64
- self._update_properties = False
65
-
66
- self.reference = reference
67
- self.extrapolation_window = extrapolation_window
68
- self.half_cell_curves = half_cell_curves
69
- self.voltage_cutoff = voltage_cutoff
70
- self.reversible_capacity_scaling = reversible_capacity_scaling
71
- self.irreversible_capacity_scaling = irreversible_capacity_scaling
72
-
73
- self._update_properties = True
74
-
75
- def _calculate_all_properties(self) -> None:
76
- self._calculate_half_cell_curves_properties()
77
-
78
- def _correct_curve_directions(self, curve: np.ndarray) -> np.ndarray:
79
- """
80
- Function to check the directions of the charge and discharge curves in the half cell data.
81
- It will ensure that the charge curve has a greater specific capacity range than the discharge curve.
82
-
83
- Parameters
84
- ----------
85
- curve : np.ndarray
86
- The half cell curve data as a numpy array with columns for specific capacity, voltage, and direction.
87
- The direction is represented as 1 for charge and -1 for discharge.
88
- """
89
- # select out the charge and discharge curves
90
- charge_curve = curve[curve[:, 2] == 1]
91
- discharge_curve = curve[curve[:, 2] == -1]
92
-
93
- # check the specific capacity ranges of the curves and make sure the charge curve has the greater range
94
- charge_specific_capacity_range = (
95
- charge_curve[:, 0].max() - charge_curve[:, 0].min()
96
- )
97
- discharge_specific_capacity_range = (
98
- discharge_curve[:, 0].max() - discharge_curve[:, 0].min()
99
- )
100
-
101
- if charge_specific_capacity_range > discharge_specific_capacity_range:
102
- return curve
103
- else:
104
- # swap the charge and discharge curves
105
- charge_curve[:, 2] = -1
106
- discharge_curve[:, 2] = 1
107
- return np.concatenate([discharge_curve, charge_curve], axis=0)
108
-
109
- def _reverse_discharge_curve(self, curve: np.ndarray) -> np.ndarray:
110
- """
111
- Function to reverse the discharge curve in the half cell data. It will ensure that the discharge curve is shifted to the right
112
-
113
- Parameters
114
- ----------
115
- curve : np.ndarray
116
- The half cell curve data as a numpy array with columns for specific capacity, voltage, and direction.
117
- The direction is represented as 1 for charge and -1 for discharge.
118
- """
119
- max_spec_cap = curve[:, 0].max()
120
- charge_curve = curve[curve[:, 2] == 1].copy()
121
- discharge_curve = curve[curve[:, 2] == -1].copy()
122
- discharge_curve[:, 0] = -discharge_curve[:, 0] + max_spec_cap
123
- return np.concatenate([charge_curve, discharge_curve], axis=0)
124
-
125
- def _make_curve_monotonic(
126
- self,
127
- curve: np.ndarray,
128
- ) -> np.ndarray:
129
- """
130
- Make charge and discharge curves monotonic separately.
131
-
132
- Parameters
133
- ----------
134
- curve : np.ndarray
135
- Array with columns [specific_capacity, voltage, direction]
136
- where direction: 1 = charge, -1 = discharge
137
- """
138
- charge_curve = curve[curve[:, 2] == 1].copy()
139
- discharge_curve = curve[curve[:, 2] == -1].copy()
140
-
141
- # Enforce monotonicity on both capacity and voltage
142
- charge_curve[:, 0] = self.enforce_monotonicity(charge_curve[:, 0])
143
- charge_curve[:, 1] = self.enforce_monotonicity(charge_curve[:, 1])
144
-
145
- # Enforce monotonicity
146
- discharge_curve[:, 0] = self.enforce_monotonicity(discharge_curve[:, 0])
147
- discharge_curve[:, 1] = self.enforce_monotonicity(discharge_curve[:, 1])
148
-
149
- return np.concatenate([charge_curve, discharge_curve], axis=0)
150
-
151
- def _add_point_to_discharge_curve(self, curve: np.ndarray) -> np.ndarray:
152
- """
153
- Function to add the last point of the charge curve to the discharge curve.
154
- """
155
- charge_curve = curve[curve[:, 2] == 1].copy()
156
- discharge_curve = curve[curve[:, 2] == -1].copy()
157
- last_charge_point = charge_curve[-1, :].copy()
158
- last_charge_point[2] = -1 # Change direction to discharge
159
- discharge_curve = np.vstack([last_charge_point, discharge_curve])
160
-
161
- return np.concatenate([charge_curve, discharge_curve], axis=0)
162
-
163
- def _process_half_cell_curves(self, half_cell_curves: np.ndarray) -> np.ndarray:
164
- """
165
- Function to process the half cell curves. It will calculate the voltage and specific capacity maximums and then reflect and shift the curves if
166
- the specific capacity at the minimum voltage is greater than the specific capacity at the maximum voltage. It will store these processed curves
167
- in the `_half_cell_curves` attribute. This contains all the experimental input data for the half cell curves.
168
-
169
- Parameters
170
- ----------
171
- half_cell_curves : Union[List[pd.DataFrame], pd.DataFrame]
172
- Half cell curves for the material, either as a list of pandas DataFrames or a single DataFrame.
173
- """
174
- new_half_cells_curves = []
175
-
176
- for curve in half_cell_curves:
177
- curve[:, 0] = curve[:, 0] * (H_TO_S * mA_TO_A / G_TO_KG)
178
- curve = self._correct_curve_directions(curve)
179
- curve = self._make_curve_monotonic(curve)
180
- curve = self._reverse_discharge_curve(curve)
181
- curve = self._add_point_to_discharge_curve(curve)
182
- new_half_cells_curves.append(curve)
183
-
184
- self._half_cell_curves = new_half_cells_curves
185
-
186
- def _apply_reversible_capacity_scaling(self, scaling: float):
187
- """
188
- Apply scaling to the reversible capacity of the half cell curves.
189
-
190
- Parameters
191
- ----------
192
- scaling : float
193
- Scaling factor for the reversible capacity. This is applied to the specific capacity of the discharge curves.
194
- """
195
- data = self._half_cell_curve.copy()
196
- charge = data[data[:, 2] == 1]
197
- discharge = data[data[:, 2] == -1]
198
- maximum_specific_capacity = data[:, 0].max()
199
- discharge[:, 0] = (
200
- scaling * (discharge[:, 0] - maximum_specific_capacity)
201
- + maximum_specific_capacity
202
- )
203
- self._half_cell_curve = np.concatenate([charge, discharge], axis=0)
204
-
205
- def _apply_irreversible_capacity_scaling(self, scaling: float):
206
- """
207
- Apply scaling to the irreversible capacity of the half cell curves.
208
-
209
- Parameters
210
- ----------
211
- scaling : float
212
- Scaling factor for the irreversible capacity. This is applied to the specific capacity of the discharge curves.
213
- """
214
- data = self._half_cell_curve.copy()
215
- data[:, 0] = data[:, 0] * scaling
216
- self._half_cell_curve = data
217
-
218
- def _interpolate_curve_on_maximum_voltage(
219
- self,
220
- input_value: float,
221
- below_curve: np.ndarray,
222
- above_curve: np.ndarray,
223
- below_voltage_max: float,
224
- above_voltage_max: float,
225
- ) -> np.ndarray:
226
- """
227
- Numpy version of interpolating between two curves at a target max voltage.
228
- Handles both charge (increasing) and discharge (decreasing) curves.
229
-
230
- Parameters
231
- ----------
232
- input_value : float
233
- The target maximum voltage to interpolate between curves.
234
- below_curve : np.ndarray
235
- The curve with the maximum voltage below the target.
236
- above_curve : np.ndarray
237
- The curve with the maximum voltage above the target.
238
- below_voltage_max : float
239
- Maximum voltage of the below curve.
240
- above_voltage_max : float
241
- Maximum voltage of the above curve.
242
-
243
- Returns
244
- -------
245
- np.ndarray
246
- Array containing the interpolated curve with columns [specific_capacity, voltage, direction].
247
- """
248
- n_points = 100
249
-
250
- # Check if this is a discharge curve (direction = -1)
251
- is_discharge = (
252
- below_curve[0, 2] == -1 if len(below_curve) > 0 else above_curve[0, 2] == -1
253
- )
254
-
255
- if is_discharge:
256
- # For discharge curves, both capacity and voltage decrease
257
- # Sort in descending order and flip for interpolation
258
- below_sorted = below_curve[
259
- np.argsort(below_curve[:, 0])[::-1]
260
- ] # descending capacity
261
- above_sorted = above_curve[
262
- np.argsort(above_curve[:, 0])[::-1]
263
- ] # descending capacity
264
-
265
- # Create grids (still from min to max for interpolation)
266
- sc_grid_low = np.linspace(
267
- below_sorted[:, 0].min(), below_sorted[:, 0].max(), n_points
268
- )
269
-
270
- sc_grid_high = np.linspace(
271
- above_sorted[:, 0].min(), above_sorted[:, 0].max(), n_points
272
- )
273
-
274
- # Interpolate voltages - flip arrays so they're increasing for np.interp
275
- v_low_interp = np.interp(
276
- sc_grid_low,
277
- below_sorted[:, 0][::-1], # flip to ascending order
278
- below_sorted[:, 1][::-1], # flip corresponding voltages
279
- )
280
-
281
- v_high_interp = np.interp(
282
- sc_grid_high,
283
- above_sorted[:, 0][::-1], # flip to ascending order
284
- above_sorted[:, 1][::-1], # flip corresponding voltages
285
- )
286
-
287
- else:
288
- # For charge curves, both capacity and voltage increase
289
- below_sorted = below_curve[
290
- np.argsort(below_curve[:, 0])
291
- ] # ascending capacity
292
- above_sorted = above_curve[
293
- np.argsort(above_curve[:, 0])
294
- ] # ascending capacity
295
-
296
- # Create grids for interpolation
297
- sc_grid_low = np.linspace(
298
- below_sorted[:, 0].min(), below_sorted[:, 0].max(), n_points
299
- )
300
-
301
- sc_grid_high = np.linspace(
302
- above_sorted[:, 0].min(), above_sorted[:, 0].max(), n_points
303
- )
304
-
305
- # Interpolate voltages (arrays already in ascending order)
306
- v_low_interp = np.interp(
307
- sc_grid_low,
308
- below_sorted[:, 0], # specific capacity
309
- below_sorted[:, 1], # voltage
310
- )
311
-
312
- v_high_interp = np.interp(
313
- sc_grid_high,
314
- above_sorted[:, 0], # specific capacity
315
- above_sorted[:, 1], # voltage
316
- )
317
-
318
- # Calculate interpolation weights
319
- weight_low = (above_voltage_max - input_value) / (
320
- above_voltage_max - below_voltage_max
321
- )
322
- weight_high = (input_value - below_voltage_max) / (
323
- above_voltage_max - below_voltage_max
324
- )
325
-
326
- # Interpolate values
327
- c_values = sc_grid_low * weight_low + sc_grid_high * weight_high
328
- v_interp = v_low_interp * weight_low + v_high_interp * weight_high
329
-
330
- # Get direction from the first curve (assuming both have same direction)
331
- direction = below_curve[0, 2] if len(below_curve) > 0 else above_curve[0, 2]
332
-
333
- # Create interpolated curve
334
- interpolated_curve = np.column_stack(
335
- [c_values, v_interp, np.full(n_points, direction)]
336
- )
337
-
338
- # If discharge curve, sort back to descending order to maintain consistency
339
- if is_discharge:
340
- interpolated_curve = interpolated_curve[
341
- np.argsort(interpolated_curve[:, 0])[::-1]
342
- ]
343
-
344
- return interpolated_curve
345
-
346
- def _interpolate_curve(self) -> np.ndarray:
347
- """
348
- Get the half cell curves interpolated on a maximum voltage using numpy arrays.
349
-
350
- Returns
351
- -------
352
- np.ndarray
353
- Interpolated curve with shape (n_points, 3) where columns are [specific_capacity, voltage, direction]
354
- """
355
- # Calculate voltage at max capacity for each curve
356
- voltages_at_max_capacity = []
357
- for curve in self._half_cell_curves:
358
- max_capacity_idx = np.argmax(curve[:, 0])
359
- voltage_at_max_capacity = curve[max_capacity_idx, 1]
360
- voltages_at_max_capacity.append(voltage_at_max_capacity)
361
-
362
- voltages_at_max_capacity = np.array(voltages_at_max_capacity)
363
-
364
- # Get the closest curve below the voltage cutoff
365
- below_mask = voltages_at_max_capacity <= self._voltage_cutoff
366
- below_voltages = voltages_at_max_capacity[below_mask]
367
- max_below_voltage = np.max(below_voltages)
368
- below_curve_idx = np.where(voltages_at_max_capacity == max_below_voltage)[0][0]
369
- closest_below_curve = self._half_cell_curves[below_curve_idx]
370
-
371
- # Get the closest curve above the voltage cutoff
372
- above_mask = voltages_at_max_capacity >= self._voltage_cutoff
373
- above_voltages = voltages_at_max_capacity[above_mask]
374
- min_above_voltage = np.min(above_voltages)
375
- above_curve_idx = np.where(voltages_at_max_capacity == min_above_voltage)[0][0]
376
- closest_above_curve = self._half_cell_curves[above_curve_idx]
377
-
378
- # Split below curve into charge and discharge
379
- below_charge = closest_below_curve[closest_below_curve[:, 2] == 1]
380
- below_discharge = closest_below_curve[closest_below_curve[:, 2] == -1]
381
-
382
- # Split above curve into charge and discharge
383
- above_charge = closest_above_curve[closest_above_curve[:, 2] == 1]
384
- above_discharge = closest_above_curve[closest_above_curve[:, 2] == -1]
385
-
386
- # Interpolate charge curve
387
- charge_curve = self._interpolate_curve_on_maximum_voltage(
388
- self._voltage_cutoff,
389
- below_charge,
390
- above_charge,
391
- max_below_voltage,
392
- min_above_voltage,
393
- )
394
-
395
- # Interpolate discharge curve
396
- discharge_curve = self._interpolate_curve_on_maximum_voltage(
397
- self._voltage_cutoff,
398
- below_discharge,
399
- above_discharge,
400
- max_below_voltage,
401
- min_above_voltage,
402
- )
403
-
404
- # Sort discharge curve by specific capacity (descending)
405
- discharge_curve = discharge_curve[np.argsort(discharge_curve[:, 0])[::-1]]
406
-
407
- # Concatenate charge and discharge curves
408
- return np.vstack([charge_curve, discharge_curve])
409
-
410
- def _prepare_arrays_for_interp(self, x_array, y_array):
411
- """
412
- Prepare arrays for np.interp by ensuring they're monotonically increasing.
413
- If both arrays are decreasing, flip both. If they're in opposite directions,
414
- flip the y array to match x direction.
415
-
416
- Parameters
417
- ----------
418
- x_array : np.ndarray
419
- The x values (e.g., voltage)
420
- y_array : np.ndarray
421
- The y values (e.g., capacity)
422
-
423
- Returns
424
- -------
425
- tuple
426
- (x_sorted, y_sorted) both monotonically increasing
427
- """
428
- # Check monotonicity direction
429
- x_increasing = np.all(np.diff(x_array) >= 0) or np.mean(np.diff(x_array)) > 0
430
- y_increasing = np.all(np.diff(y_array) >= 0) or np.mean(np.diff(y_array)) > 0
431
-
432
- # If both are decreasing, flip both
433
- if not x_increasing and not y_increasing:
434
- return x_array[::-1], y_array[::-1]
435
-
436
- # If x is decreasing but y is increasing (or vice versa), flip x and sort accordingly
437
- elif not x_increasing:
438
- # Sort by x (ascending) and reorder y accordingly
439
- sort_idx = np.argsort(x_array)
440
- return x_array[sort_idx], y_array[sort_idx]
441
-
442
- # If x is increasing (normal case)
443
- else:
444
- return x_array, y_array
445
-
446
- def _truncate_and_shift_curves(self) -> np.ndarray:
447
- """Numpy-optimized version of truncate and shift curves."""
448
-
449
- # Get curve with minimum voltage at max capacity
450
- max_capacity_voltages = [
451
- curve[np.argmax(curve[:, 0]), 1] for curve in self._half_cell_curves
452
- ]
453
- min_voltage_curve = self._half_cell_curves[
454
- np.argmin(max_capacity_voltages)
455
- ].copy()
456
-
457
- # Split and process curves
458
- charge = min_voltage_curve[min_voltage_curve[:, 2] == 1]
459
- discharge = min_voltage_curve[min_voltage_curve[:, 2] == -1]
460
-
461
- # Prepare arrays for interpolation
462
- charge_v_sorted, charge_c_sorted = self._prepare_arrays_for_interp(
463
- charge[:, 1], charge[:, 0]
464
- )
465
- discharge_v_sorted, discharge_c_sorted = self._prepare_arrays_for_interp(
466
- discharge[:, 1], discharge[:, 0]
467
- )
468
-
469
- charge_cap_interp = np.interp(
470
- self._voltage_cutoff, charge_v_sorted, charge_c_sorted
471
- )
472
- discharge_cap_interp = np.interp(
473
- self._voltage_cutoff, discharge_v_sorted, discharge_c_sorted
474
- )
475
-
476
- # Add interpolated points and truncate
477
- charge_extended = np.vstack(
478
- [charge, [charge_cap_interp, self._voltage_cutoff, 1]]
479
- )
480
- discharge_extended = np.vstack(
481
- [[discharge_cap_interp, self._voltage_cutoff, -1], discharge]
482
- )
483
-
484
- # Truncate based on material type
485
- voltage_condition = (
486
- (lambda v: v <= self._voltage_cutoff)
487
- if type(self).__name__ == "CathodeMaterial"
488
- else (lambda v: v >= self._voltage_cutoff)
489
- )
490
-
491
- charge_final = charge_extended[voltage_condition(charge_extended[:, 1])]
492
- discharge_final = discharge_extended[
493
- voltage_condition(discharge_extended[:, 1])
494
- ]
495
-
496
- # Calculate and apply shift
497
- charge_at_voltage = charge_final[
498
- np.isclose(charge_final[:, 1], self._voltage_cutoff), 0
499
- ][0]
500
- discharge_at_voltage = discharge_final[
501
- np.isclose(discharge_final[:, 1], self._voltage_cutoff), 0
502
- ][0]
503
-
504
- discharge_final[:, 0] += charge_at_voltage - discharge_at_voltage
505
-
506
- return np.vstack([charge_final, discharge_final])
507
-
508
- def _calculate_half_cell_curve(self) -> pd.DataFrame:
509
- """
510
- Calculate the half cell curve based on the voltage cutoff and whether to extrapolate or truncate the curves.
511
-
512
- Returns
513
- -------
514
- pd.DataFrame
515
- A DataFrame containing the half cell curve with columns 'specific_capacity', 'voltage', and 'direction'.
516
- """
517
- # Calculate the cutoff voltages for each curve
518
- voltages_at_max_capacity = []
519
-
520
- for curve in self._half_cell_curves:
521
- max_capacity_idx = np.argmax(curve[:, 0])
522
- voltage_at_max_capacity = curve[max_capacity_idx, 1]
523
- voltages_at_max_capacity.append(voltage_at_max_capacity)
524
-
525
- # Round voltage cutoff for consistent comparison
526
- rounded_voltage_cutoff = round(self._voltage_cutoff, 5)
527
-
528
- # If the voltage cutoff corresponds to a particular curve, then return that curve
529
- if rounded_voltage_cutoff in [round(v, 5) for v in voltages_at_max_capacity]:
530
- curve_idx = voltages_at_max_capacity.index(self._voltage_cutoff)
531
- half_cell_curve = self._half_cell_curves[curve_idx].copy()
532
-
533
- # If the voltage is between the second and third float in operating voltage range, then interpolate between the two curves
534
- elif (
535
- round(min(self._voltage_operation_window[1:]), 5)
536
- <= rounded_voltage_cutoff
537
- <= round(max(self._voltage_operation_window[1:]), 5)
538
- ):
539
- half_cell_curve = self._interpolate_curve()
540
-
541
- # If the voltage cutoff is below the second float and above the first float in the operating voltage range, then interpolate between the two curves
542
- elif (
543
- round(min(self._voltage_operation_window[:2]), 5)
544
- <= rounded_voltage_cutoff
545
- <= round(max(self._voltage_operation_window[:2]), 5)
546
- ):
547
- half_cell_curve = self._truncate_and_shift_curves()
548
-
549
- else:
550
- raise ValueError(
551
- f"Voltage cutoff {self._voltage_cutoff} is not within the range of the half cell curves. "
552
- f"Valid range is {self._voltage_operation_window[0]} to {self._voltage_operation_window[2]}."
553
- )
554
-
555
- return half_cell_curve
556
-
557
- def _get_default_curve_from_curves(self) -> None:
558
- """
559
- Get the default half cell curve from the half cell curves.
560
-
561
- :return: pd.DataFrame: The default half cell curve.
562
- """
563
- half_cell_curves = self._half_cell_curves.copy()
564
-
565
- # get the maximum specific capacity for each half cell curve
566
- maximum_specific_capacities = []
567
- for hcc in half_cell_curves:
568
- maximum_specific_capacities.append(np.max(hcc[:, 0]))
569
-
570
- # get the index of the half cell curve with the maximum specific capacity
571
- max_index = np.argmax(maximum_specific_capacities)
572
-
573
- # get the half cell curve with the maximum specific capacity
574
- self._half_cell_curve = self._half_cell_curves[max_index].copy()
575
-
576
- # get the voltage at maximum specific capacity
577
- self._voltage_cutoff = self._half_cell_curve[
578
- self._half_cell_curve[:, 0] == np.max(self._half_cell_curve[:, 0]), 1
579
- ][0]
580
-
581
- def _get_maximum_operating_voltage(self) -> float:
582
- """
583
- Function to get the maximum operating voltage of the half cell curves.
584
- """
585
- max_voltages = []
586
-
587
- for curve in self._half_cell_curves:
588
- max_capacity_idx = np.argmax(curve[:, 0])
589
- voltage_at_max_capacity = curve[max_capacity_idx, 1]
590
- max_voltages.append(voltage_at_max_capacity)
591
-
592
- self._maximum_operating_voltage = np.max(max_voltages)
593
-
594
- def _get_minimum_operating_voltage(self) -> float:
595
- """
596
- Function to get the minimum operating voltage of the half cell curves without extrapolation.
597
- """
598
- max_voltages = []
599
-
600
- for curve in self._half_cell_curves:
601
- max_capacity_idx = np.argmax(curve[:, 0])
602
- voltage_at_max_capacity = curve[max_capacity_idx, 1]
603
- max_voltages.append(voltage_at_max_capacity)
604
-
605
- self._minimum_operating_voltage = np.min(max_voltages)
606
-
607
- def _get_operating_voltage_range(self) -> tuple:
608
- """
609
- Function to get the operating voltage range of the half cell curves.
610
-
611
- Returns
612
- -------
613
- tuple: A tuple containing the discharged operating voltage with extrapolation, the discharged operating voltage without extrapolation, and the charged operating voltage.
614
- """
615
- if type(self) == CathodeMaterial:
616
-
617
- self._voltage_operation_window = (
618
- self._minimum_operating_voltage - self._extrapolation_window,
619
- self._minimum_operating_voltage,
620
- self._maximum_operating_voltage,
621
- )
622
-
623
- elif type(self) == AnodeMaterial:
624
-
625
- self._voltage_operation_window = (
626
- self._minimum_operating_voltage + self._extrapolation_window,
627
- self._minimum_operating_voltage,
628
- self._maximum_operating_voltage,
629
- )
630
-
631
- def _calculate_half_cell_curves_properties(self):
632
- self._get_maximum_operating_voltage()
633
- self._get_minimum_operating_voltage()
634
- self._get_operating_voltage_range()
635
-
636
- def plot_curves(self, **kwargs):
637
-
638
- fig = px.line(
639
- self.half_cell_curves,
640
- x="Specific Capacity (mAh/g)",
641
- y="Voltage (V)",
642
- color="Voltage at Maximum Capacity (V)",
643
- line_shape="spline",
644
- )
645
-
646
- fig.update_layout(
647
- paper_bgcolor=kwargs.get("paper_bgcolor", "white"),
648
- plot_bgcolor=kwargs.get("plot_bgcolor", "white"),
649
- **kwargs,
650
- )
651
-
652
- return fig
653
-
654
- def plot_half_cell_curve(self, **kwargs):
655
-
656
- fig = px.line(
657
- self.half_cell_curve,
658
- x="Specific Capacity (mAh/g)",
659
- y="Voltage (V)",
660
- line_shape="spline",
661
- )
662
-
663
- fig.update_layout(
664
- paper_bgcolor=kwargs.get("paper_bgcolor", "white"),
665
- plot_bgcolor=kwargs.get("plot_bgcolor", "white"),
666
- **kwargs,
667
- )
668
-
669
- fig.update_traces(line=dict(color=self.color))
670
-
671
- return fig
672
-
673
- @property
674
- def voltage_cutoff(self) -> float:
675
- """
676
- Get the maximum voltage of the half cell curves.
677
- """
678
- return self._voltage_cutoff
679
-
680
- @property
681
- def voltage_cutoff_range(self) -> tuple:
682
- """
683
- Get the valid voltage range for the half cell curves.
684
-
685
- :return: tuple: (minimum voltage, maximum voltage)
686
- """
687
- return (
688
- round(float(self._voltage_operation_window[0]), 2),
689
- round(float(self._voltage_operation_window[2]), 2),
690
- )
691
-
692
- @property
693
- def extrapolation_window(self) -> float:
694
- """
695
- Get the extrapolation window for the half cell curves.
696
-
697
- :return: float: Extrapolation window in V.
698
- """
699
- return self._extrapolation_window
700
-
701
- @property
702
- def reference(self) -> str:
703
- """
704
- Get the reference electrode for the material.
705
-
706
- :return: str: Reference electrode for the material, e.g., 'Li/Li+'
707
- """
708
- return self._reference
709
-
710
- @property
711
- def half_cell_curve(self) -> pd.DataFrame:
712
-
713
- return (
714
- pd.DataFrame(
715
- self._half_cell_curve,
716
- columns=["specific_capacity", "voltage", "direction"],
717
- )
718
- .assign(
719
- direction=lambda x: np.where(
720
- x["direction"] == 1, "charge", "discharge"
721
- ),
722
- specific_capacity=lambda x: x["specific_capacity"]
723
- * (S_TO_H * A_TO_mA / KG_TO_G),
724
- )
725
- .rename(
726
- columns={
727
- "specific_capacity": "Specific Capacity (mAh/g)",
728
- "voltage": "Voltage (V)",
729
- "direction": "Direction",
730
- }
731
- )
732
- .round(4)
733
- )
734
-
735
- @property
736
- def half_cell_curves(self) -> pd.DataFrame:
737
-
738
- data_list = []
739
-
740
- for curve in self._half_cell_curves:
741
-
742
- df = (
743
- pd.DataFrame(
744
- curve, columns=["specific_capacity", "voltage", "direction"]
745
- )
746
- .assign(
747
- direction=lambda x: np.where(
748
- x["direction"] == 1, "charge", "discharge"
749
- ),
750
- specific_capacity=lambda x: x["specific_capacity"]
751
- * (S_TO_H * A_TO_mA / KG_TO_G),
752
- voltage_at_max_capacity=lambda x: x["voltage"].max(),
753
- )
754
- .rename(
755
- columns={
756
- "specific_capacity": "Specific Capacity (mAh/g)",
757
- "voltage": "Voltage (V)",
758
- "direction": "Direction",
759
- "voltage_at_max_capacity": "Voltage at Maximum Capacity (V)",
760
- }
761
- )
762
- .round(4)
763
- )
764
-
765
- data_list.append(df)
766
-
767
- return pd.concat(data_list, ignore_index=True)
768
-
769
- @property
770
- def irreversible_capacity_scaling(self) -> float:
771
- return self._irreversible_capacity_scaling
772
-
773
- @property
774
- def irreversible_capacity_scaling_range(self) -> Tuple:
775
- return 0.8, 1.1
776
-
777
- @property
778
- def irreversible_capacity_scaling_hard_range(self) -> Tuple:
779
- return 0, 2
780
-
781
- @property
782
- def reversible_capacity_scaling(self) -> float:
783
- return self._reversible_capacity_scaling
784
-
785
- @property
786
- def reversible_capacity_scaling_range(self) -> Tuple:
787
- return 0.8, 1.1
788
-
789
- @property
790
- def reversible_capacity_scaling_hard_range(self) -> Tuple:
791
- return 0, 2
792
-
793
- @property
794
- def half_cell_curve_trace(self) -> go.Scatter:
795
-
796
- return go.Scatter(
797
- x=self.half_cell_curve["Specific Capacity (mAh/g)"],
798
- y=self.half_cell_curve["Voltage (V)"],
799
- name=self.name,
800
- line=dict(color=self._color, width=2),
801
- mode="lines",
802
- hovertemplate="<b>%{fullData.name}</b><br>" + "Capacity: %{x:.2f} mAh/g<br>" + "Voltage: %{y:.3f} V<br>" + "<i>Individual Material</i><extra></extra>",
803
- )
804
-
805
- @reference.setter
806
- def reference(self, reference: str):
807
- self.validate_electrochemical_reference(reference)
808
- self._reference = reference
809
-
810
- @reversible_capacity_scaling.setter
811
- def reversible_capacity_scaling(self, scaling: float):
812
- """
813
- Set the reversible capacity scaling factor.
814
-
815
- :param scaling: float: scaling factor for reversible capacity
816
- """
817
- self.validate_positive_float(scaling, "Reversible capacity scaling")
818
-
819
- original_scaling = (
820
- self._reversible_capacity_scaling
821
- if hasattr(self, "_reversible_capacity_scaling")
822
- else 1.0
823
- )
824
- self._reversible_capacity_scaling = scaling
825
-
826
- # undo previous scaling to get original curve
827
- if original_scaling != 1:
828
- self._apply_reversible_capacity_scaling(1 / original_scaling)
829
-
830
- # apply the new scaling factor
831
- self._apply_reversible_capacity_scaling(self._reversible_capacity_scaling)
832
-
833
- @voltage_cutoff.setter
834
- def voltage_cutoff(self, voltage: float) -> None:
835
- """
836
- Set the voltage cutoff for the half cell curves.
837
-
838
- Parameters
839
- ----------
840
- voltage : float
841
- The voltage cutoff for the half cell curves in V. This is the maximum voltage at which the half cell curve will be calculated.
842
-
843
- Raises
844
- ------
845
- ValueError
846
- If the voltage cutoff is not a positive float, or if it is outside the valid voltage range for the half cell curves.
847
- ValueError
848
- If the voltage cutoff is less than the minimum extrapolated voltage or greater than the maximum voltage of the half cell curves.
849
- ValueError
850
- If the voltage cutoff is less than the minimum voltage of the half cell curves, which requires truncation and shifting of the curves.
851
- ValueError
852
- If the voltage cutoff is greater than the maximum voltage of the half cell curves, which requires interpolation of the curves.
853
- """
854
- # Check if the voltage is None, which means we want to use the default curve
855
- if voltage is None:
856
- self._get_default_curve_from_curves()
857
-
858
- # calculate the half cell curve based on the voltage cutoff and the available data
859
- else:
860
- self.validate_positive_float(voltage, "Voltage cutoff")
861
- self._voltage_cutoff = voltage
862
- self._half_cell_curve = self._calculate_half_cell_curve()
863
-
864
- if (
865
- hasattr(self, "_irreversible_capacity_scaling")
866
- and self._irreversible_capacity_scaling != 1.0
867
- ):
868
- self._apply_irreversible_capacity_scaling(
869
- self._irreversible_capacity_scaling
870
- )
871
-
872
- if (
873
- hasattr(self, "_reversible_capacity_scaling")
874
- and self._reversible_capacity_scaling != 1.0
875
- ):
876
- self._apply_reversible_capacity_scaling(
877
- self._reversible_capacity_scaling
878
- )
879
-
880
- @irreversible_capacity_scaling.setter
881
- def irreversible_capacity_scaling(self, scaling: float):
882
- """
883
- Set the irreversible capacity scaling factor.
884
-
885
- :param scaling: float: scaling factor for irreversible capacity
886
- """
887
- self.validate_positive_float(scaling, "Irreversible capacity scaling")
888
- original_scaling = (
889
- self._irreversible_capacity_scaling
890
- if hasattr(self, "_irreversible_capacity_scaling")
891
- else 1.0
892
- )
893
- self._irreversible_capacity_scaling = float(scaling)
894
-
895
- # undo previous scaling to get original curve
896
- if original_scaling != 1:
897
- self._apply_irreversible_capacity_scaling(1 / original_scaling)
898
-
899
- # apply the new scaling factor
900
- self._apply_irreversible_capacity_scaling(self._irreversible_capacity_scaling)
901
-
902
- @half_cell_curves.setter
903
- def half_cell_curves(
904
- self, half_cell_curves: Union[List[pd.DataFrame], pd.DataFrame]
905
- ) -> None:
906
-
907
- half_cell_curves = deepcopy(half_cell_curves)
908
-
909
- if not isinstance(half_cell_curves, List):
910
- half_cell_curves = [half_cell_curves]
911
-
912
- for df in half_cell_curves:
913
- self.validate_pandas_dataframe(
914
- df,
915
- "half cell curves",
916
- column_names=["specific_capacity", "voltage", "direction"],
917
- )
918
-
919
- # map the direction values to integers for faster processing
920
- direction_map = {"charge": 1, "discharge": -1}
921
- for df in half_cell_curves:
922
- df["direction"] = df["direction"].map(direction_map)
923
-
924
- # Then convert to array
925
- array_list = [
926
- df[["specific_capacity", "voltage", "direction"]].to_numpy()
927
- for df in half_cell_curves
928
- ]
929
-
930
- # Apply additional processing to the half cell curves
931
- self._process_half_cell_curves(array_list)
932
-
933
- # Store useful values for the half cell curves
934
- self._calculate_half_cell_curves_properties()
935
-
936
- @extrapolation_window.setter
937
- @calculate_half_cell_curves_properties
938
- def extrapolation_window(self, window: float):
939
- self.validate_positive_float(window, "Extrapolation window")
940
- self._extrapolation_window = abs(float(window))
941
-
942
-
943
- class CathodeMaterial(_ActiveMaterial):
944
-
945
- def __init__(
946
- self,
947
- name: str,
948
- reference: str,
949
- specific_cost: float,
950
- density: float,
951
- half_cell_curves: Union[List[pd.DataFrame], pd.DataFrame],
952
- color: str = "#2c2c2c",
953
- voltage_cutoff: float = None,
954
- extrapolation_window: float = 0.4,
955
- reversible_capacity_scaling: float = 1.0,
956
- irreversible_capacity_scaling: float = 1.0,
957
- ):
958
- """
959
- Initialize an object that represents a cathode material.
960
-
961
- Parameters
962
- ----------
963
- name : str
964
- Name of the material.
965
- reference : str
966
- Reference electrode for the material, e.g., 'Li/Li+'.
967
- specific_cost : float
968
- Specific cost of the material in $/kg.
969
- density : float
970
- Density of the material in g/cm^3.
971
- half_cell_curves : Union[List[pd.DataFrame], pd.DataFrame]
972
- Half cell curves for the material, either as a list of pandas DataFrames or a single DataFrame.
973
- voltage_cutoff : float
974
- The voltage cutoff for the half cell curves in V. This is the maximum voltage at which the half cell curve will be calculated.
975
- extrapolation_window : float
976
- The negative voltage extrapolation window in V. This is the amount of voltage below the minimum voltage of the half cell curves that will be used for extrapolation.
977
- This is useful for cathode materials where the voltage can go below 0V, e.g., for Li-ion batteries.
978
- color : str
979
- Color of the material, used for plotting.
980
- reversible_capacity_scaling : float
981
- Scaling factor for the reversible capacity of the material. Default is 1.0 (no scaling).
982
- irreversible_capacity_scaling : float
983
- Scaling factor for the irreversible capacity of the material. Default is 1.0 (no scaling).
984
- """
985
- super().__init__(
986
- name=name,
987
- reference=reference,
988
- specific_cost=specific_cost,
989
- density=density,
990
- half_cell_curves=half_cell_curves,
991
- color=color,
992
- extrapolation_window=extrapolation_window,
993
- voltage_cutoff=voltage_cutoff,
994
- reversible_capacity_scaling=reversible_capacity_scaling,
995
- irreversible_capacity_scaling=irreversible_capacity_scaling,
996
- )
997
-
998
- @staticmethod
999
- def from_database(name) -> "CathodeMaterial":
1000
- """
1001
- Pull object from the database.
1002
-
1003
- :param name: str: Name of the current collector material.
1004
- :return: CurrentCollectorMaterial: Instance of the class.
1005
- """
1006
- database = DataManager()
1007
-
1008
- available_materials = database.get_unique_values("cathode_materials", "name")
1009
-
1010
- if name not in available_materials:
1011
- raise ValueError(
1012
- f"Material '{name}' not found in the database. Available materials: {available_materials}"
1013
- )
1014
-
1015
- data = database.get_cathode_materials(most_recent=True).query(
1016
- f"name == '{name}'"
1017
- )
1018
- string_rep = data["object"].iloc[0]
1019
- material = SerializerMixin.deserialize(string_rep)
1020
- return material
1021
-
1022
- @property
1023
- def minimum_extrapolated_voltage(self) -> float:
1024
- """
1025
- Get the minimum extrapolated voltage for the half cell curves.
1026
-
1027
- :return: float: minimum extrapolated voltage of the half cell curves
1028
- """
1029
- try:
1030
- return float(round(self._minimum_extrapolated_voltage, 2))
1031
- except Exception:
1032
- return None
1033
-
1034
-
1035
- class AnodeMaterial(_ActiveMaterial):
1036
-
1037
- def __init__(
1038
- self,
1039
- name: str,
1040
- reference: str,
1041
- specific_cost: float,
1042
- density: float,
1043
- half_cell_curves: Union[List[pd.DataFrame], pd.DataFrame],
1044
- color: str = "#2c2c2c",
1045
- extrapolation_window: float = 0.05,
1046
- voltage_cutoff: float = None,
1047
- reversible_capacity_scaling: float = 1.0,
1048
- irreversible_capacity_scaling: float = 1.0,
1049
- ):
1050
- """
1051
- Initialize an object that represents an anode material.
1052
-
1053
- Parameters
1054
- ----------
1055
- name : str
1056
- Name of the material.
1057
- reference : str
1058
- Reference electrode for the material, e.g., 'Li/Li+'.
1059
- specific_cost : float
1060
- Specific cost of the material in $/kg.
1061
- density : float
1062
- Density of the material in g/cm^3.
1063
- half_cell_curves : Union[List[pd.DataFrame], pd.DataFrame]
1064
- Half cell curves for the material, either as a list of pandas DataFrames or a single DataFrame.
1065
- color : str
1066
- Color of the material, used for plotting.
1067
- voltage_cutoff : float
1068
- The voltage cutoff for the half cell curves in V. This is the minimum voltage at which the half cell curve will be calculated.
1069
- extrapolation_window : float
1070
- The positive voltage extrapolation window in V. This is the amount of voltage above the maximum voltage of the half cell curves that will be used for extrapolation.
1071
- This is useful for anode materials where the voltage can go above 0V, e.g., for Li-ion batteries.
1072
- reversible_capacity_scaling : float
1073
- Scaling factor for the reversible capacity of the material. Default is 1.0 (no scaling).
1074
- irreversible_capacity_scaling : float
1075
- Scaling factor for the irreversible capacity of the material. Default is 1.0 (no scaling).
1076
- """
1077
- super().__init__(
1078
- name=name,
1079
- reference=reference,
1080
- specific_cost=specific_cost,
1081
- density=density,
1082
- half_cell_curves=half_cell_curves,
1083
- color=color,
1084
- voltage_cutoff=voltage_cutoff,
1085
- extrapolation_window=extrapolation_window,
1086
- reversible_capacity_scaling=reversible_capacity_scaling,
1087
- irreversible_capacity_scaling=irreversible_capacity_scaling,
1088
- )
1089
-
1090
- @staticmethod
1091
- def from_database(name) -> "AnodeMaterial":
1092
- """
1093
- Pull object from the database.
1094
-
1095
- :param name: str: Name of the current collector material.
1096
- :return: CurrentCollectorMaterial: Instance of the class.
1097
- """
1098
- database = DataManager()
1099
-
1100
- available_materials = database.get_unique_values("anode_materials", "name")
1101
-
1102
- if name not in available_materials:
1103
- raise ValueError(
1104
- f"Material '{name}' not found in the database. Available materials: {available_materials}"
1105
- )
1106
-
1107
- data = database.get_anode_materials(most_recent=True).query(f"name == '{name}'")
1108
- string_rep = data["object"].iloc[0]
1109
- material = SerializerMixin.deserialize(string_rep)
1110
- return material
1111
-
1112
-
1113
- class Binder(_Material):
1114
-
1115
- def __init__(
1116
- self, name: str, specific_cost: float, density: float, color: str = "#2c2c2c"
1117
- ):
1118
- """
1119
- Initialize an object that represents a binder.
1120
-
1121
- :param name: str: name of the material
1122
- :param specific_cost: float: specific cost of the material per kg
1123
- :param density: float: density of the material in g/cm^3 (default: 1.7)
1124
- """
1125
- super().__init__(
1126
- name=name, density=density, specific_cost=specific_cost, color=color
1127
- )
1128
-
1129
- @staticmethod
1130
- def from_database(name) -> "Binder":
1131
- """
1132
- Pull object from the database.
1133
-
1134
- :param name: str: Name of the binder material.
1135
- :return: Binder: Instance of the class.
1136
- """
1137
- database = DataManager()
1138
-
1139
- available_materials = database.get_unique_values("binder_materials", "name")
1140
-
1141
- if name not in available_materials:
1142
- raise ValueError(
1143
- f"Material '{name}' not found in the database. Available materials: {available_materials}"
1144
- )
1145
-
1146
- data = database.get_binder_materials(most_recent=True).query(
1147
- f"name == '{name}'"
1148
- )
1149
- string_rep = data["object"].iloc[0]
1150
- material = SerializerMixin.deserialize(string_rep)
1151
- return material
1152
-
1153
-
1154
- class ConductiveAdditive(_Material):
1155
-
1156
- def __init__(
1157
- self, name: str, specific_cost: float, density: float, color: str = "#2c2c2c"
1158
- ):
1159
-
1160
- super().__init__(
1161
- name=name, density=density, specific_cost=specific_cost, color=color
1162
- )
1163
-
1164
- @staticmethod
1165
- def from_database(name) -> "ConductiveAdditive":
1166
- """
1167
- Pull object from the database.
1168
-
1169
- :param name: str: Name of the conductive additive material.
1170
- :return: ConductiveAdditive: Instance of the class.
1171
- """
1172
- database = DataManager()
1173
-
1174
- available_materials = database.get_unique_values(
1175
- "conductive_additive_materials", "name"
1176
- )
1177
-
1178
- if name not in available_materials:
1179
- raise ValueError(
1180
- f"Material '{name}' not found in the database. Available materials: {available_materials}"
1181
- )
1182
-
1183
- data = database.get_conductive_additive_materials(most_recent=True).query(
1184
- f"name == '{name}'"
1185
- )
1186
- string_rep = data["object"].iloc[0]
1187
- material = SerializerMixin.deserialize(string_rep)
1188
- return material