{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Electrode Balancing with an OCV Model\n", "\n", "## Estimating stoichiometric bounds from open-circuit voltage vs. charge throughput\n", "\n", "Here we provide an example on how to perform electrode balancing for a half cell. The goal is to find the conversion from capacity to stoichiometry for a given measured electrode, by using a reference dataset for which voltage is known as a function of the stoichiometry.\n", "\n", "### Setting up the Environment\n", "\n", "If you don't already have PyBOP installed, check out the [installation guide](https://pybop-docs.readthedocs.io/en/latest/installation.html) first.\n", "\n", "We begin by importing the necessary libraries. Let's also fix the random seed to generate consistent output during development." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "import pybamm\n", "\n", "import pybop\n", "\n", "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Load the data\n", "We start by loading the data, which is available in the [pybamm-param repository](https://github.com/paramm-team/pybamm-param). We load half cell data (which is a function of stoichiometry and thus the reference data) and the three-electrode full-cell data (which is the data we want to analyse). The measurements are for an LGM50 cell, with a graphite and SiOx negative electrode and NMC811 positive electrode." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Load csv data for the negative electrode\n", "base_url = \"https://raw.githubusercontent.com/paramm-team/pybamm-param/develop/pbparam/input/data/\"\n", "reference_data = pd.read_csv(\n", " base_url + \"anode_OCP_2_lit.csv\"\n", ") # half cell lithiation data\n", "measured_data = pd.read_csv(\n", " base_url + \"anode_OCP_3_lit.csv\"\n", ") # three-electrode full cell lithiation data" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Set up model, parameters and data\n", "\n", "To perform the electrode balancing, we will use an ECM model consisting only of the open-circuit voltage (OCV) component. To achieve that, we will set the resistance to zero. We will also change the upper and lower voltage limits to ensure we do not hit them during the optimisation. For the OCV, we will use the reference data we just loaded." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "model = pybamm.equivalent_circuit.Thevenin(options={\"number of rc elements\": 0})\n", "\n", "\n", "def ocv(sto):\n", " return pybamm.Interpolant(\n", " reference_data[\"Stoichiometry\"].to_numpy(),\n", " reference_data[\"Voltage [V]\"].to_numpy(),\n", " sto,\n", " \"reference OCV\",\n", " )\n", "\n", "\n", "parameter_values = model.default_parameter_values\n", "parameter_values.update(\n", " {\n", " \"Initial SoC\": 0,\n", " \"Entropic change [V/K]\": 0,\n", " \"R0 [Ohm]\": 0,\n", " \"Lower voltage cut-off [V]\": 0,\n", " \"Upper voltage cut-off [V]\": 5,\n", " \"Open-circuit voltage [V]\": ocv,\n", " }\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We define the parameters we want to optimise. In this case, we need to optimise the initial state of charge (SoC) and the cell capacity, which will be needed to convert the capacity to stoichiometry." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "parameter_values.update(\n", " {\n", " \"Initial SoC\": pybop.Parameter(\n", " pybop.Uniform(0, 0.5),\n", " initial_value=0.05,\n", " ),\n", " \"Cell capacity [A.h]\": pybop.Parameter(\n", " pybop.Uniform(0.01, 50),\n", " initial_value=20,\n", " ),\n", " }\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now we need to assemble the dataset. This is a bit tricky, as we are doing an electrode balancing but in theory we are solving a discharge problem. However, we can use that if we impose a 1 A discharge, the time (in hours) will be the same as the capacity (in Ah). Therefore, we can treat time as capacity if we shift and scale it to the correct units (PyBaMM models take time in seconds). Note that in this case current is negative as we are lithiating the electrode." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Shift the capacity values to start from zero\n", "minimum_capacity_recorded = np.min(measured_data[\"Capacity [A.h]\"])\n", "proxy_time_data = (\n", " measured_data[\"Capacity [A.h]\"].to_numpy() - minimum_capacity_recorded\n", ") * 3600\n", "\n", "dataset = pybop.Dataset(\n", " {\n", " \"Time [s]\": proxy_time_data,\n", " \"Current function [A]\": -np.ones(len(measured_data[\"Capacity [A.h]\"])),\n", " \"Voltage [V]\": measured_data[\"Voltage [V]\"].to_numpy(),\n", " }\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Identifying the parameters\n", "\n", "Once we have defined the model, parameters and dataset, we can proceed to the optimisation. We define the problem and the cost, for which we choose the sum squared error." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "simulator = pybop.pybamm.Simulator(\n", " model, parameter_values=parameter_values, protocol=dataset\n", ")\n", "cost = pybop.SumSquaredError(dataset)\n", "problem = pybop.Problem(simulator, cost)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We choose the `SciPyMinimize` optimiser and solve the optimisation problem. We can then plot the results." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "/home/nicola/GitHub/PyBOP/pybop/optimisers/scipy_optimisers.py:235: RuntimeWarning:\n", "\n", "Method Nelder-Mead does not use gradient information (jac).\n", "\n" ] } ], "source": [ "options = pybop.SciPyMinimizeOptions(maxiter=250, method=\"Nelder-Mead\")\n", "optim = pybop.SciPyMinimize(problem, options=options)\n", "result = optim.run()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/html": [ " \n", " \n", " " ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "text/html": [ "
\n", "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "pybop.plot.problem(problem, inputs=result.best_inputs, title=\"Optimised Comparison\");" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Converting capacity to stoichiometry\n", "\n", "The goal of the electrode balancing was to convert capacity to stoichiometry, so how can we do that? To convert capacity $Q$ to stoichiometry $x$, we can use the following equation:\n", "\n", "$$\n", "x = \\pm \\frac{Q}{Q_{\\text{cell}}} + x_0.\n", "$$\n", "Here, the choice of plus or minus depends on whether we are lithiating or delithiating the electrode (related to whether the current is positive or negative). $Q_{\\text{cell}}$ is the cell capacity and $x_0$ is the initial stoichiometry, which are the two parameters we fitted. We can now convert the measured data and plot it against the reference data to check that the fitting is correct." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "from plotly import graph_objects as go\n", "\n", "fig = go.Figure(\n", " layout=go.Layout(title=\"OCP Balance\", width=800, height=600),\n", ")\n", "\n", "fig.add_trace(\n", " go.Scatter(\n", " x=reference_data[\"Stoichiometry\"],\n", " y=reference_data[\"Voltage [V]\"],\n", " mode=\"markers\",\n", " name=\"Reference\",\n", " ),\n", ")\n", "\n", "Q = result.best_inputs[\"Cell capacity [A.h]\"]\n", "sto_0 = result.best_inputs[\"Initial SoC\"]\n", "\n", "sto = measured_data[\"Capacity [A.h]\"].to_numpy() / Q + sto_0\n", "\n", "fig.add_trace(\n", " go.Scatter(x=sto, y=measured_data[\"Voltage [V]\"], mode=\"lines\", name=\"Fitted\"),\n", ")\n", "\n", "# Update axes labels\n", "fig.update_xaxes(title_text=\"Stoichiometry\")\n", "fig.update_yaxes(title_text=\"Voltage / V\")\n", "\n", "# Show figure\n", "fig.show()" ] } ], "metadata": { "kernelspec": { "display_name": "env", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.3" } }, "nbformat": 4, "nbformat_minor": 2 }