@@ -48,52 +52,59 @@ The diagram below presents PyBOP's conceptual framework. The PyBOP software spec
## Getting Started
-### Prerequisites
-To use and/or contribute to PyBOP, first install Python (3.8-3.11). On a Debian-based distribution, this looks like:
+### Installation
+
+Within your virtual environment, install PyBOP:
```bash
-sudo apt update
-sudo apt install python3 python3-virtualenv
+pip install pybop
```
-For further information, please refer to the similar [installation instructions for PyBaMM](https://docs.pybamm.org/en/latest/source/user_guide/installation/GNU-linux.html).
+To install the most recent state of PyBOP, install from the `develop` branch,
-### Installation
+```bash
+pip install git+https://github.com/pybop-team/PyBOP.git@develop
+```
-Create a virtual environment called `pybop-env` within your current directory:
+To alternatively install PyBOP from a local directory, use the following template, substituting in the relevant path:
```bash
-virtualenv pybop-env
+pip install -e "path/to/pybop"
```
-Activate the environment:
+To check whether PyBOP has been installed correctly, run one of the examples in the following section or the full set of unit tests:
```bash
-source pybop-env/bin/activate
+pytest --unit -v
```
-Later, you can deactivate the environment:
+### Prerequisites
+To use and/or contribute to PyBOP, first install Python (3.8-3.11). On a Debian-based distribution, this looks like:
```bash
-deactivate
+sudo apt update
+sudo apt install python3 python3-virtualenv
```
-Within your virtual environment, install the `develop` branch of PyBOP:
+For further information, please refer to the similar [installation instructions for PyBaMM](https://docs.pybamm.org/en/latest/source/user_guide/installation/GNU-linux.html).
+
+### Virtual Environments
+To create a virtual environment called `pybop-env` within your current directory:
```bash
-pip install git+https://github.com/pybop-team/PyBOP.git@develop
+virtualenv pybop-env
```
-To alternatively install PyBOP from a local directory, use the following template, substituting in the relevant path:
+Activate the environment:
```bash
-pip install -e "PATH_TO_PYBOP"
+source pybop-env/bin/activate
```
-To check whether PyBOP has been installed correctly, run one of the examples in the following section or the full set of unit tests:
+Later, you can deactivate the environment:
```bash
-pytest --unit -v
+deactivate
```
### Using PyBOP
From 0d5f9acd29b5d02b0cc83b0464f769497412ce54 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Tue, 21 Nov 2023 10:25:10 +0000
Subject: [PATCH 014/101] Align badge format
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 85aff7330..ba2f09f00 100644
--- a/README.md
+++ b/README.md
@@ -29,9 +29,9 @@
-
+
-
+
From 736f53718490b23dd9a0f110f35c4128012cc4c2 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Tue, 21 Nov 2023 10:29:45 +0000
Subject: [PATCH 015/101] Remove paragraph style for badges
---
README.md | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/README.md b/README.md
index ba2f09f00..9c57fbe18 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
Python Battery Optimisation and Parameterisation
-
+
@@ -34,7 +34,6 @@
-
From ecf995d8c6f50cc2c19b71bf018e1684e68d0345 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Tue, 21 Nov 2023 14:36:18 +0000
Subject: [PATCH 016/101] Add plot_cost2d with example addition
---
conftest.py | 2 ++
examples/scripts/CMAES.py | 3 ++
pybop/__init__.py | 2 +-
pybop/plotting/plot_cost2D.py | 61 +++++++++++++++++++++++++++++++++++
pybop/plotting/quick_plot.py | 22 -------------
5 files changed, 67 insertions(+), 23 deletions(-)
create mode 100644 pybop/plotting/plot_cost2D.py
delete mode 100644 pybop/plotting/quick_plot.py
diff --git a/conftest.py b/conftest.py
index b37cbd0f5..ddb99602b 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,6 +1,8 @@
import pytest
import matplotlib
+import plotly
+plotly.io.renderers.default = None
matplotlib.use("Template")
diff --git a/examples/scripts/CMAES.py b/examples/scripts/CMAES.py
index 65315b41e..0b46d8f36 100644
--- a/examples/scripts/CMAES.py
+++ b/examples/scripts/CMAES.py
@@ -53,3 +53,6 @@
plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12)
plt.tick_params(axis="both", labelsize=12)
plt.show()
+
+# Plot the cost landscape
+pybop.plot_cost2D(cost, steps=15)
diff --git a/pybop/__init__.py b/pybop/__init__.py
index 29dcd88b1..37bc7a7e6 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -67,7 +67,7 @@
#
# Plotting class
#
-from .plotting.quick_plot import QuickPlot
+from .plotting.plot_cost2D import plot_cost2D
#
# Remove any imported modules, so we don't expose them as part of pybop
diff --git a/pybop/plotting/plot_cost2D.py b/pybop/plotting/plot_cost2D.py
new file mode 100644
index 000000000..6b6ba7fcf
--- /dev/null
+++ b/pybop/plotting/plot_cost2D.py
@@ -0,0 +1,61 @@
+import numpy as np
+
+
+def plot_cost2D(cost, steps=10):
+ """
+ Query the cost landscape for a given parameter space and plot using plotly.
+ """
+
+ # Set up parameter bounds
+ bounds = get_param_bounds(cost)
+
+ # Generate grid
+ x = np.linspace(bounds[0, 0], bounds[0, 1], steps)
+ y = np.linspace(bounds[1, 0], bounds[1, 1], steps)
+
+ # Initialize cost matrix
+ costs = np.zeros((len(x), len(y)))
+
+ # Populate cost matrix
+ for i, xi in enumerate(x):
+ for j, yj in enumerate(y):
+ costs[i, j] = cost([xi, yj])
+
+ # Create figure
+ fig = create_figure(x, y, costs, bounds, cost.problem.parameters)
+
+ # Display figure
+ fig.show()
+
+ return fig
+
+
+def get_param_bounds(cost):
+ """
+ Use parameters bounds for range of cost landscape
+ """
+ bounds = np.empty((len(cost.problem.parameters), 2))
+ for i, param in enumerate(cost.problem.parameters):
+ bounds[i] = param.bounds
+ return bounds
+
+
+def create_figure(x, y, z, bounds, params):
+ # Import plotly only when needed
+ import plotly.graph_objects as go
+
+ fig = go.Figure(data=[go.Contour(x=x, y=y, z=z)])
+ # Set figure properties
+ fig.update_layout(
+ title="Cost Landscape",
+ title_x=0.5,
+ title_y=0.9,
+ xaxis_title=params[0].name,
+ yaxis_title=params[1].name,
+ width=600,
+ height=600,
+ xaxis=dict(range=bounds[0]),
+ yaxis=dict(range=bounds[1]),
+ )
+
+ return fig
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
deleted file mode 100644
index 5acbb2626..000000000
--- a/pybop/plotting/quick_plot.py
+++ /dev/null
@@ -1,22 +0,0 @@
-class QuickPlot:
- """
-
- Class to generate plots with standard variables and formatting.
-
- Plots
- --------------
- Observability
- if method == parameterisation
-
- Comparison of fitting data with optimised forward model
-
- elseif method == optimisation
-
- Pareto front
- Alternative solutions
- Initial value compared to optimal
-
- """
-
- def __init__(self):
- self.name = "Quick Plot"
From 2b06d18ff5dedd33f49bcb9bac503520f422483a Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Tue, 21 Nov 2023 16:20:54 +0000
Subject: [PATCH 017/101] Updt noxfile, setup.py for plotly dependency
---
noxfile.py | 2 +-
setup.py | 4 ++++
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/noxfile.py b/noxfile.py
index c88e483e4..a2099a563 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -6,7 +6,7 @@
@nox.session
def unit(session):
- session.run_always("pip", "install", "-e", ".")
+ session.run_always("pip", "install", "-e", ".[all]")
session.install("pytest")
session.run("pytest", "--unit", "-v")
diff --git a/setup.py b/setup.py
index 4d6b63a65..4591c3842 100644
--- a/setup.py
+++ b/setup.py
@@ -32,6 +32,10 @@
"nlopt>=2.6",
"pints>=0.5",
],
+ extras_require={
+ "plot": ["plotly>=5.0"],
+ "all": ["pybop[plot]"],
+ },
# https://pypi.org/classifiers/
classifiers=[],
python_requires=">=3.8,<=3.12",
From 68d5a4dd87a8e54339d9febcfb1625b087b958d6 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Tue, 21 Nov 2023 16:28:06 +0000
Subject: [PATCH 018/101] Updt remaining noxfile sessions for [plot]
---
noxfile.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/noxfile.py b/noxfile.py
index a2099a563..0640bf866 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -13,7 +13,7 @@ def unit(session):
@nox.session
def coverage(session):
- session.run_always("pip", "install", "-e", ".")
+ session.run_always("pip", "install", "-e", ".[all]")
session.install("pytest-cov")
session.run("pytest", "--unit", "-v", "--cov", "--cov-report=xml")
@@ -21,6 +21,6 @@ def coverage(session):
@nox.session
def notebooks(session):
"""Run the examples tests for Jupyter notebooks."""
- session.run_always("pip", "install", "-e", ".")
+ session.run_always("pip", "install", "-e", ".[all]")
session.install("pytest", "nbmake")
session.run("pytest", "--nbmake", "examples/", external=True)
From 3fcba9329faa3407e2ac580469502a5c47318782 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 22 Nov 2023 12:31:55 +0000
Subject: [PATCH 019/101] Initial CHANGELOG.md
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
create mode 100644 CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..f4155c6e1
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,7 @@
+# [Unreleased](https://github.com/pybop-team/PyBOP)
+
+# [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11)
+ - Initial release
+ - Adds Pints, NLOpt, and SciPy optimisers
+ - Adds SumofSquareError and RootMeanSquareError cost functions
+ - Adds Parameter and dataset classes
From 35212bb07891bee4afcb5ee3e436a57230ddc5b7 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 22 Nov 2023 13:35:11 +0000
Subject: [PATCH 020/101] Add pints' optimisers and corresponding examples
---
examples/scripts/{CMAES.py => spm_CMAES.py} | 2 +-
examples/scripts/spm_IRPropMin.py | 55 ++++++++++++++
examples/scripts/spm_SNES.py | 55 ++++++++++++++
examples/scripts/spm_XNES.py | 55 ++++++++++++++
examples/scripts/spm_adam.py | 59 +++++++++++++++
examples/scripts/spm_pso.py | 55 ++++++++++++++
pybop/__init__.py | 2 +-
pybop/optimisers/pints_optimisers.py | 84 ++++++++++++++++++++-
8 files changed, 363 insertions(+), 4 deletions(-)
rename examples/scripts/{CMAES.py => spm_CMAES.py} (96%)
create mode 100644 examples/scripts/spm_IRPropMin.py
create mode 100644 examples/scripts/spm_SNES.py
create mode 100644 examples/scripts/spm_XNES.py
create mode 100644 examples/scripts/spm_adam.py
create mode 100644 examples/scripts/spm_pso.py
diff --git a/examples/scripts/CMAES.py b/examples/scripts/spm_CMAES.py
similarity index 96%
rename from examples/scripts/CMAES.py
rename to examples/scripts/spm_CMAES.py
index 65315b41e..7f044409e 100644
--- a/examples/scripts/CMAES.py
+++ b/examples/scripts/spm_CMAES.py
@@ -3,7 +3,7 @@
import matplotlib.pyplot as plt
parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
-model = pybop.lithium_ion.SPMe(parameter_set=parameter_set)
+model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
# Fitting parameters
parameters = [
diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py
new file mode 100644
index 000000000..2d4dd2ec4
--- /dev/null
+++ b/examples/scripts/spm_IRPropMin.py
@@ -0,0 +1,55 @@
+import pybop
+import numpy as np
+import matplotlib.pyplot as plt
+
+parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
+
+# Fitting parameters
+parameters = [
+ pybop.Parameter(
+ "Negative electrode active material volume fraction",
+ prior=pybop.Gaussian(0.7, 0.05),
+ bounds=[0.6, 0.9],
+ ),
+ pybop.Parameter(
+ "Positive electrode active material volume fraction",
+ prior=pybop.Gaussian(0.58, 0.05),
+ bounds=[0.5, 0.8],
+ ),
+]
+
+sigma = 0.001
+t_eval = np.arange(0, 900, 2)
+values = model.predict(t_eval=t_eval)
+CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
+ 0, sigma, len(t_eval)
+)
+
+dataset = [
+ pybop.Dataset("Time [s]", t_eval),
+ pybop.Dataset("Current function [A]", values["Current [A]"].data),
+ pybop.Dataset("Terminal voltage [V]", CorruptValues),
+]
+
+# Generate problem, cost function, and optimisation class
+problem = pybop.Problem(model, parameters, dataset)
+cost = pybop.SumSquaredError(problem)
+optim = pybop.Optimisation(cost, optimiser=pybop.IRPropMin)
+optim.set_max_iterations(100)
+
+x, final_cost = optim.run()
+print("Estimated parameters:", x)
+
+# Show the generated data
+simulated_values = problem.evaluate(x)
+
+plt.figure(dpi=100)
+plt.xlabel("Time", fontsize=12)
+plt.ylabel("Values", fontsize=12)
+plt.plot(t_eval, CorruptValues, label="Measured")
+plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2)
+plt.plot(t_eval, simulated_values, label="Simulated")
+plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12)
+plt.tick_params(axis="both", labelsize=12)
+plt.show()
diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py
new file mode 100644
index 000000000..f5db3c9b9
--- /dev/null
+++ b/examples/scripts/spm_SNES.py
@@ -0,0 +1,55 @@
+import pybop
+import numpy as np
+import matplotlib.pyplot as plt
+
+parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
+
+# Fitting parameters
+parameters = [
+ pybop.Parameter(
+ "Negative electrode active material volume fraction",
+ prior=pybop.Gaussian(0.7, 0.05),
+ bounds=[0.6, 0.9],
+ ),
+ pybop.Parameter(
+ "Positive electrode active material volume fraction",
+ prior=pybop.Gaussian(0.58, 0.05),
+ bounds=[0.5, 0.8],
+ ),
+]
+
+sigma = 0.001
+t_eval = np.arange(0, 900, 2)
+values = model.predict(t_eval=t_eval)
+CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
+ 0, sigma, len(t_eval)
+)
+
+dataset = [
+ pybop.Dataset("Time [s]", t_eval),
+ pybop.Dataset("Current function [A]", values["Current [A]"].data),
+ pybop.Dataset("Terminal voltage [V]", CorruptValues),
+]
+
+# Generate problem, cost function, and optimisation class
+problem = pybop.Problem(model, parameters, dataset)
+cost = pybop.SumSquaredError(problem)
+optim = pybop.Optimisation(cost, optimiser=pybop.SNES)
+optim.set_max_iterations(100)
+
+x, final_cost = optim.run()
+print("Estimated parameters:", x)
+
+# Show the generated data
+simulated_values = problem.evaluate(x)
+
+plt.figure(dpi=100)
+plt.xlabel("Time", fontsize=12)
+plt.ylabel("Values", fontsize=12)
+plt.plot(t_eval, CorruptValues, label="Measured")
+plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2)
+plt.plot(t_eval, simulated_values, label="Simulated")
+plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12)
+plt.tick_params(axis="both", labelsize=12)
+plt.show()
diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py
new file mode 100644
index 000000000..37939245f
--- /dev/null
+++ b/examples/scripts/spm_XNES.py
@@ -0,0 +1,55 @@
+import pybop
+import numpy as np
+import matplotlib.pyplot as plt
+
+parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
+
+# Fitting parameters
+parameters = [
+ pybop.Parameter(
+ "Negative electrode active material volume fraction",
+ prior=pybop.Gaussian(0.7, 0.05),
+ bounds=[0.6, 0.9],
+ ),
+ pybop.Parameter(
+ "Positive electrode active material volume fraction",
+ prior=pybop.Gaussian(0.58, 0.05),
+ bounds=[0.5, 0.8],
+ ),
+]
+
+sigma = 0.001
+t_eval = np.arange(0, 900, 2)
+values = model.predict(t_eval=t_eval)
+CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
+ 0, sigma, len(t_eval)
+)
+
+dataset = [
+ pybop.Dataset("Time [s]", t_eval),
+ pybop.Dataset("Current function [A]", values["Current [A]"].data),
+ pybop.Dataset("Terminal voltage [V]", CorruptValues),
+]
+
+# Generate problem, cost function, and optimisation class
+problem = pybop.Problem(model, parameters, dataset)
+cost = pybop.SumSquaredError(problem)
+optim = pybop.Optimisation(cost, optimiser=pybop.XNES)
+optim.set_max_iterations(100)
+
+x, final_cost = optim.run()
+print("Estimated parameters:", x)
+
+# Show the generated data
+simulated_values = problem.evaluate(x)
+
+plt.figure(dpi=100)
+plt.xlabel("Time", fontsize=12)
+plt.ylabel("Values", fontsize=12)
+plt.plot(t_eval, CorruptValues, label="Measured")
+plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2)
+plt.plot(t_eval, simulated_values, label="Simulated")
+plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12)
+plt.tick_params(axis="both", labelsize=12)
+plt.show()
diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py
new file mode 100644
index 000000000..27949e9ac
--- /dev/null
+++ b/examples/scripts/spm_adam.py
@@ -0,0 +1,59 @@
+import pybop
+import numpy as np
+import matplotlib.pyplot as plt
+
+# Parameter set and model definition
+parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+model = pybop.lithium_ion.SPMe(parameter_set=parameter_set)
+
+# Fitting parameters
+parameters = [
+ pybop.Parameter(
+ "Negative electrode active material volume fraction",
+ prior=pybop.Gaussian(0.7, 0.05),
+ bounds=[0.6, 0.9],
+ ),
+ pybop.Parameter(
+ "Positive electrode active material volume fraction",
+ prior=pybop.Gaussian(0.58, 0.05),
+ bounds=[0.5, 0.8],
+ ),
+]
+
+# Generate data
+sigma = 0.001
+t_eval = np.arange(0, 900, 2)
+values = model.predict(t_eval=t_eval)
+corrupt_values = values["Terminal voltage [V]"].data + np.random.normal(
+ 0, sigma, len(t_eval)
+)
+
+# Dataset definition
+dataset = [
+ pybop.Dataset("Time [s]", t_eval),
+ pybop.Dataset("Current function [A]", values["Current [A]"].data),
+ pybop.Dataset("Terminal voltage [V]", corrupt_values),
+]
+
+# Generate problem, cost function, and optimisation class
+problem = pybop.Problem(model, parameters, dataset)
+cost = pybop.SumSquaredError(problem)
+optim = pybop.Optimisation(cost, optimiser=pybop.Adam)
+optim.set_max_iterations(100)
+
+# Run optimisation
+x, final_cost = optim.run()
+print("Estimated parameters:", x)
+
+# Show the generated data
+simulated_values = problem.evaluate(x)
+
+plt.figure(dpi=100)
+plt.xlabel("Time", fontsize=12)
+plt.ylabel("Values", fontsize=12)
+plt.plot(t_eval, corrupt_values, label="Measured")
+plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2)
+plt.plot(t_eval, simulated_values, label="Simulated")
+plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12)
+plt.tick_params(axis="both", labelsize=12)
+plt.show()
diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py
new file mode 100644
index 000000000..9a9cb5aab
--- /dev/null
+++ b/examples/scripts/spm_pso.py
@@ -0,0 +1,55 @@
+import pybop
+import numpy as np
+import matplotlib.pyplot as plt
+
+parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
+
+# Fitting parameters
+parameters = [
+ pybop.Parameter(
+ "Negative electrode active material volume fraction",
+ prior=pybop.Gaussian(0.7, 0.05),
+ bounds=[0.6, 0.9],
+ ),
+ pybop.Parameter(
+ "Positive electrode active material volume fraction",
+ prior=pybop.Gaussian(0.58, 0.05),
+ bounds=[0.5, 0.8],
+ ),
+]
+
+sigma = 0.001
+t_eval = np.arange(0, 900, 2)
+values = model.predict(t_eval=t_eval)
+CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
+ 0, sigma, len(t_eval)
+)
+
+dataset = [
+ pybop.Dataset("Time [s]", t_eval),
+ pybop.Dataset("Current function [A]", values["Current [A]"].data),
+ pybop.Dataset("Terminal voltage [V]", CorruptValues),
+]
+
+# Generate problem, cost function, and optimisation class
+problem = pybop.Problem(model, parameters, dataset)
+cost = pybop.SumSquaredError(problem)
+optim = pybop.Optimisation(cost, optimiser=pybop.PSO)
+optim.set_max_iterations(100)
+
+x, final_cost = optim.run()
+print("Estimated parameters:", x)
+
+# Show the generated data
+simulated_values = problem.evaluate(x)
+
+plt.figure(dpi=100)
+plt.xlabel("Time", fontsize=12)
+plt.ylabel("Values", fontsize=12)
+plt.plot(t_eval, CorruptValues, label="Measured")
+plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2)
+plt.plot(t_eval, simulated_values, label="Simulated")
+plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12)
+plt.tick_params(axis="both", labelsize=12)
+plt.show()
diff --git a/pybop/__init__.py b/pybop/__init__.py
index 29dcd88b1..b4006b3ca 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -50,7 +50,7 @@
from .optimisers.base_optimiser import BaseOptimiser
from .optimisers.nlopt_optimize import NLoptOptimize
from .optimisers.scipy_minimize import SciPyMinimize
-from .optimisers.pints_optimisers import GradientDescent, CMAES
+from .optimisers.pints_optimisers import GradientDescent, Adam, CMAES, IRPropMin, PSO, SNES, XNES
#
# Parameter classes
diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py
index 6524cb607..741c66512 100644
--- a/pybop/optimisers/pints_optimisers.py
+++ b/pybop/optimisers/pints_optimisers.py
@@ -4,19 +4,99 @@
class GradientDescent(pints.GradientDescent):
"""
Gradient descent optimiser. Inherits from the PINTS gradient descent class.
+ https://github.com/pints-team/pints/blob/main/pints/_optimisers/_gradient_descent.py
"""
def __init__(self, x0, sigma0=0.1, bounds=None):
if bounds is not None:
print("Boundaries ignored by GradientDescent")
- boundaries = None # Bounds ignored in pints.GradDesc
- super().__init__(x0, sigma0, boundaries)
+ self.boundaries = None # Bounds ignored in pints.GradDesc
+ super().__init__(x0, sigma0, self.boundaries)
+
+
+class Adam(pints.Adam):
+ """
+ Adam optimiser. Inherits from the PINTS Adam class.
+ https://github.com/pints-team/pints/blob/main/pints/_optimisers/_adam.py
+ """
+
+ def __init__(self, x0, sigma0=0.1, bounds=None):
+ if bounds is not None:
+ print("Boundaries ignored by Adam")
+
+ self.boundaries = None # Bounds ignored in pints.Adam
+ super().__init__(x0, sigma0, self.boundaries)
+
+
+class IRPropMin(pints.IRPropMin):
+ """
+ IRProp- optimiser. Inherits from the PINTS IRPropMinus class.
+ https://github.com/pints-team/pints/blob/main/pints/_optimisers/_irpropmin.py
+ """
+
+ def __init__(self, x0, sigma0=0.1, bounds=None):
+ if bounds is not None:
+ self.boundaries = pints.RectangularBoundaries(
+ bounds["lower"], bounds["upper"]
+ )
+ else:
+ self.boundaries = None
+ super().__init__(x0, sigma0, self.boundaries)
+
+
+class PSO(pints.PSO):
+ """
+ Particle swarm optimiser. Inherits from the PINTS PSO class.
+ https://github.com/pints-team/pints/blob/main/pints/_optimisers/_pso.py
+ """
+
+ def __init__(self, x0, sigma0=0.1, bounds=None):
+ if bounds is not None:
+ self.boundaries = pints.RectangularBoundaries(
+ bounds["lower"], bounds["upper"]
+ )
+ else:
+ self.boundaries = None
+ super().__init__(x0, sigma0, self.boundaries)
+
+
+class SNES(pints.SNES):
+ """
+ Stochastic natural evolution strategy optimiser. Inherits from the PINTS SNES class.
+ https://github.com/pints-team/pints/blob/main/pints/_optimisers/_snes.py
+ """
+
+ def __init__(self, x0, sigma0=0.1, bounds=None):
+ if bounds is not None:
+ self.boundaries = pints.RectangularBoundaries(
+ bounds["lower"], bounds["upper"]
+ )
+ else:
+ self.boundaries = None
+ super().__init__(x0, sigma0, self.boundaries)
+
+
+class XNES(pints.XNES):
+ """
+ Exponential natural evolution strategy optimiser. Inherits from the PINTS XNES class.
+ https://github.com/pints-team/pints/blob/main/pints/_optimisers/_xnes.py
+ """
+
+ def __init__(self, x0, sigma0=0.1, bounds=None):
+ if bounds is not None:
+ self.boundaries = pints.RectangularBoundaries(
+ bounds["lower"], bounds["upper"]
+ )
+ else:
+ self.boundaries = None
+ super().__init__(x0, sigma0, self.boundaries)
class CMAES(pints.CMAES):
"""
Class for the PINTS optimisation. Extends the BaseOptimiser class.
+ https://github.com/pints-team/pints/blob/main/pints/_optimisers/_cmaes.py
"""
def __init__(self, x0, sigma0=0.1, bounds=None):
From 0ea2a1c9a2f6be17b0b6c719abf6007e852823bb Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 22 Nov 2023 13:51:28 +0000
Subject: [PATCH 021/101] Updt Changelog
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4155c6e1..7c094508f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
# [Unreleased](https://github.com/pybop-team/PyBOP)
+ - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class
+
# [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11)
- Initial release
- Adds Pints, NLOpt, and SciPy optimisers
From 95945ce92db86fbccc642ff2bec1292cfce4c809 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 22 Nov 2023 14:20:34 +0000
Subject: [PATCH 022/101] Add pytest --examples marker, updt. tests for
addition optimisers, rm multi-soc from test_spm_optimisers + updt. to SPM
---
conftest.py | 18 ++++++++++----
pybop/__init__.py | 10 +++++++-
tests/unit/test_examples.py | 2 +-
tests/unit/test_parameterisations.py | 37 +++++++++++++++++++++++-----
4 files changed, 54 insertions(+), 13 deletions(-)
diff --git a/conftest.py b/conftest.py
index b37cbd0f5..3b3e24424 100644
--- a/conftest.py
+++ b/conftest.py
@@ -12,13 +12,21 @@ def pytest_addoption(parser):
def pytest_configure(config):
config.addinivalue_line("markers", "unit: mark test as a unit test")
+ config.addinivalue_line("markers", "examples: mark test as an example")
def pytest_collection_modifyitems(config, items):
+ def skip_marker(marker_name, reason):
+ skip = pytest.mark.skip(reason=reason)
+ for item in items:
+ if marker_name in item.keywords:
+ item.add_marker(skip)
+
if config.getoption("--unit"):
- # --unit given in cli: do not skip unit tests
+ skip_marker("examples", "need --examples option to run")
+ return
+
+ if config.getoption("--examples"):
return
- skip_unit = pytest.mark.skip(reason="need --unit option to run")
- for item in items:
- if "unit" in item.keywords:
- item.add_marker(skip_unit)
+
+ skip_marker("unit", "need --unit option to run")
diff --git a/pybop/__init__.py b/pybop/__init__.py
index b4006b3ca..0e933b8e2 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -50,7 +50,15 @@
from .optimisers.base_optimiser import BaseOptimiser
from .optimisers.nlopt_optimize import NLoptOptimize
from .optimisers.scipy_minimize import SciPyMinimize
-from .optimisers.pints_optimisers import GradientDescent, Adam, CMAES, IRPropMin, PSO, SNES, XNES
+from .optimisers.pints_optimisers import (
+ GradientDescent,
+ Adam,
+ CMAES,
+ IRPropMin,
+ PSO,
+ SNES,
+ XNES,
+)
#
# Parameter classes
diff --git a/tests/unit/test_examples.py b/tests/unit/test_examples.py
index 6e8fc09e0..dffa084e6 100644
--- a/tests/unit/test_examples.py
+++ b/tests/unit/test_examples.py
@@ -9,7 +9,7 @@ class TestExamples:
A class to test the example scripts.
"""
- @pytest.mark.unit
+ @pytest.mark.examples
def test_example_scripts(self):
path_to_example_scripts = os.path.join(
pybop.script_path, "..", "examples", "scripts"
diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py
index 142e590d0..c18100638 100644
--- a/tests/unit/test_parameterisations.py
+++ b/tests/unit/test_parameterisations.py
@@ -62,12 +62,12 @@ def test_spm(self, init_soc):
np.testing.assert_allclose(final_cost, 0, atol=1e-2)
np.testing.assert_allclose(x, x0, atol=1e-1)
- @pytest.mark.parametrize("init_soc", [0.3, 0.7])
+ @pytest.mark.parametrize("init_soc", [0.5])
@pytest.mark.unit
- def test_spme_optimisers(self, init_soc):
+ def test_spm_optimisers(self, init_soc):
# Define model
parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
- model = pybop.lithium_ion.SPMe(parameter_set=parameter_set)
+ model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
# Form dataset
x0 = np.array([0.52, 0.63])
@@ -100,16 +100,26 @@ def test_spme_optimisers(self, init_soc):
problem = pybop.Problem(
model, parameters, dataset, signal=signal, init_soc=init_soc
)
- cost = pybop.RootMeanSquaredError(problem)
+ cost = pybop.SumSquaredError(problem)
# Select optimisers
- optimisers = [pybop.NLoptOptimize, pybop.SciPyMinimize, pybop.CMAES]
+ optimisers = [
+ pybop.NLoptOptimize,
+ pybop.SciPyMinimize,
+ pybop.CMAES,
+ pybop.Adam,
+ pybop.GradientDescent,
+ pybop.PSO,
+ pybop.XNES,
+ pybop.SNES,
+ pybop.IRPropMin,
+ ]
# Test each optimiser
for optimiser in optimisers:
parameterisation = pybop.Optimisation(cost=cost, optimiser=optimiser)
- if optimiser == pybop.CMAES:
+ if optimiser in [pybop.CMAES]:
parameterisation.set_f_guessed_tracking(True)
assert parameterisation._use_f_guessed is True
parameterisation.set_max_iterations(1)
@@ -121,6 +131,21 @@ def test_spme_optimisers(self, init_soc):
x, final_cost = parameterisation.run()
assert parameterisation._max_iterations == 250
+ elif optimiser in [pybop.GradientDescent]:
+ parameterisation.optimiser.set_learning_rate(0.025)
+ parameterisation.set_max_iterations(250)
+ x, final_cost = parameterisation.run()
+
+ elif optimiser in [
+ pybop.PSO,
+ pybop.XNES,
+ pybop.SNES,
+ pybop.Adam,
+ pybop.IRPropMin,
+ ]:
+ parameterisation.set_max_iterations(250)
+ x, final_cost = parameterisation.run()
+
else:
x, final_cost = parameterisation.run()
From e0053c802aa6ad1df80a7550c694f66413030ee3 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 22 Nov 2023 14:47:44 +0000
Subject: [PATCH 023/101] Updt noxfile w/ pytest --show-locals log output
argument
---
noxfile.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/noxfile.py b/noxfile.py
index c88e483e4..b775c34cb 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -8,14 +8,14 @@
def unit(session):
session.run_always("pip", "install", "-e", ".")
session.install("pytest")
- session.run("pytest", "--unit", "-v")
+ session.run("pytest", "--unit", "-v", "--showlocals")
@nox.session
def coverage(session):
session.run_always("pip", "install", "-e", ".")
session.install("pytest-cov")
- session.run("pytest", "--unit", "-v", "--cov", "--cov-report=xml")
+ session.run("pytest", "--unit", "-v", "--cov", "--cov-report=xml", "--showlocals")
@nox.session
From 94a561252abe405498be95896f25778b2d99bcde Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 22 Nov 2023 15:37:55 +0000
Subject: [PATCH 024/101] Add SPMe test for coverage
---
tests/unit/test_models.py | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py
index ce73000a6..6925391de 100644
--- a/tests/unit/test_models.py
+++ b/tests/unit/test_models.py
@@ -34,7 +34,7 @@ def test_predict_without_pybamm(self):
@pytest.mark.unit
def test_predict_with_inputs(self):
- # Define model
+ # Define SPM
model = pybop.lithium_ion.SPM()
t_eval = np.linspace(0, 10, 100)
inputs = {
@@ -45,6 +45,11 @@ def test_predict_with_inputs(self):
res = model.predict(t_eval=t_eval, inputs=inputs)
assert len(res["Terminal voltage [V]"].data) == 100
+ # Define SPMe
+ model = pybop.lithium_ion.SPMe()
+ res = model.predict(t_eval=t_eval, inputs=inputs)
+ assert len(res["Terminal voltage [V]"].data) == 100
+
@pytest.mark.unit
def test_build(self):
model = pybop.lithium_ion.SPM()
From 95bb2beba0e55675c0aa9d07461858f51883a587 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 22 Nov 2023 15:54:49 +0000
Subject: [PATCH 025/101] Updt. pytest markers and logic, add examples to
noxfile cov
---
conftest.py | 4 ++++
noxfile.py | 10 +++++++++-
2 files changed, 13 insertions(+), 1 deletion(-)
diff --git a/conftest.py b/conftest.py
index 3b3e24424..8aa4af3a8 100644
--- a/conftest.py
+++ b/conftest.py
@@ -8,6 +8,9 @@ def pytest_addoption(parser):
parser.addoption(
"--unit", action="store_true", default=False, help="run unit tests"
)
+ parser.addoption(
+ "--examples", action="store_true", default=False, help="run examples tests"
+ )
def pytest_configure(config):
@@ -27,6 +30,7 @@ def skip_marker(marker_name, reason):
return
if config.getoption("--examples"):
+ skip_marker("unit", "need --unit option to run")
return
skip_marker("unit", "need --unit option to run")
diff --git a/noxfile.py b/noxfile.py
index b775c34cb..195703d63 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -15,7 +15,15 @@ def unit(session):
def coverage(session):
session.run_always("pip", "install", "-e", ".")
session.install("pytest-cov")
- session.run("pytest", "--unit", "-v", "--cov", "--cov-report=xml", "--showlocals")
+ session.run(
+ "pytest",
+ "--unit",
+ "--examples",
+ "-v",
+ "--cov",
+ "--cov-report=xml",
+ "--showlocals",
+ )
@nox.session
From d88430977b706bf80aeb100741eebbb46af42aa0 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 23 Nov 2023 10:28:10 +0000
Subject: [PATCH 026/101] Refactor optimisation tests, add logic tests for new
optimisers w/ bounds=None
---
tests/unit/test_optimisation.py | 152 ++++++++++++++------------------
1 file changed, 65 insertions(+), 87 deletions(-)
diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py
index 5bbb4998b..21ecf16ac 100644
--- a/tests/unit/test_optimisation.py
+++ b/tests/unit/test_optimisation.py
@@ -9,58 +9,17 @@ class TestOptimisation:
A class to test the optimisation class.
"""
- @pytest.mark.unit
- def test_standalone(self):
- # Build an Optimisation problem with a StandaloneCost
- cost = StandaloneCost()
-
- opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize)
-
- assert len(opt.x0) == opt.n_parameters
-
- x, final_cost = opt.run()
-
- np.testing.assert_allclose(x, 0, atol=1e-2)
- np.testing.assert_allclose(final_cost, 42, atol=1e-2)
-
- @pytest.mark.unit
- def test_prior_sampling(self):
- # Tests prior sampling
- model = pybop.lithium_ion.SPM()
-
- dataset = [
- pybop.Dataset("Time [s]", np.linspace(0, 3600, 100)),
- pybop.Dataset("Current function [A]", np.zeros(100)),
- pybop.Dataset("Terminal voltage [V]", np.ones(100)),
- ]
-
- param = [
- pybop.Parameter(
- "Negative electrode active material volume fraction",
- prior=pybop.Gaussian(0.75, 0.2),
- bounds=[0.73, 0.77],
- )
- ]
-
- signal = "Terminal voltage [V]"
- problem = pybop.Problem(model, param, dataset, signal=signal)
- cost = pybop.RootMeanSquaredError(problem)
-
- for i in range(50):
- opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize)
-
- assert opt.x0 <= 0.77 and opt.x0 >= 0.73
-
- @pytest.mark.unit
- def test_optimiser_construction(self):
- # Tests construction of optimisers
-
- dataset = [
+ @pytest.fixture
+ def dataset(self):
+ return [
pybop.Dataset("Time [s]", np.linspace(0, 360, 10)),
pybop.Dataset("Current function [A]", np.zeros(10)),
pybop.Dataset("Terminal voltage [V]", np.ones(10)),
]
- parameters = [
+
+ @pytest.fixture
+ def parameters(self):
+ return [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.75, 0.2),
@@ -68,69 +27,88 @@ def test_optimiser_construction(self):
)
]
- problem = pybop.Problem(
+ @pytest.fixture
+ def problem(self, parameters, dataset):
+ return pybop.Problem(
pybop.lithium_ion.SPM(), parameters, dataset, signal="Terminal voltage [V]"
)
- cost = pybop.SumSquaredError(problem)
- # Test construction of optimisers
- # NLopt
- opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize)
- assert opt.optimiser is not None
- assert opt.optimiser.name == "NLoptOptimize"
- assert opt.optimiser.n_param == 1
+ @pytest.fixture
+ def cost(self, problem):
+ return pybop.SumSquaredError(problem)
+
+ @pytest.mark.parametrize(
+ "optimiser_class, expected_name",
+ [
+ (pybop.NLoptOptimize, "NLoptOptimize"),
+ (pybop.SciPyMinimize, "SciPyMinimize"),
+ (pybop.GradientDescent, "Gradient descent"),
+ (pybop.Adam, "Adam"),
+ (pybop.CMAES, "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)"),
+ (pybop.SNES, "Seperable Natural Evolution Strategy (SNES)"),
+ (pybop.XNES, "Exponential Natural Evolution Strategy (xNES)"),
+ (pybop.PSO, "Particle Swarm Optimisation (PSO)"),
+ (pybop.IRPropMin, "iRprop-"),
+ ],
+ )
+ @pytest.mark.unit
+ def test_optimiser_classes(self, cost, optimiser_class, expected_name):
+ if optimiser_class not in [pybop.NLoptOptimize, pybop.SciPyMinimize]:
+ cost.bounds = None
+ opt = pybop.Optimisation(cost=cost, optimiser=optimiser_class)
+ assert opt.optimiser.boundaries is None
+ assert opt.optimiser.name() == expected_name
+ else:
+ opt = pybop.Optimisation(cost=cost, optimiser=optimiser_class)
+ assert opt.optimiser.name == expected_name
- # Gradient Descent
- opt = pybop.Optimisation(cost=cost, optimiser=pybop.GradientDescent)
assert opt.optimiser is not None
+ if optimiser_class == pybop.NLoptOptimize:
+ assert opt.optimiser.n_param == 1
- # None
+ @pytest.mark.unit
+ def test_default_optimiser_with_bounds(self, cost):
opt = pybop.Optimisation(cost=cost)
- assert opt.optimiser is not None
assert (
opt.optimiser.name()
== "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)"
)
- # None with no bounds
+ @pytest.mark.unit
+ def test_default_optimiser_no_bounds(self, cost):
cost.bounds = None
opt = pybop.Optimisation(cost=cost)
assert opt.optimiser.boundaries is None
- # SciPy
- opt = pybop.Optimisation(cost=cost, optimiser=pybop.SciPyMinimize)
- assert opt.optimiser is not None
- assert opt.optimiser.name == "SciPyMinimize"
-
- # Incorrect class
- class randomclass:
+ @pytest.mark.unit
+ def test_incorrect_optimiser_class(self, cost):
+ class RandomClass:
pass
with pytest.raises(ValueError):
- pybop.Optimisation(cost=cost, optimiser=randomclass)
+ pybop.Optimisation(cost=cost, optimiser=RandomClass)
@pytest.mark.unit
- def test_halting(self):
- # Tests halting criteria
- model = pybop.lithium_ion.SPM()
-
- dataset = [
- pybop.Dataset("Time [s]", np.linspace(0, 3600, 100)),
- pybop.Dataset("Current function [A]", np.zeros(100)),
- pybop.Dataset("Terminal voltage [V]", np.ones(100)),
- ]
+ def test_standalone(self):
+ # Build an Optimisation problem with a StandaloneCost
+ cost = StandaloneCost()
+ opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize)
+ x, final_cost = opt.run()
- param = [
- pybop.Parameter(
- "Negative electrode active material volume fraction",
- prior=pybop.Gaussian(0.75, 0.2),
- bounds=[0.73, 0.77],
- )
- ]
+ assert len(opt.x0) == opt.n_parameters
+ np.testing.assert_allclose(x, 0, atol=1e-2)
+ np.testing.assert_allclose(final_cost, 42, atol=1e-2)
+
+ @pytest.mark.unit
+ def test_prior_sampling(self, cost):
+ # Tests prior sampling
+ for i in range(50):
+ opt = pybop.Optimisation(cost=cost, optimiser=pybop.NLoptOptimize)
- problem = pybop.Problem(model, param, dataset, signal="Terminal voltage [V]")
- cost = pybop.SumSquaredError(problem)
+ assert opt.x0 <= 0.77 and opt.x0 >= 0.73
+ @pytest.mark.unit
+ def test_halting(self, cost):
# Test max evalutions
optim = pybop.Optimisation(cost=cost, optimiser=pybop.GradientDescent)
optim.set_max_evaluations(10)
From c2e32f07fcfc9ecf6e002c3236991ad26ad5912c Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 23 Nov 2023 12:55:59 +0000
Subject: [PATCH 027/101] Add quick_plot()
---
examples/scripts/CMAES.py | 17 ++------
pybop/__init__.py | 1 +
pybop/_problem.py | 6 +++
pybop/plotting/quick_plot.py | 85 ++++++++++++++++++++++++++++++++++++
4 files changed, 95 insertions(+), 14 deletions(-)
create mode 100644 pybop/plotting/quick_plot.py
diff --git a/examples/scripts/CMAES.py b/examples/scripts/CMAES.py
index 0b46d8f36..120d6978b 100644
--- a/examples/scripts/CMAES.py
+++ b/examples/scripts/CMAES.py
@@ -1,6 +1,5 @@
import pybop
import numpy as np
-import matplotlib.pyplot as plt
parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
model = pybop.lithium_ion.SPMe(parameter_set=parameter_set)
@@ -19,7 +18,7 @@
),
]
-sigma = 0.001
+sigma = 0.01
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
@@ -41,18 +40,8 @@
x, final_cost = optim.run()
print("Estimated parameters:", x)
-# Show the generated data
-simulated_values = problem.evaluate(x)
-
-plt.figure(dpi=100)
-plt.xlabel("Time", fontsize=12)
-plt.ylabel("Values", fontsize=12)
-plt.plot(t_eval, CorruptValues, label="Measured")
-plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2)
-plt.plot(t_eval, simulated_values, label="Simulated")
-plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12)
-plt.tick_params(axis="both", labelsize=12)
-plt.show()
+# Plot the timeseries output
+pybop.quick_plot(x, cost)
# Plot the cost landscape
pybop.plot_cost2D(cost, steps=15)
diff --git a/pybop/__init__.py b/pybop/__init__.py
index 37bc7a7e6..b9e5c8dfd 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -68,6 +68,7 @@
# Plotting class
#
from .plotting.plot_cost2D import plot_cost2D
+from .plotting.quick_plot import quick_plot
#
# Remove any imported modules, so we don't expose them as part of pybop
diff --git a/pybop/_problem.py b/pybop/_problem.py
index 469b65047..d625b4eab 100644
--- a/pybop/_problem.py
+++ b/pybop/_problem.py
@@ -95,3 +95,9 @@ def evaluateS1(self, parameters):
)
return (np.asarray(y), np.asarray(dy))
+
+ def target(self):
+ """
+ Returns the target dataset.
+ """
+ return self._target
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
new file mode 100644
index 000000000..83cb828d1
--- /dev/null
+++ b/pybop/plotting/quick_plot.py
@@ -0,0 +1,85 @@
+import numpy as np
+
+
+def quick_plot(params, cost, width=720, height=540):
+ """
+ Plot the target dataset against the minimised model output.
+
+ Inputs:
+ -------
+ x: array
+ Optimised parameters
+ cost: cost object
+ Cost object containing the problem, dataset, and signal
+ """
+
+ # Generate the model output
+ x = cost.problem._dataset["Time [s]"].data
+ y = cost.problem.evaluate(params)
+ y2 = cost.problem.target()
+
+ # Create figure
+ fig = create_figure(x, y, y2, cost, width, height)
+
+ # Display figure
+ fig.show()
+
+ return fig
+
+
+def create_figure(x, y, y2, cost, width, height):
+ # Import plotly only when needed
+ import plotly.graph_objs as go
+
+ # Estimate the uncertainty (sigma) of the model output
+ x = x.tolist()
+ sigma = np.std(y - y2)
+ y_upper = (y + sigma).tolist()
+ y_lower = (y - sigma).tolist()
+
+ # Create traces for the measured and simulated values
+ target_trace = go.Scatter(
+ x=x,
+ y=y2,
+ line=dict(color="rgb(102,102,255,0.1)"),
+ mode="markers",
+ name="Target",
+ )
+ simulated_trace = go.Scatter(
+ x=x, y=y, line=dict(width=4, color="rgb(255,128,0)"), mode="lines", name="Model"
+ )
+
+ # Create a trace for the fill area representing the uncertainty (sigma)
+ fill_trace = go.Scatter(
+ x=x + x[::-1],
+ y=y_upper + y_lower[::-1],
+ fill="toself",
+ fillcolor="rgba(255,229,204,0.8)",
+ line=dict(color="rgba(255,255,255,0)"),
+ hoverinfo="skip",
+ showlegend=False,
+ )
+
+ # Define the layout for the plot
+ layout = go.Layout(
+ title="Optimised Comparison",
+ title_x=0.55,
+ title_y=0.9,
+ xaxis=dict(title="Time [s]", titlefont_size=12, tickfont_size=12),
+ yaxis=dict(title=cost.problem.signal, titlefont_size=12, tickfont_size=12),
+ legend=dict(x=0.85, y=1, xanchor="left", yanchor="top", font_size=12),
+ showlegend=True,
+ )
+
+ # Combine the traces and layout into a figure
+ fig = go.Figure(data=[fill_trace, target_trace, simulated_trace], layout=layout)
+
+ # Update the figure to adjust the layout and axis properties
+ fig.update_layout(
+ autosize=False,
+ width=width,
+ height=height,
+ margin=dict(l=10, r=10, b=10, t=75, pad=4),
+ )
+
+ return fig
From c2575e8eed4bfe70908bdaf9bc11e23a2b481115 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 23 Nov 2023 14:17:13 +0000
Subject: [PATCH 028/101] Add optimiser trace to plot_cost2d, restore plotly
theme to quick_plot
---
examples/scripts/CMAES.py | 5 ++-
pybop/__init__.py | 2 +-
.../{plot_cost2D.py => plot_cost2d.py} | 43 +++++++++++++++++--
pybop/plotting/quick_plot.py | 4 +-
4 files changed, 47 insertions(+), 7 deletions(-)
rename pybop/plotting/{plot_cost2D.py => plot_cost2d.py} (53%)
diff --git a/examples/scripts/CMAES.py b/examples/scripts/CMAES.py
index 120d6978b..5b5a6c639 100644
--- a/examples/scripts/CMAES.py
+++ b/examples/scripts/CMAES.py
@@ -44,4 +44,7 @@
pybop.quick_plot(x, cost)
# Plot the cost landscape
-pybop.plot_cost2D(cost, steps=15)
+pybop.plot_cost2d(cost, steps=15)
+
+# Plot the cost landscape with optimisation path
+pybop.plot_cost2d(cost, optim=optim, steps=15)
diff --git a/pybop/__init__.py b/pybop/__init__.py
index b9e5c8dfd..c11433e48 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -67,7 +67,7 @@
#
# Plotting class
#
-from .plotting.plot_cost2D import plot_cost2D
+from .plotting.plot_cost2d import plot_cost2d
from .plotting.quick_plot import quick_plot
#
diff --git a/pybop/plotting/plot_cost2D.py b/pybop/plotting/plot_cost2d.py
similarity index 53%
rename from pybop/plotting/plot_cost2D.py
rename to pybop/plotting/plot_cost2d.py
index 6b6ba7fcf..fec15fd77 100644
--- a/pybop/plotting/plot_cost2D.py
+++ b/pybop/plotting/plot_cost2d.py
@@ -1,7 +1,7 @@
import numpy as np
-def plot_cost2D(cost, steps=10):
+def plot_cost2d(cost, optim=None, steps=10):
"""
Query the cost landscape for a given parameter space and plot using plotly.
"""
@@ -22,7 +22,7 @@ def plot_cost2D(cost, steps=10):
costs[i, j] = cost([xi, yj])
# Create figure
- fig = create_figure(x, y, costs, bounds, cost.problem.parameters)
+ fig = create_figure(x, y, costs, bounds, cost.problem.parameters, optim)
# Display figure
fig.show()
@@ -40,11 +40,48 @@ def get_param_bounds(cost):
return bounds
-def create_figure(x, y, z, bounds, params):
+def create_figure(x, y, z, bounds, params, optim):
# Import plotly only when needed
import plotly.graph_objects as go
fig = go.Figure(data=[go.Contour(x=x, y=y, z=z)])
+ if optim is not None:
+ optim_trace = np.array([item for sublist in optim.log for item in sublist])
+ optim_trace = optim_trace.reshape(-1, 2)
+
+ # Plot initial guess
+ fig.add_trace(
+ go.Scatter(
+ x=[optim.x0[0]],
+ y=[optim.x0[1]],
+ mode="markers",
+ marker_symbol="x",
+ marker=dict(
+ color="red",
+ line_color="midnightblue",
+ line_width=1,
+ size=12,
+ showscale=False,
+ ),
+ showlegend=False,
+ )
+ )
+
+ # Plot optimisation trace
+ fig.add_trace(
+ go.Scatter(
+ x=optim_trace[0:-1, 0],
+ y=optim_trace[0:-1, 1],
+ mode="markers",
+ marker=dict(
+ color=[i / len(optim_trace) for i in range(len(optim_trace))],
+ colorscale="YlOrBr",
+ showscale=False,
+ ),
+ showlegend=False,
+ )
+ )
+
# Set figure properties
fig.update_layout(
title="Cost Landscape",
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index 83cb828d1..f3a03a54e 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -41,12 +41,12 @@ def create_figure(x, y, y2, cost, width, height):
target_trace = go.Scatter(
x=x,
y=y2,
- line=dict(color="rgb(102,102,255,0.1)"),
mode="markers",
name="Target",
)
+
simulated_trace = go.Scatter(
- x=x, y=y, line=dict(width=4, color="rgb(255,128,0)"), mode="lines", name="Model"
+ x=x, y=y, line=dict(width=4), mode="lines", name="Model"
)
# Create a trace for the fill area representing the uncertainty (sigma)
From 5f284808cbfc6a2aece944176986e8fd98671e3a Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 23 Nov 2023 16:58:31 +0000
Subject: [PATCH 029/101] Add convergence_plot(), refactor plotting methods
into StandardPlot() class, Updt example
---
examples/scripts/CMAES.py | 9 +-
pybop/__init__.py | 3 +-
pybop/plotting/plot_convergence.py | 53 ++++++
pybop/plotting/quick_plot.py | 276 +++++++++++++++++++++--------
4 files changed, 267 insertions(+), 74 deletions(-)
create mode 100644 pybop/plotting/plot_convergence.py
diff --git a/examples/scripts/CMAES.py b/examples/scripts/CMAES.py
index 5b5a6c639..50e0fc0d0 100644
--- a/examples/scripts/CMAES.py
+++ b/examples/scripts/CMAES.py
@@ -1,6 +1,7 @@
import pybop
import numpy as np
+# Define model
parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
model = pybop.lithium_ion.SPMe(parameter_set=parameter_set)
@@ -18,6 +19,7 @@
),
]
+# Generate data
sigma = 0.01
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
@@ -25,6 +27,7 @@
0, sigma, len(t_eval)
)
+# Form dataset for optimisation
dataset = [
pybop.Dataset("Time [s]", t_eval),
pybop.Dataset("Current function [A]", values["Current [A]"].data),
@@ -37,11 +40,15 @@
optim = pybop.Optimisation(cost, optimiser=pybop.CMAES)
optim.set_max_iterations(100)
+# Run the optimisation
x, final_cost = optim.run()
print("Estimated parameters:", x)
# Plot the timeseries output
-pybop.quick_plot(x, cost)
+pybop.quick_plot(x, cost, title="Optimised Comparison")
+
+# Plot convergence
+pybop.plot_convergence(optim)
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
diff --git a/pybop/__init__.py b/pybop/__init__.py
index c11433e48..281609456 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -68,7 +68,8 @@
# Plotting class
#
from .plotting.plot_cost2d import plot_cost2d
-from .plotting.quick_plot import quick_plot
+from .plotting.quick_plot import StandardPlot, quick_plot
+from .plotting.plot_convergence import plot_convergence
#
# Remove any imported modules, so we don't expose them as part of pybop
diff --git a/pybop/plotting/plot_convergence.py b/pybop/plotting/plot_convergence.py
new file mode 100644
index 000000000..d3653dee9
--- /dev/null
+++ b/pybop/plotting/plot_convergence.py
@@ -0,0 +1,53 @@
+import pybop
+
+
+def plot_convergence(
+ optim, xaxis_title="Iteration", yaxis_title="Cost", title="Convergence"
+):
+ """
+ Plot the convergence of the optimisation algorithm.
+
+ Parameters:
+ ----------
+ optim : optimisation object
+ Optimisation object containing the cost function and optimiser.
+ xaxis_title : str, optional
+ Title for the x-axis (default is "Iteration").
+ yaxis_title : str, optional
+ Title for the y-axis (default is "Cost").
+ title : str, optional
+ Title of the plot (default is "Convergence").
+
+ Returns:
+ -------
+ fig : plotly.graph_objs.Figure
+ The Plotly figure object for the convergence plot.
+ """
+
+ # Extract the cost function from the optimisation object
+ cost_function = optim.cost
+
+ # Compute the maximum cost for each iteration
+ max_cost_per_iteration = [
+ max(cost_function(solution) for solution in log_entry)
+ for log_entry in optim.log
+ ]
+
+ # Generate a list of iteration numbers
+ iteration_numbers = list(range(1, len(max_cost_per_iteration) + 1))
+
+ # Create the convergence plot using the StandardPlot class
+ fig = pybop.StandardPlot(
+ x=iteration_numbers,
+ y=max_cost_per_iteration,
+ cost=cost_function,
+ xaxis_title=xaxis_title,
+ yaxis_title=yaxis_title,
+ title=title,
+ trace_name=optim.optimiser.name(),
+ )()
+
+ # Display the figure
+ fig.show()
+
+ return fig
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index f3a03a54e..4436c425f 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -1,85 +1,217 @@
import numpy as np
+import textwrap
+import pybop
+import plotly.graph_objs as go
-def quick_plot(params, cost, width=720, height=540):
+class StandardPlot:
+ """
+ A class for creating and displaying a plotly figure that compares a target dataset against a simulated model output.
+
+ This class provides an interface for generating interactive plots using Plotly, with the ability to include an
+ optional secondary dataset and visualize uncertainty if provided.
+
+ Attributes:
+ -----------
+ x : list
+ The x-axis data points.
+ y : list or np.ndarray
+ The primary y-axis data points representing the simulated model output.
+ y2 : list or np.ndarray, optional
+ An optional secondary y-axis data points representing the target dataset against which the model output is compared.
+ cost : float
+ The cost associated with the model output.
+ title : str, optional
+ The title of the plot.
+ xaxis_title : str, optional
+ The title for the x-axis.
+ yaxis_title : str, optional
+ The title for the y-axis.
+ trace_name : str, optional
+ The name of the primary trace representing the model output. Defaults to "Simulated".
+ width : int, optional
+ The width of the figure in pixels. Defaults to 720.
+ height : int, optional
+ The height of the figure in pixels. Defaults to 540.
+
+ Methods:
+ --------
+ wrap_text(text, width)
+ A static method that wraps text to a specified width, inserting HTML line breaks for use in plot labels.
+
+ create_layout()
+ Creates the layout for the plot, including titles and axis labels.
+
+ create_traces()
+ Creates the traces for the plot, including the primary dataset, optional secondary dataset, and an optional uncertainty visualization.
+
+ __call__()
+ Generates the plotly figure when the class instance is called as a function.
+
+ Example:
+ --------
+ >>> x_data = [1, 2, 3, 4]
+ >>> y_simulated = [10, 15, 13, 17]
+ >>> y_target = [11, 14, 12, 16]
+ >>> plot = pybop.StandardPlot(x_data, y_simulated, cost=0.05, y2=y_target,
+ title="Model vs. Target", xaxis_title="X Axis", yaxis_title="Y Axis")
+ >>> fig = plot() # Generate the figure
+ >>> fig.show() # Display the figure in a browser
+ """
+
+ def __init__(
+ self,
+ x,
+ y,
+ cost,
+ y2=None,
+ title=None,
+ xaxis_title=None,
+ yaxis_title=None,
+ trace_name=None,
+ width=720,
+ height=540,
+ ):
+ self.x = x if isinstance(x, list) else x.tolist()
+ self.y = y
+ self.y2 = y2
+ self.cost = cost
+ self.width = width
+ self.height = height
+ self.title = title
+ self.xaxis_title = xaxis_title
+ self.yaxis_title = yaxis_title
+ self.trace_name = trace_name or "Simulated"
+
+ if self.y2 is not None:
+ self.sigma = np.std(self.y - self.y2)
+ self.y_upper = (self.y + self.sigma).tolist()
+ self.y_lower = (self.y - self.sigma).tolist()
+
+ @staticmethod
+ def wrap_text(text, width):
+ """
+ Wrap text to a specified width.
+
+ Parameters:
+ -----------
+ text: str
+ Text to be wrapped.
+ width: int
+ Width to wrap text to.
+
+ Returns:
+ --------
+ str
+ Wrapped text with HTML line breaks.
+ """
+ wrapped_text = textwrap.fill(text, width=width, break_long_words=False)
+ return wrapped_text.replace("\n", " ")
+
+ def create_layout(self):
+ """
+ Create the layout for the plot.
+ """
+ return go.Layout(
+ title=self.title,
+ title_x=0.5,
+ xaxis=dict(title=self.xaxis_title, titlefont_size=12, tickfont_size=12),
+ yaxis=dict(title=self.yaxis_title, titlefont_size=12, tickfont_size=12),
+ legend=dict(x=1, y=1, xanchor="right", yanchor="top", font_size=12),
+ showlegend=True,
+ autosize=False,
+ width=self.width,
+ height=self.height,
+ margin=dict(l=10, r=10, b=10, t=75, pad=4),
+ )
+
+ def create_traces(self):
+ """
+ Create the traces for the plot.
+ """
+ traces = []
+
+ wrapped_trace_name = self.wrap_text(self.trace_name, width=40)
+ simulated_trace = go.Scatter(
+ x=self.x,
+ y=self.y,
+ line=dict(width=4),
+ mode="lines",
+ name=wrapped_trace_name,
+ )
+
+ if self.y2 is not None:
+ target_trace = go.Scatter(
+ x=self.x, y=self.y2, mode="markers", name="Target"
+ )
+ fill_trace = go.Scatter(
+ x=self.x + self.x[::-1],
+ y=self.y_upper + self.y_lower[::-1],
+ fill="toself",
+ fillcolor="rgba(255,229,204,0.8)",
+ line=dict(color="rgba(255,255,255,0)"),
+ hoverinfo="skip",
+ showlegend=False,
+ )
+ traces.extend([fill_trace, target_trace])
+
+ traces.append(simulated_trace)
+
+ return traces
+
+ def __call__(self):
+ """
+ Generate the plotly figure.
+ """
+ layout = self.create_layout()
+ traces = self.create_traces()
+ fig = go.Figure(data=traces, layout=layout)
+ return fig
+
+
+def quick_plot(params, cost, title="Scatter Plot", width=720, height=540):
"""
Plot the target dataset against the minimised model output.
- Inputs:
+ Parameters:
+ ----------
+ params : array-like
+ Optimised parameters.
+ cost : cost object
+ Cost object containing the problem, dataset, and signal.
+ title : str, optional
+ Title of the plot (default is "Scatter Plot").
+ width : int, optional
+ Width of the figure in pixels (default is 720).
+ height : int, optional
+ Height of the figure in pixels (default is 540).
+
+ Returns:
-------
- x: array
- Optimised parameters
- cost: cost object
- Cost object containing the problem, dataset, and signal
+ fig : plotly.graph_objs.Figure
+ The Plotly figure object for the scatter plot.
"""
- # Generate the model output
- x = cost.problem._dataset["Time [s]"].data
- y = cost.problem.evaluate(params)
- y2 = cost.problem.target()
-
- # Create figure
- fig = create_figure(x, y, y2, cost, width, height)
-
- # Display figure
- fig.show()
-
- return fig
-
-
-def create_figure(x, y, y2, cost, width, height):
- # Import plotly only when needed
- import plotly.graph_objs as go
-
- # Estimate the uncertainty (sigma) of the model output
- x = x.tolist()
- sigma = np.std(y - y2)
- y_upper = (y + sigma).tolist()
- y_lower = (y - sigma).tolist()
-
- # Create traces for the measured and simulated values
- target_trace = go.Scatter(
- x=x,
- y=y2,
- mode="markers",
- name="Target",
- )
-
- simulated_trace = go.Scatter(
- x=x, y=y, line=dict(width=4), mode="lines", name="Model"
- )
-
- # Create a trace for the fill area representing the uncertainty (sigma)
- fill_trace = go.Scatter(
- x=x + x[::-1],
- y=y_upper + y_lower[::-1],
- fill="toself",
- fillcolor="rgba(255,229,204,0.8)",
- line=dict(color="rgba(255,255,255,0)"),
- hoverinfo="skip",
- showlegend=False,
- )
-
- # Define the layout for the plot
- layout = go.Layout(
- title="Optimised Comparison",
- title_x=0.55,
- title_y=0.9,
- xaxis=dict(title="Time [s]", titlefont_size=12, tickfont_size=12),
- yaxis=dict(title=cost.problem.signal, titlefont_size=12, tickfont_size=12),
- legend=dict(x=0.85, y=1, xanchor="left", yanchor="top", font_size=12),
- showlegend=True,
- )
-
- # Combine the traces and layout into a figure
- fig = go.Figure(data=[fill_trace, target_trace, simulated_trace], layout=layout)
-
- # Update the figure to adjust the layout and axis properties
- fig.update_layout(
- autosize=False,
+ # Extract the time data and evaluate the model's output and target values
+ time_data = cost.problem._dataset["Time [s]"].data
+ model_output = cost.problem.evaluate(params)
+ target_output = cost.problem.target()
+
+ # Create the figure using the StandardPlot class
+ fig = pybop.StandardPlot(
+ x=time_data,
+ y=model_output,
+ cost=cost,
+ y2=target_output,
+ xaxis_title="Time [s]",
+ yaxis_title=cost.problem.signal,
+ title=title,
+ trace_name="Model",
width=width,
height=height,
- margin=dict(l=10, r=10, b=10, t=75, pad=4),
- )
+ )()
+
+ # Display the figure
+ fig.show()
return fig
From bef09c97355e1dc114f9e2a2d6649f2181597cf3 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 23 Nov 2023 17:33:07 +0000
Subject: [PATCH 030/101] Revert to plotly import on init, Updt. changelog
---
CHANGELOG.md | 14 ++++++++++----
pybop/plotting/quick_plot.py | 19 +++++++++++++------
2 files changed, 23 insertions(+), 10 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4155c6e1..5c71341cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,13 @@
# [Unreleased](https://github.com/pybop-team/PyBOP)
+## Features
+- [#114](https://github.com/pybop-team/PyBOP/issues/114) Adds standard plotting class `pybop.StandardPlot()` via plotly backend
+- [#114](https://github.com/pybop-team/PyBOP/issues/114) Adds `quick_plot()`, `plot_convergence()`, and `plot_cost2d()` methods
+
+## Bug Fixes
+
# [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11)
- - Initial release
- - Adds Pints, NLOpt, and SciPy optimisers
- - Adds SumofSquareError and RootMeanSquareError cost functions
- - Adds Parameter and dataset classes
+- Initial release
+- Adds Pints, NLOpt, and SciPy optimisers
+- Adds SumofSquareError and RootMeanSquareError cost functions
+- Adds Parameter and dataset classes
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index 4436c425f..aeca775c6 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -1,7 +1,6 @@
import numpy as np
import textwrap
import pybop
-import plotly.graph_objs as go
class StandardPlot:
@@ -88,6 +87,14 @@ def __init__(
self.y_upper = (self.y + self.sigma).tolist()
self.y_lower = (self.y - self.sigma).tolist()
+ # Attempt to import plotly when an instance is created
+ try:
+ import plotly.graph_objs as go
+
+ self.go = go
+ except ImportError as e:
+ raise ImportError(f"Plotly is required for this class to work: {e}")
+
@staticmethod
def wrap_text(text, width):
"""
@@ -112,7 +119,7 @@ def create_layout(self):
"""
Create the layout for the plot.
"""
- return go.Layout(
+ return self.go.Layout(
title=self.title,
title_x=0.5,
xaxis=dict(title=self.xaxis_title, titlefont_size=12, tickfont_size=12),
@@ -132,7 +139,7 @@ def create_traces(self):
traces = []
wrapped_trace_name = self.wrap_text(self.trace_name, width=40)
- simulated_trace = go.Scatter(
+ simulated_trace = self.go.Scatter(
x=self.x,
y=self.y,
line=dict(width=4),
@@ -141,10 +148,10 @@ def create_traces(self):
)
if self.y2 is not None:
- target_trace = go.Scatter(
+ target_trace = self.go.Scatter(
x=self.x, y=self.y2, mode="markers", name="Target"
)
- fill_trace = go.Scatter(
+ fill_trace = self.go.Scatter(
x=self.x + self.x[::-1],
y=self.y_upper + self.y_lower[::-1],
fill="toself",
@@ -165,7 +172,7 @@ def __call__(self):
"""
layout = self.create_layout()
traces = self.create_traces()
- fig = go.Figure(data=traces, layout=layout)
+ fig = self.go.Figure(data=traces, layout=layout)
return fig
From eee379d05c68ad5406030a6496fb4a7ac5e4ea10 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 23 Nov 2023 19:05:07 +0000
Subject: [PATCH 031/101] Updt. changelog
---
CHANGELOG.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c094508f..c5780d436 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,9 @@
# [Unreleased](https://github.com/pybop-team/PyBOP)
- - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class
+- [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class
# [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11)
- - Initial release
- - Adds Pints, NLOpt, and SciPy optimisers
- - Adds SumofSquareError and RootMeanSquareError cost functions
- - Adds Parameter and dataset classes
+- Initial release
+- Adds Pints, NLOpt, and SciPy optimisers
+- Adds SumofSquareError and RootMeanSquareError cost functions
+- Adds Parameter and dataset classes
From 42325aa64467282c33f19386f6d714a722388ce1 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Fri, 24 Nov 2023 14:22:50 +0000
Subject: [PATCH 032/101] Add plot_parameters() method, bugfix pytest flags,
updt. examples
---
conftest.py | 24 ++--
examples/scripts/spm_CMAES.py | 3 +
examples/scripts/spm_IRPropMin.py | 3 +
examples/scripts/spm_SNES.py | 3 +
examples/scripts/spm_XNES.py | 3 +
examples/scripts/spm_adam.py | 3 +
examples/scripts/spm_descent.py | 3 +
examples/scripts/spm_nlopt.py | 3 +
examples/scripts/spm_pso.py | 3 +
pybop/__init__.py | 1 +
pybop/optimisers/pints_optimisers.py | 4 +-
pybop/plotting/plot_parameters.py | 164 +++++++++++++++++++++++++++
pybop/plotting/quick_plot.py | 6 +-
13 files changed, 205 insertions(+), 18 deletions(-)
create mode 100644 pybop/plotting/plot_parameters.py
diff --git a/conftest.py b/conftest.py
index 17285bbc6..768c5a3e3 100644
--- a/conftest.py
+++ b/conftest.py
@@ -21,18 +21,16 @@ def pytest_configure(config):
def pytest_collection_modifyitems(config, items):
- def skip_marker(marker_name, reason):
- skip = pytest.mark.skip(reason=reason)
+ if config.getoption("--unit") and not config.getoption("--examples"):
+ skip_examples = pytest.mark.skip(
+ reason="need --examples option to run examples tests"
+ )
for item in items:
- if marker_name in item.keywords:
- item.add_marker(skip)
+ if "examples" in item.keywords:
+ item.add_marker(skip_examples)
- if config.getoption("--unit"):
- skip_marker("examples", "need --examples option to run")
- return
-
- if config.getoption("--examples"):
- skip_marker("unit", "need --unit option to run")
- return
-
- skip_marker("unit", "need --unit option to run")
+ if config.getoption("--examples") and not config.getoption("--unit"):
+ skip_unit = pytest.mark.skip(reason="need --unit option to run unit tests")
+ for item in items:
+ if "unit" in item.keywords:
+ item.add_marker(skip_unit)
diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py
index f672b139e..f260fdd94 100644
--- a/examples/scripts/spm_CMAES.py
+++ b/examples/scripts/spm_CMAES.py
@@ -50,6 +50,9 @@
# Plot convergence
pybop.plot_convergence(optim)
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py
index e3c1911bf..fcc554b2a 100644
--- a/examples/scripts/spm_IRPropMin.py
+++ b/examples/scripts/spm_IRPropMin.py
@@ -47,6 +47,9 @@
# Plot convergence
pybop.plot_convergence(optim)
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py
index ffefeefe8..10e2491b5 100644
--- a/examples/scripts/spm_SNES.py
+++ b/examples/scripts/spm_SNES.py
@@ -47,6 +47,9 @@
# Plot convergence
pybop.plot_convergence(optim)
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py
index fe2d0db44..8bd14bd7c 100644
--- a/examples/scripts/spm_XNES.py
+++ b/examples/scripts/spm_XNES.py
@@ -47,6 +47,9 @@
# Plot convergence
pybop.plot_convergence(optim)
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py
index e682e1ccf..5b1b879cf 100644
--- a/examples/scripts/spm_adam.py
+++ b/examples/scripts/spm_adam.py
@@ -50,6 +50,9 @@
# Plot convergence
pybop.plot_convergence(optim)
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py
index 3a7ff1e49..4f1d496d1 100644
--- a/examples/scripts/spm_descent.py
+++ b/examples/scripts/spm_descent.py
@@ -51,6 +51,9 @@
# Plot convergence
pybop.plot_convergence(optim)
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py
index 4869789ca..67097d14b 100644
--- a/examples/scripts/spm_nlopt.py
+++ b/examples/scripts/spm_nlopt.py
@@ -46,6 +46,9 @@
# Plot convergence
pybop.plot_convergence(optim)
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py
index 6dc341fbf..3a66e9020 100644
--- a/examples/scripts/spm_pso.py
+++ b/examples/scripts/spm_pso.py
@@ -47,6 +47,9 @@
# Plot convergence
pybop.plot_convergence(optim)
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
diff --git a/pybop/__init__.py b/pybop/__init__.py
index aac75a20c..48ff40894 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -78,6 +78,7 @@
from .plotting.plot_cost2d import plot_cost2d
from .plotting.quick_plot import StandardPlot, quick_plot
from .plotting.plot_convergence import plot_convergence
+from .plotting.plot_parameters import plot_parameters
#
# Remove any imported modules, so we don't expose them as part of pybop
diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py
index 741c66512..f8e17790e 100644
--- a/pybop/optimisers/pints_optimisers.py
+++ b/pybop/optimisers/pints_optimisers.py
@@ -9,7 +9,7 @@ class GradientDescent(pints.GradientDescent):
def __init__(self, x0, sigma0=0.1, bounds=None):
if bounds is not None:
- print("Boundaries ignored by GradientDescent")
+ print("NOTE: Boundaries ignored by Gradient Descent")
self.boundaries = None # Bounds ignored in pints.GradDesc
super().__init__(x0, sigma0, self.boundaries)
@@ -23,7 +23,7 @@ class Adam(pints.Adam):
def __init__(self, x0, sigma0=0.1, bounds=None):
if bounds is not None:
- print("Boundaries ignored by Adam")
+ print("NOTE: Boundaries ignored by Adam")
self.boundaries = None # Bounds ignored in pints.Adam
super().__init__(x0, sigma0, self.boundaries)
diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py
new file mode 100644
index 000000000..4cdee0bdb
--- /dev/null
+++ b/pybop/plotting/plot_parameters.py
@@ -0,0 +1,164 @@
+import pybop
+import math
+import plotly.graph_objects as go
+from plotly.subplots import make_subplots
+
+
+def plot_parameters(
+ optim, xaxis_titles="Iteration", yaxis_titles=None, title="Convergence"
+):
+ """
+ Plot the evolution of the parameters during the optimisation process.
+
+ Parameters:
+ ----------
+ optim : optimisation object
+ An object representing the optimisation process, which should contain
+ information about the cost function, optimiser, and the history of the
+ parameter values throughout the iterations.
+ xaxis_title : str, optional
+ Title for the x-axis, representing the iteration number or a similar
+ discrete time step in the optimisation process (default is "Iteration").
+ yaxis_title : str, optional
+ Title for the y-axis, which typically represents the metric being
+ optimised, such as cost or loss (default is "Cost").
+ title : str, optional
+ Title of the plot, which provides an overall description of what the
+ plot represents (default is "Convergence").
+
+ Returns:
+ -------
+ fig : plotly.graph_objs.Figure
+ The Plotly figure object for the plot depicting how the parameters of
+ the optimisation algorithm evolve over its course. This can be useful
+ for diagnosing the behaviour of the optimisation algorithm.
+
+ Notes:
+ -----
+ The function assumes that the 'optim' object has a 'cost.problem.parameters'
+ attribute containing the parameters of the optimisation algorithm and a 'log'
+ attribute containing a history of the iterations.
+ """
+
+ if optim.optimiser.name() in ["NLoptOptimize", "SciPyMinimize"]:
+ print("Parameter plot not yet supported for this optimiser.")
+ return
+
+ # Extract parameters from the optimisation object
+ params = optim.cost.problem.parameters
+
+ # Create the traces from the optimisation log
+ traces = create_traces(params, optim.log)
+
+ # Create the axis titles
+ axis_titles = []
+ for param in params:
+ axis_titles.append(("Function Call", param.name))
+
+ # Create the figure
+ fig = create_subplots_with_traces(traces, axis_titles=axis_titles)
+
+ # Display the figure
+ fig.show()
+
+ return fig
+
+
+def create_traces(params, trace_data, x_values=None):
+ """
+ Generate a list of Plotly Scatter trace objects from provided trace data.
+
+ This function assumes that each column in the `trace_data` represents a separate trace to be plotted,
+ and that the `params` list contains objects with a `name` attribute used for trace names.
+ Text wrapping for trace names is performed by `pybop.StandardPlot.wrap_text`.
+
+ Parameters:
+ - params (list): A list of objects, where each object has a `name` attribute used as the trace name.
+ The list should have the same length as the number of traces in `trace_data`.
+ - trace_data (list of lists): A 2D list where each inner list represents y-values for a trace.
+ - x_values (list, optional): A list of x-values to be used for all traces. If not provided, a
+ range of integers starting from 0 will be used.
+
+ Returns:
+ - list: A list of Plotly `go.Scatter` objects, each representing a trace to be plotted.
+
+ Notes:
+ - The function depends on `pybop.StandardPlot.wrap_text` for text wrapping, which needs to be available
+ in the execution context.
+ - The function assumes that `go` from `plotly.graph_objs` is already imported as `go`.
+ """
+
+ traces = []
+
+ # If x_values are not provided:
+ if x_values is None:
+ x_values = list(range(len(trace_data[0]) * len(trace_data)))
+
+ # Determine the number of elements in the smallest arrays
+ num_elements = len(trace_data[0][0])
+
+ # Initialize a list of lists to store our columns
+ columns = [[] for _ in range(num_elements)]
+
+ # Loop through each numpy array in trace_data
+ for array in trace_data:
+ # Loop through each item (which is a n-element array) in the numpy array
+ for item in array:
+ # Loop through each element in the item and append to the corresponding column
+ for i in range(num_elements):
+ columns[i].append(item[i])
+
+ # Create a trace for each column
+ for i in range(len(columns)):
+ wrap_param = pybop.StandardPlot.wrap_text(params[i].name, width=50)
+ traces.append(go.Scatter(x=x_values, y=columns[i], name=wrap_param))
+
+ return traces
+
+
+def create_subplots_with_traces(
+ traces,
+ plot_size=(1024, 576),
+ title="Parameter Convergence",
+ axis_titles=None,
+ **layout_kwargs,
+):
+ """
+ Creates a subplot figure with the given traces.
+
+ :param traces: List of plotly.graph_objs traces that will be added to the subplots.
+ :param plot_size: Tuple (width, height) representing the desired size of the plot.
+ :param title: The main title of the subplot figure.
+ :param axis_titles: List of tuples for axis titles in the form [(x_title, y_title), ...] for each subplot.
+ :param layout_kwargs: Additional keyword arguments to be passed to fig.update_layout for custom layout.
+ :return: A plotly figure object with the subplots.
+ """
+ num_traces = len(traces)
+ num_cols = int(math.ceil(math.sqrt(num_traces)))
+ num_rows = int(math.ceil(num_traces / num_cols))
+
+ fig = make_subplots(rows=num_rows, cols=num_cols, start_cell="bottom-left")
+
+ for idx, trace in enumerate(traces):
+ row = (idx // num_cols) + 1
+ col = (idx % num_cols) + 1
+ fig.add_trace(trace, row=row, col=col)
+
+ if axis_titles and idx < len(axis_titles):
+ x_title, y_title = axis_titles[idx]
+ fig.update_xaxes(title_text=x_title, row=row, col=col)
+ fig.update_yaxes(title_text=y_title, row=row, col=col)
+
+ if plot_size:
+ layout_kwargs["width"], layout_kwargs["height"] = plot_size
+
+ if title:
+ layout_kwargs["title_text"] = title
+
+ # Set the legend above the subplots
+ fig.update_layout(
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
+ **layout_kwargs,
+ )
+
+ return fig
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index aeca775c6..5d2048901 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -68,8 +68,8 @@ def __init__(
xaxis_title=None,
yaxis_title=None,
trace_name=None,
- width=720,
- height=540,
+ width=1024,
+ height=576,
):
self.x = x if isinstance(x, list) else x.tolist()
self.y = y
@@ -176,7 +176,7 @@ def __call__(self):
return fig
-def quick_plot(params, cost, title="Scatter Plot", width=720, height=540):
+def quick_plot(params, cost, title="Scatter Plot", width=1024, height=576):
"""
Plot the target dataset against the minimised model output.
From c381224bcf3d6abc6e0f210b5696078bba1b6b8b Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Tue, 28 Nov 2023 17:15:20 +0000
Subject: [PATCH 033/101] Adds initial thevenin model and example, breaks
signal definition
---
examples/scripts/ecm_CMAES.py | 55 ++++++++++++++++++++++++++++++
pybop/__init__.py | 1 +
pybop/_problem.py | 2 +-
pybop/models/empirical/__init__.py | 4 +++
pybop/models/empirical/base_ecm.py | 48 ++++++++++++++++++++++++++
5 files changed, 109 insertions(+), 1 deletion(-)
create mode 100644 examples/scripts/ecm_CMAES.py
create mode 100644 pybop/models/empirical/__init__.py
create mode 100644 pybop/models/empirical/base_ecm.py
diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py
new file mode 100644
index 000000000..fb550eb95
--- /dev/null
+++ b/examples/scripts/ecm_CMAES.py
@@ -0,0 +1,55 @@
+import pybop
+import numpy as np
+import matplotlib.pyplot as plt
+
+# parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+model = pybop.empirical.Thevenin()
+
+# Fitting parameters
+parameters = [
+ pybop.Parameter(
+ "R0 [Ohm]",
+ prior=pybop.Gaussian(0.001, 0.0001),
+ bounds=[1e-5, 1e-2],
+ ),
+ pybop.Parameter(
+ "R1 [Ohm]",
+ prior=pybop.Gaussian(0.001, 0.0001),
+ bounds=[1e-5, 1e-2],
+ ),
+]
+
+sigma = 0.001
+t_eval = np.arange(0, 900, 2)
+values = model.predict(t_eval=t_eval)
+CorruptValues = values["Battery voltage [V]"].data + np.random.normal(
+ 0, sigma, len(t_eval)
+)
+
+dataset = [
+ pybop.Dataset("Time [s]", t_eval),
+ pybop.Dataset("Current function [A]", values["Current [A]"].data),
+ pybop.Dataset("Battery voltage [V]", CorruptValues),
+]
+
+# Generate problem, cost function, and optimisation class
+problem = pybop.Problem(model, parameters, dataset)
+cost = pybop.SumSquaredError(problem)
+optim = pybop.Optimisation(cost, optimiser=pybop.CMAES)
+optim.set_max_iterations(100)
+
+x, final_cost = optim.run()
+print("Estimated parameters:", x)
+
+# Show the generated data
+simulated_values = problem.evaluate(x)
+
+plt.figure(dpi=100)
+plt.xlabel("Time", fontsize=12)
+plt.ylabel("Values", fontsize=12)
+plt.plot(t_eval, CorruptValues, label="Measured")
+plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2)
+plt.plot(t_eval, simulated_values, label="Simulated")
+plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12)
+plt.tick_params(axis="both", labelsize=12)
+plt.show()
diff --git a/pybop/__init__.py b/pybop/__init__.py
index 0e933b8e2..75287bf5c 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -38,6 +38,7 @@
#
from .models.base_model import BaseModel
from .models import lithium_ion
+from .models import empirical
#
# Main optimisation class
diff --git a/pybop/_problem.py b/pybop/_problem.py
index 469b65047..c21f77881 100644
--- a/pybop/_problem.py
+++ b/pybop/_problem.py
@@ -11,7 +11,7 @@ def __init__(
model,
parameters,
dataset,
- signal="Terminal voltage [V]",
+ signal="Battery voltage [V]",
check_model=True,
init_soc=None,
x0=None,
diff --git a/pybop/models/empirical/__init__.py b/pybop/models/empirical/__init__.py
new file mode 100644
index 000000000..7f57d913d
--- /dev/null
+++ b/pybop/models/empirical/__init__.py
@@ -0,0 +1,4 @@
+#
+# Import lithium ion based models
+#
+from .base_ecm import Thevenin
diff --git a/pybop/models/empirical/base_ecm.py b/pybop/models/empirical/base_ecm.py
new file mode 100644
index 000000000..8874c23a1
--- /dev/null
+++ b/pybop/models/empirical/base_ecm.py
@@ -0,0 +1,48 @@
+import pybamm
+from ..base_model import BaseModel
+
+
+class Thevenin(BaseModel):
+ """
+ Composition of the PyBaMM Single Particle Model class.
+
+ """
+
+ def __init__(
+ self,
+ name="Equivalent Circuit Thevenin Model",
+ parameter_set=None,
+ geometry=None,
+ submesh_types=None,
+ var_pts=None,
+ spatial_methods=None,
+ solver=None,
+ options=None,
+ **kwargs,
+ ):
+ super().__init__()
+ self.pybamm_model = pybamm.equivalent_circuit.Thevenin(
+ options=options, **kwargs
+ )
+ self._unprocessed_model = self.pybamm_model
+ self.name = name
+
+ self.default_parameter_values = self.pybamm_model.default_parameter_values
+ self._parameter_set = (
+ parameter_set or self.pybamm_model.default_parameter_values
+ )
+ self._unprocessed_parameter_set = self._parameter_set
+
+ self.geometry = geometry or self.pybamm_model.default_geometry
+ self.submesh_types = submesh_types or self.pybamm_model.default_submesh_types
+ self.var_pts = var_pts or self.pybamm_model.default_var_pts
+ self.spatial_methods = (
+ spatial_methods or self.pybamm_model.default_spatial_methods
+ )
+ self.solver = solver or self.pybamm_model.default_solver
+
+ self._model_with_set_params = None
+ self._built_model = None
+ self._built_initial_soc = None
+ self._mesh = None
+ self._disc = None
From 51e07e751417520f759162350763b8c2abdd599e Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 29 Nov 2023 10:28:49 +0000
Subject: [PATCH 034/101] Adds and displays timing to testing suite
---
conftest.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/conftest.py b/conftest.py
index 768c5a3e3..508808aac 100644
--- a/conftest.py
+++ b/conftest.py
@@ -15,6 +15,14 @@ def pytest_addoption(parser):
)
+def pytest_terminal_summary(terminalreporter, exitstatus, config):
+ """Add additional section to terminal summary reporting."""
+ total_time = sum([x.duration for x in terminalreporter.stats.get("passed", [])])
+ num_tests = len(terminalreporter.stats.get("passed", []))
+ print(f"\nTotal number of tests completed: {num_tests}")
+ print(f"Total time taken: {total_time:.2f} seconds")
+
+
def pytest_configure(config):
config.addinivalue_line("markers", "unit: mark test as a unit test")
config.addinivalue_line("markers", "examples: mark test as an example")
From c70a6cf2abf1c62bde3e87261a6abcb042164edf Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 29 Nov 2023 14:13:30 +0000
Subject: [PATCH 035/101] Updt. pybamm version pin for new signal definition,
fixes breaking signal definition between ecm / spm,spme
---
examples/scripts/ecm_CMAES.py | 15 ++++++---------
examples/scripts/spm_CMAES.py | 6 ++----
examples/scripts/spm_IRPropMin.py | 6 ++----
examples/scripts/spm_SNES.py | 6 ++----
examples/scripts/spm_XNES.py | 6 ++----
examples/scripts/spm_adam.py | 6 ++----
examples/scripts/spm_descent.py | 6 ++----
examples/scripts/spm_nlopt.py | 4 ++--
examples/scripts/spm_pso.py | 6 ++----
pybop/_problem.py | 2 +-
setup.py | 2 +-
11 files changed, 24 insertions(+), 41 deletions(-)
diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py
index fb550eb95..0db3a8f22 100644
--- a/examples/scripts/ecm_CMAES.py
+++ b/examples/scripts/ecm_CMAES.py
@@ -2,19 +2,18 @@
import numpy as np
import matplotlib.pyplot as plt
-# parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
-model = pybop.empirical.Thevenin()
+model = pybop.empirical.Thevenin() # (options={"number of rc elements": 2})
# Fitting parameters
parameters = [
pybop.Parameter(
"R0 [Ohm]",
- prior=pybop.Gaussian(0.001, 0.0001),
- bounds=[1e-5, 1e-2],
+ prior=pybop.Gaussian(0.0002, 0.0001),
+ bounds=[1e-4, 1e-2],
),
pybop.Parameter(
"R1 [Ohm]",
- prior=pybop.Gaussian(0.001, 0.0001),
+ prior=pybop.Gaussian(0.0001, 0.0001),
bounds=[1e-5, 1e-2],
),
]
@@ -22,14 +21,12 @@
sigma = 0.001
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
-CorruptValues = values["Battery voltage [V]"].data + np.random.normal(
- 0, sigma, len(t_eval)
-)
+CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))
dataset = [
pybop.Dataset("Time [s]", t_eval),
pybop.Dataset("Current function [A]", values["Current [A]"].data),
- pybop.Dataset("Battery voltage [V]", CorruptValues),
+ pybop.Dataset("Voltage [V]", CorruptValues),
]
# Generate problem, cost function, and optimisation class
diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py
index 7f044409e..c1c93ad85 100644
--- a/examples/scripts/spm_CMAES.py
+++ b/examples/scripts/spm_CMAES.py
@@ -22,14 +22,12 @@
sigma = 0.001
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
-CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
- 0, sigma, len(t_eval)
-)
+CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))
dataset = [
pybop.Dataset("Time [s]", t_eval),
pybop.Dataset("Current function [A]", values["Current [A]"].data),
- pybop.Dataset("Terminal voltage [V]", CorruptValues),
+ pybop.Dataset("Voltage [V]", CorruptValues),
]
# Generate problem, cost function, and optimisation class
diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py
index 2d4dd2ec4..ec1128cf4 100644
--- a/examples/scripts/spm_IRPropMin.py
+++ b/examples/scripts/spm_IRPropMin.py
@@ -22,14 +22,12 @@
sigma = 0.001
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
-CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
- 0, sigma, len(t_eval)
-)
+CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))
dataset = [
pybop.Dataset("Time [s]", t_eval),
pybop.Dataset("Current function [A]", values["Current [A]"].data),
- pybop.Dataset("Terminal voltage [V]", CorruptValues),
+ pybop.Dataset("Voltage [V]", CorruptValues),
]
# Generate problem, cost function, and optimisation class
diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py
index f5db3c9b9..e9a06a26c 100644
--- a/examples/scripts/spm_SNES.py
+++ b/examples/scripts/spm_SNES.py
@@ -22,14 +22,12 @@
sigma = 0.001
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
-CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
- 0, sigma, len(t_eval)
-)
+CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))
dataset = [
pybop.Dataset("Time [s]", t_eval),
pybop.Dataset("Current function [A]", values["Current [A]"].data),
- pybop.Dataset("Terminal voltage [V]", CorruptValues),
+ pybop.Dataset("Voltage [V]", CorruptValues),
]
# Generate problem, cost function, and optimisation class
diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py
index 37939245f..5470bd0fd 100644
--- a/examples/scripts/spm_XNES.py
+++ b/examples/scripts/spm_XNES.py
@@ -22,14 +22,12 @@
sigma = 0.001
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
-CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
- 0, sigma, len(t_eval)
-)
+CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))
dataset = [
pybop.Dataset("Time [s]", t_eval),
pybop.Dataset("Current function [A]", values["Current [A]"].data),
- pybop.Dataset("Terminal voltage [V]", CorruptValues),
+ pybop.Dataset("Voltage [V]", CorruptValues),
]
# Generate problem, cost function, and optimisation class
diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py
index 27949e9ac..cef1adabe 100644
--- a/examples/scripts/spm_adam.py
+++ b/examples/scripts/spm_adam.py
@@ -24,15 +24,13 @@
sigma = 0.001
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
-corrupt_values = values["Terminal voltage [V]"].data + np.random.normal(
- 0, sigma, len(t_eval)
-)
+corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))
# Dataset definition
dataset = [
pybop.Dataset("Time [s]", t_eval),
pybop.Dataset("Current function [A]", values["Current [A]"].data),
- pybop.Dataset("Terminal voltage [V]", corrupt_values),
+ pybop.Dataset("Voltage [V]", corrupt_values),
]
# Generate problem, cost function, and optimisation class
diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py
index 85f77f262..1f9376518 100644
--- a/examples/scripts/spm_descent.py
+++ b/examples/scripts/spm_descent.py
@@ -24,15 +24,13 @@
sigma = 0.001
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
-corrupt_values = values["Terminal voltage [V]"].data + np.random.normal(
- 0, sigma, len(t_eval)
-)
+corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))
# Dataset definition
dataset = [
pybop.Dataset("Time [s]", t_eval),
pybop.Dataset("Current function [A]", values["Current [A]"].data),
- pybop.Dataset("Terminal voltage [V]", corrupt_values),
+ pybop.Dataset("Voltage [V]", corrupt_values),
]
# Generate problem, cost function, and optimisation class
diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py
index 19401ed45..49c9b8a92 100644
--- a/examples/scripts/spm_nlopt.py
+++ b/examples/scripts/spm_nlopt.py
@@ -7,7 +7,7 @@
dataset = [
pybop.Dataset("Time [s]", Measurements[:, 0]),
pybop.Dataset("Current function [A]", Measurements[:, 1]),
- pybop.Dataset("Terminal voltage [V]", Measurements[:, 2]),
+ pybop.Dataset("Voltage [V]", Measurements[:, 2]),
]
# Define model
@@ -31,7 +31,7 @@
]
# Define the cost to optimise
-signal = "Terminal voltage [V]"
+signal = "Voltage [V]"
problem = pybop.Problem(model, parameters, dataset, signal=signal, init_soc=0.98)
cost = pybop.RootMeanSquaredError(problem)
diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py
index 9a9cb5aab..7a93c83c0 100644
--- a/examples/scripts/spm_pso.py
+++ b/examples/scripts/spm_pso.py
@@ -22,14 +22,12 @@
sigma = 0.001
t_eval = np.arange(0, 900, 2)
values = model.predict(t_eval=t_eval)
-CorruptValues = values["Terminal voltage [V]"].data + np.random.normal(
- 0, sigma, len(t_eval)
-)
+CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))
dataset = [
pybop.Dataset("Time [s]", t_eval),
pybop.Dataset("Current function [A]", values["Current [A]"].data),
- pybop.Dataset("Terminal voltage [V]", CorruptValues),
+ pybop.Dataset("Voltage [V]", CorruptValues),
]
# Generate problem, cost function, and optimisation class
diff --git a/pybop/_problem.py b/pybop/_problem.py
index c21f77881..671b3a1c1 100644
--- a/pybop/_problem.py
+++ b/pybop/_problem.py
@@ -11,7 +11,7 @@ def __init__(
model,
parameters,
dataset,
- signal="Battery voltage [V]",
+ signal="Voltage [V]",
check_model=True,
init_soc=None,
x0=None,
diff --git a/setup.py b/setup.py
index 4d6b63a65..47e485826 100644
--- a/setup.py
+++ b/setup.py
@@ -25,7 +25,7 @@
long_description_content_type="text/markdown",
url="https://github.com/pybop-team/PyBOP",
install_requires=[
- "pybamm>=23.1",
+ "pybamm>=23.5",
"numpy>=1.16",
"scipy>=1.3",
"pandas>=1.0",
From 329c36f8c3d98a996b01c3d3dfaceaa0b3c60c16 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Wed, 29 Nov 2023 15:10:33 +0000
Subject: [PATCH 036/101] Updt. cost_2d() with optional bounds arg + example,
updt. contributing and readme for development installation
---
CONTRIBUTING.md | 17 +++++++++++++++--
README.md | 6 +-----
examples/scripts/spm_CMAES.py | 5 +++--
pybop/plotting/plot_cost2d.py | 9 ++++++---
4 files changed, 25 insertions(+), 12 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d1ea9db24..95a6914f8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,7 +1,20 @@
# Contributing to PyBOP
-If you'd like to contribute to PyBOP, please have a look at the [pre-commit](#pre-commit-checks) and the [workflow](#workflow) guidelines below.
+If you'd like to contribute to PyBOP, please have a look at the guidelines below.
+## Installation
+
+To install PyBOP for development purposes, which includes the testing and plotting dependencies, use the `[all]` flag as demonstrated below:
+
+For `zsh`:
+
+```sh
+pip install -e '.[all]'
+```
+For `bash`:
+```sh
+pip install -e .[all]
+```
## Pre-commit checks
Before you commit any code, please perform the following checks:
@@ -123,7 +136,7 @@ If you have nox installed, to run unit tests, type
nox -s unit
```
-else, type
+Alternatively, to run tests standalone with pytest, run,
```bash
pytest --unit -v
diff --git a/README.md b/README.md
index 9c57fbe18..1420aa4e8 100644
--- a/README.md
+++ b/README.md
@@ -71,11 +71,7 @@ To alternatively install PyBOP from a local directory, use the following templat
pip install -e "path/to/pybop"
```
-To check whether PyBOP has been installed correctly, run one of the examples in the following section or the full set of unit tests:
-
-```bash
-pytest --unit -v
-```
+To check whether PyBOP has been installed correctly, run one of the examples in the following section. For a development installation, please refer to the [contributing guide](https://github.com/pybop-team/PyBOP/blob/develop/CONTRIBUTING.md#Installation).
### Prerequisites
To use and/or contribute to PyBOP, first install Python (3.8-3.11). On a Debian-based distribution, this looks like:
diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py
index f260fdd94..9a415a66a 100644
--- a/examples/scripts/spm_CMAES.py
+++ b/examples/scripts/spm_CMAES.py
@@ -56,5 +56,6 @@
# Plot the cost landscape
pybop.plot_cost2d(cost, steps=15)
-# Plot the cost landscape with optimisation path
-pybop.plot_cost2d(cost, optim=optim, steps=15)
+# Plot the cost landscape with optimisation path and updated bounds
+bounds = np.array([[0.6, 0.9], [0.5, 0.8]])
+pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15)
diff --git a/pybop/plotting/plot_cost2d.py b/pybop/plotting/plot_cost2d.py
index fec15fd77..aa7b2e3bd 100644
--- a/pybop/plotting/plot_cost2d.py
+++ b/pybop/plotting/plot_cost2d.py
@@ -1,13 +1,16 @@
import numpy as np
-def plot_cost2d(cost, optim=None, steps=10):
+def plot_cost2d(cost, bounds=None, optim=None, steps=10):
"""
Query the cost landscape for a given parameter space and plot using plotly.
"""
- # Set up parameter bounds
- bounds = get_param_bounds(cost)
+ if bounds is None:
+ # Set up parameter bounds
+ bounds = get_param_bounds(cost)
+ else:
+ bounds = bounds
# Generate grid
x = np.linspace(bounds[0, 0], bounds[0, 1], steps)
From 27d45c298e8d31ee6ab7c66d04b6d68e117ba735 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 30 Nov 2023 10:28:18 +0000
Subject: [PATCH 037/101] Add parameterisation logic to thevenin class with
example
---
examples/scripts/ecm_CMAES.py | 33 +++++++++++++++++++++++++++++-
pybop/models/empirical/base_ecm.py | 13 ++++++++----
2 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py
index 0db3a8f22..70fba190e 100644
--- a/examples/scripts/ecm_CMAES.py
+++ b/examples/scripts/ecm_CMAES.py
@@ -2,7 +2,38 @@
import numpy as np
import matplotlib.pyplot as plt
-model = pybop.empirical.Thevenin() # (options={"number of rc elements": 2})
+# Define the initial parameter set
+params = {
+ "chemistry": "ecm",
+ "Initial SoC": 0.5,
+ "Initial temperature [K]": 25 + 273.15,
+ "Cell capacity [A.h]": 5,
+ "Nominal cell capacity [A.h]": 5,
+ "Ambient temperature [K]": 25 + 273.15,
+ "Current function [A]": 5,
+ "Upper voltage cut-off [V]": 4.2,
+ "Lower voltage cut-off [V]": 3.0,
+ "Cell thermal mass [J/K]": 1000,
+ "Cell-jig heat transfer coefficient [W/K]": 10,
+ "Jig thermal mass [J/K]": 500,
+ "Jig-air heat transfer coefficient [W/K]": 10,
+ "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[
+ "Open-circuit voltage [V]"
+ ],
+ "R0 [Ohm]": 0.001,
+ "Element-1 initial overpotential [V]": 0,
+ "Element-2 initial overpotential [V]": 0,
+ "R1 [Ohm]": 0.0002,
+ "R2 [Ohm]": 0.0003,
+ "C1 [F]": 10000,
+ "C2 [F]": 5000,
+ "Entropic change [V/K]": 0.0004,
+}
+
+# Define the model
+model = pybop.empirical.Thevenin(
+ parameter_set=params, options={"number of rc elements": 2}
+)
# Fitting parameters
parameters = [
diff --git a/pybop/models/empirical/base_ecm.py b/pybop/models/empirical/base_ecm.py
index 8874c23a1..85b21d1c6 100644
--- a/pybop/models/empirical/base_ecm.py
+++ b/pybop/models/empirical/base_ecm.py
@@ -27,10 +27,15 @@ def __init__(
self._unprocessed_model = self.pybamm_model
self.name = name
- self.default_parameter_values = self.pybamm_model.default_parameter_values
- self._parameter_set = (
- parameter_set or self.pybamm_model.default_parameter_values
- )
+ if isinstance(parameter_set, dict):
+ self.default_parameter_values = pybamm.ParameterValues(parameter_set)
+ self._parameter_set = self.default_parameter_values
+ else:
+ self.default_parameter_values = self.pybamm_model.default_parameter_values
+ self._parameter_set = (
+ parameter_set or self.pybamm_model.default_parameter_values
+ )
+
self._unprocessed_parameter_set = self._parameter_set
self.geometry = geometry or self.pybamm_model.default_geometry
From 605e20b2d4deb5055ca3d1c49a2596074bb2348c Mon Sep 17 00:00:00 2001
From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com>
Date: Thu, 30 Nov 2023 12:07:14 +0000
Subject: [PATCH 038/101] Add browser check
---
pybop/plotting/quick_plot.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index 5d2048901..6960d5b17 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -1,6 +1,7 @@
import numpy as np
import textwrap
import pybop
+import os
class StandardPlot:
@@ -95,6 +96,20 @@ def __init__(
except ImportError as e:
raise ImportError(f"Plotly is required for this class to work: {e}")
+ # Check for the existence of a browser for use by plotly
+ import plotly.io as pio
+ if pio.renderers.default == "browser" and os.getenv("BROWSER") is None:
+ raise ValueError(
+ "\n\nIn order to view figures in the browser using Plotly, "
+ "you need to set the environment variable BROWSER equal to the "
+ "path to your chosen browser. To do this, please enter a command such "
+ "as the following to add this your virtual environment activation file:\n\n"
+ "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate"
+ "\n\nThen reactivate your virtual environment. Alternatively you can use a"
+ "different Plotly renderer. For more information see: "
+ "https://plotly.com/python/renderers/#setting-the-default-renderer"
+ )
+
@staticmethod
def wrap_text(text, width):
"""
From 05a36facc5ec8c8273478c04e662a48a484b1c0e Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Thu, 30 Nov 2023 12:07:30 +0000
Subject: [PATCH 039/101] style: pre-commit fixes
---
pybop/plotting/quick_plot.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index 6960d5b17..10699271a 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -98,6 +98,7 @@ def __init__(
# Check for the existence of a browser for use by plotly
import plotly.io as pio
+
if pio.renderers.default == "browser" and os.getenv("BROWSER") is None:
raise ValueError(
"\n\nIn order to view figures in the browser using Plotly, "
From 7ffaa376869923cc6d9ece14feab9631452870d1 Mon Sep 17 00:00:00 2001
From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com>
Date: Thu, 30 Nov 2023 12:39:07 +0000
Subject: [PATCH 040/101] Fix typo
---
pybop/plotting/quick_plot.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index 10699271a..fdf2f63fa 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -106,7 +106,7 @@ def __init__(
"path to your chosen browser. To do this, please enter a command such "
"as the following to add this your virtual environment activation file:\n\n"
"echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate"
- "\n\nThen reactivate your virtual environment. Alternatively you can use a"
+ "\n\nThen reactivate your virtual environment. Alternatively you can use a "
"different Plotly renderer. For more information see: "
"https://plotly.com/python/renderers/#setting-the-default-renderer"
)
From 6edfa6e566da39902dbebfb85f4262a0af4e3734 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 30 Nov 2023 15:36:28 +0000
Subject: [PATCH 041/101] Update plotly browser catch, Add logic to install
plotly if not already installed
---
pybop/plotting/plot_parameters.py | 25 ++++++++++++++++++-
pybop/plotting/quick_plot.py | 41 ++++++++++++++++++++-----------
2 files changed, 51 insertions(+), 15 deletions(-)
diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py
index 4cdee0bdb..0f6e936db 100644
--- a/pybop/plotting/plot_parameters.py
+++ b/pybop/plotting/plot_parameters.py
@@ -1,6 +1,6 @@
import pybop
import math
-import plotly.graph_objects as go
+import plotly.graph_objs as go
from plotly.subplots import make_subplots
@@ -88,6 +88,17 @@ def create_traces(params, trace_data, x_values=None):
- The function assumes that `go` from `plotly.graph_objs` is already imported as `go`.
"""
+ # Attempt to import plotly graph objects when an instance is created
+ # try:
+ # import plotly.graph_objs as go
+
+ # except ImportError:
+ # print("Plotly is not installed. Installing now...")
+ # subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"])
+
+ # # Try to import again after installing
+ # import plotly.graph_objs as go
+
traces = []
# If x_values are not provided:
@@ -133,6 +144,18 @@ def create_subplots_with_traces(
:param layout_kwargs: Additional keyword arguments to be passed to fig.update_layout for custom layout.
:return: A plotly figure object with the subplots.
"""
+
+ # Attempt to import plotly subplots when an instance is created
+ # try:
+ # from plotly.subplots import make_subplots
+
+ # except ImportError:
+ # print("Plotly is not installed. Installing now...")
+ # subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"])
+
+ # # Try to import again after installing
+ # from plotly.subplots import make_subplots
+
num_traces = len(traces)
num_cols = int(math.ceil(math.sqrt(num_traces)))
num_rows = int(math.ceil(num_traces / num_cols))
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index fdf2f63fa..3aa2cb010 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -1,7 +1,9 @@
import numpy as np
+import webbrowser
+import subprocess
import textwrap
import pybop
-import os
+import sys
class StandardPlot:
@@ -93,23 +95,34 @@ def __init__(
import plotly.graph_objs as go
self.go = go
- except ImportError as e:
- raise ImportError(f"Plotly is required for this class to work: {e}")
+
+ except ImportError:
+ print("Plotly is not installed. Installing now...")
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"])
+
+ # Try to import again after installing
+ import plotly.graph_objs as go
+
+ self.go = go
# Check for the existence of a browser for use by plotly
import plotly.io as pio
- if pio.renderers.default == "browser" and os.getenv("BROWSER") is None:
- raise ValueError(
- "\n\nIn order to view figures in the browser using Plotly, "
- "you need to set the environment variable BROWSER equal to the "
- "path to your chosen browser. To do this, please enter a command such "
- "as the following to add this your virtual environment activation file:\n\n"
- "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate"
- "\n\nThen reactivate your virtual environment. Alternatively you can use a "
- "different Plotly renderer. For more information see: "
- "https://plotly.com/python/renderers/#setting-the-default-renderer"
- )
+ if pio.renderers.default == "browser":
+ try:
+ webbrowser.get()
+ except webbrowser.Error:
+ # If no browser is found, raise an error
+ raise ValueError(
+ "\n\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, "
+ "you need to set the environment variable BROWSER equal to the "
+ "path to your chosen browser. To do this, please enter a command like "
+ "the following to add this your virtual environment activation file:\n\n"
+ "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate"
+ "\n\nThen reactivate your virtual environment. Alternatively you can use a "
+ "different Plotly renderer. For more information see: "
+ "https://plotly.com/python/renderers/#setting-the-default-renderer"
+ )
@staticmethod
def wrap_text(text, width):
From d02bb95f6fe52b1739a340f7dba5a1d6e6867faa Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 30 Nov 2023 16:14:23 +0000
Subject: [PATCH 042/101] Adds user prompt for plotly install, uncomments
plotly install code
---
pybop/plotting/plot_parameters.py | 74 ++++++++++++++++++++++---------
pybop/plotting/quick_plot.py | 31 ++++++++++---
2 files changed, 78 insertions(+), 27 deletions(-)
diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py
index 0f6e936db..61bad47f8 100644
--- a/pybop/plotting/plot_parameters.py
+++ b/pybop/plotting/plot_parameters.py
@@ -1,7 +1,7 @@
+import subprocess
import pybop
import math
-import plotly.graph_objs as go
-from plotly.subplots import make_subplots
+import sys
def plot_parameters(
@@ -89,15 +89,32 @@ def create_traces(params, trace_data, x_values=None):
"""
# Attempt to import plotly graph objects when an instance is created
- # try:
- # import plotly.graph_objs as go
-
- # except ImportError:
- # print("Plotly is not installed. Installing now...")
- # subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"])
-
- # # Try to import again after installing
- # import plotly.graph_objs as go
+ try:
+ import plotly.graph_objs as go
+
+ except ImportError:
+ user_input = (
+ input(
+ "Plotly is not installed. To proceed, we need to install plotly. (y/n)?"
+ )
+ .strip()
+ .lower()
+ )
+
+ if user_input == "y":
+ try:
+ subprocess.check_call(
+ [sys.executable, "-m", "pip", "install", "plotly"]
+ )
+ except subprocess.CalledProcessError as e:
+ print(f"Error installing plotly: {e}")
+ return
+
+ # Try to import again after installing
+ import plotly.graph_objs as go
+
+ else:
+ print("Installation cancelled by user.")
traces = []
@@ -146,15 +163,32 @@ def create_subplots_with_traces(
"""
# Attempt to import plotly subplots when an instance is created
- # try:
- # from plotly.subplots import make_subplots
-
- # except ImportError:
- # print("Plotly is not installed. Installing now...")
- # subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"])
-
- # # Try to import again after installing
- # from plotly.subplots import make_subplots
+ try:
+ from plotly.subplots import make_subplots
+
+ except ImportError:
+ user_input = (
+ input(
+ "Plotly is not installed. To proceed, we need to install plotly. (y/n)?"
+ )
+ .strip()
+ .lower()
+ )
+
+ if user_input == "y":
+ try:
+ subprocess.check_call(
+ [sys.executable, "-m", "pip", "install", "plotly"]
+ )
+ except subprocess.CalledProcessError as e:
+ print(f"Error installing plotly: {e}")
+ return
+
+ # Try to import again after installing
+ from plotly.subplots import make_subplots
+
+ else:
+ print("Installation cancelled by user.")
num_traces = len(traces)
num_cols = int(math.ceil(math.sqrt(num_traces)))
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index 3aa2cb010..7c9f431ce 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -93,21 +93,38 @@ def __init__(
# Attempt to import plotly when an instance is created
try:
import plotly.graph_objs as go
+ import plotly.io as pio
self.go = go
except ImportError:
- print("Plotly is not installed. Installing now...")
- subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"])
+ user_input = (
+ input(
+ "Plotly is not installed. To proceed, we need to install plotly. (y/n)?"
+ )
+ .strip()
+ .lower()
+ )
- # Try to import again after installing
- import plotly.graph_objs as go
+ if user_input == "y":
+ try:
+ subprocess.check_call(
+ [sys.executable, "-m", "pip", "install", "plotly"]
+ )
+ except subprocess.CalledProcessError as e:
+ print(f"Error installing plotly: {e}")
+ return
- self.go = go
+ # Try to import again after installing
+ import plotly.graph_objs as go
+ import plotly.io as pio
- # Check for the existence of a browser for use by plotly
- import plotly.io as pio
+ self.go = go
+
+ else:
+ print("Installation cancelled by user.")
+ # Check for the existence of a browser for use by plotly
if pio.renderers.default == "browser":
try:
webbrowser.get()
From c7d8e59345b4cdcf5cf18c917a368ec2cb4299ed Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 30 Nov 2023 17:01:37 +0000
Subject: [PATCH 043/101] Adds windows and macOS matrix
---
.github/workflows/test_on_push.yaml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/test_on_push.yaml b/.github/workflows/test_on_push.yaml
index 959e4c826..7d10c94b2 100644
--- a/.github/workflows/test_on_push.yaml
+++ b/.github/workflows/test_on_push.yaml
@@ -30,10 +30,11 @@ jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.8", "3.9", "3.10", "3.11"]
steps:
From 84fd660542249e353d7311382068d9f1612b9fed Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Thu, 30 Nov 2023 17:29:24 +0000
Subject: [PATCH 044/101] Updt. changelog
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5780d436..9aacc815d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
# [Unreleased](https://github.com/pybop-team/PyBOP)
+- [#127](https://github.com/pybop-team/PyBOP/issues/127) - Adds Windows and macOS runners to the `test_on_push` action
- [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class
# [v23.11](https://github.com/pybop-team/PyBOP/releases/tag/v23.11)
From d9c429e3ced23951d51cf4e3e65c86f13860aaf9 Mon Sep 17 00:00:00 2001
From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com>
Date: Thu, 30 Nov 2023 19:11:33 +0000
Subject: [PATCH 045/101] Add Plotly install help for WSL users
---
pybop/plotting/plot_parameters.py | 18 ++++++++++++++++++
pybop/plotting/quick_plot.py | 30 ++++++++++++++++++++++++------
2 files changed, 42 insertions(+), 6 deletions(-)
diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py
index 61bad47f8..d6cd06262 100644
--- a/pybop/plotting/plot_parameters.py
+++ b/pybop/plotting/plot_parameters.py
@@ -112,6 +112,15 @@ def create_traces(params, trace_data, x_values=None):
# Try to import again after installing
import plotly.graph_objs as go
+ import plotly.io as pio
+
+ # Set a default renderer if it installs without
+ if pio.renderers.default is None:
+ pio.renderers.default = "browser"
+ print(
+ "The Plotly renderer was set to an empty string during installation, which will not generate any plots, "
+ 'so we have set the default renderer as the "browser".'
+ )
else:
print("Installation cancelled by user.")
@@ -186,6 +195,15 @@ def create_subplots_with_traces(
# Try to import again after installing
from plotly.subplots import make_subplots
+ import plotly.io as pio
+
+ # Set a default renderer if it installs without
+ if pio.renderers.default == "":
+ pio.renderers.default = "browser"
+ print(
+ "The Plotly renderer was set to an empty string during installation, which will not generate any plots, "
+ 'so we have set the default renderer as the "browser".'
+ )
else:
print("Installation cancelled by user.")
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index 7c9f431ce..b260ad086 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -121,22 +121,40 @@ def __init__(
self.go = go
+ # Set a default renderer if it installs without
+ if pio.renderers.default == "":
+ pio.renderers.default = "browser"
+ print(
+ "The Plotly renderer was set to an empty string during installation, which will not generate any plots, "
+ 'so we have set the default renderer as the "browser".'
+ )
+
else:
print("Installation cancelled by user.")
+ # Check for a plotly renderer
+ if pio.renderers.default == "":
+ print(
+ "The Plotly renderer is an empty string, if this was not on purpose use the following commands "
+ "to see the options and set the renderer:\n"
+ " pio.renderers\n"
+ ' pio.renderers.default = "browser"\n'
+ "For more information see: https://plotly.com/python/renderers/#setting-the-default-renderer"
+ )
+
# Check for the existence of a browser for use by plotly
if pio.renderers.default == "browser":
try:
webbrowser.get()
except webbrowser.Error:
- # If no browser is found, raise an error
- raise ValueError(
- "\n\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, "
+ # If no browser is found, raise an exception with a helpful message
+ raise Exception(
+ "\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, "
"you need to set the environment variable BROWSER equal to the "
"path to your chosen browser. To do this, please enter a command like "
- "the following to add this your virtual environment activation file:\n\n"
- "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> pybop-env/bin/activate"
- "\n\nThen reactivate your virtual environment. Alternatively you can use a "
+ "the following to add this to your virtual environment activation file:\n\n"
+ "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> your-env/bin/activate"
+ "\n\nThen reactivate your virtual environment. Alternatively, you can use a "
"different Plotly renderer. For more information see: "
"https://plotly.com/python/renderers/#setting-the-default-renderer"
)
From 695504497fc7ac07e7ca38010cb247a9fdde1862 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Fri, 1 Dec 2023 13:29:12 +0000
Subject: [PATCH 046/101] Add PlotManager class for plotly installation, add
tests for plotly installation, updt. quick_plot and plot_parameter
---
pybop/__init__.py | 1 +
pybop/plotting/plot_parameters.py | 78 ++------------------
pybop/plotting/plotly_manager.py | 114 ++++++++++++++++++++++++++++++
pybop/plotting/quick_plot.py | 71 +------------------
tests/unit/test_plotting.py | 106 +++++++++++++++++++++++++++
5 files changed, 226 insertions(+), 144 deletions(-)
create mode 100644 pybop/plotting/plotly_manager.py
create mode 100644 tests/unit/test_plotting.py
diff --git a/pybop/__init__.py b/pybop/__init__.py
index 48ff40894..f652fe0be 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -79,6 +79,7 @@
from .plotting.quick_plot import StandardPlot, quick_plot
from .plotting.plot_convergence import plot_convergence
from .plotting.plot_parameters import plot_parameters
+from .plotting.plotly_manager import PlotlyManager
#
# Remove any imported modules, so we don't expose them as part of pybop
diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py
index d6cd06262..9f4bbc02a 100644
--- a/pybop/plotting/plot_parameters.py
+++ b/pybop/plotting/plot_parameters.py
@@ -1,7 +1,5 @@
-import subprocess
import pybop
import math
-import sys
def plot_parameters(
@@ -88,42 +86,8 @@ def create_traces(params, trace_data, x_values=None):
- The function assumes that `go` from `plotly.graph_objs` is already imported as `go`.
"""
- # Attempt to import plotly graph objects when an instance is created
- try:
- import plotly.graph_objs as go
-
- except ImportError:
- user_input = (
- input(
- "Plotly is not installed. To proceed, we need to install plotly. (y/n)?"
- )
- .strip()
- .lower()
- )
-
- if user_input == "y":
- try:
- subprocess.check_call(
- [sys.executable, "-m", "pip", "install", "plotly"]
- )
- except subprocess.CalledProcessError as e:
- print(f"Error installing plotly: {e}")
- return
-
- # Try to import again after installing
- import plotly.graph_objs as go
- import plotly.io as pio
-
- # Set a default renderer if it installs without
- if pio.renderers.default is None:
- pio.renderers.default = "browser"
- print(
- "The Plotly renderer was set to an empty string during installation, which will not generate any plots, "
- 'so we have set the default renderer as the "browser".'
- )
-
- else:
- print("Installation cancelled by user.")
+ # Attempt to import plotly when an instance is created
+ go = pybop.PlotlyManager().go
traces = []
@@ -171,42 +135,8 @@ def create_subplots_with_traces(
:return: A plotly figure object with the subplots.
"""
- # Attempt to import plotly subplots when an instance is created
- try:
- from plotly.subplots import make_subplots
-
- except ImportError:
- user_input = (
- input(
- "Plotly is not installed. To proceed, we need to install plotly. (y/n)?"
- )
- .strip()
- .lower()
- )
-
- if user_input == "y":
- try:
- subprocess.check_call(
- [sys.executable, "-m", "pip", "install", "plotly"]
- )
- except subprocess.CalledProcessError as e:
- print(f"Error installing plotly: {e}")
- return
-
- # Try to import again after installing
- from plotly.subplots import make_subplots
- import plotly.io as pio
-
- # Set a default renderer if it installs without
- if pio.renderers.default == "":
- pio.renderers.default = "browser"
- print(
- "The Plotly renderer was set to an empty string during installation, which will not generate any plots, "
- 'so we have set the default renderer as the "browser".'
- )
-
- else:
- print("Installation cancelled by user.")
+ # Attempt to import plotly when an instance is created
+ make_subplots = pybop.PlotlyManager().make_subplots
num_traces = len(traces)
num_cols = int(math.ceil(math.sqrt(num_traces)))
diff --git a/pybop/plotting/plotly_manager.py b/pybop/plotting/plotly_manager.py
new file mode 100644
index 000000000..2384f2996
--- /dev/null
+++ b/pybop/plotting/plotly_manager.py
@@ -0,0 +1,114 @@
+import subprocess
+import webbrowser
+import sys
+
+
+class PlotlyManager:
+ """
+ Manages the installation and configuration of Plotly for generating visualisations.
+
+ This class checks if Plotly is installed and, if not, prompts the user to install it.
+ It also ensures that the Plotly renderer and browser settings are properly configured
+ to display plots.
+
+ Methods:
+ `ensure_plotly_installed`: Verifies if Plotly is installed and installs it if necessary.
+ `prompt_for_plotly_installation`: Prompts the user for permission to install Plotly.
+ `install_plotly_package`: Installs the Plotly package using pip.
+ `post_install_setup`: Sets up Plotly default renderer after installation.
+ `check_renderer_settings`: Verifies that the Plotly renderer is correctly set.
+ `check_browser_availability`: Checks if a web browser is available for rendering plots.
+
+ Usage:
+ Instantiate the PlotlyManager class to automatically ensure Plotly is installed
+ and configured correctly when creating an instance.
+ Example:
+ plotly_manager = PlotlyManager()
+ """
+
+ def __init__(self):
+ self.go = None
+ self.pio = None
+ self.make_subplots = None
+ self.ensure_plotly_installed()
+ self.check_renderer_settings()
+ self.check_browser_availability()
+
+ def ensure_plotly_installed(self):
+ """Verifies if Plotly is installed, importing necessary modules and prompting for installation if missing."""
+ try:
+ import plotly.graph_objs as go
+ import plotly.io as pio
+ from plotly.subplots import make_subplots
+
+ self.go = go
+ self.pio = pio
+ self.make_subplots = make_subplots
+ except ImportError:
+ self.prompt_for_plotly_installation()
+
+ def prompt_for_plotly_installation(self):
+ """Prompts the user for permission to install Plotly and proceeds with installation if consented."""
+ user_input = (
+ input(
+ "Plotly is not installed. To proceed, we need to install plotly. (Y/n)? "
+ )
+ .strip()
+ .lower()
+ )
+ if user_input == "y":
+ self.install_plotly()
+ self.post_install_setup()
+ else:
+ print("Installation cancelled by user.")
+ sys.exit(1) # Exit if user cancels installation
+
+ def install_plotly(self):
+ """Attempts to install the Plotly package using pip and exits if installation fails."""
+ try:
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "plotly"])
+ except subprocess.CalledProcessError as e:
+ print(f"Error installing plotly: {e}")
+ sys.exit(1) # Exit if installation fails
+
+ def post_install_setup(self):
+ """After successful installation, imports Plotly and sets the default renderer if necessary."""
+ import plotly.graph_objs as go
+ import plotly.io as pio
+ from plotly.subplots import make_subplots
+
+ self.go = go
+ self.pio = pio
+ self.make_subplots = make_subplots
+ if pio.renderers.default == "":
+ pio.renderers.default = "browser"
+ print(
+ 'Set default renderer to "browser" as it was empty after installation.'
+ )
+
+ def check_renderer_settings(self):
+ """Checks if the Plotly renderer is set and provides information on how to set it if empty."""
+ if self.pio and self.pio.renderers.default == "":
+ print(
+ "The Plotly renderer is an empty string. To set the renderer, use:\n"
+ " pio.renderers\n"
+ ' pio.renderers.default = "browser"\n'
+ "For more information, see: https://plotly.com/python/renderers/#setting-the-default-renderer"
+ )
+
+ def check_browser_availability(self):
+ """Ensures a web browser is available for rendering plots with the 'browser' renderer and provides guidance if not."""
+ if self.pio and self.pio.renderers.default == "browser":
+ try:
+ webbrowser.get()
+ except webbrowser.Error:
+ raise Exception(
+ "\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, "
+ "you need to set the environment variable BROWSER equal to the "
+ "path to your chosen browser. To do this, please enter a command like "
+ "the following to add this to your virtual environment activation file:\n\n"
+ "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> your-env/bin/activate"
+ "\n\nThen reactivate your virtual environment. Alternatively, you can use a "
+ "different Plotly renderer. For more information see: "
+ "https://plotly.com/python/renderers/#setting-the-default-renderer"
+ )
diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py
index b260ad086..d6628760b 100644
--- a/pybop/plotting/quick_plot.py
+++ b/pybop/plotting/quick_plot.py
@@ -1,9 +1,6 @@
import numpy as np
-import webbrowser
-import subprocess
import textwrap
import pybop
-import sys
class StandardPlot:
@@ -91,73 +88,7 @@ def __init__(
self.y_lower = (self.y - self.sigma).tolist()
# Attempt to import plotly when an instance is created
- try:
- import plotly.graph_objs as go
- import plotly.io as pio
-
- self.go = go
-
- except ImportError:
- user_input = (
- input(
- "Plotly is not installed. To proceed, we need to install plotly. (y/n)?"
- )
- .strip()
- .lower()
- )
-
- if user_input == "y":
- try:
- subprocess.check_call(
- [sys.executable, "-m", "pip", "install", "plotly"]
- )
- except subprocess.CalledProcessError as e:
- print(f"Error installing plotly: {e}")
- return
-
- # Try to import again after installing
- import plotly.graph_objs as go
- import plotly.io as pio
-
- self.go = go
-
- # Set a default renderer if it installs without
- if pio.renderers.default == "":
- pio.renderers.default = "browser"
- print(
- "The Plotly renderer was set to an empty string during installation, which will not generate any plots, "
- 'so we have set the default renderer as the "browser".'
- )
-
- else:
- print("Installation cancelled by user.")
-
- # Check for a plotly renderer
- if pio.renderers.default == "":
- print(
- "The Plotly renderer is an empty string, if this was not on purpose use the following commands "
- "to see the options and set the renderer:\n"
- " pio.renderers\n"
- ' pio.renderers.default = "browser"\n'
- "For more information see: https://plotly.com/python/renderers/#setting-the-default-renderer"
- )
-
- # Check for the existence of a browser for use by plotly
- if pio.renderers.default == "browser":
- try:
- webbrowser.get()
- except webbrowser.Error:
- # If no browser is found, raise an exception with a helpful message
- raise Exception(
- "\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, "
- "you need to set the environment variable BROWSER equal to the "
- "path to your chosen browser. To do this, please enter a command like "
- "the following to add this to your virtual environment activation file:\n\n"
- "echo 'export BROWSER=\"/mnt/c/Program Files/Mozilla Firefox/firefox.exe\"' >> your-env/bin/activate"
- "\n\nThen reactivate your virtual environment. Alternatively, you can use a "
- "different Plotly renderer. For more information see: "
- "https://plotly.com/python/renderers/#setting-the-default-renderer"
- )
+ self.go = pybop.PlotlyManager().go
@staticmethod
def wrap_text(text, width):
diff --git a/tests/unit/test_plotting.py b/tests/unit/test_plotting.py
new file mode 100644
index 000000000..b0802cb01
--- /dev/null
+++ b/tests/unit/test_plotting.py
@@ -0,0 +1,106 @@
+from importlib.metadata import distributions
+from distutils.spawn import find_executable
+from pybop import PlotlyManager
+import subprocess
+import pytest
+
+# Find the Python executable
+python_executable = find_executable("python")
+
+
+@pytest.fixture(scope="session")
+def plotly_installed():
+ """A session-level fixture that ensures Plotly is installed after tests."""
+ # Check if Plotly is initially installed
+ initially_installed = is_package_installed("plotly")
+
+ # If Plotly is not installed initially, install it
+ if not initially_installed:
+ subprocess.check_call([python_executable, "-m", "pip", "install", "plotly"])
+
+ # Yield control back to the tests
+ yield
+
+ # After tests, if Plotly was not installed initially, uninstall it
+ if not initially_installed:
+ subprocess.check_call(
+ [python_executable, "-m", "pip", "uninstall", "plotly", "-y"]
+ )
+
+
+@pytest.fixture(scope="function")
+def uninstall_plotly_if_installed():
+ """A fixture to uninstall Plotly if it's installed before a test and reinstall it afterwards."""
+ # Check if Plotly is installed before the test
+ was_installed = is_package_installed("plotly")
+
+ # If Plotly is installed, uninstall it
+ if was_installed:
+ subprocess.check_call(
+ [python_executable, "-m", "pip", "uninstall", "plotly", "-y"]
+ )
+
+ # Yield control back to the test
+ yield
+
+ # If Plotly was uninstalled for the test, reinstall it afterwards
+ if was_installed:
+ subprocess.check_call([python_executable, "-m", "pip", "install", "plotly"])
+
+
+@pytest.mark.unit
+def test_initialization_with_plotly_installed(plotly_installed):
+ """Test initialization when Plotly is installed."""
+ assert is_package_installed("plotly")
+ plotly_manager = PlotlyManager()
+
+ import plotly.graph_objs as go
+ import plotly.io as pio
+ from plotly.subplots import make_subplots
+
+ assert plotly_manager.go == go
+ assert plotly_manager.pio == pio
+ assert plotly_manager.make_subplots == make_subplots
+
+
+@pytest.mark.unit
+def test_prompt_for_plotly_installation(mocker, uninstall_plotly_if_installed):
+ """Test prompt for Plotly installation when not installed."""
+ assert not is_package_installed("plotly")
+ mocker.patch("builtins.input", return_value="y")
+ plotly_manager = PlotlyManager()
+
+ import plotly.graph_objs as go
+ import plotly.io as pio
+ from plotly.subplots import make_subplots
+
+ assert plotly_manager.go == go
+ assert plotly_manager.pio == pio
+ assert plotly_manager.make_subplots == make_subplots
+
+
+@pytest.mark.unit
+def test_cancel_installation(mocker, uninstall_plotly_if_installed):
+ """Test exit if Plotly installation is canceled."""
+ assert not is_package_installed("plotly")
+ mocker.patch("builtins.input", return_value="n")
+ with pytest.raises(SystemExit) as pytest_wrapped_e:
+ PlotlyManager().prompt_for_plotly_installation()
+
+ assert pytest_wrapped_e.type == SystemExit
+ assert pytest_wrapped_e.value.code == 1
+ assert not is_package_installed("plotly")
+
+
+@pytest.mark.unit
+def test_post_install_setup(plotly_installed):
+ """Test post-install setup."""
+ plotly_manager = PlotlyManager()
+ plotly_manager.post_install_setup()
+
+ assert plotly_manager.pio.renderers.default == "browser"
+
+
+def is_package_installed(package_name):
+ """Check if a package is installed without raising an exception."""
+ return any(d.metadata["Name"] == package_name for d in distributions())
From 27a88aa98d2826cd92b4b6abc757f23703627473 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Fri, 1 Dec 2023 13:44:11 +0000
Subject: [PATCH 047/101] Updt. noxfile for pytest-mock requirement
---
noxfile.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/noxfile.py b/noxfile.py
index 055bf4eff..b86005a02 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -7,7 +7,7 @@
@nox.session
def unit(session):
session.run_always("pip", "install", "-e", ".[all]")
- session.install("pytest")
+ session.install("pytest", "pytest-mock")
session.run("pytest", "--unit", "-v", "--showlocals")
From 1e505ee6a53c1ae36a2ff893c3e8f5d54a5ec0e0 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Fri, 1 Dec 2023 14:03:25 +0000
Subject: [PATCH 048/101] Add pytest-mock to nofile coverage
---
noxfile.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/noxfile.py b/noxfile.py
index b86005a02..e1759a3d5 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -14,7 +14,7 @@ def unit(session):
@nox.session
def coverage(session):
session.run_always("pip", "install", "-e", ".[all]")
- session.install("pytest-cov")
+ session.install("pytest", "pytest-cov", "pytest-mock")
session.run(
"pytest",
"--unit",
From 0e143cca6471564b918d1d073efcaf65e617d142 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Fri, 1 Dec 2023 15:55:22 +0000
Subject: [PATCH 049/101] Merge develop, update thevenin example plotting
---
examples/scripts/ecm_CMAES.py | 26 ++++++++++++++------------
1 file changed, 14 insertions(+), 12 deletions(-)
diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py
index 70fba190e..35e2a9b76 100644
--- a/examples/scripts/ecm_CMAES.py
+++ b/examples/scripts/ecm_CMAES.py
@@ -1,6 +1,5 @@
import pybop
import numpy as np
-import matplotlib.pyplot as plt
# Define the initial parameter set
params = {
@@ -69,15 +68,18 @@
x, final_cost = optim.run()
print("Estimated parameters:", x)
-# Show the generated data
-simulated_values = problem.evaluate(x)
+# Plot the timeseries output
+pybop.quick_plot(x, cost, title="Optimised Comparison")
-plt.figure(dpi=100)
-plt.xlabel("Time", fontsize=12)
-plt.ylabel("Values", fontsize=12)
-plt.plot(t_eval, CorruptValues, label="Measured")
-plt.fill_between(t_eval, simulated_values - sigma, simulated_values + sigma, alpha=0.2)
-plt.plot(t_eval, simulated_values, label="Simulated")
-plt.legend(bbox_to_anchor=(0.6, 1), loc="upper left", fontsize=12)
-plt.tick_params(axis="both", labelsize=12)
-plt.show()
+# Plot convergence
+pybop.plot_convergence(optim)
+
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
+# Plot the cost landscape
+pybop.plot_cost2d(cost, steps=15)
+
+# Plot the cost landscape with optimisation path and updated bounds
+bounds = np.array([[1e-4, 1e-2], [1e-5, 1e-2]])
+pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15)
From 877f498e32bd0b9e29305109ea72099060b049e3 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Mon, 4 Dec 2023 11:46:38 +0000
Subject: [PATCH 050/101] Updt. ParameterSet cls for JSON import, updt
corresponding examples, tests
---
examples/scripts/ecm_CMAES.py | 58 +++++++++++---------
examples/scripts/parameters/ecm.json | 24 +++++++++
examples/scripts/spm_CMAES.py | 2 +-
examples/scripts/spm_IRPropMin.py | 2 +-
examples/scripts/spm_SNES.py | 2 +-
examples/scripts/spm_XNES.py | 2 +-
examples/scripts/spm_adam.py | 2 +-
examples/scripts/spm_descent.py | 2 +-
examples/scripts/spm_nlopt.py | 2 +-
examples/scripts/spm_pso.py | 2 +-
pybop/parameters/base_parameter_set.py | 73 +++++++++++++++++++++++---
tests/unit/test_parameter_sets.py | 4 +-
tests/unit/test_parameterisations.py | 8 +--
13 files changed, 137 insertions(+), 46 deletions(-)
create mode 100644 examples/scripts/parameters/ecm.json
diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py
index 35e2a9b76..fca71f2f7 100644
--- a/examples/scripts/ecm_CMAES.py
+++ b/examples/scripts/ecm_CMAES.py
@@ -2,32 +2,38 @@
import numpy as np
# Define the initial parameter set
-params = {
- "chemistry": "ecm",
- "Initial SoC": 0.5,
- "Initial temperature [K]": 25 + 273.15,
- "Cell capacity [A.h]": 5,
- "Nominal cell capacity [A.h]": 5,
- "Ambient temperature [K]": 25 + 273.15,
- "Current function [A]": 5,
- "Upper voltage cut-off [V]": 4.2,
- "Lower voltage cut-off [V]": 3.0,
- "Cell thermal mass [J/K]": 1000,
- "Cell-jig heat transfer coefficient [W/K]": 10,
- "Jig thermal mass [J/K]": 500,
- "Jig-air heat transfer coefficient [W/K]": 10,
- "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[
- "Open-circuit voltage [V]"
- ],
- "R0 [Ohm]": 0.001,
- "Element-1 initial overpotential [V]": 0,
- "Element-2 initial overpotential [V]": 0,
- "R1 [Ohm]": 0.0002,
- "R2 [Ohm]": 0.0003,
- "C1 [F]": 10000,
- "C2 [F]": 5000,
- "Entropic change [V/K]": 0.0004,
-}
+# Add definitions for R's, C's, and initial overpotentials for any additional RC elements
+# params = {
+# "chemistry": "ecm",
+# "Initial SoC": 0.5,
+# "Initial temperature [K]": 25 + 273.15,
+# "Cell capacity [A.h]": 5,
+# "Nominal cell capacity [A.h]": 5,
+# "Ambient temperature [K]": 25 + 273.15,
+# "Current function [A]": 5,
+# "Upper voltage cut-off [V]": 4.2,
+# "Lower voltage cut-off [V]": 3.0,
+# "Cell thermal mass [J/K]": 1000,
+# "Cell-jig heat transfer coefficient [W/K]": 10,
+# "Jig thermal mass [J/K]": 500,
+# "Jig-air heat transfer coefficient [W/K]": 10,
+# "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[
+# "Open-circuit voltage [V]"
+# ],
+# "R0 [Ohm]": 0.001,
+# "Element-1 initial overpotential [V]": 0,
+# "Element-2 initial overpotential [V]": 0,
+# "R1 [Ohm]": 0.0002,
+# "R2 [Ohm]": 0.0003,
+# "C1 [F]": 10000,
+# "C2 [F]": 5000,
+# "Entropic change [V/K]": 0.0004,
+# }
+
+# Params
+params = pybop.ParameterSet(
+ json_path="examples/scripts/parameters/ecm.json"
+).import_parameters()
# Define the model
model = pybop.empirical.Thevenin(
diff --git a/examples/scripts/parameters/ecm.json b/examples/scripts/parameters/ecm.json
new file mode 100644
index 000000000..17cb8a69e
--- /dev/null
+++ b/examples/scripts/parameters/ecm.json
@@ -0,0 +1,24 @@
+{
+ "chemistry": "ecm",
+ "Initial SoC": 0.5,
+ "Initial temperature [K]": 298.15,
+ "Cell capacity [A.h]": 5,
+ "Nominal cell capacity [A.h]": 5,
+ "Ambient temperature [K]": 298.15,
+ "Current function [A]": 5,
+ "Upper voltage cut-off [V]": 4.2,
+ "Lower voltage cut-off [V]": 3.0,
+ "Cell thermal mass [J/K]": 1000,
+ "Cell-jig heat transfer coefficient [W/K]": 10,
+ "Jig thermal mass [J/K]": 500,
+ "Jig-air heat transfer coefficient [W/K]": 10,
+ "Open-circuit voltage [V]": 3.7,
+ "R0 [Ohm]": 0.001,
+ "Element-1 initial overpotential [V]": 0,
+ "Element-2 initial overpotential [V]": 0,
+ "R1 [Ohm]": 0.0002,
+ "R2 [Ohm]": 0.0003,
+ "C1 [F]": 10000,
+ "C2 [F]": 5000,
+ "Entropic change [V/K]": 0.0004
+}
diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py
index 38ca0c47b..60e4e5166 100644
--- a/examples/scripts/spm_CMAES.py
+++ b/examples/scripts/spm_CMAES.py
@@ -2,7 +2,7 @@
import numpy as np
# Define model
-parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
# Fitting parameters
diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py
index 3142b5cfd..c39be7098 100644
--- a/examples/scripts/spm_IRPropMin.py
+++ b/examples/scripts/spm_IRPropMin.py
@@ -2,7 +2,7 @@
import numpy as np
# Define model
-parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
# Fitting parameters
diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py
index ba9dfd772..5ccfbb2fe 100644
--- a/examples/scripts/spm_SNES.py
+++ b/examples/scripts/spm_SNES.py
@@ -2,7 +2,7 @@
import numpy as np
# Define model
-parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
# Fitting parameters
diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py
index 25fa5ac41..7f288e8fc 100644
--- a/examples/scripts/spm_XNES.py
+++ b/examples/scripts/spm_XNES.py
@@ -2,7 +2,7 @@
import numpy as np
# Define model
-parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
# Fitting parameters
diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py
index c5be4186e..b793c6afe 100644
--- a/examples/scripts/spm_adam.py
+++ b/examples/scripts/spm_adam.py
@@ -2,7 +2,7 @@
import numpy as np
# Parameter set and model definition
-parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPMe(parameter_set=parameter_set)
# Fitting parameters
diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py
index 210d5b768..a1c6a22d6 100644
--- a/examples/scripts/spm_descent.py
+++ b/examples/scripts/spm_descent.py
@@ -2,7 +2,7 @@
import numpy as np
# Parameter set and model definition
-parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPMe(parameter_set=parameter_set)
# Fitting parameters
diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py
index eba4dd01a..5c7825932 100644
--- a/examples/scripts/spm_nlopt.py
+++ b/examples/scripts/spm_nlopt.py
@@ -10,7 +10,7 @@
]
# Define model
-parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.models.lithium_ion.SPM(
parameter_set=parameter_set, options={"thermal": "lumped"}
)
diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py
index 62c1f2c8a..1b6f84e6c 100644
--- a/examples/scripts/spm_pso.py
+++ b/examples/scripts/spm_pso.py
@@ -2,7 +2,7 @@
import numpy as np
# Define model
-parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
# Fitting parameters
diff --git a/pybop/parameters/base_parameter_set.py b/pybop/parameters/base_parameter_set.py
index dd1653d81..536af63d5 100644
--- a/pybop/parameters/base_parameter_set.py
+++ b/pybop/parameters/base_parameter_set.py
@@ -1,13 +1,74 @@
+# import pybamm
+# import json
+# import pybop
+
+# class ParameterSet:
+# """
+# Class for creating parameter sets in PyBOP.
+# """
+
+# def __new__(cls, method, name):
+# if method.casefold() == "pybamm":
+# return pybamm.ParameterValues(name).copy()
+# else:
+# raise ValueError("Only PyBaMM parameter sets are currently implemented")
+
+# def __init__(self):
+# pass
+
+# def import_parameters(self, json_path):
+# """
+# Import parameters from a JSON file.
+# """
+
+# # Read JSON file
+# with open(json_path, 'r') as file:
+# params = json.load(file)
+
+# # Set attributes based on the dictionary
+# for key, value in params.items():
+# if key == "Open-circuit voltage [V]":
+# # Assuming `pybop.empirical.Thevenin().default_parameter_values` is a dictionary
+# value = pybop.empirical.Thevenin().default_parameter_values["Open-circuit voltage [V]"]
+# setattr(self, key, value)
+
+import json
import pybamm
+import pybop
class ParameterSet:
"""
- Class for creating parameter sets in PyBOP.
+ Class for creating and importing parameter sets.
"""
- def __new__(cls, method, name):
- if method.casefold() == "pybamm":
- return pybamm.ParameterValues(name).copy()
- else:
- raise ValueError("Only PyBaMM parameter sets are currently implemented")
+ def __init__(self, json_path=None):
+ self.json_path = json_path
+
+ def import_parameters(self, json_path=None):
+ """
+ Import parameters from a JSON file.
+ """
+ if json_path is None:
+ json_path = self.json_path
+
+ # Read JSON file
+ with open(json_path, "r") as file:
+ params = json.load(file)
+
+ # Set attributes based on the dictionary
+ if "Open-circuit voltage [V]" in params:
+ params[
+ "Open-circuit voltage [V]"
+ ] = pybop.empirical.Thevenin().default_parameter_values[
+ "Open-circuit voltage [V]"
+ ]
+
+ return params
+
+ @classmethod
+ def pybamm(cls, name):
+ """
+ Create a PyBaMM parameter set.
+ """
+ return pybamm.ParameterValues(name).copy()
diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py
index 9cc478525..2ba6ce126 100644
--- a/tests/unit/test_parameter_sets.py
+++ b/tests/unit/test_parameter_sets.py
@@ -12,9 +12,9 @@ class TestParameterSets:
def test_parameter_set(self):
# Tests parameter set creation
with pytest.raises(ValueError):
- pybop.ParameterSet("pybamms", "Chen2020")
+ pybop.ParameterSet.pybamm("Chen2020s")
- parameter_test = pybop.ParameterSet("pybamm", "Chen2020")
+ parameter_test = pybop.ParameterSet.pybamm("Chen2020")
np.testing.assert_allclose(
parameter_test["Negative electrode active material volume fraction"], 0.75
)
diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py
index c18100638..e948a63f2 100644
--- a/tests/unit/test_parameterisations.py
+++ b/tests/unit/test_parameterisations.py
@@ -13,7 +13,7 @@ class TestModelParameterisation:
@pytest.mark.unit
def test_spm(self, init_soc):
# Define model
- parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+ parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
# Form dataset
@@ -66,7 +66,7 @@ def test_spm(self, init_soc):
@pytest.mark.unit
def test_spm_optimisers(self, init_soc):
# Define model
- parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+ parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
# Form dataset
@@ -159,10 +159,10 @@ def test_model_misparameterisation(self, init_soc):
# Define two different models with different parameter sets
# The optimisation should fail as the models are not the same
- parameter_set = pybop.ParameterSet("pybamm", "Chen2020")
+ parameter_set = pybop.ParameterSet.pybamm("Chen2020")
model = pybop.lithium_ion.SPM(parameter_set=parameter_set)
- second_parameter_set = pybop.ParameterSet("pybamm", "Ecker2015")
+ second_parameter_set = pybop.ParameterSet.pybamm("Ecker2015")
second_model = pybop.lithium_ion.SPM(parameter_set=second_parameter_set)
# Form observations
From a14af1ffd686f6cd62b43a093b984e75faba07c6 Mon Sep 17 00:00:00 2001
From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com>
Date: Mon, 4 Dec 2023 12:08:49 +0000
Subject: [PATCH 051/101] Split Problem into Base, Fitting and Design (#125)
* Split Problem into Base, Fitting and Design
* Add test for DesignProblem
* Add test for BaseProblem
* Rename fit_parameters to parameters
* Make model required except for BaseProblem
* Check for experiment prior to build
* Define parameters when not building
* Refactor test_problem and update Changelog
* Move setting of x0
* Add target definition
---
CHANGELOG.md | 3 +-
examples/notebooks/spm_nlopt.ipynb | 2 +-
examples/scripts/spm_CMAES.py | 2 +-
examples/scripts/spm_IRPropMin.py | 2 +-
examples/scripts/spm_SNES.py | 2 +-
examples/scripts/spm_XNES.py | 2 +-
examples/scripts/spm_adam.py | 2 +-
examples/scripts/spm_descent.py | 2 +-
examples/scripts/spm_nlopt.py | 2 +-
examples/scripts/spm_pso.py | 2 +-
pybop/__init__.py | 2 +-
pybop/_problem.py | 149 +++++++++++++++++++++------
pybop/models/base_model.py | 26 +++--
tests/unit/test_cost.py | 2 +-
tests/unit/test_optimisation.py | 8 +-
tests/unit/test_parameterisations.py | 6 +-
tests/unit/test_problem.py | 106 +++++++++++++------
17 files changed, 225 insertions(+), 95 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06710ee52..d16dc5cff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
- [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds standard plotting class `pybop.StandardPlot()` via plotly backend
- [#114](https://github.com/pybop-team/PyBOP/issues/114) - Adds `quick_plot()`, `plot_convergence()`, and `plot_cost2d()` methods
- [#116](https://github.com/pybop-team/PyBOP/issues/116) - Adds PSO, SNES, XNES, ADAM, and IPropMin optimisers to PintsOptimisers() class
+- [#38](https://github.com/pybop-team/PyBOP/issues/38) - Restructures the Problem classes ahead of adding a design optimisation example
## Bug Fixes
@@ -11,4 +12,4 @@
- Initial release
- Adds Pints, NLOpt, and SciPy optimisers
- Adds SumofSquareError and RootMeanSquareError cost functions
-- Adds Parameter and dataset classes
+- Adds Parameter and Dataset classes
diff --git a/examples/notebooks/spm_nlopt.ipynb b/examples/notebooks/spm_nlopt.ipynb
index 811575160..50f0877d3 100644
--- a/examples/notebooks/spm_nlopt.ipynb
+++ b/examples/notebooks/spm_nlopt.ipynb
@@ -334,7 +334,7 @@
"source": [
"# Define the cost to optimise\n",
"signal = \"Terminal voltage [V]\"\n",
- "problem = pybop.Problem(pyb_model, parameters, dataset, signal=signal)\n",
+ "problem = pybop.Problem(parameters, dataset, model=pyb_model, signal=signal)\n",
"cost = pybop.RootMeanSquaredError(problem)"
]
},
diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py
index 9a415a66a..c8af1eefa 100644
--- a/examples/scripts/spm_CMAES.py
+++ b/examples/scripts/spm_CMAES.py
@@ -35,7 +35,7 @@
]
# Generate problem, cost function, and optimisation class
-problem = pybop.Problem(model, parameters, dataset)
+problem = pybop.FittingProblem(model, parameters, dataset)
cost = pybop.SumSquaredError(problem)
optim = pybop.Optimisation(cost, optimiser=pybop.CMAES)
optim.set_max_iterations(100)
diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py
index fcc554b2a..b1e158903 100644
--- a/examples/scripts/spm_IRPropMin.py
+++ b/examples/scripts/spm_IRPropMin.py
@@ -33,7 +33,7 @@
]
# Generate problem, cost function, and optimisation class
-problem = pybop.Problem(model, parameters, dataset)
+problem = pybop.FittingProblem(model, parameters, dataset)
cost = pybop.SumSquaredError(problem)
optim = pybop.Optimisation(cost, optimiser=pybop.IRPropMin)
optim.set_max_iterations(100)
diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py
index 10e2491b5..2ff6e88ff 100644
--- a/examples/scripts/spm_SNES.py
+++ b/examples/scripts/spm_SNES.py
@@ -33,7 +33,7 @@
]
# Generate problem, cost function, and optimisation class
-problem = pybop.Problem(model, parameters, dataset)
+problem = pybop.FittingProblem(model, parameters, dataset)
cost = pybop.SumSquaredError(problem)
optim = pybop.Optimisation(cost, optimiser=pybop.SNES)
optim.set_max_iterations(100)
diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py
index 8bd14bd7c..d7e7a7172 100644
--- a/examples/scripts/spm_XNES.py
+++ b/examples/scripts/spm_XNES.py
@@ -33,7 +33,7 @@
]
# Generate problem, cost function, and optimisation class
-problem = pybop.Problem(model, parameters, dataset)
+problem = pybop.FittingProblem(model, parameters, dataset)
cost = pybop.SumSquaredError(problem)
optim = pybop.Optimisation(cost, optimiser=pybop.XNES)
optim.set_max_iterations(100)
diff --git a/examples/scripts/spm_adam.py b/examples/scripts/spm_adam.py
index 5b1b879cf..e3663d950 100644
--- a/examples/scripts/spm_adam.py
+++ b/examples/scripts/spm_adam.py
@@ -35,7 +35,7 @@
]
# Generate problem, cost function, and optimisation class
-problem = pybop.Problem(model, parameters, dataset)
+problem = pybop.FittingProblem(model, parameters, dataset)
cost = pybop.SumSquaredError(problem)
optim = pybop.Optimisation(cost, optimiser=pybop.Adam)
optim.set_max_iterations(100)
diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py
index 4f1d496d1..432289bc2 100644
--- a/examples/scripts/spm_descent.py
+++ b/examples/scripts/spm_descent.py
@@ -35,7 +35,7 @@
]
# Generate problem, cost function, and optimisation class
-problem = pybop.Problem(model, parameters, dataset)
+problem = pybop.FittingProblem(model, parameters, dataset)
cost = pybop.SumSquaredError(problem)
optim = pybop.Optimisation(cost, optimiser=pybop.GradientDescent)
optim.optimiser.set_learning_rate(0.025)
diff --git a/examples/scripts/spm_nlopt.py b/examples/scripts/spm_nlopt.py
index 67097d14b..a17d2da3a 100644
--- a/examples/scripts/spm_nlopt.py
+++ b/examples/scripts/spm_nlopt.py
@@ -31,7 +31,7 @@
# Define the cost to optimise
signal = "Terminal voltage [V]"
-problem = pybop.Problem(model, parameters, dataset, signal=signal, init_soc=0.98)
+problem = pybop.FittingProblem(model, parameters, dataset, signal=signal, init_soc=0.98)
cost = pybop.RootMeanSquaredError(problem)
# Build the optimisation problem
diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py
index 3a66e9020..09a5193c3 100644
--- a/examples/scripts/spm_pso.py
+++ b/examples/scripts/spm_pso.py
@@ -33,7 +33,7 @@
]
# Generate problem, cost function, and optimisation class
-problem = pybop.Problem(model, parameters, dataset)
+problem = pybop.FittingProblem(model, parameters, dataset)
cost = pybop.SumSquaredError(problem)
optim = pybop.Optimisation(cost, optimiser=pybop.PSO)
optim.set_max_iterations(100)
diff --git a/pybop/__init__.py b/pybop/__init__.py
index f652fe0be..48a24b2df 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -70,7 +70,7 @@
#
# Problem class
#
-from ._problem import Problem
+from ._problem import FittingProblem, DesignProblem
#
# Plotting class
diff --git a/pybop/_problem.py b/pybop/_problem.py
index d625b4eab..526e30bc9 100644
--- a/pybop/_problem.py
+++ b/pybop/_problem.py
@@ -1,30 +1,78 @@
import numpy as np
-class Problem:
+class BaseProblem:
"""
- Defines a PyBOP single output problem, follows the PINTS interface.
+ Defines the PyBOP base problem, following the PINTS interface.
"""
def __init__(
self,
- model,
parameters,
- dataset,
- signal="Terminal voltage [V]",
+ model=None,
check_model=True,
init_soc=None,
x0=None,
):
self._model = model
- self.parameters = parameters
- self.signal = signal
- self._model.signal = self.signal
- self._dataset = {o.name: o for o in dataset}
self.check_model = check_model
+ self.parameters = parameters
self.init_soc = init_soc
self.x0 = x0
self.n_parameters = len(self.parameters)
+
+ # Set bounds
+ self.bounds = dict(
+ lower=[param.bounds[0] for param in self.parameters],
+ upper=[param.bounds[1] for param in self.parameters],
+ )
+
+ # Sample from prior for x0
+ if x0 is None:
+ self.x0 = np.zeros(self.n_parameters)
+ for i, param in enumerate(self.parameters):
+ self.x0[i] = param.rvs(1)
+ elif len(x0) != self.n_parameters:
+ raise ValueError("x0 dimensions do not match number of parameters")
+
+ # Add the initial values to the parameter definitions
+ for i, param in enumerate(self.parameters):
+ param.update(value=self.x0[i])
+
+ def evaluate(self, parameters):
+ """
+ Evaluate the model with the given parameters and return the signal.
+ """
+ raise NotImplementedError
+
+ def evaluateS1(self, parameters):
+ """
+ Evaluate the model with the given parameters and return the signal and
+ its derivatives.
+ """
+ raise NotImplementedError
+
+
+class FittingProblem(BaseProblem):
+ """
+ Defines the problem class for a fitting (parameter estimation) problem.
+ """
+
+ def __init__(
+ self,
+ model,
+ parameters,
+ dataset,
+ signal="Terminal voltage [V]",
+ check_model=True,
+ init_soc=None,
+ x0=None,
+ ):
+ super().__init__(parameters, model, check_model, init_soc, x0)
+ if model is not None:
+ self._model.signal = signal
+ self.signal = signal
+ self._dataset = {o.name: o for o in dataset}
self.n_outputs = len([self.signal])
# Check that the dataset contains time and current
@@ -44,30 +92,71 @@ def __init__(
if len(self._target) != len(self._time_data):
raise ValueError("Time data and signal data must be the same length.")
- # Set bounds
- self.bounds = dict(
- lower=[param.bounds[0] for param in self.parameters],
- upper=[param.bounds[1] for param in self.parameters],
+ # Build the model
+ if self._model._built_model is None:
+ self._model.build(
+ dataset=self._dataset,
+ parameters={o.name: o.value for o in self.parameters},
+ check_model=self.check_model,
+ init_soc=self.init_soc,
+ )
+
+ def evaluate(self, parameters):
+ """
+ Evaluate the model with the given parameters and return the signal.
+ """
+
+ y = np.asarray(self._model.simulate(inputs=parameters, t_eval=self._time_data))
+
+ return y
+
+ def evaluateS1(self, parameters):
+ """
+ Evaluate the model with the given parameters and return the signal and
+ its derivatives.
+ """
+
+ y, dy = self._model.simulateS1(
+ inputs=parameters,
+ t_eval=self._time_data,
)
- # Sample from prior for x0
- if x0 is None:
- self.x0 = np.zeros(self.n_parameters)
- for i, param in enumerate(self.parameters):
- self.x0[i] = param.rvs(1)
- elif len(x0) != self.n_parameters:
- raise ValueError("x0 dimensions do not match number of parameters")
+ return (np.asarray(y), np.asarray(dy))
- # Add the initial values to the parameter definitions
- for i, param in enumerate(self.parameters):
- param.update(value=self.x0[i])
+ def target(self):
+ """
+ Returns the target dataset.
+ """
+ return self._target
- # Set the fitting parameters and build the model
- self.fit_parameters = {o.name: o.value for o in parameters}
- if self._model._built_model is None:
+
+class DesignProblem(BaseProblem):
+ """
+ Defines the problem class for a design optimiation problem.
+ """
+
+ def __init__(
+ self,
+ model,
+ parameters,
+ experiment,
+ check_model=True,
+ init_soc=None,
+ x0=None,
+ ):
+ super().__init__(parameters, model, check_model, init_soc, x0)
+ self.experiment = experiment
+ self._target = None
+
+ # Build the model if required
+ if experiment is not None:
+ # Leave the build until later to apply the experiment
+ self._model.parameters = {o.name: o.value for o in self.parameters}
+
+ elif self._model._built_model is None:
self._model.build(
- dataset=self._dataset,
- fit_parameters=self.fit_parameters,
+ experiment=self.experiment,
+ parameters={o.name: o.value for o in self.parameters},
check_model=self.check_model,
init_soc=self.init_soc,
)
@@ -86,11 +175,9 @@ def evaluateS1(self, parameters):
Evaluate the model with the given parameters and return the signal and
its derivatives.
"""
- for i, key in enumerate(self.fit_parameters):
- self.fit_parameters[key] = parameters[i]
y, dy = self._model.simulateS1(
- inputs=self.fit_parameters,
+ inputs=parameters,
t_eval=self._time_data,
)
diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py
index ced38437e..20100ab64 100644
--- a/pybop/models/base_model.py
+++ b/pybop/models/base_model.py
@@ -10,14 +10,14 @@ class BaseModel:
def __init__(self, name="Base Model"):
self.name = name
self.pybamm_model = None
- self.fit_parameters = None
+ self.parameters = None
self.dataset = None
self.signal = None
def build(
self,
dataset=None,
- fit_parameters=None,
+ parameters=None,
check_model=True,
init_soc=None,
):
@@ -26,10 +26,10 @@ def build(
For PyBaMM forward models, this method follows a
similar process to pybamm.Simulation.build().
"""
- self.fit_parameters = fit_parameters
+ self.parameters = parameters
self.dataset = dataset
- if self.fit_parameters is not None:
- self.fit_keys = list(self.fit_parameters.keys())
+ if self.parameters is not None:
+ self.fit_keys = list(self.parameters.keys())
if init_soc is not None:
self.set_init_soc(init_soc)
@@ -78,12 +78,12 @@ def set_params(self):
if self.model_with_set_params:
return
- if self.fit_parameters is not None:
+ if self.parameters is not None:
# set input parameters in parameter set from fitting parameters
- for i in self.fit_parameters.keys():
+ for i in self.parameters.keys():
self._parameter_set[i] = "[input]"
- if self.dataset is not None and self.fit_parameters is not None:
+ if self.dataset is not None and self.parameters is not None:
if "Current function [A]" not in self.fit_keys:
self.parameter_set["Current function [A]"] = pybamm.Interpolant(
self.dataset["Time [s]"].data,
@@ -109,9 +109,7 @@ def simulate(self, inputs, t_eval):
raise ValueError("Model must be built before calling simulate")
else:
if not isinstance(inputs, dict):
- inputs_dict = {
- key: inputs[i] for i, key in enumerate(self.fit_parameters)
- }
+ inputs_dict = {key: inputs[i] for i, key in enumerate(self.parameters)}
return self.solver.solve(
self.built_model, inputs=inputs_dict, t_eval=t_eval
)[self.signal].data
@@ -130,9 +128,7 @@ def simulateS1(self, inputs, t_eval):
raise ValueError("Model must be built before calling simulate")
else:
if not isinstance(inputs, dict):
- inputs_dict = {
- key: inputs[i] for i, key in enumerate(self.fit_parameters)
- }
+ inputs_dict = {key: inputs[i] for i, key in enumerate(self.parameters)}
sol = self.solver.solve(
self.built_model,
@@ -171,6 +167,8 @@ def predict(
"""
parameter_set = parameter_set or self._parameter_set
if inputs is not None:
+ if not isinstance(inputs, dict):
+ inputs = {key: inputs[i] for i, key in enumerate(self.parameters)}
parameter_set.update(inputs)
if self._unprocessed_model is not None:
if experiment is None:
diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py
index 0c7e329f3..ed14655ef 100644
--- a/tests/unit/test_cost.py
+++ b/tests/unit/test_cost.py
@@ -34,7 +34,7 @@ def test_costs(self, cut_off):
# Construct Problem
signal = "Voltage [V]"
model.parameter_set.update({"Lower voltage cut-off [V]": cut_off})
- problem = pybop.Problem(model, parameters, dataset, signal=signal, x0=x0)
+ problem = pybop.FittingProblem(model, parameters, dataset, signal=signal, x0=x0)
# Base Cost
base_cost = pybop.BaseCost(problem)
diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py
index 0f75e83a6..22822753d 100644
--- a/tests/unit/test_optimisation.py
+++ b/tests/unit/test_optimisation.py
@@ -29,8 +29,12 @@ def parameters(self):
@pytest.fixture
def problem(self, parameters, dataset):
- return pybop.Problem(
- pybop.lithium_ion.SPM(), parameters, dataset, signal="Terminal voltage [V]"
+ model = pybop.lithium_ion.SPM()
+ return pybop.FittingProblem(
+ model,
+ parameters,
+ dataset,
+ signal="Terminal voltage [V]",
)
@pytest.fixture
diff --git a/tests/unit/test_parameterisations.py b/tests/unit/test_parameterisations.py
index c18100638..398f137a3 100644
--- a/tests/unit/test_parameterisations.py
+++ b/tests/unit/test_parameterisations.py
@@ -44,7 +44,7 @@ def test_spm(self, init_soc):
# Define the cost to optimise
signal = "Terminal voltage [V]"
- problem = pybop.Problem(
+ problem = pybop.FittingProblem(
model, parameters, dataset, signal=signal, init_soc=init_soc
)
cost = pybop.RootMeanSquaredError(problem)
@@ -97,7 +97,7 @@ def test_spm_optimisers(self, init_soc):
# Define the cost to optimise
signal = "Terminal voltage [V]"
- problem = pybop.Problem(
+ problem = pybop.FittingProblem(
model, parameters, dataset, signal=signal, init_soc=init_soc
)
cost = pybop.SumSquaredError(problem)
@@ -193,7 +193,7 @@ def test_model_misparameterisation(self, init_soc):
# Define the cost to optimise
signal = "Terminal voltage [V]"
- problem = pybop.Problem(
+ problem = pybop.FittingProblem(
model, parameters, dataset, signal=signal, init_soc=init_soc
)
cost = pybop.RootMeanSquaredError(problem)
diff --git a/tests/unit/test_problem.py b/tests/unit/test_problem.py
index aa470d9b4..85d246df3 100644
--- a/tests/unit/test_problem.py
+++ b/tests/unit/test_problem.py
@@ -9,11 +9,13 @@ class TestProblem:
A class to test the problem class.
"""
- @pytest.mark.unit
- def test_problem(self):
- # Define model
- model = pybop.lithium_ion.SPM()
- parameters = [
+ @pytest.fixture
+ def model(self):
+ return pybop.lithium_ion.SPM()
+
+ @pytest.fixture
+ def parameters(self):
+ return [
pybop.Parameter(
"Negative electrode active material volume fraction",
prior=pybop.Gaussian(0.5, 0.02),
@@ -25,24 +27,68 @@ def test_problem(self):
bounds=[0.525, 0.75],
),
]
- signal = "Voltage [V]"
- # Form dataset
- x0 = np.array([0.52, 0.63])
- solution = self.getdata(model, x0)
+ @pytest.fixture
+ def experiment(self):
+ return pybamm.Experiment(
+ [
+ (
+ "Discharge at 1C for 5 minutes (1 second period)",
+ "Rest for 2 minutes (1 second period)",
+ "Charge at 1C for 5 minutes (1 second period)",
+ "Rest for 2 minutes (1 second period)",
+ ),
+ ]
+ * 2
+ )
- dataset = [
+ @pytest.fixture
+ def dataset(self, model, experiment):
+ model.parameter_set = model.pybamm_model.default_parameter_values
+ x0 = np.array([0.52, 0.63])
+ model.parameter_set.update(
+ {
+ "Negative electrode active material volume fraction": x0[0],
+ "Positive electrode active material volume fraction": x0[1],
+ }
+ )
+ solution = model.predict(experiment=experiment)
+ return [
pybop.Dataset("Time [s]", solution["Time [s]"].data),
pybop.Dataset("Current function [A]", solution["Current [A]"].data),
pybop.Dataset("Voltage [V]", solution["Terminal voltage [V]"].data),
]
+ @pytest.fixture
+ def signal(self):
+ return "Voltage [V]"
+
+ @pytest.mark.unit
+ def test_base_problem(self, parameters, model):
+ # Test incorrect number of initial parameter values
+ with pytest.raises(ValueError):
+ pybop._problem.BaseProblem(parameters, model=model, x0=np.array([]))
+
+ # Construct Problem
+ problem = pybop._problem.BaseProblem(parameters, model=model)
+
+ assert problem._model == model
+
+ with pytest.raises(NotImplementedError):
+ problem.evaluate([0.5, 0.5])
+ with pytest.raises(NotImplementedError):
+ problem.evaluateS1([0.5, 0.5])
+
+ @pytest.mark.unit
+ def test_fitting_problem(self, parameters, dataset, model, signal):
# Test incorrect number of initial parameter values
with pytest.raises(ValueError):
- pybop.Problem(model, parameters, dataset, signal=signal, x0=np.array([]))
+ pybop.FittingProblem(
+ model, parameters, dataset, signal=signal, x0=np.array([])
+ )
# Construct Problem
- problem = pybop.Problem(model, parameters, dataset, signal=signal)
+ problem = pybop.FittingProblem(model, parameters, dataset, signal=signal)
assert problem._model == model
assert problem._model._built_model is not None
@@ -50,25 +96,19 @@ def test_problem(self):
# Test model.simulate
model.simulate(inputs=[0.5, 0.5], t_eval=np.linspace(0, 10, 100))
- def getdata(self, model, x0):
- model.parameter_set = model.pybamm_model.default_parameter_values
+ @pytest.mark.unit
+ def test_design_problem(self, parameters, experiment, model):
+ # Test incorrect number of initial parameter values
+ with pytest.raises(ValueError):
+ pybop.DesignProblem(model, parameters, experiment, x0=np.array([]))
- model.parameter_set.update(
- {
- "Negative electrode active material volume fraction": x0[0],
- "Positive electrode active material volume fraction": x0[1],
- }
- )
- experiment = pybamm.Experiment(
- [
- (
- "Discharge at 1C for 5 minutes (1 second period)",
- "Rest for 2 minutes (1 second period)",
- "Charge at 1C for 5 minutes (1 second period)",
- "Rest for 2 minutes (1 second period)",
- ),
- ]
- * 2
- )
- sim = model.predict(experiment=experiment)
- return sim
+ # Construct Problem
+ problem = pybop.DesignProblem(model, parameters, experiment)
+
+ assert problem._model == model
+ assert (
+ problem._model._built_model is None
+ ) # building postponed with input experiment
+
+ # Test model.predict
+ model.predict(inputs=[0.5, 0.5], experiment=experiment)
From 0a4d0d21ed8990e9a7c9ea0123851659eb5d7268 Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Mon, 4 Dec 2023 19:28:00 +0000
Subject: [PATCH 052/101] Add pytest.ini with defaults, updt conftest for
default skip, add import/export parameter_set, add example .json parameters,
updt. nox for pytest-xdist parallel workers
---
conftest.py | 12 ++-
examples/scripts/ecm_CMAES.py | 42 ++-------
examples/scripts/ecm_parameters.py | 93 +++++++++++++++++++
.../parameters/fit_ecm_parameters.json | 24 +++++
.../{ecm.json => initial_ecm_parameters.json} | 2 +-
noxfile.py | 10 +-
pybop/__init__.py | 4 +-
pybop/_problem.py | 4 -
pybop/optimisation.py | 9 ++
pybop/parameters/base_parameter_set.py | 74 ---------------
.../{base_parameter.py => parameter.py} | 0
pybop/parameters/parameter_set.py | 93 +++++++++++++++++++
pytest.ini | 3 +
tests/unit/test_parameter_sets.py | 2 +-
14 files changed, 249 insertions(+), 123 deletions(-)
create mode 100644 examples/scripts/ecm_parameters.py
create mode 100644 examples/scripts/parameters/fit_ecm_parameters.json
rename examples/scripts/parameters/{ecm.json => initial_ecm_parameters.json} (94%)
delete mode 100644 pybop/parameters/base_parameter_set.py
rename pybop/parameters/{base_parameter.py => parameter.py} (100%)
create mode 100644 pybop/parameters/parameter_set.py
create mode 100644 pytest.ini
diff --git a/conftest.py b/conftest.py
index 508808aac..c632f3e50 100644
--- a/conftest.py
+++ b/conftest.py
@@ -29,7 +29,15 @@ def pytest_configure(config):
def pytest_collection_modifyitems(config, items):
- if config.getoption("--unit") and not config.getoption("--examples"):
+ unit_option = config.getoption("--unit")
+ examples_option = config.getoption("--examples")
+
+ if not unit_option and not examples_option:
+ skip_all = pytest.mark.skip(reason="need --unit or --examples option to run")
+ for item in items:
+ item.add_marker(skip_all)
+
+ elif unit_option and not examples_option:
skip_examples = pytest.mark.skip(
reason="need --examples option to run examples tests"
)
@@ -37,7 +45,7 @@ def pytest_collection_modifyitems(config, items):
if "examples" in item.keywords:
item.add_marker(skip_examples)
- if config.getoption("--examples") and not config.getoption("--unit"):
+ if examples_option and not unit_option:
skip_unit = pytest.mark.skip(reason="need --unit option to run unit tests")
for item in items:
if "unit" in item.keywords:
diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py
index fca71f2f7..d0d001c1f 100644
--- a/examples/scripts/ecm_CMAES.py
+++ b/examples/scripts/ecm_CMAES.py
@@ -1,43 +1,14 @@
import pybop
import numpy as np
-# Define the initial parameter set
-# Add definitions for R's, C's, and initial overpotentials for any additional RC elements
-# params = {
-# "chemistry": "ecm",
-# "Initial SoC": 0.5,
-# "Initial temperature [K]": 25 + 273.15,
-# "Cell capacity [A.h]": 5,
-# "Nominal cell capacity [A.h]": 5,
-# "Ambient temperature [K]": 25 + 273.15,
-# "Current function [A]": 5,
-# "Upper voltage cut-off [V]": 4.2,
-# "Lower voltage cut-off [V]": 3.0,
-# "Cell thermal mass [J/K]": 1000,
-# "Cell-jig heat transfer coefficient [W/K]": 10,
-# "Jig thermal mass [J/K]": 500,
-# "Jig-air heat transfer coefficient [W/K]": 10,
-# "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[
-# "Open-circuit voltage [V]"
-# ],
-# "R0 [Ohm]": 0.001,
-# "Element-1 initial overpotential [V]": 0,
-# "Element-2 initial overpotential [V]": 0,
-# "R1 [Ohm]": 0.0002,
-# "R2 [Ohm]": 0.0003,
-# "C1 [F]": 10000,
-# "C2 [F]": 5000,
-# "Entropic change [V/K]": 0.0004,
-# }
-
-# Params
+# Import the ECM parameter set from JSON
params = pybop.ParameterSet(
- json_path="examples/scripts/parameters/ecm.json"
-).import_parameters()
+ json_path="examples/scripts/parameters/initial_ecm_parameters.json"
+)
# Define the model
model = pybop.empirical.Thevenin(
- parameter_set=params, options={"number of rc elements": 2}
+ parameter_set=params.import_parameters(), options={"number of rc elements": 2}
)
# Fitting parameters
@@ -74,6 +45,11 @@
x, final_cost = optim.run()
print("Estimated parameters:", x)
+# Export the parameters to JSON
+params.export_parameters(
+ "examples/scripts/parameters/fit_ecm_parameters.json", fit_params=parameters
+)
+
# Plot the timeseries output
pybop.quick_plot(x, cost, title="Optimised Comparison")
diff --git a/examples/scripts/ecm_parameters.py b/examples/scripts/ecm_parameters.py
new file mode 100644
index 000000000..7938c3695
--- /dev/null
+++ b/examples/scripts/ecm_parameters.py
@@ -0,0 +1,93 @@
+import pybop
+import numpy as np
+
+# Define the initial parameter set
+# Add definitions for R's, C's, and initial overpotentials for any additional RC elements
+params = pybop.ParameterSet(
+ params_dict={
+ "chemistry": "ecm",
+ "Initial SoC": 0.5,
+ "Initial temperature [K]": 25 + 273.15,
+ "Cell capacity [A.h]": 5,
+ "Nominal cell capacity [A.h]": 5,
+ "Ambient temperature [K]": 25 + 273.15,
+ "Current function [A]": 5,
+ "Upper voltage cut-off [V]": 4.2,
+ "Lower voltage cut-off [V]": 3.0,
+ "Cell thermal mass [J/K]": 1000,
+ "Cell-jig heat transfer coefficient [W/K]": 10,
+ "Jig thermal mass [J/K]": 500,
+ "Jig-air heat transfer coefficient [W/K]": 10,
+ "Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[
+ "Open-circuit voltage [V]"
+ ],
+ "R0 [Ohm]": 0.001,
+ "Element-1 initial overpotential [V]": 0,
+ "Element-2 initial overpotential [V]": 0,
+ "R1 [Ohm]": 0.0002,
+ "R2 [Ohm]": 0.0003,
+ "C1 [F]": 10000,
+ "C2 [F]": 5000,
+ "Entropic change [V/K]": 0.0004,
+ }
+)
+
+# Define the model
+model = pybop.empirical.Thevenin(
+ parameter_set=params.import_parameters(), options={"number of rc elements": 2}
+)
+
+# Fitting parameters
+parameters = [
+ pybop.Parameter(
+ "R0 [Ohm]",
+ prior=pybop.Gaussian(0.0002, 0.0001),
+ bounds=[1e-4, 1e-2],
+ ),
+ pybop.Parameter(
+ "R1 [Ohm]",
+ prior=pybop.Gaussian(0.0001, 0.0001),
+ bounds=[1e-5, 1e-2],
+ ),
+]
+
+sigma = 0.001
+t_eval = np.arange(0, 900, 2)
+values = model.predict(t_eval=t_eval)
+CorruptValues = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))
+
+dataset = [
+ pybop.Dataset("Time [s]", t_eval),
+ pybop.Dataset("Current function [A]", values["Current [A]"].data),
+ pybop.Dataset("Voltage [V]", CorruptValues),
+]
+
+# Generate problem, cost function, and optimisation class
+problem = pybop.Problem(model, parameters, dataset)
+cost = pybop.SumSquaredError(problem)
+optim = pybop.Optimisation(cost, optimiser=pybop.CMAES)
+optim.set_max_iterations(100)
+
+x, final_cost = optim.run()
+print("Estimated parameters:", x)
+
+# Export the parameters to JSON
+params.export_parameters(
+ "examples/scripts/parameters/fit_ecm_parameters.json", fit_params=parameters
+)
+
+# Plot the timeseries output
+pybop.quick_plot(x, cost, title="Optimised Comparison")
+
+# Plot convergence
+pybop.plot_convergence(optim)
+
+# Plot the parameter traces
+pybop.plot_parameters(optim)
+
+# Plot the cost landscape
+pybop.plot_cost2d(cost, steps=15)
+
+# Plot the cost landscape with optimisation path and updated bounds
+bounds = np.array([[1e-4, 1e-2], [1e-5, 1e-2]])
+pybop.plot_cost2d(cost, optim=optim, bounds=bounds, steps=15)
diff --git a/examples/scripts/parameters/fit_ecm_parameters.json b/examples/scripts/parameters/fit_ecm_parameters.json
new file mode 100644
index 000000000..9429d2767
--- /dev/null
+++ b/examples/scripts/parameters/fit_ecm_parameters.json
@@ -0,0 +1,24 @@
+{
+ "chemistry": "ecm",
+ "Initial SoC": 0.5,
+ "Initial temperature [K]": 298.15,
+ "Cell capacity [A.h]": 5,
+ "Nominal cell capacity [A.h]": 5,
+ "Ambient temperature [K]": 298.15,
+ "Current function [A]": 5,
+ "Upper voltage cut-off [V]": 4.2,
+ "Lower voltage cut-off [V]": 3.0,
+ "Cell thermal mass [J/K]": 1000,
+ "Cell-jig heat transfer coefficient [W/K]": 10,
+ "Jig thermal mass [J/K]": 500,
+ "Jig-air heat transfer coefficient [W/K]": 10,
+ "Open-circuit voltage [V]": "Unable to write value to JSON file",
+ "R0 [Ohm]": 0.0009061740048547629,
+ "Element-1 initial overpotential [V]": 0,
+ "Element-2 initial overpotential [V]": 0,
+ "R1 [Ohm]": 0.00029231930041091097,
+ "R2 [Ohm]": 0.0003,
+ "C1 [F]": 10000,
+ "C2 [F]": 5000,
+ "Entropic change [V/K]": 0.0004
+}
diff --git a/examples/scripts/parameters/ecm.json b/examples/scripts/parameters/initial_ecm_parameters.json
similarity index 94%
rename from examples/scripts/parameters/ecm.json
rename to examples/scripts/parameters/initial_ecm_parameters.json
index 17cb8a69e..8da710968 100644
--- a/examples/scripts/parameters/ecm.json
+++ b/examples/scripts/parameters/initial_ecm_parameters.json
@@ -12,7 +12,7 @@
"Cell-jig heat transfer coefficient [W/K]": 10,
"Jig thermal mass [J/K]": 500,
"Jig-air heat transfer coefficient [W/K]": 10,
- "Open-circuit voltage [V]": 3.7,
+ "Open-circuit voltage [V]": "default",
"R0 [Ohm]": 0.001,
"Element-1 initial overpotential [V]": 0,
"Element-2 initial overpotential [V]": 0,
diff --git a/noxfile.py b/noxfile.py
index e1759a3d5..34a28e2e7 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -7,22 +7,20 @@
@nox.session
def unit(session):
session.run_always("pip", "install", "-e", ".[all]")
- session.install("pytest", "pytest-mock")
- session.run("pytest", "--unit", "-v", "--showlocals")
+ session.install("pytest", "pytest-mock", "pytest-xdist")
+ session.run("pytest", "--unit")
@nox.session
def coverage(session):
session.run_always("pip", "install", "-e", ".[all]")
- session.install("pytest", "pytest-cov", "pytest-mock")
+ session.install("pytest", "pytest-cov", "pytest-mock", "pytest-xdist")
session.run(
"pytest",
"--unit",
"--examples",
- "-v",
"--cov",
"--cov-report=xml",
- "--showlocals",
)
@@ -30,5 +28,5 @@ def coverage(session):
def notebooks(session):
"""Run the examples tests for Jupyter notebooks."""
session.run_always("pip", "install", "-e", ".[all]")
- session.install("pytest", "nbmake")
+ session.install("pytest", "nbmake", "pytest-xdist")
session.run("pytest", "--nbmake", "examples/", external=True)
diff --git a/pybop/__init__.py b/pybop/__init__.py
index 306cd0cac..fcc87287f 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -64,8 +64,8 @@
#
# Parameter classes
#
-from .parameters.base_parameter import Parameter
-from .parameters.base_parameter_set import ParameterSet
+from .parameters.parameter import Parameter
+from .parameters.parameter_set import ParameterSet
from .parameters.priors import Gaussian, Uniform, Exponential
#
diff --git a/pybop/_problem.py b/pybop/_problem.py
index c16aae531..a5770f9d1 100644
--- a/pybop/_problem.py
+++ b/pybop/_problem.py
@@ -58,10 +58,6 @@ def __init__(
elif len(x0) != self.n_parameters:
raise ValueError("x0 dimensions do not match number of parameters")
- # Add the initial values to the parameter definitions
- for i, param in enumerate(self.parameters):
- param.update(value=self.x0[i])
-
# Set the fitting parameters and build the model
self.fit_parameters = {o.name: o.value for o in parameters}
if self._model._built_model is None:
diff --git a/pybop/optimisation.py b/pybop/optimisation.py
index 6dc947de7..d051c30f5 100644
--- a/pybop/optimisation.py
+++ b/pybop/optimisation.py
@@ -116,6 +116,10 @@ def run(self):
elif not self.pints:
x, final_cost = self._run_pybop()
+ # Store the optimised parameters
+ if self.cost.problem is not None:
+ self.store_optimised_parameters(x)
+
return x, final_cost
def _run_pybop(self):
@@ -406,3 +410,8 @@ def set_max_unchanged_iterations(self, iterations=200, threshold=1e-11):
self._unchanged_max_iterations = iterations
self._unchanged_threshold = threshold
+
+ def store_optimised_parameters(self, x):
+ # Add the initial values to the parameter definitions
+ for i, param in enumerate(self.cost.problem.parameters):
+ param.update(value=x[i])
diff --git a/pybop/parameters/base_parameter_set.py b/pybop/parameters/base_parameter_set.py
deleted file mode 100644
index 536af63d5..000000000
--- a/pybop/parameters/base_parameter_set.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# import pybamm
-# import json
-# import pybop
-
-# class ParameterSet:
-# """
-# Class for creating parameter sets in PyBOP.
-# """
-
-# def __new__(cls, method, name):
-# if method.casefold() == "pybamm":
-# return pybamm.ParameterValues(name).copy()
-# else:
-# raise ValueError("Only PyBaMM parameter sets are currently implemented")
-
-# def __init__(self):
-# pass
-
-# def import_parameters(self, json_path):
-# """
-# Import parameters from a JSON file.
-# """
-
-# # Read JSON file
-# with open(json_path, 'r') as file:
-# params = json.load(file)
-
-# # Set attributes based on the dictionary
-# for key, value in params.items():
-# if key == "Open-circuit voltage [V]":
-# # Assuming `pybop.empirical.Thevenin().default_parameter_values` is a dictionary
-# value = pybop.empirical.Thevenin().default_parameter_values["Open-circuit voltage [V]"]
-# setattr(self, key, value)
-
-import json
-import pybamm
-import pybop
-
-
-class ParameterSet:
- """
- Class for creating and importing parameter sets.
- """
-
- def __init__(self, json_path=None):
- self.json_path = json_path
-
- def import_parameters(self, json_path=None):
- """
- Import parameters from a JSON file.
- """
- if json_path is None:
- json_path = self.json_path
-
- # Read JSON file
- with open(json_path, "r") as file:
- params = json.load(file)
-
- # Set attributes based on the dictionary
- if "Open-circuit voltage [V]" in params:
- params[
- "Open-circuit voltage [V]"
- ] = pybop.empirical.Thevenin().default_parameter_values[
- "Open-circuit voltage [V]"
- ]
-
- return params
-
- @classmethod
- def pybamm(cls, name):
- """
- Create a PyBaMM parameter set.
- """
- return pybamm.ParameterValues(name).copy()
diff --git a/pybop/parameters/base_parameter.py b/pybop/parameters/parameter.py
similarity index 100%
rename from pybop/parameters/base_parameter.py
rename to pybop/parameters/parameter.py
diff --git a/pybop/parameters/parameter_set.py b/pybop/parameters/parameter_set.py
new file mode 100644
index 000000000..18262ceb2
--- /dev/null
+++ b/pybop/parameters/parameter_set.py
@@ -0,0 +1,93 @@
+import json
+import types
+import pybamm
+import pybop
+
+
+class ParameterSet:
+ """
+ A class to manage the import and export of parameter sets for battery models.
+
+ Attributes:
+ json_path (str): The file path to a JSON file containing parameter data.
+ params (dict): A dictionary containing parameter key-value pairs.
+ """
+
+ def __init__(self, json_path=None, params_dict=None):
+ self.json_path = json_path
+ self.params = params_dict or {}
+ self.chemistry = None
+
+ def import_parameters(self, json_path=None):
+ """
+ Import parameters from a JSON file.
+ """
+
+ # Read JSON file
+ if not self.params and self.json_path:
+ with open(self.json_path, "r") as file:
+ self.params = json.load(file)
+ self._handle_special_cases()
+ if self.params["chemistry"] is not None:
+ self.chemistry = self.params["chemistry"]
+ return self.params
+
+ def _handle_special_cases(self):
+ """
+ Handles special cases for parameter values that require custom logic.
+ """
+ if (
+ "Open-circuit voltage [V]" in self.params
+ and self.params["Open-circuit voltage [V]"] == "default"
+ ):
+ self.params[
+ "Open-circuit voltage [V]"
+ ] = pybop.empirical.Thevenin().default_parameter_values[
+ "Open-circuit voltage [V]"
+ ]
+
+ def export_parameters(self, output_json_path, fit_params=None):
+ """
+ Export parameters to a JSON file.
+ """
+ if not self.params:
+ raise ValueError("No parameters to export. Please import parameters first.")
+
+ # Prepare a copy of the params to avoid modifying the original dict
+ exportable_params = {**{"chemistry": self.chemistry}, **self.params.copy()}
+
+ # Update parameter set
+ if fit_params is not None:
+ for i, param in enumerate(fit_params):
+ exportable_params.update({param.name: param.value})
+
+ # Replace non-serializable values
+ for key, value in exportable_params.items():
+ if isinstance(value, types.FunctionType) or not self.is_json_serializable(
+ value
+ ):
+ exportable_params[key] = "Unable to write value to JSON file"
+
+ # Write parameters to JSON file
+ with open(output_json_path, "w") as file:
+ json.dump(exportable_params, file, indent=4)
+
+ def is_json_serializable(self, value):
+ """
+ Check if the value is serializable to JSON.
+ """
+ try:
+ json.dumps(value)
+ return True
+ except (TypeError, OverflowError):
+ return False
+
+ @classmethod
+ def pybamm(cls, name):
+ """
+ Create a PyBaMM parameter set.
+ """
+ try:
+ return pybamm.ParameterValues(name).copy()
+ except ValueError as e:
+ raise ValueError(f"Parameter set '{name}' not found. PyBaMM error: {e}")
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 000000000..6d4fb1285
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+# pytest.ini
+[pytest]
+addopts = -n auto --dist loadscope --showlocals -v
diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py
index 2ba6ce126..fc9356d2f 100644
--- a/tests/unit/test_parameter_sets.py
+++ b/tests/unit/test_parameter_sets.py
@@ -12,7 +12,7 @@ class TestParameterSets:
def test_parameter_set(self):
# Tests parameter set creation
with pytest.raises(ValueError):
- pybop.ParameterSet.pybamm("Chen2020s")
+ pybop.ParameterSet.pybamm("sChen2010s")
parameter_test = pybop.ParameterSet.pybamm("Chen2020")
np.testing.assert_allclose(
From fc107aa418db75c709b704f54d6649c6e3ef845f Mon Sep 17 00:00:00 2001
From: Brady Planden
Date: Tue, 5 Dec 2023 10:07:31 +0000
Subject: [PATCH 053/101] Notebooks patch for updated Problem cls API
---
examples/notebooks/spm_nlopt.ipynb | 218 ++++++++++++++---------------
1 file changed, 109 insertions(+), 109 deletions(-)
diff --git a/examples/notebooks/spm_nlopt.ipynb b/examples/notebooks/spm_nlopt.ipynb
index 50f0877d3..b64a2a9ab 100644
--- a/examples/notebooks/spm_nlopt.ipynb
+++ b/examples/notebooks/spm_nlopt.ipynb
@@ -23,8 +23,8 @@
},
"outputs": [
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.1/2.1 MB\u001b[0m \u001b[31m19.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
"\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m139.4/139.4 kB\u001b[0m \u001b[31m14.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
@@ -191,38 +191,38 @@
},
"outputs": [
{
- "output_type": "display_data",
"data": {
- "text/plain": [
- "interactive(children=(FloatSlider(value=0.0, description='t', max=1.1333333333333333, step=0.01133333333333333…"
- ],
"application/vnd.jupyter.widget-view+json": {
+ "model_id": "8d003c14da5f4fa68284b28c15cee6e6",
"version_major": 2,
- "version_minor": 0,
- "model_id": "8d003c14da5f4fa68284b28c15cee6e6"
- }
+ "version_minor": 0
+ },
+ "text/plain": [
+ "interactive(children=(FloatSlider(value=0.0, description='t', max=1.1333333333333333, step=0.01133333333333333…"
+ ]
},
- "metadata": {}
+ "metadata": {},
+ "output_type": "display_data"
},
{
- "output_type": "execute_result",
"data": {
"text/plain": [
""
]
},
+ "execution_count": 25,
"metadata": {},
- "execution_count": 25
+ "output_type": "execute_result"
},
{
- "output_type": "display_data",
"data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAABdEAAAKxCAYAAAC8BuXeAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOzdeVxU1f8/8NeADKsDorIlAqYhmIDiRxzLHRnQj2n5KRdS3D8aaEhp8ckItcJc0ST5mAtW8nGpNFMDCUNccENHccMN0xIwU0BQAeH8/vDH/ToybIoMwuv5eNxH3Xve9973PTNzOZ65c45MCCFARERERERERERERETl6Ok6ASIiIiIiIiIiIiKi+oqd6EREREREREREREREFWAnOhERERERERERERFRBdiJTkRERERERERERERUAXaiExERERERERERERFVgJ3oREREREREREREREQVYCc6EREREREREREREVEF2IlORERERERERERERFQBdqITEREREREREREREVWAnehPyNHREZGRkbpOo1ZduXIFMpkMarW6WvFjxozBkCFDnmlOT2r//v3o2LEjDAwM6m2OpEkmk2Hr1q3P9Bzh4eGQyWSQyWR18vmti2uqrrq+9sasPr3ujwsPD4eHh4eu06Aq1EUbIyYmRronBAcHP9NzAfWr3VTX1061JykpCTKZDDk5OZXG1af3W21hO510ie30Z4vtdKL6o+zvrUwme6J/N5Xta2FhUeu5ke7Vu070MWPGQCaTYd68eRrbt27dCplMVuf5xMTEaH3zHzlyBJMmTarzfGqLtoa1vb09MjMz8fLLL+smqVoUEhICDw8PZGRkICYmRtfpPNcq+gw8qYo68TIzM+Hn51dr56lIhw4dkJmZ+Vx/fh+Xnp6OPn36wNraGkZGRmjTpg1mzZqF4uJiKeb9999HZmYmWrVqpcNMn39lf6MeX3x9fZ/ZOXX5j7za/vw/76rbgVddum5jKBQKZGZmYu7cuc/8XHXl77//hq+vL+zs7GBoaAh7e3sEBQUhLy9Pihk2bBgyMzOhVCp1mGnD9eh9Ui6Xo23btpgzZw4ePHjw1Mfu3r07MjMzYW5uDkD3n6Fnhe10qi620+s/ttOpIcrKysLUqVPRpk0bqb01aNAgJCYm6jq1StXk31W//vqr1uv5448/IJfLK/x7nJmZyS/DGrAmuk5A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SdsyMzMFAJGSklLhccoolUphaGgoAIhJkyaJkpKScjFsnD+dgIAAoa+vL0xNTTWWzz77TIp59HX/9ttvhbOzs9TZKMTDzlNjY2MRHx8vhKj83vP48cp88sknwsDAQKPxt2vXLqGvry+uXr0qbSv7PB8+fFja79HPwBdffCFcXFyk9R9++EGYmZlJnz1tn//ff/9d6Ovriz///FNje79+/URoaKjWa4iPjxd6enoiPT1da/nIkSNF//79NbbNmDFDuLq6SusODg7i7bffltZLS0uFlZWVWLFihRCi6ntuv379yjX4v/32W2FrayutV3XPfbQD71G9evUSnTp10nreRz1JG6O6r6uJiYnG39MZM2YILy+vCnOp7NyP1nPZPejjjz+WtpXdIzMzM4UQ1X/9qupEd3Bw0LhvOTs7ix49ekjrZffI//3vfxUep8zw4cOFsbGxACAGDRok7t27Vy6GnejPxqPtw9LSUpGQkCAMDQ3F+++/L86fPy8AiP3790vxN2/eFMbGxmLTpk1CCCE6duyo8eXWox7/DLKdzna6EGyns53OdjpRXTl06JAAIH788cdK42rSib5161aNfbW1q+fOnSt8fHw0tl27dk16uKVsv0c/s0II8Y9//EN88MEH0npVn/uKci8zcuRIjb9r7u7uWv+esRO94aqXw7mU+eKLL7Bu3TqcPXu2XNmJEycQExMjjR9mZmYGlUqF0tJSZGRkID09HU2aNEHnzp2lfdq2bVvuJ/e//vor+vXrhxdeeAFNmzbFqFGj8Pfff+Pu3bvVzrNJkyZ46623sH79egBAQUEBfvrpJ/j7+wMALl68iLt376J///4a+X7zzTcV/uSyzOOTXimVSo36qE7+crkcbm5u1b6eMmq1Gj169ICBgUGVsU9yjf3794eDgwPatGmDUaNGYf369TWq9zJdunSpVlxUVBQ8PT3RsmVLmJmZYeXKlbh69SoA4MaNG7h+/Tr69eundd8TJ04gPz8fzZs317i+jIyMCq9PrVajU6dOsLS01Fp+9uxZvPLKKxrbXnnllXLv90dfO1NTUygUCty4cUM6R0WvUUFBAS5duoTx48dr5Pzpp5+Wy/nRc9ja2kp1UhVtdb9x40a88sorsLGxgZmZGWbNmiXVc3WdPXsW7u7uMDU1lba98sorKC0tRXp6urStQ4cOGmMH2traVitvbR6t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9x3y8vIejuMOoGXLltDX18fPP/+M7OxsdOvWDUZGRkhISMDnn3+O999/XzrH5MmTsXz5csycORPjxo3D7t27sWnTJuzYsUOKCQkJQUBAALp06YKuXbsiMjJSo1OGiIjqh2HDhuGvv/5CWFgYsrKy4OHhgbi4OGlei6tXr0JP7/+exezevTtiY2Mxa9Ys/Oc//0G7du2wdetWvPzyy7q6BGpohNB1BkQSdqITETUwvXv3hqiksSGTyTBnzhyNzuvHWVpaIjY2ttLzuLm5Ye/evRWWjxkzBmPGjNHY5uDggJ07d1Z63N69e+P48eOVxhARUdVu3LiB0aNHIzMzE+bm5nBzc0N8fDz69++PpKQkHDp0CADQtm1bjf0yMjLg6OgIAwMDREVFYfr06RBCoG3btli8eDEmTpwoxVbny8+qOmWIiKj+CAoKqvBJ8qSkpHLb3nzzTbz55pvPOCtiZzI9tcb6HqrF62YnOhERERFRA7R69eoKy6r6whUAfH194evrW+V5qvPlZ2WdMkRERERE9R3HRCciIiIiIiIiIp2oalLrRk0m03UG9QPr4cmx7h6qhXpgJzoREREREREREemEXC7XdQpERFViJzoRERERERERERERUQXYiU5ERERERERERDpRWlqq6xSIiKrETnQiIiIiIiIiItKJe/fu6TqF+q+KycAbrMZ63c9CY63LWrxudqITEREREREREREREVWAnehERERERERERET1jUym6wzqB9bDk2PdPVQL9cBOdCIiIiIiIiIiIiKiCrATnYiIiIiIiIiIiIioAuxEJyIiIiIiIiIiIiKqADvRiYiIiIiIiIiIqH4RQtcZEEnYiU5ERERERERERDrRpEkTXadQ/7EzmZ5WY30P1eJ1sxOdiIiIiIiIiIh0wtDQUNcpEBFViZ3oRERERERERERE9Y1MpusM6gfWw5Nj3T1UC/XATnQiIiIiIiIiItIJ0ViHmSCi5wo70YmIiIiIiIiISCfu3r2r6xSIiKrETnQiIiIiIiIiIiKqX/grBapH2IlORERERERERERUX7EzmZ5WY30P1eJ1sxOdiIiIiKgBWrFiBdzc3KBQKKBQKKBUKvHLL78AAG7duoWpU6fC2dkZxsbGaN26NaZNm4bc3FyNY1y9ehUDBw6EiYkJrKysMGPGDDx48EAjJikpCZ07d4ahoSHatm2LmJiY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Rb1D5ERENU+l1kQvKCjAkSNH8P7778PR0RFNmjSBra2tQsmXmJgY5ObmwsHBQZxnaWmJZs2aITo6GoC8HleHDh1gbGwstnF0dERGRgauX78utnm9j8I2hX0Uh/W3iIiIiIiIiKg+MDIygqWlZamThoYG7OzskJaWhpiYGHHdyMhIFBQUwNbWtti+586diytXriAuLk6cAHlpxm3btlXH7tUPEomyI6gZeBzeHo+dXCUch0pNoqekpCAzMxNLly6Fk5MTfv/9dwwbNgzDhw/H8ePHAchrbWloaBT5tNLY2FistZWUlKSQQC9cXristDYZGRl4+fJlsfGx/hYRERERERER0f9YWVnByckJXl5eOH/+PE6fPg1vb2+MGTMGpqamAIBHjx7B0tIS58+fBwDIZDK0b99eYQKAZs2aoUWLFhXavopKpaamqC6pryVIqEaqcDmX0hQUFAAAhg4dCl9fXwBAp06dcObMGQQFBaF3796VubkKY/0tIiIiIiIiIiJFu3btgre3N/r37w8VFRWMGDEC69atE5fn5uYiPj4eL168qPRta2lpVXqfRESVrVKT6I0bN4aamhratm2rML+wXjkg/7QyJycHaWlpClejJycni7W2ZDKZ+Onm68sLlxX+LJz3ehs9PT0OwERERERERERE5WRoaIiQkJASl5ubm0Mo46rgspYTEdVmlfqdGQ0NDXTr1g3x8fEK82/duoXmzZsDAGxsbKCuro6IiAhxeXx8PB48eAA7OzsAgJ2dHa5evapwY4vw8HDo6emJCXo7OzuFPgrbFPZBRERERERERERERPSuKnwlemZmJu7cuSM+v3fvHuLi4mBoaIhmzZph9uzZGD16NHr16oW+ffvi6NGjOHTokHhnZn19fXh6esLPzw+GhobQ09PDtGnTYGdnhx49egAABgwYgLZt22L8+PFYvnw5kpKS8OWXX2Lq1KliOZbJkydjw4YN+PzzzzFhwgRERkZi7969OHLkSCUcFiIiIiIiIiIiqmovXryAnp6essOo2XiVP72r+vo7VIn7XeEk+sWLF9G3b1/xuZ+fHwDAzc0NwcHBGDZsGIKCghAYGIjp06fDwsIC+/btQ8+ePcV1Vq9eLdbYys7OhqOjI7799ltxuaqqKg4fPowpU6bAzs4O2tracHNzw1dffSW2adGiBY4cOQJfX1+sXbsWTZs2xffffw9HR8e3OhBERERERERERFS9WAaGiGqDCifR+/TpU+YAN2HCBEyYMKHE5Zqamti4cSM2btxYYpvmzZvj119/LTOW2NjY0gMmIiIiIiIiIiKqbSQSZUegXIX5x/p+HN4Fj51cJRyHSq2JTkRERERERERERERUlzCJTkRERERERERERERUAibRiYiIiIiIiIiIiIhKwCQ6EREREVEdtGnTJnTs2BF6enrQ09ODnZ0dfvvtN3H55s2b0adPH+jp6UEikSAtLa1IH6mpqXBxcYGenh4MDAzg6emJzMxMhTZXrlzBhx9+CE1NTZiZmWH58uVF+gkNDYWlpSU0NTXRoUOHMu99RERERK/hzVfpXdXX36FK3G8m0YmIiIiI6qCmTZti6dKliImJwcWLF9GvXz8MHToU169fBwC8ePECTk5O+OKLL0rsw8XFBdevX0d4eDgOHz6MEydOYNKkSeLyjIwMDBgwAM2bN0dMTAy++eYbBAQEYPPmzWKbM2fOYOzYsfD09ERsbCycnZ3h7OyMa9euVd3OExFRrSHhjQ+JqBZQU3YARERERERU+YYMGaLw/Ouvv8amTZtw9uxZtGvXDj4+PgCAqKioYte/ceMGjh49igsXLqBr164AgPXr12PQoEFYsWIFTE1NsWvXLuTk5GDr1q3Q0NBAu3btEBcXh1WrVonJ9rVr18LJyQmzZ88GACxevBjh4eHYsGEDgoKCit12dnY2srOzxecZGRnvciiIiKgGa9CggbJDqLnq+wcMhVcR1/fj8C547OQq4TjwSnQiIiIiojouPz8fu3fvRlZWFuzs7Mq1TnR0NAwMDMQEOgA4ODhARUUF586dE9v06tULGhoaYhtHR0fEx8fj2bNnYhsHBweFvh0dHREdHV3itgMDA6Gvry9OZmZm5d5XIiIiIqLKxiQ6EREREVEddfXqVejo6EAqlWLy5Mn45Zdf0LZt23Ktm5SUhCZNmijMU1NTg6GhIZKSksQ2xsbGCm0Kn5fVpnB5cfz9/ZGeni5ODx8+LFfMRERERERVgeVciIiIiIjqKAsLC8TFxSE9PR0//fQT3NzccPz48XIn0pVFKpVCKpUqOwwiIqoGL168gJ6enrLDICIqFZPoRERERER1lIaGBlq3bg0AsLGxwYULF7B27Vp89913Za4rk8mQkpKiMC8vLw+pqamQyWRim+TkZIU2hc/LalO4nIiI6jehsO41EVENxnIuRERERET1REFBgcINO0tjZ2eHtLQ0xMTEiPMiIyNRUFAAW1tbsc2JEyeQm5srtgkPD4eFhQUaNmwotomIiFDoOzw8vNy12YmIiOo9ftBA76q+/g5V4n4ziU5EREREVAf5+/vjxIkTuH//Pq5evQp/f39ERUXBxcUFgLxWeVxcHO7cuQNAXj89Li4OqampAAArKys4OTnBy8sL58+fx+nTp+Ht7Y0xY8bA1NQUADBu3DhoaGjA09MT169fx549e7B27Vr4+fmJccyYMQNHjx7FypUrcfPmTQQEBODixYvw9vau5iNCREREtUp9TfxSjcQkOhERERFRHZSSkgJXV1dYWFigf//+uHDhAsLCwvDRRx8BAIKCgtC5c2d4eXkBAHr16oXOnTvj4MGDYh+7du2CpaUl+vfvj0GDBqFnz57YvHmzuFxfXx+///477t27BxsbG8ycORMLFizApEmTxDb29vYICQnB5s2bYW1tjZ9++gn79+9H+/btq+lIEBER1VISibIjqBl4HN4ej51cJRwHJtGJiOqh58+fw8fHB82bN4eWlhbs7e1x4cIFcXlycjLc3d1hamqKBg0awMnJCbdv31boIyEhAcOGDYORkRH09PQwatSoIjVv3xQYGIhu3bpBV1cXTZo0gbOzM+Lj4xXa9OnTBxKJRGGaPHly5e08EVE9sWXLFty/fx/Z2dlISUnBH3/8ISbQASAgIACCIBSZ3N3dxTaGhoYICQnB8+fPkZ6ejq1bt0JHR0dhOx07dsTJkyfx6tUr/P3335gzZ06RWEaOHIn4+HhkZ2fj2rVrGDRoUJXtNxERERFRZWMSnYioHpo4cSLCw8Oxc+dOXL16FQMGDICDgwMePXoEQRDg7OyMu3fv4sCBA4iNjUXz5s3h4OCArKwsAEBWVhYGDBgAiUSCyMhInD59Gjk5ORgyZAgKCgpK3O7x48cxdepUnD17FuHh4cjNzcWAAQPEfgt5eXkhMTFRnJYvX16lx4OIiIiIiIiIqCRqyg6AiIiq18uXL7Fv3z4cOHAAvXr1AiC/GvHQoUPYtGkTXF1dcfbsWVy7dg3t2rUDAGzatAkymQw//vgjJk6ciNOnT+P+/fuIjY2Fnp4eAGD79u1o2LAhIiMj4eDgUOy2jx49qvA8ODgYTZo0QUxMjBgLADRo0AAymawqdp+IiIiIiGoQCctNEFEtwCvRiYjqmby8POTn50NTU1NhvpaWFk6dOoXs7GwAUFiuoqICqVSKU6dOAQCys7MhkUgglUrFNpqamlBRURHblEd6ejoAebmA1+3atQuNGzdG+/bt4e/vjxcvXpTYR3Z2NjIyMhQmIiIiIiKqHRo0aKDsEGo+3mCT3lV9/R2qxP1mEp2IqJ7R1dWFnZ0dFi9ejMePHyM/Px8//PADoqOjkZiYCEtLSzRr1gz+/v549uwZcnJysGzZMvz9999ITEwEAPTo0QPa2tqYM2cOXrx4gaysLMyaNQv5+flim7IUFBTAx8cHH3zwgcLN5caNG4cffvgBx44dg7+/P3bu3IlPPvmkxH4CAwOhr68vTmZmZu92gIiIiIiIiEj56mvil2okJtGJiOqhnTt3QhAEvPfee5BKpVi3bh3Gjh0LFRUVqKur4+eff8atW7dgaGiIBg0a4NixYxg4cCBUVORvG0ZGRggNDcWhQ4ego6MDfX19pKWloUuXLmKbskydOhXXrl3D7t27FeZPmjQJjo6O6NChA1xcXLBjxw788ssvSEhIKLYff39/pKeni9PDhw/f7eAQERERERHVBCx1I8fj8PZ47OQq4TiwJjoRUT3UqlUrHD9+HFlZWcjIyICJiQlGjx6Nli1bAgBsbGwQFxeH9PR05OTkwMjICLa2tujatavYx4ABA5CQkIAnT55ATU0NBgYGkMlkYh+l8fb2xuHDh3HixAk0bdq01La2trYAgDt37qBVq1ZFlkulUoWyMkREREREVHu8fPlSvM8SEVFNxSQ6EVE9pq2tDW1tbTx79gxhYWFYvny5wnJ9fX0AwO3bt3Hx4kUsXry4SB+NGzcGAERGRiIlJQUff/xxidsTBAHTpk3DL7/8gqioKLRo0aLMGOPi4gAAJiYm5d0tIiKqgxoAQFYWoKqq7FCIqL7KylJ2BFUmNTUV06ZNw6FDh6CiooIRI0Zg7dq10NHRKXW96OhozJs3D+fOnYOqqio6deqEsLAwaGlplXvbBQUF7xo+EVGVYxKdiKgeCgsLgyAIsLCwwJ07dzB79mxYWlrCw8MDABAaGgojIyM0a9YMV69exYwZM+Ds7IwBAwaIfWzbtg1WVlYwMjJCdHQ0ZsyYAV9fX1hYWIht+vfvj2HDhsHb2xuAvIRLSEgIDhw4AF1dXSQlJQGQJ+u1tLSQkJCAkJAQDBo0CI0aNcKVK1fg6+uLXr16oWPHjtV4hIiIqKbJAgBTU2WHQUT1WF2+VtrFxQWJiYkIDw9Hbm4uPDw8MGnSJISEhJS4TnR0NJycnODv74/169dDTU0Nly9fLnd5RyKi2oRJdCKieig9PR3+/v74+++/YWhoiBEjRuDrr7+Guro6ACAxMRF+fn5ITk6GiYkJXF1dMX/+fIU+4uPj4e/vj9TUVJibm2PevHnw9fVVaFNY7qXQpk2bAAB9+vRRaLdt2za4u7tDQ0MDf/zxB9asWYOsrCyYmZlhxIgR+PLLL6vgKBARERER0Y0bN3D06FFcuHBBLN+4fv16DBo0CCtWrIBpCR9g+vr6Yvr06Zg7d6447/ULat6UnZ2N7Oxs8XlGRkYl7QERUdVjEp2IqB4aNWoURo0aVeLy6dOnY/r06aX2sXTpUixdurTUNvfv31d4LpRxd3UzMzMcP3681DZERFQ/aQNIfPyYdXOJSGkyMjLq5DdioqOjYWBgoHD/IwcHB6ioqODcuXMYNmxYkXVSUlJw7tw5uLi4wN7eHgkJCbC0tMTXX3+Nnj17FrudwMBALFq0qMr2o04r4/+oOqu+7ndVqK/HshL3m0l0IiIiIiKq8V4AgLa2fCIiUob8fGVHUCWSkpLQpEkThXlqamowNDQUyy++6e7duwCAgIAArFixAp06dcKOHTvQv39/XLt2DW3atCmyjr+/P/z8/MTnGRkZMDMzq8Q9ISKqOixURURERERERERUx8ydOxcSiaTU6ebNm2/Vd+HNQD/99FN4eHigc+fOWL16NSwsLLB169Zi15FKpdDT01OYqAwSibIjqBl4HN4ej51cJRwHXolORERERERERFTHzJw5E+7u7qW2admyJWQyGVJSUhTm5+XlITU1FTKZrNj1TExMAABt27ZVmG9lZYUHDx68fdBERDUUk+hERERERERERHWMkZERjIyMymxnZ2eHtLQ0xMTEwMbGBgAQGRmJgoIC2NraFruOubk5TE1NER8frzD/1q1bGDhwYIXi1GaZLiKqBVjOhYiIiIiIiIionrKysoKTkxO8vLxw/vx5nD59Gt7e3hgzZgxM//9Gqo8ePYKlpSXOnz8PAJBIJJg9ezbWrVuHn376CXfu3MH8+fNx8+ZNeHp6KnN3iIiqRIWT6CdOnMCQIUNgamoKiUSC/fv3Kyx3d3cvUmPLyclJoU1qaipcXFygp6cHAwMDeHp6IjMzU6HNlStX8OGHH0JTUxNmZmZYvnx5kVhCQ0NhaWkJTU1NdOjQAb/++mtFd4eIiIiIiIiIqF7btWsXLC0t0b9/fwwaNAg9e/bE5s2bxeW5ubmIj4/HixcvxHk+Pj7w9/eHr68vrK2tERERgfDwcLRq1UoZu1C3CYKyI6Darr7+DlXifle4nEtWVhasra0xYcIEDB8+vNg2Tk5O2LZtm/hcKpUqLHdxcUFiYiLCw8ORm5sLDw8PTJo0CSEhIQDkd2geMGAAHBwcEBQUhKtXr2LChAkwMDDApEmTAABnzpzB2LFjERgYiH/9618ICQmBs7MzLl26hPbt21d0t4iIiIiIiIiI6iVDQ0MxJ1Mcc3NzCMUko+bOnYu5c+e+07ZfvnzJm4xS8epr4pdqpAon0QcOHFhmfSupVFrizSdu3LiBo0eP4sKFC+jatSsAYP369Rg0aBBWrFgBU1NT7Nq1Czk5Odi6dSs0NDTQrl07xMXFYdWqVWISfe3atXBycsLs2bMBAIsXL0Z4eDg2bNiAoKCgYrednZ2N7Oxs8XlGRkZFd5+IiIiIiIiIiCpJQUGBskOouSQSZUdQM/A4vD0eO7lKOA5VUhM9KioKTZo0gYWFBaZMmYKnT5+Ky6Kjo2FgYCAm0AHAwcEBKioqOHfunNimV69e0NDQENs4OjoiPj4ez549E9s4ODgobNfR0RHR0dElxhUYGAh9fX1xMjMzq5T9JSIiIiKqaTZt2oSOHTtCT08Penp6sLOzw2+//SYuf/XqFaZOnYpGjRpBR0cHI0aMQHJyskIfb5ZplEgk2L17t0KbqKgodOnSBVKpFK1bt0ZwcHCRWDZu3Ahzc3NoamrC1tZWrKlLRERERFQbVHoS3cnJCTt27EBERASWLVuG48ePY+DAgcjPzwcAJCUloUmTJgrrqKmpwdDQEElJSWIbY2NjhTaFz8tqU7i8OP7+/khPTxenhw8fvtvOEhERERHVUE2bNsXSpUsRExODixcvol+/fhg6dCiuX78OAPD19cWhQ4cQGhqK48eP4/Hjx8WWa9y2bRsSExPFydnZWVx27949DB48GH379kVcXBx8fHwwceJEhIWFiW327NkDPz8/LFy4EJcuXYK1tTUcHR2RkpJS5ceAiIiIiKgyVLicS1nGjBkjPu7QoQM6duyIVq1aISoqCv3796/szVWIVCotUp+diIiIiKguGjJkiMLzr7/+Gps2bcLZs2fRtGlTbNmyBSEhIejXrx8AebLcysoKZ8+eRY8ePcT1DAwMSizVGBQUhBYtWmDlypUAACsrK5w6dQqrV6+Go6MjAGDVqlXw8vKCh4eHuM6RI0ewdevWd66jS0RERERUHaqknMvrWrZsicaNG+POnTsAAJlMVuSqk7y8PKSmpoon5zKZrMhXSQufl9WmpBN8IiIiIqL6Kj8/H7t370ZWVhbs7OwQExOD3NxchfKIlpaWaNasWZHyiFOnTkXjxo3RvXt3bN26VeHGcmWVWMzJyUFMTIxCGxUVFTg4OJRahjE7OxsZGRkKExERERGRslT6lehv+vvvv/H06VOYmJgAAOzs7JCWloaYmBjY2NgAACIjI1FQUABbW1uxzbx585Cbmwt1dXUAQHh4OCwsLNCwYUOxTUREBHx8fMRthYeHw87Orqp3iYiIiIioVrh69Srs7Ozw6tUr6Ojo4JdffkHbtm0RFxcHDQ0NGBgYKLR/szziV199hX79+qFBgwb4/fff8dlnnyEzMxPTp08HUHKJxYyMDLx8+RLPnj1Dfn5+sW1u3rxZYtyBgYFYtGjRO+49ERFRHfH554ChobKjqH6PHys7grrjhx+AixeVHUX1i42ttK4qnETPzMwUryoH5HUQ4+LiYGhoCENDQyxatAgjRoyATCZDQkICPv/8c7Ru3Vr8OqeVlRWcnJzg5eWFoKAg5ObmwtvbG2PGjIGpqSkAYNy4cVi0aBE8PT0xZ84cXLt2DWvXrsXq1avF7c6YMQO9e/fGypUrMXjwYOzevRsXL17E5s2b3/WYEBERERHVCRYWFoiLi0N6ejp++uknuLm54fjx4+Vef/78+eLjzp07IysrC998842YRK8q/v7+8PPzE59nZGTAzMysSrdJRERU4zRpAty/D1TgvbtOMjJSdgS1V+F9KW/ckE/1VSX8DlU4iX7x4kX07dtXfF54cuvm5oZNmzbhypUr2L59O9LS0mBqaooBAwZg8eLFCrXId+3aBW9vb/Tv3x8qKioYMWIE1q1bJy7X19fH77//jqlTp8LGxgaNGzfGggULMGnSJLGNvb09QkJC8OWXX+KLL75AmzZtsH//frRv3/6tDgQRERERUV2joaGB1q1bAwBsbGxw4cIFrF27FqNHj0ZOTg7S0tIUrkYvqzyira0tFi9ejOzsbEil0hJLLOrp6UFLSwuqqqpQVVWtcBlG3suIiKj+0NbWVnYINdeePUBYGPBaKbV6p1UrwMJC2VHUXnPnyo/fixfKjkR59PWBYcPeuZsKJ9H79OmjUAfxTWFhYWX2YWhoiJCQkFLbdOzYESdPniy1zciRIzFy5Mgyt0dEREREREBBQQGys7NhY2MDdXV1REREYMSIEQCA+Ph4PHjwoNTyiHFxcWjYsKGY4Lazs8Ovv/6q0Ob1EosaGhqwsbFBREQEnJ2dxRgiIiLg7e1dBXtIRERUh5ibA59+quwoqDbT1QVcXZUdRZ1Q5TXRiYiIiIio+vn7+2PgwIFo1qwZnj9/jpCQEERFRSEsLAz6+vrw9PSEn58fDA0Noaenh2nTpsHOzg49evQAABw6dAjJycno0aMHNDU1ER4ejiVLlmDWrFniNiZPnowNGzbg888/x4QJExAZGYm9e/fiyJEjYhs/Pz+4ubmha9eu6N69O9asWYOsrCx4eHhU+zEhIiIiInobTKITEREREdVBKSkpcHV1RWJiIvT19dGxY0eEhYXho48+AgCsXr1aLK2YnZ0NR0dHfPvtt+L66urq2LhxI3x9fSEIAlq3bo1Vq1bBy8tLbNOiRQscOXIEvr6+WLt2LZo2bYrvv/9evB8SAIwePRr//PMPFixYgKSkJHTq1AlHjx4tcrNRIiKqn169egU9PT1lh0FEVCom0YmIiIiI6qAtW7aUulxTUxMbN27Exo0bi13u5OQEJyenMrfTp08fxMbGltrG29ub5VuIiKhY+fn5yg6BiKhMKsoOgIiIiIiIiIiIiIiopmISnYiIiIiIiIiIiIioBEyiExERERERERERERGVgEl0IqJ66Pnz5/Dx8UHz5s2hpaUFe3t7XLhwQVyenJwMd3d3mJqaokGDBnBycsLt27cV+khISMCwYcNgZGQEPT09jBo1CsnJyWVue+PGjTA3N4empiZsbW1x/vx5heWvXr3C1KlT0ahRI+jo6GDEiBHl6peIiIiIiIiIqCowiU5EVA9NnDgR4eHh2LlzJ65evYoBAwbAwcEBjx49giAIcHZ2xt27d3HgwAHExsaiefPmcHBwQFZWFgAgKysLAwYMgEQiQWRkJE6fPo2cnBwMGTIEBQUFJW53z5498PPzw8KFC3Hp0iVYW1vD0dERKSkpYhtfX18cOnQIoaGhOH78OB4/fozhw4dX+TEhIiIiIiIiIiqOmrIDUCZBEAAAGRkZSo6EiOqrwvGncDyqDi9fvsS+fftw4MAB9OrVCwAQEBCAQ4cOYdOmTXB1dcXZs2dx7do1tGvXDgCwadMmyGQy/Pjjj5g4cSJOnz6N+/fvIzY2Fnp6egCA7du3o2HDhoiMjISDg0Ox2161ahW8vLzg4eEBAAgKCsKRI0ewdetWzJ07F+np6diyZQtCQkLQr18/AMC2bdtgZWWFs2fPokePHkX6zM7ORnZ2tvg8PT0dAMf2+uz58+d49eoVnj9/Dm1t7Xq3faoZlDG+12U8byeimoBje+UqPI48ZyIiZSrv2F6vk+hPnz4FAJiZmSk5EiKq754/fw59ff1q2VZeXh7y8/OhqampMF9LSwunTp3C6NGjAUBhuYqKCqRSKU6dOoWJEyciOzsbEokEUqlUbKOpqQkVFRWcOnWq2CR6Tk4OYmJi4O/vr9Cvg4MDoqOjAQAxMTHIzc1VWN/S0hLNmjVDdHR0sUn0wMBALFq0qMh8ju20dOnSer19qhmqc3yvy54/fw6AYzsR1Qwc2ytHYU7GwsJCyZEQEZU9ttfrJLqhoSEA4MGDB3wDrAMyMjJgZmaGhw8filfGUu1VX15PQRDw/PlzmJqaVts2dXV1YWdnh8WLF8PKygrGxsb48ccfER0djdatW4tJa39/f3z33XfQ1tbG6tWr8ffffyMxMREA0KNHD2hra2POnDlYsmQJBEHA3LlzkZ+fL7Z505MnT5Cfnw9jY2OF+cbGxrh58yYAICkpCRoaGjAwMCjSJikpqdh+/f394efnJz5PS0tD8+bNObbXQPXl77q24utTuZQxvtdlpqamePjwIXR1dSGRSJQdDv0/jht1C1/PsnFsr1zMydRcHA9qLr42la+8Y3u9TqKrqMhLwuvr6/MXrw7R09Pj61mH1IfXUxknjDt37sSECRPw3nvvQVVVFV26dMHYsWMRExMDdXV1/Pzzz/D09IShoSFUVVXh4OCAgQMHil9vMjIyQmhoKKZMmYJ169ZBRUUFY8eORZcuXcSxtbpIpVKFK+ILcWyvuerD33Vtxten8jAhUHlUVFTQtGlTZYdBJeC4Ubfw9Swdx/bKw5xMzcfxoObia1O5yjO21+skOhFRfdWqVSscP34cWVlZyMjIgImJCUaPHo2WLVsCAGxsbBAXF4f09HTk5OTAyMgItra26Nq1q9jHgAEDkJCQgCdPnkBNTQ0GBgaQyWRiH29q3LgxVFVVkZycrDA/OTkZMpkMACCTyZCTk4O0tDSFq9Ffb0NEREREREREVJ2q93JBIiKqUbS1tWFiYoJnz54hLCwMQ4cOVViur68PIyMj3L59GxcvXiyyHJAnxw0MDBAZGYmUlBR8/PHHxW5LQ0MDNjY2iIiIEOcVFBQgIiICdnZ2AOTJe3V1dYU28fHxePDggdiGiIiIiIiIiKg61esr0aVSKRYuXFhsGQCqffh61i18PatWWFgYBEGAhYUF7ty5g9mzZ8PS0hIeHh4AgNDQUBgZGaFZs2a4evUqZsyYAWdnZwwYMEDsY9u2bbCysoKRkRGio6MxY8YM+Pr6KtwYqH///hg2bBi8vb0BAH5+fnBzc0PXrl3RvXt3rFmzBllZWeJ29fX14enpCT8/PxgaGkJPTw/Tpk2DnZ1dsTcVLQ5/d2ouvjY1G18fIqoojht1C19Pqm78nau5+NrUXHxtlEciFBa4JSKiemPv3r3w9/fH33//DUNDQ4wYMQJff/21WAds3bp1+Oabb5CcnAwTExO4urpi/vz50NDQEPuYO3cugoODkZqaCnNzc0yePBm+vr4KN3wzNzeHu7s7AgICxHkbNmzAN998g6SkJHTq1Anr1q2Dra2tuPzVq1eYOXMmfvzxR2RnZ8PR0RHffvsty7kQERERERERkVIwiU5EREREREREREREVALWRCciIiIiIiIiIiIiKgGT6EREREREREREREREJWASnYiIiIiIiIiIiIioBEyiExERERERERERERGVoN4m0Tdu3Ahzc3NoamrC1tYW58+fV3ZI9BaWLl0KiUQCHx8fcd6rV68wdepUNGrUCDo6OhgxYgSSk5OVFySVKD8/H/Pnz0eLFi2gpaWFVq1aYfHixXj9fseCIGDBggUwMTGBlpYWHBwccPv2bSVGTTUZx3bl4991zXLixAkMGTIEpqamkEgk2L9/f5E2N27cwMcffwx9fX1oa2ujW7duePDggbic76tE9QvHjbolMDAQ3bp1g66uLpo0aQJnZ2fEx8crtCnP6/XgwQMMHjwYDRo0QJMmTTB79mzk5eVV565QLVXR8/PQ0FBYWlpCU1MTHTp0wK+//lpNkdY/b/u/0+7duyGRSODs7Fy1AdZTFX1d1qxZAwsLC2hpacHMzAy+vr549epVNUVbv9TLJPqePXvg5+eHhQsX4tKlS7C2toajoyNSUlKUHRpVwIULF/Ddd9+hY8eOCvN9fX1x6NAhhIaG4vjx43j8+DGGDx+upCipNMuWLcOmTZuwYcMG3LhxA8uWLcPy5cuxfv16sc3y5cuxbt06BAUF4dy5c9DW1oajoyPfFKgIju01A/+ua5asrCxYW1tj48aNxS5PSEhAz549YWlpiaioKFy5cgXz58+Hpqam2Ibvq0T1C8eNuuX48eOYOnUqzp49i/DwcOTm5mLAgAHIysoS25T1euXn52Pw4MHIycnBmTNnsH37dgQHB2PBggXK2CWqRSp6fn7mzBmMHTsWnp6eiI2NhbOzM5ydnXHt2rVqjrzue9v/ne7fv49Zs2bhww8/rKZI65eKvi4hISGYO3cuFi5ciBs3bmDLli3Ys2cPvvjii2qOvJ4Q6qHu3bsLU6dOFZ/n5+cLpqamQmBgoBKjoop4/vy50KZNGyE8PFzo3bu3MGPGDEEQBCEtLU1QV1cXQkNDxbY3btwQAAjR0dFKipZKMnjwYGHChAkK84YPHy64uLgIgiAIBQUFgkwmE7755htxeVpamiCVSoUff/yxWmOlmo9je83Av+uaC4Dwyy+/KMwbPXq08Mknn5S4Dt9Xieo3jht1T0pKigBAOH78uCAI5Xu9fv31V0FFRUVISkoS22zatEnQ09MTsrOzq3cHqFap6Pn5qFGjhMGDByvMs7W1FT799NMqjbM+epv/nfLy8gR7e3vh+++/F9zc3IShQ4dWQ6T1S0Vfl6lTpwr9+vVTmOfn5yd88MEHVRpnfVXvrkTPyclBTEwMHBwcxHkqKipwcHBAdHS0EiOjipg6dSoGDx6s8DoCQExMDHJzcxXmW1paolmzZnx9ayB7e3tERETg1q1bAIDLly/j1KlTGDhwIADg3r17SEpKUng99fX1YWtry9eTFHBsrzn4d117FBQU4MiRI3j//ffh6OiIJk2awNbWVqF0A99Xieh1HDdqv/T0dACAoaEhgPK9XtHR0ejQoQOMjY3FNo6OjsjIyMD169erMXqqTd7m/Dw6OrrI//iOjo4cOyrZ2/7v9NVXX6FJkybw9PSsjjDrnbd5Xezt7RETEyOWfLl79y5+/fVXDBo0qFpirm/UlB1AdXvy5Any8/MVTgAAwNjYGDdv3lRSVFQRu3fvxqVLl3DhwoUiy5KSkqChoQEDAwOF+cbGxkhKSqqmCKm85s6di4yMDFhaWkJVVRX5+fn4+uuv4eLiAgDia1bc3ytfT3odx/aag3/XtUdKSgoyMzOxdOlS/Oc//8GyZctw9OhRDB8+HMeOHUPv3r35vkpECjhu1G4FBQXw8fHBBx98gPbt2wMo3/9PSUlJxb5vFy4jKs7bnJ+X9LvG37PK9TavzalTp7BlyxbExcVVQ4T109u8LuPGjcOTJ0/Qs2dPCIKAvLw8TJ48meVcqki9S6JT7fbw4UPMmDED4eHhCnUXqXbau3cvdu3ahZCQELRr1w5xcXHw8fGBqakp3NzclB0eEb0F/l3XHgUFBQCAoUOHwtfXFwDQqVMnnDlzBkFBQejdu7cywyOiGojjRu02depUXLt2DadOnVJ2KERUizx//hzjx4/Hf//7XzRu3FjZ4dBroqKisGTJEnz77bewtbXFnTt3MGPGDCxevBjz589Xdnh1Tr1Lojdu3BiqqqpF7jaenJwMmUympKiovGJiYpCSkoIuXbqI8/Lz83HixAls2LABYWFhyMnJQVpamsLVFHx9a6bZs2dj7ty5GDNmDACgQ4cO+OuvvxAYGAg3NzfxNUtOToaJiYm4XnJyMjp16qSMkKmG4thec/DvuvZo3Lgx1NTU0LZtW4X5VlZWYoJFJpPxfZWIRBw3ai9vb28cPnwYJ06cQNOmTcX55Xm9ZDKZWCrg9eWFy4iK8zbn5zKZjOfz1aCir01CQgLu37+PIUOGiPMKP1RVU1NDfHw8WrVqVbVB1wNv8zczf/58jB8/HhMnTgQg/98rKysLkyZNwrx586CiUu+qeFepenc0NTQ0YGNjg4iICHFeQUEBIiIiYGdnp8TIqDz69++Pq1evIi4uTpy6du0KFxcX8bG6urrC6xsfH48HDx7w9a2BXrx4UWRQV1VVFd+QW7RoAZlMpvB6ZmRk4Ny5c3w9SQHH9pqDf9e1h4aGBrp164b4+HiF+bdu3ULz5s0BADY2NnxfJSIRx43aRxAEeHt745dffkFkZCRatGihsLw8r5ednR2uXr2KlJQUsU14eDj09PSKfKBCVOhtzs/t7OwU2gPy3zWOHZWroq+NpaVlkTzMxx9/jL59+yIuLg5mZmbVGX6d9TZ/MyX97wXIx3+qZEq+salS7N69W5BKpUJwcLDw559/CpMmTRIMDAwU7jZOtUfv3r2FGTNmiM8nT54sNGvWTIiMjBQuXrwo2NnZCXZ2dsoLkErk5uYmvPfee8Lhw4eFe/fuCT///LPQuHFj4fPPPxfbLF26VDAwMBAOHDggXLlyRRg6dKjQokUL4eXLl0qMnGoiju01A/+ua5bnz58LsbGxQmxsrABAWLVqlRAbGyv89ddfgiAIws8//yyoq6sLmzdvFm7fvi2sX79eUFVVFU6ePCn2wfdVovqF40bdMmXKFEFfX1+IiooSEhMTxenFixdim7Jer7y8PKF9+/bCgAEDhLi4OOHo0aOCkZGR4O/vr4xdolqkrPPz8ePHC3PnzhXbnz59WlBTUxNWrFgh3LhxQ1i4cKGgrq4uXL16VVm7UGdV9LV5k5ubmzB06NBqirb+qOjrsnDhQkFXV1f48ccfhbt37wq///670KpVK2HUqFHK2oU6rV4m0QVBENavXy80a9ZM0NDQELp37y6cPXtW2SHRW3ozif7y5Uvhs88+Exo2bCg0aNBAGDZsmJCYmKi8AKlEGRkZwowZM4RmzZoJmpqaQsuWLYV58+YJ2dnZYpuCggJh/vz5grGxsSCVSoX+/fsL8fHxSoyaajKO7crHv+ua5dixYwKAIpObm5vYZsuWLULr1q0FTU1NwdraWti/f79CH3xfJapfOG7ULcW9lgCEbdu2iW3K83rdv39fGDhwoKClpSU0btxYmDlzppCbm1vNe0O1UWnn571791YYWwRBEPbu3Su8//77goaGhtCuXTvhyJEj1Rxx/VHR1+Z1TKJXnYq8Lrm5uUJAQIDQqlUrQVNTUzAzMxM+++wz4dmzZ9UfeD0gEQRe309EREREREREREREVJx6VxOdiIiIiIiIiIiIiKi8mEQnIiIiIiIiIiIiIioBk+hERERERERERERERCVgEp2IiIiIiIiIiIiIqARMohMRERERERERERERlYBJdCIiIiIiIiIiIiKiEjCJTkRERERERERERERUAibRiYiIiIiIiIiIiIhKwCQ6ERERERERERERVQt3d3c4OztX+3aDg4MhkUggkUjg4+Mjzjc3N8eaNWtKXbdwPQMDgyqNkWouJtGJStGnTx9xoIyLi6vy7bm7u4vb279/f5Vvj4ioPuLYTkRU93BsJyKqGQrHxpKmgIAArF27FsHBwUqJT09PD4mJiVi8eHGF1ktMTCwz0U51G5PoRGXw8vJCYmIi2rdvX+XbWrt2LRITE6t8O0RE9R3HdiKiuodjOxGR8iUmJorTmjVrxKR14TRr1izo6+sr7YpuiUQCmUwGXV3dCq0nk8mgr69fRVFRbcAkOlEZGjRoAJlMBjU1tSrflr6+PmQyWZVvh4iovuPYTkRU93BsJyJSPplMJk76+vpi0rpw0tHRKVLOpU+fPpg2bRp8fHzQsGFDGBsb47///S+ysrLg4eEBXV1dtG7dGr/99pvCtq5du4aBAwdCR0cHxsbGGD9+PJ48efJWcb948QITJkyArq4umjVrhs2bN7/LYaA6iEl0qjf++ecfyGQyLFmyRJx35swZaGhoICIiokJ9nTp1Curq6nj16pU47/79+5BIJPjrr7/e+g2AiIgqhmM7EVHdw7GdiKj+2b59Oxo3bozz589j2rRpmDJlCkaOHAl7e3tcunQJAwYMwPjx4/HixQsAQFpaGvr164fOnTvj4sWLOHr0KJKTkzFq1Ki32v7KlSvRtWtXxMbG4rPPPsOUKVMQHx9fmbtItRyT6FRvGBkZYevWrQgICMDFixfx/PlzjB8/Ht7e3ujfv3+F+oqLi4OVlRU0NTXFebGxsWjYsCGaN28OoOJvAEREVHEc24mI6h6O7URE9Y+1tTW+/PJLtGnTBv7+/tDU1ETjxo3h5eWFNm3aYMGCBXj69CmuXLkCANiwYQM6d+6MJUuWwNLSEp07d8bWrVtx7Ngx3Lp1q8LbHzRoED777DO0bt0ac+bMQePGjXHs2LHK3k2qxZhEp3pl0KBB8PLygouLCyZPngxtbW0EBgZWuJ/Lly+jc+fOCvPi4uJgbW0tPq/oGwAREb0dju1ERHUPx3YiovqlY8eO4mNVVVU0atQIHTp0EOcZGxsDAFJSUgDIx/djx45BR0dHnCwtLQEACQkJ77T9whI0hdsiAoCqLxZHVMOsWLEC7du3R2hoKGJiYiCVSivcR1xcHMaNG6cwLzY2Fp06dRKfV/QNgIiI3h7HdiKiuodjOxFR/aGurq7wXCKRKMyTSCQAgIKCAgBAZmYmhgwZgmXLlhXpy8TEpFK2X7gtIoBXolM9lJCQgMePH6OgoAD379+v8Pr5+fm4du1akStaLl26pHAyXtE3ACIiensc24mI6h6O7UREVJIuXbrg+vXrMDc3R+vWrRUmbW1tZYdHdRCT6FSv5OTk4JNPPsHo0aOxePFiTJw4scJXlMTHx+PVq1cwNTUV50VHR+PRo0cKJ+NERFQ9OLYTEdU9HNuJiKg0U6dORWpqKsaOHYsLFy4gISEBYWFh8PDwQH5+vrLDozqISXSqV+bNm4f09HSsW7cOc+bMwfvvv48JEyZUqI+4uDgAwPr163H79m389ttvcHV1BSA/2SciourFsZ2IqO7h2E5ERKUxNTXF6dOnkZ+fjwEDBqBDhw7w8fGBgYEBVFSY7qTKx5roVG9ERUVhzZo1OHbsGPT09AAAO3fuhLW1NTZt2oQpU6aUq5+4uDg4Ojri7t276NChA9q2bYtFixZhypQpWLduHXbu3FmVu0FERK/h2E5EVPdwbCciqhvc3d3h7u5eZH5wcLDC86ioqCJtiivjJQiCwvM2bdrg559/focIS95W4QexRIWYRKd6o0+fPsjNzVWYZ25ujvT09Ar1c/nyZXTr1g3/+c9/FOa/fsOit30DICKiiuHYTkRU93BsJyKiqpKeng4dHR1MnTq12JuSlkRHRwd5eXnQ1NSswuioJuP3G4jK8O2330JHRwdXr14FID8Z79ChQ5Vsa/LkydDR0amSvomI6H84thMR1T0c24mIqDQjRozA7du3ERcXh9mzZ1do3bi4OFy7dg2xsbFVFB3VdBKBH6kTlejRo0d4+fIlAKBZs2ZITU2FiYkJrl+/jrZt21b69lJSUpCRkQEAMDEx4R2liYiqAMd2IqK6h2M7ERERVSUm0YmIiIiIiIiIiIiISsByLkREREREREREREREJWASnYiIiIiIiIiIiIioBEyiExERERERERERERGVgEl0IiIiIiIiIiIiIqISMIlORERERERERERERFQCJtGJiIiIiIiIiIiIiErAJDoRERERERERERERUQmYRCciIiIiIiIiIiIiKgGT6EREREREREREREREJWASnYiIiIiIiIiIiIioBEyiExERERERERERERGVgEl0IiIiIiIiIiIiIqISMIlORERERERERERERFQCJtGJiIiIiIiIiIiIiErAJDoRERERERERERERUQmYRCciIiIiIiIiIiIiKgGT6FQh5ubmWLNmjVJjCA4OhoGBgVJjqKiacNyUISAgAJ06darQOhKJBPv37y9xeZ8+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IiKq+xo0aKDsEIiIysQkOhERERERERERKYVEIlF2CEREZWISnYiIiIiIiIiIiIioBEyiExERERERERGRUmRnZys7BKqpnjwBLl1SdhRUm+XlASdOALm579wVk+hERERERERERKQUeXl5yg6BaqIXLwBbW6BrV+DuXWVHQ7WVhwfQuzewY8c7d8UkOhEREREREREREdUcgYHy5LkgAI8fKzsaqo2iooAffpA//vvvd+6OSXQiIiIiIiIiIiKqGe7cAZYvV3YUVJvl5gLe3pXaJZPoREREREREREREpHyCAEyfDuTkKDsSqs02bACuX6/ULplEJyIiIiIiIiIiIuU7eBD47TdAXR3Q15fPEwTlxkS1S2IisHCh/LGRkfxnJfwOMYlOREREREREREREyvXyJeDjI388cyZgbKzUcKiWmj0beP4c6N4dGDGi0rplEp2IiIiIiIiIiIiUKzAQuH8faNoU+PJLZUdDtdHx48CuXYBEAmzcCKhUXuqbSXQiIiIiIiIiIlKKBg0aKDsEqgni44Fly+SPV60CtLWVGw/VPjk5wJQp8seTJgFdu1Zq90yiExERERERERGRUkgkEmWHQMomCPLkZ04OMHAg8O9/F11OVJYVK4AbN4AmTeTfangda6ITERERERERERFRrfXDD8CxY4CmJrBhg7wUB/C/n0RlSUgAFi+WP161CmjYUP64En+HmEQnIiIiIiIiIiKlyM7OVnYIpEypqfKbiALAggVAy5bKjYdqH0EApk4FXr0C+vcHxo2rks0wiU5EREREREREREqRl5en7BBImebOBf75B2jb9n/JdKKKCA0FwsIADQ3g22+r7BsMTKITERERERERERFR9TpzBvjvf+WPg4LkSdDXFSZDWROdSpKeDsyYIX/8xRfA++8X34410YmIiIiIiIiIiKhWyc0FPv1U/njCBODDD5UbD9VO8+YBSUny5PncuUWXsyY6ERERERERERER1Upr1gDXrgGNGgHLlys7GqqNLlyQl28BgE2bAKm0SjfHJDoRERERERERERFVj7/+AgIC5I9XrJAn0okqIi9P/k0GQQA++QTo16/KN8kkOhFRPZOamgoXFxfo6enBwMAAnp6eyMzMLHWdzZs3o0+fPtDT04NEIkFaWlqx7Y4cOQJbW1toaWmhYcOGcHZ2FpcFBwdDIpEUO6WkpAAAoqKiil2e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5AQsXqzsiIpXm2qiExERERERERERFUcqlSo7hPrl/n15HfS8PGDcOHk9a6KKyMmR/w49fAi8/z7w44+Aqqqyo6pyTKITEREREREREZFSqKlVW5EEysqSXz389CnQpQvw/fe82psqbsYM4ORJQE8POHBAfkPReoBJdCIiIiIiIiIiorpMEAB3d+DKFaBJE2D/fkBLS9lRlQ9rotccQUHyqbCWvqWlsiMqn0r4HWISnYiIiIiIiIiIlCI3N1fZIdQPS5YAP/0EqKsD+/YBZmbKjqhsvEq+Zjl5Epg2Tf54yRJg8GDlxlMerIlORERERERERES1XU5OjrJDqPv27QPmz5c/3rgR6NlTufFQ7XP3LjB8uLyW/ujRwJw5yo6o2jGJTkREREREREREVBedOwd88om8nMXUqYCXl7Ijotrm2TNg0CDgyRN5Lf2tW+vltwSYRCciIiIiIiIiIqpr7t8HPv4YePVKngRds0bZEb0d1kRXnpwc+RXo8fHyEkCHDgENGig7qopjTXQiIiIiIiIiIiJSkJYmr1mdkgJYWwO7dwNqasqOqmLq4dXONYogAJ9+CkRFAbq6wOHDgKmpsqOqGNZEJyIiIiIiIiIioiJyc4GRI4E//5QnPQ8flidBiSpiyRIgOBhQVQX27gU6dlR2RErFJDoREREREREREVFdIAjAlCnAH38A2tryBHrTpsqOimqbH38EvvxS/nj9esDJSbnx1ABMohMREREREREREdUFy5cDW7YAKiryEi6dOys7orenqSn/+fy5cuOob06fBtzd5Y9nzpR/KFNbVeLvEJPoRERERERERESkFFKpVNkh1B179wJz58ofr10L/Otfyo3nXbVpI/9586Zy46hPbt8Ghg6V31B02DD5hzK1WeHv0I0b79wVk+hERERERERERKQUarXtZpc11YkTwPjx8sfTpwPe3sqNpzIUXkUfEaHcOOqLlBR52ZanT4GuXYEffpB/o6E269JF/vPiRfnNdt9BLT8SRERERERERERE9diNG4pXD69apeyIKsfQoYBEApw5A5w/r+xo6rasLGDIEODuXaBFC3kt/QYNlB3Vu3v/faBtW/nNdr/77p26YhKdiIiIiIiIiIiUIi8vT9kh1G6JicDAgfKrbO3sgF27AFVVZUdVOd57D/jkE/njTz6R7ytVvrw8YOxY+QcVhobAb78BxsbK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d+nSpTKZTDKZTBo5cqRlfoMGDTRr1qxC1726no+PT5nGCMflZu8AAAAAAAAAADg/k8lU6PKJEyfqzTfflGEYNorImpeXlw4ePKiqVave0HrJycn64IMPNHHixDKKDI6OkehAITp06GC52piUlFTm+xs4cKBlf5988kmZ7w8AKiL6dgAof+jbAcAxJCcnW6ZZs2bJy8vLat4zzzwjb29vu43oNplMCggIUPXq1W9ovYCAAHl7e5dRVHAGJNGBIkRFRSk5OVm33357me/rzTffVHJycpnvBwAqOvp2ACh/6NsBwP4CAgIsk7e3tyVpfXWqVq1annIuHTp00JNPPqmRI0eqRo0a8vf31zvvvKPMzEwNGjRI1atXV5MmTfTf//7Xal979+5Vly5dVK1aNfn7++uxxx7T2bNnixV3VlaWHn/8cVWvXl3169fXggULSvIyoBwiiQ4UoUqVKgoICJCbW9lXP/L29lZAQECZ7wcAKjr6dgAof+jbAcB5LVu2TL6+vtq+fbuefPJJDR8+XH379lW7du20c+dO3X///XrssceUlZUlSUpLS1PHjh0VFBSk77//XnFxcTp9+rQefvjhYu3/tddeU9u2bbVr1y498cQTGj58uA4ePFiahwgnRxIdFcZvv/2mgIAATZ061TJv8+bNcnd31/r1629oW99++60qVaqkixcvWuYdP35cJpNJP//8c7GvogIAbgx9OwCUP/TtAFDxtGrVSuPGjdMtt9yisWPHytPTU76+voqKitItt9yiCRMm6Ny5c/rhhx8kSW+99ZaCgoI0depUBQYGKigoSIsXL9bGjRv1008/3fD+u3btqieeeEJNmjTR888/L19fX23cuLG0DxNOjCQ6KoxatWpp8eLFmjRpkr7//nudP39ejz32mKKjo9WpU6cb2lZSUpKaNWsmT09Py7xdu3apRo0auvnmmyXd+FVUAMCNo28HgPKHvh0AKp6WLVtaHru6uuqmm25SixYtLPP8/f0lSWfOnJEk7d69Wxs3blS1atUsU2BgoCTpyJEjJdr/1RI0V/cFSCTRUcF07dpVUVFRCg8P17Bhw1S1alVNmzbthreze/duBQUFWc1LSkpSq1atLM9v9CoqAKB46NsBoPyhbweAiqVSpUpWz00mk9U8k8kkScrNzZUkXbhwQT169FBSUpLVdOjQId17772lsv+r+wIkkuiogGbOnKkrV65o9erVWr58uTw8PG54G0lJSWrdurXVvF27dlnNu9GrqACA4qNvB4Dyh74dAFCQO+64Q/v27VODBg3UpEkTq6lq1ar2Dg/lEEl0VDhHjhzRqVOnlJubq+PHj9/w+jk5Odq7d2+eES07d+60+jB+o1dRAQDFR98OAOUPfTsAoCAjRoxQamqq+vfvrx07dujIkSNat26dBg0apJycHHuHh3Ko7G9bDjiQS5cuacCAAXrkkUfUtGlTDRkyRHv27JGfn991b+PgwYO6ePGi6tSpY5m3ZcsW/frrr3lGuQAAyh59OwCUP/TtAIDC1KlTR999952ef/553X///crOztbNN9+ssLAwubgwZhiljyQ6KpQXX3xR6enpmj17tqpVq6YvvvhCjz/+uNauXXvd20hKSpIkzZkzR0899ZQOHz6sp556SpL5wz4AwLbo2wGg/KFvBwDnN3DgQA0cODDP/KVLl1o9T0hIyNMmv28gGYZh9fyWW27RRx99VIIIC97X1b8hwFVcmkGFkZCQoFmzZum9996Tl5eXXFxc9N577+mbb77R/Pnzr3s7SUlJCg0N1dGjR9WiRQu9+OKLeumll+Tl5aXZs2eX4REAAP6Kvh0Ayh/6dgBAWUlPT1e1atX0/PPP39B61apV07Bhw8ooKjgDRqKjwujQoYMuX75sNa9BgwZKT0+/oe3s3r1bd955p/71r39ZzX/00Uctj4t7FRUAcGPo2wGg/KFvBwCUhT59+qh9+/aSJB8fnxta9+rIdFdX11KOCs6CkehAEebNm6dq1appz549kswfxlu0aFEm+xo2bJiqVatWJtsGAPyJvh0Ayh/6dgBAYapXr64mTZqoSZMm8vX1vaF1r67XsGHDMooOjs5kcEkdKNCvv/6qP/74Q5JUv359paamqnbt2tq3b5+aN29e6vs7c+aMMjIyJEm1a9dW1apVS30fAFDR0bcDQPlD3w4AAMoSSXQAAAAAAAAAAApAORcAAAAAAAAAAApAEh0AAAAAAAAAgAKQRAcAAAAAAAAAoAAk0QEAAAAAAAAAKABJdAAAAAAAAAAACkASHQAAAAAAAACAApBEBwAAAAAAAACgACTRAQAAAAAAAAAoAEl0AAAAAAAAAAAKQBIdAAAAAAAAAIAC/D9pNV++FzK5QwAAAABJRU5ErkJggg==",
"text/plain": [
"