{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "## LG M50 Single Pulse Parameter Identification\n", "\n", "This example presents an experimental parameter identification method for a two-RC circuit model. The data for this notebook is located within the same directory and was obtained from WDWidanage/Simscape-Battery-Library [[1]](https://github.com/WDWidanage/Simscape-Battery-Library/tree/a3842b91b3ccda006bc9be5d59c8bcbd167ceef7/Examples/parameterEstimation_TECMD/Data).\n", "\n", "\n", "### Setting up the Environment\n", "\n", "Before we begin, we need to ensure that we have all the necessary tools. We will install PyBOP for the fitting and Pandas for the data ingestion and manipulation:" ] }, { "cell_type": "code", "execution_count": null, "id": "1", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "/Users/engs2510/Documents/Git/Second_PyBOP/.nox/notebooks-overwrite/bin/python3: No module named pip\r\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Note: you may need to restart the kernel to use updated packages.\n", "zsh:1: no matches found: pybop[plot]\r\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Note: you may need to restart the kernel to use updated packages.\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "/Users/engs2510/Documents/Git/Second_PyBOP/.nox/notebooks-overwrite/bin/python3: No module named pip\r\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Note: you may need to restart the kernel to use updated packages.\n" ] } ], "source": [ "%pip install --upgrade pip ipywidgets -q\n", "%pip install pybop[plot] -q\n", "%pip install pandas -q" ] }, { "cell_type": "markdown", "id": "2", "metadata": {}, "source": [ "### Importing Libraries\n", "\n", "With the environment set up, we can now import PyBOP alongside other libraries we will need:" ] }, { "cell_type": "code", "execution_count": null, "id": "3", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "import pybamm\n", "from scipy.io import loadmat\n", "\n", "import pybop\n", "\n", "go = pybop.plot.PlotlyManager().go\n", "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"" ] }, { "cell_type": "markdown", "id": "4", "metadata": {}, "source": [ "Let's fix the random seed in order to generate consistent output during development, although this does not need to be done in practice." ] }, { "cell_type": "code", "execution_count": null, "id": "5", "metadata": {}, "outputs": [], "source": [ "np.random.seed(8)" ] }, { "cell_type": "markdown", "id": "6", "metadata": {}, "source": [ "## Importing Data\n", "\n", "The data is imported as a dictionary with the following key level:\n", "- [\"LGM50_5Ah_Pulse\"]\n", " - Temperature-> [\"T0\"]\n", " - State of Charge ->[\"SoC3\"]\n", " - Cell number -> [\"Cell1\"]\n", " - Corresponding data -> [\"data\"]\n", "\n", "Using SciPy's `loadmat`, we import the data from the MAT containers:" ] }, { "cell_type": "code", "execution_count": null, "id": "7", "metadata": {}, "outputs": [], "source": [ "ocp = loadmat(\n", " \"../data/LG_M50_ECM/data/LGM50_5Ah_OCV.mat\", simplify_cells=True, mat_dtype=False\n", ")\n", "pulse_data = loadmat(\n", " \"../data/LG_M50_ECM/data/LGM50_5Ah_Pulse.mat\", simplify_cells=True, mat_dtype=False\n", ")\n", "rate_data = loadmat(\n", " \"../data/LG_M50_ECM/data/LGM50_5Ah_RateTest.mat\",\n", " simplify_cells=True,\n", " mat_dtype=False,\n", ")" ] }, { "cell_type": "markdown", "id": "8", "metadata": {}, "source": [ "### Convert to Dataframes\n", "\n", "Next, we construct a dataframe from a selected pulse. In this case, we select the data for zero degrees (`T0`) with a state-of-charge of 90% (`SoC9`) and the 19th cell (`Cell19`). This can be extended to multiple pulses, or a different selection.\n", "\n", "Additionally, we apply two filters to the dataframe to ensure the data contains only monotonically increasing time samples without duplicates." ] }, { "cell_type": "code", "execution_count": null, "id": "9", "metadata": {}, "outputs": [], "source": [ "df = pd.DataFrame(pulse_data[\"LGM50_5Ah_Pulse\"][\"T0\"][\"SoC9\"][\"Cell19\"][\"data\"])\n", "df[\"ProgTime\"] = df[\"ProgTime\"] - df[\"ProgTime\"].min()\n", "df.drop_duplicates(subset=[\"ProgTime\"], inplace=True)" ] }, { "cell_type": "markdown", "id": "10", "metadata": {}, "source": [ "A plot of time vs voltage confirms the data looks correct for fitting. In this situation, we would prefer to have additional samples from the relaxation, but as we will show below, PyBOP is still able to identify parameter values that fit this system." ] }, { "cell_type": "code", "execution_count": null, "id": "11", "metadata": {}, "outputs": [ { "data": { "text/html": [ " \n", " " ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "text/html": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "go.Figure(\n", " data=go.Scatter(\n", " x=df[\"ProgTime\"],\n", " y=df[\"Voltage\"],\n", " )\n", ")" ] }, { "cell_type": "markdown", "id": "12", "metadata": {}, "source": [ "Next, we construct the OCV function from the imported `OCV` data. This is completed with a wrapper method on the `pybamm.Interpolant` function," ] }, { "cell_type": "code", "execution_count": null, "id": "13", "metadata": {}, "outputs": [], "source": [ "def ocv_LGM50(sto):\n", " name = \"OCV\"\n", " x = (ocp[\"LGM50_5Ah_OCV\"][\"T25\"][\"refSoC\"].reshape(-1) / 100,)\n", " y = ocp[\"LGM50_5Ah_OCV\"][\"T25\"][\"meanOCV\"].reshape(-1)\n", " return pybamm.Interpolant(x, y, sto, name)" ] }, { "cell_type": "markdown", "id": "14", "metadata": {}, "source": [ "We can construct the two RC parameter set with initial values as listed. Note, the initial SOC is shifted slightly to better match the zero degree data." ] }, { "cell_type": "code", "execution_count": null, "id": "15", "metadata": {}, "outputs": [], "source": [ "params = pybop.ParameterSet(\n", " params_dict={\n", " \"chemistry\": \"ecm\",\n", " \"Initial SoC\": 0.9 - 0.01,\n", " \"Initial temperature [K]\": 25 + 273.15,\n", " \"Cell capacity [A.h]\": 5,\n", " \"Nominal cell capacity [A.h]\": 5,\n", " \"Ambient temperature [K]\": 25 + 273.15,\n", " \"Current function [A]\": 4.85,\n", " \"Upper voltage cut-off [V]\": 4.2,\n", " \"Lower voltage cut-off [V]\": 3.0,\n", " \"Cell thermal mass [J/K]\": 1000,\n", " \"Cell-jig heat transfer coefficient [W/K]\": 10,\n", " \"Jig thermal mass [J/K]\": 500,\n", " \"Jig-air heat transfer coefficient [W/K]\": 10,\n", " \"Open-circuit voltage [V]\": ocv_LGM50,\n", " \"R0 [Ohm]\": 0.005,\n", " \"Element-1 initial overpotential [V]\": 0,\n", " \"Element-2 initial overpotential [V]\": 0,\n", " \"R1 [Ohm]\": 0.0001,\n", " \"R2 [Ohm]\": 0.0001,\n", " \"C1 [F]\": 3000,\n", " \"C2 [F]\": 6924,\n", " \"Entropic change [V/K]\": 0.0004,\n", " }\n", ")" ] }, { "cell_type": "markdown", "id": "16", "metadata": {}, "source": [ "## Identifying the Parameters\n", "\n", "Now that the initial parameter set is defined, we can start the PyBOP fitting process. First, we construct the model class with two RC elements and the corresponding parameter set." ] }, { "cell_type": "code", "execution_count": null, "id": "17", "metadata": {}, "outputs": [], "source": [ "model = pybop.empirical.Thevenin(\n", " parameter_set=params, options={\"number of rc elements\": 2}\n", ")" ] }, { "cell_type": "markdown", "id": "18", "metadata": {}, "source": [ "In this example, we are going to try to fit all five parameters at once. To do this, we define a `pybop.Parameter` for each fitting parameter and compile them in pybop.Parameters," ] }, { "cell_type": "code", "execution_count": null, "id": "19", "metadata": {}, "outputs": [], "source": [ "parameters = pybop.Parameters(\n", " pybop.Parameter(\n", " \"R0 [Ohm]\",\n", " prior=pybop.Gaussian(0.005, 0.0001),\n", " bounds=[1e-6, 2e-1],\n", " ),\n", " pybop.Parameter(\n", " \"R1 [Ohm]\",\n", " prior=pybop.Gaussian(0.0001, 0.0001),\n", " bounds=[1e-6, 1],\n", " ),\n", " pybop.Parameter(\n", " \"R2 [Ohm]\",\n", " prior=pybop.Gaussian(0.0001, 0.0001),\n", " bounds=[1e-6, 1],\n", " ),\n", " pybop.Parameter(\n", " \"C1 [F]\",\n", " prior=pybop.Gaussian(3000, 2500),\n", " bounds=[0.5, 1e4],\n", " ),\n", " pybop.Parameter(\n", " \"C2 [F]\",\n", " prior=pybop.Gaussian(3000, 2500),\n", " bounds=[0.5, 1e4],\n", " ),\n", ")" ] }, { "cell_type": "markdown", "id": "20", "metadata": {}, "source": [ "We can now form the `pybop.Dataset` from the experimental data. In this situation, the applied current convention is different between the experimental data and PyBOP's convention. This is solved by negating the vector on construction." ] }, { "cell_type": "code", "execution_count": null, "id": "21", "metadata": {}, "outputs": [], "source": [ "dataset = pybop.Dataset(\n", " {\n", " \"Time [s]\": df[\"ProgTime\"].values,\n", " \"Current function [A]\": -df[\"Current\"].values,\n", " \"Voltage [V]\": df[\"Voltage\"].values,\n", " }\n", ")" ] }, { "cell_type": "markdown", "id": "22", "metadata": {}, "source": [ "The `FittingProblem` class provides us with a class to hold all of the objects we need to evaluate our selected `SumSquaredError` cost function. " ] }, { "cell_type": "code", "execution_count": null, "id": "23", "metadata": {}, "outputs": [], "source": [ "problem = pybop.FittingProblem(model, parameters, dataset)\n", "cost = pybop.SumSquaredError(problem)" ] }, { "cell_type": "markdown", "id": "24", "metadata": {}, "source": [ "The cost function can be interrogated manually via the `cost([params])` API. In this example, that would look like the following," ] }, { "cell_type": "code", "execution_count": null, "id": "25", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "/Users/engs2510/Documents/Git/Second_PyBOP/.nox/notebooks-overwrite/lib/python3.12/site-packages/pybamm/solvers/base_solver.py:762: SolverWarning:\n", "\n", "Explicit interpolation times not implemented for CasADi solver with 'safe' mode\n", "\n" ] }, { "data": { "text/plain": [ "1.176754404015497" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "cost([0.01, 0.01, 0.01, 20000, 20000])" ] }, { "cell_type": "markdown", "id": "26", "metadata": {}, "source": [ "Next, we construct the optimisation class with our algorithm of choice and run it. In this case, we select the PSO method as it provides global optimisation capability. After optimisation, we can compare the initial parameters to the identified parameters." ] }, { "cell_type": "code", "execution_count": null, "id": "27", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Halt: No significant change for 55 iterations.\n", "OptimisationResult:\n", " Initial parameters: [5.00912047e-03 2.09128273e-04 1.00999900e-04 1.49995000e+00\n", " 1.49995000e+00]\n", " Optimised parameters: [4.63261982e-02 1.68622272e-01 1.29049708e-02 7.68290523e+03\n", " 5.36867333e+02]\n", " Final cost: 0.026161662141767638\n", " Optimisation time: 2.983907699584961 seconds\n", " Number of iterations: 126\n", " SciPy result available: No\n" ] } ], "source": [ "optim = pybop.PSO(cost, max_unchanged_iterations=55, threshold=1e-6)\n", "results = optim.run()" ] }, { "cell_type": "markdown", "id": "28", "metadata": {}, "source": [ "## Plotting and Visualisation\n", "\n", "PyBOP provides various plotting utilities to visualise the results of the optimisation. The `pybop.plot.quick` method provides a fast scatter of two variables. For this example, we plot the identified parameter set to the target dataset. The default plot is voltage vs time as shown below." ] }, { "cell_type": "code", "execution_count": null, "id": "29", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "pybop.plot.quick(problem, problem_inputs=results.x, title=\"Optimised Comparison\");" ] }, { "cell_type": "markdown", "id": "30", "metadata": {}, "source": [ "### Convergence and Parameter Trajectories\n", "\n", "To assess the optimisation process, we can plot the convergence of the cost function and the trajectories of the parameters:" ] }, { "cell_type": "code", "execution_count": null, "id": "31", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "text/html": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "pybop.plot.convergence(optim)\n", "pybop.plot.parameters(optim);" ] }, { "cell_type": "markdown", "id": "32", "metadata": {}, "source": [ "## Validating the Fit\n", "\n", "We've identified parameters from a single discharge pulse. Now, let's ask ourselves how well these parameters extrapolate to other operational conditions. To accomplish this, let's load a new pulse dataset at 80% SOC and compare it to our identified forward model." ] }, { "cell_type": "code", "execution_count": null, "id": "33", "metadata": {}, "outputs": [], "source": [ "df_pulse_two = pd.DataFrame(\n", " pulse_data[\"LGM50_5Ah_Pulse\"][\"T0\"][\"SoC8\"][\"Cell19\"][\"data\"]\n", ")\n", "df_pulse_two[\"ProgTime\"] = df_pulse_two[\"ProgTime\"] - df_pulse_two[\"ProgTime\"].min()\n", "df_pulse_two.drop_duplicates(subset=[\"ProgTime\"], inplace=True)" ] }, { "cell_type": "markdown", "id": "34", "metadata": {}, "source": [ "Next, we construct a new `pybop.Dataset` from the second pulse data," ] }, { "cell_type": "code", "execution_count": null, "id": "35", "metadata": {}, "outputs": [], "source": [ "dataset_two_pulse = pybop.Dataset(\n", " {\n", " \"Time [s]\": df_pulse_two[\"ProgTime\"].values,\n", " \"Current function [A]\": -df_pulse_two[\"Current\"].values,\n", " \"Voltage [V]\": df_pulse_two[\"Voltage\"].values,\n", " }\n", ")" ] }, { "cell_type": "markdown", "id": "36", "metadata": {}, "source": [ "Now that we have a new dataset, we update the target within the problem class as well as the `Initial SoC` value. Once that has been completed, we rebuild the model." ] }, { "cell_type": "code", "execution_count": null, "id": "37", "metadata": {}, "outputs": [], "source": [ "problem.set_target(dataset_two_pulse)\n", "model.build(dataset=dataset_two_pulse, initial_state={\"Initial SoC\": 0.8 - 0.0075})" ] }, { "cell_type": "markdown", "id": "38", "metadata": {}, "source": [ "Let's plot the parameterised forward model against the new pulse data:" ] }, { "cell_type": "code", "execution_count": null, "id": "39", "metadata": {}, "outputs": [ { "data": { "text/html": [ "
" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "pybop.plot.quick(problem, problem_inputs=results.x, title=\"Parameter Extrapolation\");" ] }, { "cell_type": "markdown", "id": "40", "metadata": {}, "source": [ "As expected, when identifying parameters from a single pulse, extrapolation to different operating conditions is challenging. To solve this issue, parameter identification with various pulse datasets is recommended." ] }, { "cell_type": "markdown", "id": "41", "metadata": {}, "source": [ "### Conclusion\n", "\n", "This notebook illustrates how to perform circuit model parameter identification using PSO in PyBOP, providing insights into the optimisation process through various visualisations." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "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.2" } }, "nbformat": 4, "nbformat_minor": 5 }