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, and separate reference potential and activities for lithiation and delithiation (5n parameters for n species).

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/85102ece5f921c6f7df80f33738eb8dd1f6dbed14c22eb51e813884c25a7ab7c.png

Fit the lithiation and delithiation separately to get an initial guess and bounds for the parameters

See ocp_fitting.ipynb for details.

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

msmr_param_fit = {}
for direction, data_ in data.items():
    Q_use = data_["Capacity [A.h]"].max()

    # Add Parameter objects for the MSMR parameters X_j, U0_j, and w_j
    def bounds_function(var, initial_value, Q_total):
        if var.startswith("Q"):
            return 0, Q_total
        elif var.startswith("X"):
            return 0, 1
        elif var.startswith("U"):
            return max(0, initial_value - 0.1), initial_value + 0.1
        elif var.startswith("w"):
            return 1e-2, 100

    msmr_params = {
        **iwp.objectives.get_msmr_capacity_params_for_fit(
            {"Q_n_lowex": 0.01 * Q_use},
            "negative",
            Q_use,
            parameter_format="lower excess capacity only",
        ),
        **iwp.objectives.get_msmr_params_for_fit(
            msmr_param_init, "negative", Q_use, bounds_function=bounds_function
        ),
    }

    # Set up objective and fit
    objective = iwp.objectives.MSMRHalfCell(
        "negative", data_, options={"constrain_Xj": "false", "voltage limits": (0, 1.5)}
    )
    ocp_msmr = iwp.DataFit(objective, parameters=msmr_params)

    # Run the fit
    msmr_param_fit[direction] = ocp_msmr.run({"Ambient temperature [K]": 298.15})
    fig_ax = ocp_msmr.plot_fit_results()["MSMRHalfCell"]
    ax = fig_ax[0][1]
    # ax[2].set_xlim(0, 200)
/home/docs/checkouts/readthedocs.org/user_builds/ionworks-ionworkspipeline/envs/v0.3.6/lib/python3.11/site-packages/pybamm/expression_tree/functions.py:152: RuntimeWarning: overflow encountered in exp
  return self.function(*evaluated_children)
../../_images/4432fcaa7a824f9c5d218cb3931f361aec3815ad155c44e8f4c5c25103dd5f4a.png ../../_images/b28a586f247e462f5ffc372301d67a0bfbe8c008b7cf36a676497f3170e52648.png
# Sort each one by Q so that we get capacities that are close to each other
# for the same electrode at the same time
msmr_param_fit_sorted = {
    direction: iwp.objectives.msmr_sort_params(params, sort_by="Q_n")
    for direction, params in msmr_param_fit.items()
}
# plot msmr param fit as a scatter plot on a log scale with the names as the x axis
# make sure each point has the same color in each direction
fig, ax = plt.subplots()
markers = {"delithiation": "o", "lithiation": "x"}
for direction, msmr_param in msmr_param_fit_sorted.items():
    ax.set_prop_cycle(None)
    msmr_param_sorted = dict(sorted(msmr_param.items()))
    for name, value in msmr_param_sorted.items():
        if "_0" in name:
            ax.set_prop_cycle(None)
        if "X_" in name:
            continue
        ax.scatter(name, value, label=direction, marker=markers[direction])
ax.set_yscale("log")
# rotate the x axis labels
_ = ax.set_xticklabels(ax.get_xticklabels(), rotation=45, ha="right")
/tmp/ipykernel_933/1576170936.py:16: UserWarning: set_ticklabels() should only be used with a fixed number of ticks, i.e. after set_ticks() or using a FixedLocator.
  _ = ax.set_xticklabels(ax.get_xticklabels(), rotation=45, ha="right")
../../_images/77b5a713e3c6afc1ffc2ae1fb455d855d373ef57ef7700d74b8c249beeffbf52.png

Fit the MSMR half-cell model with hysteresis

First we specify a custom callback that we will use to show the fit results on a single plot

class Callback(iwp.callbacks.SaveResultsCallback):
    def __init__(self, folder=None):
        self.folder = folder
        self.data = []
        self.fit_results = []

    def on_objective_build(self, logs):
        self.data.append(logs["data"])

    def on_datafit_finish(self, logs):
        self.fit_results.append(logs)

    def plot_fit_results(self):
        import matplotlib.pyplot as plt

        colors = [{"data": "C0", "fit": "C0"}, {"data": "C1", "fit": "C1"}]
        labels = [
            {"data": "Data (delithiation)", "fit": "Fit (delithiation)"},
            {"data": "Data (lithiation)", "fit": "Fit (lithiation)"},
        ]

        fig, axes = plt.subplots(1, 3, figsize=(10, 4))
        for idx, (data, fit_result) in enumerate(zip(self.data, self.fit_results)):
            iwp.objectives.plot_half_cell_ocp(
                data,
                fit_result["outputs"],
                fig_axes=(fig, axes),
                colors=colors[idx],
                labels=labels[idx],
            )

        fig.tight_layout()

        return fig, axes

Then create objectives, parameters, and datafit, and run

def hysteresis_bounds_function(var, initial_value, Q_use):
    if var.startswith("Q_") and "lowex" not in var:
        return 0, Q_use
    if "lowex" in var:
        var = "Negative electrode lower excess capacity [A.h]"
    var_lit = msmr_param_fit_sorted["lithiation"][var]
    var_delit = msmr_param_fit_sorted["delithiation"][var]
    min_var = min(var_lit, var_delit)
    max_var = max(var_lit, var_delit)
    delta_var = max_var - min_var
    return min_var - 0.5 * delta_var, max_var + 0.5 * delta_var


average_param_init = {}
for var in msmr_param_fit_sorted["lithiation"]:
    if "X_" in var:
        continue
    average_param_init[var] = np.mean(
        [msmr_param_fit_sorted[direction][var] for direction in data]
    )


objectives = {}
callback = Callback()
n_species = len([k for k in msmr_param_init if "U0_" in k])
for direction in ["lithiation", "delithiation"]:
    q_fn = q_fn_delith = iwp.objectives.get_q_half_cell_msmr(
        n_species, "negative", species_format="Qj", direction=direction
    )
    objectives[direction] = iwp.objectives.MSMRHalfCell(
        "negative",
        data[direction],
        options={
            "capacity function": q_fn,
            "constrain_Xj": "false",
            "dUdQ cutoff": 0.6,
            "voltage limits": (0, 1.5),
        },
        callbacks=callback,
    )

params_to_fit = {
    **iwp.objectives.get_msmr_params_for_fit(
        average_param_init,
        "negative",
        Q_max_data,
        bounds_function=hysteresis_bounds_function,
        species_format="Qj",
        hysteresis=True,
    ),
}

# we fit the lower excess capacity for each direction
for direction in ["delithiation", "lithiation"]:
    params_to_fit.update(
        iwp.objectives.get_msmr_capacity_params_for_fit(
            {
                "Q_n_d_lowex": average_param_init[
                    "Negative electrode lower excess capacity [A.h]"
                ],
                "Q_n_l_lowex": average_param_init[
                    "Negative electrode lower excess capacity [A.h]"
                ],
            },
            "negative",
            Q_max_data,
            parameter_format="lower excess capacity only",
            direction=direction,
            bounds_function=hysteresis_bounds_function,
        )
    )

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

params_fit = data_fit.run({"Ambient temperature [K]": 298.15})
fig, axes = callback.plot_fit_results()
_ = axes[2].set_xlim(0, 200)
/home/docs/checkouts/readthedocs.org/user_builds/ionworks-ionworkspipeline/envs/v0.3.6/lib/python3.11/site-packages/pybamm/expression_tree/functions.py:152: RuntimeWarning: overflow encountered in exp
  return self.function(*evaluated_children)
../../_images/22f12ee340b7aafe10a4dd49a44695f3e70886c2ce80cf6fa669e41c674d8bf4.png