diff --git a/.all-contributorsrc b/.all-contributorsrc index 4366246007..dcde101e80 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -747,7 +747,8 @@ "profile": "https://github.com/AbhishekChaudharii", "contributions": [ "doc", - "code" + "code", + "test" ] }, { @@ -960,6 +961,36 @@ "code", "test" ] + }, + { + "login": "medha-14", + "name": "Medha Bhardwaj", + "avatar_url": "https://avatars.githubusercontent.com/u/143182673?v=4", + "profile": "https://github.com/medha-14", + "contributions": [ + "code" + ] + }, + { + "login": "MarcBerliner", + "name": "Marc Berliner", + "avatar_url": "https://avatars.githubusercontent.com/u/34451391?v=4", + "profile": "http://marcberliner.com", + "contributions": [ + "code", + "doc", + "infra", + "maintenance" + ] + }, + { + "login": "Aswinr24", + "name": "Aswinr24", + "avatar_url": "https://avatars.githubusercontent.com/u/135364633?v=4", + "profile": "https://github.com/Aswinr24", + "contributions": [ + "test" + ] } ], "contributorsPerLine": 7, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0f503f09a7..6984abf32c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,7 +10,7 @@ src/pybamm/meshes/ @martinjrobins @rtimms @valentinsulzer @rtimms src/pybamm/models/ @brosaplanella @DrSOKane @rtimms @valentinsulzer @TomTranter @rtimms src/pybamm/parameters/ @brosaplanella @DrSOKane @rtimms @valentinsulzer @TomTranter @rtimms @kratman src/pybamm/plotting/ @martinjrobins @rtimms @Saransh-cpp @valentinsulzer @rtimms @kratman @agriyakhetarpal -src/pybamm/solvers/ @martinjrobins @rtimms @valentinsulzer @TomTranter @rtimms +src/pybamm/solvers/ @martinjrobins @rtimms @valentinsulzer @TomTranter @rtimms @MarcBerliner src/pybamm/spatial_methods/ @martinjrobins @rtimms @valentinsulzer @rtimms src/pybamm/* @pybamm-team/maintainers # the files directly under /pybamm/, will not recurse diff --git a/.github/release_reminder.md b/.github/release_reminder.md deleted file mode 100644 index 09c524fbec..0000000000 --- a/.github/release_reminder.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Create {{ date | date('YY.MM') }} (final or rc0) release -labels: "priority: high" ---- -Quarterly reminder to create a - - -1. pre-release if the month has just started. -2. non-pre-release if the month is about to end (**before the end of the month**). - -See [Release Workflow](https://github.com/pybamm-team/PyBaMM/blob/develop/.github/release_workflow.md) for more information. diff --git a/.github/workflows/benchmark_on_push.yml b/.github/workflows/benchmark_on_push.yml index 2883eb5f26..3aa9fce9c0 100644 --- a/.github/workflows/benchmark_on_push.yml +++ b/.github/workflows/benchmark_on_push.yml @@ -8,6 +8,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + PYBAMM_DISABLE_TELEMETRY: "true" + jobs: benchmarks: runs-on: ubuntu-latest diff --git a/.github/workflows/lychee_url_checker.yml b/.github/workflows/lychee_url_checker.yml index 9a636fda8a..e595bff826 100644 --- a/.github/workflows/lychee_url_checker.yml +++ b/.github/workflows/lychee_url_checker.yml @@ -28,7 +28,7 @@ jobs: # use stable version for now to avoid breaking changes - name: Lychee URL checker - uses: lycheeverse/lychee-action@v1.10.0 + uses: lycheeverse/lychee-action@v2.1.0 with: # arguments with file types to check args: >- diff --git a/.github/workflows/periodic_benchmarks.yml b/.github/workflows/periodic_benchmarks.yml index 641627c0ba..30603a0ea9 100644 --- a/.github/workflows/periodic_benchmarks.yml +++ b/.github/workflows/periodic_benchmarks.yml @@ -15,6 +15,9 @@ on: # workflow manually workflow_dispatch: +env: + PYBAMM_DISABLE_TELEMETRY: "true" + jobs: benchmarks: runs-on: ubuntu-latest @@ -51,7 +54,7 @@ jobs: LD_LIBRARY_PATH: $HOME/.local/lib - name: Upload results as artifact - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.3 with: name: asv_periodic_results path: results diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 9ca277b653..944c900e9a 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -18,6 +18,7 @@ on: # Set options available for all jobs that use cibuildwheel env: + PYBAMM_DISABLE_TELEMETRY: "true" # Increase pip debugging output, equivalent to `pip -vv` CIBW_BUILD_VERBOSITY: 2 # Disable build isolation to allow pre-installing build-time dependencies. @@ -75,6 +76,7 @@ jobs: run: pipx run cibuildwheel --output-dir wheelhouse env: CIBW_ENVIRONMENT: > + PYBAMM_DISABLE_TELEMETRY="true" PYBAMM_USE_VCPKG=ON VCPKG_ROOT_DIR=C:\vcpkg VCPKG_DEFAULT_TRIPLET=x64-windows-static-md @@ -92,7 +94,7 @@ jobs: python -c "import pybamm; print(pybamm.IDAKLUSolver())" python -m pytest -m cibw {project}/tests/unit - name: Upload Windows wheels - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.3 with: name: wheels_windows path: ./wheelhouse/*.whl @@ -116,6 +118,8 @@ jobs: - name: Build wheels on Linux run: pipx run cibuildwheel --output-dir wheelhouse env: + CIBW_ENVIRONMENT: > + PYBAMM_DISABLE_TELEMETRY="true" CIBW_ARCHS_LINUX: x86_64 CIBW_BEFORE_ALL_LINUX: > yum -y install openblas-devel lapack-devel && @@ -129,7 +133,7 @@ jobs: python -m pytest -m cibw {project}/tests/unit - name: Upload wheels for Linux - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.3 with: name: wheels_manylinux path: ./wheelhouse/*.whl @@ -242,7 +246,9 @@ jobs: python scripts/install_KLU_Sundials.py python -m cibuildwheel --output-dir wheelhouse env: - # 10.13 for Intel (macos-12/macos-13), 11.0 for Apple Silicon (macos-14 and macos-latest) + CIBW_ENVIRONMENT: > + PYBAMM_DISABLE_TELEMETRY="true" + # 10.13 for Intel (macos-13), 11.0 for Apple Silicon (macos-14 and macos-latest) MACOSX_DEPLOYMENT_TARGET: ${{ matrix.os == 'macos-14' && '11.0' || '10.13' }} CIBW_ARCHS_MACOS: auto CIBW_BEFORE_BUILD: python -m pip install cmake casadi setuptools wheel delocate @@ -261,7 +267,7 @@ jobs: python -m pytest -m cibw {project}/tests/unit - name: Upload wheels for macOS (amd64, arm64) - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.3 with: name: wheels_${{ matrix.os }} path: ./wheelhouse/*.whl @@ -281,7 +287,7 @@ jobs: run: pipx run build --sdist - name: Upload SDist - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.3 with: name: sdist path: ./dist/*.tar.gz diff --git a/.github/workflows/release_reminder.yml b/.github/workflows/release_reminder.yml deleted file mode 100644 index f838c8d57a..0000000000 --- a/.github/workflows/release_reminder.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Create a release reminder - -on: - schedule: - # Run at 10 am UTC on days-of-month 1 and 28 in January, May, and September. - - cron: "0 10 1,28 1,5,9 *" - -permissions: - contents: read - issues: write - -jobs: - remind: - if: github.repository_owner == 'pybamm-team' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: JasonEtco/create-an-issue@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - filename: .github/release_reminder.md diff --git a/.github/workflows/run_benchmarks_over_history.yml b/.github/workflows/run_benchmarks_over_history.yml index d01564b210..28960fb4da 100644 --- a/.github/workflows/run_benchmarks_over_history.yml +++ b/.github/workflows/run_benchmarks_over_history.yml @@ -18,6 +18,10 @@ on: ncommits: description: "Number of commits to benchmark between commit_start and commit_end" default: "100" + +env: + PYBAMM_DISABLE_TELEMETRY: "true" + jobs: benchmarks: runs-on: ubuntu-latest @@ -46,7 +50,7 @@ jobs: ${{ github.event.inputs.commit_start }}..${{ github.event.inputs.commit_end }} - name: Upload results as artifact - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.3 with: name: asv_over_history_results path: results diff --git a/.github/workflows/run_periodic_tests.yml b/.github/workflows/run_periodic_tests.yml index 9f10a9c6f7..bb164e9351 100644 --- a/.github/workflows/run_periodic_tests.yml +++ b/.github/workflows/run_periodic_tests.yml @@ -13,6 +13,7 @@ on: - cron: "0 3 * * *" env: + PYBAMM_DISABLE_TELEMETRY: "true" FORCE_COLOR: 3 PYBAMM_IDAKLU_EXPR_CASADI: ON PYBAMM_IDAKLU_EXPR_IREE: ON @@ -31,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ ubuntu-latest, macos-12, macos-14, windows-latest ] + os: [ ubuntu-latest, macos-13, macos-14, windows-latest ] python-version: [ "3.9", "3.10", "3.11", "3.12" ] name: Tests (${{ matrix.os }} / Python ${{ matrix.python-version }}) @@ -46,7 +47,7 @@ jobs: sudo apt-get install gfortran gcc graphviz pandoc libopenblas-dev texlive-latex-extra dvipng - name: Install macOS system dependencies - if: matrix.os == 'macos-12' || matrix.os == 'macos-14' + if: matrix.os == 'macos-13' || matrix.os == 'macos-14' env: HOMEBREW_NO_INSTALL_CLEANUP: 1 HOMEBREW_NO_AUTO_UPDATE: 1 @@ -89,7 +90,7 @@ jobs: - name: Upload coverage report if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.0.2 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 8b33553737..6d38fcadb6 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -59,7 +59,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + uses: actions/upload-artifact@184d73b71b93c222403b2e7f1ffebe4508014249 # v4.4.1 with: name: SARIF file path: results.sarif @@ -68,6 +68,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5 + uses: github/codeql-action/upload-sarif@ea9e4e37992a54ee68a9622e985e60c8e8f12d9f # v3.27.4 with: sarif_file: results.sarif diff --git a/.github/workflows/test_on_push.yml b/.github/workflows/test_on_push.yml index 9224b7df36..d54259bbd3 100644 --- a/.github/workflows/test_on_push.yml +++ b/.github/workflows/test_on_push.yml @@ -5,6 +5,7 @@ on: pull_request: env: + PYBAMM_DISABLE_TELEMETRY: "true" FORCE_COLOR: 3 PYBAMM_IDAKLU_EXPR_CASADI: ON PYBAMM_IDAKLU_EXPR_IREE: ON @@ -36,12 +37,11 @@ jobs: pre-commit run -a run_unit_integration_and_coverage_tests: - needs: style runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-12, macos-14, windows-latest] + os: [ubuntu-latest, macos-13, macos-14, windows-latest] python-version: ["3.9", "3.10", "3.11", "3.12"] name: Tests (${{ matrix.os }} / Python ${{ matrix.python-version }}) @@ -65,7 +65,7 @@ jobs: sudo apt-get install libopenblas-dev texlive-latex-extra dvipng - name: Install macOS system dependencies - if: matrix.os == 'macos-12' || matrix.os == 'macos-14' + if: matrix.os == 'macos-13' || matrix.os == 'macos-14' env: HOMEBREW_NO_INSTALL_CLEANUP: 1 HOMEBREW_NO_AUTO_UPDATE: 1 @@ -123,7 +123,7 @@ jobs: - name: Upload coverage report if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.0.2 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -132,7 +132,6 @@ jobs: # Skips IDAKLU module compilation for speedups, which is already tested in other jobs. run_doctests: - needs: style runs-on: ubuntu-latest strategy: fail-fast: false @@ -177,7 +176,6 @@ jobs: run: python -m nox -s docs run_example_tests: - needs: style runs-on: ubuntu-latest strategy: fail-fast: false @@ -233,7 +231,6 @@ jobs: run: python -m nox -s examples run_scripts_tests: - needs: style runs-on: ubuntu-latest strategy: fail-fast: false diff --git a/.github/workflows/work_precision_sets.yml b/.github/workflows/work_precision_sets.yml index fafc5b1738..5810956786 100644 --- a/.github/workflows/work_precision_sets.yml +++ b/.github/workflows/work_precision_sets.yml @@ -5,6 +5,9 @@ on: types: [published] workflow_dispatch: +env: + PYBAMM_DISABLE_TELEMETRY: "true" + jobs: benchmarks_on_release: if: github.repository_owner == 'pybamm-team' @@ -27,7 +30,7 @@ jobs: python benchmarks/work_precision_sets/time_vs_reltols.py python benchmarks/work_precision_sets/time_vs_abstols.py - name: Create Pull Request - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v7 with: delete-branch: true branch-suffix: short-commit-hash diff --git a/.gitignore b/.gitignore index 42c76b7c55..8632f96d30 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,6 @@ input/* # simulation outputs out/ -config.py matplotlibrc *.pickle *.sav @@ -65,6 +64,7 @@ coverage.xml htmlcov/ # virtual environment +.venv env/ venv/ venv3.5/ diff --git a/.lycheeignore b/.lycheeignore index 55a4a4c623..929fe36475 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -15,3 +15,6 @@ file:///home/runner/work/PyBaMM/PyBaMM/docs/source/user_guide/fundamentals/pybam # Errors in docs/source/user_guide/index.md file:///home/runner/work/PyBaMM/PyBaMM/docs/source/user_guide/api_docs + +# Telemetry +https://us.i.posthog.com diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43928bbc56..fa5a7336f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.6.3" + rev: "v0.7.4" hooks: - id: ruff args: [--fix, --show-fixes] @@ -13,13 +13,13 @@ repos: types_or: [python, pyi, jupyter] - repo: https://github.com/adamchainz/blacken-docs - rev: "1.18.0" + rev: "1.19.1" hooks: - id: blacken-docs additional_dependencies: [black==23.*] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-added-large-files - id: check-case-conflict diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a959748f..b273435dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,54 @@ # [Unreleased](https://github.com/pybamm-team/PyBaMM/) +# [v24.11.0](https://github.com/pybamm-team/PyBaMM/tree/v24.11.0) - 2024-11-20 + +## Features + +- Added `CoupledVariable` which provides a placeholder variable whose equation can be elsewhere in the model. ([#4556](https://github.com/pybamm-team/PyBaMM/pull/4556)) +- Adds support to `pybamm.Experiment` for the `output_variables` option in the `IDAKLUSolver`. ([#4534](https://github.com/pybamm-team/PyBaMM/pull/4534)) +- Adds an option "voltage as a state" that can be "false" (default) or "true". If "true" adds an explicit algebraic equation for the voltage. ([#4507](https://github.com/pybamm-team/PyBaMM/pull/4507)) +- Improved `QuickPlot` accuracy for simulations with Hermite interpolation. ([#4483](https://github.com/pybamm-team/PyBaMM/pull/4483)) +- Added Hermite interpolation to the (`IDAKLUSolver`) that improves the accuracy and performance of post-processing variables. ([#4464](https://github.com/pybamm-team/PyBaMM/pull/4464)) +- Added basic telemetry to record which functions are being run. See [Telemetry section in the User Guide](https://docs.pybamm.org/en/latest/source/user_guide/index.html#telemetry) for more information. ([#4441](https://github.com/pybamm-team/PyBaMM/pull/4441)) +- Added `BasicDFN` model for sodium-ion batteries ([#4451](https://github.com/pybamm-team/PyBaMM/pull/4451)) +- Added sensitivity calculation support for `pybamm.Simulation` and `pybamm.Experiment` ([#4415](https://github.com/pybamm-team/PyBaMM/pull/4415)) +- Added OpenMP parallelization to IDAKLU solver for lists of input parameters ([#4449](https://github.com/pybamm-team/PyBaMM/pull/4449)) +- Added phase-dependent particle options to LAM ([#4369](https://github.com/pybamm-team/PyBaMM/pull/4369)) +- Added a lithium ion equivalent circuit model with split open circuit voltages for each electrode (`SplitOCVR`). ([#4330](https://github.com/pybamm-team/PyBaMM/pull/4330)) +- Added the `pybamm.DiscreteTimeSum` expression node to sum an expression over a sequence of data times, and accompanying `pybamm.DiscreteTimeData` class to store the data times and values ([#4501](https://github.com/pybamm-team/PyBaMM/pull/4501)) + +## Optimizations + +- Performance refactor of JAX BDF Solver with default Jax method set to `"BDF"`. ([#4456](https://github.com/pybamm-team/PyBaMM/pull/4456)) +- Improved performance of initialization and reinitialization of ODEs in the (`IDAKLUSolver`). ([#4453](https://github.com/pybamm-team/PyBaMM/pull/4453)) +- Removed the `start_step_offset` setting and disabled minimum `dt` warnings for drive cycles with the (`IDAKLUSolver`). ([#4416](https://github.com/pybamm-team/PyBaMM/pull/4416)) + +## Bug Fixes +- Added error for binary operators on two concatenations with different numbers of children. Previously, the extra children were dropped. Also fixed bug where Q_rxn was dropped from the total heating term in half-cell models. ([#4562](https://github.com/pybamm-team/PyBaMM/pull/4562)) +- Fixed bug where Q_rxn was set to 0 for the negative electrode in half-cell models. ([#4557](https://github.com/pybamm-team/PyBaMM/pull/4557)) +- Fixed bug in post-processing solutions with infeasible experiments using the (`IDAKLUSolver`). ([#4541](https://github.com/pybamm-team/PyBaMM/pull/4541)) +- Disabled IREE on MacOS due to compatibility issues and added the CasADI + path to the environment to resolve issues on MacOS and Linux. Windows + users may still experience issues with interpolation. ([#4528](https://github.com/pybamm-team/PyBaMM/pull/4528)) +- Added `_from_json()` functionality to `Sign` which was erroneously omitted previously. ([#4517](https://github.com/pybamm-team/PyBaMM/pull/4517)) +- Fixed bug where IDAKLU solver failed when `output variables` were specified and an extrapolation event is present. ([#4440](https://github.com/pybamm-team/PyBaMM/pull/4440)) + +## Breaking changes + +- Deprecated `pybamm.Simulation.set_parameters` and `pybamm.Simulation. set_up_and_parameterise_experiment` functions in `pybamm.simulation.py`. ([#3752](https://github.com/pybamm-team/PyBaMM/pull/3752)) +- Removed all instances of `param = self.param` and now directly access `self.param` across the codebase. This change simplifies parameter references and enhances readability. ([#4484](https://github.com/pybamm-team/PyBaMM/pull/4494)) +- Removed the deprecation warning for the chemistry argument in + ParameterValues ([#4466](https://github.com/pybamm-team/PyBaMM/pull/4466)) +- The parameters "... electrode OCP entropic change [V.K-1]" and "... electrode volume change" are now expected to be functions of stoichiometry only instead of functions of both stoichiometry and maximum concentration ([#4427](https://github.com/pybamm-team/PyBaMM/pull/4427)) +- Renamed `set_events` function to `add_events_from` to better reflect its purpose. ([#4421](https://github.com/pybamm-team/PyBaMM/pull/4421)) + # [v24.9.0](https://github.com/pybamm-team/PyBaMM/tree/v24.9.0) - 2024-09-03 ## Features - Added additional user-configurable options to the (`IDAKLUSolver`) and adjusted the default values to improve performance. ([#4282](https://github.com/pybamm-team/PyBaMM/pull/4282)) - Added the diffusion element to be used in the Thevenin model. ([#4254](https://github.com/pybamm-team/PyBaMM/pull/4254)) +- Added lumped surface thermal model ([#4203](https://github.com/pybamm-team/PyBaMM/pull/4203)) ## Optimizations diff --git a/CITATION.cff b/CITATION.cff index d128cf485e..aee304dbde 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -24,6 +24,6 @@ keywords: - "expression tree" - "python" - "symbolic differentiation" -version: "24.9.0" +version: "24.11.0" repository-code: "https://github.com/pybamm-team/PyBaMM" title: "Python Battery Mathematical Modelling (PyBaMM)" diff --git a/CMakeLists.txt b/CMakeLists.txt index ad56ac34ca..ec594e5ca5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,7 +19,7 @@ endif() project(idaklu) -set(CMAKE_CXX_STANDARD 14) +set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_EXPORT_COMPILE_COMMANDS 1) @@ -82,6 +82,8 @@ pybind11_add_module(idaklu src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.cpp src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp + src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.cpp + src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.hpp src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP_solvers.cpp @@ -94,6 +96,8 @@ pybind11_add_module(idaklu src/pybamm/solvers/c_solvers/idaklu/common.cpp src/pybamm/solvers/c_solvers/idaklu/Solution.cpp src/pybamm/solvers/c_solvers/idaklu/Solution.hpp + src/pybamm/solvers/c_solvers/idaklu/SolutionData.cpp + src/pybamm/solvers/c_solvers/idaklu/SolutionData.hpp src/pybamm/solvers/c_solvers/idaklu/Options.hpp src/pybamm/solvers/c_solvers/idaklu/Options.cpp # IDAKLU expressions / function evaluation [abstract] @@ -101,6 +105,8 @@ pybind11_add_module(idaklu src/pybamm/solvers/c_solvers/idaklu/Expressions/Base/Expression.hpp src/pybamm/solvers/c_solvers/idaklu/Expressions/Base/ExpressionSet.hpp src/pybamm/solvers/c_solvers/idaklu/Expressions/Base/ExpressionTypes.hpp + src/pybamm/solvers/c_solvers/idaklu/observe.hpp + src/pybamm/solvers/c_solvers/idaklu/observe.cpp # IDAKLU expressions - concrete implementations ${IDAKLU_EXPR_CASADI_SOURCE_FILES} ${IDAKLU_EXPR_IREE_SOURCE_FILES} @@ -138,6 +144,23 @@ set_target_properties( INSTALL_RPATH_USE_LINK_PATH TRUE ) +# openmp +if (${CMAKE_SYSTEM_NAME} MATCHES "Darwin") + execute_process( + COMMAND "brew" "--prefix" + OUTPUT_VARIABLE HOMEBREW_PREFIX + OUTPUT_STRIP_TRAILING_WHITESPACE) + if (OpenMP_ROOT) + set(OpenMP_ROOT "${OpenMP_ROOT}:${HOMEBREW_PREFIX}/opt/libomp") + else() + set(OpenMP_ROOT "${HOMEBREW_PREFIX}/opt/libomp") + endif() +endif() +find_package(OpenMP) +if(OpenMP_CXX_FOUND) + target_link_libraries(idaklu PRIVATE OpenMP::OpenMP_CXX) +endif() + set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${PROJECT_SOURCE_DIR}) # Sundials find_package(SUNDIALS REQUIRED) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 556a732518..eb510f7054 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ You now have everything you need to start making changes! ### B. Writing your code -6. PyBaMM is developed in [Python](https://www.python.org)), and makes heavy use of [NumPy](https://numpy.org/) (see also [NumPy for MatLab users](https://numpy.org/doc/stable/user/numpy-for-matlab-users.html) and [Python for R users](https://www.rebeccabarter.com/blog/2023-09-11-from_r_to_python)). +6. PyBaMM is developed in [Python](https://www.python.org), and makes heavy use of [NumPy](https://numpy.org/). 7. Make sure to follow our [coding style guidelines](#coding-style-guidelines). 8. Commit your changes to your branch with [useful, descriptive commit messages](https://chris.beams.io/posts/git-commit/): Remember these are publicly visible and should still make sense a few months ahead in time. @@ -116,8 +116,8 @@ PyBaMM provides a utility function `import_optional_dependency`, to check for th Optional dependencies should never be imported at the module level, but always inside methods. For example: -``` -def use_pybtex(x,y,z): +```python +def use_pybtex(x, y, z): pybtex = import_optional_dependency("pybtex") ... ``` @@ -468,8 +468,8 @@ Editable notebooks are made available using [Google Colab](https://colab.researc GitHub does some magic with particular filenames. In particular: -- The first page people see when they go to [our GitHub page](https://github.com/pybamm-team/PyBaMM) displays the contents of [README.md](https://github.com/pybamm-team/PyBaMM/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 PyBaMM is stored in [LICENSE](https://github.com/pybamm-team/PyBaMM/blob/develop/LICENSE.txt), and [automatically](https://help.github.com/articles/adding-a-license-to-a-repository/) linked to by GitHub. +- The first page people see when they go to [our GitHub page](https://github.com/pybamm-team/PyBaMM) displays the contents of [README.md](https://github.com/pybamm-team/PyBaMM/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://docs.github.com/articles/about-readmes/). +- The license for using PyBaMM is stored in [LICENSE](https://github.com/pybamm-team/PyBaMM/blob/develop/LICENSE.txt), and [automatically](https://docs.github.com/articles/adding-a-license-to-a-repository/) linked to by GitHub. - This file, [CONTRIBUTING.md](https://github.com/pybamm-team/PyBaMM/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 diff --git a/README.md b/README.md index a904e5a67c..2b5250d856 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/pybamm-team/PyBaMM/badge)](https://scorecard.dev/viewer/?uri=github.com/pybamm-team/PyBaMM) -[![All Contributors](https://img.shields.io/badge/all_contributors-90-orange.svg)](#-contributors) +[![All Contributors](https://img.shields.io/badge/all_contributors-93-orange.svg)](#-contributors) diff --git a/all_contributors.md b/all_contributors.md index 9bb1e373d5..d4a41ba8e1 100644 --- a/all_contributors.md +++ b/all_contributors.md @@ -91,7 +91,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Agnik Bakshi
Agnik Bakshi

📖 RuiheLi
RuiheLi

💻 ⚠️ chmabaur
chmabaur

🐛 💻 - Abhishek Chaudhari
Abhishek Chaudhari

📖 💻 + Abhishek Chaudhari
Abhishek Chaudhari

📖 💻 ⚠️ Shubham Bhardwaj
Shubham Bhardwaj

🚇 Jonathan Lauber
Jonathan Lauber

🚇 @@ -120,6 +120,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Ubham16
Ubham16

💻 Mehrdad Babazadeh
Mehrdad Babazadeh

💻 ⚠️ Pip Liggins
Pip Liggins

💻 ⚠️ + Medha Bhardwaj
Medha Bhardwaj

💻 + + + Marc Berliner
Marc Berliner

💻 📖 🚇 🚧 + Aswinr24
Aswinr24

⚠️ diff --git a/conftest.py b/conftest.py index 7ac6cf3c74..77513d56db 100644 --- a/conftest.py +++ b/conftest.py @@ -51,3 +51,8 @@ def set_random_seed(): @pytest.fixture(autouse=True) def set_debug_value(): pybamm.settings.debug_mode = True + + +@pytest.fixture(autouse=True) +def disable_telemetry(): + pybamm.telemetry.disable() diff --git a/docs/conf.py b/docs/conf.py index 55a4ac3f61..76dcffb18b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -341,7 +341,7 @@ {% set github_docname = 'github/pybamm-team/pybamm/blob/develop/docs/' + -env.doc2path(env.docname, base=None) %} +env.doc2path(env.docname, base=None) | string() %} {% set notebooks_version = env.config.html_context.notebooks_version %} {% set github_download_url = env.config.html_context.github_download_url %} diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 33be0235a7..4667752157 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -9,10 +9,10 @@ API documentation :Release: |version| :Date: |today| -This reference manual details functions, modules, and objects -included in PyBaMM, describing what they are and what they do. +This reference manual details the classes, functions, modules, and objects included in PyBaMM, describing what they are and what they do. For a high-level introduction to PyBaMM, see the :ref:`user guide ` and the :ref:`examples `. + .. toctree:: :maxdepth: 2 diff --git a/docs/source/api/models/lithium_ion/ecm_split_ocv.rst b/docs/source/api/models/lithium_ion/ecm_split_ocv.rst new file mode 100644 index 0000000000..a7d833cf55 --- /dev/null +++ b/docs/source/api/models/lithium_ion/ecm_split_ocv.rst @@ -0,0 +1,7 @@ +Equivalent Circuit Model with Split OCV (SplitOCVR) +===================================================== + +.. autoclass:: pybamm.lithium_ion.SplitOCVR + :members: + +.. footbibliography:: diff --git a/docs/source/api/models/lithium_ion/index.rst b/docs/source/api/models/lithium_ion/index.rst index 1a72c3c662..52efe44d6b 100644 --- a/docs/source/api/models/lithium_ion/index.rst +++ b/docs/source/api/models/lithium_ion/index.rst @@ -12,3 +12,4 @@ Lithium-ion Models msmr yang2017 electrode_soh + ecm_split_ocv diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index 824ec6126d..9cf8d09470 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -19,3 +19,5 @@ Utility functions .. autofunction:: pybamm.has_jax .. autofunction:: pybamm.is_jax_compatible + +.. autofunction:: pybamm.set_logging_level diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index a5958b327b..6ddaf5867e 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -54,6 +54,7 @@ The notebooks are organised into subfolders, and can be viewed in the galleries notebooks/models/DFN-with-particle-size-distributions.ipynb notebooks/models/DFN.ipynb notebooks/models/electrode-state-of-health.ipynb + notebooks/models/graded-electrodes.ipynb notebooks/models/half-cell.ipynb notebooks/models/jelly-roll-model.ipynb notebooks/models/latexify.ipynb @@ -67,6 +68,7 @@ The notebooks are organised into subfolders, and can be viewed in the galleries notebooks/models/SEI-on-cracks.ipynb notebooks/models/simulate-3E-cell.ipynb notebooks/models/simulating-ORegan-2022-parameter-set.ipynb + notebooks/models/sodium-ion.ipynb notebooks/models/SPM.ipynb notebooks/models/SPMe.ipynb notebooks/models/submodel_cracking_DFN_or_SPM.ipynb @@ -85,6 +87,7 @@ The notebooks are organised into subfolders, and can be viewed in the galleries notebooks/parameterization/change-input-current.ipynb notebooks/parameterization/parameter-values.ipynb notebooks/parameterization/parameterization.ipynb + notebooks/parameterization/sensitivities_and_data_fitting.ipynb .. nbgallery:: :caption: Simulations and Experiments diff --git a/docs/source/examples/notebooks/getting_started/tutorial-4-setting-parameter-values.ipynb b/docs/source/examples/notebooks/getting_started/tutorial-4-setting-parameter-values.ipynb index a35a81932f..02206d4210 100644 --- a/docs/source/examples/notebooks/getting_started/tutorial-4-setting-parameter-values.ipynb +++ b/docs/source/examples/notebooks/getting_started/tutorial-4-setting-parameter-values.ipynb @@ -25,18 +25,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "\n", - "\u001B[1m[\u001B[0m\u001B[34;49mnotice\u001B[0m\u001B[1;39;49m]\u001B[0m\u001B[39;49m A new release of pip is available: \u001B[0m\u001B[31;49m23.3.1\u001B[0m\u001B[39;49m -> \u001B[0m\u001B[32;49m24.0\u001B[0m\n", - "\u001B[1m[\u001B[0m\u001B[34;49mnotice\u001B[0m\u001B[1;39;49m]\u001B[0m\u001B[39;49m To update, run: \u001B[0m\u001B[32;49mpip install --upgrade pip\u001B[0m\n", "Note: you may need to restart the kernel to use updated packages.\n" ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "An NVIDIA GPU may be present on this machine, but a CUDA-enabled jaxlib is not installed. Falling back to cpu.\n" - ] } ], "source": [ @@ -74,7 +64,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The parameter values are stored in a dictionary" + "The parameter values are stored in a dictionary-like object of class [`pybamm.ParameterValues`](https://docs.pybamm.org/en/latest/source/api/parameters/parameter_values.html). " ] }, { @@ -98,8 +88,8 @@ " 'EC initial concentration in electrolyte [mol.m-3]': 4541.0,\n", " 'Electrode height [m]': 0.065,\n", " 'Electrode width [m]': 1.58,\n", - " 'Electrolyte conductivity [S.m-1]': ,\n", - " 'Electrolyte diffusivity [m2.s-1]': ,\n", + " 'Electrolyte conductivity [S.m-1]': ,\n", + " 'Electrolyte diffusivity [m2.s-1]': ,\n", " 'Electron charge [C]': 1.602176634e-19,\n", " 'Faraday constant [C.mol-1]': 96485.33212,\n", " 'Ideal gas constant [J.K-1.mol-1]': 8.314462618,\n", @@ -125,14 +115,14 @@ " 'Negative current collector thickness [m]': 1.2e-05,\n", " 'Negative electrode Bruggeman coefficient (electrode)': 0,\n", " 'Negative electrode Bruggeman coefficient (electrolyte)': 1.5,\n", - " 'Negative electrode OCP [V]': ,\n", + " 'Negative electrode OCP [V]': ,\n", " 'Negative electrode OCP entropic change [V.K-1]': 0.0,\n", " 'Negative electrode active material volume fraction': 0.75,\n", " 'Negative electrode charge transfer coefficient': 0.5,\n", " 'Negative electrode conductivity [S.m-1]': 215.0,\n", " 'Negative electrode density [kg.m-3]': 1657.0,\n", " 'Negative electrode double-layer capacity [F.m-2]': 0.2,\n", - " 'Negative electrode exchange-current density [A.m-2]': ,\n", + " 'Negative electrode exchange-current density [A.m-2]': ,\n", " 'Negative electrode porosity': 0.25,\n", " 'Negative electrode reaction-driven LAM factor [m3.mol-1]': 0.0,\n", " 'Negative electrode specific heat capacity [J.kg-1.K-1]': 700.0,\n", @@ -155,14 +145,14 @@ " 'Positive current collector thickness [m]': 1.6e-05,\n", " 'Positive electrode Bruggeman coefficient (electrode)': 0,\n", " 'Positive electrode Bruggeman coefficient (electrolyte)': 1.5,\n", - " 'Positive electrode OCP [V]': ,\n", + " 'Positive electrode OCP [V]': ,\n", " 'Positive electrode OCP entropic change [V.K-1]': 0.0,\n", " 'Positive electrode active material volume fraction': 0.665,\n", " 'Positive electrode charge transfer coefficient': 0.5,\n", " 'Positive electrode conductivity [S.m-1]': 0.18,\n", " 'Positive electrode density [kg.m-3]': 3262.0,\n", " 'Positive electrode double-layer capacity [F.m-2]': 0.2,\n", - " 'Positive electrode exchange-current density [A.m-2]': ,\n", + " 'Positive electrode exchange-current density [A.m-2]': ,\n", " 'Positive electrode porosity': 0.335,\n", " 'Positive electrode reaction-driven LAM factor [m3.mol-1]': 0.0,\n", " 'Positive electrode specific heat capacity [J.kg-1.K-1]': 700.0,\n", @@ -243,8 +233,8 @@ "output_type": "stream", "text": [ "EC initial concentration in electrolyte [mol.m-3]\t4541.0\n", - "Electrolyte conductivity [S.m-1]\t\n", - "Electrolyte diffusivity [m2.s-1]\t\n", + "Electrolyte conductivity [S.m-1]\t\n", + "Electrolyte diffusivity [m2.s-1]\t\n", "Initial concentration in electrolyte [mol.m-3]\t1000.0\n", "Negative electrode Bruggeman coefficient (electrolyte)\t1.5\n", "Positive electrode Bruggeman coefficient (electrolyte)\t1.5\n", @@ -274,12 +264,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "2ac62159d85445f0b021b8800750726f", + "model_id": "5dd5facebda342afa83dca4f0838788c", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=3555.448018330181, step=35.55448018330181), …" + "interactive(children=(FloatSlider(value=0.0, description='t', max=3555.448018679505, step=35.55448018679505), …" ] }, "metadata": {}, @@ -288,7 +278,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 6, @@ -324,55 +314,58 @@ "name": "stdout", "output_type": "stream", "text": [ - "| Parameter | Type of parameter |\n", - "| ========================================================= | =========================================================================================================================================================================================================== |\n", - "| Maximum concentration in positive electrode [mol.m-3] | Parameter |\n", - "| Maximum concentration in negative electrode [mol.m-3] | Parameter |\n", - "| Nominal cell capacity [A.h] | Parameter |\n", - "| Electrode width [m] | Parameter |\n", - "| Positive electrode Bruggeman coefficient (electrode) | Parameter |\n", - "| Faraday constant [C.mol-1] | Parameter |\n", - "| Number of electrodes connected in parallel to make a cell | Parameter |\n", - "| Negative electrode Bruggeman coefficient (electrode) | Parameter |\n", - "| Initial concentration in electrolyte [mol.m-3] | Parameter |\n", - "| Electrode height [m] | Parameter |\n", - "| Lower voltage cut-off [V] | Parameter |\n", - "| Upper voltage cut-off [V] | Parameter |\n", - "| Negative electrode Bruggeman coefficient (electrolyte) | Parameter |\n", - "| Separator Bruggeman coefficient (electrolyte) | Parameter |\n", - "| Number of cells connected in series to make a battery | Parameter |\n", - "| Ideal gas constant [J.K-1.mol-1] | Parameter |\n", - "| Positive electrode thickness [m] | Parameter |\n", - "| Reference temperature [K] | Parameter |\n", - "| Initial temperature [K] | Parameter |\n", - "| Positive electrode Bruggeman coefficient (electrolyte) | Parameter |\n", - "| Negative electrode thickness [m] | Parameter |\n", - "| Separator thickness [m] | Parameter |\n", - "| Electrolyte conductivity [S.m-1] | FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Temperature [K]' |\n", - "| Positive electrode OCP [V] | FunctionParameter with inputs(s) 'Positive particle stoichiometry' |\n", - "| Negative particle radius [m] | FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' |\n", - "| Positive electrode OCP entropic change [V.K-1] | FunctionParameter with inputs(s) 'Positive particle stoichiometry', 'Maximum positive particle surface concentration [mol.m-3]' |\n", - "| Negative electrode porosity | FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' |\n", - "| Positive particle radius [m] | FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' |\n", - "| Positive electrode active material volume fraction | FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' |\n", - "| Ambient temperature [K] | FunctionParameter with inputs(s) 'Distance across electrode width [m]', 'Distance across electrode height [m]', 'Time [s]' |\n", - "| Initial concentration in positive electrode [mol.m-3] | FunctionParameter with inputs(s) 'Radial distance (r) [m]', 'Through-cell distance (x) [m]' |\n", - "| Cation transference number | FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Temperature [K]' |\n", - "| Negative electrode OCP [V] | FunctionParameter with inputs(s) 'Negative particle stoichiometry' |\n", - "| Negative particle diffusivity [m2.s-1] | FunctionParameter with inputs(s) 'Negative particle stoichiometry', 'Temperature [K]' |\n", - "| Thermodynamic factor | FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Temperature [K]' |\n", - "| Positive electrode exchange-current density [A.m-2] | FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Positive particle surface concentration [mol.m-3]', 'Maximum positive particle surface concentration [mol.m-3]', 'Temperature [K]' |\n", - "| Negative electrode active material volume fraction | FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' |\n", - "| Positive particle diffusivity [m2.s-1] | FunctionParameter with inputs(s) 'Positive particle stoichiometry', 'Temperature [K]' |\n", - "| Positive electrode porosity | FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' |\n", - "| Positive electrode conductivity [S.m-1] | FunctionParameter with inputs(s) 'Temperature [K]' |\n", - "| Initial concentration in negative electrode [mol.m-3] | FunctionParameter with inputs(s) 'Radial distance (r) [m]', 'Through-cell distance (x) [m]' |\n", - "| Negative electrode OCP entropic change [V.K-1] | FunctionParameter with inputs(s) 'Negative particle stoichiometry', 'Maximum negative particle surface concentration [mol.m-3]' |\n", - "| Current function [A] | FunctionParameter with inputs(s) 'Time [s]' |\n", - "| Electrolyte diffusivity [m2.s-1] | FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Temperature [K]' |\n", - "| Separator porosity | FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' |\n", - "| Negative electrode exchange-current density [A.m-2] | FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Negative particle surface concentration [mol.m-3]', 'Maximum negative particle surface concentration [mol.m-3]', 'Temperature [K]' |\n", - "| Negative electrode conductivity [S.m-1] | FunctionParameter with inputs(s) 'Temperature [K]' |\n" + "┌───────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐\n", + "│ Parameter │ Type of parameter │\n", + "├───────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤\n", + "│ Positive electrode Bruggeman coefficient (electrode) │ Parameter │\n", + "│ Faraday constant [C.mol-1] │ Parameter │\n", + "│ Separator Bruggeman coefficient (electrolyte) │ Parameter │\n", + "│ Reference temperature [K] │ Parameter │\n", + "│ Upper voltage cut-off [V] │ Parameter │\n", + "│ Lower voltage cut-off [V] │ Parameter │\n", + "│ Negative electrode thickness [m] │ Parameter │\n", + "│ Initial concentration in electrolyte [mol.m-3] │ Parameter │\n", + "│ Nominal cell capacity [A.h] │ Parameter │\n", + "│ Number of electrodes connected in parallel to make a cell │ Parameter │\n", + "│ Negative electrode Bruggeman coefficient (electrolyte) │ Parameter │\n", + "│ Separator thickness [m] │ Parameter │\n", + "│ Initial temperature [K] │ Parameter │\n", + "│ Maximum concentration in negative electrode [mol.m-3] │ Parameter │\n", + "│ Positive electrode Bruggeman coefficient (electrolyte) │ Parameter │\n", + "│ Positive electrode thickness [m] │ Parameter │\n", + "│ Ideal gas constant [J.K-1.mol-1] │ Parameter │\n", + "│ Maximum concentration in positive electrode [mol.m-3] │ Parameter │\n", + "│ Electrode height [m] │ Parameter │\n", + "│ Electrode width [m] │ Parameter │\n", + "│ Negative electrode Bruggeman coefficient (electrode) │ Parameter │\n", + "│ Number of cells connected in series to make a battery │ Parameter │\n", + "│ Negative electrode porosity │ FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' │\n", + "│ Positive particle radius [m] │ FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' │\n", + "│ Positive electrode OCP [V] │ FunctionParameter with inputs(s) 'Positive particle stoichiometry' │\n", + "│ Negative electrode OCP entropic change [V.K-1] │ FunctionParameter with inputs(s) 'Negative particle stoichiometry' │\n", + "│ Initial concentration in positive electrode [mol.m-3] │ FunctionParameter with inputs(s) 'Radial distance (r) [m]', 'Through-cell distance (x) [m]' │\n", + "│ Positive electrode conductivity [S.m-1] │ FunctionParameter with inputs(s) 'Temperature [K]' │\n", + "│ Negative electrode active material volume fraction │ FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' │\n", + "│ Negative particle diffusivity [m2.s-1] │ FunctionParameter with inputs(s) 'Negative particle stoichiometry', 'Temperature [K]' │\n", + "│ Initial concentration in negative electrode [mol.m-3] │ FunctionParameter with inputs(s) 'Radial distance (r) [m]', 'Through-cell distance (x) [m]' │\n", + "│ Positive electrode porosity │ FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' │\n", + "│ Positive electrode OCP entropic change [V.K-1] │ FunctionParameter with inputs(s) 'Positive particle stoichiometry' │\n", + "│ Electrolyte conductivity [S.m-1] │ FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Temperature [K]' │\n", + "│ Thermodynamic factor │ FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Temperature [K]' │\n", + "│ Electrolyte diffusivity [m2.s-1] │ FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Temperature [K]' │\n", + "│ Negative particle radius [m] │ FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' │\n", + "│ Negative electrode OCP [V] │ FunctionParameter with inputs(s) 'Negative particle stoichiometry' │\n", + "│ Cation transference number │ FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Temperature [K]' │\n", + "│ Ambient temperature [K] │ FunctionParameter with inputs(s) 'Distance across electrode width [m]', 'Distance across electrode height [m]', 'Time [s]' │\n", + "│ Current function [A] │ FunctionParameter with inputs(s) 'Time [s]' │\n", + "│ Negative electrode exchange-current density [A.m-2] │ FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Negative particle surface concentration [mol.m-3]', 'Maximum negative particle surface concentration [mol.m-3]', 'Temperature [K]' │\n", + "│ Negative electrode conductivity [S.m-1] │ FunctionParameter with inputs(s) 'Temperature [K]' │\n", + "│ Positive electrode exchange-current density [A.m-2] │ FunctionParameter with inputs(s) 'Electrolyte concentration [mol.m-3]', 'Positive particle surface concentration [mol.m-3]', 'Maximum positive particle surface concentration [mol.m-3]', 'Temperature [K]' │\n", + "│ Separator porosity │ FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' │\n", + "│ Positive electrode active material volume fraction │ FunctionParameter with inputs(s) 'Through-cell distance (x) [m]' │\n", + "│ Positive particle diffusivity [m2.s-1] │ FunctionParameter with inputs(s) 'Positive particle stoichiometry', 'Temperature [K]' │\n", + "└───────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘\n", + "\n" ] } ], @@ -424,12 +417,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "29a3805ee040456bbe863a52cc423492", + "model_id": "48c0f7150c154399b1d56dadd90a41ad", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=1703.071841649571, step=17.03071841649571), …" + "interactive(children=(FloatSlider(value=0.0, description='t', max=1703.0716533945217, step=17.030716533945217)…" ] }, "metadata": {}, @@ -438,7 +431,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 9, @@ -510,7 +503,7 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "f362d8ff79bc4b868f59470d58fdd9c6", + "model_id": "b8992b55090149ea932deb091190b655", "version_major": 2, "version_minor": 0 }, @@ -524,7 +517,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 11, @@ -539,6 +532,49 @@ "sim.plot([\"Current [A]\", \"Voltage [V]\"])" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Input parameters\n", + "\n", + "If the value of a parameter is expected to change often (e.g. running a parameter sweep) is is more convenient to set a parameter as an \"input parameter\". This is a placeholder that can be filled in with a numerical value when the model is solved.\n", + "\n", + "To set a parameter as an input parameter, we can set its value to the string `[input]` in the parameter values dictionary. For example, we can set the `Current function [A]` to be an input parameter and then run a parameter sweep over different current values like so:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "parameter_values[\"Current function [A]\"] = \"[input]\"\n", + "sim = pybamm.Simulation(model, parameter_values=parameter_values)\n", + "solns = []\n", + "for c in [0.1, 0.2, 0.3]:\n", + " soln = sim.solve([0, 3600], inputs={\"Current function [A]\": c})\n", + " plt.plot(soln[\"Time [s]\"].entries, soln[\"Voltage [V]\"].entries, label=f\"{c} A\")\n", + " solns.append(soln[\"Terminal voltage [V]\"].entries)\n", + "plt.xlabel(\"Time [s]\")\n", + "plt.ylabel(\"Terminal voltage [V]\")\n", + "plt.legend()\n", + "plt.show()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -550,7 +586,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -596,7 +632,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 14, "metadata": {}, "outputs": [ { @@ -604,10 +640,11 @@ "output_type": "stream", "text": [ "[1] Joel A. E. Andersson, Joris Gillis, Greg Horn, James B. Rawlings, and Moritz Diehl. CasADi – A software framework for nonlinear optimization and optimal control. Mathematical Programming Computation, 11(1):1–36, 2019. doi:10.1007/s12532-018-0139-4.\n", - "[2] Chang-Hui Chen, Ferran Brosa Planella, Kieran O'Regan, Dominika Gastol, W. Dhammika Widanage, and Emma Kendrick. Development of Experimental Techniques for Parameterization of Multi-scale Lithium-ion Battery Models. Journal of The Electrochemical Society, 167(8):080534, 2020. doi:10.1149/1945-7111/ab9050.\n", - "[3] Marc Doyle, Thomas F. Fuller, and John Newman. Modeling of galvanostatic charge and discharge of the lithium/polymer/insertion cell. Journal of the Electrochemical society, 140(6):1526–1533, 1993. doi:10.1149/1.2221597.\n", - "[4] Charles R. Harris, K. Jarrod Millman, Stéfan J. van der Walt, Ralf Gommers, Pauli Virtanen, David Cournapeau, Eric Wieser, Julian Taylor, Sebastian Berg, Nathaniel J. Smith, and others. Array programming with NumPy. Nature, 585(7825):357–362, 2020. doi:10.1038/s41586-020-2649-2.\n", - "[5] Valentin Sulzer, Scott G. Marquis, Robert Timms, Martin Robinson, and S. Jon Chapman. Python Battery Mathematical Modelling (PyBaMM). Journal of Open Research Software, 9(1):14, 2021. doi:10.5334/jors.309.\n", + "[2] Von DAG Bruggeman. Berechnung verschiedener physikalischer konstanten von heterogenen substanzen. i. dielektrizitätskonstanten und leitfähigkeiten der mischkörper aus isotropen substanzen. Annalen der physik, 416(7):636–664, 1935.\n", + "[3] Chang-Hui Chen, Ferran Brosa Planella, Kieran O'Regan, Dominika Gastol, W. Dhammika Widanage, and Emma Kendrick. Development of Experimental Techniques for Parameterization of Multi-scale Lithium-ion Battery Models. Journal of The Electrochemical Society, 167(8):080534, 2020. doi:10.1149/1945-7111/ab9050.\n", + "[4] Marc Doyle, Thomas F. Fuller, and John Newman. Modeling of galvanostatic charge and discharge of the lithium/polymer/insertion cell. Journal of the Electrochemical society, 140(6):1526–1533, 1993. doi:10.1149/1.2221597.\n", + "[5] Charles R. Harris, K. Jarrod Millman, Stéfan J. van der Walt, Ralf Gommers, Pauli Virtanen, David Cournapeau, Eric Wieser, Julian Taylor, Sebastian Berg, Nathaniel J. Smith, and others. Array programming with NumPy. Nature, 585(7825):357–362, 2020. doi:10.1038/s41586-020-2649-2.\n", + "[6] Valentin Sulzer, Scott G. Marquis, Robert Timms, Martin Robinson, and S. Jon Chapman. Python Battery Mathematical Modelling (PyBaMM). Journal of Open Research Software, 9(1):14, 2021. doi:10.5334/jors.309.\n", "\n" ] } @@ -619,7 +656,7 @@ ], "metadata": { "kernelspec": { - "display_name": "pybamm", + "display_name": "env", "language": "python", "name": "python3" }, @@ -633,7 +670,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.6" + "version": "3.10.12" }, "toc": { "base_numbering": 1, @@ -647,11 +684,6 @@ "toc_position": {}, "toc_section_display": true, "toc_window_display": true - }, - "vscode": { - "interpreter": { - "hash": "1a781583db2df3c2e87436f6d22cce842c2e50a5670da93a3bd820b97dc43011" - } } }, "nbformat": 4, diff --git a/docs/source/examples/notebooks/models/graded-electrodes.ipynb b/docs/source/examples/notebooks/models/graded-electrodes.ipynb new file mode 100644 index 0000000000..adb316ba2d --- /dev/null +++ b/docs/source/examples/notebooks/models/graded-electrodes.ipynb @@ -0,0 +1,277 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Simulating graded electrodes\n", + "\n", + "In this notebook we explore how to simulate the effect of graded electrodes in the performance of a battery. Graded electrodes have a composition that varies along the thickness of the electrode, typically active material volume fraction and particle size. This variation can be used to improve the performance of the battery, for example, by increasing the power density.\n", + "\n", + "As usual, we start by importing PyBaMM." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "An NVIDIA GPU may be present on this machine, but a CUDA-enabled jaxlib is not installed. Falling back to cpu.\n" + ] + } + ], + "source": [ + "%pip install \"pybamm[plot,cite]\" -q # install PyBaMM if it is not installed\n", + "import pybamm" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We use the DFN model for the simulations and the Chen2020 parameter set. Note that we will need to modify the default Chen2020 parameter set to describe graded electrodes." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "model = pybamm.lithium_ion.DFN()\n", + "parameter_values = pybamm.ParameterValues(\"Chen2020\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will vary the porosity in both electrodes and we will try three different scenarios: constant porosity, one where lower porosity occurs near the separator and one where lower porosity occurs near the current collector. All other parameters are kept constant. The varying porosity is defined to be linear centered around the default value and with a variation of $\\pm$ 10%.\n", + "\n", + "We define the varying porosities and store them in a list so we can loop over when solving the model." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "L_n = parameter_values[\"Negative electrode thickness [m]\"]\n", + "L_s = parameter_values[\"Separator thickness [m]\"]\n", + "L_p = parameter_values[\"Positive electrode thickness [m]\"]\n", + "\n", + "eps_n_0 = parameter_values[\"Negative electrode porosity\"]\n", + "eps_p_0 = parameter_values[\"Positive electrode porosity\"]\n", + "\n", + "eps_ns = [\n", + " eps_n_0,\n", + " lambda x: eps_n_0 * (1.1 - 0.2 * (x / L_n)),\n", + " lambda x: eps_n_0 * (0.9 + 0.2 * (x / L_n)),\n", + "]\n", + "\n", + "eps_ps = [\n", + " eps_p_0,\n", + " lambda x: eps_p_0 * (0.9 - 0.2 / L_p * (L_n + L_s) + 0.2 * (x / L_p)),\n", + " lambda x: eps_p_0 * (1.1 + 0.2 / L_p * (L_n + L_s) - 0.2 * (x / L_p)),\n", + "]" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note that the distance through the electrode is computed from the negative electrode, so parameter need to be defined accordingly. Next, we can just solve the models for the various parameter sets. We apply a fairly high C-rate to see the effect of the graded electrodes on the discharge capacity." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "solutions = []\n", + "\n", + "experiment = pybamm.Experiment([\"Discharge at 3C until 2.5 V\"])\n", + "\n", + "for eps_n, eps_p in zip(eps_ns, eps_ps):\n", + " parameter_values[\"Negative electrode porosity\"] = eps_n\n", + " parameter_values[\"Positive electrode porosity\"] = eps_p\n", + " sim = pybamm.Simulation(\n", + " model,\n", + " parameter_values=parameter_values,\n", + " experiment=experiment,\n", + " solver=pybamm.IDAKLUSolver(),\n", + " )\n", + " sol = sim.solve()\n", + " solutions.append(sol)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "And plot the results:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "99f847ca09da40cba550dd02dd8281a2", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(FloatSlider(value=0.0, description='t', max=673.9136958613059, step=6.7391369586130585),…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pybamm.dynamic_plot(\n", + " solutions,\n", + " labels=[\n", + " \"Constant porosity\",\n", + " \"Low porosity at separator\",\n", + " \"High porosity at separator\",\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We observe that, even though the average porosity is the same for the three cases the discharge capacity is much higher with the graded electrode where porosity is higher near the separator. This is because the higher porosity near the separator facilitates the ion transport and the better utilisation of the active material.\n", + "\n", + "As a sanity check we can plot the porosity profiles for the three cases and see they match what we intended." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "829b68c6b3e04e0ebe5537daabec2278", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(FloatSlider(value=0.0, description='t', max=673.9136958613059, step=6.7391369586130585),…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pybamm.dynamic_plot(\n", + " solutions,\n", + " output_variables=[\"Negative electrode porosity\", \"Positive electrode porosity\"],\n", + " labels=[\n", + " \"Constant porosity\",\n", + " \"Low porosity at separator\",\n", + " \"High porosity at separator\",\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[1] Joel A. E. Andersson, Joris Gillis, Greg Horn, James B. Rawlings, and Moritz Diehl. CasADi – A software framework for nonlinear optimization and optimal control. Mathematical Programming Computation, 11(1):1–36, 2019. doi:10.1007/s12532-018-0139-4.\n", + "[2] Von DAG Bruggeman. Berechnung verschiedener physikalischer konstanten von heterogenen substanzen. i. dielektrizitätskonstanten und leitfähigkeiten der mischkörper aus isotropen substanzen. Annalen der physik, 416(7):636–664, 1935.\n", + "[3] Chang-Hui Chen, Ferran Brosa Planella, Kieran O'Regan, Dominika Gastol, W. Dhammika Widanage, and Emma Kendrick. Development of Experimental Techniques for Parameterization of Multi-scale Lithium-ion Battery Models. Journal of The Electrochemical Society, 167(8):080534, 2020. doi:10.1149/1945-7111/ab9050.\n", + "[4] Marc Doyle, Thomas F. Fuller, and John Newman. Modeling of galvanostatic charge and discharge of the lithium/polymer/insertion cell. Journal of the Electrochemical society, 140(6):1526–1533, 1993. doi:10.1149/1.2221597.\n", + "[5] Charles R. Harris, K. Jarrod Millman, Stéfan J. van der Walt, Ralf Gommers, Pauli Virtanen, David Cournapeau, Eric Wieser, Julian Taylor, Sebastian Berg, Nathaniel J. Smith, and others. Array programming with NumPy. Nature, 585(7825):357–362, 2020. doi:10.1038/s41586-020-2649-2.\n", + "[6] Alan C. Hindmarsh. The PVODE and IDA algorithms. Technical Report, Lawrence Livermore National Lab., CA (US), 2000. doi:10.2172/802599.\n", + "[7] Alan C. Hindmarsh, Peter N. Brown, Keith E. Grant, Steven L. Lee, Radu Serban, Dan E. Shumaker, and Carol S. Woodward. SUNDIALS: Suite of nonlinear and differential/algebraic equation solvers. ACM Transactions on Mathematical Software (TOMS), 31(3):363–396, 2005. doi:10.1145/1089014.1089020.\n", + "[8] Peyman Mohtat, Suhak Lee, Jason B Siegel, and Anna G Stefanopoulou. Towards better estimability of electrode-specific state of health: decoding the cell expansion. Journal of Power Sources, 427:101–111, 2019.\n", + "[9] Valentin Sulzer, Scott G. Marquis, Robert Timms, Martin Robinson, and S. Jon Chapman. Python Battery Mathematical Modelling (PyBaMM). Journal of Open Research Software, 9(1):14, 2021. doi:10.5334/jors.309.\n", + "[10] Pauli Virtanen, Ralf Gommers, Travis E. Oliphant, Matt Haberland, Tyler Reddy, David Cournapeau, Evgeni Burovski, Pearu Peterson, Warren Weckesser, Jonathan Bright, and others. SciPy 1.0: fundamental algorithms for scientific computing in Python. Nature Methods, 17(3):261–272, 2020. doi:10.1038/s41592-019-0686-2.\n", + "[11] Andrew Weng, Jason B Siegel, and Anna Stefanopoulou. Differential voltage analysis for battery manufacturing process control. arXiv preprint arXiv:2303.07088, 2023.\n", + "\n" + ] + } + ], + "source": [ + "pybamm.print_citations()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.6" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb b/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb index ad428e6791..1ce1cca826 100644 --- a/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb +++ b/docs/source/examples/notebooks/models/loss_of_active_materials.ipynb @@ -30,7 +30,7 @@ "output_type": "stream", "text": [ "At t = 57.3387, , mxstep steps taken before reaching tout.\n", - "At t = 57.3387 and h = 7.05477e-15, the corrector convergence failed repeatedly or with |h| = hmin.\n", + "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3387, , mxstep steps taken before reaching tout.\n" ] @@ -83,12 +83,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "6b19474c3912495eb75217e009760637", + "model_id": "ccfc7ae873d1492197fa7b554339a3d7", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=2.329196798170269, step=0.02329196798170269)…" + "interactive(children=(FloatSlider(value=0.0, description='t', max=2.3291967981693755, step=0.02329196798169375…" ] }, "metadata": {}, @@ -97,7 +97,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 2, @@ -137,11 +137,11 @@ "output_type": "stream", "text": [ "At t = 57.3387, , mxstep steps taken before reaching tout.\n", - "At t = 57.3387 and h = 7.05477e-15, the corrector convergence failed repeatedly or with |h| = hmin.\n", + "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3387, , mxstep steps taken before reaching tout.\n", "At t = 57.3307, , mxstep steps taken before reaching tout.\n", - "At t = 57.3307, , mxstep steps taken before reaching tout.\n", + "At t = 57.3307 and h = 3.45325e-14, the corrector convergence failed repeatedly or with |h| = hmin.\n", "At t = 57.3307, , mxstep steps taken before reaching tout.\n", "At t = 57.3307, , mxstep steps taken before reaching tout.\n", "At t = 57.2504, , mxstep steps taken before reaching tout.\n", @@ -153,12 +153,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "789a681c8c574bb8b3d3016a844dd9a2", + "model_id": "b34472112ae344da92ccc8af5178c64b", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=2.329196798170269, step=0.02329196798170269)…" + "interactive(children=(FloatSlider(value=0.0, description='t', max=2.3291967981693755, step=0.02329196798169375…" ] }, "metadata": {}, @@ -167,7 +167,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 3, @@ -225,12 +225,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "ad36439975754b29bbbef1bd94379408", + "model_id": "60db1d0de494460493cc8edd5b61d4e7", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=1.8531298311682403, step=0.01853129831168240…" + "interactive(children=(FloatSlider(value=0.0, description='t', max=1.85353350947348, step=0.0185353350947348), …" ] }, "metadata": {}, @@ -239,7 +239,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 4, @@ -248,6 +248,16 @@ } ], "source": [ + "import pybamm\n", + "\n", + "experiment = pybamm.Experiment(\n", + " [\n", + " \"Discharge at 1C until 3 V\",\n", + " \"Rest for 600 seconds\",\n", + " \"Charge at 1C until 4.2 V\",\n", + " \"Hold at 4.199 V for 600 seconds\",\n", + " ]\n", + ")\n", "model = pybamm.lithium_ion.DFN(\n", " options={\n", " \"SEI\": \"solvent-diffusion limited\",\n", @@ -255,7 +265,7 @@ " }\n", ")\n", "param = pybamm.ParameterValues(\"Chen2020\")\n", - "param.update({\"Negative electrode reaction-driven LAM factor [m3.mol-1]\": 1e-3})\n", + "param.update({\"Negative electrode reaction-driven LAM factor [m3.mol-1]\": 1e-4})\n", "sim = pybamm.Simulation(\n", " model,\n", " experiment=experiment,\n", @@ -300,12 +310,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "91ea043e10d342049929095e48e98c5e", + "model_id": "1dfb1de5ccde449c9eefcda1b1f44468", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "interactive(children=(FloatSlider(value=0.0, description='t', max=1.8506629989989005, step=0.01850662998998900…" + "interactive(children=(FloatSlider(value=0.0, description='t', max=1.8506629988943608, step=0.01850662998894360…" ] }, "metadata": {}, @@ -314,7 +324,7 @@ { "data": { "text/plain": [ - "" + "" ] }, "execution_count": 5, @@ -358,6 +368,297 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## LAM with composite electrode\n", + "The LAM submodel is also compatible with multiple phases within an electrode for both stress- and reaction-driven loss of active material. Currently, there is no single parameter set that combines both LAM degradation and composite materials. The following examples use the Chen2020 composite parameter set with LAM parameters taken from the Ai2020 parameter set. " + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "# Volume change functions from Ai2020 parameters\n", + "\n", + "\n", + "def graphite_volume_change_Ai2020(sto):\n", + " p1 = 145.907\n", + " p2 = -681.229\n", + " p3 = 1334.442\n", + " p4 = -1415.710\n", + " p5 = 873.906\n", + " p6 = -312.528\n", + " p7 = 60.641\n", + " p8 = -5.706\n", + " p9 = 0.386\n", + " p10 = -4.966e-05\n", + " t_change = (\n", + " p1 * sto**9\n", + " + p2 * sto**8\n", + " + p3 * sto**7\n", + " + p4 * sto**6\n", + " + p5 * sto**5\n", + " + p6 * sto**4\n", + " + p7 * sto**3\n", + " + p8 * sto**2\n", + " + p9 * sto\n", + " + p10\n", + " )\n", + " return t_change\n", + "\n", + "\n", + "def lico2_volume_change_Ai2020(sto):\n", + " omega = pybamm.Parameter(\"Positive electrode partial molar volume [m3.mol-1]\")\n", + " c_s_max = pybamm.Parameter(\"Maximum concentration in positive electrode [mol.m-3]\")\n", + " t_change = omega * c_s_max * sto\n", + " return t_change" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Stress-driven composite anode\n", + "The secondary phase LAM parameters have been adjusted from the Ai2020 by about 10% to show less degradation in that phase. The model is set up in the same way the single-phase simulation is but with additional parameters." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "options = {\n", + " \"particle phases\": (\"2\", \"1\"),\n", + " \"open-circuit potential\": ((\"single\", \"current sigmoid\"), \"single\"),\n", + " \"loss of active material\": \"stress-driven\",\n", + "}\n", + "\n", + "model = pybamm.lithium_ion.SPM(options)\n", + "parameter_values = pybamm.ParameterValues(\"Chen2020_composite\")\n", + "second = 0.1\n", + "parameter_values.update(\n", + " {\n", + " \"Primary: Negative electrode LAM constant proportional term [s-1]\": 1e-4 / 3600,\n", + " \"Secondary: Negative electrode LAM constant proportional term [s-1]\": 1e-4\n", + " / 3600\n", + " * second,\n", + " \"Positive electrode LAM constant proportional term [s-1]\": 1e-4 / 3600,\n", + " \"Primary: Negative electrode partial molar volume [m3.mol-1]\": 3.1e-06,\n", + " \"Primary: Negative electrode Young's modulus [Pa]\": 15000000000.0,\n", + " \"Primary: Negative electrode Poisson's ratio\": 0.3,\n", + " \"Primary: Negative electrode critical stress [Pa]\": 60000000.0,\n", + " \"Secondary: Negative electrode critical stress [Pa]\": 60000000.0,\n", + " \"Primary: Negative electrode LAM constant exponential term\": 2.0,\n", + " \"Secondary: Negative electrode LAM constant exponential term\": 2.0,\n", + " \"Secondary: Negative electrode partial molar volume [m3.mol-1]\": 3.1e-06\n", + " * second,\n", + " \"Secondary: Negative electrode Young's modulus [Pa]\": 15000000000.0 * second,\n", + " \"Secondary: Negative electrode Poisson's ratio\": 0.3 * second,\n", + " \"Negative electrode reference concentration for free of deformation [mol.m-3]\": 0.0,\n", + " \"Primary: Negative electrode volume change\": graphite_volume_change_Ai2020,\n", + " \"Secondary: Negative electrode volume change\": graphite_volume_change_Ai2020,\n", + " \"Positive electrode partial molar volume [m3.mol-1]\": -7.28e-07,\n", + " \"Positive electrode Young's modulus [Pa]\": 375000000000.0,\n", + " \"Positive electrode Poisson's ratio\": 0.2,\n", + " \"Positive electrode critical stress [Pa]\": 375000000.0,\n", + " \"Positive electrode LAM constant exponential term\": 2.0,\n", + " \"Positive electrode reference concentration for free of deformation [mol.m-3]\": 0.0,\n", + " \"Positive electrode volume change\": lico2_volume_change_Ai2020,\n", + " },\n", + " check_already_exists=False,\n", + ")\n", + "\n", + "# sim = pybamm.Simulation(model, parameter_values=parameter_values)\n", + "# sim.solve([0, 4500])\n", + "experiment = pybamm.Experiment(\n", + " [\n", + " \"Discharge at 1C until 3 V\",\n", + " \"Rest for 600 seconds\",\n", + " \"Charge at 1C until 4.2 V\",\n", + " \"Hold at 4.199 V for 600 seconds\",\n", + " ]\n", + ")\n", + "sim = pybamm.Simulation(\n", + " model,\n", + " experiment=experiment,\n", + " parameter_values=parameter_values,\n", + " discretisation_kwargs={\"remove_independent_variables_from_rhs\": True},\n", + ")\n", + "solution = sim.solve(calc_esoh=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The two phase LAM model can be compared between the cathode and two anode phases." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "074bcadceb3e4fbd8cc786e798bb6508", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(FloatSlider(value=0.0, description='t', max=2.1702864080208446, step=0.02170286408020844…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "pybamm.dynamic_plot(\n", + " sim,\n", + " [\n", + " \"Voltage [V]\",\n", + " \"Current [A]\",\n", + " [\n", + " \"Average negative primary particle concentration\",\n", + " \"Average negative secondary particle concentration\",\n", + " \"Average positive particle concentration\",\n", + " ],\n", + " \"X-averaged negative electrode primary active material volume fraction\",\n", + " \"X-averaged positive electrode active material volume fraction\",\n", + " \"X-averaged negative electrode secondary active material volume fraction\",\n", + " \"Sum of x-averaged positive electrode volumetric interfacial current densities [A.m-3]\",\n", + " \"Sum of x-averaged negative electrode volumetric interfacial current densities [A.m-3]\",\n", + " \"X-averaged positive particle surface tangential stress [Pa]\",\n", + " \"X-averaged negative primary particle surface tangential stress [Pa]\",\n", + " \"X-averaged negative secondary particle surface tangential stress [Pa]\",\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reaction-driven composite anode\n", + "The same process is repeated for the reaction-driven LAM degradation." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "98a2b1762a3c43bcaa9ceff5a146d704", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(FloatSlider(value=0.0, description='t', max=2.081773444877257, step=0.02081773444877257)…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "options = {\n", + " \"particle phases\": (\"2\", \"1\"),\n", + " \"open-circuit potential\": ((\"single\", \"current sigmoid\"), \"single\"),\n", + " \"SEI\": \"solvent-diffusion limited\",\n", + " \"loss of active material\": \"reaction-driven\",\n", + "}\n", + "\n", + "model = pybamm.lithium_ion.SPM(options)\n", + "parameter_values = pybamm.ParameterValues(\"Chen2020_composite\")\n", + "second = 0.9\n", + "\n", + "parameter_values.update(\n", + " {\n", + " \"Primary: Negative electrode partial molar volume [m3.mol-1]\": 3.1e-06,\n", + " \"Primary: Negative electrode Young's modulus [Pa]\": 15000000000.0,\n", + " \"Primary: Negative electrode Poisson's ratio\": 0.3,\n", + " \"Negative electrode critical stress [Pa]\": 60000000.0,\n", + " \"Negative electrode LAM constant exponential term\": 2.0,\n", + " \"Secondary: Negative electrode partial molar volume [m3.mol-1]\": 3.1e-06\n", + " * second,\n", + " \"Secondary: Negative electrode Young's modulus [Pa]\": 15000000000.0 * second,\n", + " \"Secondary: Negative electrode Poisson's ratio\": 0.3 * second,\n", + " \"Negative electrode reference concentration for free of deformation [mol.m-3]\": 0.0,\n", + " \"Primary: Negative electrode volume change\": graphite_volume_change_Ai2020,\n", + " \"Secondary: Negative electrode volume change\": graphite_volume_change_Ai2020,\n", + " \"Positive electrode partial molar volume [m3.mol-1]\": -7.28e-07,\n", + " \"Positive electrode Young's modulus [Pa]\": 375000000000.0,\n", + " \"Positive electrode Poisson's ratio\": 0.2,\n", + " \"Positive electrode critical stress [Pa]\": 375000000.0,\n", + " \"Positive electrode LAM constant exponential term\": 2.0,\n", + " \"Positive electrode reference concentration for free of deformation [mol.m-3]\": 0.0,\n", + " \"Positive electrode volume change\": lico2_volume_change_Ai2020,\n", + " \"Primary: Negative electrode reaction-driven LAM factor [m3.mol-1]\": 1e-9,\n", + " \"Secondary: Negative electrode reaction-driven LAM factor [m3.mol-1]\": 10,\n", + " },\n", + " check_already_exists=False,\n", + ")\n", + "\n", + "# Changing secondary SEI solvent diffusivity to show different degradation between phases\n", + "parameter_values.update(\n", + " {\n", + " \"Secondary: Outer SEI solvent diffusivity [m2.s-1]\": 2.5000000000000002e-24,\n", + " }\n", + ")\n", + "\n", + "# sim = pybamm.Simulation(model, parameter_values=parameter_values)\n", + "# sim.solve([0, 4100])\n", + "sim = pybamm.Simulation(\n", + " model,\n", + " experiment=experiment,\n", + " parameter_values=parameter_values,\n", + " solver=pybamm.CasadiSolver(\"fast with events\"),\n", + ")\n", + "solution = sim.solve(calc_esoh=False)\n", + "\n", + "sim.plot(\n", + " [\n", + " \"Voltage [V]\",\n", + " \"Current [A]\",\n", + " \"Sum of x-averaged negative electrode volumetric interfacial current densities [A.m-3]\",\n", + " \"X-averaged negative electrode primary active material volume fraction\",\n", + " \"X-averaged negative electrode secondary active material volume fraction\",\n", + " \"Negative total primary SEI thickness [m]\",\n", + " \"Negative total secondary SEI thickness [m]\",\n", + " ]\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -369,22 +670,26 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 10, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "[1] Weilong Ai, Ludwig Kraft, Johannes Sturm, Andreas Jossen, and Billy Wu. Electrochemical thermal-mechanical modelling of stress inhomogeneity in lithium-ion pouch cells. Journal of The Electrochemical Society, 167(1):013512, 2019. doi:10.1149/2.0122001JES.\n", - "[2] Joel A. E. Andersson, Joris Gillis, Greg Horn, James B. Rawlings, and Moritz Diehl. CasADi – A software framework for nonlinear optimization and optimal control. Mathematical Programming Computation, 11(1):1–36, 2019. doi:10.1007/s12532-018-0139-4.\n", - "[3] Chang-Hui Chen, Ferran Brosa Planella, Kieran O'Regan, Dominika Gastol, W. Dhammika Widanage, and Emma Kendrick. Development of Experimental Techniques for Parameterization of Multi-scale Lithium-ion Battery Models. Journal of The Electrochemical Society, 167(8):080534, 2020. doi:10.1149/1945-7111/ab9050.\n", - "[4] Rutooj Deshpande, Mark Verbrugge, Yang-Tse Cheng, John Wang, and Ping Liu. Battery cycle life prediction with coupled chemical degradation and fatigue mechanics. Journal of the Electrochemical Society, 159(10):A1730, 2012. doi:10.1149/2.049210jes.\n", - "[5] Marc Doyle, Thomas F. Fuller, and John Newman. Modeling of galvanostatic charge and discharge of the lithium/polymer/insertion cell. Journal of the Electrochemical society, 140(6):1526–1533, 1993. doi:10.1149/1.2221597.\n", - "[6] Charles R. Harris, K. Jarrod Millman, Stéfan J. van der Walt, Ralf Gommers, Pauli Virtanen, David Cournapeau, Eric Wieser, Julian Taylor, Sebastian Berg, Nathaniel J. Smith, and others. Array programming with NumPy. Nature, 585(7825):357–362, 2020. doi:10.1038/s41586-020-2649-2.\n", - "[7] Scott G. Marquis. Long-term degradation of lithium-ion batteries. PhD thesis, University of Oxford, 2020.\n", - "[8] Jorn M. Reniers, Grietus Mulder, and David A. Howey. Review and performance comparison of mechanical-chemical degradation models for lithium-ion batteries. Journal of The Electrochemical Society, 166(14):A3189, 2019. doi:10.1149/2.0281914jes.\n", - "[9] Valentin Sulzer, Scott G. Marquis, Robert Timms, Martin Robinson, and S. Jon Chapman. Python Battery Mathematical Modelling (PyBaMM). Journal of Open Research Software, 9(1):14, 2021. doi:10.5334/jors.309.\n", + "[1] Weilong Ai, Niall Kirkaldy, Yang Jiang, Gregory Offer, Huizhi Wang, and Billy Wu. A composite electrode model for lithium-ion batteries with silicon/graphite negative electrodes. Journal of Power Sources, 527:231142, 2022. URL: https://www.sciencedirect.com/science/article/pii/S0378775322001604, doi:https://doi.org/10.1016/j.jpowsour.2022.231142.\n", + "[2] Weilong Ai, Ludwig Kraft, Johannes Sturm, Andreas Jossen, and Billy Wu. Electrochemical thermal-mechanical modelling of stress inhomogeneity in lithium-ion pouch cells. Journal of The Electrochemical Society, 167(1):013512, 2019. doi:10.1149/2.0122001JES.\n", + "[3] Joel A. E. Andersson, Joris Gillis, Greg Horn, James B. Rawlings, and Moritz Diehl. CasADi – A software framework for nonlinear optimization and optimal control. Mathematical Programming Computation, 11(1):1–36, 2019. doi:10.1007/s12532-018-0139-4.\n", + "[4] Ferran Brosa Planella and W. Dhammika Widanage. Systematic derivation of a Single Particle Model with Electrolyte and Side Reactions (SPMe+SR) for degradation of lithium-ion batteries. Submitted for publication, ():, 2022. doi:.\n", + "[5] Von DAG Bruggeman. Berechnung verschiedener physikalischer konstanten von heterogenen substanzen. i. dielektrizitätskonstanten und leitfähigkeiten der mischkörper aus isotropen substanzen. Annalen der physik, 416(7):636–664, 1935.\n", + "[6] Chang-Hui Chen, Ferran Brosa Planella, Kieran O'Regan, Dominika Gastol, W. Dhammika Widanage, and Emma Kendrick. Development of Experimental Techniques for Parameterization of Multi-scale Lithium-ion Battery Models. Journal of The Electrochemical Society, 167(8):080534, 2020. doi:10.1149/1945-7111/ab9050.\n", + "[7] Rutooj Deshpande, Mark Verbrugge, Yang-Tse Cheng, John Wang, and Ping Liu. Battery cycle life prediction with coupled chemical degradation and fatigue mechanics. Journal of the Electrochemical Society, 159(10):A1730, 2012. doi:10.1149/2.049210jes.\n", + "[8] Marc Doyle, Thomas F. Fuller, and John Newman. Modeling of galvanostatic charge and discharge of the lithium/polymer/insertion cell. Journal of the Electrochemical society, 140(6):1526–1533, 1993. doi:10.1149/1.2221597.\n", + "[9] Charles R. Harris, K. Jarrod Millman, Stéfan J. van der Walt, Ralf Gommers, Pauli Virtanen, David Cournapeau, Eric Wieser, Julian Taylor, Sebastian Berg, Nathaniel J. Smith, and others. Array programming with NumPy. Nature, 585(7825):357–362, 2020. doi:10.1038/s41586-020-2649-2.\n", + "[10] Scott G. Marquis. Long-term degradation of lithium-ion batteries. PhD thesis, University of Oxford, 2020.\n", + "[11] Scott G. Marquis, Valentin Sulzer, Robert Timms, Colin P. Please, and S. Jon Chapman. An asymptotic derivation of a single particle model with electrolyte. Journal of The Electrochemical Society, 166(15):A3693–A3706, 2019. doi:10.1149/2.0341915jes.\n", + "[12] Jorn M. Reniers, Grietus Mulder, and David A. Howey. Review and performance comparison of mechanical-chemical degradation models for lithium-ion batteries. Journal of The Electrochemical Society, 166(14):A3189, 2019. doi:10.1149/2.0281914jes.\n", + "[13] Valentin Sulzer, Scott G. Marquis, Robert Timms, Martin Robinson, and S. Jon Chapman. Python Battery Mathematical Modelling (PyBaMM). Journal of Open Research Software, 9(1):14, 2021. doi:10.5334/jors.309.\n", "\n" ] } @@ -417,7 +722,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.8" + "version": "3.11.9" }, "toc": { "base_numbering": 1, diff --git a/docs/source/examples/notebooks/models/sodium-ion.ipynb b/docs/source/examples/notebooks/models/sodium-ion.ipynb new file mode 100644 index 0000000000..671e7923e9 --- /dev/null +++ b/docs/source/examples/notebooks/models/sodium-ion.ipynb @@ -0,0 +1,185 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# DFN model for sodium-ion batteries\n", + "\n", + "In this notebook we use the DFN model to simulate sodium-ion batteries. The parameters are based on the article\n", + "> K. Chayambuka, G. Mulder, D.L. Danilov, P.H.L. Notten, Physics-based modeling of sodium-ion batteries part II. Model and validation, Electrochimica Acta 404 (2022) 139764. https://doi.org/10.1016/j.electacta.2021.139764.\n", + "\n", + "However, the specific values (including the data for the interpolants) are taken from the COMSOL implementation presented in [this example](https://www.comsol.com/model/1d-isothermal-sodium-ion-battery-117341). As usual, we start by importing PyBaMM." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "An NVIDIA GPU may be present on this machine, but a CUDA-enabled jaxlib is not installed. Falling back to cpu.\n" + ] + } + ], + "source": [ + "import pybamm" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We now need to define the model. In this case we take the `BasicDFN` model for sodium-ion batteries (note how it is called from the `pybamm.sodium_ion` submodule). Note that, at the moment, the model is identical to the one for lithium-ion batteries, but uses different parameter values." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "model = pybamm.sodium_ion.BasicDFN()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In order to replicate the results in the COMSOL example, we discharge at different C-rates and compare the solutions. We loop over the C-rate dictionary and solve the model for each C-rate. We append the solutions into a list so we can later plots the results." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "8ca3353c637e48d28c3b02f42d25fa03", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(FloatSlider(value=0.0, description='t', max=10.80914150213347, step=0.1080914150213347),…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "C_rates = [1 / 12, 5 / 12, 10 / 12, 1]\n", + "solutions = []\n", + "\n", + "for C_rate in C_rates:\n", + " sim = pybamm.Simulation(model, solver=pybamm.IDAKLUSolver(), C_rate=C_rate)\n", + " sol = sim.solve([0, 4000 / C_rate])\n", + " solutions.append(sol)\n", + "\n", + "pybamm.dynamic_plot(solutions)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can now perform a manual plot of voltage versus capacity, to compare the results with the COMSOL example." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "for solution, C_rate in zip(solutions, C_rates):\n", + " capacity = [i * 1000 for i in solution[\"Discharge capacity [A.h]\"].entries]\n", + " voltage = solution[\"Voltage [V]\"].entries\n", + " plt.plot(capacity, voltage, label=f\"{(12 * C_rate)} A.m-2\")\n", + "\n", + "plt.xlabel(\"Discharge Capacity [mA.h]\")\n", + "plt.ylabel(\"Voltage [V]\");" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[1] Joel A. E. Andersson, Joris Gillis, Greg Horn, James B. Rawlings, and Moritz Diehl. CasADi – A software framework for nonlinear optimization and optimal control. Mathematical Programming Computation, 11(1):1–36, 2019. doi:10.1007/s12532-018-0139-4.\n", + "[2] Kudakwashe Chayambuka, Grietus Mulder, Dmitri L Danilov, and Peter HL Notten. Physics-based modeling of sodium-ion batteries part ii. model and validation. Electrochimica Acta, 404:139764, 2022.\n", + "[3] Charles R. Harris, K. Jarrod Millman, Stéfan J. van der Walt, Ralf Gommers, Pauli Virtanen, David Cournapeau, Eric Wieser, Julian Taylor, Sebastian Berg, Nathaniel J. Smith, and others. Array programming with NumPy. Nature, 585(7825):357–362, 2020. doi:10.1038/s41586-020-2649-2.\n", + "[4] Alan C. Hindmarsh. The PVODE and IDA algorithms. Technical Report, Lawrence Livermore National Lab., CA (US), 2000. doi:10.2172/802599.\n", + "[5] Alan C. Hindmarsh, Peter N. Brown, Keith E. Grant, Steven L. Lee, Radu Serban, Dan E. Shumaker, and Carol S. Woodward. SUNDIALS: Suite of nonlinear and differential/algebraic equation solvers. ACM Transactions on Mathematical Software (TOMS), 31(3):363–396, 2005. doi:10.1145/1089014.1089020.\n", + "[6] Scott G. Marquis, Valentin Sulzer, Robert Timms, Colin P. Please, and S. Jon Chapman. An asymptotic derivation of a single particle model with electrolyte. Journal of The Electrochemical Society, 166(15):A3693–A3706, 2019. doi:10.1149/2.0341915jes.\n", + "[7] Valentin Sulzer, Scott G. Marquis, Robert Timms, Martin Robinson, and S. Jon Chapman. Python Battery Mathematical Modelling (PyBaMM). Journal of Open Research Software, 9(1):14, 2021. doi:10.5334/jors.309.\n", + "\n" + ] + } + ], + "source": [ + "pybamm.print_citations()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.6" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/source/examples/notebooks/parameterization/parameter-values.ipynb b/docs/source/examples/notebooks/parameterization/parameter-values.ipynb index b13084b166..12a2c439bf 100644 --- a/docs/source/examples/notebooks/parameterization/parameter-values.ipynb +++ b/docs/source/examples/notebooks/parameterization/parameter-values.ipynb @@ -19,7 +19,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 1, "metadata": {}, "outputs": [ { @@ -44,12 +44,12 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "In `pybamm`, the object that sets parameter values for a model is the `ParameterValues` class, which extends `dict`. This takes the values of the parameters as input, which can be either a dictionary," + "In `pybamm`, the object that sets parameter values for a model is the [`ParameterValues`](https://docs.pybamm.org/en/latest/source/api/parameters/parameter_values.html) class, which extends `dict`. This takes the values of the parameters as input, which can be either a dictionary," ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 2, "metadata": {}, "outputs": [ { @@ -81,7 +81,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 3, "metadata": {}, "outputs": [ { @@ -105,12 +105,12 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We can input functions into the parameter value (note we bypass the check that the parameter already exists)" + "We can alter the values of parameters by updating the dictionary, by using the `update` method or by using the `[]` operator." ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -121,10 +121,42 @@ " 'Electron charge [C]': 1.602176634e-19,\n", " 'Faraday constant [C.mol-1]': 96485.33212,\n", " 'Ideal gas constant [J.K-1.mol-1]': 8.314462618,\n", - " 'a': 1,\n", - " 'b': 2,\n", - " 'c': 3,\n", - " 'cube function': }\n" + " 'a': 2,\n", + " 'b': 3,\n", + " 'c': 4}\n" + ] + } + ], + "source": [ + "parameter_values[\"a\"] = 2\n", + "parameter_values.update({\"b\": 3, \"c\": 4})\n", + "print(f\"parameter values are {parameter_values}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Parameter values can either be numerical values, python functions or PyBaMM expressions. We can input functions into the parameter value like so (note we bypass the check that the parameter already exists):\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "parameter values are {'Boltzmann constant [J.K-1]': 1.380649e-23,\n", + " 'Electron charge [C]': 1.602176634e-19,\n", + " 'Faraday constant [C.mol-1]': 96485.33212,\n", + " 'Ideal gas constant [J.K-1.mol-1]': 8.314462618,\n", + " 'a': 2,\n", + " 'b': 3,\n", + " 'c': 4,\n", + " 'cube function': }\n" ] } ], @@ -141,19 +173,35 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Setting parameters for an expression" + "We can also use a PyBaMM expression to set the parameter value, allowing us to set parameters based on other parameters: " + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "parameter_values.update({\"a\": pybamm.Parameter(\"b\") + pybamm.Parameter(\"c\")})" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "We represent parameters in models using the classes `Parameter` and `FunctionParameter`. These cannot be evaluated directly," + "## Setting parameters for a PyBaMM model" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We represent parameters in models using the classes [`Parameter`](https://docs.pybamm.org/en/latest/source/api/expression_tree/parameter.html) and [`FunctionParameter`](https://docs.pybamm.org/en/latest/source/api/expression_tree/parameter.html#pybamm.FunctionParameter). These cannot be evaluated directly," ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 7, "metadata": { "tags": [ "raises-exception" @@ -190,14 +238,14 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 8, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "7.0 = 7.0\n" + "19.0 = 19.0\n" ] } ], @@ -208,14 +256,14 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 9, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "1.0 = 1.0\n" + "343.0 = 343.0\n" ] } ], @@ -233,7 +281,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 10, "metadata": {}, "outputs": [ { @@ -279,12 +327,12 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 11, "metadata": {}, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAABQkAAAGGCAYAAADYVwfrAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAACVcElEQVR4nOzdd3iTZdvH8e+ddEJJmV1QStmWvaGIog8KyBAX4quCuAcq4uR53Atw4kBxsZygKCIqCAiCssEiUGWWMjqYbWihK8n7R0oldKXQNh2/j0eOkjvXfeeMNPTqmes6T8PhcDgQERERERERERGRasvk6QBERERERERERETEs5QkFBERERERERERqeaUJBQREREREREREanmlCQUERERERERERGp5pQkFBERERERERERqeaUJBQREREREREREanmlCQUERERERERERGp5pQkFBERERERERERqeaUJBQREREREREREanmlCQUEfGwvn370rZtW0+HISIiIiLloEmTJgwePNjTYYiI5KMkoYhICbz88sv07NmTBg0a4OfnR4sWLRg7diyHDx/2dGjnzW63M2PGDIYOHUp4eDg1a9akbdu2vPjii2RkZHg6PBEREZFyN3v2bG666SZatGiBYRj07dvX0yGVmpMnTzJlyhQuv/xyQkNDqVWrFp06deL999/HZrN5OjwR8QAvTwcgIlKZbNy4kY4dOzJixAhq1arF33//zUcffcSPP/5ITEwMNWvW9HSI5+zkyZOMHj2anj17cvfddxMUFMTq1at55plnWLp0Kb/++iuGYXg6TBEREZFy8/7777Nx40a6devG0aNHPR1OqdqzZw/3338///nPfxg3bhwWi4VFixZx7733smbNGmbOnOnpEEWknClJKCJSAnPnzs13rFevXlx77bX88MMPjBgxwgNRlQ4fHx/++OMPoqOj847dcccdNGnSJC9R2K9fPw9GKCIiIlK+Pv30Uxo2bIjJZKpy5WFCQkLYsmULbdq0yTt21113ceuttzJ9+nSeeuopmjdv7sEIRaS8abuxiFR48fHx3HvvvbRq1Qp/f3/q1avHddddx969ez0dGuCsKwOQkpJyXtfZuHEj0dHR+Pv7ExkZydSpU88/uBLw8fFxSRCedtVVVwHw999/l2s8IiIiUrVV9DkeQHh4OCZT2fza/Msvv9CxY0f8/PyIiori22+/LZPnKUz9+vVdEoSnae4nUn1pJaGIVHjr169n1apVjBgxgkaNGrF3717ef/99+vbtS2xsLDVq1Cjy/OPHj7tVV6VGjRrFXgvA4XBw9OhRcnJy2LlzJ0888QRms/m8atQcP36cK664guHDh3PDDTcwZ84c7rnnHnx8fLj11luLPDc1NZXs7Oxin8PPz4+AgIASx5aUlAQ4J5IiIiIipaWizfHK086dO7n++uu5++67GTVqFNOnT+e6665j4cKFXHbZZUWeW9avW3M/kerLcDgcDk8HISJSlFOnTuHv7+9ybM2aNfTq1YtZs2Zx8803F3l+kyZNiI+PL/Z5nnnmGZ599tlixyUlJREaGpp3v1GjRrz++usMHz682HML0rdvX3777Tdef/11xo0bB0BWVhY9evQgISGBAwcO4O3tXez5xRk1ahQzZswocXyXXXYZ69atIz4+ntq1a5f4fBEREZGCVLQ5XnHatm1L/fr1Wb58+Xld53Tcc+fO5eqrrwbAarXSunVrQkJC2LRpk1vnF+dcXndWVhadOnXi1KlT7NixAy8vrSsSqU70jheRCu/MyWN2djZWq5XmzZtTu3ZtNm3aVOwE8vPPP+fUqVPFPk/Tpk3diqdu3bosXryYjIwM/vzzT7799lvS0tLcOrcwXl5e3HXXXXn3fXx8uOuuu7jnnnvYuHEjPXv2LPTc119/nePHjxf7HGFhYSWO6+WXX2bJkiW89957ShCKiIhIqapoc7zyFBYWlretF8BisTBy5EgmTZpEUlISISEhhZ5blq97zJgxxMbG8uOPPypBKFIN6V0vIhXeqVOnmDBhAtOnT+fgwYOcuQA6NTW12PN79+5dqvH4+PjkNfAYPHgw//nPf+jduzdBQUEMHjz4nK4ZFhaWrzNyy5YtAdi7d2+RScIuXbqc03MWZ/bs2Tz55JPcdttt3HPPPWXyHCIiIlJ9VZQ53rFjx8jKysq77+/vT2BgYKlcuzDNmzfHMAyXY2fO/YpKEpb23Pa0V199lY8++ogXXniBK664okyeQ0QqNiUJRaTCu//++5k+fTpjx46lV69eBAYGYhgGI0aMwG63F3v+4cOH3arbEhAQcE41+6KjowkNDeXzzz8/5yTh+Th7YluYkkx4Fy9ezMiRIxk0aFC5N1ARERGR6qGizPGuvvpql9It51qipbyUxdx2xowZPP7449x99908+eST5xuiiFRSShKKSIX3zTffMGrUKF5//fW8YxkZGW53E+7WrVuZ16vJyMhw6xPvwiQkJJCenu6ymnDHjh3Av92TC3P2xLYw7k54165dy1VXXUXXrl2ZM2eOtpqIiIhImagoc7yzS7ecS4mWktq1axcOh8NlNaG7c7/Sntt+//333H777Vx99dVMmTKl2PEiUnXpNz8RqfDMZjNn91h655133PoEFUqvbkt6ejqGYeTrEjd37lyOHz9O165d3YqnIDk5OXzwwQcujUs++OADGjRoUOx24tKsSfj3338zaNAgmjRpwoIFC/IVExcREREpLRVljldWpVuKkpCQwHfffefSuGTWrFl07NixyK3GULo1CVesWMGIESO46KKL+PzzzzGZTO69ABGpkpQkFJEKb/DgwXz66acEBgYSFRXF6tWrWbJkCfXq1XPr/NKq27Jz50769evH9ddfT+vWrTGZTGzYsIHPPvuMJk2a8OCDD7qMP/0p8N69e4u9dlhYGJMmTWLv3r20bNmS2bNnExMTw4cfflhkZ2MovYntiRMn6N+/P8ePH+fRRx/lxx9/dHm8WbNm9OrVq1SeS0RERKSizPGKsmLFClasWAE4t/mmp6fz4osvAnDRRRdx0UUX5Y01DIOLL77Yre7HLVu25LbbbmP9+vUEBwczbdo0kpOTmT59erHnltbrjo+PZ+jQoRiGwbXXXsvXX3/t8nj79u1p3759qTyXiFQOShKKSIX31ltvYTab+fzzz8nIyKB3794sWbKE/v37l2scjRo14pprruHXX39l5syZZGdnExERwZgxY/jf//6Xb0Kbnp5O8+bN3bp2nTp1mDlzJvfffz8fffQRwcHBvPvuu9xxxx1l8VIKdPToUfbv3w/AE088ke/xUaNGKUkoIiIipaaizPGK8uuvv/Lcc8+5HHvqqacA53be00nCtLQ0AEJDQ926bosWLXjnnXd49NFH2b59O5GRkcyePbtcX3tcXFxeuZz77rsv3+PPPPOMkoQi1YzhOHt9t4iInLfY2FjatGnDggULGDRokKfDEREREZEy9NNPPzF48GA2b95Mu3btPB2OiMg5UcEBEZEysGzZMnr16qUEoYiIiEg1sGzZMkaMGKEEoYhUalpJKCIiIiIiIiIiUs1pJaGIiIiIiIiIiEg1pyShiIiIiIiIiIhINackoYiI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MEApT1onWon1yPidqT6TCeaY6wPWRJgz0vlmFxERERERERE5W+x8Z+7gjAQhQAPHUab6TGZh/xQlCKsZJQkrK7sNY+ETAJiMsx90OL8sfMK5bFhERERERERE5DS7zbmC8HT+4AzOHINB3RVPK6dQzShJWFnFrwJrAvnyg3kcYD3oHCciIiIiIiIiclpuTqEwhnIK1ZKShJVVWnLpjhMRERERERGR6kE5BSmAkoSVVUBw6Y4TERERERERkWohLiPAvYHKKVQrShJWVhHRzi7GhW44NsDS0DlORERERERERAT4aUsig+fbSXDUxV7oKOUUqiMlCSsrkxkGTMq945oozCs7OmCic5yIiIiIiIiIVGt2u4M3F+/g3s83kZ7t4JsGYzAwyL/4KPe+cgrVjpKElVnUUBg+CyyhLoeTqEf6sOnOx0VERERERESkWjuZlcN9X2ziraU7AbjtwkjuvechjAJyCljCnLkG5RSqHSUJK7uooTB2K4xagP3qj3m05sv0zniLqYeiPB2ZiIiIVCMrVqxgyJAhhIWFYRgG8+bNc3nc4XDw9NNPExoair+/P/369WPnzp2eCVZERKQaOZhyimvfX83PW5PwNhu8ck17nhochZfZ5JJT4JpPnF/HblGCsJoqcZKwuAng2b799lsuu+wyGjRogMVioVevXixatMhlzLPPPothGC631q1blzS06stkhsg+mNpfx38GXoMdE9N+j+NYepanIxMREZFqIj09nQ4dOjBlypQCH3/llVd4++23mTp1KmvXrqVmzZr079+fjIyMco5URESk+tiw9xhD3/md2EQr9QN8+PKOngzvFu46KDenQLtrnV+1xbjaKnGSsLgJ4NlWrFjBZZddxk8//cTGjRu55JJLGDJkCH/++afLuDZt2pCYmJh3+/3330samgD92wTTtqGF9CwbH/y229PhiIiISDUxcOBAXnzxRa666qp8jzkcDiZPnsyTTz7JlVdeSfv27Zk1axYJCQnFfuAsIiIi5+aLtfu44aM1HE3PIirUwvdjLqRrk7qeDksqMK+SnjBw4EAGDhzo9vjJkye73H/55Zf5/vvv+eGHH+jUqdO/gXh5ERISUtJw5CyGYTDuspbcOmMDM1fv5bY+kQTV8vN0WCIiIlKNxcXFkZSURL9+/fKOBQYG0qNHD1avXs2IESM8GJ2IiEjVkplj45nvt/HV+v0AXNEuhNeu60ANnxKngKSaKfeahHa7nRMnTlC3rmv2eufOnYSFhdG0aVNuvPFG9u3bV+g1MjMzsVqtLjf51yWtgujUuDYZ2XbeW6bVhCIiIuJZSUlJAAQHB7scDw4OznvsbJrviYiIlFxi6imu/2ANX63fj2HAYwNaMeX/OitBKG4p9yTha6+9RlpaGsOHD8871qNHD2bMmMHChQt5//33iYuLo0+fPpw4caLAa0yYMIHAwMC8W3h4eIHjqivDMHj4slaAc3lxQsopD0ckIiIiUjKa74mIiBTBboO4lbDlG+dXu421e44y5J3fidmfQqC/NzNGd+fevs0xDMPT0UolUa5Jwi+++ILnnnuOOXPmEBQUlHd84MCBXHfddbRv357+/fvz008/kZKSwpw5cwq8zvjx40lNTc277d+/v7xeQqXRu3k9ukfWJctm591luzwdjoiIiFRjp0vKJCcnuxxPTk4utNyM5nsiIiKFiJ0Pk9vCzMEw9zaYOZj0SRcw85N3OJKWReuQWvww5kIubtnA05FKJVNuScKvvvqK22+/nTlz5rjUoylI7dq1admyJbt2FZzc8vX1xWKxuNzElXM1YUsA5qzfz76jJz0ckYiIiFRXkZGRhISEsHTp0rxjVquVtWvX0qtXrwLP0XxPRESkALHzYc5IsCa4HPbPSOZdrzd5sukuvr03msb1angoQKnMyiVJ+OWXXzJ69Gi+/PJLBg0aVOz4tLQ0du/eTWhoaDlEV3X1aFqPPi3qk2N38Nov2z0djoiIiFRhaWlpxMTEEBMTAziblcTExLBv3z4Mw2Ds2LG8+OKLzJ8/ny1btjBy5EjCwsIYNmyYR+MWERGpNOw2WPg44Mj3kMlwLha6Le0Danhpe7GcmxInCYuaAIJza8jIkSPzxn/xxReMHDmS119/nR49epCUlERSUhKpqal5Yx555BF+++039u7dy6pVq7jqqqswm83ccMMN5/ny5PEBrQGYvzmBrQdTixktIiIicm42bNhAp06d6NSpEwDjxo2jU6dOPP300wA89thj3H///dx5551069aNtLQ0Fi5ciJ+fnyfDFhERqTziV+VbQXgmAweG9aBznMg5KHGSsLgJYGJioktn4g8//JCcnBzuu+8+QkND824PPvhg3pgDBw5www030KpVK4YPH069evVYs2YNDRpo//z5atsw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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAABQkAAAGGCAYAAADYVwfrAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguNCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8fJSN1AAAACXBIWXMAAA9hAAAPYQGoP6dpAACVcElEQVR4nOzdd3iTZdvH8e+ddEJJmV1QStmWvaGIog8KyBAX4quCuAcq4uR53Atw4kBxsZygKCIqCAiCssEiUGWWMjqYbWihK8n7R0oldKXQNh2/j0eOkjvXfeeMNPTqmes6T8PhcDgQERERERERERGRasvk6QBERERERERERETEs5QkFBERERERERERqeaUJBQREREREREREanmlCQUERERERERERGp5pQkFBERERERERERqeaUJBQREREREREREanmlCQUERERERERERGp5pQkFBERERERERERqeaUJBQREREREREREanmlCQUEfGwvn370rZtW0+HISIiIiLloEmTJgwePNjTYYiI5KMkoYhICbz88sv07NmTBg0a4OfnR4sWLRg7diyHDx/2dGjnzW63M2PGDIYOHUp4eDg1a9akbdu2vPjii2RkZHg6PBEREZFyN3v2bG666SZatGiBYRj07dvX0yGVmpMnTzJlyhQuv/xyQkNDqVWrFp06deL999/HZrN5OjwR8QAvTwcgIlKZbNy4kY4dOzJixAhq1arF33//zUcffcSPP/5ITEwMNWvW9HSI5+zkyZOMHj2anj17cvfddxMUFMTq1at55plnWLp0Kb/++iuGYXg6TBEREZFy8/7777Nx40a6devG0aNHPR1OqdqzZw/3338///nPfxg3bhwWi4VFixZx7733smbNGmbOnOnpEEWknClJKCJSAnPnzs13rFevXlx77bX88MMPjBgxwgNRlQ4fHx/++OMPoqOj847dcccdNGnSJC9R2K9fPw9GKCIiIlK+Pv30Uxo2bIjJZKpy5WFCQkLYsmULbdq0yTt21113ceuttzJ9+nSeeuopmjdv7sEIRaS8abuxiFR48fHx3HvvvbRq1Qp/f3/q1avHddddx969ez0dGuCsKwOQkpJyXtfZuHEj0dHR+Pv7ExkZydSpU88/uBLw8fFxSRCedtVVVwHw999/l2s8IiIiUrVV9DkeQHh4OCZT2fza/Msvv9CxY0f8/PyIiori22+/LZPnKUz9+vVdEoSnae4nUn1pJaGIVHjr169n1apVjBgxgkaNGrF3717ef/99+vbtS2xsLDVq1Cjy/OPHj7tVV6VGjRrFXgvA4XBw9OhRcnJy2LlzJ0888QRms/m8atQcP36cK664guHDh3PDDTcwZ84c7rnnHnx8fLj11luLPDc1NZXs7Oxin8PPz4+AgIASx5aUlAQ4J5IiIiIipaWizfHK086dO7n++uu5++67GTVqFNOnT+e6665j4cKFXHbZZUWeW9avW3M/kerLcDgcDk8HISJSlFOnTuHv7+9ybM2aNfTq1YtZs2Zx8803F3l+kyZNiI+PL/Z5nnnmGZ599tlixyUlJREaGpp3v1GjRrz++usMHz682HML0rdvX3777Tdef/11xo0bB0BWVhY9evQgISGBAwcO4O3tXez5xRk1ahQzZswocXyXXXYZ69atIz4+ntq1a5f4fBEREZGCVLQ5XnHatm1L/fr1Wb58+Xld53Tcc+fO5eqrrwbAarXSunVrQkJC2LRpk1vnF+dcXndWVhadOnXi1KlT7NixAy8vrSsSqU70jheRCu/MyWN2djZWq5XmzZtTu3ZtNm3aVOwE8vPPP+fUqVPFPk/Tpk3diqdu3bosXryYjIwM/vzzT7799lvS0tLcOrcwXl5e3HXXXXn3fXx8uOuuu7jnnnvYuHEjPXv2LPTc119/nePHjxf7HGFhYSWO6+WXX2bJkiW89957ShCKiIhIqapoc7zyFBYWlretF8BisTBy5EgmTZpEUlISISEhhZ5blq97zJgxxMbG8uOPPypBKFIN6V0vIhXeqVOnmDBhAtOnT+fgwYOcuQA6NTW12PN79+5dqvH4+PjkNfAYPHgw//nPf+jduzdBQUEMHjz4nK4ZFhaWrzNyy5YtAdi7d2+RScIuXbqc03MWZ/bs2Tz55JPcdttt3HPPPWXyHCIiIlJ9VZQ53rFjx8jKysq77+/vT2BgYKlcuzDNmzfHMAyXY2fO/YpKEpb23Pa0V199lY8++ogXXniBK664okyeQ0QqNiUJRaTCu//++5k+fTpjx46lV69eBAYGYhgGI0aMwG63F3v+4cOH3arbEhAQcE41+6KjowkNDeXzzz8/5yTh+Th7YluYkkx4Fy9ezMiRIxk0aFC5N1ARERGR6qGizPGuvvpql9It51qipbyUxdx2xowZPP7449x99908+eST5xuiiFRSShKKSIX3zTffMGrUKF5//fW8YxkZGW53E+7WrVuZ16vJyMhw6xPvwiQkJJCenu6ymnDHjh3Av92TC3P2xLYw7k54165dy1VXXUXXrl2ZM2eOtpqIiIhImagoc7yzS7ecS4mWktq1axcOh8NlNaG7c7/Sntt+//333H777Vx99dVMmTKl2PEiUnXpNz8RqfDMZjNn91h655133PoEFUqvbkt6ejqGYeTrEjd37lyOHz9O165d3YqnIDk5OXzwwQcujUs++OADGjRoUOx24tKsSfj3338zaNAgmjRpwoIFC/IVExcREREpLRVljldWpVuKkpCQwHfffefSuGTWrFl07NixyK3GULo1CVesWMGIESO46KKL+PzzzzGZTO69ABGpkpQkFJEKb/DgwXz66acEBgYSFRXF6tWrWbJkCfXq1XPr/NKq27Jz50769evH9ddfT+vWrTGZTGzYsIHPPvuMJk2a8OCDD7qMP/0p8N69e4u9dlhYGJMmTWLv3r20bNmS2bNnExMTw4cfflhkZ2MovYntiRMn6N+/P8ePH+fRRx/lxx9/dHm8WbNm9OrVq1SeS0RERKSizPGKsmLFClasWAE4t/mmp6fz4osvAnDRRRdx0UUX5Y01DIOLL77Yre7HLVu25LbbbmP9+vUEBwczbdo0kpOTmT59erHnltbrjo+PZ+jQoRiGwbXXXsvXX3/t8nj79u1p3759qTyXiFQOShKKSIX31ltvYTab+fzzz8nIyKB3794sWbKE/v37l2scjRo14pprruHXX39l5syZZGdnExERwZgxY/jf//6Xb0Kbnp5O8+bN3bp2nTp1mDlzJvfffz8fffQRwcHBvPvuu9xxxx1l8VIKdPToUfbv3w/AE088ke/xUaNGKUkoIiIipaaizPGK8uuvv/Lcc8+5HHvqqacA53be00nCtLQ0AEJDQ926bosWLXjnnXd49NFH2b59O5GRkcyePbtcX3tcXFxeuZz77rsv3+PPPPOMkoQi1YzhOHt9t4iInLfY2FjatGnDggULGDRokKfDEREREZEy9NNPPzF48GA2b95Mu3btPB2OiMg5UcEBEZEysGzZMnr16qUEoYiIiEg1sGzZMkaMGKEEoYhUalpJKCIiIiIiIiIiUs1pJaGIiIiIiIiIiEg1pyShiIiIiIiIiIhINackoYiI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MEApT1onWon1yPidqT6TCeaY6wPWRJgz0vlmFxERERERERE5W+x8Z+7gjAQhQAPHUab6TGZh/xQlCKsZJQkrK7sNY+ETAJiMsx90OL8sfMK5bFhERERERERE5DS7zbmC8HT+4AzOHINB3RVPK6dQzShJWFnFrwJrAvnyg3kcYD3oHCciIiIiIiIiclpuTqEwhnIK1ZKShJVVWnLpjhMRERERERGR6kE5BSmAkoSVVUBw6Y4TERERERERkWohLiPAvYHKKVQrShJWVhHRzi7GhW44NsDS0DlORERERERERAT4aUsig+fbSXDUxV7oKOUUqiMlCSsrkxkGTMq945oozCs7OmCic5yIiIiIiIiIVGt2u4M3F+/g3s83kZ7t4JsGYzAwyL/4KPe+cgrVjpKElVnUUBg+CyyhLoeTqEf6sOnOx0VERERERESkWjuZlcN9X2ziraU7AbjtwkjuvechjAJyCljCnLkG5RSqHSUJK7uooTB2K4xagP3qj3m05sv0zniLqYeiPB2ZiIiIVCMrVqxgyJAhhIWFYRgG8+bNc3nc4XDw9NNPExoair+/P/369WPnzp2eCVZERKQaOZhyimvfX83PW5PwNhu8ck17nhochZfZ5JJT4JpPnF/HblGCsJoqcZKwuAng2b799lsuu+wyGjRogMVioVevXixatMhlzLPPPothGC631q1blzS06stkhsg+mNpfx38GXoMdE9N+j+NYepanIxMREZFqIj09nQ4dOjBlypQCH3/llVd4++23mTp1KmvXrqVmzZr079+fjIyMco5URESk+tiw9xhD3/md2EQr9QN8+PKOngzvFu46KDenQLtrnV+1xbjaKnGSsLgJ4NlWrFjBZZddxk8//cTGjRu55JJLGDJkCH/++afLuDZt2pCYmJh3+/3330samgD92wTTtqGF9CwbH/y229PhiIiISDUxcOBAXnzxRa666qp8jzkcDiZPnsyTTz7JlVdeSfv27Zk1axYJCQnFfuAsIiIi5+aLtfu44aM1HE3PIirUwvdjLqRrk7qeDksqMK+SnjBw4EAGDhzo9vjJkye73H/55Zf5/vvv+eGHH+jUqdO/gXh5ERISUtJw5CyGYTDuspbcOmMDM1fv5bY+kQTV8vN0WCIiIlKNxcXFkZSURL9+/fKOBQYG0qNHD1avXs2IESM8GJ2IiEjVkplj45nvt/HV+v0AXNEuhNeu60ANnxKngKSaKfeahHa7nRMnTlC3rmv2eufOnYSFhdG0aVNuvPFG9u3bV+g1MjMzsVqtLjf51yWtgujUuDYZ2XbeW6bVhCIiIuJZSUlJAAQHB7scDw4OznvsbJrviYiIlFxi6imu/2ANX63fj2HAYwNaMeX/OitBKG4p9yTha6+9RlpaGsOHD8871qNHD2bMmMHChQt5//33iYuLo0+fPpw4caLAa0yYMIHAwMC8W3h4eIHjqivDMHj4slaAc3lxQsopD0ckIiIiUjKa74mIiBTBboO4lbDlG+dXu421e44y5J3fidmfQqC/NzNGd+fevs0xDMPT0UolUa5Jwi+++ILnnnuOOXPmEBQUlHd84MCBXHfddbRv357+/fvz008/kZKSwpw5cwq8zvjx40lNTc277d+/v7xeQqXRu3k9ukfWJctm591luzwdjoiIiFRjp0vKJCcnuxxPTk4utNyM5nsiIiKFiJ0Pk9vCzMEw9zaYOZj0SRcw85N3OJKWReuQWvww5kIubtnA05FKJVNuScKvvvqK22+/nTlz5rjUoylI7dq1admyJbt2FZzc8vX1xWKxuNzElXM1YUsA5qzfz76jJz0ckYiIiFRXkZGRhISEsHTp0rxjVquVtWvX0qtXrwLP0XxPRESkALHzYc5IsCa4HPbPSOZdrzd5sukuvr03msb1angoQKnMyiVJ+OWXXzJ69Gi+/PJLBg0aVOz4tLQ0du/eTWhoaDlEV3X1aFqPPi3qk2N38Nov2z0djoiIiFRhaWlpxMTEEBMTAziblcTExLBv3z4Mw2Ds2LG8+OKLzJ8/ny1btjBy5EjCwsIYNmyYR+MWERGpNOw2WPg44Mj3kMlwLha6Le0Danhpe7GcmxInCYuaAIJza8jIkSPzxn/xxReMHDmS119/nR49epCUlERSUhKpqal5Yx555BF+++039u7dy6pVq7jqqqswm83ccMMN5/ny5PEBrQGYvzmBrQdTixktIiIicm42bNhAp06d6NSpEwDjxo2jU6dOPP300wA89thj3H///dx5551069aNtLQ0Fi5ciJ+fnyfDFhERqTziV+VbQXgmAweG9aBznMg5KHGSsLgJYGJioktn4g8//JCcnBzuu+8+QkND824PPvhg3pgDBw5www030KpVK4YPH069evVYs2YNDRpo//z5atsw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", "text/plain": [ "
" ] @@ -348,16 +396,16 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 12, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{Variable(0x5f4a102fc03b7b39, u, children=[], domains={}): Multiplication(-0x32ae6bc94fa07109, *, children=['-a', 'y[0:1]'], domains={})}" + "{Variable(-0x7fabcf6f713434a8, u, children=[], domains={}): Multiplication(0x26349e0ba31c22ee, *, children=['-a', 'y[0:1]'], domains={})}" ] }, - "execution_count": 21, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -378,7 +426,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 13, "metadata": {}, "outputs": [ { @@ -420,7 +468,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 14, "metadata": {}, "outputs": [ { @@ -443,7 +491,7 @@ ], "metadata": { "kernelspec": { - "display_name": "dev", + "display_name": "env", "language": "python", "name": "python3" }, @@ -457,7 +505,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.16" + "version": "3.10.12" }, "toc": { "base_numbering": 1, @@ -471,11 +519,6 @@ "toc_position": {}, "toc_section_display": true, "toc_window_display": true - }, - "vscode": { - "interpreter": { - "hash": "bca2b99bfac80e18288b793d52fa0653ab9b5fe5d22e7b211c44eb982a41c00c" - } } }, "nbformat": 4, diff --git a/docs/source/examples/notebooks/parameterization/parameterization.ipynb b/docs/source/examples/notebooks/parameterization/parameterization.ipynb index dabb5e5f76..a6ff34c772 100644 --- a/docs/source/examples/notebooks/parameterization/parameterization.ipynb +++ b/docs/source/examples/notebooks/parameterization/parameterization.ipynb @@ -586,7 +586,7 @@ "outputs": [ { "data": { - "text/plain": "{'Ideal gas constant [J.K-1.mol-1]': 8.314462618,\n 'Faraday constant [C.mol-1]': 96485.33212,\n 'Negative electrode thickness [m]': 0.0001,\n 'Separator thickness [m]': 2.5e-05,\n 'Positive electrode thickness [m]': 0.0001,\n 'Electrode height [m]': 0.137,\n 'Electrode width [m]': 0.207,\n 'Nominal cell capacity [A.h]': 0.680616,\n 'Current function [A]': 0.680616,\n 'Maximum concentration in negative electrode [mol.m-3]': 24983.2619938437,\n 'Negative particle diffusivity [m2.s-1]': ,\n 'Negative electrode OCP [V]': ,\n 'Negative electrode porosity': 0.3,\n 'Negative electrode active material volume fraction': 0.6,\n 'Negative particle radius [m]': 1e-05,\n 'Negative electrode Bruggeman coefficient (electrolyte)': 1.5,\n 'Negative electrode Bruggeman coefficient (electrode)': 1.5,\n 'Negative electrode exchange-current density [A.m-2]': ,\n 'Negative electrode OCP entropic change [V.K-1]': ,\n 'Maximum concentration in positive electrode [mol.m-3]': 51217.9257309275,\n 'Positive particle diffusivity [m2.s-1]': ,\n 'Positive electrode OCP [V]': ,\n 'Positive electrode porosity': 0.3,\n 'Positive electrode active material volume fraction': 0.5,\n 'Positive particle radius [m]': 1e-05,\n 'Positive electrode Bruggeman coefficient (electrolyte)': 1.5,\n 'Positive electrode Bruggeman coefficient (electrode)': 1.5,\n 'Positive electrode exchange-current density [A.m-2]': ,\n 'Positive electrode OCP entropic change [V.K-1]': ,\n 'Separator porosity': 1.0,\n 'Separator Bruggeman coefficient (electrolyte)': 1.5,\n 'Initial concentration in electrolyte [mol.m-3]': 1000.0,\n 'Reference temperature [K]': 298.15,\n 'Ambient temperature [K]': 298.15,\n 'Number of electrodes connected in parallel to make a cell': 1.0,\n 'Number of cells connected in series to make a battery': 1.0,\n 'Lower voltage cut-off [V]': 3.105,\n 'Upper voltage cut-off [V]': 4.1,\n 'Initial concentration in negative electrode [mol.m-3]': 19986.609595075,\n 'Initial concentration in positive electrode [mol.m-3]': 30730.7554385565}" + "text/plain": "{'Ideal gas constant [J.K-1.mol-1]': 8.314462618,\n 'Faraday constant [C.mol-1]': 96485.33212,\n 'Negative electrode thickness [m]': 0.0001,\n 'Separator thickness [m]': 2.5e-05,\n 'Positive electrode thickness [m]': 0.0001,\n 'Electrode height [m]': 0.137,\n 'Electrode width [m]': 0.207,\n 'Nominal cell capacity [A.h]': 0.680616,\n 'Current function [A]': 0.680616,\n 'Maximum concentration in negative electrode [mol.m-3]': 24983.2619938437,\n 'Negative particle diffusivity [m2.s-1]': ,\n 'Negative electrode OCP [V]': ,\n 'Negative electrode porosity': 0.3,\n 'Negative electrode active material volume fraction': 0.6,\n 'Negative particle radius [m]': 1e-05,\n 'Negative electrode Bruggeman coefficient (electrolyte)': 1.5,\n 'Negative electrode Bruggeman coefficient (electrode)': 1.5,\n 'Negative electrode exchange-current density [A.m-2]': ,\n 'Negative electrode OCP entropic change [V.K-1]': ,\n 'Maximum concentration in positive electrode [mol.m-3]': 51217.9257309275,\n 'Positive particle diffusivity [m2.s-1]': ,\n 'Positive electrode OCP [V]': ,\n 'Positive electrode porosity': 0.3,\n 'Positive electrode active material volume fraction': 0.5,\n 'Positive particle radius [m]': 1e-05,\n 'Positive electrode Bruggeman coefficient (electrolyte)': 1.5,\n 'Positive electrode Bruggeman coefficient (electrode)': 1.5,\n 'Positive electrode exchange-current density [A.m-2]': ,\n 'Positive electrode OCP entropic change [V.K-1]': ,\n 'Separator porosity': 1.0,\n 'Separator Bruggeman coefficient (electrolyte)': 1.5,\n 'Initial concentration in electrolyte [mol.m-3]': 1000.0,\n 'Reference temperature [K]': 298.15,\n 'Ambient temperature [K]': 298.15,\n 'Number of electrodes connected in parallel to make a cell': 1.0,\n 'Number of cells connected in series to make a battery': 1.0,\n 'Lower voltage cut-off [V]': 3.105,\n 'Upper voltage cut-off [V]': 4.1,\n 'Initial concentration in negative electrode [mol.m-3]': 19986.609595075,\n 'Initial concentration in positive electrode [mol.m-3]': 30730.7554385565}" }, "execution_count": 61, "metadata": {}, diff --git a/docs/source/examples/notebooks/parameterization/sensitivities_and_data_fitting.ipynb b/docs/source/examples/notebooks/parameterization/sensitivities_and_data_fitting.ipynb new file mode 100644 index 0000000000..09b562c1ca --- /dev/null +++ b/docs/source/examples/notebooks/parameterization/sensitivities_and_data_fitting.ipynb @@ -0,0 +1,327 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "bd9929be", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install \"pybamm[plot,cite]\" -q # install PyBaMM if it is not installed\n", + "# import dependencies\n", + "import pybamm\n", + "import numpy as np\n", + "import matplotlib.pylab as plt\n", + "import scipy.optimize" + ] + }, + { + "cell_type": "markdown", + "id": "b1223d98", + "metadata": {}, + "source": [ + "# Sensitivities and data fitting using PyBaMM\n", + "\n", + "PyBaMM input parameters [`pybamm.InputParameter`](https://docs.pybamm.org/en/stable/source/api/expression_tree/input_parameter.html) can be used to run many simulations with varying parameters. Here we will demonstrate PyBaMM's ability to calculate the senstivities of model outputs with respect to input parameters. \n", + "\n", + "To be more specific, given a model output $f(a)$, where $a$ is an input parameter, we wish to calculate $\\frac{\\partial f}{\\partial a}(a)$.\n", + "\n", + "First we will demonstrate using a toy model, given by the equations\n", + "\n", + "$$\\frac{dy}{dt} = a y$$\n", + "\n", + "with a scalar state variable $y$ and a scalar parameter $a$, and initial conditions\n", + "\n", + "$$y(0) = 1$$\n", + "\n", + "We will also define a model output given by $f = y^2$" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "25970cdf", + "metadata": {}, + "outputs": [], + "source": [ + "# setup a simple test model\n", + "model = pybamm.BaseModel(\"name\")\n", + "y = pybamm.Variable(\"y\")\n", + "a = pybamm.InputParameter(\"a\")\n", + "model.rhs = {y: a * y}\n", + "model.initial_conditions = {y: 1}\n", + "model.variables = {\"y squared\": y**2}\n", + "\n", + "solver = pybamm.IDAKLUSolver(rtol=1e-10, atol=1e-10)\n", + "t_eval = np.linspace(0, 1, 80)" + ] + }, + { + "cell_type": "markdown", + "id": "9f3d61bf", + "metadata": {}, + "source": [ + "Note that we have used the [`pybamm.IDAKLUSolver`](https://docs.pybamm.org/en/stable/source/api/solvers/idaklu_solver.html) solver for this example, this is currently the recommended solver for calculating sensitivities in PyBaMM.\n", + "\n", + "We can solve the model using a specific value of $a = 1$. However, now we will also calculate the forward sensitivities of the model by setting the argument `calculate_sensitivies=True`. Note that this argument will also accept a list of input parameters to calculate the sensitivities for, but setting it to `True` will calculate the sensitivities for **all** input parameters of the model " + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "1b1781a9", + "metadata": {}, + "outputs": [], + "source": [ + "solution = solver.solve(model, [0, 1], inputs={\"a\": 1}, calculate_sensitivities=True)" + ] + }, + { + "cell_type": "markdown", + "id": "4d6f176e", + "metadata": {}, + "source": [ + "We can now access the solution as normal, and the sensitivities using the syntax: `solution[output_name].sensitivities[input_parameter_name]`\n", + "\n", + "Note that a current restriction to the sensitivity calculation is that it will only return the sensitivities at the values of `t_eval` used to solve the model. Any interpolation between these values will have to be done manually" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "bf0a2d9c", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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/afqXOvvixpHqe2fUts+Idj+1XGE9k/ObZ9KCCGa1rdHRERERHKjuDl1RERERM6IRR0RERGRArCoIyIiIlIAFnVERERECsCijoiIiEgBWNQRERERKQCLOiIiIiIFYFFHREREpAAs6oiIiIgUgEUdOaznn38ed955p9QxiIjsim0fXQ2LOnJYBw8eRP/+/aWOQURkV2z76Gp49is5nOrqanh5eaGmpsb23KBBg7B//34JUxERtS22fXQ9rlIHIGoqV1dX/Prrrxg0aBCSkpIQHBwMd3d3qWMREbUptn10PSzqyOGo1Wrk5eUhICAAvXv3ljoOEZFdsO2j6+GcOnJIR48eZaNGRE6HbR9dC4s6ckhJSUls2IjI6bDto2thUUcOKTk5GX369JE6BhGRXbHto2thUUcOyWKxIDU1FXl5edDr9VLHISKyC7Z9dC0s6sghvfHGG1ixYgU6dOiAN954Q+o4RER2wbaProX71BEREREpAHvqiIiIiBSARR0RERGRArCoIyIiIlIAFnVERERECsCijoiIiEgBWNQRERERKQCLOiIiIiIFYFFHREREpAAs6oiIiIgUgEUdERERkQKwqCMiIiJSABZ1RERERArw/wG4APAEUxDXOQAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, axs = plt.subplots(1, 2)\n", + "axs[0].plot(t_eval, solution[\"y squared\"](t_eval))\n", + "axs[0].set_ylabel(r\"$y^2$\")\n", + "axs[0].set_xlabel(r\"$t$\")\n", + "axs[1].plot(solution.t, solution[\"y squared\"].sensitivities[\"a\"])\n", + "axs[1].set_ylabel(r\"$\\frac{dy^2}{da}$\")\n", + "axs[1].set_xlabel(r\"$t$\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "495bcf05", + "metadata": {}, + "source": [ + "## Sensitivities for the DFN model\n", + "\n", + "We can do the same for the DFN model included in PyBaMM. We will setup the DFN model using \"Current function\" as an input parameter. This is the parameter we wish to calculate the sensitivities with respect to." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "e9119add", + "metadata": {}, + "outputs": [], + "source": [ + "# now lets do the same for the DFN model\n", + "\n", + "# load model\n", + "model = pybamm.lithium_ion.DFN()\n", + "\n", + "# load parameter values and process model and geometry\n", + "param = model.default_parameter_values\n", + "\n", + "# we want to calculate the sensitivities of the \"Current function\" parameter, so set\n", + "# this an an input parameter\n", + "param.update({\"Current function [A]\": \"[input]\"})\n", + "\n", + "solver = pybamm.IDAKLUSolver(rtol=1e-3, atol=1e-6)\n", + "\n", + "sim = pybamm.Simulation(model, parameter_values=param, solver=solver)" + ] + }, + { + "cell_type": "markdown", + "id": "f198fbfe", + "metadata": {}, + "source": [ + "We can now evaluate the senstivities of, for example, the \"Terminal voltage\" output of the model with respect to the input parameter \"Current function\"." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "1d794537", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "solution = sim.solve(\n", + " [0, 3600], inputs={\"Current function [A]\": 0.15652}, calculate_sensitivities=True\n", + ")\n", + "plt.plot(\n", + " solution.t, solution[\"Terminal voltage [V]\"].sensitivities[\"Current function [A]\"]\n", + ")\n", + "\n", + "plt.xlabel(r\"$t$\")\n", + "plt.ylabel(\"sensitivities of Terminal voltage wrt Current fuction\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "d84c83fb", + "metadata": {}, + "source": [ + "## Sensitivities and data fitting\n", + "\n", + "Sensitivities are often used to aid data fitting by providing a means to calculate the gradient of the function to be minimised. Take for example the data fitting exercise we introduced in the previous notebook. Once again we will generate some fake data for fitting, like so:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "33d95c39", + "metadata": {}, + "outputs": [], + "source": [ + "t_eval = np.linspace(0, 3600, 100)\n", + "data = sim.solve([0, 3600], inputs={\"Current function [A]\": 0.2222})[\n", + " \"Terminal voltage [V]\"\n", + "](t_eval)" + ] + }, + { + "cell_type": "markdown", + "id": "d5045b57", + "metadata": {}, + "source": [ + "Now we will contruct a function to minimise, but here we will return both the value of the function, and its gradient with respect to the input parameter \"Current function\". Note that our objective function is the sum of squared different between the vector $\\mathbf{f}$, the simulated \"Terminal voltage\", and $\\mathbf{d}$, the vector of fake data, given by\n", + "\n", + "$$\\mathcal{O}(a) = \\sum_{i=0}^{i=N} (f_i(a) - d_i)^2$$ \n", + "\n", + "where $a$ is the parameter to be optimised (in this case \"Current function\"), $f_i$ is each element of the vector $\\mathbf{f}$, and $d_i$ is each element of $\\mathbf{d}$. We wish to also find the gradient of this function wrt the parameter $a$, which is:\n", + "\n", + "$$\\frac{\\partial \\mathcal{O}}{\\partial a}(a) = 2 \\sum_{i=0}^{i=N} (f_i(a) - d_i) \\frac{\\partial f_i}{\\partial a} $$ \n", + "\n", + "Using these equations, we will define a function that takes in as an argument $a$, and returns $(\\mathcal{O}(a), \\frac{\\partial \\mathcal{O}}{\\partial a}(a))$" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "ffad2bc0", + "metadata": {}, + "outputs": [], + "source": [ + "def sum_of_squares_jac(parameters):\n", + " sol = sim.solve(\n", + " [0, 3600],\n", + " t_interp=t_eval,\n", + " inputs={\"Current function [A]\": parameters[0]},\n", + " calculate_sensitivities=True,\n", + " )\n", + " term_voltage = sol[\"Terminal voltage [V]\"].data\n", + " term_voltage_sens = sol[\"Terminal voltage [V]\"].sensitivities[\n", + " \"Current function [A]\"\n", + " ]\n", + "\n", + " f = np.sum((term_voltage - data) ** 2)\n", + " g = 2 * np.sum((term_voltage - data) * term_voltage_sens)\n", + " print(\n", + " f\"evaluating function and jacobian for p = {parameters[0]}, \\tloss = {f}, grad = {g}\"\n", + " )\n", + " return f, g" + ] + }, + { + "cell_type": "markdown", + "id": "fdcae8ac", + "metadata": {}, + "source": [ + "We can then use this function along with an optimisation algorithm to recover the value of the Current function that was used to generate the data. In this case we will use the `scipy.optimize` module again. This module allows the use of a function in the form given above to perform the minimisation, using both the value of the objective function and its gradient to find the minimum value of $a$ in the least number of steps.\n", + "\n", + "Once again, we will place bounds on \"Current function [A]\" between $(0.01, 0.6)$, and use a random starting value $x_0$ between these bounds." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "44f52a7e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "starting parameter is 0.4035076613514513\n", + "evaluating function and jacobian for p = 0.4035076613514513, \tloss = 0.358632833514908, grad = 3.7278265436340283\n", + "evaluating function and jacobian for p = 0.01, \tloss = 0.8765036816401419, grad = -9.691403058800336\n", + "evaluating function and jacobian for p = 0.23970725060294026, \tloss = 0.0036706826105448887, grad = 0.41303112361463107\n", + "evaluating function and jacobian for p = 0.21929734316448973, \tloss = 0.00010628748342624681, grad = -0.07293742235816741\n", + "evaluating function and jacobian for p = 0.22236059911277223, \tloss = 2.5627985467376454e-07, grad = 0.0035066000930420284\n", + "evaluating function and jacobian for p = 0.22222008304298907, \tloss = 7.758859239380127e-09, grad = 2.935984691530423e-05\n", + "evaluating function and jacobian for p = 0.22221889660489277, \tloss = 7.74141508243804e-09, grad = -1.183255640750795e-08\n", + "recovered parameter is 0.22221889660489277\n" + ] + } + ], + "source": [ + "bounds = (0.01, 0.6)\n", + "x0 = np.random.uniform(low=bounds[0], high=bounds[1])\n", + "\n", + "print(\"starting parameter is\", x0)\n", + "res = scipy.optimize.minimize(sum_of_squares_jac, [x0], bounds=[bounds], jac=True)\n", + "print(\"recovered parameter is\", res.x[0])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/source/user_guide/fundamentals/public_api.rst b/docs/source/user_guide/fundamentals/public_api.rst new file mode 100644 index 0000000000..6d73ecaec1 --- /dev/null +++ b/docs/source/user_guide/fundamentals/public_api.rst @@ -0,0 +1,74 @@ +---------- +Public API +---------- + +.. module:: pybamm + :noindex: + +PyBaMM is a Python package for mathematical modelling and simulation of battery systems. The main classes and functions that are intended to be used by the user are described in this document. +For a more detailed description of the classes and methods, see the :doc:`API reference `. + +Available PyBaMM models +----------------------- + +PyBaMM includes a number of pre-implemented models, which can be used as they are or modified to suit your needs. The main models are: + +- :class:`lithium_ion.SPM`: Single Particle Model +- :class:`lithium_ion.SPMe`: Single Particle Model with Electrolyte +- :class:`lithium_ion.DFN`: Doyle-Fuller-Newman + +The behaviour of the models can be modified by passing in an :class:`BatteryModelOptions` object when creating the model. + +Simulations +----------- + +:class:`Simulation` is a class that automates the process of setting up a model and solving it, and acts as the highest-level API to PyBaMM. +Pass at least a :class:`BaseModel` object, and optionally the experiment, solver, parameter values, and geometry objects described below to the :class:`Simulation` object. +Any of these optional arguments not provided will be supplied by the defaults specified in the model. + +Parameters +---------- + +PyBaMM models are parameterised by a set of parameters, which are stored in a :class:`ParameterValues` object. This object acts like a Python dictionary with a few extra PyBaMM specific features and methods. +Parameters in a model are represented as either :class:`Parameter` objects or :class:`FunctionParameter` objects, and the values in the :class:`ParameterValues` object replace these objects in the model before it is solved. +The values in the :class:`ParameterValues` object can be scalars, Python functions or expressions of type :class:`Symbol`. + +Experiments +----------- + +An :class:`Experiment` object represents an experimental protocol that can be used to simulate the behaviour of a battery. The particular protocol can be provided as a Python string, or as a sequences of +:class:`step.BaseStep` objects. + +Solvers +------- + +The two main solvers in PyBaMM are the :class:`CasadiSolver` and the :class:`IDAKLUSolver`. Both are wrappers around the Sundials suite of solvers, but the :class:`CasadiSolver` uses the CasADi library +whereas the :class:`IDAKLUSolver` is PyBaMM specific. Both solvers have many options that can be set to control the solver behaviour, see the documentation for each solver for more details. + +When a model is solved, the solution is returned as a :class:`Solution` object. + +Plotting +-------- + +A solution object can be plotted using the :meth:`Solution.plot` or :meth:`Simulation.plot` methods, which returns a :class:`QuickPlot` object. +Note that the arguments to the plotting methods of both classes are the same as :class:`QuickPlot`. + +Other plotting functions are the :func:`plot_voltage_components` and :func:`plot_summary_variables` functions, which correspond to the similarly named methods of the :class:`Solution` and :class:`Simulation` classes. + +Writing PyBaMM models +--------------------- + +Each PyBaMM model, and the custom models written by users, are written as a set of expressions that describe the model. Each of the expressions is a subclass of the :class:`Symbol` class, which represents a mathematical expression. + +If you wish to create a custom model, you can use the :class:`BaseModel` class as a starting point. + + +Discretisation +-------------- + +Each PyBaMM model contains continuous operators that must be discretised before they can be solved. This is done using a :class:`Discretisation` object, which takes a :class:`Mesh` object and a dictionary of :class:`SpatialMethod` objects. + +Logging +------- + +PyBaMM uses the Python logging module to log messages at different levels of severity. Use the :func:`pybamm.set_logging_level` function to set the logging level for PyBaMM. diff --git a/docs/source/user_guide/index.md b/docs/source/user_guide/index.md index 58ce04101a..b497ed1a01 100644 --- a/docs/source/user_guide/index.md +++ b/docs/source/user_guide/index.md @@ -22,6 +22,7 @@ maxdepth: 2 --- fundamentals/index fundamentals/battery_models +fundamentals/public_api ``` ```{toctree} @@ -72,3 +73,12 @@ glob: ../examples/notebooks/creating_models/5-half-cell-model.ipynb ../examples/notebooks/creating_models/6-a-simple-SEI-model.ipynb ``` + +# Telemetry + +PyBaMM optionally collects anonymous usage data to help improve the library. This telemetry is opt-in and can be easily disabled. Here's what you need to know: + +- **What is collected**: Basic usage information like PyBaMM version, Python version, and which functions are run. +- **Why**: To understand how PyBaMM is used and prioritize development efforts. +- **Opt-out**: To disable telemetry, set the environment variable `PYBAMM_DISABLE_TELEMETRY=true` (or any value other than `false`) or use `pybamm.telemetry.disable()` in your code. +- **Privacy**: No personal information (name, email, etc) or sensitive information (parameter values, simulation results, etc) is ever collected. diff --git a/docs/source/user_guide/installation/index.rst b/docs/source/user_guide/installation/index.rst index d6411348c5..9225f1ee98 100644 --- a/docs/source/user_guide/installation/index.rst +++ b/docs/source/user_guide/installation/index.rst @@ -71,6 +71,7 @@ Package Minimum supp `typing-extensions `__ 4.10.0 `pandas `__ 1.5.0 `pooch `__ 1.8.1 +`posthog `__ 3.6.5 =================================================================== ========================== .. _install.optional_dependencies: @@ -145,8 +146,8 @@ Dependency `pre-commit `__ \- dev For managing and maintaining multi-language pre-commit hooks. `ruff `__ \- dev For code formatting. `nox `__ \- dev For running testing sessions in multiple environments. +`pytest-subtests `__ \- dev For subtests pytest fixture. `pytest-cov `__ \- dev For calculating test coverage. -`parameterized `__ \- dev For test parameterization. `pytest `__ 6.0.0 dev For running the test suites. `pytest-doctestplus `__ \- dev For running doctests. `pytest-xdist `__ \- dev For running tests in parallel across distributed workers. diff --git a/examples/scripts/multiprocess_jax_solver.py b/examples/scripts/multiprocess_jax_solver.py new file mode 100644 index 0000000000..8192256ed1 --- /dev/null +++ b/examples/scripts/multiprocess_jax_solver.py @@ -0,0 +1,57 @@ +import pybamm +import time +import numpy as np + + +# This script provides an example for massively vectorised +# model solves using the JAX BDF solver. First, +# we set up the model and process parameters +model = pybamm.lithium_ion.SPM() +model.convert_to_format = "jax" +model.events = [] # remove events (not supported in jax) +geometry = model.default_geometry +param = pybamm.ParameterValues("Chen2020") +param.update({"Current function [A]": "[input]"}) +param.process_geometry(geometry) +param.process_model(model) + +# Discretise and setup solver +mesh = pybamm.Mesh(geometry, model.default_submesh_types, model.default_var_pts) +disc = pybamm.Discretisation(mesh, model.default_spatial_methods) +disc.process_model(model) +t_eval = np.linspace(0, 3600, 100) +solver = pybamm.JaxSolver(atol=1e-6, rtol=1e-6, method="BDF") + +# Set number of vectorised solves +values = np.linspace(0.01, 1.0, 1000) +inputs = [{"Current function [A]": value} for value in values] + +# Run solve for all inputs, with a just-in-time compilation +# occurring on the first solve. All sequential solves will +# use the compiled code, with a large performance improvement. +start_time = time.time() +sol = solver.solve(model, t_eval, inputs=inputs) +print(f"Time taken: {time.time() - start_time}") # 1.3s + +# Rerun the vectorised solve, showing performance improvement +start_time = time.time() +compiled_sol = solver.solve(model, t_eval, inputs=inputs) +print(f"Compiled time taken: {time.time() - start_time}") # 0.42s + +# Plot one of the solves +plot = pybamm.QuickPlot( + sol[5], + [ + "Negative particle concentration [mol.m-3]", + "Electrolyte concentration [mol.m-3]", + "Positive particle concentration [mol.m-3]", + "Current [A]", + "Negative electrode potential [V]", + "Electrolyte potential [V]", + "Positive electrode potential [V]", + "Voltage [V]", + ], + time_unit="seconds", + spatial_unit="um", +) +plot.dynamic_plot() diff --git a/noxfile.py b/noxfile.py index 6567ed167c..5ab32f463f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -2,7 +2,6 @@ import os import sys import warnings -import platform from pathlib import Path @@ -27,28 +26,15 @@ def set_iree_state(): """ state = "ON" if os.getenv("PYBAMM_IDAKLU_EXPR_IREE", "OFF") == "ON" else "OFF" if state == "ON": - if sys.platform == "win32": + if sys.platform == "win32" or sys.platform == "darwin": warnings.warn( ( - "IREE is not enabled on Windows yet. " + "IREE is not enabled on Windows and MacOS. " "Setting PYBAMM_IDAKLU_EXPR_IREE=OFF." ), stacklevel=2, ) return "OFF" - if sys.platform == "darwin": - # iree-compiler is currently only available as a wheel on macOS 13 (or - # higher) and Python version 3.11 - mac_ver = int(platform.mac_ver()[0].split(".")[0]) - if (not sys.version_info[:2] == (3, 11)) or mac_ver < 13: - warnings.warn( - ( - "IREE is only supported on MacOS 13 (or higher) and Python" - "version 3.11. Setting PYBAMM_IDAKLU_EXPR_IREE=OFF." - ), - stacklevel=2, - ) - return "OFF" return state @@ -222,7 +208,7 @@ def run_scripts(session): # https://bitbucket.org/pybtex-devs/pybtex/issues/169/replace-pkg_resources-with # is fixed session.install("setuptools", silent=False) - session.install("-e", ".[all,dev]", silent=False) + session.install("-e", ".[all,dev,jax]", silent=False) session.run("python", "-m", "pytest", "-m", "scripts") diff --git a/pyproject.toml b/pyproject.toml index 7fb1a5ce95..1002b86cc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = [ "setuptools>=64", "wheel", # On Windows, use the CasADi vcpkg registry and CMake bundled from MSVC - "casadi>=3.6.6; platform_system!='Windows'", + "casadi>=3.6.7; platform_system!='Windows'", # Note: the version of CasADi as a build-time dependency should be matched # across platforms, so updates to its minimum version here should be accompanied # by a version bump in https://github.com/pybamm-team/casadi-vcpkg-registry. @@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta" [project] name = "pybamm" -version = "24.9.0" +version = "24.11.0" license = { file = "LICENSE.txt" } description = "Python Battery Mathematical Modelling" authors = [{name = "The PyBaMM Team", email = "pybamm@pybamm.org"}] @@ -37,13 +37,14 @@ classifiers = [ dependencies = [ "numpy>=1.23.5,<2.0.0", "scipy>=1.11.4", - "casadi>=3.6.6", + "casadi>=3.6.7", "xarray>=2022.6.0", "anytree>=2.8.0", "sympy>=1.12", "typing-extensions>=4.10.0", "pandas>=1.5.0", "pooch>=1.8.1", + "posthog", ] [project.urls] @@ -84,6 +85,7 @@ plot = [ "matplotlib>=3.6.0", ] cite = [ + "setuptools", # Fix for a pybtex issue "pybtex>=0.24.0", ] # Battery Parameter eXchange format @@ -105,12 +107,11 @@ dev = [ "pytest-cov", # For doctest "pytest-doctestplus", - # For test parameterization - "parameterized>=0.9", # pytest and its plugins "pytest>=6", "pytest-xdist", "pytest-mock", + "pytest-subtests", # For testing Jupyter notebooks "nbmake", # To access the metadata for python packages @@ -152,6 +153,7 @@ Ramadass2004 = "pybamm.input.parameters.lithium_ion.Ramadass2004:get_parameter_v Xu2019 = "pybamm.input.parameters.lithium_ion.Xu2019:get_parameter_values" ECM_Example = "pybamm.input.parameters.ecm.example_set:get_parameter_values" MSMR_Example = "pybamm.input.parameters.lithium_ion.MSMR_example_set:get_parameter_values" +Chayambuka2022 = "pybamm.input.parameters.sodium_ion.Chayambuka2022:get_parameter_values" [tool.setuptools] include-package-data = true @@ -197,6 +199,8 @@ extend-select = [ "YTT", # flake8-2020 "TID252", # relative-imports "S101", # to identify use of assert statement + "PT027", # remove unittest style assertion + "PT009", # Use pytest.raises instead of unittest-style ] ignore = [ "E741", # Ambiguous variable name @@ -230,6 +234,7 @@ minversion = "8" required_plugins = [ "pytest-xdist", "pytest-mock", + "pytest-subtests", ] norecursedirs = 'pybind11*' addopts = [ diff --git a/setup.py b/setup.py index 74de1baca4..8a49bfd715 100644 --- a/setup.py +++ b/setup.py @@ -327,6 +327,8 @@ def compile_KLU(): "src/pybamm/solvers/c_solvers/idaklu/Solution.hpp", "src/pybamm/solvers/c_solvers/idaklu/Options.hpp", "src/pybamm/solvers/c_solvers/idaklu/Options.cpp", + "src/pybamm/solvers/c_solvers/idaklu/observe.hpp", + "src/pybamm/solvers/c_solvers/idaklu/observe.cpp", "src/pybamm/solvers/c_solvers/idaklu.cpp", ], ) diff --git a/src/pybamm/CITATIONS.bib b/src/pybamm/CITATIONS.bib index 3d853738b4..62b4b1003e 100644 --- a/src/pybamm/CITATIONS.bib +++ b/src/pybamm/CITATIONS.bib @@ -22,6 +22,17 @@ @article{Ai2022 author = {Weilong Ai and Niall Kirkaldy and Yang Jiang and Gregory Offer and Huizhi Wang and Billy Wu}, } +@article{Akanni1987, + title={Effective transport coefficients in heterogeneous media}, + author={Akanni, KA and Evans, JW and Abramson, IS}, + journal={Chemical Engineering Science}, + volume={42}, + number={8}, + pages={1945--1954}, + year={1987}, + publisher={Elsevier} +} + @article{Andersson2019, author = {Andersson, Joel A. E. and Gillis, Joris and Horn, Greg and Rawlings, James B. and Diehl, Moritz}, @@ -47,6 +58,39 @@ @article{Baker2018 publisher={IOP Publishing} } +@article{Baltensperger2003, + title={Spectral differencing with a twist}, + author={Baltensperger, Richard and Trummer, Manfred R}, + journal={SIAM journal on scientific computing}, + volume={24}, + number={5}, + pages={1465--1487}, + year={2003}, + publisher={SIAM} +} + +@article{Barletta2022thevenin, + title={Th{\'e}venin’s Battery Model Parameter Estimation Based on Simulink}, + author={Barletta, Giulio and DiPrima, Piera and Papurello, Davide}, + journal={Energies}, + volume={15}, + number={17}, + pages={6207}, + year={2022}, + publisher={MDPI} +} + +@article{Beeckman1990, + title={Mathematical description of heterogeneous materials}, + author={Beeckman, JW}, + journal={Chemical engineering science}, + volume={45}, + number={8}, + pages={2603--2610}, + year={1990}, + publisher={Elsevier} +} + @article{BrosaPlanella2021, title = {Systematic derivation and validation of a reduced thermal-electrochemical model for lithium-ion batteries using asymptotic methods}, author = {Brosa Planella, Ferran and Sheikh, Muhammad and Widanage, W. Dhammika}, @@ -70,6 +114,38 @@ @article{BrosaPlanella2022 doi = {}, } +@article{Bruggeman1935, + title={Berechnung verschiedener physikalischer Konstanten von heterogenen Substanzen. I. Dielektrizit{\"a}tskonstanten und Leitf{\"a}higkeiten der Mischk{\"o}rper aus isotropen Substanzen}, + author={Bruggeman, Von DAG}, + journal={Annalen der physik}, + volume={416}, + number={7}, + pages={636--664}, + year={1935}, + publisher={Wiley Online Library} +} + +@article{Byrne1975, + title={A polyalgorithm for the numerical solution of ordinary differential equations}, + author={Byrne, George D. and Hindmarsh, Alan C.}, + journal={ACM Transactions on Mathematical Software (TOMS)}, + volume={1}, + number={1}, + pages={71--96}, + year={1975}, + publisher={ACM New York, NY, USA} +} + +@article{Chayambuka2022, + title={Physics-based modeling of sodium-ion batteries part II. Model and validation}, + author={Chayambuka, Kudakwashe and Mulder, Grietus and Danilov, Dmitri L and Notten, Peter HL}, + journal={Electrochimica Acta}, + volume={404}, + pages={139764}, + year={2022}, + publisher={Elsevier} +} + @article{Chen2020, author = {Chen, Chang-Hui and Brosa Planella, Ferran and O'Regan, Kieran and Gastol, Dominika and Widanage, W. Dhammika and Kendrick, Emma}, title = {{Development of Experimental Techniques for Parameterization of Multi-scale Lithium-ion Battery Models}}, @@ -140,6 +216,16 @@ @article{Ecker2015ii doi = {10.1149/2.0541509jes}, } +@article{Fan2022, + title={Data-driven identification of lithium-ion batteries: A nonlinear equivalent circuit model with diffusion dynamics}, + author={Fan, Chuanxin and O’Regan, Kieran and Li, Liuying and Higgins, Matthew D and Kendrick, Emma and Widanage, Widanalage D}, + journal={Applied Energy}, + volume={321}, + pages={119336}, + year={2022}, + publisher={Elsevier} +} + @article{Gustafsson2020, doi = {10.21105/joss.02369}, year = {2020}, @@ -152,6 +238,13 @@ @article{Gustafsson2020 journal = {Journal of Open Source Software}, } +@book{Hairer1993, + title={Solving ordinary differential equations. 1, Nonstiff problems}, + author={Hairer, Ernst and N{\o}rsett, Syvert P and Wanner, Gerhard}, + year={1993}, + publisher={Springer-Vlg} +} + @article{Hales2019, title={The cell cooling coefficient: a standard to define heat rejection from lithium-ion batteries}, author={Hales, Alastair and Diaz, Laura Bravo and Marzook, Mohamed Waseem and Zhao, Yan and Patel, Yatish and Offer, Gregory}, @@ -198,7 +291,7 @@ @article{Hindmarsh2005 @misc{jax2018, author = {James Bradbury and Roy Frostig and Peter Hawkins and Matthew James Johnson and Chris Leary and Dougal Maclaurin and Skye Wanderman-Milne}, title = {{JAX: composable transformations of Python+NumPy programs}}, - url = {http://github.com/google/jax}, + url = {http://github.com/jax-ml/jax}, version = {0.2.5}, year = {2018}, } @@ -252,7 +345,18 @@ @article{Lain2019 doi = {10.3390/batteries5040064}, } -@article{lin2014lumped, +@article{Landesfeind2019, + title={Temperature and concentration dependence of the ionic transport properties of lithium-ion battery electrolytes}, + author={Landesfeind, Johannes and Gasteiger, Hubert A}, + journal={Journal of The Electrochemical Society}, + volume={166}, + number={14}, + pages={A3079--A3097}, + year={2019}, + publisher={The Electrochemical Society} +} + +@article{Lin2014, title={A lumped-parameter electro-thermal model for cylindrical batteries}, author={Lin, Xinfan and Perez, Hector E and Mohan, Shankar and Siegel, Jason B and Stefanopoulou, Anna G and Ding, Yi and Castanier, Matthew P}, journal={Journal of Power Sources}, @@ -262,6 +366,17 @@ @article{lin2014lumped publisher={Elsevier} } +@article{Mackie1955, + title={The diffusion of electrolytes in a cation-exchange resin membrane I. Theoretical}, + author={Mackie, JS and Meares, P}, + journal={Proceedings of the Royal Society of London. Series A. Mathematical and Physical Sciences}, + volume={232}, + number={1191}, + pages={498--509}, + year={1955}, + publisher={The Royal Society London} +} + @article{Marquis2019, title = {{An asymptotic derivation of a single particle model with electrolyte}}, author = {Marquis, Scott G. and Sulzer, Valentin and Timms, Robert and Please, Colin P. and Chapman, S. Jon}, @@ -321,6 +436,17 @@ @article{Newman1962 publisher={IOP Publishing} } +@article{Nieto2012, +author = {Nieto, Nerea and Diaz, Luis and Gastelurrutia, Jon and Alava, Isabel and Blanco, Francisco and Ramos, Juan and Rivas, Alejandro}, +year = {2012}, +month = {11}, +pages = {A212-A217}, +title = {Thermal Modeling of Large Format Lithium-Ion Cells}, +volume = {160}, +journal = {Journal of the Electrochemical Society}, +doi = {10.1149/2.042302jes} +} + @article{Nyman2008, title={Electrochemical characterisation and modelling of the mass transport phenomena in LiPF6--EC--EMC electrolyte}, author={Nyman, Andreas and Behm, M{\aa}rten and Lindbergh, G{\"o}ran}, @@ -370,6 +496,28 @@ @article{ORegan2022 doi = {10.1016/j.electacta.2022.140700}, } +@article{Petersen1958, + title={Diffusion in a pore of varying cross section}, + author={Petersen, EE}, + journal={AIChE Journal}, + volume={4}, + number={3}, + pages={343--345}, + year={1958}, + publisher={Wiley Online Library} +} + +@article{Ploehn2004, + title={Solvent diffusion model for aging of lithium-ion battery cells}, + author={Ploehn, Harry J and Ramadass, Premanand and White, Ralph E}, + journal={Journal of The Electrochemical Society}, + volume={151}, + number={3}, + pages={A456}, + year={2004}, + publisher={IOP Publishing} +} + @article{Prada2013, title = {{A simplified electrochemical and thermal aging model of LiFePO4-graphite Li-ion batteries: power and capacity fade simulations}}, author = {Prada, Eric and Di Domenico, D. and Creff, Y. and Bernard, J. and Sauvant-Moynot, Val{\'{e}}rie and Huet, Fran{\c{c}}ois}, @@ -439,6 +587,61 @@ @article{Richardson2021 doi = {10.1016/j.electacta.2021.138909}, } +@article{Rieger2016, + title={A new method to model the thickness change of a commercial pouch cell during discharge}, + author={Rieger, Bernhard and Erhard, Simon V and Rumpf, Katharina and Jossen, Andreas}, + journal={Journal of The Electrochemical Society}, + volume={163}, + number={8}, + pages={A1566}, + year={2016}, + publisher={IOP Publishing} +} + +@article{Safari2008, + title={Multimodal physics-based aging model for life prediction of Li-ion batteries}, + author={Safari, M and Morcrette, Mathieu and Teyssot, A and Delacourt, Charles}, + journal={Journal of The Electrochemical Society}, + volume={156}, + number={3}, + pages={A145}, + year={2008}, + publisher={IOP Publishing} +} + +@article{Shampine1997, + title={The matlab ode suite}, + author={Shampine, Lawrence F and Reichelt, Mark W}, + journal={SIAM journal on scientific computing}, + volume={18}, + number={1}, + pages={1--22}, + year={1997}, + publisher={SIAM} +} + +@article{Shen2007, + title={Critical review of the impact of tortuosity on diffusion}, + author={Shen, Lihua and Chen, Zhangxin}, + journal={Chemical Engineering Science}, + volume={62}, + number={14}, + pages={3748--3755}, + year={2007}, + publisher={Elsevier} +} + +@article{Single2018, + title={Identifying the mechanism of continued growth of the solid--electrolyte interphase}, + author={Single, Fabian and Latz, Arnulf and Horstmann, Birger}, + journal={ChemSusChem}, + volume={11}, + number={12}, + pages={1950--1955}, + year={2018}, + publisher={Wiley Online Library} +} + @article{Sripad2020, title={Kinetics of lithium electrodeposition and stripping}, author={Sripad, Shashank and Korff, Daniel and DeCaluwe, Steven C and Viswanathan, Venkatasubramanian}, @@ -510,6 +713,17 @@ @article{Timms2021 doi = {10.1137/20M1336898}, } +@article{Tomadakis1993, + title={Transport properties of random arrays of freely overlapping cylinders with various orientation distributions}, + author={Tomadakis, Manolis M and Sotirchos, Stratis V}, + journal={The Journal of chemical physics}, + volume={98}, + number={1}, + pages={616--626}, + year={1993}, + publisher={American Institute of Physics} +} + @article{Valoen2005, title={Transport properties of LiPF6-based Li-ion battery electrolytes}, author={Val{\o}en, Lars Ole and Reimers, Jan N}, @@ -556,6 +770,17 @@ @article{Wang2002 doi = {10.1006/jcph.2002.7041}, } +@article{Weissberg1963, + title={Effective diffusion coefficient in porous media}, + author={Weissberg, Harold L}, + journal={Journal of Applied Physics}, + volume={34}, + number={9}, + pages={2636--2639}, + year={1963}, + publisher={American Institute of Physics} +} + @article{Weng2023, title={Differential voltage analysis for battery manufacturing process control}, author={Weng, Andrew and Siegel, Jason B and Stefanopoulou, Anna}, @@ -563,6 +788,19 @@ @article{Weng2023 year={2023} } +@article{Wycisk2022, + title = {Modified Plett-model for modeling voltage hysteresis in lithium-ion cells}, + journal = {Journal of Energy Storage}, + volume = {52}, + pages = {105016}, + year = {2022}, + issn = {2352-152X}, + doi = {https://doi.org/10.1016/j.est.2022.105016}, + url = {https://www.sciencedirect.com/science/article/pii/S2352152X22010192}, + author = {Dominik Wycisk and Marc Oldenburger and Marc Gerry Stoye and Toni Mrkonjic and Arnulf Latz}, + keywords = {Lithium-ion battery, Voltage hysteresis, Plett-model, Silicon–graphite anode}, +} + @article{Xu2019, title={Evolution of Dead Lithium Growth in Lithium Metal Batteries: Experimentally Validated Model of the Apparent Capacity Loss}, author={Xu, Shanshan and Chen, Kuan-Hung and Dasgupta, Neil P and Siegel, Jason B and Stefanopoulou, Anna G}, @@ -595,222 +833,3 @@ @article{Zhao2018 year={2018}, publisher={IOP Publishing} } - -@article{Barletta2022thevenin, - title={Th{\'e}venin’s Battery Model Parameter Estimation Based on Simulink}, - author={Barletta, Giulio and DiPrima, Piera and Papurello, Davide}, - journal={Energies}, - volume={15}, - number={17}, - pages={6207}, - year={2022}, - publisher={MDPI} -} - -@article{Nieto2012, -author = {Nieto, Nerea and Diaz, Luis and Gastelurrutia, Jon and Alava, Isabel and Blanco, Francisco and Ramos, Juan and Rivas, Alejandro}, -year = {2012}, -month = {11}, -pages = {A212-A217}, -title = {Thermal Modeling of Large Format Lithium-Ion Cells}, -volume = {160}, -journal = {Journal of the Electrochemical Society}, -doi = {10.1149/2.042302jes} -} - -@article{shampine1997matlab, - title={The matlab ode suite}, - author={Shampine, Lawrence F and Reichelt, Mark W}, - journal={SIAM journal on scientific computing}, - volume={18}, - number={1}, - pages={1--22}, - year={1997}, - publisher={SIAM} -} - -@article{byrne1975polyalgorithm, - title={A polyalgorithm for the numerical solution of ordinary differential equations}, - author={Byrne, George D. and Hindmarsh, Alan C.}, - journal={ACM Transactions on Mathematical Software (TOMS)}, - volume={1}, - number={1}, - pages={71--96}, - year={1975}, - publisher={ACM New York, NY, USA} -} - -@book{hairer1993solving, - title={Solving ordinary differential equations. 1, Nonstiff problems}, - author={Hairer, Ernst and N{\o}rsett, Syvert P and Wanner, Gerhard}, - year={1993}, - publisher={Springer-Vlg} -} - -@article{baltensperger2003spectral, - title={Spectral differencing with a twist}, - author={Baltensperger, Richard and Trummer, Manfred R}, - journal={SIAM journal on scientific computing}, - volume={24}, - number={5}, - pages={1465--1487}, - year={2003}, - publisher={SIAM} -} - -@article{rieger2016new, - title={A new method to model the thickness change of a commercial pouch cell during discharge}, - author={Rieger, Bernhard and Erhard, Simon V and Rumpf, Katharina and Jossen, Andreas}, - journal={Journal of The Electrochemical Society}, - volume={163}, - number={8}, - pages={A1566}, - year={2016}, - publisher={IOP Publishing} -} - -@article{ploehn2004solvent, - title={Solvent diffusion model for aging of lithium-ion battery cells}, - author={Ploehn, Harry J and Ramadass, Premanand and White, Ralph E}, - journal={Journal of The Electrochemical Society}, - volume={151}, - number={3}, - pages={A456}, - year={2004}, - publisher={IOP Publishing} -} - -@article{single2018identifying, - title={Identifying the mechanism of continued growth of the solid--electrolyte interphase}, - author={Single, Fabian and Latz, Arnulf and Horstmann, Birger}, - journal={ChemSusChem}, - volume={11}, - number={12}, - pages={1950--1955}, - year={2018}, - publisher={Wiley Online Library} -} - -@article{safari2008multimodal, - title={Multimodal physics-based aging model for life prediction of Li-ion batteries}, - author={Safari, M and Morcrette, Mathieu and Teyssot, A and Delacourt, Charles}, - journal={Journal of The Electrochemical Society}, - volume={156}, - number={3}, - pages={A145}, - year={2008}, - publisher={IOP Publishing} -} - -@article{landesfeind2019temperature, - title={Temperature and concentration dependence of the ionic transport properties of lithium-ion battery electrolytes}, - author={Landesfeind, Johannes and Gasteiger, Hubert A}, - journal={Journal of The Electrochemical Society}, - volume={166}, - number={14}, - pages={A3079--A3097}, - year={2019}, - publisher={The Electrochemical Society} -} -@article{akanni1987effective, - title={Effective transport coefficients in heterogeneous media}, - author={Akanni, KA and Evans, JW and Abramson, IS}, - journal={Chemical Engineering Science}, - volume={42}, - number={8}, - pages={1945--1954}, - year={1987}, - publisher={Elsevier} -} -@article{petersen1958diffusion, - title={Diffusion in a pore of varying cross section}, - author={Petersen, EE}, - journal={AIChE Journal}, - volume={4}, - number={3}, - pages={343--345}, - year={1958}, - publisher={Wiley Online Library} -} -@article{bruggeman1935berechnung, - title={Berechnung verschiedener physikalischer Konstanten von heterogenen Substanzen. I. Dielektrizit{\"a}tskonstanten und Leitf{\"a}higkeiten der Mischk{\"o}rper aus isotropen Substanzen}, - author={Bruggeman, Von DAG}, - journal={Annalen der physik}, - volume={416}, - number={7}, - pages={636--664}, - year={1935}, - publisher={Wiley Online Library} -} -@article{weissberg1963effective, - title={Effective diffusion coefficient in porous media}, - author={Weissberg, Harold L}, - journal={Journal of Applied Physics}, - volume={34}, - number={9}, - pages={2636--2639}, - year={1963}, - publisher={American Institute of Physics} -} -@article{tomadakis1993transport, - title={Transport properties of random arrays of freely overlapping cylinders with various orientation distributions}, - author={Tomadakis, Manolis M and Sotirchos, Stratis V}, - journal={The Journal of chemical physics}, - volume={98}, - number={1}, - pages={616--626}, - year={1993}, - publisher={American Institute of Physics} -} -@article{beeckman1990mathematical, - title={Mathematical description of heterogeneous materials}, - author={Beeckman, JW}, - journal={Chemical engineering science}, - volume={45}, - number={8}, - pages={2603--2610}, - year={1990}, - publisher={Elsevier} -} -@article{mackie1955diffusion, - title={The diffusion of electrolytes in a cation-exchange resin membrane I. Theoretical}, - author={Mackie, JS and Meares, P}, - journal={Proceedings of the Royal Society of London. Series A. Mathematical and Physical Sciences}, - volume={232}, - number={1191}, - pages={498--509}, - year={1955}, - publisher={The Royal Society London} -} -@article{shen2007critical, - title={Critical review of the impact of tortuosity on diffusion}, - author={Shen, Lihua and Chen, Zhangxin}, - journal={Chemical Engineering Science}, - volume={62}, - number={14}, - pages={3748--3755}, - year={2007}, - publisher={Elsevier} -} -@article{Wycisk2022, - title = {Modified Plett-model for modeling voltage hysteresis in lithium-ion cells}, - journal = {Journal of Energy Storage}, - volume = {52}, - pages = {105016}, - year = {2022}, - issn = {2352-152X}, - doi = {https://doi.org/10.1016/j.est.2022.105016}, - url = {https://www.sciencedirect.com/science/article/pii/S2352152X22010192}, - author = {Dominik Wycisk and Marc Oldenburger and Marc Gerry Stoye and Toni Mrkonjic and Arnulf Latz}, - keywords = {Lithium-ion battery, Voltage hysteresis, Plett-model, Silicon–graphite anode}, -} - -@article{Fan2022, - title={Data-driven identification of lithium-ion batteries: A nonlinear equivalent circuit model with diffusion dynamics}, - author={Fan, Chuanxin and O’Regan, Kieran and Li, Liuying and Higgins, Matthew D and Kendrick, Emma and Widanage, Widanalage D}, - journal={Applied Energy}, - volume={321}, - pages={119336}, - year={2022}, - publisher={Elsevier} -} diff --git a/src/pybamm/__init__.py b/src/pybamm/__init__.py index 36ad0b137a..b466c3896b 100644 --- a/src/pybamm/__init__.py +++ b/src/pybamm/__init__.py @@ -1,5 +1,3 @@ -import sys - from pybamm.version import __version__ # Demote expressions to 32-bit floats/ints - option used for IDAKLU-MLIR compilation @@ -23,6 +21,7 @@ from .logger import logger, set_logging_level, get_new_logger from .settings import settings from .citations import Citations, citations, print_citations +from . import config # Classes for the Expression Tree from .expression_tree.symbol import * @@ -36,10 +35,12 @@ from .expression_tree.broadcasts import * from .expression_tree.functions import * from .expression_tree.interpolant import Interpolant +from .expression_tree.discrete_time_sum import * from .expression_tree.input_parameter import InputParameter from .expression_tree.parameter import Parameter, FunctionParameter from .expression_tree.scalar import Scalar from .expression_tree.variable import * +from .expression_tree.coupled_variable import * from .expression_tree.independent_variable import * from .expression_tree.independent_variable import t from .expression_tree.vector import Vector @@ -75,6 +76,7 @@ from .models.full_battery_models import lead_acid from .models.full_battery_models import lithium_ion from .models.full_battery_models import equivalent_circuit +from .models.full_battery_models import sodium_ion # Submodel classes from .models.submodels.base_submodel import BaseSubModel @@ -157,7 +159,8 @@ # Solver classes from .solvers.solution import Solution, EmptySolution, make_cycle_solution -from .solvers.processed_variable import ProcessedVariable +from .solvers.processed_variable_time_integral import ProcessedVariableTimeIntegral +from .solvers.processed_variable import ProcessedVariable, process_variable from .solvers.processed_variable_computed import ProcessedVariableComputed from .solvers.base_solver import BaseSolver from .solvers.dummy_solver import DummySolver @@ -192,19 +195,24 @@ # Batch Study from .batch_study import BatchStudy -# Callbacks -from . import callbacks +# Callbacks, telemetry, config +from . import callbacks, telemetry, config # Pybamm Data manager using pooch from .pybamm_data import DataLoader -# Remove any imported modules, so we don't expose them as part of pybamm -del sys +# Fix Casadi import +import os +import pathlib +import sysconfig + +os.environ["CASADIPATH"] = str(pathlib.Path(sysconfig.get_path("purelib")) / "casadi") __all__ = [ "batch_study", "callbacks", "citations", + "config", "discretisations", "doc_utils", "experiment", @@ -220,8 +228,11 @@ "simulation", "solvers", "spatial_methods", + "telemetry", "type_definitions", "util", "version", "pybamm_data", ] + +config.generate() diff --git a/src/pybamm/config.py b/src/pybamm/config.py new file mode 100644 index 0000000000..0bd5c96eb6 --- /dev/null +++ b/src/pybamm/config.py @@ -0,0 +1,173 @@ +import uuid +import os +import platformdirs +from pathlib import Path +import pybamm +import sys +import threading +import time + + +def check_env_opt_out(): + return os.getenv("PYBAMM_DISABLE_TELEMETRY", "false").lower() != "false" + + +def check_opt_out(): + opt_out = check_env_opt_out() + config = pybamm.config.read() + if config: + opt_out = opt_out or not config["enable_telemetry"] + return opt_out + + +def is_running_tests(): # pragma: no cover + """ + Detect if the code is being run as part of a test suite or building docs with Sphinx. + + Returns: + bool: True if running tests or building docs, False otherwise. + """ + # Check if pytest or unittest is running + if any( + test_module in sys.modules for test_module in ["pytest", "unittest", "nose"] + ): + return True + + # Check for other common CI environment variables + ci_env_vars = [ + "GITHUB_ACTIONS", + "CI", + "TRAVIS", + "CIRCLECI", + "JENKINS_URL", + "GITLAB_CI", + ] + if any(var in os.environ for var in ci_env_vars): + return True + + # Check if building docs with Sphinx + if any(mod == "sphinx" or mod.startswith("sphinx.") for mod in sys.modules): + print( + f"Found Sphinx module: {[mod for mod in sys.modules if mod.startswith('sphinx')]}" + ) + return True + + return False + + +def ask_user_opt_in(timeout=10): + """ + Ask the user if they want to opt in to telemetry. + + Parameters + ---------- + timeout : float, optional + The timeout for the user to respond to the prompt. Default is 10 seconds. + + Returns + ------- + bool + True if the user opts in, False otherwise. + """ + print( + "PyBaMM can collect usage data and send it to the PyBaMM team to " + "help us improve the software.\n" + "We do not collect any sensitive information such as models, parameters, " + "or simulation results - only information on which parts of the code are " + "being used and how frequently.\n" + "This is entirely optional and does not impact the functionality of PyBaMM.\n" + "For more information, see https://docs.pybamm.org/en/latest/source/user_guide/index.html#telemetry" + ) + + def get_input(): # pragma: no cover + try: + user_input = ( + input("Do you want to enable telemetry? (Y/n): ").strip().lower() + ) + answer.append(user_input) + except Exception: + # Handle any input errors + pass + + time_start = time.time() + + while True: + if time.time() - time_start > timeout: + print("\nTimeout reached. Defaulting to not enabling telemetry.") + return False + + answer = [] + # Create and start input thread + input_thread = threading.Thread(target=get_input) + input_thread.daemon = True + input_thread.start() + + # Wait for either timeout or input + input_thread.join(timeout) + + if answer: + if answer[0] in ["yes", "y", ""]: + print("\nTelemetry enabled.\n") + return True + elif answer[0] in ["no", "n"]: + print("\nTelemetry disabled.\n") + return False + else: + print("\nInvalid input. Please enter 'yes/y' for yes or 'no/n' for no.") + else: + print("\nTimeout reached. Defaulting to not enabling telemetry.") + return False + + +def generate(): + if is_running_tests() or check_opt_out(): + return + + # Check if the config file already exists + if read() is not None: + return + + # Ask the user if they want to opt in to telemetry + opt_in = ask_user_opt_in() + config_file = Path(platformdirs.user_config_dir("pybamm")) / "config.yml" + write_uuid_to_file(config_file, opt_in) + + if opt_in: + pybamm.telemetry.capture("user-opted-in") + + +def read(): + config_file = Path(platformdirs.user_config_dir("pybamm")) / "config.yml" + return read_uuid_from_file(config_file) + + +def write_uuid_to_file(config_file, opt_in): + # Create the directory if it doesn't exist + config_file.parent.mkdir(parents=True, exist_ok=True) + + # Write the UUID to the config file in YAML format + with open(config_file, "w") as f: + f.write("pybamm:\n") + f.write(f" enable_telemetry: {opt_in}\n") + if opt_in: + unique_id = uuid.uuid4() + f.write(f" uuid: {unique_id}\n") + + +def read_uuid_from_file(config_file): + # Check if the config file exists + if not config_file.exists(): + return None + + # Read the UUID from the config file + with open(config_file) as f: + content = f.read().strip() + + # Extract the UUID using YAML parsing + try: + import yaml + + config = yaml.safe_load(content) + return config["pybamm"] + except (yaml.YAMLError, ValueError): + return None diff --git a/src/pybamm/discretisations/discretisation.py b/src/pybamm/discretisations/discretisation.py index af4bd2edd6..3d9579ff9c 100644 --- a/src/pybamm/discretisations/discretisation.py +++ b/src/pybamm/discretisations/discretisation.py @@ -500,8 +500,8 @@ def check_tab_conditions(self, symbol, bcs): if domain != "current collector": raise pybamm.ModelError( - f"""Boundary conditions can only be applied on the tabs in the domain - 'current collector', but {symbol} has domain {domain}""" + "Boundary conditions can only be applied on the tabs in the domain " + f"'current collector', but {symbol} has domain {domain}" ) # Replace keys with "left" and "right" as appropriate for 1D meshes if isinstance(mesh, pybamm.SubMesh1D): @@ -893,11 +893,9 @@ def _process_symbol(self, symbol): y_slices = self.y_slices[symbol] except KeyError as error: raise pybamm.ModelError( - f""" - No key set for variable '{symbol.name}'. Make sure it is included in either - model.rhs or model.algebraic in an unmodified form - (e.g. not Broadcasted) - """ + f"No key set for variable '{symbol.name}'. Make sure it is included in either " + "model.rhs or model.algebraic in an unmodified form " + "(e.g. not Broadcasted)" ) from error # Add symbol's reference and multiply by the symbol's scale # so that the state vector is of order 1 @@ -938,6 +936,11 @@ def _process_symbol(self, symbol): if symbol._expected_size is None: symbol._expected_size = expected_size return symbol.create_copy() + + elif isinstance(symbol, pybamm.CoupledVariable): + new_symbol = self.process_symbol(symbol.children[0]) + return new_symbol + else: # Backup option: return the object return symbol diff --git a/src/pybamm/expression_tree/__init__.py b/src/pybamm/expression_tree/__init__.py index 0b06746e61..7ac80e5353 100644 --- a/src/pybamm/expression_tree/__init__.py +++ b/src/pybamm/expression_tree/__init__.py @@ -2,4 +2,4 @@ 'concatenations', 'exceptions', 'functions', 'independent_variable', 'input_parameter', 'interpolant', 'matrix', 'operations', 'parameter', 'printing', 'scalar', 'state_vector', 'symbol', - 'unary_operators', 'variable', 'vector'] + 'unary_operators', 'variable', 'vector', 'discrete_time_sum' ] diff --git a/src/pybamm/expression_tree/averages.py b/src/pybamm/expression_tree/averages.py index 5fa6c5f00f..11538ea153 100644 --- a/src/pybamm/expression_tree/averages.py +++ b/src/pybamm/expression_tree/averages.py @@ -251,8 +251,8 @@ def z_average(symbol: pybamm.Symbol) -> pybamm.Symbol: # Symbol must have domain [] or ["current collector"] if symbol.domain not in [[], ["current collector"]]: raise pybamm.DomainError( - f"""z-average only implemented in the 'current collector' domain, - but symbol has domains {symbol.domain}""" + "z-average only implemented in the 'current collector' domain, " + f"but symbol has domains {symbol.domain}" ) # If symbol doesn't have a domain, its average value is itself if symbol.domain == []: @@ -285,8 +285,8 @@ def yz_average(symbol: pybamm.Symbol) -> pybamm.Symbol: # Symbol must have domain [] or ["current collector"] if symbol.domain not in [[], ["current collector"]]: raise pybamm.DomainError( - f"""y-z-average only implemented in the 'current collector' domain, - but symbol has domains {symbol.domain}""" + "y-z-average only implemented in the 'current collector' domain, " + f"but symbol has domains {symbol.domain}" ) # If symbol doesn't have a domain, its average value is itself if symbol.domain == []: diff --git a/src/pybamm/expression_tree/binary_operators.py b/src/pybamm/expression_tree/binary_operators.py index 1d630887b2..be3df653ad 100644 --- a/src/pybamm/expression_tree/binary_operators.py +++ b/src/pybamm/expression_tree/binary_operators.py @@ -36,7 +36,7 @@ def _preprocess_binary( # Check both left and right are pybamm Symbols if not (isinstance(left, pybamm.Symbol) and isinstance(right, pybamm.Symbol)): raise NotImplementedError( - f"""BinaryOperator not implemented for symbols of type {type(left)} and {type(right)}""" + f"BinaryOperator not implemented for symbols of type {type(left)} and {type(right)}" ) # Do some broadcasting in special cases, to avoid having to do this manually @@ -389,9 +389,9 @@ def _binary_jac(self, left_jac, right_jac): return left @ right_jac else: raise NotImplementedError( - f"""jac of 'MatrixMultiplication' is only - implemented for left of type 'pybamm.Array', - not {left.__class__}""" + f"jac of 'MatrixMultiplication' is only " + "implemented for left of type 'pybamm.Array', " + f"not {left.__class__}" ) def _binary_evaluate(self, left, right): @@ -856,12 +856,17 @@ def _simplified_binary_broadcast_concatenation( elif isinstance(right, pybamm.Concatenation) and not isinstance( right, pybamm.ConcatenationVariable ): - return left.create_copy( - [ - operator(left_child, right_child) - for left_child, right_child in zip(left.orphans, right.orphans) - ] - ) + if len(left.orphans) == len(right.orphans): + return left.create_copy( + [ + operator(left_child, right_child) + for left_child, right_child in zip(left.orphans, right.orphans) + ] + ) + else: + raise AssertionError( + "Concatenations must have the same number of children" + ) if isinstance(right, pybamm.Concatenation) and not isinstance( right, pybamm.ConcatenationVariable ): @@ -1541,8 +1546,8 @@ def source( if left.domain != ["current collector"] or right.domain != ["current collector"]: raise pybamm.DomainError( - f"""'source' only implemented in the 'current collector' domain, - but symbols have domains {left.domain} and {right.domain}""" + "'source' only implemented in the 'current collector' domain, " + f"but symbols have domains {left.domain} and {right.domain}" ) if boundary: return pybamm.BoundaryMass(right) @ left diff --git a/src/pybamm/expression_tree/coupled_variable.py b/src/pybamm/expression_tree/coupled_variable.py new file mode 100644 index 0000000000..04d03d2792 --- /dev/null +++ b/src/pybamm/expression_tree/coupled_variable.py @@ -0,0 +1,55 @@ +import pybamm + +from pybamm.type_definitions import DomainType + + +class CoupledVariable(pybamm.Symbol): + """ + A node in the expression tree representing a variable whose equation is set by a different model or submodel. + + + Parameters + ---------- + name : str + name of the node + domain : iterable of str + list of domains that this coupled variable is valid over + """ + + def __init__( + self, + name: str, + domain: DomainType = None, + ) -> None: + super().__init__(name, domain=domain) + + def _evaluate_for_shape(self): + """ + Returns the scalar 'NaN' to represent the shape of a parameter. + See :meth:`pybamm.Symbol.evaluate_for_shape()` + """ + return pybamm.evaluate_for_shape_using_domain(self.domains) + + def create_copy(self): + """Creates a new copy of the coupled variable.""" + new_coupled_variable = CoupledVariable(self.name, self.domain) + return new_coupled_variable + + @property + def children(self): + return self._children + + @children.setter + def children(self, expr): + self._children = expr + + def set_coupled_variable(self, symbol, expr): + """Sets the children of the coupled variable to the expression passed in expr. If the symbol is not the coupled variable, then it searches the children of the symbol for the coupled variable. The coupled variable will be replaced by its first child (symbol.children[0], which should be expr) in the discretisation step.""" + if self == symbol: + symbol.children = [ + expr, + ] + else: + for child in symbol.children: + self.set_coupled_variable(child, expr) + symbol.set_id() diff --git a/src/pybamm/expression_tree/discrete_time_sum.py b/src/pybamm/expression_tree/discrete_time_sum.py new file mode 100644 index 0000000000..41cd14960d --- /dev/null +++ b/src/pybamm/expression_tree/discrete_time_sum.py @@ -0,0 +1,88 @@ +import pybamm +import numpy as np + + +class DiscreteTimeData(pybamm.Interpolant): + """ + A class for representing data that is only defined at discrete points in time. + This is implemented as a 1D interpolant with the time points as the nodes. + + Parameters + ---------- + + time_points : :class:`numpy.ndarray` + The time points at which the data is defined + data : :class:`numpy.ndarray` + The data to be interpolated + name : str + The name of the data + + """ + + def __init__(self, time_points: np.ndarray, data: np.ndarray, name: str): + super().__init__(time_points, data, pybamm.t, name) + + def create_copy(self, new_children=None, perform_simplifications=True): + """See :meth:`pybamm.Symbol.new_copy()`.""" + return pybamm.DiscreteTimeData(self.x[0], self.y, self.name) + + +class DiscreteTimeSum(pybamm.UnaryOperator): + """ + A node in the expression tree representing a discrete time sum operator. + + .. math:: + \\sum_{i=0}^{N} f(y(t_i), t_i) + + where f is the expression given by the child, and the sum is over the discrete + time points t_i. The set of time points is given by the :class:`pybamm.DiscreteTimeData` node, + which must be somewhere in the expression tree given by the child. If the child + does not contain a :class:`pybamm.DiscreteTimeData` node, then an error will be raised when + the node is created. If the child contains multiple :class:`pybamm.DiscreteTimeData` nodes, + an error will be raised when the node is created. + + + Parameters + ---------- + child: :class:`pybamm.Symbol` + The symbol to be summed + + Attributes + ---------- + data: :class:`pybamm.DiscreteTimeData` + The discrete time data node in the child + + Raises + ------ + :class:`pybamm.ModelError` + If the child does not contain a :class:`pybamm.DiscreteTimeData` node, or if the child + contains multiple :class:`pybamm.DiscreteTimeData` nodes. + """ + + def __init__(self, child: pybamm.Symbol): + self.data = None + for node in child.pre_order(): + if isinstance(node, DiscreteTimeData): + # Check that there is exactly one DiscreteTimeData node in the child + if self.data is not None: + raise pybamm.ModelError( + "DiscreteTimeSum can only have one DiscreteTimeData node in the child" + ) + self.data = node + if self.data is None: + raise pybamm.ModelError( + "DiscreteTimeSum must contain a DiscreteTimeData node" + ) + super().__init__("discrete time sum", child) + + @property + def sum_values(self): + return self.data.y + + @property + def sum_times(self): + return self.data.x[0] + + def _unary_evaluate(self, child): + # return result of evaluating the child, we'll only implement the sum once the model is solved (in pybamm.ProcessedVariable) + return child diff --git a/src/pybamm/expression_tree/operations/convert_to_casadi.py b/src/pybamm/expression_tree/operations/convert_to_casadi.py index 274fd95154..6b61b35263 100644 --- a/src/pybamm/expression_tree/operations/convert_to_casadi.py +++ b/src/pybamm/expression_tree/operations/convert_to_casadi.py @@ -7,6 +7,7 @@ import casadi import numpy as np from scipy import special +from scipy import interpolate class CasadiConverter: @@ -165,6 +166,18 @@ def _convert(self, symbol, t, y, y_dot, inputs): # for some reason, pybamm.Interpolant always returns a column vector, so match that test = test.T return test + elif solver == "bspline": + bspline = interpolate.make_interp_spline( + symbol.x[0], symbol.y, k=3 + ) + knots = [bspline.t] + coeffs = bspline.c.flatten() + degree = [bspline.k] + m = len(coeffs) // len(symbol.x[0]) + f = casadi.Function.bspline( + symbol.name, knots, coeffs, degree, m + ) + return f(converted_children[0]) else: return casadi.interpolant( "LUT", solver, symbol.x, symbol.y.flatten() @@ -176,6 +189,20 @@ def _convert(self, symbol, t, y, y_dot, inputs): symbol.y.ravel(order="F"), converted_children, ) + elif solver == "bspline" and len(converted_children) == 2: + bspline = interpolate.RectBivariateSpline( + symbol.x[0], symbol.x[1], symbol.y + ) + [tx, ty, c] = bspline.tck + [kx, ky] = bspline.degrees + knots = [tx, ty] + coeffs = c + degree = [kx, ky] + m = 1 + f = casadi.Function.bspline( + symbol.name, knots, coeffs, degree, m + ) + return f(casadi.hcat(converted_children).T).T else: LUT = casadi.interpolant( "LUT", solver, symbol.x, symbol.y.ravel(order="F") @@ -231,8 +258,6 @@ def _convert(self, symbol, t, y, y_dot, inputs): else: raise TypeError( - f""" - Cannot convert symbol of type '{type(symbol)}' to CasADi. Symbols must all be - 'linear algebra' at this stage. - """ + f"Cannot convert symbol of type '{type(symbol)}' to CasADi. Symbols must all be " + "'linear algebra' at this stage." ) diff --git a/src/pybamm/expression_tree/unary_operators.py b/src/pybamm/expression_tree/unary_operators.py index ace1cd9942..f41897e2de 100644 --- a/src/pybamm/expression_tree/unary_operators.py +++ b/src/pybamm/expression_tree/unary_operators.py @@ -212,7 +212,8 @@ def __init__(self, child): @classmethod def _from_json(cls, snippet: dict): - raise NotImplementedError() + """See :meth:`pybamm.UnaryOperator._from_json()`.""" + return cls(snippet["children"][0]) def diff(self, variable): """See :meth:`pybamm.Symbol.diff()`.""" @@ -991,8 +992,8 @@ def __init__(self, name, child, side): if side in ["negative tab", "positive tab"]: if child.domain[0] != "current collector": raise pybamm.ModelError( - f"""Can only take boundary value on the tabs in the domain - 'current collector', but {child} has domain {child.domain[0]}""" + "Can only take boundary value on the tabs in the domain " + f"'current collector', but {child} has domain {child.domain[0]}" ) self.side = side # boundary value of a child takes the primary domain from secondary domain diff --git a/src/pybamm/expression_tree/vector.py b/src/pybamm/expression_tree/vector.py index 6dc358afb0..e9067a4ffd 100644 --- a/src/pybamm/expression_tree/vector.py +++ b/src/pybamm/expression_tree/vector.py @@ -29,9 +29,7 @@ def __init__( entries = entries[:, np.newaxis] if entries.shape[1] != 1: raise ValueError( - f""" - Entries must have 1 dimension or be column vector, not have shape {entries.shape} - """ + f"Entries must have 1 dimension or be column vector, not have shape {entries.shape}" ) if name is None: name = f"Column vector of length {entries.shape[0]!s}" diff --git a/src/pybamm/input/parameters/__init__.py b/src/pybamm/input/parameters/__init__.py index 9ef23b743d..3c21058270 100644 --- a/src/pybamm/input/parameters/__init__.py +++ b/src/pybamm/input/parameters/__init__.py @@ -1 +1 @@ -__all__ = ['ecm', 'lead_acid', 'lithium_ion'] +__all__ = ['ecm', 'lead_acid', 'lithium_ion', 'sodium_ion'] diff --git a/src/pybamm/input/parameters/lithium_ion/Ai2020.py b/src/pybamm/input/parameters/lithium_ion/Ai2020.py index b45c04fa7f..4bf51f3440 100644 --- a/src/pybamm/input/parameters/lithium_ion/Ai2020.py +++ b/src/pybamm/input/parameters/lithium_ion/Ai2020.py @@ -5,7 +5,7 @@ def graphite_diffusivity_Dualfoil1998(sto, T): """ - Graphite diffusivity as a function of stochiometry [1, 2, 3]. + Graphite diffusivity as a function of stoichiometry [1, 2, 3]. References ---------- @@ -20,7 +20,7 @@ def graphite_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature, [K] @@ -72,10 +72,10 @@ def graphite_electrolyte_exchange_current_density_Dualfoil1998( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_entropy_Enertech_Ai2020_function(sto, c_s_max): +def graphite_entropy_Enertech_Ai2020_function(sto): """ Lithium Cobalt Oxide (LiCO2) entropic change in open-circuit potential (OCP) at - a temperature of 298.15K as a function of the stochiometry. The fit is taken + a temperature of 298.15K as a function of the stoichiometry. The fit is taken from Ref [1], which is only accurate for 0.43 < sto < 0.9936. @@ -89,7 +89,7 @@ def graphite_entropy_Enertech_Ai2020_function(sto, c_s_max): Parameters ---------- sto: double - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) Returns ------- @@ -126,9 +126,9 @@ def graphite_entropy_Enertech_Ai2020_function(sto, c_s_max): return du_dT -def graphite_volume_change_Ai2020(sto, c_s_max): +def graphite_volume_change_Ai2020(sto): """ - Graphite particle volume change as a function of stochiometry [1, 2]. + Graphite particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -143,7 +143,7 @@ def graphite_volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration c_s_max : :class:`pybamm.Symbol` Maximum particle concentration [mol.m-3] @@ -214,7 +214,7 @@ def graphite_cracking_rate_Ai2020(T_dim): def lico2_diffusivity_Dualfoil1998(sto, T): """ - LiCo2 diffusivity as a function of stochiometry, in this case the + LiCo2 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Dualfoil [1]. References @@ -224,7 +224,7 @@ def lico2_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature, [K] @@ -273,10 +273,10 @@ def lico2_electrolyte_exchange_current_density_Dualfoil1998(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def lico2_entropic_change_Ai2020_function(sto, c_s_max): +def lico2_entropic_change_Ai2020_function(sto): """ Lithium Cobalt Oxide (LiCO2) entropic change in open-circuit potential (OCP) at - a temperature of 298.15K as a function of the stochiometry. The fit is taken + a temperature of 298.15K as a function of the stoichiometry. The fit is taken from Ref [1], which is only accurate for 0.43 < sto < 0.9936. @@ -290,7 +290,7 @@ def lico2_entropic_change_Ai2020_function(sto, c_s_max): Parameters ---------- sto: double - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) Returns ------- @@ -323,9 +323,9 @@ def lico2_entropic_change_Ai2020_function(sto, c_s_max): return du_dT -def lico2_volume_change_Ai2020(sto, c_s_max): +def lico2_volume_change_Ai2020(sto): """ - lico2 particle volume change as a function of stochiometry [1, 2]. + lico2 particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -340,10 +340,8 @@ def lico2_volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration - c_s_max : :class:`pybamm.Symbol` - Maximum particle concentration [mol.m-3] Returns ------- @@ -351,6 +349,7 @@ def lico2_volume_change_Ai2020(sto, c_s_max): volume change, dimensionless, normalised by particle volume """ omega = pybamm.Parameter("Positive electrode partial molar volume [m3.mol-1]") + c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") t_change = omega * c_s_max * sto return t_change @@ -518,11 +517,11 @@ def lico2_ocp_Ai2020(sto): def get_parameter_values(): """ Parameters for the Enertech cell (Ai2020), from the papers :footcite:t:`Ai2019`, - :footcite:t:`rieger2016new` and references therein. + :footcite:t:`Rieger2016` and references therein. SEI parameters are example parameters for SEI growth from the papers - :footcite:t:`Ramadass2004`, :footcite:t:`ploehn2004solvent`, - :footcite:t:`single2018identifying`, :footcite:t:`safari2008multimodal`, and + :footcite:t:`Ramadass2004`, :footcite:t:`Ploehn2004`, + :footcite:t:`Single2018`, :footcite:t:`Safari2008`, and :footcite:t:`Yang2017` .. note:: diff --git a/src/pybamm/input/parameters/lithium_ion/Chen2020.py b/src/pybamm/input/parameters/lithium_ion/Chen2020.py index 5a7460871b..eccac74615 100644 --- a/src/pybamm/input/parameters/lithium_ion/Chen2020.py +++ b/src/pybamm/input/parameters/lithium_ion/Chen2020.py @@ -4,7 +4,7 @@ def graphite_LGM50_ocp_Chen2020(sto): """ - LG M50 Graphite open-circuit potential as a function of stochiometry, fit taken + LG M50 Graphite open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -17,7 +17,7 @@ def graphite_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -75,7 +75,7 @@ def graphite_LGM50_electrolyte_exchange_current_density_Chen2020( def nmc_LGM50_ocp_Chen2020(sto): """ - LG M50 NMC open-circuit potential as a function of stochiometry, fit taken + LG M50 NMC open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -88,7 +88,7 @@ def nmc_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -213,8 +213,8 @@ def get_parameter_values(): therein. SEI parameters are example parameters for SEI growth from the papers - :footcite:t:`Ramadass2004`, :footcite:t:`ploehn2004solvent`, - :footcite:t:`single2018identifying`, :footcite:t:`safari2008multimodal`, and + :footcite:t:`Ramadass2004`, :footcite:t:`Ploehn2004`, + :footcite:t:`Single2018`, :footcite:t:`Safari2008`, and :footcite:t:`Yang2017` .. note:: diff --git a/src/pybamm/input/parameters/lithium_ion/Chen2020_composite.py b/src/pybamm/input/parameters/lithium_ion/Chen2020_composite.py index 58b6211072..69b622a7c5 100644 --- a/src/pybamm/input/parameters/lithium_ion/Chen2020_composite.py +++ b/src/pybamm/input/parameters/lithium_ion/Chen2020_composite.py @@ -43,7 +43,7 @@ def graphite_LGM50_electrolyte_exchange_current_density_Chen2020( def silicon_ocp_lithiation_Mark2016(sto): """ silicon Open-circuit Potential (OCP) as a a function of the - stochiometry. The fit is taken from the Enertech cell [1], which is only accurate + stoichiometry. The fit is taken from the Enertech cell [1], which is only accurate for 0 < sto < 1. References @@ -55,7 +55,7 @@ def silicon_ocp_lithiation_Mark2016(sto): Parameters ---------- sto: double - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) Returns ------- @@ -87,7 +87,7 @@ def silicon_ocp_lithiation_Mark2016(sto): def silicon_ocp_delithiation_Mark2016(sto): """ silicon Open-circuit Potential (OCP) as a a function of the - stochiometry. The fit is taken from the Enertech cell [1], which is only accurate + stoichiometry. The fit is taken from the Enertech cell [1], which is only accurate for 0 < sto < 1. References @@ -99,7 +99,7 @@ def silicon_ocp_delithiation_Mark2016(sto): Parameters ---------- sto: double - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) Returns ------- @@ -170,7 +170,7 @@ def silicon_LGM50_electrolyte_exchange_current_density_Chen2020( def nmc_LGM50_ocp_Chen2020(sto): """ - LG M50 NMC open-circuit potential as a function of stochiometry, fit taken + LG M50 NMC open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -183,7 +183,7 @@ def nmc_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- diff --git a/src/pybamm/input/parameters/lithium_ion/Ecker2015.py b/src/pybamm/input/parameters/lithium_ion/Ecker2015.py index 32cc631293..30ca2ef827 100644 --- a/src/pybamm/input/parameters/lithium_ion/Ecker2015.py +++ b/src/pybamm/input/parameters/lithium_ion/Ecker2015.py @@ -4,7 +4,7 @@ def graphite_diffusivity_Ecker2015(sto, T): """ - Graphite diffusivity as a function of stochiometry [1, 2, 3]. + Graphite diffusivity as a function of stoichiometry [1, 2, 3]. References ---------- @@ -21,7 +21,7 @@ def graphite_diffusivity_Ecker2015(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -42,7 +42,7 @@ def graphite_diffusivity_Ecker2015(sto, T): def graphite_ocp_Ecker2015(sto): """ - Graphite OCP as a function of stochiometry [1, 2, 3]. + Graphite OCP as a function of stoichiometry [1, 2, 3]. References ---------- @@ -59,7 +59,7 @@ def graphite_ocp_Ecker2015(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -152,7 +152,7 @@ def graphite_electrolyte_exchange_current_density_Ecker2015(c_e, c_s_surf, c_s_m def nco_diffusivity_Ecker2015(sto, T): """ - NCO diffusivity as a function of stochiometry [1, 2, 3]. + NCO diffusivity as a function of stoichiometry [1, 2, 3]. References ---------- @@ -169,7 +169,7 @@ def nco_diffusivity_Ecker2015(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -190,7 +190,7 @@ def nco_diffusivity_Ecker2015(sto, T): def nco_ocp_Ecker2015(sto): """ - NCO OCP as a function of stochiometry [1, 2, 3]. + NCO OCP as a function of stoichiometry [1, 2, 3]. References ---------- @@ -207,7 +207,7 @@ def nco_ocp_Ecker2015(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -488,8 +488,8 @@ def get_parameter_values(): by Dr. Simon O'Kane in the paper :footcite:t:`Richardson2020` SEI parameters are example parameters for SEI growth from the papers - :footcite:t:`Ramadass2004`, :footcite:t:`ploehn2004solvent`, - :footcite:t:`single2018identifying`, :footcite:t:`safari2008multimodal`, and + :footcite:t:`Ramadass2004`, :footcite:t:`Ploehn2004`, + :footcite:t:`Single2018`, :footcite:t:`Safari2008`, and :footcite:t:`Yang2017` .. note:: diff --git a/src/pybamm/input/parameters/lithium_ion/Ecker2015_graphite_halfcell.py b/src/pybamm/input/parameters/lithium_ion/Ecker2015_graphite_halfcell.py index 365bb6386c..267f55e774 100644 --- a/src/pybamm/input/parameters/lithium_ion/Ecker2015_graphite_halfcell.py +++ b/src/pybamm/input/parameters/lithium_ion/Ecker2015_graphite_halfcell.py @@ -34,7 +34,7 @@ def li_metal_electrolyte_exchange_current_density_Xu2019(c_e, c_Li, T): def graphite_diffusivity_Ecker2015(sto, T): """ - Graphite diffusivity as a function of stochiometry [1, 2, 3]. + Graphite diffusivity as a function of stoichiometry [1, 2, 3]. References ---------- @@ -51,7 +51,7 @@ def graphite_diffusivity_Ecker2015(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -72,7 +72,7 @@ def graphite_diffusivity_Ecker2015(sto, T): def graphite_ocp_Ecker2015(sto): """ - Graphite OCP as a function of stochiometry [1, 2, 3]. + Graphite OCP as a function of stoichiometry [1, 2, 3]. References ---------- @@ -89,7 +89,7 @@ def graphite_ocp_Ecker2015(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- diff --git a/src/pybamm/input/parameters/lithium_ion/Marquis2019.py b/src/pybamm/input/parameters/lithium_ion/Marquis2019.py index b1f63e6ff7..13b8f57966 100644 --- a/src/pybamm/input/parameters/lithium_ion/Marquis2019.py +++ b/src/pybamm/input/parameters/lithium_ion/Marquis2019.py @@ -4,7 +4,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): """ - Graphite MCMB 2528 diffusivity as a function of stochiometry, in this case the + Graphite MCMB 2528 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Dualfoil [1]. References @@ -14,7 +14,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -34,7 +34,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): def graphite_mcmb2528_ocp_Dualfoil1998(sto): """ Graphite MCMB 2528 Open-circuit Potential (OCP) as a function of the - stochiometry. The fit is taken from Dualfoil [1]. Dualfoil states that the data + stoichiometry. The fit is taken from Dualfoil [1]. Dualfoil states that the data was measured by Chris Bogatu at Telcordia and PolyStor materials, 2000. However, we could not find any other records of this measurment. @@ -93,10 +93,10 @@ def graphite_electrolyte_exchange_current_density_Dualfoil1998( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_entropic_change_Moura2016(sto, c_s_max): +def graphite_entropic_change_Moura2016(sto): """ Graphite entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry taken from Scott Moura's FastDFN code + 298.15K as a function of the stoichiometry taken from Scott Moura's FastDFN code [1]. References @@ -106,9 +106,12 @@ def graphite_entropic_change_Moura2016(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ + # Original parametrization was expressed in terms of c_s_max, but we want to + # express it in terms of stoichiometry only + c_s_max = 24983.2619938437 du_dT = ( -1.5 * (120.0 / c_s_max) * np.exp(-120 * sto) + (0.0351 / (0.083 * c_s_max)) * ((np.cosh((sto - 0.286) / 0.083)) ** (-2)) @@ -126,7 +129,7 @@ def graphite_entropic_change_Moura2016(sto, c_s_max): def lico2_diffusivity_Dualfoil1998(sto, T): """ - LiCo2 diffusivity as a function of stochiometry, in this case the + LiCo2 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Dualfoil [1]. References @@ -136,7 +139,7 @@ def lico2_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -155,7 +158,7 @@ def lico2_diffusivity_Dualfoil1998(sto, T): def lico2_ocp_Dualfoil1998(sto): """ Lithium Cobalt Oxide (LiCO2) Open-circuit Potential (OCP) as a a function of the - stochiometry. The fit is taken from Dualfoil [1]. Dualfoil states that the data + stoichiometry. The fit is taken from Dualfoil [1]. Dualfoil states that the data was measured by Oscar Garcia 2001 using Quallion electrodes for 0.5 < sto < 0.99 and by Marc Doyle for sto<0.4 (for unstated electrodes). We could not find any other records of the Garcia measurements. Doyles fits can be found in his @@ -170,7 +173,7 @@ def lico2_ocp_Dualfoil1998(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -222,10 +225,10 @@ def lico2_electrolyte_exchange_current_density_Dualfoil1998(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def lico2_entropic_change_Moura2016(sto, c_s_max): +def lico2_entropic_change_Moura2016(sto): """ Lithium Cobalt Oxide (LiCO2) entropic change in open-circuit potential (OCP) at - a temperature of 298.15K as a function of the stochiometry. The fit is taken + a temperature of 298.15K as a function of the stoichiometry. The fit is taken from Scott Moura's FastDFN code [1]. References @@ -235,13 +238,15 @@ def lico2_entropic_change_Moura2016(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ # Since the equation for LiCo2 from this ref. has the stretch factor, # should this too? If not, the "bumps" in the OCV don't line up. stretch = 1.062 sto = stretch * sto - + # Original parametrization was expressed in terms of c_s_max, but we want to + # express it in terms of stoichiometry only + c_s_max = 51217.9257309275 du_dT = ( 0.07645 * (-54.4806 / c_s_max) * ((1.0 / np.cosh(30.834 - 54.4806 * sto)) ** 2) + 2.1581 * (-50.294 / c_s_max) * ((np.cosh(52.294 - 50.294 * sto)) ** (-2)) @@ -333,8 +338,8 @@ def get_parameter_values(): and references therein. SEI parameters are example parameters for SEI growth from the papers - :footcite:t:`Ramadass2004`, :footcite:t:`ploehn2004solvent`, - :footcite:t:`single2018identifying`, :footcite:t:`safari2008multimodal`, and + :footcite:t:`Ramadass2004`, :footcite:t:`Ploehn2004`, + :footcite:t:`Single2018`, :footcite:t:`Safari2008`, and :footcite:t:`Yang2017` .. note:: diff --git a/src/pybamm/input/parameters/lithium_ion/Mohtat2020.py b/src/pybamm/input/parameters/lithium_ion/Mohtat2020.py index 9923d9d308..044cebe3c5 100644 --- a/src/pybamm/input/parameters/lithium_ion/Mohtat2020.py +++ b/src/pybamm/input/parameters/lithium_ion/Mohtat2020.py @@ -4,7 +4,7 @@ def graphite_diffusivity_PeymanMPM(sto, T): """ - Graphite diffusivity as a function of stochiometry, in this case the + Graphite diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Peyman MPM. References @@ -14,7 +14,7 @@ def graphite_diffusivity_PeymanMPM(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -34,7 +34,7 @@ def graphite_diffusivity_PeymanMPM(sto, T): def graphite_ocp_PeymanMPM(sto): """ Graphite Open-circuit Potential (OCP) as a function of the - stochiometry. The fit is taken from Peyman MPM [1]. + stoichiometry. The fit is taken from Peyman MPM [1]. References ---------- @@ -89,10 +89,10 @@ def graphite_electrolyte_exchange_current_density_PeymanMPM(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_entropic_change_PeymanMPM(sto, c_s_max): +def graphite_entropic_change_PeymanMPM(sto): """ Graphite entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry taken from [1] + 298.15K as a function of the stoichiometry taken from [1] References ---------- @@ -102,7 +102,7 @@ def graphite_entropic_change_PeymanMPM(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -121,7 +121,7 @@ def graphite_entropic_change_PeymanMPM(sto, c_s_max): def NMC_diffusivity_PeymanMPM(sto, T): """ - NMC diffusivity as a function of stochiometry, in this case the + NMC diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Peyman MPM. References @@ -131,7 +131,7 @@ def NMC_diffusivity_PeymanMPM(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -151,7 +151,7 @@ def NMC_diffusivity_PeymanMPM(sto, T): def NMC_ocp_PeymanMPM(sto): """ Nickel Managanese Cobalt Oxide (NMC) Open-circuit Potential (OCP) as a - function of the stochiometry. The fit is taken from Peyman MPM. + function of the stoichiometry. The fit is taken from Peyman MPM. References ---------- @@ -160,7 +160,7 @@ def NMC_ocp_PeymanMPM(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -209,7 +209,7 @@ def NMC_electrolyte_exchange_current_density_PeymanMPM(c_e, c_s_surf, c_s_max, T return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def NMC_entropic_change_PeymanMPM(sto, c_s_max): +def NMC_entropic_change_PeymanMPM(sto): """ Nickel Manganese Cobalt (NMC) entropic change in open-circuit potential (OCP) at a temperature of 298.15K as a function of the OCP. The fit is taken from [1]. @@ -224,7 +224,7 @@ def NMC_entropic_change_PeymanMPM(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -319,8 +319,8 @@ def get_parameter_values(): and references therein. SEI parameters are example parameters for SEI growth from the papers - :footcite:t:`Ramadass2004`, :footcite:t:`ploehn2004solvent`, - :footcite:t:`single2018identifying`, :footcite:t:`safari2008multimodal`, and + :footcite:t:`Ramadass2004`, :footcite:t:`Ploehn2004`, + :footcite:t:`Single2018`, :footcite:t:`Safari2008`, and :footcite:t:`Yang2017` SEI parameters diff --git a/src/pybamm/input/parameters/lithium_ion/NCA_Kim2011.py b/src/pybamm/input/parameters/lithium_ion/NCA_Kim2011.py index 7d0478b6d0..da1191fa8c 100644 --- a/src/pybamm/input/parameters/lithium_ion/NCA_Kim2011.py +++ b/src/pybamm/input/parameters/lithium_ion/NCA_Kim2011.py @@ -16,7 +16,7 @@ def graphite_diffusivity_Kim2011(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -35,7 +35,7 @@ def graphite_diffusivity_Kim2011(sto, T): def graphite_ocp_Kim2011(sto): """ - Graphite Open-circuit Potential (OCP) as a function of the stochiometry [1]. + Graphite Open-circuit Potential (OCP) as a function of the stoichiometry [1]. References ---------- @@ -92,7 +92,7 @@ def graphite_electrolyte_exchange_current_density_Kim2011(c_e, c_s_surf, c_s_max """ i0_ref = 36 # reference exchange current density at 100% SOC - sto = 0.36 # stochiometry at 100% SOC + sto = 0.36 # stoichiometry at 100% SOC c_s_n_ref = sto * c_s_max # reference electrode concentration c_e_ref = pybamm.Parameter("Initial concentration in electrolyte [mol.m-3]") alpha = 0.5 # charge transfer coefficient @@ -111,7 +111,7 @@ def graphite_electrolyte_exchange_current_density_Kim2011(c_e, c_s_surf, c_s_max def nca_diffusivity_Kim2011(sto, T): """ - NCA diffusivity as a function of stochiometry [1]. + NCA diffusivity as a function of stoichiometry [1]. References ---------- @@ -123,7 +123,7 @@ def nca_diffusivity_Kim2011(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -168,7 +168,7 @@ def nca_electrolyte_exchange_current_density_Kim2011(c_e, c_s_surf, c_s_max, T): Exchange-current density [A.m-2] """ i0_ref = 4 # reference exchange current density at 100% SOC - sto = 0.41 # stochiometry at 100% SOC + sto = 0.41 # stoichiometry at 100% SOC c_s_ref = sto * c_s_max # reference electrode concentration c_e_ref = pybamm.Parameter("Initial concentration in electrolyte [mol.m-3]") alpha = 0.5 # charge transfer coefficient @@ -252,7 +252,7 @@ def electrolyte_conductivity_Kim2011(c_e, T): def nca_ocp_Kim2011(sto): """ - Graphite Open Circuit Potential (OCP) as a function of the stochiometry [1]. + Graphite Open Circuit Potential (OCP) as a function of the stoichiometry [1]. References ---------- @@ -297,8 +297,8 @@ def get_parameter_values(): for the planar effective thermal conductivity. SEI parameters are example parameters for SEI growth from the papers - :footcite:t:`Ramadass2004`, :footcite:t:`ploehn2004solvent`, - :footcite:t:`single2018identifying`, :footcite:t:`safari2008multimodal`, and + :footcite:t:`Ramadass2004`, :footcite:t:`Ploehn2004`, + :footcite:t:`Single2018`, :footcite:t:`Safari2008`, and :footcite:t:`Yang2017` .. note:: diff --git a/src/pybamm/input/parameters/lithium_ion/OKane2022.py b/src/pybamm/input/parameters/lithium_ion/OKane2022.py index b1e852dbdf..4ccb72bf62 100644 --- a/src/pybamm/input/parameters/lithium_ion/OKane2022.py +++ b/src/pybamm/input/parameters/lithium_ion/OKane2022.py @@ -96,7 +96,7 @@ def SEI_limited_dead_lithium_OKane2022(L_sei): def graphite_LGM50_diffusivity_Chen2020(sto, T): """ - LG M50 Graphite diffusivity as a function of stochiometry, in this case the + LG M50 Graphite diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from [1]. References @@ -109,7 +109,7 @@ def graphite_LGM50_diffusivity_Chen2020(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -165,9 +165,9 @@ def graphite_LGM50_electrolyte_exchange_current_density_Chen2020( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_volume_change_Ai2020(sto, c_s_max): +def graphite_volume_change_Ai2020(sto): """ - Graphite particle volume change as a function of stochiometry [1, 2]. + Graphite particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -182,7 +182,7 @@ def graphite_volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration Returns ------- @@ -260,7 +260,7 @@ def nmc_LGM50_diffusivity_Chen2020(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -279,7 +279,7 @@ def nmc_LGM50_diffusivity_Chen2020(sto, T): def nmc_LGM50_ocp_Chen2020(sto): """ - LG M50 NMC open-circuit potential as a function of stochiometry, fit taken + LG M50 NMC open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -292,7 +292,7 @@ def nmc_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -344,9 +344,9 @@ def nmc_LGM50_electrolyte_exchange_current_density_Chen2020(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def volume_change_Ai2020(sto, c_s_max): +def volume_change_Ai2020(sto): """ - Particle volume change as a function of stochiometry [1, 2]. + Particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -361,7 +361,7 @@ def volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration Returns ------- @@ -369,6 +369,7 @@ def volume_change_Ai2020(sto, c_s_max): volume change, dimensionless, normalised by particle volume """ omega = pybamm.Parameter("Positive electrode partial molar volume [m3.mol-1]") + c_s_max = pybamm.Parameter("Maximum concentration in positive electrode [mol.m-3]") t_change = omega * c_s_max * sto return t_change diff --git a/src/pybamm/input/parameters/lithium_ion/OKane2022_graphite_SiOx_halfcell.py b/src/pybamm/input/parameters/lithium_ion/OKane2022_graphite_SiOx_halfcell.py index 35533ba80e..c343dd23f4 100644 --- a/src/pybamm/input/parameters/lithium_ion/OKane2022_graphite_SiOx_halfcell.py +++ b/src/pybamm/input/parameters/lithium_ion/OKane2022_graphite_SiOx_halfcell.py @@ -126,7 +126,7 @@ def SEI_limited_dead_lithium_OKane2022(L_sei): def graphite_LGM50_diffusivity_Chen2020(sto, T): """ - LG M50 Graphite diffusivity as a function of stochiometry, in this case the + LG M50 Graphite diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from [1]. References @@ -139,7 +139,7 @@ def graphite_LGM50_diffusivity_Chen2020(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -195,9 +195,9 @@ def graphite_LGM50_electrolyte_exchange_current_density_Chen2020( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_volume_change_Ai2020(sto, c_s_max): +def graphite_volume_change_Ai2020(sto): """ - Graphite particle volume change as a function of stochiometry [1, 2]. + Graphite particle volume change as a function of stoichiometry [1, 2]. References ---------- @@ -212,7 +212,7 @@ def graphite_volume_change_Ai2020(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry, dimensionless + Electrode stoichiometry, dimensionless should be R-averaged particle concentration Returns ------- diff --git a/src/pybamm/input/parameters/lithium_ion/ORegan2022.py b/src/pybamm/input/parameters/lithium_ion/ORegan2022.py index 3ca5f6824c..d7e240a7b6 100644 --- a/src/pybamm/input/parameters/lithium_ion/ORegan2022.py +++ b/src/pybamm/input/parameters/lithium_ion/ORegan2022.py @@ -233,7 +233,7 @@ def copper_thermal_conductivity_CRC(T): def graphite_LGM50_diffusivity_ORegan2022(sto, T): """ - LG M50 Graphite diffusivity as a function of stochiometry, in this case the + LG M50 Graphite diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from [1]. References @@ -245,7 +245,7 @@ def graphite_LGM50_diffusivity_ORegan2022(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -292,7 +292,7 @@ def graphite_LGM50_diffusivity_ORegan2022(sto, T): def graphite_LGM50_ocp_Chen2020(sto): """ - LG M50 Graphite open-circuit potential as a function of stochiometry, fit taken + LG M50 Graphite open-circuit potential as a function of stoichiometry, fit taken from [1]. References @@ -305,7 +305,7 @@ def graphite_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -439,10 +439,10 @@ def graphite_LGM50_thermal_conductivity_ORegan2022(T): return lambda_wet -def graphite_LGM50_entropic_change_ORegan2022(sto, c_s_max): +def graphite_LGM50_entropic_change_ORegan2022(sto): """ LG M50 Graphite entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry. The fit is taken from [1]. + 298.15K as a function of the stoichiometry. The fit is taken from [1]. References ---------- @@ -453,7 +453,7 @@ def graphite_LGM50_entropic_change_ORegan2022(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -525,7 +525,7 @@ def nmc_LGM50_diffusivity_ORegan2022(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -579,7 +579,7 @@ def nmc_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -712,10 +712,10 @@ def nmc_LGM50_thermal_conductivity_ORegan2022(T): return lambda_wet -def nmc_LGM50_entropic_change_ORegan2022(sto, c_s_max): +def nmc_LGM50_entropic_change_ORegan2022(sto): """ LG M50 NMC 811 entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry. The fit is taken from [1]. + 298.15K as a function of the stoichiometry. The fit is taken from [1]. References ---------- @@ -726,7 +726,7 @@ def nmc_LGM50_entropic_change_ORegan2022(sto, c_s_max): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -921,7 +921,7 @@ def get_parameter_values(): Parameters for an LG M50 cell, from the paper :footcite:t:`ORegan2022` Parameters for a LiPF6 in EC:EMC (3:7 w:w) electrolyte are from the paper - :footcite:t:`landesfeind2019temperature` and references therein. + :footcite:t:`Landesfeind2019` and references therein. """ return { diff --git a/src/pybamm/input/parameters/lithium_ion/Prada2013.py b/src/pybamm/input/parameters/lithium_ion/Prada2013.py index 0ba56516ab..f27ba23bdd 100644 --- a/src/pybamm/input/parameters/lithium_ion/Prada2013.py +++ b/src/pybamm/input/parameters/lithium_ion/Prada2013.py @@ -4,7 +4,7 @@ def graphite_LGM50_ocp_Chen2020(sto): """ - LG M50 Graphite open-circuit potential as a function of stochiometry, fit taken + LG M50 Graphite open-circuit potential as a function of stoichiometry, fit taken from [1]. Prada2013 doesn't give an OCP for graphite, so we use this instead. References @@ -17,7 +17,7 @@ def graphite_LGM50_ocp_Chen2020(sto): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry Returns ------- @@ -86,7 +86,7 @@ def LFP_ocp_Afshar2017(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ diff --git a/src/pybamm/input/parameters/lithium_ion/Ramadass2004.py b/src/pybamm/input/parameters/lithium_ion/Ramadass2004.py index 879a5f55c6..82c0df76bf 100644 --- a/src/pybamm/input/parameters/lithium_ion/Ramadass2004.py +++ b/src/pybamm/input/parameters/lithium_ion/Ramadass2004.py @@ -4,7 +4,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): """ - Graphite MCMB 2528 diffusivity as a function of stochiometry, in this case the + Graphite MCMB 2528 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Dualfoil [1]. References @@ -14,7 +14,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -34,7 +34,7 @@ def graphite_mcmb2528_diffusivity_Dualfoil1998(sto, T): def graphite_ocp_Ramadass2004(sto): """ Graphite Open-circuit Potential (OCP) as a function of the - stochiometry (theta?). The fit is taken from Ramadass 2004. + stoichiometry (theta?). The fit is taken from Ramadass 2004. References ---------- @@ -92,10 +92,10 @@ def graphite_electrolyte_exchange_current_density_Ramadass2004( return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def graphite_entropic_change_Moura2016(sto, c_s_max): +def graphite_entropic_change_Moura2016(sto): """ Graphite entropic change in open-circuit potential (OCP) at a temperature of - 298.15K as a function of the stochiometry taken from Scott Moura's FastDFN code + 298.15K as a function of the stoichiometry taken from Scott Moura's FastDFN code [1]. References @@ -105,9 +105,12 @@ def graphite_entropic_change_Moura2016(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ + # Original parametrization was expressed in terms of c_s_max, but we want to + # express it in terms of stoichiometry only + c_s_max = 24983.2619938437 du_dT = ( -1.5 * (120.0 / c_s_max) * np.exp(-120 * sto) + (0.0351 / (0.083 * c_s_max)) * ((np.cosh((sto - 0.286) / 0.083)) ** (-2)) @@ -125,7 +128,7 @@ def graphite_entropic_change_Moura2016(sto, c_s_max): def lico2_diffusivity_Ramadass2004(sto, T): """ - LiCo2 diffusivity as a function of stochiometry, in this case the + LiCo2 diffusivity as a function of stoichiometry, in this case the diffusivity is taken to be a constant. The value is taken from Ramadass 2004. References @@ -137,7 +140,7 @@ def lico2_diffusivity_Ramadass2004(sto, T): Parameters ---------- sto: :class:`pybamm.Symbol` - Electrode stochiometry + Electrode stoichiometry T: :class:`pybamm.Symbol` Dimensional temperature @@ -156,7 +159,7 @@ def lico2_diffusivity_Ramadass2004(sto, T): def lico2_ocp_Ramadass2004(sto): """ Lithium Cobalt Oxide (LiCO2) Open-circuit Potential (OCP) as a a function of the - stochiometry. The fit is taken from Ramadass 2004. Stretch is considered the + stoichiometry. The fit is taken from Ramadass 2004. Stretch is considered the overhang area negative electrode / area positive electrode, in Ramadass 2002. References @@ -168,7 +171,7 @@ def lico2_ocp_Ramadass2004(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -228,10 +231,10 @@ def lico2_electrolyte_exchange_current_density_Ramadass2004(c_e, c_s_surf, c_s_m return m_ref * arrhenius * c_e**0.5 * c_s_surf**0.5 * (c_s_max - c_s_surf) ** 0.5 -def lico2_entropic_change_Moura2016(sto, c_s_max): +def lico2_entropic_change_Moura2016(sto): """ Lithium Cobalt Oxide (LiCO2) entropic change in open-circuit potential (OCP) at - a temperature of 298.15K as a function of the stochiometry. The fit is taken + a temperature of 298.15K as a function of the stoichiometry. The fit is taken from Scott Moura's FastDFN code [1]. References @@ -241,13 +244,15 @@ def lico2_entropic_change_Moura2016(sto, c_s_max): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ # Since the equation for LiCo2 from this ref. has the stretch factor, # should this too? If not, the "bumps" in the OCV don't line up. stretch = 1.062 sto = stretch * sto - + # Original parametrization was expressed in terms of c_s_max, but we want to + # express it in terms of stoichiometry only + c_s_max = 51217.9257309275 du_dT = ( 0.07645 * (-54.4806 / c_s_max) * ((1.0 / np.cosh(30.834 - 54.4806 * sto)) ** 2) + 2.1581 * (-50.294 / c_s_max) * ((np.cosh(52.294 - 50.294 * sto)) ** (-2)) @@ -350,7 +355,7 @@ def get_parameter_values(): :footcite:t:`Zhao2018` Parameters for SEI growth are from the papers :footcite:t:`Ramadass2004` and - :footcite:t:`safari2008multimodal` + :footcite:t:`Safari2008` .. note:: Ramadass 2004 has mistakes in units and values of SEI parameters, corrected by diff --git a/src/pybamm/input/parameters/lithium_ion/Xu2019.py b/src/pybamm/input/parameters/lithium_ion/Xu2019.py index edf3bd40b0..d1c5edea98 100644 --- a/src/pybamm/input/parameters/lithium_ion/Xu2019.py +++ b/src/pybamm/input/parameters/lithium_ion/Xu2019.py @@ -36,7 +36,7 @@ def li_metal_electrolyte_exchange_current_density_Xu2019(c_e, c_Li, T): def nmc_ocp_Xu2019(sto): """ Nickel Managanese Cobalt Oxide (NMC) Open-circuit Potential (OCP) as a - function of the stochiometry, from [1]. + function of the stoichiometry, from [1]. References ---------- @@ -48,7 +48,7 @@ def nmc_ocp_Xu2019(sto): Parameters ---------- sto : :class:`pybamm.Symbol` - Stochiometry of material (li-fraction) + stoichiometry of material (li-fraction) """ @@ -201,8 +201,8 @@ def get_parameter_values(): ^^^^^^^^^^^^^^^^^^^^^^ SEI parameters are example parameters for SEI growth from the papers - :footcite:t:`Ramadass2004`, :footcite:t:`ploehn2004solvent`, - :footcite:t:`single2018identifying`, :footcite:t:`safari2008multimodal`, and + :footcite:t:`Ramadass2004`, :footcite:t:`Ploehn2004`, + :footcite:t:`Single2018`, :footcite:t:`Safari2008`, and :footcite:t:`Yang2017`. .. note:: diff --git a/src/pybamm/input/parameters/sodium_ion/Chayambuka2022.py b/src/pybamm/input/parameters/sodium_ion/Chayambuka2022.py new file mode 100644 index 0000000000..f8c423cf76 --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/Chayambuka2022.py @@ -0,0 +1,341 @@ +import pybamm +import os + +path, _ = os.path.split(os.path.abspath(__file__)) + +U_n_data = pybamm.parameters.process_1D_data("U_n.csv", path=path) +U_p_data = pybamm.parameters.process_1D_data("U_p.csv", path=path) +D_n_data = pybamm.parameters.process_1D_data("D_n.csv", path=path) +D_p_data = pybamm.parameters.process_1D_data("D_p.csv", path=path) +k_n_data = pybamm.parameters.process_1D_data("k_n.csv", path=path) +k_p_data = pybamm.parameters.process_1D_data("k_p.csv", path=path) +D_e_data = pybamm.parameters.process_1D_data("D_e.csv", path=path) +sigma_e_data = pybamm.parameters.process_1D_data("sigma_e.csv", path=path) + + +def HC_ocp_Chayambuka2022(sto): + """ + HC open-circuit potential as a function of stochiometry, data taken + from [1]. + + References + ---------- + .. [1] K. Chayambuka, G. Mulder, D.L. Danilov, P.H.L. Notten, Physics-based + modeling of sodium-ion batteries part II. Model and validation, Electrochimica + Acta 404 (2022) 139764. https://doi.org/10.1016/j.electacta.2021.139764. + + Parameters + ---------- + sto: :class:`pybamm.Symbol` + Electrode stochiometry + + Returns + ------- + :class:`pybamm.Symbol` + Open-circuit potential + """ + + name, (x, y) = U_n_data + return pybamm.Interpolant(x, y, sto, name) + + +def HC_diffusivity_Chayambuka2022(sto, T): + """ + HC diffusivity as a function of stochiometry, the data is taken from [1]. + + References + ---------- + .. [1] K. Chayambuka, G. Mulder, D.L. Danilov, P.H.L. Notten, Physics-based + modeling of sodium-ion batteries part II. Model and validation, Electrochimica + Acta 404 (2022) 139764. https://doi.org/10.1016/j.electacta.2021.139764. + + Parameters + ---------- + sto: :class:`pybamm.Symbol` + Electrode stochiometry + T: :class:`pybamm.Symbol` + Dimensional temperature + + Returns + ------- + :class:`pybamm.Symbol` + Solid diffusivity + """ + + name, (x, y) = D_n_data + c_max = pybamm.Parameter("Maximum concentration in negative electrode [mol.m-3]") + return pybamm.Interpolant(x, y, sto * c_max, name) + + +def HC_electrolyte_exchange_current_density_Chayambuka2022(c_e, c_s_surf, c_s_max, T): + """ + Exchange-current density for Butler-Volmer reactions between HC and NaPF6 in + EC:PC. + + References + ---------- + .. [1] K. Chayambuka, G. Mulder, D.L. Danilov, P.H.L. Notten, Physics-based + modeling of sodium-ion batteries part II. Model and validation, Electrochimica + Acta 404 (2022) 139764. https://doi.org/10.1016/j.electacta.2021.139764. + + Parameters + ---------- + c_e : :class:`pybamm.Symbol` + Electrolyte concentration [mol.m-3] + c_s_surf : :class:`pybamm.Symbol` + Particle concentration [mol.m-3] + c_s_max : :class:`pybamm.Symbol` + Maximum particle concentration [mol.m-3] + T : :class:`pybamm.Symbol` + Temperature [K] + + Returns + ------- + :class:`pybamm.Symbol` + Exchange-current density [A.m-2] + """ + name, (x, y) = k_n_data + k_n = pybamm.Interpolant(x, y, c_s_surf, name) + c_e0 = pybamm.Parameter("Initial concentration in electrolyte [mol.m-3]") + + return ( + pybamm.constants.F + * k_n + * (c_e / c_e0) ** 0.5 + * c_s_surf**0.5 + * (c_s_max - c_s_surf) ** 0.5 + / 2 + ) + + +def NVPF_ocp_Chayambuka2022(sto): + """ + NVPF open-circuit potential as a function of stochiometry, data taken + from [1]. + + References + ---------- + .. [1] K. Chayambuka, G. Mulder, D.L. Danilov, P.H.L. Notten, Physics-based + modeling of sodium-ion batteries part II. Model and validation, Electrochimica + Acta 404 (2022) 139764. https://doi.org/10.1016/j.electacta.2021.139764. + + Parameters + ---------- + sto: :class:`pybamm.Symbol` + Electrode stochiometry + + Returns + ------- + :class:`pybamm.Symbol` + Open-circuit potential + """ + + name, (x, y) = U_p_data + return pybamm.Interpolant(x, y, sto, name) + + +def NVPF_diffusivity_Chayambuka2022(sto, T): + """ + NVPF diffusivity as a function of stochiometry, the data is taken from [1]. + + References + ---------- + .. [1] K. Chayambuka, G. Mulder, D.L. Danilov, P.H.L. Notten, Physics-based + modeling of sodium-ion batteries part II. Model and validation, Electrochimica + Acta 404 (2022) 139764. https://doi.org/10.1016/j.electacta.2021.139764. + + Parameters + ---------- + sto: :class:`pybamm.Symbol` + Electrode stochiometry + T: :class:`pybamm.Symbol` + Dimensional temperature + + Returns + ------- + :class:`pybamm.Symbol` + Solid diffusivity + """ + + name, (x, y) = D_p_data + c_max = pybamm.Parameter("Initial concentration in electrolyte [mol.m-3]") + return pybamm.Interpolant(x, y, sto * c_max, name) + + +def NVPF_electrolyte_exchange_current_density_Chayambuka2022(c_e, c_s_surf, c_s_max, T): + """ + Exchange-current density for Butler-Volmer reactions between NVPF and NaPF6 in + EC:PC. + + References + ---------- + .. [1] K. Chayambuka, G. Mulder, D.L. Danilov, P.H.L. Notten, Physics-based + modeling of sodium-ion batteries part II. Model and validation, Electrochimica + Acta 404 (2022) 139764. https://doi.org/10.1016/j.electacta.2021.139764. + + Parameters + ---------- + c_e : :class:`pybamm.Symbol` + Electrolyte concentration [mol.m-3] + c_s_surf : :class:`pybamm.Symbol` + Particle concentration [mol.m-3] + c_s_max : :class:`pybamm.Symbol` + Maximum particle concentration [mol.m-3] + T : :class:`pybamm.Symbol` + Temperature [K] + + Returns + ------- + :class:`pybamm.Symbol` + Exchange-current density [A.m-2] + """ + name, (x, y) = k_p_data + k_p = pybamm.Interpolant(x, y, c_s_surf, name) + c_e0 = pybamm.Parameter("Initial concentration in electrolyte [mol.m-3]") + + return ( + pybamm.constants.F + * k_p + * (c_e / c_e0) ** 0.5 + * c_s_surf**0.5 + * (c_s_max - c_s_surf) ** 0.5 + / 2 + ) + + +def electrolyte_diffusivity_Chayambuka2022(c_e, T): + """ + Diffusivity of NaPF6 in EC:PC (1:1) as a function of ion concentration. The data + comes from [1] + + References + ---------- + .. [1] K. Chayambuka, G. Mulder, D.L. Danilov, P.H.L. Notten, Physics-based + modeling of sodium-ion batteries part II. Model and validation, Electrochimica + Acta 404 (2022) 139764. https://doi.org/10.1016/j.electacta.2021.139764. + + Parameters + ---------- + c_e: :class:`pybamm.Symbol` + Dimensional electrolyte concentration + T: :class:`pybamm.Symbol` + Dimensional temperature + + Returns + ------- + :class:`pybamm.Symbol` + Solid diffusivity + """ + + name, (x, y) = D_e_data + D_e = pybamm.Interpolant(x, y, c_e, name) + + # Chayambuka et al. (2022) does not provide temperature dependence + + return D_e + + +def electrolyte_conductivity_Chayambuka2022(c_e, T): + """ + Conductivity of NaPF6 in EC:PC (1:1) as a function of ion concentration. The data + comes from [1]. + + References + ---------- + .. [1] K. Chayambuka, G. Mulder, D.L. Danilov, P.H.L. Notten, Physics-based + modeling of sodium-ion batteries part II. Model and validation, Electrochimica + Acta 404 (2022) 139764. https://doi.org/10.1016/j.electacta.2021.139764. + + Parameters + ---------- + c_e: :class:`pybamm.Symbol` + Dimensional electrolyte concentration + T: :class:`pybamm.Symbol` + Dimensional temperature + + Returns + ------- + :class:`pybamm.Symbol` + Solid diffusivity + """ + + name, (x, y) = sigma_e_data + sigma_e = pybamm.Interpolant(x, y, c_e, name) + + # Chayambuka et al. (2022) does not provide temperature dependence + + return sigma_e + + +# Call dict via a function to avoid errors when editing in place +def get_parameter_values(): + """ + Parameters for a sodium-ion cell, from the paper :footcite:t:`Chayambuka2022` and references + therein. The specific parameter values are taken from the COMSOL implementation presented in + [this example](https://www.comsol.com/model/1d-isothermal-sodium-ion-battery-117341). + + """ + + return { + "chemistry": "sodium_ion", + # cell + "Negative electrode thickness [m]": 64e-6, + "Separator thickness [m]": 25e-6, + "Positive electrode thickness [m]": 68e-6, + "Electrode height [m]": 2.54e-4, + "Electrode width [m]": 1, + "Nominal cell capacity [A.h]": 3e-3, + "Current function [A]": 3e-3, + "Contact resistance [Ohm]": 0, + # negative electrode + "Negative electrode conductivity [S.m-1]": 256, + "Maximum concentration in negative electrode [mol.m-3]": 14540, + "Negative particle diffusivity [m2.s-1]": HC_diffusivity_Chayambuka2022, + "Negative electrode OCP [V]": HC_ocp_Chayambuka2022, + "Negative electrode porosity": 0.51, + "Negative electrode active material volume fraction": 0.489, # 1 - 0.51 - 0.001 + "Negative particle radius [m]": 3.48e-6, + "Negative electrode Bruggeman coefficient (electrolyte)": 1.5, + "Negative electrode Bruggeman coefficient (electrode)": 0, + "Negative electrode charge transfer coefficient": 0.5, + "Negative electrode exchange-current density [A.m-2]" + "": HC_electrolyte_exchange_current_density_Chayambuka2022, + "Negative electrode OCP entropic change [V.K-1]": 0, + # positive electrode + "Positive electrode conductivity [S.m-1]": 50, + "Maximum concentration in positive electrode [mol.m-3]": 15320, + "Positive particle diffusivity [m2.s-1]": NVPF_diffusivity_Chayambuka2022, + "Positive electrode OCP [V]": NVPF_ocp_Chayambuka2022, + "Positive electrode porosity": 0.23, + "Positive electrode active material volume fraction": 0.55, # 1 - 0.23 - 0.22 + "Positive particle radius [m]": 0.59e-6, + "Positive electrode Bruggeman coefficient (electrolyte)": 1.5, + "Positive electrode Bruggeman coefficient (electrode)": 0, + "Positive electrode charge transfer coefficient": 0.5, + "Positive electrode exchange-current density [A.m-2]" + "": NVPF_electrolyte_exchange_current_density_Chayambuka2022, + "Positive electrode OCP entropic change [V.K-1]": 0, + # separator + "Separator porosity": 0.55, + "Separator Bruggeman coefficient (electrolyte)": 1.5, + # electrolyte + "Initial concentration in electrolyte [mol.m-3]": 1000, + "Cation transference number": 0.45, + "Thermodynamic factor": 1, + "Electrolyte diffusivity [m2.s-1]": electrolyte_diffusivity_Chayambuka2022, + "Electrolyte conductivity [S.m-1]": electrolyte_conductivity_Chayambuka2022, + # experiment + "Reference temperature [K]": 298.15, + "Ambient temperature [K]": 298.15, + "Number of electrodes connected in parallel to make a cell": 1.0, + "Number of cells connected in series to make a battery": 1.0, + "Lower voltage cut-off [V]": 2.0, + "Upper voltage cut-off [V]": 4.2, + "Open-circuit voltage at 0% SOC [V]": 2.0, + "Open-circuit voltage at 100% SOC [V]": 4.2, + "Initial concentration in negative electrode [mol.m-3]": 13520, + "Initial concentration in positive electrode [mol.m-3]": 3320, + "Initial temperature [K]": 298.15, + # citations + "citations": ["Chayambuka2022"], + } diff --git a/src/pybamm/input/parameters/sodium_ion/__init__.py b/src/pybamm/input/parameters/sodium_ion/__init__.py new file mode 100644 index 0000000000..7591ba5554 --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/__init__.py @@ -0,0 +1 @@ +__all__ = ['Chayambuka2022'] diff --git a/src/pybamm/input/parameters/sodium_ion/data/D_e.csv b/src/pybamm/input/parameters/sodium_ion/data/D_e.csv new file mode 100755 index 0000000000..3fe74a8cb8 --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/data/D_e.csv @@ -0,0 +1,14 @@ +Electrolyte concentration [mol.m-3],Electrolyte diffusivity [m2.s-1] +1.131153,4.14E-11 +124.121064,3.87E-11 +249.37328,3.62E-11 +387.618465,3.33E-11 +538.734332,3.11E-11 +741.699786,2.81E-11 +1013.696117,2.49E-11 +1272.69948,2.25E-11 +1514.27698,2.14E-11 +1811.953531,1.99E-11 +2113.940691,1.86E-11 +2320.97218,1.79E-11 +2471.904616,1.76E-11 diff --git a/src/pybamm/input/parameters/sodium_ion/data/D_n.csv b/src/pybamm/input/parameters/sodium_ion/data/D_n.csv new file mode 100755 index 0000000000..12d42cce5d --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/data/D_n.csv @@ -0,0 +1,40 @@ +Negative particle concentration [mol.m-3],Negative electrode difusivity [m2.s-1] +73.891626,2.57E-16 +246.305419,3.03E-16 +418.719212,3.54E-16 +714.285714,3.99E-16 +1108.374384,4.50E-16 +1477.832512,4.83E-16 +1847.29064,5.31E-16 +2241.37931,6.21E-16 +2733.990148,7.60E-16 +3177.339901,9.20E-16 +3620.689655,1.11E-15 +4113.300493,1.36E-15 +4630.541872,1.57E-15 +5197.044335,1.79E-15 +5714.285714,1.93E-15 +6280.788177,2.02E-15 +6896.551724,1.97E-15 +7413.793103,1.84E-15 +7832.512315,1.67E-15 +8325.123153,1.45E-15 +8669.950739,1.27E-15 +9039.408867,1.06E-15 +9384.236453,8.77E-16 +9729.064039,7.08E-16 +10073.89163,5.44E-16 +10394.08867,4.39E-16 +10714.28571,3.58E-16 +10960.59113,3.14E-16 +11206.89655,2.96E-16 +11502.46305,3.14E-16 +11847.29064,3.80E-16 +12142.85714,4.72E-16 +12487.68473,6.13E-16 +12832.51232,7.78E-16 +13226.60099,1.01E-15 +13719.21182,1.21E-15 +14014.77833,1.28E-15 +14261.08374,1.28E-15 +14433.49754,1.24E-15 diff --git a/src/pybamm/input/parameters/sodium_ion/data/D_p.csv b/src/pybamm/input/parameters/sodium_ion/data/D_p.csv new file mode 100755 index 0000000000..51665bcea9 --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/data/D_p.csv @@ -0,0 +1,35 @@ +Positive particle concentration [mol.m-3],Positive electrode difusivity [m2.s-1] +131.578947,2.51E-15 +592.105263,1.71E-15 +1052.631579,1.16E-15 +1535.087719,7.41E-16 +2017.54386,4.61E-16 +2521.929825,2.62E-16 +2982.45614,1.56E-16 +3355.263158,9.92E-17 +3618.421053,7.92E-17 +3925.438596,6.61E-17 +4254.385965,5.65E-17 +4495.614035,5.53E-17 +4890.350877,6.07E-17 +5263.157895,6.65E-17 +5679.824561,7.64E-17 +6096.491228,8.38E-17 +6578.947368,8.79E-17 +7039.473684,8.81E-17 +7587.719298,8.44E-17 +8201.754386,7.52E-17 +8837.719298,6.46E-17 +9495.614035,5.05E-17 +10109.64912,4.13E-17 +10657.89474,3.45E-17 +11184.21053,3.16E-17 +11776.31579,3.17E-17 +12346.49123,3.40E-17 +12828.94737,3.57E-17 +13333.33333,3.66E-17 +13750,3.58E-17 +14254.38596,3.35E-17 +14649.12281,2.74E-17 +15000,2.29E-17 +15197.36842,1.95E-17 diff --git a/src/pybamm/input/parameters/sodium_ion/data/U_n.csv b/src/pybamm/input/parameters/sodium_ion/data/U_n.csv new file mode 100755 index 0000000000..ddb213b3db --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/data/U_n.csv @@ -0,0 +1,21 @@ +Negative particle stoichiometry,Negative electrode open-circuit potential [V] +0.001436794,1.318963892 +0.001643334,1.21982507 +0.00811789,1.112038542 +0.01027308,1.077546494 +0.035479844,0.978299913 +0.060758448,0.844570264 +0.090185795,0.719443422 +0.127982471,0.577039126 +0.169900951,0.456168787 +0.232688871,0.317967115 +0.320485995,0.1753473 +0.403954777,0.110332349 +0.483167055,0.088439192 +0.574861484,0.075112923 +0.687380455,0.066007238 +0.799899424,0.056901553 +0.891593855,0.043575284 +0.960353451,0.038968561 +0.987446008,0.034541438 +0.995806356,0.021574368 diff --git a/src/pybamm/input/parameters/sodium_ion/data/U_p.csv b/src/pybamm/input/parameters/sodium_ion/data/U_p.csv new file mode 100755 index 0000000000..a2698567e6 --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/data/U_p.csv @@ -0,0 +1,28 @@ +Positive particle stoichiometry,Positive electrode open-circuit potential [V] +0.21,4.288102031 +0.21004478,4.210892773 +0.219269532,4.175283892 +0.261497402,4.172472367 +0.332354842,4.172738781 +0.409317336,4.158176665 +0.474174208,4.152479924 +0.526985168,4.143767596 +0.560316674,4.11121965 +0.575706188,4.048901275 +0.582273973,3.941995276 +0.585975815,3.805375531 +0.590901654,3.725196032 +0.598588947,3.695521965 +0.655863013,3.698707605 +0.696598205,3.69292017 +0.741871138,3.684179499 +0.802205196,3.678465753 +0.864031932,3.675727917 +0.930500897,3.649245158 +0.959279735,3.62262069 +0.979341333,3.530616911 +0.980053837,3.450437411 +0.983058101,3.391026925 +0.992282853,3.355418044 +0.996268304,3.162363722 +0.999940293,3.031684459 diff --git a/src/pybamm/input/parameters/sodium_ion/data/k_n.csv b/src/pybamm/input/parameters/sodium_ion/data/k_n.csv new file mode 100755 index 0000000000..152ebfac4e --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/data/k_n.csv @@ -0,0 +1,32 @@ +Negative particle concentration [mol.m-3],Negative electrode exchange-current density rate [m.s-1] +121.359223,3.33E-11 +412.621359,2.15E-11 +631.067961,1.39E-11 +800.970874,9.40E-12 +970.873786,6.88E-12 +1140.776699,5.71E-12 +1262.135922,5.26E-12 +1529.126214,7.03E-12 +1796.116505,1.04E-11 +2063.106796,1.58E-11 +2500,2.65E-11 +3058.252427,4.18E-11 +3713.592233,5.83E-11 +4660.194175,7.48E-11 +5412.621359,7.96E-11 +6067.961165,7.48E-11 +6868.932039,6.47E-11 +7718.446602,4.94E-11 +8422.330097,3.47E-11 +9004.854369,2.39E-11 +9538.834951,1.61E-11 +10024.27184,1.21E-11 +10388.34951,1.06E-11 +10752.42718,1.13E-11 +11213.59223,1.51E-11 +11723.30097,2.25E-11 +12378.64078,3.54E-11 +12912.62136,4.94E-11 +13470.87379,6.34E-11 +14004.85437,7.48E-11 +14417.47573,7.96E-11 diff --git a/src/pybamm/input/parameters/sodium_ion/data/k_p.csv b/src/pybamm/input/parameters/sodium_ion/data/k_p.csv new file mode 100755 index 0000000000..7c228e907c --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/data/k_p.csv @@ -0,0 +1,24 @@ +Positive particle concentration [mol.m-3],Positive electrode exchange-current density rate [m.s-1] +21.929825,2.27E-10 +372.807018,1.74E-10 +833.333333,1.24E-10 +1293.859649,8.51E-11 +1907.894737,4.83E-11 +2456.140351,2.96E-11 +2960.526316,1.95E-11 +3442.982456,1.45E-11 +3969.298246,1.16E-11 +4802.631579,1.08E-11 +5548.245614,1.17E-11 +6381.578947,1.18E-11 +7192.982456,1.10E-11 +8092.105263,8.79E-12 +8969.298246,6.65E-12 +9649.122807,5.03E-12 +10350.87719,3.87E-12 +11074.5614,3.34E-12 +12171.05263,3.37E-12 +13223.68421,3.60E-12 +14188.59649,3.30E-12 +14934.21053,2.35E-12 +15328.94737,1.91E-12 diff --git a/src/pybamm/input/parameters/sodium_ion/data/sigma_e.csv b/src/pybamm/input/parameters/sodium_ion/data/sigma_e.csv new file mode 100644 index 0000000000..e8a1104901 --- /dev/null +++ b/src/pybamm/input/parameters/sodium_ion/data/sigma_e.csv @@ -0,0 +1,6 @@ +Electrolyte concentration [mol.m-3],Electrolyte conductivity [S.m-1] +150,0.404 +500,0.72 +1000,0.883 +1500,0.861 +2000,0.76 diff --git a/src/pybamm/logger.py b/src/pybamm/logger.py index 7dcacb5237..460e264416 100644 --- a/src/pybamm/logger.py +++ b/src/pybamm/logger.py @@ -24,6 +24,17 @@ def func(self, message, *args, **kws): def set_logging_level(level): + """ + Set the logging level for PyBaMM + + Parameters + ---------- + + level: str + The logging level to set. Should be one of 'DEBUG', 'INFO', 'WARNING', + 'ERROR', 'CRITICAL' + + """ logger.setLevel(level) diff --git a/src/pybamm/meshes/one_dimensional_submeshes.py b/src/pybamm/meshes/one_dimensional_submeshes.py index 8f27049411..027c6d0421 100644 --- a/src/pybamm/meshes/one_dimensional_submeshes.py +++ b/src/pybamm/meshes/one_dimensional_submeshes.py @@ -297,8 +297,8 @@ def __init__(self, lims, npts, edges=None): if (npts + 1) != len(edges): raise pybamm.GeometryError( - f"""User-suppled edges has should have length (npts + 1) but has length - {len(edges)}.Number of points (npts) for domain {spatial_var.domain} is {npts}.""".replace( + "User-suppled edges has should have length (npts + 1) but has length " + f"{len(edges)}.Number of points (npts) for domain {spatial_var.domain} is {npts}.".replace( "\n ", " " ) ) diff --git a/src/pybamm/meshes/scikit_fem_submeshes.py b/src/pybamm/meshes/scikit_fem_submeshes.py index ba624c7f48..2d769f3ca2 100644 --- a/src/pybamm/meshes/scikit_fem_submeshes.py +++ b/src/pybamm/meshes/scikit_fem_submeshes.py @@ -92,8 +92,8 @@ def read_lims(self, lims): # check coordinate system agrees if spatial_vars[0].coord_sys != spatial_vars[1].coord_sys: raise pybamm.DomainError( - f"""spatial variables should have the same coordinate system, - but have coordinate systems {spatial_vars[0].coord_sys} and {spatial_vars[1].coord_sys}""" + "spatial variables should have the same coordinate system, " + f"but have coordinate systems {spatial_vars[0].coord_sys} and {spatial_vars[1].coord_sys}" ) return spatial_vars, tabs @@ -360,9 +360,9 @@ def __init__(self, lims, npts, y_edges=None, z_edges=None): # check that npts equals number of user-supplied edges if npts[var.name] != len(edges[var.name]): raise pybamm.GeometryError( - f"""User-suppled edges has should have length npts but has length {len(edges[var.name])}. - Number of points (npts) for variable {var.name} in - domain {var.domain} is {npts[var.name]}.""" + f"User-supplied edges has should have length npts but has length {len(edges[var.name])}. " + f"Number of points (npts) for variable {var.name} in " + f"domain {var.domain} is {npts[var.name]}." ) # check end points of edges agree with spatial_lims diff --git a/src/pybamm/models/base_model.py b/src/pybamm/models/base_model.py index 0d4638e178..6cb3af2fd7 100644 --- a/src/pybamm/models/base_model.py +++ b/src/pybamm/models/base_model.py @@ -19,29 +19,27 @@ class BaseModel: Attributes ---------- name: str - A string giving the name of the model. + A string representing the name of the model. submodels: dict A dictionary of submodels that the model is composed of. - boundary_conditions: dict - A dictionary that maps expressions (variables) to expressions that represent - the boundary conditions. - variables: dict - A dictionary that maps strings to expressions that represent - the useful variables. - use_jacobian : bool + use_jacobian: bool Whether to use the Jacobian when solving the model (default is True). - convert_to_format : str - Whether to convert the expression trees representing the rhs and - algebraic equations, Jacobain (if using) and events into a different format: + convert_to_format: str + Specifies the format to convert the expression trees representing the RHS, + algebraic equations, Jacobian, and events. + Options are: - - None: keep PyBaMM expression tree structure. - - "python": convert into pure python code that will calculate the result of \ - calling `evaluate(t, y)` on the given expression treeself. - - "casadi": convert into CasADi expression tree, which then uses CasADi's \ - algorithm to calculate the Jacobian. - - "jax": convert into JAX expression tree + - None: retain PyBaMM expression tree structure. + - "python": convert to Python code for evaluating `evaluate(t, y)` on expressions. + - "casadi": convert to CasADi expression tree for Jacobian calculation. + - "jax": convert to JAX expression tree. Default is "casadi". + is_discretised: bool + Indicates whether the model has been discretised (default is False). + y_slices: None or list + Slices of the concatenated state vector after discretisation, used to track + different submodels in the full concatenated solution vector. """ def __init__(self, name="Unnamed model"): @@ -58,6 +56,7 @@ def __init__(self, name="Unnamed model"): self._boundary_conditions = {} self._variables_by_submodel = {} self._variables = pybamm.FuzzyDict({}) + self._coupled_variables = {} self._summary_variables = [] self._events = [] self._concatenated_rhs = None @@ -144,6 +143,8 @@ def name(self, value): @property def rhs(self): + """Returns a dictionary mapping expressions (variables) to expressions that represent + the right-hand side (RHS) of the model's differential equations.""" return self._rhs @rhs.setter @@ -152,6 +153,8 @@ def rhs(self, rhs): @property def algebraic(self): + """Returns a dictionary mapping expressions (variables) to expressions that represent + the algebraic equations of the model.""" return self._algebraic @algebraic.setter @@ -160,6 +163,8 @@ def algebraic(self, algebraic): @property def initial_conditions(self): + """Returns a dictionary mapping expressions (variables) to expressions that represent + the initial conditions for the state variables.""" return self._initial_conditions @initial_conditions.setter @@ -170,14 +175,42 @@ def initial_conditions(self, initial_conditions): @property def boundary_conditions(self): + """Returns a dictionary mapping expressions (variables) to expressions representing + the boundary conditions of the model.""" return self._boundary_conditions @boundary_conditions.setter def boundary_conditions(self, boundary_conditions): self._boundary_conditions = BoundaryConditionsDict(boundary_conditions) + @property + def coupled_variables(self): + """Returns a dictionary mapping strings to expressions representing variables needed by the model but whose equations were set by other models.""" + return self._coupled_variables + + @coupled_variables.setter + def coupled_variables(self, coupled_variables): + for name, var in coupled_variables.items(): + if ( + isinstance(var, pybamm.CoupledVariable) + and var.name != name + # Exception if the variable is also there under its own name + and not ( + var.name in coupled_variables and coupled_variables[var.name] == var + ) + ): + raise ValueError( + f"Coupled variable with name '{var.name}' is in coupled variables dictionary with " + f"name '{name}'. Names must match." + ) + self._coupled_variables = coupled_variables + + def list_coupled_variables(self): + return list(self._coupled_variables.keys()) + @property def variables(self): + """Returns a dictionary mapping strings to expressions representing the model's useful variables.""" return self._variables @variables.setter @@ -200,9 +233,7 @@ def variable_names(self): @property def variables_and_events(self): - """ - Returns variables and events in a single dictionary - """ + """Returns a dictionary containing both models variables and events.""" try: return self._variables_and_events except AttributeError: @@ -214,6 +245,8 @@ def variables_and_events(self): @property def events(self): + """Returns a dictionary mapping expressions (variables) to expressions that represent + the initial conditions for the state variables.""" return self._events @events.setter @@ -222,6 +255,7 @@ def events(self, events): @property def concatenated_rhs(self): + """Returns the concatenated right-hand side (RHS) expressions for the model after discretisation.""" return self._concatenated_rhs @concatenated_rhs.setter @@ -230,6 +264,7 @@ def concatenated_rhs(self, concatenated_rhs): @property def concatenated_algebraic(self): + """Returns the concatenated algebraic equations for the model after discretisation.""" return self._concatenated_algebraic @concatenated_algebraic.setter @@ -238,6 +273,8 @@ def concatenated_algebraic(self, concatenated_algebraic): @property def concatenated_initial_conditions(self): + """Returns the initial conditions for all variables after discretization, providing the + initial values for the state variables.""" return self._concatenated_initial_conditions @concatenated_initial_conditions.setter @@ -246,6 +283,7 @@ def concatenated_initial_conditions(self, concatenated_initial_conditions): @property def mass_matrix(self): + """Returns the mass matrix for the system of differential equations after discretisation.""" return self._mass_matrix @mass_matrix.setter @@ -254,6 +292,7 @@ def mass_matrix(self, mass_matrix): @property def mass_matrix_inv(self): + """Returns the inverse of the mass matrix for the differential equations after discretisation.""" return self._mass_matrix_inv @mass_matrix_inv.setter @@ -262,6 +301,7 @@ def mass_matrix_inv(self, mass_matrix_inv): @property def jacobian(self): + """Returns the Jacobian matrix for the model, computed automatically if `use_jacobian` is True.""" return self._jacobian @jacobian.setter @@ -270,6 +310,8 @@ def jacobian(self, jacobian): @property def jacobian_rhs(self): + """Returns the Jacobian matrix for the right-hand side (RHS) part of the model, computed + if `use_jacobian` is True.""" return self._jacobian_rhs @jacobian_rhs.setter @@ -278,6 +320,8 @@ def jacobian_rhs(self, jacobian_rhs): @property def jacobian_algebraic(self): + """Returns the Jacobian matrix for the algebraic part of the model, computed automatically + during solver setup if `use_jacobian` is True.""" return self._jacobian_algebraic @jacobian_algebraic.setter @@ -286,6 +330,7 @@ def jacobian_algebraic(self, jacobian_algebraic): @property def param(self): + """Returns a dictionary to store parameter values for the model.""" return self._param @param.setter @@ -294,6 +339,7 @@ def param(self, values): @property def options(self): + """Returns the model options dictionary that allows customization of the model's behavior.""" return self._options @options.setter @@ -326,27 +372,32 @@ def length_scales(self, values): @property def geometry(self): + """Returns the geometry of the model.""" return self._geometry @property def default_var_pts(self): + """Returns a dictionary of the default variable points for the model, which is empty by default.""" return {} @property def default_geometry(self): + """Returns a dictionary of the default geometry for the model, which is empty by default.""" return {} @property def default_submesh_types(self): + """Returns a dictionary of the default submesh types for the model, which is empty by default.""" return {} @property def default_spatial_methods(self): + """Returns a dictionary of the default spatial methods for the model, which is empty by default.""" return {} @property def default_solver(self): - """Return default solver based on whether model is ODE/DAE or algebraic""" + """Returns the default solver for the model, based on whether it is an ODE/DAE or algebraic model.""" if len(self.rhs) == 0 and len(self.algebraic) != 0: return pybamm.CasadiAlgebraicSolver() else: @@ -354,15 +405,17 @@ def default_solver(self): @property def default_quick_plot_variables(self): + """Returns the default variables for quick plotting (None by default).""" return None @property def default_parameter_values(self): + """Returns the default parameter values for the model (an empty set of parameters by default).""" return pybamm.ParameterValues({}) @property def parameters(self): - """Returns all the parameters in the model""" + """Returns a list of all parameter symbols used in the model.""" self._parameters = self._find_symbols( (pybamm.Parameter, pybamm.InputParameter, pybamm.FunctionParameter) ) @@ -370,7 +423,7 @@ def parameters(self): @property def input_parameters(self): - """Returns all the input parameters in the model""" + """Returns a list of all input parameter symbols used in the model.""" if self._input_parameters is None: self._input_parameters = self._find_symbols(pybamm.InputParameter) return self._input_parameters @@ -757,7 +810,7 @@ def build_model_equations(self): f"Setting initial conditions for {submodel_name} submodel ({self.name})" ) submodel.set_initial_conditions(self.variables) - submodel.set_events(self.variables) + submodel.add_events_from(self.variables) pybamm.logger.verbose(f"Updating {submodel_name} submodel ({self.name})") self.update(submodel) self.check_no_repeated_keys() @@ -1057,7 +1110,7 @@ def check_ics_bcs(self): for var in self.rhs.keys(): if var not in self.initial_conditions.keys(): raise pybamm.ModelError( - f"""no initial condition given for variable '{var}'""" + f"no initial condition given for variable '{var}'" ) def check_variables(self): @@ -1079,11 +1132,9 @@ def check_variables(self): for var in all_vars: if var not in vars_in_keys: raise pybamm.ModelError( - f""" - No key set for variable '{var}'. Make sure it is included in either - model.rhs or model.algebraic, in an unmodified form - (e.g. not Broadcasted) - """ + f"No key set for variable '{var}'. Make sure it is included in either " + "model.rhs or model.algebraic, in an unmodified form " + "(e.g. not Broadcasted)" ) def check_no_repeated_keys(self): @@ -1497,9 +1548,7 @@ def check_and_convert_bcs(self, boundary_conditions): # Check types if bc[1] not in ["Dirichlet", "Neumann"]: raise pybamm.ModelError( - f""" - boundary condition types must be Dirichlet or Neumann, not '{bc[1]}' - """ + f"boundary condition types must be Dirichlet or Neumann, not '{bc[1]}'" ) return boundary_conditions diff --git a/src/pybamm/models/full_battery_models/__init__.py b/src/pybamm/models/full_battery_models/__init__.py index 135f678289..0260f4dd07 100644 --- a/src/pybamm/models/full_battery_models/__init__.py +++ b/src/pybamm/models/full_battery_models/__init__.py @@ -1,2 +1,2 @@ __all__ = ['base_battery_model', 'equivalent_circuit', 'lead_acid', - 'lithium_ion'] + 'lithium_ion', 'sodium_ion'] diff --git a/src/pybamm/models/full_battery_models/base_battery_model.py b/src/pybamm/models/full_battery_models/base_battery_model.py index ccda594b14..153c447ed3 100644 --- a/src/pybamm/models/full_battery_models/base_battery_model.py +++ b/src/pybamm/models/full_battery_models/base_battery_model.py @@ -210,6 +210,9 @@ class BatteryModelOptions(pybamm.FuzzyDict): solve an algebraic equation for it. Default is "false", unless "SEI film resistance" is distributed in which case it is automatically set to "true". + * "voltage as a state" : str + Whether to make a state for the voltage and solve an algebraic equation + for it. Default is "false". * "working electrode" : str Can be "both" (default) for a standard battery or "positive" for a half-cell where the negative electrode is replaced with a lithium metal @@ -321,6 +324,7 @@ def __init__(self, extra_options): "heterogeneous catalyst", "cation-exchange membrane", ], + "voltage as a state": ["false", "true"], "working electrode": ["both", "positive"], "x-average side reactions": ["false", "true"], } @@ -618,14 +622,11 @@ def __init__(self, extra_options): options["surface form"] != "false" and options["particle size"] == "single" and options["particle"] == "Fickian diffusion" - and options["particle mechanics"] == "none" - and options["loss of active material"] == "none" ): raise pybamm.OptionError( "If there are multiple particle phases: 'surface form' cannot be " "'false', 'particle size' must be 'single', 'particle' must be " - "'Fickian diffusion'. Also the following must " - "be 'none': 'particle mechanics', 'loss of active material'" + "'Fickian diffusion'." ) if options["surface temperature"] == "lumped": @@ -752,7 +753,7 @@ def print_options(self): Print the possible options with the ones currently selected """ for key, value in self.items(): - print(f"{key!r}: {value!r} (possible: {self.possible_options[key]!r})") + print(rf"{key!r}: {value!r} (possible: {self.possible_options[key]!r})") def print_detailed_options(self): """ @@ -988,7 +989,6 @@ def options(self, extra_options): raise pybamm.OptionError( f"must use surface formulation to solve {self!s} with hydrolysis" ) - self._options = options def set_standard_output_variables(self): @@ -1033,7 +1033,7 @@ def build_model_equations(self): f"Setting initial conditions for {submodel_name} submodel ({self.name})" ) submodel.set_initial_conditions(self.variables) - submodel.set_events(self.variables) + submodel.add_events_from(self.variables) pybamm.logger.verbose(f"Updating {submodel_name} submodel ({self.name})") self.update(submodel) self.check_no_repeated_keys() diff --git a/src/pybamm/models/full_battery_models/lead_acid/basic_full.py b/src/pybamm/models/full_battery_models/lead_acid/basic_full.py index 8caac98066..a67501fc72 100644 --- a/src/pybamm/models/full_battery_models/lead_acid/basic_full.py +++ b/src/pybamm/models/full_battery_models/lead_acid/basic_full.py @@ -26,7 +26,6 @@ def __init__(self, name="Basic full model"): # `param` is a class containing all the relevant parameters and functions for # this model. These are purely symbolic at this stage, and will be set by the # `ParameterValues` class when the model is processed. - param = self.param ###################### # Variables @@ -37,17 +36,17 @@ def __init__(self, name="Basic full model"): c_e_n = pybamm.Variable( "Negative electrolyte concentration [mol.m-3]", domain="negative electrode", - scale=param.c_e_init, + scale=self.param.c_e_init, ) c_e_s = pybamm.Variable( "Separator electrolyte concentration [mol.m-3]", domain="separator", - scale=param.c_e_init, + scale=self.param.c_e_init, ) c_e_p = pybamm.Variable( "Positive electrolyte concentration [mol.m-3]", domain="positive electrode", - scale=param.c_e_init, + scale=self.param.c_e_init, ) # Concatenations combine several variables into a single variable, to simplify # implementing equations that hold over several domains @@ -57,17 +56,17 @@ def __init__(self, name="Basic full model"): phi_e_n = pybamm.Variable( "Negative electrolyte potential [V]", domain="negative electrode", - reference=-param.n.prim.U_init, + reference=-self.param.n.prim.U_init, ) phi_e_s = pybamm.Variable( "Separator electrolyte potential [V]", domain="separator", - reference=-param.n.prim.U_init, + reference=-self.param.n.prim.U_init, ) phi_e_p = pybamm.Variable( "Positive electrolyte potential [V]", domain="positive electrode", - reference=-param.n.prim.U_init, + reference=-self.param.n.prim.U_init, ) phi_e = pybamm.concatenation(phi_e_n, phi_e_s, phi_e_p) @@ -78,7 +77,7 @@ def __init__(self, name="Basic full model"): phi_s_p = pybamm.Variable( "Positive electrode potential [V]", domain="positive electrode", - reference=param.ocv_init, + reference=self.param.ocv_init, ) # Porosity @@ -92,29 +91,29 @@ def __init__(self, name="Basic full model"): eps = pybamm.concatenation(eps_n, eps_s, eps_p) # Constant temperature - T = param.T_init + T = self.param.T_init ###################### # Other set-up ###################### # Current density - i_cell = param.current_density_with_time + i_cell = self.param.current_density_with_time # transport_efficiency tor = pybamm.concatenation( - eps_n**param.n.b_e, eps_s**param.s.b_e, eps_p**param.p.b_e + eps_n**self.param.n.b_e, eps_s**self.param.s.b_e, eps_p**self.param.p.b_e ) # Interfacial reactions - F_RT = param.F / (param.R * T) - Feta_RT_n = F_RT * (phi_s_n - phi_e_n - param.n.prim.U(c_e_n, T)) - j0_n = param.n.prim.j0(c_e_n, T) - j_n = 2 * j0_n * pybamm.sinh(param.n.prim.ne / 2 * Feta_RT_n) + F_RT = self.param.F / (self.param.R * T) + Feta_RT_n = F_RT * (phi_s_n - phi_e_n - self.param.n.prim.U(c_e_n, T)) + j0_n = self.param.n.prim.j0(c_e_n, T) + j_n = 2 * j0_n * pybamm.sinh(self.param.n.prim.ne / 2 * Feta_RT_n) j_s = pybamm.PrimaryBroadcast(0, "separator") - Feta_RT_p = F_RT * (phi_s_p - phi_e_p - param.p.prim.U(c_e_p, T)) - j0_p = param.p.prim.j0(c_e_p, T) - j_p = 2 * j0_p * pybamm.sinh(param.p.prim.ne / 2 * (Feta_RT_p)) + Feta_RT_p = F_RT * (phi_s_p - phi_e_p - self.param.p.prim.U(c_e_p, T)) + j0_p = self.param.p.prim.j0(c_e_p, T) + j_p = 2 * j0_p * pybamm.sinh(self.param.p.prim.ne / 2 * (Feta_RT_p)) a_n = pybamm.Parameter("Negative electrode surface area to volume ratio [m-1]") a_p = pybamm.Parameter("Positive electrode surface area to volume ratio [m-1]") @@ -125,7 +124,7 @@ def __init__(self, name="Basic full model"): ###################### # State of Charge ###################### - I = param.current_with_time + I = self.param.current_with_time # The `rhs` dictionary contains differential equations, with the key being the # variable in the d/dt self.rhs[Q] = I / 3600 @@ -135,28 +134,32 @@ def __init__(self, name="Basic full model"): ###################### # Current in the electrolyte ###################### - i_e = (param.kappa_e(c_e, T) * tor) * ( - param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) + i_e = (self.param.kappa_e(c_e, T) * tor) * ( + self.param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) ) # multiply by Lx**2 to improve conditioning - self.algebraic[phi_e] = (pybamm.div(i_e) - a_j) * param.L_x**2 + self.algebraic[phi_e] = (pybamm.div(i_e) - a_j) * self.param.L_x**2 self.boundary_conditions[phi_e] = { "left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(0), "Neumann"), } - self.initial_conditions[phi_e] = -param.n.prim.U_init + self.initial_conditions[phi_e] = -self.param.n.prim.U_init ###################### # Current in the solid ###################### - i_s_n = -param.n.sigma(T) * (1 - eps_n) ** param.n.b_s * pybamm.grad(phi_s_n) - sigma_eff_p = param.p.sigma(T) * (1 - eps_p) ** param.p.b_s + i_s_n = ( + -self.param.n.sigma(T) + * (1 - eps_n) ** self.param.n.b_s + * pybamm.grad(phi_s_n) + ) + sigma_eff_p = self.param.p.sigma(T) * (1 - eps_p) ** self.param.p.b_s i_s_p = -sigma_eff_p * pybamm.grad(phi_s_p) # The `algebraic` dictionary contains differential equations, with the key being # the main scalar variable of interest in the equation # multiply by Lx**2 to improve conditioning - self.algebraic[phi_s_n] = (pybamm.div(i_s_n) + a_j_n) * param.L_x**2 - self.algebraic[phi_s_p] = (pybamm.div(i_s_p) + a_j_p) * param.L_x**2 + self.algebraic[phi_s_n] = (pybamm.div(i_s_n) + a_j_n) * self.param.L_x**2 + self.algebraic[phi_s_p] = (pybamm.div(i_s_p) + a_j_p) * self.param.L_x**2 self.boundary_conditions[phi_s_n] = { "left": (pybamm.Scalar(0), "Dirichlet"), "right": (pybamm.Scalar(0), "Neumann"), @@ -169,19 +172,19 @@ def __init__(self, name="Basic full model"): # initial guess for a root-finding algorithm which calculates consistent initial # conditions self.initial_conditions[phi_s_n] = pybamm.Scalar(0) - self.initial_conditions[phi_s_p] = param.ocv_init + self.initial_conditions[phi_s_p] = self.param.ocv_init ###################### # Porosity ###################### DeltaVsurf = pybamm.concatenation( - pybamm.PrimaryBroadcast(param.n.DeltaVsurf, "negative electrode"), + pybamm.PrimaryBroadcast(self.param.n.DeltaVsurf, "negative electrode"), pybamm.PrimaryBroadcast(0, "separator"), - pybamm.PrimaryBroadcast(param.p.DeltaVsurf, "positive electrode"), + pybamm.PrimaryBroadcast(self.param.p.DeltaVsurf, "positive electrode"), ) - deps_dt = DeltaVsurf * a_j / param.F + deps_dt = DeltaVsurf * a_j / self.param.F self.rhs[eps] = deps_dt - self.initial_conditions[eps] = param.epsilon_init + self.initial_conditions[eps] = self.param.epsilon_init self.events.extend( [ pybamm.Event( @@ -203,22 +206,22 @@ def __init__(self, name="Basic full model"): # Electrolyte concentration ###################### N_e = ( - -tor * param.D_e(c_e, T) * pybamm.grad(c_e) - + param.t_plus(c_e, T) * i_e / param.F + -tor * self.param.D_e(c_e, T) * pybamm.grad(c_e) + + self.param.t_plus(c_e, T) * i_e / self.param.F ) s = pybamm.concatenation( - pybamm.PrimaryBroadcast(param.n.prim.s_plus_S, "negative electrode"), + pybamm.PrimaryBroadcast(self.param.n.prim.s_plus_S, "negative electrode"), pybamm.PrimaryBroadcast(0, "separator"), - pybamm.PrimaryBroadcast(param.p.prim.s_plus_S, "positive electrode"), + pybamm.PrimaryBroadcast(self.param.p.prim.s_plus_S, "positive electrode"), ) self.rhs[c_e] = (1 / eps) * ( - -pybamm.div(N_e) + s * a_j / param.F - c_e * deps_dt + -pybamm.div(N_e) + s * a_j / self.param.F - c_e * deps_dt ) self.boundary_conditions[c_e] = { "left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(0), "Neumann"), } - self.initial_conditions[c_e] = param.c_e_init + self.initial_conditions[c_e] = self.param.c_e_init self.events.append( pybamm.Event( "Zero electrolyte concentration cut-off", pybamm.min(c_e) - 0.002 @@ -242,7 +245,11 @@ def __init__(self, name="Basic full model"): } self.events.extend( [ - pybamm.Event("Minimum voltage [V]", voltage - param.voltage_low_cut), - pybamm.Event("Maximum voltage [V]", param.voltage_high_cut - voltage), + pybamm.Event( + "Minimum voltage [V]", voltage - self.param.voltage_low_cut + ), + pybamm.Event( + "Maximum voltage [V]", self.param.voltage_high_cut - voltage + ), ] ) diff --git a/src/pybamm/models/full_battery_models/lithium_ion/__init__.py b/src/pybamm/models/full_battery_models/lithium_ion/__init__.py index b02868dbe9..556e8de31c 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/__init__.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/__init__.py @@ -24,8 +24,9 @@ from .Yang2017 import Yang2017 from .mpm import MPM from .msmr import MSMR +from .basic_splitOCVR import SplitOCVR __all__ = ['Yang2017', 'base_lithium_ion_model', 'basic_dfn', 'basic_dfn_composite', 'basic_dfn_half_cell', 'basic_spm', 'dfn', 'electrode_soh', 'electrode_soh_half_cell', 'mpm', 'msmr', - 'newman_tobias', 'spm', 'spme'] + 'newman_tobias', 'spm', 'spme', 'basic_splitOCVR'] diff --git a/src/pybamm/models/full_battery_models/lithium_ion/base_lithium_ion_model.py b/src/pybamm/models/full_battery_models/lithium_ion/base_lithium_ion_model.py index dfe2512f6e..9b801e1130 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/base_lithium_ion_model.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/base_lithium_ion_model.py @@ -105,7 +105,6 @@ def set_standard_output_variables(self): def set_degradation_variables(self): """Sets variables that quantify degradation (LAM, LLI, etc)""" - param = self.param domains = [d for d in self.options.whole_cell_domains if d != "separator"] for domain in domains: @@ -135,8 +134,8 @@ def set_degradation_variables(self): # LLI is usually defined based only on the percentage lithium lost from # particles - LLI = (1 - n_Li_particles / param.n_Li_particles_init) * 100 - LLI_tot = (1 - n_Li / param.n_Li_init) * 100 + LLI = (1 - n_Li_particles / self.param.n_Li_particles_init) * 100 + LLI_tot = (1 - n_Li / self.param.n_Li_init) * 100 self.variables.update( { @@ -146,15 +145,16 @@ def set_degradation_variables(self): # Total lithium "Total lithium [mol]": n_Li, "Total lithium in particles [mol]": n_Li_particles, - "Total lithium capacity [A.h]": n_Li * param.F / 3600, + "Total lithium capacity [A.h]": n_Li * self.param.F / 3600, "Total lithium capacity in particles [A.h]": n_Li_particles - * param.F + * self.param.F / 3600, # Lithium lost - "Total lithium lost [mol]": param.n_Li_init - n_Li, - "Total lithium lost from particles [mol]": param.n_Li_particles_init + "Total lithium lost [mol]": self.param.n_Li_init - n_Li, + "Total lithium lost from particles [mol]": self.param.n_Li_particles_init - n_Li_particles, - "Total lithium lost from electrolyte [mol]": param.n_Li_e_init - n_Li_e, + "Total lithium lost from electrolyte [mol]": self.param.n_Li_e_init + - n_Li_e, } ) @@ -177,7 +177,7 @@ def set_degradation_variables(self): { "Total lithium lost to side reactions [mol]": n_Li_lost_reactions, "Total capacity lost to side reactions [A.h]": n_Li_lost_reactions - * param.F + * self.param.F / 3600, } ) @@ -365,29 +365,31 @@ def set_crack_submodel(self): for domain in self.options.whole_cell_domains: if domain != "separator": domain = domain.split()[0].lower() - crack = getattr(self.options, domain)["particle mechanics"] - if crack == "none": - self.submodels[f"{domain} particle mechanics"] = ( - pybamm.particle_mechanics.NoMechanics( - self.param, domain, options=self.options, phase="primary" + phases = self.options.phases[domain] + for phase in phases: + crack = getattr(self.options, domain)["particle mechanics"] + if crack == "none": + self.submodels[f"{domain} {phase}particle mechanics"] = ( + pybamm.particle_mechanics.NoMechanics( + self.param, domain, options=self.options, phase=phase + ) ) - ) - elif crack == "swelling only": - self.submodels[f"{domain} particle mechanics"] = ( - pybamm.particle_mechanics.SwellingOnly( - self.param, domain, options=self.options, phase="primary" + elif crack == "swelling only": + self.submodels[f"{domain} {phase}particle mechanics"] = ( + pybamm.particle_mechanics.SwellingOnly( + self.param, domain, options=self.options, phase=phase + ) ) - ) - elif crack == "swelling and cracking": - self.submodels[f"{domain} particle mechanics"] = ( - pybamm.particle_mechanics.CrackPropagation( - self.param, - domain, - self.x_average, - options=self.options, - phase="primary", + elif crack == "swelling and cracking": + self.submodels[f"{domain} {phase}particle mechanics"] = ( + pybamm.particle_mechanics.CrackPropagation( + self.param, + domain, + self.x_average, + options=self.options, + phase=phase, + ) ) - ) def set_active_material_submodel(self): for domain in ["negative", "positive"]: @@ -401,7 +403,7 @@ def set_active_material_submodel(self): ) else: submod = pybamm.active_material.LossActiveMaterial( - self.param, domain, self.options, self.x_average + self.param, domain, self.options, self.x_average, phase ) self.submodels[f"{domain} {phase} active material"] = submod @@ -500,9 +502,8 @@ def insert_reference_electrode(self, position=None): "electrode manually." ) - param = self.param if position is None: - position = param.n.L + param.s.L / 2 + position = self.param.n.L + self.param.s.L / 2 phi_e_ref = pybamm.EvaluateAt( self.variables["Electrolyte potential [V]"], position diff --git a/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn.py b/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn.py index 08809b645f..7865b84ff3 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn.py @@ -27,7 +27,6 @@ def __init__(self, name="Doyle-Fuller-Newman model"): # `param` is a class containing all the relevant parameters and functions for # this model. These are purely symbolic at this stage, and will be set by the # `ParameterValues` class when the model is processed. - param = self.param ###################### # Variables @@ -90,14 +89,14 @@ def __init__(self, name="Doyle-Fuller-Newman model"): ) # Constant temperature - T = param.T_init + T = self.param.T_init ###################### # Other set-up ###################### # Current density - i_cell = param.current_density_with_time + i_cell = self.param.current_density_with_time # Porosity # Primary broadcasts are used to broadcast scalar quantities across a domain @@ -119,29 +118,29 @@ def __init__(self, name="Doyle-Fuller-Newman model"): # transport_efficiency tor = pybamm.concatenation( - eps_n**param.n.b_e, eps_s**param.s.b_e, eps_p**param.p.b_e + eps_n**self.param.n.b_e, eps_s**self.param.s.b_e, eps_p**self.param.p.b_e ) - a_n = 3 * param.n.prim.epsilon_s_av / param.n.prim.R_typ - a_p = 3 * param.p.prim.epsilon_s_av / param.p.prim.R_typ + a_n = 3 * self.param.n.prim.epsilon_s_av / self.param.n.prim.R_typ + a_p = 3 * self.param.p.prim.epsilon_s_av / self.param.p.prim.R_typ # Interfacial reactions # Surf takes the surface value of a variable, i.e. its boundary value on the # right side. This is also accessible via `boundary_value(x, "right")`, with # "left" providing the boundary value of the left side c_s_surf_n = pybamm.surf(c_s_n) - sto_surf_n = c_s_surf_n / param.n.prim.c_max - j0_n = param.n.prim.j0(c_e_n, c_s_surf_n, T) - eta_n = phi_s_n - phi_e_n - param.n.prim.U(sto_surf_n, T) - Feta_RT_n = param.F * eta_n / (param.R * T) - j_n = 2 * j0_n * pybamm.sinh(param.n.prim.ne / 2 * Feta_RT_n) + sto_surf_n = c_s_surf_n / self.param.n.prim.c_max + j0_n = self.param.n.prim.j0(c_e_n, c_s_surf_n, T) + eta_n = phi_s_n - phi_e_n - self.param.n.prim.U(sto_surf_n, T) + Feta_RT_n = self.param.F * eta_n / (self.param.R * T) + j_n = 2 * j0_n * pybamm.sinh(self.param.n.prim.ne / 2 * Feta_RT_n) c_s_surf_p = pybamm.surf(c_s_p) - sto_surf_p = c_s_surf_p / param.p.prim.c_max - j0_p = param.p.prim.j0(c_e_p, c_s_surf_p, T) - eta_p = phi_s_p - phi_e_p - param.p.prim.U(sto_surf_p, T) - Feta_RT_p = param.F * eta_p / (param.R * T) + sto_surf_p = c_s_surf_p / self.param.p.prim.c_max + j0_p = self.param.p.prim.j0(c_e_p, c_s_surf_p, T) + eta_p = phi_s_p - phi_e_p - self.param.p.prim.U(sto_surf_p, T) + Feta_RT_p = self.param.F * eta_p / (self.param.R * T) j_s = pybamm.PrimaryBroadcast(0, "separator") - j_p = 2 * j0_p * pybamm.sinh(param.p.prim.ne / 2 * Feta_RT_p) + j_p = 2 * j0_p * pybamm.sinh(self.param.p.prim.ne / 2 * Feta_RT_p) a_j_n = a_n * j_n a_j_p = a_p * j_p @@ -150,7 +149,7 @@ def __init__(self, name="Doyle-Fuller-Newman model"): ###################### # State of Charge ###################### - I = param.current_with_time + I = self.param.current_with_time # The `rhs` dictionary contains differential equations, with the key being the # variable in the d/dt self.rhs[Q] = I / 3600 @@ -163,39 +162,39 @@ def __init__(self, name="Doyle-Fuller-Newman model"): # The div and grad operators will be converted to the appropriate matrix # multiplication at the discretisation stage - N_s_n = -param.n.prim.D(c_s_n, T) * pybamm.grad(c_s_n) - N_s_p = -param.p.prim.D(c_s_p, T) * pybamm.grad(c_s_p) + N_s_n = -self.param.n.prim.D(c_s_n, T) * pybamm.grad(c_s_n) + N_s_p = -self.param.p.prim.D(c_s_p, T) * pybamm.grad(c_s_p) self.rhs[c_s_n] = -pybamm.div(N_s_n) self.rhs[c_s_p] = -pybamm.div(N_s_p) # Boundary conditions must be provided for equations with spatial derivatives self.boundary_conditions[c_s_n] = { "left": (pybamm.Scalar(0), "Neumann"), "right": ( - -j_n / (param.F * pybamm.surf(param.n.prim.D(c_s_n, T))), + -j_n / (self.param.F * pybamm.surf(self.param.n.prim.D(c_s_n, T))), "Neumann", ), } self.boundary_conditions[c_s_p] = { "left": (pybamm.Scalar(0), "Neumann"), "right": ( - -j_p / (param.F * pybamm.surf(param.p.prim.D(c_s_p, T))), + -j_p / (self.param.F * pybamm.surf(self.param.p.prim.D(c_s_p, T))), "Neumann", ), } - self.initial_conditions[c_s_n] = param.n.prim.c_init - self.initial_conditions[c_s_p] = param.p.prim.c_init + self.initial_conditions[c_s_n] = self.param.n.prim.c_init + self.initial_conditions[c_s_p] = self.param.p.prim.c_init ###################### # Current in the solid ###################### - sigma_eff_n = param.n.sigma(T) * eps_s_n**param.n.b_s + sigma_eff_n = self.param.n.sigma(T) * eps_s_n**self.param.n.b_s i_s_n = -sigma_eff_n * pybamm.grad(phi_s_n) - sigma_eff_p = param.p.sigma(T) * eps_s_p**param.p.b_s + sigma_eff_p = self.param.p.sigma(T) * eps_s_p**self.param.p.b_s i_s_p = -sigma_eff_p * pybamm.grad(phi_s_p) # The `algebraic` dictionary contains differential equations, with the key being # the main scalar variable of interest in the equation # multiply by Lx**2 to improve conditioning - self.algebraic[phi_s_n] = param.L_x**2 * (pybamm.div(i_s_n) + a_j_n) - self.algebraic[phi_s_p] = param.L_x**2 * (pybamm.div(i_s_p) + a_j_p) + self.algebraic[phi_s_n] = self.param.L_x**2 * (pybamm.div(i_s_n) + a_j_n) + self.algebraic[phi_s_p] = self.param.L_x**2 * (pybamm.div(i_s_p) + a_j_p) self.boundary_conditions[phi_s_n] = { "left": (pybamm.Scalar(0), "Dirichlet"), "right": (pybamm.Scalar(0), "Neumann"), @@ -208,34 +207,34 @@ def __init__(self, name="Doyle-Fuller-Newman model"): # initial guess for a root-finding algorithm which calculates consistent initial # conditions self.initial_conditions[phi_s_n] = pybamm.Scalar(0) - self.initial_conditions[phi_s_p] = param.ocv_init + self.initial_conditions[phi_s_p] = self.param.ocv_init ###################### # Current in the electrolyte ###################### - i_e = (param.kappa_e(c_e, T) * tor) * ( - param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) + i_e = (self.param.kappa_e(c_e, T) * tor) * ( + self.param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) ) # multiply by Lx**2 to improve conditioning - self.algebraic[phi_e] = param.L_x**2 * (pybamm.div(i_e) - a_j) + self.algebraic[phi_e] = self.param.L_x**2 * (pybamm.div(i_e) - a_j) self.boundary_conditions[phi_e] = { "left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(0), "Neumann"), } - self.initial_conditions[phi_e] = -param.n.prim.U_init + self.initial_conditions[phi_e] = -self.param.n.prim.U_init ###################### # Electrolyte concentration ###################### - N_e = -tor * param.D_e(c_e, T) * pybamm.grad(c_e) + N_e = -tor * self.param.D_e(c_e, T) * pybamm.grad(c_e) self.rhs[c_e] = (1 / eps) * ( - -pybamm.div(N_e) + (1 - param.t_plus(c_e, T)) * a_j / param.F + -pybamm.div(N_e) + (1 - self.param.t_plus(c_e, T)) * a_j / self.param.F ) self.boundary_conditions[c_e] = { "left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(0), "Neumann"), } - self.initial_conditions[c_e] = param.c_e_init + self.initial_conditions[c_e] = self.param.c_e_init ###################### # (Some) variables @@ -270,6 +269,6 @@ def __init__(self, name="Doyle-Fuller-Newman model"): } # Events specify points at which a solution should terminate self.events += [ - pybamm.Event("Minimum voltage [V]", voltage - param.voltage_low_cut), - pybamm.Event("Maximum voltage [V]", param.voltage_high_cut - voltage), + pybamm.Event("Minimum voltage [V]", voltage - self.param.voltage_low_cut), + pybamm.Event("Maximum voltage [V]", self.param.voltage_high_cut - voltage), ] diff --git a/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn_composite.py b/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn_composite.py index 95f65f4d50..273d1c037c 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn_composite.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn_composite.py @@ -28,7 +28,6 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): # `param` is a class containing all the relevant parameters and functions for # this model. These are purely symbolic at this stage, and will be set by the # `ParameterValues` class when the model is processed. - param = self.param ###################### # Variables @@ -39,17 +38,17 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): c_e_n = pybamm.Variable( "Negative electrolyte concentration [mol.m-3]", domain="negative electrode", - scale=param.c_e_init_av, + scale=self.param.c_e_init_av, ) c_e_s = pybamm.Variable( "Separator electrolyte concentration [mol.m-3]", domain="separator", - scale=param.c_e_init_av, + scale=self.param.c_e_init_av, ) c_e_p = pybamm.Variable( "Positive electrolyte concentration [mol.m-3]", domain="positive electrode", - scale=param.c_e_init_av, + scale=self.param.c_e_init_av, ) # Concatenations combine several variables into a single variable, to simplify # implementing equations that hold over several domains @@ -59,17 +58,17 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): phi_e_n = pybamm.Variable( "Negative electrolyte potential [V]", domain="negative electrode", - reference=-param.n.prim.U_init, + reference=-self.param.n.prim.U_init, ) phi_e_s = pybamm.Variable( "Separator electrolyte potential [V]", domain="separator", - reference=-param.n.prim.U_init, + reference=-self.param.n.prim.U_init, ) phi_e_p = pybamm.Variable( "Positive electrolyte potential [V]", domain="positive electrode", - reference=-param.n.prim.U_init, + reference=-self.param.n.prim.U_init, ) phi_e = pybamm.concatenation(phi_e_n, phi_e_s, phi_e_p) @@ -80,7 +79,7 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): phi_s_p = pybamm.Variable( "Positive electrode potential [V]", domain="positive electrode", - reference=param.ocv_init, + reference=self.param.ocv_init, ) # Particle concentrations are variables on the particle domain, but also vary in # the x-direction (electrode domain) and so must be provided with auxiliary @@ -89,30 +88,30 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): "Negative primary particle concentration [mol.m-3]", domain="negative primary particle", auxiliary_domains={"secondary": "negative electrode"}, - scale=param.n.prim.c_max, + scale=self.param.n.prim.c_max, ) c_s_n_p2 = pybamm.Variable( "Negative secondary particle concentration [mol.m-3]", domain="negative secondary particle", auxiliary_domains={"secondary": "negative electrode"}, - scale=param.n.sec.c_max, + scale=self.param.n.sec.c_max, ) c_s_p = pybamm.Variable( "Positive particle concentration [mol.m-3]", domain="positive particle", auxiliary_domains={"secondary": "positive electrode"}, - scale=param.p.prim.c_max, + scale=self.param.p.prim.c_max, ) # Constant temperature - T = param.T_init + T = self.param.T_init ###################### # Other set-up ###################### # Current density - i_cell = param.current_density_with_time + i_cell = self.param.current_density_with_time # Porosity # Primary broadcasts are used to broadcast scalar quantities across a domain @@ -138,16 +137,16 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): # Tortuosity tor = pybamm.concatenation( - eps_n**param.n.b_e, eps_s**param.s.b_e, eps_p**param.p.b_e + eps_n**self.param.n.b_e, eps_s**self.param.s.b_e, eps_p**self.param.p.b_e ) # Open-circuit potentials c_s_surf_n_p1 = pybamm.surf(c_s_n_p1) - sto_surf_n_p1 = c_s_surf_n_p1 / param.n.prim.c_max - ocp_n_p1 = param.n.prim.U(sto_surf_n_p1, T) + sto_surf_n_p1 = c_s_surf_n_p1 / self.param.n.prim.c_max + ocp_n_p1 = self.param.n.prim.U(sto_surf_n_p1, T) c_s_surf_n_p2 = pybamm.surf(c_s_n_p2) - sto_surf_n_p2 = c_s_surf_n_p2 / param.n.sec.c_max + sto_surf_n_p2 = c_s_surf_n_p2 / self.param.n.sec.c_max k = 100 m_lith = pybamm.sigmoid(i_cell, 0, k) # for lithation (current < 0) m_delith = 1 - m_lith # for delithiation (current > 0) @@ -156,38 +155,42 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): ocp_n_p2 = m_lith * U_lith + m_delith * U_delith c_s_surf_p = pybamm.surf(c_s_p) - sto_surf_p = c_s_surf_p / param.p.prim.c_max - ocp_p = param.p.prim.U(sto_surf_p, T) + sto_surf_p = c_s_surf_p / self.param.p.prim.c_max + ocp_p = self.param.p.prim.U(sto_surf_p, T) # Interfacial reactions # Surf takes the surface value of a variable, i.e. its boundary value on the # right side. This is also accessible via `boundary_value(x, "right")`, with # "left" providing the boundary value of the left side - F_RT = param.F / (param.R * T) - j0_n_p1 = param.n.prim.j0(c_e_n, c_s_surf_n_p1, T) + F_RT = self.param.F / (self.param.R * T) + j0_n_p1 = self.param.n.prim.j0(c_e_n, c_s_surf_n_p1, T) j_n_p1 = ( 2 * j0_n_p1 - * pybamm.sinh(param.n.prim.ne / 2 * F_RT * (phi_s_n - phi_e_n - ocp_n_p1)) + * pybamm.sinh( + self.param.n.prim.ne / 2 * F_RT * (phi_s_n - phi_e_n - ocp_n_p1) + ) ) - j0_n_p2 = param.n.sec.j0(c_e_n, c_s_surf_n_p2, T) + j0_n_p2 = self.param.n.sec.j0(c_e_n, c_s_surf_n_p2, T) j_n_p2 = ( 2 * j0_n_p2 - * pybamm.sinh(param.n.sec.ne / 2 * F_RT * (phi_s_n - phi_e_n - ocp_n_p2)) + * pybamm.sinh( + self.param.n.sec.ne / 2 * F_RT * (phi_s_n - phi_e_n - ocp_n_p2) + ) ) - j0_p = param.p.prim.j0(c_e_p, c_s_surf_p, T) + j0_p = self.param.p.prim.j0(c_e_p, c_s_surf_p, T) a_j_s = pybamm.PrimaryBroadcast(0, "separator") j_p = ( 2 * j0_p - * pybamm.sinh(param.p.prim.ne / 2 * F_RT * (phi_s_p - phi_e_p - ocp_p)) + * pybamm.sinh(self.param.p.prim.ne / 2 * F_RT * (phi_s_p - phi_e_p - ocp_p)) ) # Volumetric - a_n_p1 = 3 * param.n.prim.epsilon_s_av / param.n.prim.R_typ - a_n_p2 = 3 * param.n.sec.epsilon_s_av / param.n.sec.R_typ - a_p = 3 * param.p.prim.epsilon_s_av / param.p.prim.R_typ + a_n_p1 = 3 * self.param.n.prim.epsilon_s_av / self.param.n.prim.R_typ + a_n_p2 = 3 * self.param.n.sec.epsilon_s_av / self.param.n.sec.R_typ + a_p = 3 * self.param.p.prim.epsilon_s_av / self.param.p.prim.R_typ a_j_n_p1 = a_n_p1 * j_n_p1 a_j_n_p2 = a_n_p2 * j_n_p2 a_j_n = a_j_n_p1 + a_j_n_p2 @@ -197,7 +200,7 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): ###################### # State of Charge ###################### - I = param.current_with_time + I = self.param.current_with_time # The `rhs` dictionary contains differential equations, with the key being the # variable in the d/dt self.rhs[Q] = I / 3600 @@ -210,9 +213,9 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): # The div and grad operators will be converted to the appropriate matrix # multiplication at the discretisation stage - N_s_n_p1 = -param.n.prim.D(c_s_n_p1, T) * pybamm.grad(c_s_n_p1) - N_s_n_p2 = -param.n.sec.D(c_s_n_p2, T) * pybamm.grad(c_s_n_p2) - N_s_p = -param.p.prim.D(c_s_p, T) * pybamm.grad(c_s_p) + N_s_n_p1 = -self.param.n.prim.D(c_s_n_p1, T) * pybamm.grad(c_s_n_p1) + N_s_n_p2 = -self.param.n.sec.D(c_s_n_p2, T) * pybamm.grad(c_s_n_p2) + N_s_p = -self.param.p.prim.D(c_s_p, T) * pybamm.grad(c_s_p) self.rhs[c_s_n_p1] = -pybamm.div(N_s_n_p1) self.rhs[c_s_n_p2] = -pybamm.div(N_s_n_p2) self.rhs[c_s_p] = -pybamm.div(N_s_p) @@ -220,27 +223,27 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): self.boundary_conditions[c_s_n_p1] = { "left": (pybamm.Scalar(0), "Neumann"), "right": ( - -j_n_p1 / param.F / pybamm.surf(param.n.prim.D(c_s_n_p1, T)), + -j_n_p1 / self.param.F / pybamm.surf(self.param.n.prim.D(c_s_n_p1, T)), "Neumann", ), } self.boundary_conditions[c_s_n_p2] = { "left": (pybamm.Scalar(0), "Neumann"), "right": ( - -j_n_p2 / param.F / pybamm.surf(param.n.sec.D(c_s_n_p2, T)), + -j_n_p2 / self.param.F / pybamm.surf(self.param.n.sec.D(c_s_n_p2, T)), "Neumann", ), } self.boundary_conditions[c_s_p] = { "left": (pybamm.Scalar(0), "Neumann"), "right": ( - -j_p / param.F / pybamm.surf(param.p.prim.D(c_s_p, T)), + -j_p / self.param.F / pybamm.surf(self.param.p.prim.D(c_s_p, T)), "Neumann", ), } - self.initial_conditions[c_s_n_p1] = param.n.prim.c_init - self.initial_conditions[c_s_n_p2] = param.n.sec.c_init - self.initial_conditions[c_s_p] = param.p.prim.c_init + self.initial_conditions[c_s_n_p1] = self.param.n.prim.c_init + self.initial_conditions[c_s_n_p2] = self.param.n.sec.c_init + self.initial_conditions[c_s_p] = self.param.p.prim.c_init # Events specify points at which a solution should terminate tolerance = 0.0000001 self.events += [ @@ -272,15 +275,15 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): ###################### # Current in the solid ###################### - sigma_eff_n = param.n.sigma(T) * eps_s_n**param.n.b_s + sigma_eff_n = self.param.n.sigma(T) * eps_s_n**self.param.n.b_s i_s_n = -sigma_eff_n * pybamm.grad(phi_s_n) - sigma_eff_p = param.p.sigma(T) * eps_s_p**param.p.b_s + sigma_eff_p = self.param.p.sigma(T) * eps_s_p**self.param.p.b_s i_s_p = -sigma_eff_p * pybamm.grad(phi_s_p) # The `algebraic` dictionary contains differential equations, with the key being # the main scalar variable of interest in the equation # multiply by Lx**2 to improve conditioning - self.algebraic[phi_s_n] = param.L_x**2 * (pybamm.div(i_s_n) + a_j_n) - self.algebraic[phi_s_p] = param.L_x**2 * (pybamm.div(i_s_p) + a_j_p) + self.algebraic[phi_s_n] = self.param.L_x**2 * (pybamm.div(i_s_n) + a_j_n) + self.algebraic[phi_s_p] = self.param.L_x**2 * (pybamm.div(i_s_p) + a_j_p) self.boundary_conditions[phi_s_n] = { "left": (pybamm.Scalar(0), "Dirichlet"), "right": (pybamm.Scalar(0), "Neumann"), @@ -295,34 +298,34 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): # We evaluate c_n_init at x=0 and c_p_init at x=1 (this is just an initial # guess so actual value is not too important) self.initial_conditions[phi_s_n] = pybamm.Scalar(0) - self.initial_conditions[phi_s_p] = param.ocv_init + self.initial_conditions[phi_s_p] = self.param.ocv_init ###################### # Current in the electrolyte ###################### - i_e = (param.kappa_e(c_e, T) * tor) * ( - param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) + i_e = (self.param.kappa_e(c_e, T) * tor) * ( + self.param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) ) # multiply by Lx**2 to improve conditioning - self.algebraic[phi_e] = param.L_x**2 * (pybamm.div(i_e) - a_j) + self.algebraic[phi_e] = self.param.L_x**2 * (pybamm.div(i_e) - a_j) self.boundary_conditions[phi_e] = { "left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(0), "Neumann"), } - self.initial_conditions[phi_e] = -param.n.prim.U_init + self.initial_conditions[phi_e] = -self.param.n.prim.U_init ###################### # Electrolyte concentration ###################### - N_e = -tor * param.D_e(c_e, T) * pybamm.grad(c_e) + N_e = -tor * self.param.D_e(c_e, T) * pybamm.grad(c_e) self.rhs[c_e] = (1 / eps) * ( - -pybamm.div(N_e) + (1 - param.t_plus(c_e, T)) * a_j / param.F + -pybamm.div(N_e) + (1 - self.param.t_plus(c_e, T)) * a_j / self.param.F ) self.boundary_conditions[c_e] = { "left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(0), "Neumann"), } - self.initial_conditions[c_e] = param.c_e_init + self.initial_conditions[c_e] = self.param.c_e_init ###################### # (Some) variables @@ -400,8 +403,8 @@ def __init__(self, name="Composite graphite/silicon Doyle-Fuller-Newman model"): } # Events specify points at which a solution should terminate self.events += [ - pybamm.Event("Minimum voltage [V]", voltage - param.voltage_low_cut), - pybamm.Event("Maximum voltage [V]", param.voltage_high_cut - voltage), + pybamm.Event("Minimum voltage [V]", voltage - self.param.voltage_low_cut), + pybamm.Event("Maximum voltage [V]", self.param.voltage_high_cut - voltage), ] @property diff --git a/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn_half_cell.py b/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn_half_cell.py index bc1eba3a83..b23b9dba0f 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn_half_cell.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/basic_dfn_half_cell.py @@ -36,7 +36,6 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): # `param` is a class containing all the relevant parameters and functions for # this model. These are purely symbolic at this stage, and will be set by the # `ParameterValues` class when the model is processed. - param = self.param ###################### # Variables @@ -69,14 +68,14 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): phi_e = pybamm.concatenation(phi_e_s, phi_e_w) # Constant temperature - T = param.T_init + T = self.param.T_init ###################### # Other set-up ###################### # Current density - i_cell = param.current_density_with_time + i_cell = self.param.current_density_with_time # Define particle surface concentration # Surf takes the surface value of a variable, i.e. its boundary value on the @@ -94,39 +93,39 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): eps_w = pybamm.PrimaryBroadcast( pybamm.Parameter("Positive electrode porosity"), "positive electrode" ) - b_e_s = param.s.b_e - b_e_w = param.p.b_e + b_e_s = self.param.s.b_e + b_e_w = self.param.p.b_e # Interfacial reactions - j0_w = param.p.prim.j0(c_e_w, c_s_surf_w, T) - U_w = param.p.prim.U - ne_w = param.p.prim.ne + j0_w = self.param.p.prim.j0(c_e_w, c_s_surf_w, T) + U_w = self.param.p.prim.U + ne_w = self.param.p.prim.ne # Particle diffusion parameters - D_w = param.p.prim.D - c_w_init = param.p.prim.c_init + D_w = self.param.p.prim.D + c_w_init = self.param.p.prim.c_init # Electrode equation parameters eps_s_w = pybamm.Parameter("Positive electrode active material volume fraction") - b_s_w = param.p.b_s - sigma_w = param.p.sigma + b_s_w = self.param.p.b_s + sigma_w = self.param.p.sigma # Other parameters (for outputs) - c_w_max = param.p.prim.c_max - L_w = param.p.L + c_w_max = self.param.p.prim.c_max + L_w = self.param.p.L eps = pybamm.concatenation(eps_s, eps_w) tor = pybamm.concatenation(eps_s**b_e_s, eps_w**b_e_w) - F_RT = param.F / (param.R * T) - RT_F = param.R * T / param.F + F_RT = self.param.F / (self.param.R * T) + RT_F = self.param.R * T / self.param.F sto_surf_w = c_s_surf_w / c_w_max j_w = ( 2 * j0_w * pybamm.sinh(ne_w / 2 * F_RT * (phi_s_w - phi_e_w - U_w(sto_surf_w, T))) ) - R_w = param.p.prim.R_typ + R_w = self.param.p.prim.R_typ a_w = 3 * eps_s_w / R_w a_j_w = a_w * j_w a_j_s = pybamm.PrimaryBroadcast(0, "separator") @@ -135,7 +134,7 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): ###################### # State of Charge ###################### - I = param.current_with_time + I = self.param.current_with_time # The `rhs` dictionary contains differential equations, with the key being the # variable in the d/dt self.rhs[Q] = I / 3600 @@ -154,7 +153,7 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): # derivatives self.boundary_conditions[c_s_w] = { "left": (pybamm.Scalar(0), "Neumann"), - "right": (-j_w / pybamm.surf(D_w(c_s_w, T)) / param.F, "Neumann"), + "right": (-j_w / pybamm.surf(D_w(c_s_w, T)) / self.param.F, "Neumann"), } self.initial_conditions[c_s_w] = c_w_init @@ -183,21 +182,23 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): ), } # multiply by Lx**2 to improve conditioning - self.algebraic[phi_s_w] = param.L_x**2 * (pybamm.div(i_s_w) + a_j_w) + self.algebraic[phi_s_w] = self.param.L_x**2 * (pybamm.div(i_s_w) + a_j_w) # Initial conditions must also be provided for algebraic equations, as an # initial guess for a root-finding algorithm which calculates consistent # initial conditions - self.initial_conditions[phi_s_w] = param.p.prim.U_init + self.initial_conditions[phi_s_w] = self.param.p.prim.U_init ###################### # Electrolyte concentration ###################### - N_e = -tor * param.D_e(c_e, T) * pybamm.grad(c_e) + N_e = -tor * self.param.D_e(c_e, T) * pybamm.grad(c_e) self.rhs[c_e] = (1 / eps) * ( - -pybamm.div(N_e) + (1 - param.t_plus(c_e, T)) * a_j / param.F + -pybamm.div(N_e) + (1 - self.param.t_plus(c_e, T)) * a_j / self.param.F ) dce_dx = ( - -(1 - param.t_plus(c_e, T)) * i_cell / (tor * param.F * param.D_e(c_e, T)) + -(1 - self.param.t_plus(c_e, T)) + * i_cell + / (tor * self.param.F * self.param.D_e(c_e, T)) ) self.boundary_conditions[c_e] = { @@ -205,7 +206,7 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): "right": (pybamm.Scalar(0), "Neumann"), } - self.initial_conditions[c_e] = param.c_e_init + self.initial_conditions[c_e] = self.param.c_e_init self.events.append( pybamm.Event( "Zero electrolyte concentration cut-off", pybamm.min(c_e) - 0.002 @@ -215,16 +216,16 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): ###################### # Current in the electrolyte ###################### - i_e = (param.kappa_e(c_e, T) * tor) * ( - param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) + i_e = (self.param.kappa_e(c_e, T) * tor) * ( + self.param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) ) # multiply by Lx**2 to improve conditioning - self.algebraic[phi_e] = param.L_x**2 * (pybamm.div(i_e) - a_j) + self.algebraic[phi_e] = self.param.L_x**2 * (pybamm.div(i_e) - a_j) # reference potential - L_Li = param.n.L - sigma_Li = param.n.sigma - j_Li = param.j0_Li_metal(pybamm.boundary_value(c_e, "left"), c_w_max, T) + L_Li = self.param.n.L + sigma_Li = self.param.n.sigma + j_Li = self.param.j0_Li_metal(pybamm.boundary_value(c_e, "left"), c_w_max, T) eta_Li = 2 * RT_F * pybamm.arcsinh(i_cell / (2 * j_Li)) phi_s_cn = 0 @@ -237,7 +238,7 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): "right": (pybamm.Scalar(0), "Neumann"), } - self.initial_conditions[phi_e] = -param.n.prim.U_init + self.initial_conditions[phi_e] = -self.param.n.prim.U_init ###################### # (Some) variables @@ -290,11 +291,15 @@ def __init__(self, options=None, name="Doyle-Fuller-Newman half cell model"): "X-averaged positive particle surface concentration " "[mol.m-3]": c_s_surf_w_av, "Positive particle concentration [mol.m-3]": c_s_w, - "Total lithium in positive electrode [mol]": c_s_vol_av * L_w * param.A_cc, + "Total lithium in positive electrode [mol]": c_s_vol_av + * L_w + * self.param.A_cc, "Electrolyte concentration [mol.m-3]": c_e, "Separator electrolyte concentration [mol.m-3]": c_e_s, "Positive electrolyte concentration [mol.m-3]": c_e_w, - "Total lithium in electrolyte [mol]": c_e_total * param.L_x * param.A_cc, + "Total lithium in electrolyte [mol]": c_e_total + * self.param.L_x + * self.param.A_cc, "Current [A]": I, "Current variable [A]": I, # for compatibility with pybamm.Experiment "Current density [A.m-2]": i_cell, diff --git a/src/pybamm/models/full_battery_models/lithium_ion/basic_splitOCVR.py b/src/pybamm/models/full_battery_models/lithium_ion/basic_splitOCVR.py new file mode 100644 index 0000000000..386a5c08fc --- /dev/null +++ b/src/pybamm/models/full_battery_models/lithium_ion/basic_splitOCVR.py @@ -0,0 +1,100 @@ +# +# Equivalent Circuit Model with split OCV +# +import pybamm + + +class SplitOCVR(pybamm.BaseModel): + """Basic Equivalent Circuit Model that uses two OCV functions + for each electrode. This model is easily parameterizable with minimal parameters. + This class differs from the :class: pybamm.equivalent_circuit.Thevenin() due + to dual OCV functions to make up the voltage from each electrode. + + Parameters + ---------- + name: str, optional + The name of the model. + """ + + def __init__(self, name="ECM with split OCV"): + super().__init__(name) + + ###################### + # Variables + ###################### + # All variables are only time-dependent + # No domain definition needed + + theta_n = pybamm.Variable("Negative particle stoichiometry") + theta_p = pybamm.Variable("Positive particle stoichiometry") + Q = pybamm.Variable("Discharge capacity [A.h]") + V = pybamm.Variable("Voltage [V]") + + # model is isothermal + I = pybamm.FunctionParameter("Current function [A]", {"Time [s]": pybamm.t}) + + # Capacity equation + self.rhs[Q] = I / 3600 + self.initial_conditions[Q] = pybamm.Scalar(0) + + # Capacity in each electrode + Q_n = pybamm.Parameter("Negative electrode capacity [A.h]") + Q_p = pybamm.Parameter("Positive electrode capacity [A.h]") + + # State of charge electrode equations + theta_n_0 = pybamm.Parameter("Negative electrode initial stoichiometry") + theta_p_0 = pybamm.Parameter("Positive electrode initial stoichiometry") + self.rhs[theta_n] = -I / Q_n / 3600 + self.rhs[theta_p] = I / Q_p / 3600 + self.initial_conditions[theta_n] = theta_n_0 + self.initial_conditions[theta_p] = theta_p_0 + + # Resistance for IR expression + R = pybamm.Parameter("Ohmic resistance [Ohm]") + + # Open-circuit potential for each electrode + Un = pybamm.FunctionParameter( + "Negative electrode OCP [V]", {"Negative particle stoichiometry": theta_n} + ) + Up = pybamm.FunctionParameter( + "Positive electrode OCP [V]", {"Positive particle stoichiometry": theta_p} + ) + + # Voltage expression + V = Up - Un - I * R + + # Parameters for Voltage cutoff + voltage_high_cut = pybamm.Parameter("Upper voltage cut-off [V]") + voltage_low_cut = pybamm.Parameter("Lower voltage cut-off [V]") + + self.variables = { + "Negative particle stoichiometry": theta_n, + "Positive particle stoichiometry": theta_p, + "Current [A]": I, + "Discharge capacity [A.h]": Q, + "Voltage [V]": V, + "Times [s]": pybamm.t, + "Positive electrode OCP [V]": Up, + "Negative electrode OCP [V]": Un, + "Current function [A]": I, + } + + # Events specify points at which a solution should terminate + self.events += [ + pybamm.Event("Minimum voltage [V]", V - voltage_low_cut), + pybamm.Event("Maximum voltage [V]", voltage_high_cut - V), + pybamm.Event("Maximum Negative Electrode stoichiometry", 0.999 - theta_n), + pybamm.Event("Maximum Positive Electrode stoichiometry", 0.999 - theta_p), + pybamm.Event("Minimum Negative Electrode stoichiometry", theta_n - 0.0001), + pybamm.Event("Minimum Positive Electrode stoichiometry", theta_p - 0.0001), + ] + + @property + def default_quick_plot_variables(self): + return [ + "Voltage [V]", + ["Negative particle stoichiometry", "Positive particle stoichiometry"], + "Negative electrode OCP [V]", + "Positive electrode OCP [V]", + "Current [A]", + ] diff --git a/src/pybamm/models/full_battery_models/lithium_ion/basic_spm.py b/src/pybamm/models/full_battery_models/lithium_ion/basic_spm.py index 6bd93f3b27..cd1b968017 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/basic_spm.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/basic_spm.py @@ -26,7 +26,6 @@ def __init__(self, name="Single Particle Model"): # `param` is a class containing all the relevant parameters and functions for # this model. These are purely symbolic at this stage, and will be set by the # `ParameterValues` class when the model is processed. - param = self.param ###################### # Variables @@ -44,23 +43,23 @@ def __init__(self, name="Single Particle Model"): ) # Constant temperature - T = param.T_init + T = self.param.T_init ###################### # Other set-up ###################### # Current density - i_cell = param.current_density_with_time - a_n = 3 * param.n.prim.epsilon_s_av / param.n.prim.R_typ - a_p = 3 * param.p.prim.epsilon_s_av / param.p.prim.R_typ - j_n = i_cell / (param.n.L * a_n) - j_p = -i_cell / (param.p.L * a_p) + i_cell = self.param.current_density_with_time + a_n = 3 * self.param.n.prim.epsilon_s_av / self.param.n.prim.R_typ + a_p = 3 * self.param.p.prim.epsilon_s_av / self.param.p.prim.R_typ + j_n = i_cell / (self.param.n.L * a_n) + j_p = -i_cell / (self.param.p.L * a_p) ###################### # State of Charge ###################### - I = param.current_with_time + I = self.param.current_with_time # The `rhs` dictionary contains differential equations, with the key being the # variable in the d/dt self.rhs[Q] = I / 3600 @@ -73,8 +72,8 @@ def __init__(self, name="Single Particle Model"): # The div and grad operators will be converted to the appropriate matrix # multiplication at the discretisation stage - N_s_n = -param.n.prim.D(c_s_n, T) * pybamm.grad(c_s_n) - N_s_p = -param.p.prim.D(c_s_p, T) * pybamm.grad(c_s_p) + N_s_n = -self.param.n.prim.D(c_s_n, T) * pybamm.grad(c_s_n) + N_s_p = -self.param.p.prim.D(c_s_p, T) * pybamm.grad(c_s_p) self.rhs[c_s_n] = -pybamm.div(N_s_n) self.rhs[c_s_p] = -pybamm.div(N_s_p) # Surf takes the surface value of a variable, i.e. its boundary value on the @@ -86,24 +85,24 @@ def __init__(self, name="Single Particle Model"): self.boundary_conditions[c_s_n] = { "left": (pybamm.Scalar(0), "Neumann"), "right": ( - -j_n / (param.F * pybamm.surf(param.n.prim.D(c_s_n, T))), + -j_n / (self.param.F * pybamm.surf(self.param.n.prim.D(c_s_n, T))), "Neumann", ), } self.boundary_conditions[c_s_p] = { "left": (pybamm.Scalar(0), "Neumann"), "right": ( - -j_p / (param.F * pybamm.surf(param.p.prim.D(c_s_p, T))), + -j_p / (self.param.F * pybamm.surf(self.param.p.prim.D(c_s_p, T))), "Neumann", ), } # c_n_init and c_p_init are functions of r and x, but for the SPM we # take the x-averaged value since there is no x-dependence in the particles - self.initial_conditions[c_s_n] = pybamm.x_average(param.n.prim.c_init) - self.initial_conditions[c_s_p] = pybamm.x_average(param.p.prim.c_init) + self.initial_conditions[c_s_n] = pybamm.x_average(self.param.n.prim.c_init) + self.initial_conditions[c_s_p] = pybamm.x_average(self.param.p.prim.c_init) # Events specify points at which a solution should terminate - sto_surf_n = c_s_surf_n / param.n.prim.c_max - sto_surf_p = c_s_surf_p / param.p.prim.c_max + sto_surf_n = c_s_surf_n / self.param.n.prim.c_max + sto_surf_p = c_s_surf_p / self.param.p.prim.c_max self.events += [ pybamm.Event( "Minimum negative particle surface stoichiometry", @@ -130,14 +129,14 @@ def __init__(self, name="Single Particle Model"): # (Some) variables ###################### # Interfacial reactions - RT_F = param.R * T / param.F - j0_n = param.n.prim.j0(param.c_e_init_av, c_s_surf_n, T) - j0_p = param.p.prim.j0(param.c_e_init_av, c_s_surf_p, T) - eta_n = (2 / param.n.prim.ne) * RT_F * pybamm.arcsinh(j_n / (2 * j0_n)) - eta_p = (2 / param.p.prim.ne) * RT_F * pybamm.arcsinh(j_p / (2 * j0_p)) + RT_F = self.param.R * T / self.param.F + j0_n = self.param.n.prim.j0(self.param.c_e_init_av, c_s_surf_n, T) + j0_p = self.param.p.prim.j0(self.param.c_e_init_av, c_s_surf_p, T) + eta_n = (2 / self.param.n.prim.ne) * RT_F * pybamm.arcsinh(j_n / (2 * j0_n)) + eta_p = (2 / self.param.p.prim.ne) * RT_F * pybamm.arcsinh(j_p / (2 * j0_p)) phi_s_n = 0 - phi_e = -eta_n - param.n.prim.U(sto_surf_n, T) - phi_s_p = eta_p + phi_e + param.p.prim.U(sto_surf_p, T) + phi_e = -eta_n - self.param.n.prim.U(sto_surf_n, T) + phi_s_p = eta_p + phi_e + self.param.p.prim.U(sto_surf_p, T) V = phi_s_p num_cells = pybamm.Parameter( "Number of cells connected in series to make a battery" @@ -157,7 +156,7 @@ def __init__(self, name="Single Particle Model"): c_s_surf_n, "negative electrode" ), "Electrolyte concentration [mol.m-3]": pybamm.PrimaryBroadcast( - param.c_e_init_av, whole_cell + self.param.c_e_init_av, whole_cell ), "X-averaged positive particle concentration [mol.m-3]": c_s_p, "Positive particle surface " @@ -178,6 +177,6 @@ def __init__(self, name="Single Particle Model"): } # Events specify points at which a solution should terminate self.events += [ - pybamm.Event("Minimum voltage [V]", V - param.voltage_low_cut), - pybamm.Event("Maximum voltage [V]", param.voltage_high_cut - V), + pybamm.Event("Minimum voltage [V]", V - self.param.voltage_low_cut), + pybamm.Event("Maximum voltage [V]", self.param.voltage_high_cut - V), ] diff --git a/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py b/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py index a5710dc986..a743910905 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh.py @@ -657,6 +657,8 @@ def get_initial_stoichiometries(self, initial_value, tol=1e-6, inputs=None): The tolerance for the solver used to compute the initial stoichiometries. A lower value results in higher precision but may increase computation time. Default is 1e-6. + inputs : dict, optional + A dictionary of input parameters passed to the model. Returns ------- @@ -664,15 +666,14 @@ def get_initial_stoichiometries(self, initial_value, tol=1e-6, inputs=None): The initial stoichiometries that give the desired initial state of charge """ parameter_values = self.parameter_values - param = self.param x_0, x_100, y_100, y_0 = self.get_min_max_stoichiometries(inputs=inputs) if isinstance(initial_value, str) and initial_value.endswith("V"): V_init = float(initial_value[:-1]) - V_min = parameter_values.evaluate(param.ocp_soc_0) - V_max = parameter_values.evaluate(param.ocp_soc_100) + V_min = parameter_values.evaluate(self.param.ocp_soc_0) + V_max = parameter_values.evaluate(self.param.ocp_soc_100) - if not V_min <= V_init <= V_max: + if not V_min - tol <= V_init <= V_max + tol: raise ValueError( f"Initial voltage {V_init}V is outside the voltage limits " f"({V_min}, {V_max})" @@ -685,8 +686,8 @@ def get_initial_stoichiometries(self, initial_value, tol=1e-6, inputs=None): y = y_0 - soc * (y_0 - y_100) T_ref = parameter_values["Reference temperature [K]"] if self.options["open-circuit potential"] == "MSMR": - xn = param.n.prim.x - xp = param.p.prim.x + xn = self.param.n.prim.x + xp = self.param.p.prim.x Up = pybamm.Variable("Up") Un = pybamm.Variable("Un") soc_model.algebraic[Up] = x - xn(Un, T_ref) @@ -695,8 +696,8 @@ def get_initial_stoichiometries(self, initial_value, tol=1e-6, inputs=None): soc_model.initial_conditions[Up] = V_max soc_model.algebraic[soc] = Up - Un - V_init else: - Up = param.p.prim.U - Un = param.n.prim.U + Up = self.param.p.prim.U + Un = self.param.n.prim.U soc_model.algebraic[soc] = Up(y, T_ref) - Un(x, T_ref) - V_init # initial guess for soc linearly interpolates between 0 and 1 # based on V linearly interpolating between V_max and V_min @@ -727,6 +728,11 @@ def get_min_max_stoichiometries(self, inputs=None): Calculate min/max stoichiometries given voltage limits, open-circuit potentials, etc defined by parameter_values + Parameters + ---------- + inputs : dict, optional + A dictionary of input parameters passed to the model. + Returns ------- x_0, x_100, y_100, y_0 @@ -734,24 +740,25 @@ def get_min_max_stoichiometries(self, inputs=None): """ inputs = inputs or {} parameter_values = self.parameter_values - param = self.param - Q_n = parameter_values.evaluate(param.n.Q_init, inputs=inputs) - Q_p = parameter_values.evaluate(param.p.Q_init, inputs=inputs) + Q_n = parameter_values.evaluate(self.param.n.Q_init, inputs=inputs) + Q_p = parameter_values.evaluate(self.param.p.Q_init, inputs=inputs) if self.known_value == "cyclable lithium capacity": - Q_Li = parameter_values.evaluate(param.Q_Li_particles_init, inputs=inputs) + Q_Li = parameter_values.evaluate( + self.param.Q_Li_particles_init, inputs=inputs + ) all_inputs = {**inputs, "Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li} elif self.known_value == "cell capacity": Q = parameter_values.evaluate( - param.Q / param.n_electrodes_parallel, inputs=inputs + self.param.Q / self.param.n_electrodes_parallel, inputs=inputs ) all_inputs = {**inputs, "Q_n": Q_n, "Q_p": Q_p, "Q": Q} # Solve the model and check outputs sol = self.solve(all_inputs) return [sol["x_0"], sol["x_100"], sol["y_100"], sol["y_0"]] - def get_initial_ocps(self, initial_value, tol=1e-6): + def get_initial_ocps(self, initial_value, tol=1e-6, inputs=None): """ Calculate initial open-circuit potentials to start off the simulation at a particular state of charge, given voltage limits, open-circuit potentials, etc @@ -760,9 +767,14 @@ def get_initial_ocps(self, initial_value, tol=1e-6): Parameters ---------- initial_value : float - Target SOC, must be between 0 and 1. + Target initial value. + If integer, interpreted as SOC, must be between 0 and 1. + If string e.g. "4 V", interpreted as voltage, + must be between V_min and V_max. tol: float, optional Tolerance for the solver used in calculating initial stoichiometries. + inputs : dict, optional + A dictionary of input parameters passed to the model. Returns ------- @@ -770,8 +782,7 @@ def get_initial_ocps(self, initial_value, tol=1e-6): The initial open-circuit potentials at the desired initial state of charge """ parameter_values = self.parameter_values - param = self.param - x, y = self.get_initial_stoichiometries(initial_value, tol) + x, y = self.get_initial_stoichiometries(initial_value, tol, inputs=inputs) if self.options["open-circuit potential"] == "MSMR": msmr_pot_model = _get_msmr_potential_model( self.parameter_values, self.param @@ -783,8 +794,8 @@ def get_initial_ocps(self, initial_value, tol=1e-6): Up = sol["Up"].data[0] else: T_ref = parameter_values["Reference temperature [K]"] - Un = parameter_values.evaluate(param.n.prim.U(x, T_ref)) - Up = parameter_values.evaluate(param.p.prim.U(y, T_ref)) + Un = parameter_values.evaluate(self.param.n.prim.U(x, T_ref), inputs=inputs) + Up = parameter_values.evaluate(self.param.p.prim.U(y, T_ref), inputs=inputs) return Un, Up def get_min_max_ocps(self): @@ -798,16 +809,17 @@ def get_min_max_ocps(self): The min/max ocps """ parameter_values = self.parameter_values - param = self.param - Q_n = parameter_values.evaluate(param.n.Q_init) - Q_p = parameter_values.evaluate(param.p.Q_init) + Q_n = parameter_values.evaluate(self.param.n.Q_init) + Q_p = parameter_values.evaluate(self.param.p.Q_init) if self.known_value == "cyclable lithium capacity": - Q_Li = parameter_values.evaluate(param.Q_Li_particles_init) + Q_Li = parameter_values.evaluate(self.param.Q_Li_particles_init) inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li} elif self.known_value == "cell capacity": - Q = parameter_values.evaluate(param.Q / param.n_electrodes_parallel) + Q = parameter_values.evaluate( + self.param.Q / self.param.n_electrodes_parallel + ) inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q": Q} # Solve the model and check outputs sol = self.solve(inputs) @@ -822,10 +834,10 @@ def theoretical_energy_integral(self, inputs, points=1000): x_vals = np.linspace(x_100, x_0, num=points) y_vals = np.linspace(y_100, y_0, num=points) # Calculate OCV at each stoichiometry - param = self.param - T = param.T_amb_av(0) + T = self.param.T_amb_av(0) Vs = self.parameter_values.evaluate( - param.p.prim.U(y_vals, T) - param.n.prim.U(x_vals, T), inputs=inputs + self.param.p.prim.U(y_vals, T) - self.param.n.prim.U(x_vals, T), + inputs=inputs, ).flatten() # Calculate dQ Q = Q_p * (y_0 - y_100) @@ -869,8 +881,9 @@ def get_initial_stoichiometries( :class:`pybamm.BatteryModelOptions`. tol : float, optional The tolerance for the solver used to compute the initial stoichiometries. - A lower value results in higher precision but may increase computation time. Default is 1e-6. + inputs : dict, optional + A dictionary of input parameters passed to the model. Returns ------- @@ -918,6 +931,8 @@ def get_initial_ocps( param=None, known_value="cyclable lithium capacity", options=None, + tol=1e-6, + inputs=None, ): """ Calculate initial open-circuit potentials to start off the simulation at a @@ -942,6 +957,10 @@ def get_initial_ocps( options : dict-like, optional A dictionary of options to be passed to the model, see :class:`pybamm.BatteryModelOptions`. + tol: float, optional + Tolerance for the solver used in calculating initial open-circuit potentials. + inputs : dict, optional + A dictionary of input parameters passed to the model. Returns ------- @@ -949,7 +968,7 @@ def get_initial_ocps( The initial electrode OCPs that give the desired initial state of charge """ esoh_solver = ElectrodeSOHSolver(parameter_values, param, known_value, options) - return esoh_solver.get_initial_ocps(initial_value) + return esoh_solver.get_initial_ocps(initial_value, tol, inputs=inputs) def get_min_max_ocps( diff --git a/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh_half_cell.py b/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh_half_cell.py index be7ced642e..1b54b6faad 100644 --- a/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh_half_cell.py +++ b/src/pybamm/models/full_battery_models/lithium_ion/electrode_soh_half_cell.py @@ -63,6 +63,7 @@ def get_initial_stoichiometry_half_cell( parameter_values, param=None, options=None, + tol=1e-6, inputs=None, **kwargs, ): @@ -80,7 +81,14 @@ def get_initial_stoichiometry_half_cell( must be between V_min and V_max. parameter_values : pybamm.ParameterValues The parameter values to use in the calculation - + param : :class:`pybamm.LithiumIonParameters`, optional + The symbolic parameter set to use for the simulation. + If not provided, the default parameter set will be used. + tol : float, optional + The tolerance for the solver used to compute the initial stoichiometries. + Default is 1e-6. + inputs : dict, optional + A dictionary of input parameters passed to the model. Returns ------- x @@ -94,7 +102,7 @@ def get_initial_stoichiometry_half_cell( V_min = parameter_values.evaluate(param.voltage_low_cut) V_max = parameter_values.evaluate(param.voltage_high_cut) - if not V_min <= V_init <= V_max: + if not V_min - tol <= V_init <= V_max + tol: raise ValueError( f"Initial voltage {V_init}V is outside the voltage limits " f"({V_min}, {V_max})" @@ -113,7 +121,9 @@ def get_initial_stoichiometry_half_cell( soc_model.initial_conditions[soc] = (V_init - V_min) / (V_max - V_min) soc_model.variables["soc"] = soc parameter_values.process_model(soc_model) - initial_soc = pybamm.AlgebraicSolver().solve(soc_model, [0])["soc"].data[0] + initial_soc = ( + pybamm.AlgebraicSolver(tol=tol).solve(soc_model, [0])["soc"].data[0] + ) elif isinstance(initial_value, (int, float)): initial_soc = initial_value if not 0 <= initial_soc <= 1: diff --git a/src/pybamm/models/full_battery_models/sodium_ion/__init__.py b/src/pybamm/models/full_battery_models/sodium_ion/__init__.py new file mode 100644 index 0000000000..52e4e54952 --- /dev/null +++ b/src/pybamm/models/full_battery_models/sodium_ion/__init__.py @@ -0,0 +1,6 @@ +# +# Root of the sodium-ion models module. +# +from .basic_dfn import BasicDFN + +__all__ = ['basic_dfn'] diff --git a/src/pybamm/models/full_battery_models/sodium_ion/basic_dfn.py b/src/pybamm/models/full_battery_models/sodium_ion/basic_dfn.py new file mode 100644 index 0000000000..c6f618d338 --- /dev/null +++ b/src/pybamm/models/full_battery_models/sodium_ion/basic_dfn.py @@ -0,0 +1,273 @@ +# +# Basic Doyle-Fuller-Newman (DFN) Model +# +import pybamm + + +class BasicDFN(pybamm.lithium_ion.BaseModel): + """Doyle-Fuller-Newman (DFN) model of a sodium-ion battery, from + :footcite:t:`Marquis2019`. + + Parameters + ---------- + name : str, optional + The name of the model. + + """ + + def __init__(self, name="Doyle-Fuller-Newman model"): + super().__init__(name=name) + pybamm.citations.register("Marquis2019") + # `param` is a class containing all the relevant parameters and functions for + # this model. These are purely symbolic at this stage, and will be set by the + # `ParameterValues` class when the model is processed. + param = self.param + + ###################### + # Variables + ###################### + # Variables that depend on time only are created without a domain + Q = pybamm.Variable("Discharge capacity [A.h]") + + # Variables that vary spatially are created with a domain + c_e_n = pybamm.Variable( + "Negative electrolyte concentration [mol.m-3]", + domain="negative electrode", + ) + c_e_s = pybamm.Variable( + "Separator electrolyte concentration [mol.m-3]", + domain="separator", + ) + c_e_p = pybamm.Variable( + "Positive electrolyte concentration [mol.m-3]", + domain="positive electrode", + ) + # Concatenations combine several variables into a single variable, to simplify + # implementing equations that hold over several domains + c_e = pybamm.concatenation(c_e_n, c_e_s, c_e_p) + + # Electrolyte potential + phi_e_n = pybamm.Variable( + "Negative electrolyte potential [V]", + domain="negative electrode", + ) + phi_e_s = pybamm.Variable( + "Separator electrolyte potential [V]", + domain="separator", + ) + phi_e_p = pybamm.Variable( + "Positive electrolyte potential [V]", + domain="positive electrode", + ) + phi_e = pybamm.concatenation(phi_e_n, phi_e_s, phi_e_p) + + # Electrode potential + phi_s_n = pybamm.Variable( + "Negative electrode potential [V]", domain="negative electrode" + ) + phi_s_p = pybamm.Variable( + "Positive electrode potential [V]", + domain="positive electrode", + ) + # Particle concentrations are variables on the particle domain, but also vary in + # the x-direction (electrode domain) and so must be provided with auxiliary + # domains + c_s_n = pybamm.Variable( + "Negative particle concentration [mol.m-3]", + domain="negative particle", + auxiliary_domains={"secondary": "negative electrode"}, + ) + c_s_p = pybamm.Variable( + "Positive particle concentration [mol.m-3]", + domain="positive particle", + auxiliary_domains={"secondary": "positive electrode"}, + ) + + # Constant temperature + T = param.T_init + + ###################### + # Other set-up + ###################### + + # Current density + i_cell = param.current_density_with_time + + # Porosity + # Primary broadcasts are used to broadcast scalar quantities across a domain + # into a vector of the right shape, for multiplying with other vectors + eps_n = pybamm.PrimaryBroadcast( + pybamm.Parameter("Negative electrode porosity"), "negative electrode" + ) + eps_s = pybamm.PrimaryBroadcast( + pybamm.Parameter("Separator porosity"), "separator" + ) + eps_p = pybamm.PrimaryBroadcast( + pybamm.Parameter("Positive electrode porosity"), "positive electrode" + ) + eps = pybamm.concatenation(eps_n, eps_s, eps_p) + + # Active material volume fraction (eps + eps_s + eps_inactive = 1) + eps_s_n = pybamm.Parameter("Negative electrode active material volume fraction") + eps_s_p = pybamm.Parameter("Positive electrode active material volume fraction") + + # transport_efficiency + tor = pybamm.concatenation( + eps_n**param.n.b_e, eps_s**param.s.b_e, eps_p**param.p.b_e + ) + a_n = 3 * param.n.prim.epsilon_s_av / param.n.prim.R_typ + a_p = 3 * param.p.prim.epsilon_s_av / param.p.prim.R_typ + + # Interfacial reactions + # Surf takes the surface value of a variable, i.e. its boundary value on the + # right side. This is also accessible via `boundary_value(x, "right")`, with + # "left" providing the boundary value of the left side + c_s_surf_n = pybamm.surf(c_s_n) + sto_surf_n = c_s_surf_n / param.n.prim.c_max + j0_n = param.n.prim.j0(c_e_n, c_s_surf_n, T) + eta_n = phi_s_n - phi_e_n - param.n.prim.U(sto_surf_n, T) + Feta_RT_n = param.F * eta_n / (param.R * T) + j_n = 2 * j0_n * pybamm.sinh(param.n.prim.ne / 2 * Feta_RT_n) + + c_s_surf_p = pybamm.surf(c_s_p) + sto_surf_p = c_s_surf_p / param.p.prim.c_max + j0_p = param.p.prim.j0(c_e_p, c_s_surf_p, T) + eta_p = phi_s_p - phi_e_p - param.p.prim.U(sto_surf_p, T) + Feta_RT_p = param.F * eta_p / (param.R * T) + j_s = pybamm.PrimaryBroadcast(0, "separator") + j_p = 2 * j0_p * pybamm.sinh(param.p.prim.ne / 2 * Feta_RT_p) + + a_j_n = a_n * j_n + a_j_p = a_p * j_p + a_j = pybamm.concatenation(a_j_n, j_s, a_j_p) + + ###################### + # State of Charge + ###################### + I = param.current_with_time + # The `rhs` dictionary contains differential equations, with the key being the + # variable in the d/dt + self.rhs[Q] = I / 3600 + # Initial conditions must be provided for the ODEs + self.initial_conditions[Q] = pybamm.Scalar(0) + + ###################### + # Particles + ###################### + + # The div and grad operators will be converted to the appropriate matrix + # multiplication at the discretisation stage + N_s_n = -param.n.prim.D(c_s_n, T) * pybamm.grad(c_s_n) + N_s_p = -param.p.prim.D(c_s_p, T) * pybamm.grad(c_s_p) + self.rhs[c_s_n] = -pybamm.div(N_s_n) + self.rhs[c_s_p] = -pybamm.div(N_s_p) + # Boundary conditions must be provided for equations with spatial derivatives + self.boundary_conditions[c_s_n] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": ( + -j_n / (param.F * pybamm.surf(param.n.prim.D(c_s_n, T))), + "Neumann", + ), + } + self.boundary_conditions[c_s_p] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": ( + -j_p / (param.F * pybamm.surf(param.p.prim.D(c_s_p, T))), + "Neumann", + ), + } + self.initial_conditions[c_s_n] = param.n.prim.c_init + self.initial_conditions[c_s_p] = param.p.prim.c_init + ###################### + # Current in the solid + ###################### + sigma_eff_n = param.n.sigma(T) * eps_s_n**param.n.b_s + i_s_n = -sigma_eff_n * pybamm.grad(phi_s_n) + sigma_eff_p = param.p.sigma(T) * eps_s_p**param.p.b_s + i_s_p = -sigma_eff_p * pybamm.grad(phi_s_p) + # The `algebraic` dictionary contains differential equations, with the key being + # the main scalar variable of interest in the equation + # multiply by Lx**2 to improve conditioning + self.algebraic[phi_s_n] = param.L_x**2 * (pybamm.div(i_s_n) + a_j_n) + self.algebraic[phi_s_p] = param.L_x**2 * (pybamm.div(i_s_p) + a_j_p) + self.boundary_conditions[phi_s_n] = { + "left": (pybamm.Scalar(0), "Dirichlet"), + "right": (pybamm.Scalar(0), "Neumann"), + } + self.boundary_conditions[phi_s_p] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": (i_cell / pybamm.boundary_value(-sigma_eff_p, "right"), "Neumann"), + } + # Initial conditions must also be provided for algebraic equations, as an + # initial guess for a root-finding algorithm which calculates consistent initial + # conditions + self.initial_conditions[phi_s_n] = pybamm.Scalar(0) + self.initial_conditions[phi_s_p] = param.ocv_init + + ###################### + # Current in the electrolyte + ###################### + i_e = (param.kappa_e(c_e, T) * tor) * ( + param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) + ) + # multiply by Lx**2 to improve conditioning + self.algebraic[phi_e] = param.L_x**2 * (pybamm.div(i_e) - a_j) + self.boundary_conditions[phi_e] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": (pybamm.Scalar(0), "Neumann"), + } + self.initial_conditions[phi_e] = -param.n.prim.U_init + + ###################### + # Electrolyte concentration + ###################### + N_e = -tor * param.D_e(c_e, T) * pybamm.grad(c_e) + self.rhs[c_e] = (1 / eps) * ( + -pybamm.div(N_e) + (1 - param.t_plus(c_e, T)) * a_j / param.F + ) + self.boundary_conditions[c_e] = { + "left": (pybamm.Scalar(0), "Neumann"), + "right": (pybamm.Scalar(0), "Neumann"), + } + self.initial_conditions[c_e] = param.c_e_init + + ###################### + # (Some) variables + ###################### + voltage = pybamm.boundary_value(phi_s_p, "right") + num_cells = pybamm.Parameter( + "Number of cells connected in series to make a battery" + ) + # The `variables` dictionary contains all variables that might be useful for + # visualising the solution of the model + self.variables = { + "Negative particle concentration [mol.m-3]": c_s_n, + "Negative particle surface concentration [mol.m-3]": c_s_surf_n, + "Electrolyte concentration [mol.m-3]": c_e, + "Negative electrolyte concentration [mol.m-3]": c_e_n, + "Separator electrolyte concentration [mol.m-3]": c_e_s, + "Positive electrolyte concentration [mol.m-3]": c_e_p, + "Positive particle concentration [mol.m-3]": c_s_p, + "Positive particle surface concentration [mol.m-3]": c_s_surf_p, + "Current [A]": I, + "Current variable [A]": I, # for compatibility with pybamm.Experiment + "Negative electrode potential [V]": phi_s_n, + "Electrolyte potential [V]": phi_e, + "Negative electrolyte potential [V]": phi_e_n, + "Separator electrolyte potential [V]": phi_e_s, + "Positive electrolyte potential [V]": phi_e_p, + "Positive electrode potential [V]": phi_s_p, + "Voltage [V]": voltage, + "Battery voltage [V]": voltage * num_cells, + "Time [s]": pybamm.t, + "Discharge capacity [A.h]": Q, + } + # Events specify points at which a solution should terminate + self.events += [ + pybamm.Event("Minimum voltage [V]", voltage - param.voltage_low_cut), + pybamm.Event("Maximum voltage [V]", param.voltage_high_cut - voltage), + ] + + @property + def default_parameter_values(self): + return pybamm.ParameterValues("Chayambuka2022") diff --git a/src/pybamm/models/submodels/active_material/base_active_material.py b/src/pybamm/models/submodels/active_material/base_active_material.py index ba39adf852..ea0e826e09 100644 --- a/src/pybamm/models/submodels/active_material/base_active_material.py +++ b/src/pybamm/models/submodels/active_material/base_active_material.py @@ -23,7 +23,6 @@ def __init__(self, param, domain, options, phase="primary"): super().__init__(param, domain, options=options, phase=phase) def _get_standard_active_material_variables(self, eps_solid): - param = self.param phase_name = self.phase_name domain, Domain = self.domain_Domain @@ -61,9 +60,9 @@ def _get_standard_active_material_variables(self, eps_solid): C = ( pybamm.yz_average(eps_solid_av) * L - * param.A_cc + * self.param.A_cc * c_s_max - * param.F + * self.param.F / 3600 ) if phase_name == "": diff --git a/src/pybamm/models/submodels/active_material/constant_active_material.py b/src/pybamm/models/submodels/active_material/constant_active_material.py index 3237775f1c..e978168e9a 100644 --- a/src/pybamm/models/submodels/active_material/constant_active_material.py +++ b/src/pybamm/models/submodels/active_material/constant_active_material.py @@ -23,6 +23,7 @@ class Constant(BaseModel): def get_fundamental_variables(self): domain = self.domain + phase = self.phase_name eps_solid = self.phase_param.epsilon_s deps_solid_dt = pybamm.FullBroadcast( 0, f"{domain} electrode", "current collector" @@ -35,7 +36,7 @@ def get_fundamental_variables(self): variables.update( { - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase}active material " f"in {domain} electrode [mol]": pybamm.Scalar(0) } ) diff --git a/src/pybamm/models/submodels/active_material/loss_active_material.py b/src/pybamm/models/submodels/active_material/loss_active_material.py index 6f027d89e6..ffba064d03 100644 --- a/src/pybamm/models/submodels/active_material/loss_active_material.py +++ b/src/pybamm/models/submodels/active_material/loss_active_material.py @@ -23,34 +23,36 @@ class LossActiveMaterial(BaseModel): """ - def __init__(self, param, domain, options, x_average): - super().__init__(param, domain, options=options) + def __init__(self, param, domain, options, x_average, phase): + super().__init__(param, domain, options=options, phase=phase) pybamm.citations.register("Reniers2019") self.x_average = x_average def get_fundamental_variables(self): domain, Domain = self.domain_Domain + phase = self.phase_name if self.x_average is True: eps_solid_xav = pybamm.Variable( - f"X-averaged {domain} electrode active material volume fraction", + f"X-averaged {domain} electrode {phase}active material volume fraction", domain="current collector", ) eps_solid = pybamm.PrimaryBroadcast(eps_solid_xav, f"{domain} electrode") else: eps_solid = pybamm.Variable( - f"{Domain} electrode active material volume fraction", + f"{Domain} electrode {phase}active material volume fraction", domain=f"{domain} electrode", auxiliary_domains={"secondary": "current collector"}, ) variables = self._get_standard_active_material_variables(eps_solid) lli_due_to_lam = pybamm.Variable( - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase}active material " f"in {domain} electrode [mol]" ) + variables.update( { - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase}active material " f"in {domain} electrode [mol]": lli_due_to_lam } ) @@ -58,6 +60,7 @@ def get_fundamental_variables(self): def get_coupled_variables(self, variables): domain, Domain = self.domain_Domain + phase_name = self.phase_name deps_solid_dt = 0 lam_option = getattr(getattr(self.options, domain), self.phase)[ @@ -68,22 +71,22 @@ def get_coupled_variables(self, variables): # This is loss of active material model by mechanical effects if self.x_average is True: stress_t_surf = variables[ - f"X-averaged {domain} particle surface tangential stress [Pa]" + f"X-averaged {domain} {phase_name}particle surface tangential stress [Pa]" ] stress_r_surf = variables[ - f"X-averaged {domain} particle surface radial stress [Pa]" + f"X-averaged {domain} {phase_name}particle surface radial stress [Pa]" ] else: stress_t_surf = variables[ - f"{Domain} particle surface tangential stress [Pa]" + f"{Domain} {phase_name}particle surface tangential stress [Pa]" ] stress_r_surf = variables[ - f"{Domain} particle surface radial stress [Pa]" + f"{Domain} {phase_name}particle surface radial stress [Pa]" ] - beta_LAM = self.domain_param.beta_LAM - stress_critical = self.domain_param.stress_critical - m_LAM = self.domain_param.m_LAM + beta_LAM = self.phase_param.beta_LAM + stress_critical = self.phase_param.stress_critical + m_LAM = self.phase_param.m_LAM stress_h_surf = (stress_r_surf + 2 * stress_t_surf) / 3 # compressive stress make no contribution @@ -97,15 +100,15 @@ def get_coupled_variables(self, variables): deps_solid_dt += j_stress_LAM if "reaction" in lam_option: - beta_LAM_sei = self.domain_param.beta_LAM_sei + beta_LAM_sei = self.phase_param.beta_LAM_sei if self.x_average is True: a_j_sei = variables[ - f"X-averaged {domain} electrode SEI " + f"X-averaged {domain} electrode {phase_name}SEI " "volumetric interfacial current density [A.m-3]" ] else: a_j_sei = variables[ - f"{Domain} electrode SEI volumetric " + f"{Domain} electrode {phase_name}SEI volumetric " "interfacial current density [A.m-3]" ] @@ -131,19 +134,22 @@ def get_coupled_variables(self, variables): def set_rhs(self, variables): domain, Domain = self.domain_Domain + phase_name = self.phase_name if self.x_average is True: eps_solid = variables[ - f"X-averaged {domain} electrode active material volume fraction" + f"X-averaged {domain} electrode {phase_name}active material volume fraction" ] deps_solid_dt = variables[ - f"X-averaged {domain} electrode active material " + f"X-averaged {domain} electrode {phase_name}active material " "volume fraction change [s-1]" ] else: - eps_solid = variables[f"{Domain} electrode active material volume fraction"] + eps_solid = variables[ + f"{Domain} electrode {phase_name}active material volume fraction" + ] deps_solid_dt = variables[ - f"{Domain} electrode active material volume fraction change [s-1]" + f"{Domain} electrode {phase_name}active material volume fraction change [s-1]" ] # Loss of lithium due to loss of active material @@ -151,11 +157,13 @@ def set_rhs(self, variables): # simulations using adaptive inter-cycle extrapolation algorithm." # Journal of The Electrochemical Society 168.12 (2021): 120531. lli_due_to_lam = variables[ - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase_name}active material " f"in {domain} electrode [mol]" ] # Multiply by mol.m-3 * m3 to get mol - c_s_av = variables[f"Average {domain} particle concentration [mol.m-3]"] + c_s_av = variables[ + f"Average {domain} {phase_name}particle concentration [mol.m-3]" + ] V = self.domain_param.L * self.param.A_cc self.rhs = { @@ -166,20 +174,23 @@ def set_rhs(self, variables): def set_initial_conditions(self, variables): domain, Domain = self.domain_Domain + phase_name = self.phase_name - eps_solid_init = self.domain_param.prim.epsilon_s + eps_solid_init = self.phase_param.epsilon_s if self.x_average is True: eps_solid_xav = variables[ - f"X-averaged {domain} electrode active material volume fraction" + f"X-averaged {domain} electrode {phase_name}active material volume fraction" ] self.initial_conditions = {eps_solid_xav: pybamm.x_average(eps_solid_init)} else: - eps_solid = variables[f"{Domain} electrode active material volume fraction"] + eps_solid = variables[ + f"{Domain} electrode {phase_name}active material volume fraction" + ] self.initial_conditions = {eps_solid: eps_solid_init} lli_due_to_lam = variables[ - "Loss of lithium due to loss of active material " + f"Loss of lithium due to loss of {phase_name}active material " f"in {domain} electrode [mol]" ] self.initial_conditions[lli_due_to_lam] = pybamm.Scalar(0) diff --git a/src/pybamm/models/submodels/active_material/total_active_material.py b/src/pybamm/models/submodels/active_material/total_active_material.py index 5e1d7e2f92..f86486ff53 100644 --- a/src/pybamm/models/submodels/active_material/total_active_material.py +++ b/src/pybamm/models/submodels/active_material/total_active_material.py @@ -34,6 +34,7 @@ def get_coupled_variables(self, variables): f"{Domain} electrode {{}}active material volume fraction change [s-1]", f"X-averaged {domain} electrode {{}}active material " "volume fraction change [s-1]", + f"Loss of lithium due to loss of {{}}active material in {domain} electrode [mol]", ]: sumvar = sum( variables[variable_template.format(phase + " ")] for phase in phases diff --git a/src/pybamm/models/submodels/base_submodel.py b/src/pybamm/models/submodels/base_submodel.py index e120691edd..6b83d1f292 100644 --- a/src/pybamm/models/submodels/base_submodel.py +++ b/src/pybamm/models/submodels/base_submodel.py @@ -28,14 +28,28 @@ class BaseSubModel(pybamm.BaseModel): Attributes ---------- - param: parameter class - The model parameter symbols - boundary_conditions: dict - A dictionary that maps expressions (variables) to expressions that represent - the boundary conditions - variables: dict - A dictionary that maps strings to expressions that represent - the useful variables + param : parameter class + The model parameter symbols. + domain : str + The domain of the submodel, could be either 'Negative', 'Positive', 'Separator', or None. + name : str + The name of the submodel. + external : bool + A boolean flag indicating whether the variables defined by the submodel will be + provided externally by the user. Set to False by default. + options : dict or pybamm.BatteryModelOptions + A dictionary or an instance of `pybamm.BatteryModelOptions` that stores configuration + options for the submodel. + phase_name : str + A string representing the phase of the submodel, which could be "primary", + "secondary", or an empty string if there is only one phase. + phase : str or None + The current phase of the submodel, which could be "primary", "secondary", or None. + boundary_conditions : dict + A dictionary mapping variables to their respective boundary conditions. + variables : dict + A dictionary mapping variable names (strings) to expressions or objects that + represent the useful variables for the submodel. """ def __init__( @@ -112,6 +126,7 @@ def domain(self, domain): @property def domain_Domain(self): + """Returns a tuple containing the current domain and its capitalized form.""" return self._domain, self._Domain def get_parameter_info(self, by_submodel=False): @@ -221,7 +236,7 @@ def set_initial_conditions(self, variables): """ pass - def set_events(self, variables): + def add_events_from(self, variables): """ A method to set events related to the state of submodel variable. Note: this method modifies the state of self.events. Unless overwritten by a submodel, the diff --git a/src/pybamm/models/submodels/convection/through_cell/explicit_convection.py b/src/pybamm/models/submodels/convection/through_cell/explicit_convection.py index 33b58e2b23..c0423bfc41 100644 --- a/src/pybamm/models/submodels/convection/through_cell/explicit_convection.py +++ b/src/pybamm/models/submodels/convection/through_cell/explicit_convection.py @@ -19,7 +19,6 @@ def __init__(self, param): def get_coupled_variables(self, variables): # Set up - param = self.param p_s = variables["X-averaged separator pressure [Pa]"] for domain in self.options.whole_cell_domains: if domain == "separator": @@ -29,22 +28,30 @@ def get_coupled_variables(self, variables): ] if domain == "negative electrode": x_n = pybamm.standard_spatial_vars.x_n - DeltaV_k = param.n.DeltaV + DeltaV_k = self.param.n.DeltaV p_k = ( - -DeltaV_k * a_j_k_av / param.F * (-(x_n**2) + param.n.L**2) / 2 + -DeltaV_k + * a_j_k_av + / self.param.F + * (-(x_n**2) + self.param.n.L**2) + / 2 + p_s ) - v_box_k = -DeltaV_k * a_j_k_av / param.F * x_n + v_box_k = -DeltaV_k * a_j_k_av / self.param.F * x_n elif domain == "positive electrode": x_p = pybamm.standard_spatial_vars.x_p - DeltaV_k = param.p.DeltaV + DeltaV_k = self.param.p.DeltaV p_k = ( - -DeltaV_k * a_j_k_av / param.F * ((x_p - 1) ** 2 - param.p.L**2) / 2 + -DeltaV_k + * a_j_k_av + / self.param.F + * ((x_p - 1) ** 2 - self.param.p.L**2) + / 2 + p_s ) - v_box_k = -DeltaV_k * a_j_k_av / param.F * (x_p - param.L_x) + v_box_k = -DeltaV_k * a_j_k_av / self.param.F * (x_p - self.param.L_x) div_v_box_k = pybamm.PrimaryBroadcast( - -DeltaV_k * a_j_k_av / param.F, domain + -DeltaV_k * a_j_k_av / self.param.F, domain ) variables.update( @@ -58,13 +65,13 @@ def get_coupled_variables(self, variables): "X-averaged separator transverse volume-averaged acceleration [m.s-2]" ] i_boundary_cc = variables["Current collector current density [A.m-2]"] - v_box_n_right = -param.n.DeltaV * i_boundary_cc / param.F + v_box_n_right = -self.param.n.DeltaV * i_boundary_cc / self.param.F div_v_box_s_av = -div_Vbox_s div_v_box_s = pybamm.PrimaryBroadcast(div_v_box_s_av, "separator") # Simple formula for velocity in the separator x_s = pybamm.standard_spatial_vars.x_s - v_box_s = div_v_box_s_av * (x_s - param.n.L) + v_box_n_right + v_box_s = div_v_box_s_av * (x_s - self.param.n.L) + v_box_n_right variables.update( self._get_standard_sep_velocity_variables(v_box_s, div_v_box_s) diff --git a/src/pybamm/models/submodels/convection/through_cell/full_convection.py b/src/pybamm/models/submodels/convection/through_cell/full_convection.py index 0fdc089de7..07241bb236 100644 --- a/src/pybamm/models/submodels/convection/through_cell/full_convection.py +++ b/src/pybamm/models/submodels/convection/through_cell/full_convection.py @@ -43,8 +43,7 @@ def get_fundamental_variables(self): def get_coupled_variables(self, variables): # Set up - param = self.param - L_n = param.n.L + L_n = self.param.n.L x_s = pybamm.standard_spatial_vars.x_s # Transverse velocity in the separator determines through-cell velocity @@ -52,7 +51,7 @@ def get_coupled_variables(self, variables): "X-averaged separator transverse volume-averaged acceleration [m.s-2]" ] i_boundary_cc = variables["Current collector current density [A.m-2]"] - v_box_n_right = -param.n.DeltaV * i_boundary_cc / self.param.F + v_box_n_right = -self.param.n.DeltaV * i_boundary_cc / self.param.F div_v_box_s_av = -div_Vbox_s div_v_box_s = pybamm.PrimaryBroadcast(div_v_box_s_av, "separator") diff --git a/src/pybamm/models/submodels/convection/transverse/full_convection.py b/src/pybamm/models/submodels/convection/transverse/full_convection.py index 16da47ae47..0a6367fec1 100644 --- a/src/pybamm/models/submodels/convection/transverse/full_convection.py +++ b/src/pybamm/models/submodels/convection/transverse/full_convection.py @@ -37,15 +37,13 @@ def get_fundamental_variables(self): return variables def set_algebraic(self, variables): - param = self.param - p_s = variables["X-averaged separator pressure [Pa]"] # Difference in negative and positive electrode velocities determines the # velocity in the separator i_boundary_cc = variables["Current collector current density [A.m-2]"] - v_box_n_right = -param.n.DeltaV * i_boundary_cc / self.param.F - v_box_p_left = -param.p.DeltaV * i_boundary_cc / self.param.F - d_vbox_s_dx = (v_box_p_left - v_box_n_right) / param.s.L + v_box_n_right = -self.param.n.DeltaV * i_boundary_cc / self.param.F + v_box_p_left = -self.param.p.DeltaV * i_boundary_cc / self.param.F + d_vbox_s_dx = (v_box_p_left - v_box_n_right) / self.param.s.L # Simple formula for velocity in the separator div_Vbox_s = -d_vbox_s_dx diff --git a/src/pybamm/models/submodels/convection/transverse/uniform_convection.py b/src/pybamm/models/submodels/convection/transverse/uniform_convection.py index 15a073c148..a4b05f1ad5 100644 --- a/src/pybamm/models/submodels/convection/transverse/uniform_convection.py +++ b/src/pybamm/models/submodels/convection/transverse/uniform_convection.py @@ -26,15 +26,14 @@ def get_fundamental_variables(self): def get_coupled_variables(self, variables): # Set up - param = self.param z = pybamm.standard_spatial_vars.z # Difference in negative and positive electrode velocities determines the # velocity in the separator i_boundary_cc = variables["Current collector current density [A.m-2]"] - v_box_n_right = -param.n.DeltaV * i_boundary_cc / param.F - v_box_p_left = -param.p.DeltaV * i_boundary_cc / param.F - d_vbox_s_dx = (v_box_p_left - v_box_n_right) / param.s.L + v_box_n_right = -self.param.n.DeltaV * i_boundary_cc / self.param.F + v_box_p_left = -self.param.p.DeltaV * i_boundary_cc / self.param.F + d_vbox_s_dx = (v_box_p_left - v_box_n_right) / self.param.s.L # Simple formula for velocity in the separator div_Vbox_s = -d_vbox_s_dx diff --git a/src/pybamm/models/submodels/current_collector/effective_resistance_current_collector.py b/src/pybamm/models/submodels/current_collector/effective_resistance_current_collector.py index 23001b9d02..808e0a34a3 100644 --- a/src/pybamm/models/submodels/current_collector/effective_resistance_current_collector.py +++ b/src/pybamm/models/submodels/current_collector/effective_resistance_current_collector.py @@ -12,29 +12,28 @@ def default_parameter_values(self): @property def default_geometry(self): geometry = {} - param = self.param if self.options["dimensionality"] == 1: geometry["current collector"] = { - "z": {"min": 0, "max": param.L_z}, + "z": {"min": 0, "max": self.param.L_z}, "tabs": { - "negative": {"z_centre": param.n.centre_z_tab}, - "positive": {"z_centre": param.p.centre_z_tab}, + "negative": {"z_centre": self.param.n.centre_z_tab}, + "positive": {"z_centre": self.param.p.centre_z_tab}, }, } elif self.options["dimensionality"] == 2: geometry["current collector"] = { - "y": {"min": 0, "max": param.L_y}, - "z": {"min": 0, "max": param.L_z}, + "y": {"min": 0, "max": self.param.L_y}, + "z": {"min": 0, "max": self.param.L_z}, "tabs": { "negative": { - "y_centre": param.n.centre_y_tab, - "z_centre": param.n.centre_z_tab, - "width": param.n.L_tab, + "y_centre": self.param.n.centre_y_tab, + "z_centre": self.param.n.centre_z_tab, + "width": self.param.n.L_tab, }, "positive": { - "y_centre": param.p.centre_y_tab, - "z_centre": param.p.centre_z_tab, - "width": param.p.L_tab, + "y_centre": self.param.p.centre_y_tab, + "z_centre": self.param.p.centre_z_tab, + "width": self.param.p.L_tab, }, }, } @@ -131,11 +130,10 @@ def __init__( def get_fundamental_variables(self): # Get necessary parameters - param = self.param - L_cn = param.n.L_cc - L_cp = param.p.L_cc - sigma_cn = param.n.sigma_cc - sigma_cp = param.p.sigma_cc + L_cn = self.param.n.L_cc + L_cp = self.param.p.L_cc + sigma_cn = self.param.n.sigma_cc + sigma_cp = self.param.p.sigma_cc # Set model variables: Note: we solve using a scaled version that is # better conditioned @@ -273,13 +271,12 @@ def __init__(self): self.param = pybamm.LithiumIonParameters() # Get necessary parameters - param = self.param - L_cn = param.n.L_cc - L_cp = param.p.L_cc - L_tab_p = param.p.L_tab + L_cn = self.param.n.L_cc + L_cp = self.param.p.L_cc + L_tab_p = self.param.p.L_tab A_tab_p = L_cp * L_tab_p - sigma_cn = param.n.sigma_cc - sigma_cp = param.p.sigma_cc + sigma_cn = self.param.n.sigma_cc + sigma_cp = self.param.p.sigma_cc # Set model variables -- we solve a auxilliary problem in each current collector # then relate this to the potentials and resistances later @@ -347,11 +344,10 @@ def post_process(self, solution, param_values, V_av, I_av): processed potentials. """ # Get evaluated parameters - param = self.param - L_cn = param_values.evaluate(param.n.L_cc) - L_cp = param_values.evaluate(param.p.L_cc) - sigma_cn = param_values.evaluate(param.n.sigma_cc) - sigma_cp = param_values.evaluate(param.p.sigma_cc) + L_cn = param_values.evaluate(self.param.n.L_cc) + L_cp = param_values.evaluate(self.param.p.L_cc) + sigma_cn = param_values.evaluate(self.param.n.sigma_cc) + sigma_cp = param_values.evaluate(self.param.p.sigma_cc) # Process unit solutions f_n = solution["Unit solution in negative current collector"] diff --git a/src/pybamm/models/submodels/current_collector/potential_pair.py b/src/pybamm/models/submodels/current_collector/potential_pair.py index 68a9066da3..c2a197ae64 100644 --- a/src/pybamm/models/submodels/current_collector/potential_pair.py +++ b/src/pybamm/models/submodels/current_collector/potential_pair.py @@ -23,7 +23,6 @@ def __init__(self, param): pybamm.citations.register("Timms2021") def get_fundamental_variables(self): - param = self.param phi_s_cn = pybamm.Variable( "Negative current collector potential [V]", domain="current collector" ) @@ -35,7 +34,7 @@ def get_fundamental_variables(self): i_boundary_cc = pybamm.Variable( "Current collector current density [A.m-2]", domain="current collector", - scale=param.Q / (param.A_cc * param.n_electrodes_parallel), + scale=self.param.Q / (self.param.A_cc * self.param.n_electrodes_parallel), ) variables.update(self._get_standard_current_variables(i_cc, i_boundary_cc)) @@ -43,16 +42,15 @@ def get_fundamental_variables(self): return variables def set_algebraic(self, variables): - param = self.param - phi_s_cn = variables["Negative current collector potential [V]"] phi_s_cp = variables["Positive current collector potential [V]"] i_boundary_cc = variables["Current collector current density [A.m-2]"] self.algebraic = { - phi_s_cn: (param.n.sigma_cc * param.n.L_cc) * pybamm.laplacian(phi_s_cn) + phi_s_cn: (self.param.n.sigma_cc * self.param.n.L_cc) + * pybamm.laplacian(phi_s_cn) - pybamm.source(i_boundary_cc, phi_s_cn), - i_boundary_cc: (param.p.sigma_cc * param.p.L_cc) + i_boundary_cc: (self.param.p.sigma_cc * self.param.p.L_cc) * pybamm.laplacian(phi_s_cp) + pybamm.source(i_boundary_cc, phi_s_cp), } @@ -77,15 +75,14 @@ def set_boundary_conditions(self, variables): phi_s_cn = variables["Negative current collector potential [V]"] phi_s_cp = variables["Positive current collector potential [V]"] - param = self.param applied_current_density = variables["Total current density [A.m-2]"] - total_current = applied_current_density * param.A_cc + total_current = applied_current_density * self.param.A_cc # In the 1+1D model, the behaviour is averaged over the y-direction, so the # effective tab area is the cell width multiplied by the current collector # thickness - positive_tab_area = param.L_y * param.p.L_cc - pos_tab_bc = -total_current / (param.p.sigma_cc * positive_tab_area) + positive_tab_area = self.param.L_y * self.param.p.L_cc + pos_tab_bc = -total_current / (self.param.p.sigma_cc * positive_tab_area) # Boundary condition needs to be on the variables that go into the Laplacian, # even though phi_s_cp isn't a pybamm.Variable object @@ -111,20 +108,19 @@ def set_boundary_conditions(self, variables): phi_s_cn = variables["Negative current collector potential [V]"] phi_s_cp = variables["Positive current collector potential [V]"] - param = self.param applied_current_density = variables["Total current density [A.m-2]"] - total_current = applied_current_density * param.A_cc + total_current = applied_current_density * self.param.A_cc # Note: we divide by the *numerical* tab area so that the correct total # current is applied. That is, numerically integrating the current density # around the boundary gives the applied current exactly. positive_tab_area = pybamm.BoundaryIntegral( - pybamm.PrimaryBroadcast(param.p.L_cc, "current collector"), + pybamm.PrimaryBroadcast(self.param.p.L_cc, "current collector"), region="positive tab", ) # cc_area appears here due to choice of non-dimensionalisation - pos_tab_bc = -total_current / (param.p.sigma_cc * positive_tab_area) + pos_tab_bc = -total_current / (self.param.p.sigma_cc * positive_tab_area) # Boundary condition needs to be on the variables that go into the Laplacian, # even though phi_s_cp isn't a pybamm.Variable object diff --git a/src/pybamm/models/submodels/electrode/base_electrode.py b/src/pybamm/models/submodels/electrode/base_electrode.py index 4248131a75..2b37ceb0d3 100644 --- a/src/pybamm/models/submodels/electrode/base_electrode.py +++ b/src/pybamm/models/submodels/electrode/base_electrode.py @@ -119,7 +119,7 @@ def _get_standard_current_collector_potential_variables( V_cc = phi_s_cp - phi_s_cn # Voltage - # Note phi_s_cn is always zero at the negative tab + # Note phi_s_cn is always zero at the negative tab by definition V = pybamm.boundary_value(phi_s_cp, "positive tab") # Voltage is local current collector potential difference at the tabs, in 1D @@ -128,10 +128,12 @@ def _get_standard_current_collector_potential_variables( "Negative current collector potential [V]": phi_s_cn, "Positive current collector potential [V]": phi_s_cp, "Local voltage [V]": V_cc, + "Voltage expression [V]": V - delta_phi_contact, "Terminal voltage [V]": V - delta_phi_contact, - "Voltage [V]": V - delta_phi_contact, "Contact overpotential [V]": delta_phi_contact, } + if self.options["voltage as a state"] == "false": + variables.update({"Voltage [V]": V - delta_phi_contact}) return variables @@ -170,9 +172,8 @@ def _get_standard_whole_cell_variables(self, variables): phi_s_p = variables["Positive electrode potential [V]"] phi_s_cp = pybamm.boundary_value(phi_s_p, "right") if self.options["contact resistance"] == "true": - param = self.param I = variables["Current [A]"] - delta_phi_contact = I * param.R_contact + delta_phi_contact = I * self.param.R_contact else: delta_phi_contact = pybamm.Scalar(0) variables.update( diff --git a/src/pybamm/models/submodels/electrode/ohm/composite_ohm.py b/src/pybamm/models/submodels/electrode/ohm/composite_ohm.py index 4845ea9fb2..7c4d8d62b8 100644 --- a/src/pybamm/models/submodels/electrode/ohm/composite_ohm.py +++ b/src/pybamm/models/submodels/electrode/ohm/composite_ohm.py @@ -26,14 +26,13 @@ def __init__(self, param, domain, options=None): def get_coupled_variables(self, variables): domain = self.domain - param = self.param i_boundary_cc = variables["Current collector current density [A.m-2]"] # import parameters and spatial variables - L_n = param.n.L - L_p = param.p.L - L_x = param.L_x + L_n = self.param.n.L + L_p = self.param.p.L + L_x = self.param.L_x x_n = pybamm.standard_spatial_vars.x_n x_p = pybamm.standard_spatial_vars.x_p diff --git a/src/pybamm/models/submodels/electrode/ohm/leading_ohm.py b/src/pybamm/models/submodels/electrode/ohm/leading_ohm.py index 7e414f94c9..8385a31fc1 100644 --- a/src/pybamm/models/submodels/electrode/ohm/leading_ohm.py +++ b/src/pybamm/models/submodels/electrode/ohm/leading_ohm.py @@ -35,15 +35,14 @@ def get_coupled_variables(self, variables): """ Returns variables which are derived from the fundamental variables in the model. """ - param = self.param i_boundary_cc = variables["Current collector current density [A.m-2]"] phi_s_cn = variables["Negative current collector potential [V]"] # import parameters and spatial variables - L_n = param.n.L - L_p = param.p.L - L_x = param.L_x + L_n = self.param.n.L + L_p = self.param.p.L + L_x = self.param.L_x x_n = pybamm.standard_spatial_vars.x_n x_p = pybamm.standard_spatial_vars.x_p diff --git a/src/pybamm/models/submodels/electrolyte_conductivity/base_electrolyte_conductivity.py b/src/pybamm/models/submodels/electrolyte_conductivity/base_electrolyte_conductivity.py index d1178c8cc2..5a7d3163c2 100644 --- a/src/pybamm/models/submodels/electrolyte_conductivity/base_electrolyte_conductivity.py +++ b/src/pybamm/models/submodels/electrolyte_conductivity/base_electrolyte_conductivity.py @@ -217,7 +217,6 @@ def _get_electrolyte_overpotentials(self, variables): The variables including the whole-cell electrolyte potentials and currents. """ - param = self.param if self.options.electrode_types["negative"] == "planar": # No concentration overpotential in the counter electrode @@ -229,7 +228,7 @@ def _get_electrolyte_overpotentials(self, variables): c_e_n = variables["Negative electrolyte concentration [mol.m-3]"] T_n = variables["Negative electrode temperature [K]"] indef_integral_n = pybamm.IndefiniteIntegral( - param.chiRT_over_Fc(c_e_n, T_n) * pybamm.grad(c_e_n), + self.param.chiRT_over_Fc(c_e_n, T_n) * pybamm.grad(c_e_n), pybamm.standard_spatial_vars.x_n, ) @@ -243,11 +242,11 @@ def _get_electrolyte_overpotentials(self, variables): # concentration overpotential indef_integral_s = pybamm.IndefiniteIntegral( - param.chiRT_over_Fc(c_e_s, T_s) * pybamm.grad(c_e_s), + self.param.chiRT_over_Fc(c_e_s, T_s) * pybamm.grad(c_e_s), pybamm.standard_spatial_vars.x_s, ) indef_integral_p = pybamm.IndefiniteIntegral( - param.chiRT_over_Fc(c_e_p, T_p) * pybamm.grad(c_e_p), + self.param.chiRT_over_Fc(c_e_p, T_p) * pybamm.grad(c_e_p), pybamm.standard_spatial_vars.x_p, ) diff --git a/src/pybamm/models/submodels/electrolyte_conductivity/composite_conductivity.py b/src/pybamm/models/submodels/electrolyte_conductivity/composite_conductivity.py index 475d1a4232..d6c7ea6473 100644 --- a/src/pybamm/models/submodels/electrolyte_conductivity/composite_conductivity.py +++ b/src/pybamm/models/submodels/electrolyte_conductivity/composite_conductivity.py @@ -52,23 +52,22 @@ def get_coupled_variables(self, variables): T_av_s = pybamm.PrimaryBroadcast(T_av, "separator") T_av_p = pybamm.PrimaryBroadcast(T_av, "positive electrode") - param = self.param - RT_F_av = param.R * T_av / param.F - RT_F_av_s = param.R * T_av_s / param.F - RT_F_av_p = param.R * T_av_p / param.F - - L_n = param.n.L - L_s = param.s.L - L_p = param.p.L - L_x = param.L_x + RT_F_av = self.param.R * T_av / self.param.F + RT_F_av_s = self.param.R * T_av_s / self.param.F + RT_F_av_p = self.param.R * T_av_p / self.param.F + + L_n = self.param.n.L + L_s = self.param.s.L + L_p = self.param.p.L + L_x = self.param.L_x x_s = pybamm.standard_spatial_vars.x_s x_p = pybamm.standard_spatial_vars.x_p # bulk conductivities - kappa_s_av = param.kappa_e(c_e_av, T_av) * tor_s_av - kappa_p_av = param.kappa_e(c_e_av, T_av) * tor_p_av + kappa_s_av = self.param.kappa_e(c_e_av, T_av) * tor_s_av + kappa_p_av = self.param.kappa_e(c_e_av, T_av) * tor_p_av - chi_av = param.chi(c_e_av, T_av) + chi_av = self.param.chi(c_e_av, T_av) chi_av_s = pybamm.PrimaryBroadcast(chi_av, "separator") chi_av_p = pybamm.PrimaryBroadcast(chi_av, "positive electrode") @@ -79,8 +78,8 @@ def get_coupled_variables(self, variables): x_n = pybamm.standard_spatial_vars.x_n chi_av_n = pybamm.PrimaryBroadcast(chi_av, "negative electrode") T_av_n = pybamm.PrimaryBroadcast(T_av, "negative electrode") - RT_F_av_n = param.R * T_av_n / param.F - kappa_n_av = param.kappa_e(c_e_av, T_av) * tor_n_av + RT_F_av_n = self.param.R * T_av_n / self.param.F + kappa_n_av = self.param.kappa_e(c_e_av, T_av) * tor_n_av i_e_n = i_boundary_cc * x_n / L_n i_e_s = pybamm.PrimaryBroadcast(i_boundary_cc, "separator") i_e_p = i_boundary_cc * (L_x - x_p) / L_p diff --git a/src/pybamm/models/submodels/electrolyte_conductivity/full_conductivity.py b/src/pybamm/models/submodels/electrolyte_conductivity/full_conductivity.py index 5acb7d2434..a688209441 100644 --- a/src/pybamm/models/submodels/electrolyte_conductivity/full_conductivity.py +++ b/src/pybamm/models/submodels/electrolyte_conductivity/full_conductivity.py @@ -46,14 +46,13 @@ def get_fundamental_variables(self): return variables def get_coupled_variables(self, variables): - param = self.param T = variables["Cell temperature [K]"] tor = variables["Electrolyte transport efficiency"] c_e = variables["Electrolyte concentration [mol.m-3]"] phi_e = variables["Electrolyte potential [V]"] - i_e = (param.kappa_e(c_e, T) * tor) * ( - param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) + i_e = (self.param.kappa_e(c_e, T) * tor) * ( + self.param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) - pybamm.grad(phi_e) ) # Override print_name diff --git a/src/pybamm/models/submodels/electrolyte_conductivity/integrated_conductivity.py b/src/pybamm/models/submodels/electrolyte_conductivity/integrated_conductivity.py index cb9979c6bb..2250d99f6d 100644 --- a/src/pybamm/models/submodels/electrolyte_conductivity/integrated_conductivity.py +++ b/src/pybamm/models/submodels/electrolyte_conductivity/integrated_conductivity.py @@ -32,7 +32,6 @@ def _higher_order_macinnes_function(self, x): return pybamm.log(x) def get_coupled_variables(self, variables): - param = self.param c_e_av = variables["X-averaged electrolyte concentration [mol.m-3]"] i_boundary_cc = variables["Current collector current density [A.m-2]"] @@ -55,22 +54,21 @@ def get_coupled_variables(self, variables): T_av_s = pybamm.PrimaryBroadcast(T_av, "separator") T_av_p = pybamm.PrimaryBroadcast(T_av, "positive electrode") - RT_F_av = param.R * T_av / param.F - RT_F_av_n = param.R * T_av_n / param.F - RT_F_av_s = param.R * T_av_s / param.F - RT_F_av_p = param.R * T_av_p / param.F + RT_F_av = self.param.R * T_av / self.param.F + RT_F_av_n = self.param.R * T_av_n / self.param.F + RT_F_av_s = self.param.R * T_av_s / self.param.F + RT_F_av_p = self.param.R * T_av_p / self.param.F - param = self.param - L_n = param.n.L - L_p = param.p.L - L_x = param.L_x + L_n = self.param.n.L + L_p = self.param.p.L + L_x = self.param.L_x x_n = pybamm.standard_spatial_vars.x_n x_s = pybamm.standard_spatial_vars.x_s x_p = pybamm.standard_spatial_vars.x_p x_n_edge = pybamm.standard_spatial_vars.x_n_edge x_p_edge = pybamm.standard_spatial_vars.x_p_edge - chi_av = param.chi(c_e_av, T_av) + chi_av = self.param.chi(c_e_av, T_av) chi_av_n = pybamm.PrimaryBroadcast(chi_av, "negative electrode") chi_av_s = pybamm.PrimaryBroadcast(chi_av, "separator") chi_av_p = pybamm.PrimaryBroadcast(chi_av, "positive electrode") @@ -87,13 +85,13 @@ def get_coupled_variables(self, variables): # electrolyte potential indef_integral_n = pybamm.IndefiniteIntegral( - i_e_n_edge / (param.kappa_e(c_e_n, T_av_n) * tor_n), x_n + i_e_n_edge / (self.param.kappa_e(c_e_n, T_av_n) * tor_n), x_n ) indef_integral_s = pybamm.IndefiniteIntegral( - i_e_s_edge / (param.kappa_e(c_e_s, T_av_s) * tor_s), x_s + i_e_s_edge / (self.param.kappa_e(c_e_s, T_av_s) * tor_s), x_s ) indef_integral_p = pybamm.IndefiniteIntegral( - i_e_p_edge / (param.kappa_e(c_e_p, T_av_p) * tor_p), x_p + i_e_p_edge / (self.param.kappa_e(c_e_p, T_av_p) * tor_p), x_p ) integral_n = indef_integral_n diff --git a/src/pybamm/models/submodels/electrolyte_conductivity/leading_order_conductivity.py b/src/pybamm/models/submodels/electrolyte_conductivity/leading_order_conductivity.py index ad2a5b6486..42c7770c54 100644 --- a/src/pybamm/models/submodels/electrolyte_conductivity/leading_order_conductivity.py +++ b/src/pybamm/models/submodels/electrolyte_conductivity/leading_order_conductivity.py @@ -36,10 +36,9 @@ def get_coupled_variables(self, variables): i_boundary_cc = variables["Current collector current density [A.m-2]"] - param = self.param - L_n = param.n.L - L_p = param.p.L - L_x = param.L_x + L_n = self.param.n.L + L_p = self.param.p.L + L_x = self.param.L_x x_n = pybamm.standard_spatial_vars.x_n x_p = pybamm.standard_spatial_vars.x_p diff --git a/src/pybamm/models/submodels/electrolyte_conductivity/surface_potential_form/full_surface_form_conductivity.py b/src/pybamm/models/submodels/electrolyte_conductivity/surface_potential_form/full_surface_form_conductivity.py index cceb88f83e..fd32e6a83c 100644 --- a/src/pybamm/models/submodels/electrolyte_conductivity/surface_potential_form/full_surface_form_conductivity.py +++ b/src/pybamm/models/submodels/electrolyte_conductivity/surface_potential_form/full_surface_form_conductivity.py @@ -47,7 +47,6 @@ def get_fundamental_variables(self): def get_coupled_variables(self, variables): Domain = self.domain.capitalize() - param = self.param if self.domain in ["negative", "positive"]: conductivity, sigma_eff = self._get_conductivities(variables) @@ -59,7 +58,7 @@ def get_coupled_variables(self, variables): T = variables[f"{Domain} electrode temperature [K]"] i_e = conductivity * ( - param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) + self.param.chiRT_over_Fc(c_e, T) * pybamm.grad(c_e) + pybamm.grad(delta_phi) + i_boundary_cc / sigma_eff ) @@ -83,8 +82,8 @@ def get_coupled_variables(self, variables): tor_s = variables["Separator electrolyte transport efficiency"] T = variables["Separator temperature [K]"] - chiRT_over_Fc_e_s = param.chiRT_over_Fc(c_e_s, T) - kappa_s_eff = param.kappa_e(c_e_s, T) * tor_s + chiRT_over_Fc_e_s = self.param.chiRT_over_Fc(c_e_s, T) + kappa_s_eff = self.param.kappa_e(c_e_s, T) * tor_s phi_e = phi_e_n_s + pybamm.IndefiniteIntegral( chiRT_over_Fc_e_s * pybamm.grad(c_e_s) - i_boundary_cc / kappa_s_eff, @@ -124,7 +123,8 @@ def get_coupled_variables(self, variables): grad_left = -i_boundary_cc * pybamm.boundary_value(1 / sigma_eff, "left") grad_right = ( (i_boundary_cc / pybamm.boundary_value(conductivity, "right")) - - pybamm.boundary_value(param.chiRT_over_Fc(c_e, T), "right") * grad_c_e + - pybamm.boundary_value(self.param.chiRT_over_Fc(c_e, T), "right") + * grad_c_e - i_boundary_cc * pybamm.boundary_value(1 / sigma_eff, "right") ) @@ -132,7 +132,8 @@ def get_coupled_variables(self, variables): grad_c_e = pybamm.boundary_gradient(c_e, "left") grad_left = ( (i_boundary_cc / pybamm.boundary_value(conductivity, "left")) - - pybamm.boundary_value(param.chiRT_over_Fc(c_e, T), "left") * grad_c_e + - pybamm.boundary_value(self.param.chiRT_over_Fc(c_e, T), "left") + * grad_c_e - i_boundary_cc * pybamm.boundary_value(1 / sigma_eff, "left") ) grad_right = -i_boundary_cc * pybamm.boundary_value(1 / sigma_eff, "right") @@ -150,14 +151,13 @@ def get_coupled_variables(self, variables): def _get_conductivities(self, variables): Domain = self.domain.capitalize() - param = self.param tor_e = variables[f"{Domain} electrolyte transport efficiency"] tor_s = variables[f"{Domain} electrode transport efficiency"] c_e = variables[f"{Domain} electrolyte concentration [mol.m-3]"] T = variables[f"{Domain} electrode temperature [K]"] sigma = self.domain_param.sigma(T) - kappa_eff = param.kappa_e(c_e, T) * tor_e + kappa_eff = self.param.kappa_e(c_e, T) * tor_e sigma_eff = sigma * tor_s conductivity = kappa_eff / (1 + kappa_eff / sigma_eff) diff --git a/src/pybamm/models/submodels/electrolyte_diffusion/constant_concentration.py b/src/pybamm/models/submodels/electrolyte_diffusion/constant_concentration.py index eee441446f..006619e8bb 100644 --- a/src/pybamm/models/submodels/electrolyte_diffusion/constant_concentration.py +++ b/src/pybamm/models/submodels/electrolyte_diffusion/constant_concentration.py @@ -76,6 +76,6 @@ def set_boundary_conditions(self, variables): } } - def set_events(self, variables): + def add_events_from(self, variables): # No event since the concentration is constant pass diff --git a/src/pybamm/models/submodels/electrolyte_diffusion/full_diffusion.py b/src/pybamm/models/submodels/electrolyte_diffusion/full_diffusion.py index 2fdd937966..06f95bc2f1 100644 --- a/src/pybamm/models/submodels/electrolyte_diffusion/full_diffusion.py +++ b/src/pybamm/models/submodels/electrolyte_diffusion/full_diffusion.py @@ -67,10 +67,8 @@ def get_coupled_variables(self, variables): v_box = variables["Volume-averaged velocity [m.s-1]"] T = variables["Cell temperature [K]"] - param = self.param - - N_e_diffusion = -tor * param.D_e(c_e, T) * pybamm.grad(c_e) - N_e_migration = param.t_plus(c_e, T) * i_e / param.F + N_e_diffusion = -tor * self.param.D_e(c_e, T) * pybamm.grad(c_e) + N_e_migration = self.param.t_plus(c_e, T) * i_e / self.param.F N_e_convection = c_e * v_box N_e = N_e_diffusion + N_e_migration + N_e_convection @@ -106,7 +104,6 @@ def set_initial_conditions(self, variables): } def set_boundary_conditions(self, variables): - param = self.param c_e = variables["Electrolyte concentration [mol.m-3]"] c_e_conc = variables["Electrolyte concentration concatenation [mol.m-3]"] T = variables["Cell temperature [K]"] @@ -118,7 +115,8 @@ def flux_bc(side): # assuming v_box = 0 for now return ( pybamm.boundary_value( - -(1 - param.t_plus(c_e, T)) / (tor * param.D_e(c_e, T) * param.F), + -(1 - self.param.t_plus(c_e, T)) + / (tor * self.param.D_e(c_e, T) * self.param.F), side, ) * i_boundary_cc diff --git a/src/pybamm/models/submodels/electrolyte_diffusion/leading_order_diffusion.py b/src/pybamm/models/submodels/electrolyte_diffusion/leading_order_diffusion.py index 8dedc28cf5..104b12e34e 100644 --- a/src/pybamm/models/submodels/electrolyte_diffusion/leading_order_diffusion.py +++ b/src/pybamm/models/submodels/electrolyte_diffusion/leading_order_diffusion.py @@ -52,8 +52,6 @@ def get_coupled_variables(self, variables): return variables def set_rhs(self, variables): - param = self.param - c_e_av = variables["X-averaged electrolyte concentration [mol.m-3]"] T_av = variables["X-averaged cell temperature [K]"] @@ -86,17 +84,24 @@ def set_rhs(self, variables): "reaction source terms [A.m-3]" ] source_terms = ( - param.n.L * (sum_s_j_n_0 - param.t_plus(c_e_av, T_av) * sum_a_j_n_0) - + param.p.L * (sum_s_j_p_0 - param.t_plus(c_e_av, T_av) * sum_a_j_p_0) - ) / param.F + self.param.n.L + * (sum_s_j_n_0 - self.param.t_plus(c_e_av, T_av) * sum_a_j_n_0) + + self.param.p.L + * (sum_s_j_p_0 - self.param.t_plus(c_e_av, T_av) * sum_a_j_p_0) + ) / self.param.F self.rhs = { c_e_av: 1 - / (param.n.L * eps_n_av + param.s.L * eps_s_av + param.p.L * eps_p_av) + / ( + self.param.n.L * eps_n_av + + self.param.s.L * eps_s_av + + self.param.p.L * eps_p_av + ) * ( source_terms - - c_e_av * (param.n.L * deps_n_dt_av + param.p.L * deps_p_dt_av) - - c_e_av * param.s.L * div_Vbox_s_av + - c_e_av + * (self.param.n.L * deps_n_dt_av + self.param.p.L * deps_p_dt_av) + - c_e_av * self.param.s.L * div_Vbox_s_av ) } diff --git a/src/pybamm/models/submodels/equivalent_circuit_elements/diffusion_element.py b/src/pybamm/models/submodels/equivalent_circuit_elements/diffusion_element.py index c7f1b4bcd5..bf9a183875 100644 --- a/src/pybamm/models/submodels/equivalent_circuit_elements/diffusion_element.py +++ b/src/pybamm/models/submodels/equivalent_circuit_elements/diffusion_element.py @@ -102,7 +102,7 @@ def set_initial_conditions(self, variables): z = variables["Distributed SoC"] self.initial_conditions = {z: self.param.initial_soc} - def set_events(self, variables): + def add_events_from(self, variables): z_surf = variables["Surface SoC"] self.events += [ pybamm.Event("Minimum surface SoC", z_surf), diff --git a/src/pybamm/models/submodels/equivalent_circuit_elements/ocv_element.py b/src/pybamm/models/submodels/equivalent_circuit_elements/ocv_element.py index 9d1adf3d57..901b63cf74 100644 --- a/src/pybamm/models/submodels/equivalent_circuit_elements/ocv_element.py +++ b/src/pybamm/models/submodels/equivalent_circuit_elements/ocv_element.py @@ -54,7 +54,7 @@ def set_initial_conditions(self, variables): soc = variables["SoC"] self.initial_conditions = {soc: self.param.initial_soc} - def set_events(self, variables): + def add_events_from(self, variables): soc = variables["SoC"] self.events += [ pybamm.Event("Minimum SoC", soc), diff --git a/src/pybamm/models/submodels/equivalent_circuit_elements/voltage_model.py b/src/pybamm/models/submodels/equivalent_circuit_elements/voltage_model.py index 380902fca5..89f8904f32 100644 --- a/src/pybamm/models/submodels/equivalent_circuit_elements/voltage_model.py +++ b/src/pybamm/models/submodels/equivalent_circuit_elements/voltage_model.py @@ -54,7 +54,7 @@ def x_not_zero(x): return variables - def set_events(self, variables): + def add_events_from(self, variables): voltage = variables["Voltage [V]"] # Add voltage events diff --git a/src/pybamm/models/submodels/external_circuit/explicit_control_external_circuit.py b/src/pybamm/models/submodels/external_circuit/explicit_control_external_circuit.py index 760e9e2b20..6dcd9a4541 100644 --- a/src/pybamm/models/submodels/external_circuit/explicit_control_external_circuit.py +++ b/src/pybamm/models/submodels/external_circuit/explicit_control_external_circuit.py @@ -19,28 +19,40 @@ def get_fundamental_variables(self): "Current [A]": I, "C-rate": I / self.param.Q, } + if self.options.get("voltage as a state") == "true": + V = pybamm.Variable("Voltage [V]") + variables.update({"Voltage [V]": V}) return variables + def set_initial_conditions(self, variables): + if self.options.get("voltage as a state") == "true": + V = variables["Voltage [V]"] + self.initial_conditions[V] = self.param.ocv_init + + def set_algebraic(self, variables): + if self.options.get("voltage as a state") == "true": + V = variables["Voltage [V]"] + V_expression = variables["Voltage expression [V]"] + self.algebraic[V] = V - V_expression + class ExplicitPowerControl(BaseModel): """External circuit with current set explicitly to hit target power.""" def get_coupled_variables(self, variables): - param = self.param - # Current is given as applied power divided by voltage V = variables["Voltage [V]"] P = pybamm.FunctionParameter("Power function [W]", {"Time [s]": pybamm.t}) I = P / V # Update derived variables - i_cell = I / (param.n_electrodes_parallel * param.A_cc) + i_cell = I / (self.param.n_electrodes_parallel * self.param.A_cc) variables = { "Total current density [A.m-2]": i_cell, "Current [A]": I, - "C-rate": I / param.Q, + "C-rate": I / self.param.Q, } return variables @@ -50,8 +62,6 @@ class ExplicitResistanceControl(BaseModel): """External circuit with current set explicitly to hit target resistance.""" def get_coupled_variables(self, variables): - param = self.param - # Current is given as applied voltage divided by resistance V = variables["Voltage [V]"] R = pybamm.FunctionParameter( @@ -60,12 +70,12 @@ def get_coupled_variables(self, variables): I = V / R # Update derived variables - i_cell = I / (param.n_electrodes_parallel * param.A_cc) + i_cell = I / (self.param.n_electrodes_parallel * self.param.A_cc) variables = { "Total current density [A.m-2]": i_cell, "Current [A]": I, - "C-rate": I / param.Q, + "C-rate": I / self.param.Q, } return variables diff --git a/src/pybamm/models/submodels/external_circuit/function_control_external_circuit.py b/src/pybamm/models/submodels/external_circuit/function_control_external_circuit.py index 60d6fb0e40..274d35954a 100644 --- a/src/pybamm/models/submodels/external_circuit/function_control_external_circuit.py +++ b/src/pybamm/models/submodels/external_circuit/function_control_external_circuit.py @@ -29,9 +29,8 @@ def __init__(self, param, external_circuit_function, options, control="algebraic self.control = control def get_fundamental_variables(self): - param = self.param # Current is a variable - i_var = pybamm.Variable("Current variable [A]", scale=param.Q) + i_var = pybamm.Variable("Current variable [A]", scale=self.param.Q) if self.control in ["algebraic", "differential"]: I = i_var elif self.control == "differential with max": @@ -41,14 +40,17 @@ def get_fundamental_variables(self): I = pybamm.maximum(i_var, i_input) # Update derived variables - i_cell = I / (param.n_electrodes_parallel * param.A_cc) + i_cell = I / (self.param.n_electrodes_parallel * self.param.A_cc) variables = { "Current variable [A]": i_var, "Total current density [A.m-2]": i_cell, "Current [A]": I, - "C-rate": I / param.Q, + "C-rate": I / self.param.Q, } + if self.options.get("voltage as a state") == "true": + V = pybamm.Variable("Voltage [V]") + variables.update({"Voltage [V]": V}) return variables @@ -56,6 +58,9 @@ def set_initial_conditions(self, variables): # Initial condition as a guess for consistent initial conditions i_cell = variables["Current variable [A]"] self.initial_conditions[i_cell] = self.param.Q + if self.options.get("voltage as a state") == "true": + V = variables["Voltage [V]"] + self.initial_conditions[V] = self.param.ocv_init def set_rhs(self, variables): # External circuit submodels are always equations on the current @@ -72,6 +77,10 @@ def set_algebraic(self, variables): if self.control == "algebraic": i_cell = variables["Current variable [A]"] self.algebraic[i_cell] = self.external_circuit_function(variables) + if self.options.get("voltage as a state") == "true": + V = variables["Voltage [V]"] + V_expression = variables["Voltage expression [V]"] + self.algebraic[V] = V - V_expression class VoltageFunctionControl(FunctionControl): diff --git a/src/pybamm/models/submodels/interface/base_interface.py b/src/pybamm/models/submodels/interface/base_interface.py index ab9b80eae0..0ad08d5454 100644 --- a/src/pybamm/models/submodels/interface/base_interface.py +++ b/src/pybamm/models/submodels/interface/base_interface.py @@ -61,7 +61,6 @@ def _get_exchange_current_density(self, variables): j0 : :class: `pybamm.Symbol` The exchange current density. """ - param = self.param phase_param = self.phase_param domain, Domain = self.domain_Domain phase_name = self.phase_name @@ -132,8 +131,8 @@ def _get_exchange_current_density(self, variables): elif self.reaction == "lithium metal plating": # compute T on the surface of the anode (interface with separator) T = pybamm.boundary_value(T, "right") - c_Li_metal = 1 / param.V_bar_Li - j0 = param.j0_Li_metal(c_e, c_Li_metal, T) + c_Li_metal = 1 / self.param.V_bar_Li + j0 = self.param.j0_Li_metal(c_e, c_Li_metal, T) elif self.reaction == "lead-acid main": # If variable was broadcast, take only the orphan @@ -150,7 +149,7 @@ def _get_exchange_current_density(self, variables): if self.domain == "negative": j0 = pybamm.Scalar(0) elif self.domain == "positive": - j0 = param.p.prim.j0_Ox(c_e, T) + j0 = self.param.p.prim.j0_Ox(c_e, T) return j0 diff --git a/src/pybamm/models/submodels/interface/interface_utilisation/current_driven_utilisation.py b/src/pybamm/models/submodels/interface/interface_utilisation/current_driven_utilisation.py index bbd9af4fb6..e785204882 100644 --- a/src/pybamm/models/submodels/interface/interface_utilisation/current_driven_utilisation.py +++ b/src/pybamm/models/submodels/interface/interface_utilisation/current_driven_utilisation.py @@ -89,7 +89,7 @@ def set_initial_conditions(self, variables): self.initial_conditions = {u: u_init} - def set_events(self, variables): + def add_events_from(self, variables): domain, Domain = self.domain_Domain if self.reaction_loc == "full electrode": diff --git a/src/pybamm/models/submodels/interface/kinetics/diffusion_limited.py b/src/pybamm/models/submodels/interface/kinetics/diffusion_limited.py index 08c2db2175..19b8dbea97 100644 --- a/src/pybamm/models/submodels/interface/kinetics/diffusion_limited.py +++ b/src/pybamm/models/submodels/interface/kinetics/diffusion_limited.py @@ -68,7 +68,6 @@ def get_coupled_variables(self, variables): return variables def _get_diffusion_limited_current_density(self, variables): - param = self.param if self.domain == "negative": if self.order == "leading": j_p = variables[ @@ -81,10 +80,10 @@ def _get_diffusion_limited_current_density(self, variables): c_ox_s = variables["Separator oxygen concentration [mol.m-3]"] N_ox_neg_sep_interface = ( -pybamm.boundary_value(tor_s, "left") - * param.D_ox + * self.param.D_ox * pybamm.boundary_gradient(c_ox_s, "left") ) - j = -N_ox_neg_sep_interface / -param.s_ox_Ox / param.n.L + j = -N_ox_neg_sep_interface / -self.param.s_ox_Ox / self.param.n.L return j diff --git a/src/pybamm/models/submodels/interface/kinetics/inverse_kinetics/inverse_butler_volmer.py b/src/pybamm/models/submodels/interface/kinetics/inverse_kinetics/inverse_butler_volmer.py index 959cb027c1..b49993afd8 100644 --- a/src/pybamm/models/submodels/interface/kinetics/inverse_kinetics/inverse_butler_volmer.py +++ b/src/pybamm/models/submodels/interface/kinetics/inverse_kinetics/inverse_butler_volmer.py @@ -93,8 +93,9 @@ def get_coupled_variables(self, variables): return variables def _get_overpotential(self, j, j0, ne, T, u): - param = self.param - return (2 * (param.R * T) / param.F / ne) * pybamm.arcsinh(j / (2 * j0 * u)) + return (2 * (self.param.R * T) / self.param.F / ne) * pybamm.arcsinh( + j / (2 * j0 * u) + ) class CurrentForInverseButlerVolmer(BaseInterface): diff --git a/src/pybamm/models/submodels/interface/sei/sei_growth.py b/src/pybamm/models/submodels/interface/sei/sei_growth.py index bed4b04952..2f506323ce 100644 --- a/src/pybamm/models/submodels/interface/sei/sei_growth.py +++ b/src/pybamm/models/submodels/interface/sei/sei_growth.py @@ -80,7 +80,6 @@ def get_fundamental_variables(self): return variables def get_coupled_variables(self, variables): - param = self.param phase_param = self.phase_param domain, Domain = self.domain_Domain SEI_option = getattr(getattr(self.options, domain), self.phase)["SEI"] @@ -118,7 +117,7 @@ def get_coupled_variables(self, variables): R_sei = phase_param.R_sei eta_SEI = delta_phi - phase_param.U_sei - j * L_sei * R_sei # Thermal prefactor for reaction, interstitial and EC models - F_RT = param.F / (param.R * T) + F_RT = self.param.F / (self.param.R * T) # Define alpha_SEI depending on whether it is symmetric or asymmetric. This # applies to "reaction limited" and "EC reaction limited" @@ -138,12 +137,12 @@ def get_coupled_variables(self, variables): elif SEI_option == "interstitial-diffusion limited": # Scott Marquis thesis (eq. 5.96) j_sei = -( - phase_param.D_li * phase_param.c_li_0 * param.F / L_sei_outer + phase_param.D_li * phase_param.c_li_0 * self.param.F / L_sei_outer ) * pybamm.exp(-F_RT * delta_phi) elif SEI_option == "solvent-diffusion limited": # Scott Marquis thesis (eq. 5.91) - j_sei = -phase_param.D_sol * phase_param.c_sol * param.F / L_sei_outer + j_sei = -phase_param.D_sol * phase_param.c_sol * self.param.F / L_sei_outer elif SEI_option.startswith("ec reaction limited"): # we have a linear system for j and c @@ -159,7 +158,7 @@ def get_coupled_variables(self, variables): k_exp = phase_param.k_sei * pybamm.exp(-alpha_SEI * F_RT * eta_SEI) L_over_D = L_sei / phase_param.D_ec c_0 = phase_param.c_ec_0 - j_sei = -param.F * c_0 * k_exp / (1 + L_over_D * k_exp) + j_sei = -self.param.F * c_0 * k_exp / (1 + L_over_D * k_exp) c_ec = c_0 / (1 + L_over_D * k_exp) # Get variables related to the concentration @@ -177,7 +176,9 @@ def get_coupled_variables(self, variables): inner_sei_proportion = phase_param.inner_sei_proportion # All SEI growth mechanisms assumed to have Arrhenius dependence - Arrhenius = pybamm.exp(phase_param.E_sei / param.R * (1 / param.T_ref - 1 / T)) + Arrhenius = pybamm.exp( + phase_param.E_sei / self.param.R * (1 / self.param.T_ref - 1 / T) + ) j_inner = inner_sei_proportion * Arrhenius * j_sei j_outer = (1 - inner_sei_proportion) * Arrhenius * j_sei @@ -192,7 +193,6 @@ def get_coupled_variables(self, variables): def set_rhs(self, variables): phase_param = self.phase_param - param = self.param domain, Domain = self.domain_Domain if self.reaction_loc == "x-average": @@ -249,10 +249,10 @@ def set_rhs(self, variables): # V_bar / a converts from SEI moles to SEI thickness # V_bar * j_sei / (F * z_sei) is the rate of SEI thickness change dLdt_SEI_inner = ( - phase_param.V_bar_inner * j_inner / (param.F * phase_param.z_sei) + phase_param.V_bar_inner * j_inner / (self.param.F * phase_param.z_sei) ) dLdt_SEI_outer = ( - phase_param.V_bar_outer * j_outer / (param.F * phase_param.z_sei) + phase_param.V_bar_outer * j_outer / (self.param.F * phase_param.z_sei) ) # we have to add the spreading rate to account for cracking diff --git a/src/pybamm/models/submodels/oxygen_diffusion/full_oxygen_diffusion.py b/src/pybamm/models/submodels/oxygen_diffusion/full_oxygen_diffusion.py index c69312e342..7ecad6fa4c 100644 --- a/src/pybamm/models/submodels/oxygen_diffusion/full_oxygen_diffusion.py +++ b/src/pybamm/models/submodels/oxygen_diffusion/full_oxygen_diffusion.py @@ -58,9 +58,7 @@ def get_coupled_variables(self, variables): # TODO: allow charge and convection? v_box = pybamm.Scalar(0) - param = self.param - - N_ox_diffusion = -tor * param.D_ox * pybamm.grad(c_ox) + N_ox_diffusion = -tor * self.param.D_ox * pybamm.grad(c_ox) N_ox = N_ox_diffusion + c_ox * v_box # Flux in the negative electrode is zero @@ -73,8 +71,6 @@ def get_coupled_variables(self, variables): return variables def set_rhs(self, variables): - param = self.param - eps_s = variables["Separator porosity"] eps_p = variables["Positive electrode porosity"] eps = pybamm.concatenation(eps_s, eps_p) @@ -93,12 +89,12 @@ def set_rhs(self, variables): ] source_terms = pybamm.concatenation( pybamm.FullBroadcast(0, "separator", "current collector"), - param.s_ox_Ox * a_j_ox, + self.param.s_ox_Ox * a_j_ox, ) self.rhs = { c_ox: (1 / eps) - * (-pybamm.div(N_ox) + source_terms / param.F - c_ox * deps_dt) + * (-pybamm.div(N_ox) + source_terms / self.param.F - c_ox * deps_dt) } def set_boundary_conditions(self, variables): diff --git a/src/pybamm/models/submodels/oxygen_diffusion/leading_oxygen_diffusion.py b/src/pybamm/models/submodels/oxygen_diffusion/leading_oxygen_diffusion.py index 056c7f6715..bdc064c340 100644 --- a/src/pybamm/models/submodels/oxygen_diffusion/leading_oxygen_diffusion.py +++ b/src/pybamm/models/submodels/oxygen_diffusion/leading_oxygen_diffusion.py @@ -41,8 +41,6 @@ def get_coupled_variables(self, variables): return variables def set_rhs(self, variables): - param = self.param - c_ox_av = variables["X-averaged oxygen concentration [mol.m-3]"] eps_n_av = variables["X-averaged negative electrode porosity"] @@ -62,16 +60,21 @@ def set_rhs(self, variables): ] source_terms = ( - param.n.L * param.s_ox_Ox * a_j_ox_n_av - + param.p.L * param.s_ox_Ox * a_j_ox_p_av + self.param.n.L * self.param.s_ox_Ox * a_j_ox_n_av + + self.param.p.L * self.param.s_ox_Ox * a_j_ox_p_av ) self.rhs = { c_ox_av: 1 - / (param.n.L * eps_n_av + param.s.L * eps_s_av + param.p.L * eps_p_av) + / ( + self.param.n.L * eps_n_av + + self.param.s.L * eps_s_av + + self.param.p.L * eps_p_av + ) * ( - source_terms / param.F - - c_ox_av * (param.n.L * deps_n_dt_av + param.p.L * deps_p_dt_av) + source_terms / self.param.F + - c_ox_av + * (self.param.n.L * deps_n_dt_av + self.param.p.L * deps_p_dt_av) ) } diff --git a/src/pybamm/models/submodels/particle/base_particle.py b/src/pybamm/models/submodels/particle/base_particle.py index dab48b5f79..b774e58a0c 100644 --- a/src/pybamm/models/submodels/particle/base_particle.py +++ b/src/pybamm/models/submodels/particle/base_particle.py @@ -28,7 +28,6 @@ def __init__(self, param, domain, options, phase="primary"): self.size_distribution = domain_options["particle size"] == "distribution" def _get_effective_diffusivity(self, c, T, current): - param = self.param domain, Domain = self.domain_Domain domain_param = self.domain_param phase_param = self.phase_param @@ -57,10 +56,10 @@ def _get_effective_diffusivity(self, c, T, current): if stress_option == "true": # Ai2019 eq [12] sto = c / phase_param.c_max - Omega = pybamm.r_average(domain_param.Omega(sto, T)) - E = pybamm.r_average(domain_param.E(sto, T)) - nu = domain_param.nu - theta_M = Omega / (param.R * T) * (2 * Omega * E) / (9 * (1 - nu)) + Omega = pybamm.r_average(phase_param.Omega(sto, T)) + E = pybamm.r_average(phase_param.E(sto, T)) + nu = phase_param.nu + theta_M = Omega / (self.param.R * T) * (2 * Omega * E) / (9 * (1 - nu)) stress_factor = 1 + theta_M * (c - domain_param.c_0) else: stress_factor = 1 diff --git a/src/pybamm/models/submodels/particle/fickian_diffusion.py b/src/pybamm/models/submodels/particle/fickian_diffusion.py index 31c5e6be6c..634e2ce730 100644 --- a/src/pybamm/models/submodels/particle/fickian_diffusion.py +++ b/src/pybamm/models/submodels/particle/fickian_diffusion.py @@ -130,7 +130,6 @@ def get_fundamental_variables(self): def get_coupled_variables(self, variables): domain, Domain = self.domain_Domain phase_name = self.phase_name - param = self.param if self.size_distribution is False: if self.x_average is False: @@ -208,7 +207,7 @@ def get_coupled_variables(self, variables): * pybamm.div(N_s), f"{Domain} {phase_name}particle bc [mol.m-4]": -j * R_nondim - / param.F + / self.param.F / pybamm.surf(D_eff), } ) diff --git a/src/pybamm/models/submodels/particle/msmr_diffusion.py b/src/pybamm/models/submodels/particle/msmr_diffusion.py index fb712dcdef..8967116ce9 100644 --- a/src/pybamm/models/submodels/particle/msmr_diffusion.py +++ b/src/pybamm/models/submodels/particle/msmr_diffusion.py @@ -136,7 +136,6 @@ def get_fundamental_variables(self): def get_coupled_variables(self, variables): domain, Domain = self.domain_Domain phase_name = self.phase_name - param = self.param if self.size_distribution is False: if self.x_average is False: @@ -236,7 +235,7 @@ def get_coupled_variables(self, variables): / dxdU, f"{Domain} {phase_name}particle bc [V.m-1]": j * R_nondim - / param.F + / self.param.F / pybamm.surf(c_max * x * (1 - x) * f * D_eff), } ) diff --git a/src/pybamm/models/submodels/particle/x_averaged_polynomial_profile.py b/src/pybamm/models/submodels/particle/x_averaged_polynomial_profile.py index 8b4b7ffe7c..9dccc0a6c4 100644 --- a/src/pybamm/models/submodels/particle/x_averaged_polynomial_profile.py +++ b/src/pybamm/models/submodels/particle/x_averaged_polynomial_profile.py @@ -97,7 +97,6 @@ def get_fundamental_variables(self): def get_coupled_variables(self, variables): domain = self.domain - param = self.param c_s_av = variables[f"Average {domain} particle concentration [mol.m-3]"] T_av = variables[f"X-averaged {domain} electrode temperature [K]"] @@ -135,7 +134,7 @@ def get_coupled_variables(self, variables): # an extra algebraic equation to solve. For now, using the average c is an # ok approximation and means the SPM(e) still gives a system of ODEs rather # than DAEs. - c_s_surf_xav = c_s_av - (j_xav * R / 5 / param.F / D_eff_av) + c_s_surf_xav = c_s_av - (j_xav * R / 5 / self.param.F / D_eff_av) elif self.name == "quartic profile": # The surface concentration is computed from the average concentration, # the average concentration gradient and the boundary flux (see notes @@ -144,7 +143,9 @@ def get_coupled_variables(self, variables): q_s_av = variables[ f"Average {domain} particle concentration gradient [mol.m-4]" ] - c_s_surf_xav = c_s_av + R / 35 * (8 * q_s_av - (j_xav / param.F / D_eff_av)) + c_s_surf_xav = c_s_av + R / 35 * ( + 8 * q_s_av - (j_xav / self.param.F / D_eff_av) + ) # Set concentration depending on polynomial order # Since c_s_xav doesn't depend on x, we need to define a spatial @@ -223,7 +224,6 @@ def set_rhs(self, variables): # using this model with 2D current collectors with the finite element # method (see #1399) domain = self.domain - param = self.param if self.size_distribution is False: c_s_av = variables[f"Average {domain} particle concentration [mol.m-3]"] @@ -243,7 +243,7 @@ def set_rhs(self, variables): # eq 15 of Subramanian2005 # equivalent to dcdt = -i_cc / (eps * F * L) - dcdt = -3 * j_xav / param.F / R + dcdt = -3 * j_xav / self.param.F / R if self.size_distribution is False: self.rhs = {c_s_av: pybamm.source(dcdt, c_s_av)} @@ -262,7 +262,7 @@ def set_rhs(self, variables): # eq 30 of Subramanian2005 dqdt = ( -30 * pybamm.surf(D_eff_xav) * q_s_av / R**2 - - 45 / 2 * j_xav / param.F / R**2 + - 45 / 2 * j_xav / self.param.F / R**2 ) self.rhs[q_s_av] = pybamm.source(dqdt, q_s_av) diff --git a/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py b/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py index 4e25becbab..1301722da0 100644 --- a/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py +++ b/src/pybamm/models/submodels/particle_mechanics/base_mechanics.py @@ -38,30 +38,37 @@ def _get_standard_variables(self, l_cr): def _get_mechanical_results(self, variables): domain_param = self.domain_param domain, Domain = self.domain_Domain + phase_name = self.phase_name + phase_param = self.phase_param - c_s_rav = variables[f"R-averaged {domain} particle concentration [mol.m-3]"] - sto_rav = variables[f"R-averaged {domain} particle concentration"] - c_s_surf = variables[f"{Domain} particle surface concentration [mol.m-3]"] + c_s_rav = variables[ + f"R-averaged {domain} {phase_name}particle concentration [mol.m-3]" + ] + sto_rav = variables[f"R-averaged {domain} {phase_name}particle concentration"] + c_s_surf = variables[ + f"{Domain} {phase_name}particle surface concentration [mol.m-3]" + ] T_xav = variables["X-averaged cell temperature [K]"] - phase_name = self.phase_name T = pybamm.PrimaryBroadcast( variables[f"{Domain} electrode temperature [K]"], [f"{domain} {phase_name}particle"], ) - eps_s = variables[f"{Domain} electrode active material volume fraction"] + eps_s = variables[ + f"{Domain} electrode {phase_name}active material volume fraction" + ] # use a tangential approximation for omega - sto = variables[f"{Domain} particle concentration"] - Omega = pybamm.r_average(domain_param.Omega(sto, T)) - R0 = domain_param.prim.R + sto = variables[f"{Domain} {phase_name}particle concentration"] + Omega = pybamm.r_average(phase_param.Omega(sto, T)) + R0 = phase_param.R c_0 = domain_param.c_0 - E0 = pybamm.r_average(domain_param.E(sto, T)) - nu = domain_param.nu + E0 = pybamm.r_average(phase_param.E(sto, T)) + nu = phase_param.nu L0 = domain_param.L - sto_init = pybamm.r_average(domain_param.prim.c_init / domain_param.prim.c_max) + sto_init = pybamm.r_average(phase_param.c_init / phase_param.c_max) v_change = pybamm.x_average( - eps_s * domain_param.prim.t_change(sto_rav) - ) - pybamm.x_average(eps_s * domain_param.prim.t_change(sto_init)) + eps_s * phase_param.t_change(sto_rav) + ) - pybamm.x_average(eps_s * phase_param.t_change(sto_init)) electrode_thickness_change = self.param.n_electrodes_parallel * v_change * L0 # Ai2019 eq [10] @@ -81,18 +88,27 @@ def _get_mechanical_results(self, variables): variables.update( { - f"{Domain} particle surface radial stress [Pa]": stress_r_surf, - f"{Domain} particle surface tangential stress [Pa]": stress_t_surf, - f"{Domain} particle surface displacement [m]": disp_surf, - f"X-averaged {domain} particle surface " + f"{Domain} {phase_name}particle surface radial stress [Pa]": stress_r_surf, + f"{Domain} {phase_name}particle surface tangential stress [Pa]": stress_t_surf, + f"{Domain} {phase_name}particle surface displacement [m]": disp_surf, + f"X-averaged {domain} {phase_name}particle surface " "radial stress [Pa]": stress_r_surf_av, - f"X-averaged {domain} particle surface " + f"X-averaged {domain} {phase_name}particle surface " "tangential stress [Pa]": stress_t_surf_av, - f"X-averaged {domain} particle surface displacement [m]": disp_surf_av, - f"{Domain} electrode thickness change [m]": electrode_thickness_change, + f"X-averaged {domain} {phase_name}particle surface displacement [m]": disp_surf_av, + f"{Domain} electrode {phase_name}thickness change [m]": electrode_thickness_change, } ) + if ( + f"{Domain} primary thickness change [m]" in variables + and f"{Domain} secondary thickness change [m]" in variables + ): + variables[f"{Domain} thickness change [m]"] = ( + variables[f"{Domain} primary thickness change [m]"] + + variables[f"{Domain} secondary thickness change [m]"] + ) + if ( "Negative electrode thickness change [m]" in variables and "Positive electrode thickness change [m]" in variables diff --git a/src/pybamm/models/submodels/particle_mechanics/crack_propagation.py b/src/pybamm/models/submodels/particle_mechanics/crack_propagation.py index 3bb9ecb7eb..b51f1d1ebd 100644 --- a/src/pybamm/models/submodels/particle_mechanics/crack_propagation.py +++ b/src/pybamm/models/submodels/particle_mechanics/crack_propagation.py @@ -102,7 +102,7 @@ def set_initial_conditions(self, variables): l_cr_0 = pybamm.PrimaryBroadcast(l_cr_0, f"{domain} electrode") self.initial_conditions = {l_cr: l_cr_0} - def set_events(self, variables): + def add_events_from(self, variables): domain, Domain = self.domain_Domain if self.x_average is True: diff --git a/src/pybamm/models/submodels/porosity/constant_porosity.py b/src/pybamm/models/submodels/porosity/constant_porosity.py index 0b9f3c0da4..6d6b93d76f 100644 --- a/src/pybamm/models/submodels/porosity/constant_porosity.py +++ b/src/pybamm/models/submodels/porosity/constant_porosity.py @@ -27,6 +27,6 @@ def get_fundamental_variables(self): return variables - def set_events(self, variables): + def add_events_from(self, variables): # No events since porosity is constant pass diff --git a/src/pybamm/models/submodels/porosity/reaction_driven_porosity.py b/src/pybamm/models/submodels/porosity/reaction_driven_porosity.py index fc69d0f1fd..989c90cd9c 100644 --- a/src/pybamm/models/submodels/porosity/reaction_driven_porosity.py +++ b/src/pybamm/models/submodels/porosity/reaction_driven_porosity.py @@ -64,7 +64,7 @@ def get_coupled_variables(self, variables): return variables - def set_events(self, variables): + def add_events_from(self, variables): eps_p = variables["Positive electrode porosity"] self.events.append( pybamm.Event( diff --git a/src/pybamm/models/submodels/porosity/reaction_driven_porosity_ode.py b/src/pybamm/models/submodels/porosity/reaction_driven_porosity_ode.py index 476845f054..8675842ebe 100644 --- a/src/pybamm/models/submodels/porosity/reaction_driven_porosity_ode.py +++ b/src/pybamm/models/submodels/porosity/reaction_driven_porosity_ode.py @@ -46,8 +46,6 @@ def get_fundamental_variables(self): return variables def get_coupled_variables(self, variables): - param = self.param - depsdt_dict = {} for domain in self.options.whole_cell_domains: domain_param = self.param.domain_params[domain.split()[0]] @@ -59,14 +57,14 @@ def get_coupled_variables(self, variables): f"X-averaged {domain} volumetric " "interfacial current density [A.m-3]" ] - depsdt_k_av = domain_param.DeltaVsurf * a_j_k_av / param.F + depsdt_k_av = domain_param.DeltaVsurf * a_j_k_av / self.param.F depsdt_k = pybamm.PrimaryBroadcast(depsdt_k_av, domain) else: Domain = domain.capitalize() a_j_k = variables[ f"{Domain} volumetric interfacial current density [A.m-3]" ] - depsdt_k = domain_param.DeltaVsurf * a_j_k / param.F + depsdt_k = domain_param.DeltaVsurf * a_j_k / self.param.F depsdt_dict[domain] = depsdt_k variables.update(self._get_standard_porosity_change_variables(depsdt_dict)) @@ -94,7 +92,7 @@ def set_initial_conditions(self, variables): eps = variables["Porosity"] self.initial_conditions = {eps: self.param.epsilon_init} - def set_events(self, variables): + def add_events_from(self, variables): for domain in self.options.whole_cell_domains: if domain == "separator": continue diff --git a/src/pybamm/models/submodels/thermal/base_thermal.py b/src/pybamm/models/submodels/thermal/base_thermal.py index c5ebbc7dbd..d5a39435fd 100644 --- a/src/pybamm/models/submodels/thermal/base_thermal.py +++ b/src/pybamm/models/submodels/thermal/base_thermal.py @@ -34,7 +34,6 @@ def _get_standard_fundamental_variables(self, T_dict): For more information about this method in general, see :meth:`pybamm.base_submodel._get_standard_fundamental_variables` """ - param = self.param # The variable T is the concatenation of the temperature in the middle domains # (e.g. negative electrode, separator and positive electrode for a full cell), @@ -46,7 +45,7 @@ def _get_standard_fundamental_variables(self, T_dict): # (y, z) only and time y = pybamm.standard_spatial_vars.y z = pybamm.standard_spatial_vars.z - T_amb = param.T_amb(y, z, pybamm.t) + T_amb = self.param.T_amb(y, z, pybamm.t) T_amb_av = self._yz_average(T_amb) variables = { @@ -69,8 +68,6 @@ def _get_standard_fundamental_variables(self, T_dict): return variables def _get_standard_coupled_variables(self, variables): - param = self.param - # Ohmic heating in solid i_s_p = variables["Positive electrode current density [A.m-2]"] phi_s_p = variables["Positive electrode potential [V]"] @@ -78,7 +75,7 @@ def _get_standard_coupled_variables(self, variables): if self.options.electrode_types["negative"] == "planar": i_boundary_cc = variables["Current collector current density [A.m-2]"] T_n = variables["Negative electrode temperature [K]"] - Q_ohm_s_n = i_boundary_cc**2 / param.n.sigma(T_n) + Q_ohm_s_n = i_boundary_cc**2 / self.param.n.sigma(T_n) else: i_s_n = variables["Negative electrode current density [A.m-2]"] phi_s_n = variables["Negative electrode potential [V]"] @@ -91,6 +88,12 @@ def _get_standard_coupled_variables(self, variables): # TODO: change full stefan-maxwell conductivity so that i_e is always # a Concatenation i_e = variables["Electrolyte current density [A.m-2]"] + # Special case for half cell -- i_e has to be a concatenation for this to work due to a mismatch with Q_ohm, so we make a new i_e which is a concatenation. + if (not isinstance(i_e, pybamm.Concatenation)) and ( + self.options.electrode_types["negative"] == "planar" + ): + i_e = self._get_i_e_for_half_cell_thermal(variables) + phi_e = variables["Electrolyte potential [V]"] if isinstance(i_e, pybamm.Concatenation): # compute by domain if possible @@ -146,8 +149,12 @@ def _get_standard_coupled_variables(self, variables): phase_names = ["primary ", "secondary "] if self.options.electrode_types["negative"] == "planar": - Q_rxn_n = pybamm.FullBroadcast( - 0, ["negative electrode"], "current collector" + i_n = variables["Lithium metal total interfacial current density [A.m-2]"] + eta_r_n = variables["Lithium metal interface reaction overpotential [V]"] + Q_rxn_n = pybamm.PrimaryBroadcast( + i_n * eta_r_n / self.param.n.L, + ["negative electrode"], + "current collector", ) Q_rev_n = pybamm.FullBroadcast( 0, ["negative electrode"], "current collector" @@ -199,11 +206,11 @@ def _get_standard_coupled_variables(self, variables): # Compute the integrated heat source per unit simulated electrode-pair area # in W.m-2. Note: this can still be a function of y and z for # higher-dimensional pouch cell models - Q_ohm_Wm2 = Q_ohm_av * param.L - Q_rxn_Wm2 = Q_rxn_av * param.L - Q_rev_Wm2 = Q_rev_av * param.L - Q_mix_Wm2 = Q_mix_av * param.L - Q_Wm2 = Q_av * param.L + Q_ohm_Wm2 = Q_ohm_av * self.param.L + Q_rxn_Wm2 = Q_rxn_av * self.param.L + Q_rev_Wm2 = Q_rev_av * self.param.L + Q_mix_Wm2 = Q_mix_av * self.param.L + Q_Wm2 = Q_av * self.param.L # Now average over the electrode height and width Q_ohm_Wm2_av = self._yz_average(Q_ohm_Wm2) @@ -216,8 +223,8 @@ def _get_standard_coupled_variables(self, variables): # the product of electrode height * electrode width * electrode stack thickness # Note: we multiply by the number of electrode pairs, since the Q_xx_Wm2_av # variables are per electrode pair - n_elec = param.n_electrodes_parallel - A = param.L_y * param.L_z # *modelled* electrode area + n_elec = self.param.n_electrodes_parallel + A = self.param.L_y * self.param.L_z # *modelled* electrode area Q_ohm_W = Q_ohm_Wm2_av * n_elec * A Q_rxn_W = Q_rxn_Wm2_av * n_elec * A Q_rev_W = Q_rev_Wm2_av * n_elec * A @@ -226,7 +233,7 @@ def _get_standard_coupled_variables(self, variables): # Compute volume-averaged heat source terms over the *entire cell volume*, not # the product of electrode height * electrode width * electrode stack thickness - V = param.V_cell # *actual* cell volume + V = self.param.V_cell # *actual* cell volume Q_ohm_vol_av = Q_ohm_W / V Q_rxn_vol_av = Q_rxn_W / V Q_rev_vol_av = Q_rev_W / V @@ -235,7 +242,7 @@ def _get_standard_coupled_variables(self, variables): # Effective heat capacity T_vol_av = variables["Volume-averaged cell temperature [K]"] - rho_c_p_eff_av = param.rho_c_p_eff(T_vol_av) + rho_c_p_eff_av = self.param.rho_c_p_eff(T_vol_av) variables.update( { @@ -314,7 +321,6 @@ def _current_collector_heating(self, variables): def _heat_of_mixing(self, variables): """Compute heat of mixing source terms.""" - param = self.param if self.options["heat of mixing"] == "true": F = pybamm.constants.F.value @@ -339,8 +345,10 @@ def _heat_of_mixing(self, variables): T_n = variables["Negative electrode temperature [K]"] T_n_part = pybamm.PrimaryBroadcast(T_n, ["negative particle"]) dc_n_dr2 = pybamm.inner(pybamm.grad(c_n), pybamm.grad(c_n)) - D_n = param.n.prim.D(c_n, T_n_part) - dUeq_n = param.n.prim.U(c_n / param.n.prim.c_max, T_n_part).diff(c_n) + D_n = self.param.n.prim.D(c_n, T_n_part) + dUeq_n = self.param.n.prim.U( + c_n / self.param.n.prim.c_max, T_n_part + ).diff(c_n) integrand_r_n = D_n * dc_n_dr2 * dUeq_n integration_variable_r_n = [ pybamm.SpatialVariable("r", domain=integrand_r_n.domain) @@ -360,8 +368,10 @@ def _heat_of_mixing(self, variables): T_p = variables["Positive electrode temperature [K]"] T_p_part = pybamm.PrimaryBroadcast(T_p, ["positive particle"]) dc_p_dr2 = pybamm.inner(pybamm.grad(c_p), pybamm.grad(c_p)) - D_p = param.p.prim.D(c_p, T_p_part) - dUeq_p = param.p.prim.U(c_p / param.p.prim.c_max, T_p_part).diff(c_p) + D_p = self.param.p.prim.D(c_p, T_p_part) + dUeq_p = self.param.p.prim.U(c_p / self.param.p.prim.c_max, T_p_part).diff( + c_p + ) integrand_r_p = D_p * dc_p_dr2 * dUeq_p integration_variable_r_p = [ pybamm.SpatialVariable("r", domain=integrand_r_p.domain) @@ -407,3 +417,23 @@ def _yz_average(self, var): return pybamm.z_average(var) elif self.options["dimensionality"] == 2: return pybamm.yz_average(var) + + def _get_i_e_for_half_cell_thermal(self, variables): + c_e_s = variables["Separator electrolyte concentration [mol.m-3]"] + c_e_p = variables["Positive electrolyte concentration [mol.m-3]"] + grad_phi_e_s = variables["Gradient of separator electrolyte potential [V.m-1]"] + grad_phi_e_p = variables["Gradient of positive electrolyte potential [V.m-1]"] + grad_c_e_s = pybamm.grad(c_e_s) + grad_c_e_p = pybamm.grad(c_e_p) + T_s = variables["Separator temperature [K]"] + T_p = variables["Positive electrode temperature [K]"] + tor_s = variables["Separator electrolyte transport efficiency"] + tor_p = variables["Positive electrolyte transport efficiency"] + i_e_s = (self.param.kappa_e(c_e_s, T_s) * tor_s) * ( + self.param.chiRT_over_Fc(c_e_s, T_s) * grad_c_e_s - grad_phi_e_s + ) + i_e_p = (self.param.kappa_e(c_e_p, T_p) * tor_p) * ( + self.param.chiRT_over_Fc(c_e_p, T_p) * grad_c_e_p - grad_phi_e_p + ) + i_e = pybamm.concatenation(i_e_s, i_e_p) + return i_e diff --git a/src/pybamm/models/submodels/thermal/pouch_cell/pouch_cell_1D_current_collectors.py b/src/pybamm/models/submodels/thermal/pouch_cell/pouch_cell_1D_current_collectors.py index fb026a9a0a..5c29ef0a9f 100644 --- a/src/pybamm/models/submodels/thermal/pouch_cell/pouch_cell_1D_current_collectors.py +++ b/src/pybamm/models/submodels/thermal/pouch_cell/pouch_cell_1D_current_collectors.py @@ -86,24 +86,23 @@ def set_rhs(self, variables): } def set_boundary_conditions(self, variables): - param = self.param T_surf = variables["Surface temperature [K]"] T_av = variables["X-averaged cell temperature [K]"] # Find tab locations (top vs bottom) - L_y = param.L_y - L_z = param.L_z - neg_tab_z = param.n.centre_z_tab - pos_tab_z = param.p.centre_z_tab + L_y = self.param.L_y + L_z = self.param.L_z + neg_tab_z = self.param.n.centre_z_tab + pos_tab_z = self.param.p.centre_z_tab neg_tab_top_bool = pybamm.Equality(neg_tab_z, L_z) neg_tab_bottom_bool = pybamm.Equality(neg_tab_z, 0) pos_tab_top_bool = pybamm.Equality(pos_tab_z, L_z) pos_tab_bottom_bool = pybamm.Equality(pos_tab_z, 0) # Calculate tab vs non-tab area on top and bottom - neg_tab_area = param.n.L_tab * param.n.L_cc - pos_tab_area = param.p.L_tab * param.p.L_cc - total_area = param.L * param.L_y + neg_tab_area = self.param.n.L_tab * self.param.n.L_cc + pos_tab_area = self.param.p.L_tab * self.param.p.L_cc + total_area = self.param.L * self.param.L_y non_tab_top_area = ( total_area - neg_tab_area * neg_tab_top_bool @@ -118,10 +117,10 @@ def set_boundary_conditions(self, variables): # Calculate heat fluxes weighted by area # Note: can't do y-average of h_edge here since y isn't meshed. Evaluate at # midpoint. - q_tab_n = -param.n.h_tab * (T_av - T_surf) - q_tab_p = -param.p.h_tab * (T_av - T_surf) - q_edge_top = -param.h_edge(L_y / 2, L_z) * (T_av - T_surf) - q_edge_bottom = -param.h_edge(L_y / 2, 0) * (T_av - T_surf) + q_tab_n = -self.param.n.h_tab * (T_av - T_surf) + q_tab_p = -self.param.p.h_tab * (T_av - T_surf) + q_edge_top = -self.param.h_edge(L_y / 2, L_z) * (T_av - T_surf) + q_edge_bottom = -self.param.h_edge(L_y / 2, 0) * (T_av - T_surf) q_top = ( q_tab_n * neg_tab_area * neg_tab_top_bool + q_tab_p * pos_tab_area * pos_tab_top_bool @@ -136,7 +135,7 @@ def set_boundary_conditions(self, variables): # just use left and right for clarity # left = bottom of cell (z=0) # right = top of cell (z=L_z) - lambda_eff = param.lambda_eff(T_av) + lambda_eff = self.param.lambda_eff(T_av) self.boundary_conditions = { T_av: { "left": ( diff --git a/src/pybamm/models/submodels/thermal/surface/lumped.py b/src/pybamm/models/submodels/thermal/surface/lumped.py index dc481947e8..9fe3118e2f 100644 --- a/src/pybamm/models/submodels/thermal/surface/lumped.py +++ b/src/pybamm/models/submodels/thermal/surface/lumped.py @@ -19,7 +19,7 @@ class Lumped(pybamm.BaseSubModel): def __init__(self, param, options=None): super().__init__(param, options=options) - pybamm.citations.register("lin2014lumped") + pybamm.citations.register("Lin2014") def get_fundamental_variables(self): T_surf = pybamm.Variable("Surface temperature [K]") diff --git a/src/pybamm/models/submodels/transport_efficiency/bruggeman.py b/src/pybamm/models/submodels/transport_efficiency/bruggeman.py index ec26d7955d..a960f20e66 100644 --- a/src/pybamm/models/submodels/transport_efficiency/bruggeman.py +++ b/src/pybamm/models/submodels/transport_efficiency/bruggeman.py @@ -7,7 +7,7 @@ class Bruggeman(BaseModel): """Submodel for Bruggeman transport_efficiency, - :footcite:t:`bruggeman1935berechnung` + :footcite:t:`Bruggeman1935` Parameters ---------- @@ -28,7 +28,7 @@ def get_coupled_variables(self, variables): for domain in self.options.whole_cell_domains: Domain = domain.capitalize() eps_k = variables[f"{Domain} porosity"] - pybamm.citations.register("bruggeman1935berechnung") + pybamm.citations.register("Bruggeman1935") b_k = self.param.domain_params[domain.split()[0]].b_e tor_k = eps_k**b_k tor_dict[domain] = tor_k @@ -40,7 +40,7 @@ def get_coupled_variables(self, variables): else: Domain = domain.capitalize() phi_k = 1 - variables[f"{Domain} porosity"] - pybamm.citations.register("bruggeman1935berechnung") + pybamm.citations.register("Bruggeman1935") b_k = self.param.domain_params[domain.split()[0]].b_s tor_k = phi_k**b_k tor_dict[domain] = tor_k diff --git a/src/pybamm/models/submodels/transport_efficiency/cation_exchange_membrane.py b/src/pybamm/models/submodels/transport_efficiency/cation_exchange_membrane.py index 3ffb57e7de..b9165cf255 100644 --- a/src/pybamm/models/submodels/transport_efficiency/cation_exchange_membrane.py +++ b/src/pybamm/models/submodels/transport_efficiency/cation_exchange_membrane.py @@ -7,7 +7,7 @@ class CationExchangeMembrane(BaseModel): """Submodel for Cation Exchange Membrane transport_efficiency, - :footcite:t:`bruggeman1935berechnung`, :footcite:t:`shen2007critical` + :footcite:t:`Bruggeman1935`, :footcite:t:`Shen2007` Parameters ---------- @@ -23,8 +23,8 @@ def __init__(self, param, component, options=None): super().__init__(param, component, options=options) def get_coupled_variables(self, variables): - pybamm.citations.register("shen2007critical") - pybamm.citations.register("mackie1955diffusion") + pybamm.citations.register("Shen2007") + pybamm.citations.register("Mackie1955") if self.component == "Electrolyte": tor_dict = {} for domain in self.options.whole_cell_domains: diff --git a/src/pybamm/models/submodels/transport_efficiency/heterogeneous_catalyst.py b/src/pybamm/models/submodels/transport_efficiency/heterogeneous_catalyst.py index 7ec8bc3580..f60f71765c 100644 --- a/src/pybamm/models/submodels/transport_efficiency/heterogeneous_catalyst.py +++ b/src/pybamm/models/submodels/transport_efficiency/heterogeneous_catalyst.py @@ -7,7 +7,7 @@ class HeterogeneousCatalyst(BaseModel): """Submodel for Heterogeneous Catalyst transport_efficiency - :footcite:t:`beeckman1990mathematical`, :footcite:t:`shen2007critical` + :footcite:t:`Beeckman1990`, :footcite:t:`Shen2007` Parameters ---------- @@ -23,8 +23,8 @@ def __init__(self, param, component, options=None): super().__init__(param, component, options=options) def get_coupled_variables(self, variables): - pybamm.citations.register("shen2007critical") - pybamm.citations.register("beeckman1990mathematical") + pybamm.citations.register("Shen2007") + pybamm.citations.register("Beeckman1990") if self.component == "Electrolyte": tor_dict = {} for domain in self.options.whole_cell_domains: diff --git a/src/pybamm/models/submodels/transport_efficiency/hyperbola_of_revolution.py b/src/pybamm/models/submodels/transport_efficiency/hyperbola_of_revolution.py index 306c66b774..fe7e8dfb1d 100644 --- a/src/pybamm/models/submodels/transport_efficiency/hyperbola_of_revolution.py +++ b/src/pybamm/models/submodels/transport_efficiency/hyperbola_of_revolution.py @@ -7,7 +7,7 @@ class HyperbolaOfRevolution(BaseModel): """Submodel for Hyperbola of revolution transport_efficiency - :footcite:t:`petersen1958diffusion`, :footcite:t:`shen2007critical` + :footcite:t:`Petersen1958`, :footcite:t:`Shen2007` Parameters ---------- @@ -23,8 +23,8 @@ def __init__(self, param, component, options=None): super().__init__(param, component, options=options) def get_coupled_variables(self, variables): - pybamm.citations.register("shen2007critical") - pybamm.citations.register("petersen1958diffusion") + pybamm.citations.register("Shen2007") + pybamm.citations.register("Petersen1958") if self.component == "Electrolyte": tor_dict = {} for domain in self.options.whole_cell_domains: diff --git a/src/pybamm/models/submodels/transport_efficiency/ordered_packing.py b/src/pybamm/models/submodels/transport_efficiency/ordered_packing.py index 13b3a3515e..4b9e9b5dc5 100644 --- a/src/pybamm/models/submodels/transport_efficiency/ordered_packing.py +++ b/src/pybamm/models/submodels/transport_efficiency/ordered_packing.py @@ -7,7 +7,7 @@ class OrderedPacking(BaseModel): """Submodel for Ordered Packing transport_efficiency - :footcite:t:`akanni1987effective`, :footcite:t:`shen2007critical` + :footcite:t:`Akanni1987`, :footcite:t:`Shen2007` Parameters ---------- @@ -23,8 +23,8 @@ def __init__(self, param, component, options=None): super().__init__(param, component, options=options) def get_coupled_variables(self, variables): - pybamm.citations.register("shen2007critical") - pybamm.citations.register("akanni1987effective") + pybamm.citations.register("Shen2007") + pybamm.citations.register("Akanni1987") if self.component == "Electrolyte": tor_dict = {} for domain in self.options.whole_cell_domains: diff --git a/src/pybamm/models/submodels/transport_efficiency/overlapping_spheres.py b/src/pybamm/models/submodels/transport_efficiency/overlapping_spheres.py index 9bbed1fd05..ae2dbc590d 100644 --- a/src/pybamm/models/submodels/transport_efficiency/overlapping_spheres.py +++ b/src/pybamm/models/submodels/transport_efficiency/overlapping_spheres.py @@ -7,7 +7,7 @@ class OverlappingSpheres(BaseModel): """Submodel for Overlapping Spheres transport_efficiency - :footcite:t:`weissberg1963effective`, :footcite:t:`shen2007critical` + :footcite:t:`Weissberg1963`, :footcite:t:`Shen2007` Parameters ---------- @@ -23,8 +23,8 @@ def __init__(self, param, component, options=None): super().__init__(param, component, options=options) def get_coupled_variables(self, variables): - pybamm.citations.register("shen2007critical") - pybamm.citations.register("weissberg1963effective") + pybamm.citations.register("Shen2007") + pybamm.citations.register("Weissberg1963") if self.component == "Electrolyte": tor_dict = {} for domain in self.options.whole_cell_domains: diff --git a/src/pybamm/models/submodels/transport_efficiency/random_overlapping_cylinders.py b/src/pybamm/models/submodels/transport_efficiency/random_overlapping_cylinders.py index da32f2f4fe..b9eb49a54e 100644 --- a/src/pybamm/models/submodels/transport_efficiency/random_overlapping_cylinders.py +++ b/src/pybamm/models/submodels/transport_efficiency/random_overlapping_cylinders.py @@ -7,7 +7,7 @@ class RandomOverlappingCylinders(BaseModel): """Submodel for Random Overlapping Cylinders transport_efficiency, - :footcite:t:`tomadakis1993transport`, :footcite:t:`shen2007critical` + :footcite:t:`Tomadakis1993`, :footcite:t:`Shen2007` Parameters ---------- @@ -23,8 +23,8 @@ def __init__(self, param, component, options=None): super().__init__(param, component, options=options) def get_coupled_variables(self, variables): - pybamm.citations.register("shen2007critical") - pybamm.citations.register("tomadakis1993transport") + pybamm.citations.register("Shen2007") + pybamm.citations.register("Tomadakis1993") if self.component == "Electrolyte": tor_dict = {} for domain in self.options.whole_cell_domains: diff --git a/src/pybamm/parameters/bpx.py b/src/pybamm/parameters/bpx.py index 7485e805b9..df380ad627 100644 --- a/src/pybamm/parameters/bpx.py +++ b/src/pybamm/parameters/bpx.py @@ -228,12 +228,6 @@ def _bpx_to_param_dict(bpx: BPX) -> dict: def _arrhenius(Ea, T): return exp(Ea / constants.R * (1 / T_ref - 1 / T)) - def _entropic_change(sto, c_s_max, dUdT, constant=False): - if constant: - return dUdT - else: - return dUdT(sto) - # reaction rates in pybamm exchange current is defined j0 = k * sqrt(ce * cs * # (cs-cs_max)) in BPX exchange current is defined j0 = F * k_norm * sqrt((ce/ce0) * # (cs/cs_max) * (1-cs/cs_max)) @@ -284,25 +278,10 @@ def _conductivity(c_e, T, Ea, sigma_ref, constant=False): ) # entropic change - dUdT = pybamm_dict[ - phase_domain_pre_name + "entropic change coefficient [V.K-1]" - ] - if callable(dUdT): + dUdT = pybamm_dict[phase_domain_pre_name + "OCP entropic change [V.K-1]"] + if isinstance(dUdT, tuple): pybamm_dict[phase_domain_pre_name + "OCP entropic change [V.K-1]"] = ( - partial(_entropic_change, dUdT=dUdT) - ) - elif isinstance(dUdT, tuple): - pybamm_dict[phase_domain_pre_name + "OCP entropic change [V.K-1]"] = ( - partial( - _entropic_change, - dUdT=partial( - _interpolant_func, name=dUdT[0], x=dUdT[1][0], y=dUdT[1][1] - ), - ) - ) - else: - pybamm_dict[phase_domain_pre_name + "OCP entropic change [V.K-1]"] = ( - partial(_entropic_change, dUdT=dUdT, constant=True) + partial(_interpolant_func, name=dUdT[0], x=dUdT[1][0], y=dUdT[1][1]) ) # reaction rate @@ -440,6 +419,10 @@ def _get_pybamm_name(pybamm_name, domain): pybamm_name = domain.short_pre_name + pybamm_name_lower elif pybamm_name.startswith("OCP"): pybamm_name = domain.pre_name + pybamm_name + elif pybamm_name.startswith("Entropic change"): + pybamm_name = domain.pre_name + pybamm_name.replace( + "Entropic change coefficient", "OCP entropic change" + ) elif pybamm_name.startswith("Cation transference number"): pybamm_name = pybamm_name elif domain.pre_name != "": diff --git a/src/pybamm/parameters/lithium_ion_parameters.py b/src/pybamm/parameters/lithium_ion_parameters.py index f5a76c6d48..3902242d78 100644 --- a/src/pybamm/parameters/lithium_ion_parameters.py +++ b/src/pybamm/parameters/lithium_ion_parameters.py @@ -269,7 +269,6 @@ def _set_parameters(self): self.tau_s = self.geo.tau_s # Mechanical parameters - self.nu = pybamm.Parameter(f"{Domain} electrode Poisson's ratio") self.c_0 = pybamm.Parameter( f"{Domain} electrode reference concentration for free of deformation " "[mol.m-3]" @@ -283,20 +282,6 @@ def _set_parameters(self): self.b_cr = pybamm.Parameter(f"{Domain} electrode Paris' law constant b") self.m_cr = pybamm.Parameter(f"{Domain} electrode Paris' law constant m") - # Loss of active material parameters - self.m_LAM = pybamm.Parameter( - f"{Domain} electrode LAM constant exponential term" - ) - self.beta_LAM = pybamm.Parameter( - f"{Domain} electrode LAM constant proportional term [s-1]" - ) - self.stress_critical = pybamm.Parameter( - f"{Domain} electrode critical stress [Pa]" - ) - self.beta_LAM_sei = pybamm.Parameter( - f"{Domain} electrode reaction-driven LAM factor [m3.mol-1]" - ) - # Utilisation parameters self.u_init = pybamm.Parameter( f"Initial {domain} electrode interface utilisation" @@ -313,22 +298,6 @@ def C_dl(self, T): f"{Domain} electrode double-layer capacity [F.m-2]", inputs ) - def Omega(self, sto, T): - """Dimensional partial molar volume of Li in solid solution [m3.mol-1]""" - Domain = self.domain.capitalize() - inputs = {f"{Domain} particle stoichiometry": sto, "Temperature [K]": T} - return pybamm.FunctionParameter( - f"{Domain} electrode partial molar volume [m3.mol-1]", inputs - ) - - def E(self, sto, T): - """Dimensional Young's modulus""" - Domain = self.domain.capitalize() - inputs = {f"{Domain} particle stoichiometry": sto, "Temperature [K]": T} - return pybamm.FunctionParameter( - f"{Domain} electrode Young's modulus [Pa]", inputs - ) - def sigma(self, T): """Dimensional electrical conductivity in electrode""" inputs = {"Temperature [K]": T} @@ -538,6 +507,23 @@ def _set_parameters(self): if self.options["particle shape"] == "spherical": self.a_typ = 3 * pybamm.xyz_average(self.epsilon_s) / self.R_typ + # Mechanical property + self.nu = pybamm.Parameter(f"{pref}{Domain} electrode Poisson's ratio") + + # Loss of active material parameters + self.m_LAM = pybamm.Parameter( + f"{pref}{Domain} electrode LAM constant exponential term" + ) + self.beta_LAM = pybamm.Parameter( + f"{pref}{Domain} electrode LAM constant proportional term [s-1]" + ) + self.stress_critical = pybamm.Parameter( + f"{pref}{Domain} electrode critical stress [Pa]" + ) + self.beta_LAM_sei = pybamm.Parameter( + f"{pref}{Domain} electrode reaction-driven LAM factor [m3.mol-1]" + ) + def D(self, c_s, T, lithiation=None): """ Dimensional diffusivity in particle. In the parameter sets this is defined as @@ -669,11 +655,9 @@ def dUdT(self, sto): "MSMR" formulation, stoichiometry is explicitly defined as a function of U and T, and dUdT is only used to calculate the reversible heat generation term. """ - domain, Domain = self.domain_Domain + Domain = self.domain.capitalize() inputs = { f"{Domain} particle stoichiometry": sto, - f"{self.phase_prefactor}Maximum {domain} particle " - "surface concentration [mol.m-3]": self.c_max, } return pybamm.FunctionParameter( f"{self.phase_prefactor}{Domain} electrode OCP entropic change [V.K-1]", @@ -794,12 +778,33 @@ def t_change(self, sto): """ Volume change for the electrode; sto should be R-averaged """ - domain, Domain = self.domain_Domain + Domain = self.domain.capitalize() return pybamm.FunctionParameter( - f"{Domain} electrode volume change", + f"{self.phase_prefactor}{Domain} electrode volume change", { - "Particle stoichiometry": sto, - f"{self.phase_prefactor}Maximum {domain} particle " - "surface concentration [mol.m-3]": self.c_max, + f"{Domain} particle stoichiometry": sto, }, ) + + def Omega(self, sto, T): + """Dimensional partial molar volume of Li in solid solution [m3.mol-1]""" + domain, Domain = self.domain_Domain + inputs = { + f"{self.phase_prefactor} particle stoichiometry": sto, + "Temperature [K]": T, + } + return pybamm.FunctionParameter( + f"{self.phase_prefactor}{Domain} electrode partial molar volume [m3.mol-1]", + inputs, + ) + + def E(self, sto, T): + """Dimensional Young's modulus""" + domain, Domain = self.domain_Domain + inputs = { + f"{self.phase_prefactor} particle stoichiometry": sto, + "Temperature [K]": T, + } + return pybamm.FunctionParameter( + f"{self.phase_prefactor}{Domain} electrode Young's modulus [Pa]", inputs + ) diff --git a/src/pybamm/parameters/parameter_sets.py b/src/pybamm/parameters/parameter_sets.py index a3ddd0ed2e..22b476f4e0 100644 --- a/src/pybamm/parameters/parameter_sets.py +++ b/src/pybamm/parameters/parameter_sets.py @@ -18,7 +18,7 @@ class ParameterSets(Mapping): >>> import pybamm >>> list(pybamm.parameter_sets) - ['Ai2020', 'Chen2020', ...] + ['Ai2020', 'Chayambuka2022', ...] Get the docstring for a parameter set: @@ -26,7 +26,7 @@ class ParameterSets(Mapping): >>> print(pybamm.parameter_sets.get_docstring("Ai2020")) Parameters for the Enertech cell (Ai2020), from the papers :footcite:t:`Ai2019`, - :footcite:t:`rieger2016new` and references therein. + :footcite:t:`Rieger2016` and references therein. ... See also: :ref:`adding-parameter-sets` diff --git a/src/pybamm/parameters/parameter_values.py b/src/pybamm/parameters/parameter_values.py index 43c1ea17ce..bb30f24836 100644 --- a/src/pybamm/parameters/parameter_values.py +++ b/src/pybamm/parameters/parameter_values.py @@ -1,6 +1,3 @@ -# -# Parameter values for a simulation -# import numpy as np import pybamm import numbers @@ -35,15 +32,7 @@ class ParameterValues: """ - def __init__(self, values, chemistry=None): - if chemistry is not None: - raise ValueError( - "The 'chemistry' keyword argument has been deprecated. " - "Call `ParameterValues` with a dictionary dictionary of " - "parameter values, or the name of a parameter set (string), " - "as the single argument, e.g. `ParameterValues('Chen2020')`.", - ) - + def __init__(self, values): # add physical constants as default values self._dict_items = pybamm.FuzzyDict( { @@ -192,7 +181,7 @@ def items(self): return self._dict_items.items() def pop(self, *args, **kwargs): - self._dict_items.pop(*args, **kwargs) + return self._dict_items.pop(*args, **kwargs) def copy(self): """Returns a copy of the parameter values. Makes sure to copy the internal @@ -254,7 +243,7 @@ def update(self, values, check_conflict=False, check_already_exists=True, path=" f"Cannot update parameter '{name}' as it does not " + f"have a default value. ({err.args[0]}). If you are " + "sure you want to update this parameter, use " - + "param.update({{name: value}}, check_already_exists=False)" + + "param.update({name: value}, check_already_exists=False)" ) from err if isinstance(value, str): if ( @@ -930,3 +919,9 @@ def print_evaluated_parameters(self, evaluated_parameters, output_file): file.write((s + " : {:10.4g}\n").format(name, value)) else: file.write((s + " : {:10.3E}\n").format(name, value)) + + def __contains__(self, key): + return key in self._dict_items + + def __iter__(self): + return iter(self._dict_items) diff --git a/src/pybamm/plotting/quick_plot.py b/src/pybamm/plotting/quick_plot.py index 39dc974f9b..babfd2e761 100644 --- a/src/pybamm/plotting/quick_plot.py +++ b/src/pybamm/plotting/quick_plot.py @@ -84,6 +84,9 @@ class QuickPlot: variable_limits : str or dict of str, optional How to set the axis limits (for 0D or 1D variables) or colorbar limits (for 2D variables). Options are: + n_t_linear: int, optional + The number of linearly spaced time points added to the t axis for each sub-solution. + Note: this is only used if the solution has hermite interpolation enabled. - "fixed" (default): keep all axes fixes so that all data is visible - "tight": make axes tight to plot at each time @@ -105,6 +108,7 @@ def __init__( time_unit=None, spatial_unit="um", variable_limits="fixed", + n_t_linear=100, ): solutions = self.preprocess_solutions(solutions) @@ -169,6 +173,24 @@ def __init__( min_t = np.min([t[0] for t in self.ts_seconds]) max_t = np.max([t[-1] for t in self.ts_seconds]) + hermite_interp = all(sol.hermite_interpolation for sol in solutions) + + def t_sample(sol): + if hermite_interp and n_t_linear > 2: + # Linearly spaced time points + t_linspace = np.linspace(sol.t[0], sol.t[-1], n_t_linear + 2)[1:-1] + t_plot = np.union1d(sol.t, t_linspace) + else: + t_plot = sol.t + return t_plot + + ts_seconds = [] + for sol in solutions: + # Sample time points for each sub-solution + t_sol = [t_sample(sub_sol) for sub_sol in sol.sub_solutions] + ts_seconds.append(np.concatenate(t_sol)) + self.ts_seconds = ts_seconds + # Set timescale if time_unit is None: # defaults depend on how long the simulation is @@ -419,14 +441,14 @@ def reset_axis(self): spatial_vars = self.spatial_variable_dict[key] var_min = np.min( [ - ax_min(var(self.ts_seconds[i], **spatial_vars, warn=False)) + ax_min(var(self.ts_seconds[i], **spatial_vars)) for i, variable_list in enumerate(variable_lists) for var in variable_list ] ) var_max = np.max( [ - ax_max(var(self.ts_seconds[i], **spatial_vars, warn=False)) + ax_max(var(self.ts_seconds[i], **spatial_vars)) for i, variable_list in enumerate(variable_lists) for var in variable_list ] @@ -512,7 +534,7 @@ def plot(self, t, dynamic=False): full_t = self.ts_seconds[i] (self.plots[key][i][j],) = ax.plot( full_t / self.time_scaling_factor, - variable(full_t, warn=False), + variable(full_t), color=self.colors[i], linestyle=linestyle, ) @@ -548,7 +570,7 @@ def plot(self, t, dynamic=False): linestyle = self.linestyles[j] (self.plots[key][i][j],) = ax.plot( self.first_spatial_variable[key], - variable(t_in_seconds, **spatial_vars, warn=False), + variable(t_in_seconds, **spatial_vars), color=self.colors[i], linestyle=linestyle, zorder=10, @@ -570,13 +592,13 @@ def plot(self, t, dynamic=False): y_name = next(iter(spatial_vars.keys()))[0] x = self.second_spatial_variable[key] y = self.first_spatial_variable[key] - var = variable(t_in_seconds, **spatial_vars, warn=False) + var = variable(t_in_seconds, **spatial_vars) else: x_name = next(iter(spatial_vars.keys()))[0] y_name = list(spatial_vars.keys())[1][0] x = self.first_spatial_variable[key] y = self.second_spatial_variable[key] - var = variable(t_in_seconds, **spatial_vars, warn=False).T + var = variable(t_in_seconds, **spatial_vars).T ax.set_xlabel(f"{x_name} [{self.spatial_unit}]") ax.set_ylabel(f"{y_name} [{self.spatial_unit}]") vmin, vmax = self.variable_limits[key] @@ -710,7 +732,6 @@ def slider_update(self, t): var = variable( time_in_seconds, **self.spatial_variable_dict[key], - warn=False, ) plot[i][j].set_ydata(var) var_min = min(var_min, ax_min(var)) @@ -729,11 +750,11 @@ def slider_update(self, t): if self.x_first_and_y_second[key] is False: x = self.second_spatial_variable[key] y = self.first_spatial_variable[key] - var = variable(time_in_seconds, **spatial_vars, warn=False) + var = variable(time_in_seconds, **spatial_vars) else: x = self.first_spatial_variable[key] y = self.second_spatial_variable[key] - var = variable(time_in_seconds, **spatial_vars, warn=False).T + var = variable(time_in_seconds, **spatial_vars).T # store the plot and the var data (for testing) as cant access # z data from QuadMesh or QuadContourSet object if self.is_y_z[key] is True: diff --git a/src/pybamm/settings.py b/src/pybamm/settings.py index d190eaf47e..6b7a628195 100644 --- a/src/pybamm/settings.py +++ b/src/pybamm/settings.py @@ -12,7 +12,6 @@ class Settings: _abs_smoothing = "exact" max_words_in_line = 4 max_y_value = 1e5 - step_start_offset = 1e-9 tolerances = { "D_e__c_e": 10, # dimensional "kappa_e__c_e": 10, # dimensional diff --git a/src/pybamm/simulation.py b/src/pybamm/simulation.py index 5b999d6c83..cd4fc62ec8 100644 --- a/src/pybamm/simulation.py +++ b/src/pybamm/simulation.py @@ -8,9 +8,9 @@ import numpy as np import hashlib import warnings -import sys from functools import lru_cache from datetime import timedelta +import pybamm.telemetry from pybamm.util import import_optional_dependency from pybamm.expression_tree.operations.serialise import Serialise @@ -175,7 +175,12 @@ def _set_random_seed(self): % (2**32) ) - def set_up_and_parameterise_experiment(self): + def set_up_and_parameterise_experiment(self, solve_kwargs=None): + msg = "pybamm.simulation.set_up_and_parameterise_experiment is deprecated and not meant to be accessed by users." + warnings.warn(msg, DeprecationWarning, stacklevel=2) + self._set_up_and_parameterise_experiment(solve_kwargs=solve_kwargs) + + def _set_up_and_parameterise_experiment(self, solve_kwargs=None): """ Create and parameterise the models for each step in the experiment. @@ -183,6 +188,46 @@ def set_up_and_parameterise_experiment(self): reduces simulation time since the model formulation is efficient. """ parameter_values = self._parameter_values.copy() + + # some parameters are used to control the experiment, and should not be + # input parameters + restrict_list = {"Initial temperature [K]", "Ambient temperature [K]"} + for step in self.experiment.steps: + if issubclass(step.__class__, pybamm.experiment.step.BaseStepImplicit): + restrict_list.update(step.get_parameter_values([]).keys()) + elif issubclass(step.__class__, pybamm.experiment.step.BaseStepExplicit): + restrict_list.update(["Current function [A]"]) + for key in restrict_list: + if key in parameter_values.keys() and isinstance( + parameter_values[key], pybamm.InputParameter + ): + raise pybamm.ModelError( + f"Cannot use '{key}' as an input parameter in this experiment. " + f"This experiment is controlled via the following parameters: {restrict_list}. " + f"None of these parameters are able to be input parameters." + ) + + if ( + solve_kwargs is not None + and "calculate_sensitivities" in solve_kwargs + and solve_kwargs["calculate_sensitivities"] + ): + for step in self.experiment.steps: + if any( + [ + isinstance( + term, + pybamm.experiment.step.step_termination.BaseTermination, + ) + for term in step.termination + ] + ): + pybamm.logger.warning( + f"Step '{step}' has a termination condition based on an event. Sensitivity calculation will be inaccurate " + "if the time of each step event changes rapidly with respect to the parameters. " + ) + break + # Set the initial temperature to be the temperature of the first step # We can set this globally for all steps since any subsequent steps will either # start at the temperature at the end of the previous step (if non-isothermal @@ -214,10 +259,16 @@ def set_up_and_parameterise_experiment(self): ) def set_parameters(self): + msg = ( + "pybamm.set_parameters is deprecated and not meant to be accessed by users." + ) + warnings.warn(msg, DeprecationWarning, stacklevel=2) + self._set_parameters() + + def _set_parameters(self): """ A method to set the parameters in the model and the associated geometry. """ - if self._model_with_set_params: return @@ -304,7 +355,7 @@ def build(self, initial_soc=None, inputs=None): # rebuilt model so clear solver setup self._solver._model_set_up = {} - def build_for_experiment(self, initial_soc=None, inputs=None): + def build_for_experiment(self, initial_soc=None, inputs=None, solve_kwargs=None): """ Similar to :meth:`Simulation.build`, but for the case of simulating an experiment, where there may be several models and solvers to build. @@ -315,7 +366,7 @@ def build_for_experiment(self, initial_soc=None, inputs=None): if self.steps_to_built_models: return else: - self.set_up_and_parameterise_experiment() + self._set_up_and_parameterise_experiment(solve_kwargs) # Can process geometry with default parameter values (only electrical # parameters change between parameter values) @@ -411,6 +462,8 @@ def solve( Additional key-word arguments passed to `solver.solve`. See :meth:`pybamm.BaseSolver.solve`. """ + pybamm.telemetry.capture("simulation-solved") + # Setup if solver is None: solver = self._solver @@ -464,9 +517,9 @@ def solve( # the time data (to ensure the resolution of t_eval is fine enough). # We only raise a warning here as users may genuinely only want # the solution returned at some specified points. - elif ( - set(np.round(time_data, 12)).issubset(set(np.round(t_eval, 12))) - ) is False: + elif not isinstance(solver, pybamm.IDAKLUSolver) and not set( + np.round(time_data, 12) + ).issubset(set(np.round(t_eval, 12))): warnings.warn( """ t_eval does not contain all of the time points in the data @@ -478,16 +531,14 @@ def solve( ) dt_data_min = np.min(np.diff(time_data)) dt_eval_max = np.max(np.diff(t_eval)) - if dt_eval_max > dt_data_min + sys.float_info.epsilon: + if dt_eval_max > np.nextafter(dt_data_min, np.inf): warnings.warn( - f""" - The largest timestep in t_eval ({dt_eval_max}) is larger than - the smallest timestep in the data ({dt_data_min}). The returned - solution may not have the correct resolution to accurately - capture the input. Try refining t_eval. Alternatively, - passing t_eval = None automatically sets t_eval to be the - points in the data. - """, + f"The largest timestep in t_eval ({dt_eval_max}) is larger than " + f"the smallest timestep in the data ({dt_data_min}). The returned " + "solution may not have the correct resolution to accurately " + "capture the input. Try refining t_eval. Alternatively, " + "passing t_eval = None automatically sets t_eval to be the " + "points in the data.", pybamm.SolverWarning, stacklevel=2, ) @@ -498,7 +549,9 @@ def solve( elif self.operating_mode == "with experiment": callbacks.on_experiment_start(logs) - self.build_for_experiment(initial_soc=initial_soc, inputs=inputs) + self.build_for_experiment( + initial_soc=initial_soc, inputs=inputs, solve_kwargs=kwargs + ) if t_eval is not None: pybamm.logger.warning( "Ignoring t_eval as solution times are specified by the experiment" @@ -581,7 +634,7 @@ def solve( + timedelta(seconds=float(current_solution.t[-1])) ) ).total_seconds() - if rest_time > pybamm.settings.step_start_offset: + if rest_time > 0: # logs["step operating conditions"] = "Initial rest for padding" # callbacks.on_step_start(logs) @@ -738,7 +791,7 @@ def solve( + timedelta(seconds=float(step_solution.t[-1])) ) ).total_seconds() - if rest_time > pybamm.settings.step_start_offset: + if rest_time > 0: logs["step number"] = (step_num, cycle_length) logs["step operating conditions"] = "Rest for padding" callbacks.on_step_start(logs) diff --git a/src/pybamm/solvers/__init__.py b/src/pybamm/solvers/__init__.py index fc8be7e2f8..e9d9d306f4 100644 --- a/src/pybamm/solvers/__init__.py +++ b/src/pybamm/solvers/__init__.py @@ -2,4 +2,4 @@ 'casadi_algebraic_solver', 'casadi_solver', 'dummy_solver', 'idaklu_jax', 'idaklu_solver', 'jax_bdf_solver', 'jax_solver', 'lrudict', 'processed_variable', 'processed_variable_computed', - 'scipy_solver', 'solution'] + 'scipy_solver', 'solution', 'processed_variable_time_integral'] diff --git a/src/pybamm/solvers/base_solver.py b/src/pybamm/solvers/base_solver.py index efef7e9357..19aab65407 100644 --- a/src/pybamm/solvers/base_solver.py +++ b/src/pybamm/solvers/base_solver.py @@ -86,6 +86,14 @@ def supports_interp(self): def root_method(self): return self._root_method + @property + def supports_parallel_solve(self): + return False + + @property + def requires_explicit_sensitivities(self): + return True + @root_method.setter def root_method(self, method): if method == "casadi": @@ -137,7 +145,7 @@ def set_up(self, model, inputs=None, t_eval=None, ics_only=False): # see if we need to form the explicit sensitivity equations calculate_sensitivities_explicit = ( - model.calculate_sensitivities and not isinstance(self, pybamm.IDAKLUSolver) + model.calculate_sensitivities and self.requires_explicit_sensitivities ) self._set_up_model_sensitivities_inplace( @@ -490,11 +498,7 @@ def _set_up_model_sensitivities_inplace( # if we have a mass matrix, we need to extend it def extend_mass_matrix(M): M_extend = [M.entries] * (num_parameters + 1) - M_extend_pybamm = pybamm.Matrix(block_diag(M_extend, format="csr")) - return M_extend_pybamm - - model.mass_matrix = extend_mass_matrix(model.mass_matrix) - model.mass_matrix = extend_mass_matrix(model.mass_matrix) + return pybamm.Matrix(block_diag(M_extend, format="csr")) model.mass_matrix = extend_mass_matrix(model.mass_matrix) @@ -670,6 +674,33 @@ def calculate_consistent_state(self, model, time=0, inputs=None): y0 = root_sol.all_ys[0] return y0 + def _solve_process_calculate_sensitivities_arg( + inputs, model, calculate_sensitivities + ): + # get a list-only version of calculate_sensitivities + if isinstance(calculate_sensitivities, bool): + if calculate_sensitivities: + calculate_sensitivities_list = [p for p in inputs.keys()] + else: + calculate_sensitivities_list = [] + else: + calculate_sensitivities_list = calculate_sensitivities + + calculate_sensitivities_list.sort() + if not hasattr(model, "calculate_sensitivities"): + model.calculate_sensitivities = [] + + # Check that calculate_sensitivites have not been updated + sensitivities_have_changed = ( + calculate_sensitivities_list != model.calculate_sensitivities + ) + + # save sensitivity parameters so we can identify them later on + # (FYI: this is used in the Solution class) + model.calculate_sensitivities = calculate_sensitivities_list + + return calculate_sensitivities_list, sensitivities_have_changed + def solve( self, model, @@ -700,7 +731,11 @@ def solve( calculate_sensitivities : list of str or bool, optional Whether the solver calculates sensitivities of all input parameters. Defaults to False. If only a subset of sensitivities are required, can also pass a - list of input parameter names + list of input parameter names. **Limitations**: sensitivities are not calculated up to numerical tolerances + so are not guarenteed to be within the tolerances set by the solver, please raise an issue if you + require this functionality. Also, when using this feature with `pybamm.Experiment`, the sensitivities + do not take into account the movement of step-transitions wrt input parameters, so do not use this feature + if the timings of your experimental protocol change rapidly with respect to your input parameters. t_interp : None, list or ndarray, optional The times (in seconds) at which to interpolate the solution. Defaults to None. Only valid for solvers that support intra-solve interpolation (`IDAKLUSolver`). @@ -722,15 +757,6 @@ def solve( """ pybamm.logger.info(f"Start solving {model.name} with {self.name}") - # get a list-only version of calculate_sensitivities - if isinstance(calculate_sensitivities, bool): - if calculate_sensitivities: - calculate_sensitivities_list = [p for p in inputs.keys()] - else: - calculate_sensitivities_list = [] - else: - calculate_sensitivities_list = calculate_sensitivities - # Make sure model isn't empty self._check_empty_model(model) @@ -772,6 +798,12 @@ def solve( self._set_up_model_inputs(model, inputs) for inputs in inputs_list ] + calculate_sensitivities_list, sensitivities_have_changed = ( + BaseSolver._solve_process_calculate_sensitivities_arg( + model_inputs_list[0], model, calculate_sensitivities + ) + ) + # (Re-)calculate consistent initialization # Assuming initial conditions do not depend on input parameters # when len(inputs_list) > 1, only `model_inputs_list[0]` @@ -792,13 +824,8 @@ def solve( "for initial conditions." ) - # Check that calculate_sensitivites have not been updated - calculate_sensitivities_list.sort() - if hasattr(model, "calculate_sensitivities"): - model.calculate_sensitivities.sort() - else: - model.calculate_sensitivities = [] - if calculate_sensitivities_list != model.calculate_sensitivities: + # if any setup configuration has changed, we need to re-set up + if sensitivities_have_changed: self._model_set_up.pop(model, None) # CasadiSolver caches its integrators using model, so delete this too if isinstance(self, pybamm.CasadiSolver): @@ -873,17 +900,8 @@ def solve( pybamm.logger.verbose( f"Calling solver for {t_eval[start_index]} < t < {t_eval[end_index - 1]}" ) - ninputs = len(model_inputs_list) - if ninputs == 1: - new_solution = self._integrate( - model, - t_eval[start_index:end_index], - model_inputs_list[0], - t_interp=t_interp, - ) - new_solutions = [new_solution] - elif model.convert_to_format == "jax": - # Jax can parallelize over the inputs efficiently + if self.supports_parallel_solve: + # Jax and IDAKLU solver can accept a list of inputs new_solutions = self._integrate( model, t_eval[start_index:end_index], @@ -891,18 +909,28 @@ def solve( t_interp, ) else: - with mp.get_context(self._mp_context).Pool(processes=nproc) as p: - new_solutions = p.starmap( - self._integrate, - zip( - [model] * ninputs, - [t_eval[start_index:end_index]] * ninputs, - model_inputs_list, - [t_interp] * ninputs, - ), + ninputs = len(model_inputs_list) + if ninputs == 1: + new_solution = self._integrate( + model, + t_eval[start_index:end_index], + model_inputs_list[0], + t_interp=t_interp, ) - p.close() - p.join() + new_solutions = [new_solution] + else: + with mp.get_context(self._mp_context).Pool(processes=nproc) as p: + new_solutions = p.starmap( + self._integrate, + zip( + [model] * ninputs, + [t_eval[start_index:end_index]] * ninputs, + model_inputs_list, + [t_interp] * ninputs, + ), + ) + p.close() + p.join() # Setting the solve time for each segment. # pybamm.Solution.__add__ assumes attribute solve_time. solve_time = timer.time() @@ -972,7 +1000,7 @@ def solve( ) # Return solution(s) - if ninputs == 1: + if len(solutions) == 1: return solutions[0] else: return solutions @@ -1066,6 +1094,58 @@ def _check_events_with_initialization(t_eval, model, inputs_dict): f"Events {event_names} are non-positive at initial conditions" ) + def _set_sens_initial_conditions_from( + self, solution: pybamm.Solution, model: pybamm.BaseModel + ) -> tuple: + """ + A restricted version of BaseModel.set_initial_conditions_from that only extracts the + sensitivities from a solution object, and only for a model that has been descretised. + This is used when setting the initial conditions for a sensitivity model. + + Parameters + ---------- + solution : :class:`pybamm.Solution` + The solution to use to initialize the model + + model: :class:`pybamm.BaseModel` + The model whose sensitivities to set + + Returns + ------- + + initial_conditions : tuple of ndarray + The initial conditions for the sensitivities, each element of the tuple + corresponds to an input parameter + """ + + ninputs = len(model.calculate_sensitivities) + initial_conditions = tuple([] for _ in range(ninputs)) + solution = solution.last_state + for var in model.initial_conditions: + final_state = solution[var.name] + final_state = final_state.sensitivities + final_state_eval = tuple( + final_state[key] for key in model.calculate_sensitivities + ) + + scale, reference = var.scale.value, var.reference.value + for i in range(ninputs): + scaled_final_state_eval = (final_state_eval[i] - reference) / scale + initial_conditions[i].append(scaled_final_state_eval) + + # Also update the concatenated initial conditions if the model is already + # discretised + # Unpack slices for sorting + y_slices = {var: slce for var, slce in model.y_slices.items()} + slices = [y_slices[symbol][0] for symbol in model.initial_conditions.keys()] + + # sort equations according to slices + concatenated_initial_conditions = [ + casadi.vertcat(*[eq for _, eq in sorted(zip(slices, init))]) + for init in initial_conditions + ] + return concatenated_initial_conditions + def process_t_interp(self, t_interp): # set a variable for this no_interp = (not self.supports_interp) and ( @@ -1092,6 +1172,7 @@ def step( npts=None, inputs=None, save=True, + calculate_sensitivities=False, t_interp=None, ): """ @@ -1117,6 +1198,14 @@ def step( Any input parameters to pass to the model when solving save : bool, optional Save solution with all previous timesteps. Defaults to True. + calculate_sensitivities : list of str or bool, optional + Whether the solver calculates sensitivities of all input parameters. Defaults to False. + If only a subset of sensitivities are required, can also pass a + list of input parameter names. **Limitations**: sensitivities are not calculated up to numerical tolerances + so are not guarenteed to be within the tolerances set by the solver, please raise an issue if you + require this functionality. Also, when using this feature with `pybamm.Experiment`, the sensitivities + do not take into account the movement of step-transitions wrt input parameters, so do not use this feature + if the timings of your experimental protocol change rapidly with respect to your input parameters. t_interp : None, list or ndarray, optional The times (in seconds) at which to interpolate the solution. Defaults to None. Only valid for solvers that support intra-solve interpolation (`IDAKLUSolver`). @@ -1142,12 +1231,9 @@ def step( # Make sure model isn't empty self._check_empty_model(model) - # Make sure dt is greater than the offset - step_start_offset = pybamm.settings.step_start_offset - if dt <= step_start_offset: - raise pybamm.SolverError( - f"Step time must be at least {pybamm.TimerTime(step_start_offset)}" - ) + # Make sure dt is greater than zero + if dt <= 0: + raise pybamm.SolverError("Step time must be >0") # Raise deprecation warning for npts and convert it to t_eval if npts is not None: @@ -1176,11 +1262,11 @@ def step( if t_start == 0: t_start_shifted = t_start else: - # offset t_start by t_start_offset (default 1 ns) + # find the next largest floating point value for t_start # to avoid repeated times in the solution # from having the same time at the end of the previous step and # the start of the next step - t_start_shifted = t_start + step_start_offset + t_start_shifted = np.nextafter(t_start, np.inf) t_eval[0] = t_start_shifted if t_interp.size > 0 and t_interp[0] == t_start: t_interp[0] = t_start_shifted @@ -1191,8 +1277,15 @@ def step( # Set up inputs model_inputs = self._set_up_model_inputs(model, inputs) + # process calculate_sensitivities argument + calculate_sensitivities_list, sensitivities_have_changed = ( + BaseSolver._solve_process_calculate_sensitivities_arg( + model_inputs, model, calculate_sensitivities + ) + ) + first_step_this_model = model not in self._model_set_up - if first_step_this_model: + if first_step_this_model or sensitivities_have_changed: if len(self._model_set_up) > 0: existing_model = next(iter(self._model_set_up)) raise RuntimeError( @@ -1211,18 +1304,45 @@ def step( ): pybamm.logger.verbose(f"Start stepping {model.name} with {self.name}") + using_sensitivities = len(model.calculate_sensitivities) > 0 + if isinstance(old_solution, pybamm.EmptySolution): if not first_step_this_model: # reset y0 to original initial conditions self.set_up(model, model_inputs, ics_only=True) elif old_solution.all_models[-1] == model: - # initialize with old solution - model.y0 = old_solution.all_ys[-1][:, -1] + last_state = old_solution.last_state + model.y0 = last_state.all_ys[0] + if using_sensitivities and isinstance(last_state._all_sensitivities, dict): + full_sens = last_state._all_sensitivities["all"][0] + model.y0S = tuple(full_sens[:, i] for i in range(full_sens.shape[1])) + else: _, concatenated_initial_conditions = model.set_initial_conditions_from( old_solution, return_type="ics" ) model.y0 = concatenated_initial_conditions.evaluate(0, inputs=model_inputs) + if using_sensitivities: + model.y0S = self._set_sens_initial_conditions_from(old_solution, model) + + # hopefully we'll get rid of explicit sensitivities soon so we can remove this + explicit_sensitivities = model.len_rhs_sens > 0 or model.len_alg_sens > 0 + if ( + explicit_sensitivities + and using_sensitivities + and not isinstance(old_solution, pybamm.EmptySolution) + and not old_solution.all_models[-1] == model + ): + y0_list = [] + if model.len_rhs > 0: + y0_list.append(model.y0[: model.len_rhs]) + for s in model.y0S: + y0_list.append(s[: model.len_rhs]) + if model.len_alg > 0: + y0_list.append(model.y0[model.len_rhs :]) + for s in model.y0S: + y0_list.append(s[model.len_rhs :]) + model.y0 = casadi.vertcat(*y0_list) set_up_time = timer.time() @@ -1235,7 +1355,13 @@ def step( # Step pybamm.logger.verbose(f"Stepping for {t_start_shifted:.0f} < t < {t_end:.0f}") timer.reset() - solution = self._integrate(model, t_eval, model_inputs, t_interp) + + # API for _integrate is different for JaxSolver and IDAKLUSolver + if self.supports_parallel_solve: + solutions = self._integrate(model, t_eval, [model_inputs], t_interp) + solution = solutions[0] + else: + solution = self._integrate(model, t_eval, model_inputs, t_interp) solution.solve_time = timer.time() # Check if extrapolation occurred @@ -1326,6 +1452,7 @@ def get_termination_reason(solution, events): solution.t_event, solution.y_event, solution.termination, + variables_returned=solution.variables_returned, ) event_sol.solve_time = 0 event_sol.integration_time = 0 @@ -1363,8 +1490,12 @@ def check_extrapolation(self, solution, events): # second pass: check if the extrapolation events are within the tolerance last_state = solution.last_state - t = last_state.all_ts[0][0] - y = last_state.all_ys[0][:, 0] + if solution.t_event: + t = solution.t_event[0] + y = solution.y_event[:, 0] + else: + t = last_state.all_ts[0][0] + y = last_state.all_ys[0][:, 0] inputs = last_state.all_inputs[0] if isinstance(y, casadi.DM): diff --git a/src/pybamm/solvers/c_solvers/idaklu.cpp b/src/pybamm/solvers/c_solvers/idaklu.cpp index 3ef0194403..82a3cbe91c 100644 --- a/src/pybamm/solvers/c_solvers/idaklu.cpp +++ b/src/pybamm/solvers/c_solvers/idaklu.cpp @@ -9,6 +9,8 @@ #include #include "idaklu/idaklu_solver.hpp" +#include "idaklu/observe.hpp" +#include "idaklu/IDAKLUSolverGroup.hpp" #include "idaklu/IdakluJax.hpp" #include "idaklu/common.hpp" #include "idaklu/Expressions/Casadi/CasadiFunctions.hpp" @@ -26,15 +28,19 @@ casadi::Function generate_casadi_function(const std::string &data) namespace py = pybind11; PYBIND11_MAKE_OPAQUE(std::vector); +PYBIND11_MAKE_OPAQUE(std::vector); +PYBIND11_MAKE_OPAQUE(std::vector); PYBIND11_MODULE(idaklu, m) { m.doc() = "sundials solvers"; // optional module docstring py::bind_vector>(m, "VectorNdArray"); + py::bind_vector>(m, "VectorRealtypeNdArray"); + py::bind_vector>(m, "VectorSolution"); - py::class_(m, "IDAKLUSolver") - .def("solve", &IDAKLUSolver::solve, + py::class_(m, "IDAKLUSolverGroup") + .def("solve", &IDAKLUSolverGroup::solve, "perform a solve", py::arg("t_eval"), py::arg("t_interp"), @@ -43,8 +49,8 @@ PYBIND11_MODULE(idaklu, m) py::arg("inputs"), py::return_value_policy::take_ownership); - m.def("create_casadi_solver", &create_idaklu_solver, - "Create a casadi idaklu solver object", + m.def("create_casadi_solver_group", &create_idaklu_solver_group, + "Create a group of casadi idaklu solver objects", py::arg("number_of_states"), py::arg("number_of_parameters"), py::arg("rhs_alg"), @@ -69,9 +75,30 @@ PYBIND11_MODULE(idaklu, m) py::arg("options"), py::return_value_policy::take_ownership); + m.def("observe", &observe, + "Observe variables", + py::arg("ts"), + py::arg("ys"), + py::arg("inputs"), + py::arg("funcs"), + py::arg("is_f_contiguous"), + py::arg("shape"), + py::return_value_policy::take_ownership); + + m.def("observe_hermite_interp", &observe_hermite_interp, + "Observe and Hermite interpolate variables", + py::arg("t_interp"), + py::arg("ts"), + py::arg("ys"), + py::arg("yps"), + py::arg("inputs"), + py::arg("funcs"), + py::arg("shape"), + py::return_value_policy::take_ownership); + #ifdef IREE_ENABLE - m.def("create_iree_solver", &create_idaklu_solver, - "Create a iree idaklu solver object", + m.def("create_iree_solver_group", &create_idaklu_solver_group, + "Create a group of iree idaklu solver objects", py::arg("number_of_states"), py::arg("number_of_parameters"), py::arg("rhs_alg"), @@ -164,7 +191,9 @@ PYBIND11_MODULE(idaklu, m) py::class_(m, "solution") .def_readwrite("t", &Solution::t) .def_readwrite("y", &Solution::y) + .def_readwrite("yp", &Solution::yp) .def_readwrite("yS", &Solution::yS) + .def_readwrite("ypS", &Solution::ypS) .def_readwrite("y_term", &Solution::y_term) .def_readwrite("flag", &Solution::flag); } diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp index 29b451e6d3..379d64783a 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolver.hpp @@ -2,7 +2,8 @@ #define PYBAMM_IDAKLU_CASADI_SOLVER_HPP #include "common.hpp" -#include "Solution.hpp" +#include "SolutionData.hpp" + /** * Abstract base class for solutions that can use different solvers and vector @@ -24,14 +25,17 @@ class IDAKLUSolver ~IDAKLUSolver() = default; /** - * @brief Abstract solver method that returns a Solution class + * @brief Abstract solver method that executes the solver */ - virtual Solution solve( - np_array t_eval_np, - np_array t_interp_np, - np_array y0_np, - np_array yp0_np, - np_array_dense inputs) = 0; + virtual SolutionData solve( + const std::vector &t_eval, + const std::vector &t_interp, + const realtype *y0, + const realtype *yp0, + const realtype *inputs, + bool save_adaptive_steps, + bool save_interp_steps + ) = 0; /** * Abstract method to initialize the solver, once vectors and solver classes diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.cpp b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.cpp new file mode 100644 index 0000000000..8a76d73cfe --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.cpp @@ -0,0 +1,145 @@ +#include "IDAKLUSolverGroup.hpp" +#include +#include + +std::vector IDAKLUSolverGroup::solve( + np_array t_eval_np, + np_array t_interp_np, + np_array y0_np, + np_array yp0_np, + np_array inputs) { + DEBUG("IDAKLUSolverGroup::solve"); + + // If t_interp is empty, save all adaptive steps + bool save_adaptive_steps = t_interp_np.size() == 0; + + const realtype* t_eval_begin = t_eval_np.data(); + const realtype* t_eval_end = t_eval_begin + t_eval_np.size(); + const realtype* t_interp_begin = t_interp_np.data(); + const realtype* t_interp_end = t_interp_begin + t_interp_np.size(); + + // Process the time inputs + // 1. Get the sorted and unique t_eval vector + auto const t_eval = makeSortedUnique(t_eval_begin, t_eval_end); + + // 2.1. Get the sorted and unique t_interp vector + auto const t_interp_unique_sorted = makeSortedUnique(t_interp_begin, t_interp_end); + + // 2.2 Remove the t_eval values from t_interp + auto const t_interp_setdiff = setDiff(t_interp_unique_sorted.begin(), t_interp_unique_sorted.end(), t_eval_begin, t_eval_end); + + // 2.3 Finally, get the sorted and unique t_interp vector with t_eval values removed + auto const t_interp = makeSortedUnique(t_interp_setdiff.begin(), t_interp_setdiff.end()); + + int const number_of_evals = t_eval.size(); + int const number_of_interps = t_interp.size(); + + // setDiff removes entries of t_interp that overlap with + // t_eval, so we need to check if we need to interpolate any unique points. + // This is not the same as save_adaptive_steps since some entries of t_interp + // may be removed by setDiff + bool save_interp_steps = number_of_interps > 0; + + // 3. Check if the timestepping entries are valid + if (number_of_evals < 2) { + throw std::invalid_argument( + "t_eval must have at least 2 entries" + ); + } else if (save_interp_steps) { + if (t_interp.front() < t_eval.front()) { + throw std::invalid_argument( + "t_interp values must be greater than the smallest t_eval value: " + + std::to_string(t_eval.front()) + ); + } else if (t_interp.back() > t_eval.back()) { + throw std::invalid_argument( + "t_interp values must be less than the greatest t_eval value: " + + std::to_string(t_eval.back()) + ); + } + } + + auto n_coeffs = number_of_states + number_of_parameters * number_of_states; + + // check y0 and yp0 and inputs have the correct dimensions + if (y0_np.ndim() != 2) + throw std::domain_error("y0 has wrong number of dimensions. Expected 2 but got " + std::to_string(y0_np.ndim())); + if (yp0_np.ndim() != 2) + throw std::domain_error("yp0 has wrong number of dimensions. Expected 2 but got " + std::to_string(yp0_np.ndim())); + if (inputs.ndim() != 2) + throw std::domain_error("inputs has wrong number of dimensions. Expected 2 but got " + std::to_string(inputs.ndim())); + + auto number_of_groups = y0_np.shape()[0]; + + // check y0 and yp0 and inputs have the correct shape + if (y0_np.shape()[1] != n_coeffs) + throw std::domain_error( + "y0 has wrong number of cols. Expected " + std::to_string(n_coeffs) + + " but got " + std::to_string(y0_np.shape()[1])); + + if (yp0_np.shape()[1] != n_coeffs) + throw std::domain_error( + "yp0 has wrong number of cols. Expected " + std::to_string(n_coeffs) + + " but got " + std::to_string(yp0_np.shape()[1])); + + if (yp0_np.shape()[0] != number_of_groups) + throw std::domain_error( + "yp0 has wrong number of rows. Expected " + std::to_string(number_of_groups) + + " but got " + std::to_string(yp0_np.shape()[0])); + + if (inputs.shape()[0] != number_of_groups) + throw std::domain_error( + "inputs has wrong number of rows. Expected " + std::to_string(number_of_groups) + + " but got " + std::to_string(inputs.shape()[0])); + + const std::size_t solves_per_thread = number_of_groups / m_solvers.size(); + const std::size_t remainder_solves = number_of_groups % m_solvers.size(); + + const realtype *y0 = y0_np.data(); + const realtype *yp0 = yp0_np.data(); + const realtype *inputs_data = inputs.data(); + + std::vector results(number_of_groups); + + std::optional exception; + + omp_set_num_threads(m_solvers.size()); + #pragma omp parallel for + for (int i = 0; i < m_solvers.size(); i++) { + try { + for (int j = 0; j < solves_per_thread; j++) { + const std::size_t index = i * solves_per_thread + j; + const realtype *y = y0 + index * y0_np.shape(1); + const realtype *yp = yp0 + index * yp0_np.shape(1); + const realtype *input = inputs_data + index * inputs.shape(1); + results[index] = m_solvers[i]->solve(t_eval, t_interp, y, yp, input, save_adaptive_steps, save_interp_steps); + } + } catch (std::exception &e) { + // If an exception is thrown, we need to catch it and rethrow it outside the parallel region + #pragma omp critical + { + exception = e; + } + } + } + + if (exception.has_value()) { + py::set_error(PyExc_ValueError, exception->what()); + throw py::error_already_set(); + } + + for (int i = 0; i < remainder_solves; i++) { + const std::size_t index = number_of_groups - remainder_solves + i; + const realtype *y = y0 + index * y0_np.shape(1); + const realtype *yp = yp0 + index * yp0_np.shape(1); + const realtype *input = inputs_data + index * inputs.shape(1); + results[index] = m_solvers[i]->solve(t_eval, t_interp, y, yp, input, save_adaptive_steps, save_interp_steps); + } + + // create solutions (needs to be serial as we're using the Python GIL) + std::vector solutions(number_of_groups); + for (int i = 0; i < number_of_groups; i++) { + solutions[i] = results[i].generate_solution(); + } + return solutions; +} diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.hpp b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.hpp new file mode 100644 index 0000000000..609b3b6fca --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverGroup.hpp @@ -0,0 +1,48 @@ +#ifndef PYBAMM_IDAKLU_SOLVER_GROUP_HPP +#define PYBAMM_IDAKLU_SOLVER_GROUP_HPP + +#include "IDAKLUSolver.hpp" +#include "common.hpp" + +/** + * @brief class for a group of solvers. + */ +class IDAKLUSolverGroup +{ +public: + + /** + * @brief Default constructor + */ + IDAKLUSolverGroup(std::vector> solvers, int number_of_states, int number_of_parameters): + m_solvers(std::move(solvers)), + number_of_states(number_of_states), + number_of_parameters(number_of_parameters) + {} + + // no copy constructor (unique_ptr cannot be copied) + IDAKLUSolverGroup(IDAKLUSolverGroup &) = delete; + + /** + * @brief Default destructor + */ + ~IDAKLUSolverGroup() = default; + + /** + * @brief solver method that returns a vector of Solutions + */ + std::vector solve( + np_array t_eval_np, + np_array t_interp_np, + np_array y0_np, + np_array yp0_np, + np_array inputs); + + + private: + std::vector> m_solvers; + int number_of_states; + int number_of_parameters; +}; + +#endif // PYBAMM_IDAKLU_SOLVER_GROUP_HPP diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp index ca710fbff6..ee2c03abff 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.hpp @@ -52,10 +52,11 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver int const number_of_states; // cppcheck-suppress unusedStructMember int const number_of_parameters; // cppcheck-suppress unusedStructMember int const number_of_events; // cppcheck-suppress unusedStructMember + int number_of_timesteps; int precon_type; // cppcheck-suppress unusedStructMember - N_Vector yy, yp, avtol; // y, y', and absolute tolerance + N_Vector yy, yyp, y_cache, avtol; // y, y', y cache vector, and absolute tolerance N_Vector *yyS; // cppcheck-suppress unusedStructMember - N_Vector *ypS; // cppcheck-suppress unusedStructMember + N_Vector *yypS; // cppcheck-suppress unusedStructMember N_Vector id; // rhs_alg_id realtype rtol; int const jac_times_cjmass_nnz; // cppcheck-suppress unusedStructMember @@ -69,10 +70,14 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver vector res_dvar_dp; bool const sensitivity; // cppcheck-suppress unusedStructMember bool const save_outputs_only; // cppcheck-suppress unusedStructMember + bool save_hermite; // cppcheck-suppress unusedStructMember + bool is_ODE; // cppcheck-suppress unusedStructMember int length_of_return_vector; // cppcheck-suppress unusedStructMember vector t; // cppcheck-suppress unusedStructMember vector> y; // cppcheck-suppress unusedStructMember + vector> yp; // cppcheck-suppress unusedStructMember vector>> yS; // cppcheck-suppress unusedStructMember + vector>> ypS; // cppcheck-suppress unusedStructMember SetupOptions const setup_opts; SolverOptions const solver_opts; @@ -106,12 +111,16 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver /** * @brief The main solve method that solves for each variable and time step */ - Solution solve( - np_array t_eval_np, - np_array t_interp_np, - np_array y0_np, - np_array yp0_np, - np_array_dense inputs) override; + SolutionData solve( + const std::vector &t_eval, + const std::vector &t_interp, + const realtype *y0, + const realtype *yp0, + const realtype *inputs, + bool save_adaptive_steps, + bool save_interp_steps + ) override; + /** * @brief Concrete implementation of initialization method @@ -138,6 +147,11 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver */ void InitializeStorage(int const N); + /** + * @brief Initialize the storage for Hermite interpolation + */ + void InitializeHermiteStorage(int const N); + /** * @brief Apply user-configurable IDA options */ @@ -153,18 +167,51 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver */ void PrintStats(); + /** + * @brief Set a consistent initialization for ODEs + */ + void ReinitializeIntegrator(const realtype& t_val); + + /** + * @brief Set a consistent initialization for the system of equations + */ + void ConsistentInitialization( + const realtype& t_val, + const realtype& t_next, + const int& icopt); + + /** + * @brief Set a consistent initialization for DAEs + */ + void ConsistentInitializationDAE( + const realtype& t_val, + const realtype& t_next, + const int& icopt); + + /** + * @brief Set a consistent initialization for ODEs + */ + void ConsistentInitializationODE(const realtype& t_val); + /** * @brief Extend the adaptive arrays by 1 */ void ExtendAdaptiveArrays(); + /** + * @brief Extend the Hermite interpolation info by 1 + */ + void ExtendHermiteArrays(); + /** * @brief Set the step values */ void SetStep( - realtype &t_val, + realtype &tval, realtype *y_val, + realtype *yp_val, vector const &yS_val, + vector const &ypS_val, int &i_save ); @@ -179,7 +226,9 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver realtype &t_prev, realtype const &t_next, realtype *y_val, + realtype *yp_val, vector const &yS_val, + vector const &ypS_val, int &i_save ); @@ -223,6 +272,26 @@ class IDAKLUSolverOpenMP : public IDAKLUSolver int &i_save ); + /** + * @brief Save the output function results at the requested time + */ + void SetStepHermite( + realtype &t_val, + realtype *yp_val, + const vector &ypS_val, + int &i_save + ); + + /** + * @brief Save the output function sensitivities at the requested time + */ + void SetStepHermiteSensitivities( + realtype &t_val, + realtype *yp_val, + const vector &ypS_val, + int &i_save + ); + }; #include "IDAKLUSolverOpenMP.inl" diff --git a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl index 7ed4dcfad8..d128ae1809 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl +++ b/src/pybamm/solvers/c_solvers/idaklu/IDAKLUSolverOpenMP.inl @@ -1,8 +1,8 @@ #include "Expressions/Expressions.hpp" #include "sundials_functions.hpp" #include - #include "common.hpp" +#include "SolutionData.hpp" template IDAKLUSolverOpenMP::IDAKLUSolverOpenMP( @@ -47,7 +47,7 @@ IDAKLUSolverOpenMP::IDAKLUSolverOpenMP( AllocateVectors(); if (sensitivity) { yyS = N_VCloneVectorArray(number_of_parameters, yy); - ypS = N_VCloneVectorArray(number_of_parameters, yp); + yypS = N_VCloneVectorArray(number_of_parameters, yyp); } // set initial values realtype *atval = N_VGetArrayPointer(avtol); @@ -57,14 +57,14 @@ IDAKLUSolverOpenMP::IDAKLUSolverOpenMP( for (int is = 0; is < number_of_parameters; is++) { N_VConst(RCONST(0.0), yyS[is]); - N_VConst(RCONST(0.0), ypS[is]); + N_VConst(RCONST(0.0), yypS[is]); } // create Matrix objects SetMatrix(); // initialise solver - IDAInit(ida_mem, residual_eval, 0, yy, yp); + IDAInit(ida_mem, residual_eval, 0, yy, yyp); // set tolerances rtol = RCONST(rel_tol); @@ -82,15 +82,33 @@ IDAKLUSolverOpenMP::IDAKLUSolverOpenMP( if (this->setup_opts.preconditioner != "none") { precon_type = SUN_PREC_LEFT; } + + // The default is to solve a DAE for generality. This may be changed + // to an ODE during the Initialize() call + is_ODE = false; + + // Will be overwritten during the solve() call + save_hermite = solver_opts.hermite_interpolation; } template void IDAKLUSolverOpenMP::AllocateVectors() { + DEBUG("IDAKLUSolverOpenMP::AllocateVectors (num_threads = " << setup_opts.num_threads << ")"); // Create vectors - yy = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); - yp = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); - avtol = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); - id = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + if (setup_opts.num_threads == 1) { + yy = N_VNew_Serial(number_of_states, sunctx); + yyp = N_VNew_Serial(number_of_states, sunctx); + y_cache = N_VNew_Serial(number_of_states, sunctx); + avtol = N_VNew_Serial(number_of_states, sunctx); + id = N_VNew_Serial(number_of_states, sunctx); + } else { + DEBUG("IDAKLUSolverOpenMP::AllocateVectors OpenMP"); + yy = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + yyp = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + y_cache = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + avtol = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + id = N_VNew_OpenMP(number_of_states, setup_opts.num_threads, sunctx); + } } template @@ -111,6 +129,26 @@ void IDAKLUSolverOpenMP::InitializeStorage(int const N) { vector(length_of_return_vector, 0.0) ) ); + + if (save_hermite) { + InitializeHermiteStorage(N); + } +} + +template +void IDAKLUSolverOpenMP::InitializeHermiteStorage(int const N) { + yp = vector>( + N, + vector(number_of_states, 0.0) + ); + + ypS = vector>>( + N, + vector>( + number_of_parameters, + vector(number_of_states, 0.0) + ) + ); } template @@ -269,7 +307,7 @@ void IDAKLUSolverOpenMP::Initialize() { if (sensitivity) { CheckErrors(IDASensInit(ida_mem, number_of_parameters, IDA_SIMULTANEOUS, - sensitivities_eval, yyS, ypS)); + sensitivities_eval, yyS, yypS)); CheckErrors(IDASensEEtolerances(ida_mem)); } @@ -279,9 +317,13 @@ void IDAKLUSolverOpenMP::Initialize() { realtype *id_val; id_val = N_VGetArrayPointer(id); - int ii; - for (ii = 0; ii < number_of_states; ii++) { + // Determine if the system is an ODE + is_ODE = number_of_states > 0; + for (int ii = 0; ii < number_of_states; ii++) { id_val[ii] = id_np_val[ii]; + // check if id_val[ii] approximately equals 1 (>0.999) handles + // cases where id_val[ii] is not exactly 1 due to numerical errors + is_ODE &= id_val[ii] > 0.999; } // Variable types: differential (1) and algebraic (0) @@ -290,6 +332,7 @@ void IDAKLUSolverOpenMP::Initialize() { template IDAKLUSolverOpenMP::~IDAKLUSolverOpenMP() { + DEBUG("IDAKLUSolverOpenMP::~IDAKLUSolverOpenMP"); // Free memory if (sensitivity) { IDASensFree(ida_mem); @@ -300,12 +343,13 @@ IDAKLUSolverOpenMP::~IDAKLUSolverOpenMP() { SUNMatDestroy(J); N_VDestroy(avtol); N_VDestroy(yy); - N_VDestroy(yp); + N_VDestroy(yyp); + N_VDestroy(y_cache); N_VDestroy(id); if (sensitivity) { N_VDestroyVectorArray(yyS, number_of_parameters); - N_VDestroyVectorArray(ypS, number_of_parameters); + N_VDestroyVectorArray(yypS, number_of_parameters); } IDAFree(&ida_mem); @@ -313,61 +357,28 @@ IDAKLUSolverOpenMP::~IDAKLUSolverOpenMP() { } template -Solution IDAKLUSolverOpenMP::solve( - np_array t_eval_np, - np_array t_interp_np, - np_array y0_np, - np_array yp0_np, - np_array_dense inputs +SolutionData IDAKLUSolverOpenMP::solve( + const std::vector &t_eval, + const std::vector &t_interp, + const realtype *y0, + const realtype *yp0, + const realtype *inputs, + bool save_adaptive_steps, + bool save_interp_steps ) { DEBUG("IDAKLUSolver::solve"); + const int number_of_evals = t_eval.size(); + const int number_of_interps = t_interp.size(); + + // Hermite interpolation is only available when saving + // 1. adaptive steps and 2. the full solution + save_hermite = ( + solver_opts.hermite_interpolation && + save_adaptive_steps && + !save_outputs_only + ); - // If t_interp is empty, save all adaptive steps - bool save_adaptive_steps = t_interp_np.unchecked<1>().size() == 0; - - // Process the time inputs - // 1. Get the sorted and unique t_eval vector - auto const t_eval = makeSortedUnique(t_eval_np); - - // 2.1. Get the sorted and unique t_interp vector - auto const t_interp_unique_sorted = makeSortedUnique(t_interp_np); - - // 2.2 Remove the t_eval values from t_interp - auto const t_interp_setdiff = setDiff(t_interp_unique_sorted, t_eval); - - // 2.3 Finally, get the sorted and unique t_interp vector with t_eval values removed - auto const t_interp = makeSortedUnique(t_interp_setdiff); - - int const number_of_evals = t_eval.size(); - int const number_of_interps = t_interp.size(); - - // setDiff removes entries of t_interp that overlap with - // t_eval, so we need to check if we need to interpolate any unique points. - // This is not the same as save_adaptive_steps since some entries of t_interp - // may be removed by setDiff - bool save_interp_steps = number_of_interps > 0; - - // 3. Check if the timestepping entries are valid - if (number_of_evals < 2) { - throw std::invalid_argument( - "t_eval must have at least 2 entries" - ); - } else if (save_interp_steps) { - if (t_interp.front() < t_eval.front()) { - throw std::invalid_argument( - "t_interp values must be greater than the smallest t_eval value: " - + std::to_string(t_eval.front()) - ); - } else if (t_interp.back() > t_eval.back()) { - throw std::invalid_argument( - "t_interp values must be less than the greatest t_eval value: " - + std::to_string(t_eval.back()) - ); - } - } - - // Initialize length_of_return_vector, t, y, and yS InitializeStorage(number_of_evals + number_of_interps); int i_save = 0; @@ -386,34 +397,21 @@ Solution IDAKLUSolverOpenMP::solve( t_interp_next = t_interp[0]; } - auto y0 = y0_np.unchecked<1>(); - auto yp0 = yp0_np.unchecked<1>(); auto n_coeffs = number_of_states + number_of_parameters * number_of_states; - if (y0.size() != n_coeffs) { - throw std::domain_error( - "y0 has wrong size. Expected " + std::to_string(n_coeffs) + - " but got " + std::to_string(y0.size())); - } else if (yp0.size() != n_coeffs) { - throw std::domain_error( - "yp0 has wrong size. Expected " + std::to_string(n_coeffs) + - " but got " + std::to_string(yp0.size())); - } - // set inputs - auto p_inputs = inputs.unchecked<2>(); for (int i = 0; i < functions->inputs.size(); i++) { - functions->inputs[i] = p_inputs(i, 0); + functions->inputs[i] = inputs[i]; } // Setup consistent initialization realtype *y_val = N_VGetArrayPointer(yy); - realtype *yp_val = N_VGetArrayPointer(yp); + realtype *yp_val = N_VGetArrayPointer(yyp); vector yS_val(number_of_parameters); vector ypS_val(number_of_parameters); for (int p = 0 ; p < number_of_parameters; p++) { yS_val[p] = N_VGetArrayPointer(yyS[p]); - ypS_val[p] = N_VGetArrayPointer(ypS[p]); + ypS_val[p] = N_VGetArrayPointer(yypS[p]); for (int i = 0; i < number_of_states; i++) { yS_val[p][i] = y0[i + (p + 1) * number_of_states]; ypS_val[p][i] = yp0[i + (p + 1) * number_of_states]; @@ -427,40 +425,39 @@ Solution IDAKLUSolverOpenMP::solve( SetSolverOptions(); - CheckErrors(IDAReInit(ida_mem, t0, yy, yp)); - if (sensitivity) { - CheckErrors(IDASensReInit(ida_mem, IDA_SIMULTANEOUS, yyS, ypS)); - } - // Prepare first time step i_eval = 1; realtype t_eval_next = t_eval[i_eval]; + // Consistent initialization + ReinitializeIntegrator(t0); int const init_type = solver_opts.init_all_y_ic ? IDA_Y_INIT : IDA_YA_YDP_INIT; if (solver_opts.calc_ic) { - DEBUG("IDACalcIC"); - // IDACalcIC will throw a warning if it fails to find initial conditions - IDACalcIC(ida_mem, init_type, t_eval_next); + ConsistentInitialization(t0, t_eval_next, init_type); } + // Set the initial stop time + IDASetStopTime(ida_mem, t_eval_next); + + // Progress one step. This must be done before the while loop to ensure + // that we can run IDAGetDky at t0 for dky = 1 + int retval = IDASolve(ida_mem, tf, &t_val, yy, yyp, IDA_ONE_STEP); + + // Store consistent initialization + CheckErrors(IDAGetDky(ida_mem, t0, 0, yy)); if (sensitivity) { - CheckErrors(IDAGetSens(ida_mem, &t_val, yyS)); + CheckErrors(IDAGetSensDky(ida_mem, t0, 0, yyS)); } - // Store Consistent initialization - SetStep(t0, y_val, yS_val, i_save); + SetStep(t0, y_val, yp_val, yS_val, ypS_val, i_save); - // Set the initial stop time - IDASetStopTime(ida_mem, t_eval_next); + // Reset the states at t = t_val. Sensitivities are handled in the while-loop + CheckErrors(IDAGetDky(ida_mem, t_val, 0, yy)); // Solve the system - int retval; DEBUG("IDASolve"); while (true) { - // Progress one step - retval = IDASolve(ida_mem, tf, &t_val, yy, yp, IDA_ONE_STEP); - if (retval < 0) { // failed break; @@ -478,37 +475,45 @@ Solution IDAKLUSolverOpenMP::solve( bool hit_adaptive = save_adaptive_steps && retval == IDA_SUCCESS; if (sensitivity) { - CheckErrors(IDAGetSens(ida_mem, &t_val, yyS)); + CheckErrors(IDAGetSensDky(ida_mem, t_val, 0, yyS)); } if (hit_tinterp) { // Save the interpolated state at t_prev < t < t_val, for all t in t_interp - SetStepInterp(i_interp, + SetStepInterp( + i_interp, t_interp_next, t_interp, t_val, t_prev, t_eval_next, y_val, + yp_val, yS_val, + ypS_val, i_save); } - if (hit_adaptive || hit_teval || hit_event) { + if (hit_adaptive || hit_teval || hit_event || hit_final_time) { if (hit_tinterp) { // Reset the states and sensitivities at t = t_val CheckErrors(IDAGetDky(ida_mem, t_val, 0, yy)); if (sensitivity) { - CheckErrors(IDAGetSens(ida_mem, &t_val, yyS)); + CheckErrors(IDAGetSensDky(ida_mem, t_val, 0, yyS)); } } // Save the current state at t_val - if (hit_adaptive) { - // Dynamically allocate memory for the adaptive step - ExtendAdaptiveArrays(); + // First, check to make sure that the t_val is not equal to the current t value + // If it is, we don't want to save the current state twice + if (!hit_tinterp || t_val != t.back()) { + if (hit_adaptive) { + // Dynamically allocate memory for the adaptive step + ExtendAdaptiveArrays(); + } + + SetStep(t_val, y_val, yp_val, yS_val, ypS_val, i_save); } - SetStep(t_val, y_val, yS_val, i_save); } if (hit_final_time || hit_event) { @@ -516,20 +521,19 @@ Solution IDAKLUSolverOpenMP::solve( break; } else if (hit_teval) { // Set the next stop time - i_eval += 1; + i_eval++; t_eval_next = t_eval[i_eval]; CheckErrors(IDASetStopTime(ida_mem, t_eval_next)); // Reinitialize the solver to deal with the discontinuity at t = t_val. - // We must reinitialize the algebraic terms, so do not use init_type. - IDACalcIC(ida_mem, IDA_YA_YDP_INIT, t_eval_next); - CheckErrors(IDAReInit(ida_mem, t_val, yy, yp)); - if (sensitivity) { - CheckErrors(IDASensReInit(ida_mem, IDA_SIMULTANEOUS, yyS, ypS)); - } + ReinitializeIntegrator(t_val); + ConsistentInitialization(t_val, t_eval_next, IDA_YA_YDP_INIT); } t_prev = t_val; + + // Progress one step + retval = IDASolve(ida_mem, tf, &t_val, yy, yyp, IDA_ONE_STEP); } int const length_of_final_sv_slice = save_outputs_only ? number_of_states : 0; @@ -543,8 +547,8 @@ Solution IDAKLUSolverOpenMP::solve( PrintStats(); } - int const number_of_timesteps = i_save; - int count; + // store number of timesteps so we can generate the solution later + number_of_timesteps = i_save; // Copy the data to return as numpy arrays @@ -554,23 +558,9 @@ Solution IDAKLUSolverOpenMP::solve( t_return[i] = t[i]; } - py::capsule free_t_when_done( - t_return, - [](void *f) { - realtype *vect = reinterpret_cast(f); - delete[] vect; - } - ); - - np_array t_ret = np_array( - number_of_timesteps, - &t_return[0], - free_t_when_done - ); - // States, y realtype *y_return = new realtype[number_of_timesteps * length_of_return_vector]; - count = 0; + int count = 0; for (size_t i = 0; i < number_of_timesteps; i++) { for (size_t j = 0; j < length_of_return_vector; j++) { y_return[count] = y[i][j]; @@ -578,20 +568,6 @@ Solution IDAKLUSolverOpenMP::solve( } } - py::capsule free_y_when_done( - y_return, - [](void *f) { - realtype *vect = reinterpret_cast(f); - delete[] vect; - } - ); - - np_array y_ret = np_array( - number_of_timesteps * length_of_return_vector, - &y_return[0], - free_y_when_done - ); - // Sensitivity states, yS // Note: Ordering of vector is different if computing outputs vs returning // the complete state vector @@ -614,43 +590,50 @@ Solution IDAKLUSolverOpenMP::solve( } } - py::capsule free_yS_when_done( - yS_return, - [](void *f) { - realtype *vect = reinterpret_cast(f); - delete[] vect; + realtype *yp_return = new realtype[(save_hermite ? 1 : 0) * (number_of_timesteps * number_of_states)]; + realtype *ypS_return = new realtype[(save_hermite ? 1 : 0) * (arg_sens0 * arg_sens1 * arg_sens2)]; + if (save_hermite) { + count = 0; + for (size_t i = 0; i < number_of_timesteps; i++) { + for (size_t j = 0; j < number_of_states; j++) { + yp_return[count] = yp[i][j]; + count++; + } } - ); - - np_array yS_ret = np_array( - vector { - arg_sens0, - arg_sens1, - arg_sens2 - }, - &yS_return[0], - free_yS_when_done - ); - // Final state slice, yterm - py::capsule free_yterm_when_done( - yterm_return, - [](void *f) { - realtype *vect = reinterpret_cast(f); - delete[] vect; + // Sensitivity states, ypS + // Note: Ordering of vector is different if computing outputs vs returning + // the complete state vector + count = 0; + for (size_t idx0 = 0; idx0 < arg_sens0; idx0++) { + for (size_t idx1 = 0; idx1 < arg_sens1; idx1++) { + for (size_t idx2 = 0; idx2 < arg_sens2; idx2++) { + auto i = (save_outputs_only ? idx0 : idx1); + auto j = (save_outputs_only ? idx1 : idx2); + auto k = (save_outputs_only ? idx2 : idx0); + + ypS_return[count] = ypS[i][k][j]; + count++; + } + } } - ); + } - np_array y_term = np_array( + return SolutionData( + retval, + number_of_timesteps, + length_of_return_vector, + arg_sens0, + arg_sens1, + arg_sens2, length_of_final_sv_slice, - &yterm_return[0], - free_yterm_when_done - ); - - // Store the solution - Solution sol(retval, t_ret, y_ret, yS_ret, y_term); - - return sol; + save_hermite, + t_return, + y_return, + yp_return, + yS_return, + ypS_return, + yterm_return); } template @@ -666,13 +649,78 @@ void IDAKLUSolverOpenMP::ExtendAdaptiveArrays() { if (sensitivity) { yS.emplace_back(number_of_parameters, vector(length_of_return_vector, 0.0)); } + + if (save_hermite) { + ExtendHermiteArrays(); + } +} + +template +void IDAKLUSolverOpenMP::ExtendHermiteArrays() { + DEBUG("IDAKLUSolver::ExtendHermiteArrays"); + // States + yp.emplace_back(number_of_states, 0.0); + + // Sensitivity + if (sensitivity) { + ypS.emplace_back(number_of_parameters, vector(number_of_states, 0.0)); + } +} + +template +void IDAKLUSolverOpenMP::ReinitializeIntegrator(const realtype& t_val) { + DEBUG("IDAKLUSolver::ReinitializeIntegrator"); + CheckErrors(IDAReInit(ida_mem, t_val, yy, yyp)); + if (sensitivity) { + CheckErrors(IDASensReInit(ida_mem, IDA_SIMULTANEOUS, yyS, yypS)); + } +} + +template +void IDAKLUSolverOpenMP::ConsistentInitialization( + const realtype& t_val, + const realtype& t_next, + const int& icopt) { + DEBUG("IDAKLUSolver::ConsistentInitialization"); + + if (is_ODE && icopt == IDA_YA_YDP_INIT) { + ConsistentInitializationODE(t_val); + } else { + ConsistentInitializationDAE(t_val, t_next, icopt); + } +} + +template +void IDAKLUSolverOpenMP::ConsistentInitializationDAE( + const realtype& t_val, + const realtype& t_next, + const int& icopt) { + DEBUG("IDAKLUSolver::ConsistentInitializationDAE"); + IDACalcIC(ida_mem, icopt, t_next); +} + +template +void IDAKLUSolverOpenMP::ConsistentInitializationODE( + const realtype& t_val) { + DEBUG("IDAKLUSolver::ConsistentInitializationODE"); + + // For ODEs where the mass matrix M = I, we can simplify the problem + // by analytically computing the yp values. If we take our implicit + // DAE system res(t,y,yp) = f(t,y) - I*yp, then yp = res(t,y,0). This + // avoids an expensive call to IDACalcIC. + realtype *y_cache_val = N_VGetArrayPointer(y_cache); + std::memset(y_cache_val, 0, number_of_states * sizeof(realtype)); + // Overwrite yp + residual_eval(t_val, yy, y_cache, yyp, functions.get()); } template void IDAKLUSolverOpenMP::SetStep( realtype &tval, realtype *y_val, + realtype *yp_val, vector const &yS_val, + vector const &ypS_val, int &i_save ) { // Set adaptive step results for y and yS @@ -685,6 +733,10 @@ void IDAKLUSolverOpenMP::SetStep( SetStepOutput(tval, y_val, yS_val, i_save); } else { SetStepFull(tval, y_val, yS_val, i_save); + + if (save_hermite) { + SetStepHermite(tval, yp_val, ypS_val, i_save); + } } i_save++; @@ -700,7 +752,9 @@ void IDAKLUSolverOpenMP::SetStepInterp( realtype &t_prev, realtype const &t_eval_next, realtype *y_val, + realtype *yp_val, vector const &yS_val, + vector const &ypS_val, int &i_save ) { // Save the state at the requested time @@ -713,7 +767,7 @@ void IDAKLUSolverOpenMP::SetStepInterp( } // Memory is already allocated for the interpolated values - SetStep(t_interp_next, y_val, yS_val, i_save); + SetStep(t_interp_next, y_val, yp_val, yS_val, ypS_val, i_save); i_interp++; if (i_interp == (t_interp.size())) { @@ -825,12 +879,55 @@ void IDAKLUSolverOpenMP::SetStepOutputSensitivities( } } +template +void IDAKLUSolverOpenMP::SetStepHermite( + realtype &tval, + realtype *yp_val, + vector const &ypS_val, + int &i_save +) { + // Set adaptive step results for yp and ypS + DEBUG("IDAKLUSolver::SetStepHermite"); + + // States + CheckErrors(IDAGetDky(ida_mem, tval, 1, yyp)); + auto &yp_back = yp[i_save]; + for (size_t j = 0; j < length_of_return_vector; ++j) { + yp_back[j] = yp_val[j]; + + } + + // Sensitivity + if (sensitivity) { + SetStepHermiteSensitivities(tval, yp_val, ypS_val, i_save); + } +} + +template +void IDAKLUSolverOpenMP::SetStepHermiteSensitivities( + realtype &tval, + realtype *yp_val, + vector const &ypS_val, + int &i_save +) { + DEBUG("IDAKLUSolver::SetStepHermiteSensitivities"); + + // Calculate sensitivities for the full ypS array + CheckErrors(IDAGetSensDky(ida_mem, tval, 1, yypS)); + for (size_t j = 0; j < number_of_parameters; ++j) { + auto &ypS_back_j = ypS[i_save][j]; + auto &ypSval_j = ypS_val[j]; + for (size_t k = 0; k < number_of_states; ++k) { + ypS_back_j[k] = ypSval_j[k]; + } + } +} + template void IDAKLUSolverOpenMP::CheckErrors(int const & flag) { if (flag < 0) { - auto message = (std::string("IDA failed with flag ") + std::to_string(flag)).c_str(); - py::set_error(PyExc_ValueError, message); - throw py::error_already_set(); + auto message = std::string("IDA failed with flag ") + std::to_string(flag); + throw std::runtime_error(message.c_str()); } } diff --git a/src/pybamm/solvers/c_solvers/idaklu/Options.cpp b/src/pybamm/solvers/c_solvers/idaklu/Options.cpp index b6a33e016e..8eb605fe77 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/Options.cpp +++ b/src/pybamm/solvers/c_solvers/idaklu/Options.cpp @@ -1,6 +1,7 @@ #include "Options.hpp" #include #include +#include using namespace std::string_literals; @@ -11,9 +12,25 @@ SetupOptions::SetupOptions(py::dict &py_opts) precon_half_bandwidth(py_opts["precon_half_bandwidth"].cast()), precon_half_bandwidth_keep(py_opts["precon_half_bandwidth_keep"].cast()), num_threads(py_opts["num_threads"].cast()), + num_solvers(py_opts["num_solvers"].cast()), linear_solver(py_opts["linear_solver"].cast()), linsol_max_iterations(py_opts["linsol_max_iterations"].cast()) { + if (num_solvers > num_threads) + { + throw std::domain_error( + "Number of solvers must be less than or equal to the number of threads" + ); + } + + // input num_threads is the overall number of threads to use. num_solvers of these + // will be used to run solvers in parallel, leaving num_threads / num_solvers threads + // to be used by each solver. From here on num_threads is the number of threads to be used by each solver + num_threads = static_cast( + std::floor( + static_cast(num_threads) / static_cast(num_solvers) + ) + ); using_sparse_matrix = true; using_banded_matrix = false; @@ -132,6 +149,7 @@ SolverOptions::SolverOptions(py::dict &py_opts) nonlinear_convergence_coefficient(RCONST(py_opts["nonlinear_convergence_coefficient"].cast())), nonlinear_convergence_coefficient_ic(RCONST(py_opts["nonlinear_convergence_coefficient_ic"].cast())), suppress_algebraic_error(py_opts["suppress_algebraic_error"].cast()), + hermite_interpolation(py_opts["hermite_interpolation"].cast()), // IDA initial conditions calculation calc_ic(py_opts["calc_ic"].cast()), init_all_y_ic(py_opts["init_all_y_ic"].cast()), diff --git a/src/pybamm/solvers/c_solvers/idaklu/Options.hpp b/src/pybamm/solvers/c_solvers/idaklu/Options.hpp index 66a175cfff..7418c68ec3 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/Options.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/Options.hpp @@ -15,6 +15,7 @@ struct SetupOptions { int precon_half_bandwidth; int precon_half_bandwidth_keep; int num_threads; + int num_solvers; // IDALS linear solver interface std::string linear_solver; // klu, lapack, spbcg int linsol_max_iterations; @@ -37,6 +38,7 @@ struct SolverOptions { double nonlinear_convergence_coefficient; double nonlinear_convergence_coefficient_ic; sunbooleantype suppress_algebraic_error; + bool hermite_interpolation; // IDA initial conditions calculation bool calc_ic; bool init_all_y_ic; diff --git a/src/pybamm/solvers/c_solvers/idaklu/Solution.hpp b/src/pybamm/solvers/c_solvers/idaklu/Solution.hpp index 72d48fa644..8227bb9da8 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/Solution.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/Solution.hpp @@ -9,18 +9,30 @@ class Solution { public: + /** + * @brief Default Constructor + */ + Solution() = default; + /** * @brief Constructor */ - Solution(int &retval, np_array &t_np, np_array &y_np, np_array &yS_np, np_array &y_term_np) - : flag(retval), t(t_np), y(y_np), yS(yS_np), y_term(y_term_np) + Solution(int &retval, np_array &t_np, np_array &y_np, np_array &yp_np, np_array &yS_np, np_array &ypS_np, np_array &y_term_np) + : flag(retval), t(t_np), y(y_np), yp(yp_np), yS(yS_np), ypS(ypS_np), y_term(y_term_np) { } + /** + * @brief Default copy from another Solution + */ + Solution(const Solution &solution) = default; + int flag; np_array t; np_array y; + np_array yp; np_array yS; + np_array ypS; np_array y_term; }; diff --git a/src/pybamm/solvers/c_solvers/idaklu/SolutionData.cpp b/src/pybamm/solvers/c_solvers/idaklu/SolutionData.cpp new file mode 100644 index 0000000000..bc48c646d3 --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/SolutionData.cpp @@ -0,0 +1,99 @@ +#include "SolutionData.hpp" + +Solution SolutionData::generate_solution() { + py::capsule free_t_when_done( + t_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array t_ret = np_array( + number_of_timesteps, + &t_return[0], + free_t_when_done + ); + + py::capsule free_y_when_done( + y_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array y_ret = np_array( + number_of_timesteps * length_of_return_vector, + &y_return[0], + free_y_when_done + ); + + py::capsule free_yp_when_done( + yp_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array yp_ret = np_array( + (save_hermite ? 1 : 0) * number_of_timesteps * length_of_return_vector, + &yp_return[0], + free_yp_when_done + ); + + py::capsule free_yS_when_done( + yS_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array yS_ret = np_array( + std::vector { + arg_sens0, + arg_sens1, + arg_sens2 + }, + &yS_return[0], + free_yS_when_done + ); + + py::capsule free_ypS_when_done( + ypS_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array ypS_ret = np_array( + std::vector { + (save_hermite ? 1 : 0) * arg_sens0, + arg_sens1, + arg_sens2 + }, + &ypS_return[0], + free_ypS_when_done + ); + + // Final state slice, yterm + py::capsule free_yterm_when_done( + yterm_return, + [](void *f) { + realtype *vect = reinterpret_cast(f); + delete[] vect; + } + ); + + np_array y_term = np_array( + length_of_final_sv_slice, + &yterm_return[0], + free_yterm_when_done + ); + + // Store the solution + return Solution(flag, t_ret, y_ret, yp_ret, yS_ret, ypS_ret, y_term); +} diff --git a/src/pybamm/solvers/c_solvers/idaklu/SolutionData.hpp b/src/pybamm/solvers/c_solvers/idaklu/SolutionData.hpp new file mode 100644 index 0000000000..81ca7f5221 --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/SolutionData.hpp @@ -0,0 +1,82 @@ +#ifndef PYBAMM_IDAKLU_SOLUTION_DATA_HPP +#define PYBAMM_IDAKLU_SOLUTION_DATA_HPP + + +#include "common.hpp" +#include "Solution.hpp" + +/** + * @brief SolutionData class. Contains all the data needed to create a Solution + */ +class SolutionData +{ + public: + /** + * @brief Default constructor + */ + SolutionData() = default; + + /** + * @brief constructor using fields + */ + SolutionData( + int flag, + int number_of_timesteps, + int length_of_return_vector, + int arg_sens0, + int arg_sens1, + int arg_sens2, + int length_of_final_sv_slice, + bool save_hermite, + realtype *t_return, + realtype *y_return, + realtype *yp_return, + realtype *yS_return, + realtype *ypS_return, + realtype *yterm_return): + flag(flag), + number_of_timesteps(number_of_timesteps), + length_of_return_vector(length_of_return_vector), + arg_sens0(arg_sens0), + arg_sens1(arg_sens1), + arg_sens2(arg_sens2), + length_of_final_sv_slice(length_of_final_sv_slice), + save_hermite(save_hermite), + t_return(t_return), + y_return(y_return), + yp_return(yp_return), + yS_return(yS_return), + ypS_return(ypS_return), + yterm_return(yterm_return) + {} + + + /** + * @brief Default copy from another SolutionData + */ + SolutionData(const SolutionData &solution_data) = default; + + /** + * @brief Create a solution object from this data + */ + Solution generate_solution(); + +private: + + int flag; + int number_of_timesteps; + int length_of_return_vector; + int arg_sens0; + int arg_sens1; + int arg_sens2; + int length_of_final_sv_slice; + bool save_hermite; + realtype *t_return; + realtype *y_return; + realtype *yp_return; + realtype *yS_return; + realtype *ypS_return; + realtype *yterm_return; +}; + +#endif // PYBAMM_IDAKLU_SOLUTION_DATA_HPP diff --git a/src/pybamm/solvers/c_solvers/idaklu/common.cpp b/src/pybamm/solvers/c_solvers/idaklu/common.cpp index bf38acc56a..161c14f340 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/common.cpp +++ b/src/pybamm/solvers/c_solvers/idaklu/common.cpp @@ -11,21 +11,9 @@ std::vector numpy2realtype(const np_array& input_np) { return output; } -std::vector setDiff(const std::vector& A, const std::vector& B) { - std::vector result; - if (!(A.empty())) { - std::set_difference(A.begin(), A.end(), B.begin(), B.end(), std::back_inserter(result)); - } - return result; -} -std::vector makeSortedUnique(const std::vector& input) { - std::unordered_set uniqueSet(input.begin(), input.end()); // Remove duplicates - std::vector uniqueVector(uniqueSet.begin(), uniqueSet.end()); // Convert to vector - std::sort(uniqueVector.begin(), uniqueVector.end()); // Sort the vector - return uniqueVector; -} std::vector makeSortedUnique(const np_array& input_np) { - return makeSortedUnique(numpy2realtype(input_np)); + const auto input_vec = numpy2realtype(input_np); + return makeSortedUnique(input_vec.begin(), input_vec.end()); } diff --git a/src/pybamm/solvers/c_solvers/idaklu/common.hpp b/src/pybamm/solvers/c_solvers/idaklu/common.hpp index 3289326541..90672080b6 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/common.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/common.hpp @@ -31,8 +31,9 @@ #include namespace py = pybind11; -using np_array = py::array_t; -using np_array_dense = py::array_t; +// note: we rely on c_style ordering for numpy arrays so don't change this! +using np_array = py::array_t; +using np_array_realtype = py::array_t; using np_array_int = py::array_t; /** @@ -83,12 +84,25 @@ std::vector numpy2realtype(const np_array& input_np); /** * @brief Utility function to compute the set difference of two vectors */ -std::vector setDiff(const std::vector& A, const std::vector& B); +template +std::vector setDiff(const T1 a_begin, const T1 a_end, const T2 b_begin, const T2 b_end) { + std::vector result; + if (std::distance(a_begin, a_end) > 0) { + std::set_difference(a_begin, a_end, b_begin, b_end, std::back_inserter(result)); + } + return result; +} /** * @brief Utility function to make a sorted and unique vector */ -std::vector makeSortedUnique(const std::vector& input); +template +std::vector makeSortedUnique(const T input_begin, const T input_end) { + std::unordered_set uniqueSet(input_begin, input_end); // Remove duplicates + std::vector uniqueVector(uniqueSet.begin(), uniqueSet.end()); // Convert to vector + std::sort(uniqueVector.begin(), uniqueVector.end()); // Sort the vector + return uniqueVector; +} std::vector makeSortedUnique(const np_array& input_np); @@ -126,8 +140,7 @@ std::vector makeSortedUnique(const np_array& input_np); } \ std::cout << "]" << std::endl; } -#define DEBUG_v(v, M) {\ - int N = 2; \ +#define DEBUG_v(v, N) {\ std::cout << #v << "[n=" << N << "] = ["; \ for (int i = 0; i < N; i++) { \ std::cout << v[i]; \ diff --git a/src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp b/src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp index ce1765aa82..dcc1e4f8cc 100644 --- a/src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp +++ b/src/pybamm/solvers/c_solvers/idaklu/idaklu_solver.hpp @@ -2,6 +2,7 @@ #define PYBAMM_CREATE_IDAKLU_SOLVER_HPP #include "IDAKLUSolverOpenMP_solvers.hpp" +#include "IDAKLUSolverGroup.hpp" #include #include @@ -12,52 +13,21 @@ */ template IDAKLUSolver *create_idaklu_solver( - int number_of_states, + std::unique_ptr functions, int number_of_parameters, - const typename ExprSet::BaseFunctionType &rhs_alg, - const typename ExprSet::BaseFunctionType &jac_times_cjmass, const np_array_int &jac_times_cjmass_colptrs, const np_array_int &jac_times_cjmass_rowvals, const int jac_times_cjmass_nnz, const int jac_bandwidth_lower, const int jac_bandwidth_upper, - const typename ExprSet::BaseFunctionType &jac_action, - const typename ExprSet::BaseFunctionType &mass_action, - const typename ExprSet::BaseFunctionType &sens, - const typename ExprSet::BaseFunctionType &events, const int number_of_events, np_array rhs_alg_id, np_array atol_np, double rel_tol, int inputs_length, - const std::vector& var_fcns, - const std::vector& dvar_dy_fcns, - const std::vector& dvar_dp_fcns, - py::dict py_opts + SolverOptions solver_opts, + SetupOptions setup_opts ) { - auto setup_opts = SetupOptions(py_opts); - auto solver_opts = SolverOptions(py_opts); - auto functions = std::make_unique( - rhs_alg, - jac_times_cjmass, - jac_times_cjmass_nnz, - jac_bandwidth_lower, - jac_bandwidth_upper, - jac_times_cjmass_rowvals, - jac_times_cjmass_colptrs, - inputs_length, - jac_action, - mass_action, - sens, - events, - number_of_states, - number_of_events, - number_of_parameters, - var_fcns, - dvar_dy_fcns, - dvar_dp_fcns, - setup_opts - ); IDAKLUSolver *idakluSolver = nullptr; @@ -189,4 +159,88 @@ IDAKLUSolver *create_idaklu_solver( return idakluSolver; } +/** + * @brief Create a group of solvers using create_idaklu_solver + */ +template +IDAKLUSolverGroup *create_idaklu_solver_group( + int number_of_states, + int number_of_parameters, + const typename ExprSet::BaseFunctionType &rhs_alg, + const typename ExprSet::BaseFunctionType &jac_times_cjmass, + const np_array_int &jac_times_cjmass_colptrs, + const np_array_int &jac_times_cjmass_rowvals, + const int jac_times_cjmass_nnz, + const int jac_bandwidth_lower, + const int jac_bandwidth_upper, + const typename ExprSet::BaseFunctionType &jac_action, + const typename ExprSet::BaseFunctionType &mass_action, + const typename ExprSet::BaseFunctionType &sens, + const typename ExprSet::BaseFunctionType &events, + const int number_of_events, + np_array rhs_alg_id, + np_array atol_np, + double rel_tol, + int inputs_length, + const std::vector& var_fcns, + const std::vector& dvar_dy_fcns, + const std::vector& dvar_dp_fcns, + py::dict py_opts +) { + auto setup_opts = SetupOptions(py_opts); + auto solver_opts = SolverOptions(py_opts); + + + std::vector> solvers; + for (int i = 0; i < setup_opts.num_solvers; i++) { + // Note: we can't copy an ExprSet as it contains raw pointers to the functions + // So we create it in the loop + auto functions = std::make_unique( + rhs_alg, + jac_times_cjmass, + jac_times_cjmass_nnz, + jac_bandwidth_lower, + jac_bandwidth_upper, + jac_times_cjmass_rowvals, + jac_times_cjmass_colptrs, + inputs_length, + jac_action, + mass_action, + sens, + events, + number_of_states, + number_of_events, + number_of_parameters, + var_fcns, + dvar_dy_fcns, + dvar_dp_fcns, + setup_opts + ); + solvers.emplace_back( + std::unique_ptr( + create_idaklu_solver( + std::move(functions), + number_of_parameters, + jac_times_cjmass_colptrs, + jac_times_cjmass_rowvals, + jac_times_cjmass_nnz, + jac_bandwidth_lower, + jac_bandwidth_upper, + number_of_events, + rhs_alg_id, + atol_np, + rel_tol, + inputs_length, + solver_opts, + setup_opts + ) + ) + ); + } + + return new IDAKLUSolverGroup(std::move(solvers), number_of_states, number_of_parameters); +} + + + #endif // PYBAMM_CREATE_IDAKLU_SOLVER_HPP diff --git a/src/pybamm/solvers/c_solvers/idaklu/observe.cpp b/src/pybamm/solvers/c_solvers/idaklu/observe.cpp new file mode 100644 index 0000000000..6661bdecc9 --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/observe.cpp @@ -0,0 +1,352 @@ +#include "observe.hpp" + +int _setup_len_spatial(const std::vector& shape) { + // Calculate the product of all dimensions except the last (spatial dimensions) + int size_spatial = 1; + for (size_t i = 0; i < shape.size() - 1; ++i) { + size_spatial *= shape[i]; + } + + if (size_spatial == 0 || shape.back() == 0) { + throw std::invalid_argument("output array must have at least one element"); + } + + return size_spatial; +} + +// Coupled observe and Hermite interpolation of variables +class HermiteInterpolator { +public: + HermiteInterpolator(const py::detail::unchecked_reference& t, + const py::detail::unchecked_reference& y, + const py::detail::unchecked_reference& yp) + : t(t), y(y), yp(yp) {} + + void compute_knots(const size_t j, vector& c, vector& d) const { + // Called at the start of each interval + const realtype h_full = t(j + 1) - t(j); + const realtype inv_h = 1.0 / h_full; + const realtype inv_h2 = inv_h * inv_h; + const realtype inv_h3 = inv_h2 * inv_h; + + for (size_t i = 0; i < y.shape(0); ++i) { + realtype y_ij = y(i, j); + realtype yp_ij = yp(i, j); + realtype y_ijp1 = y(i, j + 1); + realtype yp_ijp1 = yp(i, j + 1); + + c[i] = 3.0 * (y_ijp1 - y_ij) * inv_h2 - (2.0 * yp_ij + yp_ijp1) * inv_h; + d[i] = 2.0 * (y_ij - y_ijp1) * inv_h3 + (yp_ij + yp_ijp1) * inv_h2; + } + } + + void interpolate(vector& entries, + realtype t_interp, + const size_t j, + vector& c, + vector& d) const { + // Must be called after compute_knots + const realtype h = t_interp - t(j); + const realtype h2 = h * h; + const realtype h3 = h2 * h; + + for (size_t i = 0; i < entries.size(); ++i) { + realtype y_ij = y(i, j); + realtype yp_ij = yp(i, j); + entries[i] = y_ij + yp_ij * h + c[i] * h2 + d[i] * h3; + } + } + +private: + const py::detail::unchecked_reference& t; + const py::detail::unchecked_reference& y; + const py::detail::unchecked_reference& yp; +}; + +class TimeSeriesInterpolator { +public: + TimeSeriesInterpolator(const np_array_realtype& _t_interp, + const vector& _ts_data, + const vector& _ys_data, + const vector& _yps_data, + const vector& _inputs, + const vector>& _funcs, + realtype* _entries, + const int _size_spatial) + : t_interp_np(_t_interp), ts_data_np(_ts_data), ys_data_np(_ys_data), + yps_data_np(_yps_data), inputs_np(_inputs), funcs(_funcs), + entries(_entries), size_spatial(_size_spatial) {} + + void process() { + auto t_interp = t_interp_np.unchecked<1>(); + ssize_t i_interp = 0; + int i_entries = 0; + const ssize_t N_interp = t_interp.size(); + + // Main processing within bounds + process_within_bounds(i_interp, i_entries, t_interp, N_interp); + + // Extrapolation for remaining points + if (i_interp < N_interp) { + extrapolate_remaining(i_interp, i_entries, t_interp, N_interp); + } + } + + void process_within_bounds( + ssize_t& i_interp, + int& i_entries, + const py::detail::unchecked_reference& t_interp, + const ssize_t N_interp + ) { + for (size_t i = 0; i < ts_data_np.size(); i++) { + const auto& t_data = ts_data_np[i].unchecked<1>(); + // Continue if there is no data + if (t_data.size() == 0) { + continue; + } + + const realtype t_data_final = t_data(t_data.size() - 1); + realtype t_interp_next = t_interp(i_interp); + // Continue if the next interpolation point is beyond the final data point + if (t_interp_next > t_data_final) { + continue; + } + + const auto& y_data = ys_data_np[i].unchecked<2>(); + const auto& yp_data = yps_data_np[i].unchecked<2>(); + const auto input = inputs_np[i].data(); + const auto func = *funcs[i]; + + resize_arrays(y_data.shape(0), funcs[i]); + args[1] = y_buffer.data(); + args[2] = input; + + ssize_t j = 0; + ssize_t j_prev = -1; + const auto itp = HermiteInterpolator(t_data, y_data, yp_data); + while (t_interp_next <= t_data_final) { + for (; j < t_data.size() - 2; ++j) { + if (t_data(j) <= t_interp_next && t_interp_next <= t_data(j + 1)) { + break; + } + } + + if (j != j_prev) { + // Compute c and d for the new interval + itp.compute_knots(j, c, d); + } + + itp.interpolate(y_buffer, t_interp(i_interp), j, c, d); + + args[0] = &t_interp(i_interp); + results[0] = &entries[i_entries]; + func(args.data(), results.data(), iw.data(), w.data(), 0); + + ++i_interp; + if (i_interp == N_interp) { + return; + } + t_interp_next = t_interp(i_interp); + i_entries += size_spatial; + j_prev = j; + } + } + } + + void extrapolate_remaining( + ssize_t& i_interp, + int& i_entries, + const py::detail::unchecked_reference& t_interp, + const ssize_t N_interp + ) { + const auto& t_data = ts_data_np.back().unchecked<1>(); + const auto& y_data = ys_data_np.back().unchecked<2>(); + const auto& yp_data = yps_data_np.back().unchecked<2>(); + const auto input = inputs_np.back().data(); + const auto func = *funcs.back(); + const ssize_t j = t_data.size() - 2; + + resize_arrays(y_data.shape(0), funcs.back()); + args[1] = y_buffer.data(); + args[2] = input; + + const auto itp = HermiteInterpolator(t_data, y_data, yp_data); + itp.compute_knots(j, c, d); + + for (; i_interp < N_interp; ++i_interp) { + const realtype t_interp_next = t_interp(i_interp); + itp.interpolate(y_buffer, t_interp_next, j, c, d); + + args[0] = &t_interp_next; + results[0] = &entries[i_entries]; + func(args.data(), results.data(), iw.data(), w.data(), 0); + + i_entries += size_spatial; + } + } + + void resize_arrays(const int M, std::shared_ptr func) { + args.resize(func->sz_arg()); + results.resize(func->sz_res()); + iw.resize(func->sz_iw()); + w.resize(func->sz_w()); + if (y_buffer.size() < M) { + y_buffer.resize(M); + c.resize(M); + d.resize(M); + } + } + +private: + const np_array_realtype& t_interp_np; + const vector& ts_data_np; + const vector& ys_data_np; + const vector& yps_data_np; + const vector& inputs_np; + const vector>& funcs; + realtype* entries; + const int size_spatial; + vector c; + vector d; + vector y_buffer; + vector args; + vector results; + vector iw; + vector w; +}; + +// Observe the raw data +class TimeSeriesProcessor { +public: + TimeSeriesProcessor(const vector& _ts, + const vector& _ys, + const vector& _inputs, + const vector>& _funcs, + realtype* _entries, + const bool _is_f_contiguous, + const int _size_spatial) + : ts(_ts), ys(_ys), inputs(_inputs), funcs(_funcs), + entries(_entries), is_f_contiguous(_is_f_contiguous), size_spatial(_size_spatial) {} + + void process() { + int i_entries = 0; + for (size_t i = 0; i < ts.size(); i++) { + const auto& t = ts[i].unchecked<1>(); + // Continue if there is no data + if (t.size() == 0) { + continue; + } + const auto& y = ys[i].unchecked<2>(); + const auto input = inputs[i].data(); + const auto func = *funcs[i]; + + resize_arrays(y.shape(0), funcs[i]); + args[2] = input; + + for (size_t j = 0; j < t.size(); j++) { + const realtype t_val = t(j); + const realtype* y_val = is_f_contiguous ? &y(0, j) : copy_to_buffer(y_buffer, y, j); + + args[0] = &t_val; + args[1] = y_val; + results[0] = &entries[i_entries]; + + func(args.data(), results.data(), iw.data(), w.data(), 0); + + i_entries += size_spatial; + } + } + } + +private: + const realtype* copy_to_buffer( + vector& entries, + const py::detail::unchecked_reference& y, + size_t j) { + for (size_t i = 0; i < entries.size(); ++i) { + entries[i] = y(i, j); + } + + return entries.data(); + } + + void resize_arrays(const int M, std::shared_ptr func) { + args.resize(func->sz_arg()); + results.resize(func->sz_res()); + iw.resize(func->sz_iw()); + w.resize(func->sz_w()); + if (!is_f_contiguous && y_buffer.size() < M) { + y_buffer.resize(M); + } + } + + const vector& ts; + const vector& ys; + const vector& inputs; + const vector>& funcs; + realtype* entries; + const bool is_f_contiguous; + int size_spatial; + vector y_buffer; + vector args; + vector results; + vector iw; + vector w; +}; + +const np_array_realtype observe_hermite_interp( + const np_array_realtype& t_interp_np, + const vector& ts_np, + const vector& ys_np, + const vector& yps_np, + const vector& inputs_np, + const vector& strings, + const vector& shape +) { + const int size_spatial = _setup_len_spatial(shape); + const auto& funcs = setup_casadi_funcs(strings); + py::array_t out_array(shape); + auto entries = out_array.mutable_data(); + + TimeSeriesInterpolator(t_interp_np, ts_np, ys_np, yps_np, inputs_np, funcs, entries, size_spatial).process(); + + return out_array; +} + +const np_array_realtype observe( + const vector& ts_np, + const vector& ys_np, + const vector& inputs_np, + const vector& strings, + const bool is_f_contiguous, + const vector& shape +) { + const int size_spatial = _setup_len_spatial(shape); + const auto& funcs = setup_casadi_funcs(strings); + py::array_t out_array(shape); + auto entries = out_array.mutable_data(); + + TimeSeriesProcessor(ts_np, ys_np, inputs_np, funcs, entries, is_f_contiguous, size_spatial).process(); + + return out_array; +} + +const vector> setup_casadi_funcs(const vector& strings) { + std::unordered_map> function_cache; + vector> funcs(strings.size()); + + for (size_t i = 0; i < strings.size(); ++i) { + const std::string& str = strings[i]; + + // Check if function is already in the local cache + if (function_cache.find(str) == function_cache.end()) { + // If not in the cache, create a new casadi::Function::deserialize and store it + function_cache[str] = std::make_shared(casadi::Function::deserialize(str)); + } + + // Retrieve the function from the cache as a shared pointer + funcs[i] = function_cache[str]; + } + + return funcs; +} diff --git a/src/pybamm/solvers/c_solvers/idaklu/observe.hpp b/src/pybamm/solvers/c_solvers/idaklu/observe.hpp new file mode 100644 index 0000000000..52a0cfdf84 --- /dev/null +++ b/src/pybamm/solvers/c_solvers/idaklu/observe.hpp @@ -0,0 +1,47 @@ +#ifndef PYBAMM_CREATE_OBSERVE_HPP +#define PYBAMM_CREATE_OBSERVE_HPP + +#include +#include +#include +#include "common.hpp" +#include +#include +using std::vector; + +#if defined(_MSC_VER) + #include + typedef SSIZE_T ssize_t; +#endif + +/** + * @brief Observe and Hermite interpolate ND variables + */ +const np_array_realtype observe_hermite_interp( + const np_array_realtype& t_interp, + const vector& ts, + const vector& ys, + const vector& yps, + const vector& inputs, + const vector& strings, + const vector& shape +); + + +/** + * @brief Observe ND variables + */ +const np_array_realtype observe( + const vector& ts_np, + const vector& ys_np, + const vector& inputs_np, + const vector& strings, + const bool is_f_contiguous, + const vector& shape +); + +const vector> setup_casadi_funcs(const vector& strings); + +int _setup_len_spatial(const vector& shape); + +#endif // PYBAMM_CREATE_OBSERVE_HPP diff --git a/src/pybamm/solvers/casadi_algebraic_solver.py b/src/pybamm/solvers/casadi_algebraic_solver.py index cf44912952..b139199f8c 100644 --- a/src/pybamm/solvers/casadi_algebraic_solver.py +++ b/src/pybamm/solvers/casadi_algebraic_solver.py @@ -146,11 +146,9 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): ) else: raise pybamm.SolverError( - f""" - Could not find acceptable solution: solver terminated - successfully, but maximum solution error ({casadi.mmax(casadi.fabs(fun))}) - above tolerance ({self.tol}) - """ + "Could not find acceptable solution: solver terminated " + f"successfully, but maximum solution error ({casadi.mmax(casadi.fabs(fun))}) " + f"above tolerance ({self.tol})" ) # Concatenate differential part @@ -170,7 +168,7 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): model, inputs_dict, termination="final time", - sensitivities=explicit_sensitivities, + all_sensitivities=explicit_sensitivities, ) sol.integration_time = integration_time return sol diff --git a/src/pybamm/solvers/casadi_solver.py b/src/pybamm/solvers/casadi_solver.py index b4ac9d1561..89e20631dd 100644 --- a/src/pybamm/solvers/casadi_solver.py +++ b/src/pybamm/solvers/casadi_solver.py @@ -193,7 +193,7 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): y0, model, inputs_dict, - sensitivities=False, + all_sensitivities=False, ) solution.solve_time = 0 solution.integration_time = 0 @@ -478,7 +478,7 @@ def integer_bisect(): np.array([t_event]), y_event[:, np.newaxis], "event", - sensitivities=bool(model.calculate_sensitivities), + all_sensitivities=False, ) solution.integration_time = ( coarse_solution.integration_time + dense_step_sol.integration_time @@ -696,7 +696,7 @@ def _run_integrator( y_sol, model, inputs_dict, - sensitivities=extract_sensitivities_in_solution, + all_sensitivities=extract_sensitivities_in_solution, check_solution=False, ) sol.integration_time = integration_time @@ -736,7 +736,7 @@ def _run_integrator( y_sol, model, inputs_dict, - sensitivities=extract_sensitivities_in_solution, + all_sensitivities=extract_sensitivities_in_solution, check_solution=False, ) sol.integration_time = integration_time diff --git a/src/pybamm/solvers/idaklu_solver.py b/src/pybamm/solvers/idaklu_solver.py index 41e0c8855f..484c1ed9b4 100644 --- a/src/pybamm/solvers/idaklu_solver.py +++ b/src/pybamm/solvers/idaklu_solver.py @@ -29,7 +29,9 @@ idaklu = importlib.util.module_from_spec(idaklu_spec) if idaklu_spec.loader: idaklu_spec.loader.exec_module(idaklu) - except ImportError: # pragma: no cover + except ImportError as e: # pragma: no cover + idaklu = None + print(f"Error loading idaklu: {e}") idaklu_spec = None @@ -78,8 +80,10 @@ class IDAKLUSolver(pybamm.BaseSolver): options = { # Print statistics of the solver after every solve "print_stats": False, - # Number of threads available for OpenMP + # Number of threads available for OpenMP (must be greater than or equal to `num_solvers`) "num_threads": 1, + # Number of solvers to use in parallel (for solving multiple sets of input parameters in parallel) + "num_solvers": num_threads, # Evaluation engine to use for jax, can be 'jax'(native) or 'iree' "jax_evaluator": "jax", ## Linear solver interface @@ -130,6 +134,10 @@ class IDAKLUSolver(pybamm.BaseSolver): "nonlinear_convergence_coefficient": 0.33, # Suppress algebraic variables from error test "suppress_algebraic_error": False, + # Store Hermite interpolation data for the solution. + # Note: this option is always disabled if output_variables are given + # or if t_interp values are specified + "hermite_interpolation": True, ## Initial conditions calculation # Positive constant in the Newton iteration convergence test within the # initial condition calculation @@ -182,6 +190,7 @@ def __init__( "precon_half_bandwidth": 5, "precon_half_bandwidth_keep": 5, "num_threads": 1, + "num_solvers": 1, "jax_evaluator": "jax", "linear_solver": "SUNLinSol_KLU", "linsol_max_iterations": 5, @@ -197,6 +206,7 @@ def __init__( "max_convergence_failures": 100, "nonlinear_convergence_coefficient": 0.33, "suppress_algebraic_error": False, + "hermite_interpolation": True, "nonlinear_convergence_coefficient_ic": 0.0033, "max_num_steps_ic": 50, "max_num_jacobians_ic": 40, @@ -209,6 +219,8 @@ def __init__( if options is None: options = default_options else: + if "num_threads" in options and "num_solvers" not in options: + options["num_solvers"] = options["num_threads"] for key, value in default_options.items(): if key not in options: options[key] = value @@ -443,7 +455,7 @@ def inputs_to_dict(inputs): if model.convert_to_format == "casadi": # Serialize casadi functions - idaklu_solver_fcn = idaklu.create_casadi_solver + idaklu_solver_fcn = idaklu.create_casadi_solver_group rhs_algebraic = idaklu.generate_function(rhs_algebraic.serialize()) jac_times_cjmass = idaklu.generate_function(jac_times_cjmass.serialize()) jac_rhs_algebraic_action = idaklu.generate_function( @@ -457,7 +469,7 @@ def inputs_to_dict(inputs): and self._options["jax_evaluator"] == "iree" ): # Convert Jax functions to MLIR (also, demote to single precision) - idaklu_solver_fcn = idaklu.create_iree_solver + idaklu_solver_fcn = idaklu.create_iree_solver_group pybamm.demote_expressions_to_32bit = True if pybamm.demote_expressions_to_32bit: warnings.warn( @@ -726,7 +738,15 @@ def _check_mlir_conversion(self, name, mlir: str): def _demote_64_to_32(self, x: pybamm.EvaluatorJax): return pybamm.EvaluatorJax._demote_64_to_32(x) - def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): + @property + def supports_parallel_solve(self): + return True + + @property + def requires_explicit_sensitivities(self): + return False + + def _integrate(self, model, t_eval, inputs_list=None, t_interp=None): """ Solve a DAE model defined by residuals with initial conditions y0. @@ -736,50 +756,68 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): The model whose solution to calculate. t_eval : numeric type The times at which to stop the integration due to a discontinuity in time. - inputs_dict : dict, optional + inputs_list: list of dict, optional Any input parameters to pass to the model when solving. t_interp : None, list or ndarray, optional The times (in seconds) at which to interpolate the solution. Defaults to `None`, which returns the adaptive time-stepping times. """ - inputs_dict = inputs_dict or {} - # stack inputs - if inputs_dict: - arrays_to_stack = [np.array(x).reshape(-1, 1) for x in inputs_dict.values()] - inputs = np.vstack(arrays_to_stack) + if not ( + model.convert_to_format == "casadi" + or ( + model.convert_to_format == "jax" + and self._options["jax_evaluator"] == "iree" + ) + ): # pragma: no cover + # Shouldn't ever reach this point + raise pybamm.SolverError("Unsupported IDAKLU solver configuration.") + + inputs_list = inputs_list or [{}] + + # stack inputs so that they are a 2D array of shape (number_of_inputs, number_of_parameters) + if inputs_list and inputs_list[0]: + inputs = np.vstack( + [ + np.hstack([np.array(x).reshape(-1) for x in inputs_dict.values()]) + for inputs_dict in inputs_list + ] + ) else: inputs = np.array([[]]) - y0full = model.y0full - ydot0full = model.ydot0full + # stack y0full and ydot0full so they are a 2D array of shape (number_of_inputs, number_of_states + number_of_parameters * number_of_states) + # note that y0full and ydot0full are currently 1D arrays (i.e. independent of inputs), but in the future we will support + # different initial conditions for different inputs (see https://github.com/pybamm-team/PyBaMM/pull/4260). For now we just repeat the same initial conditions for each input + y0full = np.vstack([model.y0full] * len(inputs_list)) + ydot0full = np.vstack([model.ydot0full] * len(inputs_list)) atol = getattr(model, "atol", self.atol) atol = self._check_atol_type(atol, y0full.size) timer = pybamm.Timer() - if model.convert_to_format == "casadi" or ( - model.convert_to_format == "jax" - and self._options["jax_evaluator"] == "iree" - ): - sol = self._setup["solver"].solve( - t_eval, - t_interp, - y0full, - ydot0full, - inputs, - ) - else: # pragma: no cover - # Shouldn't ever reach this point - raise pybamm.SolverError("Unsupported IDAKLU solver configuration.") + solns = self._setup["solver"].solve( + t_eval, + t_interp, + y0full, + ydot0full, + inputs, + ) integration_time = timer.time() + return [ + self._post_process_solution(soln, model, integration_time, inputs_dict) + for soln, inputs_dict in zip(solns, inputs_list) + ] + + def _post_process_solution(self, sol, model, integration_time, inputs_dict): number_of_sensitivity_parameters = self._setup[ "number_of_sensitivity_parameters" ] sensitivity_names = self._setup["sensitivity_names"] number_of_timesteps = sol.t.size number_of_states = model.len_rhs_and_alg - if self.output_variables: + save_outputs_only = self.output_variables + if save_outputs_only: # Substitute empty vectors for state vector 'y' y_out = np.zeros((number_of_timesteps * number_of_states, 0)) y_event = sol.y_term @@ -810,6 +848,11 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): else: raise pybamm.SolverError(f"FAILURE {self._solver_flag(sol.flag)}") + if sol.yp.size > 0: + yp = sol.yp.reshape((number_of_timesteps, number_of_states)).T + else: + yp = None + newsol = pybamm.Solution( sol.t, np.transpose(y_out), @@ -818,11 +861,13 @@ def _integrate(self, model, t_eval, inputs_dict=None, t_interp=None): np.array([sol.t[-1]]), np.transpose(y_event)[:, np.newaxis], termination, - sensitivities=yS_out, + all_sensitivities=yS_out, + all_yps=yp, + variables_returned=bool(save_outputs_only), ) + newsol.integration_time = integration_time - if not self.output_variables: - # print((newsol.y).shape) + if not save_outputs_only: return newsol # Populate variables and sensititivies dictionaries directly @@ -964,7 +1009,7 @@ def _rhs_dot_consistent_initialization(self, y0, model, time, inputs_dict): rhs0 = rhs_alg0[: model.len_rhs] - # for the differential terms, ydot = -M^-1 * (rhs) + # for the differential terms, ydot = M^-1 * (rhs) ydot0[: model.len_rhs] = model.mass_matrix_inv.entries @ rhs0 return ydot0 diff --git a/src/pybamm/solvers/jax_bdf_solver.py b/src/pybamm/solvers/jax_bdf_solver.py index 6f0c62b9a8..3fd3f2384e 100644 --- a/src/pybamm/solvers/jax_bdf_solver.py +++ b/src/pybamm/solvers/jax_bdf_solver.py @@ -28,7 +28,7 @@ MIN_FACTOR = 0.2 MAX_FACTOR = 10 - # https://github.com/google/jax/issues/4572#issuecomment-709809897 + # https://github.com/jax-ml/jax/issues/4572#issuecomment-709809897 def some_hash_function(x): return hash(str(x)) @@ -68,7 +68,7 @@ def caller(*args): def _bdf_odeint(fun, mass, rtol, atol, y0, t_eval, *args): """ Implements a Backward Difference formula (BDF) implicit multistep integrator. - The basic algorithm is derived in :footcite:t:`byrne1975polyalgorithm`. This + The basic algorithm is derived in :footcite:t:`Byrne1975`. This particular implementation follows that implemented in the Matlab routine ode15s described in :footcite:t:`shamphine1997matlab` and the SciPy implementation :footcite:t:`Virtanen2020`, which features the NDF formulas for improved @@ -111,68 +111,63 @@ def fun_bind_inputs(y, t): jac_bind_inputs = jax.jacfwd(fun_bind_inputs, argnums=0) - t0 = t_eval[0] - h0 = t_eval[1] - t0 - + t0, h0 = t_eval[0], t_eval[1] - t_eval[0] stepper = _bdf_init( fun_bind_inputs, jac_bind_inputs, mass, t0, y0, h0, rtol, atol ) - i = 0 y_out = jnp.empty((len(t_eval), len(y0)), dtype=y0.dtype) - init_state = [stepper, t_eval, i, y_out] - def cond_fun(state): - _, t_eval, i, _ = state + _, _, i, _ = state return i < len(t_eval) def body_fun(state): stepper, t_eval, i, y_out = state stepper = _bdf_step(stepper, fun_bind_inputs, jac_bind_inputs) - index = jnp.searchsorted(t_eval, stepper.t) - index = index.astype( - "int" + t_eval.dtype.name[-2:] - ) # Coerce index to correct type + index = jnp.searchsorted(t_eval, stepper.t).astype(jnp.int32) - def for_body(j, y_out): - t = t_eval[j] - y_out = y_out.at[jnp.index_exp[j, :]].set(_bdf_interpolate(stepper, t)) - return y_out + def interpolate_and_update(j, y_out): + y = _bdf_interpolate(stepper, t_eval[j]) + return y_out.at[j].set(y) - y_out = jax.lax.fori_loop(i, index, for_body, y_out) - return [stepper, t_eval, index, y_out] + y_out = jax.lax.fori_loop(i, index, interpolate_and_update, y_out) + return stepper, t_eval, index, y_out + + init_state = (stepper, t_eval, 0, y_out) + _, _, _, y_out = jax.lax.while_loop(cond_fun, body_fun, init_state) - stepper, t_eval, i, y_out = jax.lax.while_loop(cond_fun, body_fun, init_state) return y_out - BDFInternalStates = [ - "t", - "atol", - "rtol", - "M", - "newton_tol", - "order", - "h", - "n_equal_steps", - "D", - "y0", - "scale_y0", - "kappa", - "gamma", - "alpha", - "c", - "error_const", - "J", - "LU", - "U", - "psi", - "n_function_evals", - "n_jacobian_evals", - "n_lu_decompositions", - "n_steps", - "consistent_y0_failed", - ] - BDFState = collections.namedtuple("BDFState", BDFInternalStates) + BDFState = collections.namedtuple( + "BDFState", + [ + "t", + "atol", + "rtol", + "M", + "newton_tol", + "order", + "h", + "n_equal_steps", + "D", + "y0", + "scale_y0", + "kappa", + "gamma", + "alpha", + "c", + "error_const", + "J", + "LU", + "U", + "psi", + "n_function_evals", + "n_jacobian_evals", + "n_lu_decompositions", + "n_steps", + "consistent_y0_failed", + ], + ) jax.tree_util.register_pytree_node( BDFState, lambda xs: (tuple(xs), None), lambda _, xs: BDFState(*xs) @@ -211,62 +206,70 @@ def _bdf_init(fun, jac, mass, t0, y0, h0, rtol, atol): absolute tolerance for the solver """ - state = {} - state["t"] = t0 - state["atol"] = atol - state["rtol"] = rtol - state["M"] = mass EPS = jnp.finfo(y0.dtype).eps - state["newton_tol"] = jnp.maximum(10 * EPS / rtol, jnp.minimum(0.03, rtol**0.5)) + # Scaling and tolerance initialisation scale_y0 = atol + rtol * jnp.abs(y0) + newton_tol = jnp.maximum(10 * EPS / rtol, jnp.minimum(0.03, rtol**0.5)) + y0, not_converged = _select_initial_conditions( - fun, mass, t0, y0, state["newton_tol"], scale_y0 + fun, mass, t0, y0, newton_tol, scale_y0 ) - state["consistent_y0_failed"] = not_converged + # Compute initial function and step size f0 = fun(y0, t0) - order = 1 - state["order"] = order - state["h"] = _select_initial_step(atol, rtol, fun, t0, y0, f0, h0) - state["n_equal_steps"] = 0 + h = _select_initial_step(atol, rtol, fun, t0, y0, f0, h0) + + # Initialise the difference matrix, D D = jnp.empty((MAX_ORDER + 1, len(y0)), dtype=y0.dtype) D = D.at[jnp.index_exp[0, :]].set(y0) - D = D.at[jnp.index_exp[1, :]].set(f0 * state["h"]) - state["D"] = D - state["y0"] = y0 - state["scale_y0"] = scale_y0 + D = D.at[jnp.index_exp[1, :]].set(f0 * h) # kappa values for difference orders, taken from Table 1 of [1] kappa = jnp.array([0, -0.1850, -1 / 9, -0.0823, -0.0415, 0]) gamma = jnp.hstack((0, jnp.cumsum(1 / jnp.arange(1, MAX_ORDER + 1)))) alpha = 1.0 / ((1 - kappa) * gamma) - c = state["h"] * alpha[order] + c = h * alpha[1] error_const = kappa * gamma + 1 / jnp.arange(1, MAX_ORDER + 2) - state["kappa"] = kappa - state["gamma"] = gamma - state["alpha"] = alpha - state["c"] = c - state["error_const"] = error_const - + # Jacobian and LU decomp J = jac(y0, t0) - state["J"] = J - - state["LU"] = jax.scipy.linalg.lu_factor(state["M"] - c * J) - - state["U"] = _compute_R(order, 1) - state["psi"] = None - - state["n_function_evals"] = 2 - state["n_jacobian_evals"] = 1 - state["n_lu_decompositions"] = 1 - state["n_steps"] = 0 + LU = jax.scipy.linalg.lu_factor(mass - c * J) + U = _compute_R(1, 1) # Order 1 + + # Create initial BDFState + state = BDFState( + t=t0, + atol=atol, + rtol=rtol, + M=mass, + newton_tol=newton_tol, + consistent_y0_failed=not_converged, + order=1, + h=h, + n_equal_steps=0, + D=D, + y0=y0, + scale_y0=scale_y0, + kappa=kappa, + gamma=gamma, + alpha=alpha, + c=c, + error_const=error_const, + J=J, + LU=LU, + U=U, + psi=None, + n_function_evals=2, + n_jacobian_evals=1, + n_lu_decompositions=1, + n_steps=0, + ) - tuple_state = BDFState(*[state[k] for k in BDFInternalStates]) - y0, scale_y0 = _predict(tuple_state, D) - psi = _update_psi(tuple_state, D) - return tuple_state._replace(y0=y0, scale_y0=scale_y0, psi=psi) + # Predict initial y0, scale_yo, update state + y0, scale_y0 = _predict(state, D) + psi = _update_psi(state, D) + return state._replace(y0=y0, scale_y0=scale_y0, psi=psi) def _compute_R(order, factor): """ @@ -359,7 +362,7 @@ def _select_initial_step(atol, rtol, fun, t0, y0, f0, h0): comparing the predicted state against that using the provided function. Optimal step size based on the selected order is obtained using formula (4.12) - in :footcite:t:`hairer1993solving`. + in :footcite:t:`Hairer1993`. """ scale = atol + jnp.abs(y0) * rtol @@ -374,10 +377,8 @@ def _predict(state, D): """ predict forward to new step (eq 2 in [1]) """ - n = len(state.y0) - order = state.order - orders = jnp.repeat(jnp.arange(MAX_ORDER + 1).reshape(-1, 1), n, axis=1) - subD = jnp.where(orders <= order, D, 0) + orders = jnp.arange(MAX_ORDER + 1)[:, None] + subD = jnp.where(orders <= state.order, D, 0) y0 = jnp.sum(subD, axis=0) scale_y0 = state.atol + state.rtol * jnp.abs(state.y0) return y0, scale_y0 @@ -397,7 +398,7 @@ def _update_psi(state, D): def _update_difference_for_next_step(state, d): """ - update of difference equations can be done efficiently + Update of difference equations can be done efficiently by reusing d and D. From first equation on page 4 of [1]: @@ -409,34 +410,21 @@ def _update_difference_for_next_step(state, d): Combining these gives the following algorithm """ order = state.order - D = state.D - D = D.at[jnp.index_exp[order + 2]].set(d - D[order + 1]) - D = D.at[jnp.index_exp[order + 1]].set(d) - i = order - while_state = [i, D] - - def while_cond(while_state): - i, _ = while_state - return i >= 0 + D = state.D.at[order + 2].set(d - state.D[order + 1]) + D = D.at[order + 1].set(d) - def while_body(while_state): - i, D = while_state - D = D.at[jnp.index_exp[i]].add(D[i + 1]) - i -= 1 - return [i, D] + def update_D(i, D): + return D.at[order - i].add(D[order - i + 1]) - i, D = jax.lax.while_loop(while_cond, while_body, while_state) - - return D + return jax.lax.fori_loop(0, order + 1, update_D, D) def _update_step_size_and_lu(state, factor): + """ + Update step size and recompute LU decomposition. + """ state = _update_step_size(state, factor) - - # redo lu (c has changed) LU = jax.scipy.linalg.lu_factor(state.M - state.c * state.J) - n_lu_decompositions = state.n_lu_decompositions + 1 - - return state._replace(LU=LU, n_lu_decompositions=n_lu_decompositions) + return state._replace(LU=LU, n_lu_decompositions=state.n_lu_decompositions + 1) def _update_step_size(state, factor): """ @@ -449,7 +437,6 @@ def _update_step_size(state, factor): """ order = state.order h = state.h * factor - n_equal_steps = 0 c = h * state.alpha[order] # update D using equations in section 3.2 of [1] @@ -461,19 +448,14 @@ def _update_step_size(state, factor): RU = jnp.where( jnp.logical_and(I <= order, J <= order), RU, jnp.identity(MAX_ORDER + 1) ) - D = state.D - D = jnp.dot(RU.T, D) - # D = jax.ops.index_update(D, jax.ops.index[:order + 1], - # jnp.dot(RU.T, D[:order + 1])) + D = jnp.dot(RU.T, state.D) - # update psi (D has changed) + # update psi, y0 (D has changed) psi = _update_psi(state, D) - - # update y0 (D has changed) y0, scale_y0 = _predict(state, D) return state._replace( - n_equal_steps=n_equal_steps, + n_equal_steps=0, h=h, c=c, D=D, @@ -484,27 +466,23 @@ def _update_step_size(state, factor): def _update_jacobian(state, jac): """ - we update the jacobian using J(t_{n+1}, y^0_{n+1}) + Update the jacobian using J(t_{n+1}, y^0_{n+1}) following the scipy bdf implementation rather than J(t_n, y_n) as per [1] """ J = jac(state.y0, state.t + state.h) - n_jacobian_evals = state.n_jacobian_evals + 1 LU = jax.scipy.linalg.lu_factor(state.M - state.c * J) - n_lu_decompositions = state.n_lu_decompositions + 1 return state._replace( J=J, - n_jacobian_evals=n_jacobian_evals, + n_jacobian_evals=state.n_jacobian_evals + 1, LU=LU, - n_lu_decompositions=n_lu_decompositions, + n_lu_decompositions=state.n_lu_decompositions + 1, ) def _newton_iteration(state, fun): - tol = state.newton_tol - c = state.c - psi = state.psi + """ + Perform Newton iteration to solve the system. + """ y0 = state.y0 - LU = state.LU - M = state.M scale_y0 = state.scale_y0 t = state.t + state.h d = jnp.zeros(y0.shape, dtype=y0.dtype) @@ -522,17 +500,20 @@ def while_cond(while_state): def while_body(while_state): k, converged, dy_norm_old, d, y, n_function_evals = while_state + f_eval = fun(y, t) n_function_evals += 1 - b = c * f_eval - M @ (psi + d) - dy = jax.scipy.linalg.lu_solve(LU, b) + b = state.c * f_eval - state.M @ (state.psi + d) + dy = jax.scipy.linalg.lu_solve(state.LU, b) dy_norm = jnp.sqrt(jnp.mean((dy / scale_y0) ** 2)) rate = dy_norm / dy_norm_old # if iteration is not going to converge in NEWTON_MAXITER # (assuming the current rate), then abort pred = rate >= 1 - pred += rate ** (NEWTON_MAXITER - k) / (1 - rate) * dy_norm > tol + pred += ( + rate ** (NEWTON_MAXITER - k) / (1 - rate) * dy_norm > state.newton_tol + ) pred *= dy_norm_old >= 0 k += pred * (NEWTON_MAXITER - k - 1) @@ -541,7 +522,7 @@ def while_body(while_state): # if converged then break out of iteration early pred = dy_norm_old >= 0.0 - pred *= rate / (1 - rate) * dy_norm < tol + pred *= rate / (1 - rate) * dy_norm < state.newton_tol converged = (dy_norm == 0.0) + pred dy_norm_old = dy_norm @@ -564,7 +545,6 @@ def _prepare_next_step(state, d): def _prepare_next_step_order_change(state, d, y, n_iter): order = state.order - D = _update_difference_for_next_step(state, d) # Note: we are recalculating these from the while loop above, could re-use? @@ -586,7 +566,6 @@ def _prepare_next_step_order_change(state, d, y, n_iter): rms_norm(state.error_const[order + 1] * D[order + 2] / scale_y), jnp.inf, ) - error_norms = jnp.array([error_m_norm, error_norm, error_p_norm]) factors = error_norms ** (-1 / (jnp.arange(3) + order)) @@ -595,111 +574,89 @@ def _prepare_next_step_order_change(state, d, y, n_iter): max_index = jnp.argmax(factors) order += max_index - 1 + # New step size factor factor = jnp.minimum(MAX_FACTOR, safety * factors[max_index]) - - new_state = _update_step_size_and_lu(state._replace(D=D, order=order), factor) - return new_state + new_state = state._replace(D=D, order=order) + return _update_step_size_and_lu(new_state, factor) def _bdf_step(state, fun, jac): - # print('bdf_step', state.t, state.h) - # we will try and use the old jacobian unless convergence of newton iteration - # fails - updated_jacobian = False - # initialise step size and try to make the step, - # iterate, reducing step size until error is in bounds - step_accepted = False - y = jnp.empty_like(state.y0) - d = jnp.empty_like(state.y0) - n_iter = -1 - - # loop until step is accepted - while_state = [state, step_accepted, updated_jacobian, y, d, n_iter] + """ + Perform a BDF step. - def while_cond(while_state): - _, step_accepted, _, _, _, _ = while_state - return step_accepted == False # noqa: E712 + We will try and use the old jacobian unless + convergence of newton iteration fails. + """ - def while_body(while_state): - state, step_accepted, updated_jacobian, y, d, n_iter = while_state + def step_iteration(while_state): + state, updated_jacobian = while_state - # solve BDF equation using y0 as starting point + # Solve BDF equation using Newton iteration converged, n_iter, y, d, state = _newton_iteration(state, fun) - not_converged = converged == False # noqa: E712 - - # newton iteration did not converge, but jacobian has already been - # evaluated so reduce step size by 0.3 (as per [1]) and try again - state = tree_map( - partial(jnp.where, not_converged * updated_jacobian), - _update_step_size_and_lu(state, 0.3), - state, - ) - # if not_converged * updated_jacobian: - # print('not converged, update step size by 0.3') - # if not_converged * (updated_jacobian == False): - # print('not converged, update jacobian') - - # if not converged and jacobian not updated, then update the jacobian and - # try again - (state, updated_jacobian) = tree_map( - partial( - jnp.where, - not_converged * (updated_jacobian == False), # noqa: E712 + # Update Jacobian or reduce step size if not converged + # Evaluated so reduce step size by 0.3 (as per [1]) and try again + state, updated_jacobian = jax.lax.cond( + ~converged, + lambda s, uj: jax.lax.cond( + uj, + lambda s: (_update_step_size_and_lu(s, 0.3), True), + lambda s: (_update_jacobian(s, jac), True), + s, ), - (_update_jacobian(state, jac), True), - (state, False + updated_jacobian), + lambda s, uj: (s, uj), + state, + updated_jacobian, ) safety = 0.9 * (2 * NEWTON_MAXITER + 1) / (2 * NEWTON_MAXITER + n_iter) scale_y = state.atol + state.rtol * jnp.abs(y) + # Calculate error and updated step size factor # combine eq 3, 4 and 6 from [1] to obtain error # Note that error = C_k * h^{k+1} y^{k+1} # and d = D^{k+1} y_{n+1} \approx h^{k+1} y^{k+1} error = state.error_const[state.order] * d - error_norm = rms_norm(error / scale_y) - # calculate optimal step size factor as per eq 2.46 of [2] - factor = jnp.maximum( - MIN_FACTOR, safety * error_norm ** (-1 / (state.order + 1)) + # Calculate optimal step size factor as per eq 2.46 of [2] + factor = jnp.clip( + safety * error_norm ** (-1 / (state.order + 1)), MIN_FACTOR, None ) - # if converged * (error_norm > 1): - # print( - # "converged, but error is too large", - # error_norm, - # factor, - # d, - # scale_y, - # ) - - (state, step_accepted) = tree_map( - partial(jnp.where, converged * (error_norm > 1)), - (_update_step_size_and_lu(state, factor), False), - (state, converged), + # Update step size if error is too large + state = jax.lax.cond( + converged & (error_norm > 1), + lambda s: _update_step_size_and_lu(s, factor), + lambda s: s, + state, ) - return [state, step_accepted, updated_jacobian, y, d, n_iter] - - state, step_accepted, updated_jacobian, y, d, n_iter = jax.lax.while_loop( - while_cond, while_body, while_state + step_accepted = converged & (error_norm <= 1) + return (state, updated_jacobian), (step_accepted, y, d, n_iter) + + # Iterate until step is accepted + (state, _), (_, y, d, n_iter) = jax.lax.while_loop( + lambda carry_and_aux: ~carry_and_aux[1][0], + lambda carry_and_aux: step_iteration(carry_and_aux[0]), + ( + (state, False), + (False, jnp.empty_like(state.y0), jnp.empty_like(state.y0), -1), + ), ) - # take the accepted step + # Update state for the accepted step n_steps = state.n_steps + 1 t = state.t + state.h - - # a change in order is only done after running at order k for k + 1 steps - # (see page 83 of [2]) n_equal_steps = state.n_equal_steps + 1 - state = state._replace(n_equal_steps=n_equal_steps, t=t, n_steps=n_steps) - state = tree_map( - partial(jnp.where, n_equal_steps < state.order + 1), - _prepare_next_step(state, d), - _prepare_next_step_order_change(state, d, y, n_iter), + # Prepare for the next step, potentially changing order + # (see page 83 of [2]) + state = jax.lax.cond( + n_equal_steps < state.order + 1, + lambda s: _prepare_next_step(s, d), + lambda s: _prepare_next_step_order_change(s, d, y, n_iter), + state, ) return state @@ -710,8 +667,6 @@ def _bdf_interpolate(state, t_eval): definition of the interpolating polynomial can be found on page 7 of [1] """ - order = state.order - t = state.t h = state.h D = state.D j = 0 @@ -721,11 +676,11 @@ def _bdf_interpolate(state, t_eval): def while_cond(while_state): j, _, _ = while_state - return j < order + return j < state.order def while_body(while_state): j, time_factor, order_summation = while_state - time_factor *= (t_eval - (t - h * j)) / (h * (1 + j)) + time_factor *= (t_eval - (state.t - h * j)) / (h * (1 + j)) order_summation += D[j + 1] * time_factor j += 1 return [j, time_factor, order_summation] @@ -756,7 +711,7 @@ def block_fun(i, j, Ai, Aj): return onp.block(blocks) # NOTE: the code below (except the docstring on jax_bdf_integrate and other minor - # edits), has been modified from the JAX library at https://github.com/google/jax. + # edits), has been modified from the JAX library at https://github.com/jax-ml/jax. # The main difference is the addition of support for semi-explicit dae index 1 # problems via the addition of a mass matrix. # This is under an Apache license, a short form of which is given here: @@ -971,14 +926,14 @@ def ravel_first_arg_(unravel, y_flat, *args): def jax_bdf_integrate(func, y0, t_eval, *args, rtol=1e-6, atol=1e-6, mass=None): """ Backward Difference formula (BDF) implicit multistep integrator. The basic algorithm - is derived in :footcite:t:`byrne1975polyalgorithm`. This particular implementation + is derived in :footcite:t:`Byrne1975`. This particular implementation follows that implemented in the Matlab routine ode15s described in - :footcite:t:`shampine1997matlab` and the SciPy implementation + :footcite:t:`Shampine1997` and the SciPy implementation :footcite:t:`Virtanen2020` which features the NDF formulas for improved stability, with associated differences in the error constants, and calculates the jacobian at J(t_{n+1}, y^0_{n+1}). This implementation was based on that implemented in the SciPy library :footcite:t:`Virtanen2020`, which also mainly follows - :footcite:t:`shampine1997matlab` but uses the more standard jacobian update. + :footcite:t:`Shampine1997` but uses the more standard jacobian update. Parameters ---------- diff --git a/src/pybamm/solvers/jax_solver.py b/src/pybamm/solvers/jax_solver.py index da5fd4983a..c4f5a60a64 100644 --- a/src/pybamm/solvers/jax_solver.py +++ b/src/pybamm/solvers/jax_solver.py @@ -31,10 +31,10 @@ class JaxSolver(pybamm.BaseSolver): Parameters ---------- method: str, optional (see `jax.experimental.ode.odeint` for details) - * 'RK45' (default) uses jax.experimental.ode.odeint - * 'BDF' uses custom jax_bdf_integrate (see `jax_bdf_integrate.py` for details) + * 'BDF' (default) uses custom jax_bdf_integrate (see `jax_bdf_integrate.py` for details) + * 'RK45' uses jax.experimental.ode.odeint root_method: str, optional - Method to use to calculate consistent initial conditions. By default this uses + Method to use to calculate consistent initial conditions. By default, this uses the newton chord method internal to the jax bdf solver, otherwise choose from the set of default options defined in docs for pybamm.BaseSolver rtol : float, optional @@ -46,13 +46,13 @@ class JaxSolver(pybamm.BaseSolver): extra_options : dict, optional Any options to pass to the solver. Please consult `JAX documentation - `_ + `_ for details. """ def __init__( self, - method="RK45", + method="BDF", root_method=None, rtol=1e-6, atol=1e-6, @@ -185,6 +185,14 @@ def solve_model_bdf(inputs): else: return jax.jit(solve_model_bdf) + @property + def supports_parallel_solve(self): + return True + + @property + def requires_explicit_sensitivities(self): + return False + def _integrate(self, model, t_eval, inputs=None, t_interp=None): """ Solve a model defined by dydt with initial conditions y0. @@ -200,7 +208,7 @@ def _integrate(self, model, t_eval, inputs=None, t_interp=None): Returns ------- - object + list of `pybamm.Solution` An object containing the times and values of the solution, as well as various diagnostic messages. @@ -255,8 +263,8 @@ async def solve_model_async(inputs_v): # sparse matrix support in JAX resulting in high memory usage, or a bug # in the BDF solver. # - # This issue on guthub appears related: - # https://github.com/google/jax/discussions/13930 + # This issue on GitHub appears related: + # https://github.com/jax-ml/jax/discussions/13930 # # # Split input list based on the number of available xla devices # device_count = jax.local_device_count() @@ -301,6 +309,4 @@ async def solve_model_async(inputs_v): sol.integration_time = integration_time solutions.append(sol) - if len(solutions) == 1: - return solutions[0] return solutions diff --git a/src/pybamm/solvers/processed_variable.py b/src/pybamm/solvers/processed_variable.py index 8c1190c2f4..3de6e4bd50 100644 --- a/src/pybamm/solvers/processed_variable.py +++ b/src/pybamm/solvers/processed_variable.py @@ -1,11 +1,13 @@ # # Processed Variable class # +from typing import Optional import casadi import numpy as np import pybamm from scipy.integrate import cumulative_trapezoid import xarray as xr +import bisect class ProcessedVariable: @@ -23,14 +25,13 @@ class ProcessedVariable: Note that this can be any kind of node in the expression tree, not just a :class:`pybamm.Variable`. When evaluated, returns an array of size (m,n) - base_variable_casadis : list of :class:`casadi.Function` + base_variables_casadi : list of :class:`casadi.Function` A list of casadi functions. When evaluated, returns the same thing as `base_Variable.evaluate` (but more efficiently). solution : :class:`pybamm.Solution` The solution object to be used to create the processed variables - warn : bool, optional - Whether to raise warnings when trying to evaluate time and length scales. - Default is True. + time_integral : :class:`pybamm.ProcessedVariableTimeIntegral`, optional + Not none if the variable is to be time-integrated (default is None) """ def __init__( @@ -38,22 +39,21 @@ def __init__( base_variables, base_variables_casadi, solution, - warn=True, - cumtrapz_ic=None, + time_integral: Optional[pybamm.ProcessedVariableTimeIntegral] = None, ): self.base_variables = base_variables self.base_variables_casadi = base_variables_casadi self.all_ts = solution.all_ts self.all_ys = solution.all_ys + self.all_yps = solution.all_yps self.all_inputs = solution.all_inputs self.all_inputs_casadi = solution.all_inputs_casadi self.mesh = base_variables[0].mesh self.domain = base_variables[0].domain self.domains = base_variables[0].domains - self.warn = warn - self.cumtrapz_ic = cumtrapz_ic + self.time_integral = time_integral # Process spatial variables geometry = solution.all_models[0].geometry @@ -66,7 +66,7 @@ def __init__( # Sensitivity starts off uninitialized, only set when called self._sensitivities = None - self.solution_sensitivities = solution.sensitivities + self.all_solution_sensitivities = solution._all_sensitivities # Store time self.t_pts = solution.t @@ -75,46 +75,447 @@ def __init__( self.base_eval_shape = self.base_variables[0].shape self.base_eval_size = self.base_variables[0].size - # xr_data_array is initialized - self._xr_data_array = None + self._xr_array_raw = None + self._entries_raw = None + self._entries_for_interp_raw = None + self._coords_raw = None - # handle 2D (in space) finite element variables differently - if ( - self.mesh - and "current collector" in self.domain - and isinstance(self.mesh, pybamm.ScikitSubMesh2D) + def initialise(self): + if self.entries_raw_initialized: + return + + entries = self.observe_raw() + + t = self.t_pts + entries_for_interp, coords = self._interp_setup(entries, t) + + self._entries_raw = entries + self._entries_for_interp_raw = entries_for_interp + self._coords_raw = coords + + def observe_and_interp(self, t, fill_value): + """ + Interpolate the variable at the given time points and y values. + t must be a sorted array of time points. + """ + + entries = self._observe_hermite_cpp(t) + processed_entries = self._observe_postfix(entries, t) + + tf = self.t_pts[-1] + if t[-1] > tf and fill_value != "extrapolate": + # fill the rest + idx = np.searchsorted(t, tf, side="right") + processed_entries[..., idx:] = fill_value + + return processed_entries + + def observe_raw(self): + """ + Evaluate the base variable at the given time points and y values. + """ + t = self.t_pts + + # For small number of points, use Python + if pybamm.has_idaklu(): + entries = self._observe_raw_cpp() + else: + # Fallback method for when IDAKLU is not available. To be removed + # when the C++ code is migrated to a new repo + entries = self._observe_raw_python() # pragma: no cover + + return self._observe_postfix(entries, t) + + def _setup_cpp_inputs(self, t, full_range): + pybamm.logger.debug("Setting up C++ interpolation inputs") + + ts = self.all_ts + ys = self.all_ys + yps = self.all_yps + inputs = self.all_inputs_casadi + + # Remove all empty ts + idxs = np.where([ti.size > 0 for ti in ts])[0] + + # Find the indices of the time points to observe + if not full_range: + ts_nonempty = [ts[idx] for idx in idxs] + idxs_subset = _find_ts_indices(ts_nonempty, t) + idxs = idxs[idxs_subset] + + # Extract the time points and inputs + ts = [ts[idx] for idx in idxs] + ys = [ys[idx] for idx in idxs] + if self.hermite_interpolation: + yps = [yps[idx] for idx in idxs] + inputs = [self.all_inputs_casadi[idx] for idx in idxs] + + is_f_contiguous = _is_f_contiguous(ys) + + ts = pybamm.solvers.idaklu_solver.idaklu.VectorRealtypeNdArray(ts) + ys = pybamm.solvers.idaklu_solver.idaklu.VectorRealtypeNdArray(ys) + if self.hermite_interpolation: + yps = pybamm.solvers.idaklu_solver.idaklu.VectorRealtypeNdArray(yps) + else: + yps = None + inputs = pybamm.solvers.idaklu_solver.idaklu.VectorRealtypeNdArray(inputs) + + # Generate the serialized C++ functions only once + funcs_unique = {} + funcs = [None] * len(idxs) + for i in range(len(idxs)): + vars = self.base_variables_casadi[idxs[i]] + if vars not in funcs_unique: + funcs_unique[vars] = vars.serialize() + funcs[i] = funcs_unique[vars] + + return ts, ys, yps, funcs, inputs, is_f_contiguous + + def _observe_hermite_cpp(self, t): + pybamm.logger.debug("Observing and Hermite interpolating the variable in C++") + + ts, ys, yps, funcs, inputs, _ = self._setup_cpp_inputs(t, full_range=False) + shapes = self._shape(t) + return pybamm.solvers.idaklu_solver.idaklu.observe_hermite_interp( + t, ts, ys, yps, inputs, funcs, shapes + ) + + def _observe_raw_cpp(self): + pybamm.logger.debug("Observing the variable raw data in C++") + t = self.t_pts + ts, ys, _, funcs, inputs, is_f_contiguous = self._setup_cpp_inputs( + t, full_range=True + ) + shapes = self._shape(self.t_pts) + + return pybamm.solvers.idaklu_solver.idaklu.observe( + ts, ys, inputs, funcs, is_f_contiguous, shapes + ) + + def _observe_raw_python(self): + raise NotImplementedError # pragma: no cover + + def _observe_postfix(self, entries, t): + return entries + + def _interp_setup(self, entries, t): + raise NotImplementedError # pragma: no cover + + def _shape(self, t): + raise NotImplementedError # pragma: no cover + + def _process_spatial_variable_names(self, spatial_variable): + if len(spatial_variable) == 0: + return None + + # Extract names + raw_names = [] + for var in spatial_variable: + # Ignore tabs in domain names + if var == "tabs": + continue + if isinstance(var, str): + raw_names.append(var) + else: + raw_names.append(var.name) + + # Rename battery variables to match PyBaMM convention + if all([var.startswith("r") for var in raw_names]): + return "r" + elif all([var.startswith("x") for var in raw_names]): + return "x" + elif all([var.startswith("R") for var in raw_names]): + return "R" + elif len(raw_names) == 1: + return raw_names[0] + else: + raise NotImplementedError( + f"Spatial variable name not recognized for {spatial_variable}" + ) + + def __call__( + self, + t=None, + x=None, + r=None, + y=None, + z=None, + R=None, + fill_value=np.nan, + ): + # Check to see if we are interpolating exactly onto the raw solution time points + t_observe, observe_raw = self._check_observe_raw(t) + + # Check if the time points are sorted and unique + is_sorted = observe_raw or _is_sorted(t_observe) + + # Sort them if not + if not is_sorted: + idxs_sort = np.argsort(t_observe) + t_observe = t_observe[idxs_sort] + + hermite_time_interp = ( + pybamm.has_idaklu() and self.hermite_interpolation and not observe_raw + ) + + if hermite_time_interp: + entries = self.observe_and_interp(t_observe, fill_value) + + spatial_interp = any(a is not None for a in [x, r, y, z, R]) + + xr_interp = spatial_interp or not hermite_time_interp + + if xr_interp: + if hermite_time_interp: + # Already interpolated in time + t = None + entries_for_interp, coords = self._interp_setup(entries, t_observe) + else: + self.initialise() + entries_for_interp, coords = ( + self._entries_for_interp_raw, + self._coords_raw, + ) + + if self.time_integral is None: + processed_entries = self._xr_interpolate( + entries_for_interp, + coords, + observe_raw, + t, + x, + r, + y, + z, + R, + fill_value, + ) + else: + processed_entries = entries_for_interp + else: + processed_entries = entries + + if not is_sorted: + idxs_unsort = np.zeros_like(idxs_sort) + idxs_unsort[idxs_sort] = np.arange(len(t_observe)) + + processed_entries = processed_entries[..., idxs_unsort] + + # Remove a singleton time dimension if we interpolate in time with hermite + if hermite_time_interp and t_observe.size == 1: + processed_entries = np.squeeze(processed_entries, axis=-1) + + return processed_entries + + def _xr_interpolate( + self, + entries_for_interp, + coords, + observe_raw, + t=None, + x=None, + r=None, + y=None, + z=None, + R=None, + fill_value=None, + ): + """ + Evaluate the variable at arbitrary *dimensional* t (and x, r, y, z and/or R), + using interpolation + """ + if observe_raw: + if not self.xr_array_raw_initialized: + self._xr_array_raw = xr.DataArray(entries_for_interp, coords=coords) + xr_data_array = self._xr_array_raw + else: + xr_data_array = xr.DataArray(entries_for_interp, coords=coords) + + kwargs = {"t": t, "x": x, "r": r, "y": y, "z": z, "R": R} + + # Remove any None arguments + kwargs = {key: value for key, value in kwargs.items() if value is not None} + + # Use xarray interpolation, return numpy array + out = xr_data_array.interp(**kwargs, kwargs={"fill_value": fill_value}).values + + return out + + def _check_observe_raw(self, t): + """ + Checks if the raw data should be observed exactly at the solution time points + + Args: + t (np.ndarray, list, None): time points to observe + + Returns: + t_observe (np.ndarray): time points to observe + observe_raw (bool): True if observing the raw data + """ + # if this is a time integral variable, t must be None and we observe either the + # data times (for a discrete sum) or the solution times (for a continuous sum) + if self.time_integral is not None: + if self.time_integral.method == "discrete": + # discrete sum should be observed at the discrete times + t = self.time_integral.discrete_times + else: + # assume we can do a sufficiently accurate trapezoidal integration at t_pts + t = self.t_pts + + observe_raw = (t is None) or ( + np.asarray(t).size == len(self.t_pts) and np.all(t == self.t_pts) + ) + + if observe_raw: + t_observe = self.t_pts + elif not isinstance(t, np.ndarray): + if not isinstance(t, list): + t = [t] + t_observe = np.array(t) + else: + t_observe = t + + if t_observe[0] < self.t_pts[0]: + raise ValueError( + "The interpolation points must be greater than or equal to the initial solution time" + ) + + return t_observe, observe_raw + + @property + def entries(self): + """ + Returns the raw data entries of the processed variable. If the processed + variable has not been initialized (i.e. the entries have not been + calculated), then the processed variable is initialized first. + """ + self.initialise() + return self._entries_raw + + @property + def data(self): + """Same as entries, but different name""" + return self.entries + + @property + def entries_raw_initialized(self): + return self._entries_raw is not None + + @property + def xr_array_raw_initialized(self): + return self._xr_array_raw is not None + + @property + def sensitivities(self): + """ + Returns a dictionary of sensitivities for each input parameter. + The keys are the input parameters, and the value is a matrix of size + (n_x * n_t, n_p), where n_x is the number of states, n_t is the number of time + points, and n_p is the size of the input parameter + """ + # No sensitivities if there are no inputs + if len(self.all_inputs[0]) == 0: + return {} + # Otherwise initialise and return sensitivities + if self._sensitivities is None: + if self.all_solution_sensitivities: + self.initialise_sensitivity_explicit_forward() + else: + raise ValueError( + "Cannot compute sensitivities. The 'calculate_sensitivities' " + "argument of the solver.solve should be changed from 'None' to " + "allow sensitivities calculations. Check solver documentation for " + "details." + ) + return self._sensitivities + + def initialise_sensitivity_explicit_forward(self): + "Set up the sensitivity dictionary" + + all_S_var = [] + for ts, ys, inputs_stacked, inputs, base_variable, dy_dp in zip( + self.all_ts, + self.all_ys, + self.all_inputs_casadi, + self.all_inputs, + self.base_variables, + self.all_solution_sensitivities["all"], ): - return self.initialise_2D_scikit_fem() + # Set up symbolic variables + t_casadi = casadi.MX.sym("t") + y_casadi = casadi.MX.sym("y", ys.shape[0]) + p_casadi = { + name: casadi.MX.sym(name, value.shape[0]) + for name, value in inputs.items() + } + + p_casadi_stacked = casadi.vertcat(*[p for p in p_casadi.values()]) + + # Convert variable to casadi format for differentiating + var_casadi = base_variable.to_casadi(t_casadi, y_casadi, inputs=p_casadi) + dvar_dy = casadi.jacobian(var_casadi, y_casadi) + dvar_dp = casadi.jacobian(var_casadi, p_casadi_stacked) + + # Convert to functions and evaluate index-by-index + dvar_dy_func = casadi.Function( + "dvar_dy", [t_casadi, y_casadi, p_casadi_stacked], [dvar_dy] + ) + dvar_dp_func = casadi.Function( + "dvar_dp", [t_casadi, y_casadi, p_casadi_stacked], [dvar_dp] + ) + for idx, t in enumerate(ts): + u = ys[:, idx] + next_dvar_dy_eval = dvar_dy_func(t, u, inputs_stacked) + next_dvar_dp_eval = dvar_dp_func(t, u, inputs_stacked) + if idx == 0: + dvar_dy_eval = next_dvar_dy_eval + dvar_dp_eval = next_dvar_dp_eval + else: + dvar_dy_eval = casadi.diagcat(dvar_dy_eval, next_dvar_dy_eval) + dvar_dp_eval = casadi.vertcat(dvar_dp_eval, next_dvar_dp_eval) - # check variable shape - if len(self.base_eval_shape) == 0 or self.base_eval_shape[0] == 1: - return self.initialise_0D() + # Compute sensitivity + S_var = dvar_dy_eval @ dy_dp + dvar_dp_eval + all_S_var.append(S_var) - n = self.mesh.npts - base_shape = self.base_eval_shape[0] - # Try some shapes that could make the variable a 1D variable - if base_shape in [n, n + 1]: - return self.initialise_1D() + S_var = casadi.vertcat(*all_S_var) + sensitivities = {"all": S_var} - # Try some shapes that could make the variable a 2D variable - first_dim_nodes = self.mesh.nodes - first_dim_edges = self.mesh.edges - second_dim_pts = self.base_variables[0].secondary_mesh.nodes - if self.base_eval_size // len(second_dim_pts) in [ - len(first_dim_nodes), - len(first_dim_edges), - ]: - return self.initialise_2D() - - # Raise error for 3D variable - raise NotImplementedError( - f"Shape not recognized for {base_variables[0]}" - + "(note processing of 3D variables is not yet implemented)" + # Add the individual sensitivity + start = 0 + for name, inp in self.all_inputs[0].items(): + end = start + inp.shape[0] + sensitivities[name] = S_var[:, start:end] + start = end + + # Save attribute + self._sensitivities = sensitivities + + @property + def hermite_interpolation(self): + return self.all_yps is not None + + +class ProcessedVariable0D(ProcessedVariable): + def __init__( + self, + base_variables, + base_variables_casadi, + solution, + time_integral: Optional[pybamm.ProcessedVariableTimeIntegral] = None, + ): + self.dimensions = 0 + super().__init__( + base_variables, + base_variables_casadi, + solution, + time_integral=time_integral, ) - def initialise_0D(self): + def _observe_raw_python(self): + pybamm.logger.debug("Observing the variable raw data in Python") # initialise empty array of the correct size - entries = np.empty(len(self.t_pts)) + entries = np.empty(self._shape(self.t_pts)) idx = 0 # Evaluate the base_variable index-by-index for ts, ys, inputs, base_var_casadi in zip( @@ -126,22 +527,73 @@ def initialise_0D(self): entries[idx] = float(base_var_casadi(t, y, inputs)) idx += 1 - - if self.cumtrapz_ic is not None: - entries = cumulative_trapezoid( - entries, self.t_pts, initial=float(self.cumtrapz_ic) + return entries + + def _observe_postfix(self, entries, t): + if self.time_integral is None: + return entries + if self.time_integral.method == "discrete": + return np.sum(entries, axis=0, initial=self.time_integral.initial_condition) + elif self.time_integral.method == "continuous": + return cumulative_trapezoid( + entries, self.t_pts, initial=float(self.time_integral.initial_condition) ) + else: + raise ValueError( + "time_integral method must be 'discrete' or 'continuous'" + ) # pragma: no cover + def _interp_setup(self, entries, t): # save attributes for interpolation - self.entries_for_interp = entries - self.coords_for_interp = {"t": self.t_pts} + entries_for_interp = entries + coords_for_interp = {"t": t} - self.entries = entries - self.dimensions = 0 + return entries_for_interp, coords_for_interp + + def _shape(self, t): + return [len(t)] - def initialise_1D(self, fixed_t=False): - len_space = self.base_eval_shape[0] - entries = np.empty((len_space, len(self.t_pts))) + +class ProcessedVariable1D(ProcessedVariable): + """ + An object that can be evaluated at arbitrary (scalars or vectors) t and x, and + returns the (interpolated) value of the base variable at that t and x. + + Parameters + ---------- + base_variables : list of :class:`pybamm.Symbol` + A list of base variables with a method `evaluate(t,y)`, each entry of which + returns the value of that variable for that particular sub-solution. + A Solution can be comprised of sub-solutions which are the solutions of + different models. + Note that this can be any kind of node in the expression tree, not + just a :class:`pybamm.Variable`. + When evaluated, returns an array of size (m,n) + base_variables_casadi : list of :class:`casadi.Function` + A list of casadi functions. When evaluated, returns the same thing as + `base_Variable.evaluate` (but more efficiently). + solution : :class:`pybamm.Solution` + The solution object to be used to create the processed variables + """ + + def __init__( + self, + base_variables, + base_variables_casadi, + solution, + time_integral: Optional[pybamm.ProcessedVariableTimeIntegral] = None, + ): + self.dimensions = 1 + super().__init__( + base_variables, + base_variables_casadi, + solution, + time_integral=time_integral, + ) + + def _observe_raw_python(self): + pybamm.logger.debug("Observing the variable raw data in Python") + entries = np.empty(self._shape(self.t_pts)) # Evaluate the base_variable index-by-index idx = 0 @@ -153,7 +605,9 @@ def initialise_1D(self, fixed_t=False): y = ys[:, inner_idx] entries[:, idx] = base_var_casadi(t, y, inputs).full()[:, 0] idx += 1 + return entries + def _interp_setup(self, entries, t): # Get node and edge values nodes = self.mesh.nodes edges = self.mesh.edges @@ -173,8 +627,6 @@ def initialise_1D(self, fixed_t=False): ) # assign attributes for reference (either x_sol or r_sol) - self.entries = entries - self.dimensions = 1 self.spatial_variable_names = { k: self._process_spatial_variable_names(v) for k, v in self.spatial_variables.items() @@ -189,26 +641,71 @@ def initialise_1D(self, fixed_t=False): self.first_dim_pts = edges # save attributes for interpolation - self.entries_for_interp = entries_for_interp - self.coords_for_interp = {self.first_dimension: pts_for_interp, "t": self.t_pts} + coords_for_interp = {self.first_dimension: pts_for_interp, "t": t} - def initialise_2D(self): - """ - Initialise a 2D object that depends on x and r, x and z, x and R, or R and r. - """ + return entries_for_interp, coords_for_interp + + def _shape(self, t): + t_size = len(t) + space_size = self.base_eval_shape[0] + return [space_size, t_size] + + +class ProcessedVariable2D(ProcessedVariable): + """ + An object that can be evaluated at arbitrary (scalars or vectors) t and x, and + returns the (interpolated) value of the base variable at that t and x. + + Parameters + ---------- + base_variables : list of :class:`pybamm.Symbol` + A list of base variables with a method `evaluate(t,y)`, each entry of which + returns the value of that variable for that particular sub-solution. + A Solution can be comprised of sub-solutions which are the solutions of + different models. + Note that this can be any kind of node in the expression tree, not + just a :class:`pybamm.Variable`. + When evaluated, returns an array of size (m,n) + base_variables_casadi : list of :class:`casadi.Function` + A list of casadi functions. When evaluated, returns the same thing as + `base_Variable.evaluate` (but more efficiently). + solution : :class:`pybamm.Solution` + The solution object to be used to create the processed variables + """ + + def __init__( + self, + base_variables, + base_variables_casadi, + solution, + time_integral: Optional[pybamm.ProcessedVariableTimeIntegral] = None, + ): + self.dimensions = 2 + super().__init__( + base_variables, + base_variables_casadi, + solution, + time_integral=time_integral, + ) first_dim_nodes = self.mesh.nodes first_dim_edges = self.mesh.edges second_dim_nodes = self.base_variables[0].secondary_mesh.nodes - second_dim_edges = self.base_variables[0].secondary_mesh.edges if self.base_eval_size // len(second_dim_nodes) == len(first_dim_nodes): first_dim_pts = first_dim_nodes elif self.base_eval_size // len(second_dim_nodes) == len(first_dim_edges): first_dim_pts = first_dim_edges second_dim_pts = second_dim_nodes - first_dim_size = len(first_dim_pts) - second_dim_size = len(second_dim_pts) - entries = np.empty((first_dim_size, second_dim_size, len(self.t_pts))) + self.first_dim_size = len(first_dim_pts) + self.second_dim_size = len(second_dim_pts) + + def _observe_raw_python(self): + """ + Initialise a 2D object that depends on x and r, x and z, x and R, or R and r. + """ + pybamm.logger.debug("Observing the variable raw data in Python") + first_dim_size, second_dim_size, t_size = self._shape(self.t_pts) + entries = np.empty((first_dim_size, second_dim_size, t_size)) # Evaluate the base_variable index-by-index idx = 0 @@ -224,6 +721,22 @@ def initialise_2D(self): order="F", ) idx += 1 + return entries + + def _interp_setup(self, entries, t): + """ + Initialise a 2D object that depends on x and r, x and z, x and R, or R and r. + """ + first_dim_nodes = self.mesh.nodes + first_dim_edges = self.mesh.edges + second_dim_nodes = self.base_variables[0].secondary_mesh.nodes + second_dim_edges = self.base_variables[0].secondary_mesh.edges + if self.base_eval_size // len(second_dim_nodes) == len(first_dim_nodes): + first_dim_pts = first_dim_nodes + elif self.base_eval_size // len(second_dim_nodes) == len(first_dim_edges): + first_dim_pts = first_dim_edges + + second_dim_pts = second_dim_nodes # add points outside first dimension domain for extrapolation to # boundaries @@ -281,8 +794,6 @@ def initialise_2D(self): self.second_dimension = self.spatial_variable_names["secondary"] # assign attributes for reference - self.entries = entries - self.dimensions = 2 first_dim_pts_for_interp = first_dim_pts second_dim_pts_for_interp = second_dim_pts @@ -291,38 +802,68 @@ def initialise_2D(self): self.second_dim_pts = second_dim_edges # save attributes for interpolation - self.entries_for_interp = entries_for_interp - self.coords_for_interp = { + coords_for_interp = { self.first_dimension: first_dim_pts_for_interp, self.second_dimension: second_dim_pts_for_interp, - "t": self.t_pts, + "t": t, } - def initialise_2D_scikit_fem(self): + return entries_for_interp, coords_for_interp + + def _shape(self, t): + first_dim_size = self.first_dim_size + second_dim_size = self.second_dim_size + t_size = len(t) + return [first_dim_size, second_dim_size, t_size] + + +class ProcessedVariable2DSciKitFEM(ProcessedVariable2D): + """ + An object that can be evaluated at arbitrary (scalars or vectors) t and x, and + returns the (interpolated) value of the base variable at that t and x. + + Parameters + ---------- + base_variables : list of :class:`pybamm.Symbol` + A list of base variables with a method `evaluate(t,y)`, each entry of which + returns the value of that variable for that particular sub-solution. + A Solution can be comprised of sub-solutions which are the solutions of + different models. + Note that this can be any kind of node in the expression tree, not + just a :class:`pybamm.Variable`. + When evaluated, returns an array of size (m,n) + base_variables_casadi : list of :class:`casadi.Function` + A list of casadi functions. When evaluated, returns the same thing as + `base_Variable.evaluate` (but more efficiently). + solution : :class:`pybamm.Solution` + The solution object to be used to create the processed variables + """ + + def __init__( + self, + base_variables, + base_variables_casadi, + solution, + time_integral: Optional[pybamm.ProcessedVariableTimeIntegral] = None, + ): + self.dimensions = 2 + super(ProcessedVariable2D, self).__init__( + base_variables, + base_variables_casadi, + solution, + time_integral=time_integral, + ) y_sol = self.mesh.edges["y"] - len_y = len(y_sol) z_sol = self.mesh.edges["z"] - len_z = len(z_sol) - entries = np.empty((len_y, len_z, len(self.t_pts))) - # Evaluate the base_variable index-by-index - idx = 0 - for ts, ys, inputs, base_var_casadi in zip( - self.all_ts, self.all_ys, self.all_inputs_casadi, self.base_variables_casadi - ): - for inner_idx, t in enumerate(ts): - t = ts[inner_idx] - y = ys[:, inner_idx] - entries[:, :, idx] = np.reshape( - base_var_casadi(t, y, inputs).full(), - [len_y, len_z], - order="C", - ) - idx += 1 + self.first_dim_size = len(y_sol) + self.second_dim_size = len(z_sol) + + def _interp_setup(self, entries, t): + y_sol = self.mesh.edges["y"] + z_sol = self.mesh.edges["z"] # assign attributes for reference - self.entries = entries - self.dimensions = 2 self.y_sol = y_sol self.z_sol = z_sol self.first_dimension = "y" @@ -331,142 +872,112 @@ def initialise_2D_scikit_fem(self): self.second_dim_pts = z_sol # save attributes for interpolation - self.entries_for_interp = entries - self.coords_for_interp = {"y": y_sol, "z": z_sol, "t": self.t_pts} + coords_for_interp = {"y": y_sol, "z": z_sol, "t": t} - def _process_spatial_variable_names(self, spatial_variable): - if len(spatial_variable) == 0: - return None + return entries, coords_for_interp - # Extract names - raw_names = [] - for var in spatial_variable: - # Ignore tabs in domain names - if var == "tabs": - continue - if isinstance(var, str): - raw_names.append(var) - else: - raw_names.append(var.name) - # Rename battery variables to match PyBaMM convention - if all([var.startswith("r") for var in raw_names]): - return "r" - elif all([var.startswith("x") for var in raw_names]): - return "x" - elif all([var.startswith("R") for var in raw_names]): - return "R" - elif len(raw_names) == 1: - return raw_names[0] - else: - raise NotImplementedError( - f"Spatial variable name not recognized for {spatial_variable}" - ) +def process_variable(base_variables, *args, **kwargs): + mesh = base_variables[0].mesh + domain = base_variables[0].domain - def _initialize_xr_data_array(self): - """ - Initialize the xarray DataArray for interpolation. We don't do this by - default as it has some overhead (~75 us) and sometimes we only need the entries - of the processed variable, not the xarray object for interpolation. - """ - entries = self.entries_for_interp - coords = self.coords_for_interp - self._xr_data_array = xr.DataArray(entries, coords=coords) + # Evaluate base variable at initial time + base_eval_shape = base_variables[0].shape + base_eval_size = base_variables[0].size - def __call__(self, t=None, x=None, r=None, y=None, z=None, R=None, warn=True): - """ - Evaluate the variable at arbitrary *dimensional* t (and x, r, y, z and/or R), - using interpolation - """ - if self._xr_data_array is None: - self._initialize_xr_data_array() - kwargs = {"t": t, "x": x, "r": r, "y": y, "z": z, "R": R} - # Remove any None arguments - kwargs = {key: value for key, value in kwargs.items() if value is not None} - # Use xarray interpolation, return numpy array - return self._xr_data_array.interp(**kwargs).values + # handle 2D (in space) finite element variables differently + if ( + mesh + and "current collector" in domain + and isinstance(mesh, pybamm.ScikitSubMesh2D) + ): + return ProcessedVariable2DSciKitFEM(base_variables, *args, **kwargs) + + # check variable shape + if len(base_eval_shape) == 0 or base_eval_shape[0] == 1: + return ProcessedVariable0D(base_variables, *args, **kwargs) + + n = mesh.npts + base_shape = base_eval_shape[0] + # Try some shapes that could make the variable a 1D variable + if base_shape in [n, n + 1]: + return ProcessedVariable1D(base_variables, *args, **kwargs) + + # Try some shapes that could make the variable a 2D variable + first_dim_nodes = mesh.nodes + first_dim_edges = mesh.edges + second_dim_pts = base_variables[0].secondary_mesh.nodes + if base_eval_size // len(second_dim_pts) in [ + len(first_dim_nodes), + len(first_dim_edges), + ]: + return ProcessedVariable2D(base_variables, *args, **kwargs) + + # Raise error for 3D variable + raise NotImplementedError( + f"Shape not recognized for {base_variables[0]}" + + "(note processing of 3D variables is not yet implemented)" + ) + + +def _is_f_contiguous(all_ys): + """ + Check if all the ys are f-contiguous in memory - @property - def data(self): - """Same as entries, but different name""" - return self.entries + Args: + all_ys (list of np.ndarray): list of all ys - @property - def sensitivities(self): - """ - Returns a dictionary of sensitivities for each input parameter. - The keys are the input parameters, and the value is a matrix of size - (n_x * n_t, n_p), where n_x is the number of states, n_t is the number of time - points, and n_p is the size of the input parameter - """ - # No sensitivities if there are no inputs - if len(self.all_inputs[0]) == 0: - return {} - # Otherwise initialise and return sensitivities - if self._sensitivities is None: - if self.solution_sensitivities != {}: - self.initialise_sensitivity_explicit_forward() - else: - raise ValueError( - "Cannot compute sensitivities. The 'calculate_sensitivities' " - "argument of the solver.solve should be changed from 'None' to " - "allow sensitivities calculations. Check solver documentation for " - "details." - ) - return self._sensitivities + Returns: + bool: True if all ys are f-contiguous + """ - def initialise_sensitivity_explicit_forward(self): - "Set up the sensitivity dictionary" - inputs_stacked = self.all_inputs_casadi[0] - - # Set up symbolic variables - t_casadi = casadi.MX.sym("t") - y_casadi = casadi.MX.sym("y", self.all_ys[0].shape[0]) - p_casadi = { - name: casadi.MX.sym(name, value.shape[0]) - for name, value in self.all_inputs[0].items() - } + return all(isinstance(y, np.ndarray) and y.data.f_contiguous for y in all_ys) - p_casadi_stacked = casadi.vertcat(*[p for p in p_casadi.values()]) - # Convert variable to casadi format for differentiating - var_casadi = self.base_variables[0].to_casadi( - t_casadi, y_casadi, inputs=p_casadi - ) - dvar_dy = casadi.jacobian(var_casadi, y_casadi) - dvar_dp = casadi.jacobian(var_casadi, p_casadi_stacked) +def _is_sorted(t): + """ + Check if an array is sorted - # Convert to functions and evaluate index-by-index - dvar_dy_func = casadi.Function( - "dvar_dy", [t_casadi, y_casadi, p_casadi_stacked], [dvar_dy] - ) - dvar_dp_func = casadi.Function( - "dvar_dp", [t_casadi, y_casadi, p_casadi_stacked], [dvar_dp] - ) - for index, (ts, ys) in enumerate(zip(self.all_ts, self.all_ys)): - for idx, t in enumerate(ts): - u = ys[:, idx] - next_dvar_dy_eval = dvar_dy_func(t, u, inputs_stacked) - next_dvar_dp_eval = dvar_dp_func(t, u, inputs_stacked) - if index == 0 and idx == 0: - dvar_dy_eval = next_dvar_dy_eval - dvar_dp_eval = next_dvar_dp_eval - else: - dvar_dy_eval = casadi.diagcat(dvar_dy_eval, next_dvar_dy_eval) - dvar_dp_eval = casadi.vertcat(dvar_dp_eval, next_dvar_dp_eval) + Args: + t (np.ndarray): array to check - # Compute sensitivity - dy_dp = self.solution_sensitivities["all"] - S_var = dvar_dy_eval @ dy_dp + dvar_dp_eval + Returns: + bool: True if array is sorted + """ + return np.all(t[:-1] <= t[1:]) - sensitivities = {"all": S_var} - # Add the individual sensitivity - start = 0 - for name, inp in self.all_inputs[0].items(): - end = start + inp.shape[0] - sensitivities[name] = S_var[:, start:end] - start = end +def _find_ts_indices(ts, t): + """ + Parameters: + - ts: A list of numpy arrays (each sorted) whose values are successively increasing. + - t: A sorted list or array of values to find within ts. - # Save attribute - self._sensitivities = sensitivities + Returns: + - indices: A list of indices from `ts` such that at least one value of `t` falls within ts[idx]. + """ + + indices = [] + + # Get the minimum and maximum values of the target values `t` + t_min, t_max = t[0], t[-1] + + # Step 1: Use binary search to find the range of `ts` arrays where t_min and t_max could lie + low_idx = bisect.bisect_left([ts_arr[-1] for ts_arr in ts], t_min) + high_idx = bisect.bisect_right([ts_arr[0] for ts_arr in ts], t_max) + + # Step 2: Iterate over the identified range + for idx in range(low_idx, high_idx): + ts_min, ts_max = ts[idx][0], ts[idx][-1] + + # Binary search within `t` to check if any value falls within [ts_min, ts_max] + i = bisect.bisect_left(t, ts_min) + if i < len(t) and t[i] <= ts_max: + # At least one value of t is within ts[idx] + indices.append(idx) + + # extrapolating + if (t[-1] > ts[-1][-1]) and (len(indices) == 0 or indices[-1] != len(ts) - 1): + indices.append(len(ts) - 1) + + return indices diff --git a/src/pybamm/solvers/processed_variable_computed.py b/src/pybamm/solvers/processed_variable_computed.py index a717c8b0cb..befe6314b6 100644 --- a/src/pybamm/solvers/processed_variable_computed.py +++ b/src/pybamm/solvers/processed_variable_computed.py @@ -1,6 +1,7 @@ # -# Processed Variable class +# Processed Variable Computed class # +from __future__ import annotations import casadi import numpy as np import pybamm @@ -27,7 +28,7 @@ class ProcessedVariableComputed: Note that this can be any kind of node in the expression tree, not just a :class:`pybamm.Variable`. When evaluated, returns an array of size (m,n) - base_variable_casadis : list of :class:`casadi.Function` + base_variables_casadi : list of :class:`casadi.Function` A list of casadi functions. When evaluated, returns the same thing as `base_Variable.evaluate` (but more efficiently). base_variable_data : list of :numpy:array @@ -45,7 +46,6 @@ def __init__( base_variables_casadi, base_variables_data, solution, - warn=True, cumtrapz_ic=None, ): self.base_variables = base_variables @@ -60,7 +60,6 @@ def __init__( self.mesh = base_variables[0].mesh self.domain = base_variables[0].domain self.domains = base_variables[0].domains - self.warn = warn self.cumtrapz_ic = cumtrapz_ic # Sensitivity starts off uninitialized, only set when called @@ -82,33 +81,35 @@ def __init__( and isinstance(self.mesh, pybamm.ScikitSubMesh2D) ): self.initialise_2D_scikit_fem() + return # check variable shape - else: - if len(self.base_eval_shape) == 0 or self.base_eval_shape[0] == 1: - self.initialise_0D() - else: - n = self.mesh.npts - base_shape = self.base_eval_shape[0] - # Try some shapes that could make the variable a 1D variable - if base_shape in [n, n + 1]: - self.initialise_1D() - else: - # Try some shapes that could make the variable a 2D variable - first_dim_nodes = self.mesh.nodes - first_dim_edges = self.mesh.edges - second_dim_pts = self.base_variables[0].secondary_mesh.nodes - if self.base_eval_size // len(second_dim_pts) in [ - len(first_dim_nodes), - len(first_dim_edges), - ]: - self.initialise_2D() - else: - # Raise error for 3D variable - raise NotImplementedError( - f"Shape not recognized for {base_variables[0]} " - + "(note processing of 3D variables is not yet implemented)" - ) + if len(self.base_eval_shape) == 0 or self.base_eval_shape[0] == 1: + self.initialise_0D() + return + + n = self.mesh.npts + base_shape = self.base_eval_shape[0] + # Try some shapes that could make the variable a 1D variable + if base_shape in [n, n + 1]: + self.initialise_1D() + return + + # Try some shapes that could make the variable a 2D variable + first_dim_nodes = self.mesh.nodes + first_dim_edges = self.mesh.edges + second_dim_pts = self.base_variables[0].secondary_mesh.nodes + if self.base_eval_size // len(second_dim_pts) not in [ + len(first_dim_nodes), + len(first_dim_edges), + ]: + # Raise error for 3D variable + raise NotImplementedError( + f"Shape not recognized for {base_variables[0]} " + + "(note processing of 3D variables is not yet implemented)" + ) + + self.initialise_2D() def add_sensitivity(self, param, data): # unroll from sparse representation into n-d matrix @@ -203,7 +204,7 @@ def initialise_0D(self): self.entries = entries self.dimensions = 0 - def initialise_1D(self, fixed_t=False): + def initialise_1D(self): entries = self.unroll_1D() # Get node and edge values @@ -422,7 +423,7 @@ def initialise_2D_scikit_fem(self): coords={"y": y_sol, "z": z_sol, "t": self.t_pts}, ) - def __call__(self, t=None, x=None, r=None, y=None, z=None, R=None, warn=True): + def __call__(self, t=None, x=None, r=None, y=None, z=None, R=None): """ Evaluate the variable at arbitrary *dimensional* t (and x, r, y, z and/or R), using interpolation @@ -450,3 +451,27 @@ def sensitivities(self): if len(self.all_inputs[0]) == 0: return {} return self._sensitivities + + def _update( + self, other: pybamm.ProcessedVariableComputed, new_sol: pybamm.Solution + ) -> pybamm.ProcessedVariableComputed: + """ + Returns a new ProcessedVariableComputed object that is the result of appending + the data from other to this object. Used exclusively in running experiments, to + append the data from one cycle to the next. + + Parameters + ---------- + other : :class:`pybamm.ProcessedVariableComputed` + The other ProcessedVariableComputed object to append to this one + new_sol : :class:`pybamm.Solution` + The new solution object to be used to create the processed variables + """ + + bv = self.base_variables + other.base_variables + bvc = self.base_variables_casadi + other.base_variables_casadi + bvd = self.base_variables_data + other.base_variables_data + + new_var = self.__class__(bv, bvc, bvd, new_sol) + + return new_var diff --git a/src/pybamm/solvers/processed_variable_time_integral.py b/src/pybamm/solvers/processed_variable_time_integral.py new file mode 100644 index 0000000000..4fcdfb56ba --- /dev/null +++ b/src/pybamm/solvers/processed_variable_time_integral.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from typing import Literal, Optional, Union +import numpy as np +import pybamm + + +@dataclass +class ProcessedVariableTimeIntegral: + method: Literal["discrete", "continuous"] + initial_condition: np.ndarray + discrete_times: Optional[np.ndarray] + + @staticmethod + def from_pybamm_var( + var: Union[pybamm.DiscreteTimeSum, pybamm.ExplicitTimeIntegral], + ) -> "ProcessedVariableTimeIntegral": + if isinstance(var, pybamm.DiscreteTimeSum): + return ProcessedVariableTimeIntegral( + method="discrete", initial_condition=0.0, discrete_times=var.sum_times + ) + elif isinstance(var, pybamm.ExplicitTimeIntegral): + return ProcessedVariableTimeIntegral( + method="continuous", + initial_condition=var.initial_condition.evaluate(), + discrete_times=None, + ) + else: + raise ValueError("Unsupported variable type") # pragma: no cover diff --git a/src/pybamm/solvers/scipy_solver.py b/src/pybamm/solvers/scipy_solver.py index 226b096887..daa8f706de 100644 --- a/src/pybamm/solvers/scipy_solver.py +++ b/src/pybamm/solvers/scipy_solver.py @@ -150,7 +150,7 @@ def event_fn(t, y): t_event, y_event, termination, - sensitivities=bool(model.calculate_sensitivities), + all_sensitivities=bool(model.calculate_sensitivities), ) sol.integration_time = integration_time return sol diff --git a/src/pybamm/solvers/solution.py b/src/pybamm/solvers/solution.py index c3c8451634..256d596fd4 100644 --- a/src/pybamm/solvers/solution.py +++ b/src/pybamm/solvers/solution.py @@ -2,6 +2,7 @@ # Solution class # import casadi +import copy import json import numbers import numpy as np @@ -57,11 +58,14 @@ class Solution: the event happens. termination : str String to indicate why the solution terminated - - sensitivities: bool or dict + all_sensitivities: bool or dict of lists True if sensitivities included as the solution of the explicit forwards equations. False if no sensitivities included/wanted. Dict if sensitivities are - provided as a dict of {parameter: sensitivities} pairs. + provided as a dict of {parameter: [sensitivities]} pairs. + variables_returned: bool + Bool to indicate if `all_ys` contains the full state vector, or is empty because + only requested variables have been returned. True if `output_variables` is used + with a solver, otherwise False. """ @@ -74,7 +78,9 @@ def __init__( t_event=None, y_event=None, termination="final time", - sensitivities=False, + all_sensitivities=False, + all_yps=None, + variables_returned=False, check_solution=True, ): if not isinstance(all_ts, list): @@ -88,6 +94,12 @@ def __init__( self._all_ys_and_sens = all_ys self._all_models = all_models + if (all_yps is not None) and not isinstance(all_yps, list): + all_yps = [all_yps] + self._all_yps = all_yps + + self.variables_returned = variables_returned + # Set up inputs if not isinstance(all_inputs, list): all_inputs_copy = dict(all_inputs) @@ -98,7 +110,18 @@ def __init__( else: self.all_inputs = all_inputs - self.sensitivities = sensitivities + if isinstance(all_sensitivities, bool): + self._all_sensitivities = all_sensitivities + elif isinstance(all_sensitivities, dict): + self._all_sensitivities = {} + for key, value in all_sensitivities.items(): + if isinstance(value, list): + self._all_sensitivities[key] = value + else: + self._all_sensitivities[key] = [value] + + else: + raise TypeError("sensitivities arg needs to be a bool or dict") # Check no ys are too large if check_solution: @@ -117,7 +140,7 @@ def __init__( # initialize empty variables and data self._variables = pybamm.FuzzyDict() - self.data = pybamm.FuzzyDict() + self._data = pybamm.FuzzyDict() # Add self as sub-solution for compatibility with ProcessedVariable self._sub_solutions = [self] @@ -134,47 +157,31 @@ def __init__( # Solution now uses CasADi pybamm.citations.register("Andersson2019") - def extract_explicit_sensitivities(self): - # if we got here, we haven't set y yet - self.set_y() + def has_sensitivities(self) -> bool: + if isinstance(self._all_sensitivities, bool): + return self._all_sensitivities + elif isinstance(self._all_sensitivities, dict): + return len(self._all_sensitivities) > 0 - # extract sensitivities from full y solution - self._y, self._sensitivities = self._extract_explicit_sensitivities( - self.all_models[0], self.y, self.t, self.all_inputs[0] - ) + def extract_explicit_sensitivities(self): + self._all_sensitivities = {} - # make sure we remove all sensitivities from all_ys + # extract sensitivities from each sub-solution for index, (model, ys, ts, inputs) in enumerate( zip(self.all_models, self.all_ys, self.all_ts, self.all_inputs) ): - self._all_ys[index], _ = self._extract_explicit_sensitivities( + self._all_ys[index], sens_segment = self._extract_explicit_sensitivities( model, ys, ts, inputs ) + for key, value in sens_segment.items(): + if key in self._all_sensitivities: + self._all_sensitivities[key] = self._all_sensitivities[key] + [ + value + ] + else: + self._all_sensitivities[key] = [value] - def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): - """ - given a model and a solution y, extracts the sensitivities - - Parameters - -------- - model : :class:`pybamm.BaseModel` - A model that has been already setup by this base solver - y: ndarray - The solution of the full explicit sensitivity equations - t_eval: ndarray - The evaluation times - inputs: dict - parameter inputs - - Returns - ------- - y: ndarray - The solution of the ode/dae in model - sensitivities: dict of (string: ndarray) - A dictionary of parameter names, and the corresponding solution of - the sensitivity equations - """ - + def _extract_sensitivity_matrix(self, model, y): n_states = model.len_rhs_and_alg n_rhs = model.len_rhs n_alg = model.len_alg @@ -185,7 +192,6 @@ def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): n_p = model.len_alg_sens // model.len_alg len_rhs_and_sens = model.len_rhs + model.len_rhs_sens - n_t = len(t_eval) # y gets the part of the solution vector that correspond to the # actual ODE/DAE solution @@ -211,6 +217,8 @@ def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): y_full = y.full() else: y_full = y + + n_t = y.shape[1] ode_sens = y_full[n_rhs:len_rhs_and_sens, :].reshape(n_p, n_rhs, n_t) alg_sens = y_full[len_rhs_and_sens + n_alg :, :].reshape(n_p, n_alg, n_t) # 2. Concatenate into a single 3D matrix with shape (n_p, n_states, n_t) @@ -221,6 +229,44 @@ def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): n_t * n_states, n_p ) + # convert back to casadi (todo: this is not very efficient, should refactor + # to avoid this) + full_sens_matrix = casadi.DM(full_sens_matrix) + + y_dae = np.vstack( + [ + y[: model.len_rhs, :], + y[len_rhs_and_sens : len_rhs_and_sens + model.len_alg, :], + ] + ) + return y_dae, full_sens_matrix + + def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): + """ + given a model and a solution y, extracts the sensitivities + + Parameters + -------- + model : :class:`pybamm.BaseModel` + A model that has been already setup by this base solver + y: ndarray + The solution of the full explicit sensitivity equations + t_eval: ndarray + The evaluation times + inputs: dict + parameter inputs + + Returns + ------- + y: ndarray + The solution of the ode/dae in model + sensitivities: dict of (string: ndarray) + A dictionary of parameter names, and the corresponding solution of + the sensitivity equations + """ + + y_dae, full_sens_matrix = self._extract_sensitivity_matrix(model, y) + # Save the full sensitivity matrix sensitivity = {"all": full_sens_matrix} @@ -234,12 +280,6 @@ def _extract_explicit_sensitivities(self, model, y, t_eval, inputs): sensitivity[name] = full_sens_matrix[:, start:end] start = end - y_dae = np.vstack( - [ - y[: model.len_rhs, :], - y[len_rhs_and_sens : len_rhs_and_sens + model.len_alg, :], - ] - ) return y_dae, sensitivity @property @@ -262,31 +302,63 @@ def y(self): try: return self._y except AttributeError: - self.set_y() - # if y is evaluated before sensitivities then need to extract them - if isinstance(self._sensitivities, bool) and self._sensitivities: + if isinstance(self._all_sensitivities, bool) and self._all_sensitivities: self.extract_explicit_sensitivities() + self.set_y() + return self._y + @property + def data(self): + for k, v in self._variables.items(): + if k not in self._data: + self._data[k] = v.data + return self._data + @property def sensitivities(self): """Values of the sensitivities. Returns a dict of param_name: np_array""" - if isinstance(self._sensitivities, bool): - if self._sensitivities: - self.extract_explicit_sensitivities() - else: - self._sensitivities = {} + try: + return self._sensitivities + except AttributeError: + self.set_sensitivities() return self._sensitivities @sensitivities.setter def sensitivities(self, value): - """Updates the sensitivity""" + """Updates the sensitivity if False or True. Raises an error if sensitivities are a dict""" # sensitivities must be a dict or bool - if not isinstance(value, (bool, dict)): - raise TypeError("sensitivities arg needs to be a bool or dict") - self._sensitivities = value + if not isinstance(value, bool): + raise TypeError("sensitivities arg needs to be a bool") + + if isinstance(self._all_sensitivities, dict): + raise NotImplementedError( + "Setting sensitivities is not supported if sensitivities are " + "already provided as a dict of {parameter: sensitivities} pairs." + ) + + self._all_sensitivities = value + + def set_sensitivities(self): + if not self.has_sensitivities(): + self._sensitivities = {} + return + + # extract sensitivities if they are not already extracted + if isinstance(self._all_sensitivities, bool) and self._all_sensitivities: + self.extract_explicit_sensitivities() + + is_casadi = isinstance( + next(iter(self._all_sensitivities.values()))[0], (casadi.DM, casadi.MX) + ) + self._sensitivities = {} + for key, sens in self._all_sensitivities.items(): + if is_casadi: + self._sensitivities[key] = casadi.vertcat(*sens) + else: + self._sensitivities[key] = np.vstack(sens) def set_y(self): try: @@ -347,6 +419,14 @@ def all_models(self): def all_inputs_casadi(self): return [casadi.vertcat(*inp.values()) for inp in self.all_inputs] + @property + def all_yps(self): + return self._all_yps + + @property + def hermite_interpolation(self): + return self.all_yps is not None + @property def t_event(self): """Time at which the event happens""" @@ -374,14 +454,35 @@ def first_state(self): than the full solution when only the first state is needed (e.g. to initialize a model with the solution) """ + if isinstance(self._all_sensitivities, bool): + sensitivities = self._all_sensitivities + elif isinstance(self._all_sensitivities, dict): + sensitivities = {} + n_states = self.all_models[0].len_rhs_and_alg + for key in self._all_sensitivities: + sensitivities[key] = self._all_sensitivities[key][0][-n_states:, :] + + if self.all_yps is None: + all_yps = None + else: + all_yps = self.all_yps[0][:, :1] + + if not self.variables_returned: + all_ys = self.all_ys[0][:, :1] + else: + # Get first state from initial conditions as all_ys is empty + all_ys = self.all_models[0].y0full.reshape(-1, 1) + new_sol = Solution( self.all_ts[0][:1], - self.all_ys[0][:, :1], + all_ys, self.all_models[:1], self.all_inputs[:1], None, None, "final time", + all_sensitivities=sensitivities, + all_yps=all_yps, ) new_sol._all_inputs_casadi = self.all_inputs_casadi[:1] new_sol._sub_solutions = self.sub_solutions[:1] @@ -399,18 +500,38 @@ def last_state(self): than the full solution when only the final state is needed (e.g. to initialize a model with the solution) """ + if isinstance(self._all_sensitivities, bool): + sensitivities = self._all_sensitivities + elif isinstance(self._all_sensitivities, dict): + sensitivities = {} + n_states = self.all_models[-1].len_rhs_and_alg + for key in self._all_sensitivities: + sensitivities[key] = self._all_sensitivities[key][-1][-n_states:, :] + + if self.all_yps is None: + all_yps = None + else: + all_yps = self.all_yps[-1][:, -1:] + + if not self.variables_returned: + all_ys = self.all_ys[-1][:, -1:] + else: + # Get last state from y_event as all_ys is empty + all_ys = self.y_event.reshape(len(self.y_event), 1) + new_sol = Solution( self.all_ts[-1][-1:], - self.all_ys[-1][:, -1:], + all_ys, self.all_models[-1:], self.all_inputs[-1:], self.t_event, self.y_event, self.termination, + all_sensitivities=sensitivities, + all_yps=all_yps, ) new_sol._all_inputs_casadi = self.all_inputs_casadi[-1:] new_sol._sub_solutions = self.sub_solutions[-1:] - new_sol.solve_time = 0 new_sol.integration_time = 0 new_sol.set_up_time = 0 @@ -457,60 +578,70 @@ def set_summary_variables(self, all_summary_variables): def update(self, variables): """Add ProcessedVariables to the dictionary of variables in the solution""" # make sure that sensitivities are extracted if required - if isinstance(self._sensitivities, bool) and self._sensitivities: + if isinstance(self._all_sensitivities, bool) and self._all_sensitivities: self.extract_explicit_sensitivities() - # Convert single entry to list + # Single variable if isinstance(variables, str): variables = [variables] + # Process - for key in variables: - cumtrapz_ic = None - pybamm.logger.debug(f"Post-processing {key}") - vars_pybamm = [model.variables_and_events[key] for model in self.all_models] - - # Iterate through all models, some may be in the list several times and - # therefore only get set up once - vars_casadi = [] - for i, (model, ys, inputs, var_pybamm) in enumerate( - zip(self.all_models, self.all_ys, self.all_inputs, vars_pybamm) + for variable in variables: + self._update_variable(variable) + + def _update_variable(self, variable): + time_integral = None + pybamm.logger.debug(f"Post-processing {variable}") + vars_pybamm = [ + model.variables_and_events[variable] for model in self.all_models + ] + + # Iterate through all models, some may be in the list several times and + # therefore only get set up once + vars_casadi = [] + for i, (model, ys, inputs, var_pybamm) in enumerate( + zip(self.all_models, self.all_ys, self.all_inputs, vars_pybamm) + ): + if self.variables_returned and var_pybamm.has_symbol_of_classes( + pybamm.expression_tree.state_vector.StateVector ): - if ys.size == 0 and var_pybamm.has_symbol_of_classes( - pybamm.expression_tree.state_vector.StateVector - ): - raise KeyError( - f"Cannot process variable '{key}' as it was not part of the " - "solve. Please re-run the solve with `output_variables` set to " - "include this variable." - ) - elif isinstance(var_pybamm, pybamm.ExplicitTimeIntegral): - cumtrapz_ic = var_pybamm.initial_condition - cumtrapz_ic = cumtrapz_ic.evaluate() - var_pybamm = var_pybamm.child - var_casadi = self.process_casadi_var( - var_pybamm, - inputs, - ys.shape, - ) - model._variables_casadi[key] = var_casadi - vars_pybamm[i] = var_pybamm - elif key in model._variables_casadi: - var_casadi = model._variables_casadi[key] + raise KeyError( + f"Cannot process variable '{variable}' as it was not part of the " + "solve. Please re-run the solve with `output_variables` set to " + "include this variable." + ) + elif isinstance( + var_pybamm, (pybamm.ExplicitTimeIntegral, pybamm.DiscreteTimeSum) + ): + time_integral = pybamm.ProcessedVariableTimeIntegral.from_pybamm_var( + var_pybamm + ) + var_pybamm = var_pybamm.child + if variable in model._variables_casadi: + var_casadi = model._variables_casadi[variable] else: var_casadi = self.process_casadi_var( var_pybamm, inputs, ys.shape, ) - model._variables_casadi[key] = var_casadi - vars_casadi.append(var_casadi) - var = pybamm.ProcessedVariable( - vars_pybamm, vars_casadi, self, cumtrapz_ic=cumtrapz_ic - ) + model._variables_casadi[variable] = var_casadi + vars_pybamm[i] = var_pybamm + elif variable in model._variables_casadi: + var_casadi = model._variables_casadi[variable] + else: + var_casadi = self.process_casadi_var( + var_pybamm, + inputs, + ys.shape, + ) + model._variables_casadi[variable] = var_casadi + vars_casadi.append(var_casadi) + var = pybamm.process_variable( + vars_pybamm, vars_casadi, self, time_integral=time_integral + ) - # Save variable and data - self._variables[key] = var - self.data[key] = var.data + self._variables[variable] = var def process_casadi_var(self, var_pybamm, inputs, ys_shape): t_MX = casadi.MX.sym("t") @@ -520,8 +651,40 @@ def process_casadi_var(self, var_pybamm, inputs, ys_shape): } inputs_MX = casadi.vertcat(*[p for p in inputs_MX_dict.values()]) var_sym = var_pybamm.to_casadi(t_MX, y_MX, inputs=inputs_MX_dict) - var_casadi = casadi.Function("variable", [t_MX, y_MX, inputs_MX], [var_sym]) - return var_casadi + + opts = { + "cse": True, + "inputs_check": False, + "is_diff_in": [False, False, False], + "is_diff_out": [False], + "regularity_check": False, + "error_on_fail": False, + "enable_jacobian": False, + } + + # Casadi has a bug where it does not correctly handle arrays with + # zeros padded at the beginning or end. To avoid this, we add and + # subtract the same number to the variable to reinforce the + # variable bounds. This does not affect the answer + epsilon = 1.0 + var_sym = (var_sym - epsilon) + epsilon + + var_casadi = casadi.Function( + "variable", + [t_MX, y_MX, inputs_MX], + [var_sym], + opts, + ) + + # Some variables, like interpolants, cannot be expanded + try: + var_casadi_out = var_casadi.expand() + except RuntimeError as error: + if "'eval_sx' not defined for" not in str(error): + raise error # pragma: no cover + var_casadi_out = var_casadi + + return var_casadi_out def __getitem__(self, key): """Read a variable from the solution. Variables are created 'just in time', i.e. @@ -534,7 +697,7 @@ def __getitem__(self, key): Returns ------- - :class:`pybamm.ProcessedVariable` + :class:`pybamm.ProcessedVariable` or :class:`pybamm.ProcessedVariableComputed` A variable that can be evaluated at any time or spatial point. The underlying data for this variable is available in its attribute ".data" """ @@ -750,13 +913,47 @@ def __add__(self, other): return new_sol # Update list of sub-solutions + hermite_interpolation = ( + other.hermite_interpolation and self.hermite_interpolation + ) if other.all_ts[0][0] == self.all_ts[-1][-1]: # Skip first time step if it is repeated all_ts = self.all_ts + [other.all_ts[0][1:]] + other.all_ts[1:] all_ys = self.all_ys + [other.all_ys[0][:, 1:]] + other.all_ys[1:] + if hermite_interpolation: + all_yps = self.all_yps + [other.all_yps[0][:, 1:]] + other.all_yps[1:] else: all_ts = self.all_ts + other.all_ts all_ys = self.all_ys + other.all_ys + if hermite_interpolation: + all_yps = self.all_yps + other.all_yps + + if not hermite_interpolation: + all_yps = None + + # sensitivities can be: + # - bool if not using sensitivities or using explicit sensitivities which still + # need to be extracted + # - dict if sensitivities are provided as a dict of {parameter: sensitivities} + # both self and other should have the same type of sensitivities + # OR both can be either False or {} (i.e. no sensitivities) + if isinstance(self._all_sensitivities, bool) and isinstance( + other._all_sensitivities, bool + ): + all_sensitivities = self._all_sensitivities or other._all_sensitivities + elif isinstance(self._all_sensitivities, dict) and isinstance( + other._all_sensitivities, dict + ): + all_sensitivities = self._all_sensitivities + # we can assume that the keys are the same for both solutions + for key in other._all_sensitivities: + all_sensitivities[key] = ( + all_sensitivities[key] + other._all_sensitivities[key] + ) + elif not self._all_sensitivities and not other._all_sensitivities: + all_sensitivities = {} + else: + raise ValueError("Sensitivities must be of the same type") new_sol = Solution( all_ts, @@ -766,19 +963,38 @@ def __add__(self, other): other.t_event, other.y_event, other.termination, - bool(self.sensitivities), + all_sensitivities=all_sensitivities, + all_yps=all_yps, + variables_returned=other.variables_returned, ) new_sol.closest_event_idx = other.closest_event_idx new_sol._all_inputs_casadi = self.all_inputs_casadi + other.all_inputs_casadi - # Set solution time - new_sol.solve_time = self.solve_time + other.solve_time - new_sol.integration_time = self.integration_time + other.integration_time + # Add timers (if available) + for attr in ["solve_time", "integration_time", "set_up_time"]: + if ( + getattr(self, attr, None) is not None + and getattr(other, attr, None) is not None + ): + setattr(new_sol, attr, getattr(self, attr) + getattr(other, attr)) # Set sub_solutions new_sol._sub_solutions = self.sub_solutions + other.sub_solutions + # update variables which were derived at the solver stage + if other._variables and all( + isinstance(v, pybamm.ProcessedVariableComputed) + for v in other._variables.values() + ): + if not self._variables: + new_sol._variables = other._variables.copy() + else: + new_sol._variables = { + v: self._variables[v]._update(other._variables[v], new_sol) + for v in self._variables.keys() + } + return new_sol def __radd__(self, other): @@ -787,12 +1003,16 @@ def __radd__(self, other): def copy(self): new_sol = self.__class__( self.all_ts, - self.all_ys, + # need to copy y in case it is modified by extract explicit sensitivities + [copy.copy(y) for y in self.all_ys], self.all_models, self.all_inputs, self.t_event, self.y_event, self.termination, + self._all_sensitivities, + self.all_yps, + self.variables_returned, ) new_sol._all_inputs_casadi = self.all_inputs_casadi new_sol._sub_solutions = self.sub_solutions @@ -802,6 +1022,13 @@ def copy(self): new_sol.integration_time = self.integration_time new_sol.set_up_time = self.set_up_time + # copy over variables which were derived at the solver stage + if self._variables and all( + isinstance(v, pybamm.ProcessedVariableComputed) + for v in self._variables.values() + ): + new_sol._variables = self._variables.copy() + return new_sol def plot_voltage_components( @@ -902,6 +1129,9 @@ def make_cycle_solution( sum_sols.t_event, sum_sols.y_event, sum_sols.termination, + sum_sols._all_sensitivities, + sum_sols.all_yps, + sum_sols.variables_returned, ) cycle_solution._all_inputs_casadi = sum_sols.all_inputs_casadi cycle_solution._sub_solutions = sum_sols.sub_solutions diff --git a/src/pybamm/spatial_methods/spectral_volume.py b/src/pybamm/spatial_methods/spectral_volume.py index 11c6dfd6d2..8699045dca 100644 --- a/src/pybamm/spatial_methods/spectral_volume.py +++ b/src/pybamm/spatial_methods/spectral_volume.py @@ -176,7 +176,7 @@ def cv_boundary_reconstruction_matrix(self, domains): def chebyshev_differentiation_matrices(self, noe, dod): """ Chebyshev differentiation matrices, from - :footcite:t:`baltensperger2003spectral`. + :footcite:t:`Baltensperger2003`. Parameters ---------- diff --git a/src/pybamm/telemetry.py b/src/pybamm/telemetry.py new file mode 100644 index 0000000000..2dad19f814 --- /dev/null +++ b/src/pybamm/telemetry.py @@ -0,0 +1,45 @@ +from posthog import Posthog +import pybamm +import sys + + +class MockTelemetry: + def __init__(self): + self.disabled = True + + @staticmethod + def capture(**kwargs): # pragma: no cover + pass + + +if pybamm.config.check_opt_out(): + _posthog = MockTelemetry() +else: # pragma: no cover + _posthog = Posthog( + # this is the public, write only API key, so it's ok to include it here + project_api_key="phc_bLZKBW03XjgiRhbWnPsnKPr0iw0z03fA6ZZYjxgW7ej", + host="https://us.i.posthog.com", + ) + _posthog.log.setLevel("CRITICAL") + + +def disable(): + _posthog.disabled = True + + +def capture(event): # pragma: no cover + if pybamm.config.is_running_tests() or _posthog.disabled: + return + + if pybamm.config.check_opt_out(): + disable() + return + + config = pybamm.config.read() + if config: + properties = { + "python_version": sys.version, + "pybamm_version": pybamm.__version__, + } + user_id = config["uuid"] + _posthog.capture(distinct_id=user_id, event=event, properties=properties) diff --git a/src/pybamm/util.py b/src/pybamm/util.py index 527c55f526..5b10b23fcb 100644 --- a/src/pybamm/util.py +++ b/src/pybamm/util.py @@ -1,9 +1,3 @@ -# -# Utility classes for PyBaMM -# -# The code in this file is adapted from Pints -# (see https://github.com/pints-team/pints) -# import importlib.util import importlib.metadata import numbers diff --git a/src/pybamm/version.py b/src/pybamm/version.py index ca0cfd956e..66b04ecbed 100644 --- a/src/pybamm/version.py +++ b/src/pybamm/version.py @@ -1 +1 @@ -__version__ = "24.9.0" +__version__ = "24.11.0" diff --git a/tests/integration/test_models/standard_output_comparison.py b/tests/integration/test_models/standard_output_comparison.py index 4d4d16e5ca..6c56894314 100644 --- a/tests/integration/test_models/standard_output_comparison.py +++ b/tests/integration/test_models/standard_output_comparison.py @@ -66,6 +66,7 @@ def compare(self, var, atol=0, rtol=0.02): # Get variable for each model model_variables = [solution[var] for solution in self.solutions] var0 = model_variables[0] + var0.initialise() spatial_pts = {} if var0.dimensions >= 1: diff --git a/tests/integration/test_models/standard_output_tests.py b/tests/integration/test_models/standard_output_tests.py index 83b88c0ff0..ca5e27607d 100644 --- a/tests/integration/test_models/standard_output_tests.py +++ b/tests/integration/test_models/standard_output_tests.py @@ -449,7 +449,7 @@ def test_conservation(self): # this seems to be linked to using constant concentration but not sure why decimal = 12 elif self.model.options["particle phases"] != "1": - decimal = 13 + decimal = 9 elif "current-driven" in self.model.options["loss of active material"]: # current driven LAM model doesn't perfectly conserve lithium, not sure why decimal = 9 diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py index 60e8dfb819..7c176249fd 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py @@ -375,3 +375,161 @@ def temp_drive_cycle(y, z, t): model = self.model() modeltest = tests.StandardModelTest(model, parameter_values=parameter_values) modeltest.test_all(skip_output_tests=True) + + def test_composite_stress_driven_LAM(self): + options = { + "particle phases": ("2", "1"), + "open-circuit potential": (("single", "current sigmoid"), "single"), + "loss of active material": "stress-driven", + } + + # taken from Ai2020 + def graphite_volume_change_Ai2020(sto): + p1 = 145.907 + p2 = -681.229 + p3 = 1334.442 + p4 = -1415.710 + p5 = 873.906 + p6 = -312.528 + p7 = 60.641 + p8 = -5.706 + p9 = 0.386 + p10 = -4.966e-05 + t_change = ( + p1 * sto**9 + + p2 * sto**8 + + p3 * sto**7 + + p4 * sto**6 + + p5 * sto**5 + + p6 * sto**4 + + p7 * sto**3 + + p8 * sto**2 + + p9 * sto + + p10 + ) + return t_change + + # taken from Ai2020 + def lico2_volume_change_Ai2020(sto): + omega = pybamm.Parameter( + "Positive electrode partial molar volume [m3.mol-1]" + ) + c_s_max = pybamm.Parameter( + "Maximum concentration in positive electrode [mol.m-3]" + ) + t_change = omega * c_s_max * sto + return t_change + + # use Chen2020 composite and add Ai2020 stress-driven parameters + parameter_values = pybamm.ParameterValues("Chen2020_composite") + parameter_values.update( + { + "Primary: Negative electrode LAM constant proportional term [s-1]": 1e-4 + / 3600, + "Secondary: Negative electrode LAM constant proportional term [s-1]": 1e-4 + / 3600, + "Positive electrode LAM constant proportional term [s-1]": 1e-4 / 3600, + "Primary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06, + "Primary: Negative electrode Young's modulus [Pa]": 15000000000.0, + "Primary: Negative electrode Poisson's ratio": 0.3, + "Primary: Negative electrode critical stress [Pa]": 60000000.0, + "Secondary: Negative electrode critical stress [Pa]": 60000000.0, + "Primary: Negative electrode LAM constant exponential term": 2.0, + "Secondary: Negative electrode LAM constant exponential term": 2.0, + "Secondary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06, + "Secondary: Negative electrode Young's modulus [Pa]": 15000000000.0, + "Secondary: Negative electrode Poisson's ratio": 0.3, + "Negative electrode reference concentration for free of deformation [mol.m-3]": 0.0, + "Primary: Negative electrode volume change": graphite_volume_change_Ai2020, + "Secondary: Negative electrode volume change": graphite_volume_change_Ai2020, + "Positive electrode partial molar volume [m3.mol-1]": -7.28e-07, + "Positive electrode Young's modulus [Pa]": 375000000000.0, + "Positive electrode Poisson's ratio": 0.2, + "Positive electrode critical stress [Pa]": 375000000.0, + "Positive electrode LAM constant exponential term": 2.0, + "Positive electrode reference concentration for free of deformation [mol.m-3]": 0.0, + "Positive electrode volume change": lico2_volume_change_Ai2020, + }, + check_already_exists=False, + ) + + self.run_basic_processing_test(options, parameter_values=parameter_values) + + def test_composite_reaction_driven_LAM(self): + options = { + "particle phases": ("2", "1"), + "open-circuit potential": (("single", "current sigmoid"), "single"), + "loss of active material": "reaction-driven", + } + + # taken from Ai2020 + def graphite_volume_change_Ai2020(sto): + p1 = 145.907 + p2 = -681.229 + p3 = 1334.442 + p4 = -1415.710 + p5 = 873.906 + p6 = -312.528 + p7 = 60.641 + p8 = -5.706 + p9 = 0.386 + p10 = -4.966e-05 + t_change = ( + p1 * sto**9 + + p2 * sto**8 + + p3 * sto**7 + + p4 * sto**6 + + p5 * sto**5 + + p6 * sto**4 + + p7 * sto**3 + + p8 * sto**2 + + p9 * sto + + p10 + ) + return t_change + + # taken from Ai2020 + def lico2_volume_change_Ai2020(sto): + omega = pybamm.Parameter( + "Positive electrode partial molar volume [m3.mol-1]" + ) + c_s_max = pybamm.Parameter( + "Maximum concentration in positive electrode [mol.m-3]" + ) + t_change = omega * c_s_max * sto + return t_change + + # use Chen2020 composite and add Ai2020 stress-driven parameters + parameter_values = pybamm.ParameterValues("Chen2020_composite") + parameter_values.update( + { + "Primary: Negative electrode LAM constant proportional term [s-1]": 1e-4 + / 3600, + "Secondary: Negative electrode LAM constant proportional term [s-1]": 1e-4 + / 3600, + "Positive electrode LAM constant proportional term [s-1]": 1e-4 / 3600, + "Primary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06, + "Primary: Negative electrode Young's modulus [Pa]": 15000000000.0, + "Primary: Negative electrode Poisson's ratio": 0.3, + "Primary: Negative electrode critical stress [Pa]": 60000000.0, + "Secondary: Negative electrode critical stress [Pa]": 60000000.0, + "Primary: Negative electrode LAM constant exponential term": 2.0, + "Secondary: Negative electrode LAM constant exponential term": 2.0, + "Secondary: Negative electrode partial molar volume [m3.mol-1]": 3.1e-06, + "Secondary: Negative electrode Young's modulus [Pa]": 15000000000.0, + "Secondary: Negative electrode Poisson's ratio": 0.3, + "Negative electrode reference concentration for free of deformation [mol.m-3]": 0.0, + "Primary: Negative electrode volume change": graphite_volume_change_Ai2020, + "Secondary: Negative electrode volume change": graphite_volume_change_Ai2020, + "Positive electrode partial molar volume [m3.mol-1]": -7.28e-07, + "Positive electrode Young's modulus [Pa]": 375000000000.0, + "Positive electrode Poisson's ratio": 0.2, + "Positive electrode critical stress [Pa]": 375000000.0, + "Positive electrode LAM constant exponential term": 2.0, + "Positive electrode reference concentration for free of deformation [mol.m-3]": 0.0, + "Positive electrode volume change": lico2_volume_change_Ai2020, + }, + check_already_exists=False, + ) + + self.run_basic_processing_test(options, parameter_values=parameter_values) diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_basic_models.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_basic_models.py index abb0169d06..3288db75fe 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_basic_models.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_basic_models.py @@ -16,7 +16,16 @@ def test_with_experiment(self): ] ) sim = pybamm.Simulation(model, experiment=experiment) - sim.solve(calc_esoh=False) + sol = sim.solve(calc_esoh=False) + + # Check the solve returned a solution + assert sol is not None + + # Check that the solution contains the expected number of cycles + assert len(sol.cycles) == 3 + + # Check that the solution terminated because it reached final time + assert sol.termination == "final time" class TestBasicSPM(BaseBasicModelTest): diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_compare_outputs.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_compare_outputs.py index 57067d6e3b..8ae7393f83 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_compare_outputs.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_compare_outputs.py @@ -4,11 +4,10 @@ import pybamm import numpy as np -import unittest from tests import StandardOutputComparison -class TestCompareOutputs(unittest.TestCase): +class TestCompareOutputs: def test_compare_outputs_surface_form(self): # load models options = [ @@ -142,12 +141,3 @@ def test_compare_narrow_size_distribution(self): # compare outputs comparison = StandardOutputComparison(solutions) comparison.test_all(skip_first_timestep=True) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - unittest.main() diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py index 7d457fd7dc..121bc3a018 100644 --- a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py @@ -26,3 +26,9 @@ def test_composite_graphite_silicon(self): def test_composite_graphite_silicon_sei(self): pass # skip this test + + def test_composite_reaction_driven_LAM(self): + pass # skip this test + + def test_composite_stress_driven_LAM(self): + pass # skip this test diff --git a/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_splitOCVR.py b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_splitOCVR.py new file mode 100644 index 0000000000..ef4391acdd --- /dev/null +++ b/tests/integration/test_models/test_full_battery_models/test_lithium_ion/test_splitOCVR.py @@ -0,0 +1,56 @@ +# +# Test that the model works with an example parameter set +# +import pybamm +import numpy as np +import tests + + +class TestSplitOCVR: + def test_basic_processing(self): + # example parameters + qp0 = 8.73231852 + qn0 = 5.82761507 + theta0_n = 0.9013973983641687 * 0.9 + theta0_p = 0.5142305254580026 * 0.83 + + # OCV functions + def Un(theta_n): + Un = ( + 0.1493 + + 0.8493 * np.exp(-61.79 * theta_n) + + 0.3824 * np.exp(-665.8 * theta_n) + - np.exp(39.42 * theta_n - 41.92) + - 0.03131 * np.arctan(25.59 * theta_n - 4.099) + - 0.009434 * np.arctan(32.49 * theta_n - 15.74) + ) + return Un + + def Up(theta_p): + Up = ( + -10.72 * theta_p**4 + + 23.88 * theta_p**3 + - 16.77 * theta_p**2 + + 2.595 * theta_p + + 4.563 + ) + return Up + + pars = pybamm.ParameterValues( + { + "Positive electrode capacity [A.h]": qp0, + "Ohmic resistance [Ohm]": 0.001, + "Negative electrode initial stoichiometry": theta0_n, + "Lower voltage cut-off [V]": 2.8, + "Positive electrode initial stoichiometry": theta0_p, + "Upper voltage cut-off [V]": 4.2, + "Negative electrode capacity [A.h]": qn0, + "Current function [A]": 5, + "Positive electrode OCP [V]": Up, + "Negative electrode OCP [V]": Un, + "Nominal cell capacity [A.h]": 5, + } + ) + model = pybamm.lithium_ion.SplitOCVR() + modeltest = tests.StandardModelTest(model) + modeltest.test_all(param=pars) diff --git a/tests/integration/test_models/test_full_battery_models/test_sodium_ion/__init__.py b/tests/integration/test_models/test_full_battery_models/test_sodium_ion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/test_models/test_full_battery_models/test_sodium_ion/test_basic_models.py b/tests/integration/test_models/test_full_battery_models/test_sodium_ion/test_basic_models.py new file mode 100644 index 0000000000..2e0321cb1b --- /dev/null +++ b/tests/integration/test_models/test_full_battery_models/test_sodium_ion/test_basic_models.py @@ -0,0 +1,34 @@ +# +# Test basic model classes +# +import pybamm +import pytest + + +class BaseBasicModelTest: + def test_with_experiment(self): + model = self.model + experiment = pybamm.Experiment( + [ + "Discharge at C/3 until 3.5V", + "Hold at 3.5V for 1 hour", + "Rest for 10 min", + ] + ) + sim = pybamm.Simulation(model, experiment=experiment) + sol = sim.solve(calc_esoh=False) + + # Check the solve returned a solution + assert sol is not None + + # Check that the solution contains the expected number of cycles + assert len(sol.cycles) == 3 + + # Check that the solution terminated because it reached final time + assert sol.termination == "final time" + + +class TestBasicDFN(BaseBasicModelTest): + @pytest.fixture(autouse=True) + def setup(self): + self.model = pybamm.sodium_ion.BasicDFN() diff --git a/tests/integration/test_models/test_submodels/test_interface/test_butler_volmer.py b/tests/integration/test_models/test_submodels/test_interface/test_butler_volmer.py index f1f02350cd..7b2100792e 100644 --- a/tests/integration/test_models/test_submodels/test_interface/test_butler_volmer.py +++ b/tests/integration/test_models/test_submodels/test_interface/test_butler_volmer.py @@ -2,15 +2,15 @@ # Tests for the electrode-electrolyte interface equations # +import pytest import pybamm from tests import get_discretisation_for_testing -import unittest import numpy as np -class TestButlerVolmer(unittest.TestCase): - def setUp(self): +class TestButlerVolmer: + def setup_method(self): self.delta_phi_s_n = pybamm.Variable( "surface potential difference [V]", ["negative electrode"], @@ -73,7 +73,7 @@ def setUp(self): "reaction source terms [A.m-3]": 1, } - def tearDown(self): + def teardown_method(self): del self.variables del self.c_e_n del self.c_e_p @@ -114,12 +114,12 @@ def test_creation(self): ] # negative electrode Butler-Volmer is Multiplication - self.assertIsInstance(j_n, pybamm.Multiplication) - self.assertEqual(j_n.domain, ["negative electrode"]) + assert isinstance(j_n, pybamm.Multiplication) + assert j_n.domain == ["negative electrode"] # positive electrode Butler-Volmer is Multiplication - self.assertIsInstance(j_p, pybamm.Multiplication) - self.assertEqual(j_p.domain, ["positive electrode"]) + assert isinstance(j_p, pybamm.Multiplication) + assert j_p.domain == ["positive electrode"] def test_set_parameters(self): param = pybamm.LithiumIonParameters() @@ -159,9 +159,9 @@ def test_set_parameters(self): j_p = parameter_values.process_symbol(j_p) # Test for x in j_n.pre_order(): - self.assertNotIsInstance(x, pybamm.Parameter) + assert not isinstance(x, pybamm.Parameter) for x in j_p.pre_order(): - self.assertNotIsInstance(x, pybamm.Parameter) + assert not isinstance(x, pybamm.Parameter) def test_discretisation(self): param = pybamm.LithiumIonParameters() @@ -219,17 +219,13 @@ def test_discretisation(self): [mesh["negative electrode"].nodes, mesh["positive electrode"].nodes] ) y = np.concatenate([submesh**2, submesh**3, submesh**4]) - self.assertEqual( - j_n.evaluate(None, y).shape, (mesh["negative electrode"].npts, 1) - ) - self.assertEqual( - j_p.evaluate(None, y).shape, (mesh["positive electrode"].npts, 1) - ) + assert j_n.evaluate(None, y).shape == (mesh["negative electrode"].npts, 1) + assert j_p.evaluate(None, y).shape == (mesh["positive electrode"].npts, 1) # test concatenated butler-volmer whole_cell = ["negative electrode", "separator", "positive electrode"] whole_cell_mesh = disc.mesh[whole_cell] - self.assertEqual(j.evaluate(None, y).shape, (whole_cell_mesh.npts, 1)) + assert j.evaluate(None, y).shape == (whole_cell_mesh.npts, 1) def test_diff_c_e_lead_acid(self): # With intercalation @@ -361,27 +357,12 @@ def j_p(delta_phi): j_n_FD = parameter_values.process_symbol( (j_n(delta_phi + h) - j_n(delta_phi - h)) / (2 * h) ) - self.assertAlmostEqual( - j_n_diff.evaluate(inputs={"delta_phi": 0.5}) - / j_n_FD.evaluate(inputs={"delta_phi": 0.5}), - 1, - places=5, - ) + assert j_n_diff.evaluate(inputs={"delta_phi": 0.5}) / j_n_FD.evaluate( + inputs={"delta_phi": 0.5} + ) == pytest.approx(1, abs=1e-05) j_p_FD = parameter_values.process_symbol( (j_p(delta_phi + h) - j_p(delta_phi - h)) / (2 * h) ) - self.assertAlmostEqual( - j_p_diff.evaluate(inputs={"delta_phi": 0.5}) - / j_p_FD.evaluate(inputs={"delta_phi": 0.5}), - 1, - places=5, - ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - unittest.main() + assert j_p_diff.evaluate(inputs={"delta_phi": 0.5}) / j_p_FD.evaluate( + inputs={"delta_phi": 0.5} + ) == pytest.approx(1, abs=1e-05) diff --git a/tests/integration/test_solvers/test_idaklu.py b/tests/integration/test_solvers/test_idaklu.py index 88faa80dde..3ee96a9ccb 100644 --- a/tests/integration/test_solvers/test_idaklu.py +++ b/tests/integration/test_solvers/test_idaklu.py @@ -152,3 +152,55 @@ def test_interpolation(self): # test that y[1:3] = to true solution true_solution = b_value * sol.t np.testing.assert_array_almost_equal(sol.y[1:3], true_solution) + + def test_with_experiments(self): + summary_vars = [] + sols = [] + for out_vars in [True, False]: + model = pybamm.lithium_ion.SPM() + + if out_vars: + output_variables = [ + "Discharge capacity [A.h]", # 0D variables + "Time [s]", + "Current [A]", + "Voltage [V]", + "Pressure [Pa]", # 1D variable + "Positive particle effective diffusivity [m2.s-1]", # 2D variable + ] + else: + output_variables = None + + solver = pybamm.IDAKLUSolver(output_variables=output_variables) + + experiment = pybamm.Experiment( + [ + ( + "Charge at 1C until 4.2 V", + "Hold at 4.2 V until C/50", + "Rest for 1 hour", + ) + ] + ) + + sim = pybamm.Simulation( + model, + experiment=experiment, + solver=solver, + ) + + sol = sim.solve() + sols.append(sol) + summary_vars.append(sol.summary_variables) + + # check computed variables are propegated sucessfully + np.testing.assert_array_equal( + sols[0]["Pressure [Pa]"].data, sols[1]["Pressure [Pa]"].data + ) + np.testing.assert_array_almost_equal( + sols[0]["Voltage [V]"].data, sols[1]["Voltage [V]"].data + ) + + # check summary variables are the same if output variables are specified + for var in summary_vars[0].keys(): + assert summary_vars[0][var] == summary_vars[1][var] diff --git a/tests/shared.py b/tests/shared.py index 48e54e19d8..15c8d428a6 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -336,12 +336,12 @@ def no_internet_connection(): conn = socket.create_connection((host, 80), 2) conn.close() return False - except socket.gaierror: + except (socket.gaierror, TimeoutError): return True def assert_domain_equal(a, b): - "Check that two domains are equal, ignoring empty domains" + """Check that two domains are equal, ignoring empty domains""" a_dict = {k: v for k, v in a.items() if v != []} b_dict = {k: v for k, v in b.items() if v != []} assert a_dict == b_dict diff --git a/tests/testcase.py b/tests/testcase.py deleted file mode 100644 index 0b9b1f5dee..0000000000 --- a/tests/testcase.py +++ /dev/null @@ -1,17 +0,0 @@ -# -# Custom TestCase class for pybamm -# -import unittest - - -class TestCase(unittest.TestCase): - """ - Custom TestCase class for PyBaMM - TO BE REMOVED - """ - - def assertDomainEqual(self, a, b): - "Check that two domains are equal, ignoring empty domains" - a_dict = {k: v for k, v in a.items() if v != []} - b_dict = {k: v for k, v in b.items() if v != []} - self.assertEqual(a_dict, b_dict) diff --git a/tests/unit/test_citations.py b/tests/unit/test_citations.py index 7133cf234a..c87912490f 100644 --- a/tests/unit/test_citations.py +++ b/tests/unit/test_citations.py @@ -296,7 +296,7 @@ def test_reniers_2019(self): citations._reset() assert "Reniers2019" not in citations._papers_to_cite - pybamm.active_material.LossActiveMaterial(None, "negative", None, True) + pybamm.active_material.LossActiveMaterial(None, "negative", None, True, None) assert "Reniers2019" in citations._papers_to_cite assert "Reniers2019" in citations._citation_tags.keys() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000000..980c51eb25 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,169 @@ +import pytest +import select +import sys + +import pybamm +import uuid +from pathlib import Path +import platformdirs + + +class TestConfig: + @pytest.mark.parametrize("write_opt_in", [True, False]) + def test_write_read_uuid(self, tmp_path, write_opt_in): + # Create a temporary file path + config_file = tmp_path / "config.yml" + + # Call the function to write UUID to file + pybamm.config.write_uuid_to_file(config_file, write_opt_in) + + # Check that the file was created + assert config_file.exists() + + # Read the UUID using the read_uuid_from_file function + config_dict = pybamm.config.read_uuid_from_file(config_file) + # Check that the UUID was read successfully + if write_opt_in: + assert config_dict["enable_telemetry"] is True + assert "uuid" in config_dict + + # Verify that the UUID is valid + try: + uuid.UUID(config_dict["uuid"]) + except ValueError: + pytest.fail("Invalid UUID format") + else: + assert config_dict["enable_telemetry"] is False + + @pytest.mark.parametrize("user_opted_in, user_input", [(True, "y"), (False, "n")]) + def test_ask_user_opt_in(self, monkeypatch, capsys, user_opted_in, user_input): + # Mock select.select to simulate user input + def mock_select(*args, **kwargs): + return [sys.stdin], [], [] + + monkeypatch.setattr(select, "select", mock_select) + + # Mock sys.stdin.readline to return the desired input + monkeypatch.setattr(sys.stdin, "readline", lambda: user_input + "\n") + + # Call the function to ask the user if they want to opt in + opt_in = pybamm.config.ask_user_opt_in() + + # Check the result + assert opt_in is user_opted_in + + # Check that the prompt was printed + captured = capsys.readouterr() + assert "Do you want to enable telemetry? (Y/n):" in captured.out + + def test_ask_user_opt_in_invalid_input(self, monkeypatch, capsys): + # Mock select.select to simulate user input and then timeout + def mock_select(*args, **kwargs): + nonlocal call_count + if call_count == 0: + call_count += 1 + return [sys.stdin], [], [] + else: + return [], [], [] + + monkeypatch.setattr(select, "select", mock_select) + + # Mock sys.stdin.readline to return invalid input + monkeypatch.setattr(sys.stdin, "readline", lambda: "invalid\n") + + # Initialize call count + call_count = 0 + + # Call the function to ask the user if they want to opt in + opt_in = pybamm.config.ask_user_opt_in(timeout=1) + + # Check the result (should be False for timeout after invalid input) + assert opt_in is False + + # Check that the prompt, invalid input message, and timeout message were printed + captured = capsys.readouterr() + assert "Do you want to enable telemetry? (Y/n):" in captured.out + assert ( + "Invalid input. Please enter 'yes/y' for yes or 'no/n' for no." + in captured.out + ) + assert "Timeout reached. Defaulting to not enabling telemetry." in captured.out + + def test_ask_user_opt_in_timeout(self, monkeypatch, capsys): + # Mock select.select to simulate a timeout + def mock_select(*args, **kwargs): + return [], [], [] + + monkeypatch.setattr(select, "select", mock_select) + + # Call the function to ask the user if they want to opt in + opt_in = pybamm.config.ask_user_opt_in(timeout=1) + + # Check the result (should be False for timeout) + assert opt_in is False + + # Check that the prompt and timeout message were printed + captured = capsys.readouterr() + assert "Do you want to enable telemetry? (Y/n):" in captured.out + assert "Timeout reached. Defaulting to not enabling telemetry." in captured.out + + def test_generate_and_read(self, monkeypatch, tmp_path): + monkeypatch.setattr(pybamm.config, "is_running_tests", lambda: False) + monkeypatch.setattr(pybamm.config, "check_opt_out", lambda: False) + monkeypatch.setattr(pybamm.config, "ask_user_opt_in", lambda: True) + + # Mock telemetry capture + capture_called = False + + def mock_capture(event): + nonlocal capture_called + assert event == "user-opted-in" + capture_called = True + + monkeypatch.setattr(pybamm.telemetry, "capture", mock_capture) + + # Mock config directory + monkeypatch.setattr(platformdirs, "user_config_dir", lambda x: str(tmp_path)) + + # Test generate() creates new config + pybamm.config.generate() + + # Verify config was created + config = pybamm.config.read() + assert config is not None + assert config["enable_telemetry"] is True + assert "uuid" in config + assert capture_called is True + + # Test generate() does nothing if config exists + capture_called = False + pybamm.config.generate() + assert capture_called is False + + def test_read_uuid_from_file_no_file(self): + config_dict = pybamm.config.read_uuid_from_file(Path("nonexistent_file.yml")) + assert config_dict is None + + def test_read_uuid_from_file_invalid_yaml(self, tmp_path): + # Create a temporary directory and file with invalid YAML content + invalid_yaml = tmp_path / "invalid_yaml.yml" + with open(invalid_yaml, "w") as f: + f.write("invalid: yaml: content:") + + config_dict = pybamm.config.read_uuid_from_file(invalid_yaml) + + assert config_dict is None + + def test_check_opt_out(self, monkeypatch): + monkeypatch.setattr(pybamm.config, "read", lambda: {"enable_telemetry": True}) + monkeypatch.setattr(pybamm.config, "check_env_opt_out", lambda: True) + assert pybamm.config.check_opt_out() is True + monkeypatch.setattr(pybamm.config, "read", lambda: {"enable_telemetry": False}) + monkeypatch.setattr(pybamm.config, "check_env_opt_out", lambda: True) + assert pybamm.config.check_opt_out() is True + monkeypatch.setattr(pybamm.config, "read", lambda: {"enable_telemetry": True}) + monkeypatch.setattr(pybamm.config, "check_env_opt_out", lambda: False) + assert pybamm.config.check_opt_out() is False + monkeypatch.setattr(pybamm.config, "read", lambda: {"enable_telemetry": False}) + monkeypatch.setattr(pybamm.config, "check_env_opt_out", lambda: False) + assert pybamm.config.check_opt_out() is True diff --git a/tests/unit/test_experiments/test_simulation_with_experiment.py b/tests/unit/test_experiments/test_simulation_with_experiment.py index 3507d6e5c1..b455e72393 100644 --- a/tests/unit/test_experiments/test_simulation_with_experiment.py +++ b/tests/unit/test_experiments/test_simulation_with_experiment.py @@ -1,11 +1,11 @@ # # Test setting up a simulation with an experiment # +import pytest import casadi import pybamm import numpy as np import os -import unittest from datetime import datetime @@ -15,7 +15,7 @@ def default_duration(self, value): return 1 -class TestSimulationExperiment(unittest.TestCase): +class TestSimulationExperiment: def test_set_up(self): experiment = pybamm.Experiment( [ @@ -29,24 +29,22 @@ def test_set_up(self): sim = pybamm.Simulation(model, experiment=experiment) sim.build_for_experiment() - self.assertEqual(sim.experiment.args, experiment.args) + assert sim.experiment.args == experiment.args steps = sim.experiment.steps model_I = sim.experiment_unique_steps_to_model[ steps[1].basic_repr() ] # CC charge model_V = sim.experiment_unique_steps_to_model[steps[2].basic_repr()] # CV hold - self.assertIn( - "Current cut-off [A] [experiment]", - [event.name for event in model_V.events], - ) - self.assertIn( - "Charge voltage cut-off [V] [experiment]", - [event.name for event in model_I.events], - ) + assert "Current cut-off [A] [experiment]" in [ + event.name for event in model_V.events + ] + assert "Charge voltage cut-off [V] [experiment]" in [ + event.name for event in model_I.events + ] # fails if trying to set up with something that isn't an experiment - with self.assertRaisesRegex(TypeError, "experiment must be"): + with pytest.raises(TypeError, match="experiment must be"): pybamm.Simulation(model, experiment=0) def test_setup_experiment_string_or_list(self): @@ -54,17 +52,14 @@ def test_setup_experiment_string_or_list(self): sim = pybamm.Simulation(model, experiment="Discharge at C/20 for 1 hour") sim.build_for_experiment() - self.assertEqual(len(sim.experiment.steps), 1) - self.assertEqual( - sim.experiment.steps[0].description, - "Discharge at C/20 for 1 hour", - ) + assert len(sim.experiment.steps) == 1 + assert sim.experiment.steps[0].description == "Discharge at C/20 for 1 hour" sim = pybamm.Simulation( model, experiment=["Discharge at C/20 for 1 hour", pybamm.step.rest(60)], ) sim.build_for_experiment() - self.assertEqual(len(sim.experiment.steps), 2) + assert len(sim.experiment.steps) == 2 def test_run_experiment(self): s = pybamm.step.string @@ -84,12 +79,16 @@ def test_run_experiment(self): sim = pybamm.Simulation(model, experiment=experiment) # test the callback here sol = sim.solve(callbacks=pybamm.callbacks.Callback()) - self.assertEqual(sol.termination, "final time") - self.assertEqual(len(sol.cycles), 1) + assert sol.termination == "final time" + assert len(sol.cycles) == 1 # Test outputs - np.testing.assert_array_equal(sol.cycles[0].steps[0]["C-rate"].data, 1 / 20) - np.testing.assert_array_equal(sol.cycles[0].steps[1]["Current [A]"].data, -1) + np.testing.assert_array_almost_equal( + sol.cycles[0].steps[0]["C-rate"].data, 1 / 20 + ) + np.testing.assert_array_almost_equal( + sol.cycles[0].steps[1]["Current [A]"].data, -1 + ) np.testing.assert_array_almost_equal( sol.cycles[0].steps[2]["Voltage [V]"].data, 4.1, decimal=5 ) @@ -128,23 +127,23 @@ def test_run_experiment(self): # Solve again starting from solution sol2 = sim.solve(starting_solution=sol) - self.assertEqual(sol2.termination, "final time") - self.assertGreater(sol2.t[-1], sol.t[-1]) - self.assertEqual(sol2.cycles[0], sol.cycles[0]) - self.assertEqual(len(sol2.cycles), 2) + assert sol2.termination == "final time" + assert sol2.t[-1] > sol.t[-1] + assert sol2.cycles[0] == sol.cycles[0] + assert len(sol2.cycles) == 2 # Solve again starting from solution but only inputting the cycle sol2 = sim.solve(starting_solution=sol.cycles[-1]) - self.assertEqual(sol2.termination, "final time") - self.assertGreater(sol2.t[-1], sol.t[-1]) - self.assertEqual(len(sol2.cycles), 2) + assert sol2.termination == "final time" + assert sol2.t[-1] > sol.t[-1] + assert len(sol2.cycles) == 2 # Check starting solution is unchanged - self.assertEqual(len(sol.cycles), 1) + assert len(sol.cycles) == 1 # save sol2.save("test_experiment.sav") sol3 = pybamm.load("test_experiment.sav") - self.assertEqual(len(sol3.cycles), 2) + assert len(sol3.cycles) == 2 os.remove("test_experiment.sav") def test_run_experiment_multiple_times(self): @@ -168,7 +167,9 @@ def test_run_experiment_multiple_times(self): sol1["Voltage [V]"].data, sol2["Voltage [V]"].data ) - @unittest.skipIf(not pybamm.has_idaklu(), "idaklu solver is not installed") + @pytest.mark.skipif( + not pybamm.has_idaklu(), reason="idaklu solver is not installed" + ) def test_run_experiment_cccv_solvers(self): experiment_2step = pybamm.Experiment( [ @@ -199,7 +200,86 @@ def test_run_experiment_cccv_solvers(self): solutions[1]["Current [A]"](solutions[0].t), decimal=0, ) - self.assertEqual(solutions[1].termination, "final time") + assert solutions[1].termination == "final time" + + @pytest.mark.skipif( + not pybamm.has_idaklu(), reason="idaklu solver is not installed" + ) + def test_solve_with_sensitivities_and_experiment(self): + experiment_2step = pybamm.Experiment( + [ + ( + "Discharge at C/20 for 1 hour", + "Charge at 1 A until 4.1 V", + "Hold at 4.1 V until C/2", + "Discharge at 2 W for 30 min", + "Discharge at 2 W for 30 min", # repeat to cover this case (changes initialisation) + ), + ] + * 2, + ) + + solutions = [] + for solver in [ + pybamm.CasadiSolver(), + pybamm.IDAKLUSolver(), + pybamm.ScipySolver(), + ]: + for calculate_sensitivities in [False, True]: + model = pybamm.lithium_ion.SPM() + param = model.default_parameter_values + input_param_name = "Negative electrode active material volume fraction" + input_param_value = param[input_param_name] + param.update({input_param_name: "[input]"}) + sim = pybamm.Simulation( + model, + experiment=experiment_2step, + solver=solver, + parameter_values=param, + ) + solution = sim.solve( + inputs={input_param_name: input_param_value}, + calculate_sensitivities=calculate_sensitivities, + ) + solutions.append(solution) + + # check solutions are the same, leave out the last solution point as it is slightly different + # for each solve due to numerical errors + # TODO: scipy solver does not work for this experiment, with or without sensitivities, + # so we skip this test for now + for i in range(1, len(solutions) - 2): + np.testing.assert_allclose( + solutions[0]["Voltage [V]"].data[:-1], + solutions[i]["Voltage [V]"](solutions[0].t[:-1]), + rtol=5e-2, + equal_nan=True, + ) + + # check sensitivities are roughly the same. Sundials isn't doing error control on the sensitivities + # by default, and the solution can be quite coarse for quickly changing sensitivities + sens_casadi = ( + solutions[1]["Voltage [V]"] + .sensitivities[input_param_name][:-2] + .full() + .flatten() + ) + sens_idaklu = np.interp( + solutions[1].t[:-2], + solutions[3].t, + solutions[3]["Voltage [V]"] + .sensitivities[input_param_name] + .full() + .flatten(), + ) + rtol = 1e-1 + atol = 1e-2 + error = np.sqrt( + np.sum( + ((sens_casadi - sens_idaklu) / (rtol * np.abs(sens_casadi) + atol)) ** 2 + ) + / len(sens_casadi) + ) + assert error < 1.0 def test_run_experiment_drive_cycle(self): drive_cycle = np.array([np.arange(10), np.arange(10)]).T @@ -215,9 +295,8 @@ def test_run_experiment_drive_cycle(self): model = pybamm.lithium_ion.SPM() sim = pybamm.Simulation(model, experiment=experiment) sim.build_for_experiment() - self.assertEqual( - sorted([step.basic_repr() for step in experiment.steps]), - sorted(list(sim.experiment_unique_steps_to_model.keys())), + assert sorted([step.basic_repr() for step in experiment.steps]) == sorted( + list(sim.experiment_unique_steps_to_model.keys()) ) def test_run_experiment_breaks_early_infeasible(self): @@ -231,7 +310,7 @@ def test_run_experiment_breaks_early_infeasible(self): t_eval, solver=pybamm.CasadiSolver(), callbacks=pybamm.callbacks.Callback() ) pybamm.set_logging_level("WARNING") - self.assertEqual(sim._solution.termination, "event: Minimum voltage [V]") + assert sim._solution.termination == "event: Minimum voltage [V]" def test_run_experiment_breaks_early_error(self): s = pybamm.step.string @@ -254,8 +333,8 @@ def test_run_experiment_breaks_early_error(self): solver=solver, ) sol = sim.solve() - self.assertEqual(len(sol.cycles), 1) - self.assertEqual(len(sol.cycles[0].steps), 1) + assert len(sol.cycles) == 1 + assert len(sol.cycles[0].steps) == 1 # Different experiment setup style experiment = pybamm.Experiment( @@ -271,8 +350,8 @@ def test_run_experiment_breaks_early_error(self): solver=solver, ) sol = sim.solve() - self.assertEqual(len(sol.cycles), 1) - self.assertEqual(len(sol.cycles[0].steps), 1) + assert len(sol.cycles) == 1 + assert len(sol.cycles[0].steps) == 1 # Different callback - this is for coverage on the `Callback` class sol = sim.solve(callbacks=pybamm.callbacks.Callback()) @@ -287,8 +366,8 @@ def test_run_experiment_infeasible_time(self): model, parameter_values=parameter_values, experiment=experiment ) sol = sim.solve() - self.assertEqual(len(sol.cycles), 1) - self.assertEqual(len(sol.cycles[0].steps), 1) + assert len(sol.cycles) == 1 + assert len(sol.cycles[0].steps) == 1 def test_run_experiment_termination_capacity(self): # with percent @@ -365,7 +444,7 @@ def test_run_experiment_termination_voltage(self): # Only two cycles should be completed, only 2nd cycle should go below 4V np.testing.assert_array_less(4, np.min(sol.cycles[0]["Voltage [V]"].data)) np.testing.assert_array_less(np.min(sol.cycles[1]["Voltage [V]"].data), 4) - self.assertEqual(len(sol.cycles), 2) + assert len(sol.cycles) == 2 def test_run_experiment_termination_time_min(self): experiment = pybamm.Experiment( @@ -383,7 +462,7 @@ def test_run_experiment_termination_time_min(self): # Only two cycles should be completed, only 2nd cycle should go below 4V np.testing.assert_array_less(np.max(sol.cycles[0]["Time [s]"].data), 1500) np.testing.assert_array_equal(np.max(sol.cycles[1]["Time [s]"].data), 1500) - self.assertEqual(len(sol.cycles), 2) + assert len(sol.cycles) == 2 def test_run_experiment_termination_time_s(self): experiment = pybamm.Experiment( @@ -401,7 +480,7 @@ def test_run_experiment_termination_time_s(self): # Only two cycles should be completed, only 2nd cycle should go below 4V np.testing.assert_array_less(np.max(sol.cycles[0]["Time [s]"].data), 1500) np.testing.assert_array_equal(np.max(sol.cycles[1]["Time [s]"].data), 1500) - self.assertEqual(len(sol.cycles), 2) + assert len(sol.cycles) == 2 def test_run_experiment_termination_time_h(self): experiment = pybamm.Experiment( @@ -419,7 +498,7 @@ def test_run_experiment_termination_time_h(self): # Only two cycles should be completed, only 2nd cycle should go below 4V np.testing.assert_array_less(np.max(sol.cycles[0]["Time [s]"].data), 1800) np.testing.assert_array_equal(np.max(sol.cycles[1]["Time [s]"].data), 1800) - self.assertEqual(len(sol.cycles), 2) + assert len(sol.cycles) == 2 def test_save_at_cycles(self): experiment = pybamm.Experiment( @@ -439,22 +518,22 @@ def test_save_at_cycles(self): ) # Solution saves "None" for the cycles that are not saved for cycle_num in [2, 4, 6, 8]: - self.assertIsNone(sol.cycles[cycle_num]) + assert sol.cycles[cycle_num] is None for cycle_num in [0, 1, 3, 5, 7, 9]: - self.assertIsNotNone(sol.cycles[cycle_num]) + assert sol.cycles[cycle_num] is not None # Summary variables are not None - self.assertIsNotNone(sol.summary_variables["Capacity [A.h]"]) + assert sol.summary_variables["Capacity [A.h]"] is not None sol = sim.solve( solver=pybamm.CasadiSolver("fast with events"), save_at_cycles=[3, 4, 5, 9] ) # Note offset by 1 (0th cycle is cycle 1) for cycle_num in [1, 5, 6, 7]: - self.assertIsNone(sol.cycles[cycle_num]) + assert sol.cycles[cycle_num] is None for cycle_num in [0, 2, 3, 4, 8, 9]: # first & last cycle always saved - self.assertIsNotNone(sol.cycles[cycle_num]) + assert sol.cycles[cycle_num] is not None # Summary variables are not None - self.assertIsNotNone(sol.summary_variables["Capacity [A.h]"]) + assert sol.summary_variables["Capacity [A.h]"] is not None def test_cycle_summary_variables(self): # Test cycle_summary_variables works for different combinations of data and @@ -556,8 +635,8 @@ def test_run_experiment_skip_steps(self): model, parameter_values=parameter_values, experiment=experiment ) sol = sim.solve() - self.assertIsInstance(sol.cycles[0].steps[0], pybamm.EmptySolution) - self.assertIsInstance(sol.cycles[0].steps[3], pybamm.EmptySolution) + assert isinstance(sol.cycles[0].steps[0], pybamm.EmptySolution) + assert isinstance(sol.cycles[0].steps[3], pybamm.EmptySolution) # Should get the same result if we run without the charge steps # since they are skipped @@ -612,9 +691,9 @@ def test_all_empty_solution_errors(self): sim = pybamm.Simulation( model, parameter_values=parameter_values, experiment=experiment ) - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Step 'Charge at 1C until 4.2V' is infeasible due to exceeded bounds", + match="Step 'Charge at 1C until 4.2V' is infeasible due to exceeded bounds", ): sim.solve() @@ -625,7 +704,7 @@ def test_all_empty_solution_errors(self): sim = pybamm.Simulation( model, parameter_values=parameter_values, experiment=experiment ) - with self.assertRaisesRegex(pybamm.SolverError, "All steps in the cycle"): + with pytest.raises(pybamm.SolverError, match="All steps in the cycle"): sim.solve() def test_solver_error(self): @@ -642,7 +721,7 @@ def test_solver_error(self): solver=pybamm.CasadiSolver(mode="fast"), ) - with self.assertRaisesRegex(pybamm.SolverError, "IDA_CONV_FAIL"): + with pytest.raises(pybamm.SolverError, match="IDA_CONV_FAIL"): sim.solve() def test_run_experiment_half_cell(self): @@ -672,9 +751,7 @@ def test_padding_rest_model(self): experiment = pybamm.Experiment(["Rest for 1 hour"]) sim = pybamm.Simulation(model, experiment=experiment) sim.build_for_experiment() - self.assertNotIn( - "Rest for padding", sim.experiment_unique_steps_to_model.keys() - ) + assert "Rest for padding" not in sim.experiment_unique_steps_to_model.keys() # Test padding rest model exists if there are start_times experiment = pybamm.step.string( @@ -682,13 +759,13 @@ def test_padding_rest_model(self): ) sim = pybamm.Simulation(model, experiment=experiment) sim.build_for_experiment() - self.assertIn("Rest for padding", sim.experiment_unique_steps_to_model.keys()) + assert "Rest for padding" in sim.experiment_unique_steps_to_model.keys() # Check at least there is an input parameter (temperature) - self.assertGreater( - len(sim.experiment_unique_steps_to_model["Rest for padding"].parameters), 0 + assert ( + len(sim.experiment_unique_steps_to_model["Rest for padding"].parameters) > 0 ) # Check the model is the same - self.assertIsInstance( + assert isinstance( sim.experiment_unique_steps_to_model["Rest for padding"], pybamm.lithium_ion.SPM, ) @@ -710,7 +787,7 @@ def test_run_start_time_experiment(self): ) sim = pybamm.Simulation(model, experiment=experiment) sol = sim.solve(calc_esoh=False) - self.assertEqual(sol["Time [s]"].entries[-1], 5400) + assert sol["Time [s]"].entries[-1] == 5400 # Test padding rest is added if time stamp is late experiment = pybamm.Experiment( @@ -726,7 +803,7 @@ def test_run_start_time_experiment(self): ) sim = pybamm.Simulation(model, experiment=experiment) sol = sim.solve(calc_esoh=False) - self.assertEqual(sol["Time [s]"].entries[-1], 10800) + assert sol["Time [s]"].entries[-1] == 10800 def test_starting_solution(self): model = pybamm.lithium_ion.SPM() @@ -743,7 +820,7 @@ def test_starting_solution(self): solution = sim.solve(save_at_cycles=[1]) # test that the last state is correct (i.e. final cycle is saved) - self.assertEqual(solution.last_state.t[-1], 1200) + assert solution.last_state.t[-1] == 1200 experiment = pybamm.Experiment( [ @@ -756,7 +833,7 @@ def test_starting_solution(self): new_solution = sim.solve(calc_esoh=False, starting_solution=solution) # test that the final time is correct (i.e. starting solution correctly set) - self.assertEqual(new_solution["Time [s]"].entries[-1], 3600) + assert new_solution["Time [s]"].entries[-1] == 3600 def test_experiment_start_time_starting_solution(self): model = pybamm.lithium_ion.SPM() @@ -778,7 +855,7 @@ def test_experiment_start_time_starting_solution(self): ) sim = pybamm.Simulation(model, experiment=experiment) - with self.assertRaisesRegex(ValueError, "experiments with `start_time`"): + with pytest.raises(ValueError, match="experiments with `start_time`"): sim.solve(starting_solution=solution) # Test starting_solution works well with start_time @@ -815,7 +892,7 @@ def test_experiment_start_time_starting_solution(self): new_solution = sim.solve(starting_solution=solution) # test that the final time is correct (i.e. starting solution correctly set) - self.assertEqual(new_solution["Time [s]"].entries[-1], 5400) + assert new_solution["Time [s]"].entries[-1] == 5400 def test_experiment_start_time_identical_steps(self): # Test that if we have the same step twice, with different start times, @@ -841,15 +918,15 @@ def test_experiment_start_time_identical_steps(self): sim.solve(calc_esoh=False) # Check that there are 4 steps - self.assertEqual(len(experiment.steps), 4) + assert len(experiment.steps) == 4 # Check that there are only 2 unique steps - self.assertEqual(len(sim.experiment.unique_steps), 2) + assert len(sim.experiment.unique_steps) == 2 # Check that there are only 3 built models (unique steps + padding rest) - self.assertEqual(len(sim.steps_to_built_models), 3) + assert len(sim.steps_to_built_models) == 3 - def test_experiment_custom_steps(self): + def test_experiment_custom_steps(self, subtests): model = pybamm.lithium_ion.SPM() # Explicit control @@ -870,7 +947,7 @@ def custom_step_voltage(variables): return 100 * (variables["Voltage [V]"] - 4.2) for control in ["differential"]: - with self.subTest(control=control): + with subtests.test(control=control): custom_step_alg = pybamm.step.CustomStepImplicit( custom_step_voltage, control=control, duration=100, period=10 ) @@ -897,20 +974,10 @@ def neg_stoich_cutoff(variables): ) sim = pybamm.Simulation(model, experiment=experiment) sol = sim.solve(calc_esoh=False) - self.assertEqual( - sol.cycles[0].steps[0].termination, - "event: Negative stoichiometry cut-off [experiment]", + assert ( + sol.cycles[0].steps[0].termination + == "event: Negative stoichiometry cut-off [experiment]" ) neg_stoich = sol["Negative electrode stoichiometry"].data - self.assertAlmostEqual(neg_stoich[-1], 0.5, places=4) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert neg_stoich[-1] == pytest.approx(0.5, abs=0.0001) diff --git a/tests/unit/test_expression_tree/test_binary_operators.py b/tests/unit/test_expression_tree/test_binary_operators.py index e1a14206b4..206aa0799c 100644 --- a/tests/unit/test_expression_tree/test_binary_operators.py +++ b/tests/unit/test_expression_tree/test_binary_operators.py @@ -2,8 +2,8 @@ # Tests for the Binary Operator classes # -import unittest -import unittest.mock as mock +import pytest + import numpy as np from scipy.sparse import coo_matrix @@ -19,19 +19,19 @@ } -class TestBinaryOperators(unittest.TestCase): +class TestBinaryOperators: def test_binary_operator(self): a = pybamm.Symbol("a") b = pybamm.Symbol("b") bin = pybamm.BinaryOperator("binary test", a, b) - self.assertEqual(bin.children[0].name, a.name) - self.assertEqual(bin.children[1].name, b.name) + assert bin.children[0].name == a.name + assert bin.children[1].name == b.name c = pybamm.Scalar(1) d = pybamm.Scalar(2) bin2 = pybamm.BinaryOperator("binary test", c, d) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): bin2.evaluate() - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): bin2._binary_jac(a, b) def test_binary_operator_domains(self): @@ -39,28 +39,28 @@ def test_binary_operator_domains(self): a = pybamm.Symbol("a", domain=["negative electrode"]) b = pybamm.Symbol("b", domain=["negative electrode"]) bin1 = pybamm.BinaryOperator("binary test", a, b) - self.assertEqual(bin1.domain, ["negative electrode"]) + assert bin1.domain == ["negative electrode"] # one empty domain c = pybamm.Symbol("c", domain=[]) bin2 = pybamm.BinaryOperator("binary test", a, c) - self.assertEqual(bin2.domain, ["negative electrode"]) + assert bin2.domain == ["negative electrode"] bin3 = pybamm.BinaryOperator("binary test", c, b) - self.assertEqual(bin3.domain, ["negative electrode"]) + assert bin3.domain == ["negative electrode"] # mismatched domains d = pybamm.Symbol("d", domain=["positive electrode"]) - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.BinaryOperator("binary test", a, d) def test_addition(self): a = pybamm.Symbol("a") b = pybamm.Symbol("b") summ = pybamm.Addition(a, b) - self.assertEqual(summ.children[0].name, a.name) - self.assertEqual(summ.children[1].name, b.name) + assert summ.children[0].name == a.name + assert summ.children[1].name == b.name # test simplifying summ2 = pybamm.Scalar(1) + pybamm.Scalar(3) - self.assertEqual(summ2, pybamm.Scalar(4)) + assert summ2 == pybamm.Scalar(4) def test_addition_numpy_array(self): a = pybamm.Symbol("a") @@ -68,32 +68,32 @@ def test_addition_numpy_array(self): # converts numpy array to vector array = np.array([1, 2, 3]) summ3 = pybamm.Addition(a, array) - self.assertIsInstance(summ3, pybamm.Addition) - self.assertIsInstance(summ3.children[0], pybamm.Symbol) - self.assertIsInstance(summ3.children[1], pybamm.Vector) + assert isinstance(summ3, pybamm.Addition) + assert isinstance(summ3.children[0], pybamm.Symbol) + assert isinstance(summ3.children[1], pybamm.Vector) summ4 = array + a - self.assertIsInstance(summ4.children[0], pybamm.Vector) + assert isinstance(summ4.children[0], pybamm.Vector) # should error if numpy array is not 1D array = np.array([[1, 2, 3], [4, 5, 6]]) - with self.assertRaisesRegex(ValueError, "left must be a 1D array"): + with pytest.raises(ValueError, match="left must be a 1D array"): pybamm.Addition(array, a) - with self.assertRaisesRegex(ValueError, "right must be a 1D array"): + with pytest.raises(ValueError, match="right must be a 1D array"): pybamm.Addition(a, array) def test_power(self): a = pybamm.Symbol("a") b = pybamm.Symbol("b") pow1 = pybamm.Power(a, b) - self.assertEqual(pow1.name, "**") - self.assertEqual(pow1.children[0].name, a.name) - self.assertEqual(pow1.children[1].name, b.name) + assert pow1.name == "**" + assert pow1.children[0].name == a.name + assert pow1.children[1].name == b.name a = pybamm.Scalar(4) b = pybamm.Scalar(2) pow2 = pybamm.Power(a, b) - self.assertEqual(pow2.evaluate(), 16) + assert pow2.evaluate() == 16 def test_diff(self): a = pybamm.StateVector(slice(0, 1)) @@ -101,51 +101,51 @@ def test_diff(self): y = np.array([5, 3]) # power - self.assertEqual((a**b).diff(b).evaluate(y=y), 5**3 * np.log(5)) - self.assertEqual((a**b).diff(a).evaluate(y=y), 3 * 5**2) - self.assertEqual((a**b).diff(a**b).evaluate(), 1) - self.assertEqual((a**a).diff(a).evaluate(y=y), 5**5 * np.log(5) + 5 * 5**4) - self.assertEqual((a**a).diff(b).evaluate(y=y), 0) + assert (a**b).diff(b).evaluate(y=y) == 5**3 * np.log(5) + assert (a**b).diff(a).evaluate(y=y) == 3 * 5**2 + assert (a**b).diff(a**b).evaluate() == 1 + assert (a**a).diff(a).evaluate(y=y) == 5**5 * np.log(5) + 5 * 5**4 + assert (a**a).diff(b).evaluate(y=y) == 0 # addition - self.assertEqual((a + b).diff(a).evaluate(), 1) - self.assertEqual((a + b).diff(b).evaluate(), 1) - self.assertEqual((a + b).diff(a + b).evaluate(), 1) - self.assertEqual((a + a).diff(a).evaluate(), 2) - self.assertEqual((a + a).diff(b).evaluate(), 0) + assert (a + b).diff(a).evaluate() == 1 + assert (a + b).diff(b).evaluate() == 1 + assert (a + b).diff(a + b).evaluate() == 1 + assert (a + a).diff(a).evaluate() == 2 + assert (a + a).diff(b).evaluate() == 0 # subtraction - self.assertEqual((a - b).diff(a).evaluate(), 1) - self.assertEqual((a - b).diff(b).evaluate(), -1) - self.assertEqual((a - b).diff(a - b).evaluate(), 1) - self.assertEqual((a - a).diff(a).evaluate(), 0) - self.assertEqual((a + a).diff(b).evaluate(), 0) + assert (a - b).diff(a).evaluate() == 1 + assert (a - b).diff(b).evaluate() == -1 + assert (a - b).diff(a - b).evaluate() == 1 + assert (a - a).diff(a).evaluate() == 0 + assert (a + a).diff(b).evaluate() == 0 # multiplication - self.assertEqual((a * b).diff(a).evaluate(y=y), 3) - self.assertEqual((a * b).diff(b).evaluate(y=y), 5) - self.assertEqual((a * b).diff(a * b).evaluate(y=y), 1) - self.assertEqual((a * a).diff(a).evaluate(y=y), 10) - self.assertEqual((a * a).diff(b).evaluate(y=y), 0) + assert (a * b).diff(a).evaluate(y=y) == 3 + assert (a * b).diff(b).evaluate(y=y) == 5 + assert (a * b).diff(a * b).evaluate(y=y) == 1 + assert (a * a).diff(a).evaluate(y=y) == 10 + assert (a * a).diff(b).evaluate(y=y) == 0 # matrix multiplication (not implemented) matmul = a @ b - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): matmul.diff(a) # inner - self.assertEqual(pybamm.inner(a, b).diff(a).evaluate(y=y), 3) - self.assertEqual(pybamm.inner(a, b).diff(b).evaluate(y=y), 5) - self.assertEqual(pybamm.inner(a, b).diff(pybamm.inner(a, b)).evaluate(y=y), 1) - self.assertEqual(pybamm.inner(a, a).diff(a).evaluate(y=y), 10) - self.assertEqual(pybamm.inner(a, a).diff(b).evaluate(y=y), 0) + assert pybamm.inner(a, b).diff(a).evaluate(y=y) == 3 + assert pybamm.inner(a, b).diff(b).evaluate(y=y) == 5 + assert pybamm.inner(a, b).diff(pybamm.inner(a, b)).evaluate(y=y) == 1 + assert pybamm.inner(a, a).diff(a).evaluate(y=y) == 10 + assert pybamm.inner(a, a).diff(b).evaluate(y=y) == 0 # division - self.assertEqual((a / b).diff(a).evaluate(y=y), 1 / 3) - self.assertEqual((a / b).diff(b).evaluate(y=y), -5 / 9) - self.assertEqual((a / b).diff(a / b).evaluate(y=y), 1) - self.assertEqual((a / a).diff(a).evaluate(y=y), 0) - self.assertEqual((a / a).diff(b).evaluate(y=y), 0) + assert (a / b).diff(a).evaluate(y=y) == 1 / 3 + assert (a / b).diff(b).evaluate(y=y) == -5 / 9 + assert (a / b).diff(a / b).evaluate(y=y) == 1 + assert (a / a).diff(a).evaluate(y=y) == 0 + assert (a / a).diff(b).evaluate(y=y) == 0 def test_printing(self): # This in not an exhaustive list of all cases. More test cases may need to @@ -154,23 +154,23 @@ def test_printing(self): b = pybamm.Parameter("b") c = pybamm.Parameter("c") d = pybamm.Parameter("d") - self.assertEqual(str(a + b), "a + b") - self.assertEqual(str(a + b + c + d), "a + b + c + d") - self.assertEqual(str((a + b) + (c + d)), "a + b + c + d") - self.assertEqual(str(a + b - c), "a + b - c") - self.assertEqual(str(a + b - c + d), "a + b - c + d") - self.assertEqual(str((a + b) - (c + d)), "a + b - (c + d)") - self.assertEqual(str((a + b) - (c - d)), "a + b - (c - d)") - - self.assertEqual(str((a + b) * (c + d)), "(a + b) * (c + d)") - self.assertEqual(str(a * b * (c + d)), "a * b * (c + d)") - self.assertEqual(str((a * b) * (c + d)), "a * b * (c + d)") - self.assertEqual(str(a * (b * (c + d))), "a * b * (c + d)") - self.assertEqual(str((a + b) / (c + d)), "(a + b) / (c + d)") - self.assertEqual(str(a + b / (c + d)), "a + b / (c + d)") - self.assertEqual(str(a * b / (c + d)), "a * b / (c + d)") - self.assertEqual(str((a * b) / (c + d)), "a * b / (c + d)") - self.assertEqual(str(a * (b / (c + d))), "a * b / (c + d)") + assert str(a + b) == "a + b" + assert str(a + b + c + d) == "a + b + c + d" + assert str((a + b) + (c + d)) == "a + b + c + d" + assert str(a + b - c) == "a + b - c" + assert str(a + b - c + d) == "a + b - c + d" + assert str((a + b) - (c + d)) == "a + b - (c + d)" + assert str((a + b) - (c - d)) == "a + b - (c - d)" + + assert str((a + b) * (c + d)) == "(a + b) * (c + d)" + assert str(a * b * (c + d)) == "a * b * (c + d)" + assert str((a * b) * (c + d)) == "a * b * (c + d)" + assert str(a * (b * (c + d))) == "a * b * (c + d)" + assert str((a + b) / (c + d)) == "(a + b) / (c + d)" + assert str(a + b / (c + d)) == "a + b / (c + d)" + assert str(a * b / (c + d)) == "a * b / (c + d)" + assert str((a * b) / (c + d)) == "a * b / (c + d)" + assert str(a * (b / (c + d))) == "a * b / (c + d)" def test_eq(self): a = pybamm.Scalar(4) @@ -178,20 +178,20 @@ def test_eq(self): bin1 = pybamm.BinaryOperator("test", a, b) bin2 = pybamm.BinaryOperator("test", a, b) bin3 = pybamm.BinaryOperator("new test", a, b) - self.assertEqual(bin1, bin2) - self.assertNotEqual(bin1, bin3) + assert bin1 == bin2 + assert bin1 != bin3 c = pybamm.Scalar(5) bin4 = pybamm.BinaryOperator("test", a, c) - self.assertEqual(bin1, bin4) + assert bin1 == bin4 d = pybamm.Scalar(42) bin5 = pybamm.BinaryOperator("test", a, d) - self.assertNotEqual(bin1, bin5) + assert bin1 != bin5 def test_number_overloading(self): a = pybamm.Scalar(4) prod = a * 3 - self.assertIsInstance(prod, pybamm.Scalar) - self.assertEqual(prod.evaluate(), 12) + assert isinstance(prod, pybamm.Scalar) + assert prod.evaluate() == 12 def test_sparse_multiply(self): row = np.array([0, 3, 1, 0]) @@ -225,11 +225,11 @@ def test_sparse_multiply(self): np.testing.assert_array_equal( (pybammS2 * pybammD2).evaluate().toarray(), S2.toarray() * D2 ) - with self.assertRaisesRegex(pybamm.ShapeError, "inconsistent shapes"): + with pytest.raises(pybamm.ShapeError, match="inconsistent shapes"): (pybammS1 * pybammS2).test_shape() - with self.assertRaisesRegex(pybamm.ShapeError, "inconsistent shapes"): + with pytest.raises(pybamm.ShapeError, match="inconsistent shapes"): (pybammS2 * pybammS1).test_shape() - with self.assertRaisesRegex(pybamm.ShapeError, "inconsistent shapes"): + with pytest.raises(pybamm.ShapeError, match="inconsistent shapes"): (pybammS2 * pybammS1).evaluate_ignoring_errors() # Matrix multiplication is normal matrix multiplication @@ -243,9 +243,9 @@ def test_sparse_multiply(self): np.testing.assert_array_equal((pybammD2 @ pybammS1).evaluate(), D2 * S1) np.testing.assert_array_equal((pybammS2 @ pybammD1).evaluate(), S2 * D1) np.testing.assert_array_equal((pybammD1 @ pybammS2).evaluate(), D1 * S2) - with self.assertRaisesRegex(pybamm.ShapeError, "dimension mismatch"): + with pytest.raises(pybamm.ShapeError, match="dimension mismatch"): (pybammS1 @ pybammS1).test_shape() - with self.assertRaisesRegex(pybamm.ShapeError, "dimension mismatch"): + with pytest.raises(pybamm.ShapeError, match="dimension mismatch"): (pybammS2 @ pybammS2).test_shape() def test_sparse_divide(self): @@ -294,133 +294,133 @@ def test_inner(self): disc.process_model(model) # check doesn't evaluate on edges anymore - self.assertEqual(model.variables["inner"].evaluates_on_edges("primary"), False) + assert not model.variables["inner"].evaluates_on_edges("primary") def test_source(self): u = pybamm.Variable("u", domain="current collector") v = pybamm.Variable("v", domain="current collector") source = pybamm.source(u, v) - self.assertIsInstance(source.children[0], pybamm.Mass) + assert isinstance(source.children[0], pybamm.Mass) boundary_source = pybamm.source(u, v, boundary=True) - self.assertIsInstance(boundary_source.children[0], pybamm.BoundaryMass) + assert isinstance(boundary_source.children[0], pybamm.BoundaryMass) def test_source_error(self): # test error with domain not current collector v = pybamm.Vector(np.ones(5), domain="current collector") w = pybamm.Vector(2 * np.ones(3), domain="test") - with self.assertRaisesRegex(pybamm.DomainError, "'source'"): + with pytest.raises(pybamm.DomainError, match="'source'"): pybamm.source(v, w) def test_heaviside(self): b = pybamm.StateVector(slice(0, 1)) heav = 1 < b - self.assertEqual(heav.evaluate(y=np.array([2])), 1) - self.assertEqual(heav.evaluate(y=np.array([1])), 0) - self.assertEqual(heav.evaluate(y=np.array([0])), 0) - self.assertEqual(str(heav), "1.0 < y[0:1]") + assert heav.evaluate(y=np.array([2])) == 1 + assert heav.evaluate(y=np.array([1])) == 0 + assert heav.evaluate(y=np.array([0])) == 0 + assert str(heav) == "1.0 < y[0:1]" heav = 1 >= b - self.assertEqual(heav.evaluate(y=np.array([2])), 0) - self.assertEqual(heav.evaluate(y=np.array([1])), 1) - self.assertEqual(heav.evaluate(y=np.array([0])), 1) - self.assertEqual(str(heav), "y[0:1] <= 1.0") + assert heav.evaluate(y=np.array([2])) == 0 + assert heav.evaluate(y=np.array([1])) == 1 + assert heav.evaluate(y=np.array([0])) == 1 + assert str(heav) == "y[0:1] <= 1.0" # simplifications - self.assertEqual(1 < b + 2, -1 < b) - self.assertEqual(b + 1 > 2, b > 1) + assert (1 < b + 2) == (-1 < b) + assert (b + 1 > 2) == (b > 1) # expression with a subtract expr = 2 * (b < 1) - (b > 3) - self.assertEqual(expr.evaluate(y=np.array([0])), 2) - self.assertEqual(expr.evaluate(y=np.array([2])), 0) - self.assertEqual(expr.evaluate(y=np.array([4])), -1) + assert expr.evaluate(y=np.array([0])) == 2 + assert expr.evaluate(y=np.array([2])) == 0 + assert expr.evaluate(y=np.array([4])) == -1 def test_equality(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) equal = pybamm.Equality(a, b) - self.assertEqual(equal.evaluate(y=np.array([1])), 1) - self.assertEqual(equal.evaluate(y=np.array([2])), 0) - self.assertEqual(str(equal), "1.0 == y[0:1]") - self.assertEqual(equal.diff(b), 0) + assert equal.evaluate(y=np.array([1])) == 1 + assert equal.evaluate(y=np.array([2])) == 0 + assert str(equal) == "1.0 == y[0:1]" + assert equal.diff(b) == 0 def test_sigmoid(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) sigm = pybamm.sigmoid(a, b, 10) - self.assertAlmostEqual(sigm.evaluate(y=np.array([2]))[0, 0], 1) - self.assertEqual(sigm.evaluate(y=np.array([1])), 0.5) - self.assertAlmostEqual(sigm.evaluate(y=np.array([0]))[0, 0], 0) - self.assertEqual(str(sigm), "0.5 + 0.5 * tanh(-10.0 + 10.0 * y[0:1])") + assert sigm.evaluate(y=np.array([2]))[0, 0] == pytest.approx(1) + assert sigm.evaluate(y=np.array([1])) == 0.5 + pytest.approx(sigm.evaluate(y=np.array([0]))[0, 0], abs=0) + assert str(sigm) == "0.5 + 0.5 * tanh(-10.0 + 10.0 * y[0:1])" sigm = pybamm.sigmoid(b, a, 10) - self.assertAlmostEqual(sigm.evaluate(y=np.array([2]))[0, 0], 0) - self.assertEqual(sigm.evaluate(y=np.array([1])), 0.5) - self.assertAlmostEqual(sigm.evaluate(y=np.array([0]))[0, 0], 1) - self.assertEqual(str(sigm), "0.5 + 0.5 * tanh(10.0 - (10.0 * y[0:1]))") + pytest.approx(sigm.evaluate(y=np.array([2]))[0, 0], abs=0) + assert sigm.evaluate(y=np.array([1])) == 0.5 + pytest.approx(sigm.evaluate(y=np.array([0]))[0, 0], abs=1) + assert str(sigm) == "0.5 + 0.5 * tanh(10.0 - (10.0 * y[0:1]))" def test_modulo(self): a = pybamm.StateVector(slice(0, 1)) b = pybamm.Scalar(3) mod = a % b - self.assertEqual(mod.evaluate(y=np.array([4]))[0, 0], 1) - self.assertEqual(mod.evaluate(y=np.array([3]))[0, 0], 0) - self.assertEqual(mod.evaluate(y=np.array([2]))[0, 0], 2) - self.assertAlmostEqual(mod.evaluate(y=np.array([4.3]))[0, 0], 1.3) - self.assertAlmostEqual(mod.evaluate(y=np.array([2.2]))[0, 0], 2.2) - self.assertEqual(str(mod), "y[0:1] mod 3.0") + assert mod.evaluate(y=np.array([4]))[0, 0] == 1 + assert mod.evaluate(y=np.array([3]))[0, 0] == 0 + assert mod.evaluate(y=np.array([2]))[0, 0] == 2 + assert mod.evaluate(y=np.array([4.3]))[0, 0] == pytest.approx(1.3) + assert mod.evaluate(y=np.array([2.2]))[0, 0] == pytest.approx(2.2) + assert str(mod) == "y[0:1] mod 3.0" def test_minimum_maximum(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) minimum = pybamm.minimum(a, b) - self.assertEqual(minimum.evaluate(y=np.array([2])), 1) - self.assertEqual(minimum.evaluate(y=np.array([1])), 1) - self.assertEqual(minimum.evaluate(y=np.array([0])), 0) - self.assertEqual(str(minimum), "minimum(1.0, y[0:1])") + assert minimum.evaluate(y=np.array([2])) == 1 + assert minimum.evaluate(y=np.array([1])) == 1 + assert minimum.evaluate(y=np.array([0])) == 0 + assert str(minimum) == "minimum(1.0, y[0:1])" maximum = pybamm.maximum(a, b) - self.assertEqual(maximum.evaluate(y=np.array([2])), 2) - self.assertEqual(maximum.evaluate(y=np.array([1])), 1) - self.assertEqual(maximum.evaluate(y=np.array([0])), 1) - self.assertEqual(str(maximum), "maximum(1.0, y[0:1])") + assert maximum.evaluate(y=np.array([2])) == 2 + assert maximum.evaluate(y=np.array([1])) == 1 + assert maximum.evaluate(y=np.array([0])) == 1 + assert str(maximum) == "maximum(1.0, y[0:1])" def test_softminus_softplus(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) minimum = pybamm.softminus(a, b, 50) - self.assertAlmostEqual(minimum.evaluate(y=np.array([2]))[0, 0], 1) - self.assertAlmostEqual(minimum.evaluate(y=np.array([0]))[0, 0], 0) - self.assertEqual( - str(minimum), "-0.02 * log(1.9287498479639178e-22 + exp(-50.0 * y[0:1]))" + assert minimum.evaluate(y=np.array([2]))[0, 0] == pytest.approx(1) + assert minimum.evaluate(y=np.array([0]))[0, 0] == pytest.approx(0) + assert ( + str(minimum) == "-0.02 * log(1.9287498479639178e-22 + exp(-50.0 * y[0:1]))" ) maximum = pybamm.softplus(a, b, 50) - self.assertAlmostEqual(maximum.evaluate(y=np.array([2]))[0, 0], 2) - self.assertAlmostEqual(maximum.evaluate(y=np.array([0]))[0, 0], 1) - self.assertEqual( - str(maximum)[:20], - "0.02 * log(5.184705528587072e+21 + exp(50.0 * y[0:1]))"[:20], + assert maximum.evaluate(y=np.array([2]))[0, 0] == pytest.approx(2) + assert maximum.evaluate(y=np.array([0]))[0, 0] == pytest.approx(1) + assert ( + str(maximum)[:20] + == "0.02 * log(5.184705528587072e+21 + exp(50.0 * y[0:1]))"[:20] ) - self.assertEqual( - str(maximum)[-20:], - "0.02 * log(5.184705528587072e+21 + exp(50.0 * y[0:1]))"[-20:], + assert ( + str(maximum)[-20:] + == "0.02 * log(5.184705528587072e+21 + exp(50.0 * y[0:1]))"[-20:] ) # Test that smooth min/max are used when the setting is changed pybamm.settings.min_max_mode = "soft" pybamm.settings.min_max_smoothing = 10 - self.assertEqual(str(pybamm.minimum(a, b)), str(pybamm.softminus(a, b, 10))) - self.assertEqual(str(pybamm.maximum(a, b)), str(pybamm.softplus(a, b, 10))) + assert str(pybamm.minimum(a, b)) == str(pybamm.softminus(a, b, 10)) + assert str(pybamm.maximum(a, b)) == str(pybamm.softplus(a, b, 10)) # But exact min/max should still be used if both variables are constant a = pybamm.Scalar(1) b = pybamm.Scalar(2) - self.assertEqual(str(pybamm.minimum(a, b)), str(a)) - self.assertEqual(str(pybamm.maximum(a, b)), str(b)) + assert str(pybamm.minimum(a, b)) == str(a) + assert str(pybamm.maximum(a, b)) == str(b) # Change setting back for other tests pybamm.settings.set_smoothing_parameters("exact") @@ -430,36 +430,34 @@ def test_smooth_minus_plus(self): b = pybamm.StateVector(slice(0, 1)) minimum = pybamm.smooth_min(a, b, 3000) - self.assertAlmostEqual(minimum.evaluate(y=np.array([2]))[0, 0], 1) - self.assertAlmostEqual(minimum.evaluate(y=np.array([0]))[0, 0], 0) + pytest.approx(minimum.evaluate(y=np.array([2]))[0, 0], abs=1) + pytest.approx(minimum.evaluate(y=np.array([0]))[0, 0], abs=0) maximum = pybamm.smooth_max(a, b, 3000) - self.assertAlmostEqual(maximum.evaluate(y=np.array([2]))[0, 0], 2) - self.assertAlmostEqual(maximum.evaluate(y=np.array([0]))[0, 0], 1) + assert maximum.evaluate(y=np.array([2]))[0, 0] == pytest.approx(2) + assert maximum.evaluate(y=np.array([0]))[0, 0] == pytest.approx(1) minimum = pybamm.smooth_min(a, b, 1) - self.assertEqual( - str(minimum), - "0.5 * (1.0 + y[0:1] - sqrt(1.0 + (1.0 - y[0:1]) ** 2.0))", + assert ( + str(minimum) == "0.5 * (1.0 + y[0:1] - sqrt(1.0 + (1.0 - y[0:1]) ** 2.0))" ) maximum = pybamm.smooth_max(a, b, 1) - self.assertEqual( - str(maximum), - "0.5 * (sqrt(1.0 + (1.0 - y[0:1]) ** 2.0) + 1.0 + y[0:1])", + assert ( + str(maximum) == "0.5 * (sqrt(1.0 + (1.0 - y[0:1]) ** 2.0) + 1.0 + y[0:1])" ) # Test that smooth min/max are used when the setting is changed pybamm.settings.min_max_mode = "smooth" pybamm.settings.min_max_smoothing = 1 - self.assertEqual(str(pybamm.minimum(a, b)), str(pybamm.smooth_min(a, b, 1))) - self.assertEqual(str(pybamm.maximum(a, b)), str(pybamm.smooth_max(a, b, 1))) + assert str(pybamm.minimum(a, b)) == str(pybamm.smooth_min(a, b, 1)) + assert str(pybamm.maximum(a, b)) == str(pybamm.smooth_max(a, b, 1)) pybamm.settings.min_max_smoothing = 3000 a = pybamm.Scalar(1) b = pybamm.Scalar(2) - self.assertEqual(str(pybamm.minimum(a, b)), str(a)) - self.assertEqual(str(pybamm.maximum(a, b)), str(b)) + assert str(pybamm.minimum(a, b)) == str(a) + assert str(pybamm.maximum(a, b)) == str(b) # Change setting back for other tests pybamm.settings.set_smoothing_parameters("exact") @@ -480,134 +478,133 @@ def test_binary_simplifications(self): broad2_edge = pybamm.PrimaryBroadcastToEdges(2, "domain") # power - self.assertEqual((c**0), pybamm.Scalar(1)) - self.assertEqual((0**c), pybamm.Scalar(0)) - self.assertEqual((c**1), c) + assert (c**0) == pybamm.Scalar(1) + assert (0**c) == pybamm.Scalar(0) + assert (c**1) == c # power with broadcasts - self.assertEqual((c**broad2), pybamm.PrimaryBroadcast(c**2, "domain")) - self.assertEqual((broad2**c), pybamm.PrimaryBroadcast(2**c, "domain")) - self.assertEqual( - (broad2 ** pybamm.PrimaryBroadcast(c, "domain")), - pybamm.PrimaryBroadcast(2**c, "domain"), - ) + assert (c**broad2) == pybamm.PrimaryBroadcast(c**2, "domain") + assert (broad2**c) == pybamm.PrimaryBroadcast(2**c, "domain") + assert ( + broad2 ** pybamm.PrimaryBroadcast(c, "domain") + ) == pybamm.PrimaryBroadcast(2**c, "domain") # power with broadcasts to edge - self.assertIsInstance(var**broad2_edge, pybamm.Power) - self.assertEqual((var**broad2_edge).left, var) - self.assertEqual((var**broad2_edge).right, broad2_edge) + assert isinstance(var**broad2_edge, pybamm.Power) + assert (var**broad2_edge).left == var + assert (var**broad2_edge).right == broad2_edge # addition - self.assertEqual(a + b, pybamm.Scalar(1)) - self.assertEqual(b + b, pybamm.Scalar(2)) - self.assertEqual(b + a, pybamm.Scalar(1)) - self.assertEqual(0 + b, pybamm.Scalar(1)) - self.assertEqual(0 + c, c) - self.assertEqual(c + 0, c) + assert a + b == pybamm.Scalar(1) + assert b + b == pybamm.Scalar(2) + assert b + a == pybamm.Scalar(1) + assert 0 + b == pybamm.Scalar(1) + assert 0 + c == c + assert c + 0 == c # addition with subtraction - self.assertEqual(c + (d - c), d) - self.assertEqual((c - d) + d, c) + assert c + (d - c) == d + assert (c - d) + d == c # addition with broadcast zero - self.assertIsInstance((1 + broad0), pybamm.PrimaryBroadcast) + assert isinstance((1 + broad0), pybamm.PrimaryBroadcast) np.testing.assert_array_equal((1 + broad0).child.evaluate(), 1) np.testing.assert_array_equal((1 + broad0).domain, "domain") - self.assertIsInstance((broad0 + 1), pybamm.PrimaryBroadcast) + assert isinstance((broad0 + 1), pybamm.PrimaryBroadcast) np.testing.assert_array_equal((broad0 + 1).child.evaluate(), 1) np.testing.assert_array_equal((broad0 + 1).domain, "domain") # addition with broadcasts - self.assertEqual((c + broad2), pybamm.PrimaryBroadcast(c + 2, "domain")) - self.assertEqual((broad2 + c), pybamm.PrimaryBroadcast(2 + c, "domain")) + assert (c + broad2) == pybamm.PrimaryBroadcast(c + 2, "domain") + assert (broad2 + c) == pybamm.PrimaryBroadcast(2 + c, "domain") # addition with negate - self.assertEqual(c + -d, c - d) - self.assertEqual(-c + d, d - c) + assert c + -d == c - d + assert -c + d == d - c # subtraction - self.assertEqual(a - b, pybamm.Scalar(-1)) - self.assertEqual(b - b, pybamm.Scalar(0)) - self.assertEqual(b - a, pybamm.Scalar(1)) + assert a - b == pybamm.Scalar(-1) + assert b - b == pybamm.Scalar(0) + assert b - a == pybamm.Scalar(1) # subtraction with addition - self.assertEqual(c - (d + c), -d) - self.assertEqual(c - (c - d), d) - self.assertEqual((c + d) - d, c) - self.assertEqual((d + c) - d, c) - self.assertEqual((d - c) - d, -c) + assert c - (d + c) == -d + assert c - (c - d) == d + assert (c + d) - d == c + assert (d + c) - d == c + assert (d - c) - d == -c # subtraction with broadcasts - self.assertEqual((c - broad2), pybamm.PrimaryBroadcast(c - 2, "domain")) - self.assertEqual((broad2 - c), pybamm.PrimaryBroadcast(2 - c, "domain")) + assert (c - broad2) == pybamm.PrimaryBroadcast(c - 2, "domain") + assert (broad2 - c) == pybamm.PrimaryBroadcast(2 - c, "domain") # subtraction from itself - self.assertEqual((c - c), pybamm.Scalar(0)) - self.assertEqual((broad2 - broad2), broad0) + assert (c - c) == pybamm.Scalar(0) + assert (broad2 - broad2) == broad0 # subtraction with negate - self.assertEqual((c - (-d)), c + d) + assert (c - (-d)) == c + d # addition and subtraction with matrix zero - self.assertEqual(b + v, pybamm.Vector(np.ones((10, 1)))) - self.assertEqual(v + b, pybamm.Vector(np.ones((10, 1)))) - self.assertEqual(b - v, pybamm.Vector(np.ones((10, 1)))) - self.assertEqual(v - b, pybamm.Vector(-np.ones((10, 1)))) + assert b + v == pybamm.Vector(np.ones((10, 1))) + assert v + b == pybamm.Vector(np.ones((10, 1))) + assert b - v == pybamm.Vector(np.ones((10, 1))) + assert v - b == pybamm.Vector(-np.ones((10, 1))) # multiplication - self.assertEqual(a * b, pybamm.Scalar(0)) - self.assertEqual(b * a, pybamm.Scalar(0)) - self.assertEqual(b * b, pybamm.Scalar(1)) - self.assertEqual(a * a, pybamm.Scalar(0)) - self.assertEqual(a * c, pybamm.Scalar(0)) - self.assertEqual(c * a, pybamm.Scalar(0)) - self.assertEqual(b * c, c) + assert a * b == pybamm.Scalar(0) + assert b * a == pybamm.Scalar(0) + assert b * b == pybamm.Scalar(1) + assert a * a == pybamm.Scalar(0) + assert a * c == pybamm.Scalar(0) + assert c * a == pybamm.Scalar(0) + assert b * c == c # multiplication with -1 - self.assertEqual((c * -1), (-c)) - self.assertEqual((-1 * c), (-c)) + assert (c * -1) == (-c) + assert (-1 * c) == (-c) # multiplication with a negation - self.assertEqual((-c * -f), (c * f)) - self.assertEqual((-c * 4), (c * -4)) - self.assertEqual((4 * -c), (-4 * c)) + assert (-c * -f) == (c * f) + assert (-c * 4) == (c * -4) + assert (4 * -c) == (-4 * c) # multiplication with division - self.assertEqual((c * (d / c)), d) - self.assertEqual((c / d) * d, c) + assert (c * (d / c)) == d + assert (c / d) * d == c # multiplication with broadcasts - self.assertEqual((c * broad2), pybamm.PrimaryBroadcast(c * 2, "domain")) - self.assertEqual((broad2 * c), pybamm.PrimaryBroadcast(2 * c, "domain")) + assert (c * broad2) == pybamm.PrimaryBroadcast(c * 2, "domain") + assert (broad2 * c) == pybamm.PrimaryBroadcast(2 * c, "domain") # multiplication with matrix zero - self.assertEqual(b * v, pybamm.Vector(np.zeros((10, 1)))) - self.assertEqual(v * b, pybamm.Vector(np.zeros((10, 1)))) + assert b * v == pybamm.Vector(np.zeros((10, 1))) + assert v * b == pybamm.Vector(np.zeros((10, 1))) # multiplication with matrix one - self.assertEqual((f * v1), f) - self.assertEqual((v1 * f), f) + assert (f * v1) == f + assert (v1 * f) == f # multiplication with matrix minus one - self.assertEqual((f * (-v1)), (-f)) - self.assertEqual(((-v1) * f), (-f)) + assert (f * (-v1)) == (-f) + assert ((-v1) * f) == (-f) # multiplication with broadcast - self.assertEqual((var * broad2), (var * 2)) - self.assertEqual((broad2 * var), (2 * var)) + assert (var * broad2) == (var * 2) + assert (broad2 * var) == (2 * var) # multiplication with broadcast one - self.assertEqual((var * broad1), var) - self.assertEqual((broad1 * var), var) + assert (var * broad1) == var + assert (broad1 * var) == var # multiplication with broadcast minus one - self.assertEqual((var * -broad1), (-var)) - self.assertEqual((-broad1 * var), (-var)) + assert (var * -broad1) == (-var) + assert (-broad1 * var) == (-var) # division by itself - self.assertEqual((c / c), pybamm.Scalar(1)) - self.assertEqual((broad2 / broad2), broad1) + assert (c / c) == pybamm.Scalar(1) + assert (broad2 / broad2) == broad1 # division with a negation - self.assertEqual((-c / -f), (c / f)) - self.assertEqual((-c / 4), -0.25 * c) - self.assertEqual((4 / -c), (-4 / c)) + assert (-c / -f) == (c / f) + assert (-c / 4) == -0.25 * c + assert (4 / -c) == (-4 / c) # division with multiplication - self.assertEqual((c * d) / c, d) - self.assertEqual((d * c) / c, d) + assert (c * d) / c == d + assert (d * c) / c == d # division with broadcasts - self.assertEqual((c / broad2), pybamm.PrimaryBroadcast(c / 2, "domain")) - self.assertEqual((broad2 / c), pybamm.PrimaryBroadcast(2 / c, "domain")) + assert (c / broad2) == pybamm.PrimaryBroadcast(c / 2, "domain") + assert (broad2 / c) == pybamm.PrimaryBroadcast(2 / c, "domain") # division with matrix one - self.assertEqual((f / v1), f) - self.assertEqual((f / -v1), (-f)) + assert (f / v1) == f + assert (f / -v1) == (-f) # division by zero - with self.assertRaises(ZeroDivisionError): + with pytest.raises(ZeroDivisionError): b / a # division with a common term - self.assertEqual((2 * c) / (2 * var), (c / var)) - self.assertEqual((c * 2) / (var * 2), (c / var)) + assert (2 * c) / (2 * var) == (c / var) + assert (c * 2) / (var * 2) == (c / var) def test_binary_simplifications_concatenations(self): def conc_broad(x, y, z): @@ -625,10 +622,10 @@ def conc_broad(x, y, z): pybamm.InputParameter("y"), pybamm.InputParameter("z"), ) - self.assertEqual((a + 4), conc_broad(5, 6, 7)) - self.assertEqual((4 + a), conc_broad(5, 6, 7)) - self.assertEqual((a + b), conc_broad(12, 14, 16)) - self.assertIsInstance((a + c), pybamm.Concatenation) + assert (a + 4) == conc_broad(5, 6, 7) + assert (4 + a) == conc_broad(5, 6, 7) + assert (a + b) == conc_broad(12, 14, 16) + assert isinstance((a + c), pybamm.Concatenation) # No simplifications if all are Variable or StateVector objects v = pybamm.concatenation( @@ -636,8 +633,8 @@ def conc_broad(x, y, z): pybamm.Variable("y", "separator"), pybamm.Variable("z", "positive electrode"), ) - self.assertIsInstance((v * v), pybamm.Multiplication) - self.assertIsInstance((a * v), pybamm.Multiplication) + assert isinstance((v * v), pybamm.Multiplication) + assert isinstance((a * v), pybamm.Multiplication) def test_advanced_binary_simplifications(self): # MatMul simplifications that often appear when discretising spatial operators @@ -650,120 +647,120 @@ def test_advanced_binary_simplifications(self): # Do A@B first if it is constant expr = A @ (B @ var) - self.assertEqual(expr, ((A @ B) @ var)) + assert expr == ((A @ B) @ var) # Distribute the @ operator to a sum if one of the symbols being summed is # constant expr = A @ (var + vec) - self.assertEqual(expr, ((A @ var) + (A @ vec))) + assert expr == ((A @ var) + (A @ vec)) expr = A @ (var - vec) - self.assertEqual(expr, ((A @ var) - (A @ vec))) + assert expr == ((A @ var) - (A @ vec)) expr = A @ ((B @ var) + vec) - self.assertEqual(expr, (((A @ B) @ var) + (A @ vec))) + assert expr == (((A @ B) @ var) + (A @ vec)) expr = A @ ((B @ var) - vec) - self.assertEqual(expr, (((A @ B) @ var) - (A @ vec))) + assert expr == (((A @ B) @ var) - (A @ vec)) # Distribute the @ operator to a sum if both symbols being summed are matmuls expr = A @ (B @ var + C @ var2) - self.assertEqual(expr, ((A @ B) @ var + (A @ C) @ var2)) + assert expr == ((A @ B) @ var + (A @ C) @ var2) expr = A @ (B @ var - C @ var2) - self.assertEqual(expr, ((A @ B) @ var - (A @ C) @ var2)) + assert expr == ((A @ B) @ var - (A @ C) @ var2) # Reduce (A@var + B@var) to ((A+B)@var) expr = A @ var + B @ var - self.assertEqual(expr, ((A + B) @ var)) + assert expr == ((A + B) @ var) # Do A*e first if it is constant expr = A @ (5 * var) - self.assertEqual(expr, ((A * 5) @ var)) + assert expr == ((A * 5) @ var) expr = A @ (var * 5) - self.assertEqual(expr, ((A * 5) @ var)) + assert expr == ((A * 5) @ var) # Do A/e first if it is constant expr = A @ (var / 2) - self.assertEqual(expr, ((A / 2) @ var)) + assert expr == ((A / 2) @ var) # Do (vec*A) first if it is constant expr = vec * (A @ var) - self.assertEqual(expr, ((vec * A) @ var)) + assert expr == ((vec * A) @ var) expr = (A @ var) * vec - self.assertEqual(expr, ((vec * A) @ var)) + assert expr == ((vec * A) @ var) # Do (A/vec) first if it is constant expr = (A @ var) / vec - self.assertIsInstance(expr, pybamm.MatrixMultiplication) + assert isinstance(expr, pybamm.MatrixMultiplication) np.testing.assert_array_almost_equal(expr.left.evaluate(), (A / vec).evaluate()) - self.assertEqual(expr.children[1], var) + assert expr.children[1] == var # simplify additions and subtractions expr = 7 + (var + 5) - self.assertEqual(expr, (12 + var)) + assert expr == (12 + var) expr = 7 + (5 + var) - self.assertEqual(expr, (12 + var)) + assert expr == (12 + var) expr = (var + 5) + 7 - self.assertEqual(expr, (var + 12)) + assert expr == (var + 12) expr = (5 + var) + 7 - self.assertEqual(expr, (12 + var)) + assert expr == (12 + var) expr = 7 + (var - 5) - self.assertEqual(expr, (2 + var)) + assert expr == (2 + var) expr = 7 + (5 - var) - self.assertEqual(expr, (12 - var)) + assert expr == (12 - var) expr = (var - 5) + 7 - self.assertEqual(expr, (var + 2)) + assert expr == (var + 2) expr = (5 - var) + 7 - self.assertEqual(expr, (12 - var)) + assert expr == (12 - var) expr = 7 - (var + 5) - self.assertEqual(expr, (2 - var)) + assert expr == (2 - var) expr = 7 - (5 + var) - self.assertEqual(expr, (2 - var)) + assert expr == (2 - var) expr = (var + 5) - 7 - self.assertEqual(expr, (var + -2)) + assert expr == (var + -2) expr = (5 + var) - 7 - self.assertEqual(expr, (-2 + var)) + assert expr == (-2 + var) expr = 7 - (var - 5) - self.assertEqual(expr, (12 - var)) + assert expr == (12 - var) expr = 7 - (5 - var) - self.assertEqual(expr, (2 + var)) + assert expr == (2 + var) expr = (var - 5) - 7 - self.assertEqual(expr, (var - 12)) + assert expr == (var - 12) expr = (5 - var) - 7 - self.assertEqual(expr, (-2 - var)) + assert expr == (-2 - var) expr = var - (var + var2) - self.assertEqual(expr, -var2) + assert expr == -var2 # simplify multiplications and divisions expr = 10 * (var * 5) - self.assertEqual(expr, 50 * var) + assert expr == 50 * var expr = (var * 5) * 10 - self.assertEqual(expr, var * 50) + assert expr == var * 50 expr = 10 * (5 * var) - self.assertEqual(expr, 50 * var) + assert expr == 50 * var expr = (5 * var) * 10 - self.assertEqual(expr, 50 * var) + assert expr == 50 * var expr = 10 * (var / 5) - self.assertEqual(expr, (10 / 5) * var) + assert expr == (10 / 5) * var expr = (var / 5) * 10 - self.assertEqual(expr, var * (10 / 5)) + assert expr == var * (10 / 5) expr = (var * 5) / 10 - self.assertEqual(expr, var * (5 / 10)) + assert expr == var * (5 / 10) expr = (5 * var) / 10 - self.assertEqual(expr, (5 / 10) * var) + assert expr == (5 / 10) * var expr = 5 / (10 * var) - self.assertEqual(expr, (5 / 10) / var) + assert expr == (5 / 10) / var expr = 5 / (var * 10) - self.assertEqual(expr, (5 / 10) / var) + assert expr == (5 / 10) / var expr = (5 / var) / 10 - self.assertEqual(expr, (5 / 10) / var) + assert expr == (5 / 10) / var expr = 5 / (10 / var) - self.assertEqual(expr, (5 / 10) * var) + assert expr == (5 / 10) * var expr = 5 / (var / 10) - self.assertEqual(expr, 50 / var) + assert expr == 50 / var # use power rules on multiplications and divisions expr = (var * 5) ** 2 - self.assertEqual(expr, var**2 * 25) + assert expr == var**2 * 25 expr = (5 * var) ** 2 - self.assertEqual(expr, 25 * var**2) + assert expr == 25 * var**2 expr = (5 / var) ** 2 - self.assertEqual(expr, 25 / var**2) + assert expr == 25 / var**2 def test_inner_simplifications(self): a1 = pybamm.Scalar(0) @@ -776,116 +773,105 @@ def test_inner_simplifications(self): np.testing.assert_array_equal( pybamm.inner(a1, M2).evaluate().toarray(), M1.entries ) - self.assertEqual(pybamm.inner(a1, a2).evaluate(), 0) + assert pybamm.inner(a1, a2).evaluate() == 0 np.testing.assert_array_equal( pybamm.inner(M2, a1).evaluate().toarray(), M1.entries ) - self.assertEqual(pybamm.inner(a2, a1).evaluate(), 0) + assert pybamm.inner(a2, a1).evaluate() == 0 np.testing.assert_array_equal( pybamm.inner(M1, a3).evaluate().toarray(), M1.entries ) np.testing.assert_array_equal(pybamm.inner(v1, a3).evaluate(), 3 * v1.entries) - self.assertEqual(pybamm.inner(a2, a3).evaluate(), 3) - self.assertEqual(pybamm.inner(a3, a2).evaluate(), 3) - self.assertEqual(pybamm.inner(a3, a3).evaluate(), 9) + assert pybamm.inner(a2, a3).evaluate() == 3 + assert pybamm.inner(a3, a2).evaluate() == 3 + assert pybamm.inner(a3, a3).evaluate() == 9 def test_to_equation(self): # Test print_name pybamm.Addition.print_name = "test" - self.assertEqual(pybamm.Addition(1, 2).to_equation(), sympy.Symbol("test")) + assert pybamm.Addition(1, 2).to_equation() == sympy.Symbol("test") # Test Power - self.assertEqual(pybamm.Power(7, 2).to_equation(), 49) + assert pybamm.Power(7, 2).to_equation() == 49 # Test Division - self.assertEqual(pybamm.Division(10, 2).to_equation(), 5) + assert pybamm.Division(10, 2).to_equation() == 5 # Test Matrix Multiplication arr1 = pybamm.Array([[1, 0], [0, 1]]) arr2 = pybamm.Array([[4, 1], [2, 2]]) - self.assertEqual( - pybamm.MatrixMultiplication(arr1, arr2).to_equation(), - sympy.Matrix([[4.0, 1.0], [2.0, 2.0]]), + assert pybamm.MatrixMultiplication(arr1, arr2).to_equation() == sympy.Matrix( + [[4.0, 1.0], [2.0, 2.0]] ) # Test EqualHeaviside - self.assertEqual(pybamm.EqualHeaviside(1, 0).to_equation(), False) + assert not pybamm.EqualHeaviside(1, 0).to_equation() # Test NotEqualHeaviside - self.assertEqual(pybamm.NotEqualHeaviside(2, 4).to_equation(), True) + assert pybamm.NotEqualHeaviside(2, 4).to_equation() - def test_to_json(self): + def test_to_json(self, mocker): # Test Addition add_json = { "name": "+", - "id": mock.ANY, + "id": mocker.ANY, "domains": EMPTY_DOMAINS, } add = pybamm.Addition(2, 4) - self.assertEqual(add.to_json(), add_json) + assert add.to_json() == add_json add_json["children"] = [pybamm.Scalar(2), pybamm.Scalar(4)] - self.assertEqual(pybamm.Addition._from_json(add_json), add) + assert pybamm.Addition._from_json(add_json) == add # Test Power pow_json = { "name": "**", - "id": mock.ANY, + "id": mocker.ANY, "domains": EMPTY_DOMAINS, } pow = pybamm.Power(7, 2) - self.assertEqual(pow.to_json(), pow_json) + assert pow.to_json() == pow_json pow_json["children"] = [pybamm.Scalar(7), pybamm.Scalar(2)] - self.assertEqual(pybamm.Power._from_json(pow_json), pow) + assert pybamm.Power._from_json(pow_json) == pow # Test Division div_json = { "name": "/", - "id": mock.ANY, + "id": mocker.ANY, "domains": EMPTY_DOMAINS, } div = pybamm.Division(10, 5) - self.assertEqual(div.to_json(), div_json) + assert div.to_json() == div_json div_json["children"] = [pybamm.Scalar(10), pybamm.Scalar(5)] - self.assertEqual(pybamm.Division._from_json(div_json), div) + assert pybamm.Division._from_json(div_json) == div # Test EqualHeaviside equal_json = { "name": "<=", - "id": mock.ANY, + "id": mocker.ANY, "domains": EMPTY_DOMAINS, } equal_h = pybamm.EqualHeaviside(2, 4) - self.assertEqual(equal_h.to_json(), equal_json) + assert equal_h.to_json() == equal_json equal_json["children"] = [pybamm.Scalar(2), pybamm.Scalar(4)] - self.assertEqual(pybamm.EqualHeaviside._from_json(equal_json), equal_h) + assert pybamm.EqualHeaviside._from_json(equal_json) == equal_h # Test notEqualHeaviside not_equal_json = { "name": "<", - "id": mock.ANY, + "id": mocker.ANY, "domains": EMPTY_DOMAINS, } ne_h = pybamm.NotEqualHeaviside(2, 4) - self.assertEqual(ne_h.to_json(), not_equal_json) + assert ne_h.to_json() == not_equal_json not_equal_json["children"] = [pybamm.Scalar(2), pybamm.Scalar(4)] - self.assertEqual(pybamm.NotEqualHeaviside._from_json(not_equal_json), ne_h) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert pybamm.NotEqualHeaviside._from_json(not_equal_json) == ne_h diff --git a/tests/unit/test_expression_tree/test_concatenations.py b/tests/unit/test_expression_tree/test_concatenations.py index 1d7ccef610..e8fad71ce2 100644 --- a/tests/unit/test_expression_tree/test_concatenations.py +++ b/tests/unit/test_expression_tree/test_concatenations.py @@ -2,7 +2,6 @@ # Tests for the Concatenation class and subclasses # import pytest -import unittest.mock as mock from tests import assert_domain_equal @@ -377,7 +376,7 @@ def test_to_equation(self): # Test concat_sym assert pybamm.Concatenation(a, b).to_equation() == func_symbol - def test_to_from_json(self): + def test_to_from_json(self, mocker): # test DomainConcatenation mesh = get_mesh_for_testing() a = pybamm.Symbol("a", domain=["negative electrode"]) @@ -386,7 +385,7 @@ def test_to_from_json(self): json_dict = { "name": "domain_concatenation", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": ["negative electrode", "separator", "positive electrode"], "secondary": [], @@ -429,7 +428,7 @@ def test_to_from_json(self): np_json = { "name": "numpy_concatenation", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -446,3 +445,18 @@ def test_to_from_json(self): # test _from_json assert pybamm.NumpyConcatenation._from_json(np_json) == conc_np + + def test_same_number_of_children(self): + a = pybamm.Variable("y", domain=["1", "2"]) + b = pybamm.Variable("z", domain=["3"]) + + d1 = pybamm.Variable("d1", domain=["1"]) + d2 = pybamm.Variable("d2", domain=["2"]) + d3 = pybamm.Variable("d3", domain=["3"]) + + d_concat = pybamm.concatenation(pybamm.sin(d1), pybamm.sin(d2), pybamm.sin(d3)) + a_concat = pybamm.concatenation(pybamm.sin(a), pybamm.sin(b)) + with pytest.raises( + AssertionError, match="Concatenations must have the same number of children" + ): + a_concat + d_concat diff --git a/tests/unit/test_expression_tree/test_coupled_variable.py b/tests/unit/test_expression_tree/test_coupled_variable.py new file mode 100644 index 0000000000..3e60c412e5 --- /dev/null +++ b/tests/unit/test_expression_tree/test_coupled_variable.py @@ -0,0 +1,94 @@ +# +# Tests for the CoupledVariable class +# + + +import numpy as np + +import pybamm + +import pytest + + +def combine_models(list_of_models): + model = pybamm.BaseModel() + + for submodel in list_of_models: + model.coupled_variables.update(submodel.coupled_variables) + model.variables.update(submodel.variables) + model.rhs.update(submodel.rhs) + model.algebraic.update(submodel.algebraic) + model.initial_conditions.update(submodel.initial_conditions) + model.boundary_conditions.update(submodel.boundary_conditions) + + for name, coupled_variable in model.coupled_variables.items(): + if name in model.variables: + for sym in model.rhs.values(): + coupled_variable.set_coupled_variable(sym, model.variables[name]) + for sym in model.algebraic.values(): + coupled_variable.set_coupled_variable(sym, model.variables[name]) + return model + + +class TestCoupledVariable: + def test_coupled_variable(self): + model_1 = pybamm.BaseModel() + model_1_var_1 = pybamm.CoupledVariable("a") + model_1_var_2 = pybamm.Variable("b") + model_1.rhs[model_1_var_2] = -0.2 * model_1_var_1 + model_1.variables["b"] = model_1_var_2 + model_1.coupled_variables["a"] = model_1_var_1 + model_1.initial_conditions[model_1_var_2] = 1.0 + + model_2 = pybamm.BaseModel() + model_2_var_1 = pybamm.Variable("a") + model_2_var_2 = pybamm.CoupledVariable("b") + model_2.rhs[model_2_var_1] = -0.2 * model_2_var_2 + model_2.variables["a"] = model_2_var_1 + model_2.coupled_variables["b"] = model_2_var_2 + model_2.initial_conditions[model_2_var_1] = 1.0 + + model = combine_models([model_1, model_2]) + + params = pybamm.ParameterValues({}) + geometry = {} + + # Process parameters + params.process_model(model) + params.process_geometry(geometry) + + # mesh and discretise + submesh_types = {} + var_pts = {} + mesh = pybamm.Mesh(geometry, submesh_types, var_pts) + + spatial_methods = {} + disc = pybamm.Discretisation(mesh, spatial_methods) + disc.process_model(model) + + # solve + solver = pybamm.CasadiSolver() + t = np.linspace(0, 10, 1000) + solution = solver.solve(model, t) + + np.testing.assert_almost_equal( + solution["a"].entries, solution["b"].entries, decimal=10 + ) + + assert set(model.list_coupled_variables()) == set(["a", "b"]) + + def test_create_copy(self): + a = pybamm.CoupledVariable("a") + b = a.create_copy() + assert a == b + + def test_setter(self): + model = pybamm.BaseModel() + a = pybamm.CoupledVariable("a") + coupled_variables = {"a": a} + model.coupled_variables = coupled_variables + assert model.coupled_variables == coupled_variables + + with pytest.raises(ValueError, match="Coupled variable with name"): + coupled_variables = {"b": a} + model.coupled_variables = coupled_variables diff --git a/tests/unit/test_expression_tree/test_functions.py b/tests/unit/test_expression_tree/test_functions.py index 5f5324c0ae..fca7d7ee67 100644 --- a/tests/unit/test_expression_tree/test_functions.py +++ b/tests/unit/test_expression_tree/test_functions.py @@ -3,7 +3,6 @@ # import pytest -import unittest.mock as mock import numpy as np from scipy import special @@ -399,7 +398,7 @@ def test_tanh(self): abs=1e-05, ) - def test_erf(self): + def test_erf(self, mocker): a = pybamm.InputParameter("a") fun = pybamm.erf(a) assert fun.evaluate(inputs={"a": 3}) == special.erf(3) @@ -416,7 +415,7 @@ def test_erf(self): # test creation from json input_json = { "name": "erf", - "id": mock.ANY, + "id": mocker.ANY, "function": "erf", "children": [a], } diff --git a/tests/unit/test_expression_tree/test_input_parameter.py b/tests/unit/test_expression_tree/test_input_parameter.py index 87cbe79a31..884341cb4f 100644 --- a/tests/unit/test_expression_tree/test_input_parameter.py +++ b/tests/unit/test_expression_tree/test_input_parameter.py @@ -4,7 +4,6 @@ import numpy as np import pybamm import pytest -import unittest.mock as mock class TestInputParameter: @@ -49,12 +48,12 @@ def test_errors(self): with pytest.raises(KeyError): a.evaluate() - def test_to_from_json(self): + def test_to_from_json(self, mocker): a = pybamm.InputParameter("a") json_dict = { "name": "a", - "id": mock.ANY, + "id": mocker.ANY, "domain": [], "expected_size": 1, } diff --git a/tests/unit/test_expression_tree/test_operations/test_evaluate_python.py b/tests/unit/test_expression_tree/test_operations/test_evaluate_python.py index e6d8a0da83..14b980b358 100644 --- a/tests/unit/test_expression_tree/test_operations/test_evaluate_python.py +++ b/tests/unit/test_expression_tree/test_operations/test_evaluate_python.py @@ -6,7 +6,6 @@ import pybamm from tests import get_discretisation_for_testing, get_1p1d_discretisation_for_testing -import unittest import numpy as np import scipy.sparse from collections import OrderedDict @@ -746,13 +745,3 @@ def test_jax_coo_matrix(self): with pytest.raises(NotImplementedError): A.multiply(v) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_expression_tree/test_symbol.py b/tests/unit/test_expression_tree/test_symbol.py index a61d86cbe0..d7374e8da7 100644 --- a/tests/unit/test_expression_tree/test_symbol.py +++ b/tests/unit/test_expression_tree/test_symbol.py @@ -3,8 +3,6 @@ # import pytest -import os -from tempfile import TemporaryDirectory import numpy as np from scipy.sparse import csr_matrix, coo_matrix @@ -391,17 +389,17 @@ def test_symbol_repr(self): pybamm.grad(c).__repr__(), ) - def test_symbol_visualise(self): - with TemporaryDirectory() as dir_name: - test_stub = os.path.join(dir_name, "test_visualize") - test_name = f"{test_stub}.png" - c = pybamm.Variable("c", "negative electrode") - d = pybamm.Variable("d", "negative electrode") - sym = pybamm.div(c * pybamm.grad(c)) + (c / d + c - d) ** 5 - sym.visualise(test_name) - assert os.path.exists(test_name) - with pytest.raises(ValueError): - sym.visualise(test_stub) + @pytest.fixture(scope="session") + def test_symbol_visualise(self, tmp_path): + temp_file = tmp_path / "test_visualize.png" + c = pybamm.Variable("c", "negative electrode") + d = pybamm.Variable("d", "negative electrode") + sym = pybamm.div(c * pybamm.grad(c)) + (c / d + c - d) ** 5 + sym.visualise(str(temp_file)) + assert temp_file.exists() + + with pytest.raises(ValueError): + sym.visualise(str(temp_file.with_suffix(""))) def test_has_spatial_derivatives(self): var = pybamm.Variable("var", domain="test") diff --git a/tests/unit/test_expression_tree/test_symbolic_diff.py b/tests/unit/test_expression_tree/test_symbolic_diff.py index fb08740305..e03953e667 100644 --- a/tests/unit/test_expression_tree/test_symbolic_diff.py +++ b/tests/unit/test_expression_tree/test_symbolic_diff.py @@ -2,13 +2,13 @@ # Tests for the symbolic differentiation methods # +import pytest import numpy as np import pybamm -import unittest from numpy import testing -class TestSymbolicDifferentiation(unittest.TestCase): +class TestSymbolicDifferentiation: def test_advanced(self): a = pybamm.StateVector(slice(0, 1)) b = pybamm.StateVector(slice(1, 2)) @@ -16,14 +16,14 @@ def test_advanced(self): # func = (a * 2 + 5 * (-b)) / (a * b) - self.assertEqual(func.diff(a).evaluate(y=y), 1 / 5) - self.assertEqual(func.diff(b).evaluate(y=y), -2 / 9) + assert func.diff(a).evaluate(y=y) == 1 / 5 + assert func.diff(b).evaluate(y=y) == -2 / 9 # func = a * b**a testing.assert_array_almost_equal( func.diff(a).evaluate(y=y)[0], 3**5 * (5 * np.log(3) + 1) ) - self.assertEqual(func.diff(b).evaluate(y=y), 5**2 * 3**4) + assert func.diff(b).evaluate(y=y) == 5**2 * 3**4 def test_advanced_functions(self): a = pybamm.StateVector(slice(0, 1)) @@ -32,84 +32,68 @@ def test_advanced_functions(self): # func = a * pybamm.exp(b) - self.assertAlmostEqual(func.diff(a).evaluate(y=y)[0], np.exp(3)) + assert func.diff(a).evaluate(y=y)[0] == pytest.approx(np.exp(3)) func = pybamm.exp(a + 2 * b + a * b) + a * pybamm.exp(b) - self.assertEqual( - func.diff(a).evaluate(y=y), (4 * np.exp(3 * 5 + 5 + 2 * 3) + np.exp(3)) - ) - self.assertEqual( - func.diff(b).evaluate(y=y), np.exp(3) * (7 * np.exp(3 * 5 + 5 + 3) + 5) - ) + assert func.diff(a).evaluate(y=y) == (4 * np.exp(3 * 5 + 5 + 2 * 3) + np.exp(3)) + assert func.diff(b).evaluate(y=y) == np.exp(3) * (7 * np.exp(3 * 5 + 5 + 3) + 5) # func = pybamm.sin(pybamm.cos(a * 4) / 2) * pybamm.cos(4 * pybamm.exp(b / 3)) - self.assertEqual( - func.diff(a).evaluate(y=y), - -2 * np.sin(20) * np.cos(np.cos(20) / 2) * np.cos(4 * np.exp(1)), - ) - self.assertEqual( - func.diff(b).evaluate(y=y), - -4 / 3 * np.exp(1) * np.sin(4 * np.exp(1)) * np.sin(np.cos(20) / 2), - ) + assert func.diff(a).evaluate(y=y) == -2 * np.sin(20) * np.cos( + np.cos(20) / 2 + ) * np.cos(4 * np.exp(1)) + assert func.diff(b).evaluate(y=y) == -4 / 3 * np.exp(1) * np.sin( + 4 * np.exp(1) + ) * np.sin(np.cos(20) / 2) # func = pybamm.sin(a * b) - self.assertEqual(func.diff(a).evaluate(y=y), 3 * np.cos(15)) + assert func.diff(a).evaluate(y=y) == 3 * np.cos(15) def test_diff_zero(self): a = pybamm.StateVector(slice(0, 1)) b = pybamm.StateVector(slice(1, 2)) func = (a * 2 + 5 * (-a)) / (a * a) - self.assertEqual(func.diff(b), pybamm.Scalar(0)) - self.assertNotEqual(func.diff(a), pybamm.Scalar(0)) + assert func.diff(b) == pybamm.Scalar(0) + assert func.diff(a) != pybamm.Scalar(0) def test_diff_state_vector_dot(self): a = pybamm.StateVectorDot(slice(0, 1)) b = pybamm.StateVector(slice(1, 2)) - self.assertEqual(a.diff(a), pybamm.Scalar(1)) - self.assertEqual(a.diff(b), pybamm.Scalar(0)) + assert a.diff(a) == pybamm.Scalar(1) + assert a.diff(b) == pybamm.Scalar(0) def test_diff_heaviside(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) func = (a < b) * (2 * b) - self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 2) - self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 0) + assert func.diff(b).evaluate(y=np.array([2])) == 2 + assert func.diff(b).evaluate(y=np.array([-2])) == 0 def test_diff_modulo(self): a = pybamm.Scalar(3) b = pybamm.StateVector(slice(0, 1)) func = (a % b) * (b**2) - self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 0) - self.assertEqual(func.diff(b).evaluate(y=np.array([5])), 30) - self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 12) + assert func.diff(b).evaluate(y=np.array([2])) == 0 + assert func.diff(b).evaluate(y=np.array([5])) == 30 + assert func.diff(b).evaluate(y=np.array([-2])) == 12 def test_diff_maximum_minimum(self): a = pybamm.Scalar(1) b = pybamm.StateVector(slice(0, 1)) func = pybamm.minimum(a, b**3) - self.assertEqual(func.diff(b).evaluate(y=np.array([10])), 0) - self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 0) - self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 3 * (-2) ** 2) + assert func.diff(b).evaluate(y=np.array([10])) == 0 + assert func.diff(b).evaluate(y=np.array([2])) == 0 + assert func.diff(b).evaluate(y=np.array([-2])) == 3 * (-2) ** 2 func = pybamm.maximum(a, b**3) - self.assertEqual(func.diff(b).evaluate(y=np.array([10])), 3 * 10**2) - self.assertEqual(func.diff(b).evaluate(y=np.array([2])), 3 * 2**2) - self.assertEqual(func.diff(b).evaluate(y=np.array([-2])), 0) + assert func.diff(b).evaluate(y=np.array([10])) == 3 * 10**2 + assert func.diff(b).evaluate(y=np.array([2])) == 3 * 2**2 + assert func.diff(b).evaluate(y=np.array([-2])) == 0 def test_exceptions(self): a = pybamm.Symbol("a") b = pybamm.Symbol("b") - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): a._diff(b) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_expression_tree/test_unary_operators.py b/tests/unit/test_expression_tree/test_unary_operators.py index 39cd05cf1d..d7544763fe 100644 --- a/tests/unit/test_expression_tree/test_unary_operators.py +++ b/tests/unit/test_expression_tree/test_unary_operators.py @@ -1,9 +1,7 @@ # # Tests for the Unary Operator classes # -import unittest - -import unittest.mock as mock +import pytest import numpy as np from scipy.sparse import diags @@ -15,49 +13,48 @@ import pybamm -class TestUnaryOperators(unittest.TestCase): +class TestUnaryOperators: def test_unary_operator(self): a = pybamm.Symbol("a", domain=["test"]) un = pybamm.UnaryOperator("unary test", a) - self.assertEqual(un.children[0].name, a.name) - self.assertEqual(un.domain, a.domain) + assert un.children[0].name == a.name + assert un.domain == a.domain # with number a = pybamm.InputParameter("a") absval = pybamm.AbsoluteValue(-a) - self.assertEqual(absval.evaluate(inputs={"a": 10}), 10) + assert absval.evaluate(inputs={"a": 10}) == 10 - def test_negation(self): + def test_negation(self, mocker): a = pybamm.Symbol("a") nega = pybamm.Negate(a) - self.assertEqual(nega.name, "-") - self.assertEqual(nega.children[0].name, a.name) + assert nega.name == "-" + assert nega.children[0].name == a.name b = pybamm.Scalar(4) negb = pybamm.Negate(b) - self.assertEqual(negb.evaluate(), -4) + assert negb.evaluate() == -4 # Test broadcast gets switched broad_a = pybamm.PrimaryBroadcast(a, "test") neg_broad = -broad_a - self.assertEqual(neg_broad, pybamm.PrimaryBroadcast(nega, "test")) + assert neg_broad == pybamm.PrimaryBroadcast(nega, "test") broad_a = pybamm.FullBroadcast(a, "test", "test2") neg_broad = -broad_a - self.assertEqual(neg_broad, pybamm.FullBroadcast(nega, "test", "test2")) + assert neg_broad == pybamm.FullBroadcast(nega, "test", "test2") # Test recursion broad_a = pybamm.PrimaryBroadcast(pybamm.PrimaryBroadcast(a, "test"), "test2") neg_broad = -broad_a - self.assertEqual( - neg_broad, - pybamm.PrimaryBroadcast(pybamm.PrimaryBroadcast(nega, "test"), "test2"), + assert neg_broad == pybamm.PrimaryBroadcast( + pybamm.PrimaryBroadcast(nega, "test"), "test2" ) # Test from_json input_json = { "name": "-", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -66,39 +63,38 @@ def test_negation(self): }, "children": [a], } - self.assertEqual(pybamm.Negate._from_json(input_json), nega) + assert pybamm.Negate._from_json(input_json) == nega - def test_absolute(self): + def test_absolute(self, mocker): a = pybamm.Symbol("a") absa = pybamm.AbsoluteValue(a) - self.assertEqual(absa.name, "abs") - self.assertEqual(absa.children[0].name, a.name) + assert absa.name == "abs" + assert absa.children[0].name == a.name b = pybamm.Scalar(-4) absb = pybamm.AbsoluteValue(b) - self.assertEqual(absb.evaluate(), 4) + assert absb.evaluate() == 4 # Test broadcast gets switched broad_a = pybamm.PrimaryBroadcast(a, "test") abs_broad = abs(broad_a) - self.assertEqual(abs_broad, pybamm.PrimaryBroadcast(absa, "test")) + assert abs_broad == pybamm.PrimaryBroadcast(absa, "test") broad_a = pybamm.FullBroadcast(a, "test", "test2") abs_broad = abs(broad_a) - self.assertEqual(abs_broad, pybamm.FullBroadcast(absa, "test", "test2")) + assert abs_broad == pybamm.FullBroadcast(absa, "test", "test2") # Test recursion broad_a = pybamm.PrimaryBroadcast(pybamm.PrimaryBroadcast(a, "test"), "test2") abs_broad = abs(broad_a) - self.assertEqual( - abs_broad, - pybamm.PrimaryBroadcast(pybamm.PrimaryBroadcast(absa, "test"), "test2"), + assert abs_broad == pybamm.PrimaryBroadcast( + pybamm.PrimaryBroadcast(absa, "test"), "test2" ) # Test from_json input_json = { "name": "abs", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -107,24 +103,23 @@ def test_absolute(self): }, "children": [a], } - self.assertEqual(pybamm.AbsoluteValue._from_json(input_json), absa) + assert pybamm.AbsoluteValue._from_json(input_json) == absa def test_smooth_absolute_value(self): a = pybamm.StateVector(slice(0, 1)) expr = pybamm.smooth_absolute_value(a, 10) - self.assertAlmostEqual(expr.evaluate(y=np.array([1]))[0, 0], 1) - self.assertEqual(expr.evaluate(y=np.array([0])), 0) - self.assertAlmostEqual(expr.evaluate(y=np.array([-1]))[0, 0], 1) - self.assertEqual( - str(expr), - "y[0:1] * (exp(10.0 * y[0:1]) - exp(-10.0 * y[0:1])) " - "/ (exp(10.0 * y[0:1]) + exp(-10.0 * y[0:1]))", + assert expr.evaluate(y=np.array([1]))[0, 0] == pytest.approx(1) + assert expr.evaluate(y=np.array([0])) == 0 + assert expr.evaluate(y=np.array([-1]))[0, 0] == pytest.approx(1) + assert ( + str(expr) == "y[0:1] * (exp(10.0 * y[0:1]) - exp(-10.0 * y[0:1])) " + "/ (exp(10.0 * y[0:1]) + exp(-10.0 * y[0:1]))" ) def test_sign(self): b = pybamm.Scalar(-4) signb = pybamm.sign(b) - self.assertEqual(signb.evaluate(), -1) + assert signb.evaluate() == -1 A = diags(np.linspace(-1, 1, 5)) b = pybamm.Matrix(A) @@ -134,40 +129,48 @@ def test_sign(self): ) broad = pybamm.PrimaryBroadcast(-4, "test domain") - self.assertEqual(pybamm.sign(broad), pybamm.PrimaryBroadcast(-1, "test domain")) + assert pybamm.sign(broad) == pybamm.PrimaryBroadcast(-1, "test domain") conc = pybamm.Concatenation(broad, pybamm.PrimaryBroadcast(2, "another domain")) - self.assertEqual( - pybamm.sign(conc), - pybamm.Concatenation( - pybamm.PrimaryBroadcast(-1, "test domain"), - pybamm.PrimaryBroadcast(1, "another domain"), - ), + assert pybamm.sign(conc) == pybamm.Concatenation( + pybamm.PrimaryBroadcast(-1, "test domain"), + pybamm.PrimaryBroadcast(1, "another domain"), ) # Test from_json - with self.assertRaises(NotImplementedError): - # signs are always scalar/array types in a discretised model - pybamm.Sign._from_json({}) + c = pybamm.Multiplication(pybamm.Variable("a"), pybamm.Scalar(3)) + sign_json = { + "name": "sign", + "id": 5341515228900508018, + "domains": { + "primary": [], + "secondary": [], + "tertiary": [], + "quaternary": [], + }, + "children": [c], + } + + assert pybamm.sign(c) == pybamm.Sign._from_json(sign_json) - def test_floor(self): + def test_floor(self, mocker): a = pybamm.Symbol("a") floora = pybamm.Floor(a) - self.assertEqual(floora.name, "floor") - self.assertEqual(floora.children[0].name, a.name) + assert floora.name == "floor" + assert floora.children[0].name == a.name b = pybamm.Scalar(3.5) floorb = pybamm.Floor(b) - self.assertEqual(floorb.evaluate(), 3) + assert floorb.evaluate() == 3 c = pybamm.Scalar(-3.2) floorc = pybamm.Floor(c) - self.assertEqual(floorc.evaluate(), -4) + assert floorc.evaluate() == -4 # Test from_json input_json = { "name": "floor", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -176,26 +179,26 @@ def test_floor(self): }, "children": [a], } - self.assertEqual(pybamm.Floor._from_json(input_json), floora) + assert pybamm.Floor._from_json(input_json) == floora - def test_ceiling(self): + def test_ceiling(self, mocker): a = pybamm.Symbol("a") ceila = pybamm.Ceiling(a) - self.assertEqual(ceila.name, "ceil") - self.assertEqual(ceila.children[0].name, a.name) + assert ceila.name == "ceil" + assert ceila.children[0].name == a.name b = pybamm.Scalar(3.5) ceilb = pybamm.Ceiling(b) - self.assertEqual(ceilb.evaluate(), 4) + assert ceilb.evaluate() == 4 c = pybamm.Scalar(-3.2) ceilc = pybamm.Ceiling(c) - self.assertEqual(ceilc.evaluate(), -3) + assert ceilc.evaluate() == -3 # Test from_json input_json = { "name": "ceil", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -204,81 +207,77 @@ def test_ceiling(self): }, "children": [a], } - self.assertEqual(pybamm.Ceiling._from_json(input_json), ceila) + assert pybamm.Ceiling._from_json(input_json) == ceila def test_gradient(self): # gradient of scalar symbol should fail a = pybamm.Symbol("a") - with self.assertRaisesRegex( - pybamm.DomainError, "Cannot take gradient of 'a' since its domain is empty" + with pytest.raises( + pybamm.DomainError, + match="Cannot take gradient of 'a' since its domain is empty", ): pybamm.Gradient(a) # gradient of variable evaluating on edges should fail a = pybamm.PrimaryBroadcastToEdges(pybamm.Scalar(1), "test") - with self.assertRaisesRegex(TypeError, "evaluates on edges"): + with pytest.raises(TypeError, match="evaluates on edges"): pybamm.Gradient(a) # gradient of broadcast should return broadcasted zero a = pybamm.PrimaryBroadcast(pybamm.Variable("a"), "test domain") grad = pybamm.grad(a) - self.assertEqual(grad, pybamm.PrimaryBroadcastToEdges(0, "test domain")) + assert grad == pybamm.PrimaryBroadcastToEdges(0, "test domain") # gradient of a secondary broadcast moves the secondary out of the gradient a = pybamm.Symbol("a", domain="test domain") a_broad = pybamm.SecondaryBroadcast(a, "another domain") grad = pybamm.grad(a_broad) - self.assertEqual( - grad, pybamm.SecondaryBroadcast(pybamm.grad(a), "another domain") - ) + assert grad == pybamm.SecondaryBroadcast(pybamm.grad(a), "another domain") # otherwise gradient should work a = pybamm.Symbol("a", domain="test domain") grad = pybamm.Gradient(a) - self.assertEqual(grad.children[0].name, a.name) - self.assertEqual(grad.domain, a.domain) + assert grad.children[0].name == a.name + assert grad.domain == a.domain def test_div(self): # divergence of scalar symbol should fail a = pybamm.Symbol("a") - with self.assertRaisesRegex( + with pytest.raises( pybamm.DomainError, - "Cannot take divergence of 'a' since its domain is empty", + match="Cannot take divergence of 'a' since its domain is empty", ): pybamm.Divergence(a) # divergence of variable evaluating on edges should fail a = pybamm.PrimaryBroadcast(pybamm.Scalar(1), "test") - with self.assertRaisesRegex(TypeError, "evaluate on edges"): + with pytest.raises(TypeError, match="evaluate on edges"): pybamm.Divergence(a) # divergence of broadcast should return broadcasted zero a = pybamm.PrimaryBroadcastToEdges(pybamm.Variable("a"), "test domain") div = pybamm.div(a) - self.assertEqual(div, pybamm.PrimaryBroadcast(0, "test domain")) + assert div == pybamm.PrimaryBroadcast(0, "test domain") a = pybamm.PrimaryBroadcastToEdges( pybamm.Variable("a", "some domain"), "test domain" ) div = pybamm.div(a) - self.assertEqual( - div, - pybamm.PrimaryBroadcast( - pybamm.PrimaryBroadcast(0, "some domain"), "test domain" - ), + assert div == pybamm.PrimaryBroadcast( + pybamm.PrimaryBroadcast(0, "some domain"), "test domain" ) # otherwise divergence should work a = pybamm.Symbol("a", domain="test domain") div = pybamm.Divergence(pybamm.Gradient(a)) - self.assertEqual(div.domain, a.domain) + assert div.domain == a.domain # check div commutes with negation a = pybamm.Symbol("a", domain="test domain") div = pybamm.div(-pybamm.Gradient(a)) - self.assertEqual(div, (-pybamm.Divergence(pybamm.Gradient(a)))) + assert div == (-pybamm.Divergence(pybamm.Gradient(a))) div = pybamm.div(-a * pybamm.Gradient(a)) - self.assertEqual(div, (-pybamm.Divergence(a * pybamm.Gradient(a)))) + assert div == (-pybamm.Divergence(a * pybamm.Gradient(a))) # div = pybamm.div(a * -pybamm.Gradient(a)) # self.assertEqual(div, (-pybamm.Divergence(a * pybamm.Gradient(a)))) @@ -288,9 +287,9 @@ def test_integral(self): a = pybamm.Symbol("a", domain=["negative electrode"]) x = pybamm.SpatialVariable("x", ["negative electrode"]) inta = pybamm.Integral(a, x) - self.assertEqual(inta.name, "integral dx ['negative electrode']") - self.assertEqual(inta.children[0].name, a.name) - self.assertEqual(inta.integration_variable[0], x) + assert inta.name == "integral dx ['negative electrode']" + assert inta.children[0].name == a.name + assert inta.integration_variable[0] == x assert_domain_equal(inta.domains, {}) # space integral with secondary domain a_sec = pybamm.Symbol( @@ -393,18 +392,18 @@ def test_integral(self): y = pybamm.SpatialVariable("y", ["current collector"]) z = pybamm.SpatialVariable("z", ["current collector"]) inta = pybamm.Integral(b, [y, z]) - self.assertEqual(inta.name, "integral dy dz ['current collector']") - self.assertEqual(inta.children[0].name, b.name) - self.assertEqual(inta.integration_variable[0], y) - self.assertEqual(inta.integration_variable[1], z) - self.assertEqual(inta.domain, []) + assert inta.name == "integral dy dz ['current collector']" + assert inta.children[0].name == b.name + assert inta.integration_variable[0] == y + assert inta.integration_variable[1] == z + assert inta.domain == [] # Indefinite inta = pybamm.IndefiniteIntegral(a, x) - self.assertEqual(inta.name, "a integrated w.r.t x on ['negative electrode']") - self.assertEqual(inta.children[0].name, a.name) - self.assertEqual(inta.integration_variable[0], x) - self.assertEqual(inta.domain, ["negative electrode"]) + assert inta.name == "a integrated w.r.t x on ['negative electrode']" + assert inta.children[0].name == a.name + assert inta.integration_variable[0] == x + assert inta.domain == ["negative electrode"] inta_sec = pybamm.IndefiniteIntegral(a_sec, x) assert_domain_equal( inta_sec.domains, @@ -412,22 +411,20 @@ def test_integral(self): ) # backward indefinite integral inta = pybamm.BackwardIndefiniteIntegral(a, x) - self.assertEqual( - inta.name, "a integrated backward w.r.t x on ['negative electrode']" - ) + assert inta.name == "a integrated backward w.r.t x on ['negative electrode']" # expected errors a = pybamm.Symbol("a", domain=["negative electrode"]) x = pybamm.SpatialVariable("x", ["separator"]) y = pybamm.Variable("y") z = pybamm.SpatialVariable("z", ["negative electrode"]) - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.Integral(a, x) - with self.assertRaisesRegex(TypeError, "integration_variable must be"): + with pytest.raises(TypeError, match="integration_variable must be"): pybamm.Integral(a, y) - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "Indefinite integral only implemented w.r.t. one variable", + match="Indefinite integral only implemented w.r.t. one variable", ): pybamm.IndefiniteIntegral(a, [x, y]) @@ -436,166 +433,166 @@ def test_index(self): y_test = np.array([1, 2, 3, 4, 5]) # with integer ind = pybamm.Index(vec, 3) - self.assertIsInstance(ind, pybamm.Index) - self.assertEqual(ind.slice, slice(3, 4)) - self.assertEqual(ind.evaluate(y=y_test), 4) + assert isinstance(ind, pybamm.Index) + assert ind.slice == slice(3, 4) + assert ind.evaluate(y=y_test) == 4 # with -1 ind = pybamm.Index(vec, -1) - self.assertIsInstance(ind, pybamm.Index) - self.assertEqual(ind.slice, slice(-1, None)) - self.assertEqual(ind.evaluate(y=y_test), 5) - self.assertEqual(ind.name, "Index[-1]") + assert isinstance(ind, pybamm.Index) + assert ind.slice == slice(-1, None) + assert ind.evaluate(y=y_test) == 5 + assert ind.name == "Index[-1]" # with slice ind = pybamm.Index(vec, slice(1, 3)) - self.assertIsInstance(ind, pybamm.Index) - self.assertEqual(ind.slice, slice(1, 3)) + assert isinstance(ind, pybamm.Index) + assert ind.slice == slice(1, 3) np.testing.assert_array_equal(ind.evaluate(y=y_test), np.array([[2], [3]])) # with only stop slice ind = pybamm.Index(vec, slice(3)) - self.assertIsInstance(ind, pybamm.Index) - self.assertEqual(ind.slice, slice(3)) + assert isinstance(ind, pybamm.Index) + assert ind.slice == slice(3) np.testing.assert_array_equal(ind.evaluate(y=y_test), np.array([[1], [2], [3]])) # errors - with self.assertRaisesRegex(TypeError, "index must be integer or slice"): + with pytest.raises(TypeError, match="index must be integer or slice"): pybamm.Index(vec, 0.0) debug_mode = pybamm.settings.debug_mode pybamm.settings.debug_mode = True - with self.assertRaisesRegex(ValueError, "slice size exceeds child size"): + with pytest.raises(ValueError, match="slice size exceeds child size"): pybamm.Index(vec, 5) pybamm.settings.debug_mode = debug_mode def test_evaluate_at(self): a = pybamm.Symbol("a", domain=["negative electrode"]) f = pybamm.EvaluateAt(a, 1) - self.assertEqual(f.position, 1) + assert f.position == 1 def test_upwind_downwind(self): # upwind of scalar symbol should fail a = pybamm.Symbol("a") - with self.assertRaisesRegex( - pybamm.DomainError, "Cannot upwind 'a' since its domain is empty" + with pytest.raises( + pybamm.DomainError, match="Cannot upwind 'a' since its domain is empty" ): pybamm.Upwind(a) # upwind of variable evaluating on edges should fail a = pybamm.PrimaryBroadcastToEdges(pybamm.Scalar(1), "test") - with self.assertRaisesRegex(TypeError, "evaluate on nodes"): + with pytest.raises(TypeError, match="evaluate on nodes"): pybamm.Upwind(a) # otherwise upwind should work a = pybamm.Symbol("a", domain="test domain") upwind = pybamm.upwind(a) - self.assertIsInstance(upwind, pybamm.Upwind) - self.assertEqual(upwind.children[0].name, a.name) - self.assertEqual(upwind.domain, a.domain) + assert isinstance(upwind, pybamm.Upwind) + assert upwind.children[0].name == a.name + assert upwind.domain == a.domain # also test downwind a = pybamm.Symbol("a", domain="test domain") downwind = pybamm.downwind(a) - self.assertIsInstance(downwind, pybamm.Downwind) - self.assertEqual(downwind.children[0].name, a.name) - self.assertEqual(downwind.domain, a.domain) + assert isinstance(downwind, pybamm.Downwind) + assert downwind.children[0].name == a.name + assert downwind.domain == a.domain def test_diff(self): a = pybamm.StateVector(slice(0, 1)) y = np.array([5]) # negation - self.assertEqual((-a).diff(a).evaluate(y=y), -1) - self.assertEqual((-a).diff(-a).evaluate(), 1) + assert (-a).diff(a).evaluate(y=y) == -1 + assert (-a).diff(-a).evaluate() == 1 # absolute value - self.assertEqual((a**3).diff(a).evaluate(y=y), 3 * 5**2) - self.assertEqual((abs(a**3)).diff(a).evaluate(y=y), 3 * 5**2) - self.assertEqual((a**3).diff(a).evaluate(y=-y), 3 * 5**2) - self.assertEqual((abs(a**3)).diff(a).evaluate(y=-y), -3 * 5**2) + assert (a**3).diff(a).evaluate(y=y) == 3 * 5**2 + assert (abs(a**3)).diff(a).evaluate(y=y) == 3 * 5**2 + assert (a**3).diff(a).evaluate(y=-y) == 3 * 5**2 + assert (abs(a**3)).diff(a).evaluate(y=-y) == -3 * 5**2 # sign - self.assertEqual((pybamm.sign(a)).diff(a).evaluate(y=y), 0) + assert (pybamm.sign(a)).diff(a).evaluate(y=y) == 0 # floor - self.assertEqual((pybamm.Floor(a)).diff(a).evaluate(y=y), 0) + assert (pybamm.Floor(a)).diff(a).evaluate(y=y) == 0 # ceil - self.assertEqual((pybamm.Ceiling(a)).diff(a).evaluate(y=y), 0) + assert (pybamm.Ceiling(a)).diff(a).evaluate(y=y) == 0 # spatial operator (not implemented) spatial_a = pybamm.SpatialOperator("name", a) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_a.diff(a) def test_printing(self): a = pybamm.Symbol("a", domain="test") - self.assertEqual(str(-a), "-a") + assert str(-a) == "-a" grad = pybamm.Gradient(a) - self.assertEqual(grad.name, "grad") - self.assertEqual(str(grad), "grad(a)") + assert grad.name == "grad" + assert str(grad) == "grad(a)" def test_eq(self): a = pybamm.Scalar(4) un1 = pybamm.UnaryOperator("test", a) un2 = pybamm.UnaryOperator("test", a) un3 = pybamm.UnaryOperator("new test", a) - self.assertEqual(un1, un2) - self.assertNotEqual(un1, un3) + assert un1 == un2 + assert un1 != un3 a = pybamm.Scalar(4) un4 = pybamm.UnaryOperator("test", a) - self.assertEqual(un1, un4) + assert un1 == un4 d = pybamm.Scalar(42) un5 = pybamm.UnaryOperator("test", d) - self.assertNotEqual(un1, un5) + assert un1 != un5 def test_delta_function(self): a = pybamm.Symbol("a") delta_a = pybamm.DeltaFunction(a, "right", "some domain") - self.assertEqual(delta_a.side, "right") - self.assertEqual(delta_a.child, a) - self.assertEqual(delta_a.domain, ["some domain"]) - self.assertFalse(delta_a.evaluates_on_edges("primary")) + assert delta_a.side == "right" + assert delta_a.child == a + assert delta_a.domain == ["some domain"] + assert not delta_a.evaluates_on_edges("primary") a = pybamm.Symbol("a", domain="some domain") delta_a = pybamm.DeltaFunction(a, "left", "another domain") - self.assertEqual(delta_a.side, "left") + assert delta_a.side == "left" assert_domain_equal( delta_a.domains, {"primary": ["another domain"], "secondary": ["some domain"]}, ) - with self.assertRaisesRegex( - pybamm.DomainError, "Delta function domain cannot be None" + with pytest.raises( + pybamm.DomainError, match="Delta function domain cannot be None" ): delta_a = pybamm.DeltaFunction(a, "right", None) def test_boundary_operators(self): a = pybamm.Symbol("a", domain="some domain") boundary_a = pybamm.BoundaryOperator("boundary", a, "right") - self.assertEqual(boundary_a.side, "right") - self.assertEqual(boundary_a.child, a) + assert boundary_a.side == "right" + assert boundary_a.child == a def test_evaluates_on_edges(self): a = pybamm.StateVector(slice(0, 10), domain="test") - self.assertFalse(pybamm.Index(a, slice(1)).evaluates_on_edges("primary")) - self.assertFalse(pybamm.Laplacian(a).evaluates_on_edges("primary")) - self.assertFalse(pybamm.GradientSquared(a).evaluates_on_edges("primary")) - self.assertFalse(pybamm.BoundaryIntegral(a).evaluates_on_edges("primary")) - self.assertTrue(pybamm.Upwind(a).evaluates_on_edges("primary")) - self.assertTrue(pybamm.Downwind(a).evaluates_on_edges("primary")) + assert not pybamm.Index(a, slice(1)).evaluates_on_edges("primary") + assert not pybamm.Laplacian(a).evaluates_on_edges("primary") + assert not pybamm.GradientSquared(a).evaluates_on_edges("primary") + assert not pybamm.BoundaryIntegral(a).evaluates_on_edges("primary") + assert pybamm.Upwind(a).evaluates_on_edges("primary") + assert pybamm.Downwind(a).evaluates_on_edges("primary") def test_boundary_value(self): a = pybamm.Scalar(1) boundary_a = pybamm.boundary_value(a, "right") - self.assertEqual(boundary_a, a) + assert boundary_a == a boundary_broad_a = pybamm.boundary_value( pybamm.PrimaryBroadcast(a, ["negative electrode"]), "left" ) - self.assertEqual(boundary_broad_a.evaluate(), np.array([1])) + assert boundary_broad_a.evaluate() == np.array([1]) a = pybamm.Symbol("a", domain=["separator"]) boundary_a = pybamm.boundary_value(a, "right") - self.assertIsInstance(boundary_a, pybamm.BoundaryValue) - self.assertEqual(boundary_a.side, "right") + assert isinstance(boundary_a, pybamm.BoundaryValue) + assert boundary_a.side == "right" assert_domain_equal(boundary_a.domains, {}) # test with secondary domain a_sec = pybamm.Symbol( @@ -627,7 +624,7 @@ def test_boundary_value(self): }, ) boundary_a_quat = pybamm.boundary_value(a_quat, "right") - self.assertEqual(boundary_a_quat.domain, ["current collector"]) + assert boundary_a_quat.domain == ["current collector"] assert_domain_equal( boundary_a_quat.domains, { @@ -639,26 +636,26 @@ def test_boundary_value(self): # error if boundary value on tabs and domain is not "current collector" var = pybamm.Variable("var", domain=["negative electrode"]) - with self.assertRaisesRegex(pybamm.ModelError, "Can only take boundary"): + with pytest.raises(pybamm.ModelError, match="Can only take boundary"): pybamm.boundary_value(var, "negative tab") pybamm.boundary_value(var, "positive tab") # boundary value of symbol that evaluates on edges raises error symbol_on_edges = pybamm.PrimaryBroadcastToEdges(1, "domain") - with self.assertRaisesRegex( + with pytest.raises( ValueError, - "Can't take the boundary value of a symbol that evaluates on edges", + match="Can't take the boundary value of a symbol that evaluates on edges", ): pybamm.boundary_value(symbol_on_edges, "right") def test_boundary_gradient(self): var = pybamm.Variable("var", domain=["negative electrode"]) grad = pybamm.boundary_gradient(var, "right") - self.assertIsInstance(grad, pybamm.BoundaryGradient) + assert isinstance(grad, pybamm.BoundaryGradient) zero = pybamm.PrimaryBroadcast(0, ["negative electrode"]) grad = pybamm.boundary_gradient(zero, "right") - self.assertEqual(grad, 0) + assert grad == 0 def test_unary_simplifications(self): a = pybamm.Scalar(0) @@ -666,25 +663,25 @@ def test_unary_simplifications(self): d = pybamm.Scalar(-1) # negate - self.assertIsInstance((-a), pybamm.Scalar) - self.assertEqual((-a).evaluate(), 0) - self.assertIsInstance((-b), pybamm.Scalar) - self.assertEqual((-b).evaluate(), -1) + assert isinstance((-a), pybamm.Scalar) + assert (-a).evaluate() == 0 + assert isinstance((-b), pybamm.Scalar) + assert (-b).evaluate() == -1 # absolute value - self.assertIsInstance((abs(a)), pybamm.Scalar) - self.assertEqual((abs(a)).evaluate(), 0) - self.assertIsInstance((abs(d)), pybamm.Scalar) - self.assertEqual((abs(d)).evaluate(), 1) + assert isinstance((abs(a)), pybamm.Scalar) + assert (abs(a)).evaluate() == 0 + assert isinstance((abs(d)), pybamm.Scalar) + assert (abs(d)).evaluate() == 1 def test_not_constant(self): a = pybamm.NotConstant(pybamm.Scalar(1)) - self.assertEqual(a.name, "not_constant") - self.assertEqual(a.domain, []) - self.assertEqual(a.evaluate(), 1) - self.assertEqual(a.jac(pybamm.StateVector(slice(0, 1))).evaluate(), 0) - self.assertFalse(a.is_constant()) - self.assertFalse((2 * a).is_constant()) + assert a.name == "not_constant" + assert a.domain == [] + assert a.evaluate() == 1 + assert a.jac(pybamm.StateVector(slice(0, 1))).evaluate() == 0 + assert not a.is_constant() + assert not (2 * a).is_constant() def test_to_equation(self): a = pybamm.Symbol("a", domain="negative particle") @@ -695,62 +692,87 @@ def test_to_equation(self): # Test print_name pybamm.Floor.print_name = "test" - self.assertEqual(pybamm.Floor(-2.5).to_equation(), sympy.Symbol("test")) + assert pybamm.Floor(-2.5).to_equation() == sympy.Symbol("test") # Test Negate value = 4 - self.assertEqual(pybamm.Negate(value).to_equation(), -value) + assert pybamm.Negate(value).to_equation() == -value # Test AbsoluteValue - self.assertEqual(pybamm.AbsoluteValue(-value).to_equation(), value) + assert pybamm.AbsoluteValue(-value).to_equation() == value # Test Gradient - self.assertEqual(pybamm.Gradient(a).to_equation(), sympy_Gradient("a")) + assert pybamm.Gradient(a).to_equation() == sympy_Gradient("a") # Test Divergence - self.assertEqual( - pybamm.Divergence(pybamm.Gradient(a)).to_equation(), - sympy_Divergence(sympy_Gradient("a")), + assert pybamm.Divergence(pybamm.Gradient(a)).to_equation() == sympy_Divergence( + sympy_Gradient("a") ) # Test BoundaryValue - self.assertEqual( - pybamm.BoundaryValue(one, "right").to_equation(), sympy.Symbol("1") - ) - self.assertEqual( - pybamm.BoundaryValue(a, "right").to_equation(), sympy.Symbol("a^{surf}") + assert pybamm.BoundaryValue(one, "right").to_equation() == sympy.Symbol("1") + assert pybamm.BoundaryValue(a, "right").to_equation() == sympy.Symbol( + "a^{surf}" ) - self.assertEqual( - pybamm.BoundaryValue(b, "positive tab").to_equation(), sympy.Symbol(str(b)) + assert pybamm.BoundaryValue(b, "positive tab").to_equation() == sympy.Symbol( + str(b) ) - self.assertEqual( - pybamm.BoundaryValue(c, "left").to_equation(), - sympy.Symbol(r"c^{\mathtt{\text{left}}}"), + assert pybamm.BoundaryValue(c, "left").to_equation() == sympy.Symbol( + r"c^{\mathtt{\text{left}}}" ) # Test Integral xn = pybamm.SpatialVariable("xn", ["negative electrode"]) - self.assertEqual( - pybamm.Integral(d, xn).to_equation(), - sympy.Integral("d", sympy.Symbol("xn")), + assert pybamm.Integral(d, xn).to_equation() == sympy.Integral( + "d", sympy.Symbol("xn") ) def test_explicit_time_integral(self): expr = pybamm.ExplicitTimeIntegral(pybamm.Parameter("param"), pybamm.Scalar(1)) - self.assertEqual(expr.child, pybamm.Parameter("param")) - self.assertEqual(expr.initial_condition, pybamm.Scalar(1)) - self.assertEqual(expr.name, "explicit time integral") - self.assertEqual(expr.create_copy(), expr) - self.assertFalse(expr.is_constant()) - - def test_to_from_json(self): + assert expr.child == pybamm.Parameter("param") + assert expr.initial_condition == pybamm.Scalar(1) + assert expr.name == "explicit time integral" + assert expr.create_copy() == expr + assert not expr.is_constant() + + def test_discrete_time_sum(self): + times = np.array([1, 2, 3, 4, 5]) + values = np.array([2, 2, 3, 3, 1]) + data = pybamm.DiscreteTimeData(times, values, "test") + assert data.name == "test" + np.testing.assert_array_equal(data.x[0], times) + np.testing.assert_array_equal(data.y, values) + + y = pybamm.StateVector(slice(0, 1)) + + # check that raises error if data is not present + with pytest.raises(pybamm.ModelError, match="must contain a DiscreteTimeData"): + pybamm.DiscreteTimeSum(2 * y) + + # check that raises error if two data are present + data2 = pybamm.DiscreteTimeData(values, times, "test2") + with pytest.raises(pybamm.ModelError, match="only have one DiscreteTimeData"): + pybamm.DiscreteTimeSum(data + data2) + + sum = pybamm.DiscreteTimeSum(2 * data - y) + np.testing.assert_array_equal(sum.sum_times, times) + np.testing.assert_array_equal(sum.sum_values, values) + y = np.array([1]) + + # evaluate should return the values to sum up + for i in range(len(times)): + eval = sum.evaluate(y=y, t=times[i]) + expect = 2 * values[i] - y + np.testing.assert_array_equal(eval[0], expect) + + def test_to_from_json(self, mocker): # UnaryOperator a = pybamm.Symbol("a", domain=["test"]) un = pybamm.UnaryOperator("unary test", a) un_json = { "name": "unary test", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": ["test"], "secondary": [], @@ -759,10 +781,10 @@ def test_to_from_json(self): }, } - self.assertEqual(un.to_json(), un_json) + assert un.to_json() == un_json un_json["children"] = [a] - self.assertEqual(pybamm.UnaryOperator._from_json(un_json), un) + assert pybamm.UnaryOperator._from_json(un_json) == un # Index vec = pybamm.StateVector(slice(0, 5)) @@ -770,41 +792,31 @@ def test_to_from_json(self): ind_json = { "name": "Index[3]", - "id": mock.ANY, + "id": mocker.ANY, "index": {"start": 3, "stop": 4, "step": None}, "check_size": False, } - self.assertEqual(ind.to_json(), ind_json) + assert ind.to_json() == ind_json ind_json["children"] = [vec] - self.assertEqual(pybamm.Index._from_json(ind_json), ind) + assert pybamm.Index._from_json(ind_json) == ind # SpatialOperator spatial_vec = pybamm.SpatialOperator("name", vec) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_vec.to_json() - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.SpatialOperator._from_json({}) # ExplicitTimeIntegral expr = pybamm.ExplicitTimeIntegral(pybamm.Parameter("param"), pybamm.Scalar(1)) - expr_json = {"name": "explicit time integral", "id": mock.ANY} + expr_json = {"name": "explicit time integral", "id": mocker.ANY} - self.assertEqual(expr.to_json(), expr_json) + assert expr.to_json() == expr_json expr_json["children"] = [pybamm.Parameter("param")] expr_json["initial_condition"] = [pybamm.Scalar(1)] - self.assertEqual(pybamm.ExplicitTimeIntegral._from_json(expr_json), expr) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert pybamm.ExplicitTimeIntegral._from_json(expr_json) == expr diff --git a/tests/unit/test_expression_tree/test_variable.py b/tests/unit/test_expression_tree/test_variable.py index fb17968ca8..25c7955cc0 100644 --- a/tests/unit/test_expression_tree/test_variable.py +++ b/tests/unit/test_expression_tree/test_variable.py @@ -2,7 +2,7 @@ # Tests for the Variable class # -import unittest +import pytest import numpy as np @@ -10,97 +10,85 @@ import sympy -class TestVariable(unittest.TestCase): +class TestVariable: def test_variable_init(self): a = pybamm.Variable("a") - self.assertEqual(a.name, "a") - self.assertEqual(a.domain, []) + assert a.name == "a" + assert a.domain == [] a = pybamm.Variable("a", domain=["test"]) - self.assertEqual(a.domain[0], "test") - self.assertRaises(TypeError, pybamm.Variable("a", domain="test")) - self.assertEqual(a.scale, 1) - self.assertEqual(a.reference, 0) + assert a.domain[0] == "test" + assert a.scale == 1 + assert a.reference == 0 a = pybamm.Variable("a", scale=2, reference=-1) - self.assertEqual(a.scale, 2) - self.assertEqual(a.reference, -1) + assert a.scale == 2 + assert a.reference == -1 def test_variable_diff(self): a = pybamm.Variable("a") b = pybamm.Variable("b") - self.assertIsInstance(a.diff(a), pybamm.Scalar) - self.assertEqual(a.diff(a).evaluate(), 1) - self.assertIsInstance(a.diff(b), pybamm.Scalar) - self.assertEqual(a.diff(b).evaluate(), 0) + assert isinstance(a.diff(a), pybamm.Scalar) + assert a.diff(a).evaluate() == 1 + assert isinstance(a.diff(b), pybamm.Scalar) + assert a.diff(b).evaluate() == 0 def test_variable_eq(self): a1 = pybamm.Variable("a", domain=["negative electrode"]) a2 = pybamm.Variable("a", domain=["negative electrode"]) - self.assertEqual(a1, a2) + assert a1 == a2 a3 = pybamm.Variable("b", domain=["negative electrode"]) a4 = pybamm.Variable("a", domain=["positive electrode"]) - self.assertNotEqual(a1, a3) - self.assertNotEqual(a1, a4) + assert a1 != a3 + assert a1 != a4 def test_variable_bounds(self): var = pybamm.Variable("var") - self.assertEqual(var.bounds, (-np.inf, np.inf)) + assert var.bounds == (-np.inf, np.inf) var = pybamm.Variable("var", bounds=(0, 1)) - self.assertEqual(var.bounds, (0, 1)) + assert var.bounds == (0, 1) - with self.assertRaisesRegex(ValueError, "Invalid bounds"): + with pytest.raises(ValueError, match="Invalid bounds"): pybamm.Variable("var", bounds=(1, 0)) - with self.assertRaisesRegex(ValueError, "Invalid bounds"): + with pytest.raises(ValueError, match="Invalid bounds"): pybamm.Variable("var", bounds=(1, 1)) def test_to_equation(self): # Test print_name func = pybamm.Variable("test_string") func.print_name = "test" - self.assertEqual(func.to_equation(), sympy.Symbol("test")) + assert func.to_equation() == sympy.Symbol("test") # Test name - self.assertEqual(pybamm.Variable("name").to_equation(), sympy.Symbol("name")) + assert pybamm.Variable("name").to_equation() == sympy.Symbol("name") def test_to_json_error(self): func = pybamm.Variable("test_string") - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): func.to_json() -class TestVariableDot(unittest.TestCase): +class TestVariableDot: def test_variable_init(self): a = pybamm.VariableDot("a'") - self.assertEqual(a.name, "a'") - self.assertEqual(a.domain, []) + assert a.name == "a'" + assert a.domain == [] a = pybamm.VariableDot("a", domain=["test"]) - self.assertEqual(a.domain[0], "test") - self.assertRaises(TypeError, pybamm.Variable("a", domain="test")) + assert a.domain[0] == "test" def test_variable_id(self): a1 = pybamm.VariableDot("a", domain=["negative electrode"]) a2 = pybamm.VariableDot("a", domain=["negative electrode"]) - self.assertEqual(a1, a2) + assert a1 == a2 a3 = pybamm.VariableDot("b", domain=["negative electrode"]) a4 = pybamm.VariableDot("a", domain=["positive electrode"]) - self.assertNotEqual(a1, a3) - self.assertNotEqual(a1, a4) + assert a1 != a3 + assert a1 != a4 def test_variable_diff(self): a = pybamm.VariableDot("a") b = pybamm.Variable("b") - self.assertIsInstance(a.diff(a), pybamm.Scalar) - self.assertEqual(a.diff(a).evaluate(), 1) - self.assertIsInstance(a.diff(b), pybamm.Scalar) - self.assertEqual(a.diff(b).evaluate(), 0) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert isinstance(a.diff(a), pybamm.Scalar) + assert a.diff(a).evaluate() == 1 + assert isinstance(a.diff(b), pybamm.Scalar) + assert a.diff(b).evaluate() == 0 diff --git a/tests/unit/test_geometry/test_battery_geometry.py b/tests/unit/test_geometry/test_battery_geometry.py index 38e1ce1908..c73ef89af1 100644 --- a/tests/unit/test_geometry/test_battery_geometry.py +++ b/tests/unit/test_geometry/test_battery_geometry.py @@ -1,25 +1,27 @@ # # Tests for the base model class # +import pytest import pybamm -import unittest -class TestBatteryGeometry(unittest.TestCase): - def test_geometry_keys(self): - for cc_dimension in [0, 1, 2]: - geometry = pybamm.battery_geometry( - options={ - "particle size": "distribution", - "dimensionality": cc_dimension, - }, +class TestBatteryGeometry: + @pytest.fixture(params=[0, 1, 2]) + def geometry(self, request): + geometry = pybamm.battery_geometry( + options={ + "particle size": "distribution", + "dimensionality": request.param, + }, + ) + return geometry + + def test_geometry_keys(self, geometry): + for domain_geoms in geometry.values(): + assert all( + isinstance(spatial_var, str) for spatial_var in domain_geoms.keys() ) - for domain_geoms in geometry.values(): - all( - self.assertIsInstance(spatial_var, str) - for spatial_var in domain_geoms.keys() - ) geometry.print_parameter_info() def test_geometry(self): @@ -31,60 +33,60 @@ def test_geometry(self): "dimensionality": cc_dimension, }, ) - self.assertIsInstance(geometry, pybamm.Geometry) - self.assertIn("negative electrode", geometry) - self.assertIn("negative particle", geometry) - self.assertIn("negative particle size", geometry) - self.assertEqual(geometry["negative electrode"]["x_n"]["min"], 0) - self.assertEqual(geometry["negative electrode"]["x_n"]["max"], geo.n.L) + assert isinstance(geometry, pybamm.Geometry) + assert "negative electrode" in geometry + assert "negative particle" in geometry + assert "negative particle size" in geometry + assert geometry["negative electrode"]["x_n"]["min"] == 0 + assert geometry["negative electrode"]["x_n"]["max"] == geo.n.L if cc_dimension == 1: - self.assertIn("tabs", geometry["current collector"]) + assert "tabs" in geometry["current collector"] geometry = pybamm.battery_geometry(include_particles=False) - self.assertNotIn("negative particle", geometry) + assert "negative particle" not in geometry geometry = pybamm.battery_geometry() - self.assertNotIn("negative particle size", geometry) + assert "negative particle size" not in geometry geometry = pybamm.battery_geometry(form_factor="cylindrical") - self.assertEqual(geometry["current collector"]["r_macro"]["position"], 1) + assert geometry["current collector"]["r_macro"]["position"] == 1 geometry = pybamm.battery_geometry( form_factor="cylindrical", options={"dimensionality": 1} ) - self.assertEqual(geometry["current collector"]["r_macro"]["min"], geo.r_inner) - self.assertEqual(geometry["current collector"]["r_macro"]["max"], 1) + assert geometry["current collector"]["r_macro"]["min"] == geo.r_inner + assert geometry["current collector"]["r_macro"]["max"] == 1 options = {"particle phases": "2"} geometry = pybamm.battery_geometry(options=options) geo = pybamm.GeometricParameters(options=options) - self.assertEqual(geometry["negative primary particle"]["r_n_prim"]["min"], 0) - self.assertEqual( - geometry["negative primary particle"]["r_n_prim"]["max"], geo.n.prim.R_typ + assert geometry["negative primary particle"]["r_n_prim"]["min"] == 0 + assert ( + geometry["negative primary particle"]["r_n_prim"]["max"] == geo.n.prim.R_typ ) - self.assertEqual(geometry["negative secondary particle"]["r_n_sec"]["min"], 0) - self.assertEqual( - geometry["negative secondary particle"]["r_n_sec"]["max"], geo.n.sec.R_typ + assert geometry["negative secondary particle"]["r_n_sec"]["min"] == 0 + assert ( + geometry["negative secondary particle"]["r_n_sec"]["max"] == geo.n.sec.R_typ ) - self.assertEqual(geometry["positive primary particle"]["r_p_prim"]["min"], 0) - self.assertEqual( - geometry["positive primary particle"]["r_p_prim"]["max"], geo.p.prim.R_typ + assert geometry["positive primary particle"]["r_p_prim"]["min"] == 0 + assert ( + geometry["positive primary particle"]["r_p_prim"]["max"] == geo.p.prim.R_typ ) - self.assertEqual(geometry["positive secondary particle"]["r_p_sec"]["min"], 0) - self.assertEqual( - geometry["positive secondary particle"]["r_p_sec"]["max"], geo.p.sec.R_typ + assert geometry["positive secondary particle"]["r_p_sec"]["min"] == 0 + assert ( + geometry["positive secondary particle"]["r_p_sec"]["max"] == geo.p.sec.R_typ ) def test_geometry_error(self): - with self.assertRaisesRegex(pybamm.GeometryError, "Invalid current"): + with pytest.raises(pybamm.GeometryError, match="Invalid current"): pybamm.battery_geometry( form_factor="cylindrical", options={"dimensionality": 2} ) - with self.assertRaisesRegex(pybamm.GeometryError, "Invalid form"): + with pytest.raises(pybamm.GeometryError, match="Invalid form"): pybamm.battery_geometry(form_factor="triangle") -class TestReadParameters(unittest.TestCase): +class TestReadParameters: # This is the most complicated geometry and should test the parameters are # all returned for the deepest dict def test_read_parameters(self): @@ -103,37 +105,22 @@ def test_read_parameters(self): geometry = pybamm.battery_geometry(options={"dimensionality": 2}) - self.assertEqual( - set([x.name for x in geometry.parameters]), - set( - [ - x.name - for x in [ - L_n, - L_s, - L_p, - L_y, - L_z, - tab_n_y, - tab_n_z, - L_tab_n, - tab_p_y, - tab_p_z, - L_tab_p, - ] + assert set([x.name for x in geometry.parameters]) == set( + [ + x.name + for x in [ + L_n, + L_s, + L_p, + L_y, + L_z, + tab_n_y, + tab_n_z, + L_tab_n, + tab_p_y, + tab_p_z, + L_tab_p, ] - ), - ) - self.assertTrue( - all(isinstance(x, pybamm.Parameter) for x in geometry.parameters) + ] ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert all(isinstance(x, pybamm.Parameter) for x in geometry.parameters) diff --git a/tests/unit/test_meshes/test_meshes.py b/tests/unit/test_meshes/test_meshes.py index 2f3bffddfb..eecc1a8911 100644 --- a/tests/unit/test_meshes/test_meshes.py +++ b/tests/unit/test_meshes/test_meshes.py @@ -2,9 +2,9 @@ # Test for the Finite Volume Mesh class # +import pytest import pybamm import numpy as np -import unittest def get_param(): @@ -19,7 +19,19 @@ def get_param(): ) -class TestMesh(unittest.TestCase): +class TestMesh: + @pytest.fixture(scope="class") + def submesh_types(self): + submesh_types = { + "negative electrode": pybamm.Uniform1DSubMesh, + "separator": pybamm.Uniform1DSubMesh, + "positive electrode": pybamm.Uniform1DSubMesh, + "negative particle": pybamm.Uniform1DSubMesh, + "positive particle": pybamm.Uniform1DSubMesh, + "current collector": pybamm.SubMesh0D, + } + return submesh_types + def test_mesh_creation_no_parameters(self): r = pybamm.SpatialVariable( "r", domain=["negative particle"], coord_sys="spherical polar" @@ -36,17 +48,17 @@ def test_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check geometry - self.assertEqual(mesh.geometry, geometry) + assert mesh.geometry == geometry # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) # errors if old format @@ -55,12 +67,12 @@ def test_mesh_creation_no_parameters(self): "primary": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} } } - with self.assertRaisesRegex( - pybamm.GeometryError, "Geometry should no longer be given keys" + with pytest.raises( + pybamm.GeometryError, match="Geometry should no longer be given keys" ): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) - def test_mesh_creation(self): + def test_mesh_creation(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -68,52 +80,32 @@ def test_mesh_creation(self): var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } - # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check geometry - self.assertEqual(mesh.geometry, geometry) + assert mesh.geometry == geometry # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertAlmostEqual(mesh["positive electrode"].edges[-1], 0.6) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == pytest.approx(0.6) # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain != "current collector": - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 - def test_init_failure(self): + def test_init_failure(self, submesh_types): geometry = pybamm.battery_geometry() - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } - with self.assertRaisesRegex(KeyError, "Points not given"): + + with pytest.raises(KeyError, match="Points not given"): pybamm.Mesh(geometry, submesh_types, {}) var_pts = {"x_n": 10, "x_s": 10, "x_p": 12} geometry = pybamm.battery_geometry(options={"dimensionality": 1}) - with self.assertRaisesRegex(KeyError, "Points not given"): + with pytest.raises(KeyError, match="Points not given"): pybamm.Mesh(geometry, submesh_types, var_pts) # Not processing geometry parameters @@ -121,26 +113,17 @@ def test_init_failure(self): var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } - - with self.assertRaisesRegex(pybamm.DiscretisationError, "Parameter values"): + with pytest.raises(pybamm.DiscretisationError, match="Parameter values"): pybamm.Mesh(geometry, submesh_types, var_pts) # Geometry has an unrecognized variable type geometry["negative electrode"] = { "x_n": {"min": 0, "max": pybamm.Variable("var")} } - with self.assertRaisesRegex(NotImplementedError, "for symbol var"): + with pytest.raises(NotImplementedError, match="for symbol var"): pybamm.Mesh(geometry, submesh_types, var_pts) - def test_mesh_sizes(self): + def test_mesh_sizes(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -148,27 +131,19 @@ def test_mesh_sizes(self): # provide mesh properties var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) - self.assertEqual(mesh["negative electrode"].npts, var_pts["x_n"]) - self.assertEqual(mesh["separator"].npts, var_pts["x_s"]) - self.assertEqual(mesh["positive electrode"].npts, var_pts["x_p"]) + assert mesh["negative electrode"].npts == var_pts["x_n"] + assert mesh["separator"].npts == var_pts["x_s"] + assert mesh["positive electrode"].npts == var_pts["x_p"] - self.assertEqual(len(mesh["negative electrode"].edges) - 1, var_pts["x_n"]) - self.assertEqual(len(mesh["separator"].edges) - 1, var_pts["x_s"]) - self.assertEqual(len(mesh["positive electrode"].edges) - 1, var_pts["x_p"]) + assert len(mesh["negative electrode"].edges) - 1 == var_pts["x_n"] + assert len(mesh["separator"].edges) - 1 == var_pts["x_s"] + assert len(mesh["positive electrode"].edges) - 1 == var_pts["x_p"] - def test_mesh_sizes_using_standard_spatial_vars(self): + def test_mesh_sizes_using_standard_spatial_vars(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -177,27 +152,19 @@ def test_mesh_sizes_using_standard_spatial_vars(self): # provide mesh properties var = pybamm.standard_spatial_vars var_pts = {var.x_n: 10, var.x_s: 10, var.x_p: 12, var.r_n: 5, var.r_p: 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) - self.assertEqual(mesh["negative electrode"].npts, var_pts[var.x_n]) - self.assertEqual(mesh["separator"].npts, var_pts[var.x_s]) - self.assertEqual(mesh["positive electrode"].npts, var_pts[var.x_p]) + assert mesh["negative electrode"].npts == var_pts[var.x_n] + assert mesh["separator"].npts == var_pts[var.x_s] + assert mesh["positive electrode"].npts == var_pts[var.x_p] - self.assertEqual(len(mesh["negative electrode"].edges) - 1, var_pts[var.x_n]) - self.assertEqual(len(mesh["separator"].edges) - 1, var_pts[var.x_s]) - self.assertEqual(len(mesh["positive electrode"].edges) - 1, var_pts[var.x_p]) + assert len(mesh["negative electrode"].edges) - 1 == var_pts[var.x_n] + assert len(mesh["separator"].edges) - 1 == var_pts[var.x_s] + assert len(mesh["positive electrode"].edges) - 1 == var_pts[var.x_p] - def test_combine_submeshes(self): + def test_combine_submeshes(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -205,22 +172,14 @@ def test_combine_submeshes(self): # provide mesh properties var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # create submesh submesh = mesh[("negative electrode", "separator")] - self.assertEqual(submesh.edges[0], 0) - self.assertEqual(submesh.edges[-1], mesh["separator"].edges[-1]) + assert submesh.edges[0] == 0 + assert submesh.edges[-1] == mesh["separator"].edges[-1] np.testing.assert_almost_equal( submesh.nodes - np.concatenate( @@ -228,8 +187,8 @@ def test_combine_submeshes(self): ), 0, ) - self.assertEqual(submesh.internal_boundaries, [0.1]) - with self.assertRaises(pybamm.DomainError): + assert submesh.internal_boundaries == [0.1] + with pytest.raises(pybamm.DomainError): mesh.combine_submeshes("negative electrode", "positive electrode") # test errors @@ -241,15 +200,15 @@ def test_combine_submeshes(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) - with self.assertRaisesRegex(pybamm.DomainError, "trying"): + with pytest.raises(pybamm.DomainError, match="trying"): mesh.combine_submeshes("negative electrode", "negative particle") - with self.assertRaisesRegex( - ValueError, "Submesh domains being combined cannot be empty" + with pytest.raises( + ValueError, match="Submesh domains being combined cannot be empty" ): mesh.combine_submeshes() - def test_ghost_cells(self): + def test_ghost_cells(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -257,14 +216,6 @@ def test_ghost_cells(self): # provide mesh properties var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) @@ -282,7 +233,7 @@ def test_ghost_cells(self): mesh["positive electrode"].edges[-1], ) - def test_mesh_coord_sys(self): + def test_mesh_coord_sys(self, submesh_types): param = get_param() geometry = pybamm.battery_geometry() @@ -290,21 +241,12 @@ def test_mesh_coord_sys(self): var_pts = {"x_n": 10, "x_s": 10, "x_p": 12, "r_n": 5, "r_p": 6} - submesh_types = { - "negative electrode": pybamm.Uniform1DSubMesh, - "separator": pybamm.Uniform1DSubMesh, - "positive electrode": pybamm.Uniform1DSubMesh, - "negative particle": pybamm.Uniform1DSubMesh, - "positive particle": pybamm.Uniform1DSubMesh, - "current collector": pybamm.SubMesh0D, - } - # create mesh mesh = pybamm.Mesh(geometry, submesh_types, var_pts) for submesh in mesh.values(): if not isinstance(submesh, pybamm.SubMesh0D): - self.assertTrue(submesh.coord_sys in pybamm.KNOWN_COORD_SYS) + assert submesh.coord_sys in pybamm.KNOWN_COORD_SYS def test_unimplemented_meshes(self): var_pts = {"x_n": 10, "y": 10} @@ -315,10 +257,10 @@ def test_unimplemented_meshes(self): } } submesh_types = {"negative electrode": pybamm.Uniform1DSubMesh} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.Mesh(geometry, submesh_types, var_pts) - def test_1plus1D_tabs_left_right(self): + def test_1plus1_d_tabs_left_right(self): param = pybamm.ParameterValues( values={ "Electrode width [m]": 0.4, @@ -349,12 +291,12 @@ def test_1plus1D_tabs_left_right(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # negative tab should be "left" - self.assertEqual(mesh["current collector"].tabs["negative tab"], "left") + assert mesh["current collector"].tabs["negative tab"] == "left" # positive tab should be "right" - self.assertEqual(mesh["current collector"].tabs["positive tab"], "right") + assert mesh["current collector"].tabs["positive tab"] == "right" - def test_1plus1D_tabs_right_left(self): + def test_1plus1_d_tabs_right_left(self): param = pybamm.ParameterValues( values={ "Electrode width [m]": 0.4, @@ -385,10 +327,10 @@ def test_1plus1D_tabs_right_left(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # negative tab should be "right" - self.assertEqual(mesh["current collector"].tabs["negative tab"], "right") + assert mesh["current collector"].tabs["negative tab"] == "right" # positive tab should be "left" - self.assertEqual(mesh["current collector"].tabs["positive tab"], "left") + assert mesh["current collector"].tabs["positive tab"] == "left" def test_to_json(self): r = pybamm.SpatialVariable( @@ -412,20 +354,10 @@ def test_to_json(self): "base_domains": ["negative particle"], } - self.assertEqual(mesh_json, expected_json) + assert mesh_json == expected_json -class TestMeshGenerator(unittest.TestCase): +class TestMeshGenerator: def test_init_name(self): mesh_generator = pybamm.MeshGenerator(pybamm.SubMesh0D) - self.assertEqual(mesh_generator.__repr__(), "Generator for SubMesh0D") - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert mesh_generator.__repr__() == "Generator for SubMesh0D" diff --git a/tests/unit/test_meshes/test_one_dimensional_submesh.py b/tests/unit/test_meshes/test_one_dimensional_submesh.py index 82429e475c..94895b6c4b 100644 --- a/tests/unit/test_meshes/test_one_dimensional_submesh.py +++ b/tests/unit/test_meshes/test_one_dimensional_submesh.py @@ -1,20 +1,36 @@ +import pytest import pybamm -import unittest import numpy as np -class TestSubMesh1D(unittest.TestCase): +@pytest.fixture() +def r(): + r = pybamm.SpatialVariable( + "r", domain=["negative particle"], coord_sys="spherical polar" + ) + return r + + +@pytest.fixture() +def geometry(r): + geometry = { + "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} + } + return geometry + + +class TestSubMesh1D: def test_tabs(self): edges = np.linspace(0, 1, 10) tabs = {"negative": {"z_centre": 0}, "positive": {"z_centre": 1}} mesh = pybamm.SubMesh1D(edges, None, tabs=tabs) - self.assertEqual(mesh.tabs["negative tab"], "left") - self.assertEqual(mesh.tabs["positive tab"], "right") + assert mesh.tabs["negative tab"] == "left" + assert mesh.tabs["positive tab"] == "right" def test_exceptions(self): edges = np.linspace(0, 1, 10) tabs = {"negative": {"z_centre": 0.2}, "positive": {"z_centre": 1}} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.SubMesh1D(edges, None, tabs=tabs) def test_to_json(self): @@ -41,28 +57,20 @@ def test_to_json(self): "tabs": {"negative tab": "left", "positive tab": "right"}, } - self.assertEqual(mesh_json, expected_json) + assert mesh_json == expected_json # check tabs work new_mesh = pybamm.Uniform1DSubMesh._from_json(mesh_json) - self.assertEqual(mesh.tabs, new_mesh.tabs) + assert mesh.tabs == new_mesh.tabs -class TestUniform1DSubMesh(unittest.TestCase): +class TestUniform1DSubMesh: def test_exceptions(self): lims = {"a": 1, "b": 2} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.Uniform1DSubMesh(lims, None) - def test_symmetric_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_symmetric_mesh_creation_no_parameters(self, r, geometry): submesh_types = {"negative particle": pybamm.Uniform1DSubMesh} var_pts = {r: 20} @@ -70,27 +78,19 @@ def test_symmetric_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, - ) - - -class TestExponential1DSubMesh(unittest.TestCase): - def test_symmetric_mesh_creation_no_parameters_even(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } +class TestExponential1DSubMesh: + def test_symmetric_mesh_creation_no_parameters_even(self, r, geometry): submesh_params = {"side": "symmetric"} submesh_types = { "negative particle": pybamm.MeshGenerator( @@ -103,25 +103,17 @@ def test_symmetric_mesh_creation_no_parameters_even(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - def test_symmetric_mesh_creation_no_parameters_odd(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_symmetric_mesh_creation_no_parameters_odd(self, r, geometry): submesh_params = {"side": "symmetric", "stretch": 1.5} submesh_types = { "negative particle": pybamm.MeshGenerator( @@ -134,25 +126,17 @@ def test_symmetric_mesh_creation_no_parameters_odd(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, - ) - - def test_left_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_left_mesh_creation_no_parameters(self, r, geometry): submesh_params = {"side": "left"} submesh_types = { "negative particle": pybamm.MeshGenerator( @@ -165,25 +149,17 @@ def test_left_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - def test_right_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_right_mesh_creation_no_parameters(self, r, geometry): submesh_params = {"side": "right", "stretch": 2} submesh_types = { "negative particle": pybamm.MeshGenerator( @@ -196,27 +172,19 @@ def test_right_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, - ) - - -class TestChebyshev1DSubMesh(unittest.TestCase): - def test_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } +class TestChebyshev1DSubMesh: + def test_mesh_creation_no_parameters(self, r, geometry): submesh_types = {"negative particle": pybamm.Chebyshev1DSubMesh} var_pts = {r: 20} @@ -224,18 +192,18 @@ def test_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) -class TestUser1DSubMesh(unittest.TestCase): +class TestUser1DSubMesh: def test_exceptions(self): edges = np.array([0, 0.3, 1]) submesh_params = {"edges": edges} @@ -244,35 +212,27 @@ def test_exceptions(self): # error if npts+1 != len(edges) lims = {"x_n": {"min": 0, "max": 1}} npts = {"x_n": 10} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[0] not equal to edges[0] lims = {"x_n": {"min": 0.1, "max": 1}} npts = {"x_n": len(edges) - 1} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[-1] not equal to edges[-1] lims = {"x_n": {"min": 0, "max": 10}} npts = {"x_n": len(edges) - 1} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # no user points mesh = pybamm.MeshGenerator(pybamm.UserSupplied1DSubMesh) - with self.assertRaisesRegex(pybamm.GeometryError, "User mesh requires"): + with pytest.raises(pybamm.GeometryError, match="User mesh requires"): mesh(None, None) - def test_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_mesh_creation_no_parameters(self, r, geometry): edges = np.array([0, 0.3, 1]) submesh_params = {"edges": edges} submesh_types = { @@ -286,18 +246,18 @@ def test_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].nodes), var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].nodes) == var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) -class TestSpectralVolume1DSubMesh(unittest.TestCase): +class TestSpectralVolume1DSubMesh: def test_exceptions(self): edges = np.array([0, 0.3, 1]) submesh_params = {"edges": edges} @@ -306,30 +266,22 @@ def test_exceptions(self): # error if npts+1 != len(edges) lims = {"x_n": {"min": 0, "max": 1}} npts = {"x_n": 10} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[0] not equal to edges[0] lims = {"x_n": {"min": 0.1, "max": 1}} npts = {"x_n": len(edges) - 1} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[-1] not equal to edges[-1] lims = {"x_n": {"min": 0, "max": 10}} npts = {"x_n": len(edges) - 1} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) - def test_mesh_creation_no_parameters(self): - r = pybamm.SpatialVariable( - "r", domain=["negative particle"], coord_sys="spherical polar" - ) - - geometry = { - "negative particle": {r: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - } - + def test_mesh_creation_no_parameters(self, r, geometry): edges = np.array([0, 0.3, 1]) order = 3 submesh_params = {"edges": edges, "order": order} @@ -344,15 +296,15 @@ def test_mesh_creation_no_parameters(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative particle"].edges[0], 0) - self.assertEqual(mesh["negative particle"].edges[-1], 1) + assert mesh["negative particle"].edges[0] == 0 + assert mesh["negative particle"].edges[-1] == 1 # check number of edges and nodes - self.assertEqual(len(mesh["negative particle"].sv_nodes), var_pts[r]) - self.assertEqual(len(mesh["negative particle"].nodes), order * var_pts[r]) - self.assertEqual( - len(mesh["negative particle"].edges), - len(mesh["negative particle"].nodes) + 1, + assert len(mesh["negative particle"].sv_nodes) == var_pts[r] + assert len(mesh["negative particle"].nodes) == order * var_pts[r] + assert ( + len(mesh["negative particle"].edges) + == len(mesh["negative particle"].nodes) + 1 ) # check Chebyshev subdivision locations @@ -360,7 +312,7 @@ def test_mesh_creation_no_parameters(self): mesh["negative particle"].edges.tolist(), [0, 0.075, 0.225, 0.3, 0.475, 0.825, 1], ): - self.assertAlmostEqual(a, b) + assert a == pytest.approx(b) # test uniform submesh creation submesh_params = {"order": order} @@ -377,14 +329,4 @@ def test_mesh_creation_no_parameters(self): mesh["negative particle"].edges.tolist(), [0.0, 0.125, 0.375, 0.5, 0.625, 0.875, 1.0], ): - self.assertAlmostEqual(a, b) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert a == pytest.approx(b) diff --git a/tests/unit/test_meshes/test_scikit_fem_submesh.py b/tests/unit/test_meshes/test_scikit_fem_submesh.py index 07e7dd016a..30c45510e4 100644 --- a/tests/unit/test_meshes/test_scikit_fem_submesh.py +++ b/tests/unit/test_meshes/test_scikit_fem_submesh.py @@ -2,12 +2,13 @@ # Test for the scikit-fem Finite Element Mesh class # +import pytest import pybamm -import unittest import numpy as np -def get_param(): +@pytest.fixture() +def param(): return pybamm.ParameterValues( { "Electrode width [m]": 0.4, @@ -25,9 +26,8 @@ def get_param(): ) -class TestScikitFiniteElement2DSubMesh(unittest.TestCase): - def test_mesh_creation(self): - param = get_param() +class TestScikitFiniteElement2DSubMesh: + def test_mesh_creation(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -46,23 +46,19 @@ def test_mesh_creation(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertEqual(mesh["positive electrode"].edges[-1], 1) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == 1 # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain == "current collector": # NOTE: only for degree 1 npts = var_pts["y"] * var_pts["z"] - self.assertEqual(mesh[domain].npts, npts) + assert mesh[domain].npts == npts else: - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 def test_init_failure(self): submesh_types = { @@ -74,26 +70,26 @@ def test_init_failure(self): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) - with self.assertRaises(KeyError): + with pytest.raises(KeyError): pybamm.Mesh(geometry, submesh_types, {}) var_pts = {"x_n": 10, "x_s": 10, "x_p": 10, "y": 10, "z": 10} # there are parameters in the variables that need to be processed - with self.assertRaisesRegex( + with pytest.raises( pybamm.DiscretisationError, - "Parameter values have not yet been set for geometry", + match="Parameter values have not yet been set for geometry", ): pybamm.Mesh(geometry, submesh_types, var_pts) lims = {"x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.ScikitUniform2DSubMesh(lims, None) lims = { "x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "x_p": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitUniform2DSubMesh(lims, None) lims = { @@ -106,7 +102,7 @@ def test_init_failure(self): "y": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, z: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitUniform2DSubMesh(lims, npts) def test_tab_error(self): @@ -142,7 +138,7 @@ def test_tab_error(self): include_particles=False, options={"dimensionality": 2} ) param.process_geometry(geometry) - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.Mesh(geometry, submesh_types, var_pts) def test_tab_left_right(self): @@ -180,8 +176,7 @@ def test_tab_left_right(self): param.process_geometry(geometry) pybamm.Mesh(geometry, submesh_types, var_pts) - def test_to_json(self): - param = get_param() + def test_to_json(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -216,7 +211,7 @@ def test_to_json(self): ], } - self.assertEqual(mesh_json, expected_json) + assert mesh_json == expected_json # test Uniform2DSubMesh serialisation @@ -276,7 +271,7 @@ def test_to_json(self): }, } - self.assertEqual(submesh, expected_submesh) + assert submesh == expected_submesh new_submesh = pybamm.ScikitUniform2DSubMesh._from_json(submesh) @@ -284,10 +279,8 @@ def test_to_json(self): np.testing.assert_array_equal(x, y) -class TestScikitFiniteElementChebyshev2DSubMesh(unittest.TestCase): - def test_mesh_creation(self): - param = get_param() - +class TestScikitFiniteElementChebyshev2DSubMesh: + def test_mesh_creation(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -306,28 +299,24 @@ def test_mesh_creation(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertEqual(mesh["positive electrode"].edges[-1], 1) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == 1 # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain == "current collector": # NOTE: only for degree 1 npts = var_pts["y"] * var_pts["z"] - self.assertEqual(mesh[domain].npts, npts) + assert mesh[domain].npts == npts else: - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 def test_init_failure(self): # only one lim lims = {"x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.ScikitChebyshev2DSubMesh(lims, None) # different coord_sys @@ -335,7 +324,7 @@ def test_init_failure(self): "r_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "z": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitChebyshev2DSubMesh(lims, None) # not y and z @@ -343,14 +332,12 @@ def test_init_failure(self): "x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "z": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitChebyshev2DSubMesh(lims, None) -class TestScikitExponential2DSubMesh(unittest.TestCase): - def test_mesh_creation(self): - param = get_param() - +class TestScikitExponential2DSubMesh: + def test_mesh_creation(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -371,28 +358,24 @@ def test_mesh_creation(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertEqual(mesh["positive electrode"].edges[-1], 1) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == 1 # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain == "current collector": # NOTE: only for degree 1 npts = var_pts["y"] * var_pts["z"] - self.assertEqual(mesh[domain].npts, npts) + assert mesh[domain].npts == npts else: - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 def test_init_failure(self): # only one lim lims = {"x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.ScikitExponential2DSubMesh(lims, None) # different coord_sys @@ -400,7 +383,7 @@ def test_init_failure(self): "r_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "z": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitExponential2DSubMesh(lims, None) # not y and z @@ -408,18 +391,16 @@ def test_init_failure(self): "x_n": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, "z": {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}, } - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): pybamm.ScikitExponential2DSubMesh(lims, None) # side not top - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): pybamm.ScikitExponential2DSubMesh(None, None, side="bottom") -class TestScikitUser2DSubMesh(unittest.TestCase): - def test_mesh_creation(self): - param = get_param() - +class TestScikitUser2DSubMesh: + def test_mesh_creation(self, param): geometry = pybamm.battery_geometry( include_particles=False, options={"dimensionality": 2} ) @@ -444,23 +425,19 @@ def test_mesh_creation(self): mesh = pybamm.Mesh(geometry, submesh_types, var_pts) # check boundary locations - self.assertEqual(mesh["negative electrode"].edges[0], 0) - self.assertEqual(mesh["positive electrode"].edges[-1], 1) + assert mesh["negative electrode"].edges[0] == 0 + assert mesh["positive electrode"].edges[-1] == 1 # check internal boundary locations - self.assertEqual( - mesh["negative electrode"].edges[-1], mesh["separator"].edges[0] - ) - self.assertEqual( - mesh["positive electrode"].edges[0], mesh["separator"].edges[-1] - ) + assert mesh["negative electrode"].edges[-1] == mesh["separator"].edges[0] + assert mesh["positive electrode"].edges[0] == mesh["separator"].edges[-1] for domain in mesh.base_domains: if domain == "current collector": # NOTE: only for degree 1 npts = var_pts["y"] * var_pts["z"] - self.assertEqual(mesh[domain].npts, npts) + assert mesh[domain].npts == npts else: - self.assertEqual(len(mesh[domain].edges), len(mesh[domain].nodes) + 1) + assert len(mesh[domain].edges) == len(mesh[domain].nodes) + 1 def test_exceptions(self): lims = {"y": {"min": 0, "max": 1}} @@ -469,48 +446,38 @@ def test_exceptions(self): submesh_params = {"y_edges": y_edges, "z_edges": z_edges} mesh = pybamm.MeshGenerator(pybamm.UserSupplied2DSubMesh, submesh_params) # test not enough lims - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, None) lims = {"y": {"min": 0, "max": 1}, "z": {"min": 0, "max": 1}} # error if len(edges) != npts npts = {"y": 10, "z": 3} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[0] not equal to edges[0] lims = {"y": {"min": 0.1, "max": 1}, "z": {"min": 0, "max": 1}} npts = {"y": 3, "z": 3} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if lims[-1] not equal to edges[-1] lims = {"y": {"min": 0, "max": 1}, "z": {"min": 0, "max": 1.3}} npts = {"y": 3, "z": 3} - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): mesh(lims, npts) # error if different coordinate system lims = {"y": {"min": 0, "max": 1}, "r_n": {"min": 0, "max": 1}} npts = {"y": 3, "r_n": 3} - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): mesh(lims, npts) mesh = pybamm.MeshGenerator(pybamm.UserSupplied2DSubMesh) - with self.assertRaisesRegex(pybamm.GeometryError, "User mesh requires"): + with pytest.raises(pybamm.GeometryError, match="User mesh requires"): mesh(None, None) submesh_params = {"y_edges": np.array([0, 0.3, 1])} mesh = pybamm.MeshGenerator(pybamm.UserSupplied2DSubMesh, submesh_params) - with self.assertRaisesRegex(pybamm.GeometryError, "User mesh requires"): + with pytest.raises(pybamm.GeometryError, match="User mesh requires"): mesh(None, None) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_models/test_base_model.py b/tests/unit/test_models/test_base_model.py index 4d5f71201a..bfc7ad7473 100644 --- a/tests/unit/test_models/test_base_model.py +++ b/tests/unit/test_models/test_base_model.py @@ -2,10 +2,10 @@ # Tests for the base model class # +import pytest import os import platform import subprocess # nosec -import unittest from io import StringIO import sys @@ -16,7 +16,7 @@ import pybamm -class TestBaseModel(unittest.TestCase): +class TestBaseModel: def test_rhs_set_get(self): model = pybamm.BaseModel() rhs = { @@ -24,7 +24,7 @@ def test_rhs_set_get(self): pybamm.Symbol("d"): pybamm.Symbol("beta"), } model.rhs = rhs - self.assertEqual(rhs, model.rhs) + assert rhs == model.rhs # test domains rhs = { pybamm.Symbol("c", domain=["negative electrode"]): pybamm.Symbol( @@ -35,9 +35,9 @@ def test_rhs_set_get(self): ), } model.rhs = rhs - self.assertEqual(rhs, model.rhs) + assert rhs == model.rhs # non-matching domains should fail - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): model.rhs = { pybamm.Symbol("c", domain=["positive electrode"]): pybamm.Symbol( "alpha", domain=["negative electrode"] @@ -48,7 +48,7 @@ def test_algebraic_set_get(self): model = pybamm.BaseModel() algebraic = {pybamm.Symbol("b"): pybamm.Symbol("c") - pybamm.Symbol("a")} model.algebraic = algebraic - self.assertEqual(algebraic, model.algebraic) + assert algebraic == model.algebraic def test_initial_conditions_set_get(self): model = pybamm.BaseModel() @@ -57,22 +57,22 @@ def test_initial_conditions_set_get(self): pybamm.Symbol("d0"): pybamm.Symbol("delta"), } model.initial_conditions = initial_conditions - self.assertEqual(initial_conditions, model.initial_conditions) + assert initial_conditions == model.initial_conditions # Test number input c0 = pybamm.Symbol("c0") model.initial_conditions[c0] = 34 - self.assertIsInstance(model.initial_conditions[c0], pybamm.Scalar) - self.assertEqual(model.initial_conditions[c0].value, 34) + assert isinstance(model.initial_conditions[c0], pybamm.Scalar) + assert model.initial_conditions[c0].value == 34 # Variable in initial conditions should fail - with self.assertRaisesRegex( - TypeError, "Initial conditions cannot contain 'Variable' objects" + with pytest.raises( + TypeError, match="Initial conditions cannot contain 'Variable' objects" ): model.initial_conditions = {c0: pybamm.Variable("v")} # non-matching domains should fail - with self.assertRaises(pybamm.DomainError): + with pytest.raises(pybamm.DomainError): model.initial_conditions = { pybamm.Symbol("c", domain=["positive electrode"]): pybamm.Symbol( "alpha", domain=["negative electrode"] @@ -85,7 +85,7 @@ def test_boundary_conditions_set_get(self): "c": {"left": ("epsilon", "Dirichlet"), "right": ("eta", "Dirichlet")} } model.boundary_conditions = boundary_conditions - self.assertEqual(boundary_conditions, model.boundary_conditions) + assert boundary_conditions == model.boundary_conditions # Test number input c0 = pybamm.Symbol("c0") @@ -93,29 +93,29 @@ def test_boundary_conditions_set_get(self): "left": (-2, "Dirichlet"), "right": (4, "Dirichlet"), } - self.assertIsInstance(model.boundary_conditions[c0]["left"][0], pybamm.Scalar) - self.assertIsInstance(model.boundary_conditions[c0]["right"][0], pybamm.Scalar) - self.assertEqual(model.boundary_conditions[c0]["left"][0].value, -2) - self.assertEqual(model.boundary_conditions[c0]["right"][0].value, 4) - self.assertEqual(model.boundary_conditions[c0]["left"][1], "Dirichlet") - self.assertEqual(model.boundary_conditions[c0]["right"][1], "Dirichlet") + assert isinstance(model.boundary_conditions[c0]["left"][0], pybamm.Scalar) + assert isinstance(model.boundary_conditions[c0]["right"][0], pybamm.Scalar) + assert model.boundary_conditions[c0]["left"][0].value == -2 + assert model.boundary_conditions[c0]["right"][0].value == 4 + assert model.boundary_conditions[c0]["left"][1] == "Dirichlet" + assert model.boundary_conditions[c0]["right"][1] == "Dirichlet" # Check bad bc type bad_bcs = {c0: {"left": (-2, "bad type"), "right": (4, "bad type")}} - with self.assertRaisesRegex(pybamm.ModelError, "boundary condition"): + with pytest.raises(pybamm.ModelError, match="boundary condition"): model.boundary_conditions = bad_bcs def test_variables_set_get(self): model = pybamm.BaseModel() variables = {"c": "alpha", "d": "beta"} model.variables = variables - self.assertEqual(variables, model.variables) - self.assertEqual(model.variable_names(), list(variables.keys())) + assert variables == model.variables + assert model.variable_names() == list(variables.keys()) def test_jac_set_get(self): model = pybamm.BaseModel() model.jacobian = "test" - self.assertEqual(model.jacobian, "test") + assert model.jacobian == "test" def test_read_parameters(self): # Read parameters from different parts of the model @@ -143,18 +143,15 @@ def test_read_parameters(self): } # Test variables_and_events - self.assertIn("v+f+i", model.variables_and_events) - self.assertIn("Event: u=e", model.variables_and_events) + assert "v+f+i" in model.variables_and_events + assert "Event: u=e" in model.variables_and_events - self.assertEqual( - set([x.name for x in model.parameters]), - set([x.name for x in [a, b, c, d, e, f, g, h, i]]), + assert set([x.name for x in model.parameters]) == set( + [x.name for x in [a, b, c, d, e, f, g, h, i]] ) - self.assertTrue( - all( - isinstance(x, (pybamm.Parameter, pybamm.InputParameter)) - for x in model.parameters - ) + assert all( + isinstance(x, (pybamm.Parameter, pybamm.InputParameter)) + for x in model.parameters ) model.variables = { @@ -162,7 +159,8 @@ def test_read_parameters(self): } model.print_parameter_info() - def test_get_parameter_info(self): + @pytest.mark.parametrize("symbols", ["c", "d", "e", "f", "h", "i"]) + def test_get_parameter_info(self, symbols): model = pybamm.BaseModel() a = pybamm.InputParameter("a") b = pybamm.InputParameter("b", "test") @@ -187,17 +185,28 @@ def test_get_parameter_info(self): } parameter_info = model.get_parameter_info() - self.assertEqual(parameter_info["a"][1], "InputParameter") - self.assertEqual(parameter_info["b"][1], "InputParameter in ['test']") - self.assertIn("c", parameter_info) - self.assertIn("d", parameter_info) - self.assertIn("e", parameter_info) - self.assertIn("f", parameter_info) - self.assertEqual(parameter_info["g"][1], "Parameter") - self.assertIn("h", parameter_info) - self.assertIn("i", parameter_info) - - def test_get_parameter_info_submodel(self): + assert parameter_info["a"][1] == "InputParameter" + assert parameter_info["b"][1] == "InputParameter in ['test']" + assert symbols in parameter_info + assert parameter_info["g"][1] == "Parameter" + + @pytest.mark.parametrize( + "sub, key, parameter_value", + [ + ("sub1", "a", "InputParameter"), + ("sub1", "w", "InputParameter"), + ("sub1", "e", "InputParameter"), + ("sub1", "g", "Parameter"), + ("sub1", "x", "Parameter"), + ("sub1", "f", "InputParameter in ['test']"), + ("sub2", "b", "InputParameter in ['test']"), + ("sub2", "h", "Parameter"), + ("sub1", "c", "FunctionParameter with inputs(s) ''"), + ("sub2", "d", "FunctionParameter with inputs(s) ''"), + ("sub2", "i", "FunctionParameter with inputs(s) ''"), + ], + ) + def test_get_parameter_info_submodel(self, sub, key, parameter_value): submodel = pybamm.lithium_ion.SPM().submodels["electrolyte diffusion"] class SubModel1(pybamm.BaseSubModel): @@ -231,7 +240,7 @@ def set_initial_conditions(self, variables): u = variables["u"] self.initial_conditions = {u: c} - def set_events(self, variables): + def add_events_from(self, variables): e = pybamm.InputParameter("e") u = variables["u"] self.events = [pybamm.Event("u=e", u - e)] @@ -270,34 +279,15 @@ def set_initial_conditions(self, variables): expected_error_message = "Cannot use get_parameter_info" - with self.assertRaisesRegex(NotImplementedError, expected_error_message): + with pytest.raises(NotImplementedError, match=expected_error_message): submodel.get_parameter_info(by_submodel=True) - with self.assertRaisesRegex(NotImplementedError, expected_error_message): + with pytest.raises(NotImplementedError, match=expected_error_message): submodel.get_parameter_info(by_submodel=False) - self.assertIn("a", parameter_info["sub1"]) - self.assertIn("b", parameter_info["sub2"]) - self.assertEqual(parameter_info["sub1"]["a"][1], "InputParameter") - self.assertEqual(parameter_info["sub1"]["w"][1], "InputParameter") - self.assertEqual(parameter_info["sub1"]["e"][1], "InputParameter") - self.assertEqual(parameter_info["sub1"]["g"][1], "Parameter") - self.assertEqual(parameter_info["sub1"]["x"][1], "Parameter") - self.assertEqual(parameter_info["sub1"]["f"][1], "InputParameter in ['test']") - self.assertEqual(parameter_info["sub2"]["b"][1], "InputParameter in ['test']") - self.assertEqual(parameter_info["sub2"]["h"][1], "Parameter") - self.assertEqual( - parameter_info["sub1"]["c"][1], - "FunctionParameter with inputs(s) ''", - ) - self.assertEqual( - parameter_info["sub2"]["d"][1], - "FunctionParameter with inputs(s) ''", - ) - self.assertEqual( - parameter_info["sub2"]["i"][1], - "FunctionParameter with inputs(s) ''", - ) + assert "a" in parameter_info["sub1"] + assert "b" in parameter_info["sub2"] + assert parameter_info[sub][key][1] == parameter_value def test_print_parameter_info(self): model = pybamm.BaseModel() @@ -339,14 +329,31 @@ def test_print_parameter_info(self): sys.stdout = sys.__stdout__ result = captured_output.getvalue().strip() - self.assertIn("a", result) - self.assertIn("b", result) - self.assertIn("InputParameter", result) - self.assertIn("InputParameter in ['test']", result) - self.assertIn("Parameter", result) - self.assertIn("FunctionParameter with inputs(s) ''", result) - - def test_print_parameter_info_submodel(self): + assert "a" in result + assert "b" in result + assert "InputParameter" in result + assert "InputParameter in ['test']" in result + assert "Parameter" in result + assert "FunctionParameter with inputs(s) ''" in result + + @pytest.mark.parametrize( + "values", + [ + "'sub1' submodel parameters:", + "'sub2' submodel parameters:", + "Parameter", + "InputParameter", + "FunctionParameter with inputs(s) ''", + "InputParameter in ['test']", + "g", + "a", + "c", + "h", + "b", + "d", + ], + ) + def test_print_parameter_info_submodel(self, values): model = pybamm.BaseModel() a = pybamm.InputParameter("a") b = pybamm.InputParameter("b", "test") @@ -385,18 +392,7 @@ def test_print_parameter_info_submodel(self): sys.stdout = sys.__stdout__ result = captured_output.getvalue().strip() - self.assertIn("'sub1' submodel parameters:", result) - self.assertIn("'sub2' submodel parameters:", result) - self.assertIn("Parameter", result) - self.assertIn("InputParameter", result) - self.assertIn("FunctionParameter with inputs(s) ''", result) - self.assertIn("InputParameter in ['test']", result) - self.assertIn("g", result) - self.assertIn("a", result) - self.assertIn("c", result) - self.assertIn("h", result) - self.assertIn("b", result) - self.assertIn("d", result) + assert values in result def test_read_input_parameters(self): # Read input parameters from different parts of the model @@ -416,13 +412,10 @@ def test_read_input_parameters(self): model.events = [pybamm.Event("u=e", u - e)] model.variables = {"v+f": v + f} - self.assertEqual( - set([x.name for x in model.input_parameters]), - set([x.name for x in [a, b, c, d, e, f]]), - ) - self.assertTrue( - all(isinstance(x, pybamm.InputParameter) for x in model.input_parameters) + assert set([x.name for x in model.input_parameters]) == set( + [x.name for x in [a, b, c, d, e, f]] ) + assert all(isinstance(x, pybamm.InputParameter) for x in model.input_parameters) def test_update(self): # model @@ -452,19 +445,19 @@ def test_update(self): model.update(submodel) # check - self.assertEqual(model.rhs[d], submodel.rhs[d]) - self.assertEqual(model.initial_conditions[d], submodel.initial_conditions[d]) - self.assertEqual(model.boundary_conditions[d], submodel.boundary_conditions[d]) - self.assertEqual(model.variables["d"], submodel.variables["d"]) - self.assertEqual(model.rhs[c], rhs[c]) - self.assertEqual(model.initial_conditions[c], initial_conditions[c]) - self.assertEqual(model.boundary_conditions[c], boundary_conditions[c]) - self.assertEqual(model.variables["c"], variables["c"]) + assert model.rhs[d] == submodel.rhs[d] + assert model.initial_conditions[d] == submodel.initial_conditions[d] + assert model.boundary_conditions[d] == submodel.boundary_conditions[d] + assert model.variables["d"] == submodel.variables["d"] + assert model.rhs[c] == rhs[c] + assert model.initial_conditions[c] == initial_conditions[c] + assert model.boundary_conditions[c] == boundary_conditions[c] + assert model.variables["c"] == variables["c"] # update with conflicting submodel submodel2 = pybamm.BaseModel() submodel2.rhs = {d: pybamm.div(pybamm.grad(d)) - 1} - with self.assertRaises(pybamm.ModelError): + with pytest.raises(pybamm.ModelError): model.update(submodel2) # update with multiple submodels @@ -482,12 +475,12 @@ def test_update(self): model = pybamm.BaseModel() model.update(submodel1, submodel2) - self.assertEqual(model.rhs[d], submodel1.rhs[d]) - self.assertEqual(model.initial_conditions[d], submodel1.initial_conditions[d]) - self.assertEqual(model.boundary_conditions[d], submodel1.boundary_conditions[d]) - self.assertEqual(model.rhs[e], submodel2.rhs[e]) - self.assertEqual(model.initial_conditions[e], submodel2.initial_conditions[e]) - self.assertEqual(model.boundary_conditions[e], submodel2.boundary_conditions[e]) + assert model.rhs[d] == submodel1.rhs[d] + assert model.initial_conditions[d] == submodel1.initial_conditions[d] + assert model.boundary_conditions[d] == submodel1.boundary_conditions[d] + assert model.rhs[e] == submodel2.rhs[e] + assert model.initial_conditions[e] == submodel2.initial_conditions[e] + assert model.boundary_conditions[e] == submodel2.boundary_conditions[e] def test_new_copy(self): model = pybamm.BaseModel(name="a model") @@ -504,9 +497,9 @@ def test_new_copy(self): model.convert_to_format = "python" new_model = model.new_copy() - self.assertEqual(new_model.name, model.name) - self.assertEqual(new_model.use_jacobian, model.use_jacobian) - self.assertEqual(new_model.convert_to_format, model.convert_to_format) + assert new_model.name == model.name + assert new_model.use_jacobian == model.use_jacobian + assert new_model.convert_to_format == model.convert_to_format def test_check_no_repeated_keys(self): model = pybamm.BaseModel() @@ -515,7 +508,7 @@ def test_check_no_repeated_keys(self): model.rhs = {var: -1} var = pybamm.Variable("var") model.algebraic = {var: var} - with self.assertRaisesRegex(pybamm.ModelError, "Multiple equations specified"): + with pytest.raises(pybamm.ModelError, match="Multiple equations specified"): model.check_no_repeated_keys() def test_check_well_posedness_variables(self): @@ -540,26 +533,26 @@ def test_check_well_posedness_variables(self): # Underdetermined model - not enough differential equations model.rhs = {c: 5 * pybamm.div(pybamm.grad(d)) - 1} model.algebraic = {e: e - c - d} - with self.assertRaisesRegex(pybamm.ModelError, "underdetermined"): + with pytest.raises(pybamm.ModelError, match="underdetermined"): model.check_well_posedness() # Underdetermined model - not enough algebraic equations model.algebraic = {} - with self.assertRaisesRegex(pybamm.ModelError, "underdetermined"): + with pytest.raises(pybamm.ModelError, match="underdetermined"): model.check_well_posedness() # Overdetermined model - repeated keys model.algebraic = {c: c - d, d: e + d} - with self.assertRaisesRegex(pybamm.ModelError, "overdetermined"): + with pytest.raises(pybamm.ModelError, match="overdetermined"): model.check_well_posedness() # Overdetermined model - extra keys in algebraic model.rhs = {c: 5 * pybamm.div(pybamm.grad(d)) - 1, d: -d} model.algebraic = {e: c - d} - with self.assertRaisesRegex(pybamm.ModelError, "overdetermined"): + with pytest.raises(pybamm.ModelError, match="overdetermined"): model.check_well_posedness() model.rhs = {c: 1, d: -1} model.algebraic = {e: c - d} - with self.assertRaisesRegex(pybamm.ModelError, "overdetermined"): + with pytest.raises(pybamm.ModelError, match="overdetermined"): model.check_well_posedness() # After discretisation, don't check for overdetermined from extra algebraic keys @@ -568,7 +561,7 @@ def test_check_well_posedness_variables(self): # passes with post_discretisation=True model.check_well_posedness(post_discretisation=True) # fails with post_discretisation=False (default) - with self.assertRaisesRegex(pybamm.ModelError, "extra algebraic keys"): + with pytest.raises(pybamm.ModelError, match="extra algebraic keys"): model.check_well_posedness() # after discretisation, algebraic equation without a StateVector fails @@ -577,9 +570,9 @@ def test_check_well_posedness_variables(self): c: 1, d: pybamm.StateVector(slice(0, 15)) - pybamm.StateVector(slice(15, 30)), } - with self.assertRaisesRegex( + with pytest.raises( pybamm.ModelError, - "each algebraic equation must contain at least one StateVector", + match="each algebraic equation must contain at least one StateVector", ): model.check_well_posedness(post_discretisation=True) @@ -587,8 +580,8 @@ def test_check_well_posedness_variables(self): model = pybamm.BaseModel() model.rhs = {c: d.diff(pybamm.t), d: -1} model.initial_conditions = {c: 1, d: 1} - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of variable found" + with pytest.raises( + pybamm.ModelError, match="time derivative of variable found" ): model.check_well_posedness() @@ -596,8 +589,8 @@ def test_check_well_posedness_variables(self): model = pybamm.BaseModel() model.algebraic = {c: 2 * d - c, d: c * d.diff(pybamm.t) - d} model.initial_conditions = {c: 1, d: 1} - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of variable found" + with pytest.raises( + pybamm.ModelError, match="time derivative of variable found" ): model.check_well_posedness() @@ -605,8 +598,8 @@ def test_check_well_posedness_variables(self): model = pybamm.BaseModel() model.rhs = {c: d.diff(pybamm.t), d: -1} model.initial_conditions = {c: 1, d: 1} - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of variable found" + with pytest.raises( + pybamm.ModelError, match="time derivative of variable found" ): model.check_well_posedness() @@ -616,8 +609,8 @@ def test_check_well_posedness_variables(self): d: 5 * pybamm.StateVector(slice(0, 15)) - 1, c: 5 * pybamm.StateVectorDot(slice(0, 15)) - 1, } - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of state vector found" + with pytest.raises( + pybamm.ModelError, match="time derivative of state vector found" ): model.check_well_posedness(post_discretisation=True) @@ -625,8 +618,8 @@ def test_check_well_posedness_variables(self): model = pybamm.BaseModel() model.rhs = {c: 5 * pybamm.StateVectorDot(slice(0, 15)) - 1} model.initial_conditions = {c: 1} - with self.assertRaisesRegex( - pybamm.ModelError, "time derivative of state vector found" + with pytest.raises( + pybamm.ModelError, match="time derivative of state vector found" ): model.check_well_posedness(post_discretisation=True) @@ -651,7 +644,7 @@ def test_check_well_posedness_initial_boundary_conditions(self): # Model with bad initial conditions (expect assertion error) d = pybamm.Variable("d", domain=whole_cell) model.initial_conditions = {d: 3} - with self.assertRaisesRegex(pybamm.ModelError, "initial condition"): + with pytest.raises(pybamm.ModelError, match="initial condition"): model.check_well_posedness() # Algebraic well-posed model @@ -686,14 +679,14 @@ def test_check_well_posedness_output_variables(self): model.rhs = {c: -c} model.initial_conditions = {c: 1} model.variables = {"d": d} - with self.assertRaisesRegex(pybamm.ModelError, "No key set for variable"): + with pytest.raises(pybamm.ModelError, match="No key set for variable"): model.check_well_posedness() # check error is raised even if some modified form of d is in model.rhs two_d = 2 * d model.rhs[two_d] = -d model.initial_conditions[two_d] = 1 - with self.assertRaisesRegex(pybamm.ModelError, "No key set for variable"): + with pytest.raises(pybamm.ModelError, match="No key set for variable"): model.check_well_posedness() # add d to rhs, fine @@ -730,13 +723,13 @@ def test_export_casadi(self): var_fn = casadi.Function("var", [t, x, z, p], [var]) # Test that function values are as expected - self.assertEqual(x0_fn([0, 5]), 5) - self.assertEqual(z0_fn([0, 0]), 1) - self.assertEqual(rhs_fn(0, 3, 2, [7, 2]), -21) - self.assertEqual(alg_fn(0, 3, 2, [7, 2]), 1) + assert x0_fn([0, 5]) == 5 + assert z0_fn([0, 0]) == 1 + assert rhs_fn(0, 3, 2, [7, 2]) == -21 + assert alg_fn(0, 3, 2, [7, 2]) == 1 np.testing.assert_array_equal(np.array(jac_rhs_fn(5, 6, 7, [8, 9])), [[-8, 0]]) np.testing.assert_array_equal(np.array(jac_alg_fn(5, 6, 7, [8, 9])), [[1, -1]]) - self.assertEqual(var_fn(6, 3, 2, [7, 2]), -1) + assert var_fn(6, 3, 2, [7, 2]) == -1 # Now change the order of input parameters out = model.export_casadi_objects(["a+b"], input_parameter_order=["q", "p"]) @@ -756,18 +749,16 @@ def test_export_casadi(self): var_fn = casadi.Function("var", [t, x, z, p], [var]) # Test that function values are as expected - self.assertEqual(x0_fn([5, 0]), 5) - self.assertEqual(z0_fn([0, 0]), 1) - self.assertEqual(rhs_fn(0, 3, 2, [2, 7]), -21) - self.assertEqual(alg_fn(0, 3, 2, [2, 7]), 1) + assert x0_fn([5, 0]) == 5 + assert z0_fn([0, 0]) == 1 + assert rhs_fn(0, 3, 2, [2, 7]) == -21 + assert alg_fn(0, 3, 2, [2, 7]) == 1 np.testing.assert_array_equal(np.array(jac_rhs_fn(5, 6, 7, [9, 8])), [[-8, 0]]) np.testing.assert_array_equal(np.array(jac_alg_fn(5, 6, 7, [9, 8])), [[1, -1]]) - self.assertEqual(var_fn(6, 3, 2, [2, 7]), -1) + assert var_fn(6, 3, 2, [2, 7]) == -1 # Test fails if order not specified - with self.assertRaisesRegex( - ValueError, "input_parameter_order must be specified" - ): + with pytest.raises(ValueError, match="input_parameter_order must be specified"): model.export_casadi_objects(["a+b"]) # Fine if order is not specified if there is only one input parameter @@ -787,16 +778,16 @@ def test_export_casadi(self): # Test that function values are as expected # a + b - p = 3 + 2 - 7 = -2 - self.assertEqual(var_fn(6, 3, 2, [7]), -2) + assert var_fn(6, 3, 2, [7]) == -2 # Test fails if not discretised model = pybamm.lithium_ion.SPMe() - with self.assertRaisesRegex( - pybamm.DiscretisationError, "Cannot automatically discretise model" + with pytest.raises( + pybamm.DiscretisationError, match="Cannot automatically discretise model" ): model.export_casadi_objects(["Electrolyte concentration [mol.m-3]"]) - @unittest.skipIf(platform.system() == "Windows", "Skipped for Windows") + @pytest.mark.skipif(platform.system() == "Windows", reason="Skipped for Windows") def test_generate_casadi(self): model = pybamm.BaseModel() t = pybamm.t @@ -825,13 +816,13 @@ def test_generate_casadi(self): var_fn = casadi.external("variables", "./test.so") # Test that function values are as expected - self.assertEqual(x0_fn([2, 5]), 5) - self.assertEqual(z0_fn([0, 0]), 1) - self.assertEqual(rhs_fn(0, 3, 2, [7, 2]), -21) - self.assertEqual(alg_fn(0, 3, 2, [7, 2]), 1) + assert x0_fn([2, 5]) == 5 + assert z0_fn([0, 0]) == 1 + assert rhs_fn(0, 3, 2, [7, 2]) == -21 + assert alg_fn(0, 3, 2, [7, 2]) == 1 np.testing.assert_array_equal(np.array(jac_rhs_fn(5, 6, 7, [8, 9])), [[-8, 0]]) np.testing.assert_array_equal(np.array(jac_alg_fn(5, 6, 7, [8, 9])), [[1, -1]]) - self.assertEqual(var_fn(6, 3, 2, [7, 2]), -1) + assert var_fn(6, 3, 2, [7, 2]) == -1 # Remove generated files. os.remove("test.c") @@ -863,10 +854,10 @@ def test_set_initial_conditions(self): } # Test original initial conditions - self.assertEqual(model.initial_conditions[var_scalar].value, 1) - self.assertEqual(model.initial_conditions[var_1D].value, 1) - self.assertEqual(model.initial_conditions[var_2D].value, 1) - self.assertEqual(model.initial_conditions[var_concat].value, 1) + assert model.initial_conditions[var_scalar].value == 1 + assert model.initial_conditions[var_1D].value == 1 + assert model.initial_conditions[var_2D].value == 1 + assert model.initial_conditions[var_concat].value == 1 # Discretise geometry = { @@ -911,22 +902,22 @@ def test_set_initial_conditions(self): # Test new initial conditions (both in place and not) for mdl in [model, new_model]: var_scalar = mdl.variables["var_scalar"] - self.assertIsInstance(mdl.initial_conditions[var_scalar], pybamm.Vector) - self.assertEqual(mdl.initial_conditions[var_scalar].entries, 3) + assert isinstance(mdl.initial_conditions[var_scalar], pybamm.Vector) + assert mdl.initial_conditions[var_scalar].entries == 3 var_1D = mdl.variables["var_1D"] - self.assertIsInstance(mdl.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(mdl.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(mdl.initial_conditions[var_1D], pybamm.Vector) + assert mdl.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal(mdl.initial_conditions[var_1D].entries, 3) var_2D = mdl.variables["var_2D"] - self.assertIsInstance(mdl.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(mdl.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(mdl.initial_conditions[var_2D], pybamm.Vector) + assert mdl.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal(mdl.initial_conditions[var_2D].entries, 3) var_concat = mdl.variables["var_concat"] - self.assertIsInstance(mdl.initial_conditions[var_concat], pybamm.Vector) - self.assertEqual(mdl.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(mdl.initial_conditions[var_concat], pybamm.Vector) + assert mdl.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal(mdl.initial_conditions[var_concat].entries, 3) # Test updating a discretised model (out-of-place) @@ -934,30 +925,26 @@ def test_set_initial_conditions(self): # Test new initial conditions var_scalar = next(iter(new_model_disc.initial_conditions.keys())) - self.assertIsInstance( - new_model_disc.initial_conditions[var_scalar], pybamm.Vector - ) - self.assertEqual(new_model_disc.initial_conditions[var_scalar].entries, 3) + assert isinstance(new_model_disc.initial_conditions[var_scalar], pybamm.Vector) + assert new_model_disc.initial_conditions[var_scalar].entries == 3 var_1D = list(new_model_disc.initial_conditions.keys())[1] - self.assertIsInstance(new_model_disc.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(new_model_disc.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(new_model_disc.initial_conditions[var_1D], pybamm.Vector) + assert new_model_disc.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_1D].entries, 3 ) var_2D = list(new_model_disc.initial_conditions.keys())[2] - self.assertIsInstance(new_model_disc.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(new_model_disc.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(new_model_disc.initial_conditions[var_2D], pybamm.Vector) + assert new_model_disc.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_2D].entries, 3 ) var_concat = list(new_model_disc.initial_conditions.keys())[3] - self.assertIsInstance( - new_model_disc.initial_conditions[var_concat], pybamm.Vector - ) - self.assertEqual(new_model_disc.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(new_model_disc.initial_conditions[var_concat], pybamm.Vector) + assert new_model_disc.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_concat].entries, 3 ) @@ -1008,22 +995,22 @@ def test_set_initial_conditions(self): # Test new initial conditions (both in place and not) var_scalar = new_model.variables["var_scalar"] - self.assertIsInstance(new_model.initial_conditions[var_scalar], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_scalar].entries, 3) + assert isinstance(new_model.initial_conditions[var_scalar], pybamm.Vector) + assert new_model.initial_conditions[var_scalar].entries == 3 var_1D = new_model.variables["var_1D"] - self.assertIsInstance(new_model.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(new_model.initial_conditions[var_1D], pybamm.Vector) + assert new_model.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal(new_model.initial_conditions[var_1D].entries, 3) var_2D = new_model.variables["var_2D"] - self.assertIsInstance(new_model.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(new_model.initial_conditions[var_2D], pybamm.Vector) + assert new_model.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal(new_model.initial_conditions[var_2D].entries, 3) var_concat = new_model.variables["var_concat"] - self.assertIsInstance(new_model.initial_conditions[var_concat], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(new_model.initial_conditions[var_concat], pybamm.Vector) + assert new_model.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal( new_model.initial_conditions[var_concat].entries, 3 ) @@ -1040,22 +1027,22 @@ def test_set_initial_conditions(self): # Test new initial conditions (both in place and not) var_scalar = new_model.variables["var_scalar"] - self.assertIsInstance(new_model.initial_conditions[var_scalar], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_scalar].entries, 5) + assert isinstance(new_model.initial_conditions[var_scalar], pybamm.Vector) + assert new_model.initial_conditions[var_scalar].entries == 5 var_1D = new_model.variables["var_1D"] - self.assertIsInstance(new_model.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(new_model.initial_conditions[var_1D], pybamm.Vector) + assert new_model.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal(new_model.initial_conditions[var_1D].entries, 5) var_2D = new_model.variables["var_2D"] - self.assertIsInstance(new_model.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(new_model.initial_conditions[var_2D], pybamm.Vector) + assert new_model.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal(new_model.initial_conditions[var_2D].entries, 5) var_concat = new_model.variables["var_concat"] - self.assertIsInstance(new_model.initial_conditions[var_concat], pybamm.Vector) - self.assertEqual(new_model.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(new_model.initial_conditions[var_concat], pybamm.Vector) + assert new_model.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal( new_model.initial_conditions[var_concat].entries, 5 ) @@ -1066,30 +1053,26 @@ def test_set_initial_conditions(self): # Test new initial conditions var_scalar = next(iter(new_model_disc.initial_conditions.keys())) - self.assertIsInstance( - new_model_disc.initial_conditions[var_scalar], pybamm.Vector - ) - self.assertEqual(new_model_disc.initial_conditions[var_scalar].entries, 5) + assert isinstance(new_model_disc.initial_conditions[var_scalar], pybamm.Vector) + assert new_model_disc.initial_conditions[var_scalar].entries == 5 var_1D = list(new_model_disc.initial_conditions.keys())[1] - self.assertIsInstance(new_model_disc.initial_conditions[var_1D], pybamm.Vector) - self.assertEqual(new_model_disc.initial_conditions[var_1D].shape, (10, 1)) + assert isinstance(new_model_disc.initial_conditions[var_1D], pybamm.Vector) + assert new_model_disc.initial_conditions[var_1D].shape == (10, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_1D].entries, 5 ) var_2D = list(new_model_disc.initial_conditions.keys())[2] - self.assertIsInstance(new_model_disc.initial_conditions[var_2D], pybamm.Vector) - self.assertEqual(new_model_disc.initial_conditions[var_2D].shape, (50, 1)) + assert isinstance(new_model_disc.initial_conditions[var_2D], pybamm.Vector) + assert new_model_disc.initial_conditions[var_2D].shape == (50, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_2D].entries, 5 ) var_concat = list(new_model_disc.initial_conditions.keys())[3] - self.assertIsInstance( - new_model_disc.initial_conditions[var_concat], pybamm.Vector - ) - self.assertEqual(new_model_disc.initial_conditions[var_concat].shape, (20, 1)) + assert isinstance(new_model_disc.initial_conditions[var_concat], pybamm.Vector) + assert new_model_disc.initial_conditions[var_concat].shape == (20, 1) np.testing.assert_array_equal( new_model_disc.initial_conditions[var_concat].entries, 5 ) @@ -1103,7 +1086,7 @@ def test_set_initial_condition_errors(self): var = pybamm.Scalar(1) model.rhs = {var: -var} model.initial_conditions = {var: 1} - with self.assertRaisesRegex(NotImplementedError, "Variable must have type"): + with pytest.raises(NotImplementedError, match="Variable must have type"): model.set_initial_conditions_from({}) var = pybamm.Variable( @@ -1116,9 +1099,7 @@ def test_set_initial_condition_errors(self): ) model.rhs = {var: -var} model.initial_conditions = {var: 1} - with self.assertRaisesRegex( - NotImplementedError, "Variable must be 0D, 1D, or 2D" - ): + with pytest.raises(NotImplementedError, match="Variable must be 0D, 1D, or 2D"): model.set_initial_conditions_from({"var": np.ones((5, 6, 7, 8))}) var_concat_neg = pybamm.Variable("var concat neg", domain="negative electrode") @@ -1126,8 +1107,8 @@ def test_set_initial_condition_errors(self): var_concat = pybamm.concatenation(var_concat_neg, var_concat_sep) model.algebraic = {var_concat: -var_concat} model.initial_conditions = {var_concat: 1} - with self.assertRaisesRegex( - NotImplementedError, "Variable in concatenation must be 1D" + with pytest.raises( + NotImplementedError, match="Variable in concatenation must be 1D" ): model.set_initial_conditions_from({"var concat neg": np.ones((5, 6, 7))}) @@ -1136,20 +1117,20 @@ def test_set_initial_condition_errors(self): var = pybamm.Variable("var") model.rhs = {var: -var} model.initial_conditions = {var: pybamm.Scalar(1)} - with self.assertRaisesRegex(pybamm.ModelError, "must appear in the solution"): + with pytest.raises(pybamm.ModelError, match="must appear in the solution"): model.set_initial_conditions_from({"wrong var": 2}) var = pybamm.concatenation( pybamm.Variable("var", "test"), pybamm.Variable("var2", "test2") ) model.rhs = {var: -var} model.initial_conditions = {var: pybamm.Scalar(1)} - with self.assertRaisesRegex(pybamm.ModelError, "must appear in the solution"): + with pytest.raises(pybamm.ModelError, match="must appear in the solution"): model.set_initial_conditions_from({"wrong var": 2}) def test_set_variables_error(self): var = pybamm.Variable("var") model = pybamm.BaseModel() - with self.assertRaisesRegex(ValueError, "not var"): + with pytest.raises(ValueError, match="not var"): model.variables = {"not var": var} def test_build_submodels(self): @@ -1178,7 +1159,7 @@ def set_initial_conditions(self, variables): v = variables["v"] self.initial_conditions = {u: 0, v: 0} - def set_events(self, variables): + def add_events_from(self, variables): u = variables["u"] self.events.append( pybamm.Event( @@ -1202,23 +1183,23 @@ def get_coupled_variables(self, variables): "submodel 1": Submodel1(None, "negative"), "submodel 2": Submodel2(None, "negative"), } - self.assertFalse(model._built) + assert not model._built model.build_model() - self.assertTrue(model._built) + assert model._built u = model.variables["u"] v = model.variables["v"] - self.assertEqual(model.rhs[u].value, 2) - self.assertEqual(model.algebraic[v], -1.0 + v) + assert model.rhs[u].value == 2 + assert model.algebraic[v] == -1.0 + v def test_timescale_lengthscale_get_set_not_implemented(self): model = pybamm.BaseModel() - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): model.timescale - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): model.length_scales - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): model.timescale = 1 - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): model.length_scales = 1 def test_save_load_model(self): @@ -1284,7 +1265,7 @@ def test_save_load_model(self): testing.assert_array_equal(solution.all_ys, new_solution.all_ys) # raises warning if variables are saved without mesh - with self.assertWarns(pybamm.ModelWarning): + with pytest.warns(pybamm.ModelWarning): model_disc.save_model( filename="test_base_model", variables=model_disc.variables ) @@ -1297,13 +1278,3 @@ def test_save_load_model(self): new_model = pybamm.load_model("test_base_model.json") os.remove("test_base_model.json") - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_models/test_full_battery_models/test_base_battery_model.py b/tests/unit/test_models/test_full_battery_models/test_base_battery_model.py index 033dcf5345..7dcfccdb66 100644 --- a/tests/unit/test_models/test_full_battery_models/test_base_battery_model.py +++ b/tests/unit/test_models/test_full_battery_models/test_base_battery_model.py @@ -2,9 +2,9 @@ # Tests for the base battery model class # +import pytest from pybamm.models.full_battery_models.base_battery_model import BatteryModelOptions import pybamm -import unittest import io from contextlib import redirect_stdout import os @@ -51,12 +51,13 @@ 'thermal': 'x-full' (possible: ['isothermal', 'lumped', 'x-lumped', 'x-full']) 'total interfacial current density as a state': 'false' (possible: ['false', 'true']) 'transport efficiency': 'Bruggeman' (possible: ['Bruggeman', 'ordered packing', 'hyperbola of revolution', 'overlapping spheres', 'tortuosity factor', 'random overlapping cylinders', 'heterogeneous catalyst', 'cation-exchange membrane']) +'voltage as a state': 'false' (possible: ['false', 'true']) 'working electrode': 'both' (possible: ['both', 'positive']) 'x-average side reactions': 'false' (possible: ['false', 'true']) """ -class TestBaseBatteryModel(unittest.TestCase): +class TestBaseBatteryModel: def test_process_parameters_and_discretise(self): model = pybamm.lithium_ion.SPM() # Set up geometry and parameters @@ -72,9 +73,9 @@ def test_process_parameters_and_discretise(self): * model.variables["X-averaged negative particle concentration [mol.m-3]"] ) processed_c = model.process_parameters_and_discretise(c, parameter_values, disc) - self.assertIsInstance(processed_c, pybamm.Multiplication) - self.assertIsInstance(processed_c.left, pybamm.Scalar) - self.assertIsInstance(processed_c.right, pybamm.StateVector) + assert isinstance(processed_c, pybamm.Multiplication) + assert isinstance(processed_c.left, pybamm.Scalar) + assert isinstance(processed_c.right, pybamm.StateVector) # Process flux manually and check result against flux computed in particle # submodel c_n = model.variables["X-averaged negative particle concentration [mol.m-3]"] @@ -89,47 +90,39 @@ def test_process_parameters_and_discretise(self): flux_2 = model.variables["X-averaged negative particle flux [mol.m-2.s-1]"] param_flux_2 = parameter_values.process_symbol(flux_2) disc_flux_2 = disc.process_symbol(param_flux_2) - self.assertEqual(flux_1, disc_flux_2) + assert flux_1 == disc_flux_2 def test_summary_variables(self): model = pybamm.BaseBatteryModel() model.variables["var"] = pybamm.Scalar(1) model.summary_variables = ["var"] - self.assertEqual(model.summary_variables, ["var"]) - with self.assertRaisesRegex(KeyError, "No cycling variable defined"): + assert model.summary_variables == ["var"] + with pytest.raises(KeyError, match="No cycling variable defined"): model.summary_variables = ["bad var"] def test_default_geometry(self): model = pybamm.BaseBatteryModel({"dimensionality": 0}) - self.assertEqual( - model.default_geometry["current collector"]["z"]["position"], 1 - ) + assert model.default_geometry["current collector"]["z"]["position"] == 1 model = pybamm.BaseBatteryModel({"dimensionality": 1}) - self.assertEqual(model.default_geometry["current collector"]["z"]["min"], 0) + assert model.default_geometry["current collector"]["z"]["min"] == 0 model = pybamm.BaseBatteryModel({"dimensionality": 2}) - self.assertEqual(model.default_geometry["current collector"]["y"]["min"], 0) + assert model.default_geometry["current collector"]["y"]["min"] == 0 def test_default_submesh_types(self): model = pybamm.BaseBatteryModel({"dimensionality": 0}) - self.assertTrue( - issubclass( - model.default_submesh_types["current collector"], - pybamm.SubMesh0D, - ) + assert issubclass( + model.default_submesh_types["current collector"], + pybamm.SubMesh0D, ) model = pybamm.BaseBatteryModel({"dimensionality": 1}) - self.assertTrue( - issubclass( - model.default_submesh_types["current collector"], - pybamm.Uniform1DSubMesh, - ) + assert issubclass( + model.default_submesh_types["current collector"], + pybamm.Uniform1DSubMesh, ) model = pybamm.BaseBatteryModel({"dimensionality": 2}) - self.assertTrue( - issubclass( - model.default_submesh_types["current collector"], - pybamm.ScikitUniform2DSubMesh, - ) + assert issubclass( + model.default_submesh_types["current collector"], + pybamm.ScikitUniform2DSubMesh, ) def test_default_var_pts(self): @@ -149,44 +142,44 @@ def test_default_var_pts(self): "R_p": 30, } model = pybamm.BaseBatteryModel({"dimensionality": 0}) - self.assertDictEqual(var_pts, model.default_var_pts) + assert var_pts == model.default_var_pts var_pts.update({"x_n": 10, "x_s": 10, "x_p": 10}) model = pybamm.BaseBatteryModel({"dimensionality": 2}) - self.assertDictEqual(var_pts, model.default_var_pts) + assert var_pts == model.default_var_pts def test_default_spatial_methods(self): model = pybamm.BaseBatteryModel({"dimensionality": 0}) - self.assertIsInstance( + assert isinstance( model.default_spatial_methods["current collector"], pybamm.ZeroDimensionalSpatialMethod, ) model = pybamm.BaseBatteryModel({"dimensionality": 1}) - self.assertIsInstance( + assert isinstance( model.default_spatial_methods["current collector"], pybamm.FiniteVolume ) model = pybamm.BaseBatteryModel({"dimensionality": 2}) - self.assertIsInstance( + assert isinstance( model.default_spatial_methods["current collector"], pybamm.ScikitFiniteElement, ) def test_options(self): - with self.assertRaisesRegex(pybamm.OptionError, "Option"): + with pytest.raises(pybamm.OptionError, match="Option"): pybamm.BaseBatteryModel({"bad option": "bad option"}) - with self.assertRaisesRegex(pybamm.OptionError, "current collector model"): + with pytest.raises(pybamm.OptionError, match="current collector model"): pybamm.BaseBatteryModel({"current collector": "bad current collector"}) - with self.assertRaisesRegex(pybamm.OptionError, "thermal"): + with pytest.raises(pybamm.OptionError, match="thermal"): pybamm.BaseBatteryModel({"thermal": "bad thermal"}) - with self.assertRaisesRegex(pybamm.OptionError, "cell geometry"): + with pytest.raises(pybamm.OptionError, match="cell geometry"): pybamm.BaseBatteryModel({"cell geometry": "bad geometry"}) - with self.assertRaisesRegex(pybamm.OptionError, "dimensionality"): + with pytest.raises(pybamm.OptionError, match="dimensionality"): pybamm.BaseBatteryModel({"dimensionality": 5}) - with self.assertRaisesRegex(pybamm.OptionError, "current collector"): + with pytest.raises(pybamm.OptionError, match="current collector"): pybamm.BaseBatteryModel( {"dimensionality": 1, "current collector": "bad option"} ) - with self.assertRaisesRegex(pybamm.OptionError, "1D current collectors"): + with pytest.raises(pybamm.OptionError, match="1D current collectors"): pybamm.BaseBatteryModel( { "current collector": "potential pair", @@ -194,7 +187,7 @@ def test_options(self): "thermal": "x-full", } ) - with self.assertRaisesRegex(pybamm.OptionError, "2D current collectors"): + with pytest.raises(pybamm.OptionError, match="2D current collectors"): pybamm.BaseBatteryModel( { "current collector": "potential pair", @@ -202,58 +195,54 @@ def test_options(self): "thermal": "x-full", } ) - with self.assertRaisesRegex(pybamm.OptionError, "surface form"): + with pytest.raises(pybamm.OptionError, match="surface form"): pybamm.BaseBatteryModel({"surface form": "bad surface form"}) - with self.assertRaisesRegex(pybamm.OptionError, "convection"): + with pytest.raises(pybamm.OptionError, match="convection"): pybamm.BaseBatteryModel({"convection": "bad convection"}) - with self.assertRaisesRegex( - pybamm.OptionError, "cannot have transverse convection in 0D model" + with pytest.raises( + pybamm.OptionError, match="cannot have transverse convection in 0D model" ): pybamm.BaseBatteryModel({"convection": "full transverse"}) - with self.assertRaisesRegex(pybamm.OptionError, "particle"): + with pytest.raises(pybamm.OptionError, match="particle"): pybamm.BaseBatteryModel({"particle": "bad particle"}) - with self.assertRaisesRegex(pybamm.OptionError, "working electrode"): + with pytest.raises(pybamm.OptionError, match="working electrode"): pybamm.BaseBatteryModel({"working electrode": "bad working electrode"}) - with self.assertRaisesRegex(pybamm.OptionError, "The 'negative' working"): + with pytest.raises(pybamm.OptionError, match="The 'negative' working"): pybamm.BaseBatteryModel({"working electrode": "negative"}) - with self.assertRaisesRegex(pybamm.OptionError, "particle shape"): + with pytest.raises(pybamm.OptionError, match="particle shape"): pybamm.BaseBatteryModel({"particle shape": "bad particle shape"}) - with self.assertRaisesRegex(pybamm.OptionError, "operating mode"): + with pytest.raises(pybamm.OptionError, match="operating mode"): pybamm.BaseBatteryModel({"operating mode": "bad operating mode"}) - with self.assertRaisesRegex(pybamm.OptionError, "electrolyte conductivity"): + with pytest.raises(pybamm.OptionError, match="electrolyte conductivity"): pybamm.BaseBatteryModel( {"electrolyte conductivity": "bad electrolyte conductivity"} ) # SEI options - with self.assertRaisesRegex(pybamm.OptionError, "SEI"): + with pytest.raises(pybamm.OptionError, match="SEI"): pybamm.BaseBatteryModel({"SEI": "bad sei"}) - with self.assertRaisesRegex(pybamm.OptionError, "SEI film resistance"): + with pytest.raises(pybamm.OptionError, match="SEI film resistance"): pybamm.BaseBatteryModel({"SEI film resistance": "bad SEI film resistance"}) - with self.assertRaisesRegex(pybamm.OptionError, "SEI porosity change"): + with pytest.raises(pybamm.OptionError, match="SEI porosity change"): pybamm.BaseBatteryModel({"SEI porosity change": "bad SEI porosity change"}) # changing defaults based on other options model = pybamm.BaseBatteryModel() - self.assertEqual(model.options["SEI film resistance"], "none") + assert model.options["SEI film resistance"] == "none" model = pybamm.BaseBatteryModel({"SEI": "constant"}) - self.assertEqual(model.options["SEI film resistance"], "distributed") - self.assertEqual( - model.options["total interfacial current density as a state"], "true" - ) + assert model.options["SEI film resistance"] == "distributed" + assert model.options["total interfacial current density as a state"] == "true" model = pybamm.BaseBatteryModel( {"SEI film resistance": "average", "particle phases": "2"} ) - self.assertEqual( - model.options["total interfacial current density as a state"], "true" - ) - with self.assertRaisesRegex(pybamm.OptionError, "must be 'true'"): + assert model.options["total interfacial current density as a state"] == "true" + with pytest.raises(pybamm.OptionError, match="must be 'true'"): pybamm.BaseBatteryModel( { "SEI film resistance": "distributed", "total interfacial current density as a state": "false", } ) - with self.assertRaisesRegex(pybamm.OptionError, "must be 'true'"): + with pytest.raises(pybamm.OptionError, match="must be 'true'"): pybamm.BaseBatteryModel( { "SEI film resistance": "average", @@ -263,9 +252,9 @@ def test_options(self): ) # loss of active material model - with self.assertRaisesRegex(pybamm.OptionError, "loss of active material"): + with pytest.raises(pybamm.OptionError, match="loss of active material"): pybamm.BaseBatteryModel({"loss of active material": "bad LAM model"}) - with self.assertRaisesRegex(pybamm.OptionError, "loss of active material"): + with pytest.raises(pybamm.OptionError, match="loss of active material"): # can't have a 3-tuple pybamm.BaseBatteryModel( { @@ -281,11 +270,11 @@ def test_options(self): model = pybamm.BaseBatteryModel( {"loss of active material": "stress-driven", "SEI on cracks": "true"} ) - self.assertEqual( - model.options["particle mechanics"], - ("swelling and cracking", "swelling only"), + assert model.options["particle mechanics"] == ( + "swelling and cracking", + "swelling only", ) - self.assertEqual(model.options["stress-induced diffusion"], "true") + assert model.options["stress-induced diffusion"] == "true" model = pybamm.BaseBatteryModel( { "working electrode": "positive", @@ -293,29 +282,27 @@ def test_options(self): "SEI on cracks": "true", } ) - self.assertEqual(model.options["particle mechanics"], "swelling and cracking") - self.assertEqual(model.options["stress-induced diffusion"], "true") + assert model.options["particle mechanics"] == "swelling and cracking" + assert model.options["stress-induced diffusion"] == "true" # crack model - with self.assertRaisesRegex(pybamm.OptionError, "particle mechanics"): + with pytest.raises(pybamm.OptionError, match="particle mechanics"): pybamm.BaseBatteryModel({"particle mechanics": "bad particle cracking"}) - with self.assertRaisesRegex(pybamm.OptionError, "particle cracking"): + with pytest.raises(pybamm.OptionError, match="particle cracking"): pybamm.BaseBatteryModel({"particle cracking": "bad particle cracking"}) # SEI on cracks - with self.assertRaisesRegex(pybamm.OptionError, "SEI on cracks"): + with pytest.raises(pybamm.OptionError, match="SEI on cracks"): pybamm.BaseBatteryModel({"SEI on cracks": "bad SEI on cracks"}) - with self.assertRaisesRegex(pybamm.OptionError, "'SEI on cracks' is 'true'"): + with pytest.raises(pybamm.OptionError, match="'SEI on cracks' is 'true'"): pybamm.BaseBatteryModel( {"SEI on cracks": "true", "particle mechanics": "swelling only"} ) # plating model - with self.assertRaisesRegex(pybamm.OptionError, "lithium plating"): + with pytest.raises(pybamm.OptionError, match="lithium plating"): pybamm.BaseBatteryModel({"lithium plating": "bad plating"}) - with self.assertRaisesRegex( - pybamm.OptionError, "lithium plating porosity change" - ): + with pytest.raises(pybamm.OptionError, match="lithium plating porosity change"): pybamm.BaseBatteryModel( { "lithium plating porosity change": "bad lithium " @@ -324,16 +311,16 @@ def test_options(self): ) # contact resistance - with self.assertRaisesRegex(pybamm.OptionError, "contact resistance"): + with pytest.raises(pybamm.OptionError, match="contact resistance"): pybamm.BaseBatteryModel({"contact resistance": "bad contact resistance"}) - with self.assertRaisesRegex(NotImplementedError, "Contact resistance not yet"): + with pytest.raises(NotImplementedError, match="Contact resistance not yet"): pybamm.BaseBatteryModel( { "contact resistance": "true", "operating mode": "explicit power", } ) - with self.assertRaisesRegex(NotImplementedError, "Contact resistance not yet"): + with pytest.raises(NotImplementedError, match="Contact resistance not yet"): pybamm.BaseBatteryModel( { "contact resistance": "true", @@ -342,29 +329,29 @@ def test_options(self): ) # stress-induced diffusion - with self.assertRaisesRegex(pybamm.OptionError, "cannot have stress"): + with pytest.raises(pybamm.OptionError, match="cannot have stress"): pybamm.BaseBatteryModel({"stress-induced diffusion": "true"}) # hydrolysis - with self.assertRaisesRegex(pybamm.OptionError, "surface formulation"): + with pytest.raises(pybamm.OptionError, match="surface formulation"): pybamm.lead_acid.LOQS({"hydrolysis": "true", "surface form": "false"}) # timescale - with self.assertRaisesRegex(pybamm.OptionError, "timescale"): + with pytest.raises(pybamm.OptionError, match="timescale"): pybamm.BaseBatteryModel({"timescale": "bad timescale"}) # thermal x-lumped - with self.assertRaisesRegex(pybamm.OptionError, "x-lumped"): + with pytest.raises(pybamm.OptionError, match="x-lumped"): pybamm.lithium_ion.BaseModel( {"cell geometry": "arbitrary", "thermal": "x-lumped"} ) # thermal half-cell - with self.assertRaisesRegex(pybamm.OptionError, "X-full"): + with pytest.raises(pybamm.OptionError, match="X-full"): pybamm.BaseBatteryModel( {"thermal": "x-full", "working electrode": "positive"} ) - with self.assertRaisesRegex(pybamm.OptionError, "X-lumped"): + with pytest.raises(pybamm.OptionError, match="X-lumped"): pybamm.BaseBatteryModel( { "dimensionality": 2, @@ -374,7 +361,7 @@ def test_options(self): ) # thermal heat of mixing - with self.assertRaisesRegex(NotImplementedError, "Heat of mixing"): + with pytest.raises(NotImplementedError, match="Heat of mixing"): pybamm.BaseBatteryModel( { "heat of mixing": "true", @@ -383,35 +370,35 @@ def test_options(self): ) # surface thermal model - with self.assertRaisesRegex(pybamm.OptionError, "surface temperature"): + with pytest.raises(pybamm.OptionError, match="surface temperature"): pybamm.BaseBatteryModel( {"surface temperature": "lumped", "thermal": "x-full"} ) # phases - with self.assertRaisesRegex(pybamm.OptionError, "multiple particle phases"): + with pytest.raises(pybamm.OptionError, match="multiple particle phases"): pybamm.BaseBatteryModel({"particle phases": "2", "surface form": "false"}) # msmr - with self.assertRaisesRegex(pybamm.OptionError, "MSMR"): + with pytest.raises(pybamm.OptionError, match="MSMR"): pybamm.BaseBatteryModel({"open-circuit potential": "MSMR"}) - with self.assertRaisesRegex(pybamm.OptionError, "MSMR"): + with pytest.raises(pybamm.OptionError, match="MSMR"): pybamm.BaseBatteryModel({"particle": "MSMR"}) - with self.assertRaisesRegex(pybamm.OptionError, "MSMR"): + with pytest.raises(pybamm.OptionError, match="MSMR"): pybamm.BaseBatteryModel({"intercalation kinetics": "MSMR"}) - with self.assertRaisesRegex(pybamm.OptionError, "MSMR"): + with pytest.raises(pybamm.OptionError, match="MSMR"): pybamm.BaseBatteryModel( {"open-circuit potential": "MSMR", "particle": "MSMR"} ) - with self.assertRaisesRegex(pybamm.OptionError, "MSMR"): + with pytest.raises(pybamm.OptionError, match="MSMR"): pybamm.BaseBatteryModel( {"open-circuit potential": "MSMR", "intercalation kinetics": "MSMR"} ) - with self.assertRaisesRegex(pybamm.OptionError, "MSMR"): + with pytest.raises(pybamm.OptionError, match="MSMR"): pybamm.BaseBatteryModel( {"particle": "MSMR", "intercalation kinetics": "MSMR"} ) - with self.assertRaisesRegex(pybamm.OptionError, "MSMR"): + with pytest.raises(pybamm.OptionError, match="MSMR"): pybamm.BaseBatteryModel( { "open-circuit potential": "MSMR", @@ -423,7 +410,7 @@ def test_options(self): def test_build_twice(self): model = pybamm.lithium_ion.SPM() # need to pick a model to set vars and build - with self.assertRaisesRegex(pybamm.ModelError, "Model already built"): + with pytest.raises(pybamm.ModelError, match="Model already built"): model.build_model() def test_get_coupled_variables(self): @@ -431,37 +418,37 @@ def test_get_coupled_variables(self): model.submodels["current collector"] = pybamm.current_collector.Uniform( model.param ) - with self.assertRaisesRegex(pybamm.ModelError, "Missing variable"): + with pytest.raises(pybamm.ModelError, match="Missing variable"): model.build_model() def test_default_solver(self): model = pybamm.BaseBatteryModel() - self.assertIsInstance(model.default_solver, pybamm.CasadiSolver) + assert isinstance(model.default_solver, pybamm.CasadiSolver) # check that default_solver gives you a new solver, not an internal object solver = model.default_solver solver = pybamm.BaseModel() - self.assertIsInstance(model.default_solver, pybamm.CasadiSolver) - self.assertIsInstance(solver, pybamm.BaseModel) + assert isinstance(model.default_solver, pybamm.CasadiSolver) + assert isinstance(solver, pybamm.BaseModel) # check that adding algebraic variables gives algebraic solver a = pybamm.Variable("a") model.algebraic = {a: a - 1} - self.assertIsInstance(model.default_solver, pybamm.CasadiAlgebraicSolver) + assert isinstance(model.default_solver, pybamm.CasadiAlgebraicSolver) def test_option_type(self): # no entry gets default options model = pybamm.BaseBatteryModel() - self.assertIsInstance(model.options, pybamm.BatteryModelOptions) + assert isinstance(model.options, pybamm.BatteryModelOptions) # dict options get converted to BatteryModelOptions model = pybamm.BaseBatteryModel({"thermal": "isothermal"}) - self.assertIsInstance(model.options, pybamm.BatteryModelOptions) + assert isinstance(model.options, pybamm.BatteryModelOptions) # special dict types are not changed options = pybamm.FuzzyDict({"thermal": "isothermal"}) model = pybamm.BaseBatteryModel(options) - self.assertEqual(model.options, options) + assert model.options == options def test_save_load_model(self): model = pybamm.lithium_ion.SPM() @@ -479,79 +466,75 @@ def test_save_load_model(self): ) # raises error if variables are saved without mesh - with self.assertRaises(ValueError): + with pytest.raises(ValueError): model.save_model( filename="test_base_battery_model", variables=model.variables ) os.remove("test_base_battery_model.json") + def test_voltage_as_state(self): + model = pybamm.lithium_ion.SPM({"voltage as a state": "true"}) + assert model.options["voltage as a state"] == "true" + assert isinstance(model.variables["Voltage [V]"], pybamm.Variable) + + model = pybamm.lithium_ion.SPM( + {"voltage as a state": "true", "operating mode": "voltage"} + ) + assert model.options["voltage as a state"] == "true" + assert isinstance(model.variables["Voltage [V]"], pybamm.Variable) + -class TestOptions(unittest.TestCase): +class TestOptions: def test_print_options(self): with io.StringIO() as buffer, redirect_stdout(buffer): BatteryModelOptions(OPTIONS_DICT).print_options() output = buffer.getvalue() - self.assertEqual(output, PRINT_OPTIONS_OUTPUT) + assert output == PRINT_OPTIONS_OUTPUT def test_option_phases(self): options = BatteryModelOptions({}) - self.assertEqual( - options.phases, {"negative": ["primary"], "positive": ["primary"]} - ) + assert options.phases == {"negative": ["primary"], "positive": ["primary"]} options = BatteryModelOptions({"particle phases": ("1", "2")}) - self.assertEqual( - options.phases, - {"negative": ["primary"], "positive": ["primary", "secondary"]}, - ) + assert options.phases == { + "negative": ["primary"], + "positive": ["primary", "secondary"], + } def test_domain_options(self): options = BatteryModelOptions( {"particle": ("Fickian diffusion", "quadratic profile")} ) - self.assertEqual(options.negative["particle"], "Fickian diffusion") - self.assertEqual(options.positive["particle"], "quadratic profile") + assert options.negative["particle"] == "Fickian diffusion" + assert options.positive["particle"] == "quadratic profile" # something that is the same in both domains - self.assertEqual(options.negative["thermal"], "isothermal") - self.assertEqual(options.positive["thermal"], "isothermal") + assert options.negative["thermal"] == "isothermal" + assert options.positive["thermal"] == "isothermal" def test_domain_phase_options(self): options = BatteryModelOptions( {"particle mechanics": (("swelling only", "swelling and cracking"), "none")} ) - self.assertEqual( - options.negative["particle mechanics"], - ("swelling only", "swelling and cracking"), + assert options.negative["particle mechanics"] == ( + "swelling only", + "swelling and cracking", ) - self.assertEqual( - options.negative.primary["particle mechanics"], "swelling only" + assert options.negative.primary["particle mechanics"] == "swelling only" + assert ( + options.negative.secondary["particle mechanics"] == "swelling and cracking" ) - self.assertEqual( - options.negative.secondary["particle mechanics"], "swelling and cracking" - ) - self.assertEqual(options.positive["particle mechanics"], "none") - self.assertEqual(options.positive.primary["particle mechanics"], "none") - self.assertEqual(options.positive.secondary["particle mechanics"], "none") + assert options.positive["particle mechanics"] == "none" + assert options.positive.primary["particle mechanics"] == "none" + assert options.positive.secondary["particle mechanics"] == "none" def test_whole_cell_domains(self): options = BatteryModelOptions({"working electrode": "positive"}) - self.assertEqual( - options.whole_cell_domains, ["separator", "positive electrode"] - ) + assert options.whole_cell_domains == ["separator", "positive electrode"] options = BatteryModelOptions({}) - self.assertEqual( - options.whole_cell_domains, - ["negative electrode", "separator", "positive electrode"], - ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert options.whole_cell_domains == [ + "negative electrode", + "separator", + "positive electrode", + ] diff --git a/tests/unit/test_models/test_full_battery_models/test_equivalent_circuit/test_thevenin.py b/tests/unit/test_models/test_full_battery_models/test_equivalent_circuit/test_thevenin.py index 39bfaf1145..4f54db7035 100644 --- a/tests/unit/test_models/test_full_battery_models/test_equivalent_circuit/test_thevenin.py +++ b/tests/unit/test_models/test_full_battery_models/test_equivalent_circuit/test_thevenin.py @@ -3,10 +3,10 @@ # import pybamm -import unittest +import pytest -class TestThevenin(unittest.TestCase): +class TestThevenin: def test_standard_model(self): model = pybamm.equivalent_circuit.Thevenin() model.check_well_posedness() @@ -16,22 +16,18 @@ def test_default_properties(self): x = model.variables["x ECMD"] # test var_pts - self.assertEqual(model.default_var_pts, {x: 20}) + assert model.default_var_pts == {x: 20} # test geometry - self.assertEqual( - model.default_geometry, {"ECMD particle": {x: {"min": 0, "max": 1}}} - ) + assert model.default_geometry == {"ECMD particle": {x: {"min": 0, "max": 1}}} # test spatial methods - self.assertIsInstance( + assert isinstance( model.default_spatial_methods["ECMD particle"], pybamm.FiniteVolume ) # test submesh types - self.assertEqual( - model.default_submesh_types, {"ECMD particle": pybamm.Uniform1DSubMesh} - ) + assert model.default_submesh_types == {"ECMD particle": pybamm.Uniform1DSubMesh} def test_changing_number_of_rcs(self): options = {"number of rc elements": 0} @@ -50,7 +46,7 @@ def test_changing_number_of_rcs(self): model = pybamm.equivalent_circuit.Thevenin(options=options) model.check_well_posedness() - with self.assertRaisesRegex(pybamm.OptionError, "natural numbers"): + with pytest.raises(pybamm.OptionError, match="natural numbers"): options = {"number of rc elements": -1} model = pybamm.equivalent_circuit.Thevenin(options=options) model.check_well_posedness() @@ -114,35 +110,25 @@ def external_circuit_function(variables): def test_raise_option_error(self): options = {"not an option": "something"} - with self.assertRaisesRegex( - pybamm.OptionError, "Option 'not an option' not recognised" + with pytest.raises( + pybamm.OptionError, match="Option 'not an option' not recognised" ): pybamm.equivalent_circuit.Thevenin(options=options) def test_not_a_valid_option(self): options = {"operating mode": "not a valid option"} - with self.assertRaisesRegex( - pybamm.OptionError, "Option 'operating mode' must be one of" + with pytest.raises( + pybamm.OptionError, match="Option 'operating mode' must be one of" ): pybamm.equivalent_circuit.Thevenin(options=options) def test_get_default_parameters(self): model = pybamm.equivalent_circuit.Thevenin() values = model.default_parameter_values - self.assertIn("Initial SoC", list(values.keys())) + assert "Initial SoC" in list(values.keys()) values.process_model(model) def test_get_default_quick_plot_variables(self): model = pybamm.equivalent_circuit.Thevenin() variables = model.default_quick_plot_variables - self.assertIn("Current [A]", variables) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert "Current [A]" in variables diff --git a/tests/unit/test_models/test_full_battery_models/test_lead_acid/test_loqs.py b/tests/unit/test_models/test_full_battery_models/test_lead_acid/test_loqs.py index d68686936c..e1e3dce5f5 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lead_acid/test_loqs.py +++ b/tests/unit/test_models/test_full_battery_models/test_lead_acid/test_loqs.py @@ -3,10 +3,9 @@ # import pybamm -import unittest -class TestLeadAcidLOQS(unittest.TestCase): +class TestLeadAcidLOQS: def test_well_posed(self): options = {"thermal": "isothermal"} model = pybamm.lead_acid.LOQS(options) @@ -20,17 +19,15 @@ def test_well_posed(self): def test_default_geometry(self): options = {"thermal": "isothermal"} model = pybamm.lead_acid.LOQS(options) - self.assertNotIn("negative particle", model.default_geometry) - self.assertIsInstance(model.default_spatial_methods, dict) - self.assertIsInstance( + assert "negative particle" not in model.default_geometry + assert isinstance(model.default_spatial_methods, dict) + assert isinstance( model.default_spatial_methods["current collector"], pybamm.ZeroDimensionalSpatialMethod, ) - self.assertTrue( - issubclass( - model.default_submesh_types["current collector"], - pybamm.SubMesh0D, - ) + assert issubclass( + model.default_submesh_types["current collector"], + pybamm.SubMesh0D, ) def test_well_posed_with_convection(self): @@ -42,7 +39,7 @@ def test_well_posed_with_convection(self): model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - def test_well_posed_1plus1D(self): + def test_well_posed_1plus1_d(self): options = { "surface form": "differential", "current collector": "potential pair", @@ -50,17 +47,15 @@ def test_well_posed_1plus1D(self): } model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - self.assertIsInstance( + assert isinstance( model.default_spatial_methods["current collector"], pybamm.FiniteVolume ) - self.assertTrue( - issubclass( - model.default_submesh_types["current collector"], - pybamm.Uniform1DSubMesh, - ) + assert issubclass( + model.default_submesh_types["current collector"], + pybamm.Uniform1DSubMesh, ) - def test_well_posed_2plus1D(self): + def test_well_posed_2plus1_d(self): options = { "surface form": "differential", "current collector": "potential pair", @@ -68,19 +63,17 @@ def test_well_posed_2plus1D(self): } model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - self.assertIsInstance( + assert isinstance( model.default_spatial_methods["current collector"], pybamm.ScikitFiniteElement, ) - self.assertTrue( - issubclass( - model.default_submesh_types["current collector"], - pybamm.ScikitUniform2DSubMesh, - ) + assert issubclass( + model.default_submesh_types["current collector"], + pybamm.ScikitUniform2DSubMesh, ) -class TestLeadAcidLOQSWithSideReactions(unittest.TestCase): +class TestLeadAcidLOQSWithSideReactions: def test_well_posed_differential(self): options = {"surface form": "differential", "hydrolysis": "true"} model = pybamm.lead_acid.LOQS(options) @@ -92,7 +85,7 @@ def test_well_posed_algebraic(self): model.check_well_posedness() -class TestLeadAcidLOQSSurfaceForm(unittest.TestCase): +class TestLeadAcidLOQSSurfaceForm: def test_well_posed_differential(self): options = {"surface form": "differential"} model = pybamm.lead_acid.LOQS(options) @@ -103,7 +96,7 @@ def test_well_posed_algebraic(self): model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - def test_well_posed_1plus1D(self): + def test_well_posed_1plus1_d(self): options = { "surface form": "differential", "current collector": "potential pair", @@ -115,13 +108,13 @@ def test_well_posed_1plus1D(self): def test_default_geometry(self): options = {"surface form": "differential"} model = pybamm.lead_acid.LOQS(options) - self.assertIn("current collector", model.default_geometry) + assert "current collector" in model.default_geometry options.update({"current collector": "potential pair", "dimensionality": 1}) model = pybamm.lead_acid.LOQS(options) - self.assertIn("current collector", model.default_geometry) + assert "current collector" in model.default_geometry -class TestLeadAcidLOQSExternalCircuits(unittest.TestCase): +class TestLeadAcidLOQSExternalCircuits: def test_well_posed_voltage(self): options = {"operating mode": "voltage"} model = pybamm.lead_acid.LOQS(options) @@ -152,13 +145,3 @@ def test_well_posed_discharge_energy(self): options = {"calculate discharge energy": "true"} model = pybamm.lead_acid.LOQS(options) model.check_well_posedness() - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py index 9c093c0c65..7b690257dc 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/base_lithium_ion_tests.py @@ -588,3 +588,20 @@ def test_well_posed_composite_different_degradation(self): "lithium plating": (("none", "irreversible"), "none"), } self.check_well_posedness(options) + + def test_well_posed_composite_LAM(self): + # phases with LAM degradation + options = { + "particle phases": ("2", "1"), + "open-circuit potential": (("single", "current sigmoid"), "single"), + "SEI": "solvent-diffusion limited", + "loss of active material": "reaction-driven", + } + self.check_well_posedness(options) + + options = { + "particle phases": ("2", "1"), + "open-circuit potential": (("single", "current sigmoid"), "single"), + "loss of active material": "stress-driven", + } + self.check_well_posedness(options) diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_dfn.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_dfn.py index cddd59c352..973a0f348b 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_dfn.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_dfn.py @@ -7,8 +7,7 @@ class TestDFN(BaseUnitTestLithiumIon): - @pytest.fixture(autouse=True) - def setUp(self): + def setup_method(self): self.model = pybamm.lithium_ion.DFN def test_electrolyte_options(self): diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_dfn_half_cell.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_dfn_half_cell.py index 389fcf9429..395c6f54b9 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_dfn_half_cell.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_dfn_half_cell.py @@ -4,10 +4,8 @@ import pybamm from tests import BaseUnitTestLithiumIonHalfCell -import pytest class TestDFNHalfCell(BaseUnitTestLithiumIonHalfCell): - @pytest.fixture(autouse=True) - def setUp(self): + def setup_method(self): self.model = pybamm.lithium_ion.DFN diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_electrode_soh.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_electrode_soh.py index 9f044b0566..80ff155369 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_electrode_soh.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_electrode_soh.py @@ -2,11 +2,23 @@ # Tests for the lithium-ion electrode-specific SOH model # +import pytest import pybamm -import unittest -class TestElectrodeSOH(unittest.TestCase): +# Fixture for TestElectrodeSOHMSMR, TestCalculateTheoreticalEnergy and TestGetInitialOCPMSMR class. +@pytest.fixture() +def options(): + options = { + "open-circuit potential": "MSMR", + "particle": "MSMR", + "number of MSMR reactions": ("6", "4"), + "intercalation kinetics": "MSMR", + } + return options + + +class TestElectrodeSOH: def test_known_solution(self): param = pybamm.LithiumIonParameters() parameter_values = pybamm.ParameterValues("Mohtat2020") @@ -24,16 +36,16 @@ def test_known_solution(self): # Solve the model and check outputs sol = esoh_solver.solve(inputs) - self.assertAlmostEqual(sol["Up(y_100) - Un(x_100)"], Vmax, places=5) - self.assertAlmostEqual(sol["Up(y_0) - Un(x_0)"], Vmin, places=5) - self.assertAlmostEqual(sol["Q_Li"], Q_Li, places=5) + assert sol["Up(y_100) - Un(x_100)"] == pytest.approx(Vmax, abs=1e-05) + assert sol["Up(y_0) - Un(x_0)"] == pytest.approx(Vmin, abs=1e-05) + assert sol["Q_Li"] == pytest.approx(Q_Li, abs=1e-05) # Solve with split esoh and check outputs ics = esoh_solver._set_up_solve(inputs) sol_split = esoh_solver._solve_split(inputs, ics) for key in sol: if key != "Maximum theoretical energy [W.h]": - self.assertAlmostEqual(sol[key], sol_split[key].data[0], places=5) + assert sol[key] == pytest.approx(sol_split[key].data[0], abs=1e-05) else: # theoretical_energy is not present in sol_split inputs = { @@ -41,7 +53,7 @@ def test_known_solution(self): for k in ["x_0", "y_0", "x_100", "y_100", "Q_p"] } energy = esoh_solver.theoretical_energy_integral(inputs) - self.assertAlmostEqual(sol[key], energy, places=5) + assert sol[key] == pytest.approx(energy, abs=1e-05) def test_known_solution_cell_capacity(self): param = pybamm.LithiumIonParameters() @@ -62,9 +74,9 @@ def test_known_solution_cell_capacity(self): # Solve the model and check outputs sol = esoh_solver.solve(inputs) - self.assertAlmostEqual(sol["Up(y_100) - Un(x_100)"], Vmax, places=5) - self.assertAlmostEqual(sol["Up(y_0) - Un(x_0)"], Vmin, places=5) - self.assertAlmostEqual(sol["Q"], Q, places=5) + assert sol["Up(y_100) - Un(x_100)"] == pytest.approx(Vmax, abs=1e-05) + assert sol["Up(y_0) - Un(x_0)"] == pytest.approx(Vmin, abs=1e-05) + assert sol["Q"] == pytest.approx(Q, abs=1e-05) def test_error(self): param = pybamm.LithiumIonParameters() @@ -79,7 +91,7 @@ def test_error(self): inputs = {"Q_Li": Q_Li, "Q_n": Q_n, "Q_p": Q_p} # Solve the model and check outputs - with self.assertRaisesRegex(ValueError, "outside the range"): + with pytest.raises(ValueError, match="outside the range"): esoh_solver.solve(inputs) Q_Li = parameter_values.evaluate(param.Q_Li_particles_init) @@ -93,8 +105,8 @@ def test_error(self): esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver(parameter_values, param) inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li} # Solver fails to find a solution but voltage limits are not violated - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution" + with pytest.raises( + pybamm.SolverError, match="Could not find acceptable solution" ): esoh_solver.solve(inputs) # Solver fails to find a solution due to upper voltage limit @@ -108,7 +120,7 @@ def test_error(self): ) esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver(parameter_values, param) inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li} - with self.assertRaisesRegex(ValueError, "upper bound of the voltage"): + with pytest.raises(ValueError, match="upper bound of the voltage"): esoh_solver.solve(inputs) # Solver fails to find a solution due to lower voltage limit parameter_values.update( @@ -121,7 +133,7 @@ def test_error(self): ) esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver(parameter_values, param) inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q_Li": Q_Li} - with self.assertRaisesRegex(ValueError, "lower bound of the voltage"): + with pytest.raises(ValueError, match="lower bound of the voltage"): esoh_solver.solve(inputs) # errors for cell capacity based solver @@ -136,24 +148,18 @@ def test_error(self): esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver( parameter_values, param, known_value="cell capacity" ) - with self.assertRaisesRegex(ValueError, "solve_for must be "): + with pytest.raises(ValueError, match="solve_for must be "): esoh_solver._get_electrode_soh_sims_split() inputs = {"Q_n": Q_n, "Q_p": Q_p, "Q": 2 * Q_p} - with self.assertRaisesRegex( - ValueError, "larger than the maximum possible capacity" + with pytest.raises( + ValueError, match="larger than the maximum possible capacity" ): esoh_solver.solve(inputs) -class TestElectrodeSOHMSMR(unittest.TestCase): - def test_known_solution(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } +class TestElectrodeSOHMSMR: + def test_known_solution(self, options): param = pybamm.LithiumIonParameters(options=options) parameter_values = pybamm.ParameterValues("MSMR_Example") @@ -172,27 +178,21 @@ def test_known_solution(self): # Solve the model and check outputs sol = esoh_solver.solve(inputs) - self.assertAlmostEqual(sol["Up(y_100) - Un(x_100)"], Vmax, places=5) - self.assertAlmostEqual(sol["Up(y_0) - Un(x_0)"], Vmin, places=5) - self.assertAlmostEqual(sol["Q_Li"], Q_Li, places=5) + assert sol["Up(y_100) - Un(x_100)"] == pytest.approx(Vmax, abs=1e-05) + assert sol["Up(y_0) - Un(x_0)"] == pytest.approx(Vmin, abs=1e-05) + assert sol["Q_Li"] == pytest.approx(Q_Li, abs=1e-05) # Solve with split esoh and check outputs ics = esoh_solver._set_up_solve(inputs) sol_split = esoh_solver._solve_split(inputs, ics) for key in sol: if key != "Maximum theoretical energy [W.h]": - self.assertAlmostEqual(sol[key], sol_split[key].data[0], places=5) + assert sol[key] == pytest.approx(sol_split[key].data[0], abs=1e-05) # Check feasibility checks can be performed successfully esoh_solver._check_esoh_feasible(inputs) - def test_known_solution_cell_capacity(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } + def test_known_solution_cell_capacity(self, options): param = pybamm.LithiumIonParameters(options) parameter_values = pybamm.ParameterValues("MSMR_Example") @@ -211,28 +211,22 @@ def test_known_solution_cell_capacity(self): # Solve the model and check outputs sol = esoh_solver.solve(inputs) - self.assertAlmostEqual(sol["Up(y_100) - Un(x_100)"], Vmax, places=5) - self.assertAlmostEqual(sol["Up(y_0) - Un(x_0)"], Vmin, places=5) - self.assertAlmostEqual(sol["Q"], Q, places=5) + assert sol["Up(y_100) - Un(x_100)"] == pytest.approx(Vmax, abs=1e-05) + assert sol["Up(y_0) - Un(x_0)"] == pytest.approx(Vmin, abs=1e-05) + assert sol["Q"] == pytest.approx(Q, abs=1e-05) - def test_error(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } + def test_error(self, options): param = pybamm.LithiumIonParameters(options) parameter_values = pybamm.ParameterValues("MSMR_Example") esoh_solver = pybamm.lithium_ion.ElectrodeSOHSolver( parameter_values, param, known_value="cell capacity", options=options ) - with self.assertRaisesRegex(ValueError, "solve_for must be "): + with pytest.raises(ValueError, match="solve_for must be "): esoh_solver._get_electrode_soh_sims_split() -class TestElectrodeSOHHalfCell(unittest.TestCase): +class TestElectrodeSOHHalfCell: def test_known_solution(self): model = pybamm.lithium_ion.ElectrodeSOHHalfCell() param = pybamm.LithiumIonParameters({"working electrode": "positive"}) @@ -243,12 +237,12 @@ def test_known_solution(self): V_max = 4.2 # Solve the model and check outputs sol = sim.solve([0], inputs={"Q_w": Q_w}) - self.assertAlmostEqual(sol["Uw(x_100)"].data[0], V_max, places=5) - self.assertAlmostEqual(sol["Uw(x_0)"].data[0], V_min, places=5) + assert sol["Uw(x_100)"].data[0] == pytest.approx(V_max, abs=1e-05) + assert sol["Uw(x_0)"].data[0] == pytest.approx(V_min, abs=1e-05) -class TestCalculateTheoreticalEnergy(unittest.TestCase): - def test_efficiency(self): +class TestCalculateTheoreticalEnergy: + def test_efficiency(self, options): model = pybamm.lithium_ion.DFN(options={"calculate discharge energy": "true"}) parameter_values = pybamm.ParameterValues("Chen2020") sim = pybamm.Simulation(model, parameter_values=parameter_values) @@ -261,12 +255,12 @@ def test_efficiency(self): ) # Real energy should be less than discharge energy, # and both should be greater than 0 - self.assertLess(discharge_energy, theoretical_energy) - self.assertLess(0, discharge_energy) - self.assertLess(0, theoretical_energy) + assert discharge_energy < theoretical_energy + assert 0 < discharge_energy + assert 0 < theoretical_energy -class TestGetInitialSOC(unittest.TestCase): +class TestGetInitialSOC: def test_initial_soc(self): param = pybamm.LithiumIonParameters() parameter_values = pybamm.ParameterValues("Mohtat2020") @@ -276,26 +270,26 @@ def test_initial_soc(self): 1, parameter_values, param ) V = parameter_values.evaluate(param.p.prim.U(y100, T) - param.n.prim.U(x100, T)) - self.assertAlmostEqual(V, 4.2) + assert V == pytest.approx(4.2) x0, y0 = pybamm.lithium_ion.get_initial_stoichiometries( 0, parameter_values, param ) V = parameter_values.evaluate(param.p.prim.U(y0, T) - param.n.prim.U(x0, T)) - self.assertAlmostEqual(V, 2.8) + assert V == pytest.approx(2.8) x, y = pybamm.lithium_ion.get_initial_stoichiometries( 0.4, parameter_values, param ) - self.assertEqual(x, x0 + 0.4 * (x100 - x0)) - self.assertEqual(y, y0 - 0.4 * (y0 - y100)) + assert x == x0 + 0.4 * (x100 - x0) + assert y == y0 - 0.4 * (y0 - y100) x, y = pybamm.lithium_ion.get_initial_stoichiometries( "4 V", parameter_values, param ) T = parameter_values.evaluate(param.T_ref) V = parameter_values.evaluate(param.p.prim.U(y, T) - param.n.prim.U(x, T)) - self.assertAlmostEqual(V, 4) + assert V == pytest.approx(4) def test_min_max_stoich(self): param = pybamm.LithiumIonParameters() @@ -306,9 +300,9 @@ def test_min_max_stoich(self): parameter_values, param ) V = parameter_values.evaluate(param.p.prim.U(y100, T) - param.n.prim.U(x100, T)) - self.assertAlmostEqual(V, 4.2) + assert V == pytest.approx(4.2) V = parameter_values.evaluate(param.p.prim.U(y0, T) - param.n.prim.U(x0, T)) - self.assertAlmostEqual(V, 2.8) + assert V == pytest.approx(2.8) x0, x100, y100, y0 = pybamm.lithium_ion.get_min_max_stoichiometries( parameter_values, @@ -316,9 +310,9 @@ def test_min_max_stoich(self): known_value="cell capacity", ) V = parameter_values.evaluate(param.p.prim.U(y100, T) - param.n.prim.U(x100, T)) - self.assertAlmostEqual(V, 4.2) + assert V == pytest.approx(4.2) V = parameter_values.evaluate(param.p.prim.U(y0, T) - param.n.prim.U(x0, T)) - self.assertAlmostEqual(V, 2.8) + assert V == pytest.approx(2.8) def test_initial_soc_cell_capacity(self): param = pybamm.LithiumIonParameters() @@ -329,7 +323,7 @@ def test_initial_soc_cell_capacity(self): 1, parameter_values, param, known_value="cell capacity" ) V = parameter_values.evaluate(param.p.prim.U(y100, T) - param.n.prim.U(x100, T)) - self.assertAlmostEqual(V, 4.2) + assert V == pytest.approx(4.2) def test_error(self): parameter_values = pybamm.ParameterValues("Chen2020") @@ -337,43 +331,41 @@ def test_error(self): {"working electrode": "positive"} ).default_parameter_values - with self.assertRaisesRegex( - ValueError, "Initial SOC should be between 0 and 1" - ): + with pytest.raises(ValueError, match="Initial SOC should be between 0 and 1"): pybamm.lithium_ion.get_initial_stoichiometries(2, parameter_values) - with self.assertRaisesRegex(ValueError, "outside the voltage limits"): + with pytest.raises(ValueError, match="outside the voltage limits"): pybamm.lithium_ion.get_initial_stoichiometries("1 V", parameter_values) - with self.assertRaisesRegex(ValueError, "must be a float"): + with pytest.raises(ValueError, match="must be a float"): pybamm.lithium_ion.get_initial_stoichiometries("5 A", parameter_values) - with self.assertRaisesRegex(ValueError, "outside the voltage limits"): + with pytest.raises(ValueError, match="outside the voltage limits"): pybamm.lithium_ion.get_initial_stoichiometry_half_cell( "1 V", parameter_values_half_cell ) - with self.assertRaisesRegex(ValueError, "must be a float"): + with pytest.raises(ValueError, match="must be a float"): pybamm.lithium_ion.get_initial_stoichiometry_half_cell( "5 A", parameter_values_half_cell ) - with self.assertRaisesRegex( - ValueError, "Initial SOC should be between 0 and 1" - ): + with pytest.raises(ValueError, match="Initial SOC should be between 0 and 1"): pybamm.lithium_ion.get_initial_stoichiometry_half_cell( 2, parameter_values_half_cell ) - with self.assertRaisesRegex( - ValueError, "Known value must be cell capacity or cyclable lithium capacity" + with pytest.raises( + ValueError, + match="Known value must be cell capacity or cyclable lithium capacity", ): pybamm.lithium_ion.ElectrodeSOHSolver( parameter_values, known_value="something else" ) - with self.assertRaisesRegex( - ValueError, "Known value must be cell capacity or cyclable lithium capacity" + with pytest.raises( + ValueError, + match="Known value must be cell capacity or cyclable lithium capacity", ): param_MSMR = pybamm.lithium_ion.MSMR( {"number of MSMR reactions": "3"} @@ -382,24 +374,25 @@ def test_error(self): param=param_MSMR, known_value="something else" ) - with self.assertRaisesRegex( - ValueError, "Known value must be cell capacity or cyclable lithium capacity" + with pytest.raises( + ValueError, + match="Known value must be cell capacity or cyclable lithium capacity", ): pybamm.models.full_battery_models.lithium_ion.electrode_soh._ElectrodeSOH( known_value="something else" ) -class TestGetInitialOCP(unittest.TestCase): +class TestGetInitialOCP: def test_get_initial_ocp(self): param = pybamm.LithiumIonParameters() parameter_values = pybamm.ParameterValues("Mohtat2020") Un, Up = pybamm.lithium_ion.get_initial_ocps(1, parameter_values, param) - self.assertAlmostEqual(Up - Un, 4.2) + assert Up - Un == pytest.approx(4.2) Un, Up = pybamm.lithium_ion.get_initial_ocps(0, parameter_values, param) - self.assertAlmostEqual(Up - Un, 2.8) + assert Up - Un == pytest.approx(2.8) Un, Up = pybamm.lithium_ion.get_initial_ocps("4 V", parameter_values, param) - self.assertAlmostEqual(Up - Un, 4) + assert Up - Un == pytest.approx(4) def test_min_max_ocp(self): param = pybamm.LithiumIonParameters() @@ -408,61 +401,39 @@ def test_min_max_ocp(self): Un_0, Un_100, Up_100, Up_0 = pybamm.lithium_ion.get_min_max_ocps( parameter_values, param ) - self.assertAlmostEqual(Up_100 - Un_100, 4.2) - self.assertAlmostEqual(Up_0 - Un_0, 2.8) + assert Up_100 - Un_100 == pytest.approx(4.2) + assert Up_0 - Un_0 == pytest.approx(2.8) -class TestGetInitialOCPMSMR(unittest.TestCase): - def test_get_initial_ocp(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } +class TestGetInitialOCPMSMR: + def test_get_initial_ocp(self, options): param = pybamm.LithiumIonParameters(options) parameter_values = pybamm.ParameterValues("MSMR_Example") Un, Up = pybamm.lithium_ion.get_initial_ocps( 1, parameter_values, param, options=options ) - self.assertAlmostEqual(Up - Un, 4.2, places=5) + assert Up - Un == pytest.approx(4.2, abs=1e-05) Un, Up = pybamm.lithium_ion.get_initial_ocps( 0, parameter_values, param, options=options ) - self.assertAlmostEqual(Up - Un, 2.8, places=5) + assert Up - Un == pytest.approx(2.8, abs=1e-05) Un, Up = pybamm.lithium_ion.get_initial_ocps( "4 V", parameter_values, param, options=options ) - self.assertAlmostEqual(Up - Un, 4) + assert Up - Un == pytest.approx(4) - def test_min_max_ocp(self): - options = { - "open-circuit potential": "MSMR", - "particle": "MSMR", - "number of MSMR reactions": ("6", "4"), - "intercalation kinetics": "MSMR", - } + def test_min_max_ocp(self, options): param = pybamm.LithiumIonParameters(options) parameter_values = pybamm.ParameterValues("MSMR_Example") Un_0, Un_100, Up_100, Up_0 = pybamm.lithium_ion.get_min_max_ocps( parameter_values, param, options=options ) - self.assertAlmostEqual(Up_100 - Un_100, 4.2) - self.assertAlmostEqual(Up_0 - Un_0, 2.8) + assert Up_100 - Un_100 == pytest.approx(4.2) + assert Up_0 - Un_0 == pytest.approx(2.8) Un_0, Un_100, Up_100, Up_0 = pybamm.lithium_ion.get_min_max_ocps( parameter_values, param, known_value="cell capacity", options=options ) - self.assertAlmostEqual(Up_100 - Un_100, 4.2) - self.assertAlmostEqual(Up_0 - Un_0, 2.8) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert Up_100 - Un_100 == pytest.approx(4.2) + assert Up_0 - Un_0 == pytest.approx(2.8) diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_mpm.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_mpm.py index e5147f01e2..ad02212840 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_mpm.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_mpm.py @@ -2,11 +2,11 @@ # Tests for the lithium-ion MPM model # +import pytest import pybamm -import unittest -class TestMPM(unittest.TestCase): +class TestMPM: def test_well_posed(self): options = {"thermal": "isothermal"} model = pybamm.lithium_ion.MPM(options) @@ -20,9 +20,9 @@ def test_well_posed(self): def test_default_parameter_values(self): # check default parameters are added correctly model = pybamm.lithium_ion.MPM() - self.assertEqual( - model.default_parameter_values["Negative minimum particle radius [m]"], - 0.0, + assert ( + model.default_parameter_values["Negative minimum particle radius [m]"] + == 0.0 ) def test_lumped_thermal_model_1D(self): @@ -32,7 +32,7 @@ def test_lumped_thermal_model_1D(self): def test_x_full_thermal_not_implemented(self): options = {"thermal": "x-full"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_thermal_1plus1D(self): @@ -51,7 +51,7 @@ def test_particle_uniform(self): def test_particle_quadratic(self): options = {"particle": "quadratic profile"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_differential_surface_form(self): @@ -66,31 +66,31 @@ def test_current_sigmoid(self): def test_necessary_options(self): options = {"particle size": "single"} - with self.assertRaises(pybamm.OptionError): + with pytest.raises(pybamm.OptionError): pybamm.lithium_ion.MPM(options) options = {"surface form": "false"} - with self.assertRaises(pybamm.OptionError): + with pytest.raises(pybamm.OptionError): pybamm.lithium_ion.MPM(options) def test_nonspherical_particle_not_implemented(self): options = {"particle shape": "user"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_loss_active_material_stress_negative_not_implemented(self): options = {"loss of active material": ("stress-driven", "none")} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_loss_active_material_stress_positive_not_implemented(self): options = {"loss of active material": ("none", "stress-driven")} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_loss_active_material_stress_both_not_implemented(self): options = {"loss of active material": "stress-driven"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_reversible_plating_with_porosity_not_implemented(self): @@ -98,12 +98,12 @@ def test_reversible_plating_with_porosity_not_implemented(self): "lithium plating": "reversible", "lithium plating porosity change": "true", } - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_stress_induced_diffusion_not_implemented(self): options = {"stress-induced diffusion": "true"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_msmr(self): @@ -124,7 +124,7 @@ def test_wycisk_ocp(self): model.check_well_posedness() -class TestMPMExternalCircuits(unittest.TestCase): +class TestMPMExternalCircuits: def test_well_posed_voltage(self): options = {"operating mode": "voltage"} model = pybamm.lithium_ion.MPM(options) @@ -146,25 +146,25 @@ def external_circuit_function(variables): model.check_well_posedness() -class TestMPMWithSEI(unittest.TestCase): +class TestMPMWithSEI: def test_reaction_limited_not_implemented(self): options = {"SEI": "reaction limited"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_solvent_diffusion_limited_not_implemented(self): options = {"SEI": "solvent-diffusion limited"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_electron_migration_limited_not_implemented(self): options = {"SEI": "electron-migration limited"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_interstitial_diffusion_limited_not_implemented(self): options = {"SEI": "interstitial-diffusion limited"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_ec_reaction_limited_not_implemented(self): @@ -172,49 +172,39 @@ def test_ec_reaction_limited_not_implemented(self): "SEI": "ec reaction limited", "SEI porosity change": "true", } - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) -class TestMPMWithMechanics(unittest.TestCase): +class TestMPMWithMechanics: def test_well_posed_negative_cracking_not_implemented(self): options = {"particle mechanics": ("swelling and cracking", "none")} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_well_posed_positive_cracking_not_implemented(self): options = {"particle mechanics": ("none", "swelling and cracking")} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_well_posed_both_cracking_not_implemented(self): options = {"particle mechanics": "swelling and cracking"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_well_posed_both_swelling_only_not_implemented(self): options = {"particle mechanics": "swelling only"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) -class TestMPMWithPlating(unittest.TestCase): +class TestMPMWithPlating: def test_well_posed_reversible_plating_not_implemented(self): options = {"lithium plating": "reversible"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) def test_well_posed_irreversible_plating_not_implemented(self): options = {"lithium plating": "irreversible"} - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): pybamm.lithium_ion.MPM(options) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py index c979474e13..58149a69de 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_newman_tobias.py @@ -7,8 +7,7 @@ class TestNewmanTobias(BaseUnitTestLithiumIon): - @pytest.fixture(autouse=True) - def setUp(self): + def setup_method(self): self.model = pybamm.lithium_ion.NewmanTobias def test_electrolyte_options(self): @@ -39,3 +38,7 @@ def test_well_posed_composite_diffusion_hysteresis(self): @pytest.mark.skip(reason="Test currently not implemented") def test_well_posed_composite_different_degradation(self): pass # skip this test + + @pytest.mark.skip(reason="Test currently not implemented") + def test_well_posed_composite_LAM(self): + pass # skip this test diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_splitOCVR.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_splitOCVR.py new file mode 100644 index 0000000000..e807ec1607 --- /dev/null +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_splitOCVR.py @@ -0,0 +1,15 @@ +# +# Test for the ecm split-OCV model +# +import pybamm + + +class TestSplitOCVR: + def test_ecmsplitocv_well_posed(self): + model = pybamm.lithium_ion.SplitOCVR() + model.check_well_posedness() + + def test_get_default_quick_plot_variables(self): + model = pybamm.lithium_ion.SplitOCVR() + variables = model.default_quick_plot_variables + assert "Current [A]" in variables diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spm.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spm.py index 99affc7ddd..8551967dad 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spm.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spm.py @@ -7,8 +7,7 @@ class TestSPM(BaseUnitTestLithiumIon): - @pytest.fixture(autouse=True) - def setUp(self): + def setup_method(self): self.model = pybamm.lithium_ion.SPM def test_electrolyte_options(self): diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spm_half_cell.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spm_half_cell.py index c1b6b34745..8ca0c7a7b9 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spm_half_cell.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spm_half_cell.py @@ -3,10 +3,8 @@ # import pybamm from tests import BaseUnitTestLithiumIonHalfCell -import pytest class TestSPMHalfCell(BaseUnitTestLithiumIonHalfCell): - @pytest.fixture(autouse=True) - def setUp(self): + def setup_method(self): self.model = pybamm.lithium_ion.SPM diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spme.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spme.py index b0d38fa9c7..ecab4384fc 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spme.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spme.py @@ -7,8 +7,7 @@ class TestSPMe(BaseUnitTestLithiumIon): - @pytest.fixture(autouse=True) - def setUp(self): + def setup_method(self): self.model = pybamm.lithium_ion.SPMe # def test_external_variables(self): diff --git a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spme_half_cell.py b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spme_half_cell.py index 2a814c113e..f09f42a5a6 100644 --- a/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spme_half_cell.py +++ b/tests/unit/test_models/test_full_battery_models/test_lithium_ion/test_spme_half_cell.py @@ -4,10 +4,8 @@ # import pybamm from tests import BaseUnitTestLithiumIonHalfCell -import pytest class TestSPMeHalfCell(BaseUnitTestLithiumIonHalfCell): - @pytest.fixture(autouse=True) - def setUp(self): + def setup_method(self): self.model = pybamm.lithium_ion.SPMe diff --git a/tests/unit/test_models/test_full_battery_models/test_sodium_ion/__init__.py b/tests/unit/test_models/test_full_battery_models/test_sodium_ion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/test_models/test_full_battery_models/test_sodium_ion/test_basic_models.py b/tests/unit/test_models/test_full_battery_models/test_sodium_ion/test_basic_models.py new file mode 100644 index 0000000000..6b085d3a79 --- /dev/null +++ b/tests/unit/test_models/test_full_battery_models/test_sodium_ion/test_basic_models.py @@ -0,0 +1,14 @@ +# +# Tests for the basic sodium-ion models +# +import pybamm + + +class TestBasicModels: + def test_dfn_well_posed(self): + model = pybamm.sodium_ion.BasicDFN() + model.check_well_posedness() + + def test_default_parameters(self): + model = pybamm.sodium_ion.BasicDFN() + assert "Chayambuka2022" in model.default_parameter_values["citations"] diff --git a/tests/unit/test_parameters/test_bpx.py b/tests/unit/test_parameters/test_bpx.py index ab4c25f97a..3e0e32d1fd 100644 --- a/tests/unit/test_parameters/test_bpx.py +++ b/tests/unit/test_parameters/test_bpx.py @@ -1,5 +1,4 @@ import tempfile -import unittest import json import pybamm import copy @@ -7,8 +6,8 @@ import pytest -class TestBPX(unittest.TestCase): - def setUp(self): +class TestBPX: + def setup_method(self): self.base = { "Header": { "BPX": 1.0, @@ -197,13 +196,13 @@ def check_constant_output(func): stos = [0, 1] T = 298.15 p_vals = [func(sto, T) for sto in stos] - self.assertEqual(p_vals[0], p_vals[1]) + assert p_vals[0] == p_vals[1] for electrode in ["Negative", "Positive"]: D = param[f"{electrode} particle diffusivity [m2.s-1]"] dUdT = param[f"{electrode} electrode OCP entropic change [V.K-1]"] check_constant_output(D) - check_constant_output(dUdT) + assert dUdT == 1 kappa = param["Electrolyte conductivity [S.m-1]"] De = param["Electrolyte diffusivity [m2.s-1]"] @@ -250,23 +249,21 @@ def test_table_data(self): # correct child c = pybamm.Variable("c") kappa = param["Electrolyte conductivity [S.m-1]"](c, 298.15) - self.assertIsInstance(kappa, pybamm.Interpolant) - self.assertEqual(kappa.children[0], c) + assert isinstance(kappa, pybamm.Interpolant) + assert kappa.children[0] == c # Check other parameters give interpolants D = param["Electrolyte diffusivity [m2.s-1]"](c, 298.15) - self.assertIsInstance(D, pybamm.Interpolant) + assert isinstance(D, pybamm.Interpolant) for electrode in ["Negative", "Positive"]: D = param[f"{electrode} particle diffusivity [m2.s-1]"](c, 298.15) - self.assertIsInstance(D, pybamm.Interpolant) + assert isinstance(D, pybamm.Interpolant) OCP = param[f"{electrode} electrode OCP [V]"](c) - self.assertIsInstance(OCP, pybamm.Interpolant) - dUdT = param[f"{electrode} electrode OCP entropic change [V.K-1]"]( - c, 10000 - ) - self.assertIsInstance(dUdT, pybamm.Interpolant) + assert isinstance(OCP, pybamm.Interpolant) + dUdT = param[f"{electrode} electrode OCP entropic change [V.K-1]"](c) + assert isinstance(dUdT, pybamm.Interpolant) def test_bpx_soc_error(self): - with self.assertRaisesRegex(ValueError, "Target SOC"): + with pytest.raises(ValueError, match="Target SOC"): pybamm.ParameterValues.create_from_bpx("blah.json", target_soc=10) def test_bpx_arrhenius(self): @@ -305,7 +302,7 @@ def arrhenius_assertion(pv, param_key, Ea_key): calc_ratio = pybamm.exp(Ea / pybamm.constants.R * (1 / T_ref - 1 / T)).value - self.assertAlmostEqual(eval_ratio, calc_ratio) + assert eval_ratio == pytest.approx(calc_ratio) param_keys = [ "Electrolyte conductivity [S.m-1]", @@ -454,7 +451,7 @@ def test_bpx_blended_error(self): json.dump(bpx_obj, tmp) tmp.flush() - with self.assertRaisesRegex(NotImplementedError, "PyBaMM does not support"): + with pytest.raises(NotImplementedError, match="PyBaMM does not support"): pybamm.ParameterValues.create_from_bpx(tmp.name) def test_bpx_user_defined(self): @@ -478,21 +475,11 @@ def test_bpx_user_defined(self): param = pybamm.ParameterValues.create_from_bpx(tmp.name) - self.assertEqual(param["User-defined scalar parameter"], 1.0) + assert param["User-defined scalar parameter"] == 1.0 var = pybamm.Variable("var") - self.assertIsInstance( + assert isinstance( param["User-defined parameter data"](var), pybamm.Interpolant ) - self.assertIsInstance( + assert isinstance( param["User-defined parameter data function"](var), pybamm.Power ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_current_functions.py b/tests/unit/test_parameters/test_current_functions.py index b00cba0b89..6e4914092b 100644 --- a/tests/unit/test_parameters/test_current_functions.py +++ b/tests/unit/test_parameters/test_current_functions.py @@ -4,21 +4,20 @@ import pybamm import numbers -import unittest import numpy as np import pandas as pd import pytest from tests import no_internet_connection -class TestCurrentFunctions(unittest.TestCase): +class TestCurrentFunctions: def test_constant_current(self): # test simplify param = pybamm.electrical_parameters current = param.current_with_time parameter_values = pybamm.ParameterValues({"Current function [A]": 2}) processed_current = parameter_values.process_symbol(current) - self.assertIsInstance(processed_current, pybamm.Scalar) + assert isinstance(processed_current, pybamm.Scalar) @pytest.mark.skipif( no_internet_connection(), @@ -99,13 +98,3 @@ def test_output_type(self): def test_all(self): self.test_output_type() - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_ecm_parameters.py b/tests/unit/test_parameters/test_ecm_parameters.py index 543b4f4e5b..39ee80afee 100644 --- a/tests/unit/test_parameters/test_ecm_parameters.py +++ b/tests/unit/test_parameters/test_ecm_parameters.py @@ -3,7 +3,6 @@ # import pybamm -import unittest values = { @@ -33,7 +32,7 @@ parameter_values = pybamm.ParameterValues(values) -class TestEcmParameters(unittest.TestCase): +class TestEcmParameters: def test_init_parameters(self): param = pybamm.EcmParameters() @@ -54,13 +53,13 @@ def test_init_parameters(self): for symbol, key in simpled_mapped_parameters: value = parameter_values.evaluate(symbol) expected_value = values[key] - self.assertEqual(value, expected_value) + assert value == expected_value value = parameter_values.evaluate(param.initial_T_cell) - self.assertEqual(value, values["Initial temperature [K]"] - 273.15) + assert value == values["Initial temperature [K]"] - 273.15 value = parameter_values.evaluate(param.initial_T_jig) - self.assertEqual(value, values["Initial temperature [K]"] - 273.15) + assert value == values["Initial temperature [K]"] - 273.15 compatibility_parameters = [ (param.n_electrodes_parallel, 1), @@ -70,7 +69,7 @@ def test_init_parameters(self): for symbol, expected_value in compatibility_parameters: value = parameter_values.evaluate(symbol) - self.assertEqual(value, expected_value) + assert value == expected_value def test_function_parameters(self): param = pybamm.EcmParameters() @@ -89,17 +88,7 @@ def test_function_parameters(self): for symbol, key in mapped_functions: value = parameter_values.evaluate(symbol) expected_value = values[key] - self.assertEqual(value, expected_value) + assert value == expected_value value = parameter_values.evaluate(param.T_amb(sym)) - self.assertEqual(value, values["Ambient temperature [K]"] - 273.15) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert value == values["Ambient temperature [K]"] - 273.15 diff --git a/tests/unit/test_parameters/test_lead_acid_parameters.py b/tests/unit/test_parameters/test_lead_acid_parameters.py index 3fc62fde93..1d02d91ff0 100644 --- a/tests/unit/test_parameters/test_lead_acid_parameters.py +++ b/tests/unit/test_parameters/test_lead_acid_parameters.py @@ -1,19 +1,19 @@ # # Test for the standard lead acid parameters # +import pytest import os import pybamm from tests import get_discretisation_for_testing from tempfile import TemporaryDirectory -import unittest -class TestStandardParametersLeadAcid(unittest.TestCase): +class TestStandardParametersLeadAcid: def test_scipy_constants(self): constants = pybamm.constants - self.assertAlmostEqual(constants.R.evaluate(), 8.314, places=3) - self.assertAlmostEqual(constants.F.evaluate(), 96485, places=0) + assert constants.R.evaluate() == pytest.approx(8.314, abs=0.001) + assert constants.F.evaluate() == pytest.approx(96485, abs=1) def test_print_parameters(self): with TemporaryDirectory() as dir_name: @@ -30,17 +30,19 @@ def test_parameters_defaults_lead_acid(self): # Volume change positive in negative electrode and negative in positive # electrode - self.assertLess(param_eval["n.DeltaVsurf"], 0) - self.assertGreater(param_eval["p.DeltaVsurf"], 0) + assert param_eval["n.DeltaVsurf"] < 0 + assert param_eval["p.DeltaVsurf"] > 0 def test_concatenated_parameters(self): # create param = pybamm.LeadAcidParameters() eps_param = param.epsilon_init - self.assertIsInstance(eps_param, pybamm.Concatenation) - self.assertEqual( - eps_param.domain, ["negative electrode", "separator", "positive electrode"] - ) + assert isinstance(eps_param, pybamm.Concatenation) + assert eps_param.domain == [ + "negative electrode", + "separator", + "positive electrode", + ] # process parameters and discretise parameter_values = pybamm.ParameterValues("Sulzer2019") @@ -49,7 +51,7 @@ def test_concatenated_parameters(self): # test output submeshes = disc.mesh[("negative electrode", "separator", "positive electrode")] - self.assertEqual(processed_eps.shape, (submeshes.npts, 1)) + assert processed_eps.shape == (submeshes.npts, 1) def test_current_functions(self): # create current functions @@ -70,7 +72,7 @@ def test_current_functions(self): } ) current_density_eval = parameter_values.process_symbol(current_density) - self.assertAlmostEqual(current_density_eval.evaluate(t=3), 2 / (8 * 0.1 * 0.1)) + assert current_density_eval.evaluate(t=3) == pytest.approx(2 / (8 * 0.1 * 0.1)) def test_thermal_parameters(self): values = pybamm.lead_acid.BaseModel().default_parameter_values @@ -78,18 +80,18 @@ def test_thermal_parameters(self): T = 300 # dummy temperature as the values are constant # Density - self.assertEqual(values.evaluate(param.n.rho_c_p_cc(T)), 11300 * 130) - self.assertEqual(values.evaluate(param.n.rho_c_p(T)), 11300 * 130) - self.assertEqual(values.evaluate(param.s.rho_c_p(T)), 1680 * 700) - self.assertEqual(values.evaluate(param.p.rho_c_p(T)), 9375 * 256) - self.assertEqual(values.evaluate(param.p.rho_c_p_cc(T)), 9375 * 256) + assert values.evaluate(param.n.rho_c_p_cc(T)) == 11300 * 130 + assert values.evaluate(param.n.rho_c_p(T)) == 11300 * 130 + assert values.evaluate(param.s.rho_c_p(T)) == 1680 * 700 + assert values.evaluate(param.p.rho_c_p(T)) == 9375 * 256 + assert values.evaluate(param.p.rho_c_p_cc(T)) == 9375 * 256 # Thermal conductivity - self.assertEqual(values.evaluate(param.n.lambda_cc(T)), 35) - self.assertEqual(values.evaluate(param.n.lambda_(T)), 35) - self.assertEqual(values.evaluate(param.s.lambda_(T)), 0.04) - self.assertEqual(values.evaluate(param.p.lambda_(T)), 35) - self.assertEqual(values.evaluate(param.p.lambda_cc(T)), 35) + assert values.evaluate(param.n.lambda_cc(T)) == 35 + assert values.evaluate(param.n.lambda_(T)) == 35 + assert values.evaluate(param.s.lambda_(T)) == 0.04 + assert values.evaluate(param.p.lambda_(T)) == 35 + assert values.evaluate(param.p.lambda_cc(T)) == 35 def test_functions_lead_acid(self): # Load parameters to be tested @@ -112,17 +114,7 @@ def test_functions_lead_acid(self): param_eval = parameter_values.print_parameters(parameters) # Known monotonicity for functions - self.assertGreater(param_eval["chi_1"], param_eval["chi_0.5"]) - self.assertLess(param_eval["U_n_1"], param_eval["U_n_0.5"]) - self.assertGreater(param_eval["U_p_1"], param_eval["U_p_0.5"]) - self.assertGreater(param_eval["j0_Ox_1"], param_eval["j0_Ox_0.5"]) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert param_eval["chi_1"] > param_eval["chi_0.5"] + assert param_eval["U_n_1"] < param_eval["U_n_0.5"] + assert param_eval["U_p_1"] > param_eval["U_p_0.5"] + assert param_eval["j0_Ox_1"] > param_eval["j0_Ox_0.5"] diff --git a/tests/unit/test_parameters/test_lithium_ion_parameters.py b/tests/unit/test_parameters/test_lithium_ion_parameters.py index 66c4ea398e..7ac573d785 100644 --- a/tests/unit/test_parameters/test_lithium_ion_parameters.py +++ b/tests/unit/test_parameters/test_lithium_ion_parameters.py @@ -5,11 +5,10 @@ import pybamm from tempfile import TemporaryDirectory -import unittest import numpy as np -class TestLithiumIonParameterValues(unittest.TestCase): +class TestLithiumIonParameterValues: def test_print_parameters(self): with TemporaryDirectory() as dir_name: parameters = pybamm.LithiumIonParameters() @@ -138,13 +137,3 @@ def test_parameter_functions(self): c_e_test = 1000 values.evaluate(param.D_e(c_e_test, T_test)) values.evaluate(param.kappa_e(c_e_test, T_test)) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_parameter_sets/test_Ai2020.py b/tests/unit/test_parameters/test_parameter_sets/test_Ai2020.py index f7302330bf..05fe8f68fa 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_Ai2020.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_Ai2020.py @@ -22,10 +22,10 @@ def test_functions(self): 0.6098, ), "Positive electrode OCP entropic change [V.K-1]": ( - [sto, c_p_max], + [sto], -2.1373e-4, ), - "Positive electrode volume change": ([sto, c_p_max], -1.8179e-2), + "Positive electrode volume change": ([sto], -1.8179e-2), # Negative electrode "Negative electrode cracking rate": ([T], 3.9e-20), "Negative particle diffusivity [m2.s-1]": ([sto, T], 3.9e-14), @@ -34,10 +34,10 @@ def test_functions(self): 0.4172, ), "Negative electrode OCP entropic change [V.K-1]": ( - [sto, c_n_max], + [sto], -1.1033e-4, ), - "Negative electrode volume change": ([sto, c_n_max], 5.1921e-2), + "Negative electrode volume change": ([sto], 5.1921e-2), } for name, value in fun_test.items(): diff --git a/tests/unit/test_parameters/test_parameter_sets/test_Chayambuka2022.py b/tests/unit/test_parameters/test_parameter_sets/test_Chayambuka2022.py new file mode 100644 index 0000000000..db2eea0d65 --- /dev/null +++ b/tests/unit/test_parameters/test_parameter_sets/test_Chayambuka2022.py @@ -0,0 +1,41 @@ +# +# Tests for Chayambuka et al (2022) parameter set +# + +import pytest +import pybamm + + +class TestChayambuka2022: + def test_functions(self): + param = pybamm.ParameterValues("Chayambuka2022") + sto = pybamm.Scalar(0.5) + T = pybamm.Scalar(298.15) + c_e = 1000 + c_n_max = 14540 + c_p_max = 15320 + + fun_test = { + # Negative electrode + "Negative particle diffusivity [m2.s-1]": ([sto, T], 1.8761e-15), + "Negative electrode OCP [V]": ([sto], 0.0859), + "Negative electrode exchange-current density [A.m-2]" "": ( + [c_e, sto * c_n_max, c_n_max, T], + 0.0202, + ), + # Positive electrode + "Positive particle diffusivity [m2.s-1]": ([sto, T], 1.8700e-15), + "Positive electrode OCP [V]": ([sto], 4.1482), + "Positive electrode exchange-current density [A.m-2]" "": ( + [c_e, sto * c_p_max, c_p_max, T], + 0.0036, + ), + # Electrolyte + "Electrolyte diffusivity [m2.s-1]": ([c_e, T], 2.5061e-10), + "Electrolyte conductivity [S.m-1]": ([c_e, T], 0.8830), + } + + for name, value in fun_test.items(): + assert param.evaluate(param[name](*value[0])) == pytest.approx( + value[1], abs=0.0001 + ) diff --git a/tests/unit/test_parameters/test_parameter_sets/test_Ecker2015.py b/tests/unit/test_parameters/test_parameter_sets/test_Ecker2015.py index 4be67175d7..d703b46200 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_Ecker2015.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_Ecker2015.py @@ -2,11 +2,11 @@ # Tests for O'Kane (2022) parameter set # +import pytest import pybamm -import unittest -class TestEcker2015(unittest.TestCase): +class TestEcker2015: def test_functions(self): param = pybamm.ParameterValues("Ecker2015") sto = pybamm.Scalar(0.5) @@ -40,16 +40,6 @@ def test_functions(self): } for name, value in fun_test.items(): - self.assertAlmostEqual( - param.evaluate(param[name](*value[0])), value[1], places=4 + assert param.evaluate(param[name](*value[0])) == pytest.approx( + value[1], abs=0.0001 ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_parameter_sets/test_LCO_Ramadass2004.py b/tests/unit/test_parameters/test_parameter_sets/test_LCO_Ramadass2004.py index e6c4b04fdf..287c4e97d8 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_LCO_Ramadass2004.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_LCO_Ramadass2004.py @@ -21,7 +21,7 @@ def test_functions(self): 1.4517, ), "Positive electrode OCP entropic change [V.K-1]": ( - [sto, c_p_max], + [sto], -3.4664e-5, ), "Positive electrode OCP [V]": ([sto], 4.1249), @@ -32,7 +32,7 @@ def test_functions(self): 2.2007, ), "Negative electrode OCP entropic change [V.K-1]": ( - [sto, c_n_max], + [sto], -1.5079e-5, ), "Negative electrode OCP [V]": ([sto], 0.1215), diff --git a/tests/unit/test_parameters/test_parameter_sets/test_LGM50_ORegan2022.py b/tests/unit/test_parameters/test_parameter_sets/test_LGM50_ORegan2022.py index 05a38b6245..8a7e401d66 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_LGM50_ORegan2022.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_LGM50_ORegan2022.py @@ -15,7 +15,7 @@ def test_functions(self): fun_test = { # Positive electrode "Positive electrode OCP entropic change [V.K-1]": ( - [0.5, c_p_max], + [0.5], -9.7940e-07, ), "Positive electrode specific heat capacity [J.kg-1.K-1]": ( @@ -32,7 +32,7 @@ def test_functions(self): "Positive electrode thermal conductivity [W.m-1.K-1]": ([T], 0.8047), # Negative electrode "Negative electrode OCP entropic change [V.K-1]": ( - [0.5, c_n_max], + [0.5], -2.6460e-07, ), "Negative electrode specific heat capacity [J.kg-1.K-1]": ( diff --git a/tests/unit/test_parameters/test_parameter_sets/test_OKane2022.py b/tests/unit/test_parameters/test_parameter_sets/test_OKane2022.py index e34f837b38..91fa8ef87e 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_OKane2022.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_OKane2022.py @@ -2,11 +2,11 @@ # Tests for O'Kane (2022) parameter set # +import pytest import pybamm -import unittest -class TestOKane2022(unittest.TestCase): +class TestOKane2022: def test_functions(self): param = pybamm.ParameterValues("OKane2022") sto = pybamm.Scalar(0.9) @@ -27,7 +27,7 @@ def test_functions(self): 0.33947, ), "Negative electrode cracking rate": ([T], 3.9e-20), - "Negative electrode volume change": ([sto, 33133], 0.0897), + "Negative electrode volume change": ([sto], 0.0897), # Positive electrode "Positive particle diffusivity [m2.s-1]": ([sto, T], 4e-15), "Positive electrode exchange-current density [A.m-2]": ( @@ -36,20 +36,10 @@ def test_functions(self): ), "Positive electrode OCP [V]": ([sto], 3.5682), "Positive electrode cracking rate": ([T], 3.9e-20), - "Positive electrode volume change": ([sto, 63104], 0.70992), + "Positive electrode volume change": ([sto], 0.70992), } for name, value in fun_test.items(): - self.assertAlmostEqual( - param.evaluate(param[name](*value[0])), value[1], places=4 + assert param.evaluate(param[name](*value[0])) == pytest.approx( + value[1], abs=0.0001 ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_parameter_sets/test_OKane2022_negative_halfcell.py b/tests/unit/test_parameters/test_parameter_sets/test_OKane2022_negative_halfcell.py index bf39457dc4..beebeb35e3 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_OKane2022_negative_halfcell.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_OKane2022_negative_halfcell.py @@ -26,7 +26,7 @@ def test_functions(self): 0.33947, ), "Positive electrode cracking rate": ([T], 3.9e-20), - "Positive electrode volume change": ([sto, 33133], 0.0897), + "Positive electrode volume change": ([sto], 0.0897), } for name, value in fun_test.items(): diff --git a/tests/unit/test_parameters/test_parameter_sets/test_parameters_with_default_models.py b/tests/unit/test_parameters/test_parameter_sets/test_parameters_with_default_models.py index d7133a73e0..77fc3d66e7 100644 --- a/tests/unit/test_parameters/test_parameter_sets/test_parameters_with_default_models.py +++ b/tests/unit/test_parameters/test_parameter_sets/test_parameters_with_default_models.py @@ -3,11 +3,10 @@ # import pybamm -import unittest -class TestParameterValuesWithModel(unittest.TestCase): - def test_parameter_values_with_model(self): +class TestParameterValuesWithModel: + def test_parameter_values_with_model(self, subtests): param_to_model = { "Ai2020": pybamm.lithium_ion.DFN( {"particle mechanics": "swelling and cracking"} @@ -46,16 +45,6 @@ def test_parameter_values_with_model(self): # Loop over each parameter set, testing that parameters can be set for param, model in param_to_model.items(): - with self.subTest(param=param): + with subtests.test(param=param): parameter_values = pybamm.ParameterValues(param) parameter_values.process_model(model) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_parameters/test_parameter_values.py b/tests/unit/test_parameters/test_parameter_values.py index eaeb4a5a42..cdcfb30ede 100644 --- a/tests/unit/test_parameters/test_parameter_values.py +++ b/tests/unit/test_parameters/test_parameter_values.py @@ -3,8 +3,8 @@ # +import pytest import os -import unittest import numpy as np import pandas as pd @@ -17,90 +17,79 @@ ) from pybamm.expression_tree.exceptions import OptionError import casadi +from pybamm.parameters.parameter_values import ParameterValues -class TestParameterValues(unittest.TestCase): +class TestParameterValues: def test_init(self): # from dict param = pybamm.ParameterValues({"a": 1}) - self.assertEqual(param["a"], 1) - self.assertIn("a", param.keys()) - self.assertIn(1, param.values()) - self.assertIn(("a", 1), param.items()) + assert param["a"] == 1 + assert "a" in param.keys() + assert 1 in param.values() + assert ("a", 1) in param.items() # from dict with strings param = pybamm.ParameterValues({"a": "1"}) - self.assertEqual(param["a"], 1) + assert param["a"] == 1 # from dict "chemistry" key gets removed param = pybamm.ParameterValues({"a": 1, "chemistry": "lithium-ion"}) - self.assertNotIn("chemistry", param.keys()) - - # chemistry kwarg removed - with self.assertRaisesRegex( - ValueError, "'chemistry' keyword argument has been deprecated" - ): - pybamm.ParameterValues(None, chemistry="lithium-ion") + assert "chemistry" not in param.keys() # junk param values rejected - with self.assertRaisesRegex(ValueError, "'Junk' is not a valid parameter set."): + with pytest.raises(ValueError, match="'Junk' is not a valid parameter set."): pybamm.ParameterValues("Junk") def test_repr(self): param = pybamm.ParameterValues({"a": 1}) - self.assertEqual( - repr(param), - "{'Boltzmann constant [J.K-1]': 1.380649e-23,\n" + assert ( + repr(param) == "{'Boltzmann constant [J.K-1]': 1.380649e-23,\n" " 'Electron charge [C]': 1.602176634e-19,\n" " 'Faraday constant [C.mol-1]': 96485.33212,\n" " 'Ideal gas constant [J.K-1.mol-1]': 8.314462618,\n" - " 'a': 1}", - ) - self.assertEqual( - param._ipython_key_completions_(), - [ - "Ideal gas constant [J.K-1.mol-1]", - "Faraday constant [C.mol-1]", - "Boltzmann constant [J.K-1]", - "Electron charge [C]", - "a", - ], + " 'a': 1}" ) + assert param._ipython_key_completions_() == [ + "Ideal gas constant [J.K-1.mol-1]", + "Faraday constant [C.mol-1]", + "Boltzmann constant [J.K-1]", + "Electron charge [C]", + "a", + ] def test_eq(self): - self.assertEqual( - pybamm.ParameterValues({"a": 1}), pybamm.ParameterValues({"a": 1}) - ) + assert pybamm.ParameterValues({"a": 1}) == pybamm.ParameterValues({"a": 1}) def test_update(self): # equate values param = pybamm.ParameterValues({"a": 1}) - self.assertEqual(param["a"], 1) + assert param["a"] == 1 # no conflict param.update({"a": 2}) - self.assertEqual(param["a"], 2) + assert param["a"] == 2 param.update({"a": 2}, check_conflict=True) - self.assertEqual(param["a"], 2) + assert param["a"] == 2 # with conflict param.update({"a": 3}) # via __setitem__ param["a"] = 2 - self.assertEqual(param["a"], 2) - with self.assertRaisesRegex( - ValueError, "parameter 'a' already defined with value '2'" + assert param["a"] == 2 + with pytest.raises( + ValueError, match="parameter 'a' already defined with value '2'" ): param.update({"a": 4}, check_conflict=True) # with parameter not existing yet - with self.assertRaisesRegex(KeyError, "Cannot update parameter"): + with pytest.raises(KeyError, match="Cannot update parameter"): param.update({"b": 1}) # update with a ParameterValues object new_param = pybamm.ParameterValues(param) - self.assertEqual(new_param["a"], 2) + assert new_param["a"] == 2 # test deleting a parameter del param["a"] - self.assertNotIn("a", param.keys()) + assert "a" not in param.keys() def test_set_initial_stoichiometries(self): param = pybamm.ParameterValues("Chen2020") @@ -113,12 +102,12 @@ def test_set_initial_stoichiometries(self): x = param["Initial concentration in negative electrode [mol.m-3]"] x_0 = param_0["Initial concentration in negative electrode [mol.m-3]"] x_100 = param_100["Initial concentration in negative electrode [mol.m-3]"] - self.assertAlmostEqual(x, x_0 + 0.4 * (x_100 - x_0)) + assert x == pytest.approx(x_0 + 0.4 * (x_100 - x_0)) y = param["Initial concentration in positive electrode [mol.m-3]"] y_0 = param_0["Initial concentration in positive electrode [mol.m-3]"] y_100 = param_100["Initial concentration in positive electrode [mol.m-3]"] - self.assertAlmostEqual(y, y_0 - 0.4 * (y_0 - y_100)) + assert y == pytest.approx(y_0 - 0.4 * (y_0 - y_100)) def test_set_initial_stoichiometry_half_cell(self): param = pybamm.lithium_ion.DFN( @@ -137,7 +126,7 @@ def test_set_initial_stoichiometry_half_cell(self): y = param["Initial concentration in positive electrode [mol.m-3]"] y_0 = param_0["Initial concentration in positive electrode [mol.m-3]"] y_100 = param_100["Initial concentration in positive electrode [mol.m-3]"] - self.assertAlmostEqual(y, y_0 - 0.4 * (y_0 - y_100)) + assert y == pytest.approx(y_0 - 0.4 * (y_0 - y_100)) # inplace for 100% coverage param_t = pybamm.lithium_ion.DFN( @@ -161,11 +150,11 @@ def test_set_initial_stoichiometry_half_cell(self): 1, inplace=True, options={"working electrode": "positive"} ) y_100 = param_100["Initial concentration in positive electrode [mol.m-3]"] - self.assertAlmostEqual(y, y_0 - 0.4 * (y_0 - y_100)) + assert y == pytest.approx(y_0 - 0.4 * (y_0 - y_100)) # test error param = pybamm.ParameterValues("Chen2020") - with self.assertRaisesRegex(OptionError, "working electrode"): + with pytest.raises(OptionError, match="working electrode"): param.set_initial_stoichiometry_half_cell( 0.1, options={"working electrode": "negative"} ) @@ -183,20 +172,20 @@ def test_set_initial_ocps(self): Un_0 = param_0["Initial voltage in negative electrode [V]"] Up_0 = param_0["Initial voltage in positive electrode [V]"] - self.assertAlmostEqual(Up_0 - Un_0, 2.8) + assert Up_0 - Un_0 == pytest.approx(2.8) Un_100 = param_100["Initial voltage in negative electrode [V]"] Up_100 = param_100["Initial voltage in positive electrode [V]"] - self.assertAlmostEqual(Up_100 - Un_100, 4.2) + assert Up_100 - Un_100 == pytest.approx(4.2) def test_check_parameter_values(self): - with self.assertRaisesRegex(ValueError, "propotional term"): + with pytest.raises(ValueError, match="propotional term"): pybamm.ParameterValues( {"Negative electrode LAM constant propotional term": 1} ) # The + character in "1 + dlnf/dlnc" is appended with a backslash (\+), # since + has other meanings in regex - with self.assertRaisesRegex(ValueError, "Thermodynamic factor"): + with pytest.raises(ValueError, match="Thermodynamic factor"): pybamm.ParameterValues({"1 + dlnf/dlnc": 1}) def test_process_symbol(self): @@ -204,86 +193,86 @@ def test_process_symbol(self): # process parameter a = pybamm.Parameter("a") processed_a = parameter_values.process_symbol(a) - self.assertIsInstance(processed_a, pybamm.Scalar) - self.assertEqual(processed_a.value, 4) + assert isinstance(processed_a, pybamm.Scalar) + assert processed_a.value == 4 # process binary operation var = pybamm.Variable("var") add = a + var processed_add = parameter_values.process_symbol(add) - self.assertIsInstance(processed_add, pybamm.Addition) - self.assertIsInstance(processed_add.children[0], pybamm.Scalar) - self.assertIsInstance(processed_add.children[1], pybamm.Variable) - self.assertEqual(processed_add.children[0].value, 4) + assert isinstance(processed_add, pybamm.Addition) + assert isinstance(processed_add.children[0], pybamm.Scalar) + assert isinstance(processed_add.children[1], pybamm.Variable) + assert processed_add.children[0].value == 4 b = pybamm.Parameter("b") add = a + b processed_add = parameter_values.process_symbol(add) - self.assertIsInstance(processed_add, pybamm.Scalar) - self.assertEqual(processed_add.value, 6) + assert isinstance(processed_add, pybamm.Scalar) + assert processed_add.value == 6 scal = pybamm.Scalar(34) mul = a * scal processed_mul = parameter_values.process_symbol(mul) - self.assertIsInstance(processed_mul, pybamm.Scalar) - self.assertEqual(processed_mul.value, 136) + assert isinstance(processed_mul, pybamm.Scalar) + assert processed_mul.value == 136 # process integral aa = pybamm.PrimaryBroadcast(pybamm.Parameter("a"), "negative electrode") x = pybamm.SpatialVariable("x", domain=["negative electrode"]) integ = pybamm.Integral(aa, x) processed_integ = parameter_values.process_symbol(integ) - self.assertIsInstance(processed_integ, pybamm.Integral) - self.assertIsInstance(processed_integ.children[0], pybamm.PrimaryBroadcast) - self.assertEqual(processed_integ.children[0].child.value, 4) - self.assertEqual(processed_integ.integration_variable[0], x) + assert isinstance(processed_integ, pybamm.Integral) + assert isinstance(processed_integ.children[0], pybamm.PrimaryBroadcast) + assert processed_integ.children[0].child.value == 4 + assert processed_integ.integration_variable[0] == x # process unary operation v = pybamm.Variable("v", domain="test") grad = pybamm.Gradient(v) processed_grad = parameter_values.process_symbol(grad) - self.assertIsInstance(processed_grad, pybamm.Gradient) - self.assertIsInstance(processed_grad.children[0], pybamm.Variable) + assert isinstance(processed_grad, pybamm.Gradient) + assert isinstance(processed_grad.children[0], pybamm.Variable) # process delta function aa = pybamm.Parameter("a") delta_aa = pybamm.DeltaFunction(aa, "left", "some domain") processed_delta_aa = parameter_values.process_symbol(delta_aa) - self.assertIsInstance(processed_delta_aa, pybamm.DeltaFunction) - self.assertEqual(processed_delta_aa.side, "left") + assert isinstance(processed_delta_aa, pybamm.DeltaFunction) + assert processed_delta_aa.side == "left" processed_a = processed_delta_aa.children[0] - self.assertIsInstance(processed_a, pybamm.Scalar) - self.assertEqual(processed_a.value, 4) + assert isinstance(processed_a, pybamm.Scalar) + assert processed_a.value == 4 # process boundary operator (test for BoundaryValue) aa = pybamm.Parameter("a") x = pybamm.SpatialVariable("x", domain=["negative electrode"]) boundary_op = pybamm.BoundaryValue(aa * x, "left") processed_boundary_op = parameter_values.process_symbol(boundary_op) - self.assertIsInstance(processed_boundary_op, pybamm.BoundaryOperator) + assert isinstance(processed_boundary_op, pybamm.BoundaryOperator) processed_a = processed_boundary_op.children[0].children[0] processed_x = processed_boundary_op.children[0].children[1] - self.assertIsInstance(processed_a, pybamm.Scalar) - self.assertEqual(processed_a.value, 4) - self.assertEqual(processed_x, x) + assert isinstance(processed_a, pybamm.Scalar) + assert processed_a.value == 4 + assert processed_x == x # process EvaluateAt evaluate_at = pybamm.EvaluateAt(x, aa) processed_evaluate_at = parameter_values.process_symbol(evaluate_at) - self.assertIsInstance(processed_evaluate_at, pybamm.EvaluateAt) - self.assertEqual(processed_evaluate_at.children[0], x) - self.assertEqual(processed_evaluate_at.position, 4) - with self.assertRaisesRegex(ValueError, "'position' in 'EvaluateAt'"): + assert isinstance(processed_evaluate_at, pybamm.EvaluateAt) + assert processed_evaluate_at.children[0] == x + assert processed_evaluate_at.position == 4 + with pytest.raises(ValueError, match="'position' in 'EvaluateAt'"): parameter_values.process_symbol(pybamm.EvaluateAt(x, x)) # process broadcast whole_cell = ["negative electrode", "separator", "positive electrode"] broad = pybamm.PrimaryBroadcast(a, whole_cell) processed_broad = parameter_values.process_symbol(broad) - self.assertIsInstance(processed_broad, pybamm.Broadcast) - self.assertEqual(processed_broad.domain, whole_cell) - self.assertIsInstance(processed_broad.children[0], pybamm.Scalar) - self.assertEqual(processed_broad.children[0].evaluate(), 4) + assert isinstance(processed_broad, pybamm.Broadcast) + assert processed_broad.domain == whole_cell + assert isinstance(processed_broad.children[0], pybamm.Scalar) + assert processed_broad.children[0].evaluate() == 4 # process concatenation conc = pybamm.concatenation( @@ -291,8 +280,8 @@ def test_process_symbol(self): pybamm.Vector(2 * np.ones(15), domain="test 2"), ) processed_conc = parameter_values.process_symbol(conc) - self.assertIsInstance(processed_conc.children[0], pybamm.Vector) - self.assertIsInstance(processed_conc.children[1], pybamm.Vector) + assert isinstance(processed_conc.children[0], pybamm.Vector) + assert isinstance(processed_conc.children[1], pybamm.Vector) np.testing.assert_array_equal(processed_conc.children[0].entries, 1) np.testing.assert_array_equal(processed_conc.children[1].entries, 2) @@ -304,52 +293,52 @@ def test_process_symbol(self): processed_dom_con = parameter_values.process_symbol(dom_con) a_proc = processed_dom_con.children[0].children[0] b_proc = processed_dom_con.children[1].children[0] - self.assertIsInstance(a_proc, pybamm.Scalar) - self.assertIsInstance(b_proc, pybamm.Scalar) - self.assertEqual(a_proc.value, 4) - self.assertEqual(b_proc.value, 2) + assert isinstance(a_proc, pybamm.Scalar) + assert isinstance(b_proc, pybamm.Scalar) + assert a_proc.value == 4 + assert b_proc.value == 2 # process variable c = pybamm.Variable("c") processed_c = parameter_values.process_symbol(c) - self.assertIsInstance(processed_c, pybamm.Variable) - self.assertEqual(processed_c.name, "c") + assert isinstance(processed_c, pybamm.Variable) + assert processed_c.name == "c" # process scalar d = pybamm.Scalar(14) processed_d = parameter_values.process_symbol(d) - self.assertIsInstance(processed_d, pybamm.Scalar) - self.assertEqual(processed_d.value, 14) + assert isinstance(processed_d, pybamm.Scalar) + assert processed_d.value == 14 # process array types e = pybamm.Vector(np.ones(4)) processed_e = parameter_values.process_symbol(e) - self.assertIsInstance(processed_e, pybamm.Vector) + assert isinstance(processed_e, pybamm.Vector) np.testing.assert_array_equal(processed_e.evaluate(), np.ones((4, 1))) f = pybamm.Matrix(np.ones((5, 6))) processed_f = parameter_values.process_symbol(f) - self.assertIsInstance(processed_f, pybamm.Matrix) + assert isinstance(processed_f, pybamm.Matrix) np.testing.assert_array_equal(processed_f.evaluate(), np.ones((5, 6))) # process statevector g = pybamm.StateVector(slice(0, 10)) processed_g = parameter_values.process_symbol(g) - self.assertIsInstance(processed_g, pybamm.StateVector) + assert isinstance(processed_g, pybamm.StateVector) np.testing.assert_array_equal( processed_g.evaluate(y=np.ones(10)), np.ones((10, 1)) ) # not found - with self.assertRaises(KeyError): + with pytest.raises(KeyError): x = pybamm.Parameter("x") parameter_values.process_symbol(x) parameter_values = pybamm.ParameterValues({"x": np.nan}) - with self.assertRaisesRegex(ValueError, "Parameter 'x' not found"): + with pytest.raises(ValueError, match="Parameter 'x' not found"): x = pybamm.Parameter("x") parameter_values.process_symbol(x) - with self.assertRaisesRegex(ValueError, "possibly a function"): + with pytest.raises(ValueError, match="possibly a function"): x = pybamm.FunctionParameter("x", {}) parameter_values.process_symbol(x) @@ -361,11 +350,11 @@ def test_process_parameter_in_parameter(self): # process 2a parameter a = pybamm.Parameter("2a") processed_a = parameter_values.process_symbol(a) - self.assertEqual(processed_a.evaluate(), 4) + assert processed_a.evaluate() == 4 # case where parameter can't be processed b = pybamm.Parameter("b") - with self.assertRaisesRegex(TypeError, "Cannot process parameter"): + with pytest.raises(TypeError, match="Cannot process parameter"): parameter_values.process_symbol(b) def test_process_input_parameter(self): @@ -375,19 +364,19 @@ def test_process_input_parameter(self): # process input parameter a = pybamm.Parameter("a") processed_a = parameter_values.process_symbol(a) - self.assertIsInstance(processed_a, pybamm.InputParameter) - self.assertEqual(processed_a.evaluate(inputs={"a": 5}), 5) + assert isinstance(processed_a, pybamm.InputParameter) + assert processed_a.evaluate(inputs={"a": 5}) == 5 # process binary operation b = pybamm.Parameter("b") add = a + b processed_add = parameter_values.process_symbol(add) - self.assertEqual(processed_add, 3 + pybamm.InputParameter("a")) + assert processed_add == 3 + pybamm.InputParameter("a") # process complex input parameter c = pybamm.Parameter("c times 2") processed_c = parameter_values.process_symbol(c) - self.assertEqual(processed_c.evaluate(inputs={"c": 5}), 10) + assert processed_c.evaluate(inputs={"c": 5}) == 10 def test_process_function_parameter(self): def test_function(var): @@ -408,7 +397,7 @@ def test_function(var): # process function func = pybamm.FunctionParameter("func", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(inputs={"a": 3}), 369) + assert processed_func.evaluate(inputs={"a": 3}) == 369 # process constant function # this should work even if the parameter in the function is not provided @@ -416,35 +405,35 @@ def test_function(var): "const", {"a": pybamm.Parameter("not provided")} ) processed_const = parameter_values.process_symbol(const) - self.assertIsInstance(processed_const, pybamm.Scalar) - self.assertEqual(processed_const.evaluate(), 254) + assert isinstance(processed_const, pybamm.Scalar) + assert processed_const.evaluate() == 254 # process case where parameter provided is a pybamm symbol # (e.g. a multiplication) mult = pybamm.FunctionParameter("mult", {"a": a}) processed_mult = parameter_values.process_symbol(mult) - self.assertEqual(processed_mult.evaluate(inputs={"a": 14, "b": 63}), 63 * 5) + assert processed_mult.evaluate(inputs={"a": 14, "b": 63}) == 63 * 5 # process differentiated function parameter diff_func = func.diff(a) processed_diff_func = parameter_values.process_symbol(diff_func) - self.assertEqual(processed_diff_func.evaluate(inputs={"a": 3}), 123) + assert processed_diff_func.evaluate(inputs={"a": 3}) == 123 # make sure diff works, despite simplifications, when the child is constant a_const = pybamm.Scalar(3) func_const = pybamm.FunctionParameter("func", {"a": a_const}) diff_func_const = func_const.diff(a_const) processed_diff_func_const = parameter_values.process_symbol(diff_func_const) - self.assertEqual(processed_diff_func_const.evaluate(), 123) + assert processed_diff_func_const.evaluate() == 123 # function parameter that returns a python float func = pybamm.FunctionParameter("float_func", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(), 42) + assert processed_func.evaluate() == 42 # weird type raises error func = pybamm.FunctionParameter("bad type", {"a": a}) - with self.assertRaisesRegex(TypeError, "Parameter provided for"): + with pytest.raises(TypeError, match="Parameter provided for"): parameter_values.process_symbol(func) # function itself as input (different to the variable being an input) @@ -452,7 +441,7 @@ def test_function(var): a = pybamm.Scalar(3) func = pybamm.FunctionParameter("func", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(inputs={"func": 13}), 13) + assert processed_func.evaluate(inputs={"func": 13}) == 13 # make sure function keeps the domain of the original function @@ -473,11 +462,11 @@ def my_func(x): ) func3 = parameter_values.process_symbol(func) - self.assertEqual(func1.domains, func2.domains) - self.assertEqual(func1.domains, func3.domains) + assert func1.domains == func2.domains + assert func1.domains == func3.domains # [function] is deprecated - with self.assertRaisesRegex(ValueError, "[function]"): + with pytest.raises(ValueError, match="[function]"): pybamm.ParameterValues({"func": "[function]something"}) def test_process_inline_function_parameters(self): @@ -490,12 +479,12 @@ def D(c): func = pybamm.FunctionParameter("Diffusivity", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(), 9) + assert processed_func.evaluate() == 9 # process differentiated function parameter diff_func = func.diff(a) processed_diff_func = parameter_values.process_symbol(diff_func) - self.assertEqual(processed_diff_func.evaluate(), 6) + assert processed_diff_func.evaluate() == 6 def test_multi_var_function_with_parameters(self): def D(a, b): @@ -508,8 +497,8 @@ def D(a, b): processed_func = parameter_values.process_symbol(func) # Function of scalars gets automatically simplified - self.assertIsInstance(processed_func, pybamm.Scalar) - self.assertEqual(processed_func.evaluate(), 3) + assert isinstance(processed_func, pybamm.Scalar) + assert processed_func.evaluate() == 3 def test_multi_var_function_parameter(self): def D(a, b): @@ -522,7 +511,7 @@ def D(a, b): func = pybamm.FunctionParameter("Diffusivity", {"a": a, "b": b}) processed_func = parameter_values.process_symbol(func) - self.assertEqual(processed_func.evaluate(), 3) + assert processed_func.evaluate() == 3 def test_process_interpolant(self): x = np.linspace(0, 10)[:, np.newaxis] @@ -533,18 +522,18 @@ def test_process_interpolant(self): func = pybamm.FunctionParameter("Times two", {"a": a}) processed_func = parameter_values.process_symbol(func) - self.assertIsInstance(processed_func, pybamm.Interpolant) - self.assertEqual(processed_func.evaluate(inputs={"a": 3.01}), 6.02) + assert isinstance(processed_func, pybamm.Interpolant) + assert processed_func.evaluate(inputs={"a": 3.01}) == 6.02 # interpolant defined up front interp = pybamm.Interpolant(data[:, 0], data[:, 1], a, interpolator="cubic") processed_interp = parameter_values.process_symbol(interp) - self.assertEqual(processed_interp.evaluate(inputs={"a": 3.01}), 6.02) + assert processed_interp.evaluate(inputs={"a": 3.01}) == 6.02 # process differentiated function parameter diff_interp = interp.diff(a) processed_diff_interp = parameter_values.process_symbol(diff_interp) - self.assertEqual(processed_diff_interp.evaluate(inputs={"a": 3.01}), 2) + assert processed_diff_interp.evaluate(inputs={"a": 3.01}) == 2 def test_process_interpolant_2d(self): x_ = [np.linspace(0, 10), np.linspace(0, 20)] @@ -566,9 +555,9 @@ def test_process_interpolant_2d(self): func = pybamm.FunctionParameter("Times two", {"a": a, "b": b}) processed_func = parameter_values.process_symbol(func) - self.assertIsInstance(processed_func, pybamm.Interpolant) - self.assertAlmostEqual( - processed_func.evaluate(inputs={"a": 3.01, "b": 4.4}), 14.82 + assert isinstance(processed_func, pybamm.Interpolant) + assert processed_func.evaluate(inputs={"a": 3.01, "b": 4.4}) == pytest.approx( + 14.82 ) # process differentiated function parameter @@ -579,9 +568,7 @@ def test_process_interpolant_2d(self): # interpolant defined up front interp2 = pybamm.Interpolant(data[0], data[1], children=(a, b)) processed_interp2 = parameter_values.process_symbol(interp2) - self.assertEqual( - processed_interp2.evaluate(inputs={"a": 3.01, "b": 4.4}), 14.82 - ) + assert processed_interp2.evaluate(inputs={"a": 3.01, "b": 4.4}) == 14.82 y3 = (3 * x).sum(axis=1) @@ -598,7 +585,7 @@ def test_process_interpolant_2d(self): func = pybamm.FunctionParameter("Times three", {"a": a, "b": b}) processed_func = parameter_values.process_symbol(func) - self.assertIsInstance(processed_func, pybamm.Interpolant) + assert isinstance(processed_func, pybamm.Interpolant) # self.assertEqual(processed_func.evaluate().flatten()[0], 22.23) np.testing.assert_almost_equal( processed_func.evaluate(inputs={"a": 3.01, "b": 4.4}).flatten()[0], @@ -765,7 +752,7 @@ def test_process_integral_broadcast(self): param = pybamm.ParameterValues({"func": 2}) func_proc = param.process_symbol(func) - self.assertEqual(func_proc, pybamm.Scalar(2, name="func")) + assert func_proc == pybamm.Scalar(2, name="func") # test with auxiliary domains @@ -780,9 +767,8 @@ def test_process_integral_broadcast(self): param = pybamm.ParameterValues({"func": 2}) func_proc = param.process_symbol(func) - self.assertEqual( - func_proc, - pybamm.PrimaryBroadcast(pybamm.Scalar(2, name="func"), "current collector"), + assert func_proc == pybamm.PrimaryBroadcast( + pybamm.Scalar(2, name="func"), "current collector" ) # secondary and tertiary @@ -799,11 +785,8 @@ def test_process_integral_broadcast(self): param = pybamm.ParameterValues({"func": 2}) func_proc = param.process_symbol(func) - self.assertEqual( - func_proc, - pybamm.FullBroadcast( - pybamm.Scalar(2, name="func"), "negative particle", "current collector" - ), + assert func_proc == pybamm.FullBroadcast( + pybamm.Scalar(2, name="func"), "negative particle", "current collector" ) # secondary, tertiary and quaternary @@ -821,16 +804,13 @@ def test_process_integral_broadcast(self): param = pybamm.ParameterValues({"func": 2}) func_proc = param.process_symbol(func) - self.assertEqual( - func_proc, - pybamm.FullBroadcast( - pybamm.Scalar(2, name="func"), - "negative particle", - { - "secondary": "negative particle size", - "tertiary": "current collector", - }, - ), + assert func_proc == pybamm.FullBroadcast( + pybamm.Scalar(2, name="func"), + "negative particle", + { + "secondary": "negative particle size", + "tertiary": "current collector", + }, ) # special case for integral of concatenations of broadcasts @@ -854,7 +834,7 @@ def test_process_integral_broadcast(self): ) func_proc = param.process_symbol(func) - self.assertEqual(func_proc, pybamm.Scalar(3)) + assert func_proc == pybamm.Scalar(3) # with auxiliary domains var_n = pybamm.Variable( @@ -889,9 +869,8 @@ def test_process_integral_broadcast(self): ) func_proc = param.process_symbol(func) - self.assertEqual( - func_proc, - pybamm.PrimaryBroadcast(pybamm.Scalar(3), "current collector"), + assert func_proc == pybamm.PrimaryBroadcast( + pybamm.Scalar(3), "current collector" ) def test_process_size_average(self): @@ -913,16 +892,16 @@ def dist(R): ) var_av_proc = param.process_symbol(var_av) - self.assertIsInstance(var_av_proc, pybamm.SizeAverage) + assert isinstance(var_av_proc, pybamm.SizeAverage) R = pybamm.SpatialVariable("R", "negative particle size") - self.assertEqual(var_av_proc.f_a_dist, R**2) + assert var_av_proc.f_a_dist == R**2 def test_process_not_constant(self): param = pybamm.ParameterValues({"a": 4}) a = pybamm.NotConstant(pybamm.Parameter("a")) - self.assertIsInstance(param.process_symbol(a), pybamm.NotConstant) - self.assertEqual(param.process_symbol(a).evaluate(), 4) + assert isinstance(param.process_symbol(a), pybamm.NotConstant) + assert param.process_symbol(a).evaluate() == 4 def test_process_complex_expression(self): var1 = pybamm.Variable("var1") @@ -933,12 +912,12 @@ def test_process_complex_expression(self): param = pybamm.ParameterValues({"par1": 2, "par2": 4}) exp_param = param.process_symbol(expression) - self.assertEqual(exp_param, 3.0 * (2.0**var2) / ((-4.0 + var1) + var2)) + assert exp_param == 3.0 * (2.0**var2) / ((-4.0 + var1) + var2) def test_process_geometry(self): var = pybamm.Variable("var") geometry = {"negative electrode": {"x": {"min": 0, "max": var}}} - with self.assertRaisesRegex(ValueError, "Geometry parameters must be Scalars"): + with pytest.raises(ValueError, match="Geometry parameters must be Scalars"): pybamm.ParameterValues({}).process_geometry(geometry) def test_process_model(self): @@ -965,39 +944,37 @@ def test_process_model(self): parameter_values = pybamm.ParameterValues({"a": 1, "b": 2, "c": 3, "d": 42}) parameter_values.process_model(model) # rhs - self.assertIsInstance(model.rhs[var1], pybamm.Gradient) + assert isinstance(model.rhs[var1], pybamm.Gradient) # algebraic - self.assertIsInstance(model.algebraic[var2], pybamm.Multiplication) - self.assertIsInstance(model.algebraic[var2].children[0], pybamm.Scalar) - self.assertIsInstance(model.algebraic[var2].children[1], pybamm.Variable) - self.assertEqual(model.algebraic[var2].children[0].value, 3) + assert isinstance(model.algebraic[var2], pybamm.Multiplication) + assert isinstance(model.algebraic[var2].children[0], pybamm.Scalar) + assert isinstance(model.algebraic[var2].children[1], pybamm.Variable) + assert model.algebraic[var2].children[0].value == 3 # initial conditions - self.assertIsInstance(model.initial_conditions[var1], pybamm.Scalar) - self.assertEqual(model.initial_conditions[var1].value, 2) + assert isinstance(model.initial_conditions[var1], pybamm.Scalar) + assert model.initial_conditions[var1].value == 2 # boundary conditions bc_key = next(iter(model.boundary_conditions.keys())) - self.assertIsInstance(bc_key, pybamm.Variable) + assert isinstance(bc_key, pybamm.Variable) bc_value = next(iter(model.boundary_conditions.values())) - self.assertIsInstance(bc_value["left"][0], pybamm.Scalar) - self.assertEqual(bc_value["left"][0].value, 3) - self.assertIsInstance(bc_value["right"][0], pybamm.Scalar) - self.assertEqual(bc_value["right"][0].value, 42) + assert isinstance(bc_value["left"][0], pybamm.Scalar) + assert bc_value["left"][0].value == 3 + assert isinstance(bc_value["right"][0], pybamm.Scalar) + assert bc_value["right"][0].value == 42 # variables - self.assertEqual(model.variables["var1"], var1) - self.assertIsInstance(model.variables["grad_var1"], pybamm.Gradient) - self.assertIsInstance(model.variables["grad_var1"].children[0], pybamm.Variable) - self.assertEqual( - model.variables["d_var1"], (pybamm.Scalar(42, name="d") * var1) - ) - self.assertIsInstance(model.variables["d_var1"].children[0], pybamm.Scalar) - self.assertIsInstance(model.variables["d_var1"].children[1], pybamm.Variable) + assert model.variables["var1"] == var1 + assert isinstance(model.variables["grad_var1"], pybamm.Gradient) + assert isinstance(model.variables["grad_var1"].children[0], pybamm.Variable) + assert model.variables["d_var1"] == (pybamm.Scalar(42, name="d") * var1) + assert isinstance(model.variables["d_var1"].children[0], pybamm.Scalar) + assert isinstance(model.variables["d_var1"].children[1], pybamm.Variable) # bad boundary conditions model = pybamm.BaseModel() model.algebraic = {var1: var1} x = pybamm.Parameter("x") model.boundary_conditions = {var1: {"left": (x, "Dirichlet")}} - with self.assertRaises(KeyError): + with pytest.raises(KeyError): parameter_values.process_model(model) def test_inplace(self): @@ -1006,16 +983,16 @@ def test_inplace(self): new_model = param.process_model(model, inplace=False) V = model.variables["Voltage [V]"] - self.assertTrue(V.has_symbol_of_classes(pybamm.Parameter)) + assert V.has_symbol_of_classes(pybamm.Parameter) V = new_model.variables["Voltage [V]"] - self.assertFalse(V.has_symbol_of_classes(pybamm.Parameter)) + assert not V.has_symbol_of_classes(pybamm.Parameter) def test_process_empty_model(self): model = pybamm.BaseModel() parameter_values = pybamm.ParameterValues({"a": 1, "b": 2, "c": 3, "d": 42}) - with self.assertRaisesRegex( - pybamm.ModelError, "Cannot process parameters for empty model" + with pytest.raises( + pybamm.ModelError, match="Cannot process parameters for empty model" ): parameter_values.process_model(model) @@ -1024,15 +1001,15 @@ def test_evaluate(self): a = pybamm.Parameter("a") b = pybamm.Parameter("b") c = pybamm.Parameter("c") - self.assertEqual(parameter_values.evaluate(a), 1) - self.assertEqual(parameter_values.evaluate(a + (b * c)), 7) + assert parameter_values.evaluate(a) == 1 + assert parameter_values.evaluate(a + (b * c)) == 7 d = pybamm.Parameter("a") + pybamm.Parameter("b") * pybamm.Array([4, 5]) np.testing.assert_array_equal( parameter_values.evaluate(d), np.array([9, 11])[:, np.newaxis] ) y = pybamm.StateVector(slice(0, 1)) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): parameter_values.evaluate(y) def test_exchange_current_density_plating(self): @@ -1042,18 +1019,28 @@ def test_exchange_current_density_plating(self): param = pybamm.Parameter( "Exchange-current density for lithium metal electrode [A.m-2]" ) - with self.assertRaisesRegex( + with pytest.raises( KeyError, - "referring to the reaction at the surface of a lithium metal electrode", + match="referring to the reaction at the surface of a lithium metal electrode", ): parameter_values.evaluate(param) - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + def test_contains_method(self): + """Test for __contains__ method to check the functionality of 'in' keyword""" + parameter_values = ParameterValues( + {"Negative particle radius [m]": 1e-6, "Positive particle radius [m]": 2e-6} + ) + assert ( + "Negative particle radius [m]" in parameter_values + ), "Key should be found in parameter_values" + assert ( + "Invalid key" not in parameter_values + ), "Non-existent key should not be found" + + def test_iter_method(self): + """Test for __iter__ method to check if we can iterate over keys""" + parameter_values = ParameterValues( + values={"Negative particle radius [m]": 1e-6} + ) + pv = [i for i in parameter_values] + assert len(pv) == 5, "Should have 5 keys" diff --git a/tests/unit/test_parameters/test_process_parameter_data.py b/tests/unit/test_parameters/test_process_parameter_data.py index 3230f374f2..dc363b862b 100644 --- a/tests/unit/test_parameters/test_process_parameter_data.py +++ b/tests/unit/test_parameters/test_process_parameter_data.py @@ -2,67 +2,49 @@ # Tests for the parameter processing functions # - -import os import numpy as np import pybamm +import pytest +from pathlib import Path + -import unittest +@pytest.fixture +def parameters_path(): + return Path(__file__).parent.resolve() -class TestProcessParameterData(unittest.TestCase): - def test_process_1D_data(self): - name = "lico2_ocv_example" - path = os.path.abspath(os.path.dirname(__file__)) - processed = pybamm.parameters.process_1D_data(name, path) - self.assertEqual(processed[0], name) - self.assertIsInstance(processed[1], tuple) - self.assertIsInstance(processed[1][0][0], np.ndarray) - self.assertIsInstance(processed[1][1], np.ndarray) +@pytest.fixture( + params=[ + ("lico2_ocv_example", pybamm.parameters.process_1D_data), + ("lico2_diffusivity_Dualfoil1998_2D", pybamm.parameters.process_2D_data), + ("data_for_testing_2D", pybamm.parameters.process_2D_data_csv), + ("data_for_testing_3D", pybamm.parameters.process_3D_data_csv), + ] +) +def parameter_data(request, parameters_path): + name, processing_function = request.param + processed = processing_function(name, parameters_path) + return name, processed - def test_process_2D_data(self): - name = "lico2_diffusivity_Dualfoil1998_2D" - path = os.path.abspath(os.path.dirname(__file__)) - processed = pybamm.parameters.process_2D_data(name, path) - self.assertEqual(processed[0], name) - self.assertIsInstance(processed[1], tuple) - self.assertIsInstance(processed[1][0][0], np.ndarray) - self.assertIsInstance(processed[1][0][1], np.ndarray) - self.assertIsInstance(processed[1][1], np.ndarray) - def test_process_2D_data_csv(self): - name = "data_for_testing_2D" - path = os.path.abspath(os.path.dirname(__file__)) - processed = pybamm.parameters.process_2D_data_csv(name, path) +class TestProcessParameterData: + def test_processed_name(self, parameter_data): + name, processed = parameter_data + assert processed[0] == name - self.assertEqual(processed[0], name) - self.assertIsInstance(processed[1], tuple) - self.assertIsInstance(processed[1][0][0], np.ndarray) - self.assertIsInstance(processed[1][0][1], np.ndarray) - self.assertIsInstance(processed[1][1], np.ndarray) + def test_processed_structure(self, parameter_data): + name, processed = parameter_data + assert isinstance(processed[1], tuple) + assert isinstance(processed[1][0][0], np.ndarray) + assert isinstance(processed[1][1], np.ndarray) - def test_process_3D_data_csv(self): - name = "data_for_testing_3D" - path = os.path.abspath(os.path.dirname(__file__)) - processed = pybamm.parameters.process_3D_data_csv(name, path) + if len(processed[1][0]) > 1: + assert isinstance(processed[1][0][1], np.ndarray) - self.assertEqual(processed[0], name) - self.assertIsInstance(processed[1], tuple) - self.assertIsInstance(processed[1][0][0], np.ndarray) - self.assertIsInstance(processed[1][0][1], np.ndarray) - self.assertIsInstance(processed[1][0][2], np.ndarray) - self.assertIsInstance(processed[1][1], np.ndarray) + elif len(processed[1]) == 3: + assert isinstance(processed[1][0][1], np.ndarray) + assert isinstance(processed[1][0][2], np.ndarray) def test_error(self): - with self.assertRaisesRegex(FileNotFoundError, "Could not find file"): + with pytest.raises(FileNotFoundError, match="Could not find file"): pybamm.parameters.process_1D_data("not_a_real_file", "not_a_real_path") - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_plotting/test_quick_plot.py b/tests/unit/test_plotting/test_quick_plot.py index eb6c0607e3..188a725680 100644 --- a/tests/unit/test_plotting/test_quick_plot.py +++ b/tests/unit/test_plotting/test_quick_plot.py @@ -1,13 +1,18 @@ import os import pybamm -import unittest +import pytest import numpy as np from tempfile import TemporaryDirectory -class TestQuickPlot(unittest.TestCase): - def test_simple_ode_model(self): +class TestQuickPlot: + _solver_args = [pybamm.CasadiSolver()] + if pybamm.has_idaklu(): + _solver_args.append(pybamm.IDAKLUSolver()) + + @pytest.mark.parametrize("solver", _solver_args) + def test_simple_ode_model(self, solver): model = pybamm.lithium_ion.BaseModel(name="Simple ODE Model") whole_cell = ["negative electrode", "separator", "positive electrode"] @@ -48,9 +53,6 @@ def test_simple_ode_model(self): "NaN variable": pybamm.Scalar(np.nan), } - # ODEs only (don't use Jacobian) - model.use_jacobian = False - # Process and solve geometry = model.default_geometry param = model.default_parameter_values @@ -59,7 +61,6 @@ def test_simple_ode_model(self): mesh = pybamm.Mesh(geometry, model.default_submesh_types, model.default_var_pts) disc = pybamm.Discretisation(mesh, model.default_spatial_methods) disc.process_model(model) - solver = model.default_solver t_eval = np.linspace(0, 2, 100) solution = solver.solve(model, t_eval) quick_plot = pybamm.QuickPlot( @@ -77,11 +78,11 @@ def test_simple_ode_model(self): # update the axis new_axis = [0, 0.5, 0, 1] quick_plot.axis_limits.update({("a",): new_axis}) - self.assertEqual(quick_plot.axis_limits[("a",)], new_axis) + assert quick_plot.axis_limits[("a",)] == new_axis # and now reset them quick_plot.reset_axis() - self.assertNotEqual(quick_plot.axis_limits[("a",)], new_axis) + assert quick_plot.axis_limits[("a",)] != new_axis # check dynamic plot loads quick_plot.dynamic_plot(show_plot=False) @@ -90,7 +91,7 @@ def test_simple_ode_model(self): # Test with different output variables quick_plot = pybamm.QuickPlot(solution, ["b broadcasted"]) - self.assertEqual(len(quick_plot.axis_limits), 1) + assert len(quick_plot.axis_limits) == 1 quick_plot.plot(0) quick_plot = pybamm.QuickPlot( @@ -103,18 +104,18 @@ def test_simple_ode_model(self): "c broadcasted positive electrode", ], ) - self.assertEqual(len(quick_plot.axis_limits), 5) + assert len(quick_plot.axis_limits) == 5 quick_plot.plot(0) # update the axis new_axis = [0, 0.5, 0, 1] var_key = ("c broadcasted",) quick_plot.axis_limits.update({var_key: new_axis}) - self.assertEqual(quick_plot.axis_limits[var_key], new_axis) + assert quick_plot.axis_limits[var_key] == new_axis # and now reset them quick_plot.reset_axis() - self.assertNotEqual(quick_plot.axis_limits[var_key], new_axis) + assert quick_plot.axis_limits[var_key] != new_axis # check dynamic plot loads quick_plot.dynamic_plot(show_plot=False) @@ -135,60 +136,67 @@ def test_simple_ode_model(self): labels=["sol 1", "sol 2"], n_rows=2, ) - self.assertEqual(quick_plot.colors, ["r", "g", "b"]) - self.assertEqual(quick_plot.linestyles, ["-", "--"]) - self.assertEqual(quick_plot.figsize, (1, 2)) - self.assertEqual(quick_plot.labels, ["sol 1", "sol 2"]) - self.assertEqual(quick_plot.n_rows, 2) - self.assertEqual(quick_plot.n_cols, 1) + assert quick_plot.colors == ["r", "g", "b"] + assert quick_plot.linestyles == ["-", "--"] + assert quick_plot.figsize == (1, 2) + assert quick_plot.labels == ["sol 1", "sol 2"] + assert quick_plot.n_rows == 2 + assert quick_plot.n_cols == 1 + + if solution.hermite_interpolation: + t_plot = np.union1d( + solution.t, np.linspace(solution.t[0], solution.t[-1], 100 + 2)[1:-1] + ) + else: + t_plot = t_eval # Test different time units quick_plot = pybamm.QuickPlot(solution, ["a"]) - self.assertEqual(quick_plot.time_scaling_factor, 1) + assert quick_plot.time_scaling_factor == 1 quick_plot = pybamm.QuickPlot(solution, ["a"], time_unit="seconds") quick_plot.plot(0) - self.assertEqual(quick_plot.time_scaling_factor, 1) + assert quick_plot.time_scaling_factor == 1 np.testing.assert_array_almost_equal( - quick_plot.plots[("a",)][0][0].get_xdata(), t_eval + quick_plot.plots[("a",)][0][0].get_xdata(), t_plot ) np.testing.assert_array_almost_equal( - quick_plot.plots[("a",)][0][0].get_ydata(), 0.2 * t_eval + quick_plot.plots[("a",)][0][0].get_ydata(), 0.2 * t_plot ) quick_plot = pybamm.QuickPlot(solution, ["a"], time_unit="minutes") quick_plot.plot(0) - self.assertEqual(quick_plot.time_scaling_factor, 60) + assert quick_plot.time_scaling_factor == 60 np.testing.assert_array_almost_equal( - quick_plot.plots[("a",)][0][0].get_xdata(), t_eval / 60 + quick_plot.plots[("a",)][0][0].get_xdata(), t_plot / 60 ) np.testing.assert_array_almost_equal( - quick_plot.plots[("a",)][0][0].get_ydata(), 0.2 * t_eval + quick_plot.plots[("a",)][0][0].get_ydata(), 0.2 * t_plot ) quick_plot = pybamm.QuickPlot(solution, ["a"], time_unit="hours") quick_plot.plot(0) - self.assertEqual(quick_plot.time_scaling_factor, 3600) + assert quick_plot.time_scaling_factor == 3600 np.testing.assert_array_almost_equal( - quick_plot.plots[("a",)][0][0].get_xdata(), t_eval / 3600 + quick_plot.plots[("a",)][0][0].get_xdata(), t_plot / 3600 ) np.testing.assert_array_almost_equal( - quick_plot.plots[("a",)][0][0].get_ydata(), 0.2 * t_eval + quick_plot.plots[("a",)][0][0].get_ydata(), 0.2 * t_plot ) - with self.assertRaisesRegex(ValueError, "time unit"): + with pytest.raises(ValueError, match="time unit"): pybamm.QuickPlot(solution, ["a"], time_unit="bad unit") # long solution defaults to hours instead of seconds solution_long = solver.solve(model, np.linspace(0, 1e5)) quick_plot = pybamm.QuickPlot(solution_long, ["a"]) - self.assertEqual(quick_plot.time_scaling_factor, 3600) + assert quick_plot.time_scaling_factor == 3600 # Test different spatial units quick_plot = pybamm.QuickPlot(solution, ["a"]) - self.assertEqual(quick_plot.spatial_unit, r"$\mu$m") + assert quick_plot.spatial_unit == r"$\mu$m" quick_plot = pybamm.QuickPlot(solution, ["a"], spatial_unit="m") - self.assertEqual(quick_plot.spatial_unit, "m") + assert quick_plot.spatial_unit == "m" quick_plot = pybamm.QuickPlot(solution, ["a"], spatial_unit="mm") - self.assertEqual(quick_plot.spatial_unit, "mm") + assert quick_plot.spatial_unit == "mm" quick_plot = pybamm.QuickPlot(solution, ["a"], spatial_unit="um") - self.assertEqual(quick_plot.spatial_unit, r"$\mu$m") - with self.assertRaisesRegex(ValueError, "spatial unit"): + assert quick_plot.spatial_unit == r"$\mu$m" + with pytest.raises(ValueError, match="spatial unit"): pybamm.QuickPlot(solution, ["a"], spatial_unit="bad unit") # Test 2D variables @@ -197,24 +205,25 @@ def test_simple_ode_model(self): quick_plot.dynamic_plot(show_plot=False) quick_plot.slider_update(0.01) - with self.assertRaisesRegex(NotImplementedError, "Cannot plot 2D variables"): + with pytest.raises(NotImplementedError, match="Cannot plot 2D variables"): pybamm.QuickPlot([solution, solution], ["2D variable"]) # Test different variable limits quick_plot = pybamm.QuickPlot( solution, ["a", ["c broadcasted", "c broadcasted"]], variable_limits="tight" ) - self.assertEqual(quick_plot.axis_limits[("a",)][2:], [None, None]) - self.assertEqual( - quick_plot.axis_limits[("c broadcasted", "c broadcasted")][2:], [None, None] - ) + assert quick_plot.axis_limits[("a",)][2:] == [None, None] + assert quick_plot.axis_limits[("c broadcasted", "c broadcasted")][2:] == [ + None, + None, + ] quick_plot.plot(0) quick_plot.slider_update(1) quick_plot = pybamm.QuickPlot( solution, ["2D variable"], variable_limits="tight" ) - self.assertEqual(quick_plot.variable_limits[("2D variable",)], (None, None)) + assert quick_plot.variable_limits[("2D variable",)] == (None, None) quick_plot.plot(0) quick_plot.slider_update(1) @@ -223,41 +232,37 @@ def test_simple_ode_model(self): ["a", ["c broadcasted", "c broadcasted"]], variable_limits={"a": [1, 2], ("c broadcasted", "c broadcasted"): [3, 4]}, ) - self.assertEqual(quick_plot.axis_limits[("a",)][2:], [1, 2]) - self.assertEqual( - quick_plot.axis_limits[("c broadcasted", "c broadcasted")][2:], [3, 4] - ) + assert quick_plot.axis_limits[("a",)][2:] == [1, 2] + assert quick_plot.axis_limits[("c broadcasted", "c broadcasted")][2:] == [3, 4] quick_plot.plot(0) quick_plot.slider_update(1) quick_plot = pybamm.QuickPlot( solution, ["a", "b broadcasted"], variable_limits={"a": "tight"} ) - self.assertEqual(quick_plot.axis_limits[("a",)][2:], [None, None]) - self.assertNotEqual( - quick_plot.axis_limits[("b broadcasted",)][2:], [None, None] - ) + assert quick_plot.axis_limits[("a",)][2:] == [None, None] + assert quick_plot.axis_limits[("b broadcasted",)][2:] != [None, None] quick_plot.plot(0) quick_plot.slider_update(1) - with self.assertRaisesRegex( - TypeError, "variable_limits must be 'fixed', 'tight', or a dict" + with pytest.raises( + TypeError, match="variable_limits must be 'fixed', 'tight', or a dict" ): pybamm.QuickPlot( solution, ["a", "b broadcasted"], variable_limits="bad variable limits" ) # Test errors - with self.assertRaisesRegex(ValueError, "Mismatching variable domains"): + with pytest.raises(ValueError, match="Mismatching variable domains"): pybamm.QuickPlot(solution, [["a", "b broadcasted"]]) - with self.assertRaisesRegex(ValueError, "labels"): + with pytest.raises(ValueError, match="labels"): pybamm.QuickPlot( [solution, solution], ["a"], labels=["sol 1", "sol 2", "sol 3"] ) # No variable can be NaN - with self.assertRaisesRegex( - ValueError, "All-NaN variable 'NaN variable' provided" + with pytest.raises( + ValueError, match="All-NaN variable 'NaN variable' provided" ): pybamm.QuickPlot(solution, ["NaN variable"]) @@ -269,7 +274,7 @@ def test_plot_with_different_models(self): model.rhs = {a: pybamm.Scalar(0)} model.initial_conditions = {a: pybamm.Scalar(0)} solution = pybamm.CasadiSolver("fast").solve(model, [0, 1]) - with self.assertRaisesRegex(ValueError, "No default output variables"): + with pytest.raises(ValueError, match="No default output variables"): pybamm.QuickPlot(solution) def test_spm_simulation(self): @@ -462,17 +467,17 @@ def test_plot_2plus1D_spm(self): ][1] np.testing.assert_array_almost_equal(qp_data.T, phi_n[:, :, -1]) - with self.assertRaisesRegex(NotImplementedError, "Shape not recognized for"): + with pytest.raises(NotImplementedError, match="Shape not recognized for"): pybamm.QuickPlot(solution, ["Negative particle concentration [mol.m-3]"]) pybamm.close_plots() def test_invalid_input_type_failure(self): - with self.assertRaisesRegex(TypeError, "Solutions must be"): + with pytest.raises(TypeError, match="Solutions must be"): pybamm.QuickPlot(1) def test_empty_list_failure(self): - with self.assertRaisesRegex(TypeError, "QuickPlot requires at least 1"): + with pytest.raises(TypeError, match="QuickPlot requires at least 1"): pybamm.QuickPlot([]) def test_model_with_inputs(self): @@ -509,20 +514,10 @@ def test_model_with_inputs(self): pybamm.close_plots() -class TestQuickPlotAxes(unittest.TestCase): +class TestQuickPlotAxes: def test_quick_plot_axes(self): axes = pybamm.QuickPlotAxes() axes.add(("test 1", "test 2"), 1) - self.assertEqual(axes[0], 1) - self.assertEqual(axes.by_variable("test 1"), 1) - self.assertEqual(axes.by_variable("test 2"), 1) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + assert axes[0] == 1 + assert axes.by_variable("test 1") == 1 + assert axes.by_variable("test 2") == 1 diff --git a/tests/unit/test_serialisation/test_serialisation.py b/tests/unit/test_serialisation/test_serialisation.py index a1286cad26..adf53b7b46 100644 --- a/tests/unit/test_serialisation/test_serialisation.py +++ b/tests/unit/test_serialisation/test_serialisation.py @@ -4,8 +4,7 @@ import json import os -import unittest -import unittest.mock as mock +import pytest from datetime import datetime import numpy as np import pybamm @@ -14,14 +13,14 @@ from pybamm.expression_tree.operations.serialise import Serialise -def scalar_var_dict(): +def scalar_var_dict(mocker): """variable, json pair for a pybamm.Scalar instance""" a = pybamm.Scalar(5) a_dict = { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.scalar.Scalar", "name": "5.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 5.0, "children": [], } @@ -29,7 +28,7 @@ def scalar_var_dict(): return a, a_dict -def mesh_var_dict(): +def mesh_var_dict(mocker): """mesh, json pair for a pybamm.Mesh instance""" r = pybamm.SpatialVariable( @@ -48,13 +47,13 @@ def mesh_var_dict(): mesh_json = { "py/object": "pybamm.meshes.meshes.Mesh", - "py/id": mock.ANY, + "py/id": mocker.ANY, "submesh_pts": {"negative particle": {"r": 20}}, "base_domains": ["negative particle"], "sub_meshes": { "negative particle": { "py/object": "pybamm.meshes.one_dimensional_submeshes.Uniform1DSubMesh", - "py/id": mock.ANY, + "py/id": mocker.ANY, "edges": [ 0.0, 0.05, @@ -86,7 +85,7 @@ def mesh_var_dict(): return mesh, mesh_json -class TestSerialiseModels(unittest.TestCase): +class TestSerialiseModels: def test_user_defined_model_recreaction(self): # Start with a base model model = pybamm.BaseModel() @@ -146,26 +145,26 @@ def test_user_defined_model_recreaction(self): os.remove("heat_equation.json") -class TestSerialise(unittest.TestCase): +class TestSerialise: # test the symbol encoder - def test_symbol_encoder_symbol(self): + def test_symbol_encoder_symbol(self, mocker): """test basic symbol encoder with & without children""" # without children - a, a_dict = scalar_var_dict() + a, a_dict = scalar_var_dict(mocker) a_ser_json = Serialise._SymbolEncoder().default(a) - self.assertEqual(a_ser_json, a_dict) + assert a_ser_json == a_dict # with children add = pybamm.Addition(2, 4) add_json = { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.binary_operators.Addition", "name": "+", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": [], "secondary": [], @@ -174,18 +173,18 @@ def test_symbol_encoder_symbol(self): }, "children": [ { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.scalar.Scalar", "name": "2.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 2.0, "children": [], }, { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.scalar.Scalar", "name": "4.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 4.0, "children": [], }, @@ -194,32 +193,32 @@ def test_symbol_encoder_symbol(self): add_ser_json = Serialise._SymbolEncoder().default(add) - self.assertEqual(add_ser_json, add_json) + assert add_ser_json == add_json - def test_symbol_encoder_explicitTimeIntegral(self): + def test_symbol_encoder_explicit_time_integral(self, mocker): """test symbol encoder with initial conditions""" expr = pybamm.ExplicitTimeIntegral(pybamm.Scalar(5), pybamm.Scalar(1)) expr_json = { "py/object": "pybamm.expression_tree.unary_operators.ExplicitTimeIntegral", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "explicit time integral", - "id": mock.ANY, + "id": mocker.ANY, "children": [ { "py/object": "pybamm.expression_tree.scalar.Scalar", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "5.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 5.0, "children": [], } ], "initial_condition": { "py/object": "pybamm.expression_tree.scalar.Scalar", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "1.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 1.0, "children": [], }, @@ -227,9 +226,9 @@ def test_symbol_encoder_explicitTimeIntegral(self): expr_ser_json = Serialise._SymbolEncoder().default(expr) - self.assertEqual(expr_json, expr_ser_json) + assert expr_json == expr_ser_json - def test_symbol_encoder_event(self): + def test_symbol_encoder_event(self, mocker): """test symbol encoder with event""" expression = pybamm.Scalar(1) @@ -237,32 +236,32 @@ def test_symbol_encoder_event(self): event_json = { "py/object": "pybamm.models.event.Event", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "my event", "event_type": ["EventType.TERMINATION", 0], "expression": { "py/object": "pybamm.expression_tree.scalar.Scalar", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "1.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 1.0, "children": [], }, } event_ser_json = Serialise._SymbolEncoder().default(event) - self.assertEqual(event_ser_json, event_json) + assert event_ser_json == event_json # test the mesh encoder - def test_mesh_encoder(self): - mesh, mesh_json = mesh_var_dict() + def test_mesh_encoder(self, mocker): + mesh, mesh_json = mesh_var_dict(mocker) # serialise mesh mesh_ser_json = Serialise._MeshEncoder().default(mesh) - self.assertEqual(mesh_ser_json, mesh_json) + assert mesh_ser_json == mesh_json - def test_deconstruct_pybamm_dicts(self): + def test_deconstruct_pybamm_dicts(self, mocker): """tests serialisation of dictionaries with pybamm classes as keys""" x = pybamm.SpatialVariable("x", "negative electrode") @@ -273,9 +272,9 @@ def test_deconstruct_pybamm_dicts(self): "rod": { "symbol_x": { "py/object": "pybamm.expression_tree.independent_variable.SpatialVariable", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "x", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": ["negative electrode"], "secondary": [], @@ -288,40 +287,40 @@ def test_deconstruct_pybamm_dicts(self): } } - self.assertEqual(Serialise()._deconstruct_pybamm_dicts(test_dict), ser_dict) + assert Serialise()._deconstruct_pybamm_dicts(test_dict) == ser_dict - def test_get_pybamm_class(self): + def test_get_pybamm_class(self, mocker): # symbol - _, scalar_dict = scalar_var_dict() + _, scalar_dict = scalar_var_dict(mocker) scalar_class = Serialise()._get_pybamm_class(scalar_dict) - self.assertIsInstance(scalar_class, pybamm.Scalar) + assert isinstance(scalar_class, pybamm.Scalar) # mesh - _, mesh_dict = mesh_var_dict() + _, mesh_dict = mesh_var_dict(mocker) mesh_class = Serialise()._get_pybamm_class(mesh_dict) - self.assertIsInstance(mesh_class, pybamm.Mesh) + assert isinstance(mesh_class, pybamm.Mesh) - with self.assertRaises(AttributeError): + with pytest.raises(AttributeError): unrecognised_symbol = { - "py/id": mock.ANY, + "py/id": mocker.ANY, "py/object": "pybamm.expression_tree.scalar.Scale", "name": "5.0", - "id": mock.ANY, + "id": mocker.ANY, "value": 5.0, "children": [], } Serialise()._get_pybamm_class(unrecognised_symbol) - def test_reconstruct_symbol(self): - scalar, scalar_dict = scalar_var_dict() + def test_reconstruct_symbol(self, mocker): + scalar, scalar_dict = scalar_var_dict(mocker) new_scalar = Serialise()._reconstruct_symbol(scalar_dict) - self.assertEqual(new_scalar, scalar) + assert new_scalar == scalar def test_reconstruct_expression_tree(self): y = pybamm.StateVector(slice(0, 1)) @@ -395,10 +394,10 @@ def test_reconstruct_expression_tree(self): new_equation = Serialise()._reconstruct_expression_tree(equation_json) - self.assertEqual(new_equation, equation) + assert new_equation == equation - def test_reconstruct_mesh(self): - mesh, mesh_dict = mesh_var_dict() + def test_reconstruct_mesh(self, mocker): + mesh, mesh_dict = mesh_var_dict(mocker) new_mesh = Serialise()._reconstruct_mesh(mesh_dict) @@ -410,12 +409,12 @@ def test_reconstruct_mesh(self): ) # reconstructed meshes are only used for plotting, geometry not reconstructed. - with self.assertRaisesRegex( - AttributeError, "'Mesh' object has no attribute '_geometry'" + with pytest.raises( + AttributeError, match="'Mesh' object has no attribute '_geometry'" ): - self.assertEqual(new_mesh.geometry, mesh.geometry) + assert new_mesh.geometry == mesh.geometry - def test_reconstruct_pybamm_dict(self): + def test_reconstruct_pybamm_dict(self, mocker): x = pybamm.SpatialVariable("x", "negative electrode") test_dict = {"rod": {x: {"min": 0.0, "max": 2.0}}} @@ -424,9 +423,9 @@ def test_reconstruct_pybamm_dict(self): "rod": { "symbol_x": { "py/object": "pybamm.expression_tree.independent_variable.SpatialVariable", - "py/id": mock.ANY, + "py/id": mocker.ANY, "name": "x", - "id": mock.ANY, + "id": mocker.ANY, "domains": { "primary": ["negative electrode"], "secondary": [], @@ -441,13 +440,13 @@ def test_reconstruct_pybamm_dict(self): new_dict = Serialise()._reconstruct_pybamm_dict(ser_dict) - self.assertEqual(new_dict, test_dict) + assert new_dict == test_dict # test recreation if not passed a dict test_list = ["left", "right"] new_list = Serialise()._reconstruct_pybamm_dict(test_list) - self.assertEqual(test_list, new_list) + assert test_list == new_list def test_convert_options(self): options_dict = { @@ -462,7 +461,7 @@ def test_convert_options(self): "open-circuit potential": (("single", "current sigmoid"), "single"), } - self.assertEqual(Serialise()._convert_options(options_dict), options_result) + assert Serialise()._convert_options(options_dict) == options_result def test_save_load_model(self): model = pybamm.lithium_ion.SPM(name="test_spm") @@ -473,9 +472,9 @@ def test_save_load_model(self): mesh = pybamm.Mesh(geometry, model.default_submesh_types, model.default_var_pts) # test error if not discretised - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "PyBaMM can only serialise a discretised, ready-to-solve model", + match="PyBaMM can only serialise a discretised, ready-to-solve model", ): Serialise().save_model(model, filename="test_model") @@ -484,12 +483,12 @@ def test_save_load_model(self): # default save Serialise().save_model(model, filename="test_model") - self.assertTrue(os.path.exists("test_model.json")) + assert os.path.exists("test_model.json") # default save where filename isn't provided Serialise().save_model(model) filename = "test_spm_" + datetime.now().strftime("%Y_%m_%d-%p%I_%M") + ".json" - self.assertTrue(os.path.exists(filename)) + assert os.path.exists(filename) os.remove(filename) # default load @@ -500,9 +499,9 @@ def test_save_load_model(self): new_solution = new_solver.solve(new_model, [0, 3600]) # check an error is raised when plotting the solution - with self.assertRaisesRegex( + with pytest.raises( AttributeError, - "No variables to plot", + match="No variables to plot", ): new_solution.plot() @@ -519,7 +518,7 @@ def test_save_load_model(self): with open("test_model.json", "w") as f: json.dump(model_data, f) - with self.assertRaises(TypeError): + with pytest.raises(TypeError): Serialise().load_model("test_model.json") os.remove("test_model.json") @@ -534,9 +533,9 @@ def test_save_experiment_model_error(self): sim = pybamm.Simulation(model, experiment=experiment) sim.solve() - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "Serialising models coupled to experiments is not yet supported.", + match="Serialising models coupled to experiments is not yet supported.", ): sim.save_model("spm_experiment", mesh=False, variables=False) @@ -591,13 +590,3 @@ def test_serialised_model_plotting(self): # check dynamic plot loads new_solution.plot(show_plot=False) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_simulation.py b/tests/unit/test_simulation.py index 744ea2457c..becd70cbe4 100644 --- a/tests/unit/test_simulation.py +++ b/tests/unit/test_simulation.py @@ -3,8 +3,6 @@ import pandas as pd import os -import sys -import unittest import uuid import pytest from tempfile import TemporaryDirectory @@ -12,7 +10,7 @@ from tests import no_internet_connection -class TestSimulation(unittest.TestCase): +class TestSimulation: def test_simple_model(self): model = pybamm.BaseModel() v = pybamm.Variable("v") @@ -27,49 +25,49 @@ def test_basic_ops(self): sim = pybamm.Simulation(model) # check that the model is unprocessed - self.assertEqual(sim._mesh, None) - self.assertEqual(sim._disc, None) + assert sim._mesh is None + assert sim._disc is None V = sim.model.variables["Voltage [V]"] - self.assertTrue(V.has_symbol_of_classes(pybamm.Parameter)) - self.assertFalse(V.has_symbol_of_classes(pybamm.Matrix)) + assert V.has_symbol_of_classes(pybamm.Parameter) + assert not V.has_symbol_of_classes(pybamm.Matrix) sim.set_parameters() - self.assertEqual(sim._mesh, None) - self.assertEqual(sim._disc, None) + assert sim._mesh is None + assert sim._disc is None V = sim.model_with_set_params.variables["Voltage [V]"] - self.assertFalse(V.has_symbol_of_classes(pybamm.Parameter)) - self.assertFalse(V.has_symbol_of_classes(pybamm.Matrix)) + assert not V.has_symbol_of_classes(pybamm.Parameter) + assert not V.has_symbol_of_classes(pybamm.Matrix) # Make sure model is unchanged - self.assertNotEqual(sim.model, model) + assert sim.model != model V = model.variables["Voltage [V]"] - self.assertTrue(V.has_symbol_of_classes(pybamm.Parameter)) - self.assertFalse(V.has_symbol_of_classes(pybamm.Matrix)) + assert V.has_symbol_of_classes(pybamm.Parameter) + assert not V.has_symbol_of_classes(pybamm.Matrix) - self.assertEqual(sim.submesh_types, model.default_submesh_types) - self.assertEqual(sim.var_pts, model.default_var_pts) - self.assertIsNone(sim.mesh) + assert sim.submesh_types == model.default_submesh_types + assert sim.var_pts == model.default_var_pts + assert sim.mesh is None for key in sim.spatial_methods.keys(): - self.assertEqual( - sim.spatial_methods[key].__class__, - model.default_spatial_methods[key].__class__, + assert ( + sim.spatial_methods[key].__class__ + == model.default_spatial_methods[key].__class__ ) sim.build() - self.assertFalse(sim._mesh is None) - self.assertFalse(sim._disc is None) + assert sim._mesh is not None + assert sim._disc is not None V = sim.built_model.variables["Voltage [V]"] - self.assertFalse(V.has_symbol_of_classes(pybamm.Parameter)) - self.assertTrue(V.has_symbol_of_classes(pybamm.Matrix)) + assert not V.has_symbol_of_classes(pybamm.Parameter) + assert V.has_symbol_of_classes(pybamm.Matrix) def test_solve(self): sim = pybamm.Simulation(pybamm.lithium_ion.SPM()) sim.solve([0, 600]) - self.assertFalse(sim._solution is None) + assert sim._solution is not None for val in list(sim.built_model.rhs.values()): - self.assertFalse(val.has_symbol_of_classes(pybamm.Parameter)) + assert not val.has_symbol_of_classes(pybamm.Parameter) # skip test for scalar variables (e.g. discharge capacity) if val.size > 1: - self.assertTrue(val.has_symbol_of_classes(pybamm.Matrix)) + assert val.has_symbol_of_classes(pybamm.Matrix) # test solve without check sim = pybamm.Simulation( @@ -77,15 +75,15 @@ def test_solve(self): ) sol = sim.solve(t_eval=[0, 600]) for val in list(sim.built_model.rhs.values()): - self.assertFalse(val.has_symbol_of_classes(pybamm.Parameter)) + assert not val.has_symbol_of_classes(pybamm.Parameter) # skip test for scalar variables (e.g. discharge capacity) if val.size > 1: - self.assertTrue(val.has_symbol_of_classes(pybamm.Matrix)) + assert val.has_symbol_of_classes(pybamm.Matrix) # Test options that are only available when simulating an experiment - with self.assertRaisesRegex(ValueError, "save_at_cycles"): + with pytest.raises(ValueError, match="save_at_cycles"): sim.solve(save_at_cycles=2) - with self.assertRaisesRegex(ValueError, "starting_solution"): + with pytest.raises(ValueError, match="starting_solution"): sim.solve(starting_solution=sol) def test_solve_remove_independent_variables_from_rhs(self): @@ -157,8 +155,8 @@ def test_set_crate(self): model = pybamm.lithium_ion.SPM() current_1C = model.default_parameter_values["Current function [A]"] sim = pybamm.Simulation(model, C_rate=2) - self.assertEqual(sim.parameter_values["Current function [A]"], 2 * current_1C) - self.assertEqual(sim.C_rate, 2) + assert sim.parameter_values["Current function [A]"] == 2 * current_1C + assert sim.C_rate == 2 def test_step(self): dt = 0.001 @@ -166,24 +164,24 @@ def test_step(self): sim = pybamm.Simulation(model) sim.step(dt) # 1 step stores first two points - self.assertEqual(sim.solution.y.full()[0, :].size, 2) + assert sim.solution.y.full()[0, :].size == 2 np.testing.assert_array_almost_equal(sim.solution.t, np.array([0, dt])) saved_sol = sim.solution sim.step(dt) # automatically append the next step - self.assertEqual(sim.solution.y.full()[0, :].size, 4) + assert sim.solution.y.full()[0, :].size == 4 np.testing.assert_array_almost_equal( sim.solution.t, np.array([0, dt, dt + 1e-9, 2 * dt]) ) sim.step(dt, save=False) # now only store the two end step points - self.assertEqual(sim.solution.y.full()[0, :].size, 2) + assert sim.solution.y.full()[0, :].size == 2 np.testing.assert_array_almost_equal( sim.solution.t, np.array([2 * dt + 1e-9, 3 * dt]) ) # Start from saved solution sim.step(dt, starting_solution=saved_sol) - self.assertEqual(sim.solution.y.full()[0, :].size, 4) + assert sim.solution.y.full()[0, :].size == 4 np.testing.assert_array_almost_equal( sim.solution.t, np.array([0, dt, dt + 1e-9, 2 * dt]) ) @@ -197,15 +195,15 @@ def test_solve_with_initial_soc(self): param = model.default_parameter_values sim = pybamm.Simulation(model, parameter_values=param) sim.solve(t_eval=[0, 600], initial_soc=1) - self.assertEqual(sim._built_initial_soc, 1) + assert sim._built_initial_soc == 1 sim.solve(t_eval=[0, 600], initial_soc=0.5) - self.assertEqual(sim._built_initial_soc, 0.5) + assert sim._built_initial_soc == 0.5 exp = pybamm.Experiment( [pybamm.step.string("Discharge at 1C until 3.6V", period="1 minute")] ) sim = pybamm.Simulation(model, parameter_values=param, experiment=exp) sim.solve(initial_soc=0.8) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 # test with drive cycle data_loader = pybamm.DataLoader() @@ -220,12 +218,12 @@ def test_solve_with_initial_soc(self): param["Current function [A]"] = current_interpolant sim = pybamm.Simulation(model, parameter_values=param) sim.solve(initial_soc=0.8) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 # Test that build works with initial_soc sim = pybamm.Simulation(model, parameter_values=param) sim.build(initial_soc=0.5) - self.assertEqual(sim._built_initial_soc, 0.5) + assert sim._built_initial_soc == 0.5 # Test that initial soc works with a relevant input parameter model = pybamm.lithium_ion.DFN() @@ -236,7 +234,7 @@ def test_solve_with_initial_soc(self): ) sim = pybamm.Simulation(model, parameter_values=param) sim.solve(t_eval=[0, 1], initial_soc=0.8, inputs={"eps_p": og_eps_p}) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 # test having an input parameter in the ocv function model = pybamm.lithium_ion.SPM() @@ -264,14 +262,14 @@ def ocv_with_parameter(sto): model = pybamm.lithium_ion.DFN(options) sim = pybamm.Simulation(model) sim.solve([0, 1], initial_soc=0.9) - self.assertEqual(sim._built_initial_soc, 0.9) + assert sim._built_initial_soc == 0.9 # Test whether initial_soc works with half cell (build) options = {"working electrode": "positive"} model = pybamm.lithium_ion.DFN(options) sim = pybamm.Simulation(model) sim.build(initial_soc=0.9) - self.assertEqual(sim._built_initial_soc, 0.9) + assert sim._built_initial_soc == 0.9 # Test whether initial_soc works with half cell when it is a voltage model = pybamm.lithium_ion.SPM({"working electrode": "positive"}) @@ -284,14 +282,14 @@ def ocv_with_parameter(sto): sim = pybamm.Simulation(model, parameter_values=parameter_values) sol = sim.solve([0, 1], initial_soc=f"{ucv} V") voltage = sol["Terminal voltage [V]"].entries - self.assertAlmostEqual(voltage[0], ucv, places=5) + assert voltage[0] == pytest.approx(ucv, abs=1e-05) # test with MSMR model = pybamm.lithium_ion.MSMR({"number of MSMR reactions": ("6", "4")}) param = pybamm.ParameterValues("MSMR_Example") sim = pybamm.Simulation(model, parameter_values=param) sim.build(initial_soc=0.5) - self.assertEqual(sim._built_initial_soc, 0.5) + assert sim._built_initial_soc == 0.5 def test_solve_with_initial_soc_with_input_param_in_ocv(self): # test having an input parameter in the ocv function @@ -314,7 +312,18 @@ def ocv_with_parameter(sto): model, parameter_values=parameter_values, experiment=experiment ) sim.solve([0, 3600], inputs={"a": 1}, initial_soc=0.8) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 + + def test_restricted_input_params(self): + model = pybamm.lithium_ion.SPM() + parameter_values = model.default_parameter_values + parameter_values.update({"Initial temperature [K]": "[input]"}) + experiment = pybamm.Experiment(["Discharge at 1C until 2.5 V"]) + sim = pybamm.Simulation( + model, parameter_values=parameter_values, experiment=experiment + ) + with pytest.raises(pybamm.ModelError, match="Initial temperature"): + sim.solve([0, 3600]) def test_esoh_with_input_param(self): # Test that initial soc works with a relevant input parameter @@ -326,7 +335,7 @@ def test_esoh_with_input_param(self): ) sim = pybamm.Simulation(model, parameter_values=param) sim.solve(t_eval=[0, 1], initial_soc=0.8, inputs={"eps_p": original_eps_p}) - self.assertEqual(sim._built_initial_soc, 0.8) + assert sim._built_initial_soc == 0.8 def test_solve_with_inputs(self): model = pybamm.lithium_ion.SPM() @@ -338,6 +347,38 @@ def test_solve_with_inputs(self): sim.solution.all_inputs[0]["Current function [A]"], 1 ) + def test_solve_with_sensitivities(self): + model = pybamm.lithium_ion.SPM() + param = model.default_parameter_values + param.update({"Current function [A]": "[input]"}) + sim = pybamm.Simulation(model, parameter_values=param) + h = 1e-6 + sol1 = sim.solve( + t_eval=[0, 600], + inputs={"Current function [A]": 1}, + calculate_sensitivities=True, + ) + + # check that the sensitivities are stored + assert "Current function [A]" in sol1.sensitivities + + sol2 = sim.solve(t_eval=[0, 600], inputs={"Current function [A]": 1 + h}) + + # check that the sensitivities are not stored + assert "Current function [A]" not in sol2.sensitivities + + # check that the sensitivities are roughly correct + np.testing.assert_array_almost_equal( + sol1["Terminal voltage [V]"].entries + + h + * sol1["Terminal voltage [V]"] + .sensitivities["Current function [A]"] + .full() + .flatten(), + sol2["Terminal voltage [V]"].entries, + decimal=5, + ) + def test_step_with_inputs(self): dt = 0.001 model = pybamm.lithium_ion.SPM() @@ -347,17 +388,17 @@ def test_step_with_inputs(self): sim.step( dt, inputs={"Current function [A]": 1} ) # 1 step stores first two points - self.assertEqual(sim.solution.t.size, 2) - self.assertEqual(sim.solution.y.full()[0, :].size, 2) - self.assertEqual(sim.solution.t[0], 0) - self.assertEqual(sim.solution.t[1], dt) + assert sim.solution.t.size == 2 + assert sim.solution.y.full()[0, :].size == 2 + assert sim.solution.t[0] == 0 + assert sim.solution.t[1] == dt np.testing.assert_array_equal( sim.solution.all_inputs[0]["Current function [A]"], 1 ) sim.step( dt, inputs={"Current function [A]": 2} ) # automatically append the next step - self.assertEqual(sim.solution.y.full()[0, :].size, 4) + assert sim.solution.y.full()[0, :].size == 4 np.testing.assert_array_almost_equal( sim.solution.t, np.array([0, dt, dt + 1e-9, 2 * dt]) ) @@ -399,13 +440,13 @@ def oscillating(t): def f(t, x=x): return x + t - with self.assertRaises(ValueError): + with pytest.raises(ValueError): operating_mode(f) def g(t, y): return t - with self.assertRaises(TypeError): + with pytest.raises(TypeError): operating_mode(g) def test_save_load(self): @@ -418,13 +459,13 @@ def test_save_load(self): sim.save(test_name) sim_load = pybamm.load_sim(test_name) - self.assertEqual(sim.model.name, sim_load.model.name) + assert sim.model.name == sim_load.model.name # save after solving sim.solve([0, 600]) sim.save(test_name) sim_load = pybamm.load_sim(test_name) - self.assertEqual(sim.model.name, sim_load.model.name) + assert sim.model.name == sim_load.model.name # with python formats model.convert_to_format = None @@ -434,8 +475,9 @@ def test_save_load(self): model.convert_to_format = "python" sim = pybamm.Simulation(model) sim.solve([0, 600]) - with self.assertRaisesRegex( - NotImplementedError, "Cannot save simulation if model format is python" + with pytest.raises( + NotImplementedError, + match="Cannot save simulation if model format is python", ): sim.save(test_name) @@ -454,11 +496,11 @@ def test_load_param(self): os.remove(filename) raise excep - self.assertEqual( - "graphite_LGM50_electrolyte_exchange_current_density_Chen2020", - pkl_obj.parameter_values[ + assert ( + "graphite_LGM50_electrolyte_exchange_current_density_Chen2020" + == pkl_obj.parameter_values[ "Negative electrode exchange-current density [A.m-2]" - ].__name__, + ].__name__ ) os.remove(filename) @@ -474,7 +516,7 @@ def test_save_load_dae(self): sim.solve([0, 600]) sim.save(test_name) sim_load = pybamm.load_sim(test_name) - self.assertEqual(sim.model.name, sim_load.model.name) + assert sim.model.name == sim_load.model.name # with python format model.convert_to_format = None @@ -492,7 +534,7 @@ def test_save_load_dae(self): sim.solve([0, 600]) sim.save(test_name) sim_load = pybamm.load_sim(test_name) - self.assertEqual(sim.model.name, sim_load.model.name) + assert sim.model.name == sim_load.model.name def test_save_load_model(self): model = pybamm.lead_acid.LOQS({"surface form": "algebraic"}) @@ -500,7 +542,7 @@ def test_save_load_model(self): sim = pybamm.Simulation(model) # test exception if not discretised - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): sim.save_model("sim_save") # save after solving @@ -510,7 +552,7 @@ def test_save_load_model(self): # load model saved_model = pybamm.load_model("sim_save.json") - self.assertEqual(model.options, saved_model.options) + assert model.options == saved_model.options os.remove("sim_save.json") @@ -518,7 +560,7 @@ def test_plot(self): sim = pybamm.Simulation(pybamm.lithium_ion.SPM()) # test exception if not solved - with self.assertRaises(ValueError): + with pytest.raises(ValueError): sim.plot() # now solve and plot @@ -529,8 +571,8 @@ def test_plot(self): def test_create_gif(self): with TemporaryDirectory() as dir_name: sim = pybamm.Simulation(pybamm.lithium_ion.SPM()) - with self.assertRaisesRegex( - ValueError, "The simulation has not been solved yet." + with pytest.raises( + ValueError, match="The simulation has not been solved yet." ): sim.create_gif() sim.solve(t_eval=[0, 10]) @@ -577,13 +619,13 @@ def test_drive_cycle_interpolant(self): # check warning raised if the largest gap in t_eval is bigger than the # smallest gap in the data - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): sim.solve(t_eval=np.linspace(0, 10, 3)) # check warning raised if t_eval doesnt contain time_data , but has a finer # resolution (can still solve, but good for users to know they dont have # the solution returned at the data points) - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): sim.solve(t_eval=np.linspace(0, time_data[-1], 800)) def test_discontinuous_current(self): @@ -604,20 +646,20 @@ def car_current(t): ) sim.solve([0, 3600]) current = sim.solution["Current [A]"] - self.assertEqual(current(0), 1) - self.assertEqual(current(1500), -0.5) - self.assertEqual(current(3000), 0.5) + assert current(0) == 1 + assert current(1500) == -0.5 + assert current(3000) == 0.5 def test_t_eval(self): model = pybamm.lithium_ion.SPM() sim = pybamm.Simulation(model) # test no t_eval - with self.assertRaisesRegex(pybamm.SolverError, "'t_eval' must be provided"): + with pytest.raises(pybamm.SolverError, match="'t_eval' must be provided"): sim.solve() # test t_eval list of length != 2 - with self.assertRaisesRegex(pybamm.SolverError, "'t_eval' can be provided"): + with pytest.raises(pybamm.SolverError, match="'t_eval' can be provided"): sim.solve(t_eval=[0, 1, 2]) # tets list gets turned into np.linspace(t0, tf, 100) @@ -633,11 +675,3 @@ def test_battery_model_with_input_height(self): inputs = {"Electrode height [m]": 0.2} sim = pybamm.Simulation(model=model, parameter_values=parameter_values) sim.solve(t_eval=t_eval, inputs=inputs) - - -if __name__ == "__main__": - print("Add -v for more debug output") - - if "-v" in sys.argv: - debug = True - unittest.main() diff --git a/tests/unit/test_solvers/test_algebraic_solver.py b/tests/unit/test_solvers/test_algebraic_solver.py index 6e8b3a3d80..89ca7d750c 100644 --- a/tests/unit/test_solvers/test_algebraic_solver.py +++ b/tests/unit/test_solvers/test_algebraic_solver.py @@ -3,24 +3,24 @@ # import pybamm -import unittest +import pytest import numpy as np from tests import get_discretisation_for_testing -class TestAlgebraicSolver(unittest.TestCase): +class TestAlgebraicSolver: def test_algebraic_solver_init(self): solver = pybamm.AlgebraicSolver( method="hybr", tol=1e-4, extra_options={"maxfev": 100} ) - self.assertEqual(solver.method, "hybr") - self.assertEqual(solver.extra_options, {"maxfev": 100}) - self.assertEqual(solver.tol, 1e-4) + assert solver.method == "hybr" + assert solver.extra_options == {"maxfev": 100} + assert solver.tol == 1e-4 solver.method = "krylov" - self.assertEqual(solver.method, "krylov") + assert solver.method == "krylov" solver.tol = 1e-5 - self.assertEqual(solver.tol, 1e-5) + assert solver.tol == 1e-5 def test_wrong_solver(self): # Create model @@ -31,9 +31,9 @@ def test_wrong_solver(self): # test errors solver = pybamm.AlgebraicSolver() - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Cannot use algebraic solver to solve model with time derivatives", + match="Cannot use algebraic solver to solve model with time derivatives", ): solver.solve(model) @@ -61,7 +61,7 @@ def algebraic_eval(self, t, y, inputs): # Relax options and see worse results solver = pybamm.AlgebraicSolver(extra_options={"ftol": 1}) solution = solver._integrate(model, np.array([0])) - self.assertNotEqual(solution.y, -2) + assert solution.y != -2 def test_root_find_fail(self): class Model(pybamm.BaseModel): @@ -81,15 +81,16 @@ def algebraic_eval(self, t, y, inputs): model = Model() solver = pybamm.AlgebraicSolver(method="hybr") - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Could not find acceptable solution: The iteration is not making", + match="Could not find acceptable solution: The iteration is not making", ): solver._integrate(model, np.array([0])) solver = pybamm.AlgebraicSolver() - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: solver terminated" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: solver terminated", ): solver._integrate(model, np.array([0])) @@ -303,13 +304,3 @@ def test_solve_with_input(self): solver = pybamm.AlgebraicSolver() solution = solver.solve(model, np.linspace(0, 1, 10), inputs={"value": 7}) np.testing.assert_array_equal(solution.y, -7) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_base_solver.py b/tests/unit/test_solvers/test_base_solver.py index a4b43e1dd2..1733cafc4c 100644 --- a/tests/unit/test_solvers/test_base_solver.py +++ b/tests/unit/test_solvers/test_base_solver.py @@ -2,39 +2,39 @@ # Tests for the Base Solver class # +import pytest import casadi import pybamm import numpy as np from scipy.sparse import csr_matrix -import unittest - -class TestBaseSolver(unittest.TestCase): +class TestBaseSolver: def test_base_solver_init(self): solver = pybamm.BaseSolver(rtol=1e-2, atol=1e-4) - self.assertEqual(solver.rtol, 1e-2) - self.assertEqual(solver.atol, 1e-4) + assert solver.rtol == 1e-2 + assert solver.atol == 1e-4 solver.rtol = 1e-5 - self.assertEqual(solver.rtol, 1e-5) + assert solver.rtol == 1e-5 solver.rtol = 1e-7 - self.assertEqual(solver.rtol, 1e-7) + assert solver.rtol == 1e-7 + assert solver.requires_explicit_sensitivities def test_root_method_init(self): solver = pybamm.BaseSolver(root_method="casadi") - self.assertIsInstance(solver.root_method, pybamm.CasadiAlgebraicSolver) + assert isinstance(solver.root_method, pybamm.CasadiAlgebraicSolver) solver = pybamm.BaseSolver(root_method="lm") - self.assertIsInstance(solver.root_method, pybamm.AlgebraicSolver) - self.assertEqual(solver.root_method.method, "lm") + assert isinstance(solver.root_method, pybamm.AlgebraicSolver) + assert solver.root_method.method == "lm" root_solver = pybamm.AlgebraicSolver() solver = pybamm.BaseSolver(root_method=root_solver) - self.assertEqual(solver.root_method, root_solver) + assert solver.root_method == root_solver - with self.assertRaisesRegex( - pybamm.SolverError, "Root method must be an algebraic solver" + with pytest.raises( + pybamm.SolverError, match="Root method must be an algebraic solver" ): pybamm.BaseSolver(root_method=pybamm.ScipySolver()) @@ -42,9 +42,9 @@ def test_step_or_solve_empty_model(self): model = pybamm.BaseModel() solver = pybamm.BaseSolver() error = "Cannot simulate an empty model" - with self.assertRaisesRegex(pybamm.ModelError, error): + with pytest.raises(pybamm.ModelError, match=error): solver.step(None, model, None) - with self.assertRaisesRegex(pybamm.ModelError, error): + with pytest.raises(pybamm.ModelError, match=error): solver.solve(model, None) def test_t_eval_none(self): @@ -56,7 +56,7 @@ def test_t_eval_none(self): disc.process_model(model) solver = pybamm.BaseSolver() - with self.assertRaisesRegex(ValueError, "t_eval cannot be None"): + with pytest.raises(ValueError, match="t_eval cannot be None"): solver.solve(model, None) def test_nonmonotonic_teval(self): @@ -64,31 +64,29 @@ def test_nonmonotonic_teval(self): model = pybamm.BaseModel() a = pybamm.Scalar(0) model.rhs = {a: a} - with self.assertRaisesRegex( - pybamm.SolverError, "t_eval must increase monotonically" + with pytest.raises( + pybamm.SolverError, match="t_eval must increase monotonically" ): solver.solve(model, np.array([1, 2, 3, 2])) # Check stepping with step size too small - dt = 1e-9 - with self.assertRaisesRegex( - pybamm.SolverError, "Step time must be at least 1.000 ns" - ): + dt = -1e-9 + with pytest.raises(pybamm.SolverError, match="Step time must be >0"): solver.step(None, model, dt) # Checking if array t_eval lies within range dt = 2 t_eval = np.array([0, 1]) - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Elements inside array t_eval must lie in the closed interval 0 to dt", + match="Elements inside array t_eval must lie in the closed interval 0 to dt", ): solver.step(None, model, dt, t_eval=t_eval) t_eval = np.array([1, dt]) - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Elements inside array t_eval must lie in the closed interval 0 to dt", + match="Elements inside array t_eval must lie in the closed interval 0 to dt", ): solver.step(None, model, dt, t_eval=t_eval) @@ -98,8 +96,8 @@ def test_solution_time_length_fail(self): model.variables = {"v": v} solver = pybamm.DummySolver() t_eval = np.array([0]) - with self.assertRaisesRegex( - pybamm.SolverError, "Solution time vector has length 1" + with pytest.raises( + pybamm.SolverError, match="Solution time vector has length 1" ): solver.solve(model, t_eval) @@ -109,9 +107,7 @@ def test_block_symbolic_inputs(self): a = pybamm.Variable("a") p = pybamm.InputParameter("p") model.rhs = {a: a * p} - with self.assertRaisesRegex( - pybamm.SolverError, "No value provided for input 'p'" - ): + with pytest.raises(pybamm.SolverError, match="No value provided for input 'p'"): solver.solve(model, np.array([1, 2, 3])) def test_ode_solver_fail_with_dae(self): @@ -120,7 +116,7 @@ def test_ode_solver_fail_with_dae(self): model.algebraic = {a: a} model.concatenated_initial_conditions = pybamm.Scalar(0) solver = pybamm.ScipySolver() - with self.assertRaisesRegex(pybamm.SolverError, "Cannot use ODE solver"): + with pytest.raises(pybamm.SolverError, match="Cannot use ODE solver"): solver.set_up(model) def test_find_consistent_initialization(self): @@ -233,20 +229,22 @@ def algebraic_eval(self, t, y, inputs): solver = pybamm.BaseSolver(root_method="hybr") - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Could not find acceptable solution: The iteration is not making", + match="Could not find acceptable solution: The iteration is not making", ): solver.calculate_consistent_state(Model()) solver = pybamm.BaseSolver(root_method="lm") - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: solver terminated" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: solver terminated", ): solver.calculate_consistent_state(Model()) # with casadi solver = pybamm.BaseSolver(root_method="casadi") - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: Error in Function" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: Error in Function", ): solver.calculate_consistent_state(Model()) @@ -258,9 +256,9 @@ def test_discretise_model(self): model.initial_conditions = {v: 1} solver = pybamm.BaseSolver() - self.assertFalse(model.is_discretised) + assert not model.is_discretised solver.set_up(model, {}) - self.assertTrue(model.is_discretised) + assert model.is_discretised # 1D model cannot be automatically discretised model = pybamm.BaseModel() @@ -268,8 +266,8 @@ def test_discretise_model(self): model.rhs = {v: -1} model.initial_conditions = {v: 1} - with self.assertRaisesRegex( - pybamm.DiscretisationError, "Cannot automatically discretise model" + with pytest.raises( + pybamm.DiscretisationError, match="Cannot automatically discretise model" ): solver.set_up(model, {}) @@ -287,7 +285,7 @@ def test_convert_to_casadi_format(self): solver = pybamm.BaseSolver(root_method="casadi") pybamm.set_logging_level("ERROR") solver.set_up(model, {}) - self.assertEqual(model.convert_to_format, "casadi") + assert model.convert_to_format == "casadi" pybamm.set_logging_level("WARNING") def test_inputs_step(self): @@ -303,7 +301,7 @@ def test_inputs_step(self): sol = solver.step( old_solution=None, model=model, dt=1.0, inputs={input_key: interp} ) - self.assertFalse(input_key in sol.all_inputs[0]) + assert input_key not in sol.all_inputs[0] def test_extrapolation_warnings(self): # Make sure the extrapolation warnings work @@ -328,10 +326,10 @@ def test_extrapolation_warnings(self): solver = pybamm.ScipySolver() solver.set_up(model) - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): solver.step(old_solution=None, model=model, dt=1.0) - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): solver.solve(model, t_eval=[0, 1]) def test_multiple_models_error(self): @@ -346,7 +344,7 @@ def test_multiple_models_error(self): solver = pybamm.ScipySolver() solver.solve(model, t_eval=[0, 1]) - with self.assertRaisesRegex(RuntimeError, "already been initialised"): + with pytest.raises(RuntimeError, match="already been initialised"): solver.solve(model2, t_eval=[0, 1]) def test_multiprocess_context(self): @@ -355,12 +353,16 @@ def test_multiprocess_context(self): assert solver.get_platform_context("Linux") == "fork" assert solver.get_platform_context("Darwin") == "fork" - @unittest.skipIf(not pybamm.has_idaklu(), "idaklu solver is not installed") + @pytest.mark.skipif( + not pybamm.has_idaklu(), reason="idaklu solver is not installed" + ) def test_sensitivities(self): def exact_diff_a(y, a, b): return np.array([[y[0] ** 2 + 2 * a], [y[0]]]) - @unittest.skipIf(not pybamm.has_jax(), "jax or jaxlib is not installed") + @pytest.mark.skipif( + not pybamm.has_jax(), reason="jax or jaxlib is not installed" + ) def exact_diff_b(y, a, b): return np.array([[y[0]], [0]]) @@ -399,13 +401,3 @@ def exact_diff_b(y, a, b): np.testing.assert_allclose( sens_b, exact_diff_b(y, inputs["a"], inputs["b"]) ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_casadi_algebraic_solver.py b/tests/unit/test_solvers/test_casadi_algebraic_solver.py index b85f4292b9..b2dc92d25b 100644 --- a/tests/unit/test_solvers/test_casadi_algebraic_solver.py +++ b/tests/unit/test_solvers/test_casadi_algebraic_solver.py @@ -1,18 +1,18 @@ import casadi import pybamm -import unittest +import pytest import numpy as np from scipy.optimize import least_squares import tests -class TestCasadiAlgebraicSolver(unittest.TestCase): +class TestCasadiAlgebraicSolver: def test_algebraic_solver_init(self): solver = pybamm.CasadiAlgebraicSolver(tol=1e-4) - self.assertEqual(solver.tol, 1e-4) + assert solver.tol == 1e-4 solver.tol = 1e-5 - self.assertEqual(solver.tol, 1e-5) + assert solver.tol == 1e-5 def test_simple_root_find(self): # Simple system: a single algebraic equation @@ -65,13 +65,15 @@ def algebraic_eval(self, t, y, inputs): model = Model() solver = pybamm.CasadiAlgebraicSolver() - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: Error in Function" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: Error in Function", ): solver._integrate(model, np.array([0]), {}) solver = pybamm.CasadiAlgebraicSolver(extra_options={"error_on_fail": False}) - with self.assertRaisesRegex( - pybamm.SolverError, "Could not find acceptable solution: solver terminated" + with pytest.raises( + pybamm.SolverError, + match="Could not find acceptable solution: solver terminated", ): solver._integrate(model, np.array([0]), {}) @@ -91,9 +93,9 @@ def algebraic_eval(self, t, y, inputs): return y**0.5 model = NaNModel() - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Could not find acceptable solution: solver returned NaNs", + match="Could not find acceptable solution: solver returned NaNs", ): solver._integrate(model, np.array([0]), {}) @@ -170,7 +172,7 @@ def test_solve_with_input(self): np.testing.assert_array_equal(solution.y, -7) -class TestCasadiAlgebraicSolverSensitivity(unittest.TestCase): +class TestCasadiAlgebraicSolverSensitivity: def test_solve_with_symbolic_input(self): # Simple system: a single algebraic equation var = pybamm.Variable("var") @@ -344,13 +346,3 @@ def objective(x): # without Jacobian lsq_sol = least_squares(objective, [2, 2], method="lm") np.testing.assert_array_almost_equal(lsq_sol.x, [3, 3], decimal=3) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_casadi_solver.py b/tests/unit/test_solvers/test_casadi_solver.py index eaaeedf0d0..e6f631392a 100644 --- a/tests/unit/test_solvers/test_casadi_solver.py +++ b/tests/unit/test_solvers/test_casadi_solver.py @@ -1,13 +1,13 @@ +import pytest import pybamm -import unittest import numpy as np from tests import get_mesh_for_testing, get_discretisation_for_testing from scipy.sparse import eye -class TestCasadiSolver(unittest.TestCase): +class TestCasadiSolver: def test_bad_mode(self): - with self.assertRaisesRegex(ValueError, "invalid mode"): + with pytest.raises(ValueError, match="invalid mode"): pybamm.CasadiSolver(mode="bad mode") def test_model_solver(self): @@ -102,7 +102,7 @@ def test_without_grid(self): # Safe mode, without grid (enforce events that won't be triggered) solver = pybamm.CasadiSolver(mode="safe without grid", rtol=1e-8, atol=1e-8) - with self.assertRaisesRegex(pybamm.SolverError, "Maximum number of decreased"): + with pytest.raises(pybamm.SolverError, match="Maximum number of decreased"): solver.solve(model, [0, 10]) def test_model_solver_python(self): @@ -143,9 +143,9 @@ def test_model_solver_failure(self): # Solution fails early but manages to take some steps so we return it anyway # Check that the final solution does indeed stop before t=20 t_eval = np.linspace(0, 20, 100) - with self.assertWarns(pybamm.SolverWarning): + with pytest.warns(pybamm.SolverWarning): solution = solver.solve(model_disc, t_eval) - self.assertLess(solution.t[-1], 20) + assert solution.t[-1] < 20 # Solve with failure at t=0 solver = pybamm.CasadiSolver( dt_max=1e-3, return_solution_if_failed_early=True, max_step_decrease_count=2 @@ -155,7 +155,7 @@ def test_model_solver_failure(self): t_eval = np.linspace(0, 20, 100) # This one should fail immediately and throw a `SolverError` # since no progress can be made from the first timestep - with self.assertRaisesRegex(pybamm.SolverError, "Maximum number of decreased"): + with pytest.raises(pybamm.SolverError, match="Maximum number of decreased"): solver.solve(model, t_eval) def test_model_solver_events(self): @@ -289,7 +289,7 @@ def test_model_step(self): # Step again (return 5 points) step_sol_2 = solver.step(step_sol, model, dt, npts=5) np.testing.assert_array_equal( - step_sol_2.t, np.array([0, 1, 1 + 1e-9, 1.25, 1.5, 1.75, 2]) + step_sol_2.t, np.array([0, 1, np.nextafter(1, np.inf), 1.25, 1.5, 1.75, 2]) ) np.testing.assert_array_almost_equal( step_sol_2.y.full()[0], np.exp(0.1 * step_sol_2.t) @@ -390,7 +390,7 @@ def test_model_solver_with_inputs(self): solver = pybamm.CasadiSolver(rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 10, 100) solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_allclose( solution.y.full()[0], np.exp(-0.1 * solution.t), rtol=1e-04 ) @@ -399,12 +399,12 @@ def test_model_solver_with_inputs(self): solver = pybamm.CasadiSolver(mode="safe without grid", rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 10, 100) solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_allclose( solution.y.full()[0], np.exp(-0.1 * solution.t), rtol=1e-04 ) solution = solver.solve(model, t_eval, inputs={"rate": 1.1}) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_allclose( solution.y.full()[0], np.exp(-1.1 * solution.t), rtol=1e-04 ) @@ -482,8 +482,8 @@ def test_dae_solver_algebraic_model(self): solver = pybamm.CasadiSolver() t_eval = np.linspace(0, 1) - with self.assertRaisesRegex( - pybamm.SolverError, "Cannot use CasadiSolver to solve algebraic model" + with pytest.raises( + pybamm.SolverError, match="Cannot use CasadiSolver to solve algebraic model" ): solver.solve(model, t_eval) @@ -507,7 +507,7 @@ def func(var): solver = pybamm.CasadiSolver() t_eval = [0, 5] - with self.assertRaisesRegex(pybamm.SolverError, "interpolation bounds"): + with pytest.raises(pybamm.SolverError, match="interpolation bounds"): solver.solve(model, t_eval) def test_casadi_safe_no_termination(self): @@ -532,7 +532,7 @@ def test_casadi_safe_no_termination(self): solver = pybamm.CasadiSolver(mode="safe") solver.set_up(model) - with self.assertRaisesRegex(pybamm.SolverError, "interpolation bounds"): + with pytest.raises(pybamm.SolverError, match="interpolation bounds"): solver.solve(model, t_eval=[0, 1]) def test_modulo_non_smooth_events(self): @@ -581,7 +581,7 @@ def test_modulo_non_smooth_events(self): ) -class TestCasadiSolverODEsWithForwardSensitivityEquations(unittest.TestCase): +class TestCasadiSolverODEsWithForwardSensitivityEquations: def test_solve_sensitivity_scalar_var_scalar_input(self): # Create model model = pybamm.BaseModel() @@ -931,7 +931,7 @@ def test_solve_sensitivity_subset(self): solution.sensitivities["q"], (0.1 * solution.t)[:, np.newaxis], ) - self.assertTrue("r" not in solution.sensitivities) + assert "r" not in solution.sensitivities np.testing.assert_allclose( solution.sensitivities["all"], np.hstack( @@ -949,8 +949,8 @@ def test_solve_sensitivity_subset(self): calculate_sensitivities=["r"], ) np.testing.assert_allclose(solution.y[0], -1 + 0.2 * solution.t) - self.assertTrue("p" not in solution.sensitivities) - self.assertTrue("q" not in solution.sensitivities) + assert "p" not in solution.sensitivities + assert "q" not in solution.sensitivities np.testing.assert_allclose(solution.sensitivities["r"], 1) np.testing.assert_allclose( solution.sensitivities["all"], @@ -962,7 +962,7 @@ def test_solve_sensitivity_subset(self): ) -class TestCasadiSolverDAEsWithForwardSensitivityEquations(unittest.TestCase): +class TestCasadiSolverDAEsWithForwardSensitivityEquations: def test_solve_sensitivity_scalar_var_scalar_input(self): # Create model model = pybamm.BaseModel() @@ -1022,7 +1022,7 @@ def test_solve_sensitivity_algebraic(self): model, t_eval, inputs={"p": 0.1}, calculate_sensitivities=True ) np.testing.assert_array_equal(solution.t, t_eval) - np.testing.assert_allclose(solution.y[0], 0.1 * solution.t) + np.testing.assert_allclose(np.array(solution.y)[0], 0.1 * solution.t) np.testing.assert_allclose( solution.sensitivities["p"], solution.t.reshape(-1, 1), atol=1e-7 ) @@ -1066,8 +1066,8 @@ def test_solve_sensitivity_subset(self): solution.sensitivities["q"][::2], (0.1 * solution.t)[:, np.newaxis], ) - self.assertTrue("r" not in solution.sensitivities) - self.assertTrue("s" not in solution.sensitivities) + assert "r" not in solution.sensitivities + assert "s" not in solution.sensitivities np.testing.assert_allclose( solution.sensitivities["all"], np.hstack( @@ -1096,18 +1096,8 @@ def test_solver_interpolation_warning(self): # Check for warning with t_interp t_eval = np.linspace(0, 1, 10) t_interp = t_eval - with self.assertWarns( + with pytest.warns( pybamm.SolverWarning, - msg=f"Explicit interpolation times not implemented for {solver.name}", + match=f"Explicit interpolation times not implemented for {solver.name}", ): solver.solve(model, t_eval, t_interp=t_interp) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_idaklu_jax.py b/tests/unit/test_solvers/test_idaklu_jax.py index a99f108f40..53abb94c83 100644 --- a/tests/unit/test_solvers/test_idaklu_jax.py +++ b/tests/unit/test_solvers/test_idaklu_jax.py @@ -2,11 +2,10 @@ # Tests for the KLU-Jax interface class # -from parameterized import parameterized +import pytest import pybamm import numpy as np -import unittest testcase = [] if pybamm.has_idaklu() and pybamm.has_jax(): @@ -86,21 +85,21 @@ def no_jit(f): # Check the interface throws an appropriate error if either IDAKLU or JAX not available -@unittest.skipIf( +@pytest.mark.skipif( pybamm.has_idaklu() and pybamm.has_jax(), - "Both IDAKLU and JAX are available", + reason="Both IDAKLU and JAX are available", ) -class TestIDAKLUJax_NoJax(unittest.TestCase): +class TestIDAKLUJax_NoJax: def test_instantiate_fails(self): - with self.assertRaises(ModuleNotFoundError): + with pytest.raises(ModuleNotFoundError): pybamm.IDAKLUJax([], [], []) -@unittest.skipIf( +@pytest.mark.skipif( not pybamm.has_idaklu() or not pybamm.has_jax(), - "IDAKLU Solver and/or JAX are not available", + reason="IDAKLU Solver and/or JAX are not available", ) -class TestIDAKLUJax(unittest.TestCase): +class TestIDAKLUJax: # Initialisation tests def test_initialise_twice(self): @@ -110,7 +109,7 @@ def test_initialise_twice(self): output_variables=output_variables, calculate_sensitivities=True, ) - with self.assertWarns(UserWarning): + with pytest.warns(UserWarning): idaklu_jax_solver.jaxify( model, t_eval, @@ -127,15 +126,15 @@ def test_uninitialised(self): ) # simulate failure in initialisation idaklu_jax_solver.jaxpr = None - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): idaklu_jax_solver.get_jaxpr() - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): idaklu_jax_solver.jax_value() - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): idaklu_jax_solver.jax_grad() def test_no_output_variables(self): - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): idaklu_solver.jaxify( model, t_eval, @@ -170,63 +169,63 @@ def test_no_inputs(self): # Scalar evaluation - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_f_scalar(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper(f)(t_eval[k], inputs) np.testing.assert_allclose( out, np.array([sim[outvar](t_eval[k]) for outvar in output_variables]).T ) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_f_vector(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper(f)(t_eval, inputs) np.testing.assert_allclose( out, np.array([sim[outvar](t_eval) for outvar in output_variables]).T ) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_f_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper(jax.vmap(f, in_axes=in_axes))(t_eval, inputs) np.testing.assert_allclose( out, np.array([sim[outvar](t_eval) for outvar in output_variables]).T ) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_f_batch_over_inputs(self, output_variables, idaklu_jax_solver, f, wrapper): inputs_mock = np.array([1.0, 2.0, 3.0]) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): wrapper(jax.vmap(f, in_axes=(None, 0)))(t_eval, inputs_mock) # Get all vars (should mirror test_f_* [above]) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvars_call_signature( self, output_variables, idaklu_jax_solver, f, wrapper ): if wrapper == jax.jit: return # test does not involve a JAX expression - with self.assertRaises(ValueError): + with pytest.raises(ValueError): idaklu_jax_solver.get_vars() # no variable name specified idaklu_jax_solver.get_vars(output_variables) # (okay) idaklu_jax_solver.get_vars(f, output_variables) # (okay) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): idaklu_jax_solver.get_vars(1, 2, 3) # too many arguments - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvars_scalar(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper(idaklu_jax_solver.get_vars(output_variables))(t_eval[k], inputs) np.testing.assert_allclose( out, np.array([sim[outvar](t_eval[k]) for outvar in output_variables]).T ) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvars_vector(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper(idaklu_jax_solver.get_vars(output_variables))(t_eval, inputs) np.testing.assert_allclose( out, np.array([sim[outvar](t_eval) for outvar in output_variables]).T ) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvars_vector_array( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -236,7 +235,7 @@ def test_getvars_vector_array( out = idaklu_jax_solver.get_vars(array, output_variables) np.testing.assert_allclose(out, array) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvars_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper( jax.vmap( @@ -250,20 +249,20 @@ def test_getvars_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): # Isolate single output variable - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvar_call_signature( self, output_variables, idaklu_jax_solver, f, wrapper ): if wrapper == jax.jit: return # test does not involve a JAX expression - with self.assertRaises(ValueError): + with pytest.raises(ValueError): idaklu_jax_solver.get_var() # no variable name specified idaklu_jax_solver.get_var(output_variables[0]) # (okay) idaklu_jax_solver.get_var(f, output_variables[0]) # (okay) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): idaklu_jax_solver.get_var(1, 2, 3) # too many arguments - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvar_scalar_float_jaxpr( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -272,7 +271,7 @@ def test_getvar_scalar_float_jaxpr( out = wrapper(idaklu_jax_solver.get_var(outvar))(float(t_eval[k]), inputs) np.testing.assert_allclose(out, sim[outvar](float(t_eval[k]))) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvar_scalar_float_f( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -283,35 +282,35 @@ def test_getvar_scalar_float_f( ) np.testing.assert_allclose(out, sim[outvar](float(t_eval[k]))) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvar_scalar_jaxpr(self, output_variables, idaklu_jax_solver, f, wrapper): # Per variable checks using the default JAX expression (self.jaxpr) for outvar in output_variables: out = wrapper(idaklu_jax_solver.get_var(outvar))(t_eval[k], inputs) np.testing.assert_allclose(out, sim[outvar](t_eval[k])) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvar_scalar_f(self, output_variables, idaklu_jax_solver, f, wrapper): # Per variable checks using a provided JAX expression (f) for outvar in output_variables: out = wrapper(idaklu_jax_solver.get_var(outvar))(t_eval[k], inputs) np.testing.assert_allclose(out, sim[outvar](t_eval[k])) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvar_vector_jaxpr(self, output_variables, idaklu_jax_solver, f, wrapper): # Per variable checks using the default JAX expression (self.jaxpr) for outvar in output_variables: out = wrapper(idaklu_jax_solver.get_var(outvar))(t_eval, inputs) np.testing.assert_allclose(out, sim[outvar](t_eval)) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvar_vector_f(self, output_variables, idaklu_jax_solver, f, wrapper): # Per variable checks using a provided JAX expression (f) for outvar in output_variables: out = wrapper(idaklu_jax_solver.get_var(f, outvar))(t_eval, inputs) np.testing.assert_allclose(out, sim[outvar](t_eval)) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvar_vector_array(self, output_variables, idaklu_jax_solver, f, wrapper): # Per variable checks using a provided np.ndarray if wrapper == jax.jit: @@ -321,7 +320,7 @@ def test_getvar_vector_array(self, output_variables, idaklu_jax_solver, f, wrapp out = idaklu_jax_solver.get_var(array, outvar) np.testing.assert_allclose(out, sim[outvar](t_eval)) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_getvar_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): for outvar in output_variables: out = wrapper( @@ -334,7 +333,7 @@ def test_getvar_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): # Differentiation rules (jacfwd) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_scalar(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper(jax.jacfwd(f, argnums=1))(t_eval[k], inputs) flat_out, _ = tree_flatten(out) @@ -348,7 +347,7 @@ def test_jacfwd_scalar(self, output_variables, idaklu_jax_solver, f, wrapper): ).T np.testing.assert_allclose(flat_out, check.flatten()) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_vector(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper(jax.jacfwd(f, argnums=1))(t_eval, inputs) flat_out, _ = tree_flatten(out) @@ -365,7 +364,7 @@ def test_jacfwd_vector(self, output_variables, idaklu_jax_solver, f, wrapper): f"Got: {flat_out}\nExpected: {check}", ) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper( jax.vmap( @@ -384,11 +383,11 @@ def test_jacfwd_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): ) np.testing.assert_allclose(flat_out, check.flatten()) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_vmap_wrt_time( self, output_variables, idaklu_jax_solver, f, wrapper ): - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): wrapper( jax.vmap( jax.jacfwd(f, argnums=0), @@ -396,12 +395,12 @@ def test_jacfwd_vmap_wrt_time( ), )(t_eval, inputs) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_batch_over_inputs( self, output_variables, idaklu_jax_solver, f, wrapper ): inputs_mock = np.array([1.0, 2.0, 3.0]) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): wrapper( jax.vmap( jax.jacfwd(f, argnums=1), @@ -411,7 +410,7 @@ def test_jacfwd_batch_over_inputs( # Differentiation rules (jacrev) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_scalar(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper(jax.jacrev(f, argnums=1))(t_eval[k], inputs) flat_out, _ = tree_flatten(out) @@ -425,7 +424,7 @@ def test_jacrev_scalar(self, output_variables, idaklu_jax_solver, f, wrapper): ).T np.testing.assert_allclose(flat_out, check.flatten()) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_vector(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper(jax.jacrev(f, argnums=1))(t_eval, inputs) flat_out, _ = tree_flatten(out) @@ -439,7 +438,7 @@ def test_jacrev_vector(self, output_variables, idaklu_jax_solver, f, wrapper): ) np.testing.assert_allclose(flat_out, check.flatten()) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper( jax.vmap( @@ -458,12 +457,12 @@ def test_jacrev_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): ) np.testing.assert_allclose(flat_out, check.flatten()) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_batch_over_inputs( self, output_variables, idaklu_jax_solver, f, wrapper ): inputs_mock = np.array([1.0, 2.0, 3.0]) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): wrapper( jax.vmap( jax.jacrev(f, argnums=1), @@ -473,7 +472,7 @@ def test_jacrev_batch_over_inputs( # Forward differentiation rules with get_vars (multiple) and get_var (singular) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_scalar_getvars( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -496,7 +495,7 @@ def test_jacfwd_scalar_getvars( flat_check, _ = tree_flatten(check) np.testing.assert_allclose(flat_out, flat_check) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_scalar_getvar( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -515,7 +514,7 @@ def test_jacfwd_scalar_getvar( flat_check, _ = tree_flatten(check) np.testing.assert_allclose(flat_out, flat_check) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_vector_getvars( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -539,7 +538,7 @@ def test_jacfwd_vector_getvars( flat_check, _ = tree_flatten(check) np.testing.assert_allclose(flat_out, flat_check) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_vector_getvar( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -558,7 +557,7 @@ def test_jacfwd_vector_getvar( flat_check, _ = tree_flatten(check) np.testing.assert_allclose(flat_out, flat_check) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_vmap_getvars(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper( jax.vmap( @@ -577,7 +576,7 @@ def test_jacfwd_vmap_getvars(self, output_variables, idaklu_jax_solver, f, wrapp ) np.testing.assert_allclose(flat_out, check.flatten()) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacfwd_vmap_getvar(self, output_variables, idaklu_jax_solver, f, wrapper): for outvar in output_variables: out = wrapper( @@ -596,7 +595,7 @@ def test_jacfwd_vmap_getvar(self, output_variables, idaklu_jax_solver, f, wrappe # Reverse differentiation rules with get_vars (multiple) and get_var (singular) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_scalar_getvars( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -619,7 +618,7 @@ def test_jacrev_scalar_getvars( flat_check, _ = tree_flatten(check) np.testing.assert_allclose(flat_out, flat_check) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_scalar_getvar( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -640,7 +639,7 @@ def test_jacrev_scalar_getvar( f"Got: {flat_out}\nExpected: {check}", ) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_vector_getvars( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -664,7 +663,7 @@ def test_jacrev_vector_getvars( flat_check, _ = tree_flatten(check) np.testing.assert_allclose(flat_out, flat_check) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_vector_getvar( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -683,7 +682,7 @@ def test_jacrev_vector_getvar( flat_check, _ = tree_flatten(check) np.testing.assert_allclose(flat_out, flat_check) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_vmap_getvars(self, output_variables, idaklu_jax_solver, f, wrapper): out = wrapper( jax.vmap( @@ -702,7 +701,7 @@ def test_jacrev_vmap_getvars(self, output_variables, idaklu_jax_solver, f, wrapp ) np.testing.assert_allclose(flat_out, check.flatten()) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jacrev_vmap_getvar(self, output_variables, idaklu_jax_solver, f, wrapper): for outvar in output_variables: out = wrapper( @@ -721,7 +720,7 @@ def test_jacrev_vmap_getvar(self, output_variables, idaklu_jax_solver, f, wrappe # Gradient rule (takes single variable) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_grad_scalar_getvar(self, output_variables, idaklu_jax_solver, f, wrapper): for outvar in output_variables: out = wrapper( @@ -735,7 +734,7 @@ def test_grad_scalar_getvar(self, output_variables, idaklu_jax_solver, f, wrappe check = np.array([sim[outvar].sensitivities[invar][k] for invar in inputs]) np.testing.assert_allclose(flat_out, check.flatten()) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_grad_vmap_getvar(self, output_variables, idaklu_jax_solver, f, wrapper): for outvar in output_variables: out = wrapper( @@ -754,7 +753,7 @@ def test_grad_vmap_getvar(self, output_variables, idaklu_jax_solver, f, wrapper) # Value and gradient (takes single variable) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_value_and_grad_scalar( self, output_variables, idaklu_jax_solver, f, wrapper ): @@ -774,7 +773,7 @@ def test_value_and_grad_scalar( check = np.array([sim[outvar].sensitivities[invar][k] for invar in inputs]) np.testing.assert_allclose(flat_t, check.flatten()) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_value_and_grad_vmap(self, output_variables, idaklu_jax_solver, f, wrapper): for outvar in output_variables: primals, tangents = wrapper( @@ -797,7 +796,7 @@ def test_value_and_grad_vmap(self, output_variables, idaklu_jax_solver, f, wrapp # Helper functions - These return values (not jaxexprs) so cannot be JITed - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jax_vars(self, output_variables, idaklu_jax_solver, f, wrapper): if wrapper == jax.jit: # Skipping test_jax_vars for jax.jit, jit not supported on helper functions @@ -812,7 +811,7 @@ def test_jax_vars(self, output_variables, idaklu_jax_solver, f, wrapper): f"{outvar}: Got: {flat_out}\nExpected: {check}", ) - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_jax_grad(self, output_variables, idaklu_jax_solver, f, wrapper): if wrapper == jax.jit: # Skipping test_jax_grad for jax.jit, jit not supported on helper functions @@ -829,7 +828,7 @@ def test_jax_grad(self, output_variables, idaklu_jax_solver, f, wrapper): # Wrap jaxified expression in another function and take the gradient - @parameterized.expand(testcase, skip_on_empty=True) + @pytest.mark.parametrize("output_variables,idaklu_jax_solver,f,wrapper", testcase) def test_grad_wrapper_sse(self, output_variables, idaklu_jax_solver, f, wrapper): # Use surrogate for experimental data data = sim["v"](t_eval) diff --git a/tests/unit/test_solvers/test_idaklu_solver.py b/tests/unit/test_solvers/test_idaklu_solver.py index 5d71b5f945..e4d6559e71 100644 --- a/tests/unit/test_solvers/test_idaklu_solver.py +++ b/tests/unit/test_solvers/test_idaklu_solver.py @@ -4,7 +4,6 @@ from contextlib import redirect_stdout import io -import unittest import pytest import numpy as np @@ -13,8 +12,8 @@ @pytest.mark.cibw -@unittest.skipIf(not pybamm.has_idaklu(), "idaklu solver is not installed") -class TestIDAKLUSolver(unittest.TestCase): +@pytest.mark.skipif(not pybamm.has_idaklu(), reason="idaklu solver is not installed") +class TestIDAKLUSolver: def test_ida_roberts_klu(self): # this test implements a python version of the ida Roberts # example provided in sundials @@ -44,9 +43,8 @@ def test_ida_roberts_klu(self): ) # Test - t_eval = np.linspace(0, 3, 100) - t_interp = t_eval - solution = solver.solve(model, t_eval, t_interp=t_interp) + t_eval = [0, 3] + solution = solver.solve(model, t_eval) # test that final time is time of event # y = 0.1 t + y0 so y=0.2 when t=2 @@ -64,6 +62,47 @@ def test_ida_roberts_klu(self): true_solution = 0.1 * solution.t np.testing.assert_array_almost_equal(solution.y[0, :], true_solution) + def test_multiple_inputs(self): + model = pybamm.BaseModel() + var = pybamm.Variable("var") + rate = pybamm.InputParameter("rate") + model.rhs = {var: -rate * var} + model.initial_conditions = {var: 2} + disc = pybamm.Discretisation() + disc.process_model(model) + + for num_threads, num_solvers in [ + [1, None], + [2, None], + [8, None], + [8, 1], + [8, 2], + [8, 7], + ]: + options = {"num_threads": num_threads} + if num_solvers is not None: + options["num_solvers"] = num_solvers + solver = pybamm.IDAKLUSolver(rtol=1e-5, atol=1e-5, options=options) + t_interp = np.linspace(0, 1, 10) + t_eval = [t_interp[0], t_interp[-1]] + ninputs = 8 + inputs_list = [{"rate": 0.01 * (i + 1)} for i in range(ninputs)] + + solutions = solver.solve( + model, t_eval, inputs=inputs_list, t_interp=t_interp + ) + + # check solution + for inputs, solution in zip(inputs_list, solutions): + print("checking solution", inputs, solution.all_inputs) + np.testing.assert_array_equal(solution.t, t_interp) + np.testing.assert_allclose( + solution.y[0], + 2 * np.exp(-inputs["rate"] * solution.t), + atol=1e-4, + rtol=1e-4, + ) + def test_model_events(self): for form in ["casadi", "iree"]: if (form == "iree") and (not pybamm.has_jax() or not pybamm.has_iree()): @@ -105,7 +144,7 @@ def test_model_events(self): ) # Check invalid atol type raises an error - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): solver._check_atol_type({"key": "value"}, []) # enforce events that won't be triggered @@ -136,7 +175,7 @@ def test_model_events(self): options={"jax_evaluator": "iree"} if form == "iree" else {}, ) solution = solver.solve(model_disc, t_eval, t_interp=t_interp) - self.assertLess(len(solution.t), len(t_interp)) + assert len(solution.t) < len(t_interp) np.testing.assert_array_almost_equal( solution.y[0], np.exp(0.1 * solution.t), @@ -271,8 +310,7 @@ def test_sensitivities_initial_condition(self): options={"jax_evaluator": "iree"} if form == "iree" else {}, ) - t_interp = np.linspace(0, 3, 100) - t_eval = [t_interp[0], t_interp[-1]] + t_eval = [0, 3] a_value = 0.1 @@ -281,7 +319,6 @@ def test_sensitivities_initial_condition(self): t_eval, inputs={"a": a_value}, calculate_sensitivities=True, - t_interp=t_interp, ) np.testing.assert_array_almost_equal( @@ -344,7 +381,7 @@ def test_ida_roberts_klu_sensitivities(self): ) # should be no sensitivities calculated - with self.assertRaises(KeyError): + with pytest.raises(KeyError): print(sol.sensitivities["a"]) # now solve with sensitivities (this should cause set_up to be run again) @@ -566,7 +603,7 @@ def test_failures(self): solver = pybamm.IDAKLUSolver() t_eval = [0, 3] - with self.assertRaisesRegex(pybamm.SolverError, "KLU requires the Jacobian"): + with pytest.raises(pybamm.SolverError, match="KLU requires the Jacobian"): solver.solve(model, t_eval) model = pybamm.BaseModel() @@ -581,8 +618,8 @@ def test_failures(self): # will give solver error t_eval = [0, -3] - with self.assertRaisesRegex( - pybamm.SolverError, "t_eval must increase monotonically" + with pytest.raises( + pybamm.SolverError, match="t_eval must increase monotonically" ): solver.solve(model, t_eval) @@ -598,7 +635,7 @@ def test_failures(self): solver = pybamm.IDAKLUSolver() t_eval = [0, 3] - with self.assertRaisesRegex(pybamm.SolverError, "FAILURE IDA"): + with pytest.raises(ValueError, match="std::exception"): solver.solve(model, t_eval) def test_dae_solver_algebraic_model(self): @@ -677,14 +714,14 @@ def test_setup_options(self): with redirect_stdout(f): solver.solve(model, t_eval, t_interp=t_interp) s = f.getvalue() - self.assertIn("Solver Stats", s) + assert "Solver Stats" in s solver = pybamm.IDAKLUSolver(options={"print_stats": False}) f = io.StringIO() with redirect_stdout(f): solver.solve(model, t_eval, t_interp=t_interp) s = f.getvalue() - self.assertEqual(len(s), 0) + assert len(s) == 0 # test everything else for jacobian in ["none", "dense", "sparse", "matrix-free", "garbage"]: @@ -731,7 +768,7 @@ def test_setup_options(self): soln = solver.solve(model, t_eval, t_interp=t_interp) np.testing.assert_array_almost_equal(soln.y, soln_base.y, 4) else: - with self.assertRaises(ValueError): + with pytest.raises(ValueError): soln = solver.solve(model, t_eval, t_interp=t_interp) def test_solver_options(self): @@ -796,7 +833,7 @@ def test_solver_options(self): options = {option: options_fail[option]} solver = pybamm.IDAKLUSolver(options=options) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): solver.solve(model, t_eval) def test_with_output_variables(self): @@ -899,12 +936,15 @@ def construct_model(): # Check that the missing variables are not available in the solution for varname in inaccessible_vars: - with self.assertRaises(KeyError): + with pytest.raises(KeyError): sol[varname].data + # Check Solution is marked + assert sol.variables_returned is True + # Mock a 1D current collector and initialise (none in the model) sol["x_s [m]"].domain = ["current collector"] - sol["x_s [m]"].initialise_1D() + sol["x_s [m]"].entries def test_with_output_variables_and_sensitivities(self): # Construct a model and solve for all variables, then test @@ -1000,18 +1040,18 @@ def test_with_output_variables_and_sensitivities(self): # Mock a 1D current collector and initialise (none in the model) sol["x_s [m]"].domain = ["current collector"] - sol["x_s [m]"].initialise_1D() + sol["x_s [m]"].entries def test_bad_jax_evaluator(self): model = pybamm.lithium_ion.DFN() model.convert_to_format = "jax" - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): pybamm.IDAKLUSolver(options={"jax_evaluator": "bad_evaluator"}) def test_bad_jax_evaluator_output_variables(self): model = pybamm.lithium_ion.DFN() model.convert_to_format = "jax" - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): pybamm.IDAKLUSolver( options={"jax_evaluator": "bad_evaluator"}, output_variables=["Terminal voltage [V]"], @@ -1027,7 +1067,7 @@ def test_with_output_variables_and_event_termination(self): solver=pybamm.IDAKLUSolver(output_variables=["Terminal voltage [V]"]), ) sol = sim.solve(np.linspace(0, 3600, 2)) - self.assertEqual(sol.termination, "event: Minimum voltage [V]") + assert sol.termination == "event: Minimum voltage [V]" # create an event that doesn't require the state vector eps_p = model.variables["Positive electrode porosity"] @@ -1045,7 +1085,7 @@ def test_with_output_variables_and_event_termination(self): solver=pybamm.IDAKLUSolver(output_variables=["Terminal voltage [V]"]), ) sol3 = sim3.solve(np.linspace(0, 3600, 2)) - self.assertEqual(sol3.termination, "event: Minimum voltage [V]") + assert sol3.termination == "event: Minimum voltage [V]" def test_simulation_period(self): model = pybamm.lithium_ion.DFN() @@ -1068,19 +1108,40 @@ def test_simulation_period(self): def test_interpolate_time_step_start_offset(self): model = pybamm.lithium_ion.SPM() - experiment = pybamm.Experiment( - [ - "Discharge at C/10 for 10 seconds", - "Charge at C/10 for 10 seconds", - ], - period="1 seconds", - ) + + def experiment_setup(period=None): + return pybamm.Experiment( + [ + "Discharge at C/10 for 10 seconds", + "Charge at C/10 for 10 seconds", + ], + period=period, + ) + + experiment_1s = experiment_setup(period="1 seconds") solver = pybamm.IDAKLUSolver() - sim = pybamm.Simulation(model, experiment=experiment, solver=solver) - sol = sim.solve() + sim_1s = pybamm.Simulation(model, experiment=experiment_1s, solver=solver) + sol_1s = sim_1s.solve() np.testing.assert_equal( - sol.sub_solutions[0].t[-1] + pybamm.settings.step_start_offset, - sol.sub_solutions[1].t[0], + np.nextafter(sol_1s.sub_solutions[0].t[-1], np.inf), + sol_1s.sub_solutions[1].t[0], + ) + + assert not sol_1s.hermite_interpolation + + experiment = experiment_setup(period=None) + sim = pybamm.Simulation(model, experiment=experiment, solver=solver) + sol = sim.solve(model) + + assert sol.hermite_interpolation + + rtol = solver.rtol + atol = solver.atol + np.testing.assert_allclose( + sol_1s["Voltage [V]"].data, + sol["Voltage [V]"](sol_1s.t), + rtol=rtol, + atol=atol, ) def test_python_idaklu_deprecation_errors(self): @@ -1107,29 +1168,39 @@ def test_python_idaklu_deprecation_errors(self): ) if form == "python": - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Unsupported option for convert_to_format=python", + match="Unsupported option for convert_to_format=python", ): - with self.assertWarnsRegex( + with pytest.raises( DeprecationWarning, - "The python-idaklu solver has been deprecated.", + match="The python-idaklu solver has been deprecated.", ): _ = solver.solve(model, t_eval) elif form == "jax": - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - "Unsupported evaluation engine for convert_to_format=jax", + match="Unsupported evaluation engine for convert_to_format=jax", ): _ = solver.solve(model, t_eval) + def test_extrapolation_events_with_output_variables(self): + # Make sure the extrapolation checks work with output variables + model = pybamm.BaseModel() + v = pybamm.Variable("v") + c = pybamm.Variable("c") + model.variables = {"v": v, "c": c} + model.rhs = {v: -1, c: 0} + model.initial_conditions = {v: 1, c: 2} + model.events.append( + pybamm.Event( + "Triggered event", + v - 0.5, + pybamm.EventType.INTERPOLANT_EXTRAPOLATION, + ) + ) + solver = pybamm.IDAKLUSolver(output_variables=["c"]) + solver.set_up(model) -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - - unittest.main() + with pytest.warns(pybamm.SolverWarning, match="extrapolation occurred for"): + solver.solve(model, t_eval=[0, 1]) diff --git a/tests/unit/test_solvers/test_jax_bdf_solver.py b/tests/unit/test_solvers/test_jax_bdf_solver.py index e0064ae463..98eaed8e6a 100644 --- a/tests/unit/test_solvers/test_jax_bdf_solver.py +++ b/tests/unit/test_solvers/test_jax_bdf_solver.py @@ -1,16 +1,15 @@ +import pytest import pybamm -import unittest from tests import get_mesh_for_testing -import sys import numpy as np if pybamm.has_jax(): import jax -@unittest.skipIf(not pybamm.has_jax(), "jax or jaxlib is not installed") -class TestJaxBDFSolver(unittest.TestCase): +@pytest.mark.skipif(not pybamm.has_jax(), reason="jax or jaxlib is not installed") +class TestJaxBDFSolver: def test_solver_(self): # Trailing _ manipulates the random seed # Create model model = pybamm.BaseModel() @@ -113,7 +112,7 @@ def solve_bdf(rate): grad_solve_bdf = jax.jit(jax.grad(solve_bdf)) grad_bdf = grad_solve_bdf(rate) - self.assertAlmostEqual(grad_bdf, grad_num, places=3) + assert grad_bdf == pytest.approx(grad_num, abs=0.001) def test_mass_matrix_with_sensitivities(self): # Solve @@ -146,7 +145,7 @@ def solve_bdf(rate): grad_solve_bdf = jax.jit(jax.grad(solve_bdf)) grad_bdf = grad_solve_bdf(rate) - self.assertAlmostEqual(grad_bdf, grad_num, places=3) + assert grad_bdf == pytest.approx(grad_num, abs=0.001) def test_solver_with_inputs(self): # Create model @@ -176,12 +175,3 @@ def fun(y, t, inputs): ) np.testing.assert_allclose(y[:, 0].reshape(-1), np.exp(-0.1 * t_eval)) - - -if __name__ == "__main__": - print("Add -v for more debug output") - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_jax_solver.py b/tests/unit/test_solvers/test_jax_solver.py index b1c293c2f2..8f43eda3c7 100644 --- a/tests/unit/test_solvers/test_jax_solver.py +++ b/tests/unit/test_solvers/test_jax_solver.py @@ -1,16 +1,15 @@ +import pytest import pybamm -import unittest from tests import get_mesh_for_testing -import sys import numpy as np if pybamm.has_jax(): import jax -@unittest.skipIf(not pybamm.has_jax(), "jax or jaxlib is not installed") -class TestJaxSolver(unittest.TestCase): +@pytest.mark.skipif(not pybamm.has_jax(), reason="jax or jaxlib is not installed") +class TestJaxSolver: def test_model_solver(self): # Create model model = pybamm.BaseModel() @@ -38,10 +37,8 @@ def test_model_solver(self): ) # Test time - self.assertEqual( - solution.total_time, solution.solve_time + solution.set_up_time - ) - self.assertEqual(solution.termination, "final time") + assert solution.total_time == solution.solve_time + solution.set_up_time + assert solution.termination == "final time" second_solution = solver.solve(model, t_eval) @@ -76,10 +73,8 @@ def test_semi_explicit_model(self): np.testing.assert_allclose(solution.y[-1], 2 * soln, rtol=1e-7, atol=1e-7) # Test time - self.assertEqual( - solution.total_time, solution.solve_time + solution.set_up_time - ) - self.assertEqual(solution.termination, "final time") + assert solution.total_time == solution.solve_time + solution.set_up_time + assert solution.termination == "final time" second_solution = solver.solve(model, t_eval) np.testing.assert_array_equal(second_solution.y, solution.y) @@ -124,7 +119,7 @@ def solve_model(rate, solve=solve): grad_solve = jax.jit(jax.grad(solve_model)) grad = grad_solve(rate) - self.assertAlmostEqual(grad, grad_num, places=1) + assert grad == pytest.approx(grad_num, abs=0.1) def test_solver_only_works_with_jax(self): model = pybamm.BaseModel() @@ -144,7 +139,7 @@ def test_solver_only_works_with_jax(self): model.convert_to_format = convert_to_format solver = pybamm.JaxSolver() - with self.assertRaisesRegex(RuntimeError, "must be converted to JAX"): + with pytest.raises(RuntimeError, match="must be converted to JAX"): solver.solve(model, t_eval) def test_solver_doesnt_support_events(self): @@ -171,7 +166,7 @@ def test_solver_doesnt_support_events(self): # Solve solver = pybamm.JaxSolver() t_eval = np.linspace(0, 10, 100) - with self.assertRaisesRegex(RuntimeError, "Terminate events not supported"): + with pytest.raises(RuntimeError, match="Terminate events not supported"): solver.solve(model, t_eval) def test_model_solver_with_inputs(self): @@ -223,14 +218,14 @@ def test_get_solve(self): disc.process_model(model) # test that another method string gives error - with self.assertRaises(ValueError): + with pytest.raises(ValueError): solver = pybamm.JaxSolver(method="not_real") # Solve solver = pybamm.JaxSolver(rtol=1e-8, atol=1e-8) t_eval = np.linspace(0, 5, 80) - with self.assertRaisesRegex(RuntimeError, "Model is not set up for solving"): + with pytest.raises(RuntimeError, match="Model is not set up for solving"): solver.get_solve(model, t_eval) solver.solve(model, t_eval, inputs={"rate": 0.1}) @@ -243,11 +238,10 @@ def test_get_solve(self): np.testing.assert_allclose(y[0], np.exp(-0.2 * t_eval), rtol=1e-6, atol=1e-6) - -if __name__ == "__main__": - print("Add -v for more debug output") - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + # Reset solver, test passing `calculate_sensitivities` + for method in ["RK45", "BDF"]: + solver = pybamm.JaxSolver(method=method, rtol=1e-8, atol=1e-8) + solution_sens = solver.solve( + model, t_eval, inputs={"rate": 0.1}, calculate_sensitivities=True + ) + assert len(solution_sens.sensitivities) == 0 diff --git a/tests/unit/test_solvers/test_processed_variable.py b/tests/unit/test_solvers/test_processed_variable.py index b6ae669878..b3ab850607 100644 --- a/tests/unit/test_solvers/test_processed_variable.py +++ b/tests/unit/test_solvers/test_processed_variable.py @@ -7,7 +7,14 @@ import tests import numpy as np -import unittest +import pytest +from scipy.interpolate import CubicHermiteSpline + + +if pybamm.has_idaklu(): + _hermite_args = [True, False] +else: + _hermite_args = [False] def to_casadi(var_pybamm, y, inputs=None): @@ -27,55 +34,91 @@ def to_casadi(var_pybamm, y, inputs=None): return var_casadi -def process_and_check_2D_variable( - var, first_spatial_var, second_spatial_var, disc=None, geometry_options=None -): - # first_spatial_var should be on the "smaller" domain, i.e "r" for an "r-x" variable - if geometry_options is None: - geometry_options = {} - if disc is None: - disc = tests.get_discretisation_for_testing() - disc.set_variable_slices([var]) - - first_sol = disc.process_symbol(first_spatial_var).entries[:, 0] - second_sol = disc.process_symbol(second_spatial_var).entries[:, 0] - - # Keep only the first iteration of entries - first_sol = first_sol[: len(first_sol) // len(second_sol)] - var_sol = disc.process_symbol(var) - t_sol = np.linspace(0, 1) - y_sol = np.ones(len(second_sol) * len(first_sol))[:, np.newaxis] * np.linspace(0, 5) - - var_casadi = to_casadi(var_sol, y_sol) - model = tests.get_base_model_with_battery_geometry(**geometry_options) - processed_var = pybamm.ProcessedVariable( - [var_sol], - [var_casadi], - pybamm.Solution(t_sol, y_sol, model, {}), - warn=False, - ) - np.testing.assert_array_equal( - processed_var.entries, - np.reshape(y_sol, [len(first_sol), len(second_sol), len(t_sol)]), - ) - return y_sol, first_sol, second_sol, t_sol - - -class TestProcessedVariable(unittest.TestCase): - def test_processed_variable_0D(self): +class TestProcessedVariable: + @staticmethod + def _get_yps(y, hermite_interp, values=1): + if hermite_interp: + yp_sol = values * np.ones_like(y) + else: + yp_sol = None + return yp_sol + + @staticmethod + def _sol_default(t_sol, y_sol, yp_sol=None, model=None, inputs=None): + if inputs is None: + inputs = {} + if model is None: + model = tests.get_base_model_with_battery_geometry() + return pybamm.Solution( + t_sol, + y_sol, + model, + inputs, + all_yps=yp_sol, + ) + + def _process_and_check_2D_variable( + self, + var, + first_spatial_var, + second_spatial_var, + disc=None, + geometry_options=None, + hermite_interp=False, + ): + # first_spatial_var should be on the "smaller" domain, i.e "r" for an "r-x" variable + if geometry_options is None: + geometry_options = {} + if disc is None: + disc = tests.get_discretisation_for_testing() + disc.set_variable_slices([var]) + + first_sol = disc.process_symbol(first_spatial_var).entries[:, 0] + second_sol = disc.process_symbol(second_spatial_var).entries[:, 0] + + # Keep only the first iteration of entries + first_sol = first_sol[: len(first_sol) // len(second_sol)] + var_sol = disc.process_symbol(var) + t_sol = np.linspace(0, 1) + y_sol = 5 * t_sol * np.ones(len(second_sol) * len(first_sol))[:, np.newaxis] + yp_sol = self._get_yps(y_sol, hermite_interp, values=5) + + var_casadi = to_casadi(var_sol, y_sol) + model = tests.get_base_model_with_battery_geometry(**geometry_options) + processed_var = pybamm.process_variable( + [var_sol], + [var_casadi], + self._sol_default(t_sol, y_sol, yp_sol, model), + ) + np.testing.assert_array_equal( + processed_var.entries, + np.reshape(y_sol, [len(first_sol), len(second_sol), len(t_sol)]), + ) + + # check that C++ and Python give the same result + if pybamm.has_idaklu(): + np.testing.assert_array_equal( + processed_var._observe_raw_cpp(), processed_var._observe_raw_python() + ) + + return y_sol, first_sol, second_sol, t_sol, yp_sol + + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_0D(self, hermite_interp): # without space t = pybamm.t y = pybamm.StateVector(slice(0, 1)) var = t * y var.mesh = None + model = pybamm.BaseModel() t_sol = np.linspace(0, 1) y_sol = np.array([np.linspace(0, 5)]) + yp_sol = self._get_yps(y_sol, hermite_interp) var_casadi = to_casadi(var, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var], [var_casadi], - pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), {}), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol, model), ) np.testing.assert_array_equal(processed_var.entries, t_sol * y_sol[0]) @@ -84,18 +127,102 @@ def test_processed_variable_0D(self): var.mesh = None t_sol = np.array([0]) y_sol = np.array([1])[:, np.newaxis] + yp_sol = np.array([1])[:, np.newaxis] + sol = self._sol_default(t_sol, y_sol, yp_sol, model) var_casadi = to_casadi(var, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var], [var_casadi], - pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), {}), - warn=False, + sol, ) np.testing.assert_array_equal(processed_var.entries, y_sol[0]) - # check empty sensitivity works + # check that repeated calls return the same data + data1 = processed_var.data + + assert processed_var.entries_raw_initialized + + data2 = processed_var.data + + np.testing.assert_array_equal(data1, data2) + + data_t1 = processed_var(sol.t) + + assert processed_var.xr_array_raw_initialized + + data_t2 = processed_var(sol.t) + + np.testing.assert_array_equal(data_t1, data_t2) + + # check that C++ and Python give the same result + if pybamm.has_idaklu(): + np.testing.assert_array_equal( + processed_var._observe_raw_cpp(), processed_var._observe_raw_python() + ) + + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_0D_discrete_data(self, hermite_interp): + y = pybamm.StateVector(slice(0, 1)) + t_sol = np.linspace(0, 1) + y_sol = np.array([np.linspace(0, 5)]) + data_const = 3.6 + if hermite_interp: + yp_sol = 5 * np.ones_like(y_sol) + else: + yp_sol = None + + # hermite interpolation can do order 2 interpolation, otherwise make sure result is linear + order = 2 if hermite_interp else 1 + + # data is same timepoints as solution + data_t = t_sol + data_v = -data_const * data_t + data = pybamm.DiscreteTimeData(data_t, data_v, "test_data") + var = (y - data) ** order + expected_entries = (y_sol - data_v) ** order + var.mesh = None + model = pybamm.BaseModel() + var_casadi = to_casadi(var, y_sol) + processed_var = pybamm.process_variable( + [var], + [var_casadi], + self._sol_default(t_sol, y_sol, yp_sol, model), + ) + np.testing.assert_array_equal(processed_var.entries, expected_entries.flatten()) + np.testing.assert_array_equal(processed_var(data_t), expected_entries.flatten()) + + # data is different timepoints as solution + data_t = np.linspace(0, 1, 7) + data_v = -data_const * data_t + y_sol_interp = (np.interp(data_t, t_sol, y_sol[0]),) + data_v_interp = np.interp(t_sol, data_t, data_v) + data = pybamm.DiscreteTimeData(data_t, data_v, "test_data") + + # check data interp + np.testing.assert_array_almost_equal( + data.evaluate(t=t_sol).flatten(), data_v_interp + ) + + var = (y - data) ** order + expected = (y_sol_interp - data_v) ** order + expected_entries = (y_sol - data_v_interp) ** order + var.mesh = None + model = pybamm.BaseModel() + var_casadi = to_casadi(var, y_sol) + processed_var = pybamm.process_variable( + [var], + [var_casadi], + self._sol_default(t_sol, y_sol, yp_sol, model), + ) + np.testing.assert_array_almost_equal( + processed_var.entries, expected_entries.flatten(), decimal=10 + ) + np.testing.assert_array_almost_equal( + processed_var(t=data_t), expected.flatten(), decimal=10 + ) - def test_processed_variable_0D_no_sensitivity(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_0D_no_sensitivity(self, hermite_interp): # without space t = pybamm.t y = pybamm.StateVector(slice(0, 1)) @@ -103,16 +230,16 @@ def test_processed_variable_0D_no_sensitivity(self): var.mesh = None t_sol = np.linspace(0, 1) y_sol = np.array([np.linspace(0, 5)]) + yp_sol = self._get_yps(y_sol, hermite_interp) var_casadi = to_casadi(var, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var], [var_casadi], - pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), {}), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol, pybamm.BaseModel()), ) # test no inputs (i.e. no sensitivity) - self.assertDictEqual(processed_var.sensitivities, {}) + assert processed_var.sensitivities == {} # with parameter t = pybamm.t @@ -124,18 +251,18 @@ def test_processed_variable_0D_no_sensitivity(self): y_sol = np.array([np.linspace(0, 5)]) inputs = {"a": np.array([1.0])} var_casadi = to_casadi(var, y_sol, inputs=inputs) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var], [var_casadi], pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), inputs), - warn=False, ) # test no sensitivity raises error - with self.assertRaisesRegex(ValueError, "Cannot compute sensitivities"): + with pytest.raises(ValueError, match="Cannot compute sensitivities"): print(processed_var.sensitivities) - def test_processed_variable_1D(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_1D(self, hermite_interp): t = pybamm.t var = pybamm.Variable("var", domain=["negative electrode", "separator"]) x = pybamm.SpatialVariable("x", domain=["negative electrode", "separator"]) @@ -149,26 +276,21 @@ def test_processed_variable_1D(self): eqn_sol = disc.process_symbol(eqn) t_sol = np.linspace(0, 1) y_sol = np.ones_like(x_sol)[:, np.newaxis] * np.linspace(0, 5) + yp_sol = self._get_yps(y_sol, hermite_interp, values=5) var_casadi = to_casadi(var_sol, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) np.testing.assert_array_equal(processed_var.entries, y_sol) np.testing.assert_array_almost_equal(processed_var(t_sol, x_sol), y_sol) eqn_casadi = to_casadi(eqn_sol, y_sol) - processed_eqn = pybamm.ProcessedVariable( + processed_eqn = pybamm.process_variable( [eqn_sol], [eqn_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) np.testing.assert_array_almost_equal( processed_eqn(t_sol, x_sol), t_sol * y_sol + x_sol[:, np.newaxis] @@ -184,13 +306,10 @@ def test_processed_variable_1D(self): x_s_edge = pybamm.Matrix(disc.mesh["separator"].edges, domain="separator") x_s_edge.mesh = disc.mesh["separator"] x_s_casadi = to_casadi(x_s_edge, y_sol) - processed_x_s_edge = pybamm.ProcessedVariable( + processed_x_s_edge = pybamm.process_variable( [x_s_edge], [x_s_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) np.testing.assert_array_equal( x_s_edge.entries[:, 0], processed_x_s_edge.entries[:, 0] @@ -201,20 +320,25 @@ def test_processed_variable_1D(self): eqn_sol = disc.process_symbol(eqn) t_sol = np.array([0]) y_sol = np.ones_like(x_sol)[:, np.newaxis] + yp_sol = self._get_yps(y_sol, hermite_interp, values=0) eqn_casadi = to_casadi(eqn_sol, y_sol) - processed_eqn2 = pybamm.ProcessedVariable( + processed_eqn2 = pybamm.process_variable( [eqn_sol], [eqn_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) np.testing.assert_array_equal( processed_eqn2.entries, y_sol + x_sol[:, np.newaxis] ) - def test_processed_variable_1D_unknown_domain(self): + # check that C++ and Python give the same result + if pybamm.has_idaklu(): + np.testing.assert_array_equal( + processed_eqn2._observe_raw_cpp(), processed_eqn2._observe_raw_python() + ) + + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_1D_unknown_domain(self, hermite_interp): x = pybamm.SpatialVariable("x", domain="SEI layer", coord_sys="cartesian") geometry = pybamm.Geometry( {"SEI layer": {x: {"min": pybamm.Scalar(0), "max": pybamm.Scalar(1)}}} @@ -227,6 +351,7 @@ def test_processed_variable_1D_unknown_domain(self): nt = 100 y_sol = np.zeros((var_pts[x], nt)) + yp_sol = self._get_yps(y_sol, hermite_interp) model = tests.get_base_model_with_battery_geometry() model._geometry = geometry solution = pybamm.Solution( @@ -237,14 +362,16 @@ def test_processed_variable_1D_unknown_domain(self): np.linspace(0, 1, 1), np.zeros(var_pts[x]), "test", + all_yps=yp_sol, ) c = pybamm.StateVector(slice(0, var_pts[x]), domain=["SEI layer"]) c.mesh = mesh["SEI layer"] c_casadi = to_casadi(c, y_sol) - pybamm.ProcessedVariable([c], [c_casadi], solution, warn=False) + pybamm.process_variable([c], [c_casadi], solution) - def test_processed_variable_2D_x_r(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_2D_x_r(self, hermite_interp): var = pybamm.Variable( "var", domain=["negative particle"], @@ -258,9 +385,12 @@ def test_processed_variable_2D_x_r(self): ) disc = tests.get_p2d_discretisation_for_testing() - process_and_check_2D_variable(var, r, x, disc=disc) + self._process_and_check_2D_variable( + var, r, x, disc=disc, hermite_interp=hermite_interp + ) - def test_processed_variable_2D_R_x(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_2D_R_x(self, hermite_interp): var = pybamm.Variable( "var", domain=["negative particle size"], @@ -274,15 +404,17 @@ def test_processed_variable_2D_R_x(self): x = pybamm.SpatialVariable("x", domain=["negative electrode"]) disc = tests.get_size_distribution_disc_for_testing() - process_and_check_2D_variable( + self._process_and_check_2D_variable( var, R, x, disc=disc, geometry_options={"options": {"particle size": "distribution"}}, + hermite_interp=hermite_interp, ) - def test_processed_variable_2D_R_z(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_2D_R_z(self, hermite_interp): var = pybamm.Variable( "var", domain=["negative particle size"], @@ -296,15 +428,17 @@ def test_processed_variable_2D_R_z(self): z = pybamm.SpatialVariable("z", domain=["current collector"]) disc = tests.get_size_distribution_disc_for_testing() - process_and_check_2D_variable( + self._process_and_check_2D_variable( var, R, z, disc=disc, geometry_options={"options": {"particle size": "distribution"}}, + hermite_interp=hermite_interp, ) - def test_processed_variable_2D_r_R(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_2D_r_R(self, hermite_interp): var = pybamm.Variable( "var", domain=["negative particle"], @@ -318,15 +452,17 @@ def test_processed_variable_2D_r_R(self): R = pybamm.SpatialVariable("R", domain=["negative particle size"]) disc = tests.get_size_distribution_disc_for_testing() - process_and_check_2D_variable( + self._process_and_check_2D_variable( var, r, R, disc=disc, geometry_options={"options": {"particle size": "distribution"}}, + hermite_interp=hermite_interp, ) - def test_processed_variable_2D_x_z(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_2D_x_z(self, hermite_interp): var = pybamm.Variable( "var", domain=["negative electrode", "separator"], @@ -340,7 +476,9 @@ def test_processed_variable_2D_x_z(self): z = pybamm.SpatialVariable("z", domain=["current collector"]) disc = tests.get_1p1d_discretisation_for_testing() - y_sol, x_sol, z_sol, t_sol = process_and_check_2D_variable(var, x, z, disc=disc) + y_sol, x_sol, z_sol, t_sol, yp_sol = self._process_and_check_2D_variable( + var, x, z, disc=disc, hermite_interp=hermite_interp + ) del x_sol # On edges @@ -352,19 +490,17 @@ def test_processed_variable_2D_x_z(self): x_s_edge.mesh = disc.mesh["separator"] x_s_edge.secondary_mesh = disc.mesh["current collector"] x_s_casadi = to_casadi(x_s_edge, y_sol) - processed_x_s_edge = pybamm.ProcessedVariable( + processed_x_s_edge = pybamm.process_variable( [x_s_edge], [x_s_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) np.testing.assert_array_equal( x_s_edge.entries.flatten(), processed_x_s_edge.entries[:, :, 0].T.flatten() ) - def test_processed_variable_2D_space_only(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_2D_space_only(self, hermite_interp): var = pybamm.Variable( "var", domain=["negative particle"], @@ -386,22 +522,21 @@ def test_processed_variable_2D_space_only(self): var_sol = disc.process_symbol(var) t_sol = np.array([0]) y_sol = np.ones(len(x_sol) * len(r_sol))[:, np.newaxis] + yp_sol = self._get_yps(y_sol, hermite_interp) var_casadi = to_casadi(var_sol, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) np.testing.assert_array_equal( processed_var.entries, np.reshape(y_sol, [len(r_sol), len(x_sol), len(t_sol)]), ) - def test_processed_variable_2D_scikit(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_2D_scikit(self, hermite_interp): var = pybamm.Variable("var", domain=["current collector"]) disc = tests.get_2p1d_discretisation_for_testing() @@ -412,21 +547,20 @@ def test_processed_variable_2D_scikit(self): var_sol.mesh = disc.mesh["current collector"] t_sol = np.linspace(0, 1) u_sol = np.ones(var_sol.shape[0])[:, np.newaxis] * np.linspace(0, 5) + yp_sol = self._get_yps(u_sol, hermite_interp) var_casadi = to_casadi(var_sol, u_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, u_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, u_sol, yp_sol), ) np.testing.assert_array_equal( processed_var.entries, np.reshape(u_sol, [len(y), len(z), len(t_sol)]) ) - def test_processed_variable_2D_fixed_t_scikit(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_variable_2D_fixed_t_scikit(self, hermite_interp): var = pybamm.Variable("var", domain=["current collector"]) disc = tests.get_2p1d_discretisation_for_testing() @@ -437,22 +571,23 @@ def test_processed_variable_2D_fixed_t_scikit(self): var_sol.mesh = disc.mesh["current collector"] t_sol = np.array([0]) u_sol = np.ones(var_sol.shape[0])[:, np.newaxis] + yp_sol = self._get_yps(u_sol, hermite_interp) var_casadi = to_casadi(var_sol, u_sol) model = tests.get_base_model_with_battery_geometry( options={"dimensionality": 2} ) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution(t_sol, u_sol, model, {}), - warn=False, + pybamm.Solution(t_sol, u_sol, model, {}, all_yps=yp_sol), ) np.testing.assert_array_equal( processed_var.entries, np.reshape(u_sol, [len(y), len(z), len(t_sol)]) ) - def test_processed_var_0D_interpolation(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_0D_interpolation(self, hermite_interp): # without spatial dependence t = pybamm.t y = pybamm.StateVector(slice(0, 1)) @@ -462,40 +597,39 @@ def test_processed_var_0D_interpolation(self): eqn.mesh = None t_sol = np.linspace(0, 1, 1000) - y_sol = np.array([np.linspace(0, 5, 1000)]) + y_sol = np.array([5 * t_sol]) + yp_sol = self._get_yps(y_sol, hermite_interp, values=5) var_casadi = to_casadi(var, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var], [var_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) # vector np.testing.assert_array_equal(processed_var(t_sol), y_sol[0]) # scalar - np.testing.assert_array_equal(processed_var(0.5), 2.5) - np.testing.assert_array_equal(processed_var(0.7), 3.5) + np.testing.assert_array_almost_equal(processed_var(0.5), 2.5) + np.testing.assert_array_almost_equal(processed_var(0.7), 3.5) eqn_casadi = to_casadi(eqn, y_sol) - processed_eqn = pybamm.ProcessedVariable( + processed_eqn = pybamm.process_variable( [eqn], [eqn_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) np.testing.assert_array_equal(processed_eqn(t_sol), t_sol * y_sol[0]) - np.testing.assert_array_almost_equal(processed_eqn(0.5), 0.5 * 2.5) + assert processed_eqn(0.5).shape == () + + np.testing.assert_array_almost_equal(processed_eqn(0.5), 0.5 * 2.5) + np.testing.assert_array_equal(processed_eqn(2, fill_value=100), 100) # Suppress warning for this test pybamm.set_logging_level("ERROR") np.testing.assert_array_equal(processed_eqn(2), np.nan) pybamm.set_logging_level("WARNING") - def test_processed_var_0D_fixed_t_interpolation(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_0D_fixed_t_interpolation(self, hermite_interp): y = pybamm.StateVector(slice(0, 1)) var = y eqn = 2 * y @@ -504,17 +638,18 @@ def test_processed_var_0D_fixed_t_interpolation(self): t_sol = np.array([10]) y_sol = np.array([[100]]) + yp_sol = self._get_yps(y_sol, hermite_interp) eqn_casadi = to_casadi(eqn, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [eqn], [eqn_casadi], - pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), {}), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol, pybamm.BaseModel()), ) - np.testing.assert_array_equal(processed_var(), 200) + assert processed_var() == 200 - def test_processed_var_1D_interpolation(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_1D_interpolation(self, hermite_interp): t = pybamm.t var = pybamm.Variable("var", domain=["negative electrode", "separator"]) x = pybamm.SpatialVariable("x", domain=["negative electrode", "separator"]) @@ -526,58 +661,52 @@ def test_processed_var_1D_interpolation(self): var_sol = disc.process_symbol(var) eqn_sol = disc.process_symbol(eqn) t_sol = np.linspace(0, 1) - y_sol = x_sol[:, np.newaxis] * np.linspace(0, 5) + y_sol = x_sol[:, np.newaxis] * (5 * t_sol) + yp_sol = self._get_yps(y_sol, hermite_interp, values=5) var_casadi = to_casadi(var_sol, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) + # 2 vectors np.testing.assert_array_almost_equal(processed_var(t_sol, x_sol), y_sol) # 1 vector, 1 scalar np.testing.assert_array_almost_equal(processed_var(0.5, x_sol), 2.5 * x_sol) - np.testing.assert_array_equal( - processed_var(t_sol, x_sol[-1]), x_sol[-1] * np.linspace(0, 5) + np.testing.assert_array_almost_equal( + processed_var(t_sol, x_sol[-1]), + x_sol[-1] * np.linspace(0, 5), ) # 2 scalars np.testing.assert_array_almost_equal( processed_var(0.5, x_sol[-1]), 2.5 * x_sol[-1] ) eqn_casadi = to_casadi(eqn_sol, y_sol) - processed_eqn = pybamm.ProcessedVariable( + processed_eqn = pybamm.process_variable( [eqn_sol], [eqn_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) # 2 vectors np.testing.assert_array_almost_equal( processed_eqn(t_sol, x_sol), t_sol * y_sol + x_sol[:, np.newaxis] ) # 1 vector, 1 scalar - self.assertEqual(processed_eqn(0.5, x_sol[10:30]).shape, (20,)) - self.assertEqual(processed_eqn(t_sol[4:9], x_sol[-1]).shape, (5,)) + assert processed_eqn(0.5, x_sol[10:30]).shape == (20,) + assert processed_eqn(t_sol[4:9], x_sol[-1]).shape == (5,) # 2 scalars - self.assertEqual(processed_eqn(0.5, x_sol[-1]).shape, ()) + assert processed_eqn(0.5, x_sol[-1]).shape == () # test x x_disc = disc.process_symbol(x) x_casadi = to_casadi(x_disc, y_sol) - processed_x = pybamm.ProcessedVariable( + processed_x = pybamm.process_variable( [x_disc], [x_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) np.testing.assert_array_almost_equal(processed_x(t=0, x=x_sol), x_sol) @@ -587,13 +716,10 @@ def test_processed_var_1D_interpolation(self): ) r_n.mesh = disc.mesh["negative particle"] r_n_casadi = to_casadi(r_n, y_sol) - processed_r_n = pybamm.ProcessedVariable( + processed_r_n = pybamm.process_variable( [r_n], [r_n_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) np.testing.assert_array_equal(r_n.entries[:, 0], processed_r_n.entries[:, 0]) r_test = np.linspace(0, 0.5) @@ -608,17 +734,17 @@ def test_processed_var_1D_interpolation(self): model = tests.get_base_model_with_battery_geometry( options={"particle size": "distribution"} ) - processed_R_n = pybamm.ProcessedVariable( + processed_R_n = pybamm.process_variable( [R_n], [R_n_casadi], pybamm.Solution(t_sol, y_sol, model, {}), - warn=False, ) np.testing.assert_array_equal(R_n.entries[:, 0], processed_R_n.entries[:, 0]) R_test = np.linspace(0, 1) np.testing.assert_array_almost_equal(processed_R_n(0, R=R_test), R_test) - def test_processed_var_1D_fixed_t_interpolation(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_1D_fixed_t_interpolation(self, hermite_interp): var = pybamm.Variable("var", domain=["negative electrode", "separator"]) x = pybamm.SpatialVariable("x", domain=["negative electrode", "separator"]) eqn = var + x @@ -629,15 +755,13 @@ def test_processed_var_1D_fixed_t_interpolation(self): eqn_sol = disc.process_symbol(eqn) t_sol = np.array([1]) y_sol = x_sol[:, np.newaxis] + yp_sol = self._get_yps(y_sol, hermite_interp) eqn_casadi = to_casadi(eqn_sol, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [eqn_sol], [eqn_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) # vector @@ -647,7 +771,8 @@ def test_processed_var_1D_fixed_t_interpolation(self): # scalar np.testing.assert_array_almost_equal(processed_var(x=0.5), 1) - def test_processed_var_wrong_spatial_variable_names(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_wrong_spatial_variable_names(self, hermite_interp): var = pybamm.Variable( "var", domain=["domain A", "domain B"], @@ -677,6 +802,7 @@ def test_processed_var_wrong_spatial_variable_names(self): var_sol = disc.process_symbol(var) t_sol = np.linspace(0, 1) y_sol = np.ones(len(a_sol) * len(b_sol))[:, np.newaxis] * np.linspace(0, 5) + yp_sol = self._get_yps(y_sol, hermite_interp) var_casadi = to_casadi(var_sol, y_sol) model = pybamm.BaseModel() @@ -686,15 +812,15 @@ def test_processed_var_wrong_spatial_variable_names(self): "domain B": {b: {"min": 0, "max": 1}}, } ) - with self.assertRaisesRegex(NotImplementedError, "Spatial variable name"): - pybamm.ProcessedVariable( + with pytest.raises(NotImplementedError, match="Spatial variable name"): + pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution(t_sol, y_sol, model, {}), - warn=False, - ) + pybamm.Solution(t_sol, y_sol, model, {}, all_yps=yp_sol), + ).initialise() - def test_processed_var_2D_interpolation(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_2D_interpolation(self, hermite_interp): var = pybamm.Variable( "var", domain=["negative particle"], @@ -716,15 +842,13 @@ def test_processed_var_2D_interpolation(self): var_sol = disc.process_symbol(var) t_sol = np.linspace(0, 1) y_sol = np.ones(len(x_sol) * len(r_sol))[:, np.newaxis] * np.linspace(0, 5) + yp_sol = self._get_yps(y_sol, hermite_interp) var_casadi = to_casadi(var_sol, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) # 3 vectors np.testing.assert_array_equal( @@ -768,20 +892,18 @@ def test_processed_var_2D_interpolation(self): y_sol = np.ones(len(x_sol) * len(r_sol))[:, np.newaxis] * np.linspace(0, 5) var_casadi = to_casadi(var_sol, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) # 3 vectors np.testing.assert_array_equal( processed_var(t_sol, x_sol, r_sol).shape, (10, 35, 50) ) - def test_processed_var_2D_fixed_t_interpolation(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_2D_fixed_t_interpolation(self, hermite_interp): var = pybamm.Variable( "var", domain=["negative particle"], @@ -803,15 +925,13 @@ def test_processed_var_2D_fixed_t_interpolation(self): var_sol = disc.process_symbol(var) t_sol = np.array([0]) y_sol = np.ones(len(x_sol) * len(r_sol))[:, np.newaxis] + yp_sol = self._get_yps(y_sol, hermite_interp) var_casadi = to_casadi(var_sol, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) # 2 vectors np.testing.assert_array_equal( @@ -823,7 +943,8 @@ def test_processed_var_2D_fixed_t_interpolation(self): # 2 scalars np.testing.assert_array_equal(processed_var(t=0, x=0.2, r=0.2).shape, ()) - def test_processed_var_2D_secondary_broadcast(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_2D_secondary_broadcast(self, hermite_interp): var = pybamm.Variable("var", domain=["negative particle"]) broad_var = pybamm.SecondaryBroadcast(var, "negative electrode") x = pybamm.SpatialVariable("x", domain=["negative electrode"]) @@ -836,15 +957,13 @@ def test_processed_var_2D_secondary_broadcast(self): var_sol = disc.process_symbol(broad_var) t_sol = np.linspace(0, 1) y_sol = np.ones(len(x_sol) * len(r_sol))[:, np.newaxis] * np.linspace(0, 5) + yp_sol = self._get_yps(y_sol, hermite_interp) var_casadi = to_casadi(var_sol, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) # 3 vectors np.testing.assert_array_equal( @@ -877,22 +996,21 @@ def test_processed_var_2D_secondary_broadcast(self): var_sol = disc.process_symbol(broad_var) t_sol = np.linspace(0, 1) y_sol = np.ones(len(x_sol) * len(r_sol))[:, np.newaxis] * np.linspace(0, 5) + yp_sol = self._get_yps(y_sol, hermite_interp, values=5) var_casadi = to_casadi(var_sol, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, y_sol, yp_sol), ) # 3 vectors np.testing.assert_array_equal( processed_var(t_sol, x_sol, r_sol).shape, (10, 35, 50) ) - def test_processed_var_2D_scikit_interpolation(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_2_d_scikit_interpolation(self, hermite_interp): var = pybamm.Variable("var", domain=["current collector"]) disc = tests.get_2p1d_discretisation_for_testing() @@ -903,15 +1021,13 @@ def test_processed_var_2D_scikit_interpolation(self): var_sol.mesh = disc.mesh["current collector"] t_sol = np.linspace(0, 1) u_sol = np.ones(var_sol.shape[0])[:, np.newaxis] * np.linspace(0, 5) + yp_sol = self._get_yps(u_sol, hermite_interp) var_casadi = to_casadi(var_sol, u_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, u_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, u_sol, yp_sol), ) # 3 vectors np.testing.assert_array_equal( @@ -938,7 +1054,8 @@ def test_processed_var_2D_scikit_interpolation(self): # 3 scalars np.testing.assert_array_equal(processed_var(0.2, y=0.2, z=0.2).shape, ()) - def test_processed_var_2D_fixed_t_scikit_interpolation(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_2D_fixed_t_scikit_interpolation(self, hermite_interp): var = pybamm.Variable("var", domain=["current collector"]) disc = tests.get_2p1d_discretisation_for_testing() @@ -949,15 +1066,13 @@ def test_processed_var_2D_fixed_t_scikit_interpolation(self): var_sol.mesh = disc.mesh["current collector"] t_sol = np.array([0]) u_sol = np.ones(var_sol.shape[0])[:, np.newaxis] + yp_sol = self._get_yps(u_sol, hermite_interp) var_casadi = to_casadi(var_sol, u_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, u_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, u_sol, yp_sol), ) # 2 vectors np.testing.assert_array_equal( @@ -969,7 +1084,8 @@ def test_processed_var_2D_fixed_t_scikit_interpolation(self): # 2 scalars np.testing.assert_array_equal(processed_var(t=0, y=0.2, z=0.2).shape, ()) - def test_processed_var_2D_unknown_domain(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_processed_var_2D_unknown_domain(self, hermite_interp): var = pybamm.Variable( "var", domain=["domain B"], @@ -1007,6 +1123,7 @@ def test_processed_var_2D_unknown_domain(self): var_sol = disc.process_symbol(var) t_sol = np.linspace(0, 1) y_sol = np.ones(len(x_sol) * len(z_sol))[:, np.newaxis] * np.linspace(0, 5) + yp_sol = self._get_yps(y_sol, hermite_interp, values=5) var_casadi = to_casadi(var_sol, y_sol) model = pybamm.BaseModel() @@ -1016,11 +1133,10 @@ def test_processed_var_2D_unknown_domain(self): "domain B": {z: {"min": 0, "max": 1}}, } ) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution(t_sol, y_sol, model, {}), - warn=False, + pybamm.Solution(t_sol, y_sol, model, {}, all_yps=yp_sol), ) # 3 vectors np.testing.assert_array_equal( @@ -1047,7 +1163,8 @@ def test_processed_var_2D_unknown_domain(self): # 3 scalars np.testing.assert_array_equal(processed_var(t=0.2, x=0.2, z=0.2).shape, ()) - def test_3D_raises_error(self): + @pytest.mark.parametrize("hermite_interp", _hermite_args) + def test_3D_raises_error(self, hermite_interp): var = pybamm.Variable( "var", domain=["negative electrode"], @@ -1059,16 +1176,14 @@ def test_3D_raises_error(self): var_sol = disc.process_symbol(var) t_sol = np.array([0, 1, 2]) u_sol = np.ones(var_sol.shape[0] * 3)[:, np.newaxis] + yp_sol = self._get_yps(u_sol, hermite_interp, values=0) var_casadi = to_casadi(var_sol, u_sol) - with self.assertRaisesRegex(NotImplementedError, "Shape not recognized"): - pybamm.ProcessedVariable( + with pytest.raises(NotImplementedError, match="Shape not recognized"): + pybamm.process_variable( [var_sol], [var_casadi], - pybamm.Solution( - t_sol, u_sol, tests.get_base_model_with_battery_geometry(), {} - ), - warn=False, + self._sol_default(t_sol, u_sol, yp_sol), ) def test_process_spatial_variable_names(self): @@ -1080,59 +1195,137 @@ def test_process_spatial_variable_names(self): t_sol = np.linspace(0, 1) y_sol = np.array([np.linspace(0, 5)]) var_casadi = to_casadi(var, y_sol) - processed_var = pybamm.ProcessedVariable( + processed_var = pybamm.process_variable( [var], [var_casadi], pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), {}), - warn=False, ) # Test empty list returns None - self.assertIsNone(processed_var._process_spatial_variable_names([])) + assert processed_var._process_spatial_variable_names([]) is None # Test tabs is ignored - self.assertEqual( - processed_var._process_spatial_variable_names(["tabs", "var"]), - "var", - ) + assert processed_var._process_spatial_variable_names(["tabs", "var"]) == "var" # Test strings stay strings - self.assertEqual( - processed_var._process_spatial_variable_names(["y"]), - "y", - ) + assert processed_var._process_spatial_variable_names(["y"]) == "y" # Test spatial variables are converted to strings x = pybamm.SpatialVariable("x", domain=["domain"]) - self.assertEqual( - processed_var._process_spatial_variable_names([x]), - "x", - ) + assert processed_var._process_spatial_variable_names([x]) == "x" # Test renaming for PyBaMM convention - self.assertEqual( - processed_var._process_spatial_variable_names(["x_a", "x_b"]), - "x", + assert processed_var._process_spatial_variable_names(["x_a", "x_b"]) == "x" + assert processed_var._process_spatial_variable_names(["r_a", "r_b"]) == "r" + assert processed_var._process_spatial_variable_names(["R_a", "R_b"]) == "R" + + # Test error raised if spatial variable name not recognised + with pytest.raises(NotImplementedError, match="Spatial variable name"): + processed_var._process_spatial_variable_names(["var1", "var2"]) + + def test_hermite_interpolator(self): + if not pybamm.has_idaklu(): + pytest.skip("Cannot test Hermite interpolation without IDAKLU") + + # initialise dummy solution to access method + def solution_setup(t_sol, sign): + y_sol = np.array([sign * np.sin(t_sol)]) + yp_sol = np.array([sign * np.cos(t_sol)]) + sol = self._sol_default(t_sol, y_sol, yp_sol) + return sol + + # without spatial dependence + t = pybamm.t + y = pybamm.StateVector(slice(0, 1)) + var = y + eqn = t * y + var.mesh = None + eqn.mesh = None + + sign1 = +1 + t_sol1 = np.linspace(0, 1, 100) + sol1 = solution_setup(t_sol1, sign1) + + # Discontinuity in the solution + sign2 = -1 + t_sol2 = np.linspace(np.nextafter(t_sol1[-1], np.inf), t_sol1[-1] + 3, 99) + sol2 = solution_setup(t_sol2, sign2) + + sol = sol1 + sol2 + var_casadi = to_casadi(var, sol.all_ys[0]) + processed_var = pybamm.process_variable( + [var] * len(sol.all_ts), + [var_casadi] * len(sol.all_ts), + sol, ) - self.assertEqual( - processed_var._process_spatial_variable_names(["r_a", "r_b"]), - "r", + + # Ground truth spline interpolants from scipy + spls = [ + CubicHermiteSpline(t, y, yp, axis=1) + for t, y, yp in zip(sol.all_ts, sol.all_ys, sol.all_yps) + ] + + def spl(t): + t = np.array(t) + out = np.zeros(len(t)) + for i, spl in enumerate(spls): + t0 = sol.all_ts[i][0] + tf = sol.all_ts[i][-1] + + mask = t >= t0 + # Extrapolation is allowed for the final solution + if i < len(spls) - 1: + mask &= t <= tf + + out[mask] = spl(t[mask]).flatten() + return out + + t0 = sol.t[0] + tf = sol.t[-1] + + # Test extrapolation before the first solution time + t_left_extrap = t0 - 1 + with pytest.raises( + ValueError, match="interpolation points must be greater than" + ): + processed_var(t_left_extrap) + + # Test extrapolation after the last solution time + t_right_extrap = [tf + 1] + np.testing.assert_almost_equal( + spl(t_right_extrap), + processed_var(t_right_extrap, fill_value="extrapolate"), + decimal=8, ) - self.assertEqual( - processed_var._process_spatial_variable_names(["R_a", "R_b"]), - "R", + + t_dense = np.linspace(t0, tf + 1, 1000) + np.testing.assert_almost_equal( + spl(t_dense), + processed_var(t_dense, fill_value="extrapolate"), + decimal=8, ) - # Test error raised if spatial variable name not recognised - with self.assertRaisesRegex(NotImplementedError, "Spatial variable name"): - processed_var._process_spatial_variable_names(["var1", "var2"]) + t_extended = np.union1d(sol.t, sol.t[-1] + 1) + np.testing.assert_almost_equal( + spl(t_extended), + processed_var(t_extended, fill_value="extrapolate"), + decimal=8, + ) + + ## Unsorted arrays + t_unsorted = np.array([0.5, 0.4, 0.6, 0, 1]) + idxs_sort = np.argsort(t_unsorted) + t_sorted = np.sort(t_unsorted) + + y_sorted = processed_var(t_sorted) + + idxs_unsort = np.zeros_like(idxs_sort) + idxs_unsort[idxs_sort] = np.arange(len(t_unsorted)) + # Check that the unsorted and sorted arrays are the same + assert np.all(t_sorted == t_unsorted[idxs_sort]) -if __name__ == "__main__": - print("Add -v for more debug output") - import sys + y_unsorted = processed_var(t_unsorted) - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + # Check that the unsorted and sorted arrays are the same + assert np.all(y_unsorted == y_sorted[idxs_unsort]) diff --git a/tests/unit/test_solvers/test_processed_variable_computed.py b/tests/unit/test_solvers/test_processed_variable_computed.py index 407d422e4c..f28c053fd5 100644 --- a/tests/unit/test_solvers/test_processed_variable_computed.py +++ b/tests/unit/test_solvers/test_processed_variable_computed.py @@ -1,17 +1,17 @@ # # Tests for the Processed Variable Computed class # -# This class forms a container for variables (and sensitivities) calculted +# This class forms a container for variables (and sensitivities) calculated # by the idaklu solver, and does not possesses any capability to calculate # values itself since it does not have access to the full state vector # +import pytest import casadi import pybamm import tests import numpy as np -import unittest def to_casadi(var_pybamm, y, inputs=None): @@ -57,7 +57,6 @@ def process_and_check_2D_variable( [var_casadi], [y_sol], pybamm.Solution(t_sol, y_sol, model, {}), - warn=False, ) # NB: ProcessedVariableComputed does not interpret y in the same way as # ProcessedVariable; a better test of equivalence is to check that the @@ -68,7 +67,7 @@ def process_and_check_2D_variable( return y_sol, first_sol, second_sol, t_sol -class TestProcessedVariableComputed(unittest.TestCase): +class TestProcessedVariableComputed: def test_processed_variable_0D(self): # without space y = pybamm.StateVector(slice(0, 1)) @@ -77,12 +76,12 @@ def test_processed_variable_0D(self): t_sol = np.array([0]) y_sol = np.array([1])[:, np.newaxis] var_casadi = to_casadi(var, y_sol) + sol = pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), {}) processed_var = pybamm.ProcessedVariableComputed( [var], [var_casadi], [y_sol], - pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), {}), - warn=False, + sol, ) # Assert that the processed variable is the same as the solution np.testing.assert_array_equal(processed_var.entries, y_sol[0]) @@ -94,7 +93,23 @@ def test_processed_variable_0D(self): # Check cumtrapz workflow produces no errors processed_var.cumtrapz_ic = 1 - processed_var.initialise_0D() + processed_var.entries + + # check _update + t_sol2 = np.array([1]) + y_sol2 = np.array([2])[:, np.newaxis] + var_casadi = to_casadi(var, y_sol2) + sol_2 = pybamm.Solution(t_sol2, y_sol2, pybamm.BaseModel(), {}) + processed_var2 = pybamm.ProcessedVariableComputed( + [var], + [var_casadi], + [y_sol2], + sol_2, + ) + + comb_sol = sol + sol_2 + comb_var = processed_var._update(processed_var2, comb_sol) + np.testing.assert_array_equal(comb_var.entries, np.append(y_sol, y_sol2)) # check empty sensitivity works def test_processed_variable_0D_no_sensitivity(self): @@ -111,11 +126,10 @@ def test_processed_variable_0D_no_sensitivity(self): [var_casadi], [y_sol], pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), {}), - warn=False, ) # test no inputs (i.e. no sensitivity) - self.assertDictEqual(processed_var.sensitivities, {}) + assert processed_var.sensitivities == {} # with parameter t = pybamm.t @@ -132,11 +146,10 @@ def test_processed_variable_0D_no_sensitivity(self): [var_casadi], [y_sol], pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), inputs), - warn=False, ) # test no sensitivity raises error - self.assertIsNone(processed_var.sensitivities) + assert processed_var.sensitivities is None def test_processed_variable_1D(self): var = pybamm.Variable("var", domain=["negative electrode", "separator"]) @@ -157,7 +170,6 @@ def test_processed_variable_1D(self): [var_casadi], [y_sol], sol, - warn=False, ) # Ordering from idaklu with output_variables set is different to @@ -175,7 +187,7 @@ def test_processed_variable_1D(self): processed_var.mesh.edges, processed_var.mesh.nodes, ) - processed_var.initialise_1D() + processed_var.entries processed_var.mesh.nodes, processed_var.mesh.edges = ( processed_var.mesh.edges, processed_var.mesh.nodes, @@ -192,7 +204,7 @@ def test_processed_variable_1D(self): ] for domain in domain_list: processed_var.domain[0] = domain - processed_var.initialise_1D() + processed_var.entries def test_processed_variable_1D_unknown_domain(self): x = pybamm.SpatialVariable("x", domain="SEI layer", coord_sys="cartesian") @@ -220,7 +232,61 @@ def test_processed_variable_1D_unknown_domain(self): c = pybamm.StateVector(slice(0, var_pts[x]), domain=["SEI layer"]) c.mesh = mesh["SEI layer"] c_casadi = to_casadi(c, y_sol) - pybamm.ProcessedVariableComputed([c], [c_casadi], [y_sol], solution, warn=False) + pybamm.ProcessedVariableComputed([c], [c_casadi], [y_sol], solution) + + def test_processed_variable_1D_update(self): + # variable 1 + var = pybamm.Variable("var", domain=["negative electrode", "separator"]) + x = pybamm.SpatialVariable("x", domain=["negative electrode", "separator"]) + + disc = tests.get_discretisation_for_testing() + disc.set_variable_slices([var]) + x_sol1 = disc.process_symbol(x).entries[:, 0] + var_sol1 = disc.process_symbol(var) + t_sol1 = np.linspace(0, 1) + y_sol1 = np.ones_like(x_sol1)[:, np.newaxis] * np.linspace(0, 5) + + var_casadi1 = to_casadi(var_sol1, y_sol1) + sol1 = pybamm.Solution(t_sol1, y_sol1, pybamm.BaseModel(), {}) + processed_var1 = pybamm.ProcessedVariableComputed( + [var_sol1], + [var_casadi1], + [y_sol1], + sol1, + ) + + # variable 2 ------------------- + var2 = pybamm.Variable("var2", domain=["negative electrode", "separator"]) + z = pybamm.SpatialVariable("z", domain=["negative electrode", "separator"]) + + disc = tests.get_discretisation_for_testing() + disc.set_variable_slices([var2]) + z_sol2 = disc.process_symbol(z).entries[:, 0] + var_sol2 = disc.process_symbol(var2) + t_sol2 = np.linspace(2, 3) + y_sol2 = np.ones_like(z_sol2)[:, np.newaxis] * np.linspace(5, 1) + + var_casadi2 = to_casadi(var_sol2, y_sol2) + sol2 = pybamm.Solution(t_sol2, y_sol2, pybamm.BaseModel(), {}) + var_2 = pybamm.ProcessedVariableComputed( + [var_sol2], + [var_casadi2], + [y_sol2], + sol2, + ) + + comb_sol = sol1 + sol2 + comb_var = processed_var1._update(var_2, comb_sol) + + # Ordering from idaklu with output_variables set is different to + # the full solver + y_sol1 = y_sol1.reshape((y_sol1.shape[1], y_sol1.shape[0])).transpose() + y_sol2 = y_sol2.reshape((y_sol2.shape[1], y_sol2.shape[0])).transpose() + + np.testing.assert_array_equal( + comb_var.entries, np.concatenate((y_sol1, y_sol2), axis=1) + ) + np.testing.assert_array_equal(comb_var.entries, comb_var.data) def test_processed_variable_2D_x_r(self): var = pybamm.Variable( @@ -330,13 +396,12 @@ def test_processed_variable_2D_x_z(self): x_s_edge.mesh = disc.mesh["separator"] x_s_edge.secondary_mesh = disc.mesh["current collector"] x_s_casadi = to_casadi(x_s_edge, y_sol) - processed_x_s_edge = pybamm.ProcessedVariable( + processed_x_s_edge = pybamm.process_variable( [x_s_edge], [x_s_casadi], pybamm.Solution( t_sol, y_sol, tests.get_base_model_with_battery_geometry(), {} ), - warn=False, ) np.testing.assert_array_equal( x_s_edge.entries.flatten(), processed_x_s_edge.entries[:, :, 0].T.flatten() @@ -371,7 +436,6 @@ def test_processed_variable_2D_space_only(self): [var_casadi], [y_sol], pybamm.Solution(t_sol, y_sol, pybamm.BaseModel(), {}), - warn=False, ) np.testing.assert_array_equal( processed_var.entries, @@ -386,7 +450,7 @@ def test_processed_variable_2D_space_only(self): np.testing.assert_array_equal(processed_var.unroll(), y_sol.reshape(10, 40, 1)) # Check unroll function (3D) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): processed_var.dimensions = 3 processed_var.unroll() @@ -408,7 +472,6 @@ def test_processed_variable_2D_fixed_t_scikit(self): [var_casadi], [u_sol], pybamm.Solution(t_sol, u_sol, pybamm.BaseModel(), {}), - warn=False, ) np.testing.assert_array_equal( processed_var.entries, np.reshape(u_sol, [len(y), len(z), len(t_sol)]) @@ -428,21 +491,10 @@ def test_3D_raises_error(self): u_sol = np.ones(var_sol.shape[0] * 3)[:, np.newaxis] var_casadi = to_casadi(var_sol, u_sol) - with self.assertRaisesRegex(NotImplementedError, "Shape not recognized"): + with pytest.raises(NotImplementedError, match="Shape not recognized"): pybamm.ProcessedVariableComputed( [var_sol], [var_casadi], [u_sol], pybamm.Solution(t_sol, u_sol, pybamm.BaseModel(), {}), - warn=False, ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_scipy_solver.py b/tests/unit/test_solvers/test_scipy_solver.py index 446206e95c..c5516ee880 100644 --- a/tests/unit/test_solvers/test_scipy_solver.py +++ b/tests/unit/test_solvers/test_scipy_solver.py @@ -1,15 +1,14 @@ # Tests for the Scipy Solver class # +import pytest import pybamm -import unittest import numpy as np from tests import get_mesh_for_testing, get_discretisation_for_testing import warnings -import sys -class TestScipySolver(unittest.TestCase): +class TestScipySolver: def test_model_solver_python_and_jax(self): if pybamm.has_jax(): formats = ["python", "jax"] @@ -43,10 +42,8 @@ def test_model_solver_python_and_jax(self): np.testing.assert_allclose(solution.y[0], np.exp(0.1 * solution.t)) # Test time - self.assertEqual( - solution.total_time, solution.solve_time + solution.set_up_time - ) - self.assertEqual(solution.termination, "final time") + assert solution.total_time == solution.solve_time + solution.set_up_time + assert solution.termination == "final time" def test_model_solver_failure(self): # Turn off warnings to ignore sqrt error @@ -65,7 +62,7 @@ def test_model_solver_failure(self): t_eval = np.linspace(0, 3, 100) solver = pybamm.ScipySolver(rtol=1e-8, atol=1e-8, method="RK45") # Expect solver to fail when y goes negative - with self.assertRaises(pybamm.SolverError): + with pytest.raises(pybamm.SolverError): solver.solve(model, t_eval) # Turn warnings back on @@ -96,7 +93,7 @@ def test_model_solver_with_event_python(self): solver = pybamm.ScipySolver(rtol=1e-8, atol=1e-8, method="RK45") t_eval = np.linspace(0, 10, 100) solution = solver.solve(model, t_eval) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_array_equal(solution.t[:-1], t_eval[: len(solution.t) - 1]) np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) np.testing.assert_equal(solution.t_event[0], solution.t[-1]) @@ -181,7 +178,7 @@ def test_model_step_python(self): # Step again (return 5 points) step_sol_2 = solver.step(step_sol, model, dt, npts=5) np.testing.assert_array_equal( - step_sol_2.t, np.array([0, 1, 1 + 1e-9, 1.25, 1.5, 1.75, 2]) + step_sol_2.t, np.array([0, 1, np.nextafter(1, np.inf), 1.25, 1.5, 1.75, 2]) ) np.testing.assert_array_almost_equal( step_sol_2.y[0], np.exp(0.1 * step_sol_2.t) @@ -222,7 +219,7 @@ def test_step_different_model(self): np.testing.assert_array_almost_equal(step_sol1.y[0], np.exp(0.1 * step_sol1.t)) # Step again, the model has changed so this raises an error - with self.assertRaisesRegex(RuntimeError, "already been initialised"): + with pytest.raises(RuntimeError, match="already been initialised"): solver.step(step_sol1, model2, dt) def test_model_solver_with_inputs(self): @@ -245,11 +242,11 @@ def test_model_solver_with_inputs(self): solver = pybamm.ScipySolver(rtol=1e-8, atol=1e-8, method="RK45") t_eval = np.linspace(0, 10, 100) solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_array_equal(solution.t[:-1], t_eval[: len(solution.t) - 1]) np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) - def test_model_solver_multiple_inputs_happy_path(self): + def test_model_solver_multiple_inputs_happy_path(self, subtests): for convert_to_format in ["python", "casadi"]: # Create model model = pybamm.BaseModel() @@ -271,7 +268,7 @@ def test_model_solver_multiple_inputs_happy_path(self): solutions = solver.solve(model, t_eval, inputs=inputs_list, nproc=2) for i in range(ninputs): - with self.subTest(i=i): + with subtests.test(i=i): solution = solutions[i] np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( @@ -304,12 +301,10 @@ def test_model_solver_multiple_inputs_discontinuity_error(self): event_type=pybamm.EventType.DISCONTINUITY, ) ] - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - ( - "Cannot solve for a list of input parameters" - " sets with discontinuities" - ), + match="Cannot solve for a list of input parameters" + " sets with discontinuities", ): solver.solve(model, t_eval, inputs=inputs_list, nproc=2) @@ -332,13 +327,14 @@ def test_model_solver_multiple_inputs_initial_conditions_error(self): ninputs = 8 inputs_list = [{"rate": 0.01 * (i + 1)} for i in range(ninputs)] - with self.assertRaisesRegex( + with pytest.raises( pybamm.SolverError, - ("Input parameters cannot appear in expression " "for initial conditions."), + match="Input parameters cannot appear in expression " + "for initial conditions.", ): solver.solve(model, t_eval, inputs=inputs_list, nproc=2) - def test_model_solver_multiple_inputs_jax_format(self): + def test_model_solver_multiple_inputs_jax_format(self, subtests): if pybamm.has_jax(): # Create model model = pybamm.BaseModel() @@ -360,7 +356,7 @@ def test_model_solver_multiple_inputs_jax_format(self): solutions = solver.solve(model, t_eval, inputs=inputs_list, nproc=2) for i in range(ninputs): - with self.subTest(i=i): + with subtests.test(i=i): solution = solutions[i] np.testing.assert_array_equal(solution.t, t_eval) np.testing.assert_allclose( @@ -395,7 +391,7 @@ def test_model_solver_with_event_with_casadi(self): solver = pybamm.ScipySolver(rtol=1e-8, atol=1e-8, method="RK45") t_eval = np.linspace(0, 10, 100) solution = solver.solve(model_disc, t_eval) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_array_equal( solution.t[:-1], t_eval[: len(solution.t) - 1] ) @@ -422,7 +418,7 @@ def test_model_solver_with_inputs_with_casadi(self): solver = pybamm.ScipySolver(rtol=1e-8, atol=1e-8, method="RK45") t_eval = np.linspace(0, 10, 100) solution = solver.solve(model, t_eval, inputs={"rate": 0.1}) - self.assertLess(len(solution.t), len(t_eval)) + assert len(solution.t) < len(t_eval) np.testing.assert_array_equal(solution.t[:-1], t_eval[: len(solution.t) - 1]) np.testing.assert_allclose(solution.y[0], np.exp(-0.1 * solution.t)) @@ -492,7 +488,7 @@ def test_scale_and_reference(self): ) -class TestScipySolverWithSensitivity(unittest.TestCase): +class TestScipySolverWithSensitivity: def test_solve_sensitivity_scalar_var_scalar_input(self): # Create model model = pybamm.BaseModel() @@ -780,12 +776,3 @@ def test_solve_sensitivity_vector_var_vector_input(self): solution["integral of var"].sensitivities["param"], np.vstack([-2 * t * np.exp(-p_eval * t) * l_n / n for t in t_eval]), ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_solvers/test_solution.py b/tests/unit/test_solvers/test_solution.py index 995898e8dd..1ee652d7a1 100644 --- a/tests/unit/test_solvers/test_solution.py +++ b/tests/unit/test_solvers/test_solution.py @@ -1,11 +1,12 @@ # # Tests for the Solution class # +import pytest import os - +import io +import logging import json import pybamm -import unittest import numpy as np import pandas as pd from scipy.io import loadmat @@ -13,23 +14,23 @@ from tempfile import TemporaryDirectory -class TestSolution(unittest.TestCase): +class TestSolution: def test_init(self): t = np.linspace(0, 1) y = np.tile(t, (20, 1)) sol = pybamm.Solution(t, y, pybamm.BaseModel(), {}) np.testing.assert_array_equal(sol.t, t) np.testing.assert_array_equal(sol.y, y) - self.assertEqual(sol.t_event, None) - self.assertEqual(sol.y_event, None) - self.assertEqual(sol.termination, "final time") - self.assertEqual(sol.all_inputs, [{}]) - self.assertIsInstance(sol.all_models[0], pybamm.BaseModel) + assert sol.t_event is None + assert sol.y_event is None + assert sol.termination == "final time" + assert sol.all_inputs == [{}] + assert isinstance(sol.all_models[0], pybamm.BaseModel) def test_sensitivities(self): t = np.linspace(0, 1) y = np.tile(t, (20, 1)) - with self.assertRaises(TypeError): + with pytest.raises(TypeError): pybamm.Solution(t, y, pybamm.BaseModel(), {}, sensitivities=1.0) def test_errors(self): @@ -37,8 +38,8 @@ def test_errors(self): sol = pybamm.Solution( bad_ts, [np.ones((1, 3)), np.ones((1, 3))], pybamm.BaseModel(), {} ) - with self.assertRaisesRegex( - ValueError, "Solution time vector must be strictly increasing" + with pytest.raises( + ValueError, match="Solution time vector must be strictly increasing" ): sol.set_t() @@ -48,29 +49,51 @@ def test_errors(self): var = pybamm.StateVector(slice(0, 1)) model.rhs = {var: 0} model.variables = {var.name: var} - with self.assertLogs() as captured: - pybamm.Solution(ts, bad_ys, model, {}) - self.assertIn("exceeds the maximum", captured.records[0].getMessage()) + log_capture = io.StringIO() + handler = logging.StreamHandler(log_capture) + handler.setLevel(logging.ERROR) + logger = logging.getLogger("pybamm.logger") + logger.addHandler(handler) + pybamm.Solution(ts, bad_ys, model, {}) + log_output = log_capture.getvalue() + assert "exceeds the maximum" in log_output + logger.removeHandler(handler) + + with pytest.raises( + TypeError, match="sensitivities arg needs to be a bool or dict" + ): + pybamm.Solution(ts, bad_ys, model, {}, all_sensitivities="bad") + + sol = pybamm.Solution(ts, bad_ys, model, {}, all_sensitivities={}) + with pytest.raises(TypeError, match="sensitivities arg needs to be a bool"): + sol.sensitivities = "bad" + with pytest.raises( + NotImplementedError, + match="Setting sensitivities is not supported if sensitivities are already provided as a dict", + ): + sol.sensitivities = True def test_add_solutions(self): # Set up first solution t1 = np.linspace(0, 1) y1 = np.tile(t1, (20, 1)) - sol1 = pybamm.Solution(t1, y1, pybamm.BaseModel(), {"a": 1}) + yp1 = np.tile(t1, (30, 1)) + sol1 = pybamm.Solution(t1, y1, pybamm.BaseModel(), {"a": 1}, all_yps=yp1) sol1.solve_time = 1.5 sol1.integration_time = 0.3 # Set up second solution t2 = np.linspace(1, 2) y2 = np.tile(t2, (20, 1)) - sol2 = pybamm.Solution(t2, y2, pybamm.BaseModel(), {"a": 2}) + yp2 = np.tile(t1, (30, 1)) + sol2 = pybamm.Solution(t2, y2, pybamm.BaseModel(), {"a": 2}, all_yps=yp2) sol2.solve_time = 1 sol2.integration_time = 0.5 sol_sum = sol1 + sol2 # Test - self.assertEqual(sol_sum.integration_time, 0.8) + assert sol_sum.integration_time == 0.8 np.testing.assert_array_equal(sol_sum.t, np.concatenate([t1, t2[1:]])) np.testing.assert_array_equal( sol_sum.y, np.concatenate([y1, y2[:, 1:]], axis=1) @@ -78,39 +101,56 @@ def test_add_solutions(self): np.testing.assert_array_equal(sol_sum.all_inputs, [{"a": 1}, {"a": 2}]) # Test sub-solutions - self.assertEqual(len(sol_sum.sub_solutions), 2) + assert len(sol_sum.sub_solutions) == 2 np.testing.assert_array_equal(sol_sum.sub_solutions[0].t, t1) np.testing.assert_array_equal(sol_sum.sub_solutions[1].t, t2) - self.assertEqual(sol_sum.sub_solutions[0].all_models[0], sol_sum.all_models[0]) + assert sol_sum.sub_solutions[0].all_models[0] == sol_sum.all_models[0] np.testing.assert_array_equal(sol_sum.sub_solutions[0].all_inputs[0]["a"], 1) - self.assertEqual(sol_sum.sub_solutions[1].all_models[0], sol2.all_models[0]) - self.assertEqual(sol_sum.all_models[1], sol2.all_models[0]) + assert sol_sum.sub_solutions[1].all_models[0] == sol2.all_models[0] + assert sol_sum.all_models[1] == sol2.all_models[0] np.testing.assert_array_equal(sol_sum.sub_solutions[1].all_inputs[0]["a"], 2) # Add solution already contained in existing solution t3 = np.array([2]) - y3 = np.ones((20, 1)) + y3 = np.ones((1, 1)) sol3 = pybamm.Solution(t3, y3, pybamm.BaseModel(), {"a": 3}) - self.assertEqual((sol_sum + sol3).all_ts, sol_sum.copy().all_ts) + assert (sol_sum + sol3).all_ts == sol_sum.copy().all_ts # add None sol4 = sol3 + None - self.assertEqual(sol3.all_ys, sol4.all_ys) + assert sol3.all_ys == sol4.all_ys # radd sol5 = None + sol3 - self.assertEqual(sol3.all_ys, sol5.all_ys) + assert sol3.all_ys == sol5.all_ys # radd failure - with self.assertRaisesRegex( - pybamm.SolverError, "Only a Solution or None can be added to a Solution" + with pytest.raises( + pybamm.SolverError, + match="Only a Solution or None can be added to a Solution", ): sol3 + 2 - with self.assertRaisesRegex( - pybamm.SolverError, "Only a Solution or None can be added to a Solution" + with pytest.raises( + pybamm.SolverError, + match="Only a Solution or None can be added to a Solution", ): 2 + sol3 + sol1 = pybamm.Solution( + t1, + y1, + pybamm.BaseModel(), + {}, + all_sensitivities={"test": [np.ones((1, 3))]}, + ) + sol2 = pybamm.Solution(t2, y2, pybamm.BaseModel(), {}, all_sensitivities=True) + with pytest.raises(ValueError, match="Sensitivities must be of the same type"): + sol3 = sol1 + sol2 + sol1 = pybamm.Solution(t1, y3, pybamm.BaseModel(), {}, all_sensitivities=False) + sol2 = pybamm.Solution(t3, y3, pybamm.BaseModel(), {}, all_sensitivities={}) + sol3 = sol1 + sol2 + assert not sol3._all_sensitivities + def test_add_solutions_different_models(self): # Set up first solution t1 = np.linspace(0, 1) @@ -129,11 +169,55 @@ def test_add_solutions_different_models(self): # Test np.testing.assert_array_equal(sol_sum.t, np.concatenate([t1, t2[1:]])) - with self.assertRaisesRegex( - pybamm.SolverError, "The solution is made up from different models" + with pytest.raises( + pybamm.SolverError, match="The solution is made up from different models" ): sol_sum.y + @pytest.mark.skipif( + not pybamm.has_idaklu(), reason="idaklu solver is not installed" + ) + def test_add_solutions_with_computed_variables(self): + model = pybamm.BaseModel() + u = pybamm.Variable("u") + v = pybamm.Variable("v") + model.rhs = {u: 1 * v} + model.algebraic = {v: 1 - v} + model.initial_conditions = {u: 0, v: 1} + model.variables = {"2u": 2 * u} + + disc = pybamm.Discretisation() + disc.process_model(model) + + # Set up first solution + t1 = np.linspace(0, 1, 50) + solver = pybamm.IDAKLUSolver(output_variables=["2u"]) + + sol1 = solver.solve(model, t1) + + # second solution + t2 = np.linspace(2, 3, 50) + sol2 = solver.solve(model, t2) + + sol_sum = sol1 + sol2 + + # check varaibles concat appropriately + assert sol_sum["2u"].data[0] == sol1["2u"].data[0] + assert sol_sum["2u"].data[-1] == sol2["2u"].data[-1] + # Check functions still work + sol_sum["2u"].unroll() + # check solution still tagged as 'variables_returned' + assert sol_sum.variables_returned is True + + # add a solution with computed variable to an empty solution + empty_sol = pybamm.Solution( + sol1.all_ts, sol1["2u"].base_variables_data, model, {u: 0, v: 1} + ) + + sol4 = empty_sol + sol2 + assert sol4["2u"] == sol2["2u"] + assert sol4.variables_returned is True + def test_copy(self): # Set up first solution t1 = [np.linspace(0, 1), np.linspace(1, 2, 5)] @@ -145,13 +229,42 @@ def test_copy(self): sol1.integration_time = 0.3 sol_copy = sol1.copy() - self.assertEqual(sol_copy.all_ts, sol1.all_ts) - self.assertEqual(sol_copy.all_ys, sol1.all_ys) - self.assertEqual(sol_copy.all_inputs, sol1.all_inputs) - self.assertEqual(sol_copy.all_inputs_casadi, sol1.all_inputs_casadi) - self.assertEqual(sol_copy.set_up_time, sol1.set_up_time) - self.assertEqual(sol_copy.solve_time, sol1.solve_time) - self.assertEqual(sol_copy.integration_time, sol1.integration_time) + assert sol_copy.all_ts == sol1.all_ts + for ys_copy, ys1 in zip(sol_copy.all_ys, sol1.all_ys): + np.testing.assert_array_equal(ys_copy, ys1) + assert sol_copy.all_inputs == sol1.all_inputs + assert sol_copy.all_inputs_casadi == sol1.all_inputs_casadi + assert sol_copy.set_up_time == sol1.set_up_time + assert sol_copy.solve_time == sol1.solve_time + assert sol_copy.integration_time == sol1.integration_time + + @pytest.mark.skipif( + not pybamm.has_idaklu(), reason="idaklu solver is not installed" + ) + def test_copy_with_computed_variables(self): + model = pybamm.BaseModel() + u = pybamm.Variable("u") + v = pybamm.Variable("v") + model.rhs = {u: 1 * v} + model.algebraic = {v: 1 - v} + model.initial_conditions = {u: 0, v: 1} + model.variables = {"2u": 2 * u} + + disc = pybamm.Discretisation() + disc.process_model(model) + + # Set up first solution + t1 = np.linspace(0, 1, 50) + solver = pybamm.IDAKLUSolver(output_variables=["2u"]) + + sol1 = solver.solve(model, t1) + + sol2 = sol1.copy() + + assert ( + sol1._variables[k] == sol2._variables[k] for k in sol1._variables.keys() + ) + assert sol2.variables_returned is True def test_last_state(self): # Set up first solution @@ -164,14 +277,47 @@ def test_last_state(self): sol1.integration_time = 0.3 sol_last_state = sol1.last_state - self.assertEqual(sol_last_state.all_ts[0], 2) + assert sol_last_state.all_ts[0] == 2 np.testing.assert_array_equal(sol_last_state.all_ys[0], 2) - self.assertEqual(sol_last_state.all_inputs, sol1.all_inputs[-1:]) - self.assertEqual(sol_last_state.all_inputs_casadi, sol1.all_inputs_casadi[-1:]) - self.assertEqual(sol_last_state.all_models, sol1.all_models[-1:]) - self.assertEqual(sol_last_state.set_up_time, 0) - self.assertEqual(sol_last_state.solve_time, 0) - self.assertEqual(sol_last_state.integration_time, 0) + assert sol_last_state.all_inputs == sol1.all_inputs[-1:] + assert sol_last_state.all_inputs_casadi == sol1.all_inputs_casadi[-1:] + assert sol_last_state.all_models == sol1.all_models[-1:] + assert sol_last_state.set_up_time == 0 + assert sol_last_state.solve_time == 0 + assert sol_last_state.integration_time == 0 + + @pytest.mark.skipif( + not pybamm.has_idaklu(), reason="idaklu solver is not installed" + ) + def test_first_last_state_empty_y(self): + # check that first and last state work when y is empty + # due to only variables being returned (required for experiments) + model = pybamm.BaseModel() + u = pybamm.Variable("u") + v = pybamm.Variable("v") + model.rhs = {u: 1 * v} + model.algebraic = {v: 1 - v} + model.initial_conditions = {u: 0, v: 1} + model.variables = {"2u": 2 * u, "4u": 4 * u} + model._summary_variables = {"4u": model.variables["4u"]} + + disc = pybamm.Discretisation() + disc.process_model(model) + + # Set up first solution + t1 = np.linspace(0, 1, 50) + solver = pybamm.IDAKLUSolver(output_variables=["2u"]) + + sol1 = solver.solve(model, t1) + + np.testing.assert_array_equal( + sol1.first_state.all_ys[0], np.array([[0.0], [1.0]]) + ) + # check summay variables not in the solve can be evaluated at the final timestep + # via 'last_state + np.testing.assert_array_almost_equal( + sol1.last_state["4u"].entries, np.array([4.0]) + ) def test_cycles(self): model = pybamm.lithium_ion.SPM() @@ -183,14 +329,14 @@ def test_cycles(self): ) sim = pybamm.Simulation(model, experiment=experiment) sol = sim.solve() - self.assertEqual(len(sol.cycles), 2) + assert len(sol.cycles) == 2 len_cycle_1 = len(sol.cycles[0].t) - self.assertIsInstance(sol.cycles[0], pybamm.Solution) + assert isinstance(sol.cycles[0], pybamm.Solution) np.testing.assert_array_equal(sol.cycles[0].t, sol.t[:len_cycle_1]) np.testing.assert_array_equal(sol.cycles[0].y, sol.y[:, :len_cycle_1]) - self.assertIsInstance(sol.cycles[1], pybamm.Solution) + assert isinstance(sol.cycles[1], pybamm.Solution) np.testing.assert_array_equal(sol.cycles[1].t, sol.t[len_cycle_1:]) np.testing.assert_allclose(sol.cycles[1].y, sol.y[:, len_cycle_1:]) @@ -198,7 +344,7 @@ def test_total_time(self): sol = pybamm.Solution(np.array([0]), np.array([[1, 2]]), pybamm.BaseModel(), {}) sol.set_up_time = 0.5 sol.solve_time = 1.2 - self.assertEqual(sol.total_time, 1.7) + assert sol.total_time == 1.7 def test_getitem(self): model = pybamm.BaseModel() @@ -212,13 +358,13 @@ def test_getitem(self): # test create a new processed variable c_sol = solution["c"] - self.assertIsInstance(c_sol, pybamm.ProcessedVariable) + assert isinstance(c_sol, pybamm.ProcessedVariable) np.testing.assert_array_equal(c_sol.entries, c_sol(solution.t)) # test call an already created variable solution.update("2c") twoc_sol = solution["2c"] - self.assertIsInstance(twoc_sol, pybamm.ProcessedVariable) + assert isinstance(twoc_sol, pybamm.ProcessedVariable) np.testing.assert_array_equal(twoc_sol.entries, twoc_sol(solution.t)) np.testing.assert_array_equal(twoc_sol.entries, 2 * c_sol.entries) @@ -251,12 +397,12 @@ def test_save(self): solution = pybamm.ScipySolver().solve(model, np.linspace(0, 1)) # test save data - with self.assertRaises(ValueError): + with pytest.raises(ValueError): solution.save_data(f"{test_stub}.pickle") # set variables first then save solution.update(["c", "d"]) - with self.assertRaisesRegex(ValueError, "pickle"): + with pytest.raises(ValueError, match="pickle"): solution.save_data(to_format="pickle") solution.save_data(f"{test_stub}.pickle") @@ -270,12 +416,12 @@ def test_save(self): np.testing.assert_array_equal(solution.data["c"], data_load["c"].flatten()) np.testing.assert_array_equal(solution.data["d"], data_load["d"]) - with self.assertRaisesRegex(ValueError, "matlab"): + with pytest.raises(ValueError, match="matlab"): solution.save_data(to_format="matlab") # to matlab with bad variables name fails solution.update(["c + d"]) - with self.assertRaisesRegex(ValueError, "Invalid character"): + with pytest.raises(ValueError, match="Invalid character"): solution.save_data(f"{test_stub}.mat", to_format="matlab") # Works if providing alternative name solution.save_data( @@ -287,8 +433,8 @@ def test_save(self): np.testing.assert_array_equal(solution.data["c + d"], data_load["c_plus_d"]) # to csv - with self.assertRaisesRegex( - ValueError, "only 0D variables can be saved to csv" + with pytest.raises( + ValueError, match="only 0D variables can be saved to csv" ): solution.save_data(f"{test_stub}.csv", to_format="csv") # only save "c" and "2c" @@ -298,7 +444,7 @@ def test_save(self): # check string is the same as the file with open(f"{test_stub}.csv") as f: # need to strip \r chars for windows - self.assertEqual(csv_str.replace("\r", ""), f.read()) + assert csv_str.replace("\r", "") == f.read() # read csv df = pd.read_csv(f"{test_stub}.csv") @@ -312,7 +458,7 @@ def test_save(self): # check string is the same as the file with open(f"{test_stub}.json") as f: # need to strip \r chars for windows - self.assertEqual(json_str.replace("\r", ""), f.read()) + assert json_str.replace("\r", "") == f.read() # check if string has the right values json_data = json.loads(json_str) @@ -320,17 +466,15 @@ def test_save(self): np.testing.assert_array_almost_equal(json_data["d"], solution.data["d"]) # raise error if format is unknown - with self.assertRaisesRegex( - ValueError, "format 'wrong_format' not recognised" + with pytest.raises( + ValueError, match="format 'wrong_format' not recognised" ): solution.save_data(f"{test_stub}.csv", to_format="wrong_format") # test save whole solution solution.save(f"{test_stub}.pickle") solution_load = pybamm.load(f"{test_stub}.pickle") - self.assertEqual( - solution.all_models[0].name, solution_load.all_models[0].name - ) + assert solution.all_models[0].name == solution_load.all_models[0].name np.testing.assert_array_equal( solution["c"].entries, solution_load["c"].entries ) @@ -380,14 +524,44 @@ def test_solution_evals_with_inputs(self): inputs = {"Negative electrode conductivity [S.m-1]": 0.1} sim.solve(t_eval=np.linspace(0, 10, 10), inputs=inputs) time = sim.solution["Time [h]"](sim.solution.t) - self.assertEqual(len(time), 10) + assert len(time) == 10 + _solver_classes = [pybamm.CasadiSolver] + if pybamm.has_idaklu(): + _solver_classes.append(pybamm.IDAKLUSolver) -if __name__ == "__main__": - print("Add -v for more debug output") - import sys + @pytest.mark.parametrize("solver_class", _solver_classes) + def test_discrete_data_sum(self, solver_class): + model = pybamm.BaseModel(name="test_model") + c = pybamm.Variable("c") + model.rhs = {c: -c} + model.initial_conditions = {c: 1} + model.variables["c"] = c - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() + data_times = np.linspace(0, 1, 10) + if solver_class == pybamm.IDAKLUSolver: + t_eval = [data_times[0], data_times[-1]] + t_interp = data_times + else: + t_eval = data_times + t_interp = None + solver = solver_class() + data_values = solver.solve(model, t_eval=t_eval, t_interp=t_interp)["c"].entries + + data = pybamm.DiscreteTimeData(data_times, data_values, "test_data") + data_comparison = pybamm.DiscreteTimeSum((c - data) ** 2) + + model = pybamm.BaseModel(name="test_model2") + a = pybamm.InputParameter("a") + model.rhs = {c: -a * c} + model.initial_conditions = {c: 1} + model.variables["data_comparison"] = data_comparison + + solver = solver_class() + for a in [0.5, 1.0, 2.0]: + sol = solver.solve(model, t_eval=t_eval, inputs={"a": a}) + y_sol = np.exp(-a * data_times) + expected = np.sum((y_sol - data_values) ** 2) + np.testing.assert_array_almost_equal( + sol["data_comparison"](), expected, decimal=2 + ) diff --git a/tests/unit/test_spatial_methods/test_base_spatial_method.py b/tests/unit/test_spatial_methods/test_base_spatial_method.py index 647616c924..190fbd8f94 100644 --- a/tests/unit/test_spatial_methods/test_base_spatial_method.py +++ b/tests/unit/test_spatial_methods/test_base_spatial_method.py @@ -2,9 +2,9 @@ # Test for the base Spatial Method class # +import pytest import numpy as np import pybamm -import unittest from tests import ( get_mesh_for_testing, get_1p1d_mesh_for_testing, @@ -12,31 +12,31 @@ ) -class TestSpatialMethod(unittest.TestCase): +class TestSpatialMethod: def test_basics(self): mesh = get_mesh_for_testing() spatial_method = pybamm.SpatialMethod() spatial_method.build(mesh) - self.assertEqual(spatial_method.mesh, mesh) - with self.assertRaises(NotImplementedError): + assert spatial_method.mesh == mesh + with pytest.raises(NotImplementedError): spatial_method.gradient(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.divergence(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.laplacian(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.gradient_squared(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.integral(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.indefinite_integral(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.boundary_integral(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.delta_function(None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.internal_neumann_condition(None, None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.evaluate_at(None, None, None) def test_get_auxiliary_domain_repeats(self): @@ -47,20 +47,18 @@ def test_get_auxiliary_domain_repeats(self): # No auxiliary domains repeats = spatial_method._get_auxiliary_domain_repeats({}) - self.assertEqual(repeats, 1) + assert repeats == 1 # Just secondary domain repeats = spatial_method._get_auxiliary_domain_repeats( {"secondary": ["negative electrode"]} ) - self.assertEqual(repeats, mesh["negative electrode"].npts) + assert repeats == mesh["negative electrode"].npts repeats = spatial_method._get_auxiliary_domain_repeats( {"secondary": ["negative electrode", "separator"]} ) - self.assertEqual( - repeats, mesh["negative electrode"].npts + mesh["separator"].npts - ) + assert repeats == mesh["negative electrode"].npts + mesh["separator"].npts # With tertiary domain repeats = spatial_method._get_auxiliary_domain_repeats( @@ -69,17 +67,17 @@ def test_get_auxiliary_domain_repeats(self): "tertiary": ["current collector"], } ) - self.assertEqual( - repeats, - (mesh["negative electrode"].npts + mesh["separator"].npts) - * mesh["current collector"].npts, + assert ( + repeats + == (mesh["negative electrode"].npts + mesh["separator"].npts) + * mesh["current collector"].npts ) # Just tertiary domain repeats = spatial_method._get_auxiliary_domain_repeats( {"tertiary": ["current collector"]}, ) - self.assertEqual(repeats, mesh["current collector"].npts) + assert repeats == mesh["current collector"].npts # With quaternary domain repeats = spatial_method._get_auxiliary_domain_repeats( @@ -89,11 +87,11 @@ def test_get_auxiliary_domain_repeats(self): "quaternary": ["current collector"], } ) - self.assertEqual( - repeats, - mesh["negative particle size"].npts + assert ( + repeats + == mesh["negative particle size"].npts * (mesh["negative electrode"].npts + mesh["separator"].npts) - * mesh["current collector"].npts, + * mesh["current collector"].npts ) def test_discretise_spatial_variable(self): @@ -108,7 +106,7 @@ def test_discretise_spatial_variable(self): r = pybamm.SpatialVariable("r", ["negative particle"]) for var in [x1, x2, r]: var_disc = spatial_method.spatial_variable(var) - self.assertIsInstance(var_disc, pybamm.Vector) + assert isinstance(var_disc, pybamm.Vector) np.testing.assert_array_equal( var_disc.evaluate()[:, 0], mesh[var.domain].nodes ) @@ -119,7 +117,7 @@ def test_discretise_spatial_variable(self): r_edge = pybamm.SpatialVariableEdge("r", ["negative particle"]) for var in [x1_edge, x2_edge, r_edge]: var_disc = spatial_method.spatial_variable(var) - self.assertIsInstance(var_disc, pybamm.Vector) + assert isinstance(var_disc, pybamm.Vector) np.testing.assert_array_equal( var_disc.evaluate()[:, 0], mesh[var.domain].edges ) @@ -130,12 +128,12 @@ def test_boundary_value_checks(self): mesh = get_mesh_for_testing() spatial_method = pybamm.SpatialMethod() spatial_method.build(mesh) - with self.assertRaisesRegex(TypeError, "Cannot process BoundaryGradient"): + with pytest.raises(TypeError, match="Cannot process BoundaryGradient"): spatial_method.boundary_value_or_flux(symbol, child) # test also symbol "right" symbol = pybamm.BoundaryGradient(child, "right") - with self.assertRaisesRegex(TypeError, "Cannot process BoundaryGradient"): + with pytest.raises(TypeError, match="Cannot process BoundaryGradient"): spatial_method.boundary_value_or_flux(symbol, child) mesh = get_1p1d_mesh_for_testing() @@ -147,15 +145,5 @@ def test_boundary_value_checks(self): auxiliary_domains={"secondary": "current collector"}, ) symbol = pybamm.BoundaryGradient(child, "left") - with self.assertRaisesRegex(NotImplementedError, "Cannot process 2D symbol"): + with pytest.raises(NotImplementedError, match="Cannot process 2D symbol"): spatial_method.boundary_value_or_flux(symbol, child) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_extrapolation.py b/tests/unit/test_spatial_methods/test_finite_volume/test_extrapolation.py index c51e2d9a13..8479097031 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_extrapolation.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_extrapolation.py @@ -9,7 +9,6 @@ get_1p1d_mesh_for_testing, ) import numpy as np -import unittest def errors(pts, function, method_options, bcs=None): @@ -57,7 +56,7 @@ def get_errors(function, method_options, pts, bcs=None): return l_errors, r_errors -class TestExtrapolation(unittest.TestCase): +class TestExtrapolation: def test_convergence_without_bcs(self): # all tests are performed on x in [0, 1] linear = {"extrapolation": {"order": "linear"}} @@ -262,8 +261,8 @@ def test_linear_extrapolate_left_right(self): # check constant extrapolates to constant constant_y = np.ones_like(macro_submesh.nodes[:, np.newaxis]) - self.assertEqual(extrap_left_disc.evaluate(None, constant_y), 2) - self.assertEqual(extrap_right_disc.evaluate(None, constant_y), 3) + assert extrap_left_disc.evaluate(None, constant_y) == 2 + assert extrap_right_disc.evaluate(None, constant_y) == 3 # check linear variable extrapolates correctly linear_y = macro_submesh.nodes @@ -297,7 +296,7 @@ def test_linear_extrapolate_left_right(self): # check constant extrapolates to constant constant_y = np.ones_like(micro_submesh.nodes[:, np.newaxis]) - self.assertEqual(surf_eqn_disc.evaluate(None, constant_y), 1.0) + assert surf_eqn_disc.evaluate(None, constant_y) == 1.0 # check linear variable extrapolates correctly linear_y = micro_submesh.nodes @@ -359,7 +358,7 @@ def test_quadratic_extrapolate_left_right(self): np.testing.assert_array_almost_equal( extrap_flux_left_disc.evaluate(None, constant_y), 0 ) - self.assertEqual(extrap_flux_right_disc.evaluate(None, constant_y), 0) + assert extrap_flux_right_disc.evaluate(None, constant_y) == 0 # check linear variable extrapolates correctly np.testing.assert_array_almost_equal( @@ -448,7 +447,7 @@ def test_extrapolate_2d_models(self): extrap_right = pybamm.BoundaryValue(var, "right") disc.set_variable_slices([var]) extrap_right_disc = disc.process_symbol(extrap_right) - self.assertEqual(extrap_right_disc.domain, ["negative electrode"]) + assert extrap_right_disc.domain == ["negative electrode"] # evaluate y_macro = mesh["negative electrode"].nodes y_micro = mesh["negative particle"].nodes @@ -462,7 +461,7 @@ def test_extrapolate_2d_models(self): extrap_right = pybamm.BoundaryValue(var, "right") disc.set_variable_slices([var]) extrap_right_disc = disc.process_symbol(extrap_right) - self.assertEqual(extrap_right_disc.domain, []) + assert extrap_right_disc.domain == [] # 2d macroscale mesh = get_1p1d_mesh_for_testing() @@ -471,7 +470,7 @@ def test_extrapolate_2d_models(self): extrap_right = pybamm.BoundaryValue(var, "right") disc.set_variable_slices([var]) extrap_right_disc = disc.process_symbol(extrap_right) - self.assertEqual(extrap_right_disc.domain, []) + assert extrap_right_disc.domain == [] # test extrapolate to "negative tab" gives same as "left" and # "positive tab" gives same "right" (see get_mesh_for_testing) @@ -497,13 +496,3 @@ def test_extrapolate_2d_models(self): extrap_pos_disc.evaluate(None, constant_y), extrap_right_disc.evaluate(None, constant_y), ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_finite_volume.py b/tests/unit/test_spatial_methods/test_finite_volume/test_finite_volume.py index de31b770ff..204e831855 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_finite_volume.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_finite_volume.py @@ -10,10 +10,10 @@ ) import numpy as np from scipy.sparse import kron, eye -import unittest +import pytest -class TestFiniteVolume(unittest.TestCase): +class TestFiniteVolume: def test_node_to_edge_to_node(self): # Create discretisation mesh = get_mesh_for_testing() @@ -46,14 +46,14 @@ def test_node_to_edge_to_node(self): ) # bad shift key - with self.assertRaisesRegex(ValueError, "shift key"): + with pytest.raises(ValueError, match="shift key"): fin_vol.shift(c, "bad shift key", "arithmetic") - with self.assertRaisesRegex(ValueError, "shift key"): + with pytest.raises(ValueError, match="shift key"): fin_vol.shift(c, "bad shift key", "harmonic") # bad method - with self.assertRaisesRegex(ValueError, "method"): + with pytest.raises(ValueError, match="method"): fin_vol.shift(c, "shift key", "bad method") def test_concatenation(self): @@ -71,7 +71,7 @@ def test_concatenation(self): edges = [ pybamm.Vector(np.ones(mesh[dom].npts + 2), domain=dom) for dom in whole_cell ] - with self.assertRaisesRegex(pybamm.ShapeError, "child must have size n_nodes"): + with pytest.raises(pybamm.ShapeError, match="child must have size n_nodes"): fin_vol.concatenation(edges) def test_discretise_diffusivity_times_spatial_operator(self): @@ -154,14 +154,14 @@ def test_discretise_spatial_variable(self): # macroscale x1 = pybamm.SpatialVariable("x", ["negative electrode"]) x1_disc = disc.process_symbol(x1) - self.assertIsInstance(x1_disc, pybamm.Vector) + assert isinstance(x1_disc, pybamm.Vector) np.testing.assert_array_equal( x1_disc.evaluate(), disc.mesh["negative electrode"].nodes[:, np.newaxis] ) # macroscale with concatenation x2 = pybamm.SpatialVariable("x", ["negative electrode", "separator"]) x2_disc = disc.process_symbol(x2) - self.assertIsInstance(x2_disc, pybamm.Vector) + assert isinstance(x2_disc, pybamm.Vector) np.testing.assert_array_equal( x2_disc.evaluate(), disc.mesh[("negative electrode", "separator")].nodes[:, np.newaxis], @@ -169,7 +169,7 @@ def test_discretise_spatial_variable(self): # microscale r = 3 * pybamm.SpatialVariable("r", ["negative particle"]) r_disc = disc.process_symbol(r) - self.assertIsInstance(r_disc, pybamm.Vector) + assert isinstance(r_disc, pybamm.Vector) np.testing.assert_array_equal( r_disc.evaluate(), 3 * disc.mesh["negative particle"].nodes[:, np.newaxis] ) @@ -326,8 +326,8 @@ def test_boundary_value_domain(self): c_s_p_surf = pybamm.surf(c_s_p) c_s_n_surf_disc = disc.process_symbol(c_s_n_surf) c_s_p_surf_disc = disc.process_symbol(c_s_p_surf) - self.assertEqual(c_s_n_surf_disc.domain, ["negative electrode"]) - self.assertEqual(c_s_p_surf_disc.domain, ["positive electrode"]) + assert c_s_n_surf_disc.domain == ["negative electrode"] + assert c_s_p_surf_disc.domain == ["positive electrode"] def test_delta_function(self): mesh = get_mesh_for_testing() @@ -344,17 +344,17 @@ def test_delta_function(self): # Basic shape and type tests y = np.ones_like(mesh["negative electrode"].nodes[:, np.newaxis]) # Left - self.assertEqual(delta_fn_left_disc.domains, delta_fn_left.domains) - self.assertIsInstance(delta_fn_left_disc, pybamm.Multiplication) - self.assertIsInstance(delta_fn_left_disc.left, pybamm.Matrix) + assert delta_fn_left_disc.domains == delta_fn_left.domains + assert isinstance(delta_fn_left_disc, pybamm.Multiplication) + assert isinstance(delta_fn_left_disc.left, pybamm.Matrix) np.testing.assert_array_equal(delta_fn_left_disc.left.evaluate()[:, 1:], 0) - self.assertEqual(delta_fn_left_disc.shape, y.shape) + assert delta_fn_left_disc.shape == y.shape # Right - self.assertEqual(delta_fn_right_disc.domains, delta_fn_right.domains) - self.assertIsInstance(delta_fn_right_disc, pybamm.Multiplication) - self.assertIsInstance(delta_fn_right_disc.left, pybamm.Matrix) + assert delta_fn_right_disc.domains == delta_fn_right.domains + assert isinstance(delta_fn_right_disc, pybamm.Multiplication) + assert isinstance(delta_fn_right_disc.left, pybamm.Matrix) np.testing.assert_array_equal(delta_fn_right_disc.left.evaluate()[:, :-1], 0) - self.assertEqual(delta_fn_right_disc.shape, y.shape) + assert delta_fn_right_disc.shape == y.shape # Value tests # Delta function should integrate to the same thing as variable @@ -378,7 +378,7 @@ def test_heaviside(self): # process_binary_operators should work with heaviside disc_heav = disc.process_symbol(heav * var) nodes = mesh["negative electrode"].nodes - self.assertEqual(disc_heav.size, nodes.size) + assert disc_heav.size == nodes.size np.testing.assert_array_equal(disc_heav.evaluate(y=2 * np.ones_like(nodes)), 2) np.testing.assert_array_equal(disc_heav.evaluate(y=-2 * np.ones_like(nodes)), 0) @@ -404,8 +404,8 @@ def test_upwind_downwind(self): nodes = mesh["negative electrode"].nodes n = mesh["negative electrode"].npts - self.assertEqual(disc_upwind.size, nodes.size + 1) - self.assertEqual(disc_downwind.size, nodes.size + 1) + assert disc_upwind.size == nodes.size + 1 + assert disc_downwind.size == nodes.size + 1 y_test = 2 * np.ones_like(nodes) np.testing.assert_array_equal( @@ -420,7 +420,7 @@ def test_upwind_downwind(self): # Remove boundary conditions and check error is raised disc.bcs = {} disc._discretised_symbols = {} - with self.assertRaisesRegex(pybamm.ModelError, "Boundary conditions"): + with pytest.raises(pybamm.ModelError, match="Boundary conditions"): disc.process_symbol(upwind) # Set wrong boundary conditions and check error is raised @@ -430,9 +430,9 @@ def test_upwind_downwind(self): "right": (pybamm.Scalar(3), "Neumann"), } } - with self.assertRaisesRegex(pybamm.ModelError, "Dirichlet boundary conditions"): + with pytest.raises(pybamm.ModelError, match="Dirichlet boundary conditions"): disc.process_symbol(upwind) - with self.assertRaisesRegex(pybamm.ModelError, "Dirichlet boundary conditions"): + with pytest.raises(pybamm.ModelError, match="Dirichlet boundary conditions"): disc.process_symbol(downwind) def test_grad_div_with_bcs_on_tab(self): @@ -525,10 +525,10 @@ def test_neg_pos_bcs(self): # check after disc that negative tab goes to left and positive tab goes # to right disc.process_symbol(grad_eqn) - self.assertEqual(disc.bcs[var]["left"][0], pybamm.Scalar(1)) - self.assertEqual(disc.bcs[var]["left"][1], "Dirichlet") - self.assertEqual(disc.bcs[var]["right"][0], pybamm.Scalar(0)) - self.assertEqual(disc.bcs[var]["right"][1], "Neumann") + assert disc.bcs[var]["left"][0] == pybamm.Scalar(1) + assert disc.bcs[var]["left"][1] == "Dirichlet" + assert disc.bcs[var]["right"][0] == pybamm.Scalar(0) + assert disc.bcs[var]["right"][1] == "Neumann" def test_full_broadcast_domains(self): model = pybamm.BaseModel() @@ -568,12 +568,12 @@ def test_evaluate_at(self): evaluate_at = pybamm.EvaluateAt(var, position) evaluate_at_disc = disc.process_symbol(evaluate_at) - self.assertIsInstance(evaluate_at_disc, pybamm.MatrixMultiplication) - self.assertIsInstance(evaluate_at_disc.left, pybamm.Matrix) - self.assertIsInstance(evaluate_at_disc.right, pybamm.StateVector) + assert isinstance(evaluate_at_disc, pybamm.MatrixMultiplication) + assert isinstance(evaluate_at_disc.left, pybamm.Matrix) + assert isinstance(evaluate_at_disc.right, pybamm.StateVector) y = np.arange(n)[:, np.newaxis] - self.assertEqual(evaluate_at_disc.evaluate(y=y), y[idx]) + assert evaluate_at_disc.evaluate(y=y) == y[idx] def test_inner(self): # standard @@ -598,9 +598,9 @@ def test_inner(self): disc.bcs = boundary_conditions inner_disc = disc.process_symbol(inner) - self.assertIsInstance(inner_disc, pybamm.Inner) - self.assertIsInstance(inner_disc.left, pybamm.MatrixMultiplication) - self.assertIsInstance(inner_disc.right, pybamm.MatrixMultiplication) + assert isinstance(inner_disc, pybamm.Inner) + assert isinstance(inner_disc.left, pybamm.MatrixMultiplication) + assert isinstance(inner_disc.right, pybamm.MatrixMultiplication) n = mesh["negative particle"].npts y = np.ones(n)[:, np.newaxis] @@ -613,19 +613,9 @@ def test_inner(self): inner_disc = disc.process_symbol(inner) - self.assertIsInstance(inner_disc, pybamm.Inner) - self.assertIsInstance(inner_disc.left, pybamm.MatrixMultiplication) - self.assertIsInstance(inner_disc.right, pybamm.MatrixMultiplication) + assert isinstance(inner_disc, pybamm.Inner) + assert isinstance(inner_disc.left, pybamm.MatrixMultiplication) + assert isinstance(inner_disc.right, pybamm.MatrixMultiplication) m = mesh["negative electrode"].npts np.testing.assert_array_equal(inner_disc.evaluate(y=y), np.zeros((n * m, 1))) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_ghost_nodes_and_neumann.py b/tests/unit/test_spatial_methods/test_finite_volume/test_ghost_nodes_and_neumann.py index ba82f2fb09..0044d20c0a 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_ghost_nodes_and_neumann.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_ghost_nodes_and_neumann.py @@ -5,10 +5,10 @@ import pybamm from tests import get_mesh_for_testing, get_p2d_mesh_for_testing import numpy as np -import unittest +import pytest -class TestGhostNodes(unittest.TestCase): +class TestGhostNodes: def test_add_ghost_nodes(self): # Set up @@ -36,25 +36,25 @@ def test_add_ghost_nodes(self): np.testing.assert_array_equal( sym_ghost.evaluate(y=y_test)[1:-1], discretised_symbol.evaluate(y=y_test) ) - self.assertEqual( - (sym_ghost.evaluate(y=y_test)[0] + sym_ghost.evaluate(y=y_test)[1]) / 2, 0 - ) - self.assertEqual( - (sym_ghost.evaluate(y=y_test)[-2] + sym_ghost.evaluate(y=y_test)[-1]) / 2, 3 - ) + assert ( + sym_ghost.evaluate(y=y_test)[0] + sym_ghost.evaluate(y=y_test)[1] + ) / 2 == 0 + assert ( + sym_ghost.evaluate(y=y_test)[-2] + sym_ghost.evaluate(y=y_test)[-1] + ) / 2 == 3 # test errors bcs = {"left": (pybamm.Scalar(0), "x"), "right": (pybamm.Scalar(3), "Neumann")} - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.add_ghost_nodes(var, discretised_symbol, bcs) - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.add_neumann_values(var, discretised_symbol, bcs, var.domain) bcs = {"left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(3), "x")} - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.add_ghost_nodes(var, discretised_symbol, bcs) - with self.assertRaisesRegex(ValueError, "No boundary conditions"): + with pytest.raises(ValueError, match="No boundary conditions"): sp_meth.add_ghost_nodes(var, discretised_symbol, {}) - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.add_neumann_values(var, discretised_symbol, bcs, var.domain) def test_add_ghost_nodes_concatenation(self): @@ -92,22 +92,14 @@ def test_add_ghost_nodes_concatenation(self): symbol_plus_ghost_both.evaluate(None, y_test)[1:-1], discretised_symbol.evaluate(None, y_test), ) - self.assertEqual( - ( - symbol_plus_ghost_both.evaluate(None, y_test)[0] - + symbol_plus_ghost_both.evaluate(None, y_test)[1] - ) - / 2, - 0, - ) - self.assertEqual( - ( - symbol_plus_ghost_both.evaluate(None, y_test)[-2] - + symbol_plus_ghost_both.evaluate(None, y_test)[-1] - ) - / 2, - 3, - ) + assert ( + symbol_plus_ghost_both.evaluate(None, y_test)[0] + + symbol_plus_ghost_both.evaluate(None, y_test)[1] + ) / 2 == 0 + assert ( + symbol_plus_ghost_both.evaluate(None, y_test)[-2] + + symbol_plus_ghost_both.evaluate(None, y_test)[-1] + ) / 2 == 3 def test_p2d_add_ghost_nodes(self): # create discretisation @@ -187,13 +179,3 @@ def test_p2d_add_ghost_nodes(self): np.testing.assert_array_equal( (c_s_p_ghost_eval[:, -2] + c_s_p_ghost_eval[:, -1]) / 2, 3 ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_grad_div_shapes.py b/tests/unit/test_spatial_methods/test_finite_volume/test_grad_div_shapes.py index a1dd402f56..9e7f993e2a 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_grad_div_shapes.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_grad_div_shapes.py @@ -10,10 +10,9 @@ get_cylindrical_mesh_for_testing, ) import numpy as np -import unittest -class TestFiniteVolumeGradDiv(unittest.TestCase): +class TestFiniteVolumeGradDiv: def test_grad_div_shapes_Dirichlet_bcs(self): """ Test grad and div with Dirichlet boundary conditions in Cartesian coordinates @@ -637,13 +636,3 @@ def test_grad_1plus1d(self): np.testing.assert_array_almost_equal( grad_eqn_disc.evaluate(None, linear_y), expected ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_finite_volume/test_integration.py b/tests/unit/test_spatial_methods/test_finite_volume/test_integration.py index e9730a8eb7..bf6f44059a 100644 --- a/tests/unit/test_spatial_methods/test_finite_volume/test_integration.py +++ b/tests/unit/test_spatial_methods/test_finite_volume/test_integration.py @@ -2,6 +2,7 @@ # Tests for integration using Finite Volume method # +import pytest import pybamm from tests import ( get_mesh_for_testing, @@ -9,10 +10,9 @@ get_cylindrical_mesh_for_testing, ) import numpy as np -import unittest -class TestFiniteVolumeIntegration(unittest.TestCase): +class TestFiniteVolumeIntegration: def test_definite_integral(self): # create discretisation mesh = get_mesh_for_testing(xpts=200, rpts=200) @@ -37,7 +37,7 @@ def test_definite_integral(self): submesh = mesh[("negative electrode", "separator")] constant_y = np.ones_like(submesh.nodes[:, np.newaxis]) - self.assertEqual(integral_eqn_disc.evaluate(None, constant_y), ln + ls) + assert integral_eqn_disc.evaluate(None, constant_y) == ln + ls linear_y = submesh.nodes np.testing.assert_array_almost_equal( integral_eqn_disc.evaluate(None, linear_y), (ln + ls) ** 2 / 2 @@ -56,10 +56,10 @@ def test_definite_integral(self): submesh = mesh[("separator", "positive electrode")] constant_y = np.ones_like(submesh.nodes[:, np.newaxis]) - self.assertEqual(integral_eqn_disc.evaluate(None, constant_y), ls + lp) + assert integral_eqn_disc.evaluate(None, constant_y) == ls + lp linear_y = submesh.nodes - self.assertAlmostEqual( - integral_eqn_disc.evaluate(None, linear_y)[0][0], (1 - (ln) ** 2) / 2 + assert integral_eqn_disc.evaluate(None, linear_y)[0][0] == pytest.approx( + (1 - (ln) ** 2) / 2 ) cos_y = np.cos(submesh.nodes[:, np.newaxis]) np.testing.assert_array_almost_equal( @@ -122,9 +122,9 @@ def test_definite_integral(self): # test failure for secondary dimension column form finite_volume = pybamm.FiniteVolume() finite_volume.build(mesh) - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "Integral in secondary vector only implemented in 'row' form", + match="Integral in secondary vector only implemented in 'row' form", ): finite_volume.definite_integral_matrix(var, "column", "secondary") @@ -293,14 +293,14 @@ def test_definite_integral_vector(self): # row (default) vec = pybamm.DefiniteIntegralVector(var) vec_disc = disc.process_symbol(vec) - self.assertEqual(vec_disc.shape[0], 1) - self.assertEqual(vec_disc.shape[1], mesh["negative electrode"].npts) + assert vec_disc.shape[0] == 1 + assert vec_disc.shape[1] == mesh["negative electrode"].npts # column vec = pybamm.DefiniteIntegralVector(var, vector_type="column") vec_disc = disc.process_symbol(vec) - self.assertEqual(vec_disc.shape[0], mesh["negative electrode"].npts) - self.assertEqual(vec_disc.shape[1], 1) + assert vec_disc.shape[0] == mesh["negative electrode"].npts + assert vec_disc.shape[1] == 1 def test_indefinite_integral(self): # create discretisation @@ -340,7 +340,7 @@ def test_indefinite_integral(self): phi_approx = int_grad_phi_disc.evaluate(None, phi_exact) phi_approx += 1 # add constant of integration np.testing.assert_array_almost_equal(phi_exact, phi_approx) - self.assertEqual(left_boundary_value_disc.evaluate(y=phi_exact), 0) + assert left_boundary_value_disc.evaluate(y=phi_exact) == 0 # linear case phi_exact = submesh.nodes[:, np.newaxis] phi_approx = int_grad_phi_disc.evaluate(None, phi_exact) @@ -380,7 +380,7 @@ def test_indefinite_integral(self): phi_approx = int_grad_phi_disc.evaluate(None, phi_exact) phi_approx += 1 # add constant of integration np.testing.assert_array_almost_equal(phi_exact, phi_approx) - self.assertEqual(left_boundary_value_disc.evaluate(y=phi_exact), 0) + assert left_boundary_value_disc.evaluate(y=phi_exact) == 0 # linear case phi_exact = submesh.nodes[:, np.newaxis] - submesh.edges[0] @@ -441,7 +441,7 @@ def test_indefinite_integral(self): c_approx = c_integral_disc.evaluate(None, c_exact) c_approx += 1 # add constant of integration np.testing.assert_array_almost_equal(c_exact, c_approx) - self.assertEqual(left_boundary_value_disc.evaluate(y=c_exact), 0) + assert left_boundary_value_disc.evaluate(y=c_exact) == 0 # linear case c_exact = submesh.nodes[:, np.newaxis] @@ -489,7 +489,7 @@ def test_backward_indefinite_integral(self): phi_approx = int_grad_phi_disc.evaluate(None, phi_exact) phi_approx += 1 # add constant of integration np.testing.assert_array_almost_equal(phi_exact, phi_approx) - self.assertEqual(right_boundary_value_disc.evaluate(y=phi_exact), 0) + assert right_boundary_value_disc.evaluate(y=phi_exact) == 0 # linear case phi_exact = submesh.nodes - submesh.edges[-1] @@ -583,9 +583,9 @@ def test_indefinite_integral_on_nodes(self): int_c = pybamm.IndefiniteIntegral(c, r) disc.set_variable_slices([c]) - with self.assertRaisesRegex( + with pytest.raises( NotImplementedError, - "Indefinite integral on a spherical polar domain is not implemented", + match="Indefinite integral on a spherical polar domain is not implemented", ): disc.process_symbol(int_c) @@ -655,13 +655,3 @@ def test_forward_plus_backward_integral(self): full_int_phi_disc.evaluate(y=phi_exact).flatten(), int_plus_back_int_phi_disc.evaluate(y=phi_exact).flatten(), ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_scikit_finite_element.py b/tests/unit/test_spatial_methods/test_scikit_finite_element.py index 18c941517b..42b282e08a 100644 --- a/tests/unit/test_spatial_methods/test_scikit_finite_element.py +++ b/tests/unit/test_spatial_methods/test_scikit_finite_element.py @@ -2,21 +2,21 @@ # Test for the operator class # +import pytest import pybamm from tests import get_2p1d_mesh_for_testing, get_unit_2p1D_mesh_for_testing import numpy as np -import unittest -class TestScikitFiniteElement(unittest.TestCase): +class TestScikitFiniteElement: def test_not_implemented(self): mesh = get_2p1d_mesh_for_testing(include_particles=False) spatial_method = pybamm.ScikitFiniteElement() spatial_method.build(mesh) - self.assertEqual(spatial_method.mesh, mesh) - with self.assertRaises(NotImplementedError): + assert spatial_method.mesh == mesh + with pytest.raises(NotImplementedError): spatial_method.divergence(None, None, None) - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): spatial_method.indefinite_integral(None, None, None) def test_discretise_equations(self): @@ -100,7 +100,7 @@ def test_discretise_equations(self): "positive tab": (pybamm.Scalar(1), "Other BC"), } } - with self.assertRaises(ValueError): + with pytest.raises(ValueError): eqn_disc = disc.process_symbol(eqn) disc.bcs = { var: { @@ -108,19 +108,19 @@ def test_discretise_equations(self): "positive tab": (pybamm.Scalar(1), "Neumann"), } } - with self.assertRaises(ValueError): + with pytest.raises(ValueError): eqn_disc = disc.process_symbol(eqn) # raise ModelError if no BCs provided new_var = pybamm.Variable("new_var", domain="current collector") disc.set_variable_slices([new_var]) eqn = pybamm.laplacian(new_var) - with self.assertRaises(pybamm.ModelError): + with pytest.raises(pybamm.ModelError): eqn_disc = disc.process_symbol(eqn) # check GeometryError if using scikit-fem not in y or z x = pybamm.SpatialVariable("x", ["current collector"]) - with self.assertRaises(pybamm.GeometryError): + with pytest.raises(pybamm.GeometryError): disc.process_symbol(x) def test_gradient(self): @@ -389,14 +389,14 @@ def test_definite_integral_vector(self): # row (default) vec = pybamm.DefiniteIntegralVector(var) vec_disc = disc.process_symbol(vec) - self.assertEqual(vec_disc.shape[0], 1) - self.assertEqual(vec_disc.shape[1], mesh["current collector"].npts) + assert vec_disc.shape[0] == 1 + assert vec_disc.shape[1] == mesh["current collector"].npts # column vec = pybamm.DefiniteIntegralVector(var, vector_type="column") vec_disc = disc.process_symbol(vec) - self.assertEqual(vec_disc.shape[0], mesh["current collector"].npts) - self.assertEqual(vec_disc.shape[1], 1) + assert vec_disc.shape[0] == mesh["current collector"].npts + assert vec_disc.shape[1] == 1 def test_neg_pos(self): mesh = get_2p1d_mesh_for_testing(include_particles=False) @@ -423,7 +423,7 @@ def test_neg_pos(self): # test BoundaryGradient not implemented extrap_neg = pybamm.BoundaryGradient(var, "negative tab") - with self.assertRaises(NotImplementedError): + with pytest.raises(NotImplementedError): disc.process_symbol(extrap_neg) def test_boundary_integral(self): @@ -562,13 +562,3 @@ def test_disc_spatial_var(self): # spatial vars should discretise to the flattend meshgrid np.testing.assert_array_equal(y_disc.evaluate(), y_actual) np.testing.assert_array_equal(z_disc.evaluate(), z_actual) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/tests/unit/test_spatial_methods/test_spectral_volume.py b/tests/unit/test_spatial_methods/test_spectral_volume.py index f6a631e84c..1fd97c2ebd 100644 --- a/tests/unit/test_spatial_methods/test_spectral_volume.py +++ b/tests/unit/test_spatial_methods/test_spectral_volume.py @@ -2,9 +2,9 @@ # Test for the operator class # +import pytest import pybamm import numpy as np -import unittest def get_mesh_for_testing( @@ -87,10 +87,10 @@ def get_1p1d_mesh_for_testing( ) -class TestSpectralVolume(unittest.TestCase): +class TestSpectralVolume: def test_exceptions(self): sp_meth = pybamm.SpectralVolume() - with self.assertRaises(ValueError): + with pytest.raises(ValueError): sp_meth.chebyshev_differentiation_matrices(3, 3) mesh = get_mesh_for_testing() @@ -104,14 +104,14 @@ def test_exceptions(self): sp_meth.build(mesh) bcs = {"left": (pybamm.Scalar(0), "x"), "right": (pybamm.Scalar(3), "Neumann")} - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.replace_dirichlet_values(var, discretised_symbol, bcs) - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.replace_neumann_values(var, discretised_symbol, bcs) bcs = {"left": (pybamm.Scalar(0), "Neumann"), "right": (pybamm.Scalar(3), "x")} - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.replace_dirichlet_values(var, discretised_symbol, bcs) - with self.assertRaisesRegex(ValueError, "boundary condition must be"): + with pytest.raises(ValueError, match="boundary condition must be"): sp_meth.replace_neumann_values(var, discretised_symbol, bcs) def test_grad_div_shapes_Dirichlet_bcs(self): @@ -628,13 +628,3 @@ def test_grad_1plus1d(self): np.testing.assert_array_almost_equal( grad_eqn_disc.evaluate(None, linear_y), expected ) - - -if __name__ == "__main__": - print("Add -v for more debug output") - import sys - - if "-v" in sys.argv: - debug = True - pybamm.settings.debug_mode = True - unittest.main() diff --git a/vcpkg-configuration.json b/vcpkg-configuration.json index 8d5c64ac47..f3d53f56c3 100644 --- a/vcpkg-configuration.json +++ b/vcpkg-configuration.json @@ -13,7 +13,7 @@ { "kind": "git", "repository": "https://github.com/pybamm-team/casadi-vcpkg-registry.git", - "baseline": "1cb93f2fb71be26c874db724940ef8e604ee558e", + "baseline": "e4b797736790af90de505e0296b07e87719cb1a6", "packages": ["casadi"] } ] diff --git a/vcpkg.json b/vcpkg.json index 6c50d65524..27d020f3e2 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,6 @@ { "name": "pybamm", - "version-string": "24.9.0", + "version-string": "24.11.0", "dependencies": [ "casadi", {