Fitting OCP hysteresis

In this example, we show how to fit the lithiation and delithiation OCPs at the same time in a way that is consistent with the MSMR model. In particular, we want the total capacity to be the same during lithiation and delithiation. To ensure this, we fit a single capacity for each species, but allow the reaction parameters \(X_j\), \(U0_j\) and \(\omega_j\) to be different for lithiation and delithiation.

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

We use data from half-cells constructed from an LGM50 cell, as reported in https://github.com/paramm-team/pybamm-param.

data = {}
Q_max_data = 0
fig, ax = plt.subplots(1, 3, figsize=(10, 5))
for direction in ["lithiation", "delithiation"]:
    # Smooth
    raw_ocp_data = pd.read_csv(f"anode_ocp_{direction}.csv", comment="#")
    q_smoothed = savgol_filter(
        raw_ocp_data["Capacity [A.h]"].values, window_length=11, polyorder=1
    )
    U_smoothed = savgol_filter(
        raw_ocp_data["Voltage [V]"].values, window_length=11, polyorder=1
    )

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

    # Plot
    dUdQ = -np.gradient(U, q)
    dQdU = -np.gradient(q, U)
    ax[0].plot(q, U, "-x", label=direction)
    ax[0].set_xlabel("Capacity [A.h]")
    ax[0].set_ylabel("Voltage [V]")
    ax[1].plot(q, dUdQ, "-x")
    ax[1].set_xlabel("Capacity [A.h]")
    ax[1].set_ylabel("dU/dQ [V/A.h]")
    ax[1].set_ylim(0, 0.5)
    ax[2].plot(dQdU, U, "-x")
    ax[2].set_xlabel("dQ/dU [A.h/V]")
    ax[2].set_ylabel("Voltage [V]")
    ax[2].set_xlim(0, 200)
ax[0].legend()
fig.tight_layout()
../../../_images/a6030a6df7d83306ff140ec9f0b8999a60075900a36a051a922dbdbdfbfb1833.png

Next we set up the Parameter objects for the fit. For the reaction parameters, we use the values from Verbrugge et al 2017 J. Electrochem. Soc. 164 E3243 as an initial guess. We can access the values from the Material object, which is part of our library of parameter values.

We set up a bounds function that returns the bounds for the reaction parameters. Here we use the same bounds for lithiation and delithiation, and for each reaction index \(j\). We could construct a more complex bounds function that allows the bounds to be different for lithiation and delithiation, and/or different for each reaction index \(j\).

# Initial guess of Graphite from Verbrugge et al 2017 J. Electrochem. Soc. 164 E3243
msmr_param_init = iwp.Material("GRAPHITE_VERBRUGGE2017").parameter_values


# Define bounds for the MSMR parameters
def bounds_function(var, initial_value, Q_total):
    if var.startswith("X"):
        return 0, 1
    elif var.startswith("U"):
        return max(0, initial_value - 0.2), initial_value + 0.2
    elif var.startswith("w"):
        return 1e-2, 100
    else:
        raise ValueError(f"Unknown parameter {var}")

Now we create a dictionary of parameters to fit. We fit a single capacity for each species, but allow the lower excess capacity to be different for lithiation and delithiation, since the data in both directions may not span the same range of capacities. For the reaction parameters, we use the the helper function get_msmr_params_for_fit to create the Parameter objects.

params_to_fit = {
    "Negative electrode capacity [A.h]": iwp.Parameter(
        "Q_n", initial_value=Q_max_data, bounds=(Q_max_data * 0.8, Q_max_data * 1.2)
    ),
    "Negative electrode lower excess capacity (delithiation) [A.h]": iwp.Parameter(
        "Q_n_d_lowex", initial_value=0.01 * Q_max_data, bounds=(0, 0.1 * Q_max_data)
    ),
    "Negative electrode lower excess capacity (lithiation) [A.h]": iwp.Parameter(
        "Q_n_l_lowex", initial_value=0.01 * Q_max_data, bounds=(0, 0.1 * Q_max_data)
    ),
}
params_to_fit.update(
    {
        **iwp.objectives.get_msmr_params_for_fit(
            msmr_param_init,
            "negative",
            Q_max_data,
            bounds_function=bounds_function,
            species_format="Xj",
            hysteresis=True,
        )
    }
)

Next we create the objectives. For this fit, we use the OpenCircuitHalfCellHysteresisCallback that shows both the fit results on a single plot. We create a single instance of the callback and pass it to each objective.

objectives = {}
callback = iwp.objectives.OpenCircuitHalfCellHysteresisCallback()
for direction in ["lithiation", "delithiation"]:
    objectives[direction] = iwp.objectives.MSMRHalfCell(
        data[direction],
        options={
            "model": iwp.data_fits.models.MSMRHalfCellModel(
                "negative", options={"species format": "Xj", "direction": direction}
            ),
            "dUdQ cutoff": 0.2,
            "voltage limits": (0, 1),
        },
        callbacks=callback,
    )

Finally, we create the DataFit object, run the fit,

data_fit = iwp.DataFit(objectives, parameters=params_to_fit)

results = data_fit.run({"Ambient temperature [K]": 298.15})

and plot the results.

fig, axes = callback.plot_fit_results()
_ = axes[2].set_xlim(0, 200)
../../../_images/a0917d4d4bc265e87a0ac813441cd3ddbe8aa5bba4aeda6318d24bf04731797e.png