diff --git a/CHANGELOG.md b/CHANGELOG.md index e6ee1b6e32..3e3b0ded0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,28 @@ # [Unreleased](https://github.com/pybamm-team/PyBaMM/) -# [v22.4](https://github.com/pybamm-team/PyBaMM/tree/v22.4) - 2022-04-30 - ## Features -- Added a casadi version of the IDKLU solver, which is used for `model.convert_to_format = "casadi"` ([#2002](https://github.com/pybamm-team/PyBaMM/pull/2002)) - Added functionality to generate Julia expressions from a model. See [PyBaMM.jl](https://github.com/tinosulzer/PyBaMM.jl) for how to use these ([#1942](https://github.com/pybamm-team/PyBaMM/pull/1942))) +- Added basic callbacks to the Simulation class, and a LoggingCallback ([#1880](https://github.com/pybamm-team/PyBaMM/pull/1880))) ## Bug fixes - Corrected legend order in "plot_voltage_components.py", so each entry refers to the correct overpotential. ([#2061](https://github.com/pybamm-team/PyBaMM/pull/2061)) -- Remove old deprecation errors, including those in `parameter_values.py` that caused the simulation if, for example, the reaction rate is re-introduced manually ([#2022](https://github.com/pybamm-team/PyBaMM/pull/2022)) ## Breaking changes - Changed domain-specific parameter names to a nested attribute, e.g. `param.c_n_max` is now `param.n.c_max` ([#2063](https://github.com/pybamm-team/PyBaMM/pull/2063)) +# [v22.4](https://github.com/pybamm-team/PyBaMM/tree/v22.4) - 2022-04-30 + +## Features + +- Added a casadi version of the IDKLU solver, which is used for `model.convert_to_format = "casadi"` ([#2002](https://github.com/pybamm-team/PyBaMM/pull/2002)) + +## Bug fixes + +- Remove old deprecation errors, including those in `parameter_values.py` that caused the simulation if, for example, the reaction rate is re-introduced manually ([#2022](https://github.com/pybamm-team/PyBaMM/pull/2022)) + # [v22.3](https://github.com/pybamm-team/PyBaMM/tree/v22.3) - 2022-03-31 ## Features diff --git a/docs/index.rst b/docs/index.rst index a96bd47510..e0814f68d1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -81,6 +81,7 @@ API documentation source/simulation source/plotting/index source/util + source/callbacks source/citations source/parameters_cli source/batch_study diff --git a/docs/source/callbacks.rst b/docs/source/callbacks.rst new file mode 100644 index 0000000000..977c31dbad --- /dev/null +++ b/docs/source/callbacks.rst @@ -0,0 +1,13 @@ +Callbacks +========= + +.. autoclass:: pybamm.callbacks.Callback + :members: + +.. autoclass:: pybamm.callbacks.CallbackList + :members: + +.. autoclass:: pybamm.callbacks.LoggingCallback + :members: + +.. autofunction:: pybamm.callbacks.setup_callbacks diff --git a/examples/notebooks/batch_study.ipynb b/examples/notebooks/batch_study.ipynb index e4c266bcd3..0eeea03b40 100644 --- a/examples/notebooks/batch_study.ipynb +++ b/examples/notebooks/batch_study.ipynb @@ -639,7 +639,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.0" + "version": "3.8.12" }, "toc": { "base_numbering": 1, diff --git a/examples/notebooks/callbacks.ipynb b/examples/notebooks/callbacks.ipynb new file mode 100644 index 0000000000..66419ba448 --- /dev/null +++ b/examples/notebooks/callbacks.ipynb @@ -0,0 +1,303 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "403c07ba", + "metadata": {}, + "source": [ + "# Callbacks" + ] + }, + { + "cell_type": "markdown", + "id": "4e5bfd9f", + "metadata": {}, + "source": [ + "
\n", + "WARNING: This is a new, experimental feature, and the API may change in future.\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "cb2ae3b6", + "metadata": {}, + "source": [ + "Callbacks provide hooks for users to interact with the different parts of the PyBaMM pipeline, for example to log, save, or visualize outputs of intermediate functions. \n", + "\n", + "A list of available callbacks can be found in the [API docs](https://pybamm.readthedocs.io/en/latest/source/callbacks.html). Any number of callbacks can be provided as a list, and they will each be called in turn in the order provided.\n", + "\n", + "The base class [`pybamm.callbacks.Callback`](https://pybamm.readthedocs.io/en/latest/source/citations.html#pybamm.callbacks.Callback) documents the available callback methods, at which point in the pipeline they are called, and what arguments are passed to them." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "c25b83cb", + "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 -q\n", + "import pybamm\n", + "\n", + "model = pybamm.lithium_ion.DFN()\n", + "experiment = pybamm.Experiment([\n", + " (\n", + " \"Discharge at C/5 for 10 hours or until 3.3 V\",\n", + " \"Charge at 1 A until 4.1 V\",\n", + " \"Hold at 4.1 V until 10 mA\",\n", + " ),\n", + " ]\n", + " * 3\n", + ")\n", + "sim = pybamm.Simulation(model, experiment=experiment)" + ] + }, + { + "cell_type": "markdown", + "id": "6c38b44e", + "metadata": {}, + "source": [ + "## Logging callback" + ] + }, + { + "cell_type": "markdown", + "id": "538f91d4", + "metadata": {}, + "source": [ + "The `LoggingCallback` can be used to log the progress of the simulation. The default is to print to stdout (this callback is automatically created if no other `LoggingCallback` exists)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "dc4121dd", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-03-10 10:17:57.074 - [NOTICE] callbacks.on_cycle_start(174): Cycle 1/3 (18.917 ms elapsed) --------------------\n", + "2022-03-10 10:17:57.074 - [NOTICE] callbacks.on_step_start(182): Cycle 1/3, step 1/3: Discharge at C/5 for 10 hours or until 3.3 V\n", + "2022-03-10 10:17:58.139 - [NOTICE] callbacks.on_step_start(182): Cycle 1/3, step 2/3: Charge at 1 A until 4.1 V\n", + "2022-03-10 10:17:58.505 - [NOTICE] callbacks.on_step_start(182): Cycle 1/3, step 3/3: Hold at 4.1 V until 10 mA\n", + "2022-03-10 10:17:58.952 - [NOTICE] callbacks.on_cycle_start(174): Cycle 2/3 (1.898 s elapsed) --------------------\n", + "2022-03-10 10:17:58.953 - [NOTICE] callbacks.on_step_start(182): Cycle 2/3, step 1/3: Discharge at C/5 for 10 hours or until 3.3 V\n", + "2022-03-10 10:18:00.203 - [NOTICE] callbacks.on_step_start(182): Cycle 2/3, step 2/3: Charge at 1 A until 4.1 V\n", + "2022-03-10 10:18:00.464 - [NOTICE] callbacks.on_step_start(182): Cycle 2/3, step 3/3: Hold at 4.1 V until 10 mA\n", + "2022-03-10 10:18:00.675 - [NOTICE] callbacks.on_cycle_start(174): Cycle 3/3 (3.620 s elapsed) --------------------\n", + "2022-03-10 10:18:00.675 - [NOTICE] callbacks.on_step_start(182): Cycle 3/3, step 1/3: Discharge at C/5 for 10 hours or until 3.3 V\n", + "2022-03-10 10:18:01.963 - [NOTICE] callbacks.on_step_start(182): Cycle 3/3, step 2/3: Charge at 1 A until 4.1 V\n", + "2022-03-10 10:18:02.232 - [NOTICE] callbacks.on_step_start(182): Cycle 3/3, step 3/3: Hold at 4.1 V until 10 mA\n", + "2022-03-10 10:18:02.452 - [NOTICE] callbacks.on_experiment_end(222): Finish experiment simulation, took 3.620 s\n" + ] + } + ], + "source": [ + "pybamm.set_logging_level(\"NOTICE\")\n", + "sim.solve();" + ] + }, + { + "cell_type": "markdown", + "id": "67608fe3", + "metadata": {}, + "source": [ + "or to a separate file" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5e1ecca8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2022-03-10 10:18:02.483 - [NOTICE] callbacks.on_cycle_start(174): Cycle 1/3 (24.357 ms elapsed) --------------------\n", + "2022-03-10 10:18:02.483 - [NOTICE] callbacks.on_step_start(182): Cycle 1/3, step 1/3: Discharge at C/5 for 10 hours or until 3.3 V\n", + "2022-03-10 10:18:03.799 - [NOTICE] callbacks.on_step_start(182): Cycle 1/3, step 2/3: Charge at 1 A until 4.1 V\n", + "2022-03-10 10:18:04.065 - [NOTICE] callbacks.on_step_start(182): Cycle 1/3, step 3/3: Hold at 4.1 V until 10 mA\n", + "2022-03-10 10:18:04.394 - [NOTICE] callbacks.on_cycle_start(174): Cycle 2/3 (1.935 s elapsed) --------------------\n", + "2022-03-10 10:18:04.394 - [NOTICE] callbacks.on_step_start(182): Cycle 2/3, step 1/3: Discharge at C/5 for 10 hours or until 3.3 V\n", + "2022-03-10 10:18:05.607 - [NOTICE] callbacks.on_step_start(182): Cycle 2/3, step 2/3: Charge at 1 A until 4.1 V\n", + "2022-03-10 10:18:05.858 - [NOTICE] callbacks.on_step_start(182): Cycle 2/3, step 3/3: Hold at 4.1 V until 10 mA\n", + "2022-03-10 10:18:06.059 - [NOTICE] callbacks.on_cycle_start(174): Cycle 3/3 (3.600 s elapsed) --------------------\n", + "2022-03-10 10:18:06.059 - [NOTICE] callbacks.on_step_start(182): Cycle 3/3, step 1/3: Discharge at C/5 for 10 hours or until 3.3 V\n", + "2022-03-10 10:18:07.280 - [NOTICE] callbacks.on_step_start(182): Cycle 3/3, step 2/3: Charge at 1 A until 4.1 V\n", + "2022-03-10 10:18:07.539 - [NOTICE] callbacks.on_step_start(182): Cycle 3/3, step 3/3: Hold at 4.1 V until 10 mA\n", + "2022-03-10 10:18:07.743 - [NOTICE] callbacks.on_experiment_end(222): Finish experiment simulation, took 3.600 s\n", + "\n" + ] + } + ], + "source": [ + "callback = pybamm.callbacks.LoggingCallback(\"output.log\")\n", + "sim.solve(callbacks=callback);\n", + "\n", + "# Read the file that has been written, which was saved to callback.logfile\n", + "with open(callback.logfile, \"r\") as f:\n", + " print(f.read())\n", + " \n", + "# Remove the log file\n", + "import os\n", + "os.remove(callback.logfile)" + ] + }, + { + "cell_type": "markdown", + "id": "aa7b0634", + "metadata": {}, + "source": [ + "## Custom callbacks" + ] + }, + { + "cell_type": "markdown", + "id": "070fee9e", + "metadata": {}, + "source": [ + "Custom callbacks should subclass the class `pybamm.callbacks.Callback` class, and implement a subset of the callback methods `on_`, which all take as input the dictionary `logs`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "a47ce3ff", + "metadata": {}, + "outputs": [], + "source": [ + "class CustomCallback(pybamm.callbacks.Callback):\n", + " def on_experiment_end(self, logs):\n", + " print(f\"We are at the end of the simulation. Logs are {logs}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "7f44371e", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2022-03-10 10:18:07.776 - [NOTICE] callbacks.on_cycle_start(174): Cycle 1/3 (24.415 ms elapsed) --------------------\n", + "2022-03-10 10:18:07.776 - [NOTICE] callbacks.on_step_start(182): Cycle 1/3, step 1/3: Discharge at C/5 for 10 hours or until 3.3 V\n", + "2022-03-10 10:18:09.125 - [NOTICE] callbacks.on_step_start(182): Cycle 1/3, step 2/3: Charge at 1 A until 4.1 V\n", + "2022-03-10 10:18:09.390 - [NOTICE] callbacks.on_step_start(182): Cycle 1/3, step 3/3: Hold at 4.1 V until 10 mA\n", + "2022-03-10 10:18:09.714 - [NOTICE] callbacks.on_cycle_start(174): Cycle 2/3 (1.963 s elapsed) --------------------\n", + "2022-03-10 10:18:09.714 - [NOTICE] callbacks.on_step_start(182): Cycle 2/3, step 1/3: Discharge at C/5 for 10 hours or until 3.3 V\n", + "2022-03-10 10:18:11.025 - [NOTICE] callbacks.on_step_start(182): Cycle 2/3, step 2/3: Charge at 1 A until 4.1 V\n", + "2022-03-10 10:18:11.288 - [NOTICE] callbacks.on_step_start(182): Cycle 2/3, step 3/3: Hold at 4.1 V until 10 mA\n", + "2022-03-10 10:18:11.498 - [NOTICE] callbacks.on_cycle_start(174): Cycle 3/3 (3.747 s elapsed) --------------------\n", + "2022-03-10 10:18:11.499 - [NOTICE] callbacks.on_step_start(182): Cycle 3/3, step 1/3: Discharge at C/5 for 10 hours or until 3.3 V\n", + "2022-03-10 10:18:12.748 - [NOTICE] callbacks.on_step_start(182): Cycle 3/3, step 2/3: Charge at 1 A until 4.1 V\n", + "2022-03-10 10:18:13.007 - [NOTICE] callbacks.on_step_start(182): Cycle 3/3, step 3/3: Hold at 4.1 V until 10 mA\n", + "2022-03-10 10:18:13.230 - [NOTICE] callbacks.on_experiment_end(222): Finish experiment simulation, took 3.747 s\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "We are at the end of the simulation of the simulation. Logs are {'stopping conditions': {'voltage': None, 'capacity': None}, 'cycle number': (3, 3), 'elapsed time': pybamm.TimerTime(3.7469801669999967), 'step number': (3, 3), 'step operating conditions': 'Hold at 4.1 V until 10 mA', 'termination': 'event: Current cut-off (negative) [A] [experiment]', 'summary variables': {'Minimum measured discharge capacity [A.h]': -0.16806782223941116, 'Maximum measured discharge capacity [A.h]': 0.6889979156579616, 'Measured capacity [A.h]': 0.8570657378973728, 'Minimum voltage [V]': 3.300010000000005, 'Maximum voltage [V]': 4.100000000000016, 'Positive electrode capacity [A.h]': 1.9464430360887066, 'Change in positive electrode capacity [A.h]': 0.0, 'Loss of active material in positive electrode [%]': 1.1102230246251565e-14, 'Change in loss of active material in positive electrode [%]': 0.0, 'Loss of lithium inventory [%]': -2.220446049250313e-14, 'Change in loss of lithium inventory [%]': 0.0, 'Loss of lithium inventory, including electrolyte [%]': 0.0, 'Change in loss of lithium inventory, including electrolyte [%]': 0.0, 'Total lithium [mol]': 0.0799932053645051, 'Change in total lithium [mol]': 0.0, 'Total lithium in electrolyte [mol]': 0.002410514999999987, 'Change in total lithium in electrolyte [mol]': -2.168404344971009e-18, 'Total lithium in positive electrode [mol]': 0.037303833837759315, 'Change in total lithium in positive electrode [mol]': 6.938893903907228e-18, 'Total lithium in particles [mol]': 0.07758269036450512, 'Change in total lithium in particles [mol]': 0.0, 'Total lithium lost [mol]': 0.0, 'Change in total lithium lost [mol]': 0.0, 'Total lithium lost from particles [mol]': -1.3877787807814457e-17, 'Change in total lithium lost from particles [mol]': 0.0, 'Total lithium lost from electrolyte [mol]': 1.2576745200831851e-17, 'Change in total lithium lost from electrolyte [mol]': 2.168404344971009e-18, 'Loss of lithium to SEI [mol]': 0.0, 'Change in loss of lithium to SEI [mol]': 0.0, 'Loss of capacity to SEI [A.h]': 0.0, 'Change in loss of capacity to SEI [A.h]': 0.0, 'Total lithium lost to side reactions [mol]': 0.0, 'Change in total lithium lost to side reactions [mol]': 0.0, 'Total capacity lost to side reactions [A.h]': 0.0, 'Change in total capacity lost to side reactions [A.h]': 0.0, 'Local ECM resistance [Ohm]': -0.1921801399643974, 'Change in local ECM resistance [Ohm]': -0.3385111526792064, 'Negative electrode capacity [A.h]': 1.139331489107912, 'Change in negative electrode capacity [A.h]': 0.0, 'Loss of active material in negative electrode [%]': -2.220446049250313e-14, 'Change in loss of active material in negative electrode [%]': 0.0, 'Total lithium in negative electrode [mol]': 0.0402788565267458, 'Change in total lithium in negative electrode [mol]': -6.938893903907228e-18, 'Loss of lithium to lithium plating [mol]': 0.0, 'Change in loss of lithium to lithium plating [mol]': 0.0, 'Loss of capacity to lithium plating [A.h]': 0.0, 'Change in loss of capacity to lithium plating [A.h]': 0.0, 'x_100': 0.9493038520218161, 'y_100': 0.5126064431891194, 'C': 0.8728195070455337, 'x_0': 0.18322346594476246, 'y_0': 0.9610241419672079, 'Un(x_100)': 0.07517904666112207, 'Un(x_0)': 0.2527477655253842, 'Up(y_100)': 4.175179046661121, 'Up(y_0)': 3.357747765525383, 'Up(y_100) - Un(x_100)': 4.099999999999999, 'Up(y_0) - Un(x_0)': 3.1049999999999986, 'n_Li_100': 0.07758269036450512, 'n_Li_0': 0.07758269036450512, 'n_Li': 0.07758269036450512, 'C_n': 1.139331489107912, 'C_p': 1.9464430360887066, 'C_n * (x_100 - x_0)': 0.8728195070455337, 'C_p * (y_100 - y_0)': 0.8728195070455337, 'Capacity [A.h]': 0.8728195070455337}}\n" + ] + } + ], + "source": [ + "# Note the default `LoggingCallback` is also called\n", + "sim.solve(callbacks=CustomCallback());" + ] + }, + { + "cell_type": "markdown", + "id": "f83dccc3", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "The relevant papers for this notebook are:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "beb72404", + "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] 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", + "[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] 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", + "[5] 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", + "[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", + "[7] 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", + "\n" + ] + } + ], + "source": [ + "pybamm.print_citations()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "df832fba", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.12" + }, + "toc": { + "base_numbering": 1, + "nav_menu": {}, + "number_sections": true, + "sideBar": true, + "skip_h1_title": false, + "title_cell": "Table of Contents", + "title_sidebar": "Contents", + "toc_cell": false, + "toc_position": {}, + "toc_section_display": true, + "toc_window_display": true + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pybamm/__init__.py b/pybamm/__init__.py index a1c4f63679..1b989de985 100644 --- a/pybamm/__init__.py +++ b/pybamm/__init__.py @@ -46,6 +46,7 @@ have_julia, ) from .logger import logger, set_logging_level +from .logger import logger, set_logging_level, get_new_logger from .settings import settings from .citations import Citations, citations, print_citations @@ -239,6 +240,11 @@ # from .batch_study import BatchStudy +# +# Callbacks +# +from . import callbacks + # # Remove any imported modules, so we don't expose them as part of pybamm # diff --git a/pybamm/callbacks.py b/pybamm/callbacks.py new file mode 100644 index 0000000000..d536a0aae0 --- /dev/null +++ b/pybamm/callbacks.py @@ -0,0 +1,243 @@ +# +# Base class for callbacks and some useful callbacks for pybamm +# Callbacks are used to perform actions (e.g. logging, saving) +# at certain points in the simulation +# Inspired by Keras callbacks +# https://github.com/keras-team/keras/blob/master/keras/callbacks.py +# +import pybamm +import numpy as np +import inspect + + +def setup_callbacks(callbacks): + callbacks = callbacks or [] + if not isinstance(callbacks, list): + callbacks = [callbacks] + + # Check if there is a logging callback already, if not add the default one + has_logging_callback = any(isinstance(cb, LoggingCallback) for cb in callbacks) + if not has_logging_callback: + callbacks.append(LoggingCallback()) + + return CallbackList(callbacks) + + +class Callback: + """ + Base class for callbacks, for documenting callback methods. + + Callbacks are used to perform actions (e.g. logging, saving) at certain points in + the simulation. Each callback method is named `on_`, where `` + describes the point at which the callback is called. For example, the callback + `on_experiment_start` is called at the start of an experiment simulation. In + general, callbacks take a single argument, `logs`, which is a dictionary of + information about the simulation. Each callback method should return `None` + (the output of the method is ignored). + + **EXPERIMENTAL** - this class is experimental and the callback interface may + change in future releases. + """ + + def on_experiment_start(self, logs): + """ + Called at the start of an experiment simulation. + """ + pass + + def on_cycle_start(self, logs): + """ + Called at the start of each cycle in an experiment simulation. + """ + pass + + def on_step_start(self, logs): + """ + Called at the start of each step in an experiment simulation. + """ + pass + + def on_step_end(self, logs): + """ + Called at the end of each step in an experiment simulation. + """ + pass + + def on_cycle_end(self, logs): + """ + Called at the end of each cycle in an experiment simulation. + """ + pass + + def on_experiment_end(self, logs): + """ + Called at the end of an experiment simulation. + """ + pass + + def on_experiment_error(self, logs): + """ + Called when a SolverError occurs during an experiment simulation. + + For example, this could be used to send an error alert with a bug report when + running batch simulations in the cloud. + """ + pass + + def on_experiment_infeasible(self, logs): + """ + Called when an experiment simulation is infeasible. + """ + pass + + +######################################################################################## +class CallbackList(Callback): + """ + Container abstracting a list of callbacks, so that they can be called in a + single step e.g. `callbacks.on_simulation_end(...)`. + + This is done without having to redefine the method each time by using the + `callback_loop_decorator` decorator, which is applied to every method that starts + with `on_`, using the `inspect` module. See + https://stackoverflow.com/questions/1367514/how-to-decorate-a-method-inside-a-class. + + If better control over how the callbacks are called is required, it might be better + to be more explicit with the for loop. + """ + + def __init__(self, callbacks): + self.callbacks = callbacks + + def __len__(self): + return len(self.callbacks) + + def __getitem__(self, index): + return self.callbacks[index] + + +def callback_loop_decorator(func): + """ + A decorator to call the function on every callback in `self.callbacks` + """ + + def wrapper(self, *args, **kwargs): + for callback in self.callbacks: + # call the function on the callback + getattr(callback, func.__name__)(*args, **kwargs) + + return wrapper + + +# inspect.getmembers finds all the methods in the Callback class +for name, func in inspect.getmembers(CallbackList, inspect.isfunction): + if name.startswith("on_"): + # Replaces each function with the decorated version + setattr(CallbackList, name, callback_loop_decorator(func)) + +######################################################################################## + + +class LoggingCallback(Callback): + """ + Logging callback, implements methods to log progress of the simulation. + + Parameters + ---------- + logfile : str, optional + Where to send the log output. If None, uses pybamm's logger. + + **Extends:** :class:`pybamm.callbacks.Callback` + """ + + def __init__(self, logfile=None): + self.logfile = logfile + if logfile is None: + # Use pybamm's logger, which prints to command line + self.logger = pybamm.logger + else: + # Use a custom logger, this will have its own level so set it to the same + # level as the pybamm logger (users can override this) + self.logger = pybamm.get_new_logger(__name__, logfile) + self.logger.setLevel(pybamm.logger.level) + + def on_experiment_start(self, logs): + # Clear the log file + self.logger.info("Start running experiment") + if self.logfile is not None: + with open(self.logfile, "w") as f: + f.write("") + + def on_cycle_start(self, logs): + cycle_num, num_cycles = logs["cycle number"] + total_time = logs["elapsed time"] + self.logger.notice( + f"Cycle {cycle_num}/{num_cycles} ({total_time} elapsed) " + "-" * 20 + ) + + def on_step_start(self, logs): + cycle_num, num_cycles = logs["cycle number"] + step_num, cycle_length = logs["step number"] + operating_conditions = logs["step operating conditions"] + self.logger.notice( + f"Cycle {cycle_num}/{num_cycles}, step {step_num}/{cycle_length}: " + f"{operating_conditions}" + ) + + def on_step_end(self, logs): + pass + + def on_cycle_end(self, logs): + cap_stop = logs["stopping conditions"]["capacity"] + if cap_stop is not None: + cap_now = logs["summary variables"]["Capacity [A.h]"] + cap_start = logs["start capacity"] + if np.isnan(cap_now) or cap_now > cap_stop: + self.logger.notice( + f"Capacity is now {cap_now:.3f} Ah (originally {cap_start:.3f} Ah, " + f"will stop at {cap_stop:.3f} Ah)" + ) + else: + self.logger.notice( + f"Stopping experiment since capacity ({cap_now:.3f} Ah) " + f"is below stopping capacity ({cap_stop:.3f} Ah)." + ) + + voltage_stop = logs["stopping conditions"]["voltage"] + if voltage_stop is not None: + min_voltage = logs["summary variables"]["Minimum voltage [V]"] + if min_voltage > voltage_stop[0]: + self.logger.notice( + f"Minimum voltage is now {min_voltage:.3f} V " + f"(will stop at {voltage_stop[0]:.3f} V)" + ) + else: + self.logger.notice( + f"Stopping experiment since minimum voltage ({min_voltage:.3f} V) " + f"is below stopping voltage ({voltage_stop[0]:.3f} V)." + ) + + def on_experiment_end(self, logs): + elapsed_time = logs["elapsed time"] + self.logger.notice("Finish experiment simulation, took {}".format(elapsed_time)) + + def on_experiment_error(self, logs): + pass + + def on_experiment_infeasible(self, logs): + termination = logs["termination"] + cycle_num = logs["cycle number"][0] + step_num = logs["step number"][0] + operating_conditions = logs["step operating conditions"] + if step_num == 1: + cycle_num -= 1 + up_to_step = "" + else: + up_to_step = f", up to step {step_num-1}" + self.logger.warning( + f"\n\n\tExperiment is infeasible: '{termination}' was " + f"triggered during '{operating_conditions}'. The returned solution only " + f"contains the first {cycle_num} cycles{up_to_step}. " + "Try reducing the current, shortening the time interval, or reducing the " + "period.\n\n" + ) diff --git a/pybamm/logger.py b/pybamm/logger.py index 8c84ebf5da..5e96d5a010 100644 --- a/pybamm/logger.py +++ b/pybamm/logger.py @@ -9,56 +9,50 @@ import logging -def set_logging_level(level): - logger.setLevel(level) +def get_log_level_func(value): + def func(self, message, *args, **kws): + if self.isEnabledFor(value): + self._log(value, message, args, **kws) + return func -format = ( - "%(asctime)s - [%(levelname)s] %(module)s.%(funcName)s(%(lineno)d): " - + "%(message)s" -) -logging.basicConfig(format=format) -logging.Formatter(datefmt="%Y-%m-%d %H:%M:%S", fmt="%(asctime)s.%(msecs)03d") # Additional levels inspired by verboselogs -SPAM_LEVEL_NUM = 5 -logging.addLevelName(SPAM_LEVEL_NUM, "SPAM") - -VERBOSE_LEVEL_NUM = 15 -logging.addLevelName(VERBOSE_LEVEL_NUM, "VERBOSE") - -NOTICE_LEVEL_NUM = 25 -logging.addLevelName(NOTICE_LEVEL_NUM, "NOTICE") +new_levels = {"SPAM": 5, "VERBOSE": 15, "NOTICE": 25, "SUCCESS": 35} +for level, value in new_levels.items(): + logging.addLevelName(value, level) + setattr(logging.Logger, level.lower(), get_log_level_func(value)) -SUCCESS_LEVEL_NUM = 35 -logging.addLevelName(SUCCESS_LEVEL_NUM, "SUCCESS") - - -def spam(self, message, *args, **kws): - if self.isEnabledFor(SPAM_LEVEL_NUM): - self._log(SPAM_LEVEL_NUM, message, args, **kws) +FORMAT = ( + "%(asctime)s.%(msecs)03d - [%(levelname)s] %(module)s.%(funcName)s(%(lineno)d): " + "%(message)s" +) +LOG_FORMATTER = logging.Formatter(datefmt="%Y-%m-%d %H:%M:%S", fmt=FORMAT) -def verbose(self, message, *args, **kws): - if self.isEnabledFor(VERBOSE_LEVEL_NUM): - self._log(VERBOSE_LEVEL_NUM, message, args, **kws) +def set_logging_level(level): + logger.setLevel(level) -def notice(self, message, *args, **kws): - if self.isEnabledFor(NOTICE_LEVEL_NUM): - self._log(NOTICE_LEVEL_NUM, message, args, **kws) +def _get_new_logger(name, filename=None): + new_logger = logging.getLogger(name) + if filename is None: + handler = logging.StreamHandler() + else: + handler = logging.FileHandler(filename) + handler.setFormatter(LOG_FORMATTER) + new_logger.addHandler(handler) + return new_logger -def success(self, message, *args, **kws): - if self.isEnabledFor(SUCCESS_LEVEL_NUM): - self._log(SUCCESS_LEVEL_NUM, message, args, **kws) +# Only the function for getting a new logger with filename not None is exposed +def get_new_logger(name, filename): + if filename is None: + raise ValueError("filename must be specified") + return _get_new_logger(name, filename) -logging.Logger.spam = spam -logging.Logger.verbose = verbose -logging.Logger.notice = notice -logging.Logger.success = success # Create a custom logger -logger = logging.getLogger(__name__) +logger = _get_new_logger(__name__) set_logging_level("WARNING") diff --git a/pybamm/simulation.py b/pybamm/simulation.py index 6242182a46..fc9c7f0f69 100644 --- a/pybamm/simulation.py +++ b/pybamm/simulation.py @@ -536,6 +536,7 @@ def solve( calc_esoh=True, starting_solution=None, initial_soc=None, + callbacks=None, **kwargs, ): """ @@ -580,6 +581,9 @@ def solve( Initial State of Charge (SOC) for the simulation. Must be between 0 and 1. If given, overwrites the initial concentrations provided in the parameter set. + callbacks : list of callbacks, optional + A list of callbacks to be called at each time step. Each callback must + implement all the methods defined in :class:`pybamm.callbacks.BaseCallback`. **kwargs Additional key-word arguments passed to `solver.solve`. See :meth:`pybamm.BaseSolver.solve`. @@ -588,6 +592,9 @@ def solve( if solver is None: solver = self.solver + callbacks = pybamm.callbacks.setup_callbacks(callbacks) + logs = {} + if initial_soc is not None: if self._built_initial_soc != initial_soc: # reset @@ -703,6 +710,7 @@ def solve( self._solution = solver.solve(self.built_model, t_eval, **kwargs) elif self.operating_mode == "with experiment": + callbacks.on_experiment_start(logs) self.build_for_experiment(check_model=check_model) if t_eval is not None: pybamm.logger.warning( @@ -713,7 +721,6 @@ def solve( self._solution = starting_solution # Step through all experimental conditions inputs = kwargs.get("inputs", {}) - pybamm.logger.info("Start running experiment") timer = pybamm.Timer() if starting_solution is None: @@ -745,6 +752,7 @@ def solve( esoh_sim = None voltage_stop = self.experiment.termination.get("voltage") + logs["stopping conditions"] = {"voltage": voltage_stop} idx = 0 num_cycles = len(self.experiment.cycle_lengths) @@ -752,10 +760,13 @@ def solve( for cycle_num, cycle_length in enumerate( self.experiment.cycle_lengths, start=1 ): - pybamm.logger.notice( - f"Cycle {cycle_num+cycle_offset}/{num_cycles+cycle_offset} " - f"({timer.time()} elapsed) " + "-" * 20 + logs["cycle number"] = ( + cycle_num + cycle_offset, + num_cycles + cycle_offset, ) + logs["elapsed time"] = timer.time() + callbacks.on_cycle_start(logs) + steps = [] cycle_solution = None @@ -777,6 +788,8 @@ def solve( ) ) for step_num in range(1, cycle_length + 1): + # Use 1-indexing for printing cycle number as it is more + # human-intuitive exp_inputs = self._experiment_inputs[idx] dt = self._experiment_times[idx] op_conds_str = self.experiment.operating_conditions_strings[idx] @@ -784,12 +797,11 @@ def solve( "electric" ] model = self.op_conds_to_built_models[op_conds_elec] - # Use 1-indexing for printing cycle number as it is more - # human-intuitive - pybamm.logger.notice( - f"Cycle {cycle_num+cycle_offset}/{num_cycles+cycle_offset}, " - f"step {step_num}/{cycle_length}: {op_conds_str}" - ) + + logs["step number"] = (step_num, cycle_length) + logs["step operating conditions"] = op_conds_str + callbacks.on_step_start(logs) + inputs.update(exp_inputs) if current_solution is None: start_time = 0 @@ -799,19 +811,33 @@ def solve( kwargs["inputs"] = inputs # Make sure we take at least 2 timesteps npts = max(int(round(dt / exp_inputs["period"])) + 1, 2) - step_solution = solver.step( - current_solution, - model, - dt, - npts=npts, - save=False, - **kwargs, - ) + try: + step_solution = solver.step( + current_solution, + model, + dt, + npts=npts, + save=False, + **kwargs, + ) + except pybamm.SolverError as e: + logs["error"] = e + callbacks.on_experiment_error(logs) + feasible = False + # If none of the cycles worked, raise an error + if cycle_num == 1 and step_num == 1: + raise e + # Otherwise, just stop this cycle + break + steps.append(step_solution) current_solution = step_solution cycle_solution = cycle_solution + step_solution + callbacks.on_step_end(logs) + + logs["termination"] = step_solution.termination # Only allow events specified by experiment if not ( step_solution is None @@ -824,45 +850,30 @@ def solve( # Increment index for next iteration idx += 1 - # Break if the experiment is infeasible - if feasible is False: - pybamm.logger.warning( - "\n\n\tExperiment is infeasible: '{}' ".format( - step_solution.termination - ) - + "was triggered during '{}'. ".format( - self.experiment.operating_conditions_strings[idx] - ) - + "The returned solution only contains the first " - "{} cycles. ".format(cycle_num - 1 + cycle_offset) - + "Try reducing the current, shortening the time interval, " - "or reducing the period.\n\n" - ) - break - - if save_this_cycle: + if save_this_cycle or feasible is False: self._solution = self._solution + cycle_solution # At the final step of the inner loop we save the cycle - ( - cycle_solution, - cycle_summary_variables, - cycle_first_state, - ) = pybamm.make_cycle_solution( - steps, - esoh_sim, - save_this_cycle=save_this_cycle, - ) - all_cycle_solutions.append(cycle_solution) - all_summary_variables.append(cycle_summary_variables) - all_first_states.append(cycle_first_state) + if len(steps) > 0: + cycle_sol = pybamm.make_cycle_solution( + steps, + esoh_sim, + save_this_cycle=save_this_cycle, + ) + cycle_solution, cycle_sum_vars, cycle_first_state = cycle_sol + all_cycle_solutions.append(cycle_solution) + all_summary_variables.append(cycle_sum_vars) + all_first_states.append(cycle_first_state) + + logs["summary variables"] = cycle_sum_vars # Calculate capacity_start using the first cycle if cycle_num == 1: + # Note capacity_start could be defined as + # self.parameter_values["Nominal cell capacity [A.h]"] instead if "capacity" in self.experiment.termination: - # Note capacity_start could be defined as - # self.parameter_values["Nominal cell capacity [A.h]"] instead capacity_start = all_summary_variables[0]["Capacity [A.h]"] + logs["start capacity"] = capacity_start value, typ = self.experiment.termination["capacity"] if typ == "Ah": capacity_stop = value @@ -870,47 +881,33 @@ def solve( capacity_stop = value / 100 * capacity_start else: capacity_stop = None + logs["stopping conditions"]["capacity"] = capacity_stop + + callbacks.on_cycle_end(logs) + # Break if stopping conditions are met + # Logging is done in the callbacks if capacity_stop is not None: - capacity_now = cycle_summary_variables["Capacity [A.h]"] - if np.isnan(capacity_now) or capacity_now > capacity_stop: - pybamm.logger.notice( - f"Capacity is now {capacity_now:.3f} Ah " - f"(originally {capacity_start:.3f} Ah, " - f"will stop at {capacity_stop:.3f} Ah)" - ) - else: - pybamm.logger.notice( - "Stopping experiment since capacity " - f"({capacity_now:.3f} Ah) " - f"is below stopping capacity ({capacity_stop:.3f} Ah)." - ) + capacity_now = cycle_sum_vars["Capacity [A.h]"] + if not np.isnan(capacity_now) and capacity_now <= capacity_stop: break - # Check voltage stop if voltage_stop is not None: - min_voltage = np.min(cycle_solution["Battery voltage [V]"].data) - if min_voltage > voltage_stop[0]: - pybamm.logger.notice( - f"Minimum voltage is now {min_voltage:.3f} V " - f"(will stop at {voltage_stop[0]:.3f} V)" - ) - else: - pybamm.logger.notice( - "Stopping experiment since minimum voltage " - f"({min_voltage:.3f} V) " - f"is below stopping voltage ({voltage_stop[0]:.3f} V)." - ) + min_voltage = cycle_sum_vars["Minimum voltage [V]"] + if min_voltage <= voltage_stop[0]: break + # Break if the experiment is infeasible (or errored) + if feasible is False: + callbacks.on_experiment_infeasible(logs) + break + if self.solution is not None and len(all_cycle_solutions) > 0: self.solution.cycles = all_cycle_solutions self.solution.set_summary_variables(all_summary_variables) self.solution.all_first_states = all_first_states - pybamm.logger.notice( - "Finish experiment simulation, took {}".format(timer.time()) - ) + callbacks.on_experiment_end(logs) # reset parameter values if initial_soc is not None: diff --git a/pybamm/solvers/solution.py b/pybamm/solvers/solution.py index 13364443cc..01d5acb3c4 100644 --- a/pybamm/solvers/solution.py +++ b/pybamm/solvers/solution.py @@ -671,6 +671,8 @@ def sub_solutions(self): def __add__(self, other): """Adds two solutions together, e.g. when stepping""" + if other is None: + return self.copy() if not isinstance(other, Solution): raise pybamm.SolverError( "Only a Solution or None can be added to a Solution" @@ -826,8 +828,7 @@ def get_cycle_summary_variables(cycle_solution, esoh_sim): # Measured capacity variables if "Discharge capacity [A.h]" in model.variables: Q = cycle_solution["Discharge capacity [A.h]"].data - min_Q = np.min(Q) - max_Q = np.max(Q) + min_Q, max_Q = np.min(Q), np.max(Q) cycle_summary_variables.update( { @@ -837,6 +838,15 @@ def get_cycle_summary_variables(cycle_solution, esoh_sim): } ) + # Voltage variables + if "Battery voltage [V]" in model.variables: + V = cycle_solution["Battery voltage [V]"].data + min_V, max_V = np.min(V), np.max(V) + + cycle_summary_variables.update( + {"Minimum voltage [V]": min_V, "Maximum voltage [V]": max_V} + ) + # Degradation variables degradation_variables = model.summary_variables first_state = cycle_solution.first_state diff --git a/pybamm/util.py b/pybamm/util.py index 93347df7a1..26ff67a21e 100644 --- a/pybamm/util.py +++ b/pybamm/util.py @@ -203,6 +203,9 @@ def __str__(self): output.append("1 second" if time == 1 else str(time) + " seconds") return ", ".join(output) + def __repr__(self): + return f"pybamm.TimerTime({self.value})" + def __add__(self, other): if isinstance(other, numbers.Number): return TimerTime(self.value + other) diff --git a/tests/integration/test_experiments.py b/tests/integration/test_experiments.py index ea72fc052c..235974c723 100644 --- a/tests/integration/test_experiments.py +++ b/tests/integration/test_experiments.py @@ -79,8 +79,7 @@ def test_infeasible(self): ) sol = sim.solve() # this experiment fails during the third cycle (i.e. is infeasible) - # so we should just return the successful cycles (2 in this case) - self.assertEqual(len(sol.cycles), 2) + self.assertEqual(len(sol.cycles), 3) if __name__ == "__main__": diff --git a/tests/unit/test_callbacks.py b/tests/unit/test_callbacks.py new file mode 100644 index 0000000000..209dee3cdd --- /dev/null +++ b/tests/unit/test_callbacks.py @@ -0,0 +1,119 @@ +# +# Tests the citations class. +# +import pybamm +import unittest +import os +from pybamm import callbacks + + +class DummyCallback(callbacks.Callback): + def __init__(self, logs, name): + self.name = name + self.logs = logs + + def on_experiment_end(self, logs): + with open(self.logs, "w") as f: + print(self.name, file=f) + + +class TestCallbacks(unittest.TestCase): + def tearDown(self): + # Remove any test log files that were created, even if the test fails + for logfile in ["test_callback.log", "test_callback_2.log"]: + if os.path.exists(logfile): + try: + os.remove(logfile) + except PermissionError: + # Just skip this if it doesn't work (Windows doesn't allow) + pass + + def test_setup_callbacks(self): + # No callbacks, LoggingCallback should be added + callbacks = pybamm.callbacks.setup_callbacks(None) + self.assertIsInstance(callbacks, pybamm.callbacks.CallbackList) + self.assertEqual(len(callbacks), 1) + self.assertIsInstance(callbacks[0], pybamm.callbacks.LoggingCallback) + + # Single object, transformed to list + callbacks = pybamm.callbacks.setup_callbacks(1) + self.assertIsInstance(callbacks, pybamm.callbacks.CallbackList) + self.assertEqual(len(callbacks), 2) + self.assertEqual(callbacks.callbacks[0], 1) + self.assertIsInstance(callbacks[-1], pybamm.callbacks.LoggingCallback) + + # List + callbacks = pybamm.callbacks.setup_callbacks([1, 2, 3]) + self.assertIsInstance(callbacks, pybamm.callbacks.CallbackList) + self.assertEqual(callbacks.callbacks[:3], [1, 2, 3]) + self.assertIsInstance(callbacks[-1], pybamm.callbacks.LoggingCallback) + + def test_callback_list(self): + "Tests multiple callbacks in a list" + # Should work with empty callback list (does nothiing) + callbacks = pybamm.callbacks.CallbackList([]) + callbacks.on_experiment_end(None) + + # Should work with multiple callbacks + callback = pybamm.callbacks.CallbackList( + [ + DummyCallback("test_callback.log", "first"), + DummyCallback("test_callback_2.log", "second"), + ] + ) + callback.on_experiment_end(None) + with open("test_callback.log", "r") as f: + self.assertEqual(f.read(), "first\n") + with open("test_callback_2.log", "r") as f: + self.assertEqual(f.read(), "second\n") + + def test_logging_callback(self): + # No argument, should use pybamm's logger + callback = pybamm.callbacks.LoggingCallback() + self.assertEqual(callback.logger, pybamm.logger) + + pybamm.set_logging_level("NOTICE") + callback = pybamm.callbacks.LoggingCallback("test_callback.log") + self.assertEqual(callback.logfile, "test_callback.log") + + logs = { + "cycle number": (5, 12), + "step number": (1, 4), + "elapsed time": 0.45, + "step operating conditions": "Charge", + "termination": "event", + } + callback.on_experiment_start(logs) + with open("test_callback.log") as f: + self.assertEqual(f.read(), "") + + callback.on_cycle_start(logs) + with open("test_callback.log", "r") as f: + self.assertIn("Cycle 5/12", f.read()) + + callback.on_step_start(logs) + with open("test_callback.log", "r") as f: + self.assertIn("Cycle 5/12, step 1/4", f.read()) + + callback.on_experiment_infeasible(logs) + with open("test_callback.log", "r") as f: + self.assertIn("Experiment is infeasible: 'event'", f.read()) + + callback.on_experiment_end(logs) + with open("test_callback.log", "r") as f: + self.assertIn("took 0.45", f.read()) + + # Calling start again should clear the log + callback.on_experiment_start(logs) + with open("test_callback.log") as f: + self.assertEqual(f.read(), "") + + +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_experiments/test_simulation_with_experiment.py b/tests/unit/test_experiments/test_simulation_with_experiment.py index 31eedf5880..dd6a01cfe4 100644 --- a/tests/unit/test_experiments/test_simulation_with_experiment.py +++ b/tests/unit/test_experiments/test_simulation_with_experiment.py @@ -88,7 +88,8 @@ def test_run_experiment(self): ) model = pybamm.lithium_ion.DFN() sim = pybamm.Simulation(model, experiment=experiment) - sol = sim.solve() + # test the callback here + sol = sim.solve(callbacks=pybamm.callbacks.Callback()) self.assertEqual(sol.termination, "final time") self.assertEqual(len(sol.cycles), 1) @@ -176,16 +177,50 @@ def test_run_experiment_drive_cycle(self): self.assertIn(("drive_cycle", "V"), sim.op_conds_to_model_and_param) self.assertIn(("drive_cycle", "W"), sim.op_conds_to_model_and_param) - def test_run_experiment_breaks_early(self): + def test_run_experiment_breaks_early_infeasible(self): experiment = pybamm.Experiment(["Discharge at 2 C for 1 hour"]) model = pybamm.lithium_ion.SPM() sim = pybamm.Simulation(model, experiment=experiment) pybamm.set_logging_level("ERROR") # giving the time, should get ignored t_eval = [0, 1] - sim.solve(t_eval, solver=pybamm.CasadiSolver()) + sim.solve( + t_eval, solver=pybamm.CasadiSolver(), callbacks=pybamm.callbacks.Callback() + ) pybamm.set_logging_level("WARNING") - self.assertEqual(sim._solution, None) + self.assertEqual(sim._solution.termination, "event: Minimum voltage") + + def test_run_experiment_breaks_early_error(self): + experiment = pybamm.Experiment( + [("Rest for 10 minutes", "Discharge at 10 C for 1 minute")] + ) + model = pybamm.lithium_ion.DFN() + + parameter_values = pybamm.ParameterValues("Chen2020") + sim = pybamm.Simulation( + model, + experiment=experiment, + parameter_values=parameter_values, + ) + sol = sim.solve() + self.assertEqual(len(sol.cycles), 1) + self.assertEqual(len(sol.cycles[0].steps), 1) + + # Different experiment setup style + experiment = pybamm.Experiment( + ["Rest for 10 minutes", "Discharge at 10 C for 1 minute"] + ) + sim = pybamm.Simulation( + model, + experiment=experiment, + parameter_values=parameter_values, + ) + sol = sim.solve() + self.assertEqual(len(sol.cycles), 1) + self.assertEqual(len(sol.cycles[0].steps), 1) + + # Different callback - this is for coverage on the `Callback` class + sol = sim.solve(callbacks=pybamm.callbacks.Callback()) def test_run_experiment_termination_capacity(self): # with percent @@ -242,7 +277,8 @@ def test_run_experiment_termination_voltage(self): model = pybamm.lithium_ion.SPM() param = pybamm.ParameterValues("Chen2020") sim = pybamm.Simulation(model, experiment=experiment, parameter_values=param) - sol = sim.solve() + # Test with calc_esoh=False here + sol = sim.solve(calc_esoh=False) # Only two cycles should be completed, only 2nd cycle should go below 4V np.testing.assert_array_less( 4, np.min(sol.cycles[0]["Terminal voltage [V]"].data) diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index dd3f183879..5ef581be96 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -30,6 +30,10 @@ def test_logger(self): # reset pybamm.set_logging_level("WARNING") + def test_exceptions(self): + with self.assertRaises(ValueError): + pybamm.get_new_logger("test", None) + if __name__ == "__main__": print("Add -v for more debug output") diff --git a/tests/unit/test_timer.py b/tests/unit/test_timer.py index 0fb48dbee4..2691f00af7 100644 --- a/tests/unit/test_timer.py +++ b/tests/unit/test_timer.py @@ -53,6 +53,8 @@ def test_timer_format(self): "2 weeks, 0 days, 3 hours, 1 minute, 4 seconds", ) + self.assertEqual(repr(pybamm.TimerTime(1.5)), "pybamm.TimerTime(1.5)") + def test_timer_operations(self): self.assertEqual((pybamm.TimerTime(1) + 2).value, 3) self.assertEqual((1 + pybamm.TimerTime(1)).value, 2)