From 1ca8d4fdf5835d78462ddd95eafd7564980ee6d2 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 27 Oct 2023 09:56:00 +0100 Subject: [PATCH 001/101] Add Initial Sphinx Docs --- docs/Makefile | 20 ++++++++++++++++++++ docs/make.bat | 35 +++++++++++++++++++++++++++++++++++ docs/source/conf.py | 28 ++++++++++++++++++++++++++++ docs/source/index.rst | 20 ++++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..d0c3cbf1 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..747ffb7b --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000..b5be5c25 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,28 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'PyBOP' +copyright = '2023, Brady Planden, Nicola Courtier, David Howey' +author = 'Brady Planden, Nicola Courtier, David Howey' +release = '23.9' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [] + +templates_path = ['_templates'] +exclude_patterns = [] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000..d84fc8c6 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,20 @@ +.. PyBOP documentation master file, created by + sphinx-quickstart on Fri Oct 27 09:54:56 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to PyBOP's documentation! +================================= + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` From df2f48a28d6dceb0f7be2aa409f0f7363218ef6e Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:45:54 +0000 Subject: [PATCH 002/101] docs: update README.md [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11d7e9be..eb4ff59c 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Brady Planden
Brady Planden

🚇 ⚠️ 💻 💡 - NicolaCourtier
NicolaCourtier

💻 👀 + NicolaCourtier
NicolaCourtier

💻 👀 💡 David Howey
David Howey

🤔 🧑‍🏫 Martin Robinson
Martin Robinson

🤔 🧑‍🏫 From 31b4f6ece0c55388e82734971c0e4aa4128729c2 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:45:55 +0000 Subject: [PATCH 003/101] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 2be30620..73a875e5 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -26,7 +26,8 @@ "profile": "https://github.com/NicolaCourtier", "contributions": [ "code", - "review" + "review", + "example" ] }, { From ac0cc8fae386bb6458e8408db9e85cc113fde465 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:49:56 +0000 Subject: [PATCH 004/101] docs: update README.md [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eb4ff59c..2a6bf07c 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Brady Planden
Brady Planden

🚇 ⚠️ 💻 💡 - NicolaCourtier
NicolaCourtier

💻 👀 💡 + NicolaCourtier
NicolaCourtier

💻 👀 💡 ⚠️ David Howey
David Howey

🤔 🧑‍🏫 Martin Robinson
Martin Robinson

🤔 🧑‍🏫 From a8423ae7a8147197fb3fabfe15ead5aa6db202fd Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:49:57 +0000 Subject: [PATCH 005/101] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 73a875e5..2c16d0e5 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -27,7 +27,8 @@ "contributions": [ "code", "review", - "example" + "example", + "test" ] }, { From fc47adf897b810d6c93676e2cc553677960ea4c0 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:52:48 +0000 Subject: [PATCH 006/101] docs: update README.md [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a6bf07c..8bb37a91 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Brady Planden
Brady Planden

🚇 ⚠️ 💻 💡 NicolaCourtier
NicolaCourtier

💻 👀 💡 ⚠️ David Howey
David Howey

🤔 🧑‍🏫 - Martin Robinson
Martin Robinson

🤔 🧑‍🏫 + Martin Robinson
Martin Robinson

🤔 🧑‍🏫 👀 From 838d375232af2bd813f493168d7bf7893c922413 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:52:49 +0000 Subject: [PATCH 007/101] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 2c16d0e5..2b9f2180 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -48,7 +48,8 @@ "profile": "http://www.rse.ox.ac.uk", "contributions": [ "ideas", - "mentoring" + "mentoring", + "review" ] } ], From adc5f36580c993c0fdf836c0122bb8ba22fb945e Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:52:53 +0000 Subject: [PATCH 008/101] docs: update README.md [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a6bf07c..e85f1164 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - + From 4e7ef962aff2bccb11d7752883f9b4c18c03ffe3 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:52:54 +0000 Subject: [PATCH 009/101] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 2c16d0e5..656d0519 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -16,7 +16,8 @@ "infra", "test", "code", - "example" + "example", + "review" ] }, { From cffbdb62308c4cce51e2eacbb0480e081d6b6677 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:55:54 +0000 Subject: [PATCH 010/101] docs: update README.md [skip ci] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2a6bf07c..25f28c7d 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d +
Brady Planden
Brady Planden

🚇 ⚠️ 💻 💡
Brady Planden
Brady Planden

🚇 ⚠️ 💻 💡 👀
NicolaCourtier
NicolaCourtier

💻 👀 💡 ⚠️
David Howey
David Howey

🤔 🧑‍🏫
Martin Robinson
Martin Robinson

🤔 🧑‍🏫
NicolaCourtier
NicolaCourtier

💻 👀 💡 ⚠️
David Howey
David Howey

🤔 🧑‍🏫
Martin Robinson
Martin Robinson

🤔 🧑‍🏫
Ferran Brosa Planella
Ferran Brosa Planella

👀
From b5936473e4f1d3a07d5f33365a23652f1124f50e Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 16:55:55 +0000 Subject: [PATCH 011/101] docs: update .all-contributorsrc [skip ci] --- .all-contributorsrc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 2c16d0e5..0fb12644 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -50,6 +50,15 @@ "ideas", "mentoring" ] + }, + { + "login": "brosaplanella", + "name": "Ferran Brosa Planella", + "avatar_url": "https://avatars.githubusercontent.com/u/28443643?v=4", + "profile": "https://www.brosaplanella.xyz", + "contributions": [ + "review" + ] } ], "contributorsPerLine": 7, From 78d4f3b8386b547c4d5cd6beca1c6c1b98334df6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:45:43 +0000 Subject: [PATCH 012/101] chore: update pre-commit hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.5 → v0.1.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.5...v0.1.6) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5270e26f..bf4288f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.1.5" + rev: "v0.1.6" hooks: - id: ruff args: [--fix, --show-fixes] From 51e678995b515d0c2c0afdbe33447494e92cb6f8 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 21 Nov 2023 10:21:40 +0000 Subject: [PATCH 013/101] Updt readme installation instructions, Add release tag --- README.md | 47 +++++++++++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 4fc4fea8..85aff733 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ Open In Colab + + + releases +

@@ -48,52 +52,59 @@ The diagram below presents PyBOP's conceptual framework. The PyBOP software spec ## Getting Started -### Prerequisites -To use and/or contribute to PyBOP, first install Python (3.8-3.11). On a Debian-based distribution, this looks like: +### Installation + +Within your virtual environment, install PyBOP: ```bash -sudo apt update -sudo apt install python3 python3-virtualenv +pip install pybop ``` -For further information, please refer to the similar [installation instructions for PyBaMM](https://docs.pybamm.org/en/latest/source/user_guide/installation/GNU-linux.html). +To install the most recent state of PyBOP, install from the `develop` branch, -### Installation +```bash +pip install git+https://github.com/pybop-team/PyBOP.git@develop +``` -Create a virtual environment called `pybop-env` within your current directory: +To alternatively install PyBOP from a local directory, use the following template, substituting in the relevant path: ```bash -virtualenv pybop-env +pip install -e "path/to/pybop" ``` -Activate the environment: +To check whether PyBOP has been installed correctly, run one of the examples in the following section or the full set of unit tests: ```bash -source pybop-env/bin/activate +pytest --unit -v ``` -Later, you can deactivate the environment: +### Prerequisites +To use and/or contribute to PyBOP, first install Python (3.8-3.11). On a Debian-based distribution, this looks like: ```bash -deactivate +sudo apt update +sudo apt install python3 python3-virtualenv ``` -Within your virtual environment, install the `develop` branch of PyBOP: +For further information, please refer to the similar [installation instructions for PyBaMM](https://docs.pybamm.org/en/latest/source/user_guide/installation/GNU-linux.html). + +### Virtual Environments +To create a virtual environment called `pybop-env` within your current directory: ```bash -pip install git+https://github.com/pybop-team/PyBOP.git@develop +virtualenv pybop-env ``` -To alternatively install PyBOP from a local directory, use the following template, substituting in the relevant path: +Activate the environment: ```bash -pip install -e "PATH_TO_PYBOP" +source pybop-env/bin/activate ``` -To check whether PyBOP has been installed correctly, run one of the examples in the following section or the full set of unit tests: +Later, you can deactivate the environment: ```bash -pytest --unit -v +deactivate ``` ### Using PyBOP From 0d5f9acd29b5d02b0cc83b0464f769497412ce54 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 21 Nov 2023 10:25:10 +0000 Subject: [PATCH 014/101] Align badge format --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 85aff733..ba2f09f0 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ license - Open In Colab + open In colab - + releases

From 736f53718490b23dd9a0f110f35c4128012cc4c2 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 21 Nov 2023 10:29:45 +0000 Subject: [PATCH 015/101] Remove paragraph style for badges --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index ba2f09f0..9c57fbe1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ logo

Python Battery Optimisation and Parameterisation

-

+ Scheduled @@ -34,7 +34,6 @@ releases -

From ecf995d8c6f50cc2c19b71bf018e1684e68d0345 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 21 Nov 2023 14:36:18 +0000 Subject: [PATCH 016/101] Add plot_cost2d with example addition --- conftest.py | 2 ++ examples/scripts/CMAES.py | 3 ++ pybop/__init__.py | 2 +- pybop/plotting/plot_cost2D.py | 61 +++++++++++++++++++++++++++++++++++ pybop/plotting/quick_plot.py | 22 ------------- 5 files changed, 67 insertions(+), 23 deletions(-) create mode 100644 pybop/plotting/plot_cost2D.py delete mode 100644 pybop/plotting/quick_plot.py diff --git a/conftest.py b/conftest.py index b37cbd0f..ddb99602 100644 --- a/conftest.py +++ b/conftest.py @@ -1,6 +1,8 @@ import pytest import matplotlib +import plotly +plotly.io.renderers.default = None matplotlib.use("Template") diff --git a/examples/scripts/CMAES.py b/examples/scripts/CMAES.py index 65315b41..0b46d8f3 100644 --- a/examples/scripts/CMAES.py +++ b/examples/scripts/CMAES.py @@ -53,3 +53,6 @@ plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12) plt.tick_params(axis="both", labelsize=12) plt.show() + +# Plot the cost landscape +pybop.plot_cost2D(cost, steps=15) diff --git a/pybop/__init__.py b/pybop/__init__.py index 29dcd88b..37bc7a7e 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -67,7 +67,7 @@ # # Plotting class # -from .plotting.quick_plot import QuickPlot +from .plotting.plot_cost2D import plot_cost2D # # Remove any imported modules, so we don't expose them as part of pybop diff --git a/pybop/plotting/plot_cost2D.py b/pybop/plotting/plot_cost2D.py new file mode 100644 index 00000000..6b6ba7fc --- /dev/null +++ b/pybop/plotting/plot_cost2D.py @@ -0,0 +1,61 @@ +import numpy as np + + +def plot_cost2D(cost, steps=10): + """ + Query the cost landscape for a given parameter space and plot using plotly. + """ + + # Set up parameter bounds + bounds = get_param_bounds(cost) + + # Generate grid + x = np.linspace(bounds[0, 0], bounds[0, 1], steps) + y = np.linspace(bounds[1, 0], bounds[1, 1], steps) + + # Initialize cost matrix + costs = np.zeros((len(x), len(y))) + + # Populate cost matrix + for i, xi in enumerate(x): + for j, yj in enumerate(y): + costs[i, j] = cost([xi, yj]) + + # Create figure + fig = create_figure(x, y, costs, bounds, cost.problem.parameters) + + # Display figure + fig.show() + + return fig + + +def get_param_bounds(cost): + """ + Use parameters bounds for range of cost landscape + """ + bounds = np.empty((len(cost.problem.parameters), 2)) + for i, param in enumerate(cost.problem.parameters): + bounds[i] = param.bounds + return bounds + + +def create_figure(x, y, z, bounds, params): + # Import plotly only when needed + import plotly.graph_objects as go + + fig = go.Figure(data=[go.Contour(x=x, y=y, z=z)]) + # Set figure properties + fig.update_layout( + title="Cost Landscape", + title_x=0.5, + title_y=0.9, + xaxis_title=params[0].name, + yaxis_title=params[1].name, + width=600, + height=600, + xaxis=dict(range=bounds[0]), + yaxis=dict(range=bounds[1]), + ) + + return fig diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py deleted file mode 100644 index 5acbb262..00000000 --- a/pybop/plotting/quick_plot.py +++ /dev/null @@ -1,22 +0,0 @@ -class QuickPlot: - """ - - Class to generate plots with standard variables and formatting. - - Plots - -------------- - Observability - if method == parameterisation - - Comparison of fitting data with optimised forward model - - elseif method == optimisation - - Pareto front - Alternative solutions - Initial value compared to optimal - - """ - - def __init__(self): - self.name = "Quick Plot" From 2b06d18ff5dedd33f49bcb9bac503520f422483a Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 21 Nov 2023 16:20:54 +0000 Subject: [PATCH 017/101] Updt noxfile, setup.py for plotly dependency --- noxfile.py | 2 +- setup.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index c88e483e..a2099a56 100644 --- a/noxfile.py +++ b/noxfile.py @@ -6,7 +6,7 @@ @nox.session def unit(session): - session.run_always("pip", "install", "-e", ".") + session.run_always("pip", "install", "-e", ".[all]") session.install("pytest") session.run("pytest", "--unit", "-v") diff --git a/setup.py b/setup.py index 4d6b63a6..4591c384 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,10 @@ "nlopt>=2.6", "pints>=0.5", ], + extras_require={ + "plot": ["plotly>=5.0"], + "all": ["pybop[plot]"], + }, # https://pypi.org/classifiers/ classifiers=[], python_requires=">=3.8,<=3.12", From 68d5a4dd87a8e54339d9febcfb1625b087b958d6 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 21 Nov 2023 16:28:06 +0000 Subject: [PATCH 018/101] Updt remaining noxfile sessions for [plot] --- noxfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index a2099a56..0640bf86 100644 --- a/noxfile.py +++ b/noxfile.py @@ -13,7 +13,7 @@ def unit(session): @nox.session def coverage(session): - session.run_always("pip", "install", "-e", ".") + session.run_always("pip", "install", "-e", ".[all]") session.install("pytest-cov") session.run("pytest", "--unit", "-v", "--cov", "--cov-report=xml") @@ -21,6 +21,6 @@ def coverage(session): @nox.session def notebooks(session): """Run the examples tests for Jupyter notebooks.""" - session.run_always("pip", "install", "-e", ".") + session.run_always("pip", "install", "-e", ".[all]") session.install("pytest", "nbmake") session.run("pytest", "--nbmake", "examples/", external=True) From 3fcba9329faa3407e2ac580469502a5c47318782 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 22 Nov 2023 12:31:55 +0000 Subject: [PATCH 019/101] Initial CHANGELOG.md --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..f4155c6e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# [Unreleased](https://github.com/pybop-team/PyBOP) + +# [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11) + - Initial release + - Adds Pints, NLOpt, and SciPy optimisers + - Adds SumofSquareError and RootMeanSquareError cost functions + - Adds Parameter and dataset classes From 35212bb07891bee4afcb5ee3e436a57230ddc5b7 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 22 Nov 2023 13:35:11 +0000 Subject: [PATCH 020/101] Add pints' optimisers and corresponding examples --- examples/scripts/{CMAES.py => spm_CMAES.py} | 2 +- examples/scripts/spm_IRPropMin.py | 55 ++++++++++++++ examples/scripts/spm_SNES.py | 55 ++++++++++++++ examples/scripts/spm_XNES.py | 55 ++++++++++++++ examples/scripts/spm_adam.py | 59 +++++++++++++++ examples/scripts/spm_pso.py | 55 ++++++++++++++ pybop/__init__.py | 2 +- pybop/optimisers/pints_optimisers.py | 84 ++++++++++++++++++++- 8 files changed, 363 insertions(+), 4 deletions(-) rename examples/scripts/{CMAES.py => spm_CMAES.py} (96%) create mode 100644 examples/scripts/spm_IRPropMin.py create mode 100644 examples/scripts/spm_SNES.py create mode 100644 examples/scripts/spm_XNES.py create mode 100644 examples/scripts/spm_adam.py create mode 100644 examples/scripts/spm_pso.py diff --git a/examples/scripts/CMAES.py b/examples/scripts/spm_CMAES.py similarity index 96% rename from examples/scripts/CMAES.py rename to examples/scripts/spm_CMAES.py index 65315b41..7f044409 100644 --- a/examples/scripts/CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -3,7 +3,7 @@ import matplotlib.pyplot as plt parameter_set = pybop.ParameterSet("pybamm", "Chen2020") -model = pybop.lithium_ion.SPMe(parameter_set=parameter_set) +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Fitting parameters parameters = [ diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py new file mode 100644 index 00000000..2d4dd2ec --- /dev/null +++ b/examples/scripts/spm_IRPropMin.py @@ -0,0 +1,55 @@ +import pybop +import numpy as np +import matplotlib.pyplot as plt + +parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) + +# Fitting parameters +parameters = [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.7, 0.05), + bounds=[0.6, 0.9], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.58, 0.05), + bounds=[0.5, 0.8], + ), +] + +sigma = 0.001 +t_eval = np.arange(0, 900, 2) +values = model.predict(t_eval=t_eval) +CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( + 0, sigma, len(t_eval) +) + +dataset = [ + pybop.Dataset("Time [s]", t_eval), + pybop.Dataset("Current function [A]", values["Current [A]"].data), + pybop.Dataset("Terminal voltage [V]", CorruptValues), +] + +# Generate problem, cost function, and optimisation class +problem = pybop.Problem(model, parameters, dataset) +cost = pybop.SumSquaredError(problem) +optim = pybop.Optimisation(cost, optimiser=pybop.IRPropMin) +optim.set_max_iterations(100) + +x, final_cost = optim.run() +print("Estimated parameters:", x) + +# Show the generated data +simulated_values = problem.evaluate(x) + +plt.figure(dpi=100) +plt.xlabel("Time", fontsize=12) +plt.ylabel("Values", fontsize=12) +plt.plot(t_eval, CorruptValues, label="Measured") +plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2) +plt.plot(t_eval, simulated_values, label="Simulated") +plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12) +plt.tick_params(axis="both", labelsize=12) +plt.show() diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py new file mode 100644 index 00000000..f5db3c9b --- /dev/null +++ b/examples/scripts/spm_SNES.py @@ -0,0 +1,55 @@ +import pybop +import numpy as np +import matplotlib.pyplot as plt + +parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) + +# Fitting parameters +parameters = [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.7, 0.05), + bounds=[0.6, 0.9], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.58, 0.05), + bounds=[0.5, 0.8], + ), +] + +sigma = 0.001 +t_eval = np.arange(0, 900, 2) +values = model.predict(t_eval=t_eval) +CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( + 0, sigma, len(t_eval) +) + +dataset = [ + pybop.Dataset("Time [s]", t_eval), + pybop.Dataset("Current function [A]", values["Current [A]"].data), + pybop.Dataset("Terminal voltage [V]", CorruptValues), +] + +# Generate problem, cost function, and optimisation class +problem = pybop.Problem(model, parameters, dataset) +cost = pybop.SumSquaredError(problem) +optim = pybop.Optimisation(cost, optimiser=pybop.SNES) +optim.set_max_iterations(100) + +x, final_cost = optim.run() +print("Estimated parameters:", x) + +# Show the generated data +simulated_values = problem.evaluate(x) + +plt.figure(dpi=100) +plt.xlabel("Time", fontsize=12) +plt.ylabel("Values", fontsize=12) +plt.plot(t_eval, CorruptValues, label="Measured") +plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2) +plt.plot(t_eval, simulated_values, label="Simulated") +plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12) +plt.tick_params(axis="both", labelsize=12) +plt.show() diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py new file mode 100644 index 00000000..37939245 --- /dev/null +++ b/examples/scripts/spm_XNES.py @@ -0,0 +1,55 @@ +import pybop +import numpy as np +import matplotlib.pyplot as plt + +parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) + +# Fitting parameters +parameters = [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.7, 0.05), + bounds=[0.6, 0.9], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.58, 0.05), + bounds=[0.5, 0.8], + ), +] + +sigma = 0.001 +t_eval = np.arange(0, 900, 2) +values = model.predict(t_eval=t_eval) +CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( + 0, sigma, len(t_eval) +) + +dataset = [ + pybop.Dataset("Time [s]", t_eval), + pybop.Dataset("Current function [A]", values["Current [A]"].data), + pybop.Dataset("Terminal voltage [V]", CorruptValues), +] + +# Generate problem, cost function, and optimisation class +problem = pybop.Problem(model, parameters, dataset) +cost = pybop.SumSquaredError(problem) +optim = pybop.Optimisation(cost, optimiser=pybop.XNES) +optim.set_max_iterations(100) + +x, final_cost = optim.run() +print("Estimated parameters:", x) + +# Show the generated data +simulated_values = problem.evaluate(x) + +plt.figure(dpi=100) +plt.xlabel("Time", fontsize=12) +plt.ylabel("Values", fontsize=12) +plt.plot(t_eval, CorruptValues, label="Measured") +plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2) +plt.plot(t_eval, simulated_values, label="Simulated") +plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12) +plt.tick_params(axis="both", labelsize=12) +plt.show() diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py new file mode 100644 index 00000000..27949e9a --- /dev/null +++ b/examples/scripts/spm_adam.py @@ -0,0 +1,59 @@ +import pybop +import numpy as np +import matplotlib.pyplot as plt + +# Parameter set and model definition +parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +model = pybop.lithium_ion.SPMe(parameter_set=parameter_set) + +# Fitting parameters +parameters = [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.7, 0.05), + bounds=[0.6, 0.9], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.58, 0.05), + bounds=[0.5, 0.8], + ), +] + +# Generate data +sigma = 0.001 +t_eval = np.arange(0, 900, 2) +values = model.predict(t_eval=t_eval) +corrupt_values = values["Terminal voltage [V]"].data + np.random.normal( + 0, sigma, len(t_eval) +) + +# Dataset definition +dataset = [ + pybop.Dataset("Time [s]", t_eval), + pybop.Dataset("Current function [A]", values["Current [A]"].data), + pybop.Dataset("Terminal voltage [V]", corrupt_values), +] + +# Generate problem, cost function, and optimisation class +problem = pybop.Problem(model, parameters, dataset) +cost = pybop.SumSquaredError(problem) +optim = pybop.Optimisation(cost, optimiser=pybop.Adam) +optim.set_max_iterations(100) + +# Run optimisation +x, final_cost = optim.run() +print("Estimated parameters:", x) + +# Show the generated data +simulated_values = problem.evaluate(x) + +plt.figure(dpi=100) +plt.xlabel("Time", fontsize=12) +plt.ylabel("Values", fontsize=12) +plt.plot(t_eval, corrupt_values, label="Measured") +plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2) +plt.plot(t_eval, simulated_values, label="Simulated") +plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12) +plt.tick_params(axis="both", labelsize=12) +plt.show() diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py new file mode 100644 index 00000000..9a9cb5aa --- /dev/null +++ b/examples/scripts/spm_pso.py @@ -0,0 +1,55 @@ +import pybop +import numpy as np +import matplotlib.pyplot as plt + +parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) + +# Fitting parameters +parameters = [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.7, 0.05), + bounds=[0.6, 0.9], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.58, 0.05), + bounds=[0.5, 0.8], + ), +] + +sigma = 0.001 +t_eval = np.arange(0, 900, 2) +values = model.predict(t_eval=t_eval) +CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( + 0, sigma, len(t_eval) +) + +dataset = [ + pybop.Dataset("Time [s]", t_eval), + pybop.Dataset("Current function [A]", values["Current [A]"].data), + pybop.Dataset("Terminal voltage [V]", CorruptValues), +] + +# Generate problem, cost function, and optimisation class +problem = pybop.Problem(model, parameters, dataset) +cost = pybop.SumSquaredError(problem) +optim = pybop.Optimisation(cost, optimiser=pybop.PSO) +optim.set_max_iterations(100) + +x, final_cost = optim.run() +print("Estimated parameters:", x) + +# Show the generated data +simulated_values = problem.evaluate(x) + +plt.figure(dpi=100) +plt.xlabel("Time", fontsize=12) +plt.ylabel("Values", fontsize=12) +plt.plot(t_eval, CorruptValues, label="Measured") +plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2) +plt.plot(t_eval, simulated_values, label="Simulated") +plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12) +plt.tick_params(axis="both", labelsize=12) +plt.show() diff --git a/pybop/__init__.py b/pybop/__init__.py index 29dcd88b..b4006b3c 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -50,7 +50,7 @@ from .optimisers.base_optimiser import BaseOptimiser from .optimisers.nlopt_optimize import NLoptOptimize from .optimisers.scipy_minimize import SciPyMinimize -from .optimisers.pints_optimisers import GradientDescent, CMAES +from .optimisers.pints_optimisers import GradientDescent, Adam, CMAES, IRPropMin, PSO, SNES, XNES # # Parameter classes diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 6524cb60..741c6651 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -4,19 +4,99 @@ class GradientDescent(pints.GradientDescent): """ Gradient descent optimiser. Inherits from the PINTS gradient descent class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_gradient_descent.py """ def __init__(self, x0, sigma0=0.1, bounds=None): if bounds is not None: print("Boundaries ignored by GradientDescent") - boundaries = None # Bounds ignored in pints.GradDesc - super().__init__(x0, sigma0, boundaries) + self.boundaries = None # Bounds ignored in pints.GradDesc + super().__init__(x0, sigma0, self.boundaries) + + +class Adam(pints.Adam): + """ + Adam optimiser. Inherits from the PINTS Adam class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_adam.py + """ + + def __init__(self, x0, sigma0=0.1, bounds=None): + if bounds is not None: + print("Boundaries ignored by Adam") + + self.boundaries = None # Bounds ignored in pints.Adam + super().__init__(x0, sigma0, self.boundaries) + + +class IRPropMin(pints.IRPropMin): + """ + IRProp- optimiser. Inherits from the PINTS IRPropMinus class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_irpropmin.py + """ + + def __init__(self, x0, sigma0=0.1, bounds=None): + if bounds is not None: + self.boundaries = pints.RectangularBoundaries( + bounds["lower"], bounds["upper"] + ) + else: + self.boundaries = None + super().__init__(x0, sigma0, self.boundaries) + + +class PSO(pints.PSO): + """ + Particle swarm optimiser. Inherits from the PINTS PSO class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_pso.py + """ + + def __init__(self, x0, sigma0=0.1, bounds=None): + if bounds is not None: + self.boundaries = pints.RectangularBoundaries( + bounds["lower"], bounds["upper"] + ) + else: + self.boundaries = None + super().__init__(x0, sigma0, self.boundaries) + + +class SNES(pints.SNES): + """ + Stochastic natural evolution strategy optimiser. Inherits from the PINTS SNES class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_snes.py + """ + + def __init__(self, x0, sigma0=0.1, bounds=None): + if bounds is not None: + self.boundaries = pints.RectangularBoundaries( + bounds["lower"], bounds["upper"] + ) + else: + self.boundaries = None + super().__init__(x0, sigma0, self.boundaries) + + +class XNES(pints.XNES): + """ + Exponential natural evolution strategy optimiser. Inherits from the PINTS XNES class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_xnes.py + """ + + def __init__(self, x0, sigma0=0.1, bounds=None): + if bounds is not None: + self.boundaries = pints.RectangularBoundaries( + bounds["lower"], bounds["upper"] + ) + else: + self.boundaries = None + super().__init__(x0, sigma0, self.boundaries) class CMAES(pints.CMAES): """ Class for the PINTS optimisation. Extends the BaseOptimiser class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_cmaes.py """ def __init__(self, x0, sigma0=0.1, bounds=None): From 0ea2a1c9a2f6be17b0b6c719abf6007e852823bb Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 22 Nov 2023 13:51:28 +0000 Subject: [PATCH 021/101] Updt Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4155c6e..7c094508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # [Unreleased](https://github.com/pybop-team/PyBOP) + - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class + # [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11) - Initial release - Adds Pints, NLOpt, and SciPy optimisers From 95945ce92db86fbccc642ff2bec1292cfce4c809 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 22 Nov 2023 14:20:34 +0000 Subject: [PATCH 022/101] Add pytest --examples marker, updt. tests for addition optimisers, rm multi-soc from test_spm_optimisers + updt. to SPM --- conftest.py | 18 ++++++++++---- pybop/__init__.py | 10 +++++++- tests/unit/test_examples.py | 2 +- tests/unit/test_parameterisations.py | 37 +++++++++++++++++++++++----- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/conftest.py b/conftest.py index b37cbd0f..3b3e2442 100644 --- a/conftest.py +++ b/conftest.py @@ -12,13 +12,21 @@ def pytest_addoption(parser): def pytest_configure(config): config.addinivalue_line("markers", "unit: mark test as a unit test") + config.addinivalue_line("markers", "examples: mark test as an example") def pytest_collection_modifyitems(config, items): + def skip_marker(marker_name, reason): + skip = pytest.mark.skip(reason=reason) + for item in items: + if marker_name in item.keywords: + item.add_marker(skip) + if config.getoption("--unit"): - # --unit given in cli: do not skip unit tests + skip_marker("examples", "need --examples option to run") + return + + if config.getoption("--examples"): return - skip_unit = pytest.mark.skip(reason="need --unit option to run") - for item in items: - if "unit" in item.keywords: - item.add_marker(skip_unit) + + skip_marker("unit", "need --unit option to run") diff --git a/pybop/__init__.py b/pybop/__init__.py index b4006b3c..0e933b8e 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -50,7 +50,15 @@ from .optimisers.base_optimiser import BaseOptimiser from .optimisers.nlopt_optimize import NLoptOptimize from .optimisers.scipy_minimize import SciPyMinimize -from .optimisers.pints_optimisers import GradientDescent, Adam, CMAES, IRPropMin, PSO, SNES, XNES +from .optimisers.pints_optimisers import ( + GradientDescent, + Adam, + CMAES, + IRPropMin, + PSO, + SNES, + XNES, +) # # Parameter classes diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py index 6e8fc09e..dffa084e 100644 --- a/tests/unit/test_examples.py +++ b/tests/unit/test_examples.py @@ -9,7 +9,7 @@ class TestExamples: A class to test the example scripts. """ - @pytest.mark.unit + @pytest.mark.examples def test_example_scripts(self): path_to_example_scripts = os.path.join( pybop.script_path, "..", "examples", "scripts" diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py index 142e590d..c1810063 100644 --- a/tests/unit/test_parameterisations.py +++ b/tests/unit/test_parameterisations.py @@ -62,12 +62,12 @@ def test_spm(self, init_soc): np.testing.assert_allclose(final_cost, 0, atol=1e-2) np.testing.assert_allclose(x, x0, atol=1e-1) - @pytest.mark.parametrize("init_soc", [0.3, 0.7]) + @pytest.mark.parametrize("init_soc", [0.5]) @pytest.mark.unit - def test_spme_optimisers(self, init_soc): + def test_spm_optimisers(self, init_soc): # Define model parameter_set = pybop.ParameterSet("pybamm", "Chen2020") - model = pybop.lithium_ion.SPMe(parameter_set=parameter_set) + model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Form dataset x0 = np.array([0.52, 0.63]) @@ -100,16 +100,26 @@ def test_spme_optimisers(self, init_soc): problem = pybop.Problem( model, parameters, dataset, signal=signal, init_soc=init_soc ) - cost = pybop.RootMeanSquaredError(problem) + cost = pybop.SumSquaredError(problem) # Select optimisers - optimisers = [pybop.NLoptOptimize, pybop.SciPyMinimize, pybop.CMAES] + optimisers = [ + pybop.NLoptOptimize, + pybop.SciPyMinimize, + pybop.CMAES, + pybop.Adam, + pybop.GradientDescent, + pybop.PSO, + pybop.XNES, + pybop.SNES, + pybop.IRPropMin, + ] # Test each optimiser for optimiser in optimisers: parameterisation = pybop.Optimisation(cost=cost, optimiser=optimiser) - if optimiser == pybop.CMAES: + if optimiser in [pybop.CMAES]: parameterisation.set_f_guessed_tracking(True) assert parameterisation._use_f_guessed is True parameterisation.set_max_iterations(1) @@ -121,6 +131,21 @@ def test_spme_optimisers(self, init_soc): x, final_cost = parameterisation.run() assert parameterisation._max_iterations == 250 + elif optimiser in [pybop.GradientDescent]: + parameterisation.optimiser.set_learning_rate(0.025) + parameterisation.set_max_iterations(250) + x, final_cost = parameterisation.run() + + elif optimiser in [ + pybop.PSO, + pybop.XNES, + pybop.SNES, + pybop.Adam, + pybop.IRPropMin, + ]: + parameterisation.set_max_iterations(250) + x, final_cost = parameterisation.run() + else: x, final_cost = parameterisation.run() From e0053c802aa6ad1df80a7550c694f66413030ee3 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 22 Nov 2023 14:47:44 +0000 Subject: [PATCH 023/101] Updt noxfile w/ pytest --show-locals log output argument --- noxfile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index c88e483e..b775c34c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -8,14 +8,14 @@ def unit(session): session.run_always("pip", "install", "-e", ".") session.install("pytest") - session.run("pytest", "--unit", "-v") + session.run("pytest", "--unit", "-v", "--showlocals") @nox.session def coverage(session): session.run_always("pip", "install", "-e", ".") session.install("pytest-cov") - session.run("pytest", "--unit", "-v", "--cov", "--cov-report=xml") + session.run("pytest", "--unit", "-v", "--cov", "--cov-report=xml", "--showlocals") @nox.session From 94a561252abe405498be95896f25778b2d99bcde Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 22 Nov 2023 15:37:55 +0000 Subject: [PATCH 024/101] Add SPMe test for coverage --- tests/unit/test_models.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index ce73000a..6925391d 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -34,7 +34,7 @@ def test_predict_without_pybamm(self): @pytest.mark.unit def test_predict_with_inputs(self): - # Define model + # Define SPM model = pybop.lithium_ion.SPM() t_eval = np.linspace(0, 10, 100) inputs = { @@ -45,6 +45,11 @@ def test_predict_with_inputs(self): res = model.predict(t_eval=t_eval, inputs=inputs) assert len(res["Terminal voltage [V]"].data) == 100 + # Define SPMe + model = pybop.lithium_ion.SPMe() + res = model.predict(t_eval=t_eval, inputs=inputs) + assert len(res["Terminal voltage [V]"].data) == 100 + @pytest.mark.unit def test_build(self): model = pybop.lithium_ion.SPM() From 95bb2beba0e55675c0aa9d07461858f51883a587 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 22 Nov 2023 15:54:49 +0000 Subject: [PATCH 025/101] Updt. pytest markers and logic, add examples to noxfile cov --- conftest.py | 4 ++++ noxfile.py | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index 3b3e2442..8aa4af3a 100644 --- a/conftest.py +++ b/conftest.py @@ -8,6 +8,9 @@ def pytest_addoption(parser): parser.addoption( "--unit", action="store_true", default=False, help="run unit tests" ) + parser.addoption( + "--examples", action="store_true", default=False, help="run examples tests" + ) def pytest_configure(config): @@ -27,6 +30,7 @@ def skip_marker(marker_name, reason): return if config.getoption("--examples"): + skip_marker("unit", "need --unit option to run") return skip_marker("unit", "need --unit option to run") diff --git a/noxfile.py b/noxfile.py index b775c34c..195703d6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -15,7 +15,15 @@ def unit(session): def coverage(session): session.run_always("pip", "install", "-e", ".") session.install("pytest-cov") - session.run("pytest", "--unit", "-v", "--cov", "--cov-report=xml", "--showlocals") + session.run( + "pytest", + "--unit", + "--examples", + "-v", + "--cov", + "--cov-report=xml", + "--showlocals", + ) @nox.session From d88430977b706bf80aeb100741eebbb46af42aa0 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 23 Nov 2023 10:28:10 +0000 Subject: [PATCH 026/101] Refactor optimisation tests, add logic tests for new optimisers w/ bounds=None --- tests/unit/test_optimisation.py | 152 ++++++++++++++------------------ 1 file changed, 65 insertions(+), 87 deletions(-) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 5bbb4998..21ecf16a 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -9,58 +9,17 @@ class TestOptimisation: A class to test the optimisation class. """ - @pytest.mark.unit - def test_standalone(self): - # Build an Optimisation problem with a StandaloneCost - cost = StandaloneCost() - - opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize) - - assert len(opt.x0) == opt.n_parameters - - x, final_cost = opt.run() - - np.testing.assert_allclose(x, 0, atol=1e-2) - np.testing.assert_allclose(final_cost, 42, atol=1e-2) - - @pytest.mark.unit - def test_prior_sampling(self): - # Tests prior sampling - model = pybop.lithium_ion.SPM() - - dataset = [ - pybop.Dataset("Time [s]", np.linspace(0, 3600, 100)), - pybop.Dataset("Current function [A]", np.zeros(100)), - pybop.Dataset("Terminal voltage [V]", np.ones(100)), - ] - - param = [ - pybop.Parameter( - "Negative electrode active material volume fraction", - prior=pybop.Gaussian(0.75, 0.2), - bounds=[0.73, 0.77], - ) - ] - - signal = "Terminal voltage [V]" - problem = pybop.Problem(model, param, dataset, signal=signal) - cost = pybop.RootMeanSquaredError(problem) - - for i in range(50): - opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize) - - assert opt.x0 <= 0.77 and opt.x0 >= 0.73 - - @pytest.mark.unit - def test_optimiser_construction(self): - # Tests construction of optimisers - - dataset = [ + @pytest.fixture + def dataset(self): + return [ pybop.Dataset("Time [s]", np.linspace(0, 360, 10)), pybop.Dataset("Current function [A]", np.zeros(10)), pybop.Dataset("Terminal voltage [V]", np.ones(10)), ] - parameters = [ + + @pytest.fixture + def parameters(self): + return [ pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Gaussian(0.75, 0.2), @@ -68,69 +27,88 @@ def test_optimiser_construction(self): ) ] - problem = pybop.Problem( + @pytest.fixture + def problem(self, parameters, dataset): + return pybop.Problem( pybop.lithium_ion.SPM(), parameters, dataset, signal="Terminal voltage [V]" ) - cost = pybop.SumSquaredError(problem) - # Test construction of optimisers - # NLopt - opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize) - assert opt.optimiser is not None - assert opt.optimiser.name == "NLoptOptimize" - assert opt.optimiser.n_param == 1 + @pytest.fixture + def cost(self, problem): + return pybop.SumSquaredError(problem) + + @pytest.mark.parametrize( + "optimiser_class, expected_name", + [ + (pybop.NLoptOptimize, "NLoptOptimize"), + (pybop.SciPyMinimize, "SciPyMinimize"), + (pybop.GradientDescent, "Gradient descent"), + (pybop.Adam, "Adam"), + (pybop.CMAES, "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)"), + (pybop.SNES, "Seperable Natural Evolution Strategy (SNES)"), + (pybop.XNES, "Exponential Natural Evolution Strategy (xNES)"), + (pybop.PSO, "Particle Swarm Optimisation (PSO)"), + (pybop.IRPropMin, "iRprop-"), + ], + ) + @pytest.mark.unit + def test_optimiser_classes(self, cost, optimiser_class, expected_name): + if optimiser_class not in [pybop.NLoptOptimize, pybop.SciPyMinimize]: + cost.bounds = None + opt = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + assert opt.optimiser.boundaries is None + assert opt.optimiser.name() == expected_name + else: + opt = pybop.Optimisation(cost=cost, optimiser=optimiser_class) + assert opt.optimiser.name == expected_name - # Gradient Descent - opt = pybop.Optimisation(cost=cost, optimiser=pybop.GradientDescent) assert opt.optimiser is not None + if optimiser_class == pybop.NLoptOptimize: + assert opt.optimiser.n_param == 1 - # None + @pytest.mark.unit + def test_default_optimiser_with_bounds(self, cost): opt = pybop.Optimisation(cost=cost) - assert opt.optimiser is not None assert ( opt.optimiser.name() == "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)" ) - # None with no bounds + @pytest.mark.unit + def test_default_optimiser_no_bounds(self, cost): cost.bounds = None opt = pybop.Optimisation(cost=cost) assert opt.optimiser.boundaries is None - # SciPy - opt = pybop.Optimisation(cost=cost, optimiser=pybop.SciPyMinimize) - assert opt.optimiser is not None - assert opt.optimiser.name == "SciPyMinimize" - - # Incorrect class - class randomclass: + @pytest.mark.unit + def test_incorrect_optimiser_class(self, cost): + class RandomClass: pass with pytest.raises(ValueError): - pybop.Optimisation(cost=cost, optimiser=randomclass) + pybop.Optimisation(cost=cost, optimiser=RandomClass) @pytest.mark.unit - def test_halting(self): - # Tests halting criteria - model = pybop.lithium_ion.SPM() - - dataset = [ - pybop.Dataset("Time [s]", np.linspace(0, 3600, 100)), - pybop.Dataset("Current function [A]", np.zeros(100)), - pybop.Dataset("Terminal voltage [V]", np.ones(100)), - ] + def test_standalone(self): + # Build an Optimisation problem with a StandaloneCost + cost = StandaloneCost() + opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize) + x, final_cost = opt.run() - param = [ - pybop.Parameter( - "Negative electrode active material volume fraction", - prior=pybop.Gaussian(0.75, 0.2), - bounds=[0.73, 0.77], - ) - ] + assert len(opt.x0) == opt.n_parameters + np.testing.assert_allclose(x, 0, atol=1e-2) + np.testing.assert_allclose(final_cost, 42, atol=1e-2) + + @pytest.mark.unit + def test_prior_sampling(self, cost): + # Tests prior sampling + for i in range(50): + opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize) - problem = pybop.Problem(model, param, dataset, signal="Terminal voltage [V]") - cost = pybop.SumSquaredError(problem) + assert opt.x0 <= 0.77 and opt.x0 >= 0.73 + @pytest.mark.unit + def test_halting(self, cost): # Test max evalutions optim = pybop.Optimisation(cost=cost, optimiser=pybop.GradientDescent) optim.set_max_evaluations(10) From c2e32f07fcfc9ecf6e002c3236991ad26ad5912c Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 23 Nov 2023 12:55:59 +0000 Subject: [PATCH 027/101] Add quick_plot() --- examples/scripts/CMAES.py | 17 ++------ pybop/__init__.py | 1 + pybop/_problem.py | 6 +++ pybop/plotting/quick_plot.py | 85 ++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 pybop/plotting/quick_plot.py diff --git a/examples/scripts/CMAES.py b/examples/scripts/CMAES.py index 0b46d8f3..120d6978 100644 --- a/examples/scripts/CMAES.py +++ b/examples/scripts/CMAES.py @@ -1,6 +1,5 @@ import pybop import numpy as np -import matplotlib.pyplot as plt parameter_set = pybop.ParameterSet("pybamm", "Chen2020") model = pybop.lithium_ion.SPMe(parameter_set=parameter_set) @@ -19,7 +18,7 @@ ), ] -sigma = 0.001 +sigma = 0.01 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( @@ -41,18 +40,8 @@ x, final_cost = optim.run() print("Estimated parameters:", x) -# Show the generated data -simulated_values = problem.evaluate(x) - -plt.figure(dpi=100) -plt.xlabel("Time", fontsize=12) -plt.ylabel("Values", fontsize=12) -plt.plot(t_eval, CorruptValues, label="Measured") -plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2) -plt.plot(t_eval, simulated_values, label="Simulated") -plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12) -plt.tick_params(axis="both", labelsize=12) -plt.show() +# Plot the timeseries output +pybop.quick_plot(x, cost) # Plot the cost landscape pybop.plot_cost2D(cost, steps=15) diff --git a/pybop/__init__.py b/pybop/__init__.py index 37bc7a7e..b9e5c8df 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -68,6 +68,7 @@ # Plotting class # from .plotting.plot_cost2D import plot_cost2D +from .plotting.quick_plot import quick_plot # # Remove any imported modules, so we don't expose them as part of pybop diff --git a/pybop/_problem.py b/pybop/_problem.py index 469b6504..d625b4ea 100644 --- a/pybop/_problem.py +++ b/pybop/_problem.py @@ -95,3 +95,9 @@ def evaluateS1(self, parameters): ) return (np.asarray(y), np.asarray(dy)) + + def target(self): + """ + Returns the target dataset. + """ + return self._target diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py new file mode 100644 index 00000000..83cb828d --- /dev/null +++ b/pybop/plotting/quick_plot.py @@ -0,0 +1,85 @@ +import numpy as np + + +def quick_plot(params, cost, width=720, height=540): + """ + Plot the target dataset against the minimised model output. + + Inputs: + ------- + x: array + Optimised parameters + cost: cost object + Cost object containing the problem, dataset, and signal + """ + + # Generate the model output + x = cost.problem._dataset["Time [s]"].data + y = cost.problem.evaluate(params) + y2 = cost.problem.target() + + # Create figure + fig = create_figure(x, y, y2, cost, width, height) + + # Display figure + fig.show() + + return fig + + +def create_figure(x, y, y2, cost, width, height): + # Import plotly only when needed + import plotly.graph_objs as go + + # Estimate the uncertainty (sigma) of the model output + x = x.tolist() + sigma = np.std(y - y2) + y_upper = (y + sigma).tolist() + y_lower = (y - sigma).tolist() + + # Create traces for the measured and simulated values + target_trace = go.Scatter( + x=x, + y=y2, + line=dict(color="rgb(102,102,255,0.1)"), + mode="markers", + name="Target", + ) + simulated_trace = go.Scatter( + x=x, y=y, line=dict(width=4, color="rgb(255,128,0)"), mode="lines", name="Model" + ) + + # Create a trace for the fill area representing the uncertainty (sigma) + fill_trace = go.Scatter( + x=x + x[::-1], + y=y_upper + y_lower[::-1], + fill="toself", + fillcolor="rgba(255,229,204,0.8)", + line=dict(color="rgba(255,255,255,0)"), + hoverinfo="skip", + showlegend=False, + ) + + # Define the layout for the plot + layout = go.Layout( + title="Optimised Comparison", + title_x=0.55, + title_y=0.9, + xaxis=dict(title="Time [s]", titlefont_size=12, tickfont_size=12), + yaxis=dict(title=cost.problem.signal, titlefont_size=12, tickfont_size=12), + legend=dict(x=0.85, y=1, xanchor="left", yanchor="top", font_size=12), + showlegend=True, + ) + + # Combine the traces and layout into a figure + fig = go.Figure(data=[fill_trace, target_trace, simulated_trace], layout=layout) + + # Update the figure to adjust the layout and axis properties + fig.update_layout( + autosize=False, + width=width, + height=height, + margin=dict(l=10, r=10, b=10, t=75, pad=4), + ) + + return fig From c2575e8eed4bfe70908bdaf9bc11e23a2b481115 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 23 Nov 2023 14:17:13 +0000 Subject: [PATCH 028/101] Add optimiser trace to plot_cost2d, restore plotly theme to quick_plot --- examples/scripts/CMAES.py | 5 ++- pybop/__init__.py | 2 +- .../{plot_cost2D.py => plot_cost2d.py} | 43 +++++++++++++++++-- pybop/plotting/quick_plot.py | 4 +- 4 files changed, 47 insertions(+), 7 deletions(-) rename pybop/plotting/{plot_cost2D.py => plot_cost2d.py} (53%) diff --git a/examples/scripts/CMAES.py b/examples/scripts/CMAES.py index 120d6978..5b5a6c63 100644 --- a/examples/scripts/CMAES.py +++ b/examples/scripts/CMAES.py @@ -44,4 +44,7 @@ pybop.quick_plot(x, cost) # Plot the cost landscape -pybop.plot_cost2D(cost, steps=15) +pybop.plot_cost2d(cost, steps=15) + +# Plot the cost landscape with optimisation path +pybop.plot_cost2d(cost, optim=optim, steps=15) diff --git a/pybop/__init__.py b/pybop/__init__.py index b9e5c8df..c11433e4 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -67,7 +67,7 @@ # # Plotting class # -from .plotting.plot_cost2D import plot_cost2D +from .plotting.plot_cost2d import plot_cost2d from .plotting.quick_plot import quick_plot # diff --git a/pybop/plotting/plot_cost2D.py b/pybop/plotting/plot_cost2d.py similarity index 53% rename from pybop/plotting/plot_cost2D.py rename to pybop/plotting/plot_cost2d.py index 6b6ba7fc..fec15fd7 100644 --- a/pybop/plotting/plot_cost2D.py +++ b/pybop/plotting/plot_cost2d.py @@ -1,7 +1,7 @@ import numpy as np -def plot_cost2D(cost, steps=10): +def plot_cost2d(cost, optim=None, steps=10): """ Query the cost landscape for a given parameter space and plot using plotly. """ @@ -22,7 +22,7 @@ def plot_cost2D(cost, steps=10): costs[i, j] = cost([xi, yj]) # Create figure - fig = create_figure(x, y, costs, bounds, cost.problem.parameters) + fig = create_figure(x, y, costs, bounds, cost.problem.parameters, optim) # Display figure fig.show() @@ -40,11 +40,48 @@ def get_param_bounds(cost): return bounds -def create_figure(x, y, z, bounds, params): +def create_figure(x, y, z, bounds, params, optim): # Import plotly only when needed import plotly.graph_objects as go fig = go.Figure(data=[go.Contour(x=x, y=y, z=z)]) + if optim is not None: + optim_trace = np.array([item for sublist in optim.log for item in sublist]) + optim_trace = optim_trace.reshape(-1, 2) + + # Plot initial guess + fig.add_trace( + go.Scatter( + x=[optim.x0[0]], + y=[optim.x0[1]], + mode="markers", + marker_symbol="x", + marker=dict( + color="red", + line_color="midnightblue", + line_width=1, + size=12, + showscale=False, + ), + showlegend=False, + ) + ) + + # Plot optimisation trace + fig.add_trace( + go.Scatter( + x=optim_trace[0:-1, 0], + y=optim_trace[0:-1, 1], + mode="markers", + marker=dict( + color=[i / len(optim_trace) for i in range(len(optim_trace))], + colorscale="YlOrBr", + showscale=False, + ), + showlegend=False, + ) + ) + # Set figure properties fig.update_layout( title="Cost Landscape", diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 83cb828d..f3a03a54 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -41,12 +41,12 @@ def create_figure(x, y, y2, cost, width, height): target_trace = go.Scatter( x=x, y=y2, - line=dict(color="rgb(102,102,255,0.1)"), mode="markers", name="Target", ) + simulated_trace = go.Scatter( - x=x, y=y, line=dict(width=4, color="rgb(255,128,0)"), mode="lines", name="Model" + x=x, y=y, line=dict(width=4), mode="lines", name="Model" ) # Create a trace for the fill area representing the uncertainty (sigma) From 5f284808cbfc6a2aece944176986e8fd98671e3a Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 23 Nov 2023 16:58:31 +0000 Subject: [PATCH 029/101] Add convergence_plot(), refactor plotting methods into StandardPlot() class, Updt example --- examples/scripts/CMAES.py | 9 +- pybop/__init__.py | 3 +- pybop/plotting/plot_convergence.py | 53 ++++++ pybop/plotting/quick_plot.py | 276 +++++++++++++++++++++-------- 4 files changed, 267 insertions(+), 74 deletions(-) create mode 100644 pybop/plotting/plot_convergence.py diff --git a/examples/scripts/CMAES.py b/examples/scripts/CMAES.py index 5b5a6c63..50e0fc0d 100644 --- a/examples/scripts/CMAES.py +++ b/examples/scripts/CMAES.py @@ -1,6 +1,7 @@ import pybop import numpy as np +# Define model parameter_set = pybop.ParameterSet("pybamm", "Chen2020") model = pybop.lithium_ion.SPMe(parameter_set=parameter_set) @@ -18,6 +19,7 @@ ), ] +# Generate data sigma = 0.01 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) @@ -25,6 +27,7 @@ 0, sigma, len(t_eval) ) +# Form dataset for optimisation dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), @@ -37,11 +40,15 @@ optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) optim.set_max_iterations(100) +# Run the optimisation x, final_cost = optim.run() print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(x, cost) +pybop.quick_plot(x, cost, title="Optimised Comparison") + +# Plot convergence +pybop.plot_convergence(optim) # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) diff --git a/pybop/__init__.py b/pybop/__init__.py index c11433e4..28160945 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -68,7 +68,8 @@ # Plotting class # from .plotting.plot_cost2d import plot_cost2d -from .plotting.quick_plot import quick_plot +from .plotting.quick_plot import StandardPlot, quick_plot +from .plotting.plot_convergence import plot_convergence # # Remove any imported modules, so we don't expose them as part of pybop diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py new file mode 100644 index 00000000..d3653dee --- /dev/null +++ b/pybop/plotting/plot_convergence.py @@ -0,0 +1,53 @@ +import pybop + + +def plot_convergence( + optim, xaxis_title="Iteration", yaxis_title="Cost", title="Convergence" +): + """ + Plot the convergence of the optimisation algorithm. + + Parameters: + ---------- + optim : optimisation object + Optimisation object containing the cost function and optimiser. + xaxis_title : str, optional + Title for the x-axis (default is "Iteration"). + yaxis_title : str, optional + Title for the y-axis (default is "Cost"). + title : str, optional + Title of the plot (default is "Convergence"). + + Returns: + ------- + fig : plotly.graph_objs.Figure + The Plotly figure object for the convergence plot. + """ + + # Extract the cost function from the optimisation object + cost_function = optim.cost + + # Compute the maximum cost for each iteration + max_cost_per_iteration = [ + max(cost_function(solution) for solution in log_entry) + for log_entry in optim.log + ] + + # Generate a list of iteration numbers + iteration_numbers = list(range(1, len(max_cost_per_iteration) + 1)) + + # Create the convergence plot using the StandardPlot class + fig = pybop.StandardPlot( + x=iteration_numbers, + y=max_cost_per_iteration, + cost=cost_function, + xaxis_title=xaxis_title, + yaxis_title=yaxis_title, + title=title, + trace_name=optim.optimiser.name(), + )() + + # Display the figure + fig.show() + + return fig diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index f3a03a54..4436c425 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -1,85 +1,217 @@ import numpy as np +import textwrap +import pybop +import plotly.graph_objs as go -def quick_plot(params, cost, width=720, height=540): +class StandardPlot: + """ + A class for creating and displaying a plotly figure that compares a target dataset against a simulated model output. + + This class provides an interface for generating interactive plots using Plotly, with the ability to include an + optional secondary dataset and visualize uncertainty if provided. + + Attributes: + ----------- + x : list + The x-axis data points. + y : list or np.ndarray + The primary y-axis data points representing the simulated model output. + y2 : list or np.ndarray, optional + An optional secondary y-axis data points representing the target dataset against which the model output is compared. + cost : float + The cost associated with the model output. + title : str, optional + The title of the plot. + xaxis_title : str, optional + The title for the x-axis. + yaxis_title : str, optional + The title for the y-axis. + trace_name : str, optional + The name of the primary trace representing the model output. Defaults to "Simulated". + width : int, optional + The width of the figure in pixels. Defaults to 720. + height : int, optional + The height of the figure in pixels. Defaults to 540. + + Methods: + -------- + wrap_text(text, width) + A static method that wraps text to a specified width, inserting HTML line breaks for use in plot labels. + + create_layout() + Creates the layout for the plot, including titles and axis labels. + + create_traces() + Creates the traces for the plot, including the primary dataset, optional secondary dataset, and an optional uncertainty visualization. + + __call__() + Generates the plotly figure when the class instance is called as a function. + + Example: + -------- + >>> x_data = [1, 2, 3, 4] + >>> y_simulated = [10, 15, 13, 17] + >>> y_target = [11, 14, 12, 16] + >>> plot = pybop.StandardPlot(x_data, y_simulated, cost=0.05, y2=y_target, + title="Model vs. Target", xaxis_title="X Axis", yaxis_title="Y Axis") + >>> fig = plot() # Generate the figure + >>> fig.show() # Display the figure in a browser + """ + + def __init__( + self, + x, + y, + cost, + y2=None, + title=None, + xaxis_title=None, + yaxis_title=None, + trace_name=None, + width=720, + height=540, + ): + self.x = x if isinstance(x, list) else x.tolist() + self.y = y + self.y2 = y2 + self.cost = cost + self.width = width + self.height = height + self.title = title + self.xaxis_title = xaxis_title + self.yaxis_title = yaxis_title + self.trace_name = trace_name or "Simulated" + + if self.y2 is not None: + self.sigma = np.std(self.y - self.y2) + self.y_upper = (self.y + self.sigma).tolist() + self.y_lower = (self.y - self.sigma).tolist() + + @staticmethod + def wrap_text(text, width): + """ + Wrap text to a specified width. + + Parameters: + ----------- + text: str + Text to be wrapped. + width: int + Width to wrap text to. + + Returns: + -------- + str + Wrapped text with HTML line breaks. + """ + wrapped_text = textwrap.fill(text, width=width, break_long_words=False) + return wrapped_text.replace("\n", "
") + + def create_layout(self): + """ + Create the layout for the plot. + """ + return go.Layout( + title=self.title, + title_x=0.5, + xaxis=dict(title=self.xaxis_title, titlefont_size=12, tickfont_size=12), + yaxis=dict(title=self.yaxis_title, titlefont_size=12, tickfont_size=12), + legend=dict(x=1, y=1, xanchor="right", yanchor="top", font_size=12), + showlegend=True, + autosize=False, + width=self.width, + height=self.height, + margin=dict(l=10, r=10, b=10, t=75, pad=4), + ) + + def create_traces(self): + """ + Create the traces for the plot. + """ + traces = [] + + wrapped_trace_name = self.wrap_text(self.trace_name, width=40) + simulated_trace = go.Scatter( + x=self.x, + y=self.y, + line=dict(width=4), + mode="lines", + name=wrapped_trace_name, + ) + + if self.y2 is not None: + target_trace = go.Scatter( + x=self.x, y=self.y2, mode="markers", name="Target" + ) + fill_trace = go.Scatter( + x=self.x + self.x[::-1], + y=self.y_upper + self.y_lower[::-1], + fill="toself", + fillcolor="rgba(255,229,204,0.8)", + line=dict(color="rgba(255,255,255,0)"), + hoverinfo="skip", + showlegend=False, + ) + traces.extend([fill_trace, target_trace]) + + traces.append(simulated_trace) + + return traces + + def __call__(self): + """ + Generate the plotly figure. + """ + layout = self.create_layout() + traces = self.create_traces() + fig = go.Figure(data=traces, layout=layout) + return fig + + +def quick_plot(params, cost, title="Scatter Plot", width=720, height=540): """ Plot the target dataset against the minimised model output. - Inputs: + Parameters: + ---------- + params : array-like + Optimised parameters. + cost : cost object + Cost object containing the problem, dataset, and signal. + title : str, optional + Title of the plot (default is "Scatter Plot"). + width : int, optional + Width of the figure in pixels (default is 720). + height : int, optional + Height of the figure in pixels (default is 540). + + Returns: ------- - x: array - Optimised parameters - cost: cost object - Cost object containing the problem, dataset, and signal + fig : plotly.graph_objs.Figure + The Plotly figure object for the scatter plot. """ - # Generate the model output - x = cost.problem._dataset["Time [s]"].data - y = cost.problem.evaluate(params) - y2 = cost.problem.target() - - # Create figure - fig = create_figure(x, y, y2, cost, width, height) - - # Display figure - fig.show() - - return fig - - -def create_figure(x, y, y2, cost, width, height): - # Import plotly only when needed - import plotly.graph_objs as go - - # Estimate the uncertainty (sigma) of the model output - x = x.tolist() - sigma = np.std(y - y2) - y_upper = (y + sigma).tolist() - y_lower = (y - sigma).tolist() - - # Create traces for the measured and simulated values - target_trace = go.Scatter( - x=x, - y=y2, - mode="markers", - name="Target", - ) - - simulated_trace = go.Scatter( - x=x, y=y, line=dict(width=4), mode="lines", name="Model" - ) - - # Create a trace for the fill area representing the uncertainty (sigma) - fill_trace = go.Scatter( - x=x + x[::-1], - y=y_upper + y_lower[::-1], - fill="toself", - fillcolor="rgba(255,229,204,0.8)", - line=dict(color="rgba(255,255,255,0)"), - hoverinfo="skip", - showlegend=False, - ) - - # Define the layout for the plot - layout = go.Layout( - title="Optimised Comparison", - title_x=0.55, - title_y=0.9, - xaxis=dict(title="Time [s]", titlefont_size=12, tickfont_size=12), - yaxis=dict(title=cost.problem.signal, titlefont_size=12, tickfont_size=12), - legend=dict(x=0.85, y=1, xanchor="left", yanchor="top", font_size=12), - showlegend=True, - ) - - # Combine the traces and layout into a figure - fig = go.Figure(data=[fill_trace, target_trace, simulated_trace], layout=layout) - - # Update the figure to adjust the layout and axis properties - fig.update_layout( - autosize=False, + # Extract the time data and evaluate the model's output and target values + time_data = cost.problem._dataset["Time [s]"].data + model_output = cost.problem.evaluate(params) + target_output = cost.problem.target() + + # Create the figure using the StandardPlot class + fig = pybop.StandardPlot( + x=time_data, + y=model_output, + cost=cost, + y2=target_output, + xaxis_title="Time [s]", + yaxis_title=cost.problem.signal, + title=title, + trace_name="Model", width=width, height=height, - margin=dict(l=10, r=10, b=10, t=75, pad=4), - ) + )() + + # Display the figure + fig.show() return fig From bef09c97355e1dc114f9e2a2d6649f2181597cf3 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 23 Nov 2023 17:33:07 +0000 Subject: [PATCH 030/101] Revert to plotly import on init, Updt. changelog --- CHANGELOG.md | 14 ++++++++++---- pybop/plotting/quick_plot.py | 19 +++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4155c6e..5c71341c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,13 @@ # [Unreleased](https://github.com/pybop-team/PyBOP) +## Features +- [#114](https://github.com/pybop-team/PyBOP/issues/114) Adds standard plotting class `pybop.StandardPlot()` via plotly backend +- [#114](https://github.com/pybop-team/PyBOP/issues/114) Adds `quick_plot()`, `plot_convergence()`, and `plot_cost2d()` methods + +## Bug Fixes + # [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11) - - Initial release - - Adds Pints, NLOpt, and SciPy optimisers - - Adds SumofSquareError and RootMeanSquareError cost functions - - Adds Parameter and dataset classes +- Initial release +- Adds Pints, NLOpt, and SciPy optimisers +- Adds SumofSquareError and RootMeanSquareError cost functions +- Adds Parameter and dataset classes diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 4436c425..aeca775c 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -1,7 +1,6 @@ import numpy as np import textwrap import pybop -import plotly.graph_objs as go class StandardPlot: @@ -88,6 +87,14 @@ def __init__( self.y_upper = (self.y + self.sigma).tolist() self.y_lower = (self.y - self.sigma).tolist() + # Attempt to import plotly when an instance is created + try: + import plotly.graph_objs as go + + self.go = go + except ImportError as e: + raise ImportError(f"Plotly is required for this class to work: {e}") + @staticmethod def wrap_text(text, width): """ @@ -112,7 +119,7 @@ def create_layout(self): """ Create the layout for the plot. """ - return go.Layout( + return self.go.Layout( title=self.title, title_x=0.5, xaxis=dict(title=self.xaxis_title, titlefont_size=12, tickfont_size=12), @@ -132,7 +139,7 @@ def create_traces(self): traces = [] wrapped_trace_name = self.wrap_text(self.trace_name, width=40) - simulated_trace = go.Scatter( + simulated_trace = self.go.Scatter( x=self.x, y=self.y, line=dict(width=4), @@ -141,10 +148,10 @@ def create_traces(self): ) if self.y2 is not None: - target_trace = go.Scatter( + target_trace = self.go.Scatter( x=self.x, y=self.y2, mode="markers", name="Target" ) - fill_trace = go.Scatter( + fill_trace = self.go.Scatter( x=self.x + self.x[::-1], y=self.y_upper + self.y_lower[::-1], fill="toself", @@ -165,7 +172,7 @@ def __call__(self): """ layout = self.create_layout() traces = self.create_traces() - fig = go.Figure(data=traces, layout=layout) + fig = self.go.Figure(data=traces, layout=layout) return fig From eee379d05c68ad5406030a6496fb4a7ac5e4ea10 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 23 Nov 2023 19:05:07 +0000 Subject: [PATCH 031/101] Updt. changelog --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c094508..c5780d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ # [Unreleased](https://github.com/pybop-team/PyBOP) - - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class +- [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class # [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11) - - Initial release - - Adds Pints, NLOpt, and SciPy optimisers - - Adds SumofSquareError and RootMeanSquareError cost functions - - Adds Parameter and dataset classes +- Initial release +- Adds Pints, NLOpt, and SciPy optimisers +- Adds SumofSquareError and RootMeanSquareError cost functions +- Adds Parameter and dataset classes From 42325aa64467282c33f19386f6d714a722388ce1 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 24 Nov 2023 14:22:50 +0000 Subject: [PATCH 032/101] Add plot_parameters() method, bugfix pytest flags, updt. examples --- conftest.py | 24 ++-- examples/scripts/spm_CMAES.py | 3 + examples/scripts/spm_IRPropMin.py | 3 + examples/scripts/spm_SNES.py | 3 + examples/scripts/spm_XNES.py | 3 + examples/scripts/spm_adam.py | 3 + examples/scripts/spm_descent.py | 3 + examples/scripts/spm_nlopt.py | 3 + examples/scripts/spm_pso.py | 3 + pybop/__init__.py | 1 + pybop/optimisers/pints_optimisers.py | 4 +- pybop/plotting/plot_parameters.py | 164 +++++++++++++++++++++++++++ pybop/plotting/quick_plot.py | 6 +- 13 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 pybop/plotting/plot_parameters.py diff --git a/conftest.py b/conftest.py index 17285bbc..768c5a3e 100644 --- a/conftest.py +++ b/conftest.py @@ -21,18 +21,16 @@ def pytest_configure(config): def pytest_collection_modifyitems(config, items): - def skip_marker(marker_name, reason): - skip = pytest.mark.skip(reason=reason) + if config.getoption("--unit") and not config.getoption("--examples"): + skip_examples = pytest.mark.skip( + reason="need --examples option to run examples tests" + ) for item in items: - if marker_name in item.keywords: - item.add_marker(skip) + if "examples" in item.keywords: + item.add_marker(skip_examples) - if config.getoption("--unit"): - skip_marker("examples", "need --examples option to run") - return - - if config.getoption("--examples"): - skip_marker("unit", "need --unit option to run") - return - - skip_marker("unit", "need --unit option to run") + if config.getoption("--examples") and not config.getoption("--unit"): + skip_unit = pytest.mark.skip(reason="need --unit option to run unit tests") + for item in items: + if "unit" in item.keywords: + item.add_marker(skip_unit) diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index f672b139..f260fdd9 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -50,6 +50,9 @@ # Plot convergence pybop.plot_convergence(optim) +# Plot the parameter traces +pybop.plot_parameters(optim) + # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index e3c1911b..fcc554b2 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -47,6 +47,9 @@ # Plot convergence pybop.plot_convergence(optim) +# Plot the parameter traces +pybop.plot_parameters(optim) + # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py index ffefeefe..10e2491b 100644 --- a/examples/scripts/spm_SNES.py +++ b/examples/scripts/spm_SNES.py @@ -47,6 +47,9 @@ # Plot convergence pybop.plot_convergence(optim) +# Plot the parameter traces +pybop.plot_parameters(optim) + # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py index fe2d0db4..8bd14bd7 100644 --- a/examples/scripts/spm_XNES.py +++ b/examples/scripts/spm_XNES.py @@ -47,6 +47,9 @@ # Plot convergence pybop.plot_convergence(optim) +# Plot the parameter traces +pybop.plot_parameters(optim) + # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py index e682e1cc..5b1b879c 100644 --- a/examples/scripts/spm_adam.py +++ b/examples/scripts/spm_adam.py @@ -50,6 +50,9 @@ # Plot convergence pybop.plot_convergence(optim) +# Plot the parameter traces +pybop.plot_parameters(optim) + # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index 3a7ff1e4..4f1d496d 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -51,6 +51,9 @@ # Plot convergence pybop.plot_convergence(optim) +# Plot the parameter traces +pybop.plot_parameters(optim) + # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py index 4869789c..67097d14 100644 --- a/examples/scripts/spm_nlopt.py +++ b/examples/scripts/spm_nlopt.py @@ -46,6 +46,9 @@ # Plot convergence pybop.plot_convergence(optim) +# Plot the parameter traces +pybop.plot_parameters(optim) + # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index 6dc341fb..3a66e902 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -47,6 +47,9 @@ # Plot convergence pybop.plot_convergence(optim) +# Plot the parameter traces +pybop.plot_parameters(optim) + # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) diff --git a/pybop/__init__.py b/pybop/__init__.py index aac75a20..48ff4089 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -78,6 +78,7 @@ from .plotting.plot_cost2d import plot_cost2d from .plotting.quick_plot import StandardPlot, quick_plot from .plotting.plot_convergence import plot_convergence +from .plotting.plot_parameters import plot_parameters # # Remove any imported modules, so we don't expose them as part of pybop diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 741c6651..f8e17790 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -9,7 +9,7 @@ class GradientDescent(pints.GradientDescent): def __init__(self, x0, sigma0=0.1, bounds=None): if bounds is not None: - print("Boundaries ignored by GradientDescent") + print("NOTE: Boundaries ignored by Gradient Descent") self.boundaries = None # Bounds ignored in pints.GradDesc super().__init__(x0, sigma0, self.boundaries) @@ -23,7 +23,7 @@ class Adam(pints.Adam): def __init__(self, x0, sigma0=0.1, bounds=None): if bounds is not None: - print("Boundaries ignored by Adam") + print("NOTE: Boundaries ignored by Adam") self.boundaries = None # Bounds ignored in pints.Adam super().__init__(x0, sigma0, self.boundaries) diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py new file mode 100644 index 00000000..4cdee0bd --- /dev/null +++ b/pybop/plotting/plot_parameters.py @@ -0,0 +1,164 @@ +import pybop +import math +import plotly.graph_objects as go +from plotly.subplots import make_subplots + + +def plot_parameters( + optim, xaxis_titles="Iteration", yaxis_titles=None, title="Convergence" +): + """ + Plot the evolution of the parameters during the optimisation process. + + Parameters: + ---------- + optim : optimisation object + An object representing the optimisation process, which should contain + information about the cost function, optimiser, and the history of the + parameter values throughout the iterations. + xaxis_title : str, optional + Title for the x-axis, representing the iteration number or a similar + discrete time step in the optimisation process (default is "Iteration"). + yaxis_title : str, optional + Title for the y-axis, which typically represents the metric being + optimised, such as cost or loss (default is "Cost"). + title : str, optional + Title of the plot, which provides an overall description of what the + plot represents (default is "Convergence"). + + Returns: + ------- + fig : plotly.graph_objs.Figure + The Plotly figure object for the plot depicting how the parameters of + the optimisation algorithm evolve over its course. This can be useful + for diagnosing the behaviour of the optimisation algorithm. + + Notes: + ----- + The function assumes that the 'optim' object has a 'cost.problem.parameters' + attribute containing the parameters of the optimisation algorithm and a 'log' + attribute containing a history of the iterations. + """ + + if optim.optimiser.name() in ["NLoptOptimize", "SciPyMinimize"]: + print("Parameter plot not yet supported for this optimiser.") + return + + # Extract parameters from the optimisation object + params = optim.cost.problem.parameters + + # Create the traces from the optimisation log + traces = create_traces(params, optim.log) + + # Create the axis titles + axis_titles = [] + for param in params: + axis_titles.append(("Function Call", param.name)) + + # Create the figure + fig = create_subplots_with_traces(traces, axis_titles=axis_titles) + + # Display the figure + fig.show() + + return fig + + +def create_traces(params, trace_data, x_values=None): + """ + Generate a list of Plotly Scatter trace objects from provided trace data. + + This function assumes that each column in the `trace_data` represents a separate trace to be plotted, + and that the `params` list contains objects with a `name` attribute used for trace names. + Text wrapping for trace names is performed by `pybop.StandardPlot.wrap_text`. + + Parameters: + - params (list): A list of objects, where each object has a `name` attribute used as the trace name. + The list should have the same length as the number of traces in `trace_data`. + - trace_data (list of lists): A 2D list where each inner list represents y-values for a trace. + - x_values (list, optional): A list of x-values to be used for all traces. If not provided, a + range of integers starting from 0 will be used. + + Returns: + - list: A list of Plotly `go.Scatter` objects, each representing a trace to be plotted. + + Notes: + - The function depends on `pybop.StandardPlot.wrap_text` for text wrapping, which needs to be available + in the execution context. + - The function assumes that `go` from `plotly.graph_objs` is already imported as `go`. + """ + + traces = [] + + # If x_values are not provided: + if x_values is None: + x_values = list(range(len(trace_data[0]) * len(trace_data))) + + # Determine the number of elements in the smallest arrays + num_elements = len(trace_data[0][0]) + + # Initialize a list of lists to store our columns + columns = [[] for _ in range(num_elements)] + + # Loop through each numpy array in trace_data + for array in trace_data: + # Loop through each item (which is a n-element array) in the numpy array + for item in array: + # Loop through each element in the item and append to the corresponding column + for i in range(num_elements): + columns[i].append(item[i]) + + # Create a trace for each column + for i in range(len(columns)): + wrap_param = pybop.StandardPlot.wrap_text(params[i].name, width=50) + traces.append(go.Scatter(x=x_values, y=columns[i], name=wrap_param)) + + return traces + + +def create_subplots_with_traces( + traces, + plot_size=(1024, 576), + title="Parameter Convergence", + axis_titles=None, + **layout_kwargs, +): + """ + Creates a subplot figure with the given traces. + + :param traces: List of plotly.graph_objs traces that will be added to the subplots. + :param plot_size: Tuple (width, height) representing the desired size of the plot. + :param title: The main title of the subplot figure. + :param axis_titles: List of tuples for axis titles in the form [(x_title, y_title), ...] for each subplot. + :param layout_kwargs: Additional keyword arguments to be passed to fig.update_layout for custom layout. + :return: A plotly figure object with the subplots. + """ + num_traces = len(traces) + num_cols = int(math.ceil(math.sqrt(num_traces))) + num_rows = int(math.ceil(num_traces / num_cols)) + + fig = make_subplots(rows=num_rows, cols=num_cols, start_cell="bottom-left") + + for idx, trace in enumerate(traces): + row = (idx // num_cols) + 1 + col = (idx % num_cols) + 1 + fig.add_trace(trace, row=row, col=col) + + if axis_titles and idx < len(axis_titles): + x_title, y_title = axis_titles[idx] + fig.update_xaxes(title_text=x_title, row=row, col=col) + fig.update_yaxes(title_text=y_title, row=row, col=col) + + if plot_size: + layout_kwargs["width"], layout_kwargs["height"] = plot_size + + if title: + layout_kwargs["title_text"] = title + + # Set the legend above the subplots + fig.update_layout( + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + **layout_kwargs, + ) + + return fig diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index aeca775c..5d204890 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -68,8 +68,8 @@ def __init__( xaxis_title=None, yaxis_title=None, trace_name=None, - width=720, - height=540, + width=1024, + height=576, ): self.x = x if isinstance(x, list) else x.tolist() self.y = y @@ -176,7 +176,7 @@ def __call__(self): return fig -def quick_plot(params, cost, title="Scatter Plot", width=720, height=540): +def quick_plot(params, cost, title="Scatter Plot", width=1024, height=576): """ Plot the target dataset against the minimised model output. From c381224bcf3d6abc6e0f210b5696078bba1b6b8b Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 28 Nov 2023 17:15:20 +0000 Subject: [PATCH 033/101] Adds initial thevenin model and example, breaks signal definition --- examples/scripts/ecm_CMAES.py | 55 ++++++++++++++++++++++++++++++ pybop/__init__.py | 1 + pybop/_problem.py | 2 +- pybop/models/empirical/__init__.py | 4 +++ pybop/models/empirical/base_ecm.py | 48 ++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 examples/scripts/ecm_CMAES.py create mode 100644 pybop/models/empirical/__init__.py create mode 100644 pybop/models/empirical/base_ecm.py diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py new file mode 100644 index 00000000..fb550eb9 --- /dev/null +++ b/examples/scripts/ecm_CMAES.py @@ -0,0 +1,55 @@ +import pybop +import numpy as np +import matplotlib.pyplot as plt + +# parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +model = pybop.empirical.Thevenin() + +# Fitting parameters +parameters = [ + pybop.Parameter( + "R0 [Ohm]", + prior=pybop.Gaussian(0.001, 0.0001), + bounds=[1e-5, 1e-2], + ), + pybop.Parameter( + "R1 [Ohm]", + prior=pybop.Gaussian(0.001, 0.0001), + bounds=[1e-5, 1e-2], + ), +] + +sigma = 0.001 +t_eval = np.arange(0, 900, 2) +values = model.predict(t_eval=t_eval) +CorruptValues = values["Battery voltage [V]"].data + np.random.normal( + 0, sigma, len(t_eval) +) + +dataset = [ + pybop.Dataset("Time [s]", t_eval), + pybop.Dataset("Current function [A]", values["Current [A]"].data), + pybop.Dataset("Battery voltage [V]", CorruptValues), +] + +# Generate problem, cost function, and optimisation class +problem = pybop.Problem(model, parameters, dataset) +cost = pybop.SumSquaredError(problem) +optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) +optim.set_max_iterations(100) + +x, final_cost = optim.run() +print("Estimated parameters:", x) + +# Show the generated data +simulated_values = problem.evaluate(x) + +plt.figure(dpi=100) +plt.xlabel("Time", fontsize=12) +plt.ylabel("Values", fontsize=12) +plt.plot(t_eval, CorruptValues, label="Measured") +plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2) +plt.plot(t_eval, simulated_values, label="Simulated") +plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12) +plt.tick_params(axis="both", labelsize=12) +plt.show() diff --git a/pybop/__init__.py b/pybop/__init__.py index 0e933b8e..75287bf5 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -38,6 +38,7 @@ # from .models.base_model import BaseModel from .models import lithium_ion +from .models import empirical # # Main optimisation class diff --git a/pybop/_problem.py b/pybop/_problem.py index 469b6504..c21f7788 100644 --- a/pybop/_problem.py +++ b/pybop/_problem.py @@ -11,7 +11,7 @@ def __init__( model, parameters, dataset, - signal="Terminal voltage [V]", + signal="Battery voltage [V]", check_model=True, init_soc=None, x0=None, diff --git a/pybop/models/empirical/__init__.py b/pybop/models/empirical/__init__.py new file mode 100644 index 00000000..7f57d913 --- /dev/null +++ b/pybop/models/empirical/__init__.py @@ -0,0 +1,4 @@ +# +# Import lithium ion based models +# +from .base_ecm import Thevenin diff --git a/pybop/models/empirical/base_ecm.py b/pybop/models/empirical/base_ecm.py new file mode 100644 index 00000000..8874c23a --- /dev/null +++ b/pybop/models/empirical/base_ecm.py @@ -0,0 +1,48 @@ +import pybamm +from ..base_model import BaseModel + + +class Thevenin(BaseModel): + """ + Composition of the PyBaMM Single Particle Model class. + + """ + + def __init__( + self, + name="Equivalent Circuit Thevenin Model", + parameter_set=None, + geometry=None, + submesh_types=None, + var_pts=None, + spatial_methods=None, + solver=None, + options=None, + **kwargs, + ): + super().__init__() + self.pybamm_model = pybamm.equivalent_circuit.Thevenin( + options=options, **kwargs + ) + self._unprocessed_model = self.pybamm_model + self.name = name + + self.default_parameter_values = self.pybamm_model.default_parameter_values + self._parameter_set = ( + parameter_set or self.pybamm_model.default_parameter_values + ) + self._unprocessed_parameter_set = self._parameter_set + + self.geometry = geometry or self.pybamm_model.default_geometry + self.submesh_types = submesh_types or self.pybamm_model.default_submesh_types + self.var_pts = var_pts or self.pybamm_model.default_var_pts + self.spatial_methods = ( + spatial_methods or self.pybamm_model.default_spatial_methods + ) + self.solver = solver or self.pybamm_model.default_solver + + self._model_with_set_params = None + self._built_model = None + self._built_initial_soc = None + self._mesh = None + self._disc = None From 51e07e751417520f759162350763b8c2abdd599e Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 29 Nov 2023 10:28:49 +0000 Subject: [PATCH 034/101] Adds and displays timing to testing suite --- conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/conftest.py b/conftest.py index 768c5a3e..508808aa 100644 --- a/conftest.py +++ b/conftest.py @@ -15,6 +15,14 @@ def pytest_addoption(parser): ) +def pytest_terminal_summary(terminalreporter, exitstatus, config): + """Add additional section to terminal summary reporting.""" + total_time = sum([x.duration for x in terminalreporter.stats.get("passed", [])]) + num_tests = len(terminalreporter.stats.get("passed", [])) + print(f"\nTotal number of tests completed: {num_tests}") + print(f"Total time taken: {total_time:.2f} seconds") + + def pytest_configure(config): config.addinivalue_line("markers", "unit: mark test as a unit test") config.addinivalue_line("markers", "examples: mark test as an example") From c70a6cf2abf1c62bde3e87261a6abcb042164edf Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 29 Nov 2023 14:13:30 +0000 Subject: [PATCH 035/101] Updt. pybamm version pin for new signal definition, fixes breaking signal definition between ecm / spm,spme --- examples/scripts/ecm_CMAES.py | 15 ++++++--------- examples/scripts/spm_CMAES.py | 6 ++---- examples/scripts/spm_IRPropMin.py | 6 ++---- examples/scripts/spm_SNES.py | 6 ++---- examples/scripts/spm_XNES.py | 6 ++---- examples/scripts/spm_adam.py | 6 ++---- examples/scripts/spm_descent.py | 6 ++---- examples/scripts/spm_nlopt.py | 4 ++-- examples/scripts/spm_pso.py | 6 ++---- pybop/_problem.py | 2 +- setup.py | 2 +- 11 files changed, 24 insertions(+), 41 deletions(-) diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index fb550eb9..0db3a8f2 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -2,19 +2,18 @@ import numpy as np import matplotlib.pyplot as plt -# parameter_set = pybop.ParameterSet("pybamm", "Chen2020") -model = pybop.empirical.Thevenin() +model = pybop.empirical.Thevenin() # (options={"number of rc elements": 2}) # Fitting parameters parameters = [ pybop.Parameter( "R0 [Ohm]", - prior=pybop.Gaussian(0.001, 0.0001), - bounds=[1e-5, 1e-2], + prior=pybop.Gaussian(0.0002, 0.0001), + bounds=[1e-4, 1e-2], ), pybop.Parameter( "R1 [Ohm]", - prior=pybop.Gaussian(0.001, 0.0001), + prior=pybop.Gaussian(0.0001, 0.0001), bounds=[1e-5, 1e-2], ), ] @@ -22,14 +21,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Battery voltage [V]"].data + np.random.normal( - 0, sigma, len(t_eval) -) +CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Battery voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", CorruptValues), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index 7f044409..c1c93ad8 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -22,14 +22,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( - 0, sigma, len(t_eval) -) +CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Terminal voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", CorruptValues), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index 2d4dd2ec..ec1128cf 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -22,14 +22,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( - 0, sigma, len(t_eval) -) +CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Terminal voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", CorruptValues), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py index f5db3c9b..e9a06a26 100644 --- a/examples/scripts/spm_SNES.py +++ b/examples/scripts/spm_SNES.py @@ -22,14 +22,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( - 0, sigma, len(t_eval) -) +CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Terminal voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", CorruptValues), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py index 37939245..5470bd0f 100644 --- a/examples/scripts/spm_XNES.py +++ b/examples/scripts/spm_XNES.py @@ -22,14 +22,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( - 0, sigma, len(t_eval) -) +CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Terminal voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", CorruptValues), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py index 27949e9a..cef1adab 100644 --- a/examples/scripts/spm_adam.py +++ b/examples/scripts/spm_adam.py @@ -24,15 +24,13 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -corrupt_values = values["Terminal voltage [V]"].data + np.random.normal( - 0, sigma, len(t_eval) -) +corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) # Dataset definition dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Terminal voltage [V]", corrupt_values), + pybop.Dataset("Voltage [V]", corrupt_values), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index 85f77f26..1f937651 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -24,15 +24,13 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -corrupt_values = values["Terminal voltage [V]"].data + np.random.normal( - 0, sigma, len(t_eval) -) +corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) # Dataset definition dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Terminal voltage [V]", corrupt_values), + pybop.Dataset("Voltage [V]", corrupt_values), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py index 19401ed4..49c9b8a9 100644 --- a/examples/scripts/spm_nlopt.py +++ b/examples/scripts/spm_nlopt.py @@ -7,7 +7,7 @@ dataset = [ pybop.Dataset("Time [s]", Measurements[:, 0]), pybop.Dataset("Current function [A]", Measurements[:, 1]), - pybop.Dataset("Terminal voltage [V]", Measurements[:, 2]), + pybop.Dataset("Voltage [V]", Measurements[:, 2]), ] # Define model @@ -31,7 +31,7 @@ ] # Define the cost to optimise -signal = "Terminal voltage [V]" +signal = "Voltage [V]" problem = pybop.Problem(model, parameters, dataset, signal=signal, init_soc=0.98) cost = pybop.RootMeanSquaredError(problem) diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index 9a9cb5aa..7a93c83c 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -22,14 +22,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Terminal voltage [V]"].data + np.random.normal( - 0, sigma, len(t_eval) -) +CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Terminal voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", CorruptValues), ] # Generate problem, cost function, and optimisation class diff --git a/pybop/_problem.py b/pybop/_problem.py index c21f7788..671b3a1c 100644 --- a/pybop/_problem.py +++ b/pybop/_problem.py @@ -11,7 +11,7 @@ def __init__( model, parameters, dataset, - signal="Battery voltage [V]", + signal="Voltage [V]", check_model=True, init_soc=None, x0=None, diff --git a/setup.py b/setup.py index 4d6b63a6..47e48582 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ long_description_content_type="text/markdown", url="https://github.com/pybop-team/PyBOP", install_requires=[ - "pybamm>=23.1", + "pybamm>=23.5", "numpy>=1.16", "scipy>=1.3", "pandas>=1.0", From 329c36f8c3d98a996b01c3d3dfaceaa0b3c60c16 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 29 Nov 2023 15:10:33 +0000 Subject: [PATCH 036/101] Updt. cost_2d() with optional bounds arg + example, updt. contributing and readme for development installation --- CONTRIBUTING.md | 17 +++++++++++++++-- README.md | 6 +----- examples/scripts/spm_CMAES.py | 5 +++-- pybop/plotting/plot_cost2d.py | 9 ++++++--- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1ea9db2..95a6914f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,20 @@ # Contributing to PyBOP -If you'd like to contribute to PyBOP, please have a look at the [pre-commit](#pre-commit-checks) and the [workflow](#workflow) guidelines below. +If you'd like to contribute to PyBOP, please have a look at the guidelines below. +## Installation + +To install PyBOP for development purposes, which includes the testing and plotting dependencies, use the `[all]` flag as demonstrated below: + +For `zsh`: + +```sh +pip install -e '.[all]' +``` +For `bash`: +```sh +pip install -e .[all] +``` ## Pre-commit checks Before you commit any code, please perform the following checks: @@ -123,7 +136,7 @@ If you have nox installed, to run unit tests, type nox -s unit ``` -else, type +Alternatively, to run tests standalone with pytest, run, ```bash pytest --unit -v diff --git a/README.md b/README.md index 9c57fbe1..1420aa4e 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,7 @@ To alternatively install PyBOP from a local directory, use the following templat pip install -e "path/to/pybop" ``` -To check whether PyBOP has been installed correctly, run one of the examples in the following section or the full set of unit tests: - -```bash -pytest --unit -v -``` +To check whether PyBOP has been installed correctly, run one of the examples in the following section. For a development installation, please refer to the [contributing guide](https://github.com/pybop-team/PyBOP/blob/develop/CONTRIBUTING.md#Installation). ### Prerequisites To use and/or contribute to PyBOP, first install Python (3.8-3.11). On a Debian-based distribution, this looks like: diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index f260fdd9..9a415a66 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -56,5 +56,6 @@ # Plot the cost landscape pybop.plot_cost2d(cost, steps=15) -# Plot the cost landscape with optimisation path -pybop.plot_cost2d(cost, optim=optim, steps=15) +# Plot the cost landscape with optimisation path and updated bounds +bounds = np.array([[0.6, 0.9], [0.5, 0.8]]) +pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15) diff --git a/pybop/plotting/plot_cost2d.py b/pybop/plotting/plot_cost2d.py index fec15fd7..aa7b2e3b 100644 --- a/pybop/plotting/plot_cost2d.py +++ b/pybop/plotting/plot_cost2d.py @@ -1,13 +1,16 @@ import numpy as np -def plot_cost2d(cost, optim=None, steps=10): +def plot_cost2d(cost, bounds=None, optim=None, steps=10): """ Query the cost landscape for a given parameter space and plot using plotly. """ - # Set up parameter bounds - bounds = get_param_bounds(cost) + if bounds is None: + # Set up parameter bounds + bounds = get_param_bounds(cost) + else: + bounds = bounds # Generate grid x = np.linspace(bounds[0, 0], bounds[0, 1], steps) From 27d45c298e8d31ee6ab7c66d04b6d68e117ba735 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 30 Nov 2023 10:28:18 +0000 Subject: [PATCH 037/101] Add parameterisation logic to thevenin class with example --- examples/scripts/ecm_CMAES.py | 33 +++++++++++++++++++++++++++++- pybop/models/empirical/base_ecm.py | 13 ++++++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index 0db3a8f2..70fba190 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -2,7 +2,38 @@ import numpy as np import matplotlib.pyplot as plt -model = pybop.empirical.Thevenin() # (options={"number of rc elements": 2}) +# Define the initial parameter set +params = { + "chemistry": "ecm", + "Initial SoC": 0.5, + "Initial temperature [K]": 25 + 273.15, + "Cell capacity [A.h]": 5, + "Nominal cell capacity [A.h]": 5, + "Ambient temperature [K]": 25 + 273.15, + "Current function [A]": 5, + "Upper voltage cut-off [V]": 4.2, + "Lower voltage cut-off [V]": 3.0, + "Cell thermal mass [J/K]": 1000, + "Cell-jig heat transfer coefficient [W/K]": 10, + "Jig thermal mass [J/K]": 500, + "Jig-air heat transfer coefficient [W/K]": 10, + "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[ + "Open-circuit voltage [V]" + ], + "R0 [Ohm]": 0.001, + "Element-1 initial overpotential [V]": 0, + "Element-2 initial overpotential [V]": 0, + "R1 [Ohm]": 0.0002, + "R2 [Ohm]": 0.0003, + "C1 [F]": 10000, + "C2 [F]": 5000, + "Entropic change [V/K]": 0.0004, +} + +# Define the model +model = pybop.empirical.Thevenin( + parameter_set=params, options={"number of rc elements": 2} +) # Fitting parameters parameters = [ diff --git a/pybop/models/empirical/base_ecm.py b/pybop/models/empirical/base_ecm.py index 8874c23a..85b21d1c 100644 --- a/pybop/models/empirical/base_ecm.py +++ b/pybop/models/empirical/base_ecm.py @@ -27,10 +27,15 @@ def __init__( self._unprocessed_model = self.pybamm_model self.name = name - self.default_parameter_values = self.pybamm_model.default_parameter_values - self._parameter_set = ( - parameter_set or self.pybamm_model.default_parameter_values - ) + if isinstance(parameter_set, dict): + self.default_parameter_values = pybamm.ParameterValues(parameter_set) + self._parameter_set = self.default_parameter_values + else: + self.default_parameter_values = self.pybamm_model.default_parameter_values + self._parameter_set = ( + parameter_set or self.pybamm_model.default_parameter_values + ) + self._unprocessed_parameter_set = self._parameter_set self.geometry = geometry or self.pybamm_model.default_geometry From 605e20b2d4deb5055ca3d1c49a2596074bb2348c Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:07:14 +0000 Subject: [PATCH 038/101] Add browser check --- pybop/plotting/quick_plot.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 5d204890..6960d5b1 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -1,6 +1,7 @@ import numpy as np import textwrap import pybop +import os class StandardPlot: @@ -95,6 +96,20 @@ def __init__( except ImportError as e: raise ImportError(f"Plotly is required for this class to work: {e}") + # Check for the existence of a browser for use by plotly + import plotly.io as pio + if pio.renderers.default == "browser" and os.getenv("BROWSER") is None: + raise ValueError( + "\n\nIn order to view figures in the browser using Plotly, " + "you need to set the environment variable BROWSER equal to the " + "path to your chosen browser. To do this, please enter a command such " + "as the following to add this your virtual environment activation file:\n\n" + "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate" + "\n\nThen reactivate your virtual environment. Alternatively you can use a" + "different Plotly renderer. For more information see: " + "https://plotly.com/python/renderers/#setting-the-default-renderer" + ) + @staticmethod def wrap_text(text, width): """ From 05a36facc5ec8c8273478c04e662a48a484b1c0e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:07:30 +0000 Subject: [PATCH 039/101] style: pre-commit fixes --- pybop/plotting/quick_plot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 6960d5b1..10699271 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -98,6 +98,7 @@ def __init__( # Check for the existence of a browser for use by plotly import plotly.io as pio + if pio.renderers.default == "browser" and os.getenv("BROWSER") is None: raise ValueError( "\n\nIn order to view figures in the browser using Plotly, " From 7ffaa376869923cc6d9ece14feab9631452870d1 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 30 Nov 2023 12:39:07 +0000 Subject: [PATCH 040/101] Fix typo --- pybop/plotting/quick_plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 10699271..fdf2f63f 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -106,7 +106,7 @@ def __init__( "path to your chosen browser. To do this, please enter a command such " "as the following to add this your virtual environment activation file:\n\n" "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate" - "\n\nThen reactivate your virtual environment. Alternatively you can use a" + "\n\nThen reactivate your virtual environment. Alternatively you can use a " "different Plotly renderer. For more information see: " "https://plotly.com/python/renderers/#setting-the-default-renderer" ) From 6edfa6e566da39902dbebfb85f4262a0af4e3734 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 30 Nov 2023 15:36:28 +0000 Subject: [PATCH 041/101] Update plotly browser catch, Add logic to install plotly if not already installed --- pybop/plotting/plot_parameters.py | 25 ++++++++++++++++++- pybop/plotting/quick_plot.py | 41 ++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py index 4cdee0bd..0f6e936d 100644 --- a/pybop/plotting/plot_parameters.py +++ b/pybop/plotting/plot_parameters.py @@ -1,6 +1,6 @@ import pybop import math -import plotly.graph_objects as go +import plotly.graph_objs as go from plotly.subplots import make_subplots @@ -88,6 +88,17 @@ def create_traces(params, trace_data, x_values=None): - The function assumes that `go` from `plotly.graph_objs` is already imported as `go`. """ + # Attempt to import plotly graph objects when an instance is created + # try: + # import plotly.graph_objs as go + + # except ImportError: + # print("Plotly is not installed. Installing now...") + # subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"]) + + # # Try to import again after installing + # import plotly.graph_objs as go + traces = [] # If x_values are not provided: @@ -133,6 +144,18 @@ def create_subplots_with_traces( :param layout_kwargs: Additional keyword arguments to be passed to fig.update_layout for custom layout. :return: A plotly figure object with the subplots. """ + + # Attempt to import plotly subplots when an instance is created + # try: + # from plotly.subplots import make_subplots + + # except ImportError: + # print("Plotly is not installed. Installing now...") + # subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"]) + + # # Try to import again after installing + # from plotly.subplots import make_subplots + num_traces = len(traces) num_cols = int(math.ceil(math.sqrt(num_traces))) num_rows = int(math.ceil(num_traces / num_cols)) diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index fdf2f63f..3aa2cb01 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -1,7 +1,9 @@ import numpy as np +import webbrowser +import subprocess import textwrap import pybop -import os +import sys class StandardPlot: @@ -93,23 +95,34 @@ def __init__( import plotly.graph_objs as go self.go = go - except ImportError as e: - raise ImportError(f"Plotly is required for this class to work: {e}") + + except ImportError: + print("Plotly is not installed. Installing now...") + subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"]) + + # Try to import again after installing + import plotly.graph_objs as go + + self.go = go # Check for the existence of a browser for use by plotly import plotly.io as pio - if pio.renderers.default == "browser" and os.getenv("BROWSER") is None: - raise ValueError( - "\n\nIn order to view figures in the browser using Plotly, " - "you need to set the environment variable BROWSER equal to the " - "path to your chosen browser. To do this, please enter a command such " - "as the following to add this your virtual environment activation file:\n\n" - "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate" - "\n\nThen reactivate your virtual environment. Alternatively you can use a " - "different Plotly renderer. For more information see: " - "https://plotly.com/python/renderers/#setting-the-default-renderer" - ) + if pio.renderers.default == "browser": + try: + webbrowser.get() + except webbrowser.Error: + # If no browser is found, raise an error + raise ValueError( + "\n\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, " + "you need to set the environment variable BROWSER equal to the " + "path to your chosen browser. To do this, please enter a command like " + "the following to add this your virtual environment activation file:\n\n" + "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate" + "\n\nThen reactivate your virtual environment. Alternatively you can use a " + "different Plotly renderer. For more information see: " + "https://plotly.com/python/renderers/#setting-the-default-renderer" + ) @staticmethod def wrap_text(text, width): From d02bb95f6fe52b1739a340f7dba5a1d6e6867faa Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 30 Nov 2023 16:14:23 +0000 Subject: [PATCH 042/101] Adds user prompt for plotly install, uncomments plotly install code --- pybop/plotting/plot_parameters.py | 74 ++++++++++++++++++++++--------- pybop/plotting/quick_plot.py | 31 ++++++++++--- 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py index 0f6e936d..61bad47f 100644 --- a/pybop/plotting/plot_parameters.py +++ b/pybop/plotting/plot_parameters.py @@ -1,7 +1,7 @@ +import subprocess import pybop import math -import plotly.graph_objs as go -from plotly.subplots import make_subplots +import sys def plot_parameters( @@ -89,15 +89,32 @@ def create_traces(params, trace_data, x_values=None): """ # Attempt to import plotly graph objects when an instance is created - # try: - # import plotly.graph_objs as go - - # except ImportError: - # print("Plotly is not installed. Installing now...") - # subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"]) - - # # Try to import again after installing - # import plotly.graph_objs as go + try: + import plotly.graph_objs as go + + except ImportError: + user_input = ( + input( + "Plotly is not installed. To proceed, we need to install plotly. (y/n)?" + ) + .strip() + .lower() + ) + + if user_input == "y": + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "plotly"] + ) + except subprocess.CalledProcessError as e: + print(f"Error installing plotly: {e}") + return + + # Try to import again after installing + import plotly.graph_objs as go + + else: + print("Installation cancelled by user.") traces = [] @@ -146,15 +163,32 @@ def create_subplots_with_traces( """ # Attempt to import plotly subplots when an instance is created - # try: - # from plotly.subplots import make_subplots - - # except ImportError: - # print("Plotly is not installed. Installing now...") - # subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"]) - - # # Try to import again after installing - # from plotly.subplots import make_subplots + try: + from plotly.subplots import make_subplots + + except ImportError: + user_input = ( + input( + "Plotly is not installed. To proceed, we need to install plotly. (y/n)?" + ) + .strip() + .lower() + ) + + if user_input == "y": + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "plotly"] + ) + except subprocess.CalledProcessError as e: + print(f"Error installing plotly: {e}") + return + + # Try to import again after installing + from plotly.subplots import make_subplots + + else: + print("Installation cancelled by user.") num_traces = len(traces) num_cols = int(math.ceil(math.sqrt(num_traces))) diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 3aa2cb01..7c9f431c 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -93,21 +93,38 @@ def __init__( # Attempt to import plotly when an instance is created try: import plotly.graph_objs as go + import plotly.io as pio self.go = go except ImportError: - print("Plotly is not installed. Installing now...") - subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"]) + user_input = ( + input( + "Plotly is not installed. To proceed, we need to install plotly. (y/n)?" + ) + .strip() + .lower() + ) - # Try to import again after installing - import plotly.graph_objs as go + if user_input == "y": + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "plotly"] + ) + except subprocess.CalledProcessError as e: + print(f"Error installing plotly: {e}") + return - self.go = go + # Try to import again after installing + import plotly.graph_objs as go + import plotly.io as pio - # Check for the existence of a browser for use by plotly - import plotly.io as pio + self.go = go + + else: + print("Installation cancelled by user.") + # Check for the existence of a browser for use by plotly if pio.renderers.default == "browser": try: webbrowser.get() From c7d8e59345b4cdcf5cf18c917a368ec2cb4299ed Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 30 Nov 2023 17:01:37 +0000 Subject: [PATCH 043/101] Adds windows and macOS matrix --- .github/workflows/test_on_push.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_on_push.yaml b/.github/workflows/test_on_push.yaml index 959e4c82..7d10c94b 100644 --- a/.github/workflows/test_on_push.yaml +++ b/.github/workflows/test_on_push.yaml @@ -30,10 +30,11 @@ jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.8", "3.9", "3.10", "3.11"] steps: From 84fd660542249e353d7311382068d9f1612b9fed Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 30 Nov 2023 17:29:24 +0000 Subject: [PATCH 044/101] Updt. changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5780d43..9aacc815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # [Unreleased](https://github.com/pybop-team/PyBOP) +- [#127](https://github.com/pybop-team/PyBOP/issues/127) - Adds Windows and macOS runners to the `test_on_push` action - [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class # [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11) From d9c429e3ced23951d51cf4e3e65c86f13860aaf9 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 30 Nov 2023 19:11:33 +0000 Subject: [PATCH 045/101] Add Plotly install help for WSL users --- pybop/plotting/plot_parameters.py | 18 ++++++++++++++++++ pybop/plotting/quick_plot.py | 30 ++++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py index 61bad47f..d6cd0626 100644 --- a/pybop/plotting/plot_parameters.py +++ b/pybop/plotting/plot_parameters.py @@ -112,6 +112,15 @@ def create_traces(params, trace_data, x_values=None): # Try to import again after installing import plotly.graph_objs as go + import plotly.io as pio + + # Set a default renderer if it installs without + if pio.renderers.default is None: + pio.renderers.default = "browser" + print( + "The Plotly renderer was set to an empty string during installation, which will not generate any plots, " + 'so we have set the default renderer as the "browser".' + ) else: print("Installation cancelled by user.") @@ -186,6 +195,15 @@ def create_subplots_with_traces( # Try to import again after installing from plotly.subplots import make_subplots + import plotly.io as pio + + # Set a default renderer if it installs without + if pio.renderers.default == "": + pio.renderers.default = "browser" + print( + "The Plotly renderer was set to an empty string during installation, which will not generate any plots, " + 'so we have set the default renderer as the "browser".' + ) else: print("Installation cancelled by user.") diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 7c9f431c..b260ad08 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -121,22 +121,40 @@ def __init__( self.go = go + # Set a default renderer if it installs without + if pio.renderers.default == "": + pio.renderers.default = "browser" + print( + "The Plotly renderer was set to an empty string during installation, which will not generate any plots, " + 'so we have set the default renderer as the "browser".' + ) + else: print("Installation cancelled by user.") + # Check for a plotly renderer + if pio.renderers.default == "": + print( + "The Plotly renderer is an empty string, if this was not on purpose use the following commands " + "to see the options and set the renderer:\n" + " pio.renderers\n" + ' pio.renderers.default = "browser"\n' + "For more information see: https://plotly.com/python/renderers/#setting-the-default-renderer" + ) + # Check for the existence of a browser for use by plotly if pio.renderers.default == "browser": try: webbrowser.get() except webbrowser.Error: - # If no browser is found, raise an error - raise ValueError( - "\n\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, " + # If no browser is found, raise an exception with a helpful message + raise Exception( + "\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, " "you need to set the environment variable BROWSER equal to the " "path to your chosen browser. To do this, please enter a command like " - "the following to add this your virtual environment activation file:\n\n" - "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate" - "\n\nThen reactivate your virtual environment. Alternatively you can use a " + "the following to add this to your virtual environment activation file:\n\n" + "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> your-env/bin/activate" + "\n\nThen reactivate your virtual environment. Alternatively, you can use a " "different Plotly renderer. For more information see: " "https://plotly.com/python/renderers/#setting-the-default-renderer" ) From 695504497fc7ac07e7ca38010cb247a9fdde1862 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 1 Dec 2023 13:29:12 +0000 Subject: [PATCH 046/101] Add PlotManager class for plotly installation, add tests for plotly installation, updt. quick_plot and plot_parameter --- pybop/__init__.py | 1 + pybop/plotting/plot_parameters.py | 78 ++------------------ pybop/plotting/plotly_manager.py | 114 ++++++++++++++++++++++++++++++ pybop/plotting/quick_plot.py | 71 +------------------ tests/unit/test_plotting.py | 106 +++++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 144 deletions(-) create mode 100644 pybop/plotting/plotly_manager.py create mode 100644 tests/unit/test_plotting.py diff --git a/pybop/__init__.py b/pybop/__init__.py index 48ff4089..f652fe0b 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -79,6 +79,7 @@ from .plotting.quick_plot import StandardPlot, quick_plot from .plotting.plot_convergence import plot_convergence from .plotting.plot_parameters import plot_parameters +from .plotting.plotly_manager import PlotlyManager # # Remove any imported modules, so we don't expose them as part of pybop diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py index d6cd0626..9f4bbc02 100644 --- a/pybop/plotting/plot_parameters.py +++ b/pybop/plotting/plot_parameters.py @@ -1,7 +1,5 @@ -import subprocess import pybop import math -import sys def plot_parameters( @@ -88,42 +86,8 @@ def create_traces(params, trace_data, x_values=None): - The function assumes that `go` from `plotly.graph_objs` is already imported as `go`. """ - # Attempt to import plotly graph objects when an instance is created - try: - import plotly.graph_objs as go - - except ImportError: - user_input = ( - input( - "Plotly is not installed. To proceed, we need to install plotly. (y/n)?" - ) - .strip() - .lower() - ) - - if user_input == "y": - try: - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "plotly"] - ) - except subprocess.CalledProcessError as e: - print(f"Error installing plotly: {e}") - return - - # Try to import again after installing - import plotly.graph_objs as go - import plotly.io as pio - - # Set a default renderer if it installs without - if pio.renderers.default is None: - pio.renderers.default = "browser" - print( - "The Plotly renderer was set to an empty string during installation, which will not generate any plots, " - 'so we have set the default renderer as the "browser".' - ) - - else: - print("Installation cancelled by user.") + # Attempt to import plotly when an instance is created + go = pybop.PlotlyManager().go traces = [] @@ -171,42 +135,8 @@ def create_subplots_with_traces( :return: A plotly figure object with the subplots. """ - # Attempt to import plotly subplots when an instance is created - try: - from plotly.subplots import make_subplots - - except ImportError: - user_input = ( - input( - "Plotly is not installed. To proceed, we need to install plotly. (y/n)?" - ) - .strip() - .lower() - ) - - if user_input == "y": - try: - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "plotly"] - ) - except subprocess.CalledProcessError as e: - print(f"Error installing plotly: {e}") - return - - # Try to import again after installing - from plotly.subplots import make_subplots - import plotly.io as pio - - # Set a default renderer if it installs without - if pio.renderers.default == "": - pio.renderers.default = "browser" - print( - "The Plotly renderer was set to an empty string during installation, which will not generate any plots, " - 'so we have set the default renderer as the "browser".' - ) - - else: - print("Installation cancelled by user.") + # Attempt to import plotly when an instance is created + make_subplots = pybop.PlotlyManager().make_subplots num_traces = len(traces) num_cols = int(math.ceil(math.sqrt(num_traces))) diff --git a/pybop/plotting/plotly_manager.py b/pybop/plotting/plotly_manager.py new file mode 100644 index 00000000..2384f299 --- /dev/null +++ b/pybop/plotting/plotly_manager.py @@ -0,0 +1,114 @@ +import subprocess +import webbrowser +import sys + + +class PlotlyManager: + """ + Manages the installation and configuration of Plotly for generating visualisations. + + This class checks if Plotly is installed and, if not, prompts the user to install it. + It also ensures that the Plotly renderer and browser settings are properly configured + to display plots. + + Methods: + `ensure_plotly_installed`: Verifies if Plotly is installed and installs it if necessary. + `prompt_for_plotly_installation`: Prompts the user for permission to install Plotly. + `install_plotly_package`: Installs the Plotly package using pip. + `post_install_setup`: Sets up Plotly default renderer after installation. + `check_renderer_settings`: Verifies that the Plotly renderer is correctly set. + `check_browser_availability`: Checks if a web browser is available for rendering plots. + + Usage: + Instantiate the PlotlyManager class to automatically ensure Plotly is installed + and configured correctly when creating an instance. + Example: + plotly_manager = PlotlyManager() + """ + + def __init__(self): + self.go = None + self.pio = None + self.make_subplots = None + self.ensure_plotly_installed() + self.check_renderer_settings() + self.check_browser_availability() + + def ensure_plotly_installed(self): + """Verifies if Plotly is installed, importing necessary modules and prompting for installation if missing.""" + try: + import plotly.graph_objs as go + import plotly.io as pio + from plotly.subplots import make_subplots + + self.go = go + self.pio = pio + self.make_subplots = make_subplots + except ImportError: + self.prompt_for_plotly_installation() + + def prompt_for_plotly_installation(self): + """Prompts the user for permission to install Plotly and proceeds with installation if consented.""" + user_input = ( + input( + "Plotly is not installed. To proceed, we need to install plotly. (Y/n)? " + ) + .strip() + .lower() + ) + if user_input == "y": + self.install_plotly() + self.post_install_setup() + else: + print("Installation cancelled by user.") + sys.exit(1) # Exit if user cancels installation + + def install_plotly(self): + """Attempts to install the Plotly package using pip and exits if installation fails.""" + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"]) + except subprocess.CalledProcessError as e: + print(f"Error installing plotly: {e}") + sys.exit(1) # Exit if installation fails + + def post_install_setup(self): + """After successful installation, imports Plotly and sets the default renderer if necessary.""" + import plotly.graph_objs as go + import plotly.io as pio + from plotly.subplots import make_subplots + + self.go = go + self.pio = pio + self.make_subplots = make_subplots + if pio.renderers.default == "": + pio.renderers.default = "browser" + print( + 'Set default renderer to "browser" as it was empty after installation.' + ) + + def check_renderer_settings(self): + """Checks if the Plotly renderer is set and provides information on how to set it if empty.""" + if self.pio and self.pio.renderers.default == "": + print( + "The Plotly renderer is an empty string. To set the renderer, use:\n" + " pio.renderers\n" + ' pio.renderers.default = "browser"\n' + "For more information, see: https://plotly.com/python/renderers/#setting-the-default-renderer" + ) + + def check_browser_availability(self): + """Ensures a web browser is available for rendering plots with the 'browser' renderer and provides guidance if not.""" + if self.pio and self.pio.renderers.default == "browser": + try: + webbrowser.get() + except webbrowser.Error: + raise Exception( + "\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, " + "you need to set the environment variable BROWSER equal to the " + "path to your chosen browser. To do this, please enter a command like " + "the following to add this to your virtual environment activation file:\n\n" + "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> your-env/bin/activate" + "\n\nThen reactivate your virtual environment. Alternatively, you can use a " + "different Plotly renderer. For more information see: " + "https://plotly.com/python/renderers/#setting-the-default-renderer" + ) diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index b260ad08..d6628760 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -1,9 +1,6 @@ import numpy as np -import webbrowser -import subprocess import textwrap import pybop -import sys class StandardPlot: @@ -91,73 +88,7 @@ def __init__( self.y_lower = (self.y - self.sigma).tolist() # Attempt to import plotly when an instance is created - try: - import plotly.graph_objs as go - import plotly.io as pio - - self.go = go - - except ImportError: - user_input = ( - input( - "Plotly is not installed. To proceed, we need to install plotly. (y/n)?" - ) - .strip() - .lower() - ) - - if user_input == "y": - try: - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "plotly"] - ) - except subprocess.CalledProcessError as e: - print(f"Error installing plotly: {e}") - return - - # Try to import again after installing - import plotly.graph_objs as go - import plotly.io as pio - - self.go = go - - # Set a default renderer if it installs without - if pio.renderers.default == "": - pio.renderers.default = "browser" - print( - "The Plotly renderer was set to an empty string during installation, which will not generate any plots, " - 'so we have set the default renderer as the "browser".' - ) - - else: - print("Installation cancelled by user.") - - # Check for a plotly renderer - if pio.renderers.default == "": - print( - "The Plotly renderer is an empty string, if this was not on purpose use the following commands " - "to see the options and set the renderer:\n" - " pio.renderers\n" - ' pio.renderers.default = "browser"\n' - "For more information see: https://plotly.com/python/renderers/#setting-the-default-renderer" - ) - - # Check for the existence of a browser for use by plotly - if pio.renderers.default == "browser": - try: - webbrowser.get() - except webbrowser.Error: - # If no browser is found, raise an exception with a helpful message - raise Exception( - "\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, " - "you need to set the environment variable BROWSER equal to the " - "path to your chosen browser. To do this, please enter a command like " - "the following to add this to your virtual environment activation file:\n\n" - "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> your-env/bin/activate" - "\n\nThen reactivate your virtual environment. Alternatively, you can use a " - "different Plotly renderer. For more information see: " - "https://plotly.com/python/renderers/#setting-the-default-renderer" - ) + self.go = pybop.PlotlyManager().go @staticmethod def wrap_text(text, width): diff --git a/tests/unit/test_plotting.py b/tests/unit/test_plotting.py new file mode 100644 index 00000000..b0802cb0 --- /dev/null +++ b/tests/unit/test_plotting.py @@ -0,0 +1,106 @@ +from importlib.metadata import distributions +from distutils.spawn import find_executable +from pybop import PlotlyManager +import subprocess +import pytest + +# Find the Python executable +python_executable = find_executable("python") + + +@pytest.fixture(scope="session") +def plotly_installed(): + """A session-level fixture that ensures Plotly is installed after tests.""" + # Check if Plotly is initially installed + initially_installed = is_package_installed("plotly") + + # If Plotly is not installed initially, install it + if not initially_installed: + subprocess.check_call([python_executable, "-m", "pip", "install", "plotly"]) + + # Yield control back to the tests + yield + + # After tests, if Plotly was not installed initially, uninstall it + if not initially_installed: + subprocess.check_call( + [python_executable, "-m", "pip", "uninstall", "plotly", "-y"] + ) + + +@pytest.fixture(scope="function") +def uninstall_plotly_if_installed(): + """A fixture to uninstall Plotly if it's installed before a test and reinstall it afterwards.""" + # Check if Plotly is installed before the test + was_installed = is_package_installed("plotly") + + # If Plotly is installed, uninstall it + if was_installed: + subprocess.check_call( + [python_executable, "-m", "pip", "uninstall", "plotly", "-y"] + ) + + # Yield control back to the test + yield + + # If Plotly was uninstalled for the test, reinstall it afterwards + if was_installed: + subprocess.check_call([python_executable, "-m", "pip", "install", "plotly"]) + + +@pytest.mark.unit +def test_initialization_with_plotly_installed(plotly_installed): + """Test initialization when Plotly is installed.""" + assert is_package_installed("plotly") + plotly_manager = PlotlyManager() + + import plotly.graph_objs as go + import plotly.io as pio + from plotly.subplots import make_subplots + + assert plotly_manager.go == go + assert plotly_manager.pio == pio + assert plotly_manager.make_subplots == make_subplots + + +@pytest.mark.unit +def test_prompt_for_plotly_installation(mocker, uninstall_plotly_if_installed): + """Test prompt for Plotly installation when not installed.""" + assert not is_package_installed("plotly") + mocker.patch("builtins.input", return_value="y") + plotly_manager = PlotlyManager() + + import plotly.graph_objs as go + import plotly.io as pio + from plotly.subplots import make_subplots + + assert plotly_manager.go == go + assert plotly_manager.pio == pio + assert plotly_manager.make_subplots == make_subplots + + +@pytest.mark.unit +def test_cancel_installation(mocker, uninstall_plotly_if_installed): + """Test exit if Plotly installation is canceled.""" + assert not is_package_installed("plotly") + mocker.patch("builtins.input", return_value="n") + with pytest.raises(SystemExit) as pytest_wrapped_e: + PlotlyManager().prompt_for_plotly_installation() + + assert pytest_wrapped_e.type == SystemExit + assert pytest_wrapped_e.value.code == 1 + assert not is_package_installed("plotly") + + +@pytest.mark.unit +def test_post_install_setup(plotly_installed): + """Test post-install setup.""" + plotly_manager = PlotlyManager() + plotly_manager.post_install_setup() + + assert plotly_manager.pio.renderers.default == "browser" + + +def is_package_installed(package_name): + """Check if a package is installed without raising an exception.""" + return any(d.metadata["Name"] == package_name for d in distributions()) From 27a88aa98d2826cd92b4b6abc757f23703627473 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 1 Dec 2023 13:44:11 +0000 Subject: [PATCH 047/101] Updt. noxfile for pytest-mock requirement --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 055bf4ef..b86005a0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -7,7 +7,7 @@ @nox.session def unit(session): session.run_always("pip", "install", "-e", ".[all]") - session.install("pytest") + session.install("pytest", "pytest-mock") session.run("pytest", "--unit", "-v", "--showlocals") From 1e505ee6a53c1ae36a2ff893c3e8f5d54a5ec0e0 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 1 Dec 2023 14:03:25 +0000 Subject: [PATCH 048/101] Add pytest-mock to nofile coverage --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index b86005a0..e1759a3d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -14,7 +14,7 @@ def unit(session): @nox.session def coverage(session): session.run_always("pip", "install", "-e", ".[all]") - session.install("pytest-cov") + session.install("pytest", "pytest-cov", "pytest-mock") session.run( "pytest", "--unit", From 0e143cca6471564b918d1d073efcaf65e617d142 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 1 Dec 2023 15:55:22 +0000 Subject: [PATCH 049/101] Merge develop, update thevenin example plotting --- examples/scripts/ecm_CMAES.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index 70fba190..35e2a9b7 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -1,6 +1,5 @@ import pybop import numpy as np -import matplotlib.pyplot as plt # Define the initial parameter set params = { @@ -69,15 +68,18 @@ x, final_cost = optim.run() print("Estimated parameters:", x) -# Show the generated data -simulated_values = problem.evaluate(x) +# Plot the timeseries output +pybop.quick_plot(x, cost, title="Optimised Comparison") -plt.figure(dpi=100) -plt.xlabel("Time", fontsize=12) -plt.ylabel("Values", fontsize=12) -plt.plot(t_eval, CorruptValues, label="Measured") -plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2) -plt.plot(t_eval, simulated_values, label="Simulated") -plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12) -plt.tick_params(axis="both", labelsize=12) -plt.show() +# Plot convergence +pybop.plot_convergence(optim) + +# Plot the parameter traces +pybop.plot_parameters(optim) + +# Plot the cost landscape +pybop.plot_cost2d(cost, steps=15) + +# Plot the cost landscape with optimisation path and updated bounds +bounds = np.array([[1e-4, 1e-2], [1e-5, 1e-2]]) +pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15) From 877f498e32bd0b9e29305109ea72099060b049e3 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 4 Dec 2023 11:46:38 +0000 Subject: [PATCH 050/101] Updt. ParameterSet cls for JSON import, updt corresponding examples, tests --- examples/scripts/ecm_CMAES.py | 58 +++++++++++--------- examples/scripts/parameters/ecm.json | 24 +++++++++ examples/scripts/spm_CMAES.py | 2 +- examples/scripts/spm_IRPropMin.py | 2 +- examples/scripts/spm_SNES.py | 2 +- examples/scripts/spm_XNES.py | 2 +- examples/scripts/spm_adam.py | 2 +- examples/scripts/spm_descent.py | 2 +- examples/scripts/spm_nlopt.py | 2 +- examples/scripts/spm_pso.py | 2 +- pybop/parameters/base_parameter_set.py | 73 +++++++++++++++++++++++--- tests/unit/test_parameter_sets.py | 4 +- tests/unit/test_parameterisations.py | 8 +-- 13 files changed, 137 insertions(+), 46 deletions(-) create mode 100644 examples/scripts/parameters/ecm.json diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index 35e2a9b7..fca71f2f 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -2,32 +2,38 @@ import numpy as np # Define the initial parameter set -params = { - "chemistry": "ecm", - "Initial SoC": 0.5, - "Initial temperature [K]": 25 + 273.15, - "Cell capacity [A.h]": 5, - "Nominal cell capacity [A.h]": 5, - "Ambient temperature [K]": 25 + 273.15, - "Current function [A]": 5, - "Upper voltage cut-off [V]": 4.2, - "Lower voltage cut-off [V]": 3.0, - "Cell thermal mass [J/K]": 1000, - "Cell-jig heat transfer coefficient [W/K]": 10, - "Jig thermal mass [J/K]": 500, - "Jig-air heat transfer coefficient [W/K]": 10, - "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[ - "Open-circuit voltage [V]" - ], - "R0 [Ohm]": 0.001, - "Element-1 initial overpotential [V]": 0, - "Element-2 initial overpotential [V]": 0, - "R1 [Ohm]": 0.0002, - "R2 [Ohm]": 0.0003, - "C1 [F]": 10000, - "C2 [F]": 5000, - "Entropic change [V/K]": 0.0004, -} +# Add definitions for R's, C's, and initial overpotentials for any additional RC elements +# params = { +# "chemistry": "ecm", +# "Initial SoC": 0.5, +# "Initial temperature [K]": 25 + 273.15, +# "Cell capacity [A.h]": 5, +# "Nominal cell capacity [A.h]": 5, +# "Ambient temperature [K]": 25 + 273.15, +# "Current function [A]": 5, +# "Upper voltage cut-off [V]": 4.2, +# "Lower voltage cut-off [V]": 3.0, +# "Cell thermal mass [J/K]": 1000, +# "Cell-jig heat transfer coefficient [W/K]": 10, +# "Jig thermal mass [J/K]": 500, +# "Jig-air heat transfer coefficient [W/K]": 10, +# "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[ +# "Open-circuit voltage [V]" +# ], +# "R0 [Ohm]": 0.001, +# "Element-1 initial overpotential [V]": 0, +# "Element-2 initial overpotential [V]": 0, +# "R1 [Ohm]": 0.0002, +# "R2 [Ohm]": 0.0003, +# "C1 [F]": 10000, +# "C2 [F]": 5000, +# "Entropic change [V/K]": 0.0004, +# } + +# Params +params = pybop.ParameterSet( + json_path="examples/scripts/parameters/ecm.json" +).import_parameters() # Define the model model = pybop.empirical.Thevenin( diff --git a/examples/scripts/parameters/ecm.json b/examples/scripts/parameters/ecm.json new file mode 100644 index 00000000..17cb8a69 --- /dev/null +++ b/examples/scripts/parameters/ecm.json @@ -0,0 +1,24 @@ +{ + "chemistry": "ecm", + "Initial SoC": 0.5, + "Initial temperature [K]": 298.15, + "Cell capacity [A.h]": 5, + "Nominal cell capacity [A.h]": 5, + "Ambient temperature [K]": 298.15, + "Current function [A]": 5, + "Upper voltage cut-off [V]": 4.2, + "Lower voltage cut-off [V]": 3.0, + "Cell thermal mass [J/K]": 1000, + "Cell-jig heat transfer coefficient [W/K]": 10, + "Jig thermal mass [J/K]": 500, + "Jig-air heat transfer coefficient [W/K]": 10, + "Open-circuit voltage [V]": 3.7, + "R0 [Ohm]": 0.001, + "Element-1 initial overpotential [V]": 0, + "Element-2 initial overpotential [V]": 0, + "R1 [Ohm]": 0.0002, + "R2 [Ohm]": 0.0003, + "C1 [F]": 10000, + "C2 [F]": 5000, + "Entropic change [V/K]": 0.0004 +} diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index 38ca0c47..60e4e516 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -2,7 +2,7 @@ import numpy as np # Define model -parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Fitting parameters diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index 3142b5cf..c39be709 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -2,7 +2,7 @@ import numpy as np # Define model -parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Fitting parameters diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py index ba9dfd77..5ccfbb2f 100644 --- a/examples/scripts/spm_SNES.py +++ b/examples/scripts/spm_SNES.py @@ -2,7 +2,7 @@ import numpy as np # Define model -parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Fitting parameters diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py index 25fa5ac4..7f288e8f 100644 --- a/examples/scripts/spm_XNES.py +++ b/examples/scripts/spm_XNES.py @@ -2,7 +2,7 @@ import numpy as np # Define model -parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Fitting parameters diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py index c5be4186..b793c6af 100644 --- a/examples/scripts/spm_adam.py +++ b/examples/scripts/spm_adam.py @@ -2,7 +2,7 @@ import numpy as np # Parameter set and model definition -parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPMe(parameter_set=parameter_set) # Fitting parameters diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index 210d5b76..a1c6a22d 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -2,7 +2,7 @@ import numpy as np # Parameter set and model definition -parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPMe(parameter_set=parameter_set) # Fitting parameters diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py index eba4dd01..5c782593 100644 --- a/examples/scripts/spm_nlopt.py +++ b/examples/scripts/spm_nlopt.py @@ -10,7 +10,7 @@ ] # Define model -parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.models.lithium_ion.SPM( parameter_set=parameter_set, options={"thermal": "lumped"} ) diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index 62c1f2c8..1b6f84e6 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -2,7 +2,7 @@ import numpy as np # Define model -parameter_set = pybop.ParameterSet("pybamm", "Chen2020") +parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Fitting parameters diff --git a/pybop/parameters/base_parameter_set.py b/pybop/parameters/base_parameter_set.py index dd1653d8..536af63d 100644 --- a/pybop/parameters/base_parameter_set.py +++ b/pybop/parameters/base_parameter_set.py @@ -1,13 +1,74 @@ +# import pybamm +# import json +# import pybop + +# class ParameterSet: +# """ +# Class for creating parameter sets in PyBOP. +# """ + +# def __new__(cls, method, name): +# if method.casefold() == "pybamm": +# return pybamm.ParameterValues(name).copy() +# else: +# raise ValueError("Only PyBaMM parameter sets are currently implemented") + +# def __init__(self): +# pass + +# def import_parameters(self, json_path): +# """ +# Import parameters from a JSON file. +# """ + +# # Read JSON file +# with open(json_path, 'r') as file: +# params = json.load(file) + +# # Set attributes based on the dictionary +# for key, value in params.items(): +# if key == "Open-circuit voltage [V]": +# # Assuming `pybop.empirical.Thevenin().default_parameter_values` is a dictionary +# value = pybop.empirical.Thevenin().default_parameter_values["Open-circuit voltage [V]"] +# setattr(self, key, value) + +import json import pybamm +import pybop class ParameterSet: """ - Class for creating parameter sets in PyBOP. + Class for creating and importing parameter sets. """ - def __new__(cls, method, name): - if method.casefold() == "pybamm": - return pybamm.ParameterValues(name).copy() - else: - raise ValueError("Only PyBaMM parameter sets are currently implemented") + def __init__(self, json_path=None): + self.json_path = json_path + + def import_parameters(self, json_path=None): + """ + Import parameters from a JSON file. + """ + if json_path is None: + json_path = self.json_path + + # Read JSON file + with open(json_path, "r") as file: + params = json.load(file) + + # Set attributes based on the dictionary + if "Open-circuit voltage [V]" in params: + params[ + "Open-circuit voltage [V]" + ] = pybop.empirical.Thevenin().default_parameter_values[ + "Open-circuit voltage [V]" + ] + + return params + + @classmethod + def pybamm(cls, name): + """ + Create a PyBaMM parameter set. + """ + return pybamm.ParameterValues(name).copy() diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py index 9cc47852..2ba6ce12 100644 --- a/tests/unit/test_parameter_sets.py +++ b/tests/unit/test_parameter_sets.py @@ -12,9 +12,9 @@ class TestParameterSets: def test_parameter_set(self): # Tests parameter set creation with pytest.raises(ValueError): - pybop.ParameterSet("pybamms", "Chen2020") + pybop.ParameterSet.pybamm("Chen2020s") - parameter_test = pybop.ParameterSet("pybamm", "Chen2020") + parameter_test = pybop.ParameterSet.pybamm("Chen2020") np.testing.assert_allclose( parameter_test["Negative electrode active material volume fraction"], 0.75 ) diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py index c1810063..e948a63f 100644 --- a/tests/unit/test_parameterisations.py +++ b/tests/unit/test_parameterisations.py @@ -13,7 +13,7 @@ class TestModelParameterisation: @pytest.mark.unit def test_spm(self, init_soc): # Define model - parameter_set = pybop.ParameterSet("pybamm", "Chen2020") + parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Form dataset @@ -66,7 +66,7 @@ def test_spm(self, init_soc): @pytest.mark.unit def test_spm_optimisers(self, init_soc): # Define model - parameter_set = pybop.ParameterSet("pybamm", "Chen2020") + parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Form dataset @@ -159,10 +159,10 @@ def test_model_misparameterisation(self, init_soc): # Define two different models with different parameter sets # The optimisation should fail as the models are not the same - parameter_set = pybop.ParameterSet("pybamm", "Chen2020") + parameter_set = pybop.ParameterSet.pybamm("Chen2020") model = pybop.lithium_ion.SPM(parameter_set=parameter_set) - second_parameter_set = pybop.ParameterSet("pybamm", "Ecker2015") + second_parameter_set = pybop.ParameterSet.pybamm("Ecker2015") second_model = pybop.lithium_ion.SPM(parameter_set=second_parameter_set) # Form observations From a14af1ffd686f6cd62b43a093b984e75faba07c6 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Mon, 4 Dec 2023 12:08:49 +0000 Subject: [PATCH 051/101] Split Problem into Base, Fitting and Design (#125) * Split Problem into Base, Fitting and Design * Add test for DesignProblem * Add test for BaseProblem * Rename fit_parameters to parameters * Make model required except for BaseProblem * Check for experiment prior to build * Define parameters when not building * Refactor test_problem and update Changelog * Move setting of x0 * Add target definition --- CHANGELOG.md | 3 +- examples/notebooks/spm_nlopt.ipynb | 2 +- examples/scripts/spm_CMAES.py | 2 +- examples/scripts/spm_IRPropMin.py | 2 +- examples/scripts/spm_SNES.py | 2 +- examples/scripts/spm_XNES.py | 2 +- examples/scripts/spm_adam.py | 2 +- examples/scripts/spm_descent.py | 2 +- examples/scripts/spm_nlopt.py | 2 +- examples/scripts/spm_pso.py | 2 +- pybop/__init__.py | 2 +- pybop/_problem.py | 149 +++++++++++++++++++++------ pybop/models/base_model.py | 26 +++-- tests/unit/test_cost.py | 2 +- tests/unit/test_optimisation.py | 8 +- tests/unit/test_parameterisations.py | 6 +- tests/unit/test_problem.py | 106 +++++++++++++------ 17 files changed, 225 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06710ee5..d16dc5cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds standard plotting class `pybop.StandardPlot()` via plotly backend - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds `quick_plot()`, `plot_convergence()`, and `plot_cost2d()` methods - [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class +- [#38](https://github.com/pybop-team/PyBOP/issues/38) - Restructures the Problem classes ahead of adding a design optimisation example ## Bug Fixes @@ -11,4 +12,4 @@ - Initial release - Adds Pints, NLOpt, and SciPy optimisers - Adds SumofSquareError and RootMeanSquareError cost functions -- Adds Parameter and dataset classes +- Adds Parameter and Dataset classes diff --git a/examples/notebooks/spm_nlopt.ipynb b/examples/notebooks/spm_nlopt.ipynb index 81157516..50f0877d 100644 --- a/examples/notebooks/spm_nlopt.ipynb +++ b/examples/notebooks/spm_nlopt.ipynb @@ -334,7 +334,7 @@ "source": [ "# Define the cost to optimise\n", "signal = \"Terminal voltage [V]\"\n", - "problem = pybop.Problem(pyb_model, parameters, dataset, signal=signal)\n", + "problem = pybop.Problem(parameters, dataset, model=pyb_model, signal=signal)\n", "cost = pybop.RootMeanSquaredError(problem)" ] }, diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index 9a415a66..c8af1eef 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -35,7 +35,7 @@ ] # Generate problem, cost function, and optimisation class -problem = pybop.Problem(model, parameters, dataset) +problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) optim.set_max_iterations(100) diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index fcc554b2..b1e15890 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -33,7 +33,7 @@ ] # Generate problem, cost function, and optimisation class -problem = pybop.Problem(model, parameters, dataset) +problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation(cost, optimiser=pybop.IRPropMin) optim.set_max_iterations(100) diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py index 10e2491b..2ff6e88f 100644 --- a/examples/scripts/spm_SNES.py +++ b/examples/scripts/spm_SNES.py @@ -33,7 +33,7 @@ ] # Generate problem, cost function, and optimisation class -problem = pybop.Problem(model, parameters, dataset) +problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation(cost, optimiser=pybop.SNES) optim.set_max_iterations(100) diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py index 8bd14bd7..d7e7a717 100644 --- a/examples/scripts/spm_XNES.py +++ b/examples/scripts/spm_XNES.py @@ -33,7 +33,7 @@ ] # Generate problem, cost function, and optimisation class -problem = pybop.Problem(model, parameters, dataset) +problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation(cost, optimiser=pybop.XNES) optim.set_max_iterations(100) diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py index 5b1b879c..e3663d95 100644 --- a/examples/scripts/spm_adam.py +++ b/examples/scripts/spm_adam.py @@ -35,7 +35,7 @@ ] # Generate problem, cost function, and optimisation class -problem = pybop.Problem(model, parameters, dataset) +problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation(cost, optimiser=pybop.Adam) optim.set_max_iterations(100) diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index 4f1d496d..432289bc 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -35,7 +35,7 @@ ] # Generate problem, cost function, and optimisation class -problem = pybop.Problem(model, parameters, dataset) +problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation(cost, optimiser=pybop.GradientDescent) optim.optimiser.set_learning_rate(0.025) diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py index 67097d14..a17d2da3 100644 --- a/examples/scripts/spm_nlopt.py +++ b/examples/scripts/spm_nlopt.py @@ -31,7 +31,7 @@ # Define the cost to optimise signal = "Terminal voltage [V]" -problem = pybop.Problem(model, parameters, dataset, signal=signal, init_soc=0.98) +problem = pybop.FittingProblem(model, parameters, dataset, signal=signal, init_soc=0.98) cost = pybop.RootMeanSquaredError(problem) # Build the optimisation problem diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index 3a66e902..09a5193c 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -33,7 +33,7 @@ ] # Generate problem, cost function, and optimisation class -problem = pybop.Problem(model, parameters, dataset) +problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation(cost, optimiser=pybop.PSO) optim.set_max_iterations(100) diff --git a/pybop/__init__.py b/pybop/__init__.py index f652fe0b..48a24b2d 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -70,7 +70,7 @@ # # Problem class # -from ._problem import Problem +from ._problem import FittingProblem, DesignProblem # # Plotting class diff --git a/pybop/_problem.py b/pybop/_problem.py index d625b4ea..526e30bc 100644 --- a/pybop/_problem.py +++ b/pybop/_problem.py @@ -1,30 +1,78 @@ import numpy as np -class Problem: +class BaseProblem: """ - Defines a PyBOP single output problem, follows the PINTS interface. + Defines the PyBOP base problem, following the PINTS interface. """ def __init__( self, - model, parameters, - dataset, - signal="Terminal voltage [V]", + model=None, check_model=True, init_soc=None, x0=None, ): self._model = model - self.parameters = parameters - self.signal = signal - self._model.signal = self.signal - self._dataset = {o.name: o for o in dataset} self.check_model = check_model + self.parameters = parameters self.init_soc = init_soc self.x0 = x0 self.n_parameters = len(self.parameters) + + # Set bounds + self.bounds = dict( + lower=[param.bounds[0] for param in self.parameters], + upper=[param.bounds[1] for param in self.parameters], + ) + + # Sample from prior for x0 + if x0 is None: + self.x0 = np.zeros(self.n_parameters) + for i, param in enumerate(self.parameters): + self.x0[i] = param.rvs(1) + elif len(x0) != self.n_parameters: + raise ValueError("x0 dimensions do not match number of parameters") + + # Add the initial values to the parameter definitions + for i, param in enumerate(self.parameters): + param.update(value=self.x0[i]) + + def evaluate(self, parameters): + """ + Evaluate the model with the given parameters and return the signal. + """ + raise NotImplementedError + + def evaluateS1(self, parameters): + """ + Evaluate the model with the given parameters and return the signal and + its derivatives. + """ + raise NotImplementedError + + +class FittingProblem(BaseProblem): + """ + Defines the problem class for a fitting (parameter estimation) problem. + """ + + def __init__( + self, + model, + parameters, + dataset, + signal="Terminal voltage [V]", + check_model=True, + init_soc=None, + x0=None, + ): + super().__init__(parameters, model, check_model, init_soc, x0) + if model is not None: + self._model.signal = signal + self.signal = signal + self._dataset = {o.name: o for o in dataset} self.n_outputs = len([self.signal]) # Check that the dataset contains time and current @@ -44,30 +92,71 @@ def __init__( if len(self._target) != len(self._time_data): raise ValueError("Time data and signal data must be the same length.") - # Set bounds - self.bounds = dict( - lower=[param.bounds[0] for param in self.parameters], - upper=[param.bounds[1] for param in self.parameters], + # Build the model + if self._model._built_model is None: + self._model.build( + dataset=self._dataset, + parameters={o.name: o.value for o in self.parameters}, + check_model=self.check_model, + init_soc=self.init_soc, + ) + + def evaluate(self, parameters): + """ + Evaluate the model with the given parameters and return the signal. + """ + + y = np.asarray(self._model.simulate(inputs=parameters, t_eval=self._time_data)) + + return y + + def evaluateS1(self, parameters): + """ + Evaluate the model with the given parameters and return the signal and + its derivatives. + """ + + y, dy = self._model.simulateS1( + inputs=parameters, + t_eval=self._time_data, ) - # Sample from prior for x0 - if x0 is None: - self.x0 = np.zeros(self.n_parameters) - for i, param in enumerate(self.parameters): - self.x0[i] = param.rvs(1) - elif len(x0) != self.n_parameters: - raise ValueError("x0 dimensions do not match number of parameters") + return (np.asarray(y), np.asarray(dy)) - # Add the initial values to the parameter definitions - for i, param in enumerate(self.parameters): - param.update(value=self.x0[i]) + def target(self): + """ + Returns the target dataset. + """ + return self._target - # Set the fitting parameters and build the model - self.fit_parameters = {o.name: o.value for o in parameters} - if self._model._built_model is None: + +class DesignProblem(BaseProblem): + """ + Defines the problem class for a design optimiation problem. + """ + + def __init__( + self, + model, + parameters, + experiment, + check_model=True, + init_soc=None, + x0=None, + ): + super().__init__(parameters, model, check_model, init_soc, x0) + self.experiment = experiment + self._target = None + + # Build the model if required + if experiment is not None: + # Leave the build until later to apply the experiment + self._model.parameters = {o.name: o.value for o in self.parameters} + + elif self._model._built_model is None: self._model.build( - dataset=self._dataset, - fit_parameters=self.fit_parameters, + experiment=self.experiment, + parameters={o.name: o.value for o in self.parameters}, check_model=self.check_model, init_soc=self.init_soc, ) @@ -86,11 +175,9 @@ def evaluateS1(self, parameters): Evaluate the model with the given parameters and return the signal and its derivatives. """ - for i, key in enumerate(self.fit_parameters): - self.fit_parameters[key] = parameters[i] y, dy = self._model.simulateS1( - inputs=self.fit_parameters, + inputs=parameters, t_eval=self._time_data, ) diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index ced38437..20100ab6 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -10,14 +10,14 @@ class BaseModel: def __init__(self, name="Base Model"): self.name = name self.pybamm_model = None - self.fit_parameters = None + self.parameters = None self.dataset = None self.signal = None def build( self, dataset=None, - fit_parameters=None, + parameters=None, check_model=True, init_soc=None, ): @@ -26,10 +26,10 @@ def build( For PyBaMM forward models, this method follows a similar process to pybamm.Simulation.build(). """ - self.fit_parameters = fit_parameters + self.parameters = parameters self.dataset = dataset - if self.fit_parameters is not None: - self.fit_keys = list(self.fit_parameters.keys()) + if self.parameters is not None: + self.fit_keys = list(self.parameters.keys()) if init_soc is not None: self.set_init_soc(init_soc) @@ -78,12 +78,12 @@ def set_params(self): if self.model_with_set_params: return - if self.fit_parameters is not None: + if self.parameters is not None: # set input parameters in parameter set from fitting parameters - for i in self.fit_parameters.keys(): + for i in self.parameters.keys(): self._parameter_set[i] = "[input]" - if self.dataset is not None and self.fit_parameters is not None: + if self.dataset is not None and self.parameters is not None: if "Current function [A]" not in self.fit_keys: self.parameter_set["Current function [A]"] = pybamm.Interpolant( self.dataset["Time [s]"].data, @@ -109,9 +109,7 @@ def simulate(self, inputs, t_eval): raise ValueError("Model must be built before calling simulate") else: if not isinstance(inputs, dict): - inputs_dict = { - key: inputs[i] for i, key in enumerate(self.fit_parameters) - } + inputs_dict = {key: inputs[i] for i, key in enumerate(self.parameters)} return self.solver.solve( self.built_model, inputs=inputs_dict, t_eval=t_eval )[self.signal].data @@ -130,9 +128,7 @@ def simulateS1(self, inputs, t_eval): raise ValueError("Model must be built before calling simulate") else: if not isinstance(inputs, dict): - inputs_dict = { - key: inputs[i] for i, key in enumerate(self.fit_parameters) - } + inputs_dict = {key: inputs[i] for i, key in enumerate(self.parameters)} sol = self.solver.solve( self.built_model, @@ -171,6 +167,8 @@ def predict( """ parameter_set = parameter_set or self._parameter_set if inputs is not None: + if not isinstance(inputs, dict): + inputs = {key: inputs[i] for i, key in enumerate(self.parameters)} parameter_set.update(inputs) if self._unprocessed_model is not None: if experiment is None: diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py index 0c7e329f..ed14655e 100644 --- a/tests/unit/test_cost.py +++ b/tests/unit/test_cost.py @@ -34,7 +34,7 @@ def test_costs(self, cut_off): # Construct Problem signal = "Voltage [V]" model.parameter_set.update({"Lower voltage cut-off [V]": cut_off}) - problem = pybop.Problem(model, parameters, dataset, signal=signal, x0=x0) + problem = pybop.FittingProblem(model, parameters, dataset, signal=signal, x0=x0) # Base Cost base_cost = pybop.BaseCost(problem) diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 0f75e83a..22822753 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -29,8 +29,12 @@ def parameters(self): @pytest.fixture def problem(self, parameters, dataset): - return pybop.Problem( - pybop.lithium_ion.SPM(), parameters, dataset, signal="Terminal voltage [V]" + model = pybop.lithium_ion.SPM() + return pybop.FittingProblem( + model, + parameters, + dataset, + signal="Terminal voltage [V]", ) @pytest.fixture diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py index c1810063..398f137a 100644 --- a/tests/unit/test_parameterisations.py +++ b/tests/unit/test_parameterisations.py @@ -44,7 +44,7 @@ def test_spm(self, init_soc): # Define the cost to optimise signal = "Terminal voltage [V]" - problem = pybop.Problem( + problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) cost = pybop.RootMeanSquaredError(problem) @@ -97,7 +97,7 @@ def test_spm_optimisers(self, init_soc): # Define the cost to optimise signal = "Terminal voltage [V]" - problem = pybop.Problem( + problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) cost = pybop.SumSquaredError(problem) @@ -193,7 +193,7 @@ def test_model_misparameterisation(self, init_soc): # Define the cost to optimise signal = "Terminal voltage [V]" - problem = pybop.Problem( + problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) cost = pybop.RootMeanSquaredError(problem) diff --git a/tests/unit/test_problem.py b/tests/unit/test_problem.py index aa470d9b..85d246df 100644 --- a/tests/unit/test_problem.py +++ b/tests/unit/test_problem.py @@ -9,11 +9,13 @@ class TestProblem: A class to test the problem class. """ - @pytest.mark.unit - def test_problem(self): - # Define model - model = pybop.lithium_ion.SPM() - parameters = [ + @pytest.fixture + def model(self): + return pybop.lithium_ion.SPM() + + @pytest.fixture + def parameters(self): + return [ pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Gaussian(0.5, 0.02), @@ -25,24 +27,68 @@ def test_problem(self): bounds=[0.525, 0.75], ), ] - signal = "Voltage [V]" - # Form dataset - x0 = np.array([0.52, 0.63]) - solution = self.getdata(model, x0) + @pytest.fixture + def experiment(self): + return pybamm.Experiment( + [ + ( + "Discharge at 1C for 5 minutes (1 second period)", + "Rest for 2 minutes (1 second period)", + "Charge at 1C for 5 minutes (1 second period)", + "Rest for 2 minutes (1 second period)", + ), + ] + * 2 + ) - dataset = [ + @pytest.fixture + def dataset(self, model, experiment): + model.parameter_set = model.pybamm_model.default_parameter_values + x0 = np.array([0.52, 0.63]) + model.parameter_set.update( + { + "Negative electrode active material volume fraction": x0[0], + "Positive electrode active material volume fraction": x0[1], + } + ) + solution = model.predict(experiment=experiment) + return [ pybop.Dataset("Time [s]", solution["Time [s]"].data), pybop.Dataset("Current function [A]", solution["Current [A]"].data), pybop.Dataset("Voltage [V]", solution["Terminal voltage [V]"].data), ] + @pytest.fixture + def signal(self): + return "Voltage [V]" + + @pytest.mark.unit + def test_base_problem(self, parameters, model): + # Test incorrect number of initial parameter values + with pytest.raises(ValueError): + pybop._problem.BaseProblem(parameters, model=model, x0=np.array([])) + + # Construct Problem + problem = pybop._problem.BaseProblem(parameters, model=model) + + assert problem._model == model + + with pytest.raises(NotImplementedError): + problem.evaluate([0.5, 0.5]) + with pytest.raises(NotImplementedError): + problem.evaluateS1([0.5, 0.5]) + + @pytest.mark.unit + def test_fitting_problem(self, parameters, dataset, model, signal): # Test incorrect number of initial parameter values with pytest.raises(ValueError): - pybop.Problem(model, parameters, dataset, signal=signal, x0=np.array([])) + pybop.FittingProblem( + model, parameters, dataset, signal=signal, x0=np.array([]) + ) # Construct Problem - problem = pybop.Problem(model, parameters, dataset, signal=signal) + problem = pybop.FittingProblem(model, parameters, dataset, signal=signal) assert problem._model == model assert problem._model._built_model is not None @@ -50,25 +96,19 @@ def test_problem(self): # Test model.simulate model.simulate(inputs=[0.5, 0.5], t_eval=np.linspace(0, 10, 100)) - def getdata(self, model, x0): - model.parameter_set = model.pybamm_model.default_parameter_values + @pytest.mark.unit + def test_design_problem(self, parameters, experiment, model): + # Test incorrect number of initial parameter values + with pytest.raises(ValueError): + pybop.DesignProblem(model, parameters, experiment, x0=np.array([])) - model.parameter_set.update( - { - "Negative electrode active material volume fraction": x0[0], - "Positive electrode active material volume fraction": x0[1], - } - ) - experiment = pybamm.Experiment( - [ - ( - "Discharge at 1C for 5 minutes (1 second period)", - "Rest for 2 minutes (1 second period)", - "Charge at 1C for 5 minutes (1 second period)", - "Rest for 2 minutes (1 second period)", - ), - ] - * 2 - ) - sim = model.predict(experiment=experiment) - return sim + # Construct Problem + problem = pybop.DesignProblem(model, parameters, experiment) + + assert problem._model == model + assert ( + problem._model._built_model is None + ) # building postponed with input experiment + + # Test model.predict + model.predict(inputs=[0.5, 0.5], experiment=experiment) From 0a4d0d21ed8990e9a7c9ea0123851659eb5d7268 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 4 Dec 2023 19:28:00 +0000 Subject: [PATCH 052/101] Add pytest.ini with defaults, updt conftest for default skip, add import/export parameter_set, add example .json parameters, updt. nox for pytest-xdist parallel workers --- conftest.py | 12 ++- examples/scripts/ecm_CMAES.py | 42 ++------- examples/scripts/ecm_parameters.py | 93 +++++++++++++++++++ .../parameters/fit_ecm_parameters.json | 24 +++++ .../{ecm.json => initial_ecm_parameters.json} | 2 +- noxfile.py | 10 +- pybop/__init__.py | 4 +- pybop/_problem.py | 4 - pybop/optimisation.py | 9 ++ pybop/parameters/base_parameter_set.py | 74 --------------- .../{base_parameter.py => parameter.py} | 0 pybop/parameters/parameter_set.py | 93 +++++++++++++++++++ pytest.ini | 3 + tests/unit/test_parameter_sets.py | 2 +- 14 files changed, 249 insertions(+), 123 deletions(-) create mode 100644 examples/scripts/ecm_parameters.py create mode 100644 examples/scripts/parameters/fit_ecm_parameters.json rename examples/scripts/parameters/{ecm.json => initial_ecm_parameters.json} (94%) delete mode 100644 pybop/parameters/base_parameter_set.py rename pybop/parameters/{base_parameter.py => parameter.py} (100%) create mode 100644 pybop/parameters/parameter_set.py create mode 100644 pytest.ini diff --git a/conftest.py b/conftest.py index 508808aa..c632f3e5 100644 --- a/conftest.py +++ b/conftest.py @@ -29,7 +29,15 @@ def pytest_configure(config): def pytest_collection_modifyitems(config, items): - if config.getoption("--unit") and not config.getoption("--examples"): + unit_option = config.getoption("--unit") + examples_option = config.getoption("--examples") + + if not unit_option and not examples_option: + skip_all = pytest.mark.skip(reason="need --unit or --examples option to run") + for item in items: + item.add_marker(skip_all) + + elif unit_option and not examples_option: skip_examples = pytest.mark.skip( reason="need --examples option to run examples tests" ) @@ -37,7 +45,7 @@ def pytest_collection_modifyitems(config, items): if "examples" in item.keywords: item.add_marker(skip_examples) - if config.getoption("--examples") and not config.getoption("--unit"): + if examples_option and not unit_option: skip_unit = pytest.mark.skip(reason="need --unit option to run unit tests") for item in items: if "unit" in item.keywords: diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index fca71f2f..d0d001c1 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -1,43 +1,14 @@ import pybop import numpy as np -# Define the initial parameter set -# Add definitions for R's, C's, and initial overpotentials for any additional RC elements -# params = { -# "chemistry": "ecm", -# "Initial SoC": 0.5, -# "Initial temperature [K]": 25 + 273.15, -# "Cell capacity [A.h]": 5, -# "Nominal cell capacity [A.h]": 5, -# "Ambient temperature [K]": 25 + 273.15, -# "Current function [A]": 5, -# "Upper voltage cut-off [V]": 4.2, -# "Lower voltage cut-off [V]": 3.0, -# "Cell thermal mass [J/K]": 1000, -# "Cell-jig heat transfer coefficient [W/K]": 10, -# "Jig thermal mass [J/K]": 500, -# "Jig-air heat transfer coefficient [W/K]": 10, -# "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[ -# "Open-circuit voltage [V]" -# ], -# "R0 [Ohm]": 0.001, -# "Element-1 initial overpotential [V]": 0, -# "Element-2 initial overpotential [V]": 0, -# "R1 [Ohm]": 0.0002, -# "R2 [Ohm]": 0.0003, -# "C1 [F]": 10000, -# "C2 [F]": 5000, -# "Entropic change [V/K]": 0.0004, -# } - -# Params +# Import the ECM parameter set from JSON params = pybop.ParameterSet( - json_path="examples/scripts/parameters/ecm.json" -).import_parameters() + json_path="examples/scripts/parameters/initial_ecm_parameters.json" +) # Define the model model = pybop.empirical.Thevenin( - parameter_set=params, options={"number of rc elements": 2} + parameter_set=params.import_parameters(), options={"number of rc elements": 2} ) # Fitting parameters @@ -74,6 +45,11 @@ x, final_cost = optim.run() print("Estimated parameters:", x) +# Export the parameters to JSON +params.export_parameters( + "examples/scripts/parameters/fit_ecm_parameters.json", fit_params=parameters +) + # Plot the timeseries output pybop.quick_plot(x, cost, title="Optimised Comparison") diff --git a/examples/scripts/ecm_parameters.py b/examples/scripts/ecm_parameters.py new file mode 100644 index 00000000..7938c369 --- /dev/null +++ b/examples/scripts/ecm_parameters.py @@ -0,0 +1,93 @@ +import pybop +import numpy as np + +# Define the initial parameter set +# Add definitions for R's, C's, and initial overpotentials for any additional RC elements +params = pybop.ParameterSet( + params_dict={ + "chemistry": "ecm", + "Initial SoC": 0.5, + "Initial temperature [K]": 25 + 273.15, + "Cell capacity [A.h]": 5, + "Nominal cell capacity [A.h]": 5, + "Ambient temperature [K]": 25 + 273.15, + "Current function [A]": 5, + "Upper voltage cut-off [V]": 4.2, + "Lower voltage cut-off [V]": 3.0, + "Cell thermal mass [J/K]": 1000, + "Cell-jig heat transfer coefficient [W/K]": 10, + "Jig thermal mass [J/K]": 500, + "Jig-air heat transfer coefficient [W/K]": 10, + "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[ + "Open-circuit voltage [V]" + ], + "R0 [Ohm]": 0.001, + "Element-1 initial overpotential [V]": 0, + "Element-2 initial overpotential [V]": 0, + "R1 [Ohm]": 0.0002, + "R2 [Ohm]": 0.0003, + "C1 [F]": 10000, + "C2 [F]": 5000, + "Entropic change [V/K]": 0.0004, + } +) + +# Define the model +model = pybop.empirical.Thevenin( + parameter_set=params.import_parameters(), options={"number of rc elements": 2} +) + +# Fitting parameters +parameters = [ + pybop.Parameter( + "R0 [Ohm]", + prior=pybop.Gaussian(0.0002, 0.0001), + bounds=[1e-4, 1e-2], + ), + pybop.Parameter( + "R1 [Ohm]", + prior=pybop.Gaussian(0.0001, 0.0001), + bounds=[1e-5, 1e-2], + ), +] + +sigma = 0.001 +t_eval = np.arange(0, 900, 2) +values = model.predict(t_eval=t_eval) +CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) + +dataset = [ + pybop.Dataset("Time [s]", t_eval), + pybop.Dataset("Current function [A]", values["Current [A]"].data), + pybop.Dataset("Voltage [V]", CorruptValues), +] + +# Generate problem, cost function, and optimisation class +problem = pybop.Problem(model, parameters, dataset) +cost = pybop.SumSquaredError(problem) +optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) +optim.set_max_iterations(100) + +x, final_cost = optim.run() +print("Estimated parameters:", x) + +# Export the parameters to JSON +params.export_parameters( + "examples/scripts/parameters/fit_ecm_parameters.json", fit_params=parameters +) + +# Plot the timeseries output +pybop.quick_plot(x, cost, title="Optimised Comparison") + +# Plot convergence +pybop.plot_convergence(optim) + +# Plot the parameter traces +pybop.plot_parameters(optim) + +# Plot the cost landscape +pybop.plot_cost2d(cost, steps=15) + +# Plot the cost landscape with optimisation path and updated bounds +bounds = np.array([[1e-4, 1e-2], [1e-5, 1e-2]]) +pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15) diff --git a/examples/scripts/parameters/fit_ecm_parameters.json b/examples/scripts/parameters/fit_ecm_parameters.json new file mode 100644 index 00000000..9429d276 --- /dev/null +++ b/examples/scripts/parameters/fit_ecm_parameters.json @@ -0,0 +1,24 @@ +{ + "chemistry": "ecm", + "Initial SoC": 0.5, + "Initial temperature [K]": 298.15, + "Cell capacity [A.h]": 5, + "Nominal cell capacity [A.h]": 5, + "Ambient temperature [K]": 298.15, + "Current function [A]": 5, + "Upper voltage cut-off [V]": 4.2, + "Lower voltage cut-off [V]": 3.0, + "Cell thermal mass [J/K]": 1000, + "Cell-jig heat transfer coefficient [W/K]": 10, + "Jig thermal mass [J/K]": 500, + "Jig-air heat transfer coefficient [W/K]": 10, + "Open-circuit voltage [V]": "Unable to write value to JSON file", + "R0 [Ohm]": 0.0009061740048547629, + "Element-1 initial overpotential [V]": 0, + "Element-2 initial overpotential [V]": 0, + "R1 [Ohm]": 0.00029231930041091097, + "R2 [Ohm]": 0.0003, + "C1 [F]": 10000, + "C2 [F]": 5000, + "Entropic change [V/K]": 0.0004 +} diff --git a/examples/scripts/parameters/ecm.json b/examples/scripts/parameters/initial_ecm_parameters.json similarity index 94% rename from examples/scripts/parameters/ecm.json rename to examples/scripts/parameters/initial_ecm_parameters.json index 17cb8a69..8da71096 100644 --- a/examples/scripts/parameters/ecm.json +++ b/examples/scripts/parameters/initial_ecm_parameters.json @@ -12,7 +12,7 @@ "Cell-jig heat transfer coefficient [W/K]": 10, "Jig thermal mass [J/K]": 500, "Jig-air heat transfer coefficient [W/K]": 10, - "Open-circuit voltage [V]": 3.7, + "Open-circuit voltage [V]": "default", "R0 [Ohm]": 0.001, "Element-1 initial overpotential [V]": 0, "Element-2 initial overpotential [V]": 0, diff --git a/noxfile.py b/noxfile.py index e1759a3d..34a28e2e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -7,22 +7,20 @@ @nox.session def unit(session): session.run_always("pip", "install", "-e", ".[all]") - session.install("pytest", "pytest-mock") - session.run("pytest", "--unit", "-v", "--showlocals") + session.install("pytest", "pytest-mock", "pytest-xdist") + session.run("pytest", "--unit") @nox.session def coverage(session): session.run_always("pip", "install", "-e", ".[all]") - session.install("pytest", "pytest-cov", "pytest-mock") + session.install("pytest", "pytest-cov", "pytest-mock", "pytest-xdist") session.run( "pytest", "--unit", "--examples", - "-v", "--cov", "--cov-report=xml", - "--showlocals", ) @@ -30,5 +28,5 @@ def coverage(session): def notebooks(session): """Run the examples tests for Jupyter notebooks.""" session.run_always("pip", "install", "-e", ".[all]") - session.install("pytest", "nbmake") + session.install("pytest", "nbmake", "pytest-xdist") session.run("pytest", "--nbmake", "examples/", external=True) diff --git a/pybop/__init__.py b/pybop/__init__.py index 306cd0ca..fcc87287 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -64,8 +64,8 @@ # # Parameter classes # -from .parameters.base_parameter import Parameter -from .parameters.base_parameter_set import ParameterSet +from .parameters.parameter import Parameter +from .parameters.parameter_set import ParameterSet from .parameters.priors import Gaussian, Uniform, Exponential # diff --git a/pybop/_problem.py b/pybop/_problem.py index c16aae53..a5770f9d 100644 --- a/pybop/_problem.py +++ b/pybop/_problem.py @@ -58,10 +58,6 @@ def __init__( elif len(x0) != self.n_parameters: raise ValueError("x0 dimensions do not match number of parameters") - # Add the initial values to the parameter definitions - for i, param in enumerate(self.parameters): - param.update(value=self.x0[i]) - # Set the fitting parameters and build the model self.fit_parameters = {o.name: o.value for o in parameters} if self._model._built_model is None: diff --git a/pybop/optimisation.py b/pybop/optimisation.py index 6dc947de..d051c30f 100644 --- a/pybop/optimisation.py +++ b/pybop/optimisation.py @@ -116,6 +116,10 @@ def run(self): elif not self.pints: x, final_cost = self._run_pybop() + # Store the optimised parameters + if self.cost.problem is not None: + self.store_optimised_parameters(x) + return x, final_cost def _run_pybop(self): @@ -406,3 +410,8 @@ def set_max_unchanged_iterations(self, iterations=200, threshold=1e-11): self._unchanged_max_iterations = iterations self._unchanged_threshold = threshold + + def store_optimised_parameters(self, x): + # Add the initial values to the parameter definitions + for i, param in enumerate(self.cost.problem.parameters): + param.update(value=x[i]) diff --git a/pybop/parameters/base_parameter_set.py b/pybop/parameters/base_parameter_set.py deleted file mode 100644 index 536af63d..00000000 --- a/pybop/parameters/base_parameter_set.py +++ /dev/null @@ -1,74 +0,0 @@ -# import pybamm -# import json -# import pybop - -# class ParameterSet: -# """ -# Class for creating parameter sets in PyBOP. -# """ - -# def __new__(cls, method, name): -# if method.casefold() == "pybamm": -# return pybamm.ParameterValues(name).copy() -# else: -# raise ValueError("Only PyBaMM parameter sets are currently implemented") - -# def __init__(self): -# pass - -# def import_parameters(self, json_path): -# """ -# Import parameters from a JSON file. -# """ - -# # Read JSON file -# with open(json_path, 'r') as file: -# params = json.load(file) - -# # Set attributes based on the dictionary -# for key, value in params.items(): -# if key == "Open-circuit voltage [V]": -# # Assuming `pybop.empirical.Thevenin().default_parameter_values` is a dictionary -# value = pybop.empirical.Thevenin().default_parameter_values["Open-circuit voltage [V]"] -# setattr(self, key, value) - -import json -import pybamm -import pybop - - -class ParameterSet: - """ - Class for creating and importing parameter sets. - """ - - def __init__(self, json_path=None): - self.json_path = json_path - - def import_parameters(self, json_path=None): - """ - Import parameters from a JSON file. - """ - if json_path is None: - json_path = self.json_path - - # Read JSON file - with open(json_path, "r") as file: - params = json.load(file) - - # Set attributes based on the dictionary - if "Open-circuit voltage [V]" in params: - params[ - "Open-circuit voltage [V]" - ] = pybop.empirical.Thevenin().default_parameter_values[ - "Open-circuit voltage [V]" - ] - - return params - - @classmethod - def pybamm(cls, name): - """ - Create a PyBaMM parameter set. - """ - return pybamm.ParameterValues(name).copy() diff --git a/pybop/parameters/base_parameter.py b/pybop/parameters/parameter.py similarity index 100% rename from pybop/parameters/base_parameter.py rename to pybop/parameters/parameter.py diff --git a/pybop/parameters/parameter_set.py b/pybop/parameters/parameter_set.py new file mode 100644 index 00000000..18262ceb --- /dev/null +++ b/pybop/parameters/parameter_set.py @@ -0,0 +1,93 @@ +import json +import types +import pybamm +import pybop + + +class ParameterSet: + """ + A class to manage the import and export of parameter sets for battery models. + + Attributes: + json_path (str): The file path to a JSON file containing parameter data. + params (dict): A dictionary containing parameter key-value pairs. + """ + + def __init__(self, json_path=None, params_dict=None): + self.json_path = json_path + self.params = params_dict or {} + self.chemistry = None + + def import_parameters(self, json_path=None): + """ + Import parameters from a JSON file. + """ + + # Read JSON file + if not self.params and self.json_path: + with open(self.json_path, "r") as file: + self.params = json.load(file) + self._handle_special_cases() + if self.params["chemistry"] is not None: + self.chemistry = self.params["chemistry"] + return self.params + + def _handle_special_cases(self): + """ + Handles special cases for parameter values that require custom logic. + """ + if ( + "Open-circuit voltage [V]" in self.params + and self.params["Open-circuit voltage [V]"] == "default" + ): + self.params[ + "Open-circuit voltage [V]" + ] = pybop.empirical.Thevenin().default_parameter_values[ + "Open-circuit voltage [V]" + ] + + def export_parameters(self, output_json_path, fit_params=None): + """ + Export parameters to a JSON file. + """ + if not self.params: + raise ValueError("No parameters to export. Please import parameters first.") + + # Prepare a copy of the params to avoid modifying the original dict + exportable_params = {**{"chemistry": self.chemistry}, **self.params.copy()} + + # Update parameter set + if fit_params is not None: + for i, param in enumerate(fit_params): + exportable_params.update({param.name: param.value}) + + # Replace non-serializable values + for key, value in exportable_params.items(): + if isinstance(value, types.FunctionType) or not self.is_json_serializable( + value + ): + exportable_params[key] = "Unable to write value to JSON file" + + # Write parameters to JSON file + with open(output_json_path, "w") as file: + json.dump(exportable_params, file, indent=4) + + def is_json_serializable(self, value): + """ + Check if the value is serializable to JSON. + """ + try: + json.dumps(value) + return True + except (TypeError, OverflowError): + return False + + @classmethod + def pybamm(cls, name): + """ + Create a PyBaMM parameter set. + """ + try: + return pybamm.ParameterValues(name).copy() + except ValueError as e: + raise ValueError(f"Parameter set '{name}' not found. PyBaMM error: {e}") diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..6d4fb128 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +# pytest.ini +[pytest] +addopts = -n auto --dist loadscope --showlocals -v diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py index 2ba6ce12..fc9356d2 100644 --- a/tests/unit/test_parameter_sets.py +++ b/tests/unit/test_parameter_sets.py @@ -12,7 +12,7 @@ class TestParameterSets: def test_parameter_set(self): # Tests parameter set creation with pytest.raises(ValueError): - pybop.ParameterSet.pybamm("Chen2020s") + pybop.ParameterSet.pybamm("sChen2010s") parameter_test = pybop.ParameterSet.pybamm("Chen2020") np.testing.assert_allclose( From fc107aa418db75c709b704f54d6649c6e3ef845f Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 5 Dec 2023 10:07:31 +0000 Subject: [PATCH 053/101] Notebooks patch for updated Problem cls API --- examples/notebooks/spm_nlopt.ipynb | 218 ++++++++++++++--------------- 1 file changed, 109 insertions(+), 109 deletions(-) diff --git a/examples/notebooks/spm_nlopt.ipynb b/examples/notebooks/spm_nlopt.ipynb index 50f0877d..b64a2a9a 100644 --- a/examples/notebooks/spm_nlopt.ipynb +++ b/examples/notebooks/spm_nlopt.ipynb @@ -23,8 +23,8 @@ }, "outputs": [ { - "output_type": "stream", "name": "stdout", + "output_type": "stream", "text": [ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.1/2.1 MB\u001b[0m \u001b[31m19.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m139.4/139.4 kB\u001b[0m \u001b[31m14.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n", @@ -191,38 +191,38 @@ }, "outputs": [ { - "output_type": "display_data", "data": { - "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=1.1333333333333333, step=0.01133333333333333…" - ], "application/vnd.jupyter.widget-view+json": { + "model_id": "8d003c14da5f4fa68284b28c15cee6e6", "version_major": 2, - "version_minor": 0, - "model_id": "8d003c14da5f4fa68284b28c15cee6e6" - } + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(FloatSlider(value=0.0, description='t', max=1.1333333333333333, step=0.01133333333333333…" + ] }, - "metadata": {} + "metadata": {}, + "output_type": "display_data" }, { - "output_type": "execute_result", "data": { "text/plain": [ "" ] }, + "execution_count": 25, "metadata": {}, - "execution_count": 25 + "output_type": "execute_result" }, { - "output_type": "display_data", "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAABdEAAAKxCAYAAAC8BuXeAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOzdeVxU1f8/8NeADKsDorIlAqYhmIDiRxzLHRnQj2n5KRdS3D8aaEhp8ckItcJc0ST5mAtW8nGpNFMDCUNccENHccMN0xIwU0BQAeH8/vDH/ToybIoMwuv5eNxH3Xve9973PTNzOZ65c45MCCFARERERERERERERETl6Ok6ASIiIiIiIiIiIiKi+oqd6EREREREREREREREFWAnOhERERERERERERFRBdiJTkRERERERERERERUAXaiExERERERERERERFVgJ3oREREREREREREREQVYCc6EREREREREREREVEF2IlORERERERERERERFQBdqITEREREREREREREVWAnehPyNHREZGRkbpOo1ZduXIFMpkMarW6WvFjxozBkCFDnmlOT2r//v3o2LEjDAwM6m2OpEkmk2Hr1q3P9Bzh4eGQyWSQyWR18vmti2uqrrq+9sasPr3ujwsPD4eHh4eu06Aq1EUbIyYmRronBAcHP9NzAfWr3VTX1061JykpCTKZDDk5OZXG1af3W21hO510ie30Z4vtdKL6o+zvrUwme6J/N5Xta2FhUeu5ke7Vu070MWPGQCaTYd68eRrbt27dCplMVuf5xMTEaH3zHzlyBJMmTarzfGqLtoa1vb09MjMz8fLLL+smqVoUEhICDw8PZGRkICYmRtfpPNcq+gw8qYo68TIzM+Hn51dr56lIhw4dkJmZ+Vx/fh+Xnp6OPn36wNraGkZGRmjTpg1mzZqF4uJiKeb9999HZmYmWrVqpcNMn39lf6MeX3x9fZ/ZOXX5j7za/vw/76rbgVddum5jKBQKZGZmYu7cuc/8XHXl77//hq+vL+zs7GBoaAh7e3sEBQUhLy9Pihk2bBgyMzOhVCp1mGnD9eh9Ui6Xo23btpgzZw4ePHjw1Mfu3r07MjMzYW5uDkD3n6Fnhe10qi620+s/ttOpIcrKysLUqVPRpk0bqb01aNAgJCYm6jq1StXk31W//vqr1uv5448/IJfLK/x7nJmZyS/DGrAmuk5A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SdsyMzMFAJGSklLhccoolUphaGgoAIhJkyaJkpKScjFsnD+dgIAAoa+vL0xNTTWWzz77TIp59HX/9ttvhbOzs9TZKMTDzlNjY2MRHx8vhKj83vP48cp88sknwsDAQKPxt2vXLqGvry+uXr0qbSv7PB8+fFja79HPwBdffCFcXFyk9R9++EGYmZlJnz1tn//ff/9d6Ovriz///FNje79+/URoaKjWa4iPjxd6enoiPT1da/nIkSNF//79NbbNmDFDuLq6SusODg7i7bffltZLS0uFlZWVWLFihRCi6ntuv379yjX4v/32W2FrayutV3XPfbQD71G9evUSnTp10nreRz1JG6O6r6uJiYnG39MZM2YILy+vCnOp7NyP1nPZPejjjz+WtpXdIzMzM4UQ1X/9qupEd3Bw0LhvOTs7ix49ekjrZffI//3vfxUep8zw4cOFsbGxACAGDRok7t27Vy6GnejPxqPtw9LSUpGQkCAMDQ3F+++/L86fPy8AiP3790vxN2/eFMbGxmLTpk1CCCE6duyo8eXWox7/DLKdzna6EGyns53OdjpRXTl06JAAIH788cdK42rSib5161aNfbW1q+fOnSt8fHw0tl27dk16uKVsv0c/s0II8Y9//EN88MEH0npVn/uKci8zcuRIjb9r7u7uWv+esRO94aqXw7mU+eKLL7Bu3TqcPXu2XNmJEycQExMjjR9mZmYGlUqF0tJSZGRkID09HU2aNEHnzp2lfdq2bVvuJ/e//vor+vXrhxdeeAFNmzbFqFGj8Pfff+Pu3bvVzrNJkyZ46623sH79egBAQUEBfvrpJ/j7+wMALl68iLt376J///4a+X7zzTcV/uSyzOOTXimVSo36qE7+crkcbm5u1b6eMmq1Gj169ICBgUGVsU9yjf3794eDgwPatGmDUaNGYf369TWq9zJdunSpVlxUVBQ8PT3RsmVLmJmZYeXKlbh69SoA4MaNG7h+/Tr69eundd8TJ04gPz8fzZs317i+jIyMCq9PrVajU6dOsLS01Fp+9uxZvPLKKxrbXnnllXLv90dfO1NTUygUCty4cUM6R0WvUUFBAS5duoTx48dr5Pzpp5+Wy/nRc9ja2kp1UhVtdb9x40a88sorsLGxgZmZGWbNmiXVc3WdPXsW7u7uMDU1lba98sorKC0tRXp6urStQ4cOGmMH2traVitvbR6t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9x3y8vIejuMOoGXLltDX18fPP/+M7OxsdOvWDUZGRkhISMDnn3+O999/XzrH5MmTsXz5csycORPjxo3D7t27sWnTJuzYsUOKCQkJQUBAALp06YKuXbsiMjJSo1OGiIjqh2HDhuGvv/5CWFgYsrKy4OHhgbi4OGlei6tXr0JP7/+exezevTtiY2Mxa9Ys/Oc//0G7du2wdetWvPzyy7q6BGpohNB1BkQSdqITETUwvXv3hqiksSGTyTBnzhyNzuvHWVpaIjY2ttLzuLm5Ye/evRWWjxkzBmPGjNHY5uDggJ07d1Z63N69e+P48eOVxhARUdVu3LiB0aNHIzMzE+bm5nBzc0N8fDz69++PpKQkHDp0CADQtm1bjf0yMjLg6OgIAwMDREVFYfr06RBCoG3btli8eDEmTpwoxVbny8+qOmWIiKj+CAoKqvBJ8qSkpHLb3nzzTbz55pvPOCtiZzI9tcb6HqrF62YnOhERERFRA7R69eoKy6r6whUAfH194evrW+V5qvPlZ2WdMkRERERE9R3HRCciIiIiIiIiIp2oalLrRk0m03UG9QPr4cmx7h6qhXpgJzoREREREREREemEXC7XdQpERFViJzoRERERERERERERUQXYiU5ERERERERERDpRWlqq6xSIiKrETnQiIiIiIiIiItKJe/fu6TqF+q+KycAbrMZ63c9CY63LWrxudqITEREREREREREREVWAnehERERERERERET1jUym6wzqB9bDk2PdPVQL9cBOdCIiIiIiIiIiIiKiCrATnYiIiIiIiIiIiIioAuxEJyIiIiIiIiIiIiKqADvRiYiIiIiIiIiIqH4RQtcZEEnYiU5ERERERERERDrRpEkTXadQ/7EzmZ5WY30P1eJ1sxOdiIiIiIiIiIh0wtDQUNcpEBFViZ3oRERERERERERE9Y1MpusM6gfWw5Nj3T1UC/XATnQiIiIiIiIiItIJ0ViHmSCi5wo70YmIiIiIiIiISCfu3r2r6xSIiKrETnQiIiIiIiIiIiKqX/grBapH2IlORERERERERERUX7EzmZ5WY30P1eJ1sxOdiIiIiKgBWrFiBdzc3KBQKKBQKKBUKvHLL78AAG7duoWpU6fC2dkZxsbGaN26NaZNm4bc3FyNY1y9ehUDBw6EiYkJrKysMGPGDDx48EAjJikpCZ07d4ahoSHatm2LmJiY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Rb1D5ERENU+l1kQvKCjAkSNH8P7778PR0RFNmjSBra2tQsmXmJgY5ObmwsHBQZxnaWmJZs2aITo6GoC8HleHDh1gbGwstnF0dERGRgauX78utnm9j8I2hX0Uh/W3iIiIiIiIiKg+MDIygqWlZamThoYG7OzskJaWhpiYGHHdyMhIFBQUwNbWtti+586diytXriAuLk6cAHlpxm3btlXH7tUPEomyI6gZeBzeHo+dXCUch0pNoqekpCAzMxNLly6Fk5MTfv/9dwwbNgzDhw/H8ePHAchrbWloaBT5tNLY2FistZWUlKSQQC9cXristDYZGRl4+fJlsfGx/hYRERERERER0f9YWVnByckJXl5eOH/+PE6fPg1vb2+MGTMGpqamAIBHjx7B0tIS58+fBwDIZDK0b99eYQKAZs2aoUWLFhXavopKpaamqC6pryVIqEaqcDmX0hQUFAAAhg4dCl9fXwBAp06dcObMGQQFBaF3796VubkKY/0tIiIiIiIiIiJFu3btgre3N/r37w8VFRWMGDEC69atE5fn5uYiPj4eL168qPRta2lpVXqfRESVrVKT6I0bN4aamhratm2rML+wXjkg/7QyJycHaWlpClejJycni7W2ZDKZ+Onm68sLlxX+LJz3ehs9PT0OwERERERERERE5WRoaIiQkJASl5ubm0Mo46rgspYTEdVmlfqdGQ0NDXTr1g3x8fEK82/duoXmzZsDAGxsbKCuro6IiAhxeXx8PB48eAA7OzsAgJ2dHa5evapwY4vw8HDo6emJCXo7OzuFPgrbFPZBRERERERERERERPSuKnwlemZmJu7cuSM+v3fvHuLi4mBoaIhmzZph9uzZGD16NHr16oW+ffvi6NGjOHTokHhnZn19fXh6esLPzw+GhobQ09PDtGnTYGdnhx49egAABgwYgLZt22L8+PFYvnw5kpKS8OWXX2Lq1KliOZbJkydjw4YN+PzzzzFhwgRERkZi7969OHLkSCUcFiIiIiIiIiIiqmovXryAnp6essOo2XiVP72r+vo7VIn7XeEk+sWLF9G3b1/xuZ+fHwDAzc0NwcHBGDZsGIKCghAYGIjp06fDwsIC+/btQ8+ePcV1Vq9eLdbYys7OhqOjI7799ltxuaqqKg4fPowpU6bAzs4O2tracHNzw1dffSW2adGiBY4cOQJfX1+sXbsWTZs2xfff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6h6O7UREVJIuXbrg+vXrMDc3R+vWrRUmbW1tZYdHdRCT6FSv5OTk4JNPPsHo0aOxePFiTJw4scJXlMTHx+PVq1cwNTUV50VHR+PRo0cKJ+NERFQ9OLYTEdU9HNuJiKg0U6dORWpqKsaOHYsLFy4gISEBYWFh8PDwQH5+vrLDozqISXSqV+bNm4f09HSsW7cOc+bMwfvvv48JEyZUqI+4uDgAwPr163H79m389ttvcHV1BSA/2SciourFsZ2IqO7h2E5ERKUxNTXF6dOnkZ+fjwEDBqBDhw7w8fGBgYEBVFSY7qTKx5roVG9ERUVhzZo1OHbsGPT09AAAO3fuhLW1NTZt2oQpU6aUq5+4uDg4Ojri7t276NChA9q2bYtFixZhypQpWLduHXbu3FmVu0FERK/h2E5EVPdwbCciqhvc3d3h7u5eZH5wcLDC86ioqCJtiivjJQiCwvM2bdrg559/focIS95W4QexRIWYRKd6o0+fPsjNzVWYZ25ujvT09Ar1c/nyZXTr1g3/+c9/FOa/fsOit30DICKiiuHYTkRU93BsJyKiqpKeng4dHR1MnTq12JuSlkRHRwd5eXnQ1NSswuioJuP3G4jK8O2330JHRwdXr14FID8Z79ChQ5Vsa/LkydDR0amSvomI6H84thMR1T0c24mIqDQjRozA7du3ERcXh9mzZ1do3bi4OFy7dg2xsbFVFB3VdBKBH6kTlejRo0d4+fIlAKBZs2ZITU2FiYkJrl+/jrZt21b69lJSUpCRkQEAMDEx4R2liYiqAMd2IqK6h2M7ERERVSUm0YmIiIiIiIiIiIiISsByLkREREREREREREREJWASnYiIiIiIiIiIiIioBEyiExERERERERERERGVgEl0IiIiIiIiIiIiIqISMIlORERERERERERERFQCJtGJiIiIiIiIiIiIiErAJDoRERERERERERERUQmYRCciIiIiIiIiIiIiKgGT6EREREREREREREREJWASnYiIiIiIiIiIiIioBEyiExERERERERERERGVgEl0IiIiIiIiIiIiIqISMIlORERERERERERERFQCJtGJiIiIiIiIiIiIiErAJDoRERERERERERERUQmYRCciIiIiIiIiIiIiKgGT6FQh5ubmWLNmjVJjCA4OhoGBgVJjqKiacNyUISAgAJ06darQOhKJBPv37y9xeZ8+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IiKq+xo0aKDsEIiIysQkOhERERERERERKYVEIlF2CEREZWISnYiIiIiIiIiIiIioBEyiExERERERERGRUmRnZys7BKqpnjwBLl1SdhRUm+XlASdOALm579wVk+hERERERERERKQUeXl5yg6BaqIXLwBbW6BrV+DuXWVHQ7WVhwfQuzewY8c7d8UkOhEREREREREREdUcgYHy5LkgAI8fKzsaqo2iooAffpA//vvvd+6OSXQiIiIiIiIiIiKqGe7cAZYvV3YUVJvl5gLe3pXaJZPoREREREREREREpHyCAEyfDuTkKDsSqs02bACuX6/ULplEJyIiIiIiIiIiIuU7eBD47TdAXR3Q15fPEwTlxkS1S2IisHCh/LGRkfxnJfwOMYlOREREREREREREyvXyJeDjI388cyZgbKzUcKiWmj0beP4c6N4dGDGi0rplEp2IiIiIiIiIiIiUKzAQuH8faNoU+PJLZUdDtdHx48CuXYBEAmzcCKhUXuqbSXQiIiIiIiIiIlKKBg0aKDsEqgni44Fly+SPV60CtLWVGw/VPjk5wJQp8seTJgFdu1Zq90yiExERERERERGRUkgkEmWHQMomCPLkZ04OMHAg8O9/F11OVJYVK4AbN4AmTeTfangda6ITERERERERERFRrfXDD8CxY4CmJrBhg7wUB/C/n0RlSUgAFi+WP161CmjYUP64En+HmEQnIiIiIiIiIiKlyM7OVnYIpEypqfKbiALAggVAy5bKjYdqH0EApk4FXr0C+vcHxo2rks0wiU5EREREREREREqRl5en7BBImebOBf75B2jb9n/JdKKKCA0FwsIADQ3g22+r7BsMTKITERERERERERFR9TpzBvjvf+WPg4LkSdDXFSZDWROdSpKeDsyYIX/8xRfA++8X34410YmIiIiIiIiIiKhWyc0FPv1U/njCBODDD5UbD9VO8+YBSUny5PncuUWXsyY6ERERERERERER1Upr1gDXrgGNGgHLlys7GqqNLlyQl28BgE2bAKm0SjfHJDoRERERERERERFVj7/+AgIC5I9XrJAn0okqIi9P/k0GQQA++QTo16/KN8kkOhFRPZOamgoXFxfo6enBwMAAnp6eyMzMLHWdzZs3o0+fPtDT04NEIkFaWlqx7Y4cOQJbW1toaWmhYcOGcHZ2FpcFBwdDIpEUO6WkpAAAoqKiil2e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5AQsXqzsiIpXm2qiExERERERERERFUcqlSo7hPrl/n15HfS8PGDcOHk9a6KKyMmR/w49fAi8/z7w44+Aqqqyo6pyTKITEREREREREZFSqKlVW5EEysqSXz389CnQpQvw/fe82psqbsYM4ORJQE8POHBAfkPReoBJdCIiIiIiIiIiorpMEAB3d+DKFaBJE2D/fkBLS9lRlQ9rotccQUHyqbCWvqWlsiMqn0r4HWISnYiIiIiIiIiIlCI3N1fZIdQPS5YAP/0EqKsD+/YBZmbKjqhsvEq+Zjl5Epg2Tf54yRJg8GDlxlMerIlORERERERERES1XU5OjrJDqPv27QPmz5c/3rgR6NlTufFQ7XP3LjB8uLyW/ujRwJw5yo6o2jGJTkREREREREREVBedOwd88om8nMXUqYCXl7Ijotrm2TNg0CDgyRN5Lf2tW+vltwSYRCciIiIiIiIiIqpr7t8HPv4YePVKngRds0bZEb0d1kRXnpwc+RXo8fHyEkCHDgENGig7qopjTXQiIiIiIiIiIiJSkJYmr1mdkgJYWwO7dwNqasqOqmLq4dXONYogAJ9+CkRFAbq6wOHDgKmpsqOqGNZEJyIiIiIiIiIioiJyc4GRI4E//5QnPQ8flidBiSpiyRIgOBhQVQX27gU6dlR2RErFJDoREREREREREVFdIAjAlCnAH38A2tryBHrTpsqOimqbH38EvvxS/nj9esDJSbnx1ABMohMREREREREREdUFy5cDW7YAKiryEi6dOys7orenqSn/+fy5cuOob06fBtzd5Y9nzpR/KFNbVeLvEJPoRERERERERESkFFKpVNkh1B179wJz58ofr10L/Otfyo3nXbVpI/9586Zy46hPbt8Ghg6V31B02DD5hzK1WeHv0I0b79wVk+hERERERERERKQUarXtZpc11YkTwPjx8sfTpwPe3sqNpzIUXkUfEaHcOOqLlBR52ZanT4GuXYEffpB/o6E269JF/vPiRfnNdt9BLT8SRERERERERERE9diNG4pXD69apeyIKsfQoYBEApw5A5w/r+xo6rasLGDIEODuXaBFC3kt/QYNlB3Vu3v/faBtW/nNdr/77p26YhKdiIiIiIiIiIiUIi8vT9kh1G6JicDAgfKrbO3sgF27AFVVZUdVOd57D/jkE/njTz6R7ytVvrw8YOxY+QcVhobAb78BxsbK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d+nSpTKZTDKZTBo5cqRlfoMGDTRr1qxC1726no+PT5nGCMflZu8AAAAAAAAAADg/k8lU6PKJEyfqzTfflGEYNorImpeXlw4ePKiqVave0HrJycn64IMPNHHixDKKDI6OkehAITp06GC52piUlFTm+xs4cKBlf5988kmZ7w8AKiL6dgAof+jbAcAxJCcnW6ZZs2bJy8vLat4zzzwjb29vu43oNplMCggIUPXq1W9ovYCAAHl7e5dRVHAGJNGBIkRFRSk5OVm33357me/rzTffVHJycpnvBwAqOvp2ACh/6NsBwP4CAgIsk7e3tyVpfXWqVq1annIuHTp00JNPPqmRI0eqRo0a8vf31zvvvKPMzEwNGjRI1atXV5MmTfTf//7Xal979+5Vly5dVK1aNfn7++uxxx7T2bNnixV3VlaWHn/8cVWvXl3169fXggULSvIyoBwiiQ4UoUqVKgoICJCbW9lXP/L29lZAQECZ7wcAKjr6dgAof+jbAcB5LVu2TL6+vtq+fbuefPJJDR8+XH379lW7du20c+dO3X///XrssceUlZUlSUpLS1PHjh0VFBSk77//XnFxcTp9+rQefvjhYu3/tddeU9u2bbVr1y498cQTGj58uA4ePFiahwgnRxIdFcZvv/2mgIAATZ061TJv8+bNcnd31/r1629oW99++60qVaqkixcvWuYdP35cJpNJP//8c7GvogIAbgx9OwCUP/TtAFDxtGrVSuPGjdMtt9yisWPHytPTU76+voqKitItt9yiCRMm6Ny5c/rhhx8kSW+99ZaCgoI0depUBQYGKigoSIsXL9bGjRv1008/3fD+u3btqieeeEJNmjTR888/L19fX23cuLG0DxNOjCQ6KoxatWpp8eLFmjRpkr7//nudP39ejz32mKKjo9WpU6cb2lZSUpKaNWsmT09Py7xdu3apRo0auvnmmyXd+FVUAMCNo28HgPKHvh0AKp6WLVtaHru6uuqmm25SixYtLPP8/f0lSWfOnJEk7d69Wxs3blS1atUsU2BgoCTpyJEjJdr/1RI0V/cFSCTRUcF07dpVUVFRCg8P17Bhw1S1alVNmzbthreze/duBQUFWc1LSkpSq1atLM9v9CoqAKB46NsBoPyhbweAiqVSpUpWz00mk9U8k8kkScrNzZUkXbhwQT169FBSUpLVdOjQId17772lsv+r+wIkkuiogGbOnKkrV65o9erVWr58uTw8PG54G0lJSWrdurXVvF27dlnNu9GrqACA4qNvB4Dyh74dAFCQO+64Q/v27VODBg3UpEkTq6lq1ar2Dg/lEEl0VDhHjhzRqVOnlJubq+PHj9/w+jk5Odq7d2+eES07d+60+jB+o1dRAQDFR98OAOUPfTsAoCAjRoxQamqq+vfvrx07dujIkSNat26dBg0apJycHHuHh3Ko7G9bDjiQS5cuacCAAXrkkUfUtGlTDRkyRHv27JGfn991b+PgwYO6ePGi6tSpY5m3ZcsW/frrr3lGuQAAyh59OwCUP/TtAIDC1KlTR999952ef/553X///crOztbNN9+ssLAwubgwZhiljyQ6KpQXX3xR6enpmj17tqpVq6YvvvhCjz/+uNauXXvd20hKSpIkzZkzR0899ZQOHz6sp556SpL5wz4AwLbo2wGg/KFvBwDnN3DgQA0cODDP/KVLl1o9T0hIyNMmv28gGYZh9fyWW27RRx99VIIIC97X1b8hwFVcmkGFkZCQoFmzZum9996Tl5eXXFxc9N577+mbb77R/Pnzr3s7SUlJCg0N1dGjR9WiRQu9+OKLeumll+Tl5aXZs2eX4REAAP6Kvh0Ayh/6dgBAWUlPT1e1atX0/PPP39B61apV07Bhw8ooKjgDRqKjwujQoYMuX75sNa9BgwZKT0+/oe3s3r1bd955p/71r39ZzX/00Uctj4t7FRUAcGPo2wGg/KFvBwCUhT59+qh9+/aSJB8fnxta9+rIdFdX11KOCs6CkehAEebNm6dq1appz549kswfxlu0aFEm+xo2bJiqVatWJtsGAPyJvh0Ayh/6dgBAYapXr64mTZqoSZMm8vX1vaF1r67XsGHDMooOjs5kcEkdKNCvv/6qP/74Q5JUv359paamqnbt2tq3b5+aN29e6vs7c+aMMjIyJEm1a9dW1apVS30fAFDR0bcDQPlD3w4AAMoSSXQAAAAAAAAAAApAORcAAAAAAAAAAApAEh0AAAAAAAAAgAKQRAcAAAAAAAAAoAAk0QEAAAAAAAAAKABJdAAAAAAAAAAACkASHQAAAAAAAACAApBEBwAAAAAAAACgACTRAQAAAAAAAAAoAEl0AAAAAAAAAAAKQBIdAAAAAAAAAIAC/D9pNV++FzK5QwAAAABJRU5ErkJggg==", "text/plain": [ "
" - ], - "image/png": "iVBORw0KGgoAAAANSUhEUgAABdEAAAKxCAYAAAC8BuXeAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOzdeVxU1f8/8NeADKsDorIlAqYhmIDiRxzLHRnQj2n5KRdS3D8aaEhp8ckItcJc0ST5mAtW8nGpNFMDCUNccENHccMN0xIwU0BQAeH8/vDH/ToybIoMwuv5eNxH3Xve9973PTNzOZ65c45MCCFARERERERERERERETl6Ok6ASIiIiIiIiIiIiKi+oqd6EREREREREREREREFWAnOhERERERERERERFRBdiJTkRERERERERERERUAXaiExERERERERERERFVgJ3oREREREREREREREQVYCc6EREREREREREREVEF2IlORERERERERERERFQBdqITEREREREREREREVWAnehPyNHREZGRkbpOo1ZduXIFMpkMarW6WvFjxozBkCFDnmlOT2r//v3o2LEjDAwM6m2OpEkmk2Hr1q3P9Bzh4eGQyWSQyWR18vmti2uqrrq+9sasPr3ujwsPD4eHh4eu06Aq1EUbIyYmRronBAcHP9NzAfWr3VTX1061JykpCTKZDDk5OZXG1af3W21hO510ie30Z4vtdKL6o+zvrUwme6J/N5Xta2FhUeu5ke7Vu070MWPGQCaTYd68eRrbt27dCplMVuf5xMTEaH3zHzlyBJMmTarzfGqLtoa1vb09MjMz8fLLL+smqVoUEhICDw8PZGRkICYmRtfpPNcq+gw8qYo68TIzM+Hn51dr56lIhw4dkJmZ+Vx/fh+Xnp6OPn36wNraGkZGRmjTpg1mzZqF4uJiKeb9999HZmYmWrVqpcNMn39lf6MeX3x9fZ/ZOXX5j7za/vw/76rbgVddum5jKBQKZGZmYu7cuc/8XHXl77//hq+vL+zs7GBoaAh7e3sEBQUhLy9Pihk2bBgyMzOhVCp1mGnD9eh9Ui6Xo23btpgzZw4ePHjw1Mfu3r07MjMzYW5uDkD3n6Fnhe10qi620+s/ttOpIcrKysLUqVPRpk0bqb01aNAgJCYm6jq1StXk31W//vqr1uv5448/IJfLK/x7nJmZyS/DGrAmuk5A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SdsyMzMFAJGSklLhccoolUphaGgoAIhJkyaJkpKScjFsnD+dgIAAoa+vL0xNTTWWzz77TIp59HX/9ttvhbOzs9TZKMTDzlNjY2MRHx8vhKj83vP48cp88sknwsDAQKPxt2vXLqGvry+uXr0qbSv7PB8+fFja79HPwBdffCFcXFyk9R9++EGYmZlJnz1tn//ff/9d6Ovriz///FNje79+/URoaKjWa4iPjxd6enoiPT1da/nIkSNF//79NbbNmDFDuLq6SusODg7i7bffltZLS0uFlZWVWLFihRCi6ntuv379yjX4v/32W2FrayutV3XPfbQD71G9evUSnTp10nreRz1JG6O6r6uJiYnG39MZM2YILy+vCnOp7NyP1nPZPejjjz+WtpXdIzMzM4UQ1X/9qupEd3Bw0LhvOTs7ix49ekjrZffI//3vfxUep8zw4cOFsbGxACAGDRok7t27Vy6GnejPxqPtw9LSUpGQkCAMDQ3F+++/L86fPy8AiP3790vxN2/eFMbGxmLTpk1CCCE6duyo8eXWox7/DLKdzna6EGyns53OdjpRXTl06JAAIH788cdK42rSib5161aNfbW1q+fOnSt8fHw0tl27dk16uKVsv0c/s0II8Y9//EN88MEH0npVn/uKci8zcuRIjb9r7u7uWv+esRO94aqXw7mU+eKLL7Bu3TqcPXu2XNmJEycQExMjjR9mZmYGlUqF0tJSZGRkID09HU2aNEHnzp2lfdq2bVvuJ/e//vor+vXrhxdeeAFNmzbFqFGj8Pfff+Pu3bvVzrNJkyZ46623sH79egBAQUEBfvrpJ/j7+wMALl68iLt376J///4a+X7zzTcV/uSyzOOTXimVSo36qE7+crkcbm5u1b6eMmq1Gj169ICBgUGVsU9yjf3794eDgwPatGmDUaNGYf369TWq9zJdunSpVlxUVBQ8PT3RsmVLmJmZYeXKlbh69SoA4MaNG7h+/Tr69eundd8TJ04gPz8fzZs317i+jIyMCq9PrVajU6dOsLS01Fp+9uxZvPLKKxrbXnnllXLv90dfO1NTUygUCty4cUM6R0WvUUFBAS5duoTx48dr5Pzpp5+Wy/nRc9ja2kp1UhVtdb9x40a88sorsLGxgZmZGWbNmiXVc3WdPXsW7u7uMDU1lba98sorKC0tRXp6urStQ4cOGmMH2traVitvbR6t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9x3y8vIejuMOoGXLltDX18fPP/+M7OxsdOvWDUZGRkhISMDnn3+O999/XzrH5MmTsXz5csycORPjxo3D7t27sWnTJuzYsUOKCQkJQUBAALp06YKuXbsiMjJSo1OGiIjqh2HDhuGvv/5CWFgYsrKy4OHhgbi4OGlei6tXr0JP7/+exezevTtiY2Mxa9Ys/Oc//0G7du2wdetWvPzyy7q6BGpohNB1BkQSdqITETUwvXv3hqiksSGTyTBnzhyNzuvHWVpaIjY2ttLzuLm5Ye/evRWWjxkzBmPGjNHY5uDggJ07d1Z63N69e+P48eOVxhARUdVu3LiB0aNHIzMzE+bm5nBzc0N8fDz69++PpKQkHDp0CADQtm1bjf0yMjLg6OgIAwMDREVFYfr06RBCoG3btli8eDEmTpwoxVbny8+qOmWIiKj+CAoKqvBJ8qSkpHLb3nzzTbz55pvPOCtiZzI9tcb6HqrF62YnOhERERFRA7R69eoKy6r6whUAfH194evrW+V5qvPlZ2WdMkRERERE9R3HRCciIiIiIiIiIp2oalLrRk0m03UG9QPr4cmx7h6qhXpgJzoREREREREREemEXC7XdQpERFViJzoRERERERERERERUQXYiU5ERERERERERDpRWlqq6xSIiKrETnQiIiIiIiIiItKJe/fu6TqF+q+KycAbrMZ63c9CY63LWrxudqITEREREREREREREVWAnehERERERERERET1jUym6wzqB9bDk2PdPVQL9cBOdCIiIiIiIiIiIiKiCrATnYiIiIiIiIiIiIioAuxEJyIiIiIiIiIiIiKqADvRiYiIiIiIiIiIqH4RQtcZEEnYiU5ERERERERERDrRpEkTXadQ/7EzmZ5WY30P1eJ1sxOdiIiIiIiIiIh0wtDQUNcpEBFViZ3oRERERERERERE9Y1MpusM6gfWw5Nj3T1UC/XATnQiIiIiIiIiItIJ0ViHmSCi5wo70YmIiIiIiIiISCfu3r2r6xSIiKrETnQiIiIiIiIiIiKqX/grBapH2IlORERERERERERUX7EzmZ5WY30P1eJ1sxOdiIiIiKgBWrFiBdzc3KBQKKBQKKBUKvHLL78AAG7duoWpU6fC2dkZxsbGaN26NaZNm4bc3FyNY1y9ehUDBw6EiYkJrKysMGPGDDx48EAjJikpCZ07d4ahoSHatm2LmJiY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Rb1D5ERENU+l1kQvKCjAkSNH8P7778PR0RFNmjSBra2tQsmXmJgY5ObmwsHBQZxnaWmJZs2aITo6GoC8HleHDh1gbGwstnF0dERGRgauX78utnm9j8I2hX0Uh/W3iIiIiIiIiKg+MDIygqWlZamThoYG7OzskJaWhpiYGHHdyMhIFBQUwNbWtti+586diytXriAuLk6cAHlpxm3btlXH7tUPEomyI6gZeBzeHo+dXCUch0pNoqekpCAzMxNLly6Fk5MTfv/9dwwbNgzDhw/H8ePHAchrbWloaBT5tNLY2FistZWUlKSQQC9cXristDYZGRl4+fJlsfGx/hYRERERERER0f9YWVnByckJXl5eOH/+PE6fPg1vb2+MGTMGpqamAIBHjx7B0tIS58+fBwDIZDK0b99eYQKAZs2aoUWLFhXavopKpaamqC6pryVIqEaqcDmX0hQUFAAAhg4dCl9fXwBAp06dcObMGQQFBaF3796VubkKY/0tIiIiIiIiIiJFu3btgre3N/r37w8VFRWMGDEC69atE5fn5uYiPj4eL168qPRta2lpVXqfRESVrVKT6I0bN4aamhratm2rML+wXjkg/7QyJycHaWlpClejJycni7W2ZDKZ+Onm68sLlxX+LJz3ehs9PT0OwERERERERERE5WRoaIiQkJASl5ubm0Mo46rgspYTEdVmlfqdGQ0NDXTr1g3x8fEK82/duoXmzZsDAGxsbKCuro6IiAhxeXx8PB48eAA7OzsAgJ2dHa5evapwY4vw8HDo6emJCXo7OzuFPgrbFPZBRERERERERERERPSuKnwlemZmJu7cuSM+v3fvHuLi4mBoaIhmzZph9uzZGD16NHr16oW+ffvi6NGjOHTokHhnZn19fXh6esLPzw+GhobQ09PDtGnTYGdnhx49egAABgwYgLZt22L8+PFYvnw5kpKS8OWXX2Lq1KliOZbJkydjw4YN+PzzzzFhwgRERkZi7969OHLkSCUcFiIiIiIiIiIiqmovXryAnp6essOo2XiVP72r+vo7VIn7XeEk+sWLF9G3b1/xuZ+fHwDAzc0NwcHBGDZsGIKCghAYGIjp06fDwsIC+/btQ8+ePcV1Vq9eLdbYys7OhqOjI7799ltxuaqqKg4fPowpU6bAzs4O2tracHNzw1dffSW2adGiBY4cOQJfX1+sXbsWTZs2xfff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6h6O7UREVJIuXbrg+vXrMDc3R+vWrRUmbW1tZYdHdRCT6FSv5OTk4JNPPsHo0aOxePFiTJw4scJXlMTHx+PVq1cwNTUV50VHR+PRo0cKJ+NERFQ9OLYTEdU9HNuJiKg0U6dORWpqKsaOHYsLFy4gISEBYWFh8PDwQH5+vrLDozqISXSqV+bNm4f09HSsW7cOc+bMwfvvv48JEyZUqI+4uDgAwPr163H79m389ttvcHV1BSA/2SciourFsZ2IqO7h2E5ERKUxNTXF6dOnkZ+fjwEDBqBDhw7w8fGBgYEBVFSY7qTKx5roVG9ERUVhzZo1OHbsGPT09AAAO3fuhLW1NTZt2oQpU6aUq5+4uDg4Ojri7t276NChA9q2bYtFixZhypQpWLduHXbu3FmVu0FERK/h2E5EVPdwbCciqhvc3d3h7u5eZH5wcLDC86ioqCJtiivjJQiCwvM2bdrg559/focIS95W4QexRIWYRKd6o0+fPsjNzVWYZ25ujvT09Ar1c/nyZXTr1g3/+c9/FOa/fsOit30DICKiiuHYTkRU93BsJyKiqpKeng4dHR1MnTq12JuSlkRHRwd5eXnQ1NSswuioJuP3G4jK8O2330JHRwdXr14FID8Z79ChQ5Vsa/LkydDR0amSvomI6H84thMR1T0c24mIqDQjRozA7du3ERcXh9mzZ1do3bi4OFy7dg2xsbFVFB3VdBKBH6kTlejRo0d4+fIlAKBZs2ZITU2FiYkJrl+/jrZt21b69lJSUpCRkQEAMDEx4R2liYiqAMd2IqK6h2M7ERERVSUm0YmIiIiIiIiIiIiISsByLkREREREREREREREJWASnYiIiIiIiIiIiIioBEyiExERERERERERERGVgEl0IiIiIiIiIiIiIqISMIlORERERERERERERFQCJtGJiIiIiIiIiIiIiErAJDoRERERERERERERUQmYRCciIiIiIiIiIiIiKgGT6EREREREREREREREJWASnYiIiIiIiIiIiIioBEyiExERERERERERERGVgEl0IiIiIiIiIiIiIqISMIlORERERERERERERFQCJtGJiIiIiIiIiIiIiErAJDoRERERERERERERUQmYRCciIiIiIiIiIiIiKgGT6FQh5ubmWLNmjVJjCA4OhoGBgVJjqKiacNyUISAgAJ06darQOhKJBPv37y9xeZ8+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IiKq+xo0aKDsEIiIysQkOhERERERERERKYVEIlF2CEREZWISnYiIiIiIiIiIiIioBEyiExERERERERGRUmRnZys7BKqpnjwBLl1SdhRUm+XlASdOALm579wVk+hERERERERERKQUeXl5yg6BaqIXLwBbW6BrV+DuXWVHQ7WVhwfQuzewY8c7d8UkOhEREREREREREdUcgYHy5LkgAI8fKzsaqo2iooAffpA//vvvd+6OSXQiIiIiIiIiIiKqGe7cAZYvV3YUVJvl5gLe3pXaJZPoREREREREREREpHyCAEyfDuTkKDsSqs02bACuX6/ULplEJyIiIiIiIiIiIuU7eBD47TdAXR3Q15fPEwTlxkS1S2IisHCh/LGRkfxnJfwOMYlOREREREREREREyvXyJeDjI388cyZgbKzUcKiWmj0beP4c6N4dGDGi0rplEp2IiIiIiIiIiIiUKzAQuH8faNoU+PJLZUdDtdHx48CuXYBEAmzcCKhUXuqbSXQiIiIiIiIiIlKKBg0aKDsEqgni44Fly+SPV60CtLWVGw/VPjk5wJQp8seTJgFdu1Zq90yiExERERERERGRUkgkEmWHQMomCPLkZ04OMHAg8O9/F11OVJYVK4AbN4AmTeTfangda6ITERERERERERFRrfXDD8CxY4CmJrBhg7wUB/C/n0RlSUgAFi+WP161CmjYUP64En+HmEQnIiIiIiIiIiKlyM7OVnYIpEypqfKbiALAggVAy5bKjYdqH0EApk4FXr0C+vcHxo2rks0wiU5EREREREREREqRl5en7BBImebOBf75B2jb9n/JdKKKCA0FwsIADQ3g22+r7BsMTKITERERERERERFR9TpzBvjvf+WPg4LkSdDXFSZDWROdSpKeDsyYIX/8xRfA++8X34410YmIiIiIiIiIiKhWyc0FPv1U/njCBODDD5UbD9VO8+YBSUny5PncuUWXsyY6ERERERERERER1Upr1gDXrgGNGgHLlys7GqqNLlyQl28BgE2bAKm0SjfHJDoRERERERERERFVj7/+AgIC5I9XrJAn0okqIi9P/k0GQQA++QTo16/KN8kkOhFRPZOamgoXFxfo6enBwMAAnp6eyMzMLHWdzZs3o0+fPtDT04NEIkFaWlqx7Y4cOQJbW1toaWmhYcOGcHZ2FpcFBwdDIpEUO6WkpAAAoqKiil2e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5AQsXqzsiIpXm2qiExERERERERERFUcqlSo7hPrl/n15HfS8PGDcOHk9a6KKyMmR/w49fAi8/z7w44+Aqqqyo6pyTKITEREREREREZFSqKlVW5EEysqSXz389CnQpQvw/fe82psqbsYM4ORJQE8POHBAfkPReoBJdCIiIiIiIiIiorpMEAB3d+DKFaBJE2D/fkBLS9lRlQ9rotccQUHyqbCWvqWlsiMqn0r4HWISnYiIiIiIiIiIlCI3N1fZIdQPS5YAP/0EqKsD+/YBZmbKjqhsvEq+Zjl5Epg2Tf54yRJg8GDlxlMerIlORERERERERES1XU5OjrJDqPv27QPmz5c/3rgR6NlTufFQ7XP3LjB8uLyW/ujRwJw5yo6o2jGJTkREREREREREVBedOwd88om8nMXUqYCXl7Ijotrm2TNg0CDgyRN5Lf2tW+vltwSYRCciIiIiIiIiIqpr7t8HPv4YePVKngRds0bZEb0d1kRXnpwc+RXo8fHyEkCHDgENGig7qopjTXQiIiIiIiIiIiJSkJYmr1mdkgJYWwO7dwNqasqOqmLq4dXONYogAJ9+CkRFAbq6wOHDgKmpsqOqGNZEJyIiIiIiIiIioiJyc4GRI4E//5QnPQ8flidBiSpiyRIgOBhQVQX27gU6dlR2RErFJDoREREREREREVFdIAjAlCnAH38A2tryBHrTpsqOimqbH38EvvxS/nj9esDJSbnx1ABMohMREREREREREdUFy5cDW7YAKiryEi6dOys7orenqSn/+fy5cuOob06fBtzd5Y9nzpR/KFNbVeLvEJPoRERERERERESkFFKpVNkh1B179wJz58ofr10L/Otfyo3nXbVpI/9586Zy46hPbt8Ghg6V31B02DD5hzK1WeHv0I0b79wVk+hERERERERERKQUarXtZpc11YkTwPjx8sfTpwPe3sqNpzIUXkUfEaHcOOqLlBR52ZanT4GuXYEffpB/o6E269JF/vPiRfnNdt9BLT8SRERERERERERE9diNG4pXD69apeyIKsfQoYBEApw5A5w/r+xo6rasLGDIEODuXaBFC3kt/QYNlB3Vu3v/faBtW/nNdr/77p26YhKdiIiIiIiIiIiUIi8vT9kh1G6JicDAgfKrbO3sgF27AFVVZUdVOd57D/jkE/njTz6R7ytVvrw8YOxY+QcVhobAb78BxsbK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d+nSpTKZTDKZTBo5cqRlfoMGDTRr1qxC1726no+PT5nGCMflZu8AAAAAAAAAADg/k8lU6PKJEyfqzTfflGEYNorImpeXlw4ePKiqVave0HrJycn64IMPNHHixDKKDI6OkehAITp06GC52piUlFTm+xs4cKBlf5988kmZ7w8AKiL6dgAof+jbAcAxJCcnW6ZZs2bJy8vLat4zzzwjb29vu43oNplMCggIUPXq1W9ovYCAAHl7e5dRVHAGJNGBIkRFRSk5OVm33357me/rzTffVHJycpnvBwAqOvp2ACh/6NsBwP4CAgIsk7e3tyVpfXWqVq1annIuHTp00JNPPqmRI0eqRo0a8vf31zvvvKPMzEwNGjRI1atXV5MmTfTf//7Xal979+5Vly5dVK1aNfn7++uxxx7T2bNnixV3VlaWHn/8cVWvXl3169fXggULSvIyoBwiiQ4UoUqVKgoICJCbW9lXP/L29lZAQECZ7wcAKjr6dgAof+jbAcB5LVu2TL6+vtq+fbuefPJJDR8+XH379lW7du20c+dO3X///XrssceUlZUlSUpLS1PHjh0VFBSk77//XnFxcTp9+rQefvjhYu3/tddeU9u2bbVr1y498cQTGj58uA4ePFiahwgnRxIdFcZvv/2mgIAATZ061TJv8+bNcnd31/r1629oW99++60qVaqkixcvWuYdP35cJpNJP//8c7GvogIAbgx9OwCUP/TtAFDxtGrVSuPGjdMtt9yisWPHytPTU76+voqKitItt9yiCRMm6Ny5c/rhhx8kSW+99ZaCgoI0depUBQYGKigoSIsXL9bGjRv1008/3fD+u3btqieeeEJNmjTR888/L19fX23cuLG0DxNOjCQ6KoxatWpp8eLFmjRpkr7//nudP39ejz32mKKjo9WpU6cb2lZSUpKaNWsmT09Py7xdu3apRo0auvnmmyXd+FVUAMCNo28HgPKHvh0AKp6WLVtaHru6uuqmm25SixYtLPP8/f0lSWfOnJEk7d69Wxs3blS1atUsU2BgoCTpyJEjJdr/1RI0V/cFSCTRUcF07dpVUVFRCg8P17Bhw1S1alVNmzbthreze/duBQUFWc1LSkpSq1atLM9v9CoqAKB46NsBoPyhbweAiqVSpUpWz00mk9U8k8kkScrNzZUkXbhwQT169FBSUpLVdOjQId17772lsv+r+wIkkuiogGbOnKkrV65o9erVWr58uTw8PG54G0lJSWrdurXVvF27dlnNu9GrqACA4qNvB4Dyh74dAFCQO+64Q/v27VODBg3UpEkTq6lq1ar2Dg/lEEl0VDhHjhzRqVOnlJubq+PHj9/w+jk5Odq7d2+eES07d+60+jB+o1dRAQDFR98OAOUPfTsAoCAjRoxQamqq+vfvrx07dujIkSNat26dBg0apJycHHuHh3Ko7G9bDjiQS5cuacCAAXrkkUfUtGlTDRkyRHv27JGfn991b+PgwYO6ePGi6tSpY5m3ZcsW/frrr3lGuQAAyh59OwCUP/TtAIDC1KlTR999952ef/553X///crOztbNN9+ssLAwubgwZhiljyQ6KpQXX3xR6enpmj17tqpVq6YvvvhCjz/+uNauXXvd20hKSpIkzZkzR0899ZQOHz6sp556SpL5wz4AwLbo2wGg/KFvBwDnN3DgQA0cODDP/KVLl1o9T0hIyNMmv28gGYZh9fyWW27RRx99VIIIC97X1b8hwFVcmkGFkZCQoFmzZum9996Tl5eXXFxc9N577+mbb77R/Pnzr3s7SUlJCg0N1dGjR9WiRQu9+OKLeumll+Tl5aXZs2eX4REAAP6Kvh0Ayh/6dgBAWUlPT1e1atX0/PPP39B61apV07Bhw8ooKjgDRqKjwujQoYMuX75sNa9BgwZKT0+/oe3s3r1bd955p/71r39ZzX/00Uctj4t7FRUAcGPo2wGg/KFvBwCUhT59+qh9+/aSJB8fnxta9+rIdFdX11KOCs6CkehAEebNm6dq1appz549kswfxlu0aFEm+xo2bJiqVatWJtsGAPyJvh0Ayh/6dgBAYapXr64mTZqoSZMm8vX1vaF1r67XsGHDMooOjs5kcEkdKNCvv/6qP/74Q5JUv359paamqnbt2tq3b5+aN29e6vs7c+aMMjIyJEm1a9dW1apVS30fAFDR0bcDQPlD3w4AAMoSSXQAAAAAAAAAAApAORcAAAAAAAAAAApAEh0AAAAAAAAAgAKQRAcAAAAAAAAAoAAk0QEAAAAAAAAAKABJdAAAAAAAAAAACkASHQAAAAAAAACAApBEBwAAAAAAAACgACTRAQAAAAAAAAAoAEl0AAAAAAAAAAAKQBIdAAAAAAAAAIAC/D9pNV++FzK5QwAAAABJRU5ErkJggg==\n" + ] }, - "metadata": {} + "metadata": {}, + "output_type": "display_data" } ], "source": [ @@ -334,7 +334,7 @@ "source": [ "# Define the cost to optimise\n", "signal = \"Terminal voltage [V]\"\n", - "problem = pybop.Problem(parameters, dataset, model=pyb_model, signal=signal)\n", + "problem = pybop.FittingProblem(pyb_model, parameters, dataset, signal=signal)\n", "cost = pybop.RootMeanSquaredError(problem)" ] }, @@ -402,14 +402,14 @@ }, "outputs": [ { - "output_type": "execute_result", "data": { "text/plain": [ "array([0.48367449, 0.63380314])" ] }, + "execution_count": 69, "metadata": {}, - "execution_count": 69 + "output_type": "execute_result" } ], "source": [ @@ -473,24 +473,24 @@ }, "outputs": [ { - "output_type": "execute_result", "data": { "text/plain": [ "" ] }, + "execution_count": 71, "metadata": {}, - "execution_count": 71 + "output_type": "execute_result" }, { - "output_type": "display_data", "data": { + "image/png": "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", "text/plain": [ "
" - ], - "image/png": "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\n" + ] }, - "metadata": {} + "metadata": {}, + "output_type": "display_data" } ], "source": [ @@ -512,6 +512,9 @@ } ], "metadata": { + "colab": { + "provenance": [] + }, "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", @@ -529,91 +532,12 @@ "pygments_lexer": "ipython3", "version": "3.10.12" }, - "colab": { - "provenance": [] - }, "widgets": { "application/vnd.jupyter.widget-state+json": { - "8d003c14da5f4fa68284b28c15cee6e6": { - "model_module": "@jupyter-widgets/controls", - "model_name": "VBoxModel", - "model_module_version": "2.0.0", - "state": { - "_dom_classes": [ - "widget-interact" - ], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "2.0.0", - "_model_name": "VBoxModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "2.0.0", - "_view_name": "VBoxView", - "box_style": "", - "children": [ - "IPY_MODEL_aef2fa7adcc14ad0854b73d5910ae3b4", - "IPY_MODEL_7d46516469314b88be3500e2afcafcf6" - ], - "layout": "IPY_MODEL_423bffea3a1c42b49a9ad71218e5811b", - "tabbable": null, - "tooltip": null - } - }, - "aef2fa7adcc14ad0854b73d5910ae3b4": { - "model_module": "@jupyter-widgets/controls", - "model_name": "FloatSliderModel", - "model_module_version": "2.0.0", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/controls", - "_model_module_version": "2.0.0", - "_model_name": "FloatSliderModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/controls", - "_view_module_version": "2.0.0", - "_view_name": "FloatSliderView", - "behavior": "drag-tap", - "continuous_update": true, - "description": "t", - "description_allow_html": false, - "disabled": false, - "layout": "IPY_MODEL_06f2374f91c8455bb63252092512f2ed", - "max": 1.1333333333333333, - "min": 0, - "orientation": "horizontal", - "readout": true, - "readout_format": ".2f", - "step": 0.011333333333333332, - "style": "IPY_MODEL_56ff19291e464d63b23e63b8e2ac9ea3", - "tabbable": null, - "tooltip": null, - "value": 0 - } - }, - "7d46516469314b88be3500e2afcafcf6": { - "model_module": "@jupyter-widgets/output", - "model_name": "OutputModel", - "model_module_version": "1.0.0", - "state": { - "_dom_classes": [], - "_model_module": "@jupyter-widgets/output", - "_model_module_version": "1.0.0", - "_model_name": "OutputModel", - "_view_count": null, - "_view_module": "@jupyter-widgets/output", - "_view_module_version": "1.0.0", - "_view_name": "OutputView", - "layout": "IPY_MODEL_646a8670cb204a31bb56bc2380898093", - "msg_id": "", - "outputs": [], - "tabbable": null, - "tooltip": null - } - }, - "423bffea3a1c42b49a9ad71218e5811b": { + "06f2374f91c8455bb63252092512f2ed": { "model_module": "@jupyter-widgets/base", - "model_name": "LayoutModel", "model_module_version": "2.0.0", + "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", @@ -663,10 +587,10 @@ "width": null } }, - "06f2374f91c8455bb63252092512f2ed": { + "423bffea3a1c42b49a9ad71218e5811b": { "model_module": "@jupyter-widgets/base", - "model_name": "LayoutModel", "model_module_version": "2.0.0", + "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", @@ -718,8 +642,8 @@ }, "56ff19291e464d63b23e63b8e2ac9ea3": { "model_module": "@jupyter-widgets/controls", - "model_name": "SliderStyleModel", "model_module_version": "2.0.0", + "model_name": "SliderStyleModel", "state": { "_model_module": "@jupyter-widgets/controls", "_model_module_version": "2.0.0", @@ -734,8 +658,8 @@ }, "646a8670cb204a31bb56bc2380898093": { "model_module": "@jupyter-widgets/base", - "model_name": "LayoutModel", "model_module_version": "2.0.0", + "model_name": "LayoutModel", "state": { "_model_module": "@jupyter-widgets/base", "_model_module_version": "2.0.0", @@ -784,6 +708,82 @@ "visibility": null, "width": null } + }, + "7d46516469314b88be3500e2afcafcf6": { + "model_module": "@jupyter-widgets/output", + "model_module_version": "1.0.0", + "model_name": "OutputModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/output", + "_model_module_version": "1.0.0", + "_model_name": "OutputModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/output", + "_view_module_version": "1.0.0", + "_view_name": "OutputView", + "layout": "IPY_MODEL_646a8670cb204a31bb56bc2380898093", + "msg_id": "", + "outputs": [], + "tabbable": null, + "tooltip": null + } + }, + "8d003c14da5f4fa68284b28c15cee6e6": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "2.0.0", + "model_name": "VBoxModel", + "state": { + "_dom_classes": [ + "widget-interact" + ], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "2.0.0", + "_model_name": "VBoxModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "2.0.0", + "_view_name": "VBoxView", + "box_style": "", + "children": [ + "IPY_MODEL_aef2fa7adcc14ad0854b73d5910ae3b4", + "IPY_MODEL_7d46516469314b88be3500e2afcafcf6" + ], + "layout": "IPY_MODEL_423bffea3a1c42b49a9ad71218e5811b", + "tabbable": null, + "tooltip": null + } + }, + "aef2fa7adcc14ad0854b73d5910ae3b4": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "2.0.0", + "model_name": "FloatSliderModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "2.0.0", + "_model_name": "FloatSliderModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "2.0.0", + "_view_name": "FloatSliderView", + "behavior": "drag-tap", + "continuous_update": true, + "description": "t", + "description_allow_html": false, + "disabled": false, + "layout": "IPY_MODEL_06f2374f91c8455bb63252092512f2ed", + "max": 1.1333333333333333, + "min": 0, + "orientation": "horizontal", + "readout": true, + "readout_format": ".2f", + "step": 0.011333333333333332, + "style": "IPY_MODEL_56ff19291e464d63b23e63b8e2ac9ea3", + "tabbable": null, + "tooltip": null, + "value": 0 + } } } } From 6ea344773501555ec4cdc0e86ecbf30c12d7d10c Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 5 Dec 2023 12:14:46 +0000 Subject: [PATCH 054/101] Limit pytest.ini workers, updt. problem definition for ecm examples, updt. test_plotting.py to revert plotly renderer --- examples/scripts/ecm_CMAES.py | 2 +- examples/scripts/ecm_parameters.py | 2 +- examples/scripts/parameters/fit_ecm_parameters.json | 4 ++-- pytest.ini | 2 +- tests/unit/test_plotting.py | 7 +++++++ 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index d0d001c1..f1666c8c 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -37,7 +37,7 @@ ] # Generate problem, cost function, and optimisation class -problem = pybop.Problem(model, parameters, dataset) +problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) optim.set_max_iterations(100) diff --git a/examples/scripts/ecm_parameters.py b/examples/scripts/ecm_parameters.py index 7938c369..ec8a52cd 100644 --- a/examples/scripts/ecm_parameters.py +++ b/examples/scripts/ecm_parameters.py @@ -63,7 +63,7 @@ ] # Generate problem, cost function, and optimisation class -problem = pybop.Problem(model, parameters, dataset) +problem = pybop.FittingProblem(model, parameters, dataset) cost = pybop.SumSquaredError(problem) optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) optim.set_max_iterations(100) diff --git a/examples/scripts/parameters/fit_ecm_parameters.json b/examples/scripts/parameters/fit_ecm_parameters.json index 9429d276..4311c10f 100644 --- a/examples/scripts/parameters/fit_ecm_parameters.json +++ b/examples/scripts/parameters/fit_ecm_parameters.json @@ -13,10 +13,10 @@ "Jig thermal mass [J/K]": 500, "Jig-air heat transfer coefficient [W/K]": 10, "Open-circuit voltage [V]": "Unable to write value to JSON file", - "R0 [Ohm]": 0.0009061740048547629, + "R0 [Ohm]": 0.0008436445813315947, "Element-1 initial overpotential [V]": 0, "Element-2 initial overpotential [V]": 0, - "R1 [Ohm]": 0.00029231930041091097, + "R1 [Ohm]": 0.0003613399817553277, "R2 [Ohm]": 0.0003, "C1 [F]": 10000, "C2 [F]": 5000, diff --git a/pytest.ini b/pytest.ini index 6d4fb128..23afec09 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ # pytest.ini [pytest] -addopts = -n auto --dist loadscope --showlocals -v +addopts = -n 4 --dist loadfile --showlocals -v diff --git a/tests/unit/test_plotting.py b/tests/unit/test_plotting.py index b0802cb0..92a327c6 100644 --- a/tests/unit/test_plotting.py +++ b/tests/unit/test_plotting.py @@ -2,6 +2,7 @@ from distutils.spawn import find_executable from pybop import PlotlyManager import subprocess +import plotly import pytest # Find the Python executable @@ -47,6 +48,9 @@ def uninstall_plotly_if_installed(): if was_installed: subprocess.check_call([python_executable, "-m", "pip", "install", "plotly"]) + # Reset the default renderer for tests + plotly.io.renderers.default = None + @pytest.mark.unit def test_initialization_with_plotly_installed(plotly_installed): @@ -100,6 +104,9 @@ def test_post_install_setup(plotly_installed): assert plotly_manager.pio.renderers.default == "browser" + # Reset the default renderer for tests + plotly.io.renderers.default = None + def is_package_installed(package_name): """Check if a package is installed without raising an exception.""" From eeac6ca837c426102a72b533a627092bf9471dc0 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 5 Dec 2023 16:16:14 +0000 Subject: [PATCH 055/101] Updt pytest arguments to ignore capture --- examples/scripts/parameters/fit_ecm_parameters.json | 4 ++-- pytest.ini | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/scripts/parameters/fit_ecm_parameters.json b/examples/scripts/parameters/fit_ecm_parameters.json index 4311c10f..c2c9ab26 100644 --- a/examples/scripts/parameters/fit_ecm_parameters.json +++ b/examples/scripts/parameters/fit_ecm_parameters.json @@ -13,10 +13,10 @@ "Jig thermal mass [J/K]": 500, "Jig-air heat transfer coefficient [W/K]": 10, "Open-circuit voltage [V]": "Unable to write value to JSON file", - "R0 [Ohm]": 0.0008436445813315947, + "R0 [Ohm]": 0.0008868026751175451, "Element-1 initial overpotential [V]": 0, "Element-2 initial overpotential [V]": 0, - "R1 [Ohm]": 0.0003613399817553277, + "R1 [Ohm]": 0.0002891626869041692, "R2 [Ohm]": 0.0003, "C1 [F]": 10000, "C2 [F]": 5000, diff --git a/pytest.ini b/pytest.ini index 23afec09..e0faf599 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ # pytest.ini [pytest] -addopts = -n 4 --dist loadfile --showlocals -v +addopts = -n 4 --dist loadfile --showlocals -v -s From e304ea0e2436d74262c9a682f47bb3ce4bf939f2 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 5 Dec 2023 17:04:46 +0000 Subject: [PATCH 056/101] Updt. changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d16dc5cf..5b6eebfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # [Unreleased](https://github.com/pybop-team/PyBOP) ## Features +- [#107](https://github.com/pybop-team/PyBOP/issues/107) - Adds Equivalent Circuit Model (ECM) with examples, Import/Export parameter methods `ParameterSet.import_parameter` and `ParameterSet.export_parameters`, updates default FittingProblem.signal definition to `"Voltage [V]"`, and testing infrastructure - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds standard plotting class `pybop.StandardPlot()` via plotly backend - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds `quick_plot()`, `plot_convergence()`, and `plot_cost2d()` methods - [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class From 145da938cfc2628649816cd9f7f6147adcbf78ff Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 5 Dec 2023 21:13:42 +0000 Subject: [PATCH 057/101] Revert pytest-xdist for noxfile, remove redundant try-except --- examples/scripts/parameters/fit_ecm_parameters.json | 4 ++-- noxfile.py | 6 +++--- pybop/parameters/parameter_set.py | 5 +---- pytest.ini | 2 +- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/examples/scripts/parameters/fit_ecm_parameters.json b/examples/scripts/parameters/fit_ecm_parameters.json index c2c9ab26..f8f6b31c 100644 --- a/examples/scripts/parameters/fit_ecm_parameters.json +++ b/examples/scripts/parameters/fit_ecm_parameters.json @@ -13,10 +13,10 @@ "Jig thermal mass [J/K]": 500, "Jig-air heat transfer coefficient [W/K]": 10, "Open-circuit voltage [V]": "Unable to write value to JSON file", - "R0 [Ohm]": 0.0008868026751175451, + "R0 [Ohm]": 0.0008928763853238023, "Element-1 initial overpotential [V]": 0, "Element-2 initial overpotential [V]": 0, - "R1 [Ohm]": 0.0002891626869041692, + "R1 [Ohm]": 0.00031654935739793466, "R2 [Ohm]": 0.0003, "C1 [F]": 10000, "C2 [F]": 5000, diff --git a/noxfile.py b/noxfile.py index 34a28e2e..a70ef948 100644 --- a/noxfile.py +++ b/noxfile.py @@ -7,14 +7,14 @@ @nox.session def unit(session): session.run_always("pip", "install", "-e", ".[all]") - session.install("pytest", "pytest-mock", "pytest-xdist") + session.install("pytest", "pytest-mock") session.run("pytest", "--unit") @nox.session def coverage(session): session.run_always("pip", "install", "-e", ".[all]") - session.install("pytest", "pytest-cov", "pytest-mock", "pytest-xdist") + session.install("pytest", "pytest-cov", "pytest-mock") session.run( "pytest", "--unit", @@ -28,5 +28,5 @@ def coverage(session): def notebooks(session): """Run the examples tests for Jupyter notebooks.""" session.run_always("pip", "install", "-e", ".[all]") - session.install("pytest", "nbmake", "pytest-xdist") + session.install("pytest", "nbmake") session.run("pytest", "--nbmake", "examples/", external=True) diff --git a/pybop/parameters/parameter_set.py b/pybop/parameters/parameter_set.py index 18262ceb..8db93f8c 100644 --- a/pybop/parameters/parameter_set.py +++ b/pybop/parameters/parameter_set.py @@ -87,7 +87,4 @@ def pybamm(cls, name): """ Create a PyBaMM parameter set. """ - try: - return pybamm.ParameterValues(name).copy() - except ValueError as e: - raise ValueError(f"Parameter set '{name}' not found. PyBaMM error: {e}") + return pybamm.ParameterValues(name).copy() diff --git a/pytest.ini b/pytest.ini index e0faf599..2f8f1290 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ # pytest.ini [pytest] -addopts = -n 4 --dist loadfile --showlocals -v -s +addopts = --showlocals -v -s From 3f920391bb6a085861df1cfaa023c540bb4f2bd1 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Wed, 6 Dec 2023 13:08:30 +0000 Subject: [PATCH 058/101] Reduce testing time (#129) * Refactor and reduce max_iterations to 100 * Parameterise test_examples * Updt. workflow trigger to remove all pushes, updt default Optimisation end conditions, reduce unchanged_iterations conditions for test_parameterisation * Update CHANGELOG.md --------- Co-authored-by: Brady Planden --- .github/workflows/test_on_push.yaml | 1 - CHANGELOG.md | 1 + pybop/optimisation.py | 4 +- tests/unit/test_examples.py | 12 ++- tests/unit/test_parameterisations.py | 107 +++++++++------------------ 5 files changed, 49 insertions(+), 76 deletions(-) diff --git a/.github/workflows/test_on_push.yaml b/.github/workflows/test_on_push.yaml index 7d10c94b..5df3c815 100644 --- a/.github/workflows/test_on_push.yaml +++ b/.github/workflows/test_on_push.yaml @@ -1,7 +1,6 @@ name: test_on_push on: - push: workflow_dispatch: pull_request: diff --git a/CHANGELOG.md b/CHANGELOG.md index ed18f7ad..d0eaaa05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds `quick_plot()`, `plot_convergence()`, and `plot_cost2d()` methods - [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class - [#38](https://github.com/pybop-team/PyBOP/issues/38) - Restructures the Problem classes ahead of adding a design optimisation example +- [#120](https://github.com/pybop-team/PyBOP/issues/120) - Updates the parameterisation test settings including the number of iterations ## Bug Fixes diff --git a/pybop/optimisation.py b/pybop/optimisation.py index 6dc947de..3b594db0 100644 --- a/pybop/optimisation.py +++ b/pybop/optimisation.py @@ -368,7 +368,7 @@ def set_parallel(self, parallel=False): self._parallel = False self._n_workers = 1 - def set_max_iterations(self, iterations=10000): + def set_max_iterations(self, iterations=1000): """ Adds a stopping criterion, allowing the routine to halt after the given number of ``iterations``. @@ -384,7 +384,7 @@ def set_max_iterations(self, iterations=10000): raise ValueError("Maximum number of iterations cannot be negative.") self._max_iterations = iterations - def set_max_unchanged_iterations(self, iterations=200, threshold=1e-11): + def set_max_unchanged_iterations(self, iterations=25, threshold=1e-5): """ Adds a stopping criterion, allowing the routine to halt if the objective function doesn't change by more than ``threshold`` for the diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py index dffa084e..ebc3570a 100644 --- a/tests/unit/test_examples.py +++ b/tests/unit/test_examples.py @@ -9,11 +9,17 @@ class TestExamples: A class to test the example scripts. """ - @pytest.mark.examples - def test_example_scripts(self): + def list_of_examples(): + list = [] path_to_example_scripts = os.path.join( pybop.script_path, "..", "examples", "scripts" ) for example in os.listdir(path_to_example_scripts): if example.endswith(".py"): - runpy.run_path(os.path.join(path_to_example_scripts, example)) + list.append(os.path.join(path_to_example_scripts, example)) + return list + + @pytest.mark.parametrize("example", list_of_examples()) + @pytest.mark.examples + def test_example_scripts(self, example): + runpy.run_path(example) diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py index 398f137a..7f001aeb 100644 --- a/tests/unit/test_parameterisations.py +++ b/tests/unit/test_parameterisations.py @@ -9,27 +9,14 @@ class TestModelParameterisation: A class to test the model parameterisation methods. """ - @pytest.mark.parametrize("init_soc", [0.3, 0.7]) - @pytest.mark.unit - def test_spm(self, init_soc): - # Define model + @pytest.fixture + def model(self): parameter_set = pybop.ParameterSet("pybamm", "Chen2020") - model = pybop.lithium_ion.SPM(parameter_set=parameter_set) - - # Form dataset - x0 = np.array([0.52, 0.63]) - solution = self.getdata(model, x0, init_soc) + return pybop.lithium_ion.SPM(parameter_set=parameter_set) - dataset = [ - pybop.Dataset("Time [s]", solution["Time [s]"].data), - pybop.Dataset("Current function [A]", solution["Current [A]"].data), - pybop.Dataset( - "Terminal voltage [V]", solution["Terminal voltage [V]"].data - ), - ] - - # Fitting parameters - parameters = [ + @pytest.fixture + def parameters(self): + return [ pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Gaussian(0.5, 0.02), @@ -42,6 +29,23 @@ def test_spm(self, init_soc): ), ] + @pytest.fixture + def x0(self): + return np.array([0.52, 0.63]) + + @pytest.mark.parametrize("init_soc", [0.3, 0.7]) + @pytest.mark.unit + def test_spm(self, parameters, model, x0, init_soc): + # Form dataset + solution = self.getdata(model, x0, init_soc) + dataset = [ + pybop.Dataset("Time [s]", solution["Time [s]"].data), + pybop.Dataset("Current function [A]", solution["Current [A]"].data), + pybop.Dataset( + "Terminal voltage [V]", solution["Terminal voltage [V]"].data + ), + ] + # Define the cost to optimise signal = "Terminal voltage [V]" problem = pybop.FittingProblem( @@ -62,17 +66,11 @@ def test_spm(self, init_soc): np.testing.assert_allclose(final_cost, 0, atol=1e-2) np.testing.assert_allclose(x, x0, atol=1e-1) - @pytest.mark.parametrize("init_soc", [0.5]) - @pytest.mark.unit - def test_spm_optimisers(self, init_soc): - # Define model - parameter_set = pybop.ParameterSet("pybamm", "Chen2020") - model = pybop.lithium_ion.SPM(parameter_set=parameter_set) - + @pytest.fixture + def spm_cost(self, parameters, model, x0): # Form dataset - x0 = np.array([0.52, 0.63]) + init_soc = 0.5 solution = self.getdata(model, x0, init_soc) - dataset = [ pybop.Dataset("Time [s]", solution["Time [s]"].data), pybop.Dataset("Current function [A]", solution["Current [A]"].data), @@ -81,27 +79,15 @@ def test_spm_optimisers(self, init_soc): ), ] - # Fitting parameters - parameters = [ - pybop.Parameter( - "Negative electrode active material volume fraction", - prior=pybop.Gaussian(0.5, 0.02), - bounds=[0.375, 0.625], - ), - pybop.Parameter( - "Positive electrode active material volume fraction", - prior=pybop.Gaussian(0.65, 0.02), - bounds=[0.525, 0.75], - ), - ] - # Define the cost to optimise signal = "Terminal voltage [V]" problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) - cost = pybop.SumSquaredError(problem) + return pybop.SumSquaredError(problem) + @pytest.mark.unit + def test_spm_optimisers(self, spm_cost, x0): # Select optimisers optimisers = [ pybop.NLoptOptimize, @@ -117,7 +103,8 @@ def test_spm_optimisers(self, init_soc): # Test each optimiser for optimiser in optimisers: - parameterisation = pybop.Optimisation(cost=cost, optimiser=optimiser) + parameterisation = pybop.Optimisation(cost=spm_cost, optimiser=optimiser) + parameterisation.set_max_unchanged_iterations(iterations=15, threshold=5e-4) if optimiser in [pybop.CMAES]: parameterisation.set_f_guessed_tracking(True) @@ -126,14 +113,14 @@ def test_spm_optimisers(self, init_soc): x, final_cost = parameterisation.run() parameterisation.set_f_guessed_tracking(False) - parameterisation.set_max_iterations(250) + parameterisation.set_max_iterations(100) x, final_cost = parameterisation.run() - assert parameterisation._max_iterations == 250 + assert parameterisation._max_iterations == 100 elif optimiser in [pybop.GradientDescent]: parameterisation.optimiser.set_learning_rate(0.025) - parameterisation.set_max_iterations(250) + parameterisation.set_max_iterations(100) x, final_cost = parameterisation.run() elif optimiser in [ @@ -143,7 +130,7 @@ def test_spm_optimisers(self, init_soc): pybop.Adam, pybop.IRPropMin, ]: - parameterisation.set_max_iterations(250) + parameterisation.set_max_iterations(100) x, final_cost = parameterisation.run() else: @@ -155,20 +142,14 @@ def test_spm_optimisers(self, init_soc): @pytest.mark.parametrize("init_soc", [0.3, 0.7]) @pytest.mark.unit - def test_model_misparameterisation(self, init_soc): + def test_model_misparameterisation(self, parameters, model, x0, init_soc): # Define two different models with different parameter sets # The optimisation should fail as the models are not the same - - parameter_set = pybop.ParameterSet("pybamm", "Chen2020") - model = pybop.lithium_ion.SPM(parameter_set=parameter_set) - second_parameter_set = pybop.ParameterSet("pybamm", "Ecker2015") second_model = pybop.lithium_ion.SPM(parameter_set=second_parameter_set) - # Form observations - x0 = np.array([0.52, 0.63]) + # Form dataset solution = self.getdata(second_model, x0, init_soc) - dataset = [ pybop.Dataset("Time [s]", solution["Time [s]"].data), pybop.Dataset("Current function [A]", solution["Current [A]"].data), @@ -177,20 +158,6 @@ def test_model_misparameterisation(self, init_soc): ), ] - # Fitting parameters - parameters = [ - pybop.Parameter( - "Negative electrode active material volume fraction", - prior=pybop.Gaussian(0.5, 0.02), - bounds=[0.375, 0.625], - ), - pybop.Parameter( - "Positive electrode active material volume fraction", - prior=pybop.Gaussian(0.65, 0.02), - bounds=[0.525, 0.75], - ), - ] - # Define the cost to optimise signal = "Terminal voltage [V]" problem = pybop.FittingProblem( From fec8efad5760f5e42b1e5940a938ea3a62dac674 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 6 Dec 2023 13:19:05 +0000 Subject: [PATCH 059/101] Add SciPyDifferentialEvolution cls, updt tests w/ reduced assertions for SciPyMinimize --- pybop/__init__.py | 2 +- pybop/optimisation.py | 4 +- pybop/optimisers/scipy_minimize.py | 55 ------------ pybop/optimisers/scipy_optimisers.py | 124 +++++++++++++++++++++++++++ tests/unit/test_optimisation.py | 7 +- tests/unit/test_parameterisations.py | 10 ++- 6 files changed, 142 insertions(+), 60 deletions(-) delete mode 100644 pybop/optimisers/scipy_minimize.py create mode 100644 pybop/optimisers/scipy_optimisers.py diff --git a/pybop/__init__.py b/pybop/__init__.py index 48a24b2d..ff2b8af5 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -49,7 +49,7 @@ # from .optimisers.base_optimiser import BaseOptimiser from .optimisers.nlopt_optimize import NLoptOptimize -from .optimisers.scipy_minimize import SciPyMinimize +from .optimisers.scipy_optimisers import SciPyMinimize, SciPyDifferentialEvolution from .optimisers.pints_optimisers import ( GradientDescent, Adam, diff --git a/pybop/optimisation.py b/pybop/optimisation.py index 6dc947de..019c4917 100644 --- a/pybop/optimisation.py +++ b/pybop/optimisation.py @@ -58,7 +58,9 @@ def __init__( if issubclass(self.optimiser, pybop.NLoptOptimize): self.optimiser = self.optimiser(self.n_parameters) - elif issubclass(self.optimiser, pybop.SciPyMinimize): + elif issubclass( + self.optimiser, (pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution) + ): self.optimiser = self.optimiser() else: diff --git a/pybop/optimisers/scipy_minimize.py b/pybop/optimisers/scipy_minimize.py deleted file mode 100644 index a1f57fe6..00000000 --- a/pybop/optimisers/scipy_minimize.py +++ /dev/null @@ -1,55 +0,0 @@ -from scipy.optimize import minimize -from .base_optimiser import BaseOptimiser - - -class SciPyMinimize(BaseOptimiser): - """ - Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. - """ - - def __init__(self, method=None, bounds=None): - super().__init__() - self.method = method - self.bounds = bounds - - if self.method is None: - self.method = "L-BFGS-B" - - def _runoptimise(self, cost_function, x0, bounds): - """ - Run the SciPy optimisation method. - - Inputs - ---------- - cost_function: function for optimising - method: optimisation algorithm - x0: initialisation array - bounds: bounds array - """ - - if bounds is not None: - # Reformat bounds and run the optimser - bounds = ( - (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) - ) - output = minimize(cost_function, x0, method=self.method, bounds=bounds) - else: - output = minimize(cost_function, x0, method=self.method) - - # Get performance statistics - x = output.x - final_cost = output.fun - - return x, final_cost - - def needs_sensitivities(self): - """ - Returns True if the optimiser needs sensitivities. - """ - return False - - def name(self): - """ - Returns the name of the optimiser. - """ - return "SciPyMinimize" diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py new file mode 100644 index 00000000..0e91326d --- /dev/null +++ b/pybop/optimisers/scipy_optimisers.py @@ -0,0 +1,124 @@ +from scipy.optimize import minimize, differential_evolution +from .base_optimiser import BaseOptimiser + + +class SciPyMinimize(BaseOptimiser): + """ + Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. + """ + + def __init__(self, method=None, bounds=None): + super().__init__() + self.method = method + self.bounds = bounds + + if self.method is None: + self.method = "COBYLA" # "L-BFGS-B" + + def _runoptimise(self, cost_function, x0, bounds): + """ + Run the SciPy optimisation method. + + Inputs + ---------- + cost_function: function for optimising + method: optimisation algorithm + x0: initialisation array + bounds: bounds array + """ + + if bounds is not None: + # Reformat bounds and run the optimser + bounds = ( + (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) + ) + output = minimize(cost_function, x0, method=self.method, bounds=bounds) + else: + output = minimize(cost_function, x0, method=self.method) + + # Get performance statistics + x = output.x + final_cost = output.fun + + return x, final_cost + + def needs_sensitivities(self): + """ + Returns True if the optimiser needs sensitivities. + """ + return False + + def name(self): + """ + Returns the name of the optimiser. + """ + return "SciPyMinimize" + + +class SciPyDifferentialEvolution(BaseOptimiser): + """ + Wrapper class for the SciPy differential_evolution optimisation method. Extends the BaseOptimiser class. + """ + + def __init__(self, bounds=None, strategy="best1bin", maxiter=1000, popsize=15): + super().__init__() + self.bounds = bounds + self.strategy = strategy + self.maxiter = maxiter + self.popsize = popsize + + def _runoptimise(self, cost_function, x0=None, bounds=None): + """ + Run the SciPy differential_evolution optimisation method. + + Inputs + ---------- + cost_function : function + The objective function to be minimized. + x0 : array_like + Initial guess. Only used to determine the dimensionality of the problem. + bounds : sequence or `Bounds` + Bounds for variables. There are two ways to specify the bounds: + 1. Instance of `Bounds` class. + 2. Sequence of (min, max) pairs for each element in x, defining the finite lower and upper bounds for the optimizing argument of `cost_function`. + """ + + if bounds is None: + raise ValueError("Bounds must be specified for differential_evolution.") + + if x0 is not None: + print( + "Ignoring x0. Initial conditions are not used for differential_evolution." + ) + + # Reformat bounds if necessary + if isinstance(bounds, dict): + bounds = [ + (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) + ] + + output = differential_evolution( + cost_function, + bounds, + strategy=self.strategy, + maxiter=self.maxiter, + popsize=self.popsize, + ) + + # Get performance statistics + x = output.x + final_cost = output.fun + + return x, final_cost + + def needs_sensitivities(self): + """ + Returns False as differential_evolution does not need sensitivities. + """ + return False + + def name(self): + """ + Returns the name of the optimiser. + """ + return "SciPyDifferentialEvolution" diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 22822753..dc96367c 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -46,6 +46,7 @@ def cost(self, problem): [ (pybop.NLoptOptimize, "NLoptOptimize"), (pybop.SciPyMinimize, "SciPyMinimize"), + (pybop.SciPyDifferentialEvolution, "SciPyDifferentialEvolution"), (pybop.GradientDescent, "Gradient descent"), (pybop.Adam, "Adam"), (pybop.CMAES, "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)"), @@ -63,7 +64,11 @@ def test_optimiser_classes(self, cost, optimiser_class, expected_name): assert opt.optimiser is not None assert opt.optimiser.name() == expected_name - if optimiser_class not in [pybop.NLoptOptimize, pybop.SciPyMinimize]: + if optimiser_class not in [ + pybop.NLoptOptimize, + pybop.SciPyMinimize, + pybop.SciPyDifferentialEvolution, + ]: assert opt.optimiser.boundaries is None if optimiser_class == pybop.NLoptOptimize: diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py index 398f137a..ac718ea3 100644 --- a/tests/unit/test_parameterisations.py +++ b/tests/unit/test_parameterisations.py @@ -106,6 +106,7 @@ def test_spm_optimisers(self, init_soc): optimisers = [ pybop.NLoptOptimize, pybop.SciPyMinimize, + pybop.SciPyDifferentialEvolution, pybop.CMAES, pybop.Adam, pybop.GradientDescent, @@ -150,8 +151,13 @@ def test_spm_optimisers(self, init_soc): x, final_cost = parameterisation.run() # Assertions - np.testing.assert_allclose(final_cost, 0, atol=1e-2) - np.testing.assert_allclose(x, x0, atol=1e-1) + # Note: SciPyMinimize has a different tolerance due to the local optimisation algorithms + if optimiser in [pybop.SciPyMinimize]: + np.testing.assert_allclose(final_cost, 0, atol=1e-2) + np.testing.assert_allclose(x, x0, atol=2e-1) + else: + np.testing.assert_allclose(final_cost, 0, atol=1e-2) + np.testing.assert_allclose(x, x0, atol=1e-1) @pytest.mark.parametrize("init_soc", [0.3, 0.7]) @pytest.mark.unit From 3cdcb22089eac9934d849127e7137f615ae93a17 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 6 Dec 2023 13:25:04 +0000 Subject: [PATCH 060/101] Updt. changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0eaaa05..27c01b6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # [Unreleased](https://github.com/pybop-team/PyBOP) ## Features +- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Adds `SciPyDifferentialEvolution` optimiser - [#127](https://github.com/pybop-team/PyBOP/issues/127) - Adds Windows and macOS runners to the `test_on_push` action - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds standard plotting class `pybop.StandardPlot()` via plotly backend - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds `quick_plot()`, `plot_convergence()`, and `plot_cost2d()` methods @@ -9,6 +10,7 @@ - [#120](https://github.com/pybop-team/PyBOP/issues/120) - Updates the parameterisation test settings including the number of iterations ## Bug Fixes +- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Increases the SciPyMinimize optimiser assertion tolerances reduce CI/CD failures # [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11) - Initial release From 0687eb693188e218918b7db289536b97ca8d8a5f Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 7 Dec 2023 15:09:35 +0000 Subject: [PATCH 061/101] Updt. parameter value definition, updt. example variable names, concatentate ecm_CMAES and ecm_parameters --- .gitignore | 3 + examples/scripts/ecm_CMAES.py | 35 ++++++- examples/scripts/ecm_parameters.py | 93 ------------------- .../parameters/fit_ecm_parameters.json | 24 ----- examples/scripts/spm_CMAES.py | 4 +- examples/scripts/spm_IRPropMin.py | 4 +- examples/scripts/spm_SNES.py | 4 +- examples/scripts/spm_XNES.py | 4 +- examples/scripts/spm_pso.py | 4 +- pybop/optimisation.py | 4 +- pybop/parameters/parameter.py | 5 +- 11 files changed, 52 insertions(+), 132 deletions(-) delete mode 100644 examples/scripts/ecm_parameters.py delete mode 100644 examples/scripts/parameters/fit_ecm_parameters.json diff --git a/.gitignore b/.gitignore index 838f372b..b6dae2f7 100644 --- a/.gitignore +++ b/.gitignore @@ -304,3 +304,6 @@ $RECYCLE.BIN/ # Visual Studio Code settings .vscode/* + +# Output JSON files +*fit_ecm_parameters.json diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index f1666c8c..07f575fc 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -6,6 +6,37 @@ json_path="examples/scripts/parameters/initial_ecm_parameters.json" ) +# Alternatively, define the initial parameter set with a dictionary +# Add definitions for R's, C's, and initial overpotentials for any additional RC elements +# params = pybop.ParameterSet( +# params_dict={ +# "chemistry": "ecm", +# "Initial SoC": 0.5, +# "Initial temperature [K]": 25 + 273.15, +# "Cell capacity [A.h]": 5, +# "Nominal cell capacity [A.h]": 5, +# "Ambient temperature [K]": 25 + 273.15, +# "Current function [A]": 5, +# "Upper voltage cut-off [V]": 4.2, +# "Lower voltage cut-off [V]": 3.0, +# "Cell thermal mass [J/K]": 1000, +# "Cell-jig heat transfer coefficient [W/K]": 10, +# "Jig thermal mass [J/K]": 500, +# "Jig-air heat transfer coefficient [W/K]": 10, +# "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[ +# "Open-circuit voltage [V]" +# ], +# "R0 [Ohm]": 0.001, +# "Element-1 initial overpotential [V]": 0, +# "Element-2 initial overpotential [V]": 0, +# "R1 [Ohm]": 0.0002, +# "R2 [Ohm]": 0.0003, +# "C1 [F]": 10000, +# "C2 [F]": 5000, +# "Entropic change [V/K]": 0.0004, +# } +# ) + # Define the model model = pybop.empirical.Thevenin( parameter_set=params.import_parameters(), options={"number of rc elements": 2} @@ -28,12 +59,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) +corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", corrupt_values), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/ecm_parameters.py b/examples/scripts/ecm_parameters.py deleted file mode 100644 index ec8a52cd..00000000 --- a/examples/scripts/ecm_parameters.py +++ /dev/null @@ -1,93 +0,0 @@ -import pybop -import numpy as np - -# Define the initial parameter set -# Add definitions for R's, C's, and initial overpotentials for any additional RC elements -params = pybop.ParameterSet( - params_dict={ - "chemistry": "ecm", - "Initial SoC": 0.5, - "Initial temperature [K]": 25 + 273.15, - "Cell capacity [A.h]": 5, - "Nominal cell capacity [A.h]": 5, - "Ambient temperature [K]": 25 + 273.15, - "Current function [A]": 5, - "Upper voltage cut-off [V]": 4.2, - "Lower voltage cut-off [V]": 3.0, - "Cell thermal mass [J/K]": 1000, - "Cell-jig heat transfer coefficient [W/K]": 10, - "Jig thermal mass [J/K]": 500, - "Jig-air heat transfer coefficient [W/K]": 10, - "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[ - "Open-circuit voltage [V]" - ], - "R0 [Ohm]": 0.001, - "Element-1 initial overpotential [V]": 0, - "Element-2 initial overpotential [V]": 0, - "R1 [Ohm]": 0.0002, - "R2 [Ohm]": 0.0003, - "C1 [F]": 10000, - "C2 [F]": 5000, - "Entropic change [V/K]": 0.0004, - } -) - -# Define the model -model = pybop.empirical.Thevenin( - parameter_set=params.import_parameters(), options={"number of rc elements": 2} -) - -# Fitting parameters -parameters = [ - pybop.Parameter( - "R0 [Ohm]", - prior=pybop.Gaussian(0.0002, 0.0001), - bounds=[1e-4, 1e-2], - ), - pybop.Parameter( - "R1 [Ohm]", - prior=pybop.Gaussian(0.0001, 0.0001), - bounds=[1e-5, 1e-2], - ), -] - -sigma = 0.001 -t_eval = np.arange(0, 900, 2) -values = model.predict(t_eval=t_eval) -CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) - -dataset = [ - pybop.Dataset("Time [s]", t_eval), - pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", CorruptValues), -] - -# Generate problem, cost function, and optimisation class -problem = pybop.FittingProblem(model, parameters, dataset) -cost = pybop.SumSquaredError(problem) -optim = pybop.Optimisation(cost, optimiser=pybop.CMAES) -optim.set_max_iterations(100) - -x, final_cost = optim.run() -print("Estimated parameters:", x) - -# Export the parameters to JSON -params.export_parameters( - "examples/scripts/parameters/fit_ecm_parameters.json", fit_params=parameters -) - -# Plot the timeseries output -pybop.quick_plot(x, cost, title="Optimised Comparison") - -# Plot convergence -pybop.plot_convergence(optim) - -# Plot the parameter traces -pybop.plot_parameters(optim) - -# Plot the cost landscape -pybop.plot_cost2d(cost, steps=15) - -# Plot the cost landscape with optimisation path and updated bounds -bounds = np.array([[1e-4, 1e-2], [1e-5, 1e-2]]) -pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15) diff --git a/examples/scripts/parameters/fit_ecm_parameters.json b/examples/scripts/parameters/fit_ecm_parameters.json deleted file mode 100644 index f8f6b31c..00000000 --- a/examples/scripts/parameters/fit_ecm_parameters.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "chemistry": "ecm", - "Initial SoC": 0.5, - "Initial temperature [K]": 298.15, - "Cell capacity [A.h]": 5, - "Nominal cell capacity [A.h]": 5, - "Ambient temperature [K]": 298.15, - "Current function [A]": 5, - "Upper voltage cut-off [V]": 4.2, - "Lower voltage cut-off [V]": 3.0, - "Cell thermal mass [J/K]": 1000, - "Cell-jig heat transfer coefficient [W/K]": 10, - "Jig thermal mass [J/K]": 500, - "Jig-air heat transfer coefficient [W/K]": 10, - "Open-circuit voltage [V]": "Unable to write value to JSON file", - "R0 [Ohm]": 0.0008928763853238023, - "Element-1 initial overpotential [V]": 0, - "Element-2 initial overpotential [V]": 0, - "R1 [Ohm]": 0.00031654935739793466, - "R2 [Ohm]": 0.0003, - "C1 [F]": 10000, - "C2 [F]": 5000, - "Entropic change [V/K]": 0.0004 -} diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index 5fdd3b73..6e4527b6 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -23,13 +23,13 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) +corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) # Form dataset for optimisation dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", corrupt_values), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index e624c89f..794bc3c3 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -22,12 +22,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) +corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", corrupt_values), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py index e63786ca..ac24c949 100644 --- a/examples/scripts/spm_SNES.py +++ b/examples/scripts/spm_SNES.py @@ -22,12 +22,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) +corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", corrupt_values), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py index dd565fcf..81d8c26c 100644 --- a/examples/scripts/spm_XNES.py +++ b/examples/scripts/spm_XNES.py @@ -22,12 +22,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) +corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", corrupt_values), ] # Generate problem, cost function, and optimisation class diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index 27b9fc61..07bd9669 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -22,12 +22,12 @@ sigma = 0.001 t_eval = np.arange(0, 900, 2) values = model.predict(t_eval=t_eval) -CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) +corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) dataset = [ pybop.Dataset("Time [s]", t_eval), pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", CorruptValues), + pybop.Dataset("Voltage [V]", corrupt_values), ] # Generate problem, cost function, and optimisation class diff --git a/pybop/optimisation.py b/pybop/optimisation.py index d051c30f..9354ee05 100644 --- a/pybop/optimisation.py +++ b/pybop/optimisation.py @@ -412,6 +412,8 @@ def set_max_unchanged_iterations(self, iterations=200, threshold=1e-11): self._unchanged_threshold = threshold def store_optimised_parameters(self, x): - # Add the initial values to the parameter definitions + """ + Store the optimised parameters in the PyBOP parameter class. + """ for i, param in enumerate(self.cost.problem.parameters): param.update(value=x[i]) diff --git a/pybop/parameters/parameter.py b/pybop/parameters/parameter.py index fa875483..9cdf8bc2 100644 --- a/pybop/parameters/parameter.py +++ b/pybop/parameters/parameter.py @@ -6,10 +6,11 @@ class Parameter: Class for creating parameters in PyBOP. """ - def __init__(self, name, value=None, prior=None, bounds=None): + def __init__(self, name, initial_value=None, prior=None, bounds=None): self.name = name self.prior = prior - self.value = value + self.initial_value = initial_value + self.value = initial_value self.bounds = bounds self.lower_bound = self.bounds[0] self.upper_bound = self.bounds[1] From 28eed5cc920489350640fb5588f6a691552dd574 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 7 Dec 2023 17:11:28 +0000 Subject: [PATCH 062/101] Make parameter/input terminology consistent (#133) --- pybop/_problem.py | 28 +++++++++++++----------- pybop/models/base_model.py | 45 +++++++++++++++----------------------- 2 files changed, 33 insertions(+), 40 deletions(-) diff --git a/pybop/_problem.py b/pybop/_problem.py index fa6aa5b9..8cf37ec3 100644 --- a/pybop/_problem.py +++ b/pybop/_problem.py @@ -39,13 +39,13 @@ def __init__( for i, param in enumerate(self.parameters): param.update(value=self.x0[i]) - def evaluate(self, parameters): + def evaluate(self, x): """ Evaluate the model with the given parameters and return the signal. """ raise NotImplementedError - def evaluateS1(self, parameters): + def evaluateS1(self, x): """ Evaluate the model with the given parameters and return the signal and its derivatives. @@ -96,28 +96,28 @@ def __init__( if self._model._built_model is None: self._model.build( dataset=self._dataset, - parameters={o.name: o.value for o in self.parameters}, + parameters=self.parameters, check_model=self.check_model, init_soc=self.init_soc, ) - def evaluate(self, parameters): + def evaluate(self, x): """ Evaluate the model with the given parameters and return the signal. """ - y = np.asarray(self._model.simulate(inputs=parameters, t_eval=self._time_data)) + y = np.asarray(self._model.simulate(inputs=x, t_eval=self._time_data)) return y - def evaluateS1(self, parameters): + def evaluateS1(self, x): """ Evaluate the model with the given parameters and return the signal and its derivatives. """ y, dy = self._model.simulateS1( - inputs=parameters, + inputs=x, t_eval=self._time_data, ) @@ -151,33 +151,35 @@ def __init__( # Build the model if required if experiment is not None: # Leave the build until later to apply the experiment - self._model.parameters = {o.name: o.value for o in self.parameters} + self._model.parameters = self.parameters + if self.parameters is not None: + self._model.fit_keys = [param.name for param in self.parameters] elif self._model._built_model is None: self._model.build( experiment=self.experiment, - parameters={o.name: o.value for o in self.parameters}, + parameters=self.parameters, check_model=self.check_model, init_soc=self.init_soc, ) - def evaluate(self, parameters): + def evaluate(self, x): """ Evaluate the model with the given parameters and return the signal. """ - y = np.asarray(self._model.simulate(inputs=parameters, t_eval=self._time_data)) + y = np.asarray(self._model.simulate(inputs=x, t_eval=self._time_data)) return y - def evaluateS1(self, parameters): + def evaluateS1(self, x): """ Evaluate the model with the given parameters and return the signal and its derivatives. """ y, dy = self._model.simulateS1( - inputs=parameters, + inputs=x, t_eval=self._time_data, ) diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index 20100ab6..848ab029 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -26,10 +26,10 @@ def build( For PyBaMM forward models, this method follows a similar process to pybamm.Simulation.build(). """ - self.parameters = parameters self.dataset = dataset + self.parameters = parameters if self.parameters is not None: - self.fit_keys = list(self.parameters.keys()) + self.fit_keys = [param.name for param in self.parameters] if init_soc is not None: self.set_init_soc(init_soc) @@ -78,9 +78,9 @@ def set_params(self): if self.model_with_set_params: return + # Mark any simulation inputs in the parameter set if self.parameters is not None: - # set input parameters in parameter set from fitting parameters - for i in self.parameters.keys(): + for i in self.fit_keys: self._parameter_set[i] = "[input]" if self.dataset is not None and self.parameters is not None: @@ -109,14 +109,11 @@ def simulate(self, inputs, t_eval): raise ValueError("Model must be built before calling simulate") else: if not isinstance(inputs, dict): - inputs_dict = {key: inputs[i] for i, key in enumerate(self.parameters)} - return self.solver.solve( - self.built_model, inputs=inputs_dict, t_eval=t_eval - )[self.signal].data - else: - return self.solver.solve( - self.built_model, inputs=inputs, t_eval=t_eval - )[self.signal].data + inputs = {key: inputs[i] for i, key in enumerate(self.fit_keys)} + + return self.solver.solve(self.built_model, inputs=inputs, t_eval=t_eval)[ + self.signal + ].data def simulateS1(self, inputs, t_eval): """ @@ -128,21 +125,14 @@ def simulateS1(self, inputs, t_eval): raise ValueError("Model must be built before calling simulate") else: if not isinstance(inputs, dict): - inputs_dict = {key: inputs[i] for i, key in enumerate(self.parameters)} + inputs = {key: inputs[i] for i, key in enumerate(self.fit_keys)} - sol = self.solver.solve( - self.built_model, - inputs=inputs_dict, - t_eval=t_eval, - calculate_sensitivities=True, - ) - else: - sol = self.solver.solve( - self.built_model, - inputs=inputs, - t_eval=t_eval, - calculate_sensitivities=True, - ) + sol = self.solver.solve( + self.built_model, + inputs=inputs, + t_eval=t_eval, + calculate_sensitivities=True, + ) return ( sol[self.signal].data, @@ -168,8 +158,9 @@ def predict( parameter_set = parameter_set or self._parameter_set if inputs is not None: if not isinstance(inputs, dict): - inputs = {key: inputs[i] for i, key in enumerate(self.parameters)} + inputs = {key: inputs[i] for i, key in enumerate(self.fit_keys)} parameter_set.update(inputs) + if self._unprocessed_model is not None: if experiment is None: return pybamm.Simulation( From 6aaa44b3baf7b59d4731588d2480bc4270b107b8 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 7 Dec 2023 17:59:20 +0000 Subject: [PATCH 063/101] Extend non-Pints optimisers for plotting (#134) - Add logging for non-Pints optimisers - Add a SciPy minimize example - Update changelog --- CHANGELOG.md | 1 + examples/scripts/spm_nlopt.py | 1 + examples/scripts/spm_scipymin.py | 57 ++++++++++++++++++++++++++++++ pybop/optimisation.py | 2 ++ pybop/optimisers/nlopt_optimize.py | 10 +++++- pybop/optimisers/scipy_minimize.py | 12 +++++-- pybop/plotting/plot_parameters.py | 4 --- 7 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 examples/scripts/spm_scipymin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b8075be4..686bf53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - [#127](https://github.com/pybop-team/PyBOP/issues/127) - Adds Windows and macOS runners to the `test_on_push` action - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds standard plotting class `pybop.StandardPlot()` via plotly backend - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds `quick_plot()`, `plot_convergence()`, and `plot_cost2d()` methods +- [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds a SciPy minimize example and logging for non-Pints optimisers - [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class - [#38](https://github.com/pybop-team/PyBOP/issues/38) - Restructures the Problem classes ahead of adding a design optimisation example - [#120](https://github.com/pybop-team/PyBOP/issues/120) - Updates the parameterisation test settings including the number of iterations diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py index 1699bfce..258a5256 100644 --- a/examples/scripts/spm_nlopt.py +++ b/examples/scripts/spm_nlopt.py @@ -39,6 +39,7 @@ # Run the optimisation problem x, final_cost = optim.run() +print("Estimated parameters:", x) # Plot the timeseries output pybop.quick_plot(x, cost, title="Optimised Comparison") diff --git a/examples/scripts/spm_scipymin.py b/examples/scripts/spm_scipymin.py new file mode 100644 index 00000000..ec917378 --- /dev/null +++ b/examples/scripts/spm_scipymin.py @@ -0,0 +1,57 @@ +import pybop +import pandas as pd + +# Form dataset +Measurements = pd.read_csv("examples/scripts/Chen_example.csv", comment="#").to_numpy() +dataset = [ + pybop.Dataset("Time [s]", Measurements[:, 0]), + pybop.Dataset("Current function [A]", Measurements[:, 1]), + pybop.Dataset("Voltage [V]", Measurements[:, 2]), +] + +# Define model +parameter_set = pybop.ParameterSet.pybamm("Chen2020") +model = pybop.models.lithium_ion.SPM( + parameter_set=parameter_set, options={"thermal": "lumped"} +) + +# Fitting parameters +parameters = [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.75, 0.05), + bounds=[0.6, 0.9], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.65, 0.05), + bounds=[0.5, 0.8], + ), +] + +# Define the cost to optimise +signal = "Voltage [V]" +problem = pybop.FittingProblem(model, parameters, dataset, signal=signal, init_soc=0.98) +cost = pybop.RootMeanSquaredError(problem) + +# Build the optimisation problem +optim = pybop.Optimisation(cost=cost, optimiser=pybop.SciPyMinimize) + +# Run the optimisation problem +x, final_cost = optim.run() +print("Estimated parameters:", x) + +# Plot the timeseries output +pybop.quick_plot(x, cost, title="Optimised Comparison") + +# Plot convergence +pybop.plot_convergence(optim) + +# Plot the parameter traces +pybop.plot_parameters(optim) + +# Plot the cost landscape +pybop.plot_cost2d(cost, steps=15) + +# Plot the cost landscape with optimisation path +pybop.plot_cost2d(cost, optim=optim, steps=15) diff --git a/pybop/optimisation.py b/pybop/optimisation.py index df0ba465..43db2634 100644 --- a/pybop/optimisation.py +++ b/pybop/optimisation.py @@ -134,6 +134,8 @@ def _run_pybop(self): x0=self.x0, bounds=self.bounds, ) + self.log = self.optimiser.log + return x, final_cost def _run_pints(self): diff --git a/pybop/optimisers/nlopt_optimize.py b/pybop/optimisers/nlopt_optimize.py index 229934b7..b10e4f5d 100644 --- a/pybop/optimisers/nlopt_optimize.py +++ b/pybop/optimisers/nlopt_optimize.py @@ -1,5 +1,6 @@ import nlopt from .base_optimiser import BaseOptimiser +import numpy as np class NLoptOptimize(BaseOptimiser): @@ -33,8 +34,15 @@ def _runoptimise(self, cost_function, x0, bounds): bounds: bounds array """ + # Add callback storing history of parameter values + self.log = [[x0]] + + def cost_wrapper(x, grad): + self.log.append([np.array(x)]) + return cost_function(x, grad) + # Pass settings to the optimiser - self.optim.set_min_objective(cost_function) + self.optim.set_min_objective(cost_wrapper) self.optim.set_lower_bounds(bounds["lower"]) self.optim.set_upper_bounds(bounds["upper"]) diff --git a/pybop/optimisers/scipy_minimize.py b/pybop/optimisers/scipy_minimize.py index a1f57fe6..48512a35 100644 --- a/pybop/optimisers/scipy_minimize.py +++ b/pybop/optimisers/scipy_minimize.py @@ -27,14 +27,22 @@ def _runoptimise(self, cost_function, x0, bounds): bounds: bounds array """ + # Add callback storing history of parameter values + self.log = [[x0]] + + def callback(x): + self.log.append([x]) + if bounds is not None: # Reformat bounds and run the optimser bounds = ( (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) ) - output = minimize(cost_function, x0, method=self.method, bounds=bounds) + output = minimize( + cost_function, x0, method=self.method, bounds=bounds, callback=callback + ) else: - output = minimize(cost_function, x0, method=self.method) + output = minimize(cost_function, x0, method=self.method, callback=callback) # Get performance statistics x = output.x diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py index 9f4bbc02..3fb06ea3 100644 --- a/pybop/plotting/plot_parameters.py +++ b/pybop/plotting/plot_parameters.py @@ -38,10 +38,6 @@ def plot_parameters( attribute containing a history of the iterations. """ - if optim.optimiser.name() in ["NLoptOptimize", "SciPyMinimize"]: - print("Parameter plot not yet supported for this optimiser.") - return - # Extract parameters from the optimisation object params = optim.cost.problem.parameters From 7f887af0fcb27ecebc8f9c4d10d0bb30ceaac751 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 8 Dec 2023 11:00:47 +0000 Subject: [PATCH 064/101] doc dependancies --- docs/Makefile | 4 +- docs/_extension/gallery_directive.py | 145 +++ docs/api/index.rst | 11 + docs/api/pybop/_problem/index.rst | 85 ++ docs/api/pybop/costs/error_costs/index.rst | 72 ++ docs/api/pybop/costs/index.rst | 14 + docs/api/pybop/costs/standalone/index.rst | 31 + .../api/pybop/datasets/base_dataset/index.rst | 30 + docs/api/pybop/datasets/index.rst | 13 + docs/api/pybop/index.rst | 868 ++++++++++++++++++ docs/api/pybop/models/base_model/index.rst | 83 ++ docs/api/pybop/models/index.rst | 22 + .../models/lithium_ion/base_echem/index.rst | 35 + docs/api/pybop/models/lithium_ion/index.rst | 44 + docs/api/pybop/optimisation/index.rst | 120 +++ .../pybop/optimisers/base_optimiser/index.rst | 39 + docs/api/pybop/optimisers/index.rst | 16 + .../pybop/optimisers/nlopt_optimize/index.rst | 46 + .../optimisers/pints_optimisers/index.rst | 86 ++ .../pybop/optimisers/scipy_minimize/index.rst | 46 + .../pybop/parameters/base_parameter/index.rst | 41 + .../parameters/base_parameter_set/index.rst | 23 + docs/api/pybop/parameters/index.rst | 15 + docs/api/pybop/parameters/priors/index.rst | 78 ++ docs/api/pybop/plotting/index.rst | 17 + .../pybop/plotting/plot_convergence/index.rst | 38 + docs/api/pybop/plotting/plot_cost2d/index.rst | 32 + .../pybop/plotting/plot_parameters/index.rst | 89 ++ .../pybop/plotting/plotly_manager/index.rst | 87 ++ docs/api/pybop/plotting/quick_plot/index.rst | 137 +++ docs/api/pybop/version/index.rst | 11 + docs/conf.py | 78 ++ docs/contributing.md | 2 + docs/index.md | 35 + docs/make.bat | 4 +- docs/source/conf.py | 28 - docs/source/index.rst | 20 - docs/source/modules.rst | 7 + docs/source/pybop.costs.rst | 29 + docs/source/pybop.datasets.rst | 21 + docs/source/pybop.optimisers.rst | 45 + docs/source/pybop.parameters.rst | 37 + docs/source/pybop.plotting.rst | 53 ++ docs/source/pybop.rst | 42 + pybop/plotting/plot_parameters.py | 16 +- pybop/plotting/plotly_manager.py | 12 +- 46 files changed, 2741 insertions(+), 66 deletions(-) create mode 100644 docs/_extension/gallery_directive.py create mode 100644 docs/api/index.rst create mode 100644 docs/api/pybop/_problem/index.rst create mode 100644 docs/api/pybop/costs/error_costs/index.rst create mode 100644 docs/api/pybop/costs/index.rst create mode 100644 docs/api/pybop/costs/standalone/index.rst create mode 100644 docs/api/pybop/datasets/base_dataset/index.rst create mode 100644 docs/api/pybop/datasets/index.rst create mode 100644 docs/api/pybop/index.rst create mode 100644 docs/api/pybop/models/base_model/index.rst create mode 100644 docs/api/pybop/models/index.rst create mode 100644 docs/api/pybop/models/lithium_ion/base_echem/index.rst create mode 100644 docs/api/pybop/models/lithium_ion/index.rst create mode 100644 docs/api/pybop/optimisation/index.rst create mode 100644 docs/api/pybop/optimisers/base_optimiser/index.rst create mode 100644 docs/api/pybop/optimisers/index.rst create mode 100644 docs/api/pybop/optimisers/nlopt_optimize/index.rst create mode 100644 docs/api/pybop/optimisers/pints_optimisers/index.rst create mode 100644 docs/api/pybop/optimisers/scipy_minimize/index.rst create mode 100644 docs/api/pybop/parameters/base_parameter/index.rst create mode 100644 docs/api/pybop/parameters/base_parameter_set/index.rst create mode 100644 docs/api/pybop/parameters/index.rst create mode 100644 docs/api/pybop/parameters/priors/index.rst create mode 100644 docs/api/pybop/plotting/index.rst create mode 100644 docs/api/pybop/plotting/plot_convergence/index.rst create mode 100644 docs/api/pybop/plotting/plot_cost2d/index.rst create mode 100644 docs/api/pybop/plotting/plot_parameters/index.rst create mode 100644 docs/api/pybop/plotting/plotly_manager/index.rst create mode 100644 docs/api/pybop/plotting/quick_plot/index.rst create mode 100644 docs/api/pybop/version/index.rst create mode 100644 docs/conf.py create mode 100644 docs/contributing.md create mode 100644 docs/index.md delete mode 100644 docs/source/conf.py delete mode 100644 docs/source/index.rst create mode 100644 docs/source/modules.rst create mode 100644 docs/source/pybop.costs.rst create mode 100644 docs/source/pybop.datasets.rst create mode 100644 docs/source/pybop.optimisers.rst create mode 100644 docs/source/pybop.parameters.rst create mode 100644 docs/source/pybop.plotting.rst create mode 100644 docs/source/pybop.rst diff --git a/docs/Makefile b/docs/Makefile index d0c3cbf1..d4bb2cbb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,8 +5,8 @@ # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = build +SOURCEDIR = . +BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: diff --git a/docs/_extension/gallery_directive.py b/docs/_extension/gallery_directive.py new file mode 100644 index 00000000..4dcc2c63 --- /dev/null +++ b/docs/_extension/gallery_directive.py @@ -0,0 +1,145 @@ +"""A directive to generate a gallery of images from structured data. + +Generating a gallery of images that are all the same size is a common +pattern in documentation, and this can be cumbersome if the gallery is +generated programmatically. This directive wraps this particular use-case +in a helper-directive to generate it with a single YAML configuration file. + +It currently exists for maintainers of the pydata-sphinx-theme, +but might be abstracted into a standalone package if it proves useful. + +Credit: PyData Sphinx Theme +""" +from pathlib import Path +from typing import Any, Dict, List + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.application import Sphinx +from sphinx.util import logging +from sphinx.util.docutils import SphinxDirective +from yaml import safe_load + +logger = logging.getLogger(__name__) + + +TEMPLATE_GRID = """ +`````{{grid}} {columns} +{options} + +{content} + +````` +""" + +GRID_CARD = """ +````{{grid-item-card}} {title} +{options} + +{content} +```` +""" + + +class GalleryGridDirective(SphinxDirective): + """A directive to show a gallery of images and links in a Bootstrap grid. + + The grid can be generated from a YAML file that contains a list of items, or + from the content of the directive (also formatted in YAML). Use the parameter + "class-card" to add an additional CSS class to all cards. When specifying the grid + items, you can use all parameters from "grid-item-card" directive to customize + individual cards + ["image", "header", "content", "title"]. + + Danger: + This directive can only be used in the context of a Myst documentation page as + the templates use Markdown flavored formatting. + """ + + name = "gallery-grid" + has_content = True + required_arguments = 0 + optional_arguments = 1 + final_argument_whitespace = True + option_spec = { + # A class to be added to the resulting container + "grid-columns": directives.unchanged, + "class-container": directives.unchanged, + "class-card": directives.unchanged, + } + + def run(self) -> List[nodes.Node]: + """Create the gallery grid.""" + if self.arguments: + # If an argument is given, assume it's a path to a YAML file + # Parse it and load it into the directive content + path_data_rel = Path(self.arguments[0]) + path_doc, _ = self.get_source_info() + path_doc = Path(path_doc).parent + path_data = (path_doc / path_data_rel).resolve() + if not path_data.exists(): + logger.info(f"Could not find grid data at {path_data}.") + nodes.text("No grid data found at {path_data}.") + return + yaml_string = path_data.read_text() + else: + yaml_string = "\n".join(self.content) + + # Use all the element with an img-bottom key as sites to show + # and generate a card item for each of them + grid_items = [] + for item in safe_load(yaml_string): + # remove parameters that are not needed for the card options + title = item.pop("title", "") + + # build the content of the card using some extra parameters + header = f"{item.pop('header')} \n^^^ \n" if "header" in item else "" + image = f"![image]({item.pop('image')}) \n" if "image" in item else "" + content = f"{item.pop('content')} \n" if "content" in item else "" + + # optional parameter that influence all cards + if "class-card" in self.options: + item["class-card"] = self.options["class-card"] + + loc_options_str = "\n".join(f":{k}: {v}" for k, v in item.items()) + " \n" + + card = GRID_CARD.format( + options=loc_options_str, content=header + image + content, title=title + ) + grid_items.append(card) + + # Parse the template with Sphinx Design to create an output container + # Prep the options for the template grid + class_ = "gallery-directive" + f' {self.options.get("class-container", "")}' + options = {"gutter": 2, "class-container": class_} + options_str = "\n".join(f":{k}: {v}" for k, v in options.items()) + + # Create the directive string for the grid + grid_directive = TEMPLATE_GRID.format( + columns=self.options.get("grid-columns", "1 2 3 4"), + options=options_str, + content="\n".join(grid_items), + ) + + # Parse content as a directive so Sphinx Design processes it + container = nodes.container() + self.state.nested_parse([grid_directive], 0, container) + + # Sphinx Design outputs a container too, so just use that + return [container.children[0]] + + +def setup(app: Sphinx) -> Dict[str, Any]: + """Add custom configuration to sphinx app. + + Args: + app: the Sphinx application + + Returns: + the 2 parallel parameters set to ``True``. + """ + app.add_directive("gallery-grid", GalleryGridDirective) + + return { + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 00000000..52c74ac8 --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,11 @@ +API Reference +============= + +This page contains auto-generated API reference documentation [#f1]_. + +.. toctree:: + :titlesonly: + + /api/pybop/index + +.. [#f1] Created with `sphinx-autoapi `_ diff --git a/docs/api/pybop/_problem/index.rst b/docs/api/pybop/_problem/index.rst new file mode 100644 index 00000000..a51adad8 --- /dev/null +++ b/docs/api/pybop/_problem/index.rst @@ -0,0 +1,85 @@ +:py:mod:`pybop._problem` +======================== + +.. py:module:: pybop._problem + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop._problem.BaseProblem + pybop._problem.DesignProblem + pybop._problem.FittingProblem + + + + +.. py:class:: BaseProblem(parameters, model=None, check_model=True, init_soc=None, x0=None) + + + Defines the PyBOP base problem, following the PINTS interface. + + .. py:method:: evaluate(parameters) + :abstractmethod: + + Evaluate the model with the given parameters and return the signal. + + + .. py:method:: evaluateS1(parameters) + :abstractmethod: + + Evaluate the model with the given parameters and return the signal and + its derivatives. + + + +.. py:class:: DesignProblem(model, parameters, experiment, check_model=True, init_soc=None, x0=None) + + + Bases: :py:obj:`BaseProblem` + + Defines the problem class for a design optimiation problem. + + .. py:method:: evaluate(parameters) + + Evaluate the model with the given parameters and return the signal. + + + .. py:method:: evaluateS1(parameters) + + Evaluate the model with the given parameters and return the signal and + its derivatives. + + + .. py:method:: target() + + Returns the target dataset. + + + +.. py:class:: FittingProblem(model, parameters, dataset, signal='Terminal voltage [V]', check_model=True, init_soc=None, x0=None) + + + Bases: :py:obj:`BaseProblem` + + Defines the problem class for a fitting (parameter estimation) problem. + + .. py:method:: evaluate(parameters) + + Evaluate the model with the given parameters and return the signal. + + + .. py:method:: evaluateS1(parameters) + + Evaluate the model with the given parameters and return the signal and + its derivatives. + + + .. py:method:: target() + + Returns the target dataset. diff --git a/docs/api/pybop/costs/error_costs/index.rst b/docs/api/pybop/costs/error_costs/index.rst new file mode 100644 index 00000000..70f18e02 --- /dev/null +++ b/docs/api/pybop/costs/error_costs/index.rst @@ -0,0 +1,72 @@ +:py:mod:`pybop.costs.error_costs` +================================= + +.. py:module:: pybop.costs.error_costs + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.costs.error_costs.BaseCost + pybop.costs.error_costs.RootMeanSquaredError + pybop.costs.error_costs.SumSquaredError + + + + +.. py:class:: BaseCost(problem) + + + Base class for defining cost functions. + This class computes a corresponding goodness-of-fit for a corresponding model prediction and dataset. + Lower cost values indicate a better fit. + + .. py:method:: __call__(x, grad=None) + :abstractmethod: + + Returns the cost function value and computes the cost. + + + +.. py:class:: RootMeanSquaredError(problem) + + + Bases: :py:obj:`BaseCost` + + Defines the root mean square error cost function. + + .. py:method:: __call__(x, grad=None) + + Computes the cost. + + + +.. py:class:: SumSquaredError(problem) + + + Bases: :py:obj:`BaseCost` + + Defines the sum squared error cost function. + + The initial fail gradient is set equal to one, but this can be + changed at any time with :meth:`set_fail_gradient()`. + + .. py:method:: __call__(x, grad=None) + + Computes the cost. + + + .. py:method:: evaluateS1(x) + + Compute the cost and corresponding + gradients with respect to the parameters. + + + .. py:method:: set_fail_gradient(de) + + Sets the fail gradient for this optimiser. diff --git a/docs/api/pybop/costs/index.rst b/docs/api/pybop/costs/index.rst new file mode 100644 index 00000000..837718ef --- /dev/null +++ b/docs/api/pybop/costs/index.rst @@ -0,0 +1,14 @@ +:py:mod:`pybop.costs` +===================== + +.. py:module:: pybop.costs + + +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + + error_costs/index.rst + standalone/index.rst diff --git a/docs/api/pybop/costs/standalone/index.rst b/docs/api/pybop/costs/standalone/index.rst new file mode 100644 index 00000000..3d0957b4 --- /dev/null +++ b/docs/api/pybop/costs/standalone/index.rst @@ -0,0 +1,31 @@ +:py:mod:`pybop.costs.standalone` +================================ + +.. py:module:: pybop.costs.standalone + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.costs.standalone.StandaloneCost + + + + +.. py:class:: StandaloneCost(problem=None) + + + Bases: :py:obj:`pybop.BaseCost` + + Base class for defining cost functions. + This class computes a corresponding goodness-of-fit for a corresponding model prediction and dataset. + Lower cost values indicate a better fit. + + .. py:method:: __call__(x, grad=None) + + Returns the cost function value and computes the cost. diff --git a/docs/api/pybop/datasets/base_dataset/index.rst b/docs/api/pybop/datasets/base_dataset/index.rst new file mode 100644 index 00000000..9d6e1fe0 --- /dev/null +++ b/docs/api/pybop/datasets/base_dataset/index.rst @@ -0,0 +1,30 @@ +:py:mod:`pybop.datasets.base_dataset` +===================================== + +.. py:module:: pybop.datasets.base_dataset + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.datasets.base_dataset.Dataset + + + + +.. py:class:: Dataset(name, data) + + + Class for experimental observations. + + .. py:method:: Interpolant() + + + .. py:method:: __repr__() + + Return repr(self). diff --git a/docs/api/pybop/datasets/index.rst b/docs/api/pybop/datasets/index.rst new file mode 100644 index 00000000..0d868a1c --- /dev/null +++ b/docs/api/pybop/datasets/index.rst @@ -0,0 +1,13 @@ +:py:mod:`pybop.datasets` +======================== + +.. py:module:: pybop.datasets + + +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + + base_dataset/index.rst diff --git a/docs/api/pybop/index.rst b/docs/api/pybop/index.rst new file mode 100644 index 00000000..7b0272b1 --- /dev/null +++ b/docs/api/pybop/index.rst @@ -0,0 +1,868 @@ +:py:mod:`pybop` +=============== + +.. py:module:: pybop + + +Subpackages +----------- +.. toctree:: + :titlesonly: + :maxdepth: 3 + + costs/index.rst + datasets/index.rst + models/index.rst + optimisers/index.rst + parameters/index.rst + plotting/index.rst + + +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + + _problem/index.rst + optimisation/index.rst + version/index.rst + + +Package Contents +---------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.Adam + pybop.BaseCost + pybop.BaseModel + pybop.BaseOptimiser + pybop.CMAES + pybop.Dataset + pybop.DesignProblem + pybop.Exponential + pybop.FittingProblem + pybop.Gaussian + pybop.GradientDescent + pybop.IRPropMin + pybop.NLoptOptimize + pybop.Optimisation + pybop.PSO + pybop.Parameter + pybop.ParameterSet + pybop.PlotlyManager + pybop.RootMeanSquaredError + pybop.SNES + pybop.SciPyMinimize + pybop.StandardPlot + pybop.SumSquaredError + pybop.Uniform + pybop.XNES + + + +Functions +~~~~~~~~~ + +.. autoapisummary:: + + pybop.plot_convergence + pybop.plot_cost2d + pybop.plot_parameters + pybop.quick_plot + + + +Attributes +~~~~~~~~~~ + +.. autoapisummary:: + + pybop.FLOAT_FORMAT + pybop.__version__ + pybop.script_path + + +.. py:class:: Adam(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.Adam` + + Adam optimiser. Inherits from the PINTS Adam class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_adam.py + + +.. py:class:: BaseCost(problem) + + + Base class for defining cost functions. + This class computes a corresponding goodness-of-fit for a corresponding model prediction and dataset. + Lower cost values indicate a better fit. + + .. py:method:: __call__(x, grad=None) + :abstractmethod: + + Returns the cost function value and computes the cost. + + + +.. py:class:: BaseModel(name='Base Model') + + + Base class for pybop models. + + .. py:property:: built_model + + + .. py:property:: geometry + + + .. py:property:: mesh + + + .. py:property:: model_with_set_params + + + .. py:property:: parameter_set + + + .. py:property:: solver + + + .. py:property:: spatial_methods + + + .. py:property:: submesh_types + + + .. py:property:: var_pts + + + .. py:method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) + + Build the PyBOP model (if not built already). + For PyBaMM forward models, this method follows a + similar process to pybamm.Simulation.build(). + + + .. py:method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) + + Create a PyBaMM simulation object, solve it, and return a solution object. + + + .. py:method:: set_init_soc(init_soc) + + Set the initial state of charge. + + + .. py:method:: set_params() + + Set the parameters in the model. + + + .. py:method:: simulate(inputs, t_eval) + + Run the forward model and return the result in Numpy array format + aligning with Pints' ForwardModel simulate method. + + + .. py:method:: simulateS1(inputs, t_eval) + + Run the forward model and return the function evaulation and it's gradient + aligning with Pints' ForwardModel simulateS1 method. + + + +.. py:class:: BaseOptimiser + + + Base class for the optimisation methods. + + + .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) + + Run optimisation method, to be overloaded by child classes. + + + + .. py:method:: name() + + Returns the name of the optimiser. + + + .. py:method:: optimise(cost_function, x0=None, bounds=None) + + Optimisiation method to be overloaded by child classes. + + + + +.. py:class:: CMAES(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.CMAES` + + Class for the PINTS optimisation. Extends the BaseOptimiser class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_cmaes.py + + +.. py:class:: Dataset(name, data) + + + Class for experimental observations. + + .. py:method:: Interpolant() + + + .. py:method:: __repr__() + + Return repr(self). + + + +.. py:class:: DesignProblem(model, parameters, experiment, check_model=True, init_soc=None, x0=None) + + + Bases: :py:obj:`BaseProblem` + + Defines the problem class for a design optimiation problem. + + .. py:method:: evaluate(parameters) + + Evaluate the model with the given parameters and return the signal. + + + .. py:method:: evaluateS1(parameters) + + Evaluate the model with the given parameters and return the signal and + its derivatives. + + + .. py:method:: target() + + Returns the target dataset. + + + +.. py:class:: Exponential(scale) + + + Exponential prior class. + + .. py:method:: __repr__() + + Return repr(self). + + + .. py:method:: logpdf(x) + + + .. py:method:: pdf(x) + + + .. py:method:: rvs(size) + + + +.. py:class:: FittingProblem(model, parameters, dataset, signal='Terminal voltage [V]', check_model=True, init_soc=None, x0=None) + + + Bases: :py:obj:`BaseProblem` + + Defines the problem class for a fitting (parameter estimation) problem. + + .. py:method:: evaluate(parameters) + + Evaluate the model with the given parameters and return the signal. + + + .. py:method:: evaluateS1(parameters) + + Evaluate the model with the given parameters and return the signal and + its derivatives. + + + .. py:method:: target() + + Returns the target dataset. + + + +.. py:class:: Gaussian(mean, sigma) + + + Gaussian prior class. + + .. py:method:: __repr__() + + Return repr(self). + + + .. py:method:: logpdf(x) + + + .. py:method:: pdf(x) + + + .. py:method:: rvs(size) + + + +.. py:class:: GradientDescent(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.GradientDescent` + + Gradient descent optimiser. Inherits from the PINTS gradient descent class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_gradient_descent.py + + +.. py:class:: IRPropMin(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.IRPropMin` + + IRProp- optimiser. Inherits from the PINTS IRPropMinus class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_irpropmin.py + + +.. py:class:: NLoptOptimize(n_param, xtol=None, method=None) + + + Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` + + Wrapper class for the NLOpt optimiser class. Extends the BaseOptimiser class. + + .. py:method:: _runoptimise(cost_function, x0, bounds) + + Run the NLOpt optimisation method. + + Inputs + ---------- + cost_function: function for optimising + method: optimisation algorithm + x0: initialisation array + bounds: bounds array + + + .. py:method:: name() + + Returns the name of the optimiser. + + + .. py:method:: needs_sensitivities() + + Returns True if the optimiser needs sensitivities. + + + +.. py:class:: Optimisation(cost, optimiser=None, sigma0=None, verbose=False) + + + Optimisation class for PyBOP. + This class provides functionality for PyBOP optimisers and Pints optimisers. + :param cost: PyBOP cost function + :param optimiser: A PyBOP or Pints optimiser + :param sigma0: initial step size + :param verbose: print optimisation progress + + .. py:method:: _run_pints() + + Run method for PINTS optimisers. + This method is heavily based on the run method in the PINTS.OptimisationController class. + :returns: best parameters + final_cost: final cost + :rtype: x + + + .. py:method:: _run_pybop() + + Run method for PyBOP based optimisers. + :returns: best parameters + final_cost: final cost + :rtype: x + + + .. py:method:: f_guessed_tracking() + + Returns ``True`` if f_guessed instead of f_best is being tracked, + ``False`` otherwise. See also :meth:`set_f_guessed_tracking`. + + Credit: PINTS + + + .. py:method:: run() + + Run the optimisation algorithm. + Selects between PyBOP backend or Pints backend. + :returns: best parameters + final_cost: final cost + :rtype: x + + + .. py:method:: set_f_guessed_tracking(use_f_guessed=False) + + Sets the method used to track the optimiser progress to + :meth:`pints.Optimiser.f_guessed()` or + :meth:`pints.Optimiser.f_best()` (default). + + The tracked ``f`` value is used to evaluate stopping criteria. + + Credit: PINTS + + + .. py:method:: set_max_evaluations(evaluations=None) + + Adds a stopping criterion, allowing the routine to halt after the + given number of ``evaluations``. + + This criterion is disabled by default. To enable, pass in any positive + integer. To disable again, use ``set_max_evaluations(None)``. + + Credit: PINTS + + + .. py:method:: set_max_iterations(iterations=1000) + + Adds a stopping criterion, allowing the routine to halt after the + given number of ``iterations``. + + This criterion is enabled by default. To disable it, use + ``set_max_iterations(None)``. + + Credit: PINTS + + + .. py:method:: set_max_unchanged_iterations(iterations=25, threshold=1e-05) + + Adds a stopping criterion, allowing the routine to halt if the + objective function doesn't change by more than ``threshold`` for the + given number of ``iterations``. + + This criterion is enabled by default. To disable it, use + ``set_max_unchanged_iterations(None)``. + + Credit: PINTS + + + .. py:method:: set_parallel(parallel=False) + + Enables/disables parallel evaluation. + + If ``parallel=True``, the method will run using a number of worker + processes equal to the detected cpu core count. The number of workers + can be set explicitly by setting ``parallel`` to an integer greater + than 0. + Parallelisation can be disabled by setting ``parallel`` to ``0`` or + ``False``. + + Credit: PINTS + + + +.. py:class:: PSO(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.PSO` + + Particle swarm optimiser. Inherits from the PINTS PSO class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_pso.py + + +.. py:class:: Parameter(name, value=None, prior=None, bounds=None) + + + "" + Class for creating parameters in PyBOP. + + .. py:method:: __repr__() + + Return repr(self). + + + .. py:method:: rvs(n_samples) + + Returns a random value sample from the prior distribution. + + + .. py:method:: set_margin(margin) + + Sets the margin for the parameter. + + + .. py:method:: update(value) + + + +.. py:class:: ParameterSet + + + Class for creating parameter sets in PyBOP. + + +.. py:class:: PlotlyManager + + + Manages the installation and configuration of Plotly for generating visualisations. + + This class checks if Plotly is installed and, if not, prompts the user to install it. + It also ensures that the Plotly renderer and browser settings are properly configured + to display plots. + + .. method:: ``ensure_plotly_installed`` + + Verifies if Plotly is installed and installs it if necessary. + + .. method:: ``prompt_for_plotly_installation`` + + Prompts the user for permission to install Plotly. + + .. method:: ``install_plotly_package`` + + Installs the Plotly package using pip. + + .. method:: ``post_install_setup`` + + Sets up Plotly default renderer after installation. + + .. method:: ``check_renderer_settings`` + + Verifies that the Plotly renderer is correctly set. + + .. method:: ``check_browser_availability`` + + Checks if a web browser is available for rendering plots. + + + Usage: + Instantiate the PlotlyManager class to automatically ensure Plotly is installed + and configured correctly when creating an instance. + Example: + plotly_manager = PlotlyManager() + + .. py:method:: check_browser_availability() + + Ensures a web browser is available for rendering plots with the 'browser' renderer and provides guidance if not. + + + .. py:method:: check_renderer_settings() + + Checks if the Plotly renderer is set and provides information on how to set it if empty. + + + .. py:method:: ensure_plotly_installed() + + Verifies if Plotly is installed, importing necessary modules and prompting for installation if missing. + + + .. py:method:: install_plotly() + + Attempts to install the Plotly package using pip and exits if installation fails. + + + .. py:method:: post_install_setup() + + After successful installation, imports Plotly and sets the default renderer if necessary. + + + .. py:method:: prompt_for_plotly_installation() + + Prompts the user for permission to install Plotly and proceeds with installation if consented. + + + +.. py:class:: RootMeanSquaredError(problem) + + + Bases: :py:obj:`BaseCost` + + Defines the root mean square error cost function. + + .. py:method:: __call__(x, grad=None) + + Computes the cost. + + + +.. py:class:: SNES(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.SNES` + + Stochastic natural evolution strategy optimiser. Inherits from the PINTS SNES class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_snes.py + + +.. py:class:: SciPyMinimize(method=None, bounds=None) + + + Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` + + Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. + + .. py:method:: _runoptimise(cost_function, x0, bounds) + + Run the SciPy optimisation method. + + Inputs + ---------- + cost_function: function for optimising + method: optimisation algorithm + x0: initialisation array + bounds: bounds array + + + .. py:method:: name() + + Returns the name of the optimiser. + + + .. py:method:: needs_sensitivities() + + Returns True if the optimiser needs sensitivities. + + + +.. py:class:: StandardPlot(x, y, cost, y2=None, title=None, xaxis_title=None, yaxis_title=None, trace_name=None, width=1024, height=576) + + + A class for creating and displaying a plotly figure that compares a target dataset against a simulated model output. + + This class provides an interface for generating interactive plots using Plotly, with the ability to include an + optional secondary dataset and visualize uncertainty if provided. + + Attributes: + ----------- + x : list + The x-axis data points. + y : list or np.ndarray + The primary y-axis data points representing the simulated model output. + y2 : list or np.ndarray, optional + An optional secondary y-axis data points representing the target dataset against which the model output is compared. + cost : float + The cost associated with the model output. + title : str, optional + The title of the plot. + xaxis_title : str, optional + The title for the x-axis. + yaxis_title : str, optional + The title for the y-axis. + trace_name : str, optional + The name of the primary trace representing the model output. Defaults to "Simulated". + width : int, optional + The width of the figure in pixels. Defaults to 720. + height : int, optional + The height of the figure in pixels. Defaults to 540. + + Methods: + -------- + wrap_text(text, width) + A static method that wraps text to a specified width, inserting HTML line breaks for use in plot labels. + + create_layout() + Creates the layout for the plot, including titles and axis labels. + + create_traces() + Creates the traces for the plot, including the primary dataset, optional secondary dataset, and an optional uncertainty visualization. + + __call__() + Generates the plotly figure when the class instance is called as a function. + + Example: + -------- + >>> x_data = [1, 2, 3, 4] + >>> y_simulated = [10, 15, 13, 17] + >>> y_target = [11, 14, 12, 16] + >>> plot = pybop.StandardPlot(x_data, y_simulated, cost=0.05, y2=y_target, + title="Model vs. Target", xaxis_title="X Axis", yaxis_title="Y Axis") + >>> fig = plot() # Generate the figure + >>> fig.show() # Display the figure in a browser + + .. py:method:: __call__() + + Generate the plotly figure. + + + .. py:method:: create_layout() + + Create the layout for the plot. + + + .. py:method:: create_traces() + + Create the traces for the plot. + + + .. py:method:: wrap_text(text, width) + :staticmethod: + + Wrap text to a specified width. + + Parameters: + ----------- + text: str + Text to be wrapped. + width: int + Width to wrap text to. + + Returns: + -------- + str + Wrapped text with HTML line breaks. + + + +.. py:class:: SumSquaredError(problem) + + + Bases: :py:obj:`BaseCost` + + Defines the sum squared error cost function. + + The initial fail gradient is set equal to one, but this can be + changed at any time with :meth:`set_fail_gradient()`. + + .. py:method:: __call__(x, grad=None) + + Computes the cost. + + + .. py:method:: evaluateS1(x) + + Compute the cost and corresponding + gradients with respect to the parameters. + + + .. py:method:: set_fail_gradient(de) + + Sets the fail gradient for this optimiser. + + + +.. py:class:: Uniform(lower, upper) + + + Uniform prior class. + + .. py:method:: __repr__() + + Return repr(self). + + + .. py:method:: logpdf(x) + + + .. py:method:: pdf(x) + + + .. py:method:: rvs(size) + + + +.. py:class:: XNES(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.XNES` + + Exponential natural evolution strategy optimiser. Inherits from the PINTS XNES class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_xnes.py + + +.. py:function:: plot_convergence(optim, xaxis_title='Iteration', yaxis_title='Cost', title='Convergence') + + Plot the convergence of the optimisation algorithm. + + Parameters: + ---------- + optim : optimisation object + Optimisation object containing the cost function and optimiser. + xaxis_title : str, optional + Title for the x-axis (default is "Iteration"). + yaxis_title : str, optional + Title for the y-axis (default is "Cost"). + title : str, optional + Title of the plot (default is "Convergence"). + + Returns: + ------- + fig : plotly.graph_objs.Figure + The Plotly figure object for the convergence plot. + + +.. py:function:: plot_cost2d(cost, bounds=None, optim=None, steps=10) + + Query the cost landscape for a given parameter space and plot using plotly. + + +.. py:function:: plot_parameters(optim, xaxis_titles='Iteration', yaxis_titles=None, title='Convergence') + + Plot the evolution of the parameters during the optimisation process. + + Parameters: + ---------- + optim : optimisation object + An object representing the optimisation process, which should contain + information about the cost function, optimiser, and the history of the + parameter values throughout the iterations. + xaxis_title : str, optional + Title for the x-axis, representing the iteration number or a similar + discrete time step in the optimisation process (default is "Iteration"). + yaxis_title : str, optional + Title for the y-axis, which typically represents the metric being + optimised, such as cost or loss (default is "Cost"). + title : str, optional + Title of the plot, which provides an overall description of what the + plot represents (default is "Convergence"). + + Returns: + ------- + fig : plotly.graph_objs.Figure + The Plotly figure object for the plot depicting how the parameters of + the optimisation algorithm evolve over its course. This can be useful + for diagnosing the behaviour of the optimisation algorithm. + + Notes: + ----- + The function assumes that the 'optim' object has a 'cost.problem.parameters' + attribute containing the parameters of the optimisation algorithm and a 'log' + attribute containing a history of the iterations. + + +.. py:function:: quick_plot(params, cost, title='Scatter Plot', width=1024, height=576) + + Plot the target dataset against the minimised model output. + + Parameters: + ---------- + params : array-like + Optimised parameters. + cost : cost object + Cost object containing the problem, dataset, and signal. + title : str, optional + Title of the plot (default is "Scatter Plot"). + width : int, optional + Width of the figure in pixels (default is 720). + height : int, optional + Height of the figure in pixels (default is 540). + + Returns: + ------- + fig : plotly.graph_objs.Figure + The Plotly figure object for the scatter plot. + + +.. py:data:: FLOAT_FORMAT + :value: '{: .17e}' + + + +.. py:data:: __version__ + :value: '23.11' + + + +.. py:data:: script_path diff --git a/docs/api/pybop/models/base_model/index.rst b/docs/api/pybop/models/base_model/index.rst new file mode 100644 index 00000000..2eee2080 --- /dev/null +++ b/docs/api/pybop/models/base_model/index.rst @@ -0,0 +1,83 @@ +:py:mod:`pybop.models.base_model` +================================= + +.. py:module:: pybop.models.base_model + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.models.base_model.BaseModel + + + + +.. py:class:: BaseModel(name='Base Model') + + + Base class for pybop models. + + .. py:property:: built_model + + + .. py:property:: geometry + + + .. py:property:: mesh + + + .. py:property:: model_with_set_params + + + .. py:property:: parameter_set + + + .. py:property:: solver + + + .. py:property:: spatial_methods + + + .. py:property:: submesh_types + + + .. py:property:: var_pts + + + .. py:method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) + + Build the PyBOP model (if not built already). + For PyBaMM forward models, this method follows a + similar process to pybamm.Simulation.build(). + + + .. py:method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) + + Create a PyBaMM simulation object, solve it, and return a solution object. + + + .. py:method:: set_init_soc(init_soc) + + Set the initial state of charge. + + + .. py:method:: set_params() + + Set the parameters in the model. + + + .. py:method:: simulate(inputs, t_eval) + + Run the forward model and return the result in Numpy array format + aligning with Pints' ForwardModel simulate method. + + + .. py:method:: simulateS1(inputs, t_eval) + + Run the forward model and return the function evaulation and it's gradient + aligning with Pints' ForwardModel simulateS1 method. diff --git a/docs/api/pybop/models/index.rst b/docs/api/pybop/models/index.rst new file mode 100644 index 00000000..6b06a221 --- /dev/null +++ b/docs/api/pybop/models/index.rst @@ -0,0 +1,22 @@ +:py:mod:`pybop.models` +====================== + +.. py:module:: pybop.models + + +Subpackages +----------- +.. toctree:: + :titlesonly: + :maxdepth: 3 + + lithium_ion/index.rst + + +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + + base_model/index.rst diff --git a/docs/api/pybop/models/lithium_ion/base_echem/index.rst b/docs/api/pybop/models/lithium_ion/base_echem/index.rst new file mode 100644 index 00000000..7348059d --- /dev/null +++ b/docs/api/pybop/models/lithium_ion/base_echem/index.rst @@ -0,0 +1,35 @@ +:py:mod:`pybop.models.lithium_ion.base_echem` +============================================= + +.. py:module:: pybop.models.lithium_ion.base_echem + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.models.lithium_ion.base_echem.SPM + pybop.models.lithium_ion.base_echem.SPMe + + + + +.. py:class:: SPM(name='Single Particle Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) + + + Bases: :py:obj:`pybop.models.base_model.BaseModel` + + Composition of the PyBaMM Single Particle Model class. + + + +.. py:class:: SPMe(name='Single Particle Model with Electrolyte', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) + + + Bases: :py:obj:`pybop.models.base_model.BaseModel` + + Composition of the PyBaMM Single Particle Model with Electrolyte class. diff --git a/docs/api/pybop/models/lithium_ion/index.rst b/docs/api/pybop/models/lithium_ion/index.rst new file mode 100644 index 00000000..dfc4c95b --- /dev/null +++ b/docs/api/pybop/models/lithium_ion/index.rst @@ -0,0 +1,44 @@ +:py:mod:`pybop.models.lithium_ion` +================================== + +.. py:module:: pybop.models.lithium_ion + + +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + + base_echem/index.rst + + +Package Contents +---------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.models.lithium_ion.SPM + pybop.models.lithium_ion.SPMe + + + + +.. py:class:: SPM(name='Single Particle Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) + + + Bases: :py:obj:`pybop.models.base_model.BaseModel` + + Composition of the PyBaMM Single Particle Model class. + + + +.. py:class:: SPMe(name='Single Particle Model with Electrolyte', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) + + + Bases: :py:obj:`pybop.models.base_model.BaseModel` + + Composition of the PyBaMM Single Particle Model with Electrolyte class. diff --git a/docs/api/pybop/optimisation/index.rst b/docs/api/pybop/optimisation/index.rst new file mode 100644 index 00000000..9fca5c5a --- /dev/null +++ b/docs/api/pybop/optimisation/index.rst @@ -0,0 +1,120 @@ +:py:mod:`pybop.optimisation` +============================ + +.. py:module:: pybop.optimisation + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.optimisation.Optimisation + + + + +.. py:class:: Optimisation(cost, optimiser=None, sigma0=None, verbose=False) + + + Optimisation class for PyBOP. + This class provides functionality for PyBOP optimisers and Pints optimisers. + :param cost: PyBOP cost function + :param optimiser: A PyBOP or Pints optimiser + :param sigma0: initial step size + :param verbose: print optimisation progress + + .. py:method:: _run_pints() + + Run method for PINTS optimisers. + This method is heavily based on the run method in the PINTS.OptimisationController class. + :returns: best parameters + final_cost: final cost + :rtype: x + + + .. py:method:: _run_pybop() + + Run method for PyBOP based optimisers. + :returns: best parameters + final_cost: final cost + :rtype: x + + + .. py:method:: f_guessed_tracking() + + Returns ``True`` if f_guessed instead of f_best is being tracked, + ``False`` otherwise. See also :meth:`set_f_guessed_tracking`. + + Credit: PINTS + + + .. py:method:: run() + + Run the optimisation algorithm. + Selects between PyBOP backend or Pints backend. + :returns: best parameters + final_cost: final cost + :rtype: x + + + .. py:method:: set_f_guessed_tracking(use_f_guessed=False) + + Sets the method used to track the optimiser progress to + :meth:`pints.Optimiser.f_guessed()` or + :meth:`pints.Optimiser.f_best()` (default). + + The tracked ``f`` value is used to evaluate stopping criteria. + + Credit: PINTS + + + .. py:method:: set_max_evaluations(evaluations=None) + + Adds a stopping criterion, allowing the routine to halt after the + given number of ``evaluations``. + + This criterion is disabled by default. To enable, pass in any positive + integer. To disable again, use ``set_max_evaluations(None)``. + + Credit: PINTS + + + .. py:method:: set_max_iterations(iterations=1000) + + Adds a stopping criterion, allowing the routine to halt after the + given number of ``iterations``. + + This criterion is enabled by default. To disable it, use + ``set_max_iterations(None)``. + + Credit: PINTS + + + .. py:method:: set_max_unchanged_iterations(iterations=25, threshold=1e-05) + + Adds a stopping criterion, allowing the routine to halt if the + objective function doesn't change by more than ``threshold`` for the + given number of ``iterations``. + + This criterion is enabled by default. To disable it, use + ``set_max_unchanged_iterations(None)``. + + Credit: PINTS + + + .. py:method:: set_parallel(parallel=False) + + Enables/disables parallel evaluation. + + If ``parallel=True``, the method will run using a number of worker + processes equal to the detected cpu core count. The number of workers + can be set explicitly by setting ``parallel`` to an integer greater + than 0. + Parallelisation can be disabled by setting ``parallel`` to ``0`` or + ``False``. + + Credit: PINTS diff --git a/docs/api/pybop/optimisers/base_optimiser/index.rst b/docs/api/pybop/optimisers/base_optimiser/index.rst new file mode 100644 index 00000000..932d64b5 --- /dev/null +++ b/docs/api/pybop/optimisers/base_optimiser/index.rst @@ -0,0 +1,39 @@ +:py:mod:`pybop.optimisers.base_optimiser` +========================================= + +.. py:module:: pybop.optimisers.base_optimiser + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.optimisers.base_optimiser.BaseOptimiser + + + + +.. py:class:: BaseOptimiser + + + Base class for the optimisation methods. + + + .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) + + Run optimisation method, to be overloaded by child classes. + + + + .. py:method:: name() + + Returns the name of the optimiser. + + + .. py:method:: optimise(cost_function, x0=None, bounds=None) + + Optimisiation method to be overloaded by child classes. diff --git a/docs/api/pybop/optimisers/index.rst b/docs/api/pybop/optimisers/index.rst new file mode 100644 index 00000000..1524300b --- /dev/null +++ b/docs/api/pybop/optimisers/index.rst @@ -0,0 +1,16 @@ +:py:mod:`pybop.optimisers` +========================== + +.. py:module:: pybop.optimisers + + +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + + base_optimiser/index.rst + nlopt_optimize/index.rst + pints_optimisers/index.rst + scipy_minimize/index.rst diff --git a/docs/api/pybop/optimisers/nlopt_optimize/index.rst b/docs/api/pybop/optimisers/nlopt_optimize/index.rst new file mode 100644 index 00000000..b210161f --- /dev/null +++ b/docs/api/pybop/optimisers/nlopt_optimize/index.rst @@ -0,0 +1,46 @@ +:py:mod:`pybop.optimisers.nlopt_optimize` +========================================= + +.. py:module:: pybop.optimisers.nlopt_optimize + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.optimisers.nlopt_optimize.NLoptOptimize + + + + +.. py:class:: NLoptOptimize(n_param, xtol=None, method=None) + + + Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` + + Wrapper class for the NLOpt optimiser class. Extends the BaseOptimiser class. + + .. py:method:: _runoptimise(cost_function, x0, bounds) + + Run the NLOpt optimisation method. + + Inputs + ---------- + cost_function: function for optimising + method: optimisation algorithm + x0: initialisation array + bounds: bounds array + + + .. py:method:: name() + + Returns the name of the optimiser. + + + .. py:method:: needs_sensitivities() + + Returns True if the optimiser needs sensitivities. diff --git a/docs/api/pybop/optimisers/pints_optimisers/index.rst b/docs/api/pybop/optimisers/pints_optimisers/index.rst new file mode 100644 index 00000000..68226d79 --- /dev/null +++ b/docs/api/pybop/optimisers/pints_optimisers/index.rst @@ -0,0 +1,86 @@ +:py:mod:`pybop.optimisers.pints_optimisers` +=========================================== + +.. py:module:: pybop.optimisers.pints_optimisers + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.optimisers.pints_optimisers.Adam + pybop.optimisers.pints_optimisers.CMAES + pybop.optimisers.pints_optimisers.GradientDescent + pybop.optimisers.pints_optimisers.IRPropMin + pybop.optimisers.pints_optimisers.PSO + pybop.optimisers.pints_optimisers.SNES + pybop.optimisers.pints_optimisers.XNES + + + + +.. py:class:: Adam(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.Adam` + + Adam optimiser. Inherits from the PINTS Adam class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_adam.py + + +.. py:class:: CMAES(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.CMAES` + + Class for the PINTS optimisation. Extends the BaseOptimiser class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_cmaes.py + + +.. py:class:: GradientDescent(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.GradientDescent` + + Gradient descent optimiser. Inherits from the PINTS gradient descent class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_gradient_descent.py + + +.. py:class:: IRPropMin(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.IRPropMin` + + IRProp- optimiser. Inherits from the PINTS IRPropMinus class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_irpropmin.py + + +.. py:class:: PSO(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.PSO` + + Particle swarm optimiser. Inherits from the PINTS PSO class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_pso.py + + +.. py:class:: SNES(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.SNES` + + Stochastic natural evolution strategy optimiser. Inherits from the PINTS SNES class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_snes.py + + +.. py:class:: XNES(x0, sigma0=0.1, bounds=None) + + + Bases: :py:obj:`pints.XNES` + + Exponential natural evolution strategy optimiser. Inherits from the PINTS XNES class. + https://github.com/pints-team/pints/blob/main/pints/_optimisers/_xnes.py diff --git a/docs/api/pybop/optimisers/scipy_minimize/index.rst b/docs/api/pybop/optimisers/scipy_minimize/index.rst new file mode 100644 index 00000000..4d8c9233 --- /dev/null +++ b/docs/api/pybop/optimisers/scipy_minimize/index.rst @@ -0,0 +1,46 @@ +:py:mod:`pybop.optimisers.scipy_minimize` +========================================= + +.. py:module:: pybop.optimisers.scipy_minimize + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.optimisers.scipy_minimize.SciPyMinimize + + + + +.. py:class:: SciPyMinimize(method=None, bounds=None) + + + Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` + + Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. + + .. py:method:: _runoptimise(cost_function, x0, bounds) + + Run the SciPy optimisation method. + + Inputs + ---------- + cost_function: function for optimising + method: optimisation algorithm + x0: initialisation array + bounds: bounds array + + + .. py:method:: name() + + Returns the name of the optimiser. + + + .. py:method:: needs_sensitivities() + + Returns True if the optimiser needs sensitivities. diff --git a/docs/api/pybop/parameters/base_parameter/index.rst b/docs/api/pybop/parameters/base_parameter/index.rst new file mode 100644 index 00000000..5bad5034 --- /dev/null +++ b/docs/api/pybop/parameters/base_parameter/index.rst @@ -0,0 +1,41 @@ +:py:mod:`pybop.parameters.base_parameter` +========================================= + +.. py:module:: pybop.parameters.base_parameter + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.parameters.base_parameter.Parameter + + + + +.. py:class:: Parameter(name, value=None, prior=None, bounds=None) + + + "" + Class for creating parameters in PyBOP. + + .. py:method:: __repr__() + + Return repr(self). + + + .. py:method:: rvs(n_samples) + + Returns a random value sample from the prior distribution. + + + .. py:method:: set_margin(margin) + + Sets the margin for the parameter. + + + .. py:method:: update(value) diff --git a/docs/api/pybop/parameters/base_parameter_set/index.rst b/docs/api/pybop/parameters/base_parameter_set/index.rst new file mode 100644 index 00000000..d63a2ef0 --- /dev/null +++ b/docs/api/pybop/parameters/base_parameter_set/index.rst @@ -0,0 +1,23 @@ +:py:mod:`pybop.parameters.base_parameter_set` +============================================= + +.. py:module:: pybop.parameters.base_parameter_set + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.parameters.base_parameter_set.ParameterSet + + + + +.. py:class:: ParameterSet + + + Class for creating parameter sets in PyBOP. diff --git a/docs/api/pybop/parameters/index.rst b/docs/api/pybop/parameters/index.rst new file mode 100644 index 00000000..43843f7f --- /dev/null +++ b/docs/api/pybop/parameters/index.rst @@ -0,0 +1,15 @@ +:py:mod:`pybop.parameters` +========================== + +.. py:module:: pybop.parameters + + +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + + base_parameter/index.rst + base_parameter_set/index.rst + priors/index.rst diff --git a/docs/api/pybop/parameters/priors/index.rst b/docs/api/pybop/parameters/priors/index.rst new file mode 100644 index 00000000..05f5b79e --- /dev/null +++ b/docs/api/pybop/parameters/priors/index.rst @@ -0,0 +1,78 @@ +:py:mod:`pybop.parameters.priors` +================================= + +.. py:module:: pybop.parameters.priors + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.parameters.priors.Exponential + pybop.parameters.priors.Gaussian + pybop.parameters.priors.Uniform + + + + +.. py:class:: Exponential(scale) + + + Exponential prior class. + + .. py:method:: __repr__() + + Return repr(self). + + + .. py:method:: logpdf(x) + + + .. py:method:: pdf(x) + + + .. py:method:: rvs(size) + + + +.. py:class:: Gaussian(mean, sigma) + + + Gaussian prior class. + + .. py:method:: __repr__() + + Return repr(self). + + + .. py:method:: logpdf(x) + + + .. py:method:: pdf(x) + + + .. py:method:: rvs(size) + + + +.. py:class:: Uniform(lower, upper) + + + Uniform prior class. + + .. py:method:: __repr__() + + Return repr(self). + + + .. py:method:: logpdf(x) + + + .. py:method:: pdf(x) + + + .. py:method:: rvs(size) diff --git a/docs/api/pybop/plotting/index.rst b/docs/api/pybop/plotting/index.rst new file mode 100644 index 00000000..3bbad489 --- /dev/null +++ b/docs/api/pybop/plotting/index.rst @@ -0,0 +1,17 @@ +:py:mod:`pybop.plotting` +======================== + +.. py:module:: pybop.plotting + + +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + + plot_convergence/index.rst + plot_cost2d/index.rst + plot_parameters/index.rst + plotly_manager/index.rst + quick_plot/index.rst diff --git a/docs/api/pybop/plotting/plot_convergence/index.rst b/docs/api/pybop/plotting/plot_convergence/index.rst new file mode 100644 index 00000000..52e9e8c3 --- /dev/null +++ b/docs/api/pybop/plotting/plot_convergence/index.rst @@ -0,0 +1,38 @@ +:py:mod:`pybop.plotting.plot_convergence` +========================================= + +.. py:module:: pybop.plotting.plot_convergence + + +Module Contents +--------------- + + +Functions +~~~~~~~~~ + +.. autoapisummary:: + + pybop.plotting.plot_convergence.plot_convergence + + + +.. py:function:: plot_convergence(optim, xaxis_title='Iteration', yaxis_title='Cost', title='Convergence') + + Plot the convergence of the optimisation algorithm. + + Parameters: + ---------- + optim : optimisation object + Optimisation object containing the cost function and optimiser. + xaxis_title : str, optional + Title for the x-axis (default is "Iteration"). + yaxis_title : str, optional + Title for the y-axis (default is "Cost"). + title : str, optional + Title of the plot (default is "Convergence"). + + Returns: + ------- + fig : plotly.graph_objs.Figure + The Plotly figure object for the convergence plot. diff --git a/docs/api/pybop/plotting/plot_cost2d/index.rst b/docs/api/pybop/plotting/plot_cost2d/index.rst new file mode 100644 index 00000000..31ee744b --- /dev/null +++ b/docs/api/pybop/plotting/plot_cost2d/index.rst @@ -0,0 +1,32 @@ +:py:mod:`pybop.plotting.plot_cost2d` +==================================== + +.. py:module:: pybop.plotting.plot_cost2d + + +Module Contents +--------------- + + +Functions +~~~~~~~~~ + +.. autoapisummary:: + + pybop.plotting.plot_cost2d.create_figure + pybop.plotting.plot_cost2d.get_param_bounds + pybop.plotting.plot_cost2d.plot_cost2d + + + +.. py:function:: create_figure(x, y, z, bounds, params, optim) + + +.. py:function:: get_param_bounds(cost) + + Use parameters bounds for range of cost landscape + + +.. py:function:: plot_cost2d(cost, bounds=None, optim=None, steps=10) + + Query the cost landscape for a given parameter space and plot using plotly. diff --git a/docs/api/pybop/plotting/plot_parameters/index.rst b/docs/api/pybop/plotting/plot_parameters/index.rst new file mode 100644 index 00000000..07b660ca --- /dev/null +++ b/docs/api/pybop/plotting/plot_parameters/index.rst @@ -0,0 +1,89 @@ +:py:mod:`pybop.plotting.plot_parameters` +======================================== + +.. py:module:: pybop.plotting.plot_parameters + + +Module Contents +--------------- + + +Functions +~~~~~~~~~ + +.. autoapisummary:: + + pybop.plotting.plot_parameters.create_subplots_with_traces + pybop.plotting.plot_parameters.create_traces + pybop.plotting.plot_parameters.plot_parameters + + + +.. py:function:: create_subplots_with_traces(traces, plot_size=(1024, 576), title='Parameter Convergence', axis_titles=None, **layout_kwargs) + + Creates a subplot figure with the given traces. + + :param traces: List of plotly.graph_objs traces that will be added to the subplots. + :param plot_size: Tuple (width, height) representing the desired size of the plot. + :param title: The main title of the subplot figure. + :param axis_titles: List of tuples for axis titles in the form [(x_title, y_title), ...] for each subplot. + :param layout_kwargs: Additional keyword arguments to be passed to fig.update_layout for custom layout. + :return: A plotly figure object with the subplots. + + +.. py:function:: create_traces(params, trace_data, x_values=None) + + Generate a list of Plotly Scatter trace objects from provided trace data. + + This function assumes that each column in the ``trace_data`` represents a separate trace to be plotted, + and that the ``params`` list contains objects with a ``name`` attribute used for trace names. + Text wrapping for trace names is performed by ``pybop.StandardPlot.wrap_text``. + + Parameters: + - params (list): A list of objects, where each object has a ``name`` attribute used as the trace name. + The list should have the same length as the number of traces in ``trace_data``. + - trace_data (list of lists): A 2D list where each inner list represents y-values for a trace. + - x_values (list, optional): A list of x-values to be used for all traces. If not provided, a + range of integers starting from 0 will be used. + + Returns: + - list: A list of Plotly ``go.Scatter`` objects, each representing a trace to be plotted. + + Notes: + - The function depends on ``pybop.StandardPlot.wrap_text`` for text wrapping, which needs to be available + in the execution context. + - The function assumes that ``go`` from ``plotly.graph_objs`` is already imported as ``go``. + + +.. py:function:: plot_parameters(optim, xaxis_titles='Iteration', yaxis_titles=None, title='Convergence') + + Plot the evolution of the parameters during the optimisation process. + + Parameters: + ---------- + optim : optimisation object + An object representing the optimisation process, which should contain + information about the cost function, optimiser, and the history of the + parameter values throughout the iterations. + xaxis_title : str, optional + Title for the x-axis, representing the iteration number or a similar + discrete time step in the optimisation process (default is "Iteration"). + yaxis_title : str, optional + Title for the y-axis, which typically represents the metric being + optimised, such as cost or loss (default is "Cost"). + title : str, optional + Title of the plot, which provides an overall description of what the + plot represents (default is "Convergence"). + + Returns: + ------- + fig : plotly.graph_objs.Figure + The Plotly figure object for the plot depicting how the parameters of + the optimisation algorithm evolve over its course. This can be useful + for diagnosing the behaviour of the optimisation algorithm. + + Notes: + ----- + The function assumes that the 'optim' object has a 'cost.problem.parameters' + attribute containing the parameters of the optimisation algorithm and a 'log' + attribute containing a history of the iterations. diff --git a/docs/api/pybop/plotting/plotly_manager/index.rst b/docs/api/pybop/plotting/plotly_manager/index.rst new file mode 100644 index 00000000..076d6541 --- /dev/null +++ b/docs/api/pybop/plotting/plotly_manager/index.rst @@ -0,0 +1,87 @@ +:py:mod:`pybop.plotting.plotly_manager` +======================================= + +.. py:module:: pybop.plotting.plotly_manager + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.plotting.plotly_manager.PlotlyManager + + + + +.. py:class:: PlotlyManager + + + Manages the installation and configuration of Plotly for generating visualisations. + + This class checks if Plotly is installed and, if not, prompts the user to install it. + It also ensures that the Plotly renderer and browser settings are properly configured + to display plots. + + .. method:: ``ensure_plotly_installed`` + + Verifies if Plotly is installed and installs it if necessary. + + .. method:: ``prompt_for_plotly_installation`` + + Prompts the user for permission to install Plotly. + + .. method:: ``install_plotly_package`` + + Installs the Plotly package using pip. + + .. method:: ``post_install_setup`` + + Sets up Plotly default renderer after installation. + + .. method:: ``check_renderer_settings`` + + Verifies that the Plotly renderer is correctly set. + + .. method:: ``check_browser_availability`` + + Checks if a web browser is available for rendering plots. + + + Usage: + Instantiate the PlotlyManager class to automatically ensure Plotly is installed + and configured correctly when creating an instance. + Example: + plotly_manager = PlotlyManager() + + .. py:method:: check_browser_availability() + + Ensures a web browser is available for rendering plots with the 'browser' renderer and provides guidance if not. + + + .. py:method:: check_renderer_settings() + + Checks if the Plotly renderer is set and provides information on how to set it if empty. + + + .. py:method:: ensure_plotly_installed() + + Verifies if Plotly is installed, importing necessary modules and prompting for installation if missing. + + + .. py:method:: install_plotly() + + Attempts to install the Plotly package using pip and exits if installation fails. + + + .. py:method:: post_install_setup() + + After successful installation, imports Plotly and sets the default renderer if necessary. + + + .. py:method:: prompt_for_plotly_installation() + + Prompts the user for permission to install Plotly and proceeds with installation if consented. diff --git a/docs/api/pybop/plotting/quick_plot/index.rst b/docs/api/pybop/plotting/quick_plot/index.rst new file mode 100644 index 00000000..08165d30 --- /dev/null +++ b/docs/api/pybop/plotting/quick_plot/index.rst @@ -0,0 +1,137 @@ +:py:mod:`pybop.plotting.quick_plot` +=================================== + +.. py:module:: pybop.plotting.quick_plot + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.plotting.quick_plot.StandardPlot + + + +Functions +~~~~~~~~~ + +.. autoapisummary:: + + pybop.plotting.quick_plot.quick_plot + + + +.. py:class:: StandardPlot(x, y, cost, y2=None, title=None, xaxis_title=None, yaxis_title=None, trace_name=None, width=1024, height=576) + + + A class for creating and displaying a plotly figure that compares a target dataset against a simulated model output. + + This class provides an interface for generating interactive plots using Plotly, with the ability to include an + optional secondary dataset and visualize uncertainty if provided. + + Attributes: + ----------- + x : list + The x-axis data points. + y : list or np.ndarray + The primary y-axis data points representing the simulated model output. + y2 : list or np.ndarray, optional + An optional secondary y-axis data points representing the target dataset against which the model output is compared. + cost : float + The cost associated with the model output. + title : str, optional + The title of the plot. + xaxis_title : str, optional + The title for the x-axis. + yaxis_title : str, optional + The title for the y-axis. + trace_name : str, optional + The name of the primary trace representing the model output. Defaults to "Simulated". + width : int, optional + The width of the figure in pixels. Defaults to 720. + height : int, optional + The height of the figure in pixels. Defaults to 540. + + Methods: + -------- + wrap_text(text, width) + A static method that wraps text to a specified width, inserting HTML line breaks for use in plot labels. + + create_layout() + Creates the layout for the plot, including titles and axis labels. + + create_traces() + Creates the traces for the plot, including the primary dataset, optional secondary dataset, and an optional uncertainty visualization. + + __call__() + Generates the plotly figure when the class instance is called as a function. + + Example: + -------- + >>> x_data = [1, 2, 3, 4] + >>> y_simulated = [10, 15, 13, 17] + >>> y_target = [11, 14, 12, 16] + >>> plot = pybop.StandardPlot(x_data, y_simulated, cost=0.05, y2=y_target, + title="Model vs. Target", xaxis_title="X Axis", yaxis_title="Y Axis") + >>> fig = plot() # Generate the figure + >>> fig.show() # Display the figure in a browser + + .. py:method:: __call__() + + Generate the plotly figure. + + + .. py:method:: create_layout() + + Create the layout for the plot. + + + .. py:method:: create_traces() + + Create the traces for the plot. + + + .. py:method:: wrap_text(text, width) + :staticmethod: + + Wrap text to a specified width. + + Parameters: + ----------- + text: str + Text to be wrapped. + width: int + Width to wrap text to. + + Returns: + -------- + str + Wrapped text with HTML line breaks. + + + +.. py:function:: quick_plot(params, cost, title='Scatter Plot', width=1024, height=576) + + Plot the target dataset against the minimised model output. + + Parameters: + ---------- + params : array-like + Optimised parameters. + cost : cost object + Cost object containing the problem, dataset, and signal. + title : str, optional + Title of the plot (default is "Scatter Plot"). + width : int, optional + Width of the figure in pixels (default is 720). + height : int, optional + Height of the figure in pixels (default is 540). + + Returns: + ------- + fig : plotly.graph_objs.Figure + The Plotly figure object for the scatter plot. diff --git a/docs/api/pybop/version/index.rst b/docs/api/pybop/version/index.rst new file mode 100644 index 00000000..bf5679ef --- /dev/null +++ b/docs/api/pybop/version/index.rst @@ -0,0 +1,11 @@ +:py:mod:`pybop.version` +======================= + +.. py:module:: pybop.version + + +Module Contents +--------------- + +.. py:data:: __version__ + :value: '23.11' diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..c1145409 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,78 @@ +# Configuration file for the Sphinx documentation builder. + + +# -- Path setup -------------------------------------------------------------- +import sys +from pathlib import Path + +sys.path.append(str(Path(".").resolve())) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "PyBOP" +copyright = "2023, The PyBOP Team" +author = "The PyBOP Team" +release = "v23.11" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.todo", + "sphinx.ext.viewcode", + "sphinxext.rediraffe", + "sphinx_design", + "sphinx_copybutton", + "autoapi.extension", + # custom extentions + "_extension.gallery_directive", + # "_extension.component_directive", + # For extension examples and demos + "myst_parser", + # "ablog", + # "jupyter_sphinx", + # "sphinxcontrib.youtube", + # "nbsphinx", + # "numpydoc", + # "sphinx_togglebutton", + # "jupyterlite_sphinx", + # "sphinx_favicon", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# -- Options for autoapi ------------------------------------------------------- +autoapi_type = "python" +autoapi_dirs = ["../pybop"] +autoapi_keep_files = True +autoapi_root = "api" +autoapi_member_order = "groupwise" + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +# Set html_theme +html_theme = "pydata_sphinx_theme" +html_show_sourcelink = False +html_title = "PyBOP Documentation" + +# html_theme options +html_theme_options = { + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/pybop-team/pybop", + "icon": "fab fa-github-square", + }, + # other icon links + ], + "search_bar_text": "Search the docs...", + # other options +} + +html_static_path = ["_static"] diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..78caf34e --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,2 @@ +```{include} ../CONTRIBUTING.md +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..fd8a0ddc --- /dev/null +++ b/docs/index.md @@ -0,0 +1,35 @@ +--- +myst: + html_meta: + "description lang=en": | + High-level documentation for PyBOP, and corresponding links to the site. +html_theme.sidebar_secondary.remove: true +--- + +# The PyBOP Package + +A Python package for battery model optimisation and parameterisation. + +Version: v23.11 + +```{gallery-grid} +:grid-columns: 1 2 2 2 + +- header: "{fab}`bootstrap;pst-color-primary` API Documentation" + content: "Get detailed information on functions, classes, and modules that allow you to fully leverage the power of PyBOP in your own projects." + link: "api/index.html" +- header: "{fas}`bolt;pst-color-primary` Installation" + content: "Setting up PyBOP is straightforward. Follow our step-by-step guide to install PyBOP on your system." +- header: "{fas}`circle-half-stroke;pst-color-primary` Usage" + content: "Discover how to use PyBOP effectively with our usage examples. From basic tasks to advanced features, learn how to solve real-world problems with PyBOP." +- header: "{fab}`python;pst-color-primary` Contributing" + content: "Contribute to the PyBOP project and become a part of our growing community." + link: "contributing.html" +``` + +```{toctree} +:maxdepth: 2 +:hidden: + +contributing +``` diff --git a/docs/make.bat b/docs/make.bat index 747ffb7b..32bb2452 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -7,8 +7,8 @@ REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) -set SOURCEDIR=source -set BUILDDIR=build +set SOURCEDIR=. +set BUILDDIR=_build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index b5be5c25..00000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,28 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -project = 'PyBOP' -copyright = '2023, Brady Planden, Nicola Courtier, David Howey' -author = 'Brady Planden, Nicola Courtier, David Howey' -release = '23.9' - -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - -extensions = [] - -templates_path = ['_templates'] -exclude_patterns = [] - - - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -html_theme = 'alabaster' -html_static_path = ['_static'] diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index d84fc8c6..00000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,20 +0,0 @@ -.. PyBOP documentation master file, created by - sphinx-quickstart on Fri Oct 27 09:54:56 2023. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to PyBOP's documentation! -================================= - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 00000000..dfe70a82 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,7 @@ +pybop +===== + +.. toctree:: + :maxdepth: 4 + + pybop diff --git a/docs/source/pybop.costs.rst b/docs/source/pybop.costs.rst new file mode 100644 index 00000000..ea854a29 --- /dev/null +++ b/docs/source/pybop.costs.rst @@ -0,0 +1,29 @@ +Costs +=================== + +Submodules +---------- + +pybop.costs.error\_costs module +------------------------------- + +.. automodule:: pybop.costs.error_costs + :members: + :undoc-members: + :show-inheritance: + +pybop.costs.standalone module +----------------------------- + +.. automodule:: pybop.costs.standalone + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pybop.costs + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pybop.datasets.rst b/docs/source/pybop.datasets.rst new file mode 100644 index 00000000..27399b6e --- /dev/null +++ b/docs/source/pybop.datasets.rst @@ -0,0 +1,21 @@ +pybop.datasets package +====================== + +Submodules +---------- + +pybop.datasets.base\_dataset module +----------------------------------- + +.. automodule:: pybop.datasets.base_dataset + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pybop.datasets + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pybop.optimisers.rst b/docs/source/pybop.optimisers.rst new file mode 100644 index 00000000..f134f257 --- /dev/null +++ b/docs/source/pybop.optimisers.rst @@ -0,0 +1,45 @@ +pybop.optimisers package +======================== + +Submodules +---------- + +pybop.optimisers.base\_optimiser module +--------------------------------------- + +.. automodule:: pybop.optimisers.base_optimiser + :members: + :undoc-members: + :show-inheritance: + +pybop.optimisers.nlopt\_optimize module +--------------------------------------- + +.. automodule:: pybop.optimisers.nlopt_optimize + :members: + :undoc-members: + :show-inheritance: + +pybop.optimisers.pints\_optimisers module +----------------------------------------- + +.. automodule:: pybop.optimisers.pints_optimisers + :members: + :undoc-members: + :show-inheritance: + +pybop.optimisers.scipy\_minimize module +--------------------------------------- + +.. automodule:: pybop.optimisers.scipy_minimize + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pybop.optimisers + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pybop.parameters.rst b/docs/source/pybop.parameters.rst new file mode 100644 index 00000000..278ad82f --- /dev/null +++ b/docs/source/pybop.parameters.rst @@ -0,0 +1,37 @@ +pybop.parameters package +======================== + +Submodules +---------- + +pybop.parameters.base\_parameter module +--------------------------------------- + +.. automodule:: pybop.parameters.base_parameter + :members: + :undoc-members: + :show-inheritance: + +pybop.parameters.base\_parameter\_set module +-------------------------------------------- + +.. automodule:: pybop.parameters.base_parameter_set + :members: + :undoc-members: + :show-inheritance: + +pybop.parameters.priors module +------------------------------ + +.. automodule:: pybop.parameters.priors + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pybop.parameters + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pybop.plotting.rst b/docs/source/pybop.plotting.rst new file mode 100644 index 00000000..21064bb0 --- /dev/null +++ b/docs/source/pybop.plotting.rst @@ -0,0 +1,53 @@ +pybop.plotting package +====================== + +Submodules +---------- + +pybop.plotting.plot\_convergence module +--------------------------------------- + +.. automodule:: pybop.plotting.plot_convergence + :members: + :undoc-members: + :show-inheritance: + +pybop.plotting.plot\_cost2d module +---------------------------------- + +.. automodule:: pybop.plotting.plot_cost2d + :members: + :undoc-members: + :show-inheritance: + +pybop.plotting.plot\_parameters module +-------------------------------------- + +.. automodule:: pybop.plotting.plot_parameters + :members: + :undoc-members: + :show-inheritance: + +pybop.plotting.plotly\_manager module +------------------------------------- + +.. automodule:: pybop.plotting.plotly_manager + :members: + :undoc-members: + :show-inheritance: + +pybop.plotting.quick\_plot module +--------------------------------- + +.. automodule:: pybop.plotting.quick_plot + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: pybop.plotting + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/pybop.rst b/docs/source/pybop.rst new file mode 100644 index 00000000..d2ef17f3 --- /dev/null +++ b/docs/source/pybop.rst @@ -0,0 +1,42 @@ +pybop package +============= + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + pybop.costs + pybop.datasets + pybop.models + pybop.optimisers + pybop.parameters + pybop.plotting + +Submodules +---------- + +pybop.optimisation module +------------------------- + +.. automodule:: pybop.optimisation + :members: + :undoc-members: + :show-inheritance: + +.. pybop.version module +.. -------------------- + +.. .. automodule:: pybop.version +.. :members: +.. :undoc-members: +.. :show-inheritance: + +.. Module contents +.. --------------- + +.. .. automodule:: pybop +.. :members: +.. :undoc-members: +.. :show-inheritance: diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py index 9f4bbc02..e94902bc 100644 --- a/pybop/plotting/plot_parameters.py +++ b/pybop/plotting/plot_parameters.py @@ -66,24 +66,24 @@ def create_traces(params, trace_data, x_values=None): """ Generate a list of Plotly Scatter trace objects from provided trace data. - This function assumes that each column in the `trace_data` represents a separate trace to be plotted, - and that the `params` list contains objects with a `name` attribute used for trace names. - Text wrapping for trace names is performed by `pybop.StandardPlot.wrap_text`. + This function assumes that each column in the ``trace_data`` represents a separate trace to be plotted, + and that the ``params`` list contains objects with a ``name`` attribute used for trace names. + Text wrapping for trace names is performed by ``pybop.StandardPlot.wrap_text``. Parameters: - - params (list): A list of objects, where each object has a `name` attribute used as the trace name. - The list should have the same length as the number of traces in `trace_data`. + - params (list): A list of objects, where each object has a ``name`` attribute used as the trace name. + The list should have the same length as the number of traces in ``trace_data``. - trace_data (list of lists): A 2D list where each inner list represents y-values for a trace. - x_values (list, optional): A list of x-values to be used for all traces. If not provided, a range of integers starting from 0 will be used. Returns: - - list: A list of Plotly `go.Scatter` objects, each representing a trace to be plotted. + - list: A list of Plotly ``go.Scatter`` objects, each representing a trace to be plotted. Notes: - - The function depends on `pybop.StandardPlot.wrap_text` for text wrapping, which needs to be available + - The function depends on ``pybop.StandardPlot.wrap_text`` for text wrapping, which needs to be available in the execution context. - - The function assumes that `go` from `plotly.graph_objs` is already imported as `go`. + - The function assumes that ``go`` from ``plotly.graph_objs`` is already imported as ``go``. """ # Attempt to import plotly when an instance is created diff --git a/pybop/plotting/plotly_manager.py b/pybop/plotting/plotly_manager.py index 2384f299..692b44bc 100644 --- a/pybop/plotting/plotly_manager.py +++ b/pybop/plotting/plotly_manager.py @@ -12,12 +12,12 @@ class PlotlyManager: to display plots. Methods: - `ensure_plotly_installed`: Verifies if Plotly is installed and installs it if necessary. - `prompt_for_plotly_installation`: Prompts the user for permission to install Plotly. - `install_plotly_package`: Installs the Plotly package using pip. - `post_install_setup`: Sets up Plotly default renderer after installation. - `check_renderer_settings`: Verifies that the Plotly renderer is correctly set. - `check_browser_availability`: Checks if a web browser is available for rendering plots. + ``ensure_plotly_installed``: Verifies if Plotly is installed and installs it if necessary. + ``prompt_for_plotly_installation``: Prompts the user for permission to install Plotly. + ``install_plotly_package``: Installs the Plotly package using pip. + ``post_install_setup``: Sets up Plotly default renderer after installation. + ``check_renderer_settings``: Verifies that the Plotly renderer is correctly set. + ``check_browser_availability``: Checks if a web browser is available for rendering plots. Usage: Instantiate the PlotlyManager class to automatically ensure Plotly is installed From 4947adced161ccd9141af8388cc5c9557fb78764 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 8 Dec 2023 13:12:11 +0000 Subject: [PATCH 065/101] Add logging --- pybop/optimisers/scipy_optimisers.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 0e91326d..c5fe0d8e 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -27,14 +27,21 @@ def _runoptimise(self, cost_function, x0, bounds): bounds: bounds array """ + # Add callback storing history of parameter values + self.log = [[x0]] + + def callback(x): + self.log.append([x]) + + # Reformat bounds if bounds is not None: - # Reformat bounds and run the optimser bounds = ( (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) ) - output = minimize(cost_function, x0, method=self.method, bounds=bounds) - else: - output = minimize(cost_function, x0, method=self.method) + + output = minimize( + cost_function, x0, method=self.method, bounds=bounds, callback=callback + ) # Get performance statistics x = output.x @@ -91,6 +98,12 @@ def _runoptimise(self, cost_function, x0=None, bounds=None): "Ignoring x0. Initial conditions are not used for differential_evolution." ) + # Add callback storing history of parameter values + self.log = [] + + def callback(x, convergence): + self.log.append([x]) + # Reformat bounds if necessary if isinstance(bounds, dict): bounds = [ @@ -103,6 +116,7 @@ def _runoptimise(self, cost_function, x0=None, bounds=None): strategy=self.strategy, maxiter=self.maxiter, popsize=self.popsize, + callback=callback, ) # Get performance statistics From 3134b3deb4bc647a4a9e31a3c896e1773498bb69 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 8 Dec 2023 14:10:46 +0000 Subject: [PATCH 066/101] Adds User Guide, Installation, and Quick Start --- docs/api/pybop/index.rst | 93 +++++++++---------- .../pybop/plotting/plot_convergence/index.rst | 2 +- docs/api/pybop/plotting/plot_cost2d/index.rst | 24 ++++- .../pybop/plotting/plot_parameters/index.rst | 6 +- .../pybop/plotting/plotly_manager/index.rst | 39 +++----- docs/api/pybop/plotting/quick_plot/index.rst | 22 +---- docs/conf.py | 13 +-- docs/index.md | 9 +- docs/user_guide/index.md | 22 +++++ docs/user_guide/installation.rst | 67 +++++++++++++ docs/user_guide/usage.rst | 61 ++++++++++++ pybop/plotting/plot_convergence.py | 2 +- pybop/plotting/plot_cost2d.py | 24 ++++- pybop/plotting/plot_parameters.py | 6 +- pybop/plotting/plotly_manager.py | 20 ++-- pybop/plotting/quick_plot.py | 22 +---- 16 files changed, 284 insertions(+), 148 deletions(-) create mode 100644 docs/user_guide/index.md create mode 100644 docs/user_guide/installation.rst create mode 100644 docs/user_guide/usage.rst diff --git a/docs/api/pybop/index.rst b/docs/api/pybop/index.rst index 7b0272b1..075983ac 100644 --- a/docs/api/pybop/index.rst +++ b/docs/api/pybop/index.rst @@ -513,36 +513,19 @@ Attributes It also ensures that the Plotly renderer and browser settings are properly configured to display plots. - .. method:: ``ensure_plotly_installed`` - - Verifies if Plotly is installed and installs it if necessary. - - .. method:: ``prompt_for_plotly_installation`` - - Prompts the user for permission to install Plotly. - - .. method:: ``install_plotly_package`` - - Installs the Plotly package using pip. - - .. method:: ``post_install_setup`` - - Sets up Plotly default renderer after installation. - - .. method:: ``check_renderer_settings`` - - Verifies that the Plotly renderer is correctly set. - - .. method:: ``check_browser_availability`` - - Checks if a web browser is available for rendering plots. - + Methods: + ``ensure_plotly_installed``: Verifies if Plotly is installed and installs it if necessary. + ``prompt_for_plotly_installation``: Prompts the user for permission to install Plotly. + ``install_plotly_package``: Installs the Plotly package using pip. + ``post_install_setup``: Sets up Plotly default renderer after installation. + ``check_renderer_settings``: Verifies that the Plotly renderer is correctly set. + ``check_browser_availability``: Checks if a web browser is available for rendering plots. Usage: - Instantiate the PlotlyManager class to automatically ensure Plotly is installed - and configured correctly when creating an instance. - Example: - plotly_manager = PlotlyManager() + Instantiate the PlotlyManager class to automatically ensure Plotly is installed + and configured correctly when creating an instance. + Example: + plotly_manager = PlotlyManager() .. py:method:: check_browser_availability() @@ -658,22 +641,8 @@ Attributes height : int, optional The height of the figure in pixels. Defaults to 540. - Methods: - -------- - wrap_text(text, width) - A static method that wraps text to a specified width, inserting HTML line breaks for use in plot labels. - - create_layout() - Creates the layout for the plot, including titles and axis labels. - - create_traces() - Creates the traces for the plot, including the primary dataset, optional secondary dataset, and an optional uncertainty visualization. - - __call__() - Generates the plotly figure when the class instance is called as a function. - Example: - -------- + ---------- >>> x_data = [1, 2, 3, 4] >>> y_simulated = [10, 15, 13, 17] >>> y_target = [11, 14, 12, 16] @@ -710,7 +679,7 @@ Attributes Width to wrap text to. Returns: - -------- + ---------- str Wrapped text with HTML line breaks. @@ -788,14 +757,36 @@ Attributes Title of the plot (default is "Convergence"). Returns: - ------- + ---------- fig : plotly.graph_objs.Figure The Plotly figure object for the convergence plot. .. py:function:: plot_cost2d(cost, bounds=None, optim=None, steps=10) - Query the cost landscape for a given parameter space and plot using plotly. + Query the cost landscape for a given parameter space and plot it using Plotly. + + This function creates a 2D plot that visualizes the cost landscape over a grid + of points within specified parameter bounds. If no bounds are provided, it determines + them from the bounds on the parameter class. + + :param cost: A callable representing the cost function to be queried. It should + take a list of parameters and return a cost value. + :type cost: callable + :param bounds: The bounds for the parameter space as a 2x2 array, with each + sub-array representing the min and max bounds for a parameter. + If None, bounds will be determined by `get_param_bounds`. + :type bounds: numpy.ndarray, optional + :param optim: An optional optimizer instance. If provided, it will be used to + overlay optimizer-specific information on the plot. + :type optim: object, optional + :param steps: The number of steps to divide the parameter space grid. More steps + result in finer resolution but increase computational cost. + :type steps: int, optional + :return: A Plotly figure object representing the cost landscape plot. + :rtype: plotly.graph_objs.Figure + + :raises ValueError: If the cost function does not behave as expected. .. py:function:: plot_parameters(optim, xaxis_titles='Iteration', yaxis_titles=None, title='Convergence') @@ -803,7 +794,7 @@ Attributes Plot the evolution of the parameters during the optimisation process. Parameters: - ---------- + ------------ optim : optimisation object An object representing the optimisation process, which should contain information about the cost function, optimiser, and the history of the @@ -819,14 +810,14 @@ Attributes plot represents (default is "Convergence"). Returns: - ------- + ---------- fig : plotly.graph_objs.Figure The Plotly figure object for the plot depicting how the parameters of the optimisation algorithm evolve over its course. This can be useful for diagnosing the behaviour of the optimisation algorithm. Notes: - ----- + ---------- The function assumes that the 'optim' object has a 'cost.problem.parameters' attribute containing the parameters of the optimisation algorithm and a 'log' attribute containing a history of the iterations. @@ -837,7 +828,7 @@ Attributes Plot the target dataset against the minimised model output. Parameters: - ---------- + ----------- params : array-like Optimised parameters. cost : cost object @@ -850,7 +841,7 @@ Attributes Height of the figure in pixels (default is 540). Returns: - ------- + ---------- fig : plotly.graph_objs.Figure The Plotly figure object for the scatter plot. diff --git a/docs/api/pybop/plotting/plot_convergence/index.rst b/docs/api/pybop/plotting/plot_convergence/index.rst index 52e9e8c3..79d2080c 100644 --- a/docs/api/pybop/plotting/plot_convergence/index.rst +++ b/docs/api/pybop/plotting/plot_convergence/index.rst @@ -33,6 +33,6 @@ Functions Title of the plot (default is "Convergence"). Returns: - ------- + ---------- fig : plotly.graph_objs.Figure The Plotly figure object for the convergence plot. diff --git a/docs/api/pybop/plotting/plot_cost2d/index.rst b/docs/api/pybop/plotting/plot_cost2d/index.rst index 31ee744b..83023b5d 100644 --- a/docs/api/pybop/plotting/plot_cost2d/index.rst +++ b/docs/api/pybop/plotting/plot_cost2d/index.rst @@ -29,4 +29,26 @@ Functions .. py:function:: plot_cost2d(cost, bounds=None, optim=None, steps=10) - Query the cost landscape for a given parameter space and plot using plotly. + Query the cost landscape for a given parameter space and plot it using Plotly. + + This function creates a 2D plot that visualizes the cost landscape over a grid + of points within specified parameter bounds. If no bounds are provided, it determines + them from the bounds on the parameter class. + + :param cost: A callable representing the cost function to be queried. It should + take a list of parameters and return a cost value. + :type cost: callable + :param bounds: The bounds for the parameter space as a 2x2 array, with each + sub-array representing the min and max bounds for a parameter. + If None, bounds will be determined by `get_param_bounds`. + :type bounds: numpy.ndarray, optional + :param optim: An optional optimizer instance. If provided, it will be used to + overlay optimizer-specific information on the plot. + :type optim: object, optional + :param steps: The number of steps to divide the parameter space grid. More steps + result in finer resolution but increase computational cost. + :type steps: int, optional + :return: A Plotly figure object representing the cost landscape plot. + :rtype: plotly.graph_objs.Figure + + :raises ValueError: If the cost function does not behave as expected. diff --git a/docs/api/pybop/plotting/plot_parameters/index.rst b/docs/api/pybop/plotting/plot_parameters/index.rst index 07b660ca..56416c1e 100644 --- a/docs/api/pybop/plotting/plot_parameters/index.rst +++ b/docs/api/pybop/plotting/plot_parameters/index.rst @@ -60,7 +60,7 @@ Functions Plot the evolution of the parameters during the optimisation process. Parameters: - ---------- + ------------ optim : optimisation object An object representing the optimisation process, which should contain information about the cost function, optimiser, and the history of the @@ -76,14 +76,14 @@ Functions plot represents (default is "Convergence"). Returns: - ------- + ---------- fig : plotly.graph_objs.Figure The Plotly figure object for the plot depicting how the parameters of the optimisation algorithm evolve over its course. This can be useful for diagnosing the behaviour of the optimisation algorithm. Notes: - ----- + ---------- The function assumes that the 'optim' object has a 'cost.problem.parameters' attribute containing the parameters of the optimisation algorithm and a 'log' attribute containing a history of the iterations. diff --git a/docs/api/pybop/plotting/plotly_manager/index.rst b/docs/api/pybop/plotting/plotly_manager/index.rst index 076d6541..d0606b3c 100644 --- a/docs/api/pybop/plotting/plotly_manager/index.rst +++ b/docs/api/pybop/plotting/plotly_manager/index.rst @@ -26,36 +26,19 @@ Classes It also ensures that the Plotly renderer and browser settings are properly configured to display plots. - .. method:: ``ensure_plotly_installed`` - - Verifies if Plotly is installed and installs it if necessary. - - .. method:: ``prompt_for_plotly_installation`` - - Prompts the user for permission to install Plotly. - - .. method:: ``install_plotly_package`` - - Installs the Plotly package using pip. - - .. method:: ``post_install_setup`` - - Sets up Plotly default renderer after installation. - - .. method:: ``check_renderer_settings`` - - Verifies that the Plotly renderer is correctly set. - - .. method:: ``check_browser_availability`` - - Checks if a web browser is available for rendering plots. - + Methods: + ``ensure_plotly_installed``: Verifies if Plotly is installed and installs it if necessary. + ``prompt_for_plotly_installation``: Prompts the user for permission to install Plotly. + ``install_plotly_package``: Installs the Plotly package using pip. + ``post_install_setup``: Sets up Plotly default renderer after installation. + ``check_renderer_settings``: Verifies that the Plotly renderer is correctly set. + ``check_browser_availability``: Checks if a web browser is available for rendering plots. Usage: - Instantiate the PlotlyManager class to automatically ensure Plotly is installed - and configured correctly when creating an instance. - Example: - plotly_manager = PlotlyManager() + Instantiate the PlotlyManager class to automatically ensure Plotly is installed + and configured correctly when creating an instance. + Example: + plotly_manager = PlotlyManager() .. py:method:: check_browser_availability() diff --git a/docs/api/pybop/plotting/quick_plot/index.rst b/docs/api/pybop/plotting/quick_plot/index.rst index 08165d30..87d9db9c 100644 --- a/docs/api/pybop/plotting/quick_plot/index.rst +++ b/docs/api/pybop/plotting/quick_plot/index.rst @@ -56,22 +56,8 @@ Functions height : int, optional The height of the figure in pixels. Defaults to 540. - Methods: - -------- - wrap_text(text, width) - A static method that wraps text to a specified width, inserting HTML line breaks for use in plot labels. - - create_layout() - Creates the layout for the plot, including titles and axis labels. - - create_traces() - Creates the traces for the plot, including the primary dataset, optional secondary dataset, and an optional uncertainty visualization. - - __call__() - Generates the plotly figure when the class instance is called as a function. - Example: - -------- + ---------- >>> x_data = [1, 2, 3, 4] >>> y_simulated = [10, 15, 13, 17] >>> y_target = [11, 14, 12, 16] @@ -108,7 +94,7 @@ Functions Width to wrap text to. Returns: - -------- + ---------- str Wrapped text with HTML line breaks. @@ -119,7 +105,7 @@ Functions Plot the target dataset against the minimised model output. Parameters: - ---------- + ----------- params : array-like Optimised parameters. cost : cost object @@ -132,6 +118,6 @@ Functions Height of the figure in pixels (default is 540). Returns: - ------- + ---------- fig : plotly.graph_objs.Figure The Plotly figure object for the scatter plot. diff --git a/docs/conf.py b/docs/conf.py index c1145409..a6073c39 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,23 +8,19 @@ sys.path.append(str(Path(".").resolve())) # -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - project = "PyBOP" copyright = "2023, The PyBOP Team" author = "The PyBOP Team" release = "v23.11" # -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - extensions = [ "sphinx.ext.napoleon", "sphinx.ext.autodoc", "sphinx.ext.autosummary", "sphinx.ext.todo", "sphinx.ext.viewcode", - "sphinxext.rediraffe", + # "sphinxext.rediraffe", "sphinx_design", "sphinx_copybutton", "autoapi.extension", @@ -54,9 +50,6 @@ autoapi_member_order = "groupwise" # -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -# Set html_theme html_theme = "pydata_sphinx_theme" html_show_sourcelink = False html_title = "PyBOP Documentation" @@ -69,10 +62,10 @@ "url": "https://github.com/pybop-team/pybop", "icon": "fab fa-github-square", }, - # other icon links + # add other icon links as needed ], "search_bar_text": "Search the docs...", - # other options + "show_prev_next": False, } html_static_path = ["_static"] diff --git a/docs/index.md b/docs/index.md index fd8a0ddc..cea4f85c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,11 +6,11 @@ myst: html_theme.sidebar_secondary.remove: true --- -# The PyBOP Package +

Parameterise and Optimise Battery Models with PyBOP

-A Python package for battery model optimisation and parameterisation. +Welcome to PyBOP, an advanced Python package dedicated to the optimization and parameterization of battery models. PyBOP is designed to streamline your workflow, whether you are conducting academic research, working in industry, or simply interested in battery technology and modelling. -Version: v23.11 +**Version: v23.11** ```{gallery-grid} :grid-columns: 1 2 2 2 @@ -20,8 +20,10 @@ Version: v23.11 link: "api/index.html" - header: "{fas}`bolt;pst-color-primary` Installation" content: "Setting up PyBOP is straightforward. Follow our step-by-step guide to install PyBOP on your system." + link: "user_guide/installation.html" - header: "{fas}`circle-half-stroke;pst-color-primary` Usage" content: "Discover how to use PyBOP effectively with our usage examples. From basic tasks to advanced features, learn how to solve real-world problems with PyBOP." + link: "user_guide/index.html" - header: "{fab}`python;pst-color-primary` Contributing" content: "Contribute to the PyBOP project and become a part of our growing community." link: "contributing.html" @@ -32,4 +34,5 @@ Version: v23.11 :hidden: contributing +user_guide/index ``` diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md new file mode 100644 index 00000000..76fd9cb2 --- /dev/null +++ b/docs/user_guide/index.md @@ -0,0 +1,22 @@ +--- +myst: + html_meta: + "description lang=en": | + Documentation for PyBOP users. +--- + +# User Guide + +```{toctree} +:caption: Installation + +installation + +``` + +```{toctree} +:caption: Usage + +usage + +```{toctree} diff --git a/docs/user_guide/installation.rst b/docs/user_guide/installation.rst new file mode 100644 index 00000000..1482edc4 --- /dev/null +++ b/docs/user_guide/installation.rst @@ -0,0 +1,67 @@ +Installation Guide for PyBOP +***************************** + +PyBOP is a versatile Python package designed for optimization and parameterization of battery models. Follow the instructions below to install PyBOP and set up your environment to begin utilizing its capabilities. + +Installing PyBOP with pip +------------------------- + +The simplest method to install PyBOP is using pip. Run the following command in your terminal: + +.. code-block:: console + + $ pip install pybop + +This command will download and install the latest stable version of PyBOP. If you want to install a specific version, you can specify the version number using the following command: + +.. code-block:: console + + $ pip install pybop==23.11 + +Installing the Development Version +---------------------------------- + +If you're interested in the cutting-edge features and want to try out the latest enhancements, you can install the development version directly from the ``develop`` branch on GitHub: + +.. code-block:: console + + $ pip install git+https://github.com/pybop-team/PyBOP.git@develop + +Please note that the development version may be less stable than the official releases. + +Local Installation from Source +------------------------------ + +For those who prefer to install PyBOP from a local clone of the repository or wish to modify the source code, you can use pip to install the package in "editable" mode. Replace "path/to/pybop" with the actual path to your local PyBOP directory: + +.. code-block:: console + + $ pip install -e "path/to/pybop" + +In editable mode, changes you make to the source code will immediately affect the PyBOP installation without the need for reinstallation. + +Verifying Installation +---------------------- + +To verify that PyBOP has been installed successfully, try running one of the provided example scripts included in the documentation or repository. If the example executes without any errors, PyBOP is ready to use. + +For Developers +-------------- + +If you are installing PyBOP for development purposes, such as contributing to the project, please ensure that you follow the guidelines outlined in the contributing guide. It includes additional steps that might be necessary for setting up a development environment, including the installation of dependencies and setup of pre-commit hooks. + +`Contributing Guide `_ + +Further Assistance +------------------ + +If you encounter any issues during the installation process or have any questions regarding the use of PyBOP, feel free to reach out to the community via the `PyBOP GitHub Discussions `_. + +Next Steps +---------- + +After installing PyBOP, you might want to: + +* Explore the `Quick Start Guide `_ to begin using PyBOP. +* Read through the `User Manual `_ for in-depth documentation on PyBOP's features. +* Check out the `API Reference <../api/index.html>`_ for detailed information on PyBOP's programming interface. diff --git a/docs/user_guide/usage.rst b/docs/user_guide/usage.rst new file mode 100644 index 00000000..4e2b2418 --- /dev/null +++ b/docs/user_guide/usage.rst @@ -0,0 +1,61 @@ +Quick Start Guide for PyBOP +**************************** + +Welcome to the Quick Start Guide for PyBOP. This guide will help you get up and running with PyBOP. If you're new to PyBOP, we recommend you start here to learn the basics and get a feel for the package. + +Getting Started with PyBOP +-------------------------- + +PyBOP is equipped with a series of robust tools that can help you optimize various parameters within your battery models to better match empirical data or to explore the effects of different parameters on battery behavior. + +To begin using PyBOP: + +1. Install the package using pip: + + .. code-block:: console + + $ pip install pybop + + For detailed installation instructions, including how to install specific versions or from source, see the :ref:`installation` section. + +2. Once PyBOP is installed, you can import it in your Python scripts or Jupyter notebooks: + + .. code-block:: python + + import pybop + + Now you're ready to utilize PyBOP's functionality in your projects! + +Exploring Examples +------------------ + +To help you get acquainted with PyBOP's capabilities, we provide a collection of examples that demonstrate common use cases and features of the package: + +- **Jupyter Notebooks**: Interactive notebooks that include detailed explanations alongside the live code, visualizations, and results. These are an excellent resource for learning and can be easily modified and executed to suit your needs. + +- **Python Scripts**: For those who prefer working in a text editor, IDE, or for integrating into larger projects, we provide equivalent examples in plain Python script format. + +You can find these resources in the ``examples`` folder of the PyBOP repository. To access the examples, navigate to the following path after cloning or downloading the repository: + +.. code-block:: console + + path/to/pybop/examples + +These examples are also available on our `GitHub repository `_. + +Next Steps +---------- + +Once you're comfortable with the basics demonstrated in the examples, you can dive deeper into the functionality of PyBOP by: + +- Exploring the :ref:`user-guide` for a comprehensive overview of features. +- Delving into the :ref:`api-reference` for detailed API documentation. +- Checking out the :ref:`advanced-topics` for in-depth discussions on specific PyBOP features and how to customize them for complex scenarios. + +Support and Contributions +------------------------- + +If you encounter any issues or have questions as you start using PyBOP, don't hesitate to reach out to our community: + +- **Support**: Visit our :ref:`support` page for FAQs and contact information. +- **Contributions**: Interested in contributing to PyBOP? Check out our :ref:`contributing-guide` for guidelines on how to contribute. diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py index 94ffdf90..315a3871 100644 --- a/pybop/plotting/plot_convergence.py +++ b/pybop/plotting/plot_convergence.py @@ -19,7 +19,7 @@ def plot_convergence( Title of the plot (default is "Convergence"). Returns: - ------- + ---------- fig : plotly.graph_objs.Figure The Plotly figure object for the convergence plot. """ diff --git a/pybop/plotting/plot_cost2d.py b/pybop/plotting/plot_cost2d.py index aa7b2e3b..2475506c 100644 --- a/pybop/plotting/plot_cost2d.py +++ b/pybop/plotting/plot_cost2d.py @@ -3,7 +3,29 @@ def plot_cost2d(cost, bounds=None, optim=None, steps=10): """ - Query the cost landscape for a given parameter space and plot using plotly. + Query the cost landscape for a given parameter space and plot it using Plotly. + + This function creates a 2D plot that visualizes the cost landscape over a grid + of points within specified parameter bounds. If no bounds are provided, it determines + them from the bounds on the parameter class. + + :param cost: A callable representing the cost function to be queried. It should + take a list of parameters and return a cost value. + :type cost: callable + :param bounds: The bounds for the parameter space as a 2x2 array, with each + sub-array representing the min and max bounds for a parameter. + If None, bounds will be determined by `get_param_bounds`. + :type bounds: numpy.ndarray, optional + :param optim: An optional optimizer instance. If provided, it will be used to + overlay optimizer-specific information on the plot. + :type optim: object, optional + :param steps: The number of steps to divide the parameter space grid. More steps + result in finer resolution but increase computational cost. + :type steps: int, optional + :return: A Plotly figure object representing the cost landscape plot. + :rtype: plotly.graph_objs.Figure + + :raises ValueError: If the cost function does not behave as expected. """ if bounds is None: diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py index e94902bc..2dcf582d 100644 --- a/pybop/plotting/plot_parameters.py +++ b/pybop/plotting/plot_parameters.py @@ -9,7 +9,7 @@ def plot_parameters( Plot the evolution of the parameters during the optimisation process. Parameters: - ---------- + ------------ optim : optimisation object An object representing the optimisation process, which should contain information about the cost function, optimiser, and the history of the @@ -25,14 +25,14 @@ def plot_parameters( plot represents (default is "Convergence"). Returns: - ------- + ---------- fig : plotly.graph_objs.Figure The Plotly figure object for the plot depicting how the parameters of the optimisation algorithm evolve over its course. This can be useful for diagnosing the behaviour of the optimisation algorithm. Notes: - ----- + ---------- The function assumes that the 'optim' object has a 'cost.problem.parameters' attribute containing the parameters of the optimisation algorithm and a 'log' attribute containing a history of the iterations. diff --git a/pybop/plotting/plotly_manager.py b/pybop/plotting/plotly_manager.py index 692b44bc..c8b80dc9 100644 --- a/pybop/plotting/plotly_manager.py +++ b/pybop/plotting/plotly_manager.py @@ -12,18 +12,18 @@ class PlotlyManager: to display plots. Methods: - ``ensure_plotly_installed``: Verifies if Plotly is installed and installs it if necessary. - ``prompt_for_plotly_installation``: Prompts the user for permission to install Plotly. - ``install_plotly_package``: Installs the Plotly package using pip. - ``post_install_setup``: Sets up Plotly default renderer after installation. - ``check_renderer_settings``: Verifies that the Plotly renderer is correctly set. - ``check_browser_availability``: Checks if a web browser is available for rendering plots. + ``ensure_plotly_installed``: Verifies if Plotly is installed and installs it if necessary. + ``prompt_for_plotly_installation``: Prompts the user for permission to install Plotly. + ``install_plotly_package``: Installs the Plotly package using pip. + ``post_install_setup``: Sets up Plotly default renderer after installation. + ``check_renderer_settings``: Verifies that the Plotly renderer is correctly set. + ``check_browser_availability``: Checks if a web browser is available for rendering plots. Usage: - Instantiate the PlotlyManager class to automatically ensure Plotly is installed - and configured correctly when creating an instance. - Example: - plotly_manager = PlotlyManager() + Instantiate the PlotlyManager class to automatically ensure Plotly is installed + and configured correctly when creating an instance. + Example: + plotly_manager = PlotlyManager() """ def __init__(self): diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index d6628760..8d9ef02b 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -33,22 +33,8 @@ class StandardPlot: height : int, optional The height of the figure in pixels. Defaults to 540. - Methods: - -------- - wrap_text(text, width) - A static method that wraps text to a specified width, inserting HTML line breaks for use in plot labels. - - create_layout() - Creates the layout for the plot, including titles and axis labels. - - create_traces() - Creates the traces for the plot, including the primary dataset, optional secondary dataset, and an optional uncertainty visualization. - - __call__() - Generates the plotly figure when the class instance is called as a function. - Example: - -------- + ---------- >>> x_data = [1, 2, 3, 4] >>> y_simulated = [10, 15, 13, 17] >>> y_target = [11, 14, 12, 16] @@ -103,7 +89,7 @@ def wrap_text(text, width): Width to wrap text to. Returns: - -------- + ---------- str Wrapped text with HTML line breaks. """ @@ -176,7 +162,7 @@ def quick_plot(params, cost, title="Scatter Plot", width=1024, height=576): Plot the target dataset against the minimised model output. Parameters: - ---------- + ----------- params : array-like Optimised parameters. cost : cost object @@ -189,7 +175,7 @@ def quick_plot(params, cost, title="Scatter Plot", width=1024, height=576): Height of the figure in pixels (default is 540). Returns: - ------- + ---------- fig : plotly.graph_objs.Figure The Plotly figure object for the scatter plot. """ From ddae28828ef12e2c3b2eb3eea74c3e6c59543589 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 8 Dec 2023 15:24:40 +0000 Subject: [PATCH 067/101] Add nox session for docs, add pip install option for docs --- noxfile.py | 35 +++++++++++++++++++++++++++++++++++ setup.py | 9 +++++++++ 2 files changed, 44 insertions(+) diff --git a/noxfile.py b/noxfile.py index e1759a3d..dddabfa7 100644 --- a/noxfile.py +++ b/noxfile.py @@ -32,3 +32,38 @@ def notebooks(session): session.run_always("pip", "install", "-e", ".[all]") session.install("pytest", "nbmake") session.run("pytest", "--nbmake", "examples/", external=True) + + +@nox.session +def build_docs(session): + """ + Build the documentation and load it in a browser tab, rebuilding on changes. + Credit: PyBaMM Team + """ + envbindir = session.bin + session.install("-e", ".[all,docs]") + session.chdir("docs") + # Local development + if session.interactive: + session.run( + "sphinx-autobuild", + "-j", + "auto", + "--open-browser", + "-qT", + ".", + f"{envbindir}/../tmp/html", + ) + # Runs in CI only, treating warnings as errors + else: + session.run( + "sphinx-build", + "-j", + "auto", + "-b", + "html", + "-W", + "--keep-going", + ".", + f"{envbindir}/../tmp/html", + ) diff --git a/setup.py b/setup.py index 4591c384..78b54e53 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,15 @@ extras_require={ "plot": ["plotly>=5.0"], "all": ["pybop[plot]"], + "docs": [ + "sphinx>=6", + "pydata-sphinx-theme", + "sphinx-autobuild", + "sphinx-autoapi", + "sphinx_copybutton", + "sphinx_design", + "myst-parser", + ], }, # https://pypi.org/classifiers/ classifiers=[], From 097bc514d7d2da179b969a4302a74c48d992f32a Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 8 Dec 2023 16:59:58 +0000 Subject: [PATCH 068/101] Adds maximum iterations functionality to scipyminimize, nloptoptimize, baseoptimise, restore test_spm_optimisers assertions --- CHANGELOG.md | 2 +- pybop/optimisation.py | 1 + pybop/optimisers/base_optimiser.py | 3 ++- pybop/optimisers/nlopt_optimize.py | 7 ++++++- pybop/optimisers/scipy_optimisers.py | 14 ++++++++++++-- tests/unit/test_parameterisations.py | 9 ++------- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e6e17c..13535bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Features -- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Adds `SciPyDifferentialEvolution` optimiser +- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Adds `SciPyDifferentialEvolution` optimiser, adds functionality for user-selectable maximum iteration limit to `SciPyMinimize`, `NLoptOptimize`, and `BaseOptimiser` classes. - [#107](https://github.com/pybop-team/PyBOP/issues/107) - Adds Equivalent Circuit Model (ECM) with examples, Import/Export parameter methods `ParameterSet.import_parameter` and `ParameterSet.export_parameters`, updates default FittingProblem.signal definition to `"Voltage [V]"`, and testing infrastructure - [#127](https://github.com/pybop-team/PyBOP/issues/127) - Adds Windows and macOS runners to the `test_on_push` action - [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds standard plotting class `pybop.StandardPlot()` via plotly backend diff --git a/pybop/optimisation.py b/pybop/optimisation.py index 6ff7141d..5da349a0 100644 --- a/pybop/optimisation.py +++ b/pybop/optimisation.py @@ -135,6 +135,7 @@ def _run_pybop(self): cost_function=self.cost, x0=self.x0, bounds=self.bounds, + maxiter=self._max_iterations, ) self.log = self.optimiser.log diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 9d9a8b2c..b0b13385 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -8,7 +8,7 @@ class BaseOptimiser: def __init__(self): pass - def optimise(self, cost_function, x0=None, bounds=None): + def optimise(self, cost_function, x0=None, bounds=None, maxiter=None): """ Optimisiation method to be overloaded by child classes. @@ -16,6 +16,7 @@ def optimise(self, cost_function, x0=None, bounds=None): self.cost_function = cost_function self.x0 = x0 self.bounds = bounds + self.maxiter = maxiter # Run optimisation result = self._runoptimise(self.cost_function, x0=self.x0, bounds=self.bounds) diff --git a/pybop/optimisers/nlopt_optimize.py b/pybop/optimisers/nlopt_optimize.py index b10e4f5d..7d78a699 100644 --- a/pybop/optimisers/nlopt_optimize.py +++ b/pybop/optimisers/nlopt_optimize.py @@ -8,9 +8,10 @@ class NLoptOptimize(BaseOptimiser): Wrapper class for the NLOpt optimiser class. Extends the BaseOptimiser class. """ - def __init__(self, n_param, xtol=None, method=None): + def __init__(self, n_param, xtol=None, method=None, maxiter=None): super().__init__() self.n_param = n_param + self.maxiter = maxiter if method is not None: self.optim = nlopt.opt(method, self.n_param) @@ -46,6 +47,10 @@ def cost_wrapper(x, grad): self.optim.set_lower_bounds(bounds["lower"]) self.optim.set_upper_bounds(bounds["upper"]) + # Set max iterations + if self.maxiter is not None: + self.optim.set_maxeval(self.maxiter) + # Run the optimser x = self.optim.optimize(x0) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index c5fe0d8e..59f9e638 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -7,10 +7,15 @@ class SciPyMinimize(BaseOptimiser): Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. """ - def __init__(self, method=None, bounds=None): + def __init__(self, method=None, bounds=None, maxiter=None): super().__init__() self.method = method self.bounds = bounds + self.maxiter = maxiter + if self.maxiter is not None: + self.options = {"maxiter": self.maxiter} + else: + self.options = {} if self.method is None: self.method = "COBYLA" # "L-BFGS-B" @@ -40,7 +45,12 @@ def callback(x): ) output = minimize( - cost_function, x0, method=self.method, bounds=bounds, callback=callback + cost_function, + x0, + method=self.method, + bounds=bounds, + options=self.options, + callback=callback, ) # Get performance statistics diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py index af8e7fbd..af1a4d57 100644 --- a/tests/unit/test_parameterisations.py +++ b/tests/unit/test_parameterisations.py @@ -138,13 +138,8 @@ def test_spm_optimisers(self, spm_cost, x0): x, final_cost = parameterisation.run() # Assertions - # Note: SciPyMinimize has a different tolerance due to the local optimisation algorithms - if optimiser in [pybop.SciPyMinimize]: - np.testing.assert_allclose(final_cost, 0, atol=1e-2) - np.testing.assert_allclose(x, x0, atol=2e-1) - else: - np.testing.assert_allclose(final_cost, 0, atol=1e-2) - np.testing.assert_allclose(x, x0, atol=1e-1) + np.testing.assert_allclose(final_cost, 0, atol=1e-2) + np.testing.assert_allclose(x, x0, atol=1e-1) @pytest.mark.parametrize("init_soc", [0.3, 0.7]) @pytest.mark.unit From 707f16ad072bc07b08d202f4405b5697da7c15d1 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 8 Dec 2023 17:29:55 +0000 Subject: [PATCH 069/101] updt. changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13535bd2..164050d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ - [#120](https://github.com/pybop-team/PyBOP/issues/120) - Updates the parameterisation test settings including the number of iterations ## Bug Fixes -- [#131](https://github.com/pybop-team/PyBOP/issues/131) - Increases the SciPyMinimize optimiser assertion tolerances reduce CI/CD failures # [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11) - Initial release From e4257ea19bffdf0ac9873671eee5c5a35dec85a5 Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Fri, 8 Dec 2023 17:35:40 +0000 Subject: [PATCH 070/101] BUG FIX for plot_cost2d (#137) - Transpose costs and include last point in the trace --- pybop/plotting/plot_cost2d.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pybop/plotting/plot_cost2d.py b/pybop/plotting/plot_cost2d.py index aa7b2e3b..7e698cb9 100644 --- a/pybop/plotting/plot_cost2d.py +++ b/pybop/plotting/plot_cost2d.py @@ -17,12 +17,12 @@ def plot_cost2d(cost, bounds=None, optim=None, steps=10): y = np.linspace(bounds[1, 0], bounds[1, 1], steps) # Initialize cost matrix - costs = np.zeros((len(x), len(y))) + costs = np.zeros((len(y), len(x))) # Populate cost matrix for i, xi in enumerate(x): for j, yj in enumerate(y): - costs[i, j] = cost([xi, yj]) + costs[j, i] = cost([xi, yj]) # Create figure fig = create_figure(x, y, costs, bounds, cost.problem.parameters, optim) @@ -73,8 +73,8 @@ def create_figure(x, y, z, bounds, params, optim): # Plot optimisation trace fig.add_trace( go.Scatter( - x=optim_trace[0:-1, 0], - y=optim_trace[0:-1, 1], + x=optim_trace[:, 0], + y=optim_trace[:, 1], mode="markers", marker=dict( color=[i / len(optim_trace) for i in range(len(optim_trace))], From f98355a281e87b2d3aaacfa064af3dc5adb53469 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sun, 10 Dec 2023 11:40:17 +0000 Subject: [PATCH 071/101] Updt cost, dataset structure, improve docstrings, iterate on docs site --- docs/api/pybop/_costs/index.rst | 153 ++++++++++++ docs/api/pybop/_dataset/index.rst | 50 ++++ docs/api/pybop/costs/error_costs/index.rst | 72 ------ docs/api/pybop/costs/index.rst | 14 -- docs/api/pybop/costs/standalone/index.rst | 31 --- .../api/pybop/datasets/base_dataset/index.rst | 30 --- docs/api/pybop/datasets/index.rst | 13 - docs/api/pybop/index.rst | 133 ++++++++-- docs/conf.py | 5 +- docs/index.md | 4 +- docs/source/modules.rst | 7 - docs/source/pybop.costs.rst | 29 --- docs/source/pybop.datasets.rst | 21 -- docs/source/pybop.optimisers.rst | 45 ---- docs/source/pybop.parameters.rst | 37 --- docs/source/pybop.plotting.rst | 53 ---- docs/source/pybop.rst | 42 ---- docs/user_guide/index.md | 2 +- examples/costs/standalone.py | 72 ++++++ .../parameters/fit_ecm_parameters.json | 24 ++ noxfile.py | 2 +- pybop/__init__.py | 4 +- pybop/_costs.py | 229 ++++++++++++++++++ pybop/_dataset.py | 64 +++++ pybop/costs/__init__.py | 0 pybop/costs/error_costs.py | 114 --------- pybop/costs/standalone.py | 18 -- pybop/datasets/__init__.py | 0 pybop/datasets/base_dataset.py | 20 -- setup.py | 1 + tests/unit/test_optimisation.py | 2 +- 31 files changed, 718 insertions(+), 573 deletions(-) create mode 100644 docs/api/pybop/_costs/index.rst create mode 100644 docs/api/pybop/_dataset/index.rst delete mode 100644 docs/api/pybop/costs/error_costs/index.rst delete mode 100644 docs/api/pybop/costs/index.rst delete mode 100644 docs/api/pybop/costs/standalone/index.rst delete mode 100644 docs/api/pybop/datasets/base_dataset/index.rst delete mode 100644 docs/api/pybop/datasets/index.rst delete mode 100644 docs/source/modules.rst delete mode 100644 docs/source/pybop.costs.rst delete mode 100644 docs/source/pybop.datasets.rst delete mode 100644 docs/source/pybop.optimisers.rst delete mode 100644 docs/source/pybop.parameters.rst delete mode 100644 docs/source/pybop.plotting.rst delete mode 100644 docs/source/pybop.rst create mode 100644 examples/costs/standalone.py create mode 100644 examples/scripts/parameters/fit_ecm_parameters.json create mode 100644 pybop/_costs.py create mode 100644 pybop/_dataset.py delete mode 100644 pybop/costs/__init__.py delete mode 100644 pybop/costs/error_costs.py delete mode 100644 pybop/costs/standalone.py delete mode 100644 pybop/datasets/__init__.py delete mode 100644 pybop/datasets/base_dataset.py diff --git a/docs/api/pybop/_costs/index.rst b/docs/api/pybop/_costs/index.rst new file mode 100644 index 00000000..36d8cab8 --- /dev/null +++ b/docs/api/pybop/_costs/index.rst @@ -0,0 +1,153 @@ +:py:mod:`pybop._costs` +====================== + +.. py:module:: pybop._costs + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop._costs.BaseCost + pybop._costs.RootMeanSquaredError + pybop._costs.SumSquaredError + + + + +.. py:class:: BaseCost(problem) + + + Base class for defining cost functions. + + This class is intended to be subclassed to create specific cost functions + for evaluating model predictions against a set of data. The cost function + quantifies the goodness-of-fit between the model predictions and the + observed data, with a lower cost value indicating a better fit. + + :param problem: A problem instance containing the data and functions necessary for + evaluating the cost function. + :type problem: object + :param _target: An array containing the target data to fit. + :type _target: array-like + :param x0: The initial guess for the model parameters. + :type x0: array-like + :param bounds: The bounds for the model parameters. + :type bounds: tuple + :param n_parameters: The number of parameters in the model. + :type n_parameters: int + + .. py:method:: __call__(x, grad=None) + :abstractmethod: + + Calculate the cost function value for a given set of parameters. + + This method must be implemented by subclasses. + + :param x: The parameters for which to evaluate the cost. + :type x: array-like + :param grad: An array to store the gradient of the cost function with respect + to the parameters. + :type grad: array-like, optional + + :returns: The calculated cost function value. + :rtype: float + + :raises NotImplementedError: If the method has not been implemented by the subclass. + + + +.. py:class:: RootMeanSquaredError(problem) + + + Bases: :py:obj:`BaseCost` + + Root mean square error cost function. + + Computes the root mean square error between model predictions and the target + data, providing a measure of the differences between predicted values and + observed values. + + Inherits all parameters and attributes from ``BaseCost``. + + + .. py:method:: __call__(x, grad=None) + + Calculate the root mean square error for a given set of parameters. + + :param x: The parameters for which to evaluate the cost. + :type x: array-like + :param grad: An array to store the gradient of the cost function with respect + to the parameters. + :type grad: array-like, optional + + :returns: The root mean square error. + :rtype: float + + :raises ValueError: If an error occurs during the calculation of the cost. + + + +.. py:class:: SumSquaredError(problem) + + + Bases: :py:obj:`BaseCost` + + Sum of squared errors cost function. + + Computes the sum of the squares of the differences between model predictions + and target data, which serves as a measure of the total error between the + predicted and observed values. + + Inherits all parameters and attributes from ``BaseCost``. + + Additional Attributes + --------------------- + _de : float + The gradient of the cost function to use if an error occurs during + evaluation. Defaults to 1.0. + + + .. py:method:: __call__(x, grad=None) + + Calculate the sum of squared errors for a given set of parameters. + + :param x: The parameters for which to evaluate the cost. + :type x: array-like + :param grad: An array to store the gradient of the cost function with respect + to the parameters. + :type grad: array-like, optional + + :returns: The sum of squared errors. + :rtype: float + + :raises ValueError: If an error occurs during the calculation of the cost. + + + .. py:method:: evaluateS1(x) + + Compute the cost and its gradient with respect to the parameters. + + :param x: The parameters for which to compute the cost and gradient. + :type x: array-like + + :returns: A tuple containing the cost and the gradient. The cost is a float, + and the gradient is an array-like of the same length as `x`. + :rtype: tuple + + :raises ValueError: If an error occurs during the calculation of the cost or gradient. + + + .. py:method:: set_fail_gradient(de) + + Set the fail gradient to a specified value. + + The fail gradient is used if an error occurs during the calculation + of the gradient. This method allows updating the default gradient value. + + :param de: The new fail gradient value to be used. + :type de: float diff --git a/docs/api/pybop/_dataset/index.rst b/docs/api/pybop/_dataset/index.rst new file mode 100644 index 00000000..53913bed --- /dev/null +++ b/docs/api/pybop/_dataset/index.rst @@ -0,0 +1,50 @@ +:py:mod:`pybop._dataset` +======================== + +.. py:module:: pybop._dataset + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop._dataset.Dataset + + + + +.. py:class:: Dataset(name, data) + + + Represents a collection of experimental observations. + + This class provides a structured way to store and work with experimental data, + which may include applying operations such as interpolation. + + :param name: The name of the dataset, providing a label for identification. + :type name: str + :param data: The actual experimental data, typically in a structured form such as + a NumPy array or a pandas DataFrame. + :type data: array-like + + .. py:method:: Interpolant() + + Create an interpolation function of the dataset based on the independent variable. + + Currently, only time-based interpolation is supported. This method modifies + the instance's Interpolant attribute to be an interpolation function that + can be evaluated at different points in time. + + :raises NotImplementedError: If the independent variable for interpolation is not supported. + + + .. py:method:: __repr__() + + Return a string representation of the Dataset instance. + + :returns: A string that includes the name and data of the dataset. + :rtype: str diff --git a/docs/api/pybop/costs/error_costs/index.rst b/docs/api/pybop/costs/error_costs/index.rst deleted file mode 100644 index 70f18e02..00000000 --- a/docs/api/pybop/costs/error_costs/index.rst +++ /dev/null @@ -1,72 +0,0 @@ -:py:mod:`pybop.costs.error_costs` -================================= - -.. py:module:: pybop.costs.error_costs - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.costs.error_costs.BaseCost - pybop.costs.error_costs.RootMeanSquaredError - pybop.costs.error_costs.SumSquaredError - - - - -.. py:class:: BaseCost(problem) - - - Base class for defining cost functions. - This class computes a corresponding goodness-of-fit for a corresponding model prediction and dataset. - Lower cost values indicate a better fit. - - .. py:method:: __call__(x, grad=None) - :abstractmethod: - - Returns the cost function value and computes the cost. - - - -.. py:class:: RootMeanSquaredError(problem) - - - Bases: :py:obj:`BaseCost` - - Defines the root mean square error cost function. - - .. py:method:: __call__(x, grad=None) - - Computes the cost. - - - -.. py:class:: SumSquaredError(problem) - - - Bases: :py:obj:`BaseCost` - - Defines the sum squared error cost function. - - The initial fail gradient is set equal to one, but this can be - changed at any time with :meth:`set_fail_gradient()`. - - .. py:method:: __call__(x, grad=None) - - Computes the cost. - - - .. py:method:: evaluateS1(x) - - Compute the cost and corresponding - gradients with respect to the parameters. - - - .. py:method:: set_fail_gradient(de) - - Sets the fail gradient for this optimiser. diff --git a/docs/api/pybop/costs/index.rst b/docs/api/pybop/costs/index.rst deleted file mode 100644 index 837718ef..00000000 --- a/docs/api/pybop/costs/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -:py:mod:`pybop.costs` -===================== - -.. py:module:: pybop.costs - - -Submodules ----------- -.. toctree:: - :titlesonly: - :maxdepth: 1 - - error_costs/index.rst - standalone/index.rst diff --git a/docs/api/pybop/costs/standalone/index.rst b/docs/api/pybop/costs/standalone/index.rst deleted file mode 100644 index 3d0957b4..00000000 --- a/docs/api/pybop/costs/standalone/index.rst +++ /dev/null @@ -1,31 +0,0 @@ -:py:mod:`pybop.costs.standalone` -================================ - -.. py:module:: pybop.costs.standalone - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.costs.standalone.StandaloneCost - - - - -.. py:class:: StandaloneCost(problem=None) - - - Bases: :py:obj:`pybop.BaseCost` - - Base class for defining cost functions. - This class computes a corresponding goodness-of-fit for a corresponding model prediction and dataset. - Lower cost values indicate a better fit. - - .. py:method:: __call__(x, grad=None) - - Returns the cost function value and computes the cost. diff --git a/docs/api/pybop/datasets/base_dataset/index.rst b/docs/api/pybop/datasets/base_dataset/index.rst deleted file mode 100644 index 9d6e1fe0..00000000 --- a/docs/api/pybop/datasets/base_dataset/index.rst +++ /dev/null @@ -1,30 +0,0 @@ -:py:mod:`pybop.datasets.base_dataset` -===================================== - -.. py:module:: pybop.datasets.base_dataset - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.datasets.base_dataset.Dataset - - - - -.. py:class:: Dataset(name, data) - - - Class for experimental observations. - - .. py:method:: Interpolant() - - - .. py:method:: __repr__() - - Return repr(self). diff --git a/docs/api/pybop/datasets/index.rst b/docs/api/pybop/datasets/index.rst deleted file mode 100644 index 0d868a1c..00000000 --- a/docs/api/pybop/datasets/index.rst +++ /dev/null @@ -1,13 +0,0 @@ -:py:mod:`pybop.datasets` -======================== - -.. py:module:: pybop.datasets - - -Submodules ----------- -.. toctree:: - :titlesonly: - :maxdepth: 1 - - base_dataset/index.rst diff --git a/docs/api/pybop/index.rst b/docs/api/pybop/index.rst index 075983ac..6ac0d00d 100644 --- a/docs/api/pybop/index.rst +++ b/docs/api/pybop/index.rst @@ -10,8 +10,6 @@ Subpackages :titlesonly: :maxdepth: 3 - costs/index.rst - datasets/index.rst models/index.rst optimisers/index.rst parameters/index.rst @@ -24,6 +22,8 @@ Submodules :titlesonly: :maxdepth: 1 + _costs/index.rst + _dataset/index.rst _problem/index.rst optimisation/index.rst version/index.rst @@ -100,13 +100,41 @@ Attributes Base class for defining cost functions. - This class computes a corresponding goodness-of-fit for a corresponding model prediction and dataset. - Lower cost values indicate a better fit. + + This class is intended to be subclassed to create specific cost functions + for evaluating model predictions against a set of data. The cost function + quantifies the goodness-of-fit between the model predictions and the + observed data, with a lower cost value indicating a better fit. + + :param problem: A problem instance containing the data and functions necessary for + evaluating the cost function. + :type problem: object + :param _target: An array containing the target data to fit. + :type _target: array-like + :param x0: The initial guess for the model parameters. + :type x0: array-like + :param bounds: The bounds for the model parameters. + :type bounds: tuple + :param n_parameters: The number of parameters in the model. + :type n_parameters: int .. py:method:: __call__(x, grad=None) :abstractmethod: - Returns the cost function value and computes the cost. + Calculate the cost function value for a given set of parameters. + + This method must be implemented by subclasses. + + :param x: The parameters for which to evaluate the cost. + :type x: array-like + :param grad: An array to store the gradient of the cost function with respect + to the parameters. + :type grad: array-like, optional + + :returns: The calculated cost function value. + :rtype: float + + :raises NotImplementedError: If the method has not been implemented by the subclass. @@ -213,14 +241,34 @@ Attributes .. py:class:: Dataset(name, data) - Class for experimental observations. + Represents a collection of experimental observations. + + This class provides a structured way to store and work with experimental data, + which may include applying operations such as interpolation. + + :param name: The name of the dataset, providing a label for identification. + :type name: str + :param data: The actual experimental data, typically in a structured form such as + a NumPy array or a pandas DataFrame. + :type data: array-like .. py:method:: Interpolant() + Create an interpolation function of the dataset based on the independent variable. + + Currently, only time-based interpolation is supported. This method modifies + the instance's Interpolant attribute to be an interpolation function that + can be evaluated at different points in time. + + :raises NotImplementedError: If the independent variable for interpolation is not supported. + .. py:method:: __repr__() - Return repr(self). + Return a string representation of the Dataset instance. + + :returns: A string that includes the name and data of the dataset. + :rtype: str @@ -563,11 +611,29 @@ Attributes Bases: :py:obj:`BaseCost` - Defines the root mean square error cost function. + Root mean square error cost function. + + Computes the root mean square error between model predictions and the target + data, providing a measure of the differences between predicted values and + observed values. + + Inherits all parameters and attributes from ``BaseCost``. + .. py:method:: __call__(x, grad=None) - Computes the cost. + Calculate the root mean square error for a given set of parameters. + + :param x: The parameters for which to evaluate the cost. + :type x: array-like + :param grad: An array to store the gradient of the cost function with respect + to the parameters. + :type grad: array-like, optional + + :returns: The root mean square error. + :rtype: float + + :raises ValueError: If an error occurs during the calculation of the cost. @@ -690,25 +756,60 @@ Attributes Bases: :py:obj:`BaseCost` - Defines the sum squared error cost function. + Sum of squared errors cost function. + + Computes the sum of the squares of the differences between model predictions + and target data, which serves as a measure of the total error between the + predicted and observed values. + + Inherits all parameters and attributes from ``BaseCost``. + + Additional Attributes + --------------------- + _de : float + The gradient of the cost function to use if an error occurs during + evaluation. Defaults to 1.0. - The initial fail gradient is set equal to one, but this can be - changed at any time with :meth:`set_fail_gradient()`. .. py:method:: __call__(x, grad=None) - Computes the cost. + Calculate the sum of squared errors for a given set of parameters. + + :param x: The parameters for which to evaluate the cost. + :type x: array-like + :param grad: An array to store the gradient of the cost function with respect + to the parameters. + :type grad: array-like, optional + + :returns: The sum of squared errors. + :rtype: float + + :raises ValueError: If an error occurs during the calculation of the cost. .. py:method:: evaluateS1(x) - Compute the cost and corresponding - gradients with respect to the parameters. + Compute the cost and its gradient with respect to the parameters. + + :param x: The parameters for which to compute the cost and gradient. + :type x: array-like + + :returns: A tuple containing the cost and the gradient. The cost is a float, + and the gradient is an array-like of the same length as `x`. + :rtype: tuple + + :raises ValueError: If an error occurs during the calculation of the cost or gradient. .. py:method:: set_fail_gradient(de) - Sets the fail gradient for this optimiser. + Set the fail gradient to a specified value. + + The fail gradient is used if an error occurs during the calculation + of the gradient. This method allows updating the default gradient value. + + :param de: The new fail gradient value to be used. + :type de: float diff --git a/docs/conf.py b/docs/conf.py index a6073c39..84364515 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,23 +20,20 @@ "sphinx.ext.autosummary", "sphinx.ext.todo", "sphinx.ext.viewcode", - # "sphinxext.rediraffe", "sphinx_design", "sphinx_copybutton", "autoapi.extension", # custom extentions "_extension.gallery_directive", - # "_extension.component_directive", # For extension examples and demos "myst_parser", # "ablog", # "jupyter_sphinx", - # "sphinxcontrib.youtube", # "nbsphinx", # "numpydoc", # "sphinx_togglebutton", # "jupyterlite_sphinx", - # "sphinx_favicon", + "sphinx_favicon", ] templates_path = ["_templates"] diff --git a/docs/index.md b/docs/index.md index cea4f85c..94d276fe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ html_theme.sidebar_secondary.remove: true

Parameterise and Optimise Battery Models with PyBOP

-Welcome to PyBOP, an advanced Python package dedicated to the optimization and parameterization of battery models. PyBOP is designed to streamline your workflow, whether you are conducting academic research, working in industry, or simply interested in battery technology and modelling. +Welcome to PyBOP, a Python package dedicated to the optimization and parameterization of battery models. PyBOP is designed to streamline your workflow, whether you are conducting academic research, working in industry, or simply interested in battery technology and modelling. **Version: v23.11** @@ -33,6 +33,6 @@ Welcome to PyBOP, an advanced Python package dedicated to the optimization and p :maxdepth: 2 :hidden: -contributing user_guide/index +contributing ``` diff --git a/docs/source/modules.rst b/docs/source/modules.rst deleted file mode 100644 index dfe70a82..00000000 --- a/docs/source/modules.rst +++ /dev/null @@ -1,7 +0,0 @@ -pybop -===== - -.. toctree:: - :maxdepth: 4 - - pybop diff --git a/docs/source/pybop.costs.rst b/docs/source/pybop.costs.rst deleted file mode 100644 index ea854a29..00000000 --- a/docs/source/pybop.costs.rst +++ /dev/null @@ -1,29 +0,0 @@ -Costs -=================== - -Submodules ----------- - -pybop.costs.error\_costs module -------------------------------- - -.. automodule:: pybop.costs.error_costs - :members: - :undoc-members: - :show-inheritance: - -pybop.costs.standalone module ------------------------------ - -.. automodule:: pybop.costs.standalone - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: pybop.costs - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/pybop.datasets.rst b/docs/source/pybop.datasets.rst deleted file mode 100644 index 27399b6e..00000000 --- a/docs/source/pybop.datasets.rst +++ /dev/null @@ -1,21 +0,0 @@ -pybop.datasets package -====================== - -Submodules ----------- - -pybop.datasets.base\_dataset module ------------------------------------ - -.. automodule:: pybop.datasets.base_dataset - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: pybop.datasets - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/pybop.optimisers.rst b/docs/source/pybop.optimisers.rst deleted file mode 100644 index f134f257..00000000 --- a/docs/source/pybop.optimisers.rst +++ /dev/null @@ -1,45 +0,0 @@ -pybop.optimisers package -======================== - -Submodules ----------- - -pybop.optimisers.base\_optimiser module ---------------------------------------- - -.. automodule:: pybop.optimisers.base_optimiser - :members: - :undoc-members: - :show-inheritance: - -pybop.optimisers.nlopt\_optimize module ---------------------------------------- - -.. automodule:: pybop.optimisers.nlopt_optimize - :members: - :undoc-members: - :show-inheritance: - -pybop.optimisers.pints\_optimisers module ------------------------------------------ - -.. automodule:: pybop.optimisers.pints_optimisers - :members: - :undoc-members: - :show-inheritance: - -pybop.optimisers.scipy\_minimize module ---------------------------------------- - -.. automodule:: pybop.optimisers.scipy_minimize - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: pybop.optimisers - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/pybop.parameters.rst b/docs/source/pybop.parameters.rst deleted file mode 100644 index 278ad82f..00000000 --- a/docs/source/pybop.parameters.rst +++ /dev/null @@ -1,37 +0,0 @@ -pybop.parameters package -======================== - -Submodules ----------- - -pybop.parameters.base\_parameter module ---------------------------------------- - -.. automodule:: pybop.parameters.base_parameter - :members: - :undoc-members: - :show-inheritance: - -pybop.parameters.base\_parameter\_set module --------------------------------------------- - -.. automodule:: pybop.parameters.base_parameter_set - :members: - :undoc-members: - :show-inheritance: - -pybop.parameters.priors module ------------------------------- - -.. automodule:: pybop.parameters.priors - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: pybop.parameters - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/pybop.plotting.rst b/docs/source/pybop.plotting.rst deleted file mode 100644 index 21064bb0..00000000 --- a/docs/source/pybop.plotting.rst +++ /dev/null @@ -1,53 +0,0 @@ -pybop.plotting package -====================== - -Submodules ----------- - -pybop.plotting.plot\_convergence module ---------------------------------------- - -.. automodule:: pybop.plotting.plot_convergence - :members: - :undoc-members: - :show-inheritance: - -pybop.plotting.plot\_cost2d module ----------------------------------- - -.. automodule:: pybop.plotting.plot_cost2d - :members: - :undoc-members: - :show-inheritance: - -pybop.plotting.plot\_parameters module --------------------------------------- - -.. automodule:: pybop.plotting.plot_parameters - :members: - :undoc-members: - :show-inheritance: - -pybop.plotting.plotly\_manager module -------------------------------------- - -.. automodule:: pybop.plotting.plotly_manager - :members: - :undoc-members: - :show-inheritance: - -pybop.plotting.quick\_plot module ---------------------------------- - -.. automodule:: pybop.plotting.quick_plot - :members: - :undoc-members: - :show-inheritance: - -Module contents ---------------- - -.. automodule:: pybop.plotting - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/pybop.rst b/docs/source/pybop.rst deleted file mode 100644 index d2ef17f3..00000000 --- a/docs/source/pybop.rst +++ /dev/null @@ -1,42 +0,0 @@ -pybop package -============= - -Subpackages ------------ - -.. toctree:: - :maxdepth: 4 - - pybop.costs - pybop.datasets - pybop.models - pybop.optimisers - pybop.parameters - pybop.plotting - -Submodules ----------- - -pybop.optimisation module -------------------------- - -.. automodule:: pybop.optimisation - :members: - :undoc-members: - :show-inheritance: - -.. pybop.version module -.. -------------------- - -.. .. automodule:: pybop.version -.. :members: -.. :undoc-members: -.. :show-inheritance: - -.. Module contents -.. --------------- - -.. .. automodule:: pybop -.. :members: -.. :undoc-members: -.. :show-inheritance: diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 76fd9cb2..0a5496f4 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -19,4 +19,4 @@ installation usage -```{toctree} +``` diff --git a/examples/costs/standalone.py b/examples/costs/standalone.py new file mode 100644 index 00000000..f4fa74f9 --- /dev/null +++ b/examples/costs/standalone.py @@ -0,0 +1,72 @@ +import pybop +import numpy as np + + +class StandaloneCost(pybop.BaseCost): + """ + A standalone cost function example that inherits from pybop.BaseCost. + + This class represents a simple cost function without a problem obkect, used for demonstration purposes. + It is a quadratic function of one variable with a constant term, defined by + the formula: cost(x) = x^2 + 42. + + Parameters + ---------- + problem : object, optional + A dummy problem instance used to initialize the superclass. This is not + used in the current class but is accepted for compatibility with the + BaseCost interface. + x0 : array-like + The initial guess for the optimization problem, set to [4.2]. + n_parameters : int + The number of parameters in the model, which is 1 in this case. + bounds : dict + A dictionary containing the lower and upper bounds for the parameter, + set to [-1] and [10], respectively. + + Methods + ------- + __call__(x, grad=None) + Calculate the cost for a given parameter value. + """ + + def __init__(self, problem=None): + """ + Initialize the StandaloneCost class with optional problem instance. + + The problem parameter is not utilized in this subclass. The initial guess, + number of parameters, and bounds are predefined for the standalone cost function. + """ + super().__init__(problem) + + self.x0 = np.array([4.2]) + self.n_parameters = len(self.x0) + + self.bounds = dict( + lower=[-1], + upper=[10], + ) + + def __call__(self, x, grad=None): + """ + Calculate the cost for a given parameter value. + + The cost function is defined as cost(x) = x^2 + 42, where x is the + parameter value. + + Parameters + ---------- + x : array-like + A one-element array containing the parameter value for which to + evaluate the cost. + grad : array-like, optional + Unused parameter, present for compatibility with gradient-based + optimizers. + + Returns + ------- + float + The calculated cost value for the given parameter. + """ + + return x[0] ** 2 + 42 diff --git a/examples/scripts/parameters/fit_ecm_parameters.json b/examples/scripts/parameters/fit_ecm_parameters.json new file mode 100644 index 00000000..63377470 --- /dev/null +++ b/examples/scripts/parameters/fit_ecm_parameters.json @@ -0,0 +1,24 @@ +{ + "chemistry": "ecm", + "Initial SoC": 0.5, + "Initial temperature [K]": 298.15, + "Cell capacity [A.h]": 5, + "Nominal cell capacity [A.h]": 5, + "Ambient temperature [K]": 298.15, + "Current function [A]": 5, + "Upper voltage cut-off [V]": 4.2, + "Lower voltage cut-off [V]": 3.0, + "Cell thermal mass [J/K]": 1000, + "Cell-jig heat transfer coefficient [W/K]": 10, + "Jig thermal mass [J/K]": 500, + "Jig-air heat transfer coefficient [W/K]": 10, + "Open-circuit voltage [V]": "Unable to write value to JSON file", + "R0 [Ohm]": 0.000944987987318333, + "Element-1 initial overpotential [V]": 0, + "Element-2 initial overpotential [V]": 0, + "R1 [Ohm]": 0.0002590935163068119, + "R2 [Ohm]": 0.0003, + "C1 [F]": 10000, + "C2 [F]": 5000, + "Entropic change [V/K]": 0.0004 +} diff --git a/noxfile.py b/noxfile.py index dddabfa7..739f86e0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -35,7 +35,7 @@ def notebooks(session): @nox.session -def build_docs(session): +def docs(session): """ Build the documentation and load it in a browser tab, rebuilding on changes. Credit: PyBaMM Team diff --git a/pybop/__init__.py b/pybop/__init__.py index 48a24b2d..7b1fad9e 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -26,12 +26,12 @@ # # Cost function class # -from .costs.error_costs import BaseCost, RootMeanSquaredError, SumSquaredError +from ._costs import BaseCost, RootMeanSquaredError, SumSquaredError # # Dataset class # -from .datasets.base_dataset import Dataset +from ._dataset import Dataset # # Model classes diff --git a/pybop/_costs.py b/pybop/_costs.py new file mode 100644 index 00000000..71ae6c98 --- /dev/null +++ b/pybop/_costs.py @@ -0,0 +1,229 @@ +import numpy as np + + +class BaseCost: + """ + Base class for defining cost functions. + + This class is intended to be subclassed to create specific cost functions + for evaluating model predictions against a set of data. The cost function + quantifies the goodness-of-fit between the model predictions and the + observed data, with a lower cost value indicating a better fit. + + Parameters + ---------- + problem : object + A problem instance containing the data and functions necessary for + evaluating the cost function. + _target : array-like + An array containing the target data to fit. + x0 : array-like + The initial guess for the model parameters. + bounds : tuple + The bounds for the model parameters. + n_parameters : int + The number of parameters in the model. + """ + + def __init__(self, problem): + self.problem = problem + if problem is not None: + self._target = problem._target + self.x0 = problem.x0 + self.bounds = problem.bounds + self.n_parameters = problem.n_parameters + + def __call__(self, x, grad=None): + """ + Calculate the cost function value for a given set of parameters. + + This method must be implemented by subclasses. + + Parameters + ---------- + x : array-like + The parameters for which to evaluate the cost. + grad : array-like, optional + An array to store the gradient of the cost function with respect + to the parameters. + + Returns + ------- + float + The calculated cost function value. + + Raises + ------ + NotImplementedError + If the method has not been implemented by the subclass. + """ + + raise NotImplementedError + + +class RootMeanSquaredError(BaseCost): + """ + Root mean square error cost function. + + Computes the root mean square error between model predictions and the target + data, providing a measure of the differences between predicted values and + observed values. + + Inherits all parameters and attributes from ``BaseCost``. + + """ + + def __init__(self, problem): + super(RootMeanSquaredError, self).__init__(problem) + + def __call__(self, x, grad=None): + """ + Calculate the root mean square error for a given set of parameters. + + Parameters + ---------- + x : array-like + The parameters for which to evaluate the cost. + grad : array-like, optional + An array to store the gradient of the cost function with respect + to the parameters. + + Returns + ------- + float + The root mean square error. + + Raises + ------ + ValueError + If an error occurs during the calculation of the cost. + """ + + try: + prediction = self.problem.evaluate(x) + + if len(prediction) < len(self._target): + return np.float64(np.inf) # simulation stopped early + else: + return np.sqrt(np.mean((prediction - self._target) ** 2)) + + except Exception as e: + raise ValueError(f"Error in cost calculation: {e}") + + +class SumSquaredError(BaseCost): + """ + Sum of squared errors cost function. + + Computes the sum of the squares of the differences between model predictions + and target data, which serves as a measure of the total error between the + predicted and observed values. + + Inherits all parameters and attributes from ``BaseCost``. + + Additional Attributes + --------------------- + _de : float + The gradient of the cost function to use if an error occurs during + evaluation. Defaults to 1.0. + + """ + + def __init__(self, problem): + super(SumSquaredError, self).__init__(problem) + + # Default fail gradient + self._de = 1.0 + + def __call__(self, x, grad=None): + """ + Calculate the sum of squared errors for a given set of parameters. + + Parameters + ---------- + x : array-like + The parameters for which to evaluate the cost. + grad : array-like, optional + An array to store the gradient of the cost function with respect + to the parameters. + + Returns + ------- + float + The sum of squared errors. + + Raises + ------ + ValueError + If an error occurs during the calculation of the cost. + """ + try: + prediction = self.problem.evaluate(x) + + if len(prediction) < len(self._target): + return np.float64(np.inf) # simulation stopped early + else: + return np.sum( + (np.sum(((prediction - self._target) ** 2), axis=0)), + axis=0, + ) + + except Exception as e: + raise ValueError(f"Error in cost calculation: {e}") + + def evaluateS1(self, x): + """ + Compute the cost and its gradient with respect to the parameters. + + Parameters + ---------- + x : array-like + The parameters for which to compute the cost and gradient. + + Returns + ------- + tuple + A tuple containing the cost and the gradient. The cost is a float, + and the gradient is an array-like of the same length as `x`. + + Raises + ------ + ValueError + If an error occurs during the calculation of the cost or gradient. + """ + try: + y, dy = self.problem.evaluateS1(x) + if len(y) < len(self._target): + e = np.float64(np.inf) + de = self._de * np.ones(self.problem.n_parameters) + else: + dy = dy.reshape( + ( + self.problem.n_time_data, + self.problem.n_outputs, + self.problem.n_parameters, + ) + ) + r = y - self._target + e = np.sum(np.sum(r**2, axis=0), axis=0) + de = 2 * np.sum(np.sum((r.T * dy.T), axis=2), axis=1) + + return e, de + + except Exception as e: + raise ValueError(f"Error in cost calculation: {e}") + + def set_fail_gradient(self, de): + """ + Set the fail gradient to a specified value. + + The fail gradient is used if an error occurs during the calculation + of the gradient. This method allows updating the default gradient value. + + Parameters + ---------- + de : float + The new fail gradient value to be used. + """ + de = float(de) + self._de = de diff --git a/pybop/_dataset.py b/pybop/_dataset.py new file mode 100644 index 00000000..9a5f6650 --- /dev/null +++ b/pybop/_dataset.py @@ -0,0 +1,64 @@ +import pybamm + + +class Dataset: + """ + Represents a collection of experimental observations. + + This class provides a structured way to store and work with experimental data, + which may include applying operations such as interpolation. + + Parameters + ---------- + name : str + The name of the dataset, providing a label for identification. + data : array-like + The actual experimental data, typically in a structured form such as + a NumPy array or a pandas DataFrame. + + """ + + def __init__(self, name, data): + """ + Initialize a Dataset instance with a name and data. + + Parameters + ---------- + name : str + The name for the dataset. + data : array-like + The experimental data to store within the dataset. + """ + + self.name = name + self.data = data + + def __repr__(self): + """ + Return a string representation of the Dataset instance. + + Returns + ------- + str + A string that includes the name and data of the dataset. + """ + return f"Dataset: {self.name} \n Data: {self.data}" + + def Interpolant(self): + """ + Create an interpolation function of the dataset based on the independent variable. + + Currently, only time-based interpolation is supported. This method modifies + the instance's Interpolant attribute to be an interpolation function that + can be evaluated at different points in time. + + Raises + ------ + NotImplementedError + If the independent variable for interpolation is not supported. + """ + + if self.variable == "time": + self.Interpolant = pybamm.Interpolant(self.x, self.y, pybamm.t) + else: + NotImplementedError("Only time interpolation is supported") diff --git a/pybop/costs/__init__.py b/pybop/costs/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pybop/costs/error_costs.py b/pybop/costs/error_costs.py deleted file mode 100644 index 2c497d45..00000000 --- a/pybop/costs/error_costs.py +++ /dev/null @@ -1,114 +0,0 @@ -import numpy as np - - -class BaseCost: - """ - Base class for defining cost functions. - This class computes a corresponding goodness-of-fit for a corresponding model prediction and dataset. - Lower cost values indicate a better fit. - """ - - def __init__(self, problem): - self.problem = problem - if problem is not None: - self._target = problem._target - self.x0 = problem.x0 - self.bounds = problem.bounds - self.n_parameters = problem.n_parameters - - def __call__(self, x, grad=None): - """ - Returns the cost function value and computes the cost. - """ - raise NotImplementedError - - -class RootMeanSquaredError(BaseCost): - """ - Defines the root mean square error cost function. - """ - - def __init__(self, problem): - super(RootMeanSquaredError, self).__init__(problem) - - def __call__(self, x, grad=None): - """ - Computes the cost. - """ - try: - prediction = self.problem.evaluate(x) - - if len(prediction) < len(self._target): - return np.float64(np.inf) # simulation stopped early - else: - return np.sqrt(np.mean((prediction - self._target) ** 2)) - - except Exception as e: - raise ValueError(f"Error in cost calculation: {e}") - - -class SumSquaredError(BaseCost): - """ - Defines the sum squared error cost function. - - The initial fail gradient is set equal to one, but this can be - changed at any time with :meth:`set_fail_gradient()`. - """ - - def __init__(self, problem): - super(SumSquaredError, self).__init__(problem) - - # Default fail gradient - self._de = 1.0 - - def __call__(self, x, grad=None): - """ - Computes the cost. - """ - try: - prediction = self.problem.evaluate(x) - - if len(prediction) < len(self._target): - return np.float64(np.inf) # simulation stopped early - else: - return np.sum( - (np.sum(((prediction - self._target) ** 2), axis=0)), - axis=0, - ) - - except Exception as e: - raise ValueError(f"Error in cost calculation: {e}") - - def evaluateS1(self, x): - """ - Compute the cost and corresponding - gradients with respect to the parameters. - """ - try: - y, dy = self.problem.evaluateS1(x) - if len(y) < len(self._target): - e = np.float64(np.inf) - de = self._de * np.ones(self.problem.n_parameters) - else: - dy = dy.reshape( - ( - self.problem.n_time_data, - self.problem.n_outputs, - self.problem.n_parameters, - ) - ) - r = y - self._target - e = np.sum(np.sum(r**2, axis=0), axis=0) - de = 2 * np.sum(np.sum((r.T * dy.T), axis=2), axis=1) - - return e, de - - except Exception as e: - raise ValueError(f"Error in cost calculation: {e}") - - def set_fail_gradient(self, de): - """ - Sets the fail gradient for this optimiser. - """ - de = float(de) - self._de = de diff --git a/pybop/costs/standalone.py b/pybop/costs/standalone.py deleted file mode 100644 index 197dcca5..00000000 --- a/pybop/costs/standalone.py +++ /dev/null @@ -1,18 +0,0 @@ -import pybop -import numpy as np - - -class StandaloneCost(pybop.BaseCost): - def __init__(self, problem=None): - super().__init__(problem) - - self.x0 = np.array([4.2]) - self.n_parameters = len(self.x0) - - self.bounds = dict( - lower=[-1], - upper=[10], - ) - - def __call__(self, x, grad=None): - return x[0] ** 2 + 42 diff --git a/pybop/datasets/__init__.py b/pybop/datasets/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pybop/datasets/base_dataset.py b/pybop/datasets/base_dataset.py deleted file mode 100644 index ed194ae4..00000000 --- a/pybop/datasets/base_dataset.py +++ /dev/null @@ -1,20 +0,0 @@ -import pybamm - - -class Dataset: - """ - Class for experimental observations. - """ - - def __init__(self, name, data): - self.name = name - self.data = data - - def __repr__(self): - return f"Dataset: {self.name} \n Data: {self.data}" - - def Interpolant(self): - if self.variable == "time": - self.Interpolant = pybamm.Interpolant(self.x, self.y, pybamm.t) - else: - NotImplementedError("Only time interpolation is supported") diff --git a/setup.py b/setup.py index 78b54e53..2437425a 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ "sphinx-autobuild", "sphinx-autoapi", "sphinx_copybutton", + "sphinx_favicon", "sphinx_design", "myst-parser", ], diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 22822753..b6adbbdf 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -1,7 +1,7 @@ import pybop import numpy as np import pytest -from pybop.costs.standalone import StandaloneCost +from examples.costs.standalone import StandaloneCost class TestOptimisation: From dafa7509d61da417fe9bbe384af8004504ed58a1 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sun, 10 Dec 2023 16:54:00 +0000 Subject: [PATCH 072/101] Updts docstrings across package, remove redundant base naming convention for certain filenames --- docs/_templates/autoapi/index.rst | 16 + docs/api/index.rst | 1 + docs/api/pybop/_problem/index.rst | 14 +- docs/api/pybop/index.rst | 717 ++++++++++++++++-- docs/api/pybop/models/base_model/index.rst | 117 ++- docs/api/pybop/models/empirical/ecm/index.rst | 47 ++ docs/api/pybop/models/empirical/index.rst | 56 ++ docs/api/pybop/models/index.rst | 1 + .../models/lithium_ion/base_echem/index.rst | 35 - .../pybop/models/lithium_ion/echem/index.rst | 76 ++ docs/api/pybop/models/lithium_ion/index.rst | 49 +- docs/api/pybop/optimisation/index.rst | 5 + .../pybop/optimisers/base_optimiser/index.rst | 52 +- docs/api/pybop/optimisers/index.rst | 2 +- .../pybop/optimisers/nlopt_optimize/index.rst | 57 +- .../optimisers/pints_optimisers/index.rst | 131 +++- .../pybop/optimisers/scipy_minimize/index.rst | 46 -- .../optimisers/scipy_optimisers/index.rst | 115 +++ .../pybop/parameters/base_parameter/index.rst | 41 - .../parameters/base_parameter_set/index.rst | 23 - docs/api/pybop/parameters/index.rst | 4 +- docs/api/pybop/parameters/parameter/index.rst | 95 +++ .../pybop/parameters/parameter_set/index.rst | 95 +++ docs/api/pybop/parameters/priors/index.rst | 112 ++- docs/conf.py | 1 + pybop/models/base_model.py | 141 +++- pybop/models/empirical/__init__.py | 2 +- .../models/empirical/{base_ecm.py => ecm.py} | 26 +- pybop/models/lithium_ion/__init__.py | 2 +- pybop/models/lithium_ion/base_echem.py | 88 --- pybop/models/lithium_ion/echem.py | 138 ++++ pybop/optimisers/base_optimiser.py | 59 +- pybop/optimisers/nlopt_optimize.py | 59 +- pybop/optimisers/pints_optimisers.py | 138 +++- pybop/optimisers/scipy_optimisers.py | 98 ++- pybop/parameters/parameter.py | 84 +- pybop/parameters/parameter_set.py | 83 +- pybop/parameters/priors.py | 175 ++++- 38 files changed, 2559 insertions(+), 442 deletions(-) create mode 100644 docs/_templates/autoapi/index.rst create mode 100644 docs/api/pybop/models/empirical/ecm/index.rst create mode 100644 docs/api/pybop/models/empirical/index.rst delete mode 100644 docs/api/pybop/models/lithium_ion/base_echem/index.rst create mode 100644 docs/api/pybop/models/lithium_ion/echem/index.rst delete mode 100644 docs/api/pybop/optimisers/scipy_minimize/index.rst create mode 100644 docs/api/pybop/optimisers/scipy_optimisers/index.rst delete mode 100644 docs/api/pybop/parameters/base_parameter/index.rst delete mode 100644 docs/api/pybop/parameters/base_parameter_set/index.rst create mode 100644 docs/api/pybop/parameters/parameter/index.rst create mode 100644 docs/api/pybop/parameters/parameter_set/index.rst rename pybop/models/empirical/{base_ecm.py => ecm.py} (51%) delete mode 100644 pybop/models/lithium_ion/base_echem.py create mode 100644 pybop/models/lithium_ion/echem.py diff --git a/docs/_templates/autoapi/index.rst b/docs/_templates/autoapi/index.rst new file mode 100644 index 00000000..8cb56d2e --- /dev/null +++ b/docs/_templates/autoapi/index.rst @@ -0,0 +1,16 @@ +API Reference +============= + +This page contains auto-generated API reference documentation [#f1]_. + +.. toctree:: + :titlesonly: + :maxdepth: 2 + + {% for page in pages %} + {% if page.top_level_object and page.display %} + {{ page.include_path }} + {% endif %} + {% endfor %} + +.. [#f1] Created with `sphinx-autoapi `_ diff --git a/docs/api/index.rst b/docs/api/index.rst index 52c74ac8..a248a75b 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -5,6 +5,7 @@ This page contains auto-generated API reference documentation [#f1]_. .. toctree:: :titlesonly: + :maxdepth: 2 /api/pybop/index diff --git a/docs/api/pybop/_problem/index.rst b/docs/api/pybop/_problem/index.rst index a51adad8..58091c11 100644 --- a/docs/api/pybop/_problem/index.rst +++ b/docs/api/pybop/_problem/index.rst @@ -24,13 +24,13 @@ Classes Defines the PyBOP base problem, following the PINTS interface. - .. py:method:: evaluate(parameters) + .. py:method:: evaluate(x) :abstractmethod: Evaluate the model with the given parameters and return the signal. - .. py:method:: evaluateS1(parameters) + .. py:method:: evaluateS1(x) :abstractmethod: Evaluate the model with the given parameters and return the signal and @@ -45,12 +45,12 @@ Classes Defines the problem class for a design optimiation problem. - .. py:method:: evaluate(parameters) + .. py:method:: evaluate(x) Evaluate the model with the given parameters and return the signal. - .. py:method:: evaluateS1(parameters) + .. py:method:: evaluateS1(x) Evaluate the model with the given parameters and return the signal and its derivatives. @@ -62,19 +62,19 @@ Classes -.. py:class:: FittingProblem(model, parameters, dataset, signal='Terminal voltage [V]', check_model=True, init_soc=None, x0=None) +.. py:class:: FittingProblem(model, parameters, dataset, signal='Voltage [V]', check_model=True, init_soc=None, x0=None) Bases: :py:obj:`BaseProblem` Defines the problem class for a fitting (parameter estimation) problem. - .. py:method:: evaluate(parameters) + .. py:method:: evaluate(x) Evaluate the model with the given parameters and return the signal. - .. py:method:: evaluateS1(parameters) + .. py:method:: evaluateS1(x) Evaluate the model with the given parameters and return the signal and its derivatives. diff --git a/docs/api/pybop/index.rst b/docs/api/pybop/index.rst index 6ac0d00d..a3132137 100644 --- a/docs/api/pybop/index.rst +++ b/docs/api/pybop/index.rst @@ -57,6 +57,7 @@ Classes pybop.PlotlyManager pybop.RootMeanSquaredError pybop.SNES + pybop.SciPyDifferentialEvolution pybop.SciPyMinimize pybop.StandardPlot pybop.SumSquaredError @@ -92,8 +93,23 @@ Attributes Bases: :py:obj:`pints.Adam` - Adam optimiser. Inherits from the PINTS Adam class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_adam.py + Implements the Adam optimization algorithm. + + This class extends the Adam optimizer from the PINTS library, which combines + ideas from RMSProp and Stochastic Gradient Descent with momentum. Note that + this optimizer does not support boundary constraints. + + :param x0: Initial position from which optimization will start. + :type x0: array_like + :param sigma0: Initial step size (default is 0.1). + :type sigma0: float, optional + :param bounds: Ignored by this optimizer, provided for API consistency. + :type bounds: sequence or ``Bounds``, optional + + .. seealso:: + + :obj:`pints.Adam` + The PINTS implementation this class is based on. .. py:class:: BaseCost(problem) @@ -141,7 +157,37 @@ Attributes .. py:class:: BaseModel(name='Base Model') - Base class for pybop models. + A base class for constructing and simulating models using PyBaMM. + + This class serves as a foundation for building specific models in PyBaMM. + It provides methods to set up the model, define parameters, and perform + simulations. The class is designed to be subclassed for creating models + with custom behavior. + + .. method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) + + Construct the PyBaMM model if not already built. + + .. method:: set_init_soc(init_soc) + + Set the initial state of charge for the battery model. + + .. method:: set_params() + + Assign the parameters to the model. + + .. method:: simulate(inputs, t_eval) + + Execute the forward model simulation and return the result. + + .. method:: simulateS1(inputs, t_eval) + + Perform the forward model simulation with sensitivities. + + .. method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) + + Solve the model using PyBaMM's simulation framework and return the solution. + .. py:property:: built_model @@ -172,60 +218,168 @@ Attributes .. py:method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) - Build the PyBOP model (if not built already). - For PyBaMM forward models, this method follows a - similar process to pybamm.Simulation.build(). + Construct the PyBaMM model if not already built, and set parameters. + + This method initializes the model components, applies the given parameters, + sets up the mesh and discretization if needed, and prepares the model + for simulations. + + :param dataset: The dataset to be used in the model construction. + :type dataset: pybamm.Dataset, optional + :param parameters: A dictionary containing parameter values to apply to the model. + :type parameters: dict, optional + :param check_model: If True, the model will be checked for correctness after construction. + :type check_model: bool, optional + :param init_soc: The initial state of charge to be used in simulations. + :type init_soc: float, optional .. py:method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) - Create a PyBaMM simulation object, solve it, and return a solution object. + Solve the model using PyBaMM's simulation framework and return the solution. + + This method sets up a PyBaMM simulation by configuring the model, parameters, experiment + (if any), and initial state of charge (if provided). It then solves the simulation and + returns the resulting solution object. + + :param inputs: Input parameters for the simulation. If the input is array-like, it is converted + to a dictionary using the model's fitting keys. Defaults to None, indicating + that the default parameters should be used. + :type inputs: dict or array-like, optional + :param t_eval: An array of time points at which to evaluate the solution. Defaults to None, + which means the time points need to be specified within experiment or elsewhere. + :type t_eval: array-like, optional + :param parameter_set: A PyBaMM ParameterValues object or a dictionary containing the parameter values + to use for the simulation. Defaults to the model's current ParameterValues if None. + :type parameter_set: pybamm.ParameterValues, optional + :param experiment: A PyBaMM Experiment object specifying the experimental conditions under which + the simulation should be run. Defaults to None, indicating no experiment. + :type experiment: pybamm.Experiment, optional + :param init_soc: The initial state of charge for the simulation, as a fraction (between 0 and 1). + Defaults to None. + :type init_soc: float, optional + + :returns: The solution object returned after solving the simulation. + :rtype: pybamm.Solution + + :raises ValueError: If the model has not been configured properly before calling this method or + if PyBaMM models are not supported by the current simulation method. .. py:method:: set_init_soc(init_soc) - Set the initial state of charge. + Set the initial state of charge for the battery model. + + :param init_soc: The initial state of charge to be used in the model. + :type init_soc: float .. py:method:: set_params() - Set the parameters in the model. + Assign the parameters to the model. + + This method processes the model with the given parameters, sets up + the geometry, and updates the model instance. .. py:method:: simulate(inputs, t_eval) - Run the forward model and return the result in Numpy array format - aligning with Pints' ForwardModel simulate method. + Execute the forward model simulation and return the result. + + :param inputs: The input parameters for the simulation. If array-like, it will be + converted to a dictionary using the model's fit keys. + :type inputs: dict or array-like + :param t_eval: An array of time points at which to evaluate the solution. + :type t_eval: array-like + + :returns: The simulation result corresponding to the specified signal. + :rtype: array-like + + :raises ValueError: If the model has not been built before simulation. .. py:method:: simulateS1(inputs, t_eval) - Run the forward model and return the function evaulation and it's gradient - aligning with Pints' ForwardModel simulateS1 method. + Perform the forward model simulation with sensitivities. + + :param inputs: The input parameters for the simulation. If array-like, it will be + converted to a dictionary using the model's fit keys. + :type inputs: dict or array-like + :param t_eval: An array of time points at which to evaluate the solution and its + sensitivities. + :type t_eval: array-like + + :returns: A tuple containing the simulation result and the sensitivities. + :rtype: tuple + + :raises ValueError: If the model has not been built before simulation. .. py:class:: BaseOptimiser - Base class for the optimisation methods. + A base class for defining optimisation methods. + + This class serves as a template for creating optimisers. It provides a basic structure for + an optimisation algorithm, including the initial setup and a method stub for performing + the optimisation process. Child classes should override the optimise and _runoptimise + methods with specific algorithms. + + .. method:: optimise(cost_function, x0=None, bounds=None, maxiter=None) + + Initiates the optimisation process. This is a stub and should be implemented in child classes. + + .. method:: _runoptimise(cost_function, x0=None, bounds=None) + + Contains the logic for the optimisation algorithm. This is a stub and should be implemented in child classes. + + .. method:: name() + + Returns the name of the optimiser. .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) - Run optimisation method, to be overloaded by child classes. + Contains the logic for the optimisation algorithm. + + This method should be implemented by child classes to perform the actual optimisation. + :param cost_function: The cost function to be minimised by the optimiser. + :type cost_function: callable + :param x0: Initial guess for the parameters. Default is None. + :type x0: ndarray, optional + :param bounds: Bounds on the parameters. Default is None. + :type bounds: sequence or Bounds, optional + + :returns: * *This method is expected to return the result of the optimisation, the format of which* + * *will be determined by the child class implementation.* .. py:method:: name() Returns the name of the optimiser. + :returns: The name of the optimiser, which is "BaseOptimiser" for this base class. + :rtype: str + + + .. py:method:: optimise(cost_function, x0=None, bounds=None, maxiter=None) - .. py:method:: optimise(cost_function, x0=None, bounds=None) + Initiates the optimisation process. - Optimisiation method to be overloaded by child classes. + This method should be overridden by child classes with the specific optimisation algorithm. + :param cost_function: The cost function to be minimised by the optimiser. + :type cost_function: callable + :param x0: Initial guess for the parameters. Default is None. + :type x0: ndarray, optional + :param bounds: Bounds on the parameters. Default is None. + :type bounds: sequence or Bounds, optional + :param maxiter: Maximum number of iterations to perform. Default is None. + :type maxiter: int, optional + + :rtype: The result of the optimisation process. The specific type of this result will depend on the child implementation. @@ -234,8 +388,23 @@ Attributes Bases: :py:obj:`pints.CMAES` - Class for the PINTS optimisation. Extends the BaseOptimiser class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_cmaes.py + Adapter for the Covariance Matrix Adaptation Evolution Strategy (CMA-ES) optimizer in PINTS. + + CMA-ES is an evolutionary algorithm for difficult non-linear non-convex optimization problems. + It adapts the covariance matrix of a multivariate normal distribution to capture the shape of the cost landscape. + + :param x0: The initial parameter vector to optimize. + :type x0: array_like + :param sigma0: Initial standard deviation of the sampling distribution, defaults to 0.1. + :type sigma0: float, optional + :param bounds: A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. + If ``None``, no bounds are enforced. + :type bounds: dict, optional + + .. seealso:: + + :obj:`pints.CMAES` + PINTS implementation of CMA-ES algorithm. .. py:class:: Dataset(name, data) @@ -279,12 +448,12 @@ Attributes Defines the problem class for a design optimiation problem. - .. py:method:: evaluate(parameters) + .. py:method:: evaluate(x) Evaluate the model with the given parameters and return the signal. - .. py:method:: evaluateS1(parameters) + .. py:method:: evaluateS1(x) Evaluate the model with the given parameters and return the signal and its derivatives. @@ -299,36 +468,68 @@ Attributes .. py:class:: Exponential(scale) - Exponential prior class. + Represents an exponential distribution with a specified scale parameter. + + This class provides methods to calculate the pdf, the log pdf, and to generate random + variates from the distribution. + + :param scale: The scale parameter (lambda) of the exponential distribution. + :type scale: float .. py:method:: __repr__() - Return repr(self). + Returns a string representation of the Uniform object. .. py:method:: logpdf(x) + Calculates the logarithm of the pdf of the exponential distribution at x. + + :param x: The point at which to evaluate the log pdf. + :type x: float + + :returns: The log of the probability density function value at x. + :rtype: float + .. py:method:: pdf(x) + Calculates the probability density function of the exponential distribution at x. + + :param x: The point at which to evaluate the pdf. + :type x: float + + :returns: The probability density function value at x. + :rtype: float + .. py:method:: rvs(size) + Generates random variates from the exponential distribution. + :param size: The number of random variates to generate. + :type size: int -.. py:class:: FittingProblem(model, parameters, dataset, signal='Terminal voltage [V]', check_model=True, init_soc=None, x0=None) + :returns: An array of random variates from the exponential distribution. + :rtype: array_like + + :raises ValueError: If the size parameter is not positive. + + + +.. py:class:: FittingProblem(model, parameters, dataset, signal='Voltage [V]', check_model=True, init_soc=None, x0=None) Bases: :py:obj:`BaseProblem` Defines the problem class for a fitting (parameter estimation) problem. - .. py:method:: evaluate(parameters) + .. py:method:: evaluate(x) Evaluate the model with the given parameters and return the signal. - .. py:method:: evaluateS1(parameters) + .. py:method:: evaluateS1(x) Evaluate the model with the given parameters and return the signal and its derivatives. @@ -343,21 +544,55 @@ Attributes .. py:class:: Gaussian(mean, sigma) - Gaussian prior class. + Represents a Gaussian (normal) distribution with a given mean and standard deviation. + + This class provides methods to calculate the probability density function (pdf), + the logarithm of the pdf, and to generate random variates (rvs) from the distribution. + + :param mean: The mean (mu) of the Gaussian distribution. + :type mean: float + :param sigma: The standard deviation (sigma) of the Gaussian distribution. + :type sigma: float .. py:method:: __repr__() - Return repr(self). + Returns a string representation of the Gaussian object. .. py:method:: logpdf(x) + Calculates the logarithm of the probability density function of the Gaussian distribution at x. + + :param x: The point at which to evaluate the log pdf. + :type x: float + + :returns: The logarithm of the probability density function value at x. + :rtype: float + .. py:method:: pdf(x) + Calculates the probability density function of the Gaussian distribution at x. + + :param x: The point at which to evaluate the pdf. + :type x: float + + :returns: The probability density function value at x. + :rtype: float + .. py:method:: rvs(size) + Generates random variates from the Gaussian distribution. + + :param size: The number of random variates to generate. + :type size: int + + :returns: An array of random variates from the Gaussian distribution. + :rtype: array_like + + :raises ValueError: If the size parameter is not positive. + .. py:class:: GradientDescent(x0, sigma0=0.1, bounds=None) @@ -365,8 +600,23 @@ Attributes Bases: :py:obj:`pints.GradientDescent` - Gradient descent optimiser. Inherits from the PINTS gradient descent class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_gradient_descent.py + Implements a simple gradient descent optimization algorithm. + + This class extends the gradient descent optimizer from the PINTS library, designed + to minimize a scalar function of one or more variables. Note that this optimizer + does not support boundary constraints. + + :param x0: Initial position from which optimization will start. + :type x0: array_like + :param sigma0: Initial step size (default is 0.1). + :type sigma0: float, optional + :param bounds: Ignored by this optimizer, provided for API consistency. + :type bounds: sequence or ``Bounds``, optional + + .. seealso:: + + :obj:`pints.GradientDescent` + The PINTS implementation this class is based on. .. py:class:: IRPropMin(x0, sigma0=0.1, bounds=None) @@ -374,37 +624,87 @@ Attributes Bases: :py:obj:`pints.IRPropMin` - IRProp- optimiser. Inherits from the PINTS IRPropMinus class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_irpropmin.py + Implements the iRpropMin optimization algorithm. + + This class inherits from the PINTS IRPropMin class, which is an optimizer that + uses resilient backpropagation with weight-backtracking. It is designed to handle + problems with large plateaus, noisy gradients, and local minima. + + :param x0: Initial position from which optimization will start. + :type x0: array_like + :param sigma0: Initial step size (default is 0.1). + :type sigma0: float, optional + :param bounds: Lower and upper bounds for each optimization parameter. + :type bounds: dict, optional + .. seealso:: -.. py:class:: NLoptOptimize(n_param, xtol=None, method=None) + :obj:`pints.IRPropMin` + The PINTS implementation this class is based on. + + +.. py:class:: NLoptOptimize(n_param, xtol=None, method=None, maxiter=None) Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - Wrapper class for the NLOpt optimiser class. Extends the BaseOptimiser class. + Extends BaseOptimiser to utilize the NLopt library for nonlinear optimization. + + This class serves as an interface to the NLopt optimization algorithms. It allows the user to + define an optimization problem with bounds, initial guesses, and to select an optimization method + provided by NLopt. + + :param n_param: Number of parameters to optimize. + :type n_param: int + :param xtol: The relative tolerance for optimization (stopping criteria). If not provided, a default of 1e-5 is used. + :type xtol: float, optional + :param method: The NLopt algorithm to use for optimization. If not provided, LN_BOBYQA is used by default. + :type method: nlopt.algorithm, optional + :param maxiter: The maximum number of iterations to perform during optimization. If not provided, NLopt's default is used. + :type maxiter: int, optional + + .. method:: _runoptimise(cost_function, x0, bounds) + + Performs the optimization using the NLopt library. + + .. method:: needs_sensitivities() + + Indicates whether the optimizer requires gradient information. + + .. method:: name() + + Returns the name of the optimizer. + .. py:method:: _runoptimise(cost_function, x0, bounds) - Run the NLOpt optimisation method. + Runs the optimization process using the NLopt library. - Inputs - ---------- - cost_function: function for optimising - method: optimisation algorithm - x0: initialisation array - bounds: bounds array + :param cost_function: The objective function to minimize. It should take an array of parameter values and return the scalar cost. + :type cost_function: callable + :param x0: The initial guess for the parameters. + :type x0: array_like + :param bounds: A dictionary containing the 'lower' and 'upper' bounds arrays for the parameters. + :type bounds: dict + + :returns: A tuple containing the optimized parameter values and the final cost. + :rtype: tuple .. py:method:: name() - Returns the name of the optimiser. + Returns the name of this optimizer instance. + + :returns: The name 'NLoptOptimize' representing this NLopt optimization class. + :rtype: str .. py:method:: needs_sensitivities() - Returns True if the optimiser needs sensitivities. + Indicates if the optimizer requires gradient information for the cost function. + + :returns: False, as the default NLopt algorithms do not require gradient information. + :rtype: bool @@ -511,45 +811,192 @@ Attributes Credit: PINTS + .. py:method:: store_optimised_parameters(x) + + Store the optimised parameters in the PyBOP parameter class. + + .. py:class:: PSO(x0, sigma0=0.1, bounds=None) Bases: :py:obj:`pints.PSO` - Particle swarm optimiser. Inherits from the PINTS PSO class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_pso.py + Implements a particle swarm optimization (PSO) algorithm. + + This class extends the PSO optimizer from the PINTS library. PSO is a + metaheuristic optimization method inspired by the social behavior of birds + flocking or fish schooling, suitable for global optimization problems. + + :param x0: Initial positions of particles, which the optimization will use. + :type x0: array_like + :param sigma0: Spread of the initial particle positions (default is 0.1). + :type sigma0: float, optional + :param bounds: Lower and upper bounds for each optimization parameter. + :type bounds: dict, optional + .. seealso:: -.. py:class:: Parameter(name, value=None, prior=None, bounds=None) + :obj:`pints.PSO` + The PINTS implementation this class is based on. - "" - Class for creating parameters in PyBOP. +.. py:class:: Parameter(name, initial_value=None, prior=None, bounds=None) + + + Represents a parameter within the PyBOP framework. + + This class encapsulates the definition of a parameter, including its name, prior + distribution, initial value, bounds, and a margin to ensure the parameter stays + within feasible limits during optimization or sampling. + + :param name: The name of the parameter. + :type name: str + :param initial_value: The initial value to be assigned to the parameter. Defaults to None. + :type initial_value: float, optional + :param prior: The prior distribution from which parameter values are drawn. Defaults to None. + :type prior: scipy.stats distribution, optional + :param bounds: A tuple defining the lower and upper bounds for the parameter. + Defaults to None. + :type bounds: tuple, optional + + .. method:: rvs(n_samples) + + Draw random samples from the parameter's prior distribution. + + .. method:: update(value) + + Update the parameter's current value. + + .. method:: set_margin(margin) + + Set the margin to a specified positive value less than 1. + + + :raises ValueError: If the lower bound is not strictly less than the upper bound, or if + the margin is set outside the interval (0, 1). .. py:method:: __repr__() - Return repr(self). + Return a string representation of the Parameter instance. + + :returns: A string including the parameter's name, prior, bounds, and current value. + :rtype: str .. py:method:: rvs(n_samples) - Returns a random value sample from the prior distribution. + Draw random samples from the parameter's prior distribution. + + The samples are constrained to be within the parameter's bounds, excluding + a predefined margin at the boundaries. + + :param n_samples: The number of samples to draw. + :type n_samples: int + + :returns: An array of samples drawn from the prior distribution within the parameter's bounds. + :rtype: array-like .. py:method:: set_margin(margin) - Sets the margin for the parameter. + Set the margin to a specified positive value less than 1. + + The margin is used to ensure parameter samples are not drawn exactly at the bounds, + which may be problematic in some optimization or sampling algorithms. + + :param margin: The new margin value to be used, which must be in the interval (0, 1). + :type margin: float + + :raises ValueError: If the margin is not between 0 and 1. .. py:method:: update(value) + Update the parameter's current value. + + :param value: The new value to be assigned to the parameter. + :type value: float + + + +.. py:class:: ParameterSet(json_path=None, params_dict=None) + + + Handles the import and export of parameter sets for battery models. + + This class provides methods to load parameters from a JSON file and to export them + back to a JSON file. It also includes custom logic to handle special cases, such + as parameter values that require specific initialization. + :param json_path: Path to a JSON file containing parameter data. If provided, parameters will be imported from this file during initialization. + :type json_path: str, optional + :param params_dict: A dictionary of parameters to initialize the ParameterSet with. If not provided, an empty dictionary is used. + :type params_dict: dict, optional -.. py:class:: ParameterSet + .. py:method:: _handle_special_cases() + Processes special cases for parameter values that require custom handling. + + For example, if the open-circuit voltage is specified as 'default', it will + fetch the default value from the PyBaMM empirical Thevenin model. + + + .. py:method:: export_parameters(output_json_path, fit_params=None) + + Exports parameters to a JSON file specified by `output_json_path`. + + The current state of the `params` attribute is written to the file. If `fit_params` + is provided, these parameters are updated before export. Non-serializable values + are handled and noted in the output JSON. + + :param output_json_path: The file path where the JSON output will be saved. + :type output_json_path: str + :param fit_params: Parameters that have been fitted and need to be included in the export. + :type fit_params: list of fitted parameter objects, optional + + :raises ValueError: If there are no parameters to export. + + + .. py:method:: import_parameters(json_path=None) + + Imports parameters from a JSON file specified by the `json_path` attribute. + + If a `json_path` is provided at initialization or as an argument, that JSON file + is loaded and the parameters are stored in the `params` attribute. Special cases + are handled appropriately. + + :param json_path: Path to the JSON file from which to import parameters. If provided, it overrides the instance's `json_path`. + :type json_path: str, optional + + :returns: The dictionary containing the imported parameters. + :rtype: dict + + :raises FileNotFoundError: If the specified JSON file cannot be found. + + + .. py:method:: is_json_serializable(value) + + Determines if the given `value` can be serialized to JSON format. + + :param value: The value to check for JSON serializability. + :type value: any + + :returns: True if the value is JSON serializable, False otherwise. + :rtype: bool + + + .. py:method:: pybamm(name) + :classmethod: + + Retrieves a PyBaMM parameter set by name. + + :param name: The name of the PyBaMM parameter set to retrieve. + :type name: str + + :returns: A PyBaMM parameter set corresponding to the provided name. + :rtype: pybamm.ParameterValues - Class for creating parameter sets in PyBOP. .. py:class:: PlotlyManager @@ -642,37 +1089,120 @@ Attributes Bases: :py:obj:`pints.SNES` - Stochastic natural evolution strategy optimiser. Inherits from the PINTS SNES class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_snes.py + Implements the stochastic natural evolution strategy (SNES) optimization algorithm. + + Inheriting from the PINTS SNES class, this optimizer is an evolutionary algorithm + that evolves a probability distribution on the parameter space, guiding the search + for the optimum based on the natural gradient of expected fitness. + + :param x0: Initial position from which optimization will start. + :type x0: array_like + :param sigma0: Initial step size (default is 0.1). + :type sigma0: float, optional + :param bounds: Lower and upper bounds for each optimization parameter. + :type bounds: dict, optional + + .. seealso:: + :obj:`pints.SNES` + The PINTS implementation this class is based on. -.. py:class:: SciPyMinimize(method=None, bounds=None) + +.. py:class:: SciPyDifferentialEvolution(bounds=None, strategy='best1bin', maxiter=1000, popsize=15) + + + Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` + + Adapts SciPy's differential_evolution function for global optimization. + + This class provides a global optimization strategy based on differential evolution, useful for problems involving continuous parameters and potentially multiple local minima. + + :param bounds: Bounds for variables. Must be provided as it is essential for differential evolution. + :type bounds: sequence or ``Bounds`` + :param strategy: The differential evolution strategy to use. Defaults to 'best1bin'. + :type strategy: str, optional + :param maxiter: Maximum number of iterations to perform. Defaults to 1000. + :type maxiter: int, optional + :param popsize: The number of individuals in the population. Defaults to 15. + :type popsize: int, optional + + .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) + + Executes the optimization process using SciPy's differential_evolution function. + + :param cost_function: The objective function to minimize. + :type cost_function: callable + :param x0: Ignored parameter, provided for API consistency. + :type x0: array_like, optional + :param bounds: Bounds for the variables, required for differential evolution. + :type bounds: sequence or ``Bounds`` + + :returns: A tuple (x, final_cost) containing the optimized parameters and the value of ``cost_function`` at the optimum. + :rtype: tuple + + + .. py:method:: name() + + Provides the name of the optimization strategy. + + :returns: The name 'SciPyDifferentialEvolution'. + :rtype: str + + + .. py:method:: needs_sensitivities() + + Determines if the optimization algorithm requires gradient information. + + :returns: False, indicating that gradient information is not required for differential evolution. + :rtype: bool + + + +.. py:class:: SciPyMinimize(method=None, bounds=None, maxiter=None) Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. + Adapts SciPy's minimize function for use as an optimization strategy. + + This class provides an interface to various scalar minimization algorithms implemented in SciPy, allowing fine-tuning of the optimization process through method selection and option configuration. + + :param method: The type of solver to use. If not specified, defaults to 'COBYLA'. + :type method: str, optional + :param bounds: Bounds for variables as supported by the selected method. + :type bounds: sequence or ``Bounds``, optional + :param maxiter: Maximum number of iterations to perform. + :type maxiter: int, optional .. py:method:: _runoptimise(cost_function, x0, bounds) - Run the SciPy optimisation method. + Executes the optimization process using SciPy's minimize function. - Inputs - ---------- - cost_function: function for optimising - method: optimisation algorithm - x0: initialisation array - bounds: bounds array + :param cost_function: The objective function to minimize. + :type cost_function: callable + :param x0: Initial guess for the parameters. + :type x0: array_like + :param bounds: Bounds for the variables. + :type bounds: sequence or `Bounds` + + :returns: A tuple (x, final_cost) containing the optimized parameters and the value of `cost_function` at the optimum. + :rtype: tuple .. py:method:: name() - Returns the name of the optimiser. + Provides the name of the optimization strategy. + + :returns: The name 'SciPyMinimize'. + :rtype: str .. py:method:: needs_sensitivities() - Returns True if the optimiser needs sensitivities. + Determines if the optimization algorithm requires gradient information. + + :returns: False, indicating that gradient information is not required. + :rtype: bool @@ -816,21 +1346,55 @@ Attributes .. py:class:: Uniform(lower, upper) - Uniform prior class. + Represents a uniform distribution over a specified interval. + + This class provides methods to calculate the pdf, the log pdf, and to generate + random variates from the distribution. + + :param lower: The lower bound of the distribution. + :type lower: float + :param upper: The upper bound of the distribution. + :type upper: float .. py:method:: __repr__() - Return repr(self). + Returns a string representation of the Uniform object. .. py:method:: logpdf(x) + Calculates the logarithm of the pdf of the uniform distribution at x. + + :param x: The point at which to evaluate the log pdf. + :type x: float + + :returns: The log of the probability density function value at x. + :rtype: float + .. py:method:: pdf(x) + Calculates the probability density function of the uniform distribution at x. + + :param x: The point at which to evaluate the pdf. + :type x: float + + :returns: The probability density function value at x. + :rtype: float + .. py:method:: rvs(size) + Generates random variates from the uniform distribution. + + :param size: The number of random variates to generate. + :type size: int + + :returns: An array of random variates from the uniform distribution. + :rtype: array_like + + :raises ValueError: If the size parameter is not positive. + .. py:class:: XNES(x0, sigma0=0.1, bounds=None) @@ -838,8 +1402,21 @@ Attributes Bases: :py:obj:`pints.XNES` - Exponential natural evolution strategy optimiser. Inherits from the PINTS XNES class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_xnes.py + Implements the Exponential Natural Evolution Strategy (XNES) optimizer from PINTS. + + XNES is an evolutionary algorithm that samples from a multivariate normal distribution, which is updated iteratively to fit the distribution of successful solutions. + + :param x0: The initial parameter vector to optimize. + :type x0: array_like + :param sigma0: Initial standard deviation of the sampling distribution, defaults to 0.1. + :type sigma0: float, optional + :param bounds: A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. If ``None``, no bounds are enforced. + :type bounds: dict, optional + + .. seealso:: + + :obj:`pints.XNES` + PINTS implementation of XNES algorithm. .. py:function:: plot_convergence(optim, xaxis_title='Iteration', yaxis_title='Cost', title='Convergence') diff --git a/docs/api/pybop/models/base_model/index.rst b/docs/api/pybop/models/base_model/index.rst index 2eee2080..ab17a5db 100644 --- a/docs/api/pybop/models/base_model/index.rst +++ b/docs/api/pybop/models/base_model/index.rst @@ -20,7 +20,37 @@ Classes .. py:class:: BaseModel(name='Base Model') - Base class for pybop models. + A base class for constructing and simulating models using PyBaMM. + + This class serves as a foundation for building specific models in PyBaMM. + It provides methods to set up the model, define parameters, and perform + simulations. The class is designed to be subclassed for creating models + with custom behavior. + + .. method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) + + Construct the PyBaMM model if not already built. + + .. method:: set_init_soc(init_soc) + + Set the initial state of charge for the battery model. + + .. method:: set_params() + + Assign the parameters to the model. + + .. method:: simulate(inputs, t_eval) + + Execute the forward model simulation and return the result. + + .. method:: simulateS1(inputs, t_eval) + + Perform the forward model simulation with sensitivities. + + .. method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) + + Solve the model using PyBaMM's simulation framework and return the solution. + .. py:property:: built_model @@ -51,33 +81,98 @@ Classes .. py:method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) - Build the PyBOP model (if not built already). - For PyBaMM forward models, this method follows a - similar process to pybamm.Simulation.build(). + Construct the PyBaMM model if not already built, and set parameters. + + This method initializes the model components, applies the given parameters, + sets up the mesh and discretization if needed, and prepares the model + for simulations. + + :param dataset: The dataset to be used in the model construction. + :type dataset: pybamm.Dataset, optional + :param parameters: A dictionary containing parameter values to apply to the model. + :type parameters: dict, optional + :param check_model: If True, the model will be checked for correctness after construction. + :type check_model: bool, optional + :param init_soc: The initial state of charge to be used in simulations. + :type init_soc: float, optional .. py:method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) - Create a PyBaMM simulation object, solve it, and return a solution object. + Solve the model using PyBaMM's simulation framework and return the solution. + + This method sets up a PyBaMM simulation by configuring the model, parameters, experiment + (if any), and initial state of charge (if provided). It then solves the simulation and + returns the resulting solution object. + + :param inputs: Input parameters for the simulation. If the input is array-like, it is converted + to a dictionary using the model's fitting keys. Defaults to None, indicating + that the default parameters should be used. + :type inputs: dict or array-like, optional + :param t_eval: An array of time points at which to evaluate the solution. Defaults to None, + which means the time points need to be specified within experiment or elsewhere. + :type t_eval: array-like, optional + :param parameter_set: A PyBaMM ParameterValues object or a dictionary containing the parameter values + to use for the simulation. Defaults to the model's current ParameterValues if None. + :type parameter_set: pybamm.ParameterValues, optional + :param experiment: A PyBaMM Experiment object specifying the experimental conditions under which + the simulation should be run. Defaults to None, indicating no experiment. + :type experiment: pybamm.Experiment, optional + :param init_soc: The initial state of charge for the simulation, as a fraction (between 0 and 1). + Defaults to None. + :type init_soc: float, optional + + :returns: The solution object returned after solving the simulation. + :rtype: pybamm.Solution + + :raises ValueError: If the model has not been configured properly before calling this method or + if PyBaMM models are not supported by the current simulation method. .. py:method:: set_init_soc(init_soc) - Set the initial state of charge. + Set the initial state of charge for the battery model. + + :param init_soc: The initial state of charge to be used in the model. + :type init_soc: float .. py:method:: set_params() - Set the parameters in the model. + Assign the parameters to the model. + + This method processes the model with the given parameters, sets up + the geometry, and updates the model instance. .. py:method:: simulate(inputs, t_eval) - Run the forward model and return the result in Numpy array format - aligning with Pints' ForwardModel simulate method. + Execute the forward model simulation and return the result. + + :param inputs: The input parameters for the simulation. If array-like, it will be + converted to a dictionary using the model's fit keys. + :type inputs: dict or array-like + :param t_eval: An array of time points at which to evaluate the solution. + :type t_eval: array-like + + :returns: The simulation result corresponding to the specified signal. + :rtype: array-like + + :raises ValueError: If the model has not been built before simulation. .. py:method:: simulateS1(inputs, t_eval) - Run the forward model and return the function evaulation and it's gradient - aligning with Pints' ForwardModel simulateS1 method. + Perform the forward model simulation with sensitivities. + + :param inputs: The input parameters for the simulation. If array-like, it will be + converted to a dictionary using the model's fit keys. + :type inputs: dict or array-like + :param t_eval: An array of time points at which to evaluate the solution and its + sensitivities. + :type t_eval: array-like + + :returns: A tuple containing the simulation result and the sensitivities. + :rtype: tuple + + :raises ValueError: If the model has not been built before simulation. diff --git a/docs/api/pybop/models/empirical/ecm/index.rst b/docs/api/pybop/models/empirical/ecm/index.rst new file mode 100644 index 00000000..e861789d --- /dev/null +++ b/docs/api/pybop/models/empirical/ecm/index.rst @@ -0,0 +1,47 @@ +:py:mod:`pybop.models.empirical.ecm` +==================================== + +.. py:module:: pybop.models.empirical.ecm + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.models.empirical.ecm.Thevenin + + + + +.. py:class:: Thevenin(name='Equivalent Circuit Thevenin Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None, **kwargs) + + + Bases: :py:obj:`pybop.models.base_model.BaseModel` + + The Thevenin class represents an equivalent circuit model based on the Thevenin model in PyBaMM. + + This class encapsulates the PyBaMM equivalent circuit Thevenin model, providing an interface + to define the parameters, geometry, submesh types, variable points, spatial methods, and solver + to be used for simulations. + + :param name: A name for the model instance. Defaults to "Equivalent Circuit Thevenin Model". + :type name: str, optional + :param parameter_set: A dictionary of parameters to be used for the model. If None, the default parameters from PyBaMM are used. + :type parameter_set: dict or None, optional + :param geometry: The geometry definitions for the model. If None, the default geometry from PyBaMM is used. + :type geometry: dict or None, optional + :param submesh_types: The types of submeshes to use. If None, the default submesh types from PyBaMM are used. + :type submesh_types: dict or None, optional + :param var_pts: The number of points for each variable in the model to define the discretization. If None, the default is used. + :type var_pts: dict or None, optional + :param spatial_methods: The spatial methods to be used for discretization. If None, the default spatial methods from PyBaMM are used. + :type spatial_methods: dict or None, optional + :param solver: The solver to use for simulating the model. If None, the default solver from PyBaMM is used. + :type solver: pybamm.Solver or None, optional + :param options: A dictionary of options to pass to the PyBaMM Thevenin model. + :type options: dict or None, optional + :param \*\*kwargs: Additional arguments passed to the PyBaMM Thevenin model constructor. diff --git a/docs/api/pybop/models/empirical/index.rst b/docs/api/pybop/models/empirical/index.rst new file mode 100644 index 00000000..0adb3d9e --- /dev/null +++ b/docs/api/pybop/models/empirical/index.rst @@ -0,0 +1,56 @@ +:py:mod:`pybop.models.empirical` +================================ + +.. py:module:: pybop.models.empirical + + +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + + ecm/index.rst + + +Package Contents +---------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.models.empirical.Thevenin + + + + +.. py:class:: Thevenin(name='Equivalent Circuit Thevenin Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None, **kwargs) + + + Bases: :py:obj:`pybop.models.base_model.BaseModel` + + The Thevenin class represents an equivalent circuit model based on the Thevenin model in PyBaMM. + + This class encapsulates the PyBaMM equivalent circuit Thevenin model, providing an interface + to define the parameters, geometry, submesh types, variable points, spatial methods, and solver + to be used for simulations. + + :param name: A name for the model instance. Defaults to "Equivalent Circuit Thevenin Model". + :type name: str, optional + :param parameter_set: A dictionary of parameters to be used for the model. If None, the default parameters from PyBaMM are used. + :type parameter_set: dict or None, optional + :param geometry: The geometry definitions for the model. If None, the default geometry from PyBaMM is used. + :type geometry: dict or None, optional + :param submesh_types: The types of submeshes to use. If None, the default submesh types from PyBaMM are used. + :type submesh_types: dict or None, optional + :param var_pts: The number of points for each variable in the model to define the discretization. If None, the default is used. + :type var_pts: dict or None, optional + :param spatial_methods: The spatial methods to be used for discretization. If None, the default spatial methods from PyBaMM are used. + :type spatial_methods: dict or None, optional + :param solver: The solver to use for simulating the model. If None, the default solver from PyBaMM is used. + :type solver: pybamm.Solver or None, optional + :param options: A dictionary of options to pass to the PyBaMM Thevenin model. + :type options: dict or None, optional + :param \*\*kwargs: Additional arguments passed to the PyBaMM Thevenin model constructor. diff --git a/docs/api/pybop/models/index.rst b/docs/api/pybop/models/index.rst index 6b06a221..20988ab8 100644 --- a/docs/api/pybop/models/index.rst +++ b/docs/api/pybop/models/index.rst @@ -10,6 +10,7 @@ Subpackages :titlesonly: :maxdepth: 3 + empirical/index.rst lithium_ion/index.rst diff --git a/docs/api/pybop/models/lithium_ion/base_echem/index.rst b/docs/api/pybop/models/lithium_ion/base_echem/index.rst deleted file mode 100644 index 7348059d..00000000 --- a/docs/api/pybop/models/lithium_ion/base_echem/index.rst +++ /dev/null @@ -1,35 +0,0 @@ -:py:mod:`pybop.models.lithium_ion.base_echem` -============================================= - -.. py:module:: pybop.models.lithium_ion.base_echem - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.models.lithium_ion.base_echem.SPM - pybop.models.lithium_ion.base_echem.SPMe - - - - -.. py:class:: SPM(name='Single Particle Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) - - - Bases: :py:obj:`pybop.models.base_model.BaseModel` - - Composition of the PyBaMM Single Particle Model class. - - - -.. py:class:: SPMe(name='Single Particle Model with Electrolyte', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) - - - Bases: :py:obj:`pybop.models.base_model.BaseModel` - - Composition of the PyBaMM Single Particle Model with Electrolyte class. diff --git a/docs/api/pybop/models/lithium_ion/echem/index.rst b/docs/api/pybop/models/lithium_ion/echem/index.rst new file mode 100644 index 00000000..4c115a68 --- /dev/null +++ b/docs/api/pybop/models/lithium_ion/echem/index.rst @@ -0,0 +1,76 @@ +:py:mod:`pybop.models.lithium_ion.echem` +======================================== + +.. py:module:: pybop.models.lithium_ion.echem + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.models.lithium_ion.echem.SPM + pybop.models.lithium_ion.echem.SPMe + + + + +.. py:class:: SPM(name='Single Particle Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) + + + Bases: :py:obj:`pybop.models.base_model.BaseModel` + + Wraps the Single Particle Model (SPM) for simulating lithium-ion batteries, as implemented in PyBaMM. + + The SPM is a simplified physics-based model that represents a lithium-ion cell using a single + spherical particle to simulate the behavior of the negative and positive electrodes. + + :param name: The name for the model instance, defaulting to "Single Particle Model". + :type name: str, optional + :param parameter_set: The parameters for the model. If None, default parameters provided by PyBaMM are used. + :type parameter_set: pybamm.ParameterValues or dict, optional + :param geometry: The geometry definitions for the model. If None, default geometry from PyBaMM is used. + :type geometry: dict, optional + :param submesh_types: The types of submeshes to use. If None, default submesh types from PyBaMM are used. + :type submesh_types: dict, optional + :param var_pts: The discretization points for each variable in the model. If None, default points from PyBaMM are used. + :type var_pts: dict, optional + :param spatial_methods: The spatial methods used for discretization. If None, default spatial methods from PyBaMM are used. + :type spatial_methods: dict, optional + :param solver: The solver to use for simulating the model. If None, the default solver from PyBaMM is used. + :type solver: pybamm.Solver, optional + :param options: A dictionary of options to customize the behavior of the PyBaMM model. + :type options: dict, optional + + +.. py:class:: SPMe(name='Single Particle Model with Electrolyte', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) + + + Bases: :py:obj:`pybop.models.base_model.BaseModel` + + Represents the Single Particle Model with Electrolyte (SPMe) for lithium-ion batteries. + + The SPMe extends the basic Single Particle Model (SPM) by incorporating electrolyte dynamics, + making it suitable for simulations where electrolyte effects are non-negligible. This class + provides a framework to define the model parameters, geometry, mesh types, discretization + points, spatial methods, and numerical solvers for simulation within the PyBaMM ecosystem. + + :param name: A name for the model instance, defaults to "Single Particle Model with Electrolyte". + :type name: str, optional + :param parameter_set: A dictionary or a ParameterValues object containing the parameters for the model. If None, the default PyBaMM parameters for SPMe are used. + :type parameter_set: pybamm.ParameterValues or dict, optional + :param geometry: A dictionary defining the model's geometry. If None, the default PyBaMM geometry for SPMe is used. + :type geometry: dict, optional + :param submesh_types: A dictionary defining the types of submeshes to use. If None, the default PyBaMM submesh types for SPMe are used. + :type submesh_types: dict, optional + :param var_pts: A dictionary specifying the number of points for each variable for discretization. If None, the default PyBaMM variable points for SPMe are used. + :type var_pts: dict, optional + :param spatial_methods: A dictionary specifying the spatial methods for discretization. If None, the default PyBaMM spatial methods for SPMe are used. + :type spatial_methods: dict, optional + :param solver: The solver to use for simulating the model. If None, the default PyBaMM solver for SPMe is used. + :type solver: pybamm.Solver, optional + :param options: A dictionary of options to customize the behavior of the PyBaMM model. + :type options: dict, optional diff --git a/docs/api/pybop/models/lithium_ion/index.rst b/docs/api/pybop/models/lithium_ion/index.rst index dfc4c95b..4200a205 100644 --- a/docs/api/pybop/models/lithium_ion/index.rst +++ b/docs/api/pybop/models/lithium_ion/index.rst @@ -10,7 +10,7 @@ Submodules :titlesonly: :maxdepth: 1 - base_echem/index.rst + echem/index.rst Package Contents @@ -32,8 +32,27 @@ Classes Bases: :py:obj:`pybop.models.base_model.BaseModel` - Composition of the PyBaMM Single Particle Model class. - + Wraps the Single Particle Model (SPM) for simulating lithium-ion batteries, as implemented in PyBaMM. + + The SPM is a simplified physics-based model that represents a lithium-ion cell using a single + spherical particle to simulate the behavior of the negative and positive electrodes. + + :param name: The name for the model instance, defaulting to "Single Particle Model". + :type name: str, optional + :param parameter_set: The parameters for the model. If None, default parameters provided by PyBaMM are used. + :type parameter_set: pybamm.ParameterValues or dict, optional + :param geometry: The geometry definitions for the model. If None, default geometry from PyBaMM is used. + :type geometry: dict, optional + :param submesh_types: The types of submeshes to use. If None, default submesh types from PyBaMM are used. + :type submesh_types: dict, optional + :param var_pts: The discretization points for each variable in the model. If None, default points from PyBaMM are used. + :type var_pts: dict, optional + :param spatial_methods: The spatial methods used for discretization. If None, default spatial methods from PyBaMM are used. + :type spatial_methods: dict, optional + :param solver: The solver to use for simulating the model. If None, the default solver from PyBaMM is used. + :type solver: pybamm.Solver, optional + :param options: A dictionary of options to customize the behavior of the PyBaMM model. + :type options: dict, optional .. py:class:: SPMe(name='Single Particle Model with Electrolyte', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) @@ -41,4 +60,26 @@ Classes Bases: :py:obj:`pybop.models.base_model.BaseModel` - Composition of the PyBaMM Single Particle Model with Electrolyte class. + Represents the Single Particle Model with Electrolyte (SPMe) for lithium-ion batteries. + + The SPMe extends the basic Single Particle Model (SPM) by incorporating electrolyte dynamics, + making it suitable for simulations where electrolyte effects are non-negligible. This class + provides a framework to define the model parameters, geometry, mesh types, discretization + points, spatial methods, and numerical solvers for simulation within the PyBaMM ecosystem. + + :param name: A name for the model instance, defaults to "Single Particle Model with Electrolyte". + :type name: str, optional + :param parameter_set: A dictionary or a ParameterValues object containing the parameters for the model. If None, the default PyBaMM parameters for SPMe are used. + :type parameter_set: pybamm.ParameterValues or dict, optional + :param geometry: A dictionary defining the model's geometry. If None, the default PyBaMM geometry for SPMe is used. + :type geometry: dict, optional + :param submesh_types: A dictionary defining the types of submeshes to use. If None, the default PyBaMM submesh types for SPMe are used. + :type submesh_types: dict, optional + :param var_pts: A dictionary specifying the number of points for each variable for discretization. If None, the default PyBaMM variable points for SPMe are used. + :type var_pts: dict, optional + :param spatial_methods: A dictionary specifying the spatial methods for discretization. If None, the default PyBaMM spatial methods for SPMe are used. + :type spatial_methods: dict, optional + :param solver: The solver to use for simulating the model. If None, the default PyBaMM solver for SPMe is used. + :type solver: pybamm.Solver, optional + :param options: A dictionary of options to customize the behavior of the PyBaMM model. + :type options: dict, optional diff --git a/docs/api/pybop/optimisation/index.rst b/docs/api/pybop/optimisation/index.rst index 9fca5c5a..619f9eee 100644 --- a/docs/api/pybop/optimisation/index.rst +++ b/docs/api/pybop/optimisation/index.rst @@ -118,3 +118,8 @@ Classes ``False``. Credit: PINTS + + + .. py:method:: store_optimised_parameters(x) + + Store the optimised parameters in the PyBOP parameter class. diff --git a/docs/api/pybop/optimisers/base_optimiser/index.rst b/docs/api/pybop/optimisers/base_optimiser/index.rst index 932d64b5..4f34d0c2 100644 --- a/docs/api/pybop/optimisers/base_optimiser/index.rst +++ b/docs/api/pybop/optimisers/base_optimiser/index.rst @@ -20,20 +20,64 @@ Classes .. py:class:: BaseOptimiser - Base class for the optimisation methods. + A base class for defining optimisation methods. + + This class serves as a template for creating optimisers. It provides a basic structure for + an optimisation algorithm, including the initial setup and a method stub for performing + the optimisation process. Child classes should override the optimise and _runoptimise + methods with specific algorithms. + + .. method:: optimise(cost_function, x0=None, bounds=None, maxiter=None) + + Initiates the optimisation process. This is a stub and should be implemented in child classes. + + .. method:: _runoptimise(cost_function, x0=None, bounds=None) + + Contains the logic for the optimisation algorithm. This is a stub and should be implemented in child classes. + + .. method:: name() + + Returns the name of the optimiser. .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) - Run optimisation method, to be overloaded by child classes. + Contains the logic for the optimisation algorithm. + + This method should be implemented by child classes to perform the actual optimisation. + :param cost_function: The cost function to be minimised by the optimiser. + :type cost_function: callable + :param x0: Initial guess for the parameters. Default is None. + :type x0: ndarray, optional + :param bounds: Bounds on the parameters. Default is None. + :type bounds: sequence or Bounds, optional + + :returns: * *This method is expected to return the result of the optimisation, the format of which* + * *will be determined by the child class implementation.* .. py:method:: name() Returns the name of the optimiser. + :returns: The name of the optimiser, which is "BaseOptimiser" for this base class. + :rtype: str + + + .. py:method:: optimise(cost_function, x0=None, bounds=None, maxiter=None) + + Initiates the optimisation process. + + This method should be overridden by child classes with the specific optimisation algorithm. - .. py:method:: optimise(cost_function, x0=None, bounds=None) + :param cost_function: The cost function to be minimised by the optimiser. + :type cost_function: callable + :param x0: Initial guess for the parameters. Default is None. + :type x0: ndarray, optional + :param bounds: Bounds on the parameters. Default is None. + :type bounds: sequence or Bounds, optional + :param maxiter: Maximum number of iterations to perform. Default is None. + :type maxiter: int, optional - Optimisiation method to be overloaded by child classes. + :rtype: The result of the optimisation process. The specific type of this result will depend on the child implementation. diff --git a/docs/api/pybop/optimisers/index.rst b/docs/api/pybop/optimisers/index.rst index 1524300b..8bffb2f0 100644 --- a/docs/api/pybop/optimisers/index.rst +++ b/docs/api/pybop/optimisers/index.rst @@ -13,4 +13,4 @@ Submodules base_optimiser/index.rst nlopt_optimize/index.rst pints_optimisers/index.rst - scipy_minimize/index.rst + scipy_optimisers/index.rst diff --git a/docs/api/pybop/optimisers/nlopt_optimize/index.rst b/docs/api/pybop/optimisers/nlopt_optimize/index.rst index b210161f..29273b74 100644 --- a/docs/api/pybop/optimisers/nlopt_optimize/index.rst +++ b/docs/api/pybop/optimisers/nlopt_optimize/index.rst @@ -17,30 +17,65 @@ Classes -.. py:class:: NLoptOptimize(n_param, xtol=None, method=None) +.. py:class:: NLoptOptimize(n_param, xtol=None, method=None, maxiter=None) Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - Wrapper class for the NLOpt optimiser class. Extends the BaseOptimiser class. + Extends BaseOptimiser to utilize the NLopt library for nonlinear optimization. + + This class serves as an interface to the NLopt optimization algorithms. It allows the user to + define an optimization problem with bounds, initial guesses, and to select an optimization method + provided by NLopt. + + :param n_param: Number of parameters to optimize. + :type n_param: int + :param xtol: The relative tolerance for optimization (stopping criteria). If not provided, a default of 1e-5 is used. + :type xtol: float, optional + :param method: The NLopt algorithm to use for optimization. If not provided, LN_BOBYQA is used by default. + :type method: nlopt.algorithm, optional + :param maxiter: The maximum number of iterations to perform during optimization. If not provided, NLopt's default is used. + :type maxiter: int, optional + + .. method:: _runoptimise(cost_function, x0, bounds) + + Performs the optimization using the NLopt library. + + .. method:: needs_sensitivities() + + Indicates whether the optimizer requires gradient information. + + .. method:: name() + + Returns the name of the optimizer. + .. py:method:: _runoptimise(cost_function, x0, bounds) - Run the NLOpt optimisation method. + Runs the optimization process using the NLopt library. - Inputs - ---------- - cost_function: function for optimising - method: optimisation algorithm - x0: initialisation array - bounds: bounds array + :param cost_function: The objective function to minimize. It should take an array of parameter values and return the scalar cost. + :type cost_function: callable + :param x0: The initial guess for the parameters. + :type x0: array_like + :param bounds: A dictionary containing the 'lower' and 'upper' bounds arrays for the parameters. + :type bounds: dict + + :returns: A tuple containing the optimized parameter values and the final cost. + :rtype: tuple .. py:method:: name() - Returns the name of the optimiser. + Returns the name of this optimizer instance. + + :returns: The name 'NLoptOptimize' representing this NLopt optimization class. + :rtype: str .. py:method:: needs_sensitivities() - Returns True if the optimiser needs sensitivities. + Indicates if the optimizer requires gradient information for the cost function. + + :returns: False, as the default NLopt algorithms do not require gradient information. + :rtype: bool diff --git a/docs/api/pybop/optimisers/pints_optimisers/index.rst b/docs/api/pybop/optimisers/pints_optimisers/index.rst index 68226d79..3b3ce69c 100644 --- a/docs/api/pybop/optimisers/pints_optimisers/index.rst +++ b/docs/api/pybop/optimisers/pints_optimisers/index.rst @@ -28,8 +28,23 @@ Classes Bases: :py:obj:`pints.Adam` - Adam optimiser. Inherits from the PINTS Adam class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_adam.py + Implements the Adam optimization algorithm. + + This class extends the Adam optimizer from the PINTS library, which combines + ideas from RMSProp and Stochastic Gradient Descent with momentum. Note that + this optimizer does not support boundary constraints. + + :param x0: Initial position from which optimization will start. + :type x0: array_like + :param sigma0: Initial step size (default is 0.1). + :type sigma0: float, optional + :param bounds: Ignored by this optimizer, provided for API consistency. + :type bounds: sequence or ``Bounds``, optional + + .. seealso:: + + :obj:`pints.Adam` + The PINTS implementation this class is based on. .. py:class:: CMAES(x0, sigma0=0.1, bounds=None) @@ -37,8 +52,23 @@ Classes Bases: :py:obj:`pints.CMAES` - Class for the PINTS optimisation. Extends the BaseOptimiser class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_cmaes.py + Adapter for the Covariance Matrix Adaptation Evolution Strategy (CMA-ES) optimizer in PINTS. + + CMA-ES is an evolutionary algorithm for difficult non-linear non-convex optimization problems. + It adapts the covariance matrix of a multivariate normal distribution to capture the shape of the cost landscape. + + :param x0: The initial parameter vector to optimize. + :type x0: array_like + :param sigma0: Initial standard deviation of the sampling distribution, defaults to 0.1. + :type sigma0: float, optional + :param bounds: A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. + If ``None``, no bounds are enforced. + :type bounds: dict, optional + + .. seealso:: + + :obj:`pints.CMAES` + PINTS implementation of CMA-ES algorithm. .. py:class:: GradientDescent(x0, sigma0=0.1, bounds=None) @@ -46,8 +76,23 @@ Classes Bases: :py:obj:`pints.GradientDescent` - Gradient descent optimiser. Inherits from the PINTS gradient descent class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_gradient_descent.py + Implements a simple gradient descent optimization algorithm. + + This class extends the gradient descent optimizer from the PINTS library, designed + to minimize a scalar function of one or more variables. Note that this optimizer + does not support boundary constraints. + + :param x0: Initial position from which optimization will start. + :type x0: array_like + :param sigma0: Initial step size (default is 0.1). + :type sigma0: float, optional + :param bounds: Ignored by this optimizer, provided for API consistency. + :type bounds: sequence or ``Bounds``, optional + + .. seealso:: + + :obj:`pints.GradientDescent` + The PINTS implementation this class is based on. .. py:class:: IRPropMin(x0, sigma0=0.1, bounds=None) @@ -55,8 +100,23 @@ Classes Bases: :py:obj:`pints.IRPropMin` - IRProp- optimiser. Inherits from the PINTS IRPropMinus class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_irpropmin.py + Implements the iRpropMin optimization algorithm. + + This class inherits from the PINTS IRPropMin class, which is an optimizer that + uses resilient backpropagation with weight-backtracking. It is designed to handle + problems with large plateaus, noisy gradients, and local minima. + + :param x0: Initial position from which optimization will start. + :type x0: array_like + :param sigma0: Initial step size (default is 0.1). + :type sigma0: float, optional + :param bounds: Lower and upper bounds for each optimization parameter. + :type bounds: dict, optional + + .. seealso:: + + :obj:`pints.IRPropMin` + The PINTS implementation this class is based on. .. py:class:: PSO(x0, sigma0=0.1, bounds=None) @@ -64,8 +124,23 @@ Classes Bases: :py:obj:`pints.PSO` - Particle swarm optimiser. Inherits from the PINTS PSO class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_pso.py + Implements a particle swarm optimization (PSO) algorithm. + + This class extends the PSO optimizer from the PINTS library. PSO is a + metaheuristic optimization method inspired by the social behavior of birds + flocking or fish schooling, suitable for global optimization problems. + + :param x0: Initial positions of particles, which the optimization will use. + :type x0: array_like + :param sigma0: Spread of the initial particle positions (default is 0.1). + :type sigma0: float, optional + :param bounds: Lower and upper bounds for each optimization parameter. + :type bounds: dict, optional + + .. seealso:: + + :obj:`pints.PSO` + The PINTS implementation this class is based on. .. py:class:: SNES(x0, sigma0=0.1, bounds=None) @@ -73,8 +148,23 @@ Classes Bases: :py:obj:`pints.SNES` - Stochastic natural evolution strategy optimiser. Inherits from the PINTS SNES class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_snes.py + Implements the stochastic natural evolution strategy (SNES) optimization algorithm. + + Inheriting from the PINTS SNES class, this optimizer is an evolutionary algorithm + that evolves a probability distribution on the parameter space, guiding the search + for the optimum based on the natural gradient of expected fitness. + + :param x0: Initial position from which optimization will start. + :type x0: array_like + :param sigma0: Initial step size (default is 0.1). + :type sigma0: float, optional + :param bounds: Lower and upper bounds for each optimization parameter. + :type bounds: dict, optional + + .. seealso:: + + :obj:`pints.SNES` + The PINTS implementation this class is based on. .. py:class:: XNES(x0, sigma0=0.1, bounds=None) @@ -82,5 +172,18 @@ Classes Bases: :py:obj:`pints.XNES` - Exponential natural evolution strategy optimiser. Inherits from the PINTS XNES class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_xnes.py + Implements the Exponential Natural Evolution Strategy (XNES) optimizer from PINTS. + + XNES is an evolutionary algorithm that samples from a multivariate normal distribution, which is updated iteratively to fit the distribution of successful solutions. + + :param x0: The initial parameter vector to optimize. + :type x0: array_like + :param sigma0: Initial standard deviation of the sampling distribution, defaults to 0.1. + :type sigma0: float, optional + :param bounds: A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. If ``None``, no bounds are enforced. + :type bounds: dict, optional + + .. seealso:: + + :obj:`pints.XNES` + PINTS implementation of XNES algorithm. diff --git a/docs/api/pybop/optimisers/scipy_minimize/index.rst b/docs/api/pybop/optimisers/scipy_minimize/index.rst deleted file mode 100644 index 4d8c9233..00000000 --- a/docs/api/pybop/optimisers/scipy_minimize/index.rst +++ /dev/null @@ -1,46 +0,0 @@ -:py:mod:`pybop.optimisers.scipy_minimize` -========================================= - -.. py:module:: pybop.optimisers.scipy_minimize - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.optimisers.scipy_minimize.SciPyMinimize - - - - -.. py:class:: SciPyMinimize(method=None, bounds=None) - - - Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - - Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. - - .. py:method:: _runoptimise(cost_function, x0, bounds) - - Run the SciPy optimisation method. - - Inputs - ---------- - cost_function: function for optimising - method: optimisation algorithm - x0: initialisation array - bounds: bounds array - - - .. py:method:: name() - - Returns the name of the optimiser. - - - .. py:method:: needs_sensitivities() - - Returns True if the optimiser needs sensitivities. diff --git a/docs/api/pybop/optimisers/scipy_optimisers/index.rst b/docs/api/pybop/optimisers/scipy_optimisers/index.rst new file mode 100644 index 00000000..5080cb51 --- /dev/null +++ b/docs/api/pybop/optimisers/scipy_optimisers/index.rst @@ -0,0 +1,115 @@ +:py:mod:`pybop.optimisers.scipy_optimisers` +=========================================== + +.. py:module:: pybop.optimisers.scipy_optimisers + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.optimisers.scipy_optimisers.SciPyDifferentialEvolution + pybop.optimisers.scipy_optimisers.SciPyMinimize + + + + +.. py:class:: SciPyDifferentialEvolution(bounds=None, strategy='best1bin', maxiter=1000, popsize=15) + + + Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` + + Adapts SciPy's differential_evolution function for global optimization. + + This class provides a global optimization strategy based on differential evolution, useful for problems involving continuous parameters and potentially multiple local minima. + + :param bounds: Bounds for variables. Must be provided as it is essential for differential evolution. + :type bounds: sequence or ``Bounds`` + :param strategy: The differential evolution strategy to use. Defaults to 'best1bin'. + :type strategy: str, optional + :param maxiter: Maximum number of iterations to perform. Defaults to 1000. + :type maxiter: int, optional + :param popsize: The number of individuals in the population. Defaults to 15. + :type popsize: int, optional + + .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) + + Executes the optimization process using SciPy's differential_evolution function. + + :param cost_function: The objective function to minimize. + :type cost_function: callable + :param x0: Ignored parameter, provided for API consistency. + :type x0: array_like, optional + :param bounds: Bounds for the variables, required for differential evolution. + :type bounds: sequence or ``Bounds`` + + :returns: A tuple (x, final_cost) containing the optimized parameters and the value of ``cost_function`` at the optimum. + :rtype: tuple + + + .. py:method:: name() + + Provides the name of the optimization strategy. + + :returns: The name 'SciPyDifferentialEvolution'. + :rtype: str + + + .. py:method:: needs_sensitivities() + + Determines if the optimization algorithm requires gradient information. + + :returns: False, indicating that gradient information is not required for differential evolution. + :rtype: bool + + + +.. py:class:: SciPyMinimize(method=None, bounds=None, maxiter=None) + + + Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` + + Adapts SciPy's minimize function for use as an optimization strategy. + + This class provides an interface to various scalar minimization algorithms implemented in SciPy, allowing fine-tuning of the optimization process through method selection and option configuration. + + :param method: The type of solver to use. If not specified, defaults to 'COBYLA'. + :type method: str, optional + :param bounds: Bounds for variables as supported by the selected method. + :type bounds: sequence or ``Bounds``, optional + :param maxiter: Maximum number of iterations to perform. + :type maxiter: int, optional + + .. py:method:: _runoptimise(cost_function, x0, bounds) + + Executes the optimization process using SciPy's minimize function. + + :param cost_function: The objective function to minimize. + :type cost_function: callable + :param x0: Initial guess for the parameters. + :type x0: array_like + :param bounds: Bounds for the variables. + :type bounds: sequence or `Bounds` + + :returns: A tuple (x, final_cost) containing the optimized parameters and the value of `cost_function` at the optimum. + :rtype: tuple + + + .. py:method:: name() + + Provides the name of the optimization strategy. + + :returns: The name 'SciPyMinimize'. + :rtype: str + + + .. py:method:: needs_sensitivities() + + Determines if the optimization algorithm requires gradient information. + + :returns: False, indicating that gradient information is not required. + :rtype: bool diff --git a/docs/api/pybop/parameters/base_parameter/index.rst b/docs/api/pybop/parameters/base_parameter/index.rst deleted file mode 100644 index 5bad5034..00000000 --- a/docs/api/pybop/parameters/base_parameter/index.rst +++ /dev/null @@ -1,41 +0,0 @@ -:py:mod:`pybop.parameters.base_parameter` -========================================= - -.. py:module:: pybop.parameters.base_parameter - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.parameters.base_parameter.Parameter - - - - -.. py:class:: Parameter(name, value=None, prior=None, bounds=None) - - - "" - Class for creating parameters in PyBOP. - - .. py:method:: __repr__() - - Return repr(self). - - - .. py:method:: rvs(n_samples) - - Returns a random value sample from the prior distribution. - - - .. py:method:: set_margin(margin) - - Sets the margin for the parameter. - - - .. py:method:: update(value) diff --git a/docs/api/pybop/parameters/base_parameter_set/index.rst b/docs/api/pybop/parameters/base_parameter_set/index.rst deleted file mode 100644 index d63a2ef0..00000000 --- a/docs/api/pybop/parameters/base_parameter_set/index.rst +++ /dev/null @@ -1,23 +0,0 @@ -:py:mod:`pybop.parameters.base_parameter_set` -============================================= - -.. py:module:: pybop.parameters.base_parameter_set - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.parameters.base_parameter_set.ParameterSet - - - - -.. py:class:: ParameterSet - - - Class for creating parameter sets in PyBOP. diff --git a/docs/api/pybop/parameters/index.rst b/docs/api/pybop/parameters/index.rst index 43843f7f..9e385c56 100644 --- a/docs/api/pybop/parameters/index.rst +++ b/docs/api/pybop/parameters/index.rst @@ -10,6 +10,6 @@ Submodules :titlesonly: :maxdepth: 1 - base_parameter/index.rst - base_parameter_set/index.rst + parameter/index.rst + parameter_set/index.rst priors/index.rst diff --git a/docs/api/pybop/parameters/parameter/index.rst b/docs/api/pybop/parameters/parameter/index.rst new file mode 100644 index 00000000..881ec13d --- /dev/null +++ b/docs/api/pybop/parameters/parameter/index.rst @@ -0,0 +1,95 @@ +:py:mod:`pybop.parameters.parameter` +==================================== + +.. py:module:: pybop.parameters.parameter + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.parameters.parameter.Parameter + + + + +.. py:class:: Parameter(name, initial_value=None, prior=None, bounds=None) + + + Represents a parameter within the PyBOP framework. + + This class encapsulates the definition of a parameter, including its name, prior + distribution, initial value, bounds, and a margin to ensure the parameter stays + within feasible limits during optimization or sampling. + + :param name: The name of the parameter. + :type name: str + :param initial_value: The initial value to be assigned to the parameter. Defaults to None. + :type initial_value: float, optional + :param prior: The prior distribution from which parameter values are drawn. Defaults to None. + :type prior: scipy.stats distribution, optional + :param bounds: A tuple defining the lower and upper bounds for the parameter. + Defaults to None. + :type bounds: tuple, optional + + .. method:: rvs(n_samples) + + Draw random samples from the parameter's prior distribution. + + .. method:: update(value) + + Update the parameter's current value. + + .. method:: set_margin(margin) + + Set the margin to a specified positive value less than 1. + + + :raises ValueError: If the lower bound is not strictly less than the upper bound, or if + the margin is set outside the interval (0, 1). + + .. py:method:: __repr__() + + Return a string representation of the Parameter instance. + + :returns: A string including the parameter's name, prior, bounds, and current value. + :rtype: str + + + .. py:method:: rvs(n_samples) + + Draw random samples from the parameter's prior distribution. + + The samples are constrained to be within the parameter's bounds, excluding + a predefined margin at the boundaries. + + :param n_samples: The number of samples to draw. + :type n_samples: int + + :returns: An array of samples drawn from the prior distribution within the parameter's bounds. + :rtype: array-like + + + .. py:method:: set_margin(margin) + + Set the margin to a specified positive value less than 1. + + The margin is used to ensure parameter samples are not drawn exactly at the bounds, + which may be problematic in some optimization or sampling algorithms. + + :param margin: The new margin value to be used, which must be in the interval (0, 1). + :type margin: float + + :raises ValueError: If the margin is not between 0 and 1. + + + .. py:method:: update(value) + + Update the parameter's current value. + + :param value: The new value to be assigned to the parameter. + :type value: float diff --git a/docs/api/pybop/parameters/parameter_set/index.rst b/docs/api/pybop/parameters/parameter_set/index.rst new file mode 100644 index 00000000..2b22be47 --- /dev/null +++ b/docs/api/pybop/parameters/parameter_set/index.rst @@ -0,0 +1,95 @@ +:py:mod:`pybop.parameters.parameter_set` +======================================== + +.. py:module:: pybop.parameters.parameter_set + + +Module Contents +--------------- + +Classes +~~~~~~~ + +.. autoapisummary:: + + pybop.parameters.parameter_set.ParameterSet + + + + +.. py:class:: ParameterSet(json_path=None, params_dict=None) + + + Handles the import and export of parameter sets for battery models. + + This class provides methods to load parameters from a JSON file and to export them + back to a JSON file. It also includes custom logic to handle special cases, such + as parameter values that require specific initialization. + + :param json_path: Path to a JSON file containing parameter data. If provided, parameters will be imported from this file during initialization. + :type json_path: str, optional + :param params_dict: A dictionary of parameters to initialize the ParameterSet with. If not provided, an empty dictionary is used. + :type params_dict: dict, optional + + .. py:method:: _handle_special_cases() + + Processes special cases for parameter values that require custom handling. + + For example, if the open-circuit voltage is specified as 'default', it will + fetch the default value from the PyBaMM empirical Thevenin model. + + + .. py:method:: export_parameters(output_json_path, fit_params=None) + + Exports parameters to a JSON file specified by `output_json_path`. + + The current state of the `params` attribute is written to the file. If `fit_params` + is provided, these parameters are updated before export. Non-serializable values + are handled and noted in the output JSON. + + :param output_json_path: The file path where the JSON output will be saved. + :type output_json_path: str + :param fit_params: Parameters that have been fitted and need to be included in the export. + :type fit_params: list of fitted parameter objects, optional + + :raises ValueError: If there are no parameters to export. + + + .. py:method:: import_parameters(json_path=None) + + Imports parameters from a JSON file specified by the `json_path` attribute. + + If a `json_path` is provided at initialization or as an argument, that JSON file + is loaded and the parameters are stored in the `params` attribute. Special cases + are handled appropriately. + + :param json_path: Path to the JSON file from which to import parameters. If provided, it overrides the instance's `json_path`. + :type json_path: str, optional + + :returns: The dictionary containing the imported parameters. + :rtype: dict + + :raises FileNotFoundError: If the specified JSON file cannot be found. + + + .. py:method:: is_json_serializable(value) + + Determines if the given `value` can be serialized to JSON format. + + :param value: The value to check for JSON serializability. + :type value: any + + :returns: True if the value is JSON serializable, False otherwise. + :rtype: bool + + + .. py:method:: pybamm(name) + :classmethod: + + Retrieves a PyBaMM parameter set by name. + + :param name: The name of the PyBaMM parameter set to retrieve. + :type name: str + + :returns: A PyBaMM parameter set corresponding to the provided name. + :rtype: pybamm.ParameterValues diff --git a/docs/api/pybop/parameters/priors/index.rst b/docs/api/pybop/parameters/priors/index.rst index 05f5b79e..9e633272 100644 --- a/docs/api/pybop/parameters/priors/index.rst +++ b/docs/api/pybop/parameters/priors/index.rst @@ -22,57 +22,157 @@ Classes .. py:class:: Exponential(scale) - Exponential prior class. + Represents an exponential distribution with a specified scale parameter. + + This class provides methods to calculate the pdf, the log pdf, and to generate random + variates from the distribution. + + :param scale: The scale parameter (lambda) of the exponential distribution. + :type scale: float .. py:method:: __repr__() - Return repr(self). + Returns a string representation of the Uniform object. .. py:method:: logpdf(x) + Calculates the logarithm of the pdf of the exponential distribution at x. + + :param x: The point at which to evaluate the log pdf. + :type x: float + + :returns: The log of the probability density function value at x. + :rtype: float + .. py:method:: pdf(x) + Calculates the probability density function of the exponential distribution at x. + + :param x: The point at which to evaluate the pdf. + :type x: float + + :returns: The probability density function value at x. + :rtype: float + .. py:method:: rvs(size) + Generates random variates from the exponential distribution. + + :param size: The number of random variates to generate. + :type size: int + + :returns: An array of random variates from the exponential distribution. + :rtype: array_like + + :raises ValueError: If the size parameter is not positive. + .. py:class:: Gaussian(mean, sigma) - Gaussian prior class. + Represents a Gaussian (normal) distribution with a given mean and standard deviation. + + This class provides methods to calculate the probability density function (pdf), + the logarithm of the pdf, and to generate random variates (rvs) from the distribution. + + :param mean: The mean (mu) of the Gaussian distribution. + :type mean: float + :param sigma: The standard deviation (sigma) of the Gaussian distribution. + :type sigma: float .. py:method:: __repr__() - Return repr(self). + Returns a string representation of the Gaussian object. .. py:method:: logpdf(x) + Calculates the logarithm of the probability density function of the Gaussian distribution at x. + + :param x: The point at which to evaluate the log pdf. + :type x: float + + :returns: The logarithm of the probability density function value at x. + :rtype: float + .. py:method:: pdf(x) + Calculates the probability density function of the Gaussian distribution at x. + + :param x: The point at which to evaluate the pdf. + :type x: float + + :returns: The probability density function value at x. + :rtype: float + .. py:method:: rvs(size) + Generates random variates from the Gaussian distribution. + + :param size: The number of random variates to generate. + :type size: int + + :returns: An array of random variates from the Gaussian distribution. + :rtype: array_like + + :raises ValueError: If the size parameter is not positive. + .. py:class:: Uniform(lower, upper) - Uniform prior class. + Represents a uniform distribution over a specified interval. + + This class provides methods to calculate the pdf, the log pdf, and to generate + random variates from the distribution. + + :param lower: The lower bound of the distribution. + :type lower: float + :param upper: The upper bound of the distribution. + :type upper: float .. py:method:: __repr__() - Return repr(self). + Returns a string representation of the Uniform object. .. py:method:: logpdf(x) + Calculates the logarithm of the pdf of the uniform distribution at x. + + :param x: The point at which to evaluate the log pdf. + :type x: float + + :returns: The log of the probability density function value at x. + :rtype: float + .. py:method:: pdf(x) + Calculates the probability density function of the uniform distribution at x. + + :param x: The point at which to evaluate the pdf. + :type x: float + + :returns: The probability density function value at x. + :rtype: float + .. py:method:: rvs(size) + + Generates random variates from the uniform distribution. + + :param size: The number of random variates to generate. + :type size: int + + :returns: An array of random variates from the uniform distribution. + :rtype: array_like + + :raises ValueError: If the size parameter is not positive. diff --git a/docs/conf.py b/docs/conf.py index 84364515..f3e3ce0c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,6 +37,7 @@ ] templates_path = ["_templates"] +autoapi_template_dir = "_templates/autoapi" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for autoapi ------------------------------------------------------- diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index 848ab029..f2b67495 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -4,10 +4,38 @@ class BaseModel: """ - Base class for pybop models. + A base class for constructing and simulating models using PyBaMM. + + This class serves as a foundation for building specific models in PyBaMM. + It provides methods to set up the model, define parameters, and perform + simulations. The class is designed to be subclassed for creating models + with custom behavior. + + Methods + ------- + build(dataset=None, parameters=None, check_model=True, init_soc=None) + Construct the PyBaMM model if not already built. + set_init_soc(init_soc) + Set the initial state of charge for the battery model. + set_params() + Assign the parameters to the model. + simulate(inputs, t_eval) + Execute the forward model simulation and return the result. + simulateS1(inputs, t_eval) + Perform the forward model simulation with sensitivities. + predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) + Solve the model using PyBaMM's simulation framework and return the solution. """ def __init__(self, name="Base Model"): + """ + Initialize the BaseModel with an optional name. + + Parameters + ---------- + name : str, optional + The name given to the model instance. + """ self.name = name self.pybamm_model = None self.parameters = None @@ -22,9 +50,22 @@ def build( init_soc=None, ): """ - Build the PyBOP model (if not built already). - For PyBaMM forward models, this method follows a - similar process to pybamm.Simulation.build(). + Construct the PyBaMM model if not already built, and set parameters. + + This method initializes the model components, applies the given parameters, + sets up the mesh and discretization if needed, and prepares the model + for simulations. + + Parameters + ---------- + dataset : pybamm.Dataset, optional + The dataset to be used in the model construction. + parameters : dict, optional + A dictionary containing parameter values to apply to the model. + check_model : bool, optional + If True, the model will be checked for correctness after construction. + init_soc : float, optional + The initial state of charge to be used in simulations. """ self.dataset = dataset self.parameters = parameters @@ -53,7 +94,12 @@ def build( def set_init_soc(self, init_soc): """ - Set the initial state of charge. + Set the initial state of charge for the battery model. + + Parameters + ---------- + init_soc : float + The initial state of charge to be used in the model. """ if self._built_initial_soc != init_soc: # reset @@ -73,7 +119,10 @@ def set_init_soc(self, init_soc): def set_params(self): """ - Set the parameters in the model. + Assign the parameters to the model. + + This method processes the model with the given parameters, sets up + the geometry, and updates the model instance. """ if self.model_with_set_params: return @@ -101,8 +150,25 @@ def set_params(self): def simulate(self, inputs, t_eval): """ - Run the forward model and return the result in Numpy array format - aligning with Pints' ForwardModel simulate method. + Execute the forward model simulation and return the result. + + Parameters + ---------- + inputs : dict or array-like + The input parameters for the simulation. If array-like, it will be + converted to a dictionary using the model's fit keys. + t_eval : array-like + An array of time points at which to evaluate the solution. + + Returns + ------- + array-like + The simulation result corresponding to the specified signal. + + Raises + ------ + ValueError + If the model has not been built before simulation. """ if self._built_model is None: @@ -117,8 +183,26 @@ def simulate(self, inputs, t_eval): def simulateS1(self, inputs, t_eval): """ - Run the forward model and return the function evaulation and it's gradient - aligning with Pints' ForwardModel simulateS1 method. + Perform the forward model simulation with sensitivities. + + Parameters + ---------- + inputs : dict or array-like + The input parameters for the simulation. If array-like, it will be + converted to a dictionary using the model's fit keys. + t_eval : array-like + An array of time points at which to evaluate the solution and its + sensitivities. + + Returns + ------- + tuple + A tuple containing the simulation result and the sensitivities. + + Raises + ------ + ValueError + If the model has not been built before simulation. """ if self._built_model is None: @@ -153,7 +237,42 @@ def predict( init_soc=None, ): """ - Create a PyBaMM simulation object, solve it, and return a solution object. + Solve the model using PyBaMM's simulation framework and return the solution. + + This method sets up a PyBaMM simulation by configuring the model, parameters, experiment + (if any), and initial state of charge (if provided). It then solves the simulation and + returns the resulting solution object. + + Parameters + ---------- + inputs : dict or array-like, optional + Input parameters for the simulation. If the input is array-like, it is converted + to a dictionary using the model's fitting keys. Defaults to None, indicating + that the default parameters should be used. + t_eval : array-like, optional + An array of time points at which to evaluate the solution. Defaults to None, + which means the time points need to be specified within experiment or elsewhere. + parameter_set : pybamm.ParameterValues, optional + A PyBaMM ParameterValues object or a dictionary containing the parameter values + to use for the simulation. Defaults to the model's current ParameterValues if None. + experiment : pybamm.Experiment, optional + A PyBaMM Experiment object specifying the experimental conditions under which + the simulation should be run. Defaults to None, indicating no experiment. + init_soc : float, optional + The initial state of charge for the simulation, as a fraction (between 0 and 1). + Defaults to None. + + Returns + ------- + pybamm.Solution + The solution object returned after solving the simulation. + + Raises + ------ + ValueError + If the model has not been configured properly before calling this method or + if PyBaMM models are not supported by the current simulation method. + """ parameter_set = parameter_set or self._parameter_set if inputs is not None: diff --git a/pybop/models/empirical/__init__.py b/pybop/models/empirical/__init__.py index 7f57d913..58790627 100644 --- a/pybop/models/empirical/__init__.py +++ b/pybop/models/empirical/__init__.py @@ -1,4 +1,4 @@ # # Import lithium ion based models # -from .base_ecm import Thevenin +from .ecm import Thevenin diff --git a/pybop/models/empirical/base_ecm.py b/pybop/models/empirical/ecm.py similarity index 51% rename from pybop/models/empirical/base_ecm.py rename to pybop/models/empirical/ecm.py index 85b21d1c..e6d29c3b 100644 --- a/pybop/models/empirical/base_ecm.py +++ b/pybop/models/empirical/ecm.py @@ -4,8 +4,32 @@ class Thevenin(BaseModel): """ - Composition of the PyBaMM Single Particle Model class. + The Thevenin class represents an equivalent circuit model based on the Thevenin model in PyBaMM. + This class encapsulates the PyBaMM equivalent circuit Thevenin model, providing an interface + to define the parameters, geometry, submesh types, variable points, spatial methods, and solver + to be used for simulations. + + Parameters + ---------- + name : str, optional + A name for the model instance. Defaults to "Equivalent Circuit Thevenin Model". + parameter_set : dict or None, optional + A dictionary of parameters to be used for the model. If None, the default parameters from PyBaMM are used. + geometry : dict or None, optional + The geometry definitions for the model. If None, the default geometry from PyBaMM is used. + submesh_types : dict or None, optional + The types of submeshes to use. If None, the default submesh types from PyBaMM are used. + var_pts : dict or None, optional + The number of points for each variable in the model to define the discretization. If None, the default is used. + spatial_methods : dict or None, optional + The spatial methods to be used for discretization. If None, the default spatial methods from PyBaMM are used. + solver : pybamm.Solver or None, optional + The solver to use for simulating the model. If None, the default solver from PyBaMM is used. + options : dict or None, optional + A dictionary of options to pass to the PyBaMM Thevenin model. + **kwargs : + Additional arguments passed to the PyBaMM Thevenin model constructor. """ def __init__( diff --git a/pybop/models/lithium_ion/__init__.py b/pybop/models/lithium_ion/__init__.py index 69b51653..d61591b4 100644 --- a/pybop/models/lithium_ion/__init__.py +++ b/pybop/models/lithium_ion/__init__.py @@ -1,4 +1,4 @@ # # Import lithium ion based models # -from .base_echem import SPM, SPMe +from .echem import SPM, SPMe diff --git a/pybop/models/lithium_ion/base_echem.py b/pybop/models/lithium_ion/base_echem.py deleted file mode 100644 index d22a99e6..00000000 --- a/pybop/models/lithium_ion/base_echem.py +++ /dev/null @@ -1,88 +0,0 @@ -import pybamm -from ..base_model import BaseModel - - -class SPM(BaseModel): - """ - Composition of the PyBaMM Single Particle Model class. - - """ - - def __init__( - self, - name="Single Particle Model", - parameter_set=None, - geometry=None, - submesh_types=None, - var_pts=None, - spatial_methods=None, - solver=None, - options=None, - ): - super().__init__() - self.pybamm_model = pybamm.lithium_ion.SPM(options=options) - self._unprocessed_model = self.pybamm_model - self.name = name - - self.default_parameter_values = self.pybamm_model.default_parameter_values - self._parameter_set = ( - parameter_set or self.pybamm_model.default_parameter_values - ) - self._unprocessed_parameter_set = self._parameter_set - - self.geometry = geometry or self.pybamm_model.default_geometry - self.submesh_types = submesh_types or self.pybamm_model.default_submesh_types - self.var_pts = var_pts or self.pybamm_model.default_var_pts - self.spatial_methods = ( - spatial_methods or self.pybamm_model.default_spatial_methods - ) - self.solver = solver or self.pybamm_model.default_solver - - self._model_with_set_params = None - self._built_model = None - self._built_initial_soc = None - self._mesh = None - self._disc = None - - -class SPMe(BaseModel): - """ - Composition of the PyBaMM Single Particle Model with Electrolyte class. - - """ - - def __init__( - self, - name="Single Particle Model with Electrolyte", - parameter_set=None, - geometry=None, - submesh_types=None, - var_pts=None, - spatial_methods=None, - solver=None, - options=None, - ): - super().__init__() - self.pybamm_model = pybamm.lithium_ion.SPMe(options=options) - self._unprocessed_model = self.pybamm_model - self.name = name - - self.default_parameter_values = self.pybamm_model.default_parameter_values - self._parameter_set = ( - parameter_set or self.pybamm_model.default_parameter_values - ) - self._unprocessed_parameter_set = self._parameter_set - - self.geometry = geometry or self.pybamm_model.default_geometry - self.submesh_types = submesh_types or self.pybamm_model.default_submesh_types - self.var_pts = var_pts or self.pybamm_model.default_var_pts - self.spatial_methods = ( - spatial_methods or self.pybamm_model.default_spatial_methods - ) - self.solver = solver or self.pybamm_model.default_solver - - self._model_with_set_params = None - self._built_model = None - self._built_initial_soc = None - self._mesh = None - self._disc = None diff --git a/pybop/models/lithium_ion/echem.py b/pybop/models/lithium_ion/echem.py new file mode 100644 index 00000000..35086fe8 --- /dev/null +++ b/pybop/models/lithium_ion/echem.py @@ -0,0 +1,138 @@ +import pybamm +from ..base_model import BaseModel + + +class SPM(BaseModel): + """ + Wraps the Single Particle Model (SPM) for simulating lithium-ion batteries, as implemented in PyBaMM. + + The SPM is a simplified physics-based model that represents a lithium-ion cell using a single + spherical particle to simulate the behavior of the negative and positive electrodes. + + Parameters + ---------- + name : str, optional + The name for the model instance, defaulting to "Single Particle Model". + parameter_set : pybamm.ParameterValues or dict, optional + The parameters for the model. If None, default parameters provided by PyBaMM are used. + geometry : dict, optional + The geometry definitions for the model. If None, default geometry from PyBaMM is used. + submesh_types : dict, optional + The types of submeshes to use. If None, default submesh types from PyBaMM are used. + var_pts : dict, optional + The discretization points for each variable in the model. If None, default points from PyBaMM are used. + spatial_methods : dict, optional + The spatial methods used for discretization. If None, default spatial methods from PyBaMM are used. + solver : pybamm.Solver, optional + The solver to use for simulating the model. If None, the default solver from PyBaMM is used. + options : dict, optional + A dictionary of options to customize the behavior of the PyBaMM model. + """ + + def __init__( + self, + name="Single Particle Model", + parameter_set=None, + geometry=None, + submesh_types=None, + var_pts=None, + spatial_methods=None, + solver=None, + options=None, + ): + super().__init__() + self.pybamm_model = pybamm.lithium_ion.SPM(options=options) + self._unprocessed_model = self.pybamm_model + self.name = name + + # Set parameters, using either the provided ones or the default + self.default_parameter_values = self.pybamm_model.default_parameter_values + self._parameter_set = ( + parameter_set or self.pybamm_model.default_parameter_values + ) + self._unprocessed_parameter_set = self._parameter_set + + # Define model geometry and discretization + self.geometry = geometry or self.pybamm_model.default_geometry + self.submesh_types = submesh_types or self.pybamm_model.default_submesh_types + self.var_pts = var_pts or self.pybamm_model.default_var_pts + self.spatial_methods = ( + spatial_methods or self.pybamm_model.default_spatial_methods + ) + self.solver = solver or self.pybamm_model.default_solver + + # Internal attributes for the built model are initialized but not set + self._model_with_set_params = None + self._built_model = None + self._built_initial_soc = None + self._mesh = None + self._disc = None + + +class SPMe(BaseModel): + """ + Represents the Single Particle Model with Electrolyte (SPMe) for lithium-ion batteries. + + The SPMe extends the basic Single Particle Model (SPM) by incorporating electrolyte dynamics, + making it suitable for simulations where electrolyte effects are non-negligible. This class + provides a framework to define the model parameters, geometry, mesh types, discretization + points, spatial methods, and numerical solvers for simulation within the PyBaMM ecosystem. + + Parameters + ---------- + name: str, optional + A name for the model instance, defaults to "Single Particle Model with Electrolyte". + parameter_set: pybamm.ParameterValues or dict, optional + A dictionary or a ParameterValues object containing the parameters for the model. If None, the default PyBaMM parameters for SPMe are used. + geometry: dict, optional + A dictionary defining the model's geometry. If None, the default PyBaMM geometry for SPMe is used. + submesh_types: dict, optional + A dictionary defining the types of submeshes to use. If None, the default PyBaMM submesh types for SPMe are used. + var_pts: dict, optional + A dictionary specifying the number of points for each variable for discretization. If None, the default PyBaMM variable points for SPMe are used. + spatial_methods: dict, optional + A dictionary specifying the spatial methods for discretization. If None, the default PyBaMM spatial methods for SPMe are used. + solver: pybamm.Solver, optional + The solver to use for simulating the model. If None, the default PyBaMM solver for SPMe is used. + options: dict, optional + A dictionary of options to customize the behavior of the PyBaMM model. + """ + + def __init__( + self, + name="Single Particle Model with Electrolyte", + parameter_set=None, + geometry=None, + submesh_types=None, + var_pts=None, + spatial_methods=None, + solver=None, + options=None, + ): + super().__init__() + self.pybamm_model = pybamm.lithium_ion.SPMe(options=options) + self._unprocessed_model = self.pybamm_model + self.name = name + + # Set parameters, using either the provided ones or the default + self.default_parameter_values = self.pybamm_model.default_parameter_values + self._parameter_set = ( + parameter_set or self.pybamm_model.default_parameter_values + ) + self._unprocessed_parameter_set = self._parameter_set + + # Define model geometry and discretization + self.geometry = geometry or self.pybamm_model.default_geometry + self.submesh_types = submesh_types or self.pybamm_model.default_submesh_types + self.var_pts = var_pts or self.pybamm_model.default_var_pts + self.spatial_methods = ( + spatial_methods or self.pybamm_model.default_spatial_methods + ) + self.solver = solver or self.pybamm_model.default_solver + + # Internal attributes for the built model are initialized but not set + self._model_with_set_params = None + self._built_model = None + self._built_initial_soc = None + self._mesh = None + self._disc = None diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index b0b13385..c15fcb92 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -1,17 +1,48 @@ class BaseOptimiser: """ + A base class for defining optimisation methods. - Base class for the optimisation methods. + This class serves as a template for creating optimisers. It provides a basic structure for + an optimisation algorithm, including the initial setup and a method stub for performing + the optimisation process. Child classes should override the optimise and _runoptimise + methods with specific algorithms. + Methods + ------- + optimise(cost_function, x0=None, bounds=None, maxiter=None) + Initiates the optimisation process. This is a stub and should be implemented in child classes. + _runoptimise(cost_function, x0=None, bounds=None) + Contains the logic for the optimisation algorithm. This is a stub and should be implemented in child classes. + name() + Returns the name of the optimiser. """ def __init__(self): + """ + Initializes the BaseOptimiser. + """ pass def optimise(self, cost_function, x0=None, bounds=None, maxiter=None): """ - Optimisiation method to be overloaded by child classes. + Initiates the optimisation process. + + This method should be overridden by child classes with the specific optimisation algorithm. + Parameters + ---------- + cost_function : callable + The cost function to be minimised by the optimiser. + x0 : ndarray, optional + Initial guess for the parameters. Default is None. + bounds : sequence or Bounds, optional + Bounds on the parameters. Default is None. + maxiter : int, optional + Maximum number of iterations to perform. Default is None. + + Returns + ------- + The result of the optimisation process. The specific type of this result will depend on the child implementation. """ self.cost_function = cost_function self.x0 = x0 @@ -25,13 +56,33 @@ def optimise(self, cost_function, x0=None, bounds=None, maxiter=None): def _runoptimise(self, cost_function, x0=None, bounds=None): """ - Run optimisation method, to be overloaded by child classes. + Contains the logic for the optimisation algorithm. + + This method should be implemented by child classes to perform the actual optimisation. + Parameters + ---------- + cost_function : callable + The cost function to be minimised by the optimiser. + x0 : ndarray, optional + Initial guess for the parameters. Default is None. + bounds : sequence or Bounds, optional + Bounds on the parameters. Default is None. + + Returns + ------- + This method is expected to return the result of the optimisation, the format of which + will be determined by the child class implementation. """ pass def name(self): """ Returns the name of the optimiser. + + Returns + ------- + str + The name of the optimiser, which is "BaseOptimiser" for this base class. """ - return "Base Optimiser" + return "BaseOptimiser" diff --git a/pybop/optimisers/nlopt_optimize.py b/pybop/optimisers/nlopt_optimize.py index 7d78a699..26f802a4 100644 --- a/pybop/optimisers/nlopt_optimize.py +++ b/pybop/optimisers/nlopt_optimize.py @@ -5,7 +5,31 @@ class NLoptOptimize(BaseOptimiser): """ - Wrapper class for the NLOpt optimiser class. Extends the BaseOptimiser class. + Extends BaseOptimiser to utilize the NLopt library for nonlinear optimization. + + This class serves as an interface to the NLopt optimization algorithms. It allows the user to + define an optimization problem with bounds, initial guesses, and to select an optimization method + provided by NLopt. + + Parameters + ---------- + n_param : int + Number of parameters to optimize. + xtol : float, optional + The relative tolerance for optimization (stopping criteria). If not provided, a default of 1e-5 is used. + method : nlopt.algorithm, optional + The NLopt algorithm to use for optimization. If not provided, LN_BOBYQA is used by default. + maxiter : int, optional + The maximum number of iterations to perform during optimization. If not provided, NLopt's default is used. + + Methods + ------- + _runoptimise(cost_function, x0, bounds) + Performs the optimization using the NLopt library. + needs_sensitivities() + Indicates whether the optimizer requires gradient information. + name() + Returns the name of the optimizer. """ def __init__(self, n_param, xtol=None, method=None, maxiter=None): @@ -25,14 +49,21 @@ def __init__(self, n_param, xtol=None, method=None, maxiter=None): def _runoptimise(self, cost_function, x0, bounds): """ - Run the NLOpt optimisation method. + Runs the optimization process using the NLopt library. - Inputs + Parameters ---------- - cost_function: function for optimising - method: optimisation algorithm - x0: initialisation array - bounds: bounds array + cost_function : callable + The objective function to minimize. It should take an array of parameter values and return the scalar cost. + x0 : array_like + The initial guess for the parameters. + bounds : dict + A dictionary containing the 'lower' and 'upper' bounds arrays for the parameters. + + Returns + ------- + tuple + A tuple containing the optimized parameter values and the final cost. """ # Add callback storing history of parameter values @@ -61,12 +92,22 @@ def cost_wrapper(x, grad): def needs_sensitivities(self): """ - Returns True if the optimiser needs sensitivities. + Indicates if the optimizer requires gradient information for the cost function. + + Returns + ------- + bool + False, as the default NLopt algorithms do not require gradient information. """ return False def name(self): """ - Returns the name of the optimiser. + Returns the name of this optimizer instance. + + Returns + ------- + str + The name 'NLoptOptimize' representing this NLopt optimization class. """ return "NLoptOptimize" diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index f8e17790..765b43c9 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -3,8 +3,24 @@ class GradientDescent(pints.GradientDescent): """ - Gradient descent optimiser. Inherits from the PINTS gradient descent class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_gradient_descent.py + Implements a simple gradient descent optimization algorithm. + + This class extends the gradient descent optimizer from the PINTS library, designed + to minimize a scalar function of one or more variables. Note that this optimizer + does not support boundary constraints. + + Parameters + ---------- + x0 : array_like + Initial position from which optimization will start. + sigma0 : float, optional + Initial step size (default is 0.1). + bounds : sequence or ``Bounds``, optional + Ignored by this optimizer, provided for API consistency. + + See Also + -------- + pints.GradientDescent : The PINTS implementation this class is based on. """ def __init__(self, x0, sigma0=0.1, bounds=None): @@ -17,8 +33,24 @@ def __init__(self, x0, sigma0=0.1, bounds=None): class Adam(pints.Adam): """ - Adam optimiser. Inherits from the PINTS Adam class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_adam.py + Implements the Adam optimization algorithm. + + This class extends the Adam optimizer from the PINTS library, which combines + ideas from RMSProp and Stochastic Gradient Descent with momentum. Note that + this optimizer does not support boundary constraints. + + Parameters + ---------- + x0 : array_like + Initial position from which optimization will start. + sigma0 : float, optional + Initial step size (default is 0.1). + bounds : sequence or ``Bounds``, optional + Ignored by this optimizer, provided for API consistency. + + See Also + -------- + pints.Adam : The PINTS implementation this class is based on. """ def __init__(self, x0, sigma0=0.1, bounds=None): @@ -31,8 +63,24 @@ def __init__(self, x0, sigma0=0.1, bounds=None): class IRPropMin(pints.IRPropMin): """ - IRProp- optimiser. Inherits from the PINTS IRPropMinus class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_irpropmin.py + Implements the iRpropMin optimization algorithm. + + This class inherits from the PINTS IRPropMin class, which is an optimizer that + uses resilient backpropagation with weight-backtracking. It is designed to handle + problems with large plateaus, noisy gradients, and local minima. + + Parameters + ---------- + x0 : array_like + Initial position from which optimization will start. + sigma0 : float, optional + Initial step size (default is 0.1). + bounds : dict, optional + Lower and upper bounds for each optimization parameter. + + See Also + -------- + pints.IRPropMin : The PINTS implementation this class is based on. """ def __init__(self, x0, sigma0=0.1, bounds=None): @@ -47,8 +95,24 @@ def __init__(self, x0, sigma0=0.1, bounds=None): class PSO(pints.PSO): """ - Particle swarm optimiser. Inherits from the PINTS PSO class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_pso.py + Implements a particle swarm optimization (PSO) algorithm. + + This class extends the PSO optimizer from the PINTS library. PSO is a + metaheuristic optimization method inspired by the social behavior of birds + flocking or fish schooling, suitable for global optimization problems. + + Parameters + ---------- + x0 : array_like + Initial positions of particles, which the optimization will use. + sigma0 : float, optional + Spread of the initial particle positions (default is 0.1). + bounds : dict, optional + Lower and upper bounds for each optimization parameter. + + See Also + -------- + pints.PSO : The PINTS implementation this class is based on. """ def __init__(self, x0, sigma0=0.1, bounds=None): @@ -63,8 +127,24 @@ def __init__(self, x0, sigma0=0.1, bounds=None): class SNES(pints.SNES): """ - Stochastic natural evolution strategy optimiser. Inherits from the PINTS SNES class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_snes.py + Implements the stochastic natural evolution strategy (SNES) optimization algorithm. + + Inheriting from the PINTS SNES class, this optimizer is an evolutionary algorithm + that evolves a probability distribution on the parameter space, guiding the search + for the optimum based on the natural gradient of expected fitness. + + Parameters + ---------- + x0 : array_like + Initial position from which optimization will start. + sigma0 : float, optional + Initial step size (default is 0.1). + bounds : dict, optional + Lower and upper bounds for each optimization parameter. + + See Also + -------- + pints.SNES : The PINTS implementation this class is based on. """ def __init__(self, x0, sigma0=0.1, bounds=None): @@ -79,8 +159,22 @@ def __init__(self, x0, sigma0=0.1, bounds=None): class XNES(pints.XNES): """ - Exponential natural evolution strategy optimiser. Inherits from the PINTS XNES class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_xnes.py + Implements the Exponential Natural Evolution Strategy (XNES) optimizer from PINTS. + + XNES is an evolutionary algorithm that samples from a multivariate normal distribution, which is updated iteratively to fit the distribution of successful solutions. + + Parameters + ---------- + x0 : array_like + The initial parameter vector to optimize. + sigma0 : float, optional + Initial standard deviation of the sampling distribution, defaults to 0.1. + bounds : dict, optional + A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. If ``None``, no bounds are enforced. + + See Also + -------- + pints.XNES : PINTS implementation of XNES algorithm. """ def __init__(self, x0, sigma0=0.1, bounds=None): @@ -95,8 +189,24 @@ def __init__(self, x0, sigma0=0.1, bounds=None): class CMAES(pints.CMAES): """ - Class for the PINTS optimisation. Extends the BaseOptimiser class. - https://github.com/pints-team/pints/blob/main/pints/_optimisers/_cmaes.py + Adapter for the Covariance Matrix Adaptation Evolution Strategy (CMA-ES) optimizer in PINTS. + + CMA-ES is an evolutionary algorithm for difficult non-linear non-convex optimization problems. + It adapts the covariance matrix of a multivariate normal distribution to capture the shape of the cost landscape. + + Parameters + ---------- + x0 : array_like + The initial parameter vector to optimize. + sigma0 : float, optional + Initial standard deviation of the sampling distribution, defaults to 0.1. + bounds : dict, optional + A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. + If ``None``, no bounds are enforced. + + See Also + -------- + pints.CMAES : PINTS implementation of CMA-ES algorithm. """ def __init__(self, x0, sigma0=0.1, bounds=None): diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 59f9e638..ce7e4fe5 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -4,7 +4,18 @@ class SciPyMinimize(BaseOptimiser): """ - Wrapper class for the SciPy optimisation class. Extends the BaseOptimiser class. + Adapts SciPy's minimize function for use as an optimization strategy. + + This class provides an interface to various scalar minimization algorithms implemented in SciPy, allowing fine-tuning of the optimization process through method selection and option configuration. + + Parameters + ---------- + method : str, optional + The type of solver to use. If not specified, defaults to 'COBYLA'. + bounds : sequence or ``Bounds``, optional + Bounds for variables as supported by the selected method. + maxiter : int, optional + Maximum number of iterations to perform. """ def __init__(self, method=None, bounds=None, maxiter=None): @@ -22,14 +33,21 @@ def __init__(self, method=None, bounds=None, maxiter=None): def _runoptimise(self, cost_function, x0, bounds): """ - Run the SciPy optimisation method. + Executes the optimization process using SciPy's minimize function. - Inputs + Parameters ---------- - cost_function: function for optimising - method: optimisation algorithm - x0: initialisation array - bounds: bounds array + cost_function : callable + The objective function to minimize. + x0 : array_like + Initial guess for the parameters. + bounds : sequence or `Bounds` + Bounds for the variables. + + Returns + ------- + tuple + A tuple (x, final_cost) containing the optimized parameters and the value of `cost_function` at the optimum. """ # Add callback storing history of parameter values @@ -61,20 +79,43 @@ def callback(x): def needs_sensitivities(self): """ - Returns True if the optimiser needs sensitivities. + Determines if the optimization algorithm requires gradient information. + + Returns + ------- + bool + False, indicating that gradient information is not required. """ return False def name(self): """ - Returns the name of the optimiser. + Provides the name of the optimization strategy. + + Returns + ------- + str + The name 'SciPyMinimize'. """ return "SciPyMinimize" class SciPyDifferentialEvolution(BaseOptimiser): """ - Wrapper class for the SciPy differential_evolution optimisation method. Extends the BaseOptimiser class. + Adapts SciPy's differential_evolution function for global optimization. + + This class provides a global optimization strategy based on differential evolution, useful for problems involving continuous parameters and potentially multiple local minima. + + Parameters + ---------- + bounds : sequence or ``Bounds`` + Bounds for variables. Must be provided as it is essential for differential evolution. + strategy : str, optional + The differential evolution strategy to use. Defaults to 'best1bin'. + maxiter : int, optional + Maximum number of iterations to perform. Defaults to 1000. + popsize : int, optional + The number of individuals in the population. Defaults to 15. """ def __init__(self, bounds=None, strategy="best1bin", maxiter=1000, popsize=15): @@ -86,18 +127,21 @@ def __init__(self, bounds=None, strategy="best1bin", maxiter=1000, popsize=15): def _runoptimise(self, cost_function, x0=None, bounds=None): """ - Run the SciPy differential_evolution optimisation method. + Executes the optimization process using SciPy's differential_evolution function. - Inputs + Parameters ---------- - cost_function : function - The objective function to be minimized. - x0 : array_like - Initial guess. Only used to determine the dimensionality of the problem. - bounds : sequence or `Bounds` - Bounds for variables. There are two ways to specify the bounds: - 1. Instance of `Bounds` class. - 2. Sequence of (min, max) pairs for each element in x, defining the finite lower and upper bounds for the optimizing argument of `cost_function`. + cost_function : callable + The objective function to minimize. + x0 : array_like, optional + Ignored parameter, provided for API consistency. + bounds : sequence or ``Bounds`` + Bounds for the variables, required for differential evolution. + + Returns + ------- + tuple + A tuple (x, final_cost) containing the optimized parameters and the value of ``cost_function`` at the optimum. """ if bounds is None: @@ -137,12 +181,22 @@ def callback(x, convergence): def needs_sensitivities(self): """ - Returns False as differential_evolution does not need sensitivities. + Determines if the optimization algorithm requires gradient information. + + Returns + ------- + bool + False, indicating that gradient information is not required for differential evolution. """ return False def name(self): """ - Returns the name of the optimiser. + Provides the name of the optimization strategy. + + Returns + ------- + str + The name 'SciPyDifferentialEvolution'. """ return "SciPyDifferentialEvolution" diff --git a/pybop/parameters/parameter.py b/pybop/parameters/parameter.py index 9cdf8bc2..37514bfc 100644 --- a/pybop/parameters/parameter.py +++ b/pybop/parameters/parameter.py @@ -2,11 +2,45 @@ class Parameter: - """ "" - Class for creating parameters in PyBOP. + """ + Represents a parameter within the PyBOP framework. + + This class encapsulates the definition of a parameter, including its name, prior + distribution, initial value, bounds, and a margin to ensure the parameter stays + within feasible limits during optimization or sampling. + + Parameters + ---------- + name : str + The name of the parameter. + initial_value : float, optional + The initial value to be assigned to the parameter. Defaults to None. + prior : scipy.stats distribution, optional + The prior distribution from which parameter values are drawn. Defaults to None. + bounds : tuple, optional + A tuple defining the lower and upper bounds for the parameter. + Defaults to None. + + Methods + ------- + rvs(n_samples) + Draw random samples from the parameter's prior distribution. + update(value) + Update the parameter's current value. + set_margin(margin) + Set the margin to a specified positive value less than 1. + + Raises + ------ + ValueError + If the lower bound is not strictly less than the upper bound, or if + the margin is set outside the interval (0, 1). """ def __init__(self, name, initial_value=None, prior=None, bounds=None): + """ + Construct the parameter class with a name, initial value, prior, and bounds. + """ self.name = name self.prior = prior self.initial_value = initial_value @@ -21,7 +55,20 @@ def __init__(self, name, initial_value=None, prior=None, bounds=None): def rvs(self, n_samples): """ - Returns a random value sample from the prior distribution. + Draw random samples from the parameter's prior distribution. + + The samples are constrained to be within the parameter's bounds, excluding + a predefined margin at the boundaries. + + Parameters + ---------- + n_samples : int + The number of samples to draw. + + Returns + ------- + array-like + An array of samples drawn from the prior distribution within the parameter's bounds. """ samples = self.prior.rvs(n_samples) @@ -32,14 +79,43 @@ def rvs(self, n_samples): return samples def update(self, value): + """ + Update the parameter's current value. + + Parameters + ---------- + value : float + The new value to be assigned to the parameter. + """ self.value = value def __repr__(self): + """ + Return a string representation of the Parameter instance. + + Returns + ------- + str + A string including the parameter's name, prior, bounds, and current value. + """ return f"Parameter: {self.name} \n Prior: {self.prior} \n Bounds: {self.bounds} \n Value: {self.value}" def set_margin(self, margin): """ - Sets the margin for the parameter. + Set the margin to a specified positive value less than 1. + + The margin is used to ensure parameter samples are not drawn exactly at the bounds, + which may be problematic in some optimization or sampling algorithms. + + Parameters + ---------- + margin : float + The new margin value to be used, which must be in the interval (0, 1). + + Raises + ------ + ValueError + If the margin is not between 0 and 1. """ if not 0 < margin < 1: raise ValueError("Margin must be between 0 and 1") diff --git a/pybop/parameters/parameter_set.py b/pybop/parameters/parameter_set.py index 8db93f8c..946d05ba 100644 --- a/pybop/parameters/parameter_set.py +++ b/pybop/parameters/parameter_set.py @@ -6,11 +6,18 @@ class ParameterSet: """ - A class to manage the import and export of parameter sets for battery models. + Handles the import and export of parameter sets for battery models. - Attributes: - json_path (str): The file path to a JSON file containing parameter data. - params (dict): A dictionary containing parameter key-value pairs. + This class provides methods to load parameters from a JSON file and to export them + back to a JSON file. It also includes custom logic to handle special cases, such + as parameter values that require specific initialization. + + Parameters + ---------- + json_path : str, optional + Path to a JSON file containing parameter data. If provided, parameters will be imported from this file during initialization. + params_dict : dict, optional + A dictionary of parameters to initialize the ParameterSet with. If not provided, an empty dictionary is used. """ def __init__(self, json_path=None, params_dict=None): @@ -20,7 +27,26 @@ def __init__(self, json_path=None, params_dict=None): def import_parameters(self, json_path=None): """ - Import parameters from a JSON file. + Imports parameters from a JSON file specified by the `json_path` attribute. + + If a `json_path` is provided at initialization or as an argument, that JSON file + is loaded and the parameters are stored in the `params` attribute. Special cases + are handled appropriately. + + Parameters + ---------- + json_path : str, optional + Path to the JSON file from which to import parameters. If provided, it overrides the instance's `json_path`. + + Returns + ------- + dict + The dictionary containing the imported parameters. + + Raises + ------ + FileNotFoundError + If the specified JSON file cannot be found. """ # Read JSON file @@ -34,7 +60,10 @@ def import_parameters(self, json_path=None): def _handle_special_cases(self): """ - Handles special cases for parameter values that require custom logic. + Processes special cases for parameter values that require custom handling. + + For example, if the open-circuit voltage is specified as 'default', it will + fetch the default value from the PyBaMM empirical Thevenin model. """ if ( "Open-circuit voltage [V]" in self.params @@ -48,7 +77,23 @@ def _handle_special_cases(self): def export_parameters(self, output_json_path, fit_params=None): """ - Export parameters to a JSON file. + Exports parameters to a JSON file specified by `output_json_path`. + + The current state of the `params` attribute is written to the file. If `fit_params` + is provided, these parameters are updated before export. Non-serializable values + are handled and noted in the output JSON. + + Parameters + ---------- + output_json_path : str + The file path where the JSON output will be saved. + fit_params : list of fitted parameter objects, optional + Parameters that have been fitted and need to be included in the export. + + Raises + ------ + ValueError + If there are no parameters to export. """ if not self.params: raise ValueError("No parameters to export. Please import parameters first.") @@ -74,7 +119,17 @@ def export_parameters(self, output_json_path, fit_params=None): def is_json_serializable(self, value): """ - Check if the value is serializable to JSON. + Determines if the given `value` can be serialized to JSON format. + + Parameters + ---------- + value : any + The value to check for JSON serializability. + + Returns + ------- + bool + True if the value is JSON serializable, False otherwise. """ try: json.dumps(value) @@ -85,6 +140,16 @@ def is_json_serializable(self, value): @classmethod def pybamm(cls, name): """ - Create a PyBaMM parameter set. + Retrieves a PyBaMM parameter set by name. + + Parameters + ---------- + name : str + The name of the PyBaMM parameter set to retrieve. + + Returns + ------- + pybamm.ParameterValues + A PyBaMM parameter set corresponding to the provided name. """ return pybamm.ParameterValues(name).copy() diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index f98e9b76..088e584d 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -3,7 +3,17 @@ class Gaussian: """ - Gaussian prior class. + Represents a Gaussian (normal) distribution with a given mean and standard deviation. + + This class provides methods to calculate the probability density function (pdf), + the logarithm of the pdf, and to generate random variates (rvs) from the distribution. + + Parameters + ---------- + mean : float + The mean (mu) of the Gaussian distribution. + sigma : float + The standard deviation (sigma) of the Gaussian distribution. """ def __init__(self, mean, sigma): @@ -12,24 +22,81 @@ def __init__(self, mean, sigma): self.sigma = sigma def pdf(self, x): + """ + Calculates the probability density function of the Gaussian distribution at x. + + Parameters + ---------- + x : float + The point at which to evaluate the pdf. + + Returns + ------- + float + The probability density function value at x. + """ return stats.norm.pdf(x, loc=self.mean, scale=self.sigma) def logpdf(self, x): + """ + Calculates the logarithm of the probability density function of the Gaussian distribution at x. + + Parameters + ---------- + x : float + The point at which to evaluate the log pdf. + + Returns + ------- + float + The logarithm of the probability density function value at x. + """ return stats.norm.logpdf(x, loc=self.mean, scale=self.sigma) def rvs(self, size): + """ + Generates random variates from the Gaussian distribution. + + Parameters + ---------- + size : int + The number of random variates to generate. + + Returns + ------- + array_like + An array of random variates from the Gaussian distribution. + + Raises + ------ + ValueError + If the size parameter is not positive. + """ if size < 0: raise ValueError("size must be positive") else: return stats.norm.rvs(loc=self.mean, scale=self.sigma, size=size) def __repr__(self): + """ + Returns a string representation of the Gaussian object. + """ return f"{self.name}, mean: {self.mean}, sigma: {self.sigma}" class Uniform: """ - Uniform prior class. + Represents a uniform distribution over a specified interval. + + This class provides methods to calculate the pdf, the log pdf, and to generate + random variates from the distribution. + + Parameters + ---------- + lower : float + The lower bound of the distribution. + upper : float + The upper bound of the distribution. """ def __init__(self, lower, upper): @@ -38,12 +105,56 @@ def __init__(self, lower, upper): self.upper = upper def pdf(self, x): + """ + Calculates the probability density function of the uniform distribution at x. + + Parameters + ---------- + x : float + The point at which to evaluate the pdf. + + Returns + ------- + float + The probability density function value at x. + """ return stats.uniform.pdf(x, loc=self.lower, scale=self.upper - self.lower) def logpdf(self, x): + """ + Calculates the logarithm of the pdf of the uniform distribution at x. + + Parameters + ---------- + x : float + The point at which to evaluate the log pdf. + + Returns + ------- + float + The log of the probability density function value at x. + """ return stats.uniform.logpdf(x, loc=self.lower, scale=self.upper - self.lower) def rvs(self, size): + """ + Generates random variates from the uniform distribution. + + Parameters + ---------- + size : int + The number of random variates to generate. + + Returns + ------- + array_like + An array of random variates from the uniform distribution. + + Raises + ------ + ValueError + If the size parameter is not positive. + """ if size < 0: raise ValueError("size must be positive") else: @@ -52,12 +163,23 @@ def rvs(self, size): ) def __repr__(self): + """ + Returns a string representation of the Uniform object. + """ return f"{self.name}, lower: {self.lower}, upper: {self.upper}" class Exponential: """ - Exponential prior class. + Represents an exponential distribution with a specified scale parameter. + + This class provides methods to calculate the pdf, the log pdf, and to generate random + variates from the distribution. + + Parameters + ---------- + scale : float + The scale parameter (lambda) of the exponential distribution. """ def __init__(self, scale): @@ -65,16 +187,63 @@ def __init__(self, scale): self.scale = scale def pdf(self, x): + """ + Calculates the probability density function of the exponential distribution at x. + + Parameters + ---------- + x : float + The point at which to evaluate the pdf. + + Returns + ------- + float + The probability density function value at x. + """ return stats.expon.pdf(x, scale=self.scale) def logpdf(self, x): + """ + Calculates the logarithm of the pdf of the exponential distribution at x. + + Parameters + ---------- + x : float + The point at which to evaluate the log pdf. + + Returns + ------- + float + The log of the probability density function value at x. + """ return stats.expon.logpdf(x, scale=self.scale) def rvs(self, size): + """ + Generates random variates from the exponential distribution. + + Parameters + ---------- + size : int + The number of random variates to generate. + + Returns + ------- + array_like + An array of random variates from the exponential distribution. + + Raises + ------ + ValueError + If the size parameter is not positive. + """ if size < 0: raise ValueError("size must be positive") else: return stats.expon.rvs(scale=self.scale, size=size) def __repr__(self): + """ + Returns a string representation of the Uniform object. + """ return f"{self.name}, scale: {self.scale}" From 5ebc1f595cb59c7ecb3b92c7e2f39134d2affc0f Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 09:05:37 +0000 Subject: [PATCH 073/101] Add windows/macos runners --- .github/workflows/scheduled_tests.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/scheduled_tests.yaml b/.github/workflows/scheduled_tests.yaml index 2de1a0f8..dc2e6506 100644 --- a/.github/workflows/scheduled_tests.yaml +++ b/.github/workflows/scheduled_tests.yaml @@ -12,10 +12,11 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.8", "3.9", "3.10", "3.11"] steps: From 8743034a56d5b0debd295faa7abf877d6eed2370 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 11:03:35 +0000 Subject: [PATCH 074/101] Add deploy docs workflow --- .github/workflows/deploy-docs.yaml | 37 ++++++++++++++++++++++++++ .github/workflows/scheduled_tests.yaml | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy-docs.yaml diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml new file mode 100644 index 00000000..4f080fd0 --- /dev/null +++ b/.github/workflows/deploy-docs.yaml @@ -0,0 +1,37 @@ +name: Deploy Documentation + +on: + # release: + # types: [created] + push: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Check out PyBOP repository + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + id: setup-python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + cache: 'pip' + cache-dependency-path: setup.py + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[docs] + + - name: Build documentation with sphinx-build + run: | + sphinx-build -b html docs/ docs/_build/html + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/_build/html + publish_branch: gh-pages diff --git a/.github/workflows/scheduled_tests.yaml b/.github/workflows/scheduled_tests.yaml index 2de1a0f8..d1a0e02d 100644 --- a/.github/workflows/scheduled_tests.yaml +++ b/.github/workflows/scheduled_tests.yaml @@ -32,7 +32,7 @@ jobs: python -m nox -s unit python -m nox -s notebooks - #M-series Mac Mini + #M-series Mac Mini build-apple-mseries: runs-on: [self-hosted, macOS, ARM64] env: From 17e60ca8bf9c0aaca834ea0d2104aced0679f161 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 11:17:23 +0000 Subject: [PATCH 075/101] Add workflow dispatch to deploy workflow --- .github/workflows/deploy-docs.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 4f080fd0..de0003e6 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -3,6 +3,7 @@ name: Deploy Documentation on: # release: # types: [created] + workflow_dispatch: push: jobs: @@ -11,6 +12,8 @@ jobs: steps: - name: Check out PyBOP repository uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod - name: Set up Python 3.11 id: setup-python From 6909d3002fd3462d77a4940146ea4c7ef1eeafe5 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 11:28:30 +0000 Subject: [PATCH 076/101] updt permissions for deployment action --- .github/workflows/deploy-docs.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index de0003e6..390b12a9 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -6,6 +6,9 @@ on: workflow_dispatch: push: +permissions: + contents: write # allow write access for docs deployment + jobs: build-and-deploy: runs-on: ubuntu-latest From 5dfec0bfd23f9cd9cb848c1fa39ff4d34b1148b0 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 14:04:18 +0000 Subject: [PATCH 077/101] Finish docstring update, add pypi icon, update links & frontpage --- docs/_static/custom-icon.js | 17 + docs/api/index.rst | 12 - docs/api/pybop/_costs/index.rst | 153 -- docs/api/pybop/_dataset/index.rst | 50 - docs/api/pybop/_problem/index.rst | 85 - docs/api/pybop/index.rst | 1537 ----------------- docs/api/pybop/models/base_model/index.rst | 178 -- docs/api/pybop/models/empirical/ecm/index.rst | 47 - docs/api/pybop/models/empirical/index.rst | 56 - docs/api/pybop/models/index.rst | 23 - .../pybop/models/lithium_ion/echem/index.rst | 76 - docs/api/pybop/models/lithium_ion/index.rst | 85 - docs/api/pybop/optimisation/index.rst | 125 -- .../pybop/optimisers/base_optimiser/index.rst | 83 - docs/api/pybop/optimisers/index.rst | 16 - .../pybop/optimisers/nlopt_optimize/index.rst | 81 - .../optimisers/pints_optimisers/index.rst | 189 -- .../optimisers/scipy_optimisers/index.rst | 115 -- docs/api/pybop/parameters/index.rst | 15 - docs/api/pybop/parameters/parameter/index.rst | 95 - .../pybop/parameters/parameter_set/index.rst | 95 - docs/api/pybop/parameters/priors/index.rst | 178 -- docs/api/pybop/plotting/index.rst | 17 - .../pybop/plotting/plot_convergence/index.rst | 38 - docs/api/pybop/plotting/plot_cost2d/index.rst | 54 - .../pybop/plotting/plot_parameters/index.rst | 89 - .../pybop/plotting/plotly_manager/index.rst | 70 - docs/api/pybop/plotting/quick_plot/index.rst | 123 -- docs/api/pybop/version/index.rst | 11 - docs/conf.py | 8 +- docs/index.md | 2 +- docs/user_guide/installation.rst | 5 +- pybop/_problem.py | 105 +- pybop/optimisation.py | 160 +- pybop/plotting/plot_cost2d.py | 84 +- pybop/plotting/plot_parameters.py | 103 +- pybop/plotting/plotly_manager.py | 81 +- pybop/plotting/quick_plot.py | 134 +- 38 files changed, 468 insertions(+), 3927 deletions(-) create mode 100644 docs/_static/custom-icon.js delete mode 100644 docs/api/index.rst delete mode 100644 docs/api/pybop/_costs/index.rst delete mode 100644 docs/api/pybop/_dataset/index.rst delete mode 100644 docs/api/pybop/_problem/index.rst delete mode 100644 docs/api/pybop/index.rst delete mode 100644 docs/api/pybop/models/base_model/index.rst delete mode 100644 docs/api/pybop/models/empirical/ecm/index.rst delete mode 100644 docs/api/pybop/models/empirical/index.rst delete mode 100644 docs/api/pybop/models/index.rst delete mode 100644 docs/api/pybop/models/lithium_ion/echem/index.rst delete mode 100644 docs/api/pybop/models/lithium_ion/index.rst delete mode 100644 docs/api/pybop/optimisation/index.rst delete mode 100644 docs/api/pybop/optimisers/base_optimiser/index.rst delete mode 100644 docs/api/pybop/optimisers/index.rst delete mode 100644 docs/api/pybop/optimisers/nlopt_optimize/index.rst delete mode 100644 docs/api/pybop/optimisers/pints_optimisers/index.rst delete mode 100644 docs/api/pybop/optimisers/scipy_optimisers/index.rst delete mode 100644 docs/api/pybop/parameters/index.rst delete mode 100644 docs/api/pybop/parameters/parameter/index.rst delete mode 100644 docs/api/pybop/parameters/parameter_set/index.rst delete mode 100644 docs/api/pybop/parameters/priors/index.rst delete mode 100644 docs/api/pybop/plotting/index.rst delete mode 100644 docs/api/pybop/plotting/plot_convergence/index.rst delete mode 100644 docs/api/pybop/plotting/plot_cost2d/index.rst delete mode 100644 docs/api/pybop/plotting/plot_parameters/index.rst delete mode 100644 docs/api/pybop/plotting/plotly_manager/index.rst delete mode 100644 docs/api/pybop/plotting/quick_plot/index.rst delete mode 100644 docs/api/pybop/version/index.rst diff --git a/docs/_static/custom-icon.js b/docs/_static/custom-icon.js new file mode 100644 index 00000000..ac9c6c91 --- /dev/null +++ b/docs/_static/custom-icon.js @@ -0,0 +1,17 @@ +/******************************************************************************* + * Set a custom icon for pypi as it's not available in the fa built-in brands + * Taken from: https://github.com/pydata/pydata-sphinx-theme/blob/main/docs/_static/custom-icon.js + */ +FontAwesome.library.add( + (faListOldStyle = { + prefix: "fa-custom", + iconName: "pypi", + icon: [ + 17.313, // viewBox width + 19.807, // viewBox height + [], // ligature + "e001", // unicode codepoint - private use area + "m10.383 0.2-3.239 1.1769 3.1883 1.1614 3.239-1.1798zm-3.4152 1.2411-3.2362 1.1769 3.1855 1.1614 3.2369-1.1769zm6.7177 0.00281-3.2947 1.2009v3.8254l3.2947-1.1988zm-3.4145 1.2439-3.2926 1.1981v3.8254l0.17548-0.064132 3.1171-1.1347zm-6.6564 0.018325v3.8247l3.244 1.1805v-3.8254zm10.191 0.20931v2.3137l3.1777-1.1558zm3.2947 1.2425-3.2947 1.1988v3.8254l3.2947-1.1988zm-8.7058 0.45739c0.00929-1.931e-4 0.018327-2.977e-4 0.027485 0 0.25633 0.00851 0.4263 0.20713 0.42638 0.49826 1.953e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36226 0.13215-0.65608-0.073306-0.65613-0.4588-6.28e-5 -0.38556 0.2938-0.80504 0.65613-0.93662 0.068422-0.024919 0.13655-0.038114 0.20156-0.039466zm5.2913 0.78369-3.2947 1.1988v3.8247l3.2947-1.1981zm-10.132 1.239-3.2362 1.1769 3.1883 1.1614 3.2362-1.1769zm6.7177 0.00213-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2439-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.016195v3.8275l3.244 1.1805v-3.8254zm16.9 0.21143-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm-3.4124 1.2432-3.2947 1.1988v3.8254l3.2947-1.1988zm-6.6585 0.019027v3.8247l3.244 1.1805v-3.8254zm13.485 1.4497-3.2947 1.1988v3.8247l3.2947-1.1981zm-3.4145 1.2411-3.2926 1.2016v3.8247l3.2926-1.2009zm2.4018 0.38127c0.0093-1.83e-4 0.01833-3.16e-4 0.02749 0 0.25633 0.0085 0.4263 0.20713 0.42638 0.49826 1.97e-4 0.38532-0.29327 0.80469-0.65542 0.93662-0.36188 0.1316-0.65525-0.07375-0.65542-0.4588-1.95e-4 -0.38532 0.29328-0.80469 0.65542-0.93662 0.06842-0.02494 0.13655-0.03819 0.20156-0.03947zm-5.8142 0.86403-3.244 1.1805v1.4201l3.244 1.1805z", // svg path (https://simpleicons.org/icons/pypi.svg) + ], + }) +); diff --git a/docs/api/index.rst b/docs/api/index.rst deleted file mode 100644 index a248a75b..00000000 --- a/docs/api/index.rst +++ /dev/null @@ -1,12 +0,0 @@ -API Reference -============= - -This page contains auto-generated API reference documentation [#f1]_. - -.. toctree:: - :titlesonly: - :maxdepth: 2 - - /api/pybop/index - -.. [#f1] Created with `sphinx-autoapi `_ diff --git a/docs/api/pybop/_costs/index.rst b/docs/api/pybop/_costs/index.rst deleted file mode 100644 index 36d8cab8..00000000 --- a/docs/api/pybop/_costs/index.rst +++ /dev/null @@ -1,153 +0,0 @@ -:py:mod:`pybop._costs` -====================== - -.. py:module:: pybop._costs - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop._costs.BaseCost - pybop._costs.RootMeanSquaredError - pybop._costs.SumSquaredError - - - - -.. py:class:: BaseCost(problem) - - - Base class for defining cost functions. - - This class is intended to be subclassed to create specific cost functions - for evaluating model predictions against a set of data. The cost function - quantifies the goodness-of-fit between the model predictions and the - observed data, with a lower cost value indicating a better fit. - - :param problem: A problem instance containing the data and functions necessary for - evaluating the cost function. - :type problem: object - :param _target: An array containing the target data to fit. - :type _target: array-like - :param x0: The initial guess for the model parameters. - :type x0: array-like - :param bounds: The bounds for the model parameters. - :type bounds: tuple - :param n_parameters: The number of parameters in the model. - :type n_parameters: int - - .. py:method:: __call__(x, grad=None) - :abstractmethod: - - Calculate the cost function value for a given set of parameters. - - This method must be implemented by subclasses. - - :param x: The parameters for which to evaluate the cost. - :type x: array-like - :param grad: An array to store the gradient of the cost function with respect - to the parameters. - :type grad: array-like, optional - - :returns: The calculated cost function value. - :rtype: float - - :raises NotImplementedError: If the method has not been implemented by the subclass. - - - -.. py:class:: RootMeanSquaredError(problem) - - - Bases: :py:obj:`BaseCost` - - Root mean square error cost function. - - Computes the root mean square error between model predictions and the target - data, providing a measure of the differences between predicted values and - observed values. - - Inherits all parameters and attributes from ``BaseCost``. - - - .. py:method:: __call__(x, grad=None) - - Calculate the root mean square error for a given set of parameters. - - :param x: The parameters for which to evaluate the cost. - :type x: array-like - :param grad: An array to store the gradient of the cost function with respect - to the parameters. - :type grad: array-like, optional - - :returns: The root mean square error. - :rtype: float - - :raises ValueError: If an error occurs during the calculation of the cost. - - - -.. py:class:: SumSquaredError(problem) - - - Bases: :py:obj:`BaseCost` - - Sum of squared errors cost function. - - Computes the sum of the squares of the differences between model predictions - and target data, which serves as a measure of the total error between the - predicted and observed values. - - Inherits all parameters and attributes from ``BaseCost``. - - Additional Attributes - --------------------- - _de : float - The gradient of the cost function to use if an error occurs during - evaluation. Defaults to 1.0. - - - .. py:method:: __call__(x, grad=None) - - Calculate the sum of squared errors for a given set of parameters. - - :param x: The parameters for which to evaluate the cost. - :type x: array-like - :param grad: An array to store the gradient of the cost function with respect - to the parameters. - :type grad: array-like, optional - - :returns: The sum of squared errors. - :rtype: float - - :raises ValueError: If an error occurs during the calculation of the cost. - - - .. py:method:: evaluateS1(x) - - Compute the cost and its gradient with respect to the parameters. - - :param x: The parameters for which to compute the cost and gradient. - :type x: array-like - - :returns: A tuple containing the cost and the gradient. The cost is a float, - and the gradient is an array-like of the same length as `x`. - :rtype: tuple - - :raises ValueError: If an error occurs during the calculation of the cost or gradient. - - - .. py:method:: set_fail_gradient(de) - - Set the fail gradient to a specified value. - - The fail gradient is used if an error occurs during the calculation - of the gradient. This method allows updating the default gradient value. - - :param de: The new fail gradient value to be used. - :type de: float diff --git a/docs/api/pybop/_dataset/index.rst b/docs/api/pybop/_dataset/index.rst deleted file mode 100644 index 53913bed..00000000 --- a/docs/api/pybop/_dataset/index.rst +++ /dev/null @@ -1,50 +0,0 @@ -:py:mod:`pybop._dataset` -======================== - -.. py:module:: pybop._dataset - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop._dataset.Dataset - - - - -.. py:class:: Dataset(name, data) - - - Represents a collection of experimental observations. - - This class provides a structured way to store and work with experimental data, - which may include applying operations such as interpolation. - - :param name: The name of the dataset, providing a label for identification. - :type name: str - :param data: The actual experimental data, typically in a structured form such as - a NumPy array or a pandas DataFrame. - :type data: array-like - - .. py:method:: Interpolant() - - Create an interpolation function of the dataset based on the independent variable. - - Currently, only time-based interpolation is supported. This method modifies - the instance's Interpolant attribute to be an interpolation function that - can be evaluated at different points in time. - - :raises NotImplementedError: If the independent variable for interpolation is not supported. - - - .. py:method:: __repr__() - - Return a string representation of the Dataset instance. - - :returns: A string that includes the name and data of the dataset. - :rtype: str diff --git a/docs/api/pybop/_problem/index.rst b/docs/api/pybop/_problem/index.rst deleted file mode 100644 index 58091c11..00000000 --- a/docs/api/pybop/_problem/index.rst +++ /dev/null @@ -1,85 +0,0 @@ -:py:mod:`pybop._problem` -======================== - -.. py:module:: pybop._problem - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop._problem.BaseProblem - pybop._problem.DesignProblem - pybop._problem.FittingProblem - - - - -.. py:class:: BaseProblem(parameters, model=None, check_model=True, init_soc=None, x0=None) - - - Defines the PyBOP base problem, following the PINTS interface. - - .. py:method:: evaluate(x) - :abstractmethod: - - Evaluate the model with the given parameters and return the signal. - - - .. py:method:: evaluateS1(x) - :abstractmethod: - - Evaluate the model with the given parameters and return the signal and - its derivatives. - - - -.. py:class:: DesignProblem(model, parameters, experiment, check_model=True, init_soc=None, x0=None) - - - Bases: :py:obj:`BaseProblem` - - Defines the problem class for a design optimiation problem. - - .. py:method:: evaluate(x) - - Evaluate the model with the given parameters and return the signal. - - - .. py:method:: evaluateS1(x) - - Evaluate the model with the given parameters and return the signal and - its derivatives. - - - .. py:method:: target() - - Returns the target dataset. - - - -.. py:class:: FittingProblem(model, parameters, dataset, signal='Voltage [V]', check_model=True, init_soc=None, x0=None) - - - Bases: :py:obj:`BaseProblem` - - Defines the problem class for a fitting (parameter estimation) problem. - - .. py:method:: evaluate(x) - - Evaluate the model with the given parameters and return the signal. - - - .. py:method:: evaluateS1(x) - - Evaluate the model with the given parameters and return the signal and - its derivatives. - - - .. py:method:: target() - - Returns the target dataset. diff --git a/docs/api/pybop/index.rst b/docs/api/pybop/index.rst deleted file mode 100644 index a3132137..00000000 --- a/docs/api/pybop/index.rst +++ /dev/null @@ -1,1537 +0,0 @@ -:py:mod:`pybop` -=============== - -.. py:module:: pybop - - -Subpackages ------------ -.. toctree:: - :titlesonly: - :maxdepth: 3 - - models/index.rst - optimisers/index.rst - parameters/index.rst - plotting/index.rst - - -Submodules ----------- -.. toctree:: - :titlesonly: - :maxdepth: 1 - - _costs/index.rst - _dataset/index.rst - _problem/index.rst - optimisation/index.rst - version/index.rst - - -Package Contents ----------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.Adam - pybop.BaseCost - pybop.BaseModel - pybop.BaseOptimiser - pybop.CMAES - pybop.Dataset - pybop.DesignProblem - pybop.Exponential - pybop.FittingProblem - pybop.Gaussian - pybop.GradientDescent - pybop.IRPropMin - pybop.NLoptOptimize - pybop.Optimisation - pybop.PSO - pybop.Parameter - pybop.ParameterSet - pybop.PlotlyManager - pybop.RootMeanSquaredError - pybop.SNES - pybop.SciPyDifferentialEvolution - pybop.SciPyMinimize - pybop.StandardPlot - pybop.SumSquaredError - pybop.Uniform - pybop.XNES - - - -Functions -~~~~~~~~~ - -.. autoapisummary:: - - pybop.plot_convergence - pybop.plot_cost2d - pybop.plot_parameters - pybop.quick_plot - - - -Attributes -~~~~~~~~~~ - -.. autoapisummary:: - - pybop.FLOAT_FORMAT - pybop.__version__ - pybop.script_path - - -.. py:class:: Adam(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.Adam` - - Implements the Adam optimization algorithm. - - This class extends the Adam optimizer from the PINTS library, which combines - ideas from RMSProp and Stochastic Gradient Descent with momentum. Note that - this optimizer does not support boundary constraints. - - :param x0: Initial position from which optimization will start. - :type x0: array_like - :param sigma0: Initial step size (default is 0.1). - :type sigma0: float, optional - :param bounds: Ignored by this optimizer, provided for API consistency. - :type bounds: sequence or ``Bounds``, optional - - .. seealso:: - - :obj:`pints.Adam` - The PINTS implementation this class is based on. - - -.. py:class:: BaseCost(problem) - - - Base class for defining cost functions. - - This class is intended to be subclassed to create specific cost functions - for evaluating model predictions against a set of data. The cost function - quantifies the goodness-of-fit between the model predictions and the - observed data, with a lower cost value indicating a better fit. - - :param problem: A problem instance containing the data and functions necessary for - evaluating the cost function. - :type problem: object - :param _target: An array containing the target data to fit. - :type _target: array-like - :param x0: The initial guess for the model parameters. - :type x0: array-like - :param bounds: The bounds for the model parameters. - :type bounds: tuple - :param n_parameters: The number of parameters in the model. - :type n_parameters: int - - .. py:method:: __call__(x, grad=None) - :abstractmethod: - - Calculate the cost function value for a given set of parameters. - - This method must be implemented by subclasses. - - :param x: The parameters for which to evaluate the cost. - :type x: array-like - :param grad: An array to store the gradient of the cost function with respect - to the parameters. - :type grad: array-like, optional - - :returns: The calculated cost function value. - :rtype: float - - :raises NotImplementedError: If the method has not been implemented by the subclass. - - - -.. py:class:: BaseModel(name='Base Model') - - - A base class for constructing and simulating models using PyBaMM. - - This class serves as a foundation for building specific models in PyBaMM. - It provides methods to set up the model, define parameters, and perform - simulations. The class is designed to be subclassed for creating models - with custom behavior. - - .. method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) - - Construct the PyBaMM model if not already built. - - .. method:: set_init_soc(init_soc) - - Set the initial state of charge for the battery model. - - .. method:: set_params() - - Assign the parameters to the model. - - .. method:: simulate(inputs, t_eval) - - Execute the forward model simulation and return the result. - - .. method:: simulateS1(inputs, t_eval) - - Perform the forward model simulation with sensitivities. - - .. method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) - - Solve the model using PyBaMM's simulation framework and return the solution. - - - .. py:property:: built_model - - - .. py:property:: geometry - - - .. py:property:: mesh - - - .. py:property:: model_with_set_params - - - .. py:property:: parameter_set - - - .. py:property:: solver - - - .. py:property:: spatial_methods - - - .. py:property:: submesh_types - - - .. py:property:: var_pts - - - .. py:method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) - - Construct the PyBaMM model if not already built, and set parameters. - - This method initializes the model components, applies the given parameters, - sets up the mesh and discretization if needed, and prepares the model - for simulations. - - :param dataset: The dataset to be used in the model construction. - :type dataset: pybamm.Dataset, optional - :param parameters: A dictionary containing parameter values to apply to the model. - :type parameters: dict, optional - :param check_model: If True, the model will be checked for correctness after construction. - :type check_model: bool, optional - :param init_soc: The initial state of charge to be used in simulations. - :type init_soc: float, optional - - - .. py:method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) - - Solve the model using PyBaMM's simulation framework and return the solution. - - This method sets up a PyBaMM simulation by configuring the model, parameters, experiment - (if any), and initial state of charge (if provided). It then solves the simulation and - returns the resulting solution object. - - :param inputs: Input parameters for the simulation. If the input is array-like, it is converted - to a dictionary using the model's fitting keys. Defaults to None, indicating - that the default parameters should be used. - :type inputs: dict or array-like, optional - :param t_eval: An array of time points at which to evaluate the solution. Defaults to None, - which means the time points need to be specified within experiment or elsewhere. - :type t_eval: array-like, optional - :param parameter_set: A PyBaMM ParameterValues object or a dictionary containing the parameter values - to use for the simulation. Defaults to the model's current ParameterValues if None. - :type parameter_set: pybamm.ParameterValues, optional - :param experiment: A PyBaMM Experiment object specifying the experimental conditions under which - the simulation should be run. Defaults to None, indicating no experiment. - :type experiment: pybamm.Experiment, optional - :param init_soc: The initial state of charge for the simulation, as a fraction (between 0 and 1). - Defaults to None. - :type init_soc: float, optional - - :returns: The solution object returned after solving the simulation. - :rtype: pybamm.Solution - - :raises ValueError: If the model has not been configured properly before calling this method or - if PyBaMM models are not supported by the current simulation method. - - - .. py:method:: set_init_soc(init_soc) - - Set the initial state of charge for the battery model. - - :param init_soc: The initial state of charge to be used in the model. - :type init_soc: float - - - .. py:method:: set_params() - - Assign the parameters to the model. - - This method processes the model with the given parameters, sets up - the geometry, and updates the model instance. - - - .. py:method:: simulate(inputs, t_eval) - - Execute the forward model simulation and return the result. - - :param inputs: The input parameters for the simulation. If array-like, it will be - converted to a dictionary using the model's fit keys. - :type inputs: dict or array-like - :param t_eval: An array of time points at which to evaluate the solution. - :type t_eval: array-like - - :returns: The simulation result corresponding to the specified signal. - :rtype: array-like - - :raises ValueError: If the model has not been built before simulation. - - - .. py:method:: simulateS1(inputs, t_eval) - - Perform the forward model simulation with sensitivities. - - :param inputs: The input parameters for the simulation. If array-like, it will be - converted to a dictionary using the model's fit keys. - :type inputs: dict or array-like - :param t_eval: An array of time points at which to evaluate the solution and its - sensitivities. - :type t_eval: array-like - - :returns: A tuple containing the simulation result and the sensitivities. - :rtype: tuple - - :raises ValueError: If the model has not been built before simulation. - - - -.. py:class:: BaseOptimiser - - - A base class for defining optimisation methods. - - This class serves as a template for creating optimisers. It provides a basic structure for - an optimisation algorithm, including the initial setup and a method stub for performing - the optimisation process. Child classes should override the optimise and _runoptimise - methods with specific algorithms. - - .. method:: optimise(cost_function, x0=None, bounds=None, maxiter=None) - - Initiates the optimisation process. This is a stub and should be implemented in child classes. - - .. method:: _runoptimise(cost_function, x0=None, bounds=None) - - Contains the logic for the optimisation algorithm. This is a stub and should be implemented in child classes. - - .. method:: name() - - Returns the name of the optimiser. - - - .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) - - Contains the logic for the optimisation algorithm. - - This method should be implemented by child classes to perform the actual optimisation. - - :param cost_function: The cost function to be minimised by the optimiser. - :type cost_function: callable - :param x0: Initial guess for the parameters. Default is None. - :type x0: ndarray, optional - :param bounds: Bounds on the parameters. Default is None. - :type bounds: sequence or Bounds, optional - - :returns: * *This method is expected to return the result of the optimisation, the format of which* - * *will be determined by the child class implementation.* - - - .. py:method:: name() - - Returns the name of the optimiser. - - :returns: The name of the optimiser, which is "BaseOptimiser" for this base class. - :rtype: str - - - .. py:method:: optimise(cost_function, x0=None, bounds=None, maxiter=None) - - Initiates the optimisation process. - - This method should be overridden by child classes with the specific optimisation algorithm. - - :param cost_function: The cost function to be minimised by the optimiser. - :type cost_function: callable - :param x0: Initial guess for the parameters. Default is None. - :type x0: ndarray, optional - :param bounds: Bounds on the parameters. Default is None. - :type bounds: sequence or Bounds, optional - :param maxiter: Maximum number of iterations to perform. Default is None. - :type maxiter: int, optional - - :rtype: The result of the optimisation process. The specific type of this result will depend on the child implementation. - - - -.. py:class:: CMAES(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.CMAES` - - Adapter for the Covariance Matrix Adaptation Evolution Strategy (CMA-ES) optimizer in PINTS. - - CMA-ES is an evolutionary algorithm for difficult non-linear non-convex optimization problems. - It adapts the covariance matrix of a multivariate normal distribution to capture the shape of the cost landscape. - - :param x0: The initial parameter vector to optimize. - :type x0: array_like - :param sigma0: Initial standard deviation of the sampling distribution, defaults to 0.1. - :type sigma0: float, optional - :param bounds: A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. - If ``None``, no bounds are enforced. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.CMAES` - PINTS implementation of CMA-ES algorithm. - - -.. py:class:: Dataset(name, data) - - - Represents a collection of experimental observations. - - This class provides a structured way to store and work with experimental data, - which may include applying operations such as interpolation. - - :param name: The name of the dataset, providing a label for identification. - :type name: str - :param data: The actual experimental data, typically in a structured form such as - a NumPy array or a pandas DataFrame. - :type data: array-like - - .. py:method:: Interpolant() - - Create an interpolation function of the dataset based on the independent variable. - - Currently, only time-based interpolation is supported. This method modifies - the instance's Interpolant attribute to be an interpolation function that - can be evaluated at different points in time. - - :raises NotImplementedError: If the independent variable for interpolation is not supported. - - - .. py:method:: __repr__() - - Return a string representation of the Dataset instance. - - :returns: A string that includes the name and data of the dataset. - :rtype: str - - - -.. py:class:: DesignProblem(model, parameters, experiment, check_model=True, init_soc=None, x0=None) - - - Bases: :py:obj:`BaseProblem` - - Defines the problem class for a design optimiation problem. - - .. py:method:: evaluate(x) - - Evaluate the model with the given parameters and return the signal. - - - .. py:method:: evaluateS1(x) - - Evaluate the model with the given parameters and return the signal and - its derivatives. - - - .. py:method:: target() - - Returns the target dataset. - - - -.. py:class:: Exponential(scale) - - - Represents an exponential distribution with a specified scale parameter. - - This class provides methods to calculate the pdf, the log pdf, and to generate random - variates from the distribution. - - :param scale: The scale parameter (lambda) of the exponential distribution. - :type scale: float - - .. py:method:: __repr__() - - Returns a string representation of the Uniform object. - - - .. py:method:: logpdf(x) - - Calculates the logarithm of the pdf of the exponential distribution at x. - - :param x: The point at which to evaluate the log pdf. - :type x: float - - :returns: The log of the probability density function value at x. - :rtype: float - - - .. py:method:: pdf(x) - - Calculates the probability density function of the exponential distribution at x. - - :param x: The point at which to evaluate the pdf. - :type x: float - - :returns: The probability density function value at x. - :rtype: float - - - .. py:method:: rvs(size) - - Generates random variates from the exponential distribution. - - :param size: The number of random variates to generate. - :type size: int - - :returns: An array of random variates from the exponential distribution. - :rtype: array_like - - :raises ValueError: If the size parameter is not positive. - - - -.. py:class:: FittingProblem(model, parameters, dataset, signal='Voltage [V]', check_model=True, init_soc=None, x0=None) - - - Bases: :py:obj:`BaseProblem` - - Defines the problem class for a fitting (parameter estimation) problem. - - .. py:method:: evaluate(x) - - Evaluate the model with the given parameters and return the signal. - - - .. py:method:: evaluateS1(x) - - Evaluate the model with the given parameters and return the signal and - its derivatives. - - - .. py:method:: target() - - Returns the target dataset. - - - -.. py:class:: Gaussian(mean, sigma) - - - Represents a Gaussian (normal) distribution with a given mean and standard deviation. - - This class provides methods to calculate the probability density function (pdf), - the logarithm of the pdf, and to generate random variates (rvs) from the distribution. - - :param mean: The mean (mu) of the Gaussian distribution. - :type mean: float - :param sigma: The standard deviation (sigma) of the Gaussian distribution. - :type sigma: float - - .. py:method:: __repr__() - - Returns a string representation of the Gaussian object. - - - .. py:method:: logpdf(x) - - Calculates the logarithm of the probability density function of the Gaussian distribution at x. - - :param x: The point at which to evaluate the log pdf. - :type x: float - - :returns: The logarithm of the probability density function value at x. - :rtype: float - - - .. py:method:: pdf(x) - - Calculates the probability density function of the Gaussian distribution at x. - - :param x: The point at which to evaluate the pdf. - :type x: float - - :returns: The probability density function value at x. - :rtype: float - - - .. py:method:: rvs(size) - - Generates random variates from the Gaussian distribution. - - :param size: The number of random variates to generate. - :type size: int - - :returns: An array of random variates from the Gaussian distribution. - :rtype: array_like - - :raises ValueError: If the size parameter is not positive. - - - -.. py:class:: GradientDescent(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.GradientDescent` - - Implements a simple gradient descent optimization algorithm. - - This class extends the gradient descent optimizer from the PINTS library, designed - to minimize a scalar function of one or more variables. Note that this optimizer - does not support boundary constraints. - - :param x0: Initial position from which optimization will start. - :type x0: array_like - :param sigma0: Initial step size (default is 0.1). - :type sigma0: float, optional - :param bounds: Ignored by this optimizer, provided for API consistency. - :type bounds: sequence or ``Bounds``, optional - - .. seealso:: - - :obj:`pints.GradientDescent` - The PINTS implementation this class is based on. - - -.. py:class:: IRPropMin(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.IRPropMin` - - Implements the iRpropMin optimization algorithm. - - This class inherits from the PINTS IRPropMin class, which is an optimizer that - uses resilient backpropagation with weight-backtracking. It is designed to handle - problems with large plateaus, noisy gradients, and local minima. - - :param x0: Initial position from which optimization will start. - :type x0: array_like - :param sigma0: Initial step size (default is 0.1). - :type sigma0: float, optional - :param bounds: Lower and upper bounds for each optimization parameter. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.IRPropMin` - The PINTS implementation this class is based on. - - -.. py:class:: NLoptOptimize(n_param, xtol=None, method=None, maxiter=None) - - - Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - - Extends BaseOptimiser to utilize the NLopt library for nonlinear optimization. - - This class serves as an interface to the NLopt optimization algorithms. It allows the user to - define an optimization problem with bounds, initial guesses, and to select an optimization method - provided by NLopt. - - :param n_param: Number of parameters to optimize. - :type n_param: int - :param xtol: The relative tolerance for optimization (stopping criteria). If not provided, a default of 1e-5 is used. - :type xtol: float, optional - :param method: The NLopt algorithm to use for optimization. If not provided, LN_BOBYQA is used by default. - :type method: nlopt.algorithm, optional - :param maxiter: The maximum number of iterations to perform during optimization. If not provided, NLopt's default is used. - :type maxiter: int, optional - - .. method:: _runoptimise(cost_function, x0, bounds) - - Performs the optimization using the NLopt library. - - .. method:: needs_sensitivities() - - Indicates whether the optimizer requires gradient information. - - .. method:: name() - - Returns the name of the optimizer. - - - .. py:method:: _runoptimise(cost_function, x0, bounds) - - Runs the optimization process using the NLopt library. - - :param cost_function: The objective function to minimize. It should take an array of parameter values and return the scalar cost. - :type cost_function: callable - :param x0: The initial guess for the parameters. - :type x0: array_like - :param bounds: A dictionary containing the 'lower' and 'upper' bounds arrays for the parameters. - :type bounds: dict - - :returns: A tuple containing the optimized parameter values and the final cost. - :rtype: tuple - - - .. py:method:: name() - - Returns the name of this optimizer instance. - - :returns: The name 'NLoptOptimize' representing this NLopt optimization class. - :rtype: str - - - .. py:method:: needs_sensitivities() - - Indicates if the optimizer requires gradient information for the cost function. - - :returns: False, as the default NLopt algorithms do not require gradient information. - :rtype: bool - - - -.. py:class:: Optimisation(cost, optimiser=None, sigma0=None, verbose=False) - - - Optimisation class for PyBOP. - This class provides functionality for PyBOP optimisers and Pints optimisers. - :param cost: PyBOP cost function - :param optimiser: A PyBOP or Pints optimiser - :param sigma0: initial step size - :param verbose: print optimisation progress - - .. py:method:: _run_pints() - - Run method for PINTS optimisers. - This method is heavily based on the run method in the PINTS.OptimisationController class. - :returns: best parameters - final_cost: final cost - :rtype: x - - - .. py:method:: _run_pybop() - - Run method for PyBOP based optimisers. - :returns: best parameters - final_cost: final cost - :rtype: x - - - .. py:method:: f_guessed_tracking() - - Returns ``True`` if f_guessed instead of f_best is being tracked, - ``False`` otherwise. See also :meth:`set_f_guessed_tracking`. - - Credit: PINTS - - - .. py:method:: run() - - Run the optimisation algorithm. - Selects between PyBOP backend or Pints backend. - :returns: best parameters - final_cost: final cost - :rtype: x - - - .. py:method:: set_f_guessed_tracking(use_f_guessed=False) - - Sets the method used to track the optimiser progress to - :meth:`pints.Optimiser.f_guessed()` or - :meth:`pints.Optimiser.f_best()` (default). - - The tracked ``f`` value is used to evaluate stopping criteria. - - Credit: PINTS - - - .. py:method:: set_max_evaluations(evaluations=None) - - Adds a stopping criterion, allowing the routine to halt after the - given number of ``evaluations``. - - This criterion is disabled by default. To enable, pass in any positive - integer. To disable again, use ``set_max_evaluations(None)``. - - Credit: PINTS - - - .. py:method:: set_max_iterations(iterations=1000) - - Adds a stopping criterion, allowing the routine to halt after the - given number of ``iterations``. - - This criterion is enabled by default. To disable it, use - ``set_max_iterations(None)``. - - Credit: PINTS - - - .. py:method:: set_max_unchanged_iterations(iterations=25, threshold=1e-05) - - Adds a stopping criterion, allowing the routine to halt if the - objective function doesn't change by more than ``threshold`` for the - given number of ``iterations``. - - This criterion is enabled by default. To disable it, use - ``set_max_unchanged_iterations(None)``. - - Credit: PINTS - - - .. py:method:: set_parallel(parallel=False) - - Enables/disables parallel evaluation. - - If ``parallel=True``, the method will run using a number of worker - processes equal to the detected cpu core count. The number of workers - can be set explicitly by setting ``parallel`` to an integer greater - than 0. - Parallelisation can be disabled by setting ``parallel`` to ``0`` or - ``False``. - - Credit: PINTS - - - .. py:method:: store_optimised_parameters(x) - - Store the optimised parameters in the PyBOP parameter class. - - - -.. py:class:: PSO(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.PSO` - - Implements a particle swarm optimization (PSO) algorithm. - - This class extends the PSO optimizer from the PINTS library. PSO is a - metaheuristic optimization method inspired by the social behavior of birds - flocking or fish schooling, suitable for global optimization problems. - - :param x0: Initial positions of particles, which the optimization will use. - :type x0: array_like - :param sigma0: Spread of the initial particle positions (default is 0.1). - :type sigma0: float, optional - :param bounds: Lower and upper bounds for each optimization parameter. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.PSO` - The PINTS implementation this class is based on. - - -.. py:class:: Parameter(name, initial_value=None, prior=None, bounds=None) - - - Represents a parameter within the PyBOP framework. - - This class encapsulates the definition of a parameter, including its name, prior - distribution, initial value, bounds, and a margin to ensure the parameter stays - within feasible limits during optimization or sampling. - - :param name: The name of the parameter. - :type name: str - :param initial_value: The initial value to be assigned to the parameter. Defaults to None. - :type initial_value: float, optional - :param prior: The prior distribution from which parameter values are drawn. Defaults to None. - :type prior: scipy.stats distribution, optional - :param bounds: A tuple defining the lower and upper bounds for the parameter. - Defaults to None. - :type bounds: tuple, optional - - .. method:: rvs(n_samples) - - Draw random samples from the parameter's prior distribution. - - .. method:: update(value) - - Update the parameter's current value. - - .. method:: set_margin(margin) - - Set the margin to a specified positive value less than 1. - - - :raises ValueError: If the lower bound is not strictly less than the upper bound, or if - the margin is set outside the interval (0, 1). - - .. py:method:: __repr__() - - Return a string representation of the Parameter instance. - - :returns: A string including the parameter's name, prior, bounds, and current value. - :rtype: str - - - .. py:method:: rvs(n_samples) - - Draw random samples from the parameter's prior distribution. - - The samples are constrained to be within the parameter's bounds, excluding - a predefined margin at the boundaries. - - :param n_samples: The number of samples to draw. - :type n_samples: int - - :returns: An array of samples drawn from the prior distribution within the parameter's bounds. - :rtype: array-like - - - .. py:method:: set_margin(margin) - - Set the margin to a specified positive value less than 1. - - The margin is used to ensure parameter samples are not drawn exactly at the bounds, - which may be problematic in some optimization or sampling algorithms. - - :param margin: The new margin value to be used, which must be in the interval (0, 1). - :type margin: float - - :raises ValueError: If the margin is not between 0 and 1. - - - .. py:method:: update(value) - - Update the parameter's current value. - - :param value: The new value to be assigned to the parameter. - :type value: float - - - -.. py:class:: ParameterSet(json_path=None, params_dict=None) - - - Handles the import and export of parameter sets for battery models. - - This class provides methods to load parameters from a JSON file and to export them - back to a JSON file. It also includes custom logic to handle special cases, such - as parameter values that require specific initialization. - - :param json_path: Path to a JSON file containing parameter data. If provided, parameters will be imported from this file during initialization. - :type json_path: str, optional - :param params_dict: A dictionary of parameters to initialize the ParameterSet with. If not provided, an empty dictionary is used. - :type params_dict: dict, optional - - .. py:method:: _handle_special_cases() - - Processes special cases for parameter values that require custom handling. - - For example, if the open-circuit voltage is specified as 'default', it will - fetch the default value from the PyBaMM empirical Thevenin model. - - - .. py:method:: export_parameters(output_json_path, fit_params=None) - - Exports parameters to a JSON file specified by `output_json_path`. - - The current state of the `params` attribute is written to the file. If `fit_params` - is provided, these parameters are updated before export. Non-serializable values - are handled and noted in the output JSON. - - :param output_json_path: The file path where the JSON output will be saved. - :type output_json_path: str - :param fit_params: Parameters that have been fitted and need to be included in the export. - :type fit_params: list of fitted parameter objects, optional - - :raises ValueError: If there are no parameters to export. - - - .. py:method:: import_parameters(json_path=None) - - Imports parameters from a JSON file specified by the `json_path` attribute. - - If a `json_path` is provided at initialization or as an argument, that JSON file - is loaded and the parameters are stored in the `params` attribute. Special cases - are handled appropriately. - - :param json_path: Path to the JSON file from which to import parameters. If provided, it overrides the instance's `json_path`. - :type json_path: str, optional - - :returns: The dictionary containing the imported parameters. - :rtype: dict - - :raises FileNotFoundError: If the specified JSON file cannot be found. - - - .. py:method:: is_json_serializable(value) - - Determines if the given `value` can be serialized to JSON format. - - :param value: The value to check for JSON serializability. - :type value: any - - :returns: True if the value is JSON serializable, False otherwise. - :rtype: bool - - - .. py:method:: pybamm(name) - :classmethod: - - Retrieves a PyBaMM parameter set by name. - - :param name: The name of the PyBaMM parameter set to retrieve. - :type name: str - - :returns: A PyBaMM parameter set corresponding to the provided name. - :rtype: pybamm.ParameterValues - - - -.. py:class:: PlotlyManager - - - Manages the installation and configuration of Plotly for generating visualisations. - - This class checks if Plotly is installed and, if not, prompts the user to install it. - It also ensures that the Plotly renderer and browser settings are properly configured - to display plots. - - Methods: - ``ensure_plotly_installed``: Verifies if Plotly is installed and installs it if necessary. - ``prompt_for_plotly_installation``: Prompts the user for permission to install Plotly. - ``install_plotly_package``: Installs the Plotly package using pip. - ``post_install_setup``: Sets up Plotly default renderer after installation. - ``check_renderer_settings``: Verifies that the Plotly renderer is correctly set. - ``check_browser_availability``: Checks if a web browser is available for rendering plots. - - Usage: - Instantiate the PlotlyManager class to automatically ensure Plotly is installed - and configured correctly when creating an instance. - Example: - plotly_manager = PlotlyManager() - - .. py:method:: check_browser_availability() - - Ensures a web browser is available for rendering plots with the 'browser' renderer and provides guidance if not. - - - .. py:method:: check_renderer_settings() - - Checks if the Plotly renderer is set and provides information on how to set it if empty. - - - .. py:method:: ensure_plotly_installed() - - Verifies if Plotly is installed, importing necessary modules and prompting for installation if missing. - - - .. py:method:: install_plotly() - - Attempts to install the Plotly package using pip and exits if installation fails. - - - .. py:method:: post_install_setup() - - After successful installation, imports Plotly and sets the default renderer if necessary. - - - .. py:method:: prompt_for_plotly_installation() - - Prompts the user for permission to install Plotly and proceeds with installation if consented. - - - -.. py:class:: RootMeanSquaredError(problem) - - - Bases: :py:obj:`BaseCost` - - Root mean square error cost function. - - Computes the root mean square error between model predictions and the target - data, providing a measure of the differences between predicted values and - observed values. - - Inherits all parameters and attributes from ``BaseCost``. - - - .. py:method:: __call__(x, grad=None) - - Calculate the root mean square error for a given set of parameters. - - :param x: The parameters for which to evaluate the cost. - :type x: array-like - :param grad: An array to store the gradient of the cost function with respect - to the parameters. - :type grad: array-like, optional - - :returns: The root mean square error. - :rtype: float - - :raises ValueError: If an error occurs during the calculation of the cost. - - - -.. py:class:: SNES(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.SNES` - - Implements the stochastic natural evolution strategy (SNES) optimization algorithm. - - Inheriting from the PINTS SNES class, this optimizer is an evolutionary algorithm - that evolves a probability distribution on the parameter space, guiding the search - for the optimum based on the natural gradient of expected fitness. - - :param x0: Initial position from which optimization will start. - :type x0: array_like - :param sigma0: Initial step size (default is 0.1). - :type sigma0: float, optional - :param bounds: Lower and upper bounds for each optimization parameter. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.SNES` - The PINTS implementation this class is based on. - - -.. py:class:: SciPyDifferentialEvolution(bounds=None, strategy='best1bin', maxiter=1000, popsize=15) - - - Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - - Adapts SciPy's differential_evolution function for global optimization. - - This class provides a global optimization strategy based on differential evolution, useful for problems involving continuous parameters and potentially multiple local minima. - - :param bounds: Bounds for variables. Must be provided as it is essential for differential evolution. - :type bounds: sequence or ``Bounds`` - :param strategy: The differential evolution strategy to use. Defaults to 'best1bin'. - :type strategy: str, optional - :param maxiter: Maximum number of iterations to perform. Defaults to 1000. - :type maxiter: int, optional - :param popsize: The number of individuals in the population. Defaults to 15. - :type popsize: int, optional - - .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) - - Executes the optimization process using SciPy's differential_evolution function. - - :param cost_function: The objective function to minimize. - :type cost_function: callable - :param x0: Ignored parameter, provided for API consistency. - :type x0: array_like, optional - :param bounds: Bounds for the variables, required for differential evolution. - :type bounds: sequence or ``Bounds`` - - :returns: A tuple (x, final_cost) containing the optimized parameters and the value of ``cost_function`` at the optimum. - :rtype: tuple - - - .. py:method:: name() - - Provides the name of the optimization strategy. - - :returns: The name 'SciPyDifferentialEvolution'. - :rtype: str - - - .. py:method:: needs_sensitivities() - - Determines if the optimization algorithm requires gradient information. - - :returns: False, indicating that gradient information is not required for differential evolution. - :rtype: bool - - - -.. py:class:: SciPyMinimize(method=None, bounds=None, maxiter=None) - - - Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - - Adapts SciPy's minimize function for use as an optimization strategy. - - This class provides an interface to various scalar minimization algorithms implemented in SciPy, allowing fine-tuning of the optimization process through method selection and option configuration. - - :param method: The type of solver to use. If not specified, defaults to 'COBYLA'. - :type method: str, optional - :param bounds: Bounds for variables as supported by the selected method. - :type bounds: sequence or ``Bounds``, optional - :param maxiter: Maximum number of iterations to perform. - :type maxiter: int, optional - - .. py:method:: _runoptimise(cost_function, x0, bounds) - - Executes the optimization process using SciPy's minimize function. - - :param cost_function: The objective function to minimize. - :type cost_function: callable - :param x0: Initial guess for the parameters. - :type x0: array_like - :param bounds: Bounds for the variables. - :type bounds: sequence or `Bounds` - - :returns: A tuple (x, final_cost) containing the optimized parameters and the value of `cost_function` at the optimum. - :rtype: tuple - - - .. py:method:: name() - - Provides the name of the optimization strategy. - - :returns: The name 'SciPyMinimize'. - :rtype: str - - - .. py:method:: needs_sensitivities() - - Determines if the optimization algorithm requires gradient information. - - :returns: False, indicating that gradient information is not required. - :rtype: bool - - - -.. py:class:: StandardPlot(x, y, cost, y2=None, title=None, xaxis_title=None, yaxis_title=None, trace_name=None, width=1024, height=576) - - - A class for creating and displaying a plotly figure that compares a target dataset against a simulated model output. - - This class provides an interface for generating interactive plots using Plotly, with the ability to include an - optional secondary dataset and visualize uncertainty if provided. - - Attributes: - ----------- - x : list - The x-axis data points. - y : list or np.ndarray - The primary y-axis data points representing the simulated model output. - y2 : list or np.ndarray, optional - An optional secondary y-axis data points representing the target dataset against which the model output is compared. - cost : float - The cost associated with the model output. - title : str, optional - The title of the plot. - xaxis_title : str, optional - The title for the x-axis. - yaxis_title : str, optional - The title for the y-axis. - trace_name : str, optional - The name of the primary trace representing the model output. Defaults to "Simulated". - width : int, optional - The width of the figure in pixels. Defaults to 720. - height : int, optional - The height of the figure in pixels. Defaults to 540. - - Example: - ---------- - >>> x_data = [1, 2, 3, 4] - >>> y_simulated = [10, 15, 13, 17] - >>> y_target = [11, 14, 12, 16] - >>> plot = pybop.StandardPlot(x_data, y_simulated, cost=0.05, y2=y_target, - title="Model vs. Target", xaxis_title="X Axis", yaxis_title="Y Axis") - >>> fig = plot() # Generate the figure - >>> fig.show() # Display the figure in a browser - - .. py:method:: __call__() - - Generate the plotly figure. - - - .. py:method:: create_layout() - - Create the layout for the plot. - - - .. py:method:: create_traces() - - Create the traces for the plot. - - - .. py:method:: wrap_text(text, width) - :staticmethod: - - Wrap text to a specified width. - - Parameters: - ----------- - text: str - Text to be wrapped. - width: int - Width to wrap text to. - - Returns: - ---------- - str - Wrapped text with HTML line breaks. - - - -.. py:class:: SumSquaredError(problem) - - - Bases: :py:obj:`BaseCost` - - Sum of squared errors cost function. - - Computes the sum of the squares of the differences between model predictions - and target data, which serves as a measure of the total error between the - predicted and observed values. - - Inherits all parameters and attributes from ``BaseCost``. - - Additional Attributes - --------------------- - _de : float - The gradient of the cost function to use if an error occurs during - evaluation. Defaults to 1.0. - - - .. py:method:: __call__(x, grad=None) - - Calculate the sum of squared errors for a given set of parameters. - - :param x: The parameters for which to evaluate the cost. - :type x: array-like - :param grad: An array to store the gradient of the cost function with respect - to the parameters. - :type grad: array-like, optional - - :returns: The sum of squared errors. - :rtype: float - - :raises ValueError: If an error occurs during the calculation of the cost. - - - .. py:method:: evaluateS1(x) - - Compute the cost and its gradient with respect to the parameters. - - :param x: The parameters for which to compute the cost and gradient. - :type x: array-like - - :returns: A tuple containing the cost and the gradient. The cost is a float, - and the gradient is an array-like of the same length as `x`. - :rtype: tuple - - :raises ValueError: If an error occurs during the calculation of the cost or gradient. - - - .. py:method:: set_fail_gradient(de) - - Set the fail gradient to a specified value. - - The fail gradient is used if an error occurs during the calculation - of the gradient. This method allows updating the default gradient value. - - :param de: The new fail gradient value to be used. - :type de: float - - - -.. py:class:: Uniform(lower, upper) - - - Represents a uniform distribution over a specified interval. - - This class provides methods to calculate the pdf, the log pdf, and to generate - random variates from the distribution. - - :param lower: The lower bound of the distribution. - :type lower: float - :param upper: The upper bound of the distribution. - :type upper: float - - .. py:method:: __repr__() - - Returns a string representation of the Uniform object. - - - .. py:method:: logpdf(x) - - Calculates the logarithm of the pdf of the uniform distribution at x. - - :param x: The point at which to evaluate the log pdf. - :type x: float - - :returns: The log of the probability density function value at x. - :rtype: float - - - .. py:method:: pdf(x) - - Calculates the probability density function of the uniform distribution at x. - - :param x: The point at which to evaluate the pdf. - :type x: float - - :returns: The probability density function value at x. - :rtype: float - - - .. py:method:: rvs(size) - - Generates random variates from the uniform distribution. - - :param size: The number of random variates to generate. - :type size: int - - :returns: An array of random variates from the uniform distribution. - :rtype: array_like - - :raises ValueError: If the size parameter is not positive. - - - -.. py:class:: XNES(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.XNES` - - Implements the Exponential Natural Evolution Strategy (XNES) optimizer from PINTS. - - XNES is an evolutionary algorithm that samples from a multivariate normal distribution, which is updated iteratively to fit the distribution of successful solutions. - - :param x0: The initial parameter vector to optimize. - :type x0: array_like - :param sigma0: Initial standard deviation of the sampling distribution, defaults to 0.1. - :type sigma0: float, optional - :param bounds: A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. If ``None``, no bounds are enforced. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.XNES` - PINTS implementation of XNES algorithm. - - -.. py:function:: plot_convergence(optim, xaxis_title='Iteration', yaxis_title='Cost', title='Convergence') - - Plot the convergence of the optimisation algorithm. - - Parameters: - ---------- - optim : optimisation object - Optimisation object containing the cost function and optimiser. - xaxis_title : str, optional - Title for the x-axis (default is "Iteration"). - yaxis_title : str, optional - Title for the y-axis (default is "Cost"). - title : str, optional - Title of the plot (default is "Convergence"). - - Returns: - ---------- - fig : plotly.graph_objs.Figure - The Plotly figure object for the convergence plot. - - -.. py:function:: plot_cost2d(cost, bounds=None, optim=None, steps=10) - - Query the cost landscape for a given parameter space and plot it using Plotly. - - This function creates a 2D plot that visualizes the cost landscape over a grid - of points within specified parameter bounds. If no bounds are provided, it determines - them from the bounds on the parameter class. - - :param cost: A callable representing the cost function to be queried. It should - take a list of parameters and return a cost value. - :type cost: callable - :param bounds: The bounds for the parameter space as a 2x2 array, with each - sub-array representing the min and max bounds for a parameter. - If None, bounds will be determined by `get_param_bounds`. - :type bounds: numpy.ndarray, optional - :param optim: An optional optimizer instance. If provided, it will be used to - overlay optimizer-specific information on the plot. - :type optim: object, optional - :param steps: The number of steps to divide the parameter space grid. More steps - result in finer resolution but increase computational cost. - :type steps: int, optional - :return: A Plotly figure object representing the cost landscape plot. - :rtype: plotly.graph_objs.Figure - - :raises ValueError: If the cost function does not behave as expected. - - -.. py:function:: plot_parameters(optim, xaxis_titles='Iteration', yaxis_titles=None, title='Convergence') - - Plot the evolution of the parameters during the optimisation process. - - Parameters: - ------------ - optim : optimisation object - An object representing the optimisation process, which should contain - information about the cost function, optimiser, and the history of the - parameter values throughout the iterations. - xaxis_title : str, optional - Title for the x-axis, representing the iteration number or a similar - discrete time step in the optimisation process (default is "Iteration"). - yaxis_title : str, optional - Title for the y-axis, which typically represents the metric being - optimised, such as cost or loss (default is "Cost"). - title : str, optional - Title of the plot, which provides an overall description of what the - plot represents (default is "Convergence"). - - Returns: - ---------- - fig : plotly.graph_objs.Figure - The Plotly figure object for the plot depicting how the parameters of - the optimisation algorithm evolve over its course. This can be useful - for diagnosing the behaviour of the optimisation algorithm. - - Notes: - ---------- - The function assumes that the 'optim' object has a 'cost.problem.parameters' - attribute containing the parameters of the optimisation algorithm and a 'log' - attribute containing a history of the iterations. - - -.. py:function:: quick_plot(params, cost, title='Scatter Plot', width=1024, height=576) - - Plot the target dataset against the minimised model output. - - Parameters: - ----------- - params : array-like - Optimised parameters. - cost : cost object - Cost object containing the problem, dataset, and signal. - title : str, optional - Title of the plot (default is "Scatter Plot"). - width : int, optional - Width of the figure in pixels (default is 720). - height : int, optional - Height of the figure in pixels (default is 540). - - Returns: - ---------- - fig : plotly.graph_objs.Figure - The Plotly figure object for the scatter plot. - - -.. py:data:: FLOAT_FORMAT - :value: '{: .17e}' - - - -.. py:data:: __version__ - :value: '23.11' - - - -.. py:data:: script_path diff --git a/docs/api/pybop/models/base_model/index.rst b/docs/api/pybop/models/base_model/index.rst deleted file mode 100644 index ab17a5db..00000000 --- a/docs/api/pybop/models/base_model/index.rst +++ /dev/null @@ -1,178 +0,0 @@ -:py:mod:`pybop.models.base_model` -================================= - -.. py:module:: pybop.models.base_model - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.models.base_model.BaseModel - - - - -.. py:class:: BaseModel(name='Base Model') - - - A base class for constructing and simulating models using PyBaMM. - - This class serves as a foundation for building specific models in PyBaMM. - It provides methods to set up the model, define parameters, and perform - simulations. The class is designed to be subclassed for creating models - with custom behavior. - - .. method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) - - Construct the PyBaMM model if not already built. - - .. method:: set_init_soc(init_soc) - - Set the initial state of charge for the battery model. - - .. method:: set_params() - - Assign the parameters to the model. - - .. method:: simulate(inputs, t_eval) - - Execute the forward model simulation and return the result. - - .. method:: simulateS1(inputs, t_eval) - - Perform the forward model simulation with sensitivities. - - .. method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) - - Solve the model using PyBaMM's simulation framework and return the solution. - - - .. py:property:: built_model - - - .. py:property:: geometry - - - .. py:property:: mesh - - - .. py:property:: model_with_set_params - - - .. py:property:: parameter_set - - - .. py:property:: solver - - - .. py:property:: spatial_methods - - - .. py:property:: submesh_types - - - .. py:property:: var_pts - - - .. py:method:: build(dataset=None, parameters=None, check_model=True, init_soc=None) - - Construct the PyBaMM model if not already built, and set parameters. - - This method initializes the model components, applies the given parameters, - sets up the mesh and discretization if needed, and prepares the model - for simulations. - - :param dataset: The dataset to be used in the model construction. - :type dataset: pybamm.Dataset, optional - :param parameters: A dictionary containing parameter values to apply to the model. - :type parameters: dict, optional - :param check_model: If True, the model will be checked for correctness after construction. - :type check_model: bool, optional - :param init_soc: The initial state of charge to be used in simulations. - :type init_soc: float, optional - - - .. py:method:: predict(inputs=None, t_eval=None, parameter_set=None, experiment=None, init_soc=None) - - Solve the model using PyBaMM's simulation framework and return the solution. - - This method sets up a PyBaMM simulation by configuring the model, parameters, experiment - (if any), and initial state of charge (if provided). It then solves the simulation and - returns the resulting solution object. - - :param inputs: Input parameters for the simulation. If the input is array-like, it is converted - to a dictionary using the model's fitting keys. Defaults to None, indicating - that the default parameters should be used. - :type inputs: dict or array-like, optional - :param t_eval: An array of time points at which to evaluate the solution. Defaults to None, - which means the time points need to be specified within experiment or elsewhere. - :type t_eval: array-like, optional - :param parameter_set: A PyBaMM ParameterValues object or a dictionary containing the parameter values - to use for the simulation. Defaults to the model's current ParameterValues if None. - :type parameter_set: pybamm.ParameterValues, optional - :param experiment: A PyBaMM Experiment object specifying the experimental conditions under which - the simulation should be run. Defaults to None, indicating no experiment. - :type experiment: pybamm.Experiment, optional - :param init_soc: The initial state of charge for the simulation, as a fraction (between 0 and 1). - Defaults to None. - :type init_soc: float, optional - - :returns: The solution object returned after solving the simulation. - :rtype: pybamm.Solution - - :raises ValueError: If the model has not been configured properly before calling this method or - if PyBaMM models are not supported by the current simulation method. - - - .. py:method:: set_init_soc(init_soc) - - Set the initial state of charge for the battery model. - - :param init_soc: The initial state of charge to be used in the model. - :type init_soc: float - - - .. py:method:: set_params() - - Assign the parameters to the model. - - This method processes the model with the given parameters, sets up - the geometry, and updates the model instance. - - - .. py:method:: simulate(inputs, t_eval) - - Execute the forward model simulation and return the result. - - :param inputs: The input parameters for the simulation. If array-like, it will be - converted to a dictionary using the model's fit keys. - :type inputs: dict or array-like - :param t_eval: An array of time points at which to evaluate the solution. - :type t_eval: array-like - - :returns: The simulation result corresponding to the specified signal. - :rtype: array-like - - :raises ValueError: If the model has not been built before simulation. - - - .. py:method:: simulateS1(inputs, t_eval) - - Perform the forward model simulation with sensitivities. - - :param inputs: The input parameters for the simulation. If array-like, it will be - converted to a dictionary using the model's fit keys. - :type inputs: dict or array-like - :param t_eval: An array of time points at which to evaluate the solution and its - sensitivities. - :type t_eval: array-like - - :returns: A tuple containing the simulation result and the sensitivities. - :rtype: tuple - - :raises ValueError: If the model has not been built before simulation. diff --git a/docs/api/pybop/models/empirical/ecm/index.rst b/docs/api/pybop/models/empirical/ecm/index.rst deleted file mode 100644 index e861789d..00000000 --- a/docs/api/pybop/models/empirical/ecm/index.rst +++ /dev/null @@ -1,47 +0,0 @@ -:py:mod:`pybop.models.empirical.ecm` -==================================== - -.. py:module:: pybop.models.empirical.ecm - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.models.empirical.ecm.Thevenin - - - - -.. py:class:: Thevenin(name='Equivalent Circuit Thevenin Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None, **kwargs) - - - Bases: :py:obj:`pybop.models.base_model.BaseModel` - - The Thevenin class represents an equivalent circuit model based on the Thevenin model in PyBaMM. - - This class encapsulates the PyBaMM equivalent circuit Thevenin model, providing an interface - to define the parameters, geometry, submesh types, variable points, spatial methods, and solver - to be used for simulations. - - :param name: A name for the model instance. Defaults to "Equivalent Circuit Thevenin Model". - :type name: str, optional - :param parameter_set: A dictionary of parameters to be used for the model. If None, the default parameters from PyBaMM are used. - :type parameter_set: dict or None, optional - :param geometry: The geometry definitions for the model. If None, the default geometry from PyBaMM is used. - :type geometry: dict or None, optional - :param submesh_types: The types of submeshes to use. If None, the default submesh types from PyBaMM are used. - :type submesh_types: dict or None, optional - :param var_pts: The number of points for each variable in the model to define the discretization. If None, the default is used. - :type var_pts: dict or None, optional - :param spatial_methods: The spatial methods to be used for discretization. If None, the default spatial methods from PyBaMM are used. - :type spatial_methods: dict or None, optional - :param solver: The solver to use for simulating the model. If None, the default solver from PyBaMM is used. - :type solver: pybamm.Solver or None, optional - :param options: A dictionary of options to pass to the PyBaMM Thevenin model. - :type options: dict or None, optional - :param \*\*kwargs: Additional arguments passed to the PyBaMM Thevenin model constructor. diff --git a/docs/api/pybop/models/empirical/index.rst b/docs/api/pybop/models/empirical/index.rst deleted file mode 100644 index 0adb3d9e..00000000 --- a/docs/api/pybop/models/empirical/index.rst +++ /dev/null @@ -1,56 +0,0 @@ -:py:mod:`pybop.models.empirical` -================================ - -.. py:module:: pybop.models.empirical - - -Submodules ----------- -.. toctree:: - :titlesonly: - :maxdepth: 1 - - ecm/index.rst - - -Package Contents ----------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.models.empirical.Thevenin - - - - -.. py:class:: Thevenin(name='Equivalent Circuit Thevenin Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None, **kwargs) - - - Bases: :py:obj:`pybop.models.base_model.BaseModel` - - The Thevenin class represents an equivalent circuit model based on the Thevenin model in PyBaMM. - - This class encapsulates the PyBaMM equivalent circuit Thevenin model, providing an interface - to define the parameters, geometry, submesh types, variable points, spatial methods, and solver - to be used for simulations. - - :param name: A name for the model instance. Defaults to "Equivalent Circuit Thevenin Model". - :type name: str, optional - :param parameter_set: A dictionary of parameters to be used for the model. If None, the default parameters from PyBaMM are used. - :type parameter_set: dict or None, optional - :param geometry: The geometry definitions for the model. If None, the default geometry from PyBaMM is used. - :type geometry: dict or None, optional - :param submesh_types: The types of submeshes to use. If None, the default submesh types from PyBaMM are used. - :type submesh_types: dict or None, optional - :param var_pts: The number of points for each variable in the model to define the discretization. If None, the default is used. - :type var_pts: dict or None, optional - :param spatial_methods: The spatial methods to be used for discretization. If None, the default spatial methods from PyBaMM are used. - :type spatial_methods: dict or None, optional - :param solver: The solver to use for simulating the model. If None, the default solver from PyBaMM is used. - :type solver: pybamm.Solver or None, optional - :param options: A dictionary of options to pass to the PyBaMM Thevenin model. - :type options: dict or None, optional - :param \*\*kwargs: Additional arguments passed to the PyBaMM Thevenin model constructor. diff --git a/docs/api/pybop/models/index.rst b/docs/api/pybop/models/index.rst deleted file mode 100644 index 20988ab8..00000000 --- a/docs/api/pybop/models/index.rst +++ /dev/null @@ -1,23 +0,0 @@ -:py:mod:`pybop.models` -====================== - -.. py:module:: pybop.models - - -Subpackages ------------ -.. toctree:: - :titlesonly: - :maxdepth: 3 - - empirical/index.rst - lithium_ion/index.rst - - -Submodules ----------- -.. toctree:: - :titlesonly: - :maxdepth: 1 - - base_model/index.rst diff --git a/docs/api/pybop/models/lithium_ion/echem/index.rst b/docs/api/pybop/models/lithium_ion/echem/index.rst deleted file mode 100644 index 4c115a68..00000000 --- a/docs/api/pybop/models/lithium_ion/echem/index.rst +++ /dev/null @@ -1,76 +0,0 @@ -:py:mod:`pybop.models.lithium_ion.echem` -======================================== - -.. py:module:: pybop.models.lithium_ion.echem - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.models.lithium_ion.echem.SPM - pybop.models.lithium_ion.echem.SPMe - - - - -.. py:class:: SPM(name='Single Particle Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) - - - Bases: :py:obj:`pybop.models.base_model.BaseModel` - - Wraps the Single Particle Model (SPM) for simulating lithium-ion batteries, as implemented in PyBaMM. - - The SPM is a simplified physics-based model that represents a lithium-ion cell using a single - spherical particle to simulate the behavior of the negative and positive electrodes. - - :param name: The name for the model instance, defaulting to "Single Particle Model". - :type name: str, optional - :param parameter_set: The parameters for the model. If None, default parameters provided by PyBaMM are used. - :type parameter_set: pybamm.ParameterValues or dict, optional - :param geometry: The geometry definitions for the model. If None, default geometry from PyBaMM is used. - :type geometry: dict, optional - :param submesh_types: The types of submeshes to use. If None, default submesh types from PyBaMM are used. - :type submesh_types: dict, optional - :param var_pts: The discretization points for each variable in the model. If None, default points from PyBaMM are used. - :type var_pts: dict, optional - :param spatial_methods: The spatial methods used for discretization. If None, default spatial methods from PyBaMM are used. - :type spatial_methods: dict, optional - :param solver: The solver to use for simulating the model. If None, the default solver from PyBaMM is used. - :type solver: pybamm.Solver, optional - :param options: A dictionary of options to customize the behavior of the PyBaMM model. - :type options: dict, optional - - -.. py:class:: SPMe(name='Single Particle Model with Electrolyte', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) - - - Bases: :py:obj:`pybop.models.base_model.BaseModel` - - Represents the Single Particle Model with Electrolyte (SPMe) for lithium-ion batteries. - - The SPMe extends the basic Single Particle Model (SPM) by incorporating electrolyte dynamics, - making it suitable for simulations where electrolyte effects are non-negligible. This class - provides a framework to define the model parameters, geometry, mesh types, discretization - points, spatial methods, and numerical solvers for simulation within the PyBaMM ecosystem. - - :param name: A name for the model instance, defaults to "Single Particle Model with Electrolyte". - :type name: str, optional - :param parameter_set: A dictionary or a ParameterValues object containing the parameters for the model. If None, the default PyBaMM parameters for SPMe are used. - :type parameter_set: pybamm.ParameterValues or dict, optional - :param geometry: A dictionary defining the model's geometry. If None, the default PyBaMM geometry for SPMe is used. - :type geometry: dict, optional - :param submesh_types: A dictionary defining the types of submeshes to use. If None, the default PyBaMM submesh types for SPMe are used. - :type submesh_types: dict, optional - :param var_pts: A dictionary specifying the number of points for each variable for discretization. If None, the default PyBaMM variable points for SPMe are used. - :type var_pts: dict, optional - :param spatial_methods: A dictionary specifying the spatial methods for discretization. If None, the default PyBaMM spatial methods for SPMe are used. - :type spatial_methods: dict, optional - :param solver: The solver to use for simulating the model. If None, the default PyBaMM solver for SPMe is used. - :type solver: pybamm.Solver, optional - :param options: A dictionary of options to customize the behavior of the PyBaMM model. - :type options: dict, optional diff --git a/docs/api/pybop/models/lithium_ion/index.rst b/docs/api/pybop/models/lithium_ion/index.rst deleted file mode 100644 index 4200a205..00000000 --- a/docs/api/pybop/models/lithium_ion/index.rst +++ /dev/null @@ -1,85 +0,0 @@ -:py:mod:`pybop.models.lithium_ion` -================================== - -.. py:module:: pybop.models.lithium_ion - - -Submodules ----------- -.. toctree:: - :titlesonly: - :maxdepth: 1 - - echem/index.rst - - -Package Contents ----------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.models.lithium_ion.SPM - pybop.models.lithium_ion.SPMe - - - - -.. py:class:: SPM(name='Single Particle Model', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) - - - Bases: :py:obj:`pybop.models.base_model.BaseModel` - - Wraps the Single Particle Model (SPM) for simulating lithium-ion batteries, as implemented in PyBaMM. - - The SPM is a simplified physics-based model that represents a lithium-ion cell using a single - spherical particle to simulate the behavior of the negative and positive electrodes. - - :param name: The name for the model instance, defaulting to "Single Particle Model". - :type name: str, optional - :param parameter_set: The parameters for the model. If None, default parameters provided by PyBaMM are used. - :type parameter_set: pybamm.ParameterValues or dict, optional - :param geometry: The geometry definitions for the model. If None, default geometry from PyBaMM is used. - :type geometry: dict, optional - :param submesh_types: The types of submeshes to use. If None, default submesh types from PyBaMM are used. - :type submesh_types: dict, optional - :param var_pts: The discretization points for each variable in the model. If None, default points from PyBaMM are used. - :type var_pts: dict, optional - :param spatial_methods: The spatial methods used for discretization. If None, default spatial methods from PyBaMM are used. - :type spatial_methods: dict, optional - :param solver: The solver to use for simulating the model. If None, the default solver from PyBaMM is used. - :type solver: pybamm.Solver, optional - :param options: A dictionary of options to customize the behavior of the PyBaMM model. - :type options: dict, optional - - -.. py:class:: SPMe(name='Single Particle Model with Electrolyte', parameter_set=None, geometry=None, submesh_types=None, var_pts=None, spatial_methods=None, solver=None, options=None) - - - Bases: :py:obj:`pybop.models.base_model.BaseModel` - - Represents the Single Particle Model with Electrolyte (SPMe) for lithium-ion batteries. - - The SPMe extends the basic Single Particle Model (SPM) by incorporating electrolyte dynamics, - making it suitable for simulations where electrolyte effects are non-negligible. This class - provides a framework to define the model parameters, geometry, mesh types, discretization - points, spatial methods, and numerical solvers for simulation within the PyBaMM ecosystem. - - :param name: A name for the model instance, defaults to "Single Particle Model with Electrolyte". - :type name: str, optional - :param parameter_set: A dictionary or a ParameterValues object containing the parameters for the model. If None, the default PyBaMM parameters for SPMe are used. - :type parameter_set: pybamm.ParameterValues or dict, optional - :param geometry: A dictionary defining the model's geometry. If None, the default PyBaMM geometry for SPMe is used. - :type geometry: dict, optional - :param submesh_types: A dictionary defining the types of submeshes to use. If None, the default PyBaMM submesh types for SPMe are used. - :type submesh_types: dict, optional - :param var_pts: A dictionary specifying the number of points for each variable for discretization. If None, the default PyBaMM variable points for SPMe are used. - :type var_pts: dict, optional - :param spatial_methods: A dictionary specifying the spatial methods for discretization. If None, the default PyBaMM spatial methods for SPMe are used. - :type spatial_methods: dict, optional - :param solver: The solver to use for simulating the model. If None, the default PyBaMM solver for SPMe is used. - :type solver: pybamm.Solver, optional - :param options: A dictionary of options to customize the behavior of the PyBaMM model. - :type options: dict, optional diff --git a/docs/api/pybop/optimisation/index.rst b/docs/api/pybop/optimisation/index.rst deleted file mode 100644 index 619f9eee..00000000 --- a/docs/api/pybop/optimisation/index.rst +++ /dev/null @@ -1,125 +0,0 @@ -:py:mod:`pybop.optimisation` -============================ - -.. py:module:: pybop.optimisation - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.optimisation.Optimisation - - - - -.. py:class:: Optimisation(cost, optimiser=None, sigma0=None, verbose=False) - - - Optimisation class for PyBOP. - This class provides functionality for PyBOP optimisers and Pints optimisers. - :param cost: PyBOP cost function - :param optimiser: A PyBOP or Pints optimiser - :param sigma0: initial step size - :param verbose: print optimisation progress - - .. py:method:: _run_pints() - - Run method for PINTS optimisers. - This method is heavily based on the run method in the PINTS.OptimisationController class. - :returns: best parameters - final_cost: final cost - :rtype: x - - - .. py:method:: _run_pybop() - - Run method for PyBOP based optimisers. - :returns: best parameters - final_cost: final cost - :rtype: x - - - .. py:method:: f_guessed_tracking() - - Returns ``True`` if f_guessed instead of f_best is being tracked, - ``False`` otherwise. See also :meth:`set_f_guessed_tracking`. - - Credit: PINTS - - - .. py:method:: run() - - Run the optimisation algorithm. - Selects between PyBOP backend or Pints backend. - :returns: best parameters - final_cost: final cost - :rtype: x - - - .. py:method:: set_f_guessed_tracking(use_f_guessed=False) - - Sets the method used to track the optimiser progress to - :meth:`pints.Optimiser.f_guessed()` or - :meth:`pints.Optimiser.f_best()` (default). - - The tracked ``f`` value is used to evaluate stopping criteria. - - Credit: PINTS - - - .. py:method:: set_max_evaluations(evaluations=None) - - Adds a stopping criterion, allowing the routine to halt after the - given number of ``evaluations``. - - This criterion is disabled by default. To enable, pass in any positive - integer. To disable again, use ``set_max_evaluations(None)``. - - Credit: PINTS - - - .. py:method:: set_max_iterations(iterations=1000) - - Adds a stopping criterion, allowing the routine to halt after the - given number of ``iterations``. - - This criterion is enabled by default. To disable it, use - ``set_max_iterations(None)``. - - Credit: PINTS - - - .. py:method:: set_max_unchanged_iterations(iterations=25, threshold=1e-05) - - Adds a stopping criterion, allowing the routine to halt if the - objective function doesn't change by more than ``threshold`` for the - given number of ``iterations``. - - This criterion is enabled by default. To disable it, use - ``set_max_unchanged_iterations(None)``. - - Credit: PINTS - - - .. py:method:: set_parallel(parallel=False) - - Enables/disables parallel evaluation. - - If ``parallel=True``, the method will run using a number of worker - processes equal to the detected cpu core count. The number of workers - can be set explicitly by setting ``parallel`` to an integer greater - than 0. - Parallelisation can be disabled by setting ``parallel`` to ``0`` or - ``False``. - - Credit: PINTS - - - .. py:method:: store_optimised_parameters(x) - - Store the optimised parameters in the PyBOP parameter class. diff --git a/docs/api/pybop/optimisers/base_optimiser/index.rst b/docs/api/pybop/optimisers/base_optimiser/index.rst deleted file mode 100644 index 4f34d0c2..00000000 --- a/docs/api/pybop/optimisers/base_optimiser/index.rst +++ /dev/null @@ -1,83 +0,0 @@ -:py:mod:`pybop.optimisers.base_optimiser` -========================================= - -.. py:module:: pybop.optimisers.base_optimiser - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.optimisers.base_optimiser.BaseOptimiser - - - - -.. py:class:: BaseOptimiser - - - A base class for defining optimisation methods. - - This class serves as a template for creating optimisers. It provides a basic structure for - an optimisation algorithm, including the initial setup and a method stub for performing - the optimisation process. Child classes should override the optimise and _runoptimise - methods with specific algorithms. - - .. method:: optimise(cost_function, x0=None, bounds=None, maxiter=None) - - Initiates the optimisation process. This is a stub and should be implemented in child classes. - - .. method:: _runoptimise(cost_function, x0=None, bounds=None) - - Contains the logic for the optimisation algorithm. This is a stub and should be implemented in child classes. - - .. method:: name() - - Returns the name of the optimiser. - - - .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) - - Contains the logic for the optimisation algorithm. - - This method should be implemented by child classes to perform the actual optimisation. - - :param cost_function: The cost function to be minimised by the optimiser. - :type cost_function: callable - :param x0: Initial guess for the parameters. Default is None. - :type x0: ndarray, optional - :param bounds: Bounds on the parameters. Default is None. - :type bounds: sequence or Bounds, optional - - :returns: * *This method is expected to return the result of the optimisation, the format of which* - * *will be determined by the child class implementation.* - - - .. py:method:: name() - - Returns the name of the optimiser. - - :returns: The name of the optimiser, which is "BaseOptimiser" for this base class. - :rtype: str - - - .. py:method:: optimise(cost_function, x0=None, bounds=None, maxiter=None) - - Initiates the optimisation process. - - This method should be overridden by child classes with the specific optimisation algorithm. - - :param cost_function: The cost function to be minimised by the optimiser. - :type cost_function: callable - :param x0: Initial guess for the parameters. Default is None. - :type x0: ndarray, optional - :param bounds: Bounds on the parameters. Default is None. - :type bounds: sequence or Bounds, optional - :param maxiter: Maximum number of iterations to perform. Default is None. - :type maxiter: int, optional - - :rtype: The result of the optimisation process. The specific type of this result will depend on the child implementation. diff --git a/docs/api/pybop/optimisers/index.rst b/docs/api/pybop/optimisers/index.rst deleted file mode 100644 index 8bffb2f0..00000000 --- a/docs/api/pybop/optimisers/index.rst +++ /dev/null @@ -1,16 +0,0 @@ -:py:mod:`pybop.optimisers` -========================== - -.. py:module:: pybop.optimisers - - -Submodules ----------- -.. toctree:: - :titlesonly: - :maxdepth: 1 - - base_optimiser/index.rst - nlopt_optimize/index.rst - pints_optimisers/index.rst - scipy_optimisers/index.rst diff --git a/docs/api/pybop/optimisers/nlopt_optimize/index.rst b/docs/api/pybop/optimisers/nlopt_optimize/index.rst deleted file mode 100644 index 29273b74..00000000 --- a/docs/api/pybop/optimisers/nlopt_optimize/index.rst +++ /dev/null @@ -1,81 +0,0 @@ -:py:mod:`pybop.optimisers.nlopt_optimize` -========================================= - -.. py:module:: pybop.optimisers.nlopt_optimize - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.optimisers.nlopt_optimize.NLoptOptimize - - - - -.. py:class:: NLoptOptimize(n_param, xtol=None, method=None, maxiter=None) - - - Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - - Extends BaseOptimiser to utilize the NLopt library for nonlinear optimization. - - This class serves as an interface to the NLopt optimization algorithms. It allows the user to - define an optimization problem with bounds, initial guesses, and to select an optimization method - provided by NLopt. - - :param n_param: Number of parameters to optimize. - :type n_param: int - :param xtol: The relative tolerance for optimization (stopping criteria). If not provided, a default of 1e-5 is used. - :type xtol: float, optional - :param method: The NLopt algorithm to use for optimization. If not provided, LN_BOBYQA is used by default. - :type method: nlopt.algorithm, optional - :param maxiter: The maximum number of iterations to perform during optimization. If not provided, NLopt's default is used. - :type maxiter: int, optional - - .. method:: _runoptimise(cost_function, x0, bounds) - - Performs the optimization using the NLopt library. - - .. method:: needs_sensitivities() - - Indicates whether the optimizer requires gradient information. - - .. method:: name() - - Returns the name of the optimizer. - - - .. py:method:: _runoptimise(cost_function, x0, bounds) - - Runs the optimization process using the NLopt library. - - :param cost_function: The objective function to minimize. It should take an array of parameter values and return the scalar cost. - :type cost_function: callable - :param x0: The initial guess for the parameters. - :type x0: array_like - :param bounds: A dictionary containing the 'lower' and 'upper' bounds arrays for the parameters. - :type bounds: dict - - :returns: A tuple containing the optimized parameter values and the final cost. - :rtype: tuple - - - .. py:method:: name() - - Returns the name of this optimizer instance. - - :returns: The name 'NLoptOptimize' representing this NLopt optimization class. - :rtype: str - - - .. py:method:: needs_sensitivities() - - Indicates if the optimizer requires gradient information for the cost function. - - :returns: False, as the default NLopt algorithms do not require gradient information. - :rtype: bool diff --git a/docs/api/pybop/optimisers/pints_optimisers/index.rst b/docs/api/pybop/optimisers/pints_optimisers/index.rst deleted file mode 100644 index 3b3ce69c..00000000 --- a/docs/api/pybop/optimisers/pints_optimisers/index.rst +++ /dev/null @@ -1,189 +0,0 @@ -:py:mod:`pybop.optimisers.pints_optimisers` -=========================================== - -.. py:module:: pybop.optimisers.pints_optimisers - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.optimisers.pints_optimisers.Adam - pybop.optimisers.pints_optimisers.CMAES - pybop.optimisers.pints_optimisers.GradientDescent - pybop.optimisers.pints_optimisers.IRPropMin - pybop.optimisers.pints_optimisers.PSO - pybop.optimisers.pints_optimisers.SNES - pybop.optimisers.pints_optimisers.XNES - - - - -.. py:class:: Adam(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.Adam` - - Implements the Adam optimization algorithm. - - This class extends the Adam optimizer from the PINTS library, which combines - ideas from RMSProp and Stochastic Gradient Descent with momentum. Note that - this optimizer does not support boundary constraints. - - :param x0: Initial position from which optimization will start. - :type x0: array_like - :param sigma0: Initial step size (default is 0.1). - :type sigma0: float, optional - :param bounds: Ignored by this optimizer, provided for API consistency. - :type bounds: sequence or ``Bounds``, optional - - .. seealso:: - - :obj:`pints.Adam` - The PINTS implementation this class is based on. - - -.. py:class:: CMAES(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.CMAES` - - Adapter for the Covariance Matrix Adaptation Evolution Strategy (CMA-ES) optimizer in PINTS. - - CMA-ES is an evolutionary algorithm for difficult non-linear non-convex optimization problems. - It adapts the covariance matrix of a multivariate normal distribution to capture the shape of the cost landscape. - - :param x0: The initial parameter vector to optimize. - :type x0: array_like - :param sigma0: Initial standard deviation of the sampling distribution, defaults to 0.1. - :type sigma0: float, optional - :param bounds: A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. - If ``None``, no bounds are enforced. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.CMAES` - PINTS implementation of CMA-ES algorithm. - - -.. py:class:: GradientDescent(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.GradientDescent` - - Implements a simple gradient descent optimization algorithm. - - This class extends the gradient descent optimizer from the PINTS library, designed - to minimize a scalar function of one or more variables. Note that this optimizer - does not support boundary constraints. - - :param x0: Initial position from which optimization will start. - :type x0: array_like - :param sigma0: Initial step size (default is 0.1). - :type sigma0: float, optional - :param bounds: Ignored by this optimizer, provided for API consistency. - :type bounds: sequence or ``Bounds``, optional - - .. seealso:: - - :obj:`pints.GradientDescent` - The PINTS implementation this class is based on. - - -.. py:class:: IRPropMin(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.IRPropMin` - - Implements the iRpropMin optimization algorithm. - - This class inherits from the PINTS IRPropMin class, which is an optimizer that - uses resilient backpropagation with weight-backtracking. It is designed to handle - problems with large plateaus, noisy gradients, and local minima. - - :param x0: Initial position from which optimization will start. - :type x0: array_like - :param sigma0: Initial step size (default is 0.1). - :type sigma0: float, optional - :param bounds: Lower and upper bounds for each optimization parameter. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.IRPropMin` - The PINTS implementation this class is based on. - - -.. py:class:: PSO(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.PSO` - - Implements a particle swarm optimization (PSO) algorithm. - - This class extends the PSO optimizer from the PINTS library. PSO is a - metaheuristic optimization method inspired by the social behavior of birds - flocking or fish schooling, suitable for global optimization problems. - - :param x0: Initial positions of particles, which the optimization will use. - :type x0: array_like - :param sigma0: Spread of the initial particle positions (default is 0.1). - :type sigma0: float, optional - :param bounds: Lower and upper bounds for each optimization parameter. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.PSO` - The PINTS implementation this class is based on. - - -.. py:class:: SNES(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.SNES` - - Implements the stochastic natural evolution strategy (SNES) optimization algorithm. - - Inheriting from the PINTS SNES class, this optimizer is an evolutionary algorithm - that evolves a probability distribution on the parameter space, guiding the search - for the optimum based on the natural gradient of expected fitness. - - :param x0: Initial position from which optimization will start. - :type x0: array_like - :param sigma0: Initial step size (default is 0.1). - :type sigma0: float, optional - :param bounds: Lower and upper bounds for each optimization parameter. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.SNES` - The PINTS implementation this class is based on. - - -.. py:class:: XNES(x0, sigma0=0.1, bounds=None) - - - Bases: :py:obj:`pints.XNES` - - Implements the Exponential Natural Evolution Strategy (XNES) optimizer from PINTS. - - XNES is an evolutionary algorithm that samples from a multivariate normal distribution, which is updated iteratively to fit the distribution of successful solutions. - - :param x0: The initial parameter vector to optimize. - :type x0: array_like - :param sigma0: Initial standard deviation of the sampling distribution, defaults to 0.1. - :type sigma0: float, optional - :param bounds: A dictionary with 'lower' and 'upper' keys containing arrays for lower and upper bounds on the parameters. If ``None``, no bounds are enforced. - :type bounds: dict, optional - - .. seealso:: - - :obj:`pints.XNES` - PINTS implementation of XNES algorithm. diff --git a/docs/api/pybop/optimisers/scipy_optimisers/index.rst b/docs/api/pybop/optimisers/scipy_optimisers/index.rst deleted file mode 100644 index 5080cb51..00000000 --- a/docs/api/pybop/optimisers/scipy_optimisers/index.rst +++ /dev/null @@ -1,115 +0,0 @@ -:py:mod:`pybop.optimisers.scipy_optimisers` -=========================================== - -.. py:module:: pybop.optimisers.scipy_optimisers - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.optimisers.scipy_optimisers.SciPyDifferentialEvolution - pybop.optimisers.scipy_optimisers.SciPyMinimize - - - - -.. py:class:: SciPyDifferentialEvolution(bounds=None, strategy='best1bin', maxiter=1000, popsize=15) - - - Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - - Adapts SciPy's differential_evolution function for global optimization. - - This class provides a global optimization strategy based on differential evolution, useful for problems involving continuous parameters and potentially multiple local minima. - - :param bounds: Bounds for variables. Must be provided as it is essential for differential evolution. - :type bounds: sequence or ``Bounds`` - :param strategy: The differential evolution strategy to use. Defaults to 'best1bin'. - :type strategy: str, optional - :param maxiter: Maximum number of iterations to perform. Defaults to 1000. - :type maxiter: int, optional - :param popsize: The number of individuals in the population. Defaults to 15. - :type popsize: int, optional - - .. py:method:: _runoptimise(cost_function, x0=None, bounds=None) - - Executes the optimization process using SciPy's differential_evolution function. - - :param cost_function: The objective function to minimize. - :type cost_function: callable - :param x0: Ignored parameter, provided for API consistency. - :type x0: array_like, optional - :param bounds: Bounds for the variables, required for differential evolution. - :type bounds: sequence or ``Bounds`` - - :returns: A tuple (x, final_cost) containing the optimized parameters and the value of ``cost_function`` at the optimum. - :rtype: tuple - - - .. py:method:: name() - - Provides the name of the optimization strategy. - - :returns: The name 'SciPyDifferentialEvolution'. - :rtype: str - - - .. py:method:: needs_sensitivities() - - Determines if the optimization algorithm requires gradient information. - - :returns: False, indicating that gradient information is not required for differential evolution. - :rtype: bool - - - -.. py:class:: SciPyMinimize(method=None, bounds=None, maxiter=None) - - - Bases: :py:obj:`pybop.optimisers.base_optimiser.BaseOptimiser` - - Adapts SciPy's minimize function for use as an optimization strategy. - - This class provides an interface to various scalar minimization algorithms implemented in SciPy, allowing fine-tuning of the optimization process through method selection and option configuration. - - :param method: The type of solver to use. If not specified, defaults to 'COBYLA'. - :type method: str, optional - :param bounds: Bounds for variables as supported by the selected method. - :type bounds: sequence or ``Bounds``, optional - :param maxiter: Maximum number of iterations to perform. - :type maxiter: int, optional - - .. py:method:: _runoptimise(cost_function, x0, bounds) - - Executes the optimization process using SciPy's minimize function. - - :param cost_function: The objective function to minimize. - :type cost_function: callable - :param x0: Initial guess for the parameters. - :type x0: array_like - :param bounds: Bounds for the variables. - :type bounds: sequence or `Bounds` - - :returns: A tuple (x, final_cost) containing the optimized parameters and the value of `cost_function` at the optimum. - :rtype: tuple - - - .. py:method:: name() - - Provides the name of the optimization strategy. - - :returns: The name 'SciPyMinimize'. - :rtype: str - - - .. py:method:: needs_sensitivities() - - Determines if the optimization algorithm requires gradient information. - - :returns: False, indicating that gradient information is not required. - :rtype: bool diff --git a/docs/api/pybop/parameters/index.rst b/docs/api/pybop/parameters/index.rst deleted file mode 100644 index 9e385c56..00000000 --- a/docs/api/pybop/parameters/index.rst +++ /dev/null @@ -1,15 +0,0 @@ -:py:mod:`pybop.parameters` -========================== - -.. py:module:: pybop.parameters - - -Submodules ----------- -.. toctree:: - :titlesonly: - :maxdepth: 1 - - parameter/index.rst - parameter_set/index.rst - priors/index.rst diff --git a/docs/api/pybop/parameters/parameter/index.rst b/docs/api/pybop/parameters/parameter/index.rst deleted file mode 100644 index 881ec13d..00000000 --- a/docs/api/pybop/parameters/parameter/index.rst +++ /dev/null @@ -1,95 +0,0 @@ -:py:mod:`pybop.parameters.parameter` -==================================== - -.. py:module:: pybop.parameters.parameter - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.parameters.parameter.Parameter - - - - -.. py:class:: Parameter(name, initial_value=None, prior=None, bounds=None) - - - Represents a parameter within the PyBOP framework. - - This class encapsulates the definition of a parameter, including its name, prior - distribution, initial value, bounds, and a margin to ensure the parameter stays - within feasible limits during optimization or sampling. - - :param name: The name of the parameter. - :type name: str - :param initial_value: The initial value to be assigned to the parameter. Defaults to None. - :type initial_value: float, optional - :param prior: The prior distribution from which parameter values are drawn. Defaults to None. - :type prior: scipy.stats distribution, optional - :param bounds: A tuple defining the lower and upper bounds for the parameter. - Defaults to None. - :type bounds: tuple, optional - - .. method:: rvs(n_samples) - - Draw random samples from the parameter's prior distribution. - - .. method:: update(value) - - Update the parameter's current value. - - .. method:: set_margin(margin) - - Set the margin to a specified positive value less than 1. - - - :raises ValueError: If the lower bound is not strictly less than the upper bound, or if - the margin is set outside the interval (0, 1). - - .. py:method:: __repr__() - - Return a string representation of the Parameter instance. - - :returns: A string including the parameter's name, prior, bounds, and current value. - :rtype: str - - - .. py:method:: rvs(n_samples) - - Draw random samples from the parameter's prior distribution. - - The samples are constrained to be within the parameter's bounds, excluding - a predefined margin at the boundaries. - - :param n_samples: The number of samples to draw. - :type n_samples: int - - :returns: An array of samples drawn from the prior distribution within the parameter's bounds. - :rtype: array-like - - - .. py:method:: set_margin(margin) - - Set the margin to a specified positive value less than 1. - - The margin is used to ensure parameter samples are not drawn exactly at the bounds, - which may be problematic in some optimization or sampling algorithms. - - :param margin: The new margin value to be used, which must be in the interval (0, 1). - :type margin: float - - :raises ValueError: If the margin is not between 0 and 1. - - - .. py:method:: update(value) - - Update the parameter's current value. - - :param value: The new value to be assigned to the parameter. - :type value: float diff --git a/docs/api/pybop/parameters/parameter_set/index.rst b/docs/api/pybop/parameters/parameter_set/index.rst deleted file mode 100644 index 2b22be47..00000000 --- a/docs/api/pybop/parameters/parameter_set/index.rst +++ /dev/null @@ -1,95 +0,0 @@ -:py:mod:`pybop.parameters.parameter_set` -======================================== - -.. py:module:: pybop.parameters.parameter_set - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.parameters.parameter_set.ParameterSet - - - - -.. py:class:: ParameterSet(json_path=None, params_dict=None) - - - Handles the import and export of parameter sets for battery models. - - This class provides methods to load parameters from a JSON file and to export them - back to a JSON file. It also includes custom logic to handle special cases, such - as parameter values that require specific initialization. - - :param json_path: Path to a JSON file containing parameter data. If provided, parameters will be imported from this file during initialization. - :type json_path: str, optional - :param params_dict: A dictionary of parameters to initialize the ParameterSet with. If not provided, an empty dictionary is used. - :type params_dict: dict, optional - - .. py:method:: _handle_special_cases() - - Processes special cases for parameter values that require custom handling. - - For example, if the open-circuit voltage is specified as 'default', it will - fetch the default value from the PyBaMM empirical Thevenin model. - - - .. py:method:: export_parameters(output_json_path, fit_params=None) - - Exports parameters to a JSON file specified by `output_json_path`. - - The current state of the `params` attribute is written to the file. If `fit_params` - is provided, these parameters are updated before export. Non-serializable values - are handled and noted in the output JSON. - - :param output_json_path: The file path where the JSON output will be saved. - :type output_json_path: str - :param fit_params: Parameters that have been fitted and need to be included in the export. - :type fit_params: list of fitted parameter objects, optional - - :raises ValueError: If there are no parameters to export. - - - .. py:method:: import_parameters(json_path=None) - - Imports parameters from a JSON file specified by the `json_path` attribute. - - If a `json_path` is provided at initialization or as an argument, that JSON file - is loaded and the parameters are stored in the `params` attribute. Special cases - are handled appropriately. - - :param json_path: Path to the JSON file from which to import parameters. If provided, it overrides the instance's `json_path`. - :type json_path: str, optional - - :returns: The dictionary containing the imported parameters. - :rtype: dict - - :raises FileNotFoundError: If the specified JSON file cannot be found. - - - .. py:method:: is_json_serializable(value) - - Determines if the given `value` can be serialized to JSON format. - - :param value: The value to check for JSON serializability. - :type value: any - - :returns: True if the value is JSON serializable, False otherwise. - :rtype: bool - - - .. py:method:: pybamm(name) - :classmethod: - - Retrieves a PyBaMM parameter set by name. - - :param name: The name of the PyBaMM parameter set to retrieve. - :type name: str - - :returns: A PyBaMM parameter set corresponding to the provided name. - :rtype: pybamm.ParameterValues diff --git a/docs/api/pybop/parameters/priors/index.rst b/docs/api/pybop/parameters/priors/index.rst deleted file mode 100644 index 9e633272..00000000 --- a/docs/api/pybop/parameters/priors/index.rst +++ /dev/null @@ -1,178 +0,0 @@ -:py:mod:`pybop.parameters.priors` -================================= - -.. py:module:: pybop.parameters.priors - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.parameters.priors.Exponential - pybop.parameters.priors.Gaussian - pybop.parameters.priors.Uniform - - - - -.. py:class:: Exponential(scale) - - - Represents an exponential distribution with a specified scale parameter. - - This class provides methods to calculate the pdf, the log pdf, and to generate random - variates from the distribution. - - :param scale: The scale parameter (lambda) of the exponential distribution. - :type scale: float - - .. py:method:: __repr__() - - Returns a string representation of the Uniform object. - - - .. py:method:: logpdf(x) - - Calculates the logarithm of the pdf of the exponential distribution at x. - - :param x: The point at which to evaluate the log pdf. - :type x: float - - :returns: The log of the probability density function value at x. - :rtype: float - - - .. py:method:: pdf(x) - - Calculates the probability density function of the exponential distribution at x. - - :param x: The point at which to evaluate the pdf. - :type x: float - - :returns: The probability density function value at x. - :rtype: float - - - .. py:method:: rvs(size) - - Generates random variates from the exponential distribution. - - :param size: The number of random variates to generate. - :type size: int - - :returns: An array of random variates from the exponential distribution. - :rtype: array_like - - :raises ValueError: If the size parameter is not positive. - - - -.. py:class:: Gaussian(mean, sigma) - - - Represents a Gaussian (normal) distribution with a given mean and standard deviation. - - This class provides methods to calculate the probability density function (pdf), - the logarithm of the pdf, and to generate random variates (rvs) from the distribution. - - :param mean: The mean (mu) of the Gaussian distribution. - :type mean: float - :param sigma: The standard deviation (sigma) of the Gaussian distribution. - :type sigma: float - - .. py:method:: __repr__() - - Returns a string representation of the Gaussian object. - - - .. py:method:: logpdf(x) - - Calculates the logarithm of the probability density function of the Gaussian distribution at x. - - :param x: The point at which to evaluate the log pdf. - :type x: float - - :returns: The logarithm of the probability density function value at x. - :rtype: float - - - .. py:method:: pdf(x) - - Calculates the probability density function of the Gaussian distribution at x. - - :param x: The point at which to evaluate the pdf. - :type x: float - - :returns: The probability density function value at x. - :rtype: float - - - .. py:method:: rvs(size) - - Generates random variates from the Gaussian distribution. - - :param size: The number of random variates to generate. - :type size: int - - :returns: An array of random variates from the Gaussian distribution. - :rtype: array_like - - :raises ValueError: If the size parameter is not positive. - - - -.. py:class:: Uniform(lower, upper) - - - Represents a uniform distribution over a specified interval. - - This class provides methods to calculate the pdf, the log pdf, and to generate - random variates from the distribution. - - :param lower: The lower bound of the distribution. - :type lower: float - :param upper: The upper bound of the distribution. - :type upper: float - - .. py:method:: __repr__() - - Returns a string representation of the Uniform object. - - - .. py:method:: logpdf(x) - - Calculates the logarithm of the pdf of the uniform distribution at x. - - :param x: The point at which to evaluate the log pdf. - :type x: float - - :returns: The log of the probability density function value at x. - :rtype: float - - - .. py:method:: pdf(x) - - Calculates the probability density function of the uniform distribution at x. - - :param x: The point at which to evaluate the pdf. - :type x: float - - :returns: The probability density function value at x. - :rtype: float - - - .. py:method:: rvs(size) - - Generates random variates from the uniform distribution. - - :param size: The number of random variates to generate. - :type size: int - - :returns: An array of random variates from the uniform distribution. - :rtype: array_like - - :raises ValueError: If the size parameter is not positive. diff --git a/docs/api/pybop/plotting/index.rst b/docs/api/pybop/plotting/index.rst deleted file mode 100644 index 3bbad489..00000000 --- a/docs/api/pybop/plotting/index.rst +++ /dev/null @@ -1,17 +0,0 @@ -:py:mod:`pybop.plotting` -======================== - -.. py:module:: pybop.plotting - - -Submodules ----------- -.. toctree:: - :titlesonly: - :maxdepth: 1 - - plot_convergence/index.rst - plot_cost2d/index.rst - plot_parameters/index.rst - plotly_manager/index.rst - quick_plot/index.rst diff --git a/docs/api/pybop/plotting/plot_convergence/index.rst b/docs/api/pybop/plotting/plot_convergence/index.rst deleted file mode 100644 index 79d2080c..00000000 --- a/docs/api/pybop/plotting/plot_convergence/index.rst +++ /dev/null @@ -1,38 +0,0 @@ -:py:mod:`pybop.plotting.plot_convergence` -========================================= - -.. py:module:: pybop.plotting.plot_convergence - - -Module Contents ---------------- - - -Functions -~~~~~~~~~ - -.. autoapisummary:: - - pybop.plotting.plot_convergence.plot_convergence - - - -.. py:function:: plot_convergence(optim, xaxis_title='Iteration', yaxis_title='Cost', title='Convergence') - - Plot the convergence of the optimisation algorithm. - - Parameters: - ---------- - optim : optimisation object - Optimisation object containing the cost function and optimiser. - xaxis_title : str, optional - Title for the x-axis (default is "Iteration"). - yaxis_title : str, optional - Title for the y-axis (default is "Cost"). - title : str, optional - Title of the plot (default is "Convergence"). - - Returns: - ---------- - fig : plotly.graph_objs.Figure - The Plotly figure object for the convergence plot. diff --git a/docs/api/pybop/plotting/plot_cost2d/index.rst b/docs/api/pybop/plotting/plot_cost2d/index.rst deleted file mode 100644 index 83023b5d..00000000 --- a/docs/api/pybop/plotting/plot_cost2d/index.rst +++ /dev/null @@ -1,54 +0,0 @@ -:py:mod:`pybop.plotting.plot_cost2d` -==================================== - -.. py:module:: pybop.plotting.plot_cost2d - - -Module Contents ---------------- - - -Functions -~~~~~~~~~ - -.. autoapisummary:: - - pybop.plotting.plot_cost2d.create_figure - pybop.plotting.plot_cost2d.get_param_bounds - pybop.plotting.plot_cost2d.plot_cost2d - - - -.. py:function:: create_figure(x, y, z, bounds, params, optim) - - -.. py:function:: get_param_bounds(cost) - - Use parameters bounds for range of cost landscape - - -.. py:function:: plot_cost2d(cost, bounds=None, optim=None, steps=10) - - Query the cost landscape for a given parameter space and plot it using Plotly. - - This function creates a 2D plot that visualizes the cost landscape over a grid - of points within specified parameter bounds. If no bounds are provided, it determines - them from the bounds on the parameter class. - - :param cost: A callable representing the cost function to be queried. It should - take a list of parameters and return a cost value. - :type cost: callable - :param bounds: The bounds for the parameter space as a 2x2 array, with each - sub-array representing the min and max bounds for a parameter. - If None, bounds will be determined by `get_param_bounds`. - :type bounds: numpy.ndarray, optional - :param optim: An optional optimizer instance. If provided, it will be used to - overlay optimizer-specific information on the plot. - :type optim: object, optional - :param steps: The number of steps to divide the parameter space grid. More steps - result in finer resolution but increase computational cost. - :type steps: int, optional - :return: A Plotly figure object representing the cost landscape plot. - :rtype: plotly.graph_objs.Figure - - :raises ValueError: If the cost function does not behave as expected. diff --git a/docs/api/pybop/plotting/plot_parameters/index.rst b/docs/api/pybop/plotting/plot_parameters/index.rst deleted file mode 100644 index 56416c1e..00000000 --- a/docs/api/pybop/plotting/plot_parameters/index.rst +++ /dev/null @@ -1,89 +0,0 @@ -:py:mod:`pybop.plotting.plot_parameters` -======================================== - -.. py:module:: pybop.plotting.plot_parameters - - -Module Contents ---------------- - - -Functions -~~~~~~~~~ - -.. autoapisummary:: - - pybop.plotting.plot_parameters.create_subplots_with_traces - pybop.plotting.plot_parameters.create_traces - pybop.plotting.plot_parameters.plot_parameters - - - -.. py:function:: create_subplots_with_traces(traces, plot_size=(1024, 576), title='Parameter Convergence', axis_titles=None, **layout_kwargs) - - Creates a subplot figure with the given traces. - - :param traces: List of plotly.graph_objs traces that will be added to the subplots. - :param plot_size: Tuple (width, height) representing the desired size of the plot. - :param title: The main title of the subplot figure. - :param axis_titles: List of tuples for axis titles in the form [(x_title, y_title), ...] for each subplot. - :param layout_kwargs: Additional keyword arguments to be passed to fig.update_layout for custom layout. - :return: A plotly figure object with the subplots. - - -.. py:function:: create_traces(params, trace_data, x_values=None) - - Generate a list of Plotly Scatter trace objects from provided trace data. - - This function assumes that each column in the ``trace_data`` represents a separate trace to be plotted, - and that the ``params`` list contains objects with a ``name`` attribute used for trace names. - Text wrapping for trace names is performed by ``pybop.StandardPlot.wrap_text``. - - Parameters: - - params (list): A list of objects, where each object has a ``name`` attribute used as the trace name. - The list should have the same length as the number of traces in ``trace_data``. - - trace_data (list of lists): A 2D list where each inner list represents y-values for a trace. - - x_values (list, optional): A list of x-values to be used for all traces. If not provided, a - range of integers starting from 0 will be used. - - Returns: - - list: A list of Plotly ``go.Scatter`` objects, each representing a trace to be plotted. - - Notes: - - The function depends on ``pybop.StandardPlot.wrap_text`` for text wrapping, which needs to be available - in the execution context. - - The function assumes that ``go`` from ``plotly.graph_objs`` is already imported as ``go``. - - -.. py:function:: plot_parameters(optim, xaxis_titles='Iteration', yaxis_titles=None, title='Convergence') - - Plot the evolution of the parameters during the optimisation process. - - Parameters: - ------------ - optim : optimisation object - An object representing the optimisation process, which should contain - information about the cost function, optimiser, and the history of the - parameter values throughout the iterations. - xaxis_title : str, optional - Title for the x-axis, representing the iteration number or a similar - discrete time step in the optimisation process (default is "Iteration"). - yaxis_title : str, optional - Title for the y-axis, which typically represents the metric being - optimised, such as cost or loss (default is "Cost"). - title : str, optional - Title of the plot, which provides an overall description of what the - plot represents (default is "Convergence"). - - Returns: - ---------- - fig : plotly.graph_objs.Figure - The Plotly figure object for the plot depicting how the parameters of - the optimisation algorithm evolve over its course. This can be useful - for diagnosing the behaviour of the optimisation algorithm. - - Notes: - ---------- - The function assumes that the 'optim' object has a 'cost.problem.parameters' - attribute containing the parameters of the optimisation algorithm and a 'log' - attribute containing a history of the iterations. diff --git a/docs/api/pybop/plotting/plotly_manager/index.rst b/docs/api/pybop/plotting/plotly_manager/index.rst deleted file mode 100644 index d0606b3c..00000000 --- a/docs/api/pybop/plotting/plotly_manager/index.rst +++ /dev/null @@ -1,70 +0,0 @@ -:py:mod:`pybop.plotting.plotly_manager` -======================================= - -.. py:module:: pybop.plotting.plotly_manager - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.plotting.plotly_manager.PlotlyManager - - - - -.. py:class:: PlotlyManager - - - Manages the installation and configuration of Plotly for generating visualisations. - - This class checks if Plotly is installed and, if not, prompts the user to install it. - It also ensures that the Plotly renderer and browser settings are properly configured - to display plots. - - Methods: - ``ensure_plotly_installed``: Verifies if Plotly is installed and installs it if necessary. - ``prompt_for_plotly_installation``: Prompts the user for permission to install Plotly. - ``install_plotly_package``: Installs the Plotly package using pip. - ``post_install_setup``: Sets up Plotly default renderer after installation. - ``check_renderer_settings``: Verifies that the Plotly renderer is correctly set. - ``check_browser_availability``: Checks if a web browser is available for rendering plots. - - Usage: - Instantiate the PlotlyManager class to automatically ensure Plotly is installed - and configured correctly when creating an instance. - Example: - plotly_manager = PlotlyManager() - - .. py:method:: check_browser_availability() - - Ensures a web browser is available for rendering plots with the 'browser' renderer and provides guidance if not. - - - .. py:method:: check_renderer_settings() - - Checks if the Plotly renderer is set and provides information on how to set it if empty. - - - .. py:method:: ensure_plotly_installed() - - Verifies if Plotly is installed, importing necessary modules and prompting for installation if missing. - - - .. py:method:: install_plotly() - - Attempts to install the Plotly package using pip and exits if installation fails. - - - .. py:method:: post_install_setup() - - After successful installation, imports Plotly and sets the default renderer if necessary. - - - .. py:method:: prompt_for_plotly_installation() - - Prompts the user for permission to install Plotly and proceeds with installation if consented. diff --git a/docs/api/pybop/plotting/quick_plot/index.rst b/docs/api/pybop/plotting/quick_plot/index.rst deleted file mode 100644 index 87d9db9c..00000000 --- a/docs/api/pybop/plotting/quick_plot/index.rst +++ /dev/null @@ -1,123 +0,0 @@ -:py:mod:`pybop.plotting.quick_plot` -=================================== - -.. py:module:: pybop.plotting.quick_plot - - -Module Contents ---------------- - -Classes -~~~~~~~ - -.. autoapisummary:: - - pybop.plotting.quick_plot.StandardPlot - - - -Functions -~~~~~~~~~ - -.. autoapisummary:: - - pybop.plotting.quick_plot.quick_plot - - - -.. py:class:: StandardPlot(x, y, cost, y2=None, title=None, xaxis_title=None, yaxis_title=None, trace_name=None, width=1024, height=576) - - - A class for creating and displaying a plotly figure that compares a target dataset against a simulated model output. - - This class provides an interface for generating interactive plots using Plotly, with the ability to include an - optional secondary dataset and visualize uncertainty if provided. - - Attributes: - ----------- - x : list - The x-axis data points. - y : list or np.ndarray - The primary y-axis data points representing the simulated model output. - y2 : list or np.ndarray, optional - An optional secondary y-axis data points representing the target dataset against which the model output is compared. - cost : float - The cost associated with the model output. - title : str, optional - The title of the plot. - xaxis_title : str, optional - The title for the x-axis. - yaxis_title : str, optional - The title for the y-axis. - trace_name : str, optional - The name of the primary trace representing the model output. Defaults to "Simulated". - width : int, optional - The width of the figure in pixels. Defaults to 720. - height : int, optional - The height of the figure in pixels. Defaults to 540. - - Example: - ---------- - >>> x_data = [1, 2, 3, 4] - >>> y_simulated = [10, 15, 13, 17] - >>> y_target = [11, 14, 12, 16] - >>> plot = pybop.StandardPlot(x_data, y_simulated, cost=0.05, y2=y_target, - title="Model vs. Target", xaxis_title="X Axis", yaxis_title="Y Axis") - >>> fig = plot() # Generate the figure - >>> fig.show() # Display the figure in a browser - - .. py:method:: __call__() - - Generate the plotly figure. - - - .. py:method:: create_layout() - - Create the layout for the plot. - - - .. py:method:: create_traces() - - Create the traces for the plot. - - - .. py:method:: wrap_text(text, width) - :staticmethod: - - Wrap text to a specified width. - - Parameters: - ----------- - text: str - Text to be wrapped. - width: int - Width to wrap text to. - - Returns: - ---------- - str - Wrapped text with HTML line breaks. - - - -.. py:function:: quick_plot(params, cost, title='Scatter Plot', width=1024, height=576) - - Plot the target dataset against the minimised model output. - - Parameters: - ----------- - params : array-like - Optimised parameters. - cost : cost object - Cost object containing the problem, dataset, and signal. - title : str, optional - Title of the plot (default is "Scatter Plot"). - width : int, optional - Width of the figure in pixels (default is 720). - height : int, optional - Height of the figure in pixels (default is 540). - - Returns: - ---------- - fig : plotly.graph_objs.Figure - The Plotly figure object for the scatter plot. diff --git a/docs/api/pybop/version/index.rst b/docs/api/pybop/version/index.rst deleted file mode 100644 index bf5679ef..00000000 --- a/docs/api/pybop/version/index.rst +++ /dev/null @@ -1,11 +0,0 @@ -:py:mod:`pybop.version` -======================= - -.. py:module:: pybop.version - - -Module Contents ---------------- - -.. py:data:: __version__ - :value: '23.11' diff --git a/docs/conf.py b/docs/conf.py index f3e3ce0c..55f6b90c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,16 +54,22 @@ # html_theme options html_theme_options = { + "header_links_before_dropdown": 4, "icon_links": [ + { + "name": "PyPI", + "url": "https://pypi.org/project/pybop/", + "icon": "fa-custom fa-pypi", + }, { "name": "GitHub", "url": "https://github.com/pybop-team/pybop", "icon": "fab fa-github-square", }, - # add other icon links as needed ], "search_bar_text": "Search the docs...", "show_prev_next": False, } html_static_path = ["_static"] +html_js_files = ["custom-icon.js"] diff --git a/docs/index.md b/docs/index.md index 94d276fe..622dd79d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,7 +6,7 @@ myst: html_theme.sidebar_secondary.remove: true --- -

Parameterise and Optimise Battery Models with PyBOP

+# PyBOP: Optimise and Parameterise Battery Models Welcome to PyBOP, a Python package dedicated to the optimization and parameterization of battery models. PyBOP is designed to streamline your workflow, whether you are conducting academic research, working in industry, or simply interested in battery technology and modelling. diff --git a/docs/user_guide/installation.rst b/docs/user_guide/installation.rst index 1482edc4..1204d725 100644 --- a/docs/user_guide/installation.rst +++ b/docs/user_guide/installation.rst @@ -50,7 +50,7 @@ For Developers If you are installing PyBOP for development purposes, such as contributing to the project, please ensure that you follow the guidelines outlined in the contributing guide. It includes additional steps that might be necessary for setting up a development environment, including the installation of dependencies and setup of pre-commit hooks. -`Contributing Guide `_ +`Contributing Guide <../contributing.html>`_ Further Assistance ------------------ @@ -62,6 +62,5 @@ Next Steps After installing PyBOP, you might want to: -* Explore the `Quick Start Guide `_ to begin using PyBOP. -* Read through the `User Manual `_ for in-depth documentation on PyBOP's features. +* Explore the `Quick Start Guide `_ to begin using PyBOP. * Check out the `API Reference <../api/index.html>`_ for detailed information on PyBOP's programming interface. diff --git a/pybop/_problem.py b/pybop/_problem.py index 8cf37ec3..5e9110d6 100644 --- a/pybop/_problem.py +++ b/pybop/_problem.py @@ -3,7 +3,20 @@ class BaseProblem: """ - Defines the PyBOP base problem, following the PINTS interface. + Base class for defining a problem within the PyBOP framework, compatible with PINTS. + + Parameters + ---------- + parameters : list + List of parameters for the problem. + model : object, optional + The model to be used for the problem (default: None). + check_model : bool, optional + Flag to indicate if the model should be checked (default: True). + init_soc : float, optional + Initial state of charge (default: None). + x0 : np.ndarray, optional + Initial parameter values (default: None). """ def __init__( @@ -42,20 +55,52 @@ def __init__( def evaluate(self, x): """ Evaluate the model with the given parameters and return the signal. + + Parameters + ---------- + x : np.ndarray + Parameter values to evaluate the model at. + + Raises + ------ + NotImplementedError + This method must be implemented by subclasses. """ raise NotImplementedError def evaluateS1(self, x): """ - Evaluate the model with the given parameters and return the signal and - its derivatives. + Evaluate the model with the given parameters and return the signal and its derivatives. + + Parameters + ---------- + x : np.ndarray + Parameter values to evaluate the model at. + + Raises + ------ + NotImplementedError + This method must be implemented by subclasses. """ raise NotImplementedError class FittingProblem(BaseProblem): """ - Defines the problem class for a fitting (parameter estimation) problem. + Problem class for fitting (parameter estimation) problems. + + Extends `BaseProblem` with specifics for fitting a model to a dataset. + + Parameters + ---------- + model : object + The model to fit. + parameters : list + List of parameters for the problem. + dataset : list + List of data objects to fit the model to. + signal : str, optional + The signal to fit (default: "Voltage [V]"). """ def __init__( @@ -104,6 +149,11 @@ def __init__( def evaluate(self, x): """ Evaluate the model with the given parameters and return the signal. + + Parameters + ---------- + x : np.ndarray + Parameter values to evaluate the model at. """ y = np.asarray(self._model.simulate(inputs=x, t_eval=self._time_data)) @@ -112,8 +162,12 @@ def evaluate(self, x): def evaluateS1(self, x): """ - Evaluate the model with the given parameters and return the signal and - its derivatives. + Evaluate the model with the given parameters and return the signal and its derivatives. + + Parameters + ---------- + x : np.ndarray + Parameter values to evaluate the model at. """ y, dy = self._model.simulateS1( @@ -125,14 +179,30 @@ def evaluateS1(self, x): def target(self): """ - Returns the target dataset. + Return the target dataset. + + Returns + ------- + np.ndarray + The target dataset array. """ return self._target class DesignProblem(BaseProblem): """ - Defines the problem class for a design optimiation problem. + Problem class for design optimization problems. + + Extends `BaseProblem` with specifics for applying a model to an experimental design. + + Parameters + ---------- + model : object + The model to apply the design to. + parameters : list + List of parameters for the problem. + experiment : object + The experimental setup to apply the model to. """ def __init__( @@ -166,6 +236,11 @@ def __init__( def evaluate(self, x): """ Evaluate the model with the given parameters and return the signal. + + Parameters + ---------- + x : np.ndarray + Parameter values to evaluate the model at. """ y = np.asarray(self._model.simulate(inputs=x, t_eval=self._time_data)) @@ -174,8 +249,12 @@ def evaluate(self, x): def evaluateS1(self, x): """ - Evaluate the model with the given parameters and return the signal and - its derivatives. + Evaluate the model with the given parameters and return the signal and its derivatives. + + Parameters + ---------- + x : np.ndarray + Parameter values to evaluate the model at. """ y, dy = self._model.simulateS1( @@ -187,6 +266,10 @@ def evaluateS1(self, x): def target(self): """ - Returns the target dataset. + Return the target dataset (not applicable for design problems). + + Returns + ------- + None """ return self._target diff --git a/pybop/optimisation.py b/pybop/optimisation.py index 5da349a0..d18c6a43 100644 --- a/pybop/optimisation.py +++ b/pybop/optimisation.py @@ -5,14 +5,31 @@ class Optimisation: """ - Optimisation class for PyBOP. - This class provides functionality for PyBOP optimisers and Pints optimisers. - args: - cost: PyBOP cost function - optimiser: A PyBOP or Pints optimiser - sigma0: initial step size - verbose: print optimisation progress - + A class for conducting optimization using PyBOP or PINTS optimizers. + + Parameters + ---------- + cost : pints.ErrorMeasure or pints.LogPDF + An objective function to be optimized, which can be either a PINTS error measure or log PDF. + optimiser : pints.Optimiser or subclass of pybop.BaseOptimizer, optional + An optimizer from either the PINTS or PyBOP framework to perform the optimization (default: None). + sigma0 : float or sequence, optional + Initial step size or standard deviation for the optimizer (default: None). + verbose : bool, optional + If True, the optimization progress is printed (default: False). + + Attributes + ---------- + x0 : numpy.ndarray + Initial parameter values for the optimization. + bounds : dict + Dictionary containing the parameter bounds with keys 'lower' and 'upper'. + n_parameters : int + Number of parameters in the optimization problem. + sigma0 : float or sequence + Initial step size or standard deviation for the optimizer. + log : list + Log of the optimization process. """ def __init__( @@ -106,11 +123,14 @@ def __init__( def run(self): """ - Run the optimisation algorithm. - Selects between PyBOP backend or Pints backend. - returns: - x: best parameters - final_cost: final cost + Run the optimization and return the optimized parameters and final cost. + + Returns + ------- + x : numpy.ndarray + The best parameter set found by the optimization. + final_cost : float + The final cost associated with the best parameters. """ if self.pints: @@ -126,10 +146,14 @@ def run(self): def _run_pybop(self): """ - Run method for PyBOP based optimisers. - returns: - x: best parameters - final_cost: final cost + Internal method to run the optimization using a PyBOP optimizer. + + Returns + ------- + x : numpy.ndarray + The best parameter set found by the optimization. + final_cost : float + The final cost associated with the best parameters. """ x, final_cost = self.optimiser.optimise( cost_function=self.cost, @@ -143,11 +167,18 @@ def _run_pybop(self): def _run_pints(self): """ - Run method for PINTS optimisers. + Internal method to run the optimization using a PINTS optimizer. + + Returns + ------- + x : numpy.ndarray + The best parameter set found by the optimization. + final_cost : float + The final cost associated with the best parameters. + + See Also + -------- This method is heavily based on the run method in the PINTS.OptimisationController class. - returns: - x: best parameters - final_cost: final cost """ # Check stopping criteria @@ -319,34 +350,37 @@ def _run_pints(self): def f_guessed_tracking(self): """ - Returns ``True`` if f_guessed instead of f_best is being tracked, - ``False`` otherwise. See also :meth:`set_f_guessed_tracking`. - + Check if f_guessed instead of f_best is being tracked. Credit: PINTS + + Returns + ------- + bool + True if f_guessed is being tracked, False otherwise. """ return self._use_f_guessed def set_f_guessed_tracking(self, use_f_guessed=False): """ - Sets the method used to track the optimiser progress to - :meth:`pints.Optimiser.f_guessed()` or - :meth:`pints.Optimiser.f_best()` (default). - - The tracked ``f`` value is used to evaluate stopping criteria. - + Set the method used to track the optimizer progress. Credit: PINTS + + Parameters + ---------- + use_f_guessed : bool, optional + If True, track f_guessed; otherwise, track f_best (default: False). """ self._use_f_guessed = bool(use_f_guessed) def set_max_evaluations(self, evaluations=None): """ - Adds a stopping criterion, allowing the routine to halt after the - given number of ``evaluations``. - - This criterion is disabled by default. To enable, pass in any positive - integer. To disable again, use ``set_max_evaluations(None)``. - + Set a maximum number of evaluations stopping criterion. Credit: PINTS + + Parameters + ---------- + evaluations : int, optional + The maximum number of evaluations after which to stop the optimization (default: None). """ if evaluations is not None: evaluations = int(evaluations) @@ -356,16 +390,14 @@ def set_max_evaluations(self, evaluations=None): def set_parallel(self, parallel=False): """ - Enables/disables parallel evaluation. - - If ``parallel=True``, the method will run using a number of worker - processes equal to the detected cpu core count. The number of workers - can be set explicitly by setting ``parallel`` to an integer greater - than 0. - Parallelisation can be disabled by setting ``parallel`` to ``0`` or - ``False``. - + Enable or disable parallel evaluation. Credit: PINTS + + Parameters + ---------- + parallel : bool or int, optional + If True, use as many worker processes as there are CPU cores. If an integer, use that many workers. + If False or 0, disable parallelism (default: False). """ if parallel is True: self._parallel = True @@ -379,13 +411,14 @@ def set_parallel(self, parallel=False): def set_max_iterations(self, iterations=1000): """ - Adds a stopping criterion, allowing the routine to halt after the - given number of ``iterations``. - - This criterion is enabled by default. To disable it, use - ``set_max_iterations(None)``. - + Set the maximum number of iterations as a stopping criterion. Credit: PINTS + + Parameters + ---------- + iterations : int, optional + The maximum number of iterations to run (default is 1000). + Set to `None` to remove this stopping criterion. """ if iterations is not None: iterations = int(iterations) @@ -395,14 +428,16 @@ def set_max_iterations(self, iterations=1000): def set_max_unchanged_iterations(self, iterations=25, threshold=1e-5): """ - Adds a stopping criterion, allowing the routine to halt if the - objective function doesn't change by more than ``threshold`` for the - given number of ``iterations``. - - This criterion is enabled by default. To disable it, use - ``set_max_unchanged_iterations(None)``. - + Set the maximum number of iterations without significant change as a stopping criterion. Credit: PINTS + + Parameters + ---------- + iterations : int, optional + The maximum number of unchanged iterations to run (default is 25). + Set to `None` to remove this stopping criterion. + threshold : float, optional + The minimum significant change in the objective function value that resets the unchanged iteration counter (default is 1e-5). """ if iterations is not None: iterations = int(iterations) @@ -418,7 +453,14 @@ def set_max_unchanged_iterations(self, iterations=25, threshold=1e-5): def store_optimised_parameters(self, x): """ - Store the optimised parameters in the PyBOP parameter class. + Update the problem parameters with optimized values. + + The optimized parameter values are stored within the associated PyBOP parameter class. + + Parameters + ---------- + x : array-like + Optimized parameter values. """ for i, param in enumerate(self.cost.problem.parameters): param.update(value=x[i]) diff --git a/pybop/plotting/plot_cost2d.py b/pybop/plotting/plot_cost2d.py index 43a84ce6..f8e7b187 100644 --- a/pybop/plotting/plot_cost2d.py +++ b/pybop/plotting/plot_cost2d.py @@ -3,29 +3,31 @@ def plot_cost2d(cost, bounds=None, optim=None, steps=10): """ - Query the cost landscape for a given parameter space and plot it using Plotly. - - This function creates a 2D plot that visualizes the cost landscape over a grid - of points within specified parameter bounds. If no bounds are provided, it determines - them from the bounds on the parameter class. - - :param cost: A callable representing the cost function to be queried. It should - take a list of parameters and return a cost value. - :type cost: callable - :param bounds: The bounds for the parameter space as a 2x2 array, with each - sub-array representing the min and max bounds for a parameter. - If None, bounds will be determined by `get_param_bounds`. - :type bounds: numpy.ndarray, optional - :param optim: An optional optimizer instance. If provided, it will be used to - overlay optimizer-specific information on the plot. - :type optim: object, optional - :param steps: The number of steps to divide the parameter space grid. More steps - result in finer resolution but increase computational cost. - :type steps: int, optional - :return: A Plotly figure object representing the cost landscape plot. - :rtype: plotly.graph_objs.Figure - - :raises ValueError: If the cost function does not behave as expected. + Plot a 2D visualization of a cost landscape using Plotly. + + This function generates a contour plot representing the cost landscape for a provided + callable cost function over a grid of parameter values within the specified bounds. + + Parameters + ---------- + cost : callable + The cost function to be evaluated. Must accept a list of parameters and return a cost value. + bounds : numpy.ndarray, optional + A 2x2 array specifying the [min, max] bounds for each parameter. If None, uses `get_param_bounds`. + optim : object, optional + An optimizer instance which, if provided, overlays its specific trace on the plot. + steps : int, optional + The number of intervals to divide the parameter space into along each dimension (default is 10). + + Returns + ------- + plotly.graph_objs.Figure + The Plotly figure object containing the cost landscape plot. + + Raises + ------ + ValueError + If the cost function does not return a valid cost when called with a parameter list. """ if bounds is None: @@ -57,7 +59,17 @@ def plot_cost2d(cost, bounds=None, optim=None, steps=10): def get_param_bounds(cost): """ - Use parameters bounds for range of cost landscape + Retrieve parameter bounds from a cost function's associated problem parameters. + + Parameters + ---------- + cost : callable + The cost function with an associated 'problem' attribute containing 'parameters'. + + Returns + ------- + numpy.ndarray + An array of shape (n_parameters, 2) containing the bounds for each parameter. """ bounds = np.empty((len(cost.problem.parameters), 2)) for i, param in enumerate(cost.problem.parameters): @@ -66,6 +78,30 @@ def get_param_bounds(cost): def create_figure(x, y, z, bounds, params, optim): + """ + Create a Plotly figure with a 2D contour plot of the cost landscape. + + Parameters + ---------- + x : numpy.ndarray + 1D array of x-coordinates for the meshgrid. + y : numpy.ndarray + 1D array of y-coordinates for the meshgrid. + z : numpy.ndarray + 2D array of cost function values corresponding to the meshgrid. + bounds : numpy.ndarray + A 2x2 array specifying the [min, max] bounds for each parameter. + params : iterable + An iterable of parameter objects with 'name' attributes for axis labeling. + optim : object + An optimizer instance with 'log' and 'x0' attributes for plotting traces. + + Returns + ------- + plotly.graph_objs.Figure + The Plotly figure object with the contour plot and optimization traces. + """ + # Import plotly only when needed import plotly.graph_objects as go diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py index 1deab9bf..84307a72 100644 --- a/pybop/plotting/plot_parameters.py +++ b/pybop/plotting/plot_parameters.py @@ -6,36 +6,23 @@ def plot_parameters( optim, xaxis_titles="Iteration", yaxis_titles=None, title="Convergence" ): """ - Plot the evolution of the parameters during the optimisation process. - - Parameters: - ------------ - optim : optimisation object - An object representing the optimisation process, which should contain - information about the cost function, optimiser, and the history of the - parameter values throughout the iterations. - xaxis_title : str, optional - Title for the x-axis, representing the iteration number or a similar - discrete time step in the optimisation process (default is "Iteration"). - yaxis_title : str, optional - Title for the y-axis, which typically represents the metric being - optimised, such as cost or loss (default is "Cost"). - title : str, optional - Title of the plot, which provides an overall description of what the - plot represents (default is "Convergence"). + Plot the evolution of parameters during the optimization process using Plotly. - Returns: + Parameters ---------- - fig : plotly.graph_objs.Figure - The Plotly figure object for the plot depicting how the parameters of - the optimisation algorithm evolve over its course. This can be useful - for diagnosing the behaviour of the optimisation algorithm. + optim : object + The optimization object containing the history of parameter values and associated cost. + xaxis_titles : str, optional + Title for the x-axis, defaulting to "Iteration". + yaxis_titles : list of str, optional + Titles for the y-axes, one for each parameter. If None, parameter names are used. + title : str, optional + Title of the plot, defaulting to "Convergence". - Notes: - ---------- - The function assumes that the 'optim' object has a 'cost.problem.parameters' - attribute containing the parameters of the optimisation algorithm and a 'log' - attribute containing a history of the iterations. + Returns + ------- + plotly.graph_objs.Figure + A Plotly figure object showing the parameter evolution over iterations. """ # Extract parameters from the optimisation object @@ -60,26 +47,21 @@ def plot_parameters( def create_traces(params, trace_data, x_values=None): """ - Generate a list of Plotly Scatter trace objects from provided trace data. - - This function assumes that each column in the ``trace_data`` represents a separate trace to be plotted, - and that the ``params`` list contains objects with a ``name`` attribute used for trace names. - Text wrapping for trace names is performed by ``pybop.StandardPlot.wrap_text``. - - Parameters: - - params (list): A list of objects, where each object has a ``name`` attribute used as the trace name. - The list should have the same length as the number of traces in ``trace_data``. - - trace_data (list of lists): A 2D list where each inner list represents y-values for a trace. - - x_values (list, optional): A list of x-values to be used for all traces. If not provided, a - range of integers starting from 0 will be used. - - Returns: - - list: A list of Plotly ``go.Scatter`` objects, each representing a trace to be plotted. - - Notes: - - The function depends on ``pybop.StandardPlot.wrap_text`` for text wrapping, which needs to be available - in the execution context. - - The function assumes that ``go`` from ``plotly.graph_objs`` is already imported as ``go``. + Create traces for plotting parameter evolution. + + Parameters + ---------- + params : list + List of parameter objects, each having a 'name' attribute used for labeling the trace. + trace_data : list of numpy.ndarray + A list of arrays representing the historical values of each parameter. + x_values : list or numpy.ndarray, optional + The x-axis values for plotting. If None, defaults to sequential integers. + + Returns + ------- + list of plotly.graph_objs.Scatter + A list of Scatter trace objects, one for each parameter. """ # Attempt to import plotly when an instance is created @@ -121,14 +103,25 @@ def create_subplots_with_traces( **layout_kwargs, ): """ - Creates a subplot figure with the given traces. - - :param traces: List of plotly.graph_objs traces that will be added to the subplots. - :param plot_size: Tuple (width, height) representing the desired size of the plot. - :param title: The main title of the subplot figure. - :param axis_titles: List of tuples for axis titles in the form [(x_title, y_title), ...] for each subplot. - :param layout_kwargs: Additional keyword arguments to be passed to fig.update_layout for custom layout. - :return: A plotly figure object with the subplots. + Create a subplot with individual traces for each parameter. + + Parameters + ---------- + traces : list of plotly.graph_objs.Scatter + Traces to be plotted, one trace per subplot. + plot_size : tuple of int, optional + The size of the plot as (width, height), defaulting to (1024, 576). + title : str, optional + The title of the plot, defaulting to "Parameter Convergence". + axis_titles : list of tuple of str, optional + A list of (x_title, y_title) pairs for each subplot. If None, titles are omitted. + **layout_kwargs : dict + Additional keyword arguments to customize the layout. + + Returns + ------- + plotly.graph_objs.Figure + A Plotly figure object with subplots for each trace. """ # Attempt to import plotly when an instance is created diff --git a/pybop/plotting/plotly_manager.py b/pybop/plotting/plotly_manager.py index c8b80dc9..b53b15ef 100644 --- a/pybop/plotting/plotly_manager.py +++ b/pybop/plotting/plotly_manager.py @@ -5,28 +5,47 @@ class PlotlyManager: """ - Manages the installation and configuration of Plotly for generating visualisations. - - This class checks if Plotly is installed and, if not, prompts the user to install it. - It also ensures that the Plotly renderer and browser settings are properly configured - to display plots. - - Methods: - ``ensure_plotly_installed``: Verifies if Plotly is installed and installs it if necessary. - ``prompt_for_plotly_installation``: Prompts the user for permission to install Plotly. - ``install_plotly_package``: Installs the Plotly package using pip. - ``post_install_setup``: Sets up Plotly default renderer after installation. - ``check_renderer_settings``: Verifies that the Plotly renderer is correctly set. - ``check_browser_availability``: Checks if a web browser is available for rendering plots. - - Usage: - Instantiate the PlotlyManager class to automatically ensure Plotly is installed - and configured correctly when creating an instance. - Example: - plotly_manager = PlotlyManager() + Manages the installation and configuration of Plotly for generating visualizations. + + This class ensures that Plotly is installed and properly configured to display + plots in a web browser. + + Upon instantiation, it checks for Plotly's presence, installs it if missing, + and configures the default renderer and browser settings. + + Attributes + ---------- + go : module + The Plotly graph_objects module for creating figures. + pio : module + The Plotly input/output module for configuring the renderer. + make_subplots : function + The function from Plotly for creating subplot figures. + + Methods + ------- + ensure_plotly_installed() + Verifies if Plotly is installed, importing necessary modules and prompting for installation if missing. + prompt_for_plotly_installation() + Prompts the user for permission to install Plotly and proceeds with installation if consented. + install_plotly() + Installs the Plotly package using pip. + post_install_setup() + Sets up Plotly default renderer after installation. + check_renderer_settings() + Verifies that the Plotly renderer is correctly set. + check_browser_availability() + Checks if a web browser is available for rendering plots. + + Examples + -------- + >>> plotly_manager = PlotlyManager() """ def __init__(self): + """ + Initialize the PlotlyManager, ensuring Plotly is installed and configured. + """ self.go = None self.pio = None self.make_subplots = None @@ -35,7 +54,9 @@ def __init__(self): self.check_browser_availability() def ensure_plotly_installed(self): - """Verifies if Plotly is installed, importing necessary modules and prompting for installation if missing.""" + """ + Check if Plotly is installed and import necessary modules; prompt for installation if missing. + """ try: import plotly.graph_objs as go import plotly.io as pio @@ -48,7 +69,9 @@ def ensure_plotly_installed(self): self.prompt_for_plotly_installation() def prompt_for_plotly_installation(self): - """Prompts the user for permission to install Plotly and proceeds with installation if consented.""" + """ + Prompt the user for Plotly installation and install it upon agreement. + """ user_input = ( input( "Plotly is not installed. To proceed, we need to install plotly. (Y/n)? " @@ -64,7 +87,9 @@ def prompt_for_plotly_installation(self): sys.exit(1) # Exit if user cancels installation def install_plotly(self): - """Attempts to install the Plotly package using pip and exits if installation fails.""" + """ + Install the Plotly package using pip. Exit if installation fails. + """ try: subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"]) except subprocess.CalledProcessError as e: @@ -72,7 +97,9 @@ def install_plotly(self): sys.exit(1) # Exit if installation fails def post_install_setup(self): - """After successful installation, imports Plotly and sets the default renderer if necessary.""" + """ + Import Plotly modules and set the default renderer after installation. + """ import plotly.graph_objs as go import plotly.io as pio from plotly.subplots import make_subplots @@ -87,7 +114,9 @@ def post_install_setup(self): ) def check_renderer_settings(self): - """Checks if the Plotly renderer is set and provides information on how to set it if empty.""" + """ + Check and provide information on setting the Plotly renderer if it's not already set. + """ if self.pio and self.pio.renderers.default == "": print( "The Plotly renderer is an empty string. To set the renderer, use:\n" @@ -97,7 +126,9 @@ def check_renderer_settings(self): ) def check_browser_availability(self): - """Ensures a web browser is available for rendering plots with the 'browser' renderer and provides guidance if not.""" + """ + Confirm a web browser is available for Plotly's 'browser' renderer; provide guidance if not. + """ if self.pio and self.pio.renderers.default == "browser": try: webbrowser.get() diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 8d9ef02b..95a5bfeb 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -5,43 +5,32 @@ class StandardPlot: """ - A class for creating and displaying a plotly figure that compares a target dataset against a simulated model output. + A class for creating and displaying Plotly figures for model output comparison. - This class provides an interface for generating interactive plots using Plotly, with the ability to include an - optional secondary dataset and visualize uncertainty if provided. + Generates interactive plots comparing simulated model output with an optional target dataset and visualizes uncertainty. - Attributes: - ----------- - x : list - The x-axis data points. + Parameters + ---------- + x : list or np.ndarray + X-axis data points. y : list or np.ndarray - The primary y-axis data points representing the simulated model output. - y2 : list or np.ndarray, optional - An optional secondary y-axis data points representing the target dataset against which the model output is compared. + Primary Y-axis data points for simulated model output. cost : float - The cost associated with the model output. + Cost associated with the model output. + y2 : list or np.ndarray, optional + Secondary Y-axis data points for the target dataset (default: None). title : str, optional - The title of the plot. + Title of the plot (default: None). xaxis_title : str, optional - The title for the x-axis. + Title for the x-axis (default: None). yaxis_title : str, optional - The title for the y-axis. + Title for the y-axis (default: None). trace_name : str, optional - The name of the primary trace representing the model output. Defaults to "Simulated". + Name for the primary trace (default: "Simulated"). width : int, optional - The width of the figure in pixels. Defaults to 720. + Width of the figure in pixels (default: 1024). height : int, optional - The height of the figure in pixels. Defaults to 540. - - Example: - ---------- - >>> x_data = [1, 2, 3, 4] - >>> y_simulated = [10, 15, 13, 17] - >>> y_target = [11, 14, 12, 16] - >>> plot = pybop.StandardPlot(x_data, y_simulated, cost=0.05, y2=y_target, - title="Model vs. Target", xaxis_title="X Axis", yaxis_title="Y Axis") - >>> fig = plot() # Generate the figure - >>> fig.show() # Display the figure in a browser + Height of the figure in pixels (default: 576). """ def __init__( @@ -57,6 +46,32 @@ def __init__( width=1024, height=576, ): + """ + Initialize the StandardPlot object with simulation and optional target data. + + Parameters + ---------- + x : list or np.ndarray + X-axis data points. + y : list or np.ndarray + Primary Y-axis data points for simulated model output. + cost : float + Cost associated with the model output. + y2 : list or np.ndarray, optional + Secondary Y-axis data points for target dataset (default: None). + title : str, optional + Plot title (default: None). + xaxis_title : str, optional + X-axis title (default: None). + yaxis_title : str, optional + Y-axis title (default: None). + trace_name : str, optional + Name for the primary trace (default: "Simulated"). + width : int, optional + Figure width in pixels (default: 1024). + height : int, optional + Figure height in pixels (default: 576). + """ self.x = x if isinstance(x, list) else x.tolist() self.y = y self.y2 = y2 @@ -79,26 +94,31 @@ def __init__( @staticmethod def wrap_text(text, width): """ - Wrap text to a specified width. - - Parameters: - ----------- - text: str - Text to be wrapped. - width: int - Width to wrap text to. + Wrap text to a specified width with HTML line breaks. - Returns: + Parameters ---------- + text : str + The text to wrap. + width : int + The width to wrap the text to. + + Returns + ------- str - Wrapped text with HTML line breaks. + The wrapped text. """ wrapped_text = textwrap.fill(text, width=width, break_long_words=False) return wrapped_text.replace("\n", "
") def create_layout(self): """ - Create the layout for the plot. + Create the layout for the Plotly figure. + + Returns + ------- + plotly.graph_objs.Layout + The layout for the Plotly figure. """ return self.go.Layout( title=self.title, @@ -115,7 +135,12 @@ def create_layout(self): def create_traces(self): """ - Create the traces for the plot. + Create traces for the Plotly figure. + + Returns + ------- + list + A list of plotly.graph_objs.Scatter objects to be used as traces. """ traces = [] @@ -149,7 +174,12 @@ def create_traces(self): def __call__(self): """ - Generate the plotly figure. + Generate the Plotly figure. + + Returns + ------- + plotly.graph_objs.Figure + The generated Plotly figure. """ layout = self.create_layout() traces = self.create_traces() @@ -159,24 +189,24 @@ def __call__(self): def quick_plot(params, cost, title="Scatter Plot", width=1024, height=576): """ - Plot the target dataset against the minimised model output. + Quickly plot the target dataset against minimized model output. - Parameters: - ----------- + Parameters + ---------- params : array-like - Optimised parameters. - cost : cost object - Cost object containing the problem, dataset, and signal. + Optimized parameters. + cost : object + Cost object with problem, dataset, and signal attributes. title : str, optional - Title of the plot (default is "Scatter Plot"). + Title of the plot (default: "Scatter Plot"). width : int, optional - Width of the figure in pixels (default is 720). + Width of the figure in pixels (default: 1024). height : int, optional - Height of the figure in pixels (default is 540). + Height of the figure in pixels (default: 576). - Returns: - ---------- - fig : plotly.graph_objs.Figure + Returns + ------- + plotly.graph_objs.Figure The Plotly figure object for the scatter plot. """ From cec7d6460ee946530a56bfc8b963b54157ac2a6d Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 14:48:34 +0000 Subject: [PATCH 078/101] Updts. changelog and contributing, reverts deployment workflow trigger to on release --- .github/workflows/deploy-docs.yaml | 5 ++--- CHANGELOG.md | 1 + CONTRIBUTING.md | 10 ++++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 390b12a9..bd8c9a87 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -1,10 +1,9 @@ name: Deploy Documentation on: - # release: - # types: [created] + release: + types: [created] workflow_dispatch: - push: permissions: contents: write # allow write access for docs deployment diff --git a/CHANGELOG.md b/CHANGELOG.md index 164050d0..478b61c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features +- [#141](https://github.com/pybop-team/PyBOP/pull/141) - Adds documentation with Sphinx and PyData Sphinx Theme. Updates docstrings across package, relocates `costs` and `dataset` to top-level of package. Adds noxfile session and deployment workflow for docs. - [#131](https://github.com/pybop-team/PyBOP/issues/131) - Adds `SciPyDifferentialEvolution` optimiser, adds functionality for user-selectable maximum iteration limit to `SciPyMinimize`, `NLoptOptimize`, and `BaseOptimiser` classes. - [#107](https://github.com/pybop-team/PyBOP/issues/107) - Adds Equivalent Circuit Model (ECM) with examples, Import/Export parameter methods `ParameterSet.import_parameter` and `ParameterSet.export_parameters`, updates default FittingProblem.signal definition to `"Voltage [V]"`, and testing infrastructure - [#127](https://github.com/pybop-team/PyBOP/issues/127) - Adds Windows and macOS runners to the `test_on_push` action diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95a6914f..8b83f204 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,6 +126,16 @@ def plot_great_things(self, x, y, z): This allows people to (1) use PyBOP without ever importing Matplotlib and (2) configure Matplotlib's back-end in their scripts, which _must_ be done before e.g. `pyplot` is first imported. +### Building documentation + +We use [Sphinx](http://www.sphinx-doc.org/en/stable/) to build our documentation. A nox session has been created to reduce the overhead when building the documentation locally. To run this session, type + +```bash +nox -s docs +``` + +This will build the docs using sphinx-autobuild and render them in your browser. + ## Testing All code requires testing. We use the [pytest](https://docs.pytest.org/en/) package for our tests. (These tests typically just check that the code runs without error, and so, are more _debugging_ than _testing_ in a strict sense. Nevertheless, they are very useful to have!) From 32d6766fd3d6913a1b6b71e2c23ee2e1af10e85c Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 16:54:28 +0000 Subject: [PATCH 079/101] Updt deployment to use nox session --- .github/workflows/deploy-docs.yaml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index bd8c9a87..8d460734 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -27,16 +27,13 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -e .[docs] - - - name: Build documentation with sphinx-build - run: | - sphinx-build -b html docs/ docs/_build/html + python -m pip install --upgrade pip nox + - name: Using Python 3.11, build the docs + run: nox -s docs - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs/_build/html + publish_dir: ./tmp/html publish_branch: gh-pages From 33ca4d0464a329812db355ed8a62a29e1fb88955 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 17:01:09 +0000 Subject: [PATCH 080/101] restores on push deployment for testing --- .github/workflows/deploy-docs.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 8d460734..75c2ab2d 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -4,6 +4,7 @@ on: release: types: [created] workflow_dispatch: + push: permissions: contents: write # allow write access for docs deployment From 32f5a2740c6218a680a2f9ab3a6752362857fcb8 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 17:34:16 +0000 Subject: [PATCH 081/101] Remove duplicate docstrings, updt broken links in contributing.md --- CONTRIBUTING.md | 6 +++--- docs/_templates/autoapi/index.rst | 2 ++ docs/contributing.md | 2 ++ docs/user_guide/installation.rst | 2 ++ docs/user_guide/usage.rst | 9 ++------- pybop/models/base_model.py | 14 -------------- pybop/optimisers/base_optimiser.py | 9 --------- pybop/optimisers/nlopt_optimize.py | 9 --------- pybop/parameters/parameter.py | 9 --------- pybop/plotting/plot_convergence.py | 4 ++-- pybop/plotting/plotly_manager.py | 15 --------------- 11 files changed, 13 insertions(+), 68 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b83f204..bc98f5ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ If you'd like to contribute to PyBOP, please have a look at the guidelines below. -## Installation +## Developer-Installation To install PyBOP for development purposes, which includes the testing and plotting dependencies, use the `[all]` flag as demonstrated below: @@ -30,7 +30,7 @@ pip install pre-commit pre-commit install ``` -This would run the checks every time a commit is created locally. The checks will only run on the files modified by that commit, but the checks can be triggered for all the files using - +This would run the checks every time a commit is created locally. The checks will only run on the files modified by that commit, but the checks can be triggered for all the files using, ```bash pre-commit run --all-files @@ -47,7 +47,7 @@ We use [GIT](https://en.wikipedia.org/wiki/Git) and [GitHub](https://en.wikipedi 1. Create an [issue](https://guides.github.com/features/issues/) where new proposals can be discussed before any coding is done. 2. Create a [branch](https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/) of this repo (ideally on your own [fork](https://help.github.com/articles/fork-a-repo/)), where all changes will be made 3. Download the source code onto your local system, by [cloning](https://help.github.com/articles/cloning-a-repository/) the repository (or your fork of the repository). -4. [Install](Developer-Install) PyBOP with the developer options. +4. [Install](#developer-installation) PyBOP with the developer options. 5. [Test](#testing) if your installation worked: `$ pytest --unit -v`. You now have everything you need to start making changes! diff --git a/docs/_templates/autoapi/index.rst b/docs/_templates/autoapi/index.rst index 8cb56d2e..d6075995 100644 --- a/docs/_templates/autoapi/index.rst +++ b/docs/_templates/autoapi/index.rst @@ -1,3 +1,5 @@ +.. _api-reference: + API Reference ============= diff --git a/docs/contributing.md b/docs/contributing.md index 78caf34e..a7ea4b6f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,2 +1,4 @@ +(contributing-guide) = + ```{include} ../CONTRIBUTING.md ``` diff --git a/docs/user_guide/installation.rst b/docs/user_guide/installation.rst index 1204d725..08f7ea00 100644 --- a/docs/user_guide/installation.rst +++ b/docs/user_guide/installation.rst @@ -1,3 +1,5 @@ +.. _installation: + Installation Guide for PyBOP ***************************** diff --git a/docs/user_guide/usage.rst b/docs/user_guide/usage.rst index 4e2b2418..14c7f678 100644 --- a/docs/user_guide/usage.rst +++ b/docs/user_guide/usage.rst @@ -46,16 +46,11 @@ These examples are also available on our `GitHub repository >> plotly_manager = PlotlyManager() From c34a9ea7b208fe6d723d05d0d32fce727b34136b Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 17:40:37 +0000 Subject: [PATCH 082/101] Remove treat warnings as errors --- noxfile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index fbb4912d..8116a3e0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -60,7 +60,6 @@ def docs(session): "auto", "-b", "html", - "-W", "--keep-going", ".", f"{envbindir}/../tmp/html", From c2bcc998ca6c585af351e71de5e031bab605e823 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Dec 2023 17:56:56 +0000 Subject: [PATCH 083/101] chore: update pre-commit hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.1.6 → v0.1.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.6...v0.1.7) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bf4288f1..732d6cc3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.1.6" + rev: "v0.1.7" hooks: - id: ruff args: [--fix, --show-fixes] From 3a0b2b4d5fab3261838428862b83b5ccd30e8f83 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 17:57:07 +0000 Subject: [PATCH 084/101] relocate _build directory for deployable access --- .github/workflows/deploy-docs.yaml | 2 +- noxfile.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 75c2ab2d..52d5b057 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -36,5 +36,5 @@ jobs: uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./tmp/html + publish_dir: ./docs/_build/html publish_branch: gh-pages diff --git a/noxfile.py b/noxfile.py index 8116a3e0..f21a4f13 100644 --- a/noxfile.py +++ b/noxfile.py @@ -62,5 +62,5 @@ def docs(session): "html", "--keep-going", ".", - f"{envbindir}/../tmp/html", + "_build/html", ) From 960954ac68e6ea724b99cb1817c48300f28baa87 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 11 Dec 2023 21:12:03 +0000 Subject: [PATCH 085/101] Add UKRI and Faraday Institution to contributors --- .all-contributorsrc | 18 ++++++++++++++++++ README.md | 2 ++ assets/UKRI.png | Bin 0 -> 8689 bytes assets/UKRI.svg | 1 + 4 files changed, 21 insertions(+) create mode 100644 assets/UKRI.png create mode 100644 assets/UKRI.svg diff --git a/.all-contributorsrc b/.all-contributorsrc index 56d2915e..304a73ce 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -61,6 +61,24 @@ "contributions": [ "review" ] + }, + { + "login": "FaradayInstitution", + "name": "Faraday Institution", + "avatar_url": "/assets/UKRI.svg", + "profile": "https://faraday.ac.uk", + "contributions": [ + "financial" + ] + }, + { + "login": "UKRI", + "name": "UK Research and Innovation", + "avatar_url": "/assets/UKRI.svg", + "profile": "https://www.ukri.org/", + "contributions": [ + "financial" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 1420aa4e..bf5f66c6 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d David Howey
David Howey

🤔 🧑‍🏫 Martin Robinson
Martin Robinson

🤔 🧑‍🏫 👀 Ferran Brosa Planella
Ferran Brosa Planella

👀 + UKRI
UKRI

💵 + Faraday Institution
Faraday Institution

💵 diff --git a/assets/UKRI.png b/assets/UKRI.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a0ba3e607358717c5cc0e6e5bb4ae8b9663355 GIT binary patch literal 8689 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 literal 0 HcmV?d00001 diff --git a/assets/UKRI.svg b/assets/UKRI.svg new file mode 100644 index 00000000..a16a21cf --- /dev/null +++ b/assets/UKRI.svg @@ -0,0 +1 @@ + From a807a56dfee1ac62f63bbc9918ae51336aa9be4e Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 12 Dec 2023 09:25:18 +0000 Subject: [PATCH 086/101] Tidy conf.py --- docs/conf.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 55f6b90c..c66713fd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,12 +27,6 @@ "_extension.gallery_directive", # For extension examples and demos "myst_parser", - # "ablog", - # "jupyter_sphinx", - # "nbsphinx", - # "numpydoc", - # "sphinx_togglebutton", - # "jupyterlite_sphinx", "sphinx_favicon", ] From 7aaa4a84e1bbfb2a221ee67bdda0771107d1435f Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 13 Dec 2023 09:22:52 +0000 Subject: [PATCH 087/101] Updt noxfile for notebooks, optimisation.py change to _optimisation.py, updt .gitignore, trigger deploy on release only --- .github/workflows/deploy-docs.yaml | 1 - .gitignore | 2 +- .../parameters/fit_ecm_parameters.json | 24 ------------------- noxfile.py | 2 +- pybop/__init__.py | 2 +- pybop/{optimisation.py => _optimisation.py} | 0 6 files changed, 3 insertions(+), 28 deletions(-) delete mode 100644 examples/scripts/parameters/fit_ecm_parameters.json rename pybop/{optimisation.py => _optimisation.py} (100%) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 52d5b057..bb64d714 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -4,7 +4,6 @@ on: release: types: [created] workflow_dispatch: - push: permissions: contents: write # allow write access for docs deployment diff --git a/.gitignore b/.gitignore index b6dae2f7..3fae572d 100644 --- a/.gitignore +++ b/.gitignore @@ -306,4 +306,4 @@ $RECYCLE.BIN/ .vscode/* # Output JSON files -*fit_ecm_parameters.json +**/fit_ecm_parameters.json diff --git a/examples/scripts/parameters/fit_ecm_parameters.json b/examples/scripts/parameters/fit_ecm_parameters.json deleted file mode 100644 index 63377470..00000000 --- a/examples/scripts/parameters/fit_ecm_parameters.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "chemistry": "ecm", - "Initial SoC": 0.5, - "Initial temperature [K]": 298.15, - "Cell capacity [A.h]": 5, - "Nominal cell capacity [A.h]": 5, - "Ambient temperature [K]": 298.15, - "Current function [A]": 5, - "Upper voltage cut-off [V]": 4.2, - "Lower voltage cut-off [V]": 3.0, - "Cell thermal mass [J/K]": 1000, - "Cell-jig heat transfer coefficient [W/K]": 10, - "Jig thermal mass [J/K]": 500, - "Jig-air heat transfer coefficient [W/K]": 10, - "Open-circuit voltage [V]": "Unable to write value to JSON file", - "R0 [Ohm]": 0.000944987987318333, - "Element-1 initial overpotential [V]": 0, - "Element-2 initial overpotential [V]": 0, - "R1 [Ohm]": 0.0002590935163068119, - "R2 [Ohm]": 0.0003, - "C1 [F]": 10000, - "C2 [F]": 5000, - "Entropic change [V/K]": 0.0004 -} diff --git a/noxfile.py b/noxfile.py index f21a4f13..ddb7bd19 100644 --- a/noxfile.py +++ b/noxfile.py @@ -29,7 +29,7 @@ def notebooks(session): """Run the examples tests for Jupyter notebooks.""" session.run_always("pip", "install", "-e", ".[all]") session.install("pytest", "nbmake") - session.run("pytest", "--nbmake", "examples/", external=True) + session.run("pytest", "--nbmake", "--examples", "examples/", external=True) @nox.session diff --git a/pybop/__init__.py b/pybop/__init__.py index 045a0fd5..9c9a23e1 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -43,7 +43,7 @@ # # Main optimisation class # -from .optimisation import Optimisation +from ._optimisation import Optimisation # # Optimiser class diff --git a/pybop/optimisation.py b/pybop/_optimisation.py similarity index 100% rename from pybop/optimisation.py rename to pybop/_optimisation.py From 6ac18801ef590ccfadf8d2ea0bbf918a13e113bc Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 14 Dec 2023 10:07:15 +0000 Subject: [PATCH 088/101] Automate version linking --- .gitignore | 3 +++ docs/conf.py | 9 ++++++--- docs/index.md | 2 -- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3fae572d..bc3caa2c 100644 --- a/.gitignore +++ b/.gitignore @@ -175,6 +175,9 @@ instance/ # Sphinx documentation docs/_build/ +docs/examples/generated/ +docs/api/ +warnings.txt # PyBuilder .pybuilder/ diff --git a/docs/conf.py b/docs/conf.py index c66713fd..9524d11f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -2,16 +2,19 @@ # -- Path setup -------------------------------------------------------------- +import os import sys -from pathlib import Path -sys.path.append(str(Path(".").resolve())) +root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, root_path) + +from pybop.version import __version__ # noqa: E402 # -- Project information ----------------------------------------------------- project = "PyBOP" copyright = "2023, The PyBOP Team" author = "The PyBOP Team" -release = "v23.11" +release = f"v{__version__}" # -- General configuration --------------------------------------------------- extensions = [ diff --git a/docs/index.md b/docs/index.md index 622dd79d..d49d0a75 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,8 +10,6 @@ html_theme.sidebar_secondary.remove: true Welcome to PyBOP, a Python package dedicated to the optimization and parameterization of battery models. PyBOP is designed to streamline your workflow, whether you are conducting academic research, working in industry, or simply interested in battery technology and modelling. -**Version: v23.11** - ```{gallery-grid} :grid-columns: 1 2 2 2 From af10b312936d74101bbdb0b6ff0ff1cd552fb53a Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 14 Dec 2023 11:44:11 +0000 Subject: [PATCH 089/101] Updt. landing page grid and header names, docstring typos, contributing render method --- CONTRIBUTING.md | 15 ++++++------- docs/Contributing.md | 8 +++++++ docs/contributing.md | 4 ---- docs/index.md | 21 +++++++++--------- docs/{user_guide => }/installation.rst | 6 ++--- .../{user_guide/usage.rst => quick_start.rst} | 6 +++-- docs/user_guide/index.md | 22 ------------------- examples/costs/standalone.py | 2 +- noxfile.py | 2 +- pybop/_optimisation.py | 20 ++++++++--------- pybop/optimisers/nlopt_optimize.py | 4 ++-- pybop/optimisers/pints_optimisers.py | 22 +++++++++---------- pybop/parameters/priors.py | 6 ++--- pybop/plotting/plot_convergence.py | 4 ++-- pybop/plotting/plot_cost2d.py | 4 ++-- 15 files changed, 65 insertions(+), 81 deletions(-) create mode 100644 docs/Contributing.md delete mode 100644 docs/contributing.md rename docs/{user_guide => }/installation.rst (95%) rename docs/{user_guide/usage.rst => quick_start.rst} (87%) delete mode 100644 docs/user_guide/index.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc98f5ec..055221c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to PyBOP +# Contributing If you'd like to contribute to PyBOP, please have a look at the guidelines below. @@ -82,7 +82,7 @@ python -m pip install pre-commit pre-commit run ruff ``` -ruff is configured inside the file `pre-commit-config.yaml`, allowing us to ignore some errors. If you think this should be added or removed, please submit an [issue](#issues) +ruff is configured inside the file `pre-commit-config.yaml`, allowing us to ignore some errors. If you think this should be added or removed, please submit an [issue](https://guides.github.com/features/issues/). When you commit your changes they will be checked against ruff automatically (see [Pre-commit checks](#pre-commit-checks)). @@ -293,7 +293,7 @@ Configuration files: setup.py ``` -Note that this file must be kept in sync with the version number in [pybop/**init**.py](pybop/__init__.py). +Note that this file must be kept in sync with the version number in [pybop/**init**.py](https://github.com/pybop-team/PyBOP/blob/develop/pybop/__init__.py). ### Continuous Integration using GitHub actions @@ -316,11 +316,10 @@ Code coverage (how much of our code is seen by the (Linux) unit tests) is tested GitHub does some magic with particular filenames. In particular: -- The first page people see when they go to [our GitHub page](https://github.com/pybop-team/PyBOP) displays the contents of [README.md](README.md), which is written in the [Markdown](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) format. Some guidelines can be found [here](https://help.github.com/articles/about-readmes/). -- The license for using PyBOP is stored in [LICENSE](LICENSE.txt), and [automatically](https://help.github.com/articles/adding-a-license-to-a-repository/) linked to by GitHub. -- This file, [CONTRIBUTING.md](CONTRIBUTING.md) is recognised as the contribution guidelines and a link is [automatically](https://github.com/blog/1184-contributing-guidelines) displayed when new issues or pull requests are created. +- The first page people see when they go to [our GitHub page](https://github.com/pybop-team/PyBOP) displays the contents of [README.md](https://github.com/pybop-team/PyBOP/blob/develop/README.md), which is written in the [Markdown](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) format. Some guidelines can be found [here](https://help.github.com/articles/about-readmes/). +- The license for using PyBOP is stored in [LICENSE](https://github.com/pybop-team/PyBOP/blob/develop/LICENSE), and [automatically](https://help.github.com/articles/adding-a-license-to-a-repository/) linked to by GitHub. +- This file, [CONTRIBUTING.md](https://github.com/pybop-team/PyBOP/blob/develop/CONTRIBUTING.md) is recognised as the contribution guidelines and a link is [automatically](https://github.com/blog/1184-contributing-guidelines) displayed when new issues or pull requests are created. ## Acknowledgements -This CONTRIBUTING.md file, along with large sections of the code infrastructure, -was copied from the excellent [Pints repo](https://github.com/pints-team/pints), and [PyBaMM repo](https://github.com/pybamm-team/PyBaMM) +This CONTRIBUTING.md file, along with large sections of the code infrastructure, was copied from the excellent [Pints repo](https://github.com/pints-team/pints), and [PyBaMM repo](https://github.com/pybamm-team/PyBaMM) diff --git a/docs/Contributing.md b/docs/Contributing.md new file mode 100644 index 00000000..ab90a2bb --- /dev/null +++ b/docs/Contributing.md @@ -0,0 +1,8 @@ +--- +myst: + html_meta: + "description lang=en": | + Contributing docs.. +--- + +```{include} ../CONTRIBUTING.md diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index a7ea4b6f..00000000 --- a/docs/contributing.md +++ /dev/null @@ -1,4 +0,0 @@ -(contributing-guide) = - -```{include} ../CONTRIBUTING.md -``` diff --git a/docs/index.md b/docs/index.md index d49d0a75..d45182ae 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,24 +13,25 @@ Welcome to PyBOP, a Python package dedicated to the optimization and parameteriz ```{gallery-grid} :grid-columns: 1 2 2 2 -- header: "{fab}`bootstrap;pst-color-primary` API Documentation" - content: "Get detailed information on functions, classes, and modules that allow you to fully leverage the power of PyBOP in your own projects." - link: "api/index.html" - header: "{fas}`bolt;pst-color-primary` Installation" content: "Setting up PyBOP is straightforward. Follow our step-by-step guide to install PyBOP on your system." - link: "user_guide/installation.html" -- header: "{fas}`circle-half-stroke;pst-color-primary` Usage" - content: "Discover how to use PyBOP effectively with our usage examples. From basic tasks to advanced features, learn how to solve real-world problems with PyBOP." - link: "user_guide/index.html" + link: "installation.html" +- header: "{fas}`circle-half-stroke;pst-color-primary` Quick Start" + content: "Discover how to use PyBOP effectively. From basic tasks to advanced features, learn how to solve real-world problems with PyBOP." + link: "quick_start.html" - header: "{fab}`python;pst-color-primary` Contributing" content: "Contribute to the PyBOP project and become a part of our growing community." - link: "contributing.html" + link: "Contributing.html" +- header: "{fab}`bootstrap;pst-color-primary` API Reference" + content: "Get detailed information on functions, classes, and modules that allow you to fully leverage the power of PyBOP in your own projects." + link: "api/index.html" ``` ```{toctree} :maxdepth: 2 :hidden: -user_guide/index -contributing +installation +quick_start +Contributing ``` diff --git a/docs/user_guide/installation.rst b/docs/installation.rst similarity index 95% rename from docs/user_guide/installation.rst rename to docs/installation.rst index 08f7ea00..8fa9cdd1 100644 --- a/docs/user_guide/installation.rst +++ b/docs/installation.rst @@ -1,6 +1,6 @@ .. _installation: -Installation Guide for PyBOP +Installation ***************************** PyBOP is a versatile Python package designed for optimization and parameterization of battery models. Follow the instructions below to install PyBOP and set up your environment to begin utilizing its capabilities. @@ -52,7 +52,7 @@ For Developers If you are installing PyBOP for development purposes, such as contributing to the project, please ensure that you follow the guidelines outlined in the contributing guide. It includes additional steps that might be necessary for setting up a development environment, including the installation of dependencies and setup of pre-commit hooks. -`Contributing Guide <../contributing.html>`_ +`Contributing Guide <../Contributing.html>`_ Further Assistance ------------------ @@ -64,5 +64,5 @@ Next Steps After installing PyBOP, you might want to: -* Explore the `Quick Start Guide `_ to begin using PyBOP. +* Explore the `Quick Start Guide `_ to begin using PyBOP. * Check out the `API Reference <../api/index.html>`_ for detailed information on PyBOP's programming interface. diff --git a/docs/user_guide/usage.rst b/docs/quick_start.rst similarity index 87% rename from docs/user_guide/usage.rst rename to docs/quick_start.rst index 14c7f678..e8ebbc0c 100644 --- a/docs/user_guide/usage.rst +++ b/docs/quick_start.rst @@ -1,4 +1,4 @@ -Quick Start Guide for PyBOP +Quick Start **************************** Welcome to the Quick Start Guide for PyBOP. This guide will help you get up and running with PyBOP. If you're new to PyBOP, we recommend you start here to learn the basics and get a feel for the package. @@ -53,4 +53,6 @@ Support and Contributions If you encounter any issues or have questions as you start using PyBOP, don't hesitate to reach out to our community: -- **Contributions**: Interested in contributing to PyBOP? Check out our [contributing-guide](contributing-guide) for guidelines on how to contribute. +- **GitHub Issues**: Report bugs or request new features by opening an `Issue `_ +- **GitHub Discussions**: Post your questions or feedback on our `GitHub Discussions `_ +- **Contributions**: Interested in contributing to PyBOP? Check out our `Contributing Guide <../Contributing.html>`_ for guidelines. diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md deleted file mode 100644 index 0a5496f4..00000000 --- a/docs/user_guide/index.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -myst: - html_meta: - "description lang=en": | - Documentation for PyBOP users. ---- - -# User Guide - -```{toctree} -:caption: Installation - -installation - -``` - -```{toctree} -:caption: Usage - -usage - -``` diff --git a/examples/costs/standalone.py b/examples/costs/standalone.py index f4fa74f9..9836a7e7 100644 --- a/examples/costs/standalone.py +++ b/examples/costs/standalone.py @@ -6,7 +6,7 @@ class StandaloneCost(pybop.BaseCost): """ A standalone cost function example that inherits from pybop.BaseCost. - This class represents a simple cost function without a problem obkect, used for demonstration purposes. + This class represents a simple cost function without a problem object, used for demonstration purposes. It is a quadratic function of one variable with a constant term, defined by the formula: cost(x) = x^2 + 42. diff --git a/noxfile.py b/noxfile.py index ddb7bd19..9ab8a051 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,7 +1,7 @@ import nox # nox options -nox.options.reuse_existing_virtualenvs = True +nox.options.reuse_existing_virtualenvs = False @nox.session diff --git a/pybop/_optimisation.py b/pybop/_optimisation.py index d18c6a43..8052298d 100644 --- a/pybop/_optimisation.py +++ b/pybop/_optimisation.py @@ -5,16 +5,16 @@ class Optimisation: """ - A class for conducting optimization using PyBOP or PINTS optimizers. + A class for conducting optimization using PyBOP or PINTS optimisers. Parameters ---------- - cost : pints.ErrorMeasure or pints.LogPDF - An objective function to be optimized, which can be either a PINTS error measure or log PDF. - optimiser : pints.Optimiser or subclass of pybop.BaseOptimizer, optional - An optimizer from either the PINTS or PyBOP framework to perform the optimization (default: None). + cost : pybop.BaseCost or pints.ErrorMeasure + An objective function to be optimized, which can be either a pybop.Cost or PINTS error measure + optimiser : pybop.Optimiser or subclass of pybop.BaseOptimiser, optional + An optimiser from either the PINTS or PyBOP framework to perform the optimization (default: None). sigma0 : float or sequence, optional - Initial step size or standard deviation for the optimizer (default: None). + Initial step size or standard deviation for the optimiser (default: None). verbose : bool, optional If True, the optimization progress is printed (default: False). @@ -27,7 +27,7 @@ class Optimisation: n_parameters : int Number of parameters in the optimization problem. sigma0 : float or sequence - Initial step size or standard deviation for the optimizer. + Initial step size or standard deviation for the optimiser. log : list Log of the optimization process. """ @@ -146,7 +146,7 @@ def run(self): def _run_pybop(self): """ - Internal method to run the optimization using a PyBOP optimizer. + Internal method to run the optimization using a PyBOP optimiser. Returns ------- @@ -167,7 +167,7 @@ def _run_pybop(self): def _run_pints(self): """ - Internal method to run the optimization using a PINTS optimizer. + Internal method to run the optimization using a PINTS optimiser. Returns ------- @@ -362,7 +362,7 @@ def f_guessed_tracking(self): def set_f_guessed_tracking(self, use_f_guessed=False): """ - Set the method used to track the optimizer progress. + Set the method used to track the optimiser progress. Credit: PINTS Parameters diff --git a/pybop/optimisers/nlopt_optimize.py b/pybop/optimisers/nlopt_optimize.py index ae2235e2..d6b4df2b 100644 --- a/pybop/optimisers/nlopt_optimize.py +++ b/pybop/optimisers/nlopt_optimize.py @@ -83,7 +83,7 @@ def cost_wrapper(x, grad): def needs_sensitivities(self): """ - Indicates if the optimizer requires gradient information for the cost function. + Indicates if the optimiser requires gradient information for the cost function. Returns ------- @@ -94,7 +94,7 @@ def needs_sensitivities(self): def name(self): """ - Returns the name of this optimizer instance. + Returns the name of this optimiser instance. Returns ------- diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 765b43c9..7b70e97f 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -5,8 +5,8 @@ class GradientDescent(pints.GradientDescent): """ Implements a simple gradient descent optimization algorithm. - This class extends the gradient descent optimizer from the PINTS library, designed - to minimize a scalar function of one or more variables. Note that this optimizer + This class extends the gradient descent optimiser from the PINTS library, designed + to minimize a scalar function of one or more variables. Note that this optimiser does not support boundary constraints. Parameters @@ -16,7 +16,7 @@ class GradientDescent(pints.GradientDescent): sigma0 : float, optional Initial step size (default is 0.1). bounds : sequence or ``Bounds``, optional - Ignored by this optimizer, provided for API consistency. + Ignored by this optimiser, provided for API consistency. See Also -------- @@ -35,9 +35,9 @@ class Adam(pints.Adam): """ Implements the Adam optimization algorithm. - This class extends the Adam optimizer from the PINTS library, which combines + This class extends the Adam optimiser from the PINTS library, which combines ideas from RMSProp and Stochastic Gradient Descent with momentum. Note that - this optimizer does not support boundary constraints. + this optimiser does not support boundary constraints. Parameters ---------- @@ -46,7 +46,7 @@ class Adam(pints.Adam): sigma0 : float, optional Initial step size (default is 0.1). bounds : sequence or ``Bounds``, optional - Ignored by this optimizer, provided for API consistency. + Ignored by this optimiser, provided for API consistency. See Also -------- @@ -65,7 +65,7 @@ class IRPropMin(pints.IRPropMin): """ Implements the iRpropMin optimization algorithm. - This class inherits from the PINTS IRPropMin class, which is an optimizer that + This class inherits from the PINTS IRPropMin class, which is an optimiser that uses resilient backpropagation with weight-backtracking. It is designed to handle problems with large plateaus, noisy gradients, and local minima. @@ -97,7 +97,7 @@ class PSO(pints.PSO): """ Implements a particle swarm optimization (PSO) algorithm. - This class extends the PSO optimizer from the PINTS library. PSO is a + This class extends the PSO optimiser from the PINTS library. PSO is a metaheuristic optimization method inspired by the social behavior of birds flocking or fish schooling, suitable for global optimization problems. @@ -129,7 +129,7 @@ class SNES(pints.SNES): """ Implements the stochastic natural evolution strategy (SNES) optimization algorithm. - Inheriting from the PINTS SNES class, this optimizer is an evolutionary algorithm + Inheriting from the PINTS SNES class, this optimiser is an evolutionary algorithm that evolves a probability distribution on the parameter space, guiding the search for the optimum based on the natural gradient of expected fitness. @@ -159,7 +159,7 @@ def __init__(self, x0, sigma0=0.1, bounds=None): class XNES(pints.XNES): """ - Implements the Exponential Natural Evolution Strategy (XNES) optimizer from PINTS. + Implements the Exponential Natural Evolution Strategy (XNES) optimiser from PINTS. XNES is an evolutionary algorithm that samples from a multivariate normal distribution, which is updated iteratively to fit the distribution of successful solutions. @@ -189,7 +189,7 @@ def __init__(self, x0, sigma0=0.1, bounds=None): class CMAES(pints.CMAES): """ - Adapter for the Covariance Matrix Adaptation Evolution Strategy (CMA-ES) optimizer in PINTS. + Adapter for the Covariance Matrix Adaptation Evolution Strategy (CMA-ES) optimiser in PINTS. CMA-ES is an evolutionary algorithm for difficult non-linear non-convex optimization problems. It adapts the covariance matrix of a multivariate normal distribution to capture the shape of the cost landscape. diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index 088e584d..482baff4 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -70,7 +70,7 @@ def rvs(self, size): Raises ------ ValueError - If the size parameter is not positive. + If the size parameter is negative. """ if size < 0: raise ValueError("size must be positive") @@ -153,7 +153,7 @@ def rvs(self, size): Raises ------ ValueError - If the size parameter is not positive. + If the size parameter is negative. """ if size < 0: raise ValueError("size must be positive") @@ -235,7 +235,7 @@ def rvs(self, size): Raises ------ ValueError - If the size parameter is not positive. + If the size parameter is negative. """ if size < 0: raise ValueError("size must be positive") diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py index d1853ab4..81e9ef65 100644 --- a/pybop/plotting/plot_convergence.py +++ b/pybop/plotting/plot_convergence.py @@ -7,7 +7,7 @@ def plot_convergence( """ Plot the convergence of the optimisation algorithm. - Parameters: + Parameters ----------- optim : optimisation object Optimisation object containing the cost function and optimiser. @@ -18,7 +18,7 @@ def plot_convergence( title : str, optional Title of the plot (default is "Convergence"). - Returns: + Returns --------- fig : plotly.graph_objs.Figure The Plotly figure object for the convergence plot. diff --git a/pybop/plotting/plot_cost2d.py b/pybop/plotting/plot_cost2d.py index f8e7b187..ee43d9d0 100644 --- a/pybop/plotting/plot_cost2d.py +++ b/pybop/plotting/plot_cost2d.py @@ -15,7 +15,7 @@ def plot_cost2d(cost, bounds=None, optim=None, steps=10): bounds : numpy.ndarray, optional A 2x2 array specifying the [min, max] bounds for each parameter. If None, uses `get_param_bounds`. optim : object, optional - An optimizer instance which, if provided, overlays its specific trace on the plot. + An optimiser instance which, if provided, overlays its specific trace on the plot. steps : int, optional The number of intervals to divide the parameter space into along each dimension (default is 10). @@ -94,7 +94,7 @@ def create_figure(x, y, z, bounds, params, optim): params : iterable An iterable of parameter objects with 'name' attributes for axis labeling. optim : object - An optimizer instance with 'log' and 'x0' attributes for plotting traces. + An optimiser instance with 'log' and 'x0' attributes for plotting traces. Returns ------- From a51d720791afe31a5e87408000d70661d8d87be0 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 14 Dec 2023 12:07:59 +0000 Subject: [PATCH 090/101] Add readthedocs configuration --- pybop/readthedocs.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 pybop/readthedocs.yaml diff --git a/pybop/readthedocs.yaml b/pybop/readthedocs.yaml new file mode 100644 index 00000000..aaeb9cfc --- /dev/null +++ b/pybop/readthedocs.yaml @@ -0,0 +1,20 @@ +version: 2 + +build: + os: ubuntu-20.04 + tools: + python: "3.11" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +formats: + - htmlzip + - pdf + - epub + +python: + install: + - method: pip + path: .[docs] From a0d44e55e239a889a529245c831e9536e6bc533f Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 14 Dec 2023 14:02:17 +0000 Subject: [PATCH 091/101] Add version switcher, updt header location, link rtd versions --- docs/conf.py | 17 +++++++++++++++++ noxfile.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 9524d11f..11588cda 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,6 +45,10 @@ autoapi_member_order = "groupwise" # -- Options for HTML output ------------------------------------------------- +# Define the json_url for our version switcher. +json_url = "http://pybop-docs.readthedocs.io/en/latest/_static/switcher.json" +version_match = os.environ.get("READTHEDOCS_VERSION") + html_theme = "pydata_sphinx_theme" html_show_sourcelink = False html_title = "PyBOP Documentation" @@ -66,7 +70,20 @@ ], "search_bar_text": "Search the docs...", "show_prev_next": False, + "navbar_align": "content", + "navbar_center": ["navbar-nav", "version-switcher"], + "show_version_warning_banner": True, + "switcher": { + "json_url": json_url, + "version_match": version_match, + }, + "footer_start": ["copyright"], + "footer_center": ["sphinx-version"], } html_static_path = ["_static"] html_js_files = ["custom-icon.js"] + +# -- Language ---------------------------------------------------------------- + +language = "en" diff --git a/noxfile.py b/noxfile.py index 9ab8a051..ddb7bd19 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,7 +1,7 @@ import nox # nox options -nox.options.reuse_existing_virtualenvs = False +nox.options.reuse_existing_virtualenvs = True @nox.session From c80e0d8fe25fd193d79cb298baf15713f848fb09 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 14 Dec 2023 14:16:55 +0000 Subject: [PATCH 092/101] bugfix - readthedocs.yaml location --- pybop/readthedocs.yaml => readthedocs.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pybop/readthedocs.yaml => readthedocs.yaml (100%) diff --git a/pybop/readthedocs.yaml b/readthedocs.yaml similarity index 100% rename from pybop/readthedocs.yaml rename to readthedocs.yaml From 351738bf0431f979912d01a33c2d3a9c85f7242d Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 14 Dec 2023 14:55:52 +0000 Subject: [PATCH 093/101] Add switcher.json --- docs/_static/switcher.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/_static/switcher.json diff --git a/docs/_static/switcher.json b/docs/_static/switcher.json new file mode 100644 index 00000000..682d1999 --- /dev/null +++ b/docs/_static/switcher.json @@ -0,0 +1,12 @@ +[ + { + "version": "latest", + "url": "https://pybop-docs.readthedocs.io/en/latest/", + "preferred": true + }, + { + "name": "v23.11 (stable)", + "version": "v23.11", + "url": "https://pybop-docs.readthedocs.io/en/latest/" + } + ] From 226670a5eb6024b5dd4ac1a063931368ecb2d4e1 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 14 Dec 2023 15:01:00 +0000 Subject: [PATCH 094/101] Remove gh-pages deployment in favour of rtd --- .github/workflows/deploy-docs.yaml | 39 --------------------- pybop/readthedocs.yaml => .readthedocs.yaml | 0 2 files changed, 39 deletions(-) delete mode 100644 .github/workflows/deploy-docs.yaml rename pybop/readthedocs.yaml => .readthedocs.yaml (100%) diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml deleted file mode 100644 index bb64d714..00000000 --- a/.github/workflows/deploy-docs.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy Documentation - -on: - release: - types: [created] - workflow_dispatch: - -permissions: - contents: write # allow write access for docs deployment - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - steps: - - name: Check out PyBOP repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod - - - name: Set up Python 3.11 - id: setup-python - uses: actions/setup-python@v4 - with: - python-version: 3.11 - cache: 'pip' - cache-dependency-path: setup.py - - - name: Install dependencies - run: | - python -m pip install --upgrade pip nox - - name: Using Python 3.11, build the docs - run: nox -s docs - - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs/_build/html - publish_branch: gh-pages diff --git a/pybop/readthedocs.yaml b/.readthedocs.yaml similarity index 100% rename from pybop/readthedocs.yaml rename to .readthedocs.yaml From 3ba9a6aa295c5d68e5718187ae1a955dec2e11a4 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 14 Dec 2023 15:32:43 +0000 Subject: [PATCH 095/101] Add contributing links, add print version install verification --- CONTRIBUTING.md | 4 ++-- docs/conf.py | 4 ++-- docs/installation.rst | 19 +++++++++++-------- docs/quick_start.rst | 2 +- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 055221c9..9fecf0c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,7 +17,7 @@ pip install -e .[all] ``` ## Pre-commit checks -Before you commit any code, please perform the following checks: +Before you commit any code, please perform the following checks using [Nox](https://nox.thea.codes/en/stable/index.html): - [All tests pass](#testing): `$ nox -s unit` @@ -128,7 +128,7 @@ This allows people to (1) use PyBOP without ever importing Matplotlib and (2) co ### Building documentation -We use [Sphinx](http://www.sphinx-doc.org/en/stable/) to build our documentation. A nox session has been created to reduce the overhead when building the documentation locally. To run this session, type +We use [Sphinx](http://www.sphinx-doc.org/en/stable/) to build our documentation. A [Nox](https://nox.thea.codes/en/stable/index.html) session has been created to reduce the overhead when building the documentation locally. To run this session, type ```bash nox -s docs diff --git a/docs/conf.py b/docs/conf.py index 11588cda..14f8a0b1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -73,12 +73,12 @@ "navbar_align": "content", "navbar_center": ["navbar-nav", "version-switcher"], "show_version_warning_banner": True, + "footer_start": ["copyright"], + "footer_center": ["sphinx-version"], "switcher": { "json_url": json_url, "version_match": version_match, }, - "footer_start": ["copyright"], - "footer_center": ["sphinx-version"], } html_static_path = ["_static"] diff --git a/docs/installation.rst b/docs/installation.rst index 8fa9cdd1..3c0080c1 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -12,13 +12,13 @@ The simplest method to install PyBOP is using pip. Run the following command in .. code-block:: console - $ pip install pybop + pip install pybop This command will download and install the latest stable version of PyBOP. If you want to install a specific version, you can specify the version number using the following command: .. code-block:: console - $ pip install pybop==23.11 + pip install pybop==23.11 Installing the Development Version ---------------------------------- @@ -27,7 +27,7 @@ If you're interested in the cutting-edge features and want to try out the latest .. code-block:: console - $ pip install git+https://github.com/pybop-team/PyBOP.git@develop + pip install git+https://github.com/pybop-team/PyBOP.git@develop Please note that the development version may be less stable than the official releases. @@ -38,21 +38,24 @@ For those who prefer to install PyBOP from a local clone of the repository or wi .. code-block:: console - $ pip install -e "path/to/pybop" + pip install -e "path/to/pybop" In editable mode, changes you make to the source code will immediately affect the PyBOP installation without the need for reinstallation. Verifying Installation ---------------------- -To verify that PyBOP has been installed successfully, try running one of the provided example scripts included in the documentation or repository. If the example executes without any errors, PyBOP is ready to use. +To verify that PyBOP has been installed successfully, try running one of the provided example scripts included in the documentation or repository. If the example executes without any errors, PyBOP is ready to use. Alternatively, you can run the following command in your terminal to check the version of PyBOP that is installed: + +.. code-block:: console + + python -c "import pybop; print(pybop.__version__)" + For Developers -------------- -If you are installing PyBOP for development purposes, such as contributing to the project, please ensure that you follow the guidelines outlined in the contributing guide. It includes additional steps that might be necessary for setting up a development environment, including the installation of dependencies and setup of pre-commit hooks. - -`Contributing Guide <../Contributing.html>`_ +If you are installing PyBOP for development purposes, such as contributing to the project, please ensure that you follow the guidelines outlined in the `Contributing Guide <../Contributing.html>`_. It includes additional steps that might be necessary for setting up a development environment, including the installation of dependencies and setup of pre-commit hooks. Further Assistance ------------------ diff --git a/docs/quick_start.rst b/docs/quick_start.rst index e8ebbc0c..683c82b4 100644 --- a/docs/quick_start.rst +++ b/docs/quick_start.rst @@ -14,7 +14,7 @@ To begin using PyBOP: .. code-block:: console - $ pip install pybop + pip install pybop For detailed installation instructions, including how to install specific versions or from source, see the :ref:`installation` section. From 14881c3afc24244de5f1b26e921b55c525f7ff2b Mon Sep 17 00:00:00 2001 From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com> Date: Thu, 14 Dec 2023 17:50:27 +0000 Subject: [PATCH 096/101] Reformat signal into a list (#147) * Make Dataset contain a dictionary * Extend signal to a list of strings * Align with set_max_iterations * Turn signal into list * Parameterise and test multiple signals * Update CHANGELOG.md * Relax test_multiple_signals assertion * Create test_dataset.py * Update Dataset to accept PyBaMM Solution * Update test_problem.py * Move signal to BaseProblem * Add test on signal type --- CHANGELOG.md | 1 + examples/notebooks/spm_nlopt.ipynb | 12 +- examples/scripts/ecm_CMAES.py | 13 +- examples/scripts/spm_CMAES.py | 14 ++- examples/scripts/spm_IRPropMin.py | 13 +- examples/scripts/spm_SNES.py | 12 +- examples/scripts/spm_XNES.py | 13 +- examples/scripts/spm_adam.py | 14 ++- examples/scripts/spm_descent.py | 14 ++- examples/scripts/spm_nlopt.py | 14 ++- examples/scripts/spm_pso.py | 13 +- examples/scripts/spm_scipymin.py | 14 ++- pybop/_dataset.py | 18 +-- pybop/_problem.py | 90 ++++++++------ pybop/models/base_model.py | 31 ++--- pybop/optimisers/nlopt_optimize.py | 6 +- pybop/optimisers/scipy_optimisers.py | 17 +-- pybop/plotting/quick_plot.py | 33 ++--- tests/unit/test_cost.py | 14 ++- tests/unit/test_dataset.py | 40 +++++++ tests/unit/test_optimisation.py | 14 ++- tests/unit/test_parameterisations.py | 173 ++++++++++++++++----------- tests/unit/test_problem.py | 43 ++++++- 23 files changed, 395 insertions(+), 231 deletions(-) create mode 100644 tests/unit/test_dataset.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 478b61c6..cd9d7180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class - [#38](https://github.com/pybop-team/PyBOP/issues/38) - Restructures the Problem classes ahead of adding a design optimisation example - [#120](https://github.com/pybop-team/PyBOP/issues/120) - Updates the parameterisation test settings including the number of iterations +- [#145](https://github.com/pybop-team/PyBOP/issues/145) - Reformats Dataset to contain a dictionary and signal into a list of strings ## Bug Fixes diff --git a/examples/notebooks/spm_nlopt.ipynb b/examples/notebooks/spm_nlopt.ipynb index b64a2a9a..baf89804 100644 --- a/examples/notebooks/spm_nlopt.ipynb +++ b/examples/notebooks/spm_nlopt.ipynb @@ -277,11 +277,13 @@ "outputs": [], "source": [ "pyb_model = pybop.lithium_ion.SPM()\n", - "dataset = [\n", - " pybop.Dataset(\"Time [s]\", synthetic_sol[\"Time [s]\"].data),\n", - " pybop.Dataset(\"Current function [A]\", synthetic_sol[\"Current [A]\"].data),\n", - " pybop.Dataset(\"Terminal voltage [V]\", corrupt_V),\n", - "]" + "dataset = pybop.Dataset(\n", + " {\n", + " \"Time [s]\": synthetic_sol[\"Time [s]\"].data,\n", + " \"Current function [A]\": synthetic_sol[\"Current [A]\"].data,\n", + " \"Terminal voltage [V]\": corrupt_V,\n", + " }\n", + ")" ] }, { diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index 07f575fc..d60a8a8c 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -61,11 +61,14 @@ values = model.predict(t_eval=t_eval) corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) -dataset = [ - pybop.Dataset("Time [s]", t_eval), - pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", corrupt_values), -] +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index 6e4527b6..63aa0cc6 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -25,12 +25,14 @@ values = model.predict(t_eval=t_eval) corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) -# Form dataset for optimisation -dataset = [ - pybop.Dataset("Time [s]", t_eval), - pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", corrupt_values), -] +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index 794bc3c3..b764184d 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -24,11 +24,14 @@ values = model.predict(t_eval=t_eval) corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) -dataset = [ - pybop.Dataset("Time [s]", t_eval), - pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", corrupt_values), -] +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py index ac24c949..cc5995a9 100644 --- a/examples/scripts/spm_SNES.py +++ b/examples/scripts/spm_SNES.py @@ -24,11 +24,13 @@ values = model.predict(t_eval=t_eval) corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) -dataset = [ - pybop.Dataset("Time [s]", t_eval), - pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", corrupt_values), -] +dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py index 81d8c26c..7f46a352 100644 --- a/examples/scripts/spm_XNES.py +++ b/examples/scripts/spm_XNES.py @@ -24,11 +24,14 @@ values = model.predict(t_eval=t_eval) corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) -dataset = [ - pybop.Dataset("Time [s]", t_eval), - pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", corrupt_values), -] +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py index b8654f2a..fba51adb 100644 --- a/examples/scripts/spm_adam.py +++ b/examples/scripts/spm_adam.py @@ -25,12 +25,14 @@ values = model.predict(t_eval=t_eval) corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) -# Dataset definition -dataset = [ - pybop.Dataset("Time [s]", t_eval), - pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", corrupt_values), -] +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index a1936a95..4f7ddf57 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -25,12 +25,14 @@ values = model.predict(t_eval=t_eval) corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) -# Dataset definition -dataset = [ - pybop.Dataset("Time [s]", t_eval), - pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", corrupt_values), -] +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py index 258a5256..7ba0acd8 100644 --- a/examples/scripts/spm_nlopt.py +++ b/examples/scripts/spm_nlopt.py @@ -3,11 +3,13 @@ # Form dataset Measurements = pd.read_csv("examples/scripts/Chen_example.csv", comment="#").to_numpy() -dataset = [ - pybop.Dataset("Time [s]", Measurements[:, 0]), - pybop.Dataset("Current function [A]", Measurements[:, 1]), - pybop.Dataset("Voltage [V]", Measurements[:, 2]), -] +dataset = pybop.Dataset( + { + "Time [s]": Measurements[:, 0], + "Current function [A]": Measurements[:, 1], + "Voltage [V]": Measurements[:, 2], + } +) # Define model parameter_set = pybop.ParameterSet.pybamm("Chen2020") @@ -30,7 +32,7 @@ ] # Define the cost to optimise -signal = "Voltage [V]" +signal = ["Voltage [V]"] problem = pybop.FittingProblem(model, parameters, dataset, signal=signal, init_soc=0.98) cost = pybop.RootMeanSquaredError(problem) diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index 07bd9669..fd55552d 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -24,11 +24,14 @@ values = model.predict(t_eval=t_eval) corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval)) -dataset = [ - pybop.Dataset("Time [s]", t_eval), - pybop.Dataset("Current function [A]", values["Current [A]"].data), - pybop.Dataset("Voltage [V]", corrupt_values), -] +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": t_eval, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) diff --git a/examples/scripts/spm_scipymin.py b/examples/scripts/spm_scipymin.py index ec917378..af81b6c2 100644 --- a/examples/scripts/spm_scipymin.py +++ b/examples/scripts/spm_scipymin.py @@ -3,11 +3,13 @@ # Form dataset Measurements = pd.read_csv("examples/scripts/Chen_example.csv", comment="#").to_numpy() -dataset = [ - pybop.Dataset("Time [s]", Measurements[:, 0]), - pybop.Dataset("Current function [A]", Measurements[:, 1]), - pybop.Dataset("Voltage [V]", Measurements[:, 2]), -] +dataset = pybop.Dataset( + { + "Time [s]": Measurements[:, 0], + "Current function [A]": Measurements[:, 1], + "Voltage [V]": Measurements[:, 2], + } +) # Define model parameter_set = pybop.ParameterSet.pybamm("Chen2020") @@ -30,7 +32,7 @@ ] # Define the cost to optimise -signal = "Voltage [V]" +signal = ["Voltage [V]"] problem = pybop.FittingProblem(model, parameters, dataset, signal=signal, init_soc=0.98) cost = pybop.RootMeanSquaredError(problem) diff --git a/pybop/_dataset.py b/pybop/_dataset.py index 9a5f6650..1263ace3 100644 --- a/pybop/_dataset.py +++ b/pybop/_dataset.py @@ -18,20 +18,22 @@ class Dataset: """ - def __init__(self, name, data): + def __init__(self, data_dictionary): """ Initialize a Dataset instance with a name and data. Parameters ---------- - name : str - The name for the dataset. - data : array-like + data_dictionary : dict or instance of pybamm.solvers.solution.Solution The experimental data to store within the dataset. """ - self.name = name - self.data = data + if isinstance(data_dictionary, pybamm.solvers.solution.Solution): + data_dictionary = data_dictionary.get_data_dict() + if not isinstance(data_dictionary, dict): + raise ValueError("The input to pybop.Dataset must be a dictionary.") + self.data = data_dictionary + self.names = self.data.keys() def __repr__(self): """ @@ -40,9 +42,9 @@ def __repr__(self): Returns ------- str - A string that includes the name and data of the dataset. + A string that includes the type and contents of the dataset. """ - return f"Dataset: {self.name} \n Data: {self.data}" + return f"Dataset: {type(self.data)} \n Contains: {self.names}" def Interpolant(self): """ diff --git a/pybop/_problem.py b/pybop/_problem.py index 5e9110d6..ca5b7dd6 100644 --- a/pybop/_problem.py +++ b/pybop/_problem.py @@ -24,15 +24,24 @@ def __init__( parameters, model=None, check_model=True, + signal=["Voltage [V]"], init_soc=None, x0=None, ): + self.parameters = parameters self._model = model self.check_model = check_model - self.parameters = parameters + if isinstance(signal, str): + signal = [signal] + elif not all(isinstance(item, str) for item in signal): + raise ValueError("Signal should be either a string or list of strings.") + self.signal = signal self.init_soc = init_soc self.x0 = x0 self.n_parameters = len(self.parameters) + self.n_outputs = len(self.signal) + self._time_data = None + self._target = None # Set bounds self.bounds = dict( @@ -84,6 +93,28 @@ def evaluateS1(self, x): """ raise NotImplementedError + def time_data(self): + """ + Returns the time data. + + Returns + ------- + np.ndarray + The time array. + """ + return self._time_data + + def target(self): + """ + Return the target dataset. + + Returns + ------- + np.ndarray + The target dataset array. + """ + return self._target + class FittingProblem(BaseProblem): """ @@ -108,34 +139,40 @@ def __init__( model, parameters, dataset, - signal="Voltage [V]", check_model=True, + signal=["Voltage [V]"], init_soc=None, x0=None, ): - super().__init__(parameters, model, check_model, init_soc, x0) - if model is not None: - self._model.signal = signal - self.signal = signal - self._dataset = {o.name: o for o in dataset} - self.n_outputs = len([self.signal]) + super().__init__(parameters, model, check_model, signal, init_soc, x0) + self._dataset = dataset.data # Check that the dataset contains time and current - for name in ["Time [s]", "Current function [A]", signal]: + for name in ["Time [s]", "Current function [A]"] + self.signal: if name not in self._dataset: raise ValueError(f"expected {name} in list of dataset") - self._time_data = self._dataset["Time [s]"].data + self._time_data = self._dataset["Time [s]"] self.n_time_data = len(self._time_data) - self._target = self._dataset[signal].data - if np.any(self._time_data < 0): raise ValueError("Times can not be negative.") if np.any(self._time_data[:-1] >= self._time_data[1:]): raise ValueError("Times must be increasing.") - if len(self._target) != len(self._time_data): - raise ValueError("Time data and signal data must be the same length.") + target = [self._dataset[signal] for signal in self.signal] + self._target = np.vstack(target).T + if self.n_outputs == 1: + if len(self._target) != self.n_time_data: + raise ValueError("Time data and target data must be the same length.") + else: + if self._target.shape != (self.n_time_data, self.n_outputs): + raise ValueError("Time data and target data must be the same shape.") + + # Add useful parameters to model + if model is not None: + self._model.signal = self.signal + self._model.n_outputs = self.n_outputs + self._model.n_time_data = self.n_time_data # Build the model if self._model._built_model is None: @@ -177,17 +214,6 @@ def evaluateS1(self, x): return (np.asarray(y), np.asarray(dy)) - def target(self): - """ - Return the target dataset. - - Returns - ------- - np.ndarray - The target dataset array. - """ - return self._target - class DesignProblem(BaseProblem): """ @@ -211,12 +237,12 @@ def __init__( parameters, experiment, check_model=True, + signal=["Voltage [V]"], init_soc=None, x0=None, ): - super().__init__(parameters, model, check_model, init_soc, x0) + super().__init__(parameters, model, check_model, signal, init_soc, x0) self.experiment = experiment - self._target = None # Build the model if required if experiment is not None: @@ -263,13 +289,3 @@ def evaluateS1(self, x): ) return (np.asarray(y), np.asarray(dy)) - - def target(self): - """ - Return the target dataset (not applicable for design problems). - - Returns - ------- - None - """ - return self._target diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index 3dad02f0..6e679cb2 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -121,8 +121,8 @@ def set_params(self): if self.dataset is not None and self.parameters is not None: if "Current function [A]" not in self.fit_keys: self.parameter_set["Current function [A]"] = pybamm.Interpolant( - self.dataset["Time [s]"].data, - self.dataset["Current function [A]"].data, + self.dataset["Time [s]"], + self.dataset["Current function [A]"], pybamm.t, ) # Set t_eval @@ -163,9 +163,11 @@ def simulate(self, inputs, t_eval): if not isinstance(inputs, dict): inputs = {key: inputs[i] for i, key in enumerate(self.fit_keys)} - return self.solver.solve(self.built_model, inputs=inputs, t_eval=t_eval)[ - self.signal - ].data + sol = self.solver.solve(self.built_model, inputs=inputs, t_eval=t_eval) + + predictions = [sol[signal].data for signal in self.signal] + + return np.vstack(predictions).T def simulateS1(self, inputs, t_eval): """ @@ -204,15 +206,16 @@ def simulateS1(self, inputs, t_eval): calculate_sensitivities=True, ) - return ( - sol[self.signal].data, - np.asarray( - [ - sol[self.signal].sensitivities[key].toarray() - for key in self.fit_keys - ] - ).T, - ) + predictions = [sol[signal].data for signal in self.signal] + + sensitivities = [ + np.array( + [[sol[signal].sensitivities[key]] for signal in self.signal] + ).reshape(len(sol[self.signal[0]].data), self.n_outputs) + for key in self.fit_keys + ] + + return np.vstack(predictions).T, np.dstack(sensitivities) def predict( self, diff --git a/pybop/optimisers/nlopt_optimize.py b/pybop/optimisers/nlopt_optimize.py index d6b4df2b..12d7dfba 100644 --- a/pybop/optimisers/nlopt_optimize.py +++ b/pybop/optimisers/nlopt_optimize.py @@ -26,7 +26,7 @@ class NLoptOptimize(BaseOptimiser): def __init__(self, n_param, xtol=None, method=None, maxiter=None): super().__init__() self.n_param = n_param - self.maxiter = maxiter + self._max_iterations = maxiter if method is not None: self.optim = nlopt.opt(method, self.n_param) @@ -70,8 +70,8 @@ def cost_wrapper(x, grad): self.optim.set_upper_bounds(bounds["upper"]) # Set max iterations - if self.maxiter is not None: - self.optim.set_maxeval(self.maxiter) + if self._max_iterations is not None: + self.optim.set_maxeval(self._max_iterations) # Run the optimser x = self.optim.optimize(x0) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index ce7e4fe5..5c17ff0a 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -22,11 +22,8 @@ def __init__(self, method=None, bounds=None, maxiter=None): super().__init__() self.method = method self.bounds = bounds - self.maxiter = maxiter - if self.maxiter is not None: - self.options = {"maxiter": self.maxiter} - else: - self.options = {} + self.options = {} + self._max_iterations = maxiter if self.method is None: self.method = "COBYLA" # "L-BFGS-B" @@ -62,6 +59,12 @@ def callback(x): (lower, upper) for lower, upper in zip(bounds["lower"], bounds["upper"]) ) + # Set max iterations + if self._max_iterations is not None: + self.options = {"maxiter": self._max_iterations} + else: + self.options.pop("maxiter", None) + output = minimize( cost_function, x0, @@ -122,7 +125,7 @@ def __init__(self, bounds=None, strategy="best1bin", maxiter=1000, popsize=15): super().__init__() self.bounds = bounds self.strategy = strategy - self.maxiter = maxiter + self._max_iterations = maxiter self.popsize = popsize def _runoptimise(self, cost_function, x0=None, bounds=None): @@ -168,7 +171,7 @@ def callback(x, convergence): cost_function, bounds, strategy=self.strategy, - maxiter=self.maxiter, + maxiter=self._max_iterations, popsize=self.popsize, callback=callback, ) diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 95a5bfeb..6f0cf616 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -215,21 +215,22 @@ def quick_plot(params, cost, title="Scatter Plot", width=1024, height=576): model_output = cost.problem.evaluate(params) target_output = cost.problem.target() - # Create the figure using the StandardPlot class - fig = pybop.StandardPlot( - x=time_data, - y=model_output, - cost=cost, - y2=target_output, - xaxis_title="Time [s]", - yaxis_title=cost.problem.signal, - title=title, - trace_name="Model", - width=width, - height=height, - )() - - # Display the figure - fig.show() + for i in range(0, cost.problem.n_outputs): + # Create the figure using the StandardPlot class + fig = pybop.StandardPlot( + x=time_data, + y=model_output[:, i], + cost=cost, + y2=target_output[:, i], + xaxis_title="Time [s]", + yaxis_title=cost.problem.signal[i], + title=title, + trace_name="Model", + width=width, + height=height, + )() + + # Display the figure + fig.show() return fig diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py index ed14655e..36249948 100644 --- a/tests/unit/test_cost.py +++ b/tests/unit/test_cost.py @@ -25,14 +25,16 @@ def test_costs(self, cut_off): # Form dataset x0 = np.array([0.52]) solution = self.getdata(model, x0) - dataset = [ - pybop.Dataset("Time [s]", solution["Time [s]"].data), - pybop.Dataset("Current function [A]", solution["Current [A]"].data), - pybop.Dataset("Voltage [V]", solution["Terminal voltage [V]"].data), - ] + dataset = pybop.Dataset( + { + "Time [s]": solution["Time [s]"].data, + "Current function [A]": solution["Current [A]"].data, + "Voltage [V]": solution["Terminal voltage [V]"].data, + } + ) # Construct Problem - signal = "Voltage [V]" + signal = ["Voltage [V]"] model.parameter_set.update({"Lower voltage cut-off [V]": cut_off}) problem = pybop.FittingProblem(model, parameters, dataset, signal=signal, x0=x0) diff --git a/tests/unit/test_dataset.py b/tests/unit/test_dataset.py new file mode 100644 index 00000000..b5cfa96d --- /dev/null +++ b/tests/unit/test_dataset.py @@ -0,0 +1,40 @@ +import pytest +import pybop +import numpy as np + + +class TestDataset: + """ + Class to test dataset construction. + """ + + @pytest.mark.unit + def test_dataset(self): + # Construct and simulate model + model = pybop.lithium_ion.SPM() + model.parameter_set = model.pybamm_model.default_parameter_values + solution = model.predict(t_eval=np.linspace(0, 10, 100)) + + # Form dataset + data_dictionary = { + "Time [s]": solution["Time [s]"].data, + "Current [A]": solution["Current [A]"].data, + "Terminal voltage [V]": solution["Terminal voltage [V]"].data, + } + dataset = pybop.Dataset(data_dictionary) + + # Test repr + print(dataset) + + # Test data structure + assert dataset.data == data_dictionary + + # Test exception for non-dictionary inputs + with pytest.raises(ValueError): + pybop.Dataset(["StringInputShouldNotWork"]) + with pytest.raises(ValueError): + pybop.Dataset(solution["Time [s]"].data) + + # Test conversion of pybamm solution into dictionary + assert dataset.data == pybop.Dataset(solution).data + assert dataset.names == pybop.Dataset(solution).names diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 965f2720..cf82a51e 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -11,11 +11,13 @@ class TestOptimisation: @pytest.fixture def dataset(self): - return [ - pybop.Dataset("Time [s]", np.linspace(0, 360, 10)), - pybop.Dataset("Current function [A]", np.zeros(10)), - pybop.Dataset("Terminal voltage [V]", np.ones(10)), - ] + return pybop.Dataset( + { + "Time [s]": np.linspace(0, 360, 10), + "Current function [A]": np.zeros(10), + "Terminal voltage [V]": np.ones(10), + } + ) @pytest.fixture def parameters(self): @@ -34,7 +36,7 @@ def problem(self, parameters, dataset): model, parameters, dataset, - signal="Terminal voltage [V]", + signal=["Terminal voltage [V]"], ) @pytest.fixture diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py index af1a4d57..8f3ffde9 100644 --- a/tests/unit/test_parameterisations.py +++ b/tests/unit/test_parameterisations.py @@ -38,16 +38,16 @@ def x0(self): def test_spm(self, parameters, model, x0, init_soc): # Form dataset solution = self.getdata(model, x0, init_soc) - dataset = [ - pybop.Dataset("Time [s]", solution["Time [s]"].data), - pybop.Dataset("Current function [A]", solution["Current [A]"].data), - pybop.Dataset( - "Terminal voltage [V]", solution["Terminal voltage [V]"].data - ), - ] + dataset = pybop.Dataset( + { + "Time [s]": solution["Time [s]"].data, + "Current function [A]": solution["Current [A]"].data, + "Terminal voltage [V]": solution["Terminal voltage [V]"].data, + } + ) # Define the cost to optimise - signal = "Terminal voltage [V]" + signal = ["Terminal voltage [V]"] problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) @@ -71,75 +71,110 @@ def spm_cost(self, parameters, model, x0): # Form dataset init_soc = 0.5 solution = self.getdata(model, x0, init_soc) - dataset = [ - pybop.Dataset("Time [s]", solution["Time [s]"].data), - pybop.Dataset("Current function [A]", solution["Current [A]"].data), - pybop.Dataset( - "Terminal voltage [V]", solution["Terminal voltage [V]"].data - ), - ] + dataset = pybop.Dataset( + { + "Time [s]": solution["Time [s]"].data, + "Current function [A]": solution["Current [A]"].data, + "Terminal voltage [V]": solution["Terminal voltage [V]"].data, + } + ) # Define the cost to optimise - signal = "Terminal voltage [V]" + signal = ["Terminal voltage [V]"] problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) return pybop.SumSquaredError(problem) - @pytest.mark.unit - def test_spm_optimisers(self, spm_cost, x0): - # Select optimisers - optimisers = [ + @pytest.mark.parametrize( + "optimiser", + [ pybop.NLoptOptimize, pybop.SciPyMinimize, pybop.SciPyDifferentialEvolution, - pybop.CMAES, pybop.Adam, + pybop.CMAES, pybop.GradientDescent, + pybop.IRPropMin, pybop.PSO, - pybop.XNES, pybop.SNES, - pybop.IRPropMin, - ] + pybop.XNES, + ], + ) + @pytest.mark.unit + def test_spm_optimisers(self, optimiser, spm_cost, x0): + # Test each optimiser + parameterisation = pybop.Optimisation(cost=spm_cost, optimiser=optimiser) + parameterisation.set_max_unchanged_iterations(iterations=15, threshold=5e-4) + + if optimiser in [pybop.CMAES]: + parameterisation.set_f_guessed_tracking(True) + assert parameterisation._use_f_guessed is True + parameterisation.set_max_iterations(1) + x, final_cost = parameterisation.run() + + parameterisation.set_f_guessed_tracking(False) + parameterisation.set_max_iterations(100) + + x, final_cost = parameterisation.run() + assert parameterisation._max_iterations == 100 + + elif optimiser in [pybop.GradientDescent]: + parameterisation.optimiser.set_learning_rate(0.025) + parameterisation.set_max_iterations(100) + x, final_cost = parameterisation.run() + else: + parameterisation.set_max_iterations(100) + x, final_cost = parameterisation.run() + + # Assertions + np.testing.assert_allclose(final_cost, 0, atol=1e-2) + np.testing.assert_allclose(x, x0, atol=1e-1) + + @pytest.fixture + def spm_two_signal_cost(self, parameters, model, x0): + # Form dataset + init_soc = 0.5 + solution = self.getdata(model, x0, init_soc) + dataset = pybop.Dataset( + { + "Time [s]": solution["Time [s]"].data, + "Current function [A]": solution["Current [A]"].data, + "Terminal voltage [V]": solution["Terminal voltage [V]"].data, + } + ) + + # Define the cost to optimise + signal = ["Terminal voltage [V]", "Time [s]"] + problem = pybop.FittingProblem( + model, parameters, dataset, signal=signal, init_soc=init_soc + ) + return pybop.SumSquaredError(problem) + + @pytest.mark.parametrize( + "optimiser", + [ + pybop.NLoptOptimize, + pybop.SciPyMinimize, + pybop.Adam, + pybop.CMAES, + ], + ) + @pytest.mark.unit + def test_multiple_signals(self, optimiser, spm_two_signal_cost, x0): # Test each optimiser - for optimiser in optimisers: - parameterisation = pybop.Optimisation(cost=spm_cost, optimiser=optimiser) - parameterisation.set_max_unchanged_iterations(iterations=15, threshold=5e-4) - - if optimiser in [pybop.CMAES]: - parameterisation.set_f_guessed_tracking(True) - assert parameterisation._use_f_guessed is True - parameterisation.set_max_iterations(1) - x, final_cost = parameterisation.run() - - parameterisation.set_f_guessed_tracking(False) - parameterisation.set_max_iterations(100) - - x, final_cost = parameterisation.run() - assert parameterisation._max_iterations == 100 - - elif optimiser in [pybop.GradientDescent]: - parameterisation.optimiser.set_learning_rate(0.025) - parameterisation.set_max_iterations(100) - x, final_cost = parameterisation.run() - - elif optimiser in [ - pybop.PSO, - pybop.XNES, - pybop.SNES, - pybop.Adam, - pybop.IRPropMin, - ]: - parameterisation.set_max_iterations(100) - x, final_cost = parameterisation.run() - - else: - x, final_cost = parameterisation.run() - - # Assertions - np.testing.assert_allclose(final_cost, 0, atol=1e-2) - np.testing.assert_allclose(x, x0, atol=1e-1) + parameterisation = pybop.Optimisation( + cost=spm_two_signal_cost, optimiser=optimiser + ) + parameterisation.set_max_unchanged_iterations(iterations=15, threshold=5e-4) + parameterisation.set_max_iterations(100) + + x, final_cost = parameterisation.run() + + # Assertions + np.testing.assert_allclose(final_cost, 0, atol=2e-2) + np.testing.assert_allclose(x, x0, atol=1e-1) @pytest.mark.parametrize("init_soc", [0.3, 0.7]) @pytest.mark.unit @@ -151,16 +186,16 @@ def test_model_misparameterisation(self, parameters, model, x0, init_soc): # Form dataset solution = self.getdata(second_model, x0, init_soc) - dataset = [ - pybop.Dataset("Time [s]", solution["Time [s]"].data), - pybop.Dataset("Current function [A]", solution["Current [A]"].data), - pybop.Dataset( - "Terminal voltage [V]", solution["Terminal voltage [V]"].data - ), - ] + dataset = pybop.Dataset( + { + "Time [s]": solution["Time [s]"].data, + "Current function [A]": solution["Current [A]"].data, + "Terminal voltage [V]": solution["Terminal voltage [V]"].data, + } + ) # Define the cost to optimise - signal = "Terminal voltage [V]" + signal = ["Terminal voltage [V]"] problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) diff --git a/tests/unit/test_problem.py b/tests/unit/test_problem.py index 85d246df..976388e9 100644 --- a/tests/unit/test_problem.py +++ b/tests/unit/test_problem.py @@ -53,11 +53,13 @@ def dataset(self, model, experiment): } ) solution = model.predict(experiment=experiment) - return [ - pybop.Dataset("Time [s]", solution["Time [s]"].data), - pybop.Dataset("Current function [A]", solution["Current [A]"].data), - pybop.Dataset("Voltage [V]", solution["Terminal voltage [V]"].data), - ] + return pybop.Dataset( + { + "Time [s]": solution["Time [s]"].data, + "Current function [A]": solution["Current [A]"].data, + "Voltage [V]": solution["Terminal voltage [V]"].data, + } + ) @pytest.fixture def signal(self): @@ -79,6 +81,9 @@ def test_base_problem(self, parameters, model): with pytest.raises(NotImplementedError): problem.evaluateS1([0.5, 0.5]) + with pytest.raises(ValueError): + pybop._problem.BaseProblem(parameters, model=model, signal=[0.5, 0.5]) + @pytest.mark.unit def test_fitting_problem(self, parameters, dataset, model, signal): # Test incorrect number of initial parameter values @@ -96,6 +101,34 @@ def test_fitting_problem(self, parameters, dataset, model, signal): # Test model.simulate model.simulate(inputs=[0.5, 0.5], t_eval=np.linspace(0, 10, 100)) + # Test problem construction errors + for bad_dataset in [ + pybop.Dataset({"Time [s]": np.array([0])}), + pybop.Dataset( + { + "Time [s]": np.array([-1]), + "Current function [A]": np.array([0]), + "Voltage [V]": np.array([0]), + } + ), + pybop.Dataset( + { + "Time [s]": np.array([1, 0]), + "Current function [A]": np.array([0, 0]), + "Voltage [V]": np.array([0, 0]), + } + ), + pybop.Dataset( + { + "Time [s]": np.array([0]), + "Current function [A]": np.array([0, 0]), + "Voltage [V]": np.array([0, 0]), + } + ), + ]: + with pytest.raises(ValueError): + pybop.FittingProblem(model, parameters, bad_dataset, signal=signal) + @pytest.mark.unit def test_design_problem(self, parameters, experiment, model): # Test incorrect number of initial parameter values From 89cd71298746527369b7e51107e3fa5dab6e50d8 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 15 Dec 2023 09:12:07 +0000 Subject: [PATCH 097/101] bugfix - switcher link, add version matching, comment out banner --- docs/conf.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 14f8a0b1..2b8b9278 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,8 +46,25 @@ # -- Options for HTML output ------------------------------------------------- # Define the json_url for our version switcher. -json_url = "http://pybop-docs.readthedocs.io/en/latest/_static/switcher.json" +json_url = "https://pybop-docs.readthedocs.io/en/latest/_static/switcher.json" version_match = os.environ.get("READTHEDOCS_VERSION") +release = f"v{__version__}" + +# If READTHEDOCS_VERSION doesn't exist, we're not on RTD +# If it is an integer, we're in a PR build and the version isn't correct. +# If it's "latest" → change to "dev" (that's what we want the switcher to call it) +# Credit: PyData Theme: https://github.com/pydata/pydata-sphinx-theme/blob/main/docs/conf.py +if not version_match or version_match.isdigit() or version_match == "latest": + # For local development, infer the version to match from the package. + if "latest" in release or "rc" in release: + version_match = "latest" + # We want to keep the relative reference if we are in dev mode + # but we want the whole url if we are effectively in a released version + json_url = "_static/switcher.json" + else: + version_match = release +elif version_match == "stable": + version_match = release html_theme = "pydata_sphinx_theme" html_show_sourcelink = False @@ -72,7 +89,7 @@ "show_prev_next": False, "navbar_align": "content", "navbar_center": ["navbar-nav", "version-switcher"], - "show_version_warning_banner": True, + # "show_version_warning_banner": True, # Commented until we have a stable release with docs "footer_start": ["copyright"], "footer_center": ["sphinx-version"], "switcher": { From 760ceb84eeb88376740a735b000331d7e2956674 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 19 Dec 2023 09:26:35 +0000 Subject: [PATCH 098/101] Increment version to 23.12 --- CHANGELOG.md | 5 +++++ CITATION.cff | 1 + pybop/version.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9d7180..f6645b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Features +## Bug Fixes + +# [v23.12]() +## Features + - [#141](https://github.com/pybop-team/PyBOP/pull/141) - Adds documentation with Sphinx and PyData Sphinx Theme. Updates docstrings across package, relocates `costs` and `dataset` to top-level of package. Adds noxfile session and deployment workflow for docs. - [#131](https://github.com/pybop-team/PyBOP/issues/131) - Adds `SciPyDifferentialEvolution` optimiser, adds functionality for user-selectable maximum iteration limit to `SciPyMinimize`, `NLoptOptimize`, and `BaseOptimiser` classes. - [#107](https://github.com/pybop-team/PyBOP/issues/107) - Adds Equivalent Circuit Model (ECM) with examples, Import/Export parameter methods `ParameterSet.import_parameter` and `ParameterSet.export_parameters`, updates default FittingProblem.signal definition to `"Voltage [V]"`, and testing infrastructure diff --git a/CITATION.cff b/CITATION.cff index e1efab89..11ce822f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,4 +11,5 @@ authors: family-names: Courtier - given-names: David family-names: Howey +version: "23.12" # Update this when you release a new version repository-code: 'https://www.github.com/pybop-team/pybop' diff --git a/pybop/version.py b/pybop/version.py index 915a9aed..f3d58cf8 100644 --- a/pybop/version.py +++ b/pybop/version.py @@ -1 +1 @@ -__version__ = "23.11" +__version__ = "23.12" From f2d842042560d264db002eee010d50c5ffa75eb2 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 19 Dec 2023 09:41:01 +0000 Subject: [PATCH 099/101] Updt CHANGELOG 23.12 hyperlink --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6645b2f..3269d569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## Bug Fixes -# [v23.12]() +# [v23.12](https://github.com/pybop-team/PyBOP/tree/v23.12) - 2023-12-19 ## Features - [#141](https://github.com/pybop-team/PyBOP/pull/141) - Adds documentation with Sphinx and PyData Sphinx Theme. Updates docstrings across package, relocates `costs` and `dataset` to top-level of package. Adds noxfile session and deployment workflow for docs. From 3f825a51f7fc88da2c4213cf501041a0c32498cd Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 19 Dec 2023 09:59:30 +0000 Subject: [PATCH 100/101] Updt. trigger for release workflow --- .github/workflows/release-action.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-action.yaml b/.github/workflows/release-action.yaml index 44f32673..c7bea10a 100644 --- a/.github/workflows/release-action.yaml +++ b/.github/workflows/release-action.yaml @@ -2,9 +2,8 @@ name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI on: workflow_dispatch: - pull_request: - branches: - - main + release: + types: [created, published] jobs: build: @@ -34,7 +33,9 @@ jobs: publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes + if: > + startsWith(github.ref, 'refs/tags/') && + !contains(github.ref, 'rc') needs: - build runs-on: ubuntu-latest From c0eb5a610429bf99e3d81c0abe23c189fabe6e9e Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 19 Dec 2023 10:03:27 +0000 Subject: [PATCH 101/101] Add pre-release trigger --- .github/workflows/release-action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-action.yaml b/.github/workflows/release-action.yaml index c7bea10a..8cbaa5f4 100644 --- a/.github/workflows/release-action.yaml +++ b/.github/workflows/release-action.yaml @@ -3,7 +3,7 @@ name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI on: workflow_dispatch: release: - types: [created, published] + types: [published, prereleased] jobs: build: