diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml new file mode 100644 index 00000000..bb64d714 --- /dev/null +++ b/.github/workflows/deploy-docs.yaml @@ -0,0 +1,39 @@ +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/.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: diff --git a/.gitignore b/.gitignore index b6dae2f7..bc3caa2c 100644 --- a/.gitignore +++ b/.gitignore @@ -175,6 +175,9 @@ instance/ # Sphinx documentation docs/_build/ +docs/examples/generated/ +docs/api/ +warnings.txt # PyBuilder .pybuilder/ @@ -306,4 +309,4 @@ $RECYCLE.BIN/ .vscode/* # Output JSON files -*fit_ecm_parameters.json +**/fit_ecm_parameters.json 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..055221c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ -# Contributing to PyBOP +# Contributing 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! @@ -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)). @@ -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!) @@ -283,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 @@ -306,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/Makefile b/docs/Makefile new file mode 100644 index 00000000..d4bb2cbb --- /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 = . +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/_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/_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/_templates/autoapi/index.rst b/docs/_templates/autoapi/index.rst new file mode 100644 index 00000000..d6075995 --- /dev/null +++ b/docs/_templates/autoapi/index.rst @@ -0,0 +1,18 @@ +.. _api-reference: + +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/conf.py b/docs/conf.py new file mode 100644 index 00000000..11588cda --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,89 @@ +# Configuration file for the Sphinx documentation builder. + + +# -- Path setup -------------------------------------------------------------- +import os +import sys + +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 = f"v{__version__}" + +# -- General configuration --------------------------------------------------- +extensions = [ + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.todo", + "sphinx.ext.viewcode", + "sphinx_design", + "sphinx_copybutton", + "autoapi.extension", + # custom extentions + "_extension.gallery_directive", + # For extension examples and demos + "myst_parser", + "sphinx_favicon", +] + +templates_path = ["_templates"] +autoapi_template_dir = "_templates/autoapi" +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 ------------------------------------------------- +# 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" + +# 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", + }, + ], + "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/docs/index.md b/docs/index.md new file mode 100644 index 00000000..d45182ae --- /dev/null +++ b/docs/index.md @@ -0,0 +1,37 @@ +--- +myst: + html_meta: + "description lang=en": | + High-level documentation for PyBOP, and corresponding links to the site. +html_theme.sidebar_secondary.remove: true +--- + +# 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. + +```{gallery-grid} +:grid-columns: 1 2 2 2 + +- 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: "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" +- 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: + +installation +quick_start +Contributing +``` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 00000000..8fa9cdd1 --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,68 @@ +.. _installation: + +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. + +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 <../Contributing.html>`_ + +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. +* Check out the `API Reference <../api/index.html>`_ for detailed information on PyBOP's programming interface. diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..32bb2452 --- /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=. +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/quick_start.rst b/docs/quick_start.rst new file mode 100644 index 00000000..e8ebbc0c --- /dev/null +++ b/docs/quick_start.rst @@ -0,0 +1,58 @@ +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. + +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 delving into the :ref:`api-reference` for detailed API documentation. + +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: + +- **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/examples/costs/standalone.py b/examples/costs/standalone.py new file mode 100644 index 00000000..9836a7e7 --- /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 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. + + 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/noxfile.py b/noxfile.py index a70ef948..ddb7bd19 100644 --- a/noxfile.py +++ b/noxfile.py @@ -29,4 +29,38 @@ 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 +def 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", + "--keep-going", + ".", + "_build/html", + ) diff --git a/pybop/__init__.py b/pybop/__init__.py index 1d701d26..9c9a23e1 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 @@ -43,7 +43,7 @@ # # Main optimisation class # -from .optimisation import Optimisation +from ._optimisation import Optimisation # # Optimiser class 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/optimisation.py b/pybop/_optimisation.py similarity index 74% rename from pybop/optimisation.py rename to pybop/_optimisation.py index 5da349a0..8052298d 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 optimisers. + + Parameters + ---------- + 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 optimiser (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 optimiser. + 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 optimiser. + + 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 optimiser. + + 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 optimiser 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/_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/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/pybop/models/base_model.py b/pybop/models/base_model.py index 848ab029..3dad02f0 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -4,10 +4,24 @@ 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. + """ 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 +36,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 +80,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 +105,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 +136,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 +169,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 +223,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..29cc219a 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -1,17 +1,39 @@ 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. """ 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 +47,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..d6b4df2b 100644 --- a/pybop/optimisers/nlopt_optimize.py +++ b/pybop/optimisers/nlopt_optimize.py @@ -5,7 +5,22 @@ 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. """ def __init__(self, n_param, xtol=None, method=None, maxiter=None): @@ -25,14 +40,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 +83,22 @@ def cost_wrapper(x, grad): def needs_sensitivities(self): """ - Returns True if the optimiser needs sensitivities. + Indicates if the optimiser 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 optimiser 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..7b70e97f 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 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 + ---------- + 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 optimiser, 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 optimiser from the PINTS library, which combines + ideas from RMSProp and Stochastic Gradient Descent with momentum. Note that + this optimiser 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 optimiser, 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 optimiser 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 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. + + 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 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. + + 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) 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. + + 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) 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. + + 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..15dffecb 100644 --- a/pybop/parameters/parameter.py +++ b/pybop/parameters/parameter.py @@ -2,11 +2,36 @@ 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. + + 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 +46,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 +70,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..482baff4 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 negative. + """ 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 negative. + """ 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 negative. + """ 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}" diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py index 94ffdf90..81e9ef65 100644 --- a/pybop/plotting/plot_convergence.py +++ b/pybop/plotting/plot_convergence.py @@ -7,8 +7,8 @@ def plot_convergence( """ Plot the convergence of the optimisation algorithm. - Parameters: - ---------- + Parameters + ----------- optim : optimisation object Optimisation object containing the cost function and optimiser. xaxis_title : str, optional @@ -18,8 +18,8 @@ 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 7e698cb9..ee43d9d0 100644 --- a/pybop/plotting/plot_cost2d.py +++ b/pybop/plotting/plot_cost2d.py @@ -3,7 +3,31 @@ def plot_cost2d(cost, bounds=None, optim=None, steps=10): """ - Query the cost landscape for a given parameter space and plot using plotly. + 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 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). + + 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: @@ -35,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): @@ -44,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 optimiser 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 3fb06ea3..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. + Plot the evolution of parameters during the optimization process using Plotly. - Parameters: + 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"). + 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, which provides an overall description of what the - plot represents (default is "Convergence"). + Title of the plot, defaulting to "Convergence". - Returns: + 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. + 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 2384f299..44f551f5 100644 --- a/pybop/plotting/plotly_manager.py +++ b/pybop/plotting/plotly_manager.py @@ -5,28 +5,32 @@ class PlotlyManager: """ - Manages the installation and configuration of Plotly for generating visualisations. + Manages the installation and configuration of Plotly for generating visualizations. - 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. + This class ensures that Plotly is installed and properly configured to display + plots in a web browser. - 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. + Upon instantiation, it checks for Plotly's presence, installs it if missing, + and configures the default renderer and browser settings. - Usage: - Instantiate the PlotlyManager class to automatically ensure Plotly is installed - and configured correctly when creating an instance. - Example: - plotly_manager = PlotlyManager() + 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. + + 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 +39,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 +54,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 +72,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 +82,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 +99,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 +111,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 d6628760..95a5bfeb 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -5,57 +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. - - 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 + Height of the figure in pixels (default: 576). """ def __init__( @@ -71,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 @@ -93,26 +94,31 @@ def __init__( @staticmethod def wrap_text(text, width): """ - Wrap text to a specified width. + Wrap text to a specified width with HTML line breaks. - Parameters: - ----------- - text: str - Text to be wrapped. - width: int - Width to wrap text to. + Parameters + ---------- + text : str + The text to wrap. + width : int + The width to wrap the text to. - Returns: - -------- + 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, @@ -129,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 = [] @@ -163,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() @@ -173,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: + Returns ------- - fig : plotly.graph_objs.Figure + plotly.graph_objs.Figure The Plotly figure object for the scatter plot. """ 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] diff --git a/setup.py b/setup.py index e4a55049..d0bf68ae 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,16 @@ extras_require={ "plot": ["plotly>=5.0"], "all": ["pybop[plot]"], + "docs": [ + "sphinx>=6", + "pydata-sphinx-theme", + "sphinx-autobuild", + "sphinx-autoapi", + "sphinx_copybutton", + "sphinx_favicon", + "sphinx_design", + "myst-parser", + ], }, # https://pypi.org/classifiers/ classifiers=[], diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index dc96367c..965f2720 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: