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.
- steer_materials/Base.py +103 -1
- steer_materials/__init__.py +1 -5
- {steer_materials-0.1.15.dist-info → steer_materials-0.1.17.dist-info}/METADATA +2 -2
- steer_materials-0.1.17.dist-info/RECORD +6 -0
- steer_materials/CellMaterials/Base.py +0 -278
- steer_materials/CellMaterials/Electrode.py +0 -1188
- steer_materials/CellMaterials/__init__.py +0 -0
- steer_materials/Electrolytes/Electrolytes.py +0 -16
- steer_materials/Electrolytes/Ions.py +0 -0
- steer_materials/Electrolytes/Salts.py +0 -27
- steer_materials/Electrolytes/Solvents.py +0 -0
- steer_materials/Electrolytes/__init__.py +0 -0
- steer_materials-0.1.15.dist-info/RECORD +0 -14
- {steer_materials-0.1.15.dist-info → steer_materials-0.1.17.dist-info}/WHEEL +0 -0
- {steer_materials-0.1.15.dist-info → steer_materials-0.1.17.dist-info}/top_level.txt +0 -0
|
@@ -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
|