from typing import Callable, Optional
import numpy as np
import pybop
from pybop import BaseApplication
[docs]
class OCPMerge(BaseApplication):
"""
Generate a representative open-circuit potential (OCP) without hysteresis by
merging the provided charge and discharge branches of the OCP.
Parameters
----------
ocp_discharge : pybop.Dataset
A dataset containing the "Stoichiometry" and "Voltage [V]" obtained from a
discharge measurement.
ocp_charge : pybop.Dataset
A dataset containing the "Stoichiometry" and "Voltage [V]" obtained from a
charge measurement.
n_sto_points : int, optional
The number of points in stoichiometry at which to calculate the voltage.
"""
def __init__(
self,
ocp_discharge: pybop.Dataset,
ocp_charge: pybop.Dataset,
n_sto_points: int = 101,
):
[docs]
self.ocp_discharge = ocp_discharge
[docs]
self.ocp_charge = ocp_charge
[docs]
self.n_sto_points = n_sto_points
[docs]
def __call__(self) -> pybop.Dataset:
# Use the discharge branch as the target to fit
voltage_discharge = pybop.Interpolant(
self.ocp_discharge["Stoichiometry"], self.ocp_discharge["Voltage [V]"]
)
# Use the charge branch as the model output
voltage_charge = pybop.Interpolant(
self.ocp_charge["Stoichiometry"], self.ocp_charge["Voltage [V]"]
)
if np.sign(
self.ocp_charge["Stoichiometry"][-1] - self.ocp_charge["Stoichiometry"][0]
) == np.sign(
self.ocp_charge["Voltage [V]"][-1] - self.ocp_charge["Voltage [V]"][0]
):
# Increasing stoichiometry corresponds to increasing voltage (full cell)
sto_min = np.min(self.ocp_charge["Stoichiometry"])
sto_max = np.max(self.ocp_discharge["Stoichiometry"])
low_sto_fit = voltage_charge
high_sto_fit = voltage_discharge
else:
# Decreasing stoichiometry corresponds to increasing voltage (electrode)
sto_min = np.min(self.ocp_discharge["Stoichiometry"])
sto_max = np.max(self.ocp_charge["Stoichiometry"])
low_sto_fit = voltage_discharge
high_sto_fit = voltage_charge
# Generate evenly spaced data for dataset creation
self.sto_evenly_spaced = np.linspace(sto_min, sto_max, self.n_sto_points)
# Define a linear transition from the charge branch at low voltage
# to the charge branch at high voltage
transition = np.linspace(0, 1, len(self.sto_evenly_spaced))
voltage_merge = (1 - transition) * low_sto_fit(
self.sto_evenly_spaced
) + transition * high_sto_fit(self.sto_evenly_spaced)
self.dataset = pybop.Dataset(
{"Stoichiometry": self.sto_evenly_spaced, "Voltage [V]": voltage_merge}
)
self.check_monotonicity(voltage_merge)
return self.dataset
[docs]
class OCPAverage(BaseApplication):
"""
Estimate the equlilibrium open-circuit potential (OCP) by averaging the charge
and discharge branches, using a method loosely based on method 4(a) proposed by
Lu et al. (2021) available at: https://doi.org/10.1149/1945-7111/ac11a5
Parameters
----------
ocp_discharge : pybop.Dataset
A dataset containing the "Stoichiometry" and "Voltage [V]" obtained from a
discharge measurement.
ocp_charge : pybop.Dataset
A dataset containing the "Stoichiometry" and "Voltage [V]" obtained from a
charge measurement.
n_sto_points : int, optional
The number of points in stoichiometry at which to calculate the voltage.
allow_stretching : bool, optional
If True, the OCPs are allowed to stretch as well as shift with respect to
the stoichiometry (default: True)
cost : pybop.BaseCost, optional
The cost function to quantify the error (default: pybop.MeanAbsoluteError).
optimiser : pybop.BaseOptimiser, optional
The optimisation algorithm to use (default: pybop.SciPyMinimize).
verbose : bool, optional
If True, progress messages are printed (default: True).
"""
def __init__(
self,
ocp_discharge: pybop.Dataset,
ocp_charge: pybop.Dataset,
n_sto_points: int = 101,
allow_stretching: bool = True,
cost: Optional[pybop.BaseCost] = pybop.MeanAbsoluteError,
optimiser: Optional[pybop.BaseOptimiser] = pybop.SciPyMinimize,
verbose: bool = True,
):
[docs]
self.ocp_discharge = ocp_discharge
[docs]
self.ocp_charge = ocp_charge
[docs]
self.n_sto_points = n_sto_points
[docs]
self.allow_stretching = allow_stretching
[docs]
self.optimiser = optimiser
[docs]
def __call__(self) -> pybop.Dataset:
# Use the discharge branch as the target to fit
voltage_discharge = pybop.Interpolant(
self.ocp_discharge["Stoichiometry"], self.ocp_discharge["Voltage [V]"]
)
differential_capacity_discharge = pybop.Interpolant(
self.ocp_discharge["Stoichiometry"],
np.nan_to_num(
np.gradient(
self.ocp_discharge["Stoichiometry"],
self.ocp_discharge["Voltage [V]"],
)
),
)
# Use the charge branch as the model output
voltage_charge = pybop.Interpolant(
self.ocp_charge["Stoichiometry"], self.ocp_charge["Voltage [V]"]
)
differential_capacity_charge = pybop.Interpolant(
self.ocp_charge["Stoichiometry"],
np.nan_to_num(
np.gradient(
self.ocp_charge["Stoichiometry"], self.ocp_charge["Voltage [V]"]
)
),
)
# Generate evenly spaced data for fitting
sto_evenly_spaced = np.linspace(
np.min(self.ocp_discharge["Stoichiometry"]),
np.max(self.ocp_discharge["Stoichiometry"]),
101,
)
interpolated_dataset = pybop.Dataset(
{
"Stoichiometry": sto_evenly_spaced,
"Voltage [mV]": 1e3 * voltage_discharge(sto_evenly_spaced),
"Differential capacity [V-1]": differential_capacity_discharge(
sto_evenly_spaced
),
}
)
# Define the optimisation parameters
self.parameters = pybop.Parameters(
pybop.Parameter(
"shift",
initial_value=0.05,
),
)
if self.allow_stretching:
self.parameters.add(
pybop.Parameter(
"stretch",
initial_value=1.0,
),
)
# Create the fitting problem
class FunctionFitting(pybop.FittingProblem):
if self.allow_stretching:
def evaluate(self, inputs):
return {
"Voltage [mV]": 1e3
* voltage_charge(
inputs["stretch"] * self.domain_data + inputs["shift"]
),
"Differential capacity [V-1]": differential_capacity_charge(
inputs["stretch"] * self.domain_data + inputs["shift"]
),
}
else:
def evaluate(self, inputs):
return {
"Voltage [mV]": 1e3
* voltage_charge(self.domain_data + inputs["shift"]),
"Differential capacity [V-1]": differential_capacity_charge(
self.domain_data + inputs["shift"]
),
}
self.model = None
self.problem = FunctionFitting(
model=self.model,
parameters=self.parameters,
dataset=interpolated_dataset,
signal=["Voltage [mV]", "Differential capacity [V-1]"],
domain="Stoichiometry",
)
# Optimise the fit between the charge and discharge branches
self.cost = self.cost(self.problem, weighting="equal")
self.optim = self.optimiser(cost=self.cost, verbose=self.verbose)
self.results = self.optim.run()
self.stretch = np.sqrt(self.results.x[1]) if self.allow_stretching else 1.0
self.shift = self.results.x[0] / (self.stretch + 1.0)
if self.verbose:
print(
f"The stoichiometry stretch and shift values are ({self.stretch}, {self.shift})."
)
def stretch_and_shift(sto):
return self.stretch * sto + self.shift
def inverse_stretch_and_shift(sto):
return (sto - self.shift) / self.stretch
# Define the average OCP using the optimised parameters
sto_min = np.maximum(
stretch_and_shift(np.min(self.ocp_discharge["Stoichiometry"])),
inverse_stretch_and_shift(np.min(self.ocp_charge["Stoichiometry"])),
)
sto_max = np.minimum(
stretch_and_shift(np.max(self.ocp_discharge["Stoichiometry"])),
inverse_stretch_and_shift(np.max(self.ocp_charge["Stoichiometry"])),
)
sto_range = np.linspace(sto_min, sto_max, self.n_sto_points)
voltage = (
voltage_discharge(inverse_stretch_and_shift(sto_range))
+ voltage_charge(stretch_and_shift(sto_range))
) / 2
self.dataset = pybop.Dataset(
{"Stoichiometry": sto_range, "Voltage [V]": voltage}
)
self.check_monotonicity(voltage)
return self.dataset
[docs]
class OCPCapacityToStoichiometry(BaseApplication):
"""
Estimate the stoichiometry from a measurement of open-circuit voltage versus
charge capacity.
Parameters
----------
ocv_dataset : pybop.Dataset
A dataset containing the "Charge capacity [A.h]" and "Voltage [V]" obtained
from an OCV measurement.
ocv_function : Callable
The open-circuit voltage as a function of stoichiometry.
cost : pybop.BaseCost, optional
The cost function to quantify the error (default: pybop.RootMeanSquaredError).
optimiser : pybop.BaseOptimiser, optional
The optimisation algorithm to use (default: pybop.SciPyMinimize).
verbose : bool, optional
If True, progress messages are printed (default: True).
"""
def __init__(
self,
ocv_dataset: pybop.Dataset,
ocv_function: Callable,
cost: Optional[pybop.BaseCost] = pybop.RootMeanSquaredError,
optimiser: Optional[pybop.BaseOptimiser] = pybop.SciPyMinimize,
verbose: bool = True,
):
[docs]
self.ocv_dataset = ocv_dataset
[docs]
self.ocv_function = ocv_function
[docs]
self.optimiser = optimiser
[docs]
def __call__(self) -> pybop.Dataset:
# Use the OCV dataset as the target to fit and the OCV function as the model
# Define the optimisation parameters
self.parameters = pybop.Parameters(
pybop.Parameter(
"shift",
initial_value=0,
),
pybop.Parameter(
"stretch",
initial_value=np.max(self.ocv_dataset["Charge capacity [A.h]"])
- np.min(self.ocv_dataset["Charge capacity [A.h]"]),
),
)
# Create the fitting problem
ocv_function = self.ocv_function
class FunctionFitting(pybop.FittingProblem):
def evaluate(self, inputs):
return {
"Voltage [V]": ocv_function(
(self.domain_data - inputs["shift"]) / inputs["stretch"]
),
}
self.model = None
self.problem = FunctionFitting(
model=self.model,
parameters=self.parameters,
dataset=self.ocv_dataset,
signal=["Voltage [V]"],
domain="Charge capacity [A.h]",
)
# Optimise the fit between the OCV function and the dataset
self.cost = self.cost(self.problem, weighting="domain")
self.optim = self.optimiser(cost=self.cost, verbose=self.verbose)
self.results = self.optim.run()
self.stretch = self.results.x[1]
self.shift = self.results.x[0]
if self.verbose:
print(
f"The capacity stretch and shift values are ({self.stretch} A.h, {self.shift} A.h)."
)
# Scale charge capacity into stoichiometry (ascending)
stoichiometry = (
self.ocv_dataset["Charge capacity [A.h]"] - self.shift
) / self.stretch
self.dataset = pybop.Dataset(
{
"Stoichiometry": stoichiometry,
"Voltage [V]": self.ocv_dataset["Voltage [V]"],
}
if stoichiometry[-1] > stoichiometry[0]
else {
"Stoichiometry": np.flipud(stoichiometry),
"Voltage [V]": np.flipud(self.ocv_dataset["Voltage [V]"]),
}
)
self.check_monotonicity(self.ocv_dataset["Voltage [V]"])
return self.dataset