Half-cell OCP fitting

In this example, we show how to fit the half-cell Open-Circuit Potential to pseudo-OCP data. Our recommended workflow for fitting OCPs is to first fit the MSMR model to the data and then create an interpolant to find a function \(U(x)\), where \(x\) is the stoichiometry. For comparison, we also show how to fit a function \(U(x)\) directly to pseudo-OCP data.

import ionworkspipeline as iwp
import numpy as np
import pandas as pd
import pybamm
import matplotlib.pyplot as plt
from scipy.signal import savgol_filter

Pre-process and visualize the data

First, we load and pre-process the data.

# Load data
raw_ocp_data = pd.read_csv("anode_ocp_delithiation.csv", comment="#")
q = raw_ocp_data["Capacity [A.h]"].values
U = raw_ocp_data["Voltage [V]"].values

# Smooth
q_smoothed = savgol_filter(q, window_length=11, polyorder=1)
U_smoothed = savgol_filter(U, window_length=11, polyorder=1)
dQdU_smoothed = -np.gradient(q_smoothed, U_smoothed)

# Store in a dataframe
ocp_data = pd.DataFrame({"Capacity [A.h]": q_smoothed, "Voltage [V]": U_smoothed})
ocp_data = iwp.data_fits.preprocess.sort_capacity_and_ocp(ocp_data)
# Maximum capacity, will be used later as an initial guess for the electrode capacity
Q_max_data = ocp_data["Capacity [A.h]"].max()

# Plot
_, ax = plt.subplots(1, 2, figsize=(10, 5))
ax[0].plot(q_smoothed, U_smoothed, "-x")
ax[0].set_xlabel("Capacity [A.h]")
ax[0].set_ylabel("Voltage [V]")
ax[1].plot(dQdU_smoothed, U_smoothed, "-x")
ax[1].set_xlabel("dQ/dU [A.h/V]")
ax[1].set_ylabel("Voltage [V]")
ax[1].set_xlim(0, 200)
plt.tight_layout()
/home/docs/checkouts/readthedocs.org/user_builds/ionworks-ionworkspipeline/envs/v0.4.1/lib/python3.11/site-packages/numpy/lib/function_base.py:1242: RuntimeWarning: divide by zero encountered in divide
  a = -(dx2)/(dx1 * (dx1 + dx2))
/home/docs/checkouts/readthedocs.org/user_builds/ionworks-ionworkspipeline/envs/v0.4.1/lib/python3.11/site-packages/numpy/lib/function_base.py:1243: RuntimeWarning: divide by zero encountered in divide
  b = (dx2 - dx1) / (dx1 * dx2)
/home/docs/checkouts/readthedocs.org/user_builds/ionworks-ionworkspipeline/envs/v0.4.1/lib/python3.11/site-packages/numpy/lib/function_base.py:1244: RuntimeWarning: divide by zero encountered in divide
  c = dx1 / (dx2 * (dx1 + dx2))
/home/docs/checkouts/readthedocs.org/user_builds/ionworks-ionworkspipeline/envs/v0.4.1/lib/python3.11/site-packages/numpy/lib/function_base.py:1250: RuntimeWarning: invalid value encountered in add
  out[tuple(slice1)] = a * f[tuple(slice2)] + b * f[tuple(slice3)] + c * f[tuple(slice4)]
../../../_images/9447c8ba1d8efafed303f6370e25450dfd73da96a16ddc7d72b951ba3662abfd.png

Fit the MSMR model to data

We begin by setting up initial guesses for our MSMR parameters.

# Initial guess of Graphite from Verbrugge et al 2017 J. Electrochem. Soc. 164 E3243
msmr_param_init = {
    "U0_p_0": 0.088,
    "X_p_0": 0.43,
    "w_p_0": 0.086,
    "U0_p_1": 0.13,
    "X_p_1": 0.24,
    "w_p_1": 0.080,
    "U0_p_2": 0.14,
    "X_p_2": 0.15,
    "w_p_2": 0.72,
    "U0_p_3": 0.17,
    "X_p_3": 0.055,
    "w_p_3": 2.5,
    "U0_p_4": 0.21,
    "X_p_4": 0.067,
    "w_p_4": 0.095,
    "U0_p_5": 0.36,
    "X_p_5": 0.055,
    "w_p_5": 6.0,
    "Number of reactions in positive electrode": 6,
    "Positive electrode lower excess capacity [A.h]": 0.0,
    "Positive electrode capacity [A.h]": Q_max_data,
    "Ambient temperature [K]": 298.15,
}

We can plot these against the data and, if required, manually adjust them to get a better starting point. For this example, let’s keep the Graphite parameters as our initial guess.

fig, ax = iwp.objectives.plot_each_species_msmr(
    ocp_data, msmr_param_init, "positive", voltage_limits=(0, 1.5)
)
ax[1].set_xlim(0, 50)
(0.0, 50.0)
../../../_images/ab1436300874ca57116695eac0b9800f441ec4b7fb2b1090d4b6f6ed1cba95ec.png

Now we can set up the objective and parameters to fit, run the data fit and plot the results.

# Pass in the lithiation function, and fit the electrode capacity and lower excess
# capacity by making them Parameter objects
n_species = msmr_param_init["Number of reactions in positive electrode"]
theta_half_cell = iwp.objectives.get_theta_half_cell_msmr(n_species, "positive")
msmr_params = {
    "Positive electrode lithiation": theta_half_cell,
    "Ambient temperature [K]": 298.15,
    "Positive electrode capacity [A.h]": iwp.Parameter(
        "Q", initial_value=Q_max_data, bounds=(0.9 * Q_max_data, 1.2 * Q_max_data)
    ),
    "Positive electrode lower excess capacity [A.h]": (
        iwp.Parameter(
            "Q_lowex", initial_value=0.01 * Q_max_data, bounds=(0, 0.01 * Q_max_data)
        )
    ),
}
# Add Parameter objects for the MSMR parameters X_j, U0_j, and w_j
for k, v in msmr_param_init.items():
    if "X" in k:
        bounds = (0, 1)
    elif "U0_" in k:
        # allow a 500 mV variation either way
        bounds = (max(0, v - 0.5), v + 0.5)
    elif "w_" in k:
        bounds = (1e-2, 100)
    else:
        continue
    msmr_params[k] = iwp.Parameter(k, initial_value=v, bounds=bounds)

# Calculate dUdQ cutoff to prevent overfitting to extreme dUdQ values
dUdq_cutoff = iwp.data_fits.util.calculate_dUdQ_cutoff(ocp_data)

# Set up objective and fit
objective = iwp.objectives.MSMRHalfCell(
    "positive", ocp_data, options={"dUdQ cutoff": dUdq_cutoff, "species format": "Xj"}
)
ocp_msmr = iwp.DataFit(objective, parameters=msmr_params)

# Run the fit
results = ocp_msmr.run({})

# Plot results
fig_ax = ocp_msmr.plot_fit_results()
axes = fig_ax["MSMRHalfCell"][0][1]
_ = axes[2].set_xlim(0, 100)
/home/docs/checkouts/readthedocs.org/user_builds/ionworks-ionworkspipeline/envs/v0.4.1/lib/python3.11/site-packages/pybamm/expression_tree/functions.py:152: RuntimeWarning: overflow encountered in exp
  return self.function(*evaluated_children)
../../../_images/d197de58f892f89bddb584eea692120d364fee3b71b9c328a0ec5fc9c7335e80.png

Lastly, we can look at the fitted parameters.

results
Result(
  Positive electrode lithiation: functools.partial(<function _theta_half_...
  Ambient temperature [K]: 298.15
  Positive electrode capacity [A.h]: 5.71564
  Positive electrode lower excess capacity [A.h]: 0.0476304
  U0_p_0: 0.0923055
  X_p_0: 0.204122
  w_p_0: 0.019608
  U0_p_1: 0.132874
  X_p_1: 0.154917
  w_p_1: 0.0398248
  U0_p_2: 0.217237
  X_p_2: 0.0517805
  w_p_2: 0.19139
  U0_p_3: 0.0920336
  X_p_3: 0.107726
  w_p_3: 0.120801
  U0_p_4: 0.14377
  X_p_4: 0.120262
  w_p_4: 0.539899
  U0_p_5: 0.12359
  X_p_5: 0.361193
  w_p_5: 5.68549
)

This MSMR fit can then be used to generate an interpolant for the OCP either manually or using the OCPMSMRInterpolant pipeline element,

voltage_limits = (0, 1.5)
ocp_msmr_interpolant = iwp.calculations.OCPMSMRInterpolant("positive", voltage_limits)
params_fit_msmr_interpolant = ocp_msmr_interpolant.run(results)

We can see in the returned parameter values that we have a “Positive electrode OCP [V]” function as well as the original data.

params_fit_msmr_interpolant
{'Positive electrode OCP [V]': <function ionworkspipeline.calculations.ocp_interpolant.OCPMSMRInterpolant.run.<locals>.U(sto)>,
 'Positive electrode OCP data [V]': array([[2.92085227e-05, 2.95102802e-05, 2.98151549e-05, ...,
         8.89978146e-01, 8.90763455e-01, 8.91545534e-01],
        [1.50000000e+00, 1.49849850e+00, 1.49699700e+00, ...,
         3.00300300e-03, 1.50150150e-03, 0.00000000e+00]])}

We can access the “data” generated from the MSMR function as follows:

theta_U = ocp_msmr_interpolant.fit_results_["interpolant"]
theta = theta_U[0]  # stoichiometry
U = theta_U[1]  # potential

Fit OCP as a function of stoichiometry

Now, we directly fit a function \(U(x)\) to the pseudo-OCP data.

First, we define the function and initial guesses for the parameters.

def U_half_cell(theta):
    u00 = pybamm.Parameter("u00_pos")
    u10 = pybamm.Parameter("u10_pos")
    u11 = pybamm.Parameter("u11_pos")
    u20 = pybamm.Parameter("u20_pos")
    u21 = pybamm.Parameter("u21_pos")
    u22 = pybamm.Parameter("u22_pos")
    u30 = pybamm.Parameter("u30_pos")
    u31 = pybamm.Parameter("u31_pos")
    u32 = pybamm.Parameter("u32_pos")
    u40 = pybamm.Parameter("u40_pos")
    u41 = pybamm.Parameter("u41_pos")
    u42 = pybamm.Parameter("u42_pos")

    u_eq = (
        u00
        + u10 * pybamm.exp(-u11 * theta)
        + u20 * pybamm.tanh(u21 * (theta - u22))
        + u30 * pybamm.tanh(u31 * (theta - u32))
        + u40 * pybamm.tanh(u41 * (theta - u42))
    )

    return u_eq


ocp_param_init = {
    "u00_pos": 0.2,
    "u10_pos": 2,
    "u11_pos": 30,
    "u20_pos": -0.1,
    "u21_pos": 30,
    "u22_pos": 0.1,
    "u30_pos": -0.1,
    "u31_pos": 30,
    "u32_pos": 0.3,
    "u40_pos": -0.1,
    "u41_pos": 30,
    "u42_pos": 0.6,
}

Then we set up our DataFit object and fit the data.

# Set up Parameter objects to fit
ocp_params = {k: iwp.Parameter(k, initial_value=v) for k, v in ocp_param_init.items()}
# Add the functional form of the OCP
ocp_params = {"Positive electrode OCP [V]": U_half_cell, **ocp_params}

# Use the experimental capacity to map between capacity in the experiment and the
# stoichiometry. In practice we would calculate the capacity from other means,
# (e.g. electrode loading) or use the MSMR model to fit the electrode capacity.
known_parameter_values = {"Positive electrode capacity [A.h]": Q_max_data}

# Set up objective and fit
objective = iwp.objectives.OCPHalfCell(
    "positive",
    ocp_data,
)
ocp = iwp.DataFit(objective, parameters=ocp_params)

# Fit
params_fit_ocp = ocp.run(known_parameter_values)

# Plot
_ = ocp.plot_fit_results()
../../../_images/8bd4e981c1cd5e6c7122de084b8bc8437fb31e463a98d423f42dd908b5118982.png

Then, we look at the fitted parameters.

params_fit_ocp
Result(
  Positive electrode OCP [V]: <function U_half_cell at 0x7f77c0380180>
  u00_pos: 0.267657
  u10_pos: 0.14828
  u11_pos: 21.9388
  u20_pos: -0.115907
  u21_pos: 17.9196
  u22_pos: 0.0648083
  u30_pos: -0.038146
  u31_pos: 17.4316
  u32_pos: 0.24671
  u40_pos: -0.0194791
  u41_pos: 32.1575
  u42_pos: 0.593834
)