From 30417018f93c1d8f083c2f7205b21c5445b29b39 Mon Sep 17 00:00:00 2001
From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com>
Date: Fri, 28 Nov 2025 17:16:58 +0000
Subject: [PATCH 01/37] More updates from restructure (#844)
* Update data readme
* Add axis labels
* Update descriptions
* Add cost names
* Remove multiprocessing import
* Update applications
---
examples/data/README.md | 2 +-
.../battery_parameterisation/gitt_fitting.py | 1 +
.../stoichiometry_fitting.py | 2 +
pybop/__init__.py | 22 +--
pybop/_utils.py | 2 -
pybop/applications/base_method.py | 142 ++++++++++-----
pybop/applications/gitt_methods.py | 164 +++++++++---------
pybop/applications/ocp_methods.py | 119 ++++++-------
pybop/costs/base_cost.py | 5 +
pybop/costs/error_measures.py | 13 +-
tests/unit/test_import.py | 19 --
11 files changed, 248 insertions(+), 243 deletions(-)
diff --git a/examples/data/README.md b/examples/data/README.md
index 8f65d70c2..4d46dbbec 100644
--- a/examples/data/README.md
+++ b/examples/data/README.md
@@ -1,2 +1,2 @@
## Data directory
-This directory contains both the experimental and synthetic data used in the examples.
+This directory contains the experimental data used in the examples.
diff --git a/examples/scripts/battery_parameterisation/gitt_fitting.py b/examples/scripts/battery_parameterisation/gitt_fitting.py
index eed45cb53..405b4ffc1 100644
--- a/examples/scripts/battery_parameterisation/gitt_fitting.py
+++ b/examples/scripts/battery_parameterisation/gitt_fitting.py
@@ -72,6 +72,7 @@
# Run the identified model
identified_model = pybop.lithium_ion.SPDiffusion(build=True)
+grouped_parameter_values.update(gitt_fit.best_inputs)
grouped_parameter_values["Current function [A]"] = pybamm.Interpolant(
dataset["Time [s]"], dataset["Current function [A]"], pybamm.t
)
diff --git a/examples/scripts/battery_parameterisation/stoichiometry_fitting.py b/examples/scripts/battery_parameterisation/stoichiometry_fitting.py
index e73ddbfb5..a98277730 100644
--- a/examples/scripts/battery_parameterisation/stoichiometry_fitting.py
+++ b/examples/scripts/battery_parameterisation/stoichiometry_fitting.py
@@ -36,4 +36,6 @@ def noise(sigma):
fitted_dataset["Voltage [V]"],
],
trace_names=["Ground truth", "Data vs. stoichiometry"],
+ xaxis_title="Stoichiometry",
+ yaxis_title="Voltage / V",
)
diff --git a/pybop/__init__.py b/pybop/__init__.py
index b815915c8..a56b2e996 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -8,24 +8,6 @@
import sys
from os import path
-#
-# Multiprocessing
-#
-try:
- import multiprocessing as mp
- if sys.platform == "win32":
- mp.set_start_method("spawn")
- else:
- mp.set_start_method("fork")
-except Exception as e: # pragma: no cover
- error_message = (
- "Multiprocessing context could not be set. "
- "Continuing import without setting context.\n"
- f"Error: {e}"
- ) # pragma: no cover
- print(error_message) # pragma: no cover
- pass # pragma: no cover
-
#
# Version info
#
@@ -118,7 +100,7 @@
from ._evaluation import PopulationEvaluator, ScalarEvaluator, SequentialEvaluator
#
-# Optimisation logging
+# Optimisation logging and result
#
from ._logging import Logger
from ._result import OptimisationResult
@@ -182,7 +164,7 @@
)
#
-# Classification classes
+# Analysis
#
from .analysis.classification import classify_using_hessian
diff --git a/pybop/_utils.py b/pybop/_utils.py
index 8932c5722..5b754b547 100644
--- a/pybop/_utils.py
+++ b/pybop/_utils.py
@@ -17,7 +17,6 @@ def is_numeric(x):
@dataclass(frozen=True)
class FailedVariable:
"""
- Check if a variable is numeric.
Container for a failed PyBaMM variable that returns np.inf.
Args:
@@ -41,7 +40,6 @@ def __post_init__(self) -> None:
class FailedSolution:
"""
- return isinstance(x, int | float | np.number)
Container for a failed PyBaMM solution that returns [np.inf] for all processed variables.
This class mimics the interface of a successful PyBaMM solution but returns
diff --git a/pybop/applications/base_method.py b/pybop/applications/base_method.py
index 7b9e68ef2..4b8c1c5ab 100644
--- a/pybop/applications/base_method.py
+++ b/pybop/applications/base_method.py
@@ -13,41 +13,88 @@ class BaseApplication:
A base class for PyBOP's application methods.
"""
- def check_monotonicity(self, voltage):
- if not (
- all(x < y for x, y in zip(voltage, voltage[1:], strict=False))
- or all(x > y for x, y in zip(voltage, voltage[1:], strict=False))
- ):
- warnings.warn("OCV is not strictly monotonic.", stacklevel=1)
+ def check_monotonicity(self, voltage: np.ndarray) -> None:
+ """
+ Check if voltage data is monotonic and warn if not.
+
+ Parameters
+ ----------
+ voltage : np.ndarray
+ Voltage array to check for monotonicity.
+ """
+ is_increasing = np.all(np.diff(voltage) > 0)
+ is_decreasing = np.all(np.diff(voltage) < 0)
+
+ if not (is_increasing or is_decreasing):
+ warnings.warn("OCV is not strictly monotonic.", stacklevel=2)
class Interpolant:
"""
A class that returns a pybamm.Interpolant to pybamm models and otherwise
a numeric interpolant.
+
+ Parameters
+ ----------
+ x : array_like
+ Input coordinates.
+ y : array_like
+ Output values corresponding to x.
+ name : str, optional
+ Name for the interpolant when used in PyBaMM.
+ bounds_error : bool, optional
+ If True, raise error when interpolating outside bounds.
+ fill_value : str or float, optional
+ Value to use for out-of-bounds interpolation.
+ axis : int, optional
+ Axis along which to interpolate.
"""
def __init__(
- self, x, y, name=None, bounds_error=False, fill_value="extrapolate", axis=0
+ self,
+ x: np.ndarray,
+ y: np.ndarray,
+ name: str | None = None,
+ bounds_error: bool = False,
+ fill_value: str | float = "extrapolate",
+ axis: int = 0,
):
self.x = np.asarray(x)
self.y = np.asarray(y)
self.name = name
- self.interp1d = interpolate.interp1d(
- x,
- y,
+ self._interp_func = self._create_interpolant(bounds_error, fill_value, axis)
+
+ def _create_interpolant(
+ self, bounds_error: bool, fill_value: str | float, axis: int
+ ):
+ """Create the scipy interpolation function."""
+ return interpolate.interp1d(
+ self.x,
+ self.y,
bounds_error=bounds_error,
fill_value=fill_value,
axis=axis,
)
- def __call__(self, x):
+ def __call__(self, x: float | np.ndarray):
+ """
+ Evaluate the interpolant at given points.
+
+ Parameters
+ ----------
+ x : float or array_like
+ Points at which to evaluate the interpolant.
+
+ Returns
+ -------
+ float, array_like, or PybammInterpolant
+ Interpolated values or PyBaMM interpolant object.
+ """
try:
- # Try to evaluate the interpolant numerically, this will return an
- # error if x is a PyBaMM object
- return self.interp1d(x)
- except (Exception, SystemExit, KeyboardInterrupt):
- # Evaluate the interpolant as a PyBaMM function for use in a model
+ # Try numeric evaluation first
+ return self._interp_func(x)
+ except Exception:
+ # Fall back to PyBaMM interpolant for symbolic evaluation
return PybammInterpolant(self.x, self.y, x, name=self.name)
@@ -62,56 +109,59 @@ class InverseOCV:
The open-circuit voltage as a function of stoichiometry.
optimiser : pybop.BaseOptimiser, optional
The optimisation algorithm to use (default: pybop.SciPyMinimize).
- verbose : bool, optional
- If True, progress messages are printed (default: False).
+ optimiser_options : pybop.OptimiserOptions, optional
+ Options for the optimiser.
"""
def __init__(
self,
ocv_function: Callable,
- optimiser: pybop.BaseOptimiser | None = pybop.SciPyMinimize,
- verbose: bool = False,
+ optimiser: pybop.BaseOptimiser | None = None,
+ optimiser_options: pybop.OptimiserOptions | None = None,
):
- self.ocv_function = ocv_function
- self.optimiser = optimiser
- self.verbose = verbose
-
- def __call__(self, ocv_value: float):
- """
- Estimate and return the stoichiometry.
-
- Parameters
- ----------
- ocv_value : float
- The open-circuit voltage value [V] for which to estimate the stoichiometry.
+ self.optimiser = optimiser or pybop.SciPyMinimize
+ self.optimiser_options = optimiser_options or self.optimiser.default_options()
- Returns
- -------
- float
- The stoichiometry corresponding to the open-circuit voltage value.
- """
- ocv_function = self.ocv_function
-
- self.parameters = pybop.Parameters(
+ parameters = pybop.Parameters(
{"Root": pybop.Parameter(initial_value=0.5, bounds=[0, 1])}
)
# Set up a root-finding cost function
class OCVRoot(pybop.BaseSimulator):
+ def __init__(self, ocv_value: float):
+ super().__init__(parameters=parameters)
+ self.ocv_value = ocv_value
+
def batch_solve(self, inputs, calculate_sensitivities: bool = False):
solutions = []
for x in inputs:
- diff = np.abs(ocv_function(x["Root"]) - ocv_value)
+ diff = np.abs(ocv_function(x["Root"]) - self.ocv_value)
sol = pybop.Solution()
sol.set_solution_variable("Difference", data=np.asarray([diff]))
solutions.append(sol)
return solutions
+ self.ocv_root = OCVRoot
+
# Minimise to find the stoichiometry
- cost = pybop.DesignCost(target="Difference")
- cost.minimising = True
- problem = pybop.Problem(OCVRoot(self.parameters), cost)
- options = pybop.SciPyMinimizeOptions(verbose=self.verbose)
- optim = self.optimiser(problem=problem, options=options)
+ self.cost = pybop.DesignCost(target="Difference")
+ self.cost.minimising = True
+
+ def __call__(self, ocv_value: float) -> float:
+ """
+ Estimate and return the stoichiometry.
+
+ Parameters
+ ----------
+ ocv_value : float
+ The open-circuit voltage value [V] for which to estimate the stoichiometry.
+
+ Returns
+ -------
+ float
+ The stoichiometry corresponding to the open-circuit voltage value.
+ """
+ problem = pybop.Problem(self.ocv_root(ocv_value), self.cost)
+ optim = self.optimiser(problem, options=self.optimiser_options)
result = optim.run()
return result.best_inputs["Root"]
diff --git a/pybop/applications/gitt_methods.py b/pybop/applications/gitt_methods.py
index ea240741d..06d5f7ac6 100644
--- a/pybop/applications/gitt_methods.py
+++ b/pybop/applications/gitt_methods.py
@@ -1,5 +1,3 @@
-from copy import copy
-
import numpy as np
from pybamm import ParameterValues
@@ -12,69 +10,66 @@ class GITTPulseFit(BaseApplication):
"""
Fit the diffusion timescale of one pulse from a galvanostatic intermittent
titration technique (GITT) measurement using the diffusion model for a single,
- spherical particle representing the working electrode.
+ spherical particle representing one electrode.
The cost function requires a "domain"-based weighting to fit (possibly non-uniform)
data consistently across the observed time period.
Parameters
----------
- gitt_pulse : pybop.Dataset
- A dataset containing the "Time [s]", "Current function [A]" and "Voltage [V]"
- for one pulse obtained from a GITT measurement.
parameter_values : pybamm.ParameterValues
A parameter set containing values for the parameters of the SPDiffusion model.
cost : pybop.ErrorMeasure | pybop.LogLikelihood, optional
The cost function to quantify the error (default: pybop.RootMeanSquaredError).
optimiser : pybop.BaseOptimiser, optional
The optimisation algorithm to use (default: pybop.SciPyMinimize).
- verbose : bool, optional
- If True, progress messages are printed (default: True).
+ optimiser_options : pybop.OptimiserOptions, optional
+ Options for the optimiser.
"""
def __init__(
self,
parameter_values: ParameterValues,
cost: pybop.ErrorMeasure | pybop.LogLikelihood | None = None,
- optimiser: pybop.BaseOptimiser | None = pybop.SciPyMinimize,
- verbose: bool = True,
+ optimiser: pybop.BaseOptimiser | None = None,
+ optimiser_options: pybop.OptimiserOptions | None = None,
):
- self.parameter_values = parameter_values
+ self.parameter_values = parameter_values.copy()
self.parameters = {
"Particle diffusion time scale [s]": pybop.Parameter(bounds=[0, np.inf]),
"Series resistance [Ohm]": pybop.Parameter(bounds=[0, np.inf]),
}
- self.model = pybop.lithium_ion.SPDiffusion(build=True)
self.cost = cost or pybop.RootMeanSquaredError
- self.verbose = verbose
- self.optimiser = optimiser
- self.optim = None
- self.result = None
+ self.optimiser = optimiser or pybop.SciPyMinimize
+ self.optimiser_options = optimiser_options or self.optimiser.default_options()
+
+ # Create model
+ self.model = pybop.lithium_ion.SPDiffusion()
+ self.problem = None
- def __call__(self, gitt_pulse: pybop.Dataset) -> pybop.OptimisationResult:
- # Update starting point
+ def __call__(
+ self,
+ gitt_pulse: pybop.Dataset,
+ initial_parameter_values: dict[str, float] | None = None,
+ ) -> pybop.OptimisationResult:
+ # Update parameter values
+ parameter_values = self.parameter_values.copy()
+ if initial_parameter_values is not None:
+ parameter_values.update(initial_parameter_values)
for key, param in self.parameters.items():
- param.update_initial_value(self.parameter_values[key])
- self.parameter_values.update(self.parameters)
+ param.update_initial_value(parameter_values[key])
+ parameter_values.update(self.parameters)
# Define the problem
simulator = pybop.pybamm.Simulator(
- self.model,
- parameter_values=self.parameter_values,
- protocol=gitt_pulse,
+ self.model, parameter_values=parameter_values, protocol=gitt_pulse
)
cost = self.cost(gitt_pulse, weighting="domain")
self.problem = pybop.Problem(simulator=simulator, cost=cost)
+ optim = self.optimiser(self.problem, options=self.optimiser_options)
+ result = optim.run()
- # Build and run the optimisation problem
- options = pybop.SciPyMinimizeOptions(verbose=self.verbose, tol=1e-8)
- self.optim = self.optimiser(problem=self.problem, options=options)
- self.result = self.optim.run()
- self.parameter_values.update(self.problem.parameters.to_dict(self.result.x))
-
- # pybop.plot.problem(problem=problem, inputs=self.result.best_inputs)
-
- return self.result
+ return result
class GITTFit(BaseApplication):
@@ -95,8 +90,8 @@ class GITTFit(BaseApplication):
The cost function to quantify the error (default: pybop.RootMeanSquaredError).
optimiser : pybop.BaseOptimiser, optional
The optimisation algorithm to use (default: pybop.SciPyMinimize).
- verbose : bool, optional
- If True, progress messages are printed (default: False).
+ optimiser_options : pybop.OptimiserOptions, optional
+ Options for the optimiser.
"""
def __init__(
@@ -105,20 +100,24 @@ def __init__(
pulse_index: list[np.ndarray],
parameter_values: ParameterValues,
cost: pybop.ErrorMeasure | pybop.LogLikelihood | None = None,
- optimiser: pybop.BaseOptimiser | None = pybop.SciPyMinimize,
- verbose: bool = False,
+ optimiser: pybop.BaseOptimiser | None = None,
+ optimiser_options: pybop.OptimiserOptions | None = None,
):
self.gitt_dataset = gitt_dataset
self.pulse_index = pulse_index
- self.parameter_values = parameter_values
self.cost = cost or pybop.RootMeanSquaredError
- self.optimiser = optimiser
- self.verbose = verbose
- self.gitt_pulse = pybop.GITTPulseFit(
- parameter_values=self.parameter_values.copy(),
+ self.optimiser = optimiser or pybop.SciPyMinimize
+ self.optimiser_options = optimiser_options or self.optimiser.default_options()
+
+ # Set up OCV root-finding function
+ self.inverse_ocp = pybop.InverseOCV(parameter_values["Electrode OCP [V]"])
+
+ # Initialise single pulse fitter
+ self.pulse_fit = GITTPulseFit(
+ parameter_values=parameter_values,
cost=self.cost,
optimiser=self.optimiser,
- verbose=self.verbose,
+ optimiser_options=self.optimiser_options,
)
def __call__(self) -> pybop.Dataset:
@@ -127,53 +126,52 @@ def __call__(self) -> pybop.Dataset:
stoichiometry = []
diffusion_time = []
series_resistance = []
- final_costs = []
-
- inverse_ocp = pybop.InverseOCV(self.parameter_values["Electrode OCP [V]"])
+ best_cost = []
+ initial_parameter_values = {}
for index in self.pulse_index:
- # Estimate the initial stoichiometry from the initial voltage
- self.gitt_pulse.parameter_values["Initial stoichiometry"] = inverse_ocp(
- self.gitt_dataset["Voltage [V]"][index[0]]
- )
-
- # Check that initial current is zero
- if self.gitt_dataset["Current function [A]"][index[0]] != 0:
- raise ValueError(
- "The initial current in the pulse dataset must be zero."
- )
-
- # Estimate the parameters for this pulse
try:
- gitt_result = self.gitt_pulse(
- gitt_pulse=self.gitt_dataset.get_subset(index)
- )
- self.pulses.append(copy(self.gitt_pulse.optim))
+ # Check that initial current is zero
+ pulse_data = self.gitt_dataset.get_subset(index)
+ if pulse_data["Current function [A]"][0] != 0:
+ raise ValueError(
+ "The initial current in the pulse dataset must be zero."
+ )
+
+ # Estimate the initial stoichiometry from the initial voltage
+ initial_sto = self.inverse_ocp(pulse_data["Voltage [V]"][0])
+ initial_parameter_values.update({"Initial stoichiometry": initial_sto})
+
+ # Estimate the parameters for this pulse
+ pulse_result = self.pulse_fit(pulse_data, initial_parameter_values)
# Log the result
+ self.pulses.append(pulse_result)
diffusion_time.append(
- self.gitt_pulse.parameter_values[
- "Particle diffusion time scale [s]"
- ]
+ pulse_result.best_inputs["Particle diffusion time scale [s]"]
)
series_resistance.append(
- self.gitt_pulse.parameter_values["Series resistance [Ohm]"]
- )
- stoichiometry.append(
- self.gitt_pulse.parameter_values["Initial stoichiometry"]
+ pulse_result.best_inputs["Series resistance [Ohm]"]
)
- final_costs.append(gitt_result.best_cost)
+ stoichiometry.append(initial_sto)
+ best_cost.append(pulse_result.best_cost)
- except (Exception, SystemExit, KeyboardInterrupt):
+ # Pass the optimised parameters to the next pulse
+ initial_parameter_values = pulse_result.best_inputs
+
+ except (SystemExit, KeyboardInterrupt) as e:
+ if self.optimiser_options.verbose:
+ print(f"Failed to process pulse at index {index}: {e}")
self.pulses.append(None)
# Save parameters versus stoichiometry (ascending)
+ cost_name = add_spaces(self.cost.__name__) + " [V]"
self.parameter_data = pybop.Dataset(
{
"Stoichiometry": np.asarray(stoichiometry),
"Particle diffusion time scale [s]": np.asarray(diffusion_time),
"Series resistance [Ohm]": np.asarray(series_resistance),
- add_spaces(self.cost.__name__) + " [V]": np.asarray(final_costs),
+ cost_name: np.asarray(best_cost),
}
if len(stoichiometry) > 1 and stoichiometry[-1] > stoichiometry[0]
else {
@@ -182,23 +180,19 @@ def __call__(self) -> pybop.Dataset:
np.asarray(diffusion_time)
),
"Series resistance [Ohm]": np.flipud(np.asarray(series_resistance)),
- add_spaces(self.cost.__name__) + " [V]": np.flipud(
- np.asarray(final_costs)
- ),
+ cost_name: np.flipud(np.asarray(best_cost)),
},
domain="Stoichiometry",
)
- # Update parameter set
- self.parameter_values.update(
- {
- "Particle diffusion time scale [s]": np.mean(
- self.parameter_data["Particle diffusion time scale [s]"],
- ),
- "Series resistance [Ohm]": np.mean(
- self.parameter_data["Series resistance [Ohm]"],
- ),
- }
- )
+ # Compute mean values
+ self.best_inputs = {
+ "Particle diffusion time scale [s]": np.mean(
+ self.parameter_data["Particle diffusion time scale [s]"]
+ ),
+ "Series resistance [Ohm]": np.mean(
+ self.parameter_data["Series resistance [Ohm]"]
+ ),
+ }
return self.parameter_data
diff --git a/pybop/applications/ocp_methods.py b/pybop/applications/ocp_methods.py
index d028426df..3bb19bc74 100644
--- a/pybop/applications/ocp_methods.py
+++ b/pybop/applications/ocp_methods.py
@@ -44,11 +44,14 @@ def __call__(self) -> pybop.Dataset:
self.ocp_charge["Stoichiometry"], self.ocp_charge["Voltage [V]"]
)
- if np.sign(
+ # Determine electrode type and stoichiometry range
+ is_full_cell = np.sign(
self.ocp_charge["Stoichiometry"][-1] - self.ocp_charge["Stoichiometry"][0]
) == np.sign(
self.ocp_charge["Voltage [V]"][-1] - self.ocp_charge["Voltage [V]"][0]
- ):
+ )
+
+ if is_full_cell:
# Increasing stoichiometry corresponds to increasing voltage (full cell)
sto_min = np.min(self.ocp_charge["Stoichiometry"])
sto_max = np.max(self.ocp_discharge["Stoichiometry"])
@@ -64,9 +67,8 @@ def __call__(self) -> pybop.Dataset:
# Generate evenly spaced data for dataset creation
self.sto_evenly_spaced = np.linspace(sto_min, sto_max, self.n_sto_points)
- # Define a linear transition from the charge branch at low voltage
- # to the charge branch at high voltage
- transition = np.linspace(0, 1, len(self.sto_evenly_spaced))
+ # Generate coefficients for linear transition
+ transition = np.linspace(0, 1, self.n_sto_points)
voltage_merge = (1 - transition) * low_sto_fit(
self.sto_evenly_spaced
) + transition * high_sto_fit(self.sto_evenly_spaced)
@@ -75,13 +77,12 @@ def __call__(self) -> pybop.Dataset:
{"Stoichiometry": self.sto_evenly_spaced, "Voltage [V]": voltage_merge}
)
self.check_monotonicity(voltage_merge)
-
return self.dataset
class OCPAverage(BaseApplication):
"""
- Estimate the equlilibrium open-circuit potential (OCP) by averaging the charge
+ Estimate the equilibrium open-circuit potential (OCP) by averaging the charge
and discharge branches, using a method loosely based on method 4(a) proposed by
Lu et al. (2021) available at: https://doi.org/10.1149/1945-7111/ac11a5
@@ -102,8 +103,8 @@ class OCPAverage(BaseApplication):
The cost function to quantify the error (default: pybop.RootMeanSquaredError).
optimiser : pybop.BaseOptimiser, optional
The optimisation algorithm to use (default: pybop.SciPyMinimize).
- verbose : bool, optional
- If True, progress messages are printed (default: True).
+ optimiser_options : pybop.OptimiserOptions, optional
+ Options for the optimiser.
"""
def __init__(
@@ -113,43 +114,40 @@ def __init__(
n_sto_points: int = 101,
allow_stretching: bool = True,
cost: pybop.ErrorMeasure | pybop.LogLikelihood | None = None,
- optimiser: pybop.BaseOptimiser | None = pybop.SciPyMinimize,
- verbose: bool = True,
+ optimiser: pybop.BaseOptimiser | None = None,
+ optimiser_options: pybop.OptimiserOptions | None = None,
):
self.ocp_discharge = ocp_discharge
self.ocp_charge = ocp_charge
self.n_sto_points = n_sto_points
self.allow_stretching = allow_stretching
self.cost = cost or pybop.RootMeanSquaredError
- self.optimiser = optimiser
- self.verbose = verbose
+ self.optimiser = optimiser or pybop.SciPyMinimize
+ self.optimiser_options = optimiser_options or self.optimiser.default_options()
+
+ # Define a function to compute the differential capacity
+ def differential_capacity_interpolant(stoichiometry, voltage):
+ return pybop.Interpolant(
+ stoichiometry, np.nan_to_num(np.gradient(stoichiometry, voltage))
+ )
+
+ self.differential_capacity_interpolant = differential_capacity_interpolant
def __call__(self) -> pybop.Dataset:
# Use the discharge branch as the target to fit
voltage_discharge = pybop.Interpolant(
self.ocp_discharge["Stoichiometry"], self.ocp_discharge["Voltage [V]"]
)
- differential_capacity_discharge = pybop.Interpolant(
- self.ocp_discharge["Stoichiometry"],
- np.nan_to_num(
- np.gradient(
- self.ocp_discharge["Stoichiometry"],
- self.ocp_discharge["Voltage [V]"],
- )
- ),
+ differential_capacity_discharge = self.differential_capacity_interpolant(
+ self.ocp_discharge["Stoichiometry"], self.ocp_discharge["Voltage [V]"]
)
# Use the charge branch as the model output
voltage_charge = pybop.Interpolant(
self.ocp_charge["Stoichiometry"], self.ocp_charge["Voltage [V]"]
)
- differential_capacity_charge = pybop.Interpolant(
- self.ocp_charge["Stoichiometry"],
- np.nan_to_num(
- np.gradient(
- self.ocp_charge["Stoichiometry"], self.ocp_charge["Voltage [V]"]
- )
- ),
+ differential_capacity_charge = self.differential_capacity_interpolant(
+ self.ocp_charge["Stoichiometry"], self.ocp_charge["Voltage [V]"]
)
# Generate evenly spaced data for fitting
@@ -171,19 +169,10 @@ def __call__(self) -> pybop.Dataset:
# Define the optimisation parameters
self.parameters = pybop.Parameters(
- {
- "shift": pybop.Parameter(
- initial_value=0.05,
- ),
- }
+ {"shift": pybop.Parameter(initial_value=0.05)}
)
if self.allow_stretching:
- self.parameters.add(
- "stretch",
- pybop.Parameter(
- initial_value=1.0,
- ),
- )
+ self.parameters.add("stretch", pybop.Parameter(initial_value=1.0))
# Create the fitting problem
class OCVCurve(pybop.BaseSimulator):
@@ -239,9 +228,10 @@ def batch_solve(self, inputs, calculate_sensitivities: bool = False):
self.problem = pybop.Problem(simulator=OCVCurve(self.parameters), cost=cost)
# Optimise the fit between the charge and discharge branches
- options = pybop.SciPyMinimizeOptions(verbose=self.verbose)
- self.optim = self.optimiser(problem=self.problem, options=options)
- self.result = self.optim.run()
+ optim = self.optimiser(problem=self.problem, options=self.optimiser_options)
+ self.result = optim.run()
+
+ # Extract parameter values
self.stretch = (
np.sqrt(self.result.best_inputs["stretch"])
if self.allow_stretching
@@ -249,11 +239,12 @@ def batch_solve(self, inputs, calculate_sensitivities: bool = False):
)
self.shift = self.result.best_inputs["shift"] / (self.stretch + 1.0)
- if self.verbose:
+ if self.optimiser_options.verbose:
print(
f"The stoichiometry stretch and shift values are ({self.stretch}, {self.shift})."
)
+ # Create transformation functions
def stretch_and_shift(sto):
return self.stretch * sto + self.shift
@@ -269,6 +260,7 @@ def inverse_stretch_and_shift(sto):
stretch_and_shift(np.max(self.ocp_discharge["Stoichiometry"])),
inverse_stretch_and_shift(np.max(self.ocp_charge["Stoichiometry"])),
)
+
sto_range = np.linspace(sto_min, sto_max, self.n_sto_points)
voltage = (
voltage_discharge(inverse_stretch_and_shift(sto_range))
@@ -279,7 +271,6 @@ def inverse_stretch_and_shift(sto):
{"Stoichiometry": sto_range, "Voltage [V]": voltage}
)
self.check_monotonicity(voltage)
-
return self.dataset
@@ -299,8 +290,8 @@ class OCPCapacityToStoichiometry(BaseApplication):
The cost function to quantify the error (default: pybop.RootMeanSquaredError).
optimiser : pybop.BaseOptimiser, optional
The optimisation algorithm to use (default: pybop.SciPyMinimize).
- verbose : bool, optional
- If True, progress messages are printed (default: True).
+ optimiser_options : pybop.OptimiserOptions, optional
+ Options for the optimiser.
"""
def __init__(
@@ -308,30 +299,28 @@ def __init__(
ocv_dataset: pybop.Dataset,
ocv_function: Callable,
cost: pybop.ErrorMeasure | pybop.LogLikelihood | None = None,
- optimiser: pybop.BaseOptimiser | None = pybop.SciPyMinimize,
- verbose: bool = True,
+ optimiser: pybop.BaseOptimiser | None = None,
+ optimiser_options: pybop.OptimiserOptions | None = None,
):
self.ocv_dataset = ocv_dataset
self.ocv_dataset.domain = "Charge capacity [A.h]"
self.ocv_function = ocv_function
- cost = cost or pybop.RootMeanSquaredError
- self.cost = cost(self.ocv_dataset, weighting="domain")
- self.optimiser = optimiser
- self.verbose = verbose
+ self.cost = cost or pybop.RootMeanSquaredError
+ self.optimiser = optimiser or pybop.SciPyMinimize
+ self.optimiser_options = optimiser_options or self.optimiser.default_options()
def __call__(self) -> pybop.Dataset:
# Use the OCV dataset as the target to fit and the OCV function as the model
+ cost = self.cost(self.ocv_dataset, weighting="domain")
# Define the optimisation parameters
- self.parameters = pybop.Parameters(
+ capacity_range = np.max(self.ocv_dataset["Charge capacity [A.h]"]) - np.min(
+ self.ocv_dataset["Charge capacity [A.h]"]
+ )
+ parameters = pybop.Parameters(
{
- "shift": pybop.Parameter(
- initial_value=0,
- ),
- "stretch": pybop.Parameter(
- initial_value=np.max(self.ocv_dataset["Charge capacity [A.h]"])
- - np.min(self.ocv_dataset["Charge capacity [A.h]"]),
- ),
+ "shift": pybop.Parameter(initial_value=0),
+ "stretch": pybop.Parameter(initial_value=capacity_range),
}
)
@@ -357,16 +346,16 @@ def batch_solve(self, inputs, calculate_sensitivities: bool = False):
solutions.append(sol)
return solutions
- problem = pybop.Problem(simulator=OCVCurve(self.parameters), cost=self.cost)
+ problem = pybop.Problem(simulator=OCVCurve(parameters), cost=cost)
# Optimise the fit between the OCV function and the dataset
- options = pybop.SciPyMinimizeOptions(verbose=self.verbose)
- self.optim = self.optimiser(problem=problem, options=options)
- self.result = self.optim.run()
+ optim = self.optimiser(problem, options=self.optimiser_options)
+ self.result = optim.run()
+
self.stretch = self.result.best_inputs["stretch"]
self.shift = self.result.best_inputs["shift"]
- if self.verbose:
+ if self.optimiser_options.verbose:
print(
f"The capacity stretch and shift values are ({self.stretch} A.h, {self.shift} A.h)."
)
diff --git a/pybop/costs/base_cost.py b/pybop/costs/base_cost.py
index a9644345d..b67a54cb1 100644
--- a/pybop/costs/base_cost.py
+++ b/pybop/costs/base_cost.py
@@ -1,5 +1,6 @@
import numpy as np
+from pybop._utils import add_spaces
from pybop.parameters.parameter import Inputs, Parameters
from pybop.simulators.solution import Solution
@@ -106,6 +107,10 @@ def failure(self, calculate_sensitivities: bool = True):
else:
return np.inf if self.minimising else -np.inf
+ @property
+ def name(self):
+ return add_spaces(type(self).__name__)
+
@property
def n_parameters(self):
return len(self.parameters)
diff --git a/pybop/costs/error_measures.py b/pybop/costs/error_measures.py
index c6ed7c4fe..b4baf6e39 100644
--- a/pybop/costs/error_measures.py
+++ b/pybop/costs/error_measures.py
@@ -2,7 +2,7 @@
import pybamm
from pybop._dataset import Dataset
-from pybop._utils import FailedSolution, add_spaces
+from pybop._utils import FailedSolution
from pybop.costs.base_cost import BaseCost
from pybop.parameters.parameter import Inputs
from pybop.simulators.solution import Solution
@@ -182,10 +182,6 @@ def __call__(
"""
raise NotImplementedError
- @property
- def name(self):
- return add_spaces(type(self).__name__)
-
@property
def target_data(self):
return self._target_data
@@ -365,6 +361,10 @@ def __call__(
return e
+ @property
+ def __name__(self):
+ return f"Minkowski distance (p = {self.p})"
+
class SumOfPower(ErrorMeasure):
"""
@@ -427,3 +427,6 @@ def __call__(
return e, de
return e
+
+ def __name__(self):
+ return f"Sum of Power (p = {self.p})"
diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py
index 5843c0d10..f3fc23d19 100644
--- a/tests/unit/test_import.py
+++ b/tests/unit/test_import.py
@@ -1,6 +1,4 @@
-import importlib
import sys
-from unittest.mock import patch
import pytest
@@ -8,23 +6,6 @@
class TestImport:
pytestmark = pytest.mark.unit
- def test_multiprocessing_init_non_win32(self, monkeypatch):
- """Test multiprocessing init on non-Windows platforms"""
- monkeypatch.setattr(sys, "platform", "linux")
- # Unload pybop and its sub-modules
- self.unload_pybop()
- with patch("multiprocessing.set_start_method") as mock_set_start_method:
- importlib.import_module("pybop")
- mock_set_start_method.assert_called_once_with("fork")
-
- def test_multiprocessing_init_win32(self, monkeypatch):
- """Test multiprocessing init on Windows"""
- monkeypatch.setattr(sys, "platform", "win32")
- self.unload_pybop()
- with patch("multiprocessing.set_start_method") as mock_set_start_method:
- importlib.import_module("pybop")
- mock_set_start_method.assert_called_once_with("spawn")
-
def unload_pybop(self):
"""
Unload pybop and its sub-modules. Credit PyBaMM team:
From c01f27d8bcac9c87ca6582add2cea291db635420 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Mon, 1 Dec 2025 19:41:32 +0000
Subject: [PATCH 02/37] chore: update pre-commit hooks (#845)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.14.3 → v0.14.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.14.3...v0.14.7)
- [github.com/kynan/nbstripout: 0.8.1 → 0.8.2](https://github.com/kynan/nbstripout/compare/0.8.1...0.8.2)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
---
.pre-commit-config.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4a5bebe76..bd608c64f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,7 +5,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: "v0.14.3"
+ rev: "v0.14.7"
hooks:
- id: ruff
args: [--fix, --show-fixes]
@@ -35,7 +35,7 @@ repos:
- id: rst-inline-touching-normal
- repo: https://github.com/kynan/nbstripout
- rev: 0.8.1
+ rev: 0.8.2
hooks:
- id: nbstripout
args: ['--keep-output', '--drop-empty-cells']
From 8a3e3fcff8ae76c82ecf87e1c83bb985deba44dd Mon Sep 17 00:00:00 2001
From: Sarah Roggendorf <33656497+SarahRo@users.noreply.github.com>
Date: Wed, 3 Dec 2025 10:45:17 +0000
Subject: [PATCH 03/37] Fix image not displaying in readme (#847)
* replace image in readme
* replace image again
* switch to relative path
* add background
* add background
* update changelog
---
CHANGELOG.md | 2 ++
README.md | 2 +-
assets/PyBOP_components.svg | 4 ++++
3 files changed, 7 insertions(+), 1 deletion(-)
create mode 100644 assets/PyBOP_components.svg
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3264eb74..1c20f4bb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@
## Bug Fixes
+- [#847](https://github.com/pybop-team/PyBOP/pull/847) - Update readme and diagram of pybop components so that the diagram is displayed correctly in the readme.
+
## Breaking Changes
# [v25.11](https://github.com/pybop-team/PyBOP/tree/v25.11) - 2025-11-24
diff --git a/README.md b/README.md
index 909cec3aa..42a46bb7d 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ To understand how to update your use of PyBOP, please take a look at the example
-
+
## 💻 Installation
diff --git a/assets/PyBOP_components.svg b/assets/PyBOP_components.svg
new file mode 100644
index 000000000..9210bce21
--- /dev/null
+++ b/assets/PyBOP_components.svg
@@ -0,0 +1,4 @@
+
+
+
+Problem Cost Simulator model protocol target options Optimiser / Sampler model param... cost parame... Solution Evaluation Result evaluate run solve Inputs Text is not SVG - cannot display
From 460b6e3e419ee4aa8a28d29351826834dc3db443 Mon Sep 17 00:00:00 2001
From: Dibyendu-IITKGP <32595915+Dibyendu-IITKGP@users.noreply.github.com>
Date: Sun, 7 Dec 2025 12:25:30 +0000
Subject: [PATCH 04/37] Finite difference calculations of the Hessian matrix
are updated, new example notebook is added for sensitivity analysis using
SALib. (#838)
* finite difference calculations are updated, new example notebook added for sensitivity analysis
* style: pre-commit fixes
* updated
* style: pre-commit fixes
* updated
* updated
* style: pre-commit fixes
* integration test updated
* integration test updated
* integration test updated
* test updated
* tests updated
* tests updated
* style: pre-commit fixes
* tests updated
* style: pre-commit fixes
* tests updated
* tests updated
* tests updated
* tests updated
* tests updated
* style: pre-commit fixes
* modified the eample notebook
* style: pre-commit fixes
* unit test added
* integration test renamed
* unit test updated
* Add normalise option
* Update notebooks
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com>
---
CHANGELOG.md | 2 +
.../ecm_multipulse_identification.ipynb | 95 +-
.../sensitivity_analysis_hessian.ipynb | 19073 ++++++++++++++++
.../sensitivity_analysis_salib.ipynb | 829 +
pybop/__init__.py | 2 +-
pybop/analysis/classification.py | 149 +-
...test_classification.py => test_hessian.py} | 118 +-
tests/unit/test_classification.py | 90 +
8 files changed, 20316 insertions(+), 42 deletions(-)
create mode 100644 examples/notebooks/battery_parameterisation/sensitivity_analysis_hessian.ipynb
create mode 100644 examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb
rename tests/integration/{test_classification.py => test_hessian.py} (60%)
create mode 100644 tests/unit/test_classification.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c20f4bb4..d81deccd2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,8 @@
## Bug Fixes
+- [#834](https://github.com/pybop-team/PyBOP/issues/834) - Finite difference calculations of the Hessian matrix are updated. A new notebbok file is added which demonstrates sensitivity analysis using SALib.
+
## Breaking Changes
- [#829](https://github.com/pybop-team/PyBOP/pull/829) - Create `SamplingResult` and best inputs property for results.
diff --git a/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb b/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb
index dc2caa6f3..9996508cf 100644
--- a/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb
+++ b/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb
@@ -9,7 +9,7 @@
"\n",
"## Estimating ECM parameters from multi-pulse HPPC data\n",
"\n",
- "This notebook provides example usage for estimating stationary parameters for a two RC branch Thevenin model using multi-pulse HPPC data.\n",
+ "This notebook provides example usage for estimating stationary parameters for a two RC branch Thevenin model using multi-pulse HPPC data. With the estimated parameters, a thermal model is created and simulated.\n",
"\n",
"### Setting up the Environment\n",
"\n",
@@ -60,7 +60,7 @@
"source": [
"## Setting up the model\n",
"\n",
- "In this example, we use the Thevenin model with two RC elements and the default parameter value for the \"Open-circuit voltage [V]\", as provided by the original PyBaMM class. The other relevant parameters for the ECM model implementation are updated as per the cell specification."
+ "In this example, we use the Thevenin model with two RC elements and the default parameter value for the \"Open-circuit voltage [V]\", as provided by the original PyBaMM class. To update this, provide a function definition that matches this [function](https://github.com/pybamm-team/PyBaMM/blob/1943aa5ab2895b5378220595923dbae3d66b13c9/pybamm/input/parameters/ecm/example_set.py#L17). The other relevant parameters for the ECM model implementation are updated as per the cell specification."
]
},
{
@@ -161,9 +161,9 @@
"data": {
"text/html": [
" \n",
- "
\n",
- "
\n",
- "
\n",
- "
\n",
- "
\n",
+ " \n",
+ " "
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "text/html": [
+ "
"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "pybop.plot.problem(problem, inputs=result.best_inputs, title=\"Optimised Comparison\");"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "14",
+ "metadata": {},
+ "source": [
+ "## Performing sensitivity analysis\n",
+ "\n",
+ "We use the Sobol and Morris methods from SALib to perform sensitivity analysis on the two RC pair model. More information on the sensitivity analysis methods available from SALib can be found [here](https://salib.readthedocs.io/en/latest/)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "15",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Define the SALib problem dictionary for sensitivity analysis\n",
+ "salib_dict = {\n",
+ " \"names\": problem.parameters.names,\n",
+ " \"bounds\": problem.parameters.get_bounds_array(),\n",
+ " \"num_vars\": len(problem.parameters),\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "16",
+ "metadata": {},
+ "source": [
+ "Let's create a helper function to evaluate the samples in a loop, using the `pybop.Problem` to evaluate the cost for each set of input parameters."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "17",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def evaluate_samples(param_values: np.ndarray):\n",
+ " \"\"\"\n",
+ " param_values: np.ndarray shape (N, n_params)\n",
+ " returns: np.ndarray Y shape (N,)\n",
+ " \"\"\"\n",
+ " t0 = time.time()\n",
+ " N = param_values.shape[0]\n",
+ " cost_values = np.empty(N)\n",
+ " # Loop over 1/10th of the array at a time\n",
+ " n = 0\n",
+ " for params in np.array_split(param_values, 10):\n",
+ " n_i = len(params)\n",
+ " cost_values[n : n + n_i] = problem.evaluate(params).values\n",
+ " n += n_i\n",
+ " print(\n",
+ " f\"Eval {n}/{N}, elapsed time={time.time() - t0:.1f} s, last cost={cost_values[n - 1]:.6g}\"\n",
+ " )\n",
+ " return cost_values"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "18",
+ "metadata": {},
+ "source": [
+ "## Sobol sensitivity analysis\n",
+ "\n",
+ "Choose a base sample size N (use N=512 or higher for better estimates) and generate a set of samples."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "19",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Running model for Saltelli sample of size: (896, 5)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 90/896, elapsed time=1.4 s, last cost=0.155896\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 180/896, elapsed time=2.7 s, last cost=2.91953\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 270/896, elapsed time=4.0 s, last cost=3.443\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 360/896, elapsed time=5.3 s, last cost=0.0400496\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 450/896, elapsed time=6.5 s, last cost=2.15141\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 540/896, elapsed time=7.9 s, last cost=6.34475\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 629/896, elapsed time=9.1 s, last cost=2.45682\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 718/896, elapsed time=10.7 s, last cost=3.48555\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 807/896, elapsed time=11.8 s, last cost=6.53673\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 896/896, elapsed time=13.1 s, last cost=3.19176\n"
+ ]
+ }
+ ],
+ "source": [
+ "N = 128\n",
+ "param_values = sobol_sample.sample(salib_dict, N, calc_second_order=False)\n",
+ "\n",
+ "print(\"Running model for Saltelli sample of size:\", param_values.shape)\n",
+ "Y_saltelli = evaluate_samples(param_values)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "20",
+ "metadata": {},
+ "source": [
+ "Use the samples to analyse the Sobol indices."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "21",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " ST ST_conf\n",
+ "R0 [Ohm] 0.991417 0.164807\n",
+ "R1 [Ohm] 0.018585 0.005463\n",
+ "R2 [Ohm] 0.001103 0.000744\n",
+ "Tau1 [s] 0.008247 0.003998\n",
+ "Tau2 [s] 0.001137 0.000547\n",
+ " S1 S1_conf\n",
+ "R0 [Ohm] 0.972346 0.222216\n",
+ "R1 [Ohm] 0.014623 0.026848\n",
+ "R2 [Ohm] -0.000235 0.007942\n",
+ "Tau1 [s] 0.010430 0.023832\n",
+ "Tau2 [s] -0.002567 0.008329\n",
+ "Sobol S1: [ 9.72345726e-01 1.46225250e-02 -2.35067959e-04 1.04295189e-02\n",
+ " -2.56709598e-03]\n",
+ "Sobol ST: [0.99141718 0.01858489 0.00110346 0.00824684 0.00113709]\n"
+ ]
+ }
+ ],
+ "source": [
+ "Si = sobol.analyze(\n",
+ " salib_dict, Y_saltelli, calc_second_order=False, print_to_console=True\n",
+ ")\n",
+ "print(\"Sobol S1:\", Si[\"S1\"])\n",
+ "print(\"Sobol ST:\", Si[\"ST\"])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22",
+ "metadata": {},
+ "source": [
+ "Plot the results."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "23",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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AFgAwR8AAACwAII/AAAAYAEEfwAAAMACCP4AAACABRD8AQAAAAsg+AMAAAAWQPAHAAAALKBCBv9Ro0bJZrPle0yaNMml24yOjja3FR0d7dJtXauBAwcWeF9mz57t7rIAAABQCq46+M+ePbtAeLTZbPL19VVERIR69eqlyMhIZWRkFNnPvn379Oabb2rcuHG66aab5OvrK5vNpnr16l3rvhTg5+en8PBwhYeHy263F7luamqqli5dqjvuuEN16tSRv7+/goOD1axZM40fP17ffPON0+pyl9DQUPP98Pb2dnc5AAAAKEVeJWkcHh5uPk9ISNDp06d1+vRpbdq0SUuXLtWmTZsUGhpaaNu7775bR48eLcnmr2jw4MFatmzZFdfbvHmzxowZoxMnTpiv2e12paWl6eDBgzp48KDefvtt9enTR++//76qVKniwqpd57333jOfd+3aVdu3b3djNQAAAChNJRrqc+bMGfORlJSko0ePaty4cZKkPXv26LHHHnPY1sfHRzfeeKPGjBmjN954Q8OHDy9JKdds9erV6tu3r06cOKGaNWsqMjJSMTExiouLU2pqqg4cOKBJkybJy8tLGzZs0C233KJz5865pVYAAADgWpXojP/l6tSpo7feekt//PGHvv76a61Zs0ZvvvmmAgMDC6x74MABeXp6mj+fP3/emaUUy4EDBzRmzBhlZmaqZcuW2rp1q6pWrZpvnaZNm2rBggXq2bOn7rrrLh0+fFhDhw7Vli1bSr1eAAAA4Fq55OLe3r17S5LS09P1v//9r9B18oZ+d5k+fbqSk5Pl6+urtWvXFgj9efXt21fTp0+XJG3dulVffvllkX2fPXtWjz/+uOrXr29ea3D//ffr4MGDha6/bds283oJSfrll180ZMgQRUREyN/fX82aNdMrr7yizMxMs83OnTs1cOBA1ahRQ35+fmrRooUWLVokwzCu9q0AAABABeeS4J83eGZlZbliEyV2+vRprV+/XpI0ZMgQNWnS5IptJk+erKCgIEnSokWLHK4XFRWl66+/XgsXLjSHBZ07d06rV69Wu3bt9PPPPxe5nQ0bNqhdu3ZatWqVkpOTzWsNnnzySXNIVGRkpLp06aLPPvtMKSkpSktLU1RUlB555BFNmzatOG8BAAAALMQlwX/jxo2SJJvNpvr167tiEyW2bds2ZWdnS5LuueeeYrUJ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Dz7QoEGDVL9+fVWqVElBQUH6y1/+ouHDh+vTTz8tcT3uZrfbzffDz8/P3eUAAACgFHmVpHF4eLj5PCEhQadPn9bp06e1adMmLV26VJs2bSo0cE+cOFGRkZHmzx4eHrLb7YqNjdXu3bu1e/duLVy4UOvXr9ctt9xyzfV16NChWGff9+/fr6FDh+rgwYPma4GBgcrOztYff/yhP/74Q//617/Utm1brV69WvXq1bvmmtzp2Wef1bPPPitJGjVqlJYvX+7migAAAFBaSjTU58yZM+YjKSlJR48e1bhx4yRJe/bs0WOPPVZou4yMDFWrVk1PPPGEdu3apdTUVF26dEkJCQmKjIxUlSpVdPbsWfXr10/nz58vSYlX9O2336pz5846ePCgQkND9Y9//EOnT59WQkKCkpKSFB0drTlz5iggIED/+c9/1K5du3x/IAAAAADlgVPH+NepU0dvvfWWbrvtNknSmjVrlJiYWGC9Bx98UNHR0Zo/f77at28vb29vSTln2ceOHavPP/9ckhQTE6OlS5c6s8R8zp07p8GDByspKUm1atXSjz/+qClTpqh69ermOnXr1tXMmTO1fft2hYSE6Ny5c7r33nuVmprqsroAAAAAZ3PJxb29e/eWJKWnp+t///tfgeXt2rWTv7+/w/bt27dX8+bNJUk//vijK0qUJL300ks6c+aMJOn9999Xw4YNHa7bunVrLVy4UJIUFRWld955p8i+ExISNH36dDVt2lT+/v6qUqWK7rjjDv3www+Frh8dHW1eLxEdHW1+e1KnTh35+fmpYcOGmj59upKSksw2//3vfzVs2DDVrl1bfn5+atSokebOnVvk9RUAAACwJpcEf8MwzOdZWVnX1EfuxafX2v5KMjIyzOsMunbtqq5du16xzbBhw8w/DhYtWuRwvdOnT+umm27S888/r6NHj8rDw0MxMTH68ssvdeutt2rTpk1Fbmffvn268cYbFRkZqbi4OGVmZurPP//U888/rz59+igjI0Nffvml2rVrpw8++EAJCQlKT0/X4cOHNWPGDA0fPrz4bwQAAAAswSXBf+PGjZJypresX7/+Vbe/cOGC/vvf/0qSWrZs6dTacu3Zs0fx8fGSpHvuuadYbfJOC3rgwAGdPXu20PUefvhh+fj46Ouvv1ZSUpISExP1n//8R02aNFF6errGjx+v7Oxsh9sZO3as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pwYMGGD+bLfb5efnp/Pnz2vTpk3atGmT1q1bp1WrVsnDo/T/tvHz85OUcyGwqzz77LOaNWuW+XNAQIAMw9CRI0d05MgRbd68Wa1bt1bXrl1dVgMAAADKj3J5xj8gIEATJkzQ5s2bFRcXp7i4OMXHx+vixYt6/fXXZbfbtXbtWr3xxhulXtuFCxf03//+V5LUsmVLl2zj6NGj5vUPU6ZM0cmTJ5WUlKSEhATFxsZqx44deuihhxQUFFSi7aSlpSk+Pj7fAwAAAOVTuQz+bdu21ZIlS9SjR49801JWrlxZjz32mN555x1J0sKFC0u9thkzZig9PV1eXl4aNWqUS7bxww8/KDs7W40bN9Y//vEPRUREmMuCg4PVqVMnLVq0SDfffHOJtjNv3jwFBwebj9q1a5e0dECStG3bNnM4XXlV1JBAAADKonIZ/K+kX79+kqQ//vijVGcEWr16tZYsWSJJevLJJ9WkSROXbCckJESSlJCQYE556grTpk0zv1GJi4vT8ePHXbYtOFba97r46aefNHv2bL322mtO3xcAAOA+5XKMv5QTepcsWaIvvvhCBw4cMOervtyJEydUvXp1l9ezY8cOc1782267Tc8++6zLttW2bVuFhYXp9OnTateunSZOnKgePXqoSZMmTj2D6uvrK19fX6f1dyUv7r9QattypadbhTm1v9K+18VPP/2kOXPmqG7dupo0adJVtwcAAGVTuQz+hw4dUvfu3XXixAnztYCAAIWEhJgX8549e1aSXHpGPNfu3bvVr18/paSkqGPHjvr000/l5eW6tzYkJEQrV67U0KFDFRUVpUcffVRSzjCfW2+9Vffdd58GDx5sTjWK8q283esCAACUTeVyqM/o0aN14sQJ1atXT2vXrtXFixeVlJSkc+fO6cyZMzp58qS5rqtvV79792717t1bCQkJat++vTZs2FAqd6nt0aOHjhw5ohUrVmjkyJFq1KiR4uLi9Pnnn2v48OFq1apVvvcBAAAA1lbugv/x48e1a9cuSdLKlSt17733qnLlyvnWKersZ+6Z+NTUVIfrxMXFFauWXbt2qVevXoqPj1f79u21cePGEs+kczUqVaqk4cOHa9myZTp06JBOnDihl156SX5+fvm+CYB17d+/XyNGjFDdunXl5+en0NBQdejQQa+99prS0tIKrG+z2cwha0ePHi1wzcDs2bPNdZOTk7Vy5UqNGDFCN954o6pWrSpfX19FRERo4MCB2rBhg0v37cyZM3ryySd13XXXqVKlSqpUqZKuu+46PfXUU+Y3fpe7/ILcP/74Q+PHj1f9+vXl6+tb4P4eJ0+e1IQJE1S7dm35+vqqVq1aGj16tA4fPlysGtPT07V48WJ169ZNYWFh8vHxUfXq1TVgwIAi35/cGrdt26Zz585pypQpaty4sQICAsr1BdEAAPcqd0N98l5g2qpVq0LX2bJli8P2oaGhkqRz584pLS2t0DHsP/zwwxXr2LVrV74z/V999VWphv7C1KxZU0899ZTi4+P1/PPPa/PmzW6tB+61YMECTZ061fzWKzg4WElJSdq9e7d2796t9957T1999ZVq1KhhtgkPD1dKSori4+Pl4eGhqlWr5usz77dZa9asMf9IsNlsstvt8vLy0unTp/Xpp5/q008/1dSpU/XKK684fd+2b9+ugQMHKjY2VlLOH8GS9Ntvv+m3335TZGSkPvvsM3Xq1MlhH7t27dKECROUmJiogICAAkPj9u3bpx49eujSpUuScq6XiIuL07Jly/TJJ5/o7bffLrLGo0ePql+/foqKipL0f+/R2bNn9dlnn+mzzz7TxIkT9eabbzrs4/Dhw7r//vt19uxZ+fn5MXwPAFAi5e6Mf3BwsPn8559/LrA8ISFBc+fOddj+hhtukJQzBGjdunUFlqekpGjBggVF1pA39Hfo0EEbN27MN62oqxV2pjav3As63XHzMpQNX3zxhaZMmSLDMDRgwAD9+eefio2NVWJiolasWKGgoCD98ssvuvfee/PdaO7MmTN6/fXXJUm1a9fWmTNn8j2eeOIJc93Q0FA98cQT+u6775SYmKjY2FglJSXp1KlTmjNnjry9vfWPf/xDn332mVP37fjx42bob968ubn9xMREffvtt2rSpIkuXbqkAQMGFDncbcKECbruuuv0448/KikpSYmJidq0aZOknH9H7rrrLl26dEl16tTRpk2bzHtl7Nq1S7Vr19aECRMc9p2UlKTevXsrKipKXbt21bZt25SSkqLY2FjFxsbq1VdfVWBgoJYsWWK+34WZPHmyQkJCtHXrViUlJSk+Pl6///77tb95AABLK3fJsFmzZqpTp44kacyYMdq7d6+5bPfu3eratat5hq4wtWrVMs8CTpkyRVu2bDGDz969e9WjRw+d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AaACGDduXL4z5q1atTIeeugh45133jF+/fVXh2faDcMwTpw4YYbradOmFbrOlClTDEmGt7e3cerUqXzLcoO/JGPGjBmFth82bJghyahcubKRkpJivp6cnGxUrlzZ/EahMAsXLjT737NnT75lrg7+koxZs2YV2v7uu+82JBk9evQosKx58+ZmqM/KyiqwfMmSJWb/lwf/rKwso1GjRoYkY+nSpQ7rv/POOw1JxuOPP15g2Ztvvml+8/L1118bhmEYEyZMMP+YufyPBQDWQPAHgAogIyPDmDFjhlGpUqV8w0tyH9WqVTMmT55snDlzpkDb119/3ZBk+Pn5mWfqLxcTE2MOO1m4cGG+ZbnB39/f32H7Q4cOmbV89tln5ut5z4w7+qYiMzPTqFGjhiHJmDJlSr5lrg7+vr6+RkJCQqHtly9fbr63ef3888/mPm3evLnQtllZWUbNmjULDf7ffPONIckICwsr9I+GXB999JEhyWjatGmhy3P/MImIiDCWLl1q1rR27VqHfQKo2Li4FwAqAC8vLz377LM6efKk3n//fT3wwAO64YYb5OPjI0k6d+6cFixYoBYtWug///lPvrZ79uyRJLVp08bhjb1CQ0PVunXrfOtfrnXr1g7bN2rUSLVq1SrQPvd57dq11bhx40Lbenp66rbbbity266Se1FtYSIiIiRJMTEx+V7PrdHLy0udO3cutK2Hh4fDO/bu3LlTkhQXF6eIiAhVr1690Me4ceMkSUePHi20n8jISNWpU0enTp3ShAkTJEkPPPCA7r333iL2GEBFRvAHgAokODhYw4YN09tvv62ffvpJcXFx2rx5s/r37y9JunDhgu655558s9GcO3dOkq54I6vc4J67/uWu1D53ed72ztq2qwQFBTlc5uWVMyN2ZmZmvtdzawwLC5Ovr6/D9rn7dLlTp05JkjIyMnT27FmHj0uXLkmSw+lZQ0NDtWjRIvPnBg0a6PXXX3dYD4CKj+APABWYn5+fevTooc8++0wjR46UJJ04cUJfffWVmyuDI7lTl7Zr105GzpDcKz4cefvtt83nJ0+e1OHDh11eP4Cyi+APABYxfvx48/nvv/9uPq9WrZqknD8IipK7PHf9y508ebLI9rnL87Z31rbLktwaL1y4oPT0dIfrOXq/qlevLsnxEJ7ieuONN/TZZ5/J09NTzZs3V1pamu6//34lJyeXqF8A5RfBHwAsIu9Y9bxDUPKO3Y+Liyu0bWxsbL5rAQqzZ88eJSYmFrrs8OHDZnjP3V7e5ydOnNChQ4cKbZuVlaVvvvmmyG2XJbn7lJmZqR07dhS6TnZ2trZt21boso4dO0qSzpw5c83XNPz666968sknJUkzZ87Uv//9b4WEhOjAgQOaPHnyNfUJoPwj+ANAOXfkyBGHoTmv5cuXm89vuukm8/k999wjLy8vpaam6qWXXiq07QsvvKC0tDR5e3vrnnvuKXSdlJQUvfLKK4Uumzt3riSpcuXK6tmzp/l6z549VaVKFUk5d8stzNKlS81x70OGDHGwd2XH9ddfr2bNmkmSnn/+eWVnZxdY591333X4LUe3bt30l7/8RZI0efLkIr81kApeXJySkqL7779fqamp6tSpk5555hnVrVtXb731liTprbfe0scff3zV+wWg/CP4A0A5FxUVpWbNmqlfv35asWKFoqOjzWUZGRnav3+/Ro8erVdffVWS1LZtW3Xq1Mlcp2bNmnr88cclSS+++KJmzZql2NhYSTln+mfMmKH58+dLkqZMmaIaNWoUWkdwcLCee+45zZs3TwkJCZJyhrs8/vjj5h8dM2bMkJ+fn9nG39/fDPwrV67UxIkTdfbsWUlScnKyFi5cqEmTJkmSBg8erJtvvrkE71Tpef755yVJ33zzjYYOHWqG/NTUVC1ZskSPPPKIQkJCCm3r5eWlJUuWyMvLS999951uvfVWbd26VRkZGeY6f/75p5YsWaI2bdpo8eLF+dpPnjxZv/32m0JCQvTBBx/I09NTkjRo0CCNHTtWkjRu3DgdP37c2bsNoKxz1zyiAADn+OqrrwrM2+/j42NUrlzZvKNv7uOmm24yTp48WaCPtLQ047777jPX8/DwMEJDQw0PDw/ztSFDhhjp6ekF2ubO4z9y5Ehj8ODBhiTD09PTCA0Nzbf9ESNGOJyXfvLkyfluQBYaGmreVEyS0a1bNyM+Pr5AO1fP41/YXXVz5c637+ij9Jlnnsn33ufdp86dOxvTpk0rchvr1q0zgoKCzPbe3t5GlSpVzPsp5D7mzp1rtvn444+LnK8/KSnJaNq0qVkDd+8FrIUz/gBQzvXq1Uv/+9//9Prrr2vQoEFq1qyZfH19FRsbq4CAADVq1Ej33XefVq1apR9//NGcfz4vHx8frV69Wh999JH69OmjKlWqKCEhQVWqVFGfPn30ySef6MMPP5S3t3eRtaxcuVKLFy9Wq1atlJmZqUqVKql9+/ZasWKFli9fLg+Pwj92Xn31VX399de65557FB4ersTERAUFBalbt2569913tXnz5iKn1iyL5s6dqy+++EK33Xab7Ha70tLS1KxZM7344ovaunWreY8FRwYOHKjDhw9r1qxZatu2rQIDAxUbGytfX1/dcMMNeuCBB7Ru3TpzLP/x48f1wAMPSJLGjh1b6Hz9AQEBWrlypXx9fbVjxw5zCBYAa7AZRhHzgAEAAACoEDjjDwAAAFgAwR8AAACwAII/AAAAYAEEfwAAAMACCP4AAACABRD8AQAAAAsg+AMAAAAWQPAHAAAALIDgDwAAAFgAwR8AAACwAII/AAAAYAEEfwAAAMACCP4AAACABRD8AQAAAAv4fxqTYEgfbVO9AAAAAElFTkSuQmCC",
+ "text/plain": [
+ "
"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "plt.rcParams.update({\"font.size\": 18, \"font.weight\": \"normal\"})\n",
+ "plt.figure(figsize=(8, 5))\n",
+ "labels = salib_dict[\"names\"]\n",
+ "plt.barh(labels, Si[\"ST\"], xerr=Si[\"ST_conf\"], color=\"skyblue\", label=\"Total order\")\n",
+ "# uncomment the below line to see first order indices\n",
+ "# plt.barh(labels, Si[\"S1\"], xerr=Si[\"S1_conf\"], color=\"orange\", alpha=0.7, label=\"First order\")\n",
+ "plt.xlabel(\"Sobol index\", fontweight=\"normal\")\n",
+ "plt.legend()\n",
+ "plt.gca().invert_yaxis()\n",
+ "plt.tight_layout()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "24",
+ "metadata": {},
+ "source": [
+ "## Morris sensitivity analysis\n",
+ "\n",
+ "Generate a set of samples."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "25",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Morris sample generated: (24, 5)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 3/24, elapsed time=0.2 s, last cost=9.98395\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 6/24, elapsed time=0.4 s, last cost=1.30937\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 9/24, elapsed time=0.7 s, last cost=0.0330074\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 12/24, elapsed time=0.9 s, last cost=0.140275\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 14/24, elapsed time=1.1 s, last cost=6.45869\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 16/24, elapsed time=1.3 s, last cost=0.315565\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 18/24, elapsed time=1.4 s, last cost=0.322637\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 20/24, elapsed time=1.6 s, last cost=0.567131\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 22/24, elapsed time=1.7 s, last cost=6.64031\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Eval 24/24, elapsed time=1.9 s, last cost=8.7264\n",
+ "Model evaluations complete: (24,)\n",
+ "[7.02927617 7.60795018 9.98394694 1.33636604 1.30936964 1.3093725\n",
+ " 3.18786692 3.90932627 0.03300742 0.03482044 0.14028371 0.14027547\n",
+ " 7.19636823 6.45868702 6.45864622 0.3155649 0.3226275 0.32263744\n",
+ " 0.56708676 0.56713068 7.60961218 6.64031222 8.87236197 8.72639956]\n"
+ ]
+ }
+ ],
+ "source": [
+ "# User settings\n",
+ "r = 10 # number of candidate trajectories (will generate r*(k+1) samples)\n",
+ "levels = 4 # number of grid levels for each parameter\n",
+ "optimal_k = 4 # number of optimal trajectories to select (< r)\n",
+ "fail_val = 1e5 # threshold to mark failed runs\n",
+ "seed = 123 # random seed for reproducibility\n",
+ "\n",
+ "# Generate Morris samples\n",
+ "param_values_morris = morris_sample.sample(\n",
+ " salib_dict,\n",
+ " N=r,\n",
+ " num_levels=levels,\n",
+ " optimal_trajectories=optimal_k,\n",
+ " local_optimization=False,\n",
+ " seed=seed,\n",
+ ")\n",
+ "print(\"Morris sample generated:\", param_values_morris.shape)\n",
+ "\n",
+ "# Evaluate model for all samples\n",
+ "Y_morris = evaluate_samples(param_values_morris)\n",
+ "print(\"Model evaluations complete:\", Y_morris.shape)\n",
+ "print(Y_morris)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "26",
+ "metadata": {},
+ "source": [
+ "Reshape the samples into trajectories and identify failures. Then analyse the Morris indices."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "27",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Total trajectories: 4, Failed trajectories: 0, Valid trajectories: 4\n",
+ "Valid samples shape: (24, 5) Valid Y shape: (24,)\n",
+ " mu mu_star sigma mu_star_conf\n",
+ "R0 [Ohm] 9.641048 9.641048 2.987019 2.328316\n",
+ "R1 [Ohm] 1.965099 2.044197 1.799502 1.546974\n",
+ "R2 [Ohm] 0.066828 0.068188 0.103008 0.088456\n",
+ "Tau1 [s] -0.487570 0.487570 0.569701 0.520493\n",
+ "Tau2 [s] -0.363501 0.363507 0.726966 0.719571\n",
+ "\n",
+ "Morris mu* (clean): [9.641048461758706 2.044196618906839 0.06818791206851062\n",
+ " 0.4875702106674279 0.36350694628871716]\n",
+ "Morris sigma (clean): [2.9870195 1.79950199 0.10300767 0.56970091 0.72696612]\n"
+ ]
+ }
+ ],
+ "source": [
+ "k = salib_dict[\"num_vars\"] # number of parameters\n",
+ "r_total = int(param_values_morris.shape[0] / (k + 1))\n",
+ "\n",
+ "samples_reshaped = param_values_morris.reshape((r_total, k + 1, k))\n",
+ "Y_reshaped = Y_morris.reshape((r_total, k + 1))\n",
+ "\n",
+ "traj_has_fail = np.any(Y_reshaped > fail_val, axis=1)\n",
+ "n_fail_traj = np.sum(traj_has_fail)\n",
+ "n_valid_traj = r_total - n_fail_traj\n",
+ "\n",
+ "print(\n",
+ " f\"Total trajectories: {r_total}, Failed trajectories: {n_fail_traj}, Valid trajectories: {n_valid_traj}\"\n",
+ ")\n",
+ "\n",
+ "if n_valid_traj == 0:\n",
+ " raise RuntimeError(\n",
+ " \"All trajectories failed — cannot compute Morris indices. \"\n",
+ " \"Consider reducing penalty or tightening bounds.\"\n",
+ " )\n",
+ "\n",
+ "# Keep only valid trajectories\n",
+ "valid_idx = np.where(~traj_has_fail)[0]\n",
+ "samples_valid = samples_reshaped[valid_idx].reshape((n_valid_traj * (k + 1), k))\n",
+ "Y_valid = Y_reshaped[valid_idx].reshape((n_valid_traj * (k + 1),))\n",
+ "\n",
+ "print(\"Valid samples shape:\", samples_valid.shape, \"Valid Y shape:\", Y_valid.shape)\n",
+ "\n",
+ "# Recompute Morris indices on valid trajectories\n",
+ "morris_res = morris_analyze.analyze(\n",
+ " salib_dict, samples_valid, Y_valid, num_levels=levels, print_to_console=True\n",
+ ")\n",
+ "\n",
+ "print(\"\\nMorris mu* (clean):\", morris_res[\"mu_star\"])\n",
+ "print(\"Morris sigma (clean):\", morris_res[\"sigma\"])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "28",
+ "metadata": {},
+ "source": [
+ "Plot the results of Morris's method."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "29",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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AhERERER6TDZycqwYcOk940bN8akSZMwatQomJmZqV1H9hEXIiIiIiKi7GQnK8bGxhg+fDgmTZqEli1byqojMDBQbvNERERERKTjZCcrYWFhsLa21mAoRERERERE/0/2bmBHjhzB3r171S5/4MABbN26VW5zRERERERUxshOVtzd3TF16lS1y0+fPh0eHh5ymyMiIiIiojKmSOesZG1TrK3yRERERERUdmnlBPu8xMXFcfcvIiIiIiJSW7EkK/7+/oiOjkbFihWLozkiIiIiItIBau8G5uPjAx8fnxzXoqKi0LlzZ5XPiKKImJgYPHjwAIIgoEuXLvIjJSIiIiKiMkXtZCUwMBB+fn45rqWmpua6pkqdOnXg6elZiNCIiIiIiKgsUztZ6dixY46vFyxYAHNzc0yfPl3lM3p6erC0tESDBg3QsWNH6Ovryw6UiIiIiIjKFrWTlQ4dOqBDhw7S11nJyvz587USGBERERERlW2yT7APCAjgSAkREREREWmN7GSlatWqmoyDiIiIiIgoh2I7Z0UTkpKScOLECSxevBiDBw9G1apVIQgCBEEo8uJ9T09Pqa78Xs+ePdPMhyEiIiIionypNbJSvXp1AEDNmjVx+vTpHNcKQxAEPH/+vNDPZfn777/Ru3dv2c+rQ6FQwNbWVuV9AwPZg1FERERERFQIav3mHRgYCAAwNjbOda0wBEEo9DPvs7GxQdOmTaXXv/71L7x+/brI9WZp06aN2tsxExERERGR9qiVrGzZsgUAYGVlletacXJ1dUVUVFSOa999912xx0FERERERNqnVrLi5uam1jVt4+5jRERERERlh+wFGC9fvgQA2Nvb55geRkREREREpAmydwNzdnZG9erVc03L+tA9ePAADRo0gKmpKczNzVGnTh2MHz8et2/fLunQiIiIiIjKFNnJirm5OaysrODk5KTJeEpcZGQkHj16BBMTE6SkpODJkyfw8vJCs2bN8MMPP5R0eEREREREZYbsaWDOzs54+vQp0tPTdWItSa1atbB8+XIMGDAA1apVg0KhQGpqKvz8/DBnzhzcvHkTS5YsgY2NDaZPn66ynpSUFKSkpEhfx8XFAQCUSiWUSqXG486qUxt1U8lhv+om9qtuYr/qHvapbsqvX9nXpZcgiqIo58F58+ZhyZIlOHToEPr166fpuNTm7OyMoKAgzJ8/v8gH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MCIiIiIiIm3R2MhKcnIyoqOjoVQq8y1XpUoVTTVJREREREQ6rEjJSlJSEpYvX46dO3fi2bNnBZYXBAFpaWlFaZKIiIiIiMoI2clKTEwM2rdvjwcPHkAURbWeUbccERERERGR7GRl0aJFuH//PhQKBaZMmYIBAwbAyckJBgYam1lGRERERERlmOzM4tChQxAEAatWrcKkSZM0GRMREREREZH83cBCQ0Ohp6eHsWPHajIeIiIiIiIiAEUYWbG1tUVycjKMjY01GQ8RERERERGAIoystGvXDrGxsQgNDdVkPERERERERACKkKzMmjULBgYGWLRokSbjISIiIiIiAlCEZKVZs2bw9vaGj48Pxo0bhxcvXmgyLiIiIiIiKuNkr1mpXr06AEBfXx/e3t7w9vaGra0tLCwsVD4jCAKeP38ut0kiIiIiIipDZCcrgYGBua69ffsWb9++VfmMIAhymyMiIiIiojJGdrKyZcsWTcZBRERERESUg+xkxc3NTZNxFCgpKQnnz5/HzZs3cevWLdy8eRMvX74EAMyfPx+enp5FbiM8PBzLly/HsWPH8PLlS5iYmOCjjz6Cm5sbxo0bx5EhIiIiIqJiJDtZKW5///03evfurbX6b968iR49ekjT2MzNzREfH49Lly7h0qVL2LdvH44cOQJDQ0OtxUBERERERP9P9m5gJcHGxgZdunTBzJkzsXPnTjg6Omqk3tjYWPTt2xdv375F3bp1cf36dcTHxyMxMRFr166FQqHAqVOnMHXqVI20R0REREREBftgRlZcXV0RFRWV49p3332nkbpXrlyJ169fw8TEBH/++SeqVasGADA0NMRXX32FuLg4zJkzBxs2bMDUqVNRu3ZtjbRLRERERESqqTWyoq+vD319fXz00Ue5rhXmZWAgPzfS19eX/WxBtm7dCgAYMWKElKhkN2XKFJibmyM9PR07duzQWhxERERERPT/1EpWRFGUXnldK8yrtPnnn3+khfq9evXKs4y5uTlcXV0BAKdPny622IiIiIiIyjK1hjrOnTsHADA1Nc117UN3//596X2DBg1UlmvQoAFOnDiBhw8fFkdYRERERERlnlrJSocOHdS69iEKCwuT3lesWFFluax7cXFxSEhIgLm5eZ7lUlJSkJKSIn0dFxcHAFAqlVAqlZoIOYesOrVRN5Uc9qtuYr/qJvar7mGf6qb8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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "labels = salib_dict[\"names\"]\n",
+ "plt.figure(figsize=(8, 5))\n",
+ "plt.scatter(morris_res[\"mu_star\"], morris_res[\"sigma\"], s=60, color=\"teal\")\n",
+ "for i, txt in enumerate(labels):\n",
+ " plt.text(morris_res[\"mu_star\"][i] * 1, morris_res[\"sigma\"][i] * 1.1, txt)\n",
+ "plt.xlabel(\"mu* (overall influence)\")\n",
+ "plt.ylabel(\"sigma (nonlinearity / interactions)\")\n",
+ "plt.title(\"Morris screening\")\n",
+ "plt.grid(True)\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "30",
+ "metadata": {},
+ "source": [
+ "## Concluding thoughts\n",
+ "\n",
+ "This notebook illustrates how to extract EC parameters from a HPPC pulse using PyBOP. A sensitivity analysis of the model is performed using the Sobol and Morris methods from the SALib module. Other methods from SALib can also be used. More information can be found [here](https://salib.readthedocs.io/en/latest/)."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "pybop-env",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.12.3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pybop/__init__.py b/pybop/__init__.py
index a56b2e996..3e6b2d846 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -166,7 +166,7 @@
#
# Analysis
#
-from .analysis.classification import classify_using_hessian
+from .analysis.classification import classify_using_hessian, plot_hessian_eigenvectors
#
# Applications
diff --git a/pybop/analysis/classification.py b/pybop/analysis/classification.py
index 69c204276..857715e6f 100644
--- a/pybop/analysis/classification.py
+++ b/pybop/analysis/classification.py
@@ -6,7 +6,8 @@
def classify_using_hessian(
result: OptimisationResult,
dx=None,
- cost_tolerance: float | None = 1e-5,
+ cost_tolerance: float = 1e-5,
+ normalise: bool = True,
):
"""
A simple check for parameter correlations based on numerical approximation
@@ -21,6 +22,9 @@ def classify_using_hessian(
bounds and as the perturbation distance in the finite difference calculations.
cost_tolerance : float, optional
A small positive tolerance used for cost value comparisons (default: 1e-5).
+ normalise : bool, optional
+ If True, the Hessian is scaled by the step size in the parameters so that the
+ Hessian entries are in the same unit as the cost values (default: True).
"""
x = result.x
dx = np.asarray(dx) if dx is not None else np.maximum(x, 1e-40) * 1e-2
@@ -28,6 +32,13 @@ def classify_using_hessian(
problem = result.optim.problem
parameters = problem.parameters
minimising = result.minimising
+ cost_tolerance = float(cost_tolerance)
+
+ # Prepare outputs
+ stationarity_confirmed = False
+ cfd_hessian = np.full((2, 2), np.nan, dtype=float)
+ eigenvalues = np.array([np.nan, np.nan], dtype=float)
+ eigenvectors = np.full((2, 2), np.nan, dtype=float)
def cost(x):
return problem.evaluate(x).values
@@ -76,15 +87,16 @@ def check_proximity_to_bounds(parameters, x, dx, names) -> str:
message += check_proximity_to_bounds(parameters, x, dx, names)
else:
- # Estimate the Hessian using second-order accurate central finite differences
- # cfd_hessian = np.zeros((2, 2))
+ # By default, normalise Hessian with respect to the finite differencing step in the
+ # parameter values, in order to compute eigenvalues in the same unit as the cost
+
+ # Estimate the normalised Hessian using second-order accurate central finite differences
# cfd_hessian[0, 0] = costs[2,1,0] - 2 * costs[1,1,0] + costs[0,1,0]
# cfd_hessian[0, 1] = (costs[2,2,0] - costs[2,0,0] + costs[0,0,0] - costs[0,2,0]) / 4
# cfd_hessian[1, 0] = cfd_hessian[0, 1]
# cfd_hessian[1, 1] = costs[1,2,0] - 2 * costs[1,1,0] + costs[1,0,0]
- # Estimate the Hessian using fourth-order accurate central finite differences
- cfd_hessian = np.zeros((2, 2))
+ # Estimate the normalised Hessian using fourth-order accurate central finite differences
cfd_hessian[0, 0] = (
-costs[2, 1, 1]
+ 16 * costs[2, 1, 0]
@@ -105,6 +117,13 @@ def check_proximity_to_bounds(parameters, x, dx, names) -> str:
- costs[1, 0, 1]
) / 12
+ if not normalise:
+ # Replace the normalised Hessian by the true Hessian
+ cfd_hessian[0, 0] /= dx[0] ** 2
+ cfd_hessian[0, 1] /= dx[0] * dx[1]
+ cfd_hessian[1, 0] /= dx[0] * dx[1]
+ cfd_hessian[1, 1] /= dx[1] ** 2
+
# Compute the eigenvalues and sort into ascending order
eigenvalues, eigenvectors = np.linalg.eig(cfd_hessian)
idx = eigenvalues.argsort()
@@ -113,10 +132,13 @@ def check_proximity_to_bounds(parameters, x, dx, names) -> str:
# Classify the result
if np.all(eigenvalues > cost_tolerance):
+ stationarity_confirmed = True
message = "The optimiser has located a minimum."
elif np.all(eigenvalues < -cost_tolerance):
+ stationarity_confirmed = True
message = "The optimiser has located a maximum."
elif np.all(np.abs(eigenvalues) > cost_tolerance):
+ stationarity_confirmed = True
message = "The optimiser has located a saddle point."
elif np.all(np.abs(eigenvalues) < cost_tolerance):
message = f"The cost variation is smaller than the cost tolerance: {cost_tolerance}."
@@ -138,5 +160,120 @@ def check_proximity_to_bounds(parameters, x, dx, names) -> str:
if np.allclose(best_cost, diagonal_costs, atol=cost_tolerance, rtol=0):
message += " There may be a correlation between these parameters."
+ if normalise:
+ # Now, after the checks, replace the normalised Hessian by the true Hessian
+ cfd_hessian[0, 0] /= dx[0] ** 2
+ cfd_hessian[0, 1] /= dx[0] * dx[1]
+ cfd_hessian[1, 0] /= dx[0] * dx[1]
+ cfd_hessian[1, 1] /= dx[1] ** 2
+
+ # Scale the results to match the true Hessian
+ for k in range(eigenvectors.shape[1]):
+ vec = eigenvectors[:, k] * dx
+ eigenvectors[:, k] = vec / np.linalg.norm(vec)
+ eigenvalues /= dx**2
+
print(message)
- return message
+
+ # Pack everything useful into a dictionary
+ return {
+ "message": message,
+ "cost": cost,
+ "cfd_hessian": cfd_hessian,
+ "stationarity_confirmed": stationarity_confirmed,
+ "eigenvalues": eigenvalues,
+ "eigenvectors": eigenvectors,
+ "x": x,
+ "dx": dx,
+ "names": names,
+ "best_cost": best_cost,
+ }
+
+
+def plot_hessian_eigenvectors(info, steps: int = 10):
+ """
+ A function to plot the eigenvectors computed for the Hessian at an optimal point.
+
+ Parameters
+ ----------
+ info : dict
+ The output from pybop.classify_using_Hessian.
+ steps : int
+ Grid resolution per axis.
+ """
+ import matplotlib.pyplot as plt
+
+ cost = info["cost"]
+ x = info["x"]
+ dx = info["dx"]
+ names = info["names"]
+ eigenvalues = info["eigenvalues"]
+ eigenvectors = info["eigenvectors"]
+
+ # Build a plotting span around x
+ span_multiplier = 4.0
+ span0 = (x[0] - span_multiplier * dx[0], x[0] + span_multiplier * dx[0])
+ span1 = (x[1] - span_multiplier * dx[1], x[1] + span_multiplier * dx[1])
+ param0 = np.linspace(span0[0], span0[1], steps)
+ param1 = np.linspace(span1[0], span1[1], steps)
+
+ # Evaluate cost on the grid
+ Z = np.empty((steps, steps), dtype=float)
+ for i in range(steps):
+ for j in range(steps):
+ p = np.array([param0[i], param1[j]], dtype=float)
+ try:
+ Z[i, j] = float(cost(p))
+ except Exception:
+ Z[i, j] = np.nan
+
+ # Pack everything useful into a dictionary
+ info.update(
+ {
+ "span0": span0,
+ "span1": span1,
+ "param0": param0,
+ "param1": param1,
+ "Z": Z,
+ }
+ )
+
+ # Cost contours
+ fig, ax = plt.subplots(figsize=(6, 5))
+ ax.scatter(
+ [x[0]], [x[1]], marker="x", s=60, label=f"Result (cost={info['best_cost']:.3g})"
+ )
+ Z[~np.isfinite(Z)] = np.nan
+ if Z.size == 0:
+ ax.text(
+ 0.5, 0.5, "No finite cost values on contour grid", ha="center", va="center"
+ )
+ else:
+ vmin, vmax = np.nanmin(Z), np.nanmax(Z)
+ levels = np.linspace(vmin, vmax, 10)
+ cs = ax.contour(param0, param1, Z.T, levels=levels)
+ ax.clabel(cs, inline=1, fontsize=8)
+
+ # Add eigenvectors
+ if info["stationarity_confirmed"]:
+ colours = ["red", "purple"]
+ for k, val in enumerate(eigenvalues):
+ vec = eigenvectors[:, k]
+ if np.isfinite(vec).all():
+ ax.axline(
+ x,
+ slope=vec[1] / vec[0],
+ color=colours[k],
+ linestyle="--",
+ linewidth=1.2,
+ label=f"eig {k} (λ={val:.3g})",
+ )
+
+ ax.set_xlim(info["span0"])
+ ax.set_ylim(info["span1"])
+ ax.set_xlabel(names[0])
+ ax.set_ylabel(names[1])
+ ax.set_title("Cost contours")
+ ax.legend(loc="best")
+
+ return fig, ax
diff --git a/tests/integration/test_classification.py b/tests/integration/test_hessian.py
similarity index 60%
rename from tests/integration/test_classification.py
rename to tests/integration/test_hessian.py
index 0b4fc8477..65cc504fa 100644
--- a/tests/integration/test_classification.py
+++ b/tests/integration/test_hessian.py
@@ -95,18 +95,97 @@ def test_classify_using_hessian(self, simulator, dataset):
logger.extend_log(x_search=[x0], x_model=[x0], cost=[problem(x0)])
result = pybop.OptimisationResult(optim=optim, logger=logger, time=1.0)
+ info = pybop.classify_using_hessian(result)
+ pybop.plot_hessian_eigenvectors(info, steps=3)
+
+ assert isinstance(info, dict)
+ for k in (
+ "message",
+ "cfd_hessian",
+ "eigenvalues",
+ "eigenvectors",
+ "x",
+ "dx",
+ "names",
+ "best_cost",
+ "span0",
+ "span1",
+ "param0",
+ "param1",
+ "Z",
+ ):
+ assert k in info
+ assert info["cfd_hessian"].shape == (2, 2)
+ assert info["eigenvalues"].shape == (2,)
+ assert info["eigenvectors"].shape == (2, 2)
+ assert info["x"].shape == (2,)
+ assert info["dx"].shape == (2,)
+ assert len(info["names"]) == 2
+ assert isinstance(info["best_cost"], float)
+ assert isinstance(info["span0"], tuple)
+ assert len(info["span0"]) == 2
+ assert isinstance(info["span1"], tuple)
+ assert len(info["span1"]) == 2
+ assert info["Z"].ndim == 2
+ assert info["Z"].shape[0] == info["Z"].shape[1] # grid is square
+
+ # Hessian may be NaN on some model combinations
+ H = info["cfd_hessian"]
+
+ if np.isfinite(H).all():
+ # finite and (approximately) symmetric
+ assert np.allclose(H, H.T, atol=1e-8)
+ # Eigenvalues should be sorted ascending (only meaningful if finite)
+ evals = info["eigenvalues"]
+ assert evals[0] <= evals[1]
+ # Eigenvectors should be finite and non-zero norm
+ evecs = info["eigenvectors"]
+ assert np.isfinite(evecs).all()
+ for k in range(evecs.shape[1]):
+ nrm = np.linalg.norm(evecs[:, k])
+ assert nrm > 0.0
+ else:
+ # If full Hessian contains NaNs, try to use eigenvalues/eigenvectors
+ evals = info.get("eigenvalues", None)
+ evecs = info.get("eigenvectors", None)
+
+ if evals is not None and np.all(np.isfinite(evals)):
+ # If eigenvalues are finite we can still check ordering
+ assert evals[0] <= evals[1]
+
+ elif evecs is not None and np.isfinite(evecs).all():
+ # If eigenvectors are finite, check their norms
+ for k in range(evecs.shape[1]):
+ nrm = np.linalg.norm(evecs[:, k])
+ assert nrm > 0.0
+
+ else:
+ # Fallback: ensure the Z grid exists (this confirms classification ran)
+ assert isinstance(info.get("Z", None), np.ndarray)
+
+ # Check the grid Z contains at least some finite values
+ Z = info["Z"]
+ assert np.isfinite(Z).any()
+
+ # Check p0 and p1
+ p0 = info["param0"]
+ p1 = info["param1"]
+ x_center = info.get("x", np.asarray(x))
+ assert p0.min() < x_center[0] < p0.max()
+ assert p1.min() < x_center[1] < p1.max()
+
if np.all(x == np.asarray([0.05, 0.05])):
- message = pybop.classify_using_hessian(result)
- assert message == "The optimiser has located a minimum."
+ info = pybop.classify_using_hessian(result)
+ assert info["message"] == "The optimiser has located a minimum."
elif np.all(x == np.asarray([0.1, 0.05])):
- message = pybop.classify_using_hessian(result)
- assert message == (
+ info = pybop.classify_using_hessian(result)
+ assert info["message"] == (
"The optimiser has not converged to a stationary point."
" The result is near the upper bound of R0 [Ohm]."
)
elif np.all(x == np.asarray([0.05, 0.01])):
- message = pybop.classify_using_hessian(result)
- assert message == (
+ info = pybop.classify_using_hessian(result)
+ assert info["message"] == (
"The optimiser has not converged to a stationary point."
" The result is near the lower bound of R1 [Ohm]."
)
@@ -122,11 +201,8 @@ def test_classify_using_hessian(self, simulator, dataset):
logger.extend_log(x_search=[x], x_model=[x], cost=[problem(x)])
result = pybop.OptimisationResult(optim=optim, logger=logger, time=1.0)
- message = pybop.classify_using_hessian(result)
- assert message == "The optimiser has located a maximum."
-
- # message = pybop.classify_using_hessian(result)
- # assert message == "The optimiser has located a saddle point."
+ info = pybop.classify_using_hessian(result)
+ assert info["message"] == "The optimiser has located a maximum."
def test_insensitive_classify_using_hessian(self, model, parameter_values):
param_R0_a = pybop.Parameter(bounds=[0, 0.002])
@@ -166,25 +242,25 @@ def test_insensitive_classify_using_hessian(self, model, parameter_values):
logger.extend_log(x_search=[x], x_model=[x], cost=[problem(x)])
result = pybop.OptimisationResult(optim=optim, logger=logger, time=1.0)
- message = pybop.classify_using_hessian(result)
- assert message == (
+ info = pybop.classify_using_hessian(result)
+ expected1 = (
"The cost variation is too small to classify with certainty."
" The cost is insensitive to a change of 1e-42 in R0_b [Ohm]."
)
+ expected2 = "The cost variation is smaller than the cost tolerance: 0.01."
+ assert info["message"] in {expected1, expected2}
- message = pybop.classify_using_hessian(result, dx=[0.0001, 0.0001])
- assert message == (
+ info = pybop.classify_using_hessian(result, dx=[0.0001, 0.0001])
+ assert info["message"] == (
"The optimiser has located a minimum."
" There may be a correlation between these parameters."
)
- message = pybop.classify_using_hessian(result, cost_tolerance=1e-2)
- assert message == (
- "The cost variation is smaller than the cost tolerance: 0.01."
- )
+ info = pybop.classify_using_hessian(result, cost_tolerance=1e-2)
+ assert info["message"] in {expected1, expected2}
- message = pybop.classify_using_hessian(result, dx=[1, 1])
- assert message == (
+ info = pybop.classify_using_hessian(result, dx=[1, 1])
+ assert info["message"] == (
"Classification cannot proceed due to infinite cost value(s)."
" The result is near the upper bound of R0_a [Ohm]."
)
diff --git a/tests/unit/test_classification.py b/tests/unit/test_classification.py
new file mode 100644
index 000000000..d25ef98cc
--- /dev/null
+++ b/tests/unit/test_classification.py
@@ -0,0 +1,90 @@
+import numpy as np
+import pytest
+
+import pybop
+from pybop.costs.evaluation import Evaluation
+
+
+class SeparableParaboloidProblem(pybop.Problem):
+ """
+ Simple paraboloid cost:
+ f(x) = (x0 - c0)**2 + (x1 - c1)**2 + c
+ """
+
+ def __init__(self, centre: np.ndarray, c: float = 0.0):
+ super().__init__(simulator=None, cost=None)
+ self.parameters = pybop.Parameters(
+ {
+ "x0": pybop.Parameter(bounds=[-10, 10]),
+ "x1": pybop.Parameter(bounds=[-10, 10]),
+ }
+ )
+ self.c0 = centre[0]
+ self.c1 = centre[1]
+ self.c = float(c)
+
+ def evaluate_batch(self, inputs, calculate_sensitivities=False):
+ val = []
+ for x in inputs:
+ val.append((x["x0"] - self.c0) ** 2 + (x["x1"] - self.c1) ** 2 + self.c)
+ return Evaluation(values=np.array(val))
+
+
+@pytest.fixture(params=[np.asarray([0.0, 0.0]), np.asarray([0.05, 0.05])])
+def optimisation_result(request):
+ """
+ Build a result where result.x is the true minimiser (the paraboloid centre).
+ That ensures classify_using_hessian computes a finite Hessian and eigenvalues.
+ """
+ centre = np.asarray(request.param, dtype=float)
+ problem = SeparableParaboloidProblem(centre=centre, c=1.0) # small offset c
+ optim = pybop.XNES(problem)
+
+ logger = pybop.Logger(minimising=True)
+ logger.iteration = 1
+ logger.extend_log(
+ x_search=[centre],
+ x_model=[centre],
+ cost=problem.evaluate(centre).values,
+ )
+ return pybop.OptimisationResult(optim=optim, logger=logger, time=1.0)
+
+
+@pytest.mark.unit
+def test_classify_paraboloid_minimum_and_grid(optimisation_result):
+ result = optimisation_result
+
+ dx = np.asarray([1e-3, 1e-3])
+ steps = 3
+
+ info = pybop.classify_using_hessian(result, dx=dx, cost_tolerance=1e-8)
+ pybop.plot_hessian_eigenvectors(info, steps=steps)
+
+ # Basic structure checks
+ assert isinstance(info, dict)
+ assert info["cfd_hessian"].shape == (2, 2)
+ assert info["eigenvalues"].shape == (2,)
+ assert info["eigenvectors"].shape == (2, 2)
+ assert info["x"].shape == (2,)
+ assert info["dx"].shape == (2,)
+ assert isinstance(info["names"], list) and len(info["names"]) == 2
+ assert isinstance(info["best_cost"], float)
+ assert isinstance(info["span0"], tuple) and len(info["span0"]) == 2
+ assert isinstance(info["span1"], tuple) and len(info["span1"]) == 2
+
+ # Grid checks: a finite paraboloid evaluates to finite values everywhere
+ assert info["Z"].shape == (steps, steps)
+ assert np.isfinite(info["Z"]).all()
+
+ # Hessian should be finite and symmetric (within numerical tolerance)
+ H = info["cfd_hessian"]
+ assert np.isfinite(H).all()
+ assert np.allclose(H, H.T, atol=1e-8)
+
+ # Eigenvalues are sorted ascending and finite
+ evals = info["eigenvalues"]
+ assert evals[0] <= evals[1]
+ assert np.isfinite(evals).all()
+
+ # Because the paraboloid is convex and result.x is the minimiser, expect a minimum
+ assert "minimum" in info["message"].lower()
From a4cd7665fea28a68504ae3807e4a2303e67e74ca Mon Sep 17 00:00:00 2001
From: Sarah Roggendorf <33656497+SarahRo@users.noreply.github.com>
Date: Sun, 7 Dec 2025 13:30:24 +0000
Subject: [PATCH 05/37] Restructure priors (#839)
* rename BasePrior as Distribution
* initialise distribution with stats.rv_continuous/stats.distributions.rv_frozen
* distributions as subclasses of Parameter
* clean up class structure
* tidy up some details
* remove unnecessary changes, remove unused method
* fix failing tests
* improve test coverage
* remove margin from ParameterInfo
* update __call_ function
* remove set_bounds and remove_bounds methods
* remove ParameterBounds object
* remove ParameterDistribution object
* Rename ParameterInfo -> Parameter
* improve coverage
* rename files
* update changelog
* Update examples
* Update descriptions
---------
Co-authored-by: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com>
---
CHANGELOG.md | 2 +
benchmarks/benchmark_optim_construction.py | 6 +-
benchmarks/benchmark_parameterisation.py | 6 +-
.../benchmark_track_parameterisation.py | 6 +-
.../echem_identification_pitfalls.ipynb | 6 +-
.../ecm_monte_carlo_sampling.ipynb | 9 +-
.../ecm_multipulse_identification.ipynb | 19 +-
.../ecm_scipy_constraints.ipynb | 9 +-
.../electrode_balancing.ipynb | 6 +-
.../lgm50_pulse_validation.ipynb | 15 +-
.../pouch_cell_identification.ipynb | 8 +-
.../sensitivity_analysis_salib.ipynb | 15 +-
.../comparing_cost_functions.ipynb | 8 +-
.../optimiser_calibration.ipynb | 2 +-
.../energy_based_electrode_design.ipynb | 8 +-
.../maximum_a_posteriori.ipynb | 4 +-
.../optimising_with_adamw.ipynb | 8 +-
.../setting_optimiser_options.ipynb | 7 +-
.../ecm_tau_redefined.py | 21 +-
.../full_cell_balancing.py | 28 ++-
.../battery_parameterisation/simple_dfn.py | 14 +-
.../battery_parameterisation/simple_ecm.py | 14 +-
.../battery_parameterisation/simple_eis.py | 6 +-
.../comparison_examples/gitt_models.py | 5 +-
.../multi_start_optimisation.py | 4 +-
.../design_optimisation/maximising_energy.py | 6 +-
.../design_optimisation/maximising_power.py | 10 +-
.../getting_started/exponential_decay.py | 4 +-
.../fitting_multiple_problems.py | 4 +-
.../getting_started/functional_parameters.py | 4 +-
.../getting_started/linked_parameters.py | 14 +-
.../getting_started/maximum_a_posteriori.py | 6 +-
.../getting_started/maximum_likelihood.py | 9 +-
.../getting_started/monte_carlo_sampling.py | 4 +-
.../getting_started/optimising_with_adamw.py | 10 +-
.../optimising_with_scipy_minimize.py | 14 +-
.../optimising_with_simulated_annealing.py | 14 +-
.../pints_ask_tell_interface.py | 4 +-
.../scripts/getting_started/weighted_cost.py | 10 +-
multiprocessing_bench.py | 8 +-
noxfile.py | 2 +-
pybop/__init__.py | 2 +-
pybop/costs/likelihoods.py | 25 +-
pybop/optimisers/base_optimiser.py | 6 +-
.../{priors.py => distributions.py} | 216 ++++++++++++------
pybop/parameters/parameter.py | 192 ++++++----------
pybop/problems/problem.py | 2 +-
pybop/simulators/base_simulator.py | 9 +-
.../integration/test_eis_parameterisation.py | 11 +-
tests/integration/test_half_cell_model.py | 12 +-
tests/integration/test_hessian.py | 14 +-
.../test_model_experiment_changes.py | 19 +-
tests/integration/test_monte_carlo.py | 10 +-
.../integration/test_monte_carlo_thevenin.py | 19 +-
.../integration/test_optimisation_options.py | 10 +-
.../integration/test_spm_parameterisations.py | 88 +++++--
.../test_thevenin_parameterisation.py | 14 +-
tests/integration/test_transformation.py | 14 +-
tests/integration/test_weighted_cost.py | 20 +-
tests/unit/test_classifier.py | 4 +-
tests/unit/test_cost.py | 11 +-
.../{test_priors.py => test_distributions.py} | 88 +++----
tests/unit/test_evaluation.py | 10 +-
tests/unit/test_likelihoods.py | 10 +-
tests/unit/test_optimisation.py | 149 +++++++++---
tests/unit/test_parameters.py | 96 +++++---
tests/unit/test_plots.py | 78 +++++--
tests/unit/test_posterior.py | 29 ++-
tests/unit/test_problem.py | 14 +-
tests/unit/test_sampling.py | 19 +-
tests/unit/test_simulator.py | 55 +++++
71 files changed, 1008 insertions(+), 597 deletions(-)
rename pybop/parameters/{priors.py => distributions.py} (62%)
rename tests/unit/{test_priors.py => test_distributions.py} (61%)
create mode 100644 tests/unit/test_simulator.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d81deccd2..51ddb4bbe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@
## Breaking Changes
+- [#839](https://github.com/pybop-team/PyBOP/pull/839) - Renames 'prior' as 'distribution' ``for pybop.Parameter``. Allows construction of a ``pybop.Parameter`` with a distribution of type ``scipy.stats.distributions.rv_frozen``. Removes ``margins``, ``set_bounds``, ``remove_bounds`` from ``pybop.Parameter``.
+
# [v25.11](https://github.com/pybop-team/PyBOP/tree/v25.11) - 2025-11-24
## Features
diff --git a/benchmarks/benchmark_optim_construction.py b/benchmarks/benchmark_optim_construction.py
index 3298d59a8..41e9bb5ec 100644
--- a/benchmarks/benchmark_optim_construction.py
+++ b/benchmarks/benchmark_optim_construction.py
@@ -51,13 +51,11 @@ def setup(self, model, parameter_set, optimiser):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.02),
- bounds=[0.375, 0.7],
+ pybop.Gaussian(0.6, 0.02, truncated_at=[0.375, 0.7]),
initial_value=0.63,
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.5, 0.02),
- bounds=[0.375, 0.625],
+ pybop.Gaussian(0.5, 0.02, truncated_at=[0.375, 0.625]),
initial_value=0.51,
),
}
diff --git a/benchmarks/benchmark_parameterisation.py b/benchmarks/benchmark_parameterisation.py
index 8d3d66380..b5edbd5cc 100644
--- a/benchmarks/benchmark_parameterisation.py
+++ b/benchmarks/benchmark_parameterisation.py
@@ -67,12 +67,10 @@ def setup(self, model, parameter_set, optimiser):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.03),
- bounds=[0.375, 0.7],
+ pybop.Gaussian(0.55, 0.03, truncated_at=[0.375, 0.7]),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.03),
- bounds=[0.375, 0.7],
+ pybop.Gaussian(0.55, 0.03, truncated_at=[0.375, 0.7]),
),
}
)
diff --git a/benchmarks/benchmark_track_parameterisation.py b/benchmarks/benchmark_track_parameterisation.py
index cd4dc9bee..7891e55f3 100644
--- a/benchmarks/benchmark_track_parameterisation.py
+++ b/benchmarks/benchmark_track_parameterisation.py
@@ -67,12 +67,10 @@ def setup(self, model, parameter_set, optimiser):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.03),
- bounds=[0.375, 0.7],
+ pybop.Gaussian(0.55, 0.03, truncated_at=[0.375, 0.7]),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.03),
- bounds=[0.375, 0.7],
+ pybop.Gaussian(0.55, 0.03, truncated_at=[0.375, 0.7]),
),
}
)
diff --git a/examples/notebooks/battery_parameterisation/echem_identification_pitfalls.ipynb b/examples/notebooks/battery_parameterisation/echem_identification_pitfalls.ipynb
index f5b3795e4..221e33c37 100644
--- a/examples/notebooks/battery_parameterisation/echem_identification_pitfalls.ipynb
+++ b/examples/notebooks/battery_parameterisation/echem_identification_pitfalls.ipynb
@@ -207,12 +207,10 @@
"source": [
"parameters = {\n",
" \"Negative electrode thickness [m]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(8.52e-05, 0.05e-05),\n",
- " bounds=[75e-06, 95e-06],\n",
+ " pybop.Gaussian(8.52e-05, 0.05e-05, truncated_at=[75e-06, 95e-06]),\n",
" ),\n",
" \"Positive electrode thickness [m]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(7.56e-05, 0.05e-05),\n",
- " bounds=[65e-06, 85e-06],\n",
+ " pybop.Gaussian(7.56e-05, 0.05e-05, truncated_at=[65e-06, 85e-06]),\n",
" ),\n",
"}\n",
"true_values = [parameter_values[p] for p in parameters.keys()]\n",
diff --git a/examples/notebooks/battery_parameterisation/ecm_monte_carlo_sampling.ipynb b/examples/notebooks/battery_parameterisation/ecm_monte_carlo_sampling.ipynb
index aee2d7a2f..4b02d9de9 100644
--- a/examples/notebooks/battery_parameterisation/ecm_monte_carlo_sampling.ipynb
+++ b/examples/notebooks/battery_parameterisation/ecm_monte_carlo_sampling.ipynb
@@ -292,16 +292,13 @@
"parameter_values.update(\n",
" {\n",
" \"R0 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(3e-3, 1e-3),\n",
- " bounds=[1e-5, 1e-2],\n",
+ " pybop.Gaussian(3e-3, 1e-3, truncated_at=[1e-5, 1e-2]),\n",
" ),\n",
" \"R1 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(5e-3, 1e-3),\n",
- " bounds=[1e-5, 1e-2],\n",
+ " pybop.Gaussian(5e-3, 1e-3, truncated_at=[1e-5, 1e-2]),\n",
" ),\n",
" \"R2 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(1e-4, 5e-5),\n",
- " bounds=[1e-5, 1e-2],\n",
+ " pybop.Gaussian(1e-4, 5e-5, truncated_at=[1e-5, 1e-2]),\n",
" ),\n",
" }\n",
")"
diff --git a/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb b/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb
index 9996508cf..88cbe10ff 100644
--- a/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb
+++ b/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb
@@ -274,24 +274,19 @@
"parameter_values.update(\n",
" {\n",
" \"R0 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(r0_guess, r0_guess / 10),\n",
- " bounds=[0, 0.1],\n",
+ " pybop.Gaussian(r0_guess, r0_guess / 10, truncated_at=[0, 0.1]),\n",
" ),\n",
" \"R1 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(r0_guess, r0_guess / 10),\n",
- " bounds=[0, 0.1],\n",
- " ),\n",
- " \"C1 [F]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(500, 100),\n",
- " bounds=[100, 1000],\n",
+ " pybop.Gaussian(r0_guess, r0_guess / 10, truncated_at=[0, 0.1]),\n",
" ),\n",
" \"R2 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(r0_guess, r0_guess / 10),\n",
- " bounds=[0, 0.1],\n",
+ " pybop.Gaussian(r0_guess, r0_guess / 10, truncated_at=[0, 0.1]),\n",
+ " ),\n",
+ " \"C1 [F]\": pybop.Parameter(\n",
+ " pybop.Gaussian(500, 100, truncated_at=[100, 1000]),\n",
" ),\n",
" \"C2 [F]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(2000, 500),\n",
- " bounds=[1000, 10000],\n",
+ " pybop.Gaussian(2000, 500, truncated_at=[1000, 10000]),\n",
" ),\n",
" }\n",
")"
diff --git a/examples/notebooks/battery_parameterisation/ecm_scipy_constraints.ipynb b/examples/notebooks/battery_parameterisation/ecm_scipy_constraints.ipynb
index 78c51dfca..f60cf1684 100644
--- a/examples/notebooks/battery_parameterisation/ecm_scipy_constraints.ipynb
+++ b/examples/notebooks/battery_parameterisation/ecm_scipy_constraints.ipynb
@@ -147,16 +147,13 @@
"parameter_values.update(\n",
" {\n",
" \"R0 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(1e-2, 1e-3),\n",
- " bounds=[1e-3, 1e-1],\n",
+ " pybop.Gaussian(1e-2, 1e-3, truncated_at=[1e-3, 1e-1]),\n",
" ),\n",
" \"R1 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(4e-3, 1e-4),\n",
- " bounds=[1e-5, 1e-2],\n",
+ " pybop.Gaussian(4e-3, 1e-4, truncated_at=[1e-5, 1e-2]),\n",
" ),\n",
" \"C1 [F]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(7000, 300),\n",
- " bounds=[1e3, 1e5],\n",
+ " pybop.Gaussian(7000, 300, truncated_at=[1e3, 1e5]),\n",
" ),\n",
" }\n",
")"
diff --git a/examples/notebooks/battery_parameterisation/electrode_balancing.ipynb b/examples/notebooks/battery_parameterisation/electrode_balancing.ipynb
index 3db30060a..370ea2b77 100644
--- a/examples/notebooks/battery_parameterisation/electrode_balancing.ipynb
+++ b/examples/notebooks/battery_parameterisation/electrode_balancing.ipynb
@@ -114,14 +114,12 @@
"parameter_values.update(\n",
" {\n",
" \"Initial SoC\": pybop.Parameter(\n",
- " prior=pybop.Uniform(0, 0.5),\n",
+ " pybop.Uniform(0, 0.5),\n",
" initial_value=0.05,\n",
- " bounds=[0, 0.5],\n",
" ),\n",
" \"Cell capacity [A.h]\": pybop.Parameter(\n",
+ " pybop.Uniform(0.01, 50),\n",
" initial_value=20,\n",
- " prior=pybop.Uniform(0.01, 50),\n",
- " bounds=[0.01, 50],\n",
" ),\n",
" }\n",
")"
diff --git a/examples/notebooks/battery_parameterisation/lgm50_pulse_validation.ipynb b/examples/notebooks/battery_parameterisation/lgm50_pulse_validation.ipynb
index 3533bf2f8..85663aeac 100644
--- a/examples/notebooks/battery_parameterisation/lgm50_pulse_validation.ipynb
+++ b/examples/notebooks/battery_parameterisation/lgm50_pulse_validation.ipynb
@@ -281,24 +281,19 @@
"parameter_values.update(\n",
" {\n",
" \"R0 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(0.005, 0.0001),\n",
- " bounds=[1e-6, 2e-1],\n",
+ " pybop.Gaussian(0.005, 0.0001, truncated_at=[1e-6, 2e-1]),\n",
" ),\n",
" \"R1 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(0.0001, 0.0001),\n",
- " bounds=[1e-6, 1],\n",
+ " pybop.Gaussian(0.0001, 0.0001, truncated_at=[1e-6, 1]),\n",
" ),\n",
" \"R2 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(0.0001, 0.0001),\n",
- " bounds=[1e-6, 1],\n",
+ " pybop.Gaussian(0.0001, 0.0001, truncated_at=[1e-6, 1]),\n",
" ),\n",
" \"C1 [F]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(3000, 2500),\n",
- " bounds=[0.5, 1e4],\n",
+ " pybop.Gaussian(3000, 2500, truncated_at=[0.5, 1e4]),\n",
" ),\n",
" \"C2 [F]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(3000, 2500),\n",
- " bounds=[0.5, 1e4],\n",
+ " pybop.Gaussian(3000, 2500, truncated_at=[0.5, 1e4]),\n",
" ),\n",
" }\n",
")"
diff --git a/examples/notebooks/battery_parameterisation/pouch_cell_identification.ipynb b/examples/notebooks/battery_parameterisation/pouch_cell_identification.ipynb
index 27149d34b..466b89c2d 100644
--- a/examples/notebooks/battery_parameterisation/pouch_cell_identification.ipynb
+++ b/examples/notebooks/battery_parameterisation/pouch_cell_identification.ipynb
@@ -145,7 +145,7 @@
"source": [
"## Identifying the parameters\n",
"\n",
- "To set up the parameter estimation process, we select the parameters for estimation and set up their prior distributions and bounds."
+ "To set up the parameter estimation process, we select the parameters for estimation and set up their distributions and bounds."
]
},
{
@@ -159,12 +159,10 @@
"parameter_values.update(\n",
" {\n",
" \"Negative electrode active material volume fraction\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(0.7, 0.05),\n",
- " bounds=[0.45, 0.9],\n",
+ " pybop.Gaussian(0.7, 0.05, truncated_at=[0.45, 0.9]),\n",
" ),\n",
" \"Positive electrode active material volume fraction\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(0.58, 0.05),\n",
- " bounds=[0.5, 0.8],\n",
+ " pybop.Gaussian(0.58, 0.05, truncated_at=[0.5, 0.8]),\n",
" ),\n",
" }\n",
")"
diff --git a/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb b/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb
index 6fd7b5eca..bcd6e3360 100644
--- a/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb
+++ b/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb
@@ -157,24 +157,19 @@
"parameter_values.update(\n",
" {\n",
" \"R0 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(r_guess, r_guess / 10),\n",
- " bounds=[1e-06, 0.1],\n",
+ " pybop.Gaussian(r_guess, r_guess / 10, truncated_at=[1e-06, 0.1]),\n",
" ),\n",
" \"R1 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(r_guess, r_guess / 10),\n",
- " bounds=[1e-06, 0.02],\n",
+ " pybop.Gaussian(r_guess, r_guess / 10, truncated_at=[1e-06, 0.02]),\n",
" ),\n",
" \"R2 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(r_guess, r_guess / 10),\n",
- " bounds=[1e-06, 0.02],\n",
+ " pybop.Gaussian(r_guess, r_guess / 10, truncated_at=[1e-06, 0.02]),\n",
" ),\n",
" \"Tau1 [s]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(0.004, 0.0004),\n",
- " bounds=[1e-03, 10],\n",
+ " pybop.Gaussian(0.004, 0.0004, truncated_at=[1e-03, 10]),\n",
" ),\n",
" \"Tau2 [s]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(20, 2),\n",
- " bounds=[10, 100],\n",
+ " pybop.Gaussian(20, 2, truncated_at=[10, 100]),\n",
" ),\n",
" }\n",
")"
diff --git a/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb b/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb
index 0cdb44d00..882615261 100644
--- a/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb
+++ b/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb
@@ -106,12 +106,10 @@
"parameter_values.update(\n",
" {\n",
" \"Positive electrode thickness [m]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(7.56e-05, 0.5e-05),\n",
- " bounds=[65e-06, 10e-05],\n",
+ " pybop.Gaussian(7.56e-05, 0.5e-05, truncated_at=[65e-06, 10e-05]),\n",
" ),\n",
" \"Positive particle radius [m]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(5.22e-06, 0.5e-06),\n",
- " bounds=[2e-06, 9e-06],\n",
+ " pybop.Gaussian(5.22e-06, 0.5e-06, truncated_at=[2e-06, 9e-06]),\n",
" ),\n",
" }\n",
")\n",
@@ -215,7 +213,7 @@
}
],
"source": [
- "samples = simulator.parameters.sample_from_priors(2)\n",
+ "samples = simulator.parameters.sample_from_distributions(2)\n",
"print(\"Samples:\", samples)\n",
"inputs = [simulator.parameters.to_dict(s) for s in samples]\n",
"solution = simulator.solve(inputs)\n",
diff --git a/examples/notebooks/comparison_examples/optimiser_calibration.ipynb b/examples/notebooks/comparison_examples/optimiser_calibration.ipynb
index 6525b29f2..4d4f2564d 100644
--- a/examples/notebooks/comparison_examples/optimiser_calibration.ipynb
+++ b/examples/notebooks/comparison_examples/optimiser_calibration.ipynb
@@ -109,7 +109,7 @@
"source": [
"## Identifying the parameters\n",
"\n",
- "We select the parameters for estimation and set up their prior distributions and bounds:"
+ "We select the parameters for estimation and set up their distributions and bounds:"
]
},
{
diff --git a/examples/notebooks/design_optimisation/energy_based_electrode_design.ipynb b/examples/notebooks/design_optimisation/energy_based_electrode_design.ipynb
index f10425e70..b606ae4f1 100644
--- a/examples/notebooks/design_optimisation/energy_based_electrode_design.ipynb
+++ b/examples/notebooks/design_optimisation/energy_based_electrode_design.ipynb
@@ -91,7 +91,7 @@
"id": "ffS3CF_704qA"
},
"source": [
- "Next, we define the model parameters for optimisation. Furthermore, PyBOP provides functionality to define a prior for the parameters. The initial parameters values used in the optimisation will be randomly drawn from the prior distribution."
+ "Next, we define the model parameters for optimisation. Furthermore, PyBOP provides functionality to define a distribution for the parameters. The initial parameters values used in the optimisation will be randomly drawn from the prior distribution."
]
},
{
@@ -105,12 +105,10 @@
"parameter_values.update(\n",
" {\n",
" \"Positive electrode thickness [m]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(7.56e-05, 0.05e-05),\n",
- " bounds=[65e-06, 10e-05],\n",
+ " pybop.Gaussian(7.56e-05, 0.05e-05, truncated_at=[65e-06, 10e-05]),\n",
" ),\n",
" \"Positive particle radius [m]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(5.22e-06, 0.05e-06),\n",
- " bounds=[2e-06, 9e-06],\n",
+ " pybop.Gaussian(5.22e-06, 0.05e-06, truncated_at=[2e-06, 9e-06]),\n",
" ),\n",
" }\n",
")"
diff --git a/examples/notebooks/getting_started/maximum_a_posteriori.ipynb b/examples/notebooks/getting_started/maximum_a_posteriori.ipynb
index 46d8d1bd9..a9589ce9c 100644
--- a/examples/notebooks/getting_started/maximum_a_posteriori.ipynb
+++ b/examples/notebooks/getting_started/maximum_a_posteriori.ipynb
@@ -166,10 +166,10 @@
"source": [
"parameters = {\n",
" \"Negative particle radius [m]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(4e-6, 1e-6),\n",
+ " pybop.Gaussian(4e-6, 1e-6),\n",
" ),\n",
" \"Positive particle radius [m]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(5e-6, 1e-6),\n",
+ " pybop.Gaussian(5e-6, 1e-6),\n",
" ),\n",
"}\n",
"true_values = [parameter_values[p] for p in parameters.keys()]\n",
diff --git a/examples/notebooks/getting_started/optimising_with_adamw.ipynb b/examples/notebooks/getting_started/optimising_with_adamw.ipynb
index 41148e622..13819c043 100644
--- a/examples/notebooks/getting_started/optimising_with_adamw.ipynb
+++ b/examples/notebooks/getting_started/optimising_with_adamw.ipynb
@@ -99,7 +99,7 @@
"source": [
"## Identifying the parameters\n",
"\n",
- "We select the model parameters for estimation and set up their prior distributions and bounds:"
+ "We select the model parameters for estimation and set up their distributions and bounds:"
]
},
{
@@ -113,12 +113,10 @@
"parameter_values.update(\n",
" {\n",
" \"Negative electrode active material volume fraction\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(0.6, 0.02),\n",
- " bounds=[0.5, 0.8],\n",
+ " pybop.Gaussian(0.6, 0.02, truncated_at=[0.5, 0.8]),\n",
" ),\n",
" \"Positive electrode active material volume fraction\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(0.48, 0.02),\n",
- " bounds=[0.4, 0.7],\n",
+ " pybop.Gaussian(0.48, 0.02, truncated_at=[0.4, 0.7]),\n",
" ),\n",
" }\n",
")"
diff --git a/examples/notebooks/getting_started/setting_optimiser_options.ipynb b/examples/notebooks/getting_started/setting_optimiser_options.ipynb
index aa25dbbc6..e1ddd8ace 100644
--- a/examples/notebooks/getting_started/setting_optimiser_options.ipynb
+++ b/examples/notebooks/getting_started/setting_optimiser_options.ipynb
@@ -86,8 +86,11 @@
"parameter_values.update(\n",
" {\n",
" \"R0 [Ohm]\": pybop.Parameter(\n",
- " prior=pybop.Gaussian(0.0002, 0.0001),\n",
- " bounds=[1e-4, 1e-2],\n",
+ " pybop.Gaussian(\n",
+ " 0.0002,\n",
+ " 0.0001,\n",
+ " truncated_at=[1e-4, 1e-2],\n",
+ " )\n",
" )\n",
" }\n",
")\n",
diff --git a/examples/scripts/battery_parameterisation/ecm_tau_redefined.py b/examples/scripts/battery_parameterisation/ecm_tau_redefined.py
index ae110a133..3f91007c6 100644
--- a/examples/scripts/battery_parameterisation/ecm_tau_redefined.py
+++ b/examples/scripts/battery_parameterisation/ecm_tau_redefined.py
@@ -96,16 +96,25 @@
parameter_values.update(
{
"R0 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.0002, 0.0001),
- bounds=[1e-4, 1e-2],
+ pybop.Gaussian(
+ 0.0002,
+ 0.0001,
+ truncated_at=[1e-4, 1e-2],
+ ),
),
"R1 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.0001, 0.0001),
- bounds=[1e-5, 1e-2],
+ pybop.Gaussian(
+ 0.0001,
+ 0.0001,
+ truncated_at=[1e-5, 1e-2],
+ ),
),
"tau1 [s]": pybop.Parameter(
- prior=pybop.Gaussian(1.0, 0.025),
- bounds=[0, 3.0],
+ pybop.Gaussian(
+ 1.0,
+ 0.025,
+ truncated_at=[0, 3.0],
+ ),
),
}
)
diff --git a/examples/scripts/battery_parameterisation/full_cell_balancing.py b/examples/scripts/battery_parameterisation/full_cell_balancing.py
index e87f5614d..1b87344cb 100644
--- a/examples/scripts/battery_parameterisation/full_cell_balancing.py
+++ b/examples/scripts/battery_parameterisation/full_cell_balancing.py
@@ -62,23 +62,35 @@ def noisy(data, sigma):
parameter_values.update(
{
"Maximum concentration in negative electrode [mol.m-3]": pybop.Parameter(
- prior=pybop.Gaussian(cs_n_max, 6e3),
- bounds=[cs_n_max * 0.75, cs_n_max * 1.25],
+ pybop.Gaussian(
+ cs_n_max,
+ 6e3,
+ truncated_at=[cs_n_max * 0.75, cs_n_max * 1.25],
+ ),
initial_value=cs_n_max * 0.8,
),
"Maximum concentration in positive electrode [mol.m-3]": pybop.Parameter(
- prior=pybop.Gaussian(cs_p_max, 6e3),
- bounds=[cs_p_max * 0.75, cs_p_max * 1.25],
+ pybop.Gaussian(
+ cs_p_max,
+ 6e3,
+ truncated_at=[cs_p_max * 0.75, cs_p_max * 1.25],
+ ),
initial_value=cs_p_max * 0.8,
),
"Initial concentration in negative electrode [mol.m-3]": pybop.Parameter(
- prior=pybop.Gaussian(cs_n_init, 6e3),
- bounds=[cs_n_max * 0.75, cs_n_max * 1.25],
+ pybop.Gaussian(
+ cs_n_init,
+ 6e3,
+ truncated_at=[cs_n_max * 0.75, cs_n_max * 1.25],
+ ),
initial_value=cs_n_max * 0.8,
),
"Initial concentration in positive electrode [mol.m-3]": pybop.Parameter(
- prior=pybop.Gaussian(cs_p_init, 6e3),
- bounds=[0, cs_p_max * 0.5],
+ pybop.Gaussian(
+ cs_p_init,
+ 6e3,
+ truncated_at=[0, cs_p_max * 0.5],
+ ),
initial_value=cs_p_max * 0.2,
),
}
diff --git a/examples/scripts/battery_parameterisation/simple_dfn.py b/examples/scripts/battery_parameterisation/simple_dfn.py
index 4185f7abe..f3e404313 100644
--- a/examples/scripts/battery_parameterisation/simple_dfn.py
+++ b/examples/scripts/battery_parameterisation/simple_dfn.py
@@ -45,14 +45,20 @@
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.05),
+ pybop.Gaussian(
+ 0.68,
+ 0.05,
+ truncated_at=[0.4, 0.9],
+ ),
initial_value=0.65,
- bounds=[0.4, 0.9],
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.58, 0.05),
+ pybop.Gaussian(
+ 0.58,
+ 0.05,
+ truncated_at=[0.4, 0.9],
+ ),
initial_value=0.65,
- bounds=[0.4, 0.9],
),
}
)
diff --git a/examples/scripts/battery_parameterisation/simple_ecm.py b/examples/scripts/battery_parameterisation/simple_ecm.py
index 931336e33..3b30d8dae 100644
--- a/examples/scripts/battery_parameterisation/simple_ecm.py
+++ b/examples/scripts/battery_parameterisation/simple_ecm.py
@@ -79,12 +79,18 @@
parameter_values.update(
{
"R0 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.0002, 0.0001),
- bounds=[1e-4, 1e-2],
+ pybop.Gaussian(
+ 0.0002,
+ 0.0001,
+ truncated_at=[1e-4, 1e-2],
+ )
),
"R1 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.0001, 0.0001),
- bounds=[1e-5, 1e-3],
+ pybop.Gaussian(
+ 0.0001,
+ 0.0001,
+ truncated_at=[1e-5, 1e-3],
+ )
),
}
)
diff --git a/examples/scripts/battery_parameterisation/simple_eis.py b/examples/scripts/battery_parameterisation/simple_eis.py
index 518b248e8..ec4b17fc6 100644
--- a/examples/scripts/battery_parameterisation/simple_eis.py
+++ b/examples/scripts/battery_parameterisation/simple_eis.py
@@ -63,12 +63,10 @@ def noisy(data, sigma):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.4, 0.75),
- bounds=[0.375, 0.75],
+ pybop.Uniform(0.4, 0.75)
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.4, 0.75),
- bounds=[0.375, 0.75],
+ pybop.Uniform(0.4, 0.75)
),
}
)
diff --git a/examples/scripts/comparison_examples/gitt_models.py b/examples/scripts/comparison_examples/gitt_models.py
index b8660029f..933e395ef 100644
--- a/examples/scripts/comparison_examples/gitt_models.py
+++ b/examples/scripts/comparison_examples/gitt_models.py
@@ -35,10 +35,7 @@
for model in [pybop.lithium_ion.WeppnerHuggins(), pybop.lithium_ion.SPDiffusion()]:
# GITT target parameter
- diffusion_parameter = pybop.Parameter(
- prior=pybop.Gaussian(5000, 1000),
- )
-
+ diffusion_parameter = pybop.Parameter(pybop.Gaussian(5000, 1000))
if isinstance(model, pybop.lithium_ion.WeppnerHuggins):
# Group parameter values
grouped_parameter_values = (
diff --git a/examples/scripts/comparison_examples/multi_start_optimisation.py b/examples/scripts/comparison_examples/multi_start_optimisation.py
index e637ba204..c842bfda2 100644
--- a/examples/scripts/comparison_examples/multi_start_optimisation.py
+++ b/examples/scripts/comparison_examples/multi_start_optimisation.py
@@ -35,10 +35,10 @@
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.65, 0.1),
+ pybop.Gaussian(0.65, 0.1)
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.1),
+ pybop.Gaussian(0.55, 0.1)
),
}
)
diff --git a/examples/scripts/design_optimisation/maximising_energy.py b/examples/scripts/design_optimisation/maximising_energy.py
index 710a4dbea..46436300f 100644
--- a/examples/scripts/design_optimisation/maximising_energy.py
+++ b/examples/scripts/design_optimisation/maximising_energy.py
@@ -46,12 +46,10 @@
parameter_values.update(
{
"Positive electrode thickness [m]": pybop.Parameter(
- prior=pybop.Gaussian(7.56e-05, 0.1e-05),
- bounds=[65e-06, 10e-05],
+ pybop.Gaussian(7.56e-05, 0.1e-05, truncated_at=[65e-06, 10e-05]),
),
"Positive particle radius [m]": pybop.Parameter(
- prior=pybop.Gaussian(5.22e-06, 0.1e-06),
- bounds=[2e-06, 9e-06],
+ pybop.Gaussian(5.22e-06, 0.1e-06, truncated_at=[2e-06, 9e-06]),
),
}
)
diff --git a/examples/scripts/design_optimisation/maximising_power.py b/examples/scripts/design_optimisation/maximising_power.py
index 8395e6717..bd4d980a8 100644
--- a/examples/scripts/design_optimisation/maximising_power.py
+++ b/examples/scripts/design_optimisation/maximising_power.py
@@ -46,12 +46,14 @@
parameter_values.update(
{
"Positive electrode thickness [m]": pybop.Parameter(
- prior=pybop.Gaussian(7.56e-05, 0.5e-05),
- bounds=[65e-06, 10e-05],
+ pybop.Gaussian(7.56e-05, 0.5e-05, truncated_at=[65e-06, 10e-05]),
),
"Nominal cell capacity [A.h]": pybop.Parameter( # controls the C-rate in the experiment
- prior=pybop.Gaussian(discharge_rate, 0.2),
- bounds=[0.8 * discharge_rate, 1.2 * discharge_rate],
+ pybop.Gaussian(
+ discharge_rate,
+ 0.2,
+ truncated_at=[0.8 * discharge_rate, 1.2 * discharge_rate],
+ ),
),
}
)
diff --git a/examples/scripts/getting_started/exponential_decay.py b/examples/scripts/getting_started/exponential_decay.py
index ae6a53b1a..d0cf47edb 100644
--- a/examples/scripts/getting_started/exponential_decay.py
+++ b/examples/scripts/getting_started/exponential_decay.py
@@ -30,8 +30,8 @@ def noisy(data, sigma):
# Fitting parameters
parameter_values.update(
{
- "k": pybop.Parameter(prior=pybop.Gaussian(0.5, 0.05)),
- "y0": pybop.Parameter(prior=pybop.Gaussian(0.2, 0.05)),
+ "k": pybop.Parameter(pybop.Gaussian(0.5, 0.05)),
+ "y0": pybop.Parameter(pybop.Gaussian(0.2, 0.05)),
}
)
diff --git a/examples/scripts/getting_started/fitting_multiple_problems.py b/examples/scripts/getting_started/fitting_multiple_problems.py
index 9fdeddfd9..1ecd17509 100644
--- a/examples/scripts/getting_started/fitting_multiple_problems.py
+++ b/examples/scripts/getting_started/fitting_multiple_problems.py
@@ -48,10 +48,10 @@
param_copy.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.05),
+ pybop.Gaussian(0.68, 0.05),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.58, 0.05),
+ pybop.Gaussian(0.58, 0.05),
),
}
)
diff --git a/examples/scripts/getting_started/functional_parameters.py b/examples/scripts/getting_started/functional_parameters.py
index 2b783b7dd..c3c8d3905 100644
--- a/examples/scripts/getting_started/functional_parameters.py
+++ b/examples/scripts/getting_started/functional_parameters.py
@@ -67,10 +67,10 @@ def positive_electrode_exchange_current_density(c_e, c_s_surf, c_s_max, T):
parameter_values.update(
{
"Positive electrode reference exchange-current density [A.m-2]": pybop.Parameter(
- prior=pybop.Gaussian(1, 0.1),
+ distribution=pybop.Gaussian(1, 0.1),
),
"Positive electrode charge transfer coefficient": pybop.Parameter(
- prior=pybop.Gaussian(0.5, 0.1),
+ distribution=pybop.Gaussian(0.5, 0.1),
),
}
)
diff --git a/examples/scripts/getting_started/linked_parameters.py b/examples/scripts/getting_started/linked_parameters.py
index a44806890..505d620bb 100644
--- a/examples/scripts/getting_started/linked_parameters.py
+++ b/examples/scripts/getting_started/linked_parameters.py
@@ -64,12 +64,18 @@
parameter_values.update(
{
"Positive electrode thickness [m]": pybop.Parameter(
- prior=pybop.Gaussian(7.56e-05, 0.1e-05),
- bounds=[65e-06, 10e-05],
+ distribution=pybop.Gaussian(
+ 7.56e-05,
+ 0.1e-05,
+ truncated_at=[65e-06, 10e-05],
+ ),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.15),
- bounds=[0.1, 0.9],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.15,
+ truncated_at=[0.1, 0.9],
+ ),
),
}
)
diff --git a/examples/scripts/getting_started/maximum_a_posteriori.py b/examples/scripts/getting_started/maximum_a_posteriori.py
index cfd791c08..937f4aa55 100644
--- a/examples/scripts/getting_started/maximum_a_posteriori.py
+++ b/examples/scripts/getting_started/maximum_a_posteriori.py
@@ -40,14 +40,12 @@
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.3, 0.8),
- bounds=[0.3, 0.8],
+ distribution=pybop.Uniform(0.3, 0.8),
initial_value=0.653,
transformation=pybop.LogTransformation(),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.3, 0.8),
- bounds=[0.4, 0.7],
+ distribution=pybop.Uniform(0.3, 0.8),
initial_value=0.657,
transformation=pybop.LogTransformation(),
),
diff --git a/examples/scripts/getting_started/maximum_likelihood.py b/examples/scripts/getting_started/maximum_likelihood.py
index 5664b5274..3d74dfa15 100644
--- a/examples/scripts/getting_started/maximum_likelihood.py
+++ b/examples/scripts/getting_started/maximum_likelihood.py
@@ -36,11 +36,14 @@
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.05),
- bounds=[0.5, 0.8],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.05,
+ truncated_at=[0.5, 0.8],
+ )
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.48, 0.05),
+ distribution=pybop.Gaussian(0.48, 0.05),
),
}
)
diff --git a/examples/scripts/getting_started/monte_carlo_sampling.py b/examples/scripts/getting_started/monte_carlo_sampling.py
index c8e50874e..37faaafa5 100644
--- a/examples/scripts/getting_started/monte_carlo_sampling.py
+++ b/examples/scripts/getting_started/monte_carlo_sampling.py
@@ -39,11 +39,11 @@ def noisy(data, sigma):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.02),
+ distribution=pybop.Gaussian(0.68, 0.02),
transformation=pybop.LogTransformation(),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.65, 0.02),
+ distribution=pybop.Gaussian(0.65, 0.02),
transformation=pybop.LogTransformation(),
),
}
diff --git a/examples/scripts/getting_started/optimising_with_adamw.py b/examples/scripts/getting_started/optimising_with_adamw.py
index f1c60c9c9..ffad9388a 100644
--- a/examples/scripts/getting_started/optimising_with_adamw.py
+++ b/examples/scripts/getting_started/optimising_with_adamw.py
@@ -32,14 +32,16 @@
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.05),
+ distribution=pybop.Gaussian(
+ 0.68,
+ 0.05,
+ truncated_at=[0.4, 0.9],
+ ),
initial_value=0.45,
- bounds=[0.4, 0.9],
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.58, 0.05),
+ distribution=pybop.Gaussian(0.58, 0.05, truncated_at=[0.4, 0.9]),
initial_value=0.45,
- bounds=[0.4, 0.9],
),
}
)
diff --git a/examples/scripts/getting_started/optimising_with_scipy_minimize.py b/examples/scripts/getting_started/optimising_with_scipy_minimize.py
index be6d0c180..8300ad71d 100644
--- a/examples/scripts/getting_started/optimising_with_scipy_minimize.py
+++ b/examples/scripts/getting_started/optimising_with_scipy_minimize.py
@@ -42,12 +42,18 @@
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.05),
- bounds=[0.5, 0.8],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.05,
+ truncated_at=[0.5, 0.8],
+ ),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.48, 0.05),
- bounds=[0.4, 0.7],
+ distribution=pybop.Gaussian(
+ 0.48,
+ 0.05,
+ truncated_at=[0.4, 0.7],
+ ),
),
}
)
diff --git a/examples/scripts/getting_started/optimising_with_simulated_annealing.py b/examples/scripts/getting_started/optimising_with_simulated_annealing.py
index 3113486fe..0f8fb6859 100644
--- a/examples/scripts/getting_started/optimising_with_simulated_annealing.py
+++ b/examples/scripts/getting_started/optimising_with_simulated_annealing.py
@@ -36,12 +36,18 @@
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.1),
- bounds=[0.4, 0.85],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.1,
+ truncated_at=[0.4, 0.85],
+ ),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.1),
- bounds=[0.4, 0.85],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.1,
+ truncated_at=[0.4, 0.85],
+ ),
),
}
)
diff --git a/examples/scripts/getting_started/pints_ask_tell_interface.py b/examples/scripts/getting_started/pints_ask_tell_interface.py
index 52e698660..bd6de0d04 100644
--- a/examples/scripts/getting_started/pints_ask_tell_interface.py
+++ b/examples/scripts/getting_started/pints_ask_tell_interface.py
@@ -29,10 +29,10 @@
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.05),
+ distribution=pybop.Gaussian(0.55, 0.05),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.05),
+ distribution=pybop.Gaussian(0.55, 0.05),
),
}
)
diff --git a/examples/scripts/getting_started/weighted_cost.py b/examples/scripts/getting_started/weighted_cost.py
index e40706a31..4c9e264da 100644
--- a/examples/scripts/getting_started/weighted_cost.py
+++ b/examples/scripts/getting_started/weighted_cost.py
@@ -54,12 +54,14 @@ def noisy(data, sigma):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.05),
- bounds=[0.5, 0.8],
+ distribution=pybop.Gaussian(0.68, 0.05, truncated_at=[0.5, 0.8])
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.58, 0.05),
- bounds=[0.4, 0.7],
+ distribution=pybop.Gaussian(
+ 0.58,
+ 0.05,
+ truncated_at=[0.4, 0.7],
+ )
),
}
)
diff --git a/multiprocessing_bench.py b/multiprocessing_bench.py
index 5ee7dc7ab..db18e76f2 100644
--- a/multiprocessing_bench.py
+++ b/multiprocessing_bench.py
@@ -33,14 +33,12 @@
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.05),
+ pybop.Gaussian(0.68, 0.05, truncated_at=[0.4, 0.9]),
initial_value=0.65,
- bounds=[0.4, 0.9],
),
- "Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.58, 0.05),
+ "Positive electrode active material volume fraction": pybop.ParameterDistrbution(
+ pybop.Gaussian(0.58, 0.05, truncated_at=[0.4, 0.9]),
initial_value=0.65,
- bounds=[0.4, 0.9],
),
}
)
diff --git a/noxfile.py b/noxfile.py
index bac75313a..687b240f4 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -114,7 +114,6 @@ def run_tests(session):
"--integration",
"--nbmake",
"--examples",
- "--notebooks",
"-n",
"auto",
*specific_tests,
@@ -151,6 +150,7 @@ def run_quick(session):
"""
run_tests(session)
run_doc_tests(session)
+ notebooks(session)
@nox.session
diff --git a/pybop/__init__.py b/pybop/__init__.py
index 3e6b2d846..ad5b90035 100644
--- a/pybop/__init__.py
+++ b/pybop/__init__.py
@@ -48,7 +48,7 @@
# Parameter classes
#
from .parameters.parameter import Parameter, Parameters
-from .parameters.priors import BasePrior, Gaussian, Uniform, Exponential, JointPrior
+from .parameters.distributions import (Distribution, Gaussian, Uniform, Exponential, JointDistribution)
#
# Model classes
diff --git a/pybop/costs/likelihoods.py b/pybop/costs/likelihoods.py
index 46388bf1d..6ad8f5396 100644
--- a/pybop/costs/likelihoods.py
+++ b/pybop/costs/likelihoods.py
@@ -3,8 +3,8 @@
from pybop._dataset import Dataset
from pybop.costs.error_measures import ErrorMeasure
+from pybop.parameters.distributions import Distribution, JointDistribution, Uniform
from pybop.parameters.parameter import Inputs, Parameter, Parameters
-from pybop.parameters.priors import BasePrior, JointPrior, Uniform
class LogLikelihood(ErrorMeasure):
@@ -133,9 +133,8 @@ def _add_single_sigma(self, index, value):
sigma = value
elif isinstance(value, int | float):
sigma = Parameter(
+ distribution=Uniform(1e-8 * value, 3 * value),
initial_value=value,
- prior=Uniform(1e-8 * value, 3 * value),
- bounds=[1e-8, 3 * value],
)
else:
raise TypeError(
@@ -198,16 +197,16 @@ class LogPosterior(LogLikelihood):
---------------------
log_likelihood : LogLikelihood
The likelihood class of type ``LogLikelihood``.
- prior : Optional, Union[pybop.BasePrior, stats.rv_continuous]
- The prior class of type ``BasePrior`` or ``stats.rv_continuous``.
- If not provided, the prior class will be taken from the parameter priors
+ prior : Optional, Union[pybop.Parameter, stats.distributions.rv_frozen]
+ The prior class of type ``Parameter``, ``Distribution`` or ``stats.distributions.rv_frozen``.
+ If not provided, the prior class will be taken from the parameter distributions
constructed in the `pybop.Parameters` class.
"""
def __init__(
self,
log_likelihood: LogLikelihood,
- prior: BasePrior | stats.rv_continuous | None = None,
+ prior: Parameter | stats.distributions.rv_frozen | Distribution | None = None,
):
dataset = Dataset(log_likelihood.dataset)
dataset.domain = log_likelihood.domain
@@ -219,9 +218,17 @@ def __init__(
def set_joint_prior(self):
if self.prior is None:
- self.joint_prior = JointPrior(*self.parameters.priors())
- else:
+ self.joint_prior = JointDistribution(*self.parameters.distributions())
+ elif isinstance(self.prior, (stats.distributions.rv_frozen)):
+ self.joint_prior = Distribution(self.prior)
+ elif isinstance(self.prior, Parameter):
+ self.joint_prior = self.prior.distribution
+ elif isinstance(self.prior, Distribution):
self.joint_prior = self.prior
+ else:
+ raise TypeError(
+ "All priors must either be of type pybop.Parameter, pybop.Distribution or scipy.stats.distributions.rv_frozen"
+ )
def __call__(
self,
diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py
index f46542bee..9f8055e60 100644
--- a/pybop/optimisers/base_optimiser.py
+++ b/pybop/optimisers/base_optimiser.py
@@ -144,9 +144,9 @@ def run(self) -> OptimisationResult:
results = []
for i in range(self._multistart):
if i >= 1:
- if not self.problem.parameters.priors():
- raise RuntimeError("Priors must be provided for multi-start")
- initial_values = self.problem.parameters.sample_from_priors(1)[0]
+ if not self.problem.parameters.distributions():
+ raise RuntimeError("Distributions must be provided for multi-start")
+ initial_values = self.problem.parameters.sample_from_distributions(1)[0]
self.problem.parameters.update(initial_values=initial_values)
self._set_up_optimiser()
results.append(self._run())
diff --git a/pybop/parameters/priors.py b/pybop/parameters/distributions.py
similarity index 62%
rename from pybop/parameters/priors.py
rename to pybop/parameters/distributions.py
index 12574c736..7562c2bbb 100644
--- a/pybop/parameters/priors.py
+++ b/pybop/parameters/distributions.py
@@ -2,27 +2,29 @@
import scipy.stats as stats
-class BasePrior:
+class Distribution:
"""
- A base class for defining prior distributions.
+ A base class for defining parameter distributions.
- This class provides a foundation for implementing various prior distributions.
+ This class provides a foundation for implementing various distributions.
It includes methods for calculating the probability density function (PDF),
log probability density function (log PDF), and generating random variates
from the distribution.
Attributes
----------
- distribution : scipy.stats.rv_continuous
+ distribution : scipy.stats.distributions.rv_frozen
The underlying continuous random variable distribution.
- loc : float
- The location parameter of the distribution.
- scale : float
- The scale parameter of the distribution.
"""
- def __init__(self):
- pass
+ def __init__(
+ self,
+ distribution: stats.distributions.rv_frozen | None = None,
+ ):
+ self.distribution = distribution
+
+ def support(self):
+ return self.distribution.support()
def pdf(self, x):
"""
@@ -38,7 +40,10 @@ def pdf(self, x):
float
The probability density function value at x.
"""
- return self.distribution.pdf(x, loc=self.loc, scale=self.scale)
+ if self.distribution is None:
+ raise NotImplementedError
+ else:
+ return self.distribution.pdf(x)
def logpdf(self, x):
"""
@@ -54,7 +59,10 @@ def logpdf(self, x):
float
The logarithm of the probability density function value at x.
"""
- return self.distribution.logpdf(x, loc=self.loc, scale=self.scale)
+ if self.distribution is None:
+ raise NotImplementedError
+ else:
+ return self.distribution.logpdf(x)
def icdf(self, q):
"""
@@ -70,7 +78,10 @@ def icdf(self, q):
float
The inverse cumulative distribution function value at q.
"""
- return self.distribution.ppf(q, loc=self.loc, scale=self.scale)
+ if self.distribution is None:
+ raise NotImplementedError
+ else:
+ return self.distribution.ppf(q)
def cdf(self, x):
"""
@@ -86,7 +97,10 @@ def cdf(self, x):
float
The cumulative distribution function value at x.
"""
- return self.distribution.cdf(x, loc=self.loc, scale=self.scale)
+ if self.distribution is None:
+ raise NotImplementedError
+ else:
+ return self.distribution.cdf(x)
def rvs(self, size=1, random_state=None):
"""
@@ -118,9 +132,10 @@ def rvs(self, size=1, random_state=None):
if isinstance(size, tuple) and any(s < 1 for s in size):
raise ValueError("size must be a tuple of positive integers")
- return self.distribution.rvs(
- loc=self.loc, scale=self.scale, size=size, random_state=random_state
- )
+ if self.distribution is None:
+ raise NotImplementedError
+ else:
+ return self.distribution.rvs(size=size, random_state=random_state)
def logpdfS1(self, x):
"""
@@ -156,12 +171,15 @@ def _dlogpdf_dx(self, x):
float
The value(s) of the first derivative at x.
"""
- # Use a finite difference approximation of the gradient
- delta = max(abs(x) * 1e-3, np.finfo(float).eps)
- log_prior_upper = self.joint_prior.logpdf(x + delta)
- log_prior_lower = self.joint_prior.logpdf(x - delta)
+ if self.distribution is None:
+ raise NotImplementedError
+ else:
+ # Use a finite difference approximation of the gradient
+ delta = max(abs(x) * 1e-3, np.finfo(float).eps)
+ log_distribution_upper = self.logpdf(x + delta)
+ log_distribution_lower = self.logpdf(x - delta)
- return (log_prior_upper - log_prior_lower) / (2 * delta)
+ return (log_distribution_upper - log_distribution_lower) / (2 * delta)
def verify(self, x):
"""
@@ -174,29 +192,35 @@ def verify(self, x):
return x
def __repr__(self):
- """Return a string representation of the object."""
- return f"{self.__class__.__name__}, loc: {self.loc}, scale: {self.scale}"
+ """
+ Returns a string representation of the object.
+ """
+ return f"{self.__class__.__name__}, mean: {self.mean()}, standard deviation: {self.std()}"
- @property
def mean(self):
- """The mean of the distribution."""
- return self.distribution.mean(loc=self.loc, scale=self.scale)
+ """
+ Get the mean of the distribution.
- @property
- def sigma(self):
- """The standard deviation of the distribution."""
- return self.distribution.std(loc=self.loc, scale=self.scale)
+ Returns
+ -------
+ float
+ The mean of the distribution.
+ """
+ return self.distribution.mean()
- def bounds(self) -> tuple[float, float] | None:
- """Get the bounds of the distribution, if any."""
- upper = self.distribution.ppf(1, loc=self.loc, scale=self.scale)
- lower = self.distribution.ppf(0, loc=self.loc, scale=self.scale)
- if np.isinf(upper) and np.isinf(lower):
- return None
- return (lower, upper)
+ def std(self):
+ """
+ Get the standard deviation of the distribution.
+
+ Returns
+ -------
+ float
+ The standard deviation of the distribution.
+ """
+ return self.distribution.std()
-class Gaussian(BasePrior):
+class Gaussian(Distribution):
"""
Represents a Gaussian (normal) distribution with a given mean and standard deviation.
@@ -211,13 +235,26 @@ class Gaussian(BasePrior):
The standard deviation (sigma) of the Gaussian distribution.
"""
- def __init__(self, mean, sigma, random_state=None):
- super().__init__()
+ def __init__(
+ self,
+ mean,
+ sigma,
+ truncated_at: list[float] = None,
+ ):
+ if truncated_at is not None:
+ distribution = stats.truncnorm(
+ (truncated_at[0] - mean) / sigma,
+ (truncated_at[1] - mean) / sigma,
+ loc=mean,
+ scale=sigma,
+ )
+ else:
+ distribution = stats.norm(loc=mean, scale=sigma)
+ super().__init__(distribution)
self.name = "Gaussian"
+ self._n_parameters = 1
self.loc = mean
self.scale = sigma
- self.distribution = stats.norm
- self._n_parameters = 1
def _dlogpdf_dx(self, x):
"""
@@ -236,7 +273,7 @@ def _dlogpdf_dx(self, x):
return (self.loc - x) / self.scale**2
-class Uniform(BasePrior):
+class Uniform(Distribution):
"""
Represents a uniform distribution over a specified interval.
@@ -251,14 +288,15 @@ class Uniform(BasePrior):
The upper bound of the distribution.
"""
- def __init__(self, lower, upper, random_state=None):
- super().__init__()
+ def __init__(
+ self,
+ lower,
+ upper,
+ ):
+ super().__init__(stats.uniform(loc=lower, scale=upper - lower))
self.name = "Uniform"
self.lower = lower
self.upper = upper
- self.loc = lower
- self.scale = upper - lower
- self.distribution = stats.uniform
self._n_parameters = 1
def _dlogpdf_dx(self, x):
@@ -277,8 +315,20 @@ def _dlogpdf_dx(self, x):
"""
return np.zeros_like(x)
+ def mean(self):
+ """
+ Returns the mean of the distribution.
+ """
+ return (self.upper - self.lower) / 2
-class Exponential(BasePrior):
+ def __repr__(self):
+ """
+ Returns a string representation of the object.
+ """
+ return f"{self.__class__.__name__}, lower: {self.lower}, upper: {self.upper}"
+
+
+class Exponential(Distribution):
"""
Represents an exponential distribution with a specified scale parameter.
@@ -291,13 +341,16 @@ class Exponential(BasePrior):
The scale parameter (lambda) of the exponential distribution.
"""
- def __init__(self, loc=0, scale=1, random_state=None):
- super().__init__()
+ def __init__(
+ self,
+ scale: float,
+ loc: float = 0,
+ ):
+ super().__init__(stats.expon(loc=loc, scale=scale))
self.name = "Exponential"
+ self._n_parameters = 1
self.loc = loc
self.scale = scale
- self.distribution = stats.expon
- self._n_parameters = 1
def _dlogpdf_dx(self, x):
"""
@@ -315,32 +368,48 @@ def _dlogpdf_dx(self, x):
"""
return -1 / self.scale * np.ones_like(x)
+ def __repr__(self):
+ """
+ Returns a string representation of the object.
+ """
+ return f"{self.__class__.__name__}, loc: {self.loc}, scale: {self.scale}"
+
-class JointPrior(BasePrior):
+class JointDistribution(Distribution):
"""
- Represents a joint prior distribution composed of multiple prior distributions.
+ Represents a joint distribution composed of multiple distributions.
Parameters
----------
- priors : BasePrior
- One or more prior distributions to combine into a joint distribution.
+ distributions : Distribution
+ One or more distributions to combine into a joint distribution.
"""
- def __init__(self, *priors: BasePrior):
+ def __init__(self, *distributions: Distribution | stats.distributions.rv_frozen):
super().__init__()
- if all(prior is None for prior in priors):
+ if all(distribution is None for distribution in distributions):
return
- if not all(isinstance(prior, BasePrior) for prior in priors):
- raise ValueError("All priors must be instances of BasePrior")
+ if not all(
+ isinstance(distribution, (Distribution, stats.distributions.rv_frozen))
+ for distribution in distributions
+ ):
+ raise ValueError(
+ "All distributions must be instances of Distribution or scipy.stats.distributions.rv_frozen"
+ )
- self._n_parameters = len(priors)
- self._priors: list[BasePrior] = list(priors)
+ self._n_parameters = len(distributions)
+ self._distributions: list[Distribution] = [
+ distribution
+ if isinstance(distribution, Distribution)
+ else Distribution(distribution)
+ for distribution in distributions
+ ]
def logpdf(self, x: float | np.ndarray) -> float:
"""
- Evaluates the joint log-prior distribution at a given point.
+ Evaluates the log of the joint distribution at a given point.
Parameters
----------
@@ -358,11 +427,14 @@ def logpdf(self, x: float | np.ndarray) -> float:
f"Input x must have length {self._n_parameters}, got {len(x)}"
)
- return sum(prior.logpdf(x) for prior, x in zip(self._priors, x, strict=False))
+ return sum(
+ distribution.logpdf(x)
+ for distribution, x in zip(self._distributions, x, strict=False)
+ )
def logpdfS1(self, x: float | np.ndarray) -> tuple[float, np.ndarray]:
"""
- Evaluates the first derivative of the joint log-prior distribution at a given point.
+ Evaluates the first derivative of the log of the joint distribution at a given point.
Parameters
----------
@@ -383,8 +455,8 @@ def logpdfS1(self, x: float | np.ndarray) -> tuple[float, np.ndarray]:
log_probs = []
derivatives = []
- for prior, xi in zip(self._priors, x, strict=False):
- p, dp = prior.logpdfS1(xi)
+ for distribution, xi in zip(self._distributions, x, strict=False):
+ p, dp = distribution.logpdfS1(xi)
log_probs.append(p)
derivatives.append(dp)
@@ -397,5 +469,7 @@ def logpdfS1(self, x: float | np.ndarray) -> tuple[float, np.ndarray]:
return output, doutput.T
def __repr__(self) -> str:
- priors_repr = ", ".join([repr(prior) for prior in self._priors])
- return f"{self.__class__.__name__}(priors: [{priors_repr}])"
+ distributions_repr = "; ".join(
+ [repr(distribution) for distribution in self._distributions]
+ )
+ return f"{self.__class__.__name__}(distributions: [{distributions_repr}])"
diff --git a/pybop/parameters/parameter.py b/pybop/parameters/parameter.py
index ba68f15a7..b041c3a73 100644
--- a/pybop/parameters/parameter.py
+++ b/pybop/parameters/parameter.py
@@ -7,9 +7,10 @@
from typing import Any
import numpy as np
+import scipy.stats as stats
from numpy.typing import NDArray
-from pybop.parameters.priors import BasePrior, Uniform
+from pybop.parameters.distributions import Distribution
from pybop.transformation.base_transformation import Transformation
from pybop.transformation.transformations import (
ComposedTransformation,
@@ -90,47 +91,56 @@ class Parameter:
"""
Represents a parameter within the PyBOP framework.
- This class encapsulates the definition of a parameter, including its name, prior
- distribution, initial value, bounds, and a margin to ensure the parameter stays
- within feasible limits during optimisation or sampling.
+ This class encapsulates the definition of a parameter, including its
+ initial value, bounds.
Parameters
----------
- initial_value : NumericValue, optional
- Initial parameter value
+ distribution : stats.distribution.rv_frozen | Distribution
+ Distribution of the parameter
bounds : tuple[float, float], optional
Parameter bounds as (lower, upper)
- prior : pybop.BasePrior, optional
- Prior distribution object
+ initial_value : NumericValue, optional
+ Initial parameter value
transformation : Transformation, optional
Parameter transformation
- margin : float, default=1e-4
- Safety margin for bounds sampling
"""
def __init__(
self,
- *,
- initial_value: float = None,
+ distribution: stats.distributions.rv_frozen | Distribution | None = None,
bounds: BoundsPair | None = None,
- prior: BasePrior | None = None,
+ initial_value: float = None,
transformation: Transformation | None = None,
- margin: float = 1e-4,
) -> None:
- self._prior = prior
+ self._distribution = distribution
+ self._bounds = None
self._transformation = transformation or IdentityTransformation()
- # Set bounds with validation
- self._bounds: Bounds | None = None
+ if self._distribution is not None:
+ lower, upper = self._distribution.support()
+ if np.isinf(lower) and np.isinf(upper):
+ self._bounds = None
+ else:
+ self._bounds = Bounds(lower, upper)
+
if bounds is not None:
+ if distribution is not None:
+ raise ParameterError(
+ "Bounds can only be set if no distribution is provided. If a bounded distribution is needed, please ensure the distribution itself is bounded."
+ )
+ # Set bounds with validation
self._bounds = Bounds(bounds[0], bounds[1])
- if self._prior is None and all(np.isfinite(np.asarray(bounds))):
- self._prior = Uniform(bounds[0], bounds[1])
- self._set_margin(margin)
+ # Add uniform distribution for finite bounds in order to sample initial values
+ if all(np.isfinite(np.asarray(bounds))):
+ self._distribution = stats.uniform(
+ loc=bounds[0], scale=bounds[1] - bounds[0]
+ )
+
+ if initial_value is None and self._distribution is not None:
+ initial_value = self.sample_from_distribution()[0]
# Validate and set values
- if initial_value is None and self._prior is not None:
- initial_value = self.sample_from_prior()[0]
self._initial_value = (
float(initial_value) if initial_value is not None else None
)
@@ -138,7 +148,7 @@ def __init__(
# Validate initial values are within bounds
self._validate_values_within_bounds()
- def sample_from_prior(
+ def sample_from_distribution(
self,
n_samples: int = 1,
*,
@@ -146,7 +156,7 @@ def sample_from_prior(
transformed: bool = False,
) -> NDArray[np.floating] | None:
"""
- Sample from parameter's prior distribution.
+ Sample from parameter's distribution.
Parameters
----------
@@ -160,21 +170,14 @@ def sample_from_prior(
Returns
-------
NDArray[np.floating] or None
- Array of samples, or None if no prior exists
+ Array of samples, or None if no distribution exists exists
"""
- if self._prior is None:
+ if self._distribution is None:
return None
- samples = self._prior.rvs(n_samples, random_state=random_state)
+ samples = self._distribution.rvs(n_samples, random_state=random_state)
samples = np.atleast_1d(samples).astype(float)
- # Apply bounds clipping if bounds exist
- if self._bounds is not None:
- offset = self._margin * self._bounds.width()
- effective_lower = self._bounds.lower + offset
- effective_upper = self._bounds.upper - offset
- samples = np.clip(samples, effective_lower, effective_upper)
-
if transformed:
samples = np.array([self._transformation.to_search(s)[0] for s in samples])
@@ -193,34 +196,7 @@ def update_initial_value(self, value: NumericValue) -> None:
def __repr__(self) -> str:
"""String representation of the parameter."""
- return f"Parameter: Prior: {self.prior} \n Bounds: {self.bounds}"
-
- def _set_margin(self, margin: float) -> None:
- """
- Set the margin to a specified positive value less than 1.
-
- The margin is used to ensure parameter samples are not drawn exactly at the bounds,
- which may be problematic in some optimization or sampling algorithms.
- """
- if not 0 < margin < 1:
- raise ParameterValidationError("Margin must be between 0 and 1")
- self._margin = margin
-
- def set_bounds(self, bounds: BoundsPair) -> None:
- """
- Set new parameter bounds.
-
- Parameters
- ----------
- bounds : tuple[float, float]
- New bounds as (lower, upper)
- """
- if bounds is None or (
- not np.isfinite(bounds[0]) and not np.isfinite(bounds[1])
- ):
- self._bounds = None
- else:
- self._bounds = Bounds(bounds[0], bounds[1])
+ return f"Parameter - Distribution: {self._distribution}, Bounds: ({self.bounds[0]}, {self.bounds[1]}), Initial value: {self.initial_value}"
def _validate_values_within_bounds(self) -> None:
"""Validate that initial values are within bounds."""
@@ -239,8 +215,8 @@ def get_initial_value_transformed(self) -> NDArray | None:
return self._transformation.to_search(self._initial_value)[0]
def __call__(self, *unused_args, **unused_kwargs) -> float:
- "Return the current value. The unused arguments are to pass pybamm.ParameterValues checks."
- return self._current_value
+ "Return the initial value. The unused arguments are to pass pybamm.ParameterValues checks."
+ return self._initial_value
@property
def initial_value(self) -> float:
@@ -254,17 +230,13 @@ def bounds(self) -> BoundsPair | None:
)
@property
- def prior(self) -> Any | None:
- return self._prior
+ def distribution(self) -> Any | None:
+ return self._distribution
@property
def transformation(self) -> Transformation:
return self._transformation
- def __hash__(self) -> int:
- """Hash based on name."""
- return hash(self._name)
-
class Parameters:
"""
@@ -291,6 +263,9 @@ def __init__(self, parameters: dict | Parameters = None) -> None:
def __getitem__(self, name: str) -> Parameter:
return self.get(name)
+ def __setitem__(self, name: str, param: Parameter) -> None:
+ self.set(name, param)
+
def __len__(self) -> int:
return len(self._parameters)
@@ -326,7 +301,7 @@ def _add(
raise TypeError("Expected Parameter instance")
if name in self._parameters:
- raise ParameterError(f"Parameter '{name}' already exists")
+ raise ParameterError(f"Parameter for '{name}' already exists")
self._parameters[name] = parameter
@@ -338,7 +313,7 @@ def remove(self, name: str) -> Parameter:
if not isinstance(name, str):
raise TypeError("The input name is not a string.")
if name not in self._parameters:
- raise ParameterNotFoundError(f"Parameter '{name}' not found")
+ raise ParameterNotFoundError(f"Parameter for '{name}' not found")
return self._parameters.pop(name)
def join(self, parameters=None):
@@ -358,9 +333,17 @@ def join(self, parameters=None):
def get(self, name: str) -> Parameter:
"""Get a parameter by name."""
if name not in self._parameters:
- raise ParameterNotFoundError(f"Parameter '{name}' not found")
+ raise ParameterNotFoundError(f"Parameter for '{name}' not found")
return self._parameters[name]
+ def set(self, name: str, param: Parameter) -> None:
+ """Get a parameter by name."""
+ if name not in self._parameters:
+ raise ParameterNotFoundError(f"Parameter for '{name}' not found")
+ if not isinstance(param, Parameter):
+ raise TypeError({"Paremeter must be of type pybop.ParemterInfo"})
+ self._parameters[name] = param
+
def get_bounds(self, transformed: bool = False) -> dict:
"""
Get bounds, for either all or no parameters.
@@ -412,7 +395,6 @@ def update(
self,
*,
initial_values: ArrayLike | Inputs | None = None,
- bounds: Sequence[BoundsPair] | dict[str, BoundsPair] | None = None,
**individual_updates: dict[str, Any],
) -> None:
"""
@@ -434,24 +416,10 @@ def update(
if isinstance(updates, dict):
if "initial_value" in updates:
param.update_initial_value(updates["initial_value"])
- if "bounds" in updates:
- param.set_bounds(updates["bounds"])
# Handle bulk updates
if initial_values is not None:
self._bulk_update_initial_values(initial_values)
- if bounds is not None:
- # Allow conversion from get_bounds output type to Sequence[BoundsPair] type
- if isinstance(bounds, dict) and "upper" in bounds.keys():
- converted_bounds = []
- for i in range(len(bounds["lower"])):
- converted_bounds.append([bounds["lower"][i], bounds["upper"][i]])
- bounds = converted_bounds
- self._bulk_update_bounds(bounds)
-
- def remove_bounds(self) -> None:
- for param in self._parameters.values():
- param.set_bounds(None)
def _bulk_update_initial_values(self, values: ArrayLike | Inputs) -> None:
"""Update initial values in bulk."""
@@ -471,26 +439,7 @@ def _bulk_update_initial_values(self, values: ArrayLike | Inputs) -> None:
for param, value in zip(param_list, values_array, strict=False):
param.update_initial_value(value)
- def _bulk_update_bounds(
- self, bounds: Sequence[BoundsPair] | dict[str, BoundsPair]
- ) -> None:
- """Update bounds in bulk."""
- if isinstance(bounds, dict):
- for name, bound_pair in bounds.items():
- self.get(name).set_bounds(bound_pair)
- else:
- param_list = list(self._parameters.values())
-
- if len(bounds) != len(param_list):
- raise ParameterValidationError(
- f"Bounds array length {len(bounds)} doesn't match "
- f"parameter count {len(param_list)}"
- )
-
- for param, bound_pair in zip(param_list, bounds, strict=False):
- param.set_bounds(bound_pair)
-
- def sample_from_priors(
+ def sample_from_distributions(
self,
n_samples: int = 1,
*,
@@ -498,17 +447,17 @@ def sample_from_priors(
transformed: bool = False,
) -> NDArray[np.floating] | None:
"""
- Sample from all parameter priors.
+ Sample from all parameter distributions.
Returns
-------
NDArray[np.floating] or None
- Array of shape (n_samples, n_parameters) or None if any prior is missing
+ Array of shape (n_samples, n_parameters) or None if any distribution is missing
"""
all_samples = []
for param in self._parameters.values():
- samples = param.sample_from_prior(
+ samples = param.sample_from_distribution(
n_samples, random_state=random_state, transformed=transformed
)
if samples is None:
@@ -530,8 +479,8 @@ def get_sigma0(self, transformed: bool = False) -> list:
for param in self._parameters.values():
sig = None
- if hasattr(param.prior, "sigma"):
- sig = param.prior.sigma
+ if param.distribution is not None and hasattr(param.distribution, "std"):
+ sig = param.distribution.std()
elif param.bounds is not None:
lower, upper = param.bounds
if np.isfinite(upper - lower):
@@ -547,12 +496,12 @@ def get_sigma0(self, transformed: bool = False) -> list:
sigma0.extend([sig or 0.05])
return sigma0
- def priors(self) -> list:
- """Return the prior distribution of each parameter."""
+ def distributions(self) -> list:
+ """Return the initial distribution of each parameter."""
return [
- param.prior
+ param.distribution
for param in self._parameters.values()
- if param.prior is not None
+ if param.distribution is not None
]
def get_initial_values(self, *, transformed: bool = False) -> NDArray[np.floating]:
@@ -573,9 +522,9 @@ def get_initial_values(self, *, transformed: bool = False) -> NDArray[np.floatin
for name, param in self._parameters.items():
value = param.initial_value
if value is None:
- # Try to sample from prior if available
- if param.prior is not None:
- samples = param.sample_from_prior(1, transformed=transformed)
+ # Try to sample from distribution if available
+ if param.distribution is not None:
+ samples = param.sample_from_distribution(1, transformed=transformed)
if samples is not None:
param.update_initial_value(samples[0])
value = samples[0] if transformed else param.initial_value
@@ -667,8 +616,7 @@ def verify_inputs(self, inputs: Inputs) -> bool:
def __repr__(self) -> str:
param_summary = "\n".join(
- f" {name}: prior= {param.prior}, bounds={param.bounds}"
- for name, param in self._parameters.items()
+ f" {name}: {param}" for name, param in self._parameters.items()
)
return f"Parameters({len(self)}):\n{param_summary}"
diff --git a/pybop/problems/problem.py b/pybop/problems/problem.py
index f49bd04e0..ae2cfaab4 100644
--- a/pybop/problems/problem.py
+++ b/pybop/problems/problem.py
@@ -270,7 +270,7 @@ def get_finite_initial_cost(self):
cost0 = np.abs(self.evaluate(x0).values[0])
nsamples = 0
while np.isinf(cost0) and nsamples < 10:
- x0 = self.parameters.sample_from_priors()[0]
+ x0 = self.parameters.sample_from_distributions()[0]
if x0 is None:
break
diff --git a/pybop/simulators/base_simulator.py b/pybop/simulators/base_simulator.py
index a18923560..ea3545304 100644
--- a/pybop/simulators/base_simulator.py
+++ b/pybop/simulators/base_simulator.py
@@ -10,24 +10,21 @@ class BaseSimulator:
Base simulator.
"""
- def __init__(self, parameters: Parameters | None = None):
+ def __init__(self, parameters: Parameters | dict | None = None):
if parameters is None:
parameters = Parameters()
# Check if parameters is a list of pybop.Parameter objects
- elif isinstance(parameters, list):
+ elif isinstance(parameters, dict):
if all(isinstance(param, Parameter) for param in parameters):
parameters = Parameters(*parameters)
else:
raise TypeError(
"All elements in the list must be pybop.Parameter objects."
)
- # Check if parameters is a single pybop.Parameter object
- elif isinstance(parameters, Parameter):
- parameters = Parameters(parameters)
# Check if parameters is already a pybop.Parameters object
elif not isinstance(parameters, Parameters):
raise TypeError(
- "The input parameters must be a pybop.Parameter, a list of pybop.Parameter objects, or a pybop.Parameters object."
+ "The input parameters must be a a dictionary of Parameter objects or a pybop.Parameters object."
)
self.parameters = parameters
diff --git a/tests/integration/test_eis_parameterisation.py b/tests/integration/test_eis_parameterisation.py
index 3e4fcd250..be20b0019 100644
--- a/tests/integration/test_eis_parameterisation.py
+++ b/tests/integration/test_eis_parameterisation.py
@@ -1,6 +1,7 @@
import numpy as np
import pybamm
import pytest
+from scipy import stats
import pybop
@@ -41,14 +42,12 @@ def parameter_values(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.3, 0.9),
- initial_value=pybop.Uniform(0.4, 0.75).rvs()[0],
- bounds=[0.375, 0.775],
+ distribution=stats.uniform(loc=0.3, scale=0.9 - 0.3),
+ initial_value=stats.uniform(loc=0.4, scale=0.75 - 0.4).rvs(),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.3, 0.9),
- initial_value=pybop.Uniform(0.4, 0.75).rvs()[0],
- bounds=[0.375, 0.775],
+ distribution=stats.uniform(loc=0.3, scale=0.9 - 0.3),
+ initial_value=stats.uniform(loc=0.4, scale=0.75 - 0.4).rvs(),
),
}
diff --git a/tests/integration/test_half_cell_model.py b/tests/integration/test_half_cell_model.py
index b8ae0069d..269c69796 100644
--- a/tests/integration/test_half_cell_model.py
+++ b/tests/integration/test_half_cell_model.py
@@ -2,6 +2,7 @@
import pybamm
import pytest
from pybamm import Parameter
+from scipy import stats
import pybop
@@ -68,7 +69,7 @@ def parameter_values(self, model):
def parameters(self):
return {
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.4, 0.75),
+ stats.uniform(0.4, 0.75 - 0.4),
# no bounds
),
}
@@ -114,9 +115,12 @@ def design_problem(self, model, parameter_values):
parameter_values.update(
{
"Positive electrode thickness [m]": pybop.Parameter(
- prior=pybop.Gaussian(5e-05, 5e-06),
- bounds=[2e-06, 10e-05],
- ),
+ distribution=pybop.Gaussian(
+ 5e-05,
+ 5e-06,
+ truncated_at=[2e-06, 10e-05],
+ ),
+ )
}
)
experiment = pybamm.Experiment(
diff --git a/tests/integration/test_hessian.py b/tests/integration/test_hessian.py
index 65cc504fa..2f624e86b 100644
--- a/tests/integration/test_hessian.py
+++ b/tests/integration/test_hessian.py
@@ -26,12 +26,18 @@ def parameters(self, request):
self.ground_truth = request.param
return {
"R0 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.05, 0.01),
- bounds=[0.02, 0.08],
+ distribution=pybop.Gaussian(
+ 0.05,
+ 0.01,
+ truncated_at=[0.02, 0.08],
+ )
),
"R1 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.05, 0.01),
- bounds=[0.02, 0.08],
+ distribution=pybop.Gaussian(
+ 0.05,
+ 0.01,
+ truncated_at=[0.02, 0.08],
+ )
),
}
diff --git a/tests/integration/test_model_experiment_changes.py b/tests/integration/test_model_experiment_changes.py
index bdb81bcb2..7332f1683 100644
--- a/tests/integration/test_model_experiment_changes.py
+++ b/tests/integration/test_model_experiment_changes.py
@@ -1,6 +1,7 @@
import numpy as np
import pybamm
import pytest
+from scipy import stats
import pybop
@@ -17,8 +18,11 @@ class TestModelAndExperimentChanges:
[
{
"Negative particle radius [m]": pybop.Parameter( # geometric parameter
- prior=pybop.Gaussian(6e-06, 0.1e-6),
- bounds=[1e-6, 9e-6],
+ distribution=pybop.Gaussian(
+ 6e-06,
+ 0.1e-6,
+ truncated_at=[1e-6, 9e-6],
+ ),
initial_value=5.86e-6,
),
},
@@ -27,8 +31,11 @@ class TestModelAndExperimentChanges:
[
{
"Positive particle diffusivity [m2.s-1]": pybop.Parameter( # non-geometric parameter
- prior=pybop.Gaussian(3.43e-15, 1e-15),
- bounds=[1e-15, 5e-15],
+ distribution=pybop.Gaussian(
+ 3.43e-15,
+ 1e-15,
+ truncated_at=[1e-15, 5e-15],
+ ),
initial_value=4e-15,
),
},
@@ -157,7 +164,7 @@ def test_multi_fitting_problem(self, solver):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.05),
+ distribution=stats.norm(loc=0.68, scale=0.05),
)
}
)
@@ -180,7 +187,7 @@ def test_multi_fitting_problem(self, solver):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.05),
+ distribution=stats.norm(loc=0.68, scale=0.05),
)
}
)
diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py
index 5697b68f5..9584eaa85 100644
--- a/tests/integration/test_monte_carlo.py
+++ b/tests/integration/test_monte_carlo.py
@@ -1,6 +1,7 @@
import numpy as np
import pybamm
import pytest
+from scipy import stats
import pybop
from pybop import (
@@ -51,13 +52,12 @@ def model_and_parameter_values(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.575, 0.05),
- initial_value=pybop.Uniform(0.4, 0.7).rvs()[0],
- bounds=[0.375, 0.725],
+ pybop.Gaussian(0.575, 0.05, truncated_at=[0.375, 0.725]),
+ initial_value=stats.uniform(0.4, 0.7 - 0.4).rvs(),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.525, 0.05),
- initial_value=pybop.Uniform(0.4, 0.7).rvs()[0],
+ stats.norm(loc=0.525, scale=0.05),
+ initial_value=stats.uniform(0.4, 0.7 - 0.4).rvs(),
# no bounds
),
}
diff --git a/tests/integration/test_monte_carlo_thevenin.py b/tests/integration/test_monte_carlo_thevenin.py
index 5b772a691..19edda208 100644
--- a/tests/integration/test_monte_carlo_thevenin.py
+++ b/tests/integration/test_monte_carlo_thevenin.py
@@ -3,6 +3,7 @@
import numpy as np
import pybamm
import pytest
+from scipy import stats
import pybop
from pybop import (
@@ -69,16 +70,22 @@ def parameter_values(self, model):
def parameters(self):
return {
"R0 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(5e-2, 5e-3),
+ distribution=pybop.Gaussian(
+ 5e-2,
+ 5e-3,
+ truncated_at=[1e-4, 1e-1],
+ ),
transformation=pybop.LogTransformation(),
- initial_value=pybop.Uniform(2e-3, 8e-2).rvs()[0],
- bounds=[1e-4, 1e-1],
+ initial_value=stats.uniform(2e-3, 8e-2 - 2e-3).rvs(),
),
"R1 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(5e-2, 5e-3),
+ distribution=pybop.Gaussian(
+ 5e-2,
+ 5e-3,
+ truncated_at=[1e-4, 1e-1],
+ ),
transformation=pybop.LogTransformation(),
- initial_value=pybop.Uniform(2e-3, 8e-2).rvs()[0],
- bounds=[1e-4, 1e-1],
+ initial_value=stats.uniform(2e-3, 8e-2 - 2e-3).rvs(),
),
}
diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py
index 26faa516a..b9bff0a16 100644
--- a/tests/integration/test_optimisation_options.py
+++ b/tests/integration/test_optimisation_options.py
@@ -1,6 +1,7 @@
import numpy as np
import pybamm
import pytest
+from scipy import stats
import pybop
@@ -40,11 +41,14 @@ def parameter_values(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.05),
- bounds=[0.375, 0.75],
+ distribution=pybop.Gaussian(
+ 0.55,
+ 0.05,
+ truncated_at=[0.375, 0.75],
+ ),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.05),
+ stats.norm(loc=0.55, scale=0.05),
# no bounds
),
}
diff --git a/tests/integration/test_spm_parameterisations.py b/tests/integration/test_spm_parameterisations.py
index 3af763fd5..7a9366efa 100644
--- a/tests/integration/test_spm_parameterisations.py
+++ b/tests/integration/test_spm_parameterisations.py
@@ -1,6 +1,7 @@
import numpy as np
import pybamm
import pytest
+from scipy import stats
import pybop
@@ -43,14 +44,25 @@ def model_and_parameter_values(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.3, 0.9),
- initial_value=pybop.Uniform(0.4, 0.75).rvs()[0],
- bounds=[0.3, 0.8],
+ stats.uniform(0.3, 0.9 - 0.3),
+ initial_value=stats.uniform(0.4, 0.75 - 0.4).rvs(),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.3, 0.9),
- initial_value=pybop.Uniform(0.4, 0.75).rvs()[0],
- # no bounds
+ stats.uniform(0.3, 0.9 - 0.3),
+ initial_value=stats.uniform(0.4, 0.75 - 0.4).rvs(),
+ ),
+ }
+
+ @pytest.fixture
+ def priors(self):
+ return {
+ "Negative electrode active material volume fraction": pybop.Parameter(
+ pybop.Uniform(0.3, 0.9),
+ initial_value=stats.uniform(0.4, 0.75 - 0.4).rvs(),
+ ),
+ "Positive electrode active material volume fraction": pybop.Parameter(
+ pybop.Uniform(0.3, 0.9),
+ initial_value=stats.uniform(0.4, 0.75 - 0.4).rvs(),
),
}
@@ -85,13 +97,18 @@ def optimiser(self, request):
return request.param
@pytest.fixture
- def optim(self, optimiser, model_and_parameter_values, parameters, cost_class):
+ def optim(
+ self, optimiser, model_and_parameter_values, parameters, priors, cost_class
+ ):
model, parameter_values = model_and_parameter_values
parameter_values.set_initial_state(0.6)
dataset = self.get_data(model, parameter_values)
# Define the problem
- parameter_values.update(parameters)
+ if cost_class is pybop.LogPosterior:
+ parameter_values.update(priors)
+ else:
+ parameter_values.update(parameters)
simulator = pybop.pybamm.Simulator(
model, parameter_values=parameter_values, protocol=dataset
)
@@ -132,9 +149,30 @@ def optim(self, optimiser, model_and_parameter_values, parameters, cost_class):
]:
bounds = {"lower": [0.375, 0.375], "upper": [0.775, 0.775]}
if isinstance(cost, pybop.GaussianLogLikelihood):
- bounds["lower"].append(0.0)
- bounds["upper"].append(0.05)
- problem.parameters.update(bounds=bounds)
+ cost.set_sigma0(
+ pybop.Parameter(
+ distribution=pybop.Uniform(
+ max(1e-8 * self.sigma0, 0.0), min(3 * self.sigma0, 0.05)
+ ),
+ initial_value=self.sigma0,
+ )
+ )
+ problem.parameters["Negative electrode active material volume fraction"] = (
+ pybop.Parameter(
+ stats.uniform(
+ bounds["lower"][0], bounds["upper"][0] - bounds["lower"][0]
+ ),
+ initial_value=stats.uniform(0.4, 0.75 - 0.4).rvs(),
+ )
+ )
+ problem.parameters["Positive electrode active material volume fraction"] = (
+ pybop.Parameter(
+ stats.uniform(
+ bounds["lower"][1], bounds["upper"][1] - bounds["lower"][1]
+ ),
+ initial_value=stats.uniform(0.4, 0.75 - 0.4).rvs(),
+ )
+ )
# Create optimiser
optim = optimiser(problem, options=options)
@@ -231,9 +269,31 @@ def test_multiple_signals(self, multi_optimiser, two_signal_problem):
if multi_optimiser is pybop.SciPyDifferentialEvolution:
bounds = {"lower": [0.375, 0.375], "upper": [0.775, 0.775]}
if isinstance(two_signal_problem.cost, pybop.GaussianLogLikelihood):
- bounds["lower"].extend([0.0, 0.0])
- bounds["upper"].extend([0.05, 0.05])
- two_signal_problem.parameters.update(bounds=bounds)
+ two_signal_problem.cost.set_sigma0(
+ pybop.Parameter(
+ distribution=pybop.Uniform(
+ max(1e-8 * self.sigma0 * 4, 0.0),
+ min(3 * self.sigma0 * 4, 0.05),
+ ),
+ initial_value=self.sigma0 * 4,
+ )
+ )
+ two_signal_problem.parameters[
+ "Negative electrode active material volume fraction"
+ ] = pybop.Parameter(
+ stats.uniform(
+ bounds["lower"][0], bounds["upper"][0] - bounds["lower"][0]
+ ),
+ initial_value=stats.uniform(0.4, 0.75 - 0.4).rvs(),
+ )
+ two_signal_problem.parameters[
+ "Positive electrode active material volume fraction"
+ ] = pybop.Parameter(
+ stats.uniform(
+ bounds["lower"][1], bounds["upper"][1] - bounds["lower"][1]
+ ),
+ initial_value=stats.uniform(0.4, 0.75 - 0.4).rvs(),
+ )
# Test each optimiser
optim = multi_optimiser(two_signal_problem, options=options)
diff --git a/tests/integration/test_thevenin_parameterisation.py b/tests/integration/test_thevenin_parameterisation.py
index dee6786a7..9510c0efd 100644
--- a/tests/integration/test_thevenin_parameterisation.py
+++ b/tests/integration/test_thevenin_parameterisation.py
@@ -51,13 +51,19 @@ def parameter_values(self, model):
def parameters(self):
return {
"R0 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.05, 0.01),
- bounds=[1e-6, 0.1],
+ distribution=pybop.Gaussian(
+ 0.05,
+ 0.01,
+ truncated_at=[1e-6, 0.1],
+ ),
transformation=pybop.LogTransformation(),
),
"R1 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.05, 0.01),
- bounds=[1e-6, 0.1],
+ distribution=pybop.Gaussian(
+ 0.05,
+ 0.01,
+ truncated_at=[1e-6, 0.1],
+ ),
transformation=pybop.LogTransformation(),
),
}
diff --git a/tests/integration/test_transformation.py b/tests/integration/test_transformation.py
index df7a0392b..b158d4fea 100644
--- a/tests/integration/test_transformation.py
+++ b/tests/integration/test_transformation.py
@@ -65,13 +65,19 @@ def parameter_values(self, model):
def parameters(self, transformation_r0, transformation_r1):
return {
"R0 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.05, 0.02),
- bounds=[1e-4, 0.1],
+ distribution=pybop.Gaussian(
+ 0.05,
+ 0.02,
+ truncated_at=[1e-4, 0.1],
+ ),
transformation=transformation_r0,
),
"R1 [Ohm]": pybop.Parameter(
- prior=pybop.Gaussian(0.05, 0.02),
- bounds=[1e-4, 0.1],
+ distribution=pybop.Gaussian(
+ 0.05,
+ 0.02,
+ truncated_at=[1e-4, 0.1],
+ ),
transformation=transformation_r1,
),
}
diff --git a/tests/integration/test_weighted_cost.py b/tests/integration/test_weighted_cost.py
index 1ded34cc7..fb6746af8 100644
--- a/tests/integration/test_weighted_cost.py
+++ b/tests/integration/test_weighted_cost.py
@@ -2,6 +2,7 @@
import pybamm
import pytest
from pybamm import Parameter
+from scipy import stats
import pybop
@@ -67,11 +68,10 @@ def parameter_values(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.4, 0.75),
- bounds=[0.375, 0.75],
+ stats.uniform(0.4, 0.75 - 0.4),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Uniform(0.4, 0.75),
+ stats.uniform(0.4, 0.75 - 0.4),
# no bounds
),
}
@@ -144,12 +144,18 @@ def weighted_design_cost(self, model, parameter_values, design_targets):
parameter_values.update(
{
"Positive electrode thickness [m]": pybop.Parameter(
- prior=pybop.Gaussian(5e-05, 5e-06),
- bounds=[2e-06, 10e-05],
+ distribution=pybop.Gaussian(
+ 5e-05,
+ 5e-06,
+ truncated_at=[2e-06, 10e-05],
+ )
),
"Negative electrode thickness [m]": pybop.Parameter(
- prior=pybop.Gaussian(5e-05, 5e-06),
- bounds=[2e-06, 10e-05],
+ distribution=pybop.Gaussian(
+ 5e-05,
+ 5e-06,
+ truncated_at=[2e-06, 10e-05],
+ )
),
}
)
diff --git a/tests/unit/test_classifier.py b/tests/unit/test_classifier.py
index 9a7ed02c8..f371302e5 100644
--- a/tests/unit/test_classifier.py
+++ b/tests/unit/test_classifier.py
@@ -1,6 +1,7 @@
import numpy as np
import pybamm
import pytest
+from scipy import stats
import pybop
@@ -34,8 +35,7 @@ def problem(self):
parameter_values.update(
{
"R0 [Ohm]": pybop.Parameter(
- prior=pybop.Uniform(0.001, 0.1),
- bounds=[1e-4, 0.1],
+ distribution=stats.uniform(loc=0.001, scale=0.1 - 0.001)
)
}
)
diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py
index b02ab880b..c75ef6f97 100644
--- a/tests/unit/test_cost.py
+++ b/tests/unit/test_cost.py
@@ -29,8 +29,9 @@ def ground_truth(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.5, 0.01),
- bounds=[0.375, 0.625],
+ distribution=pybop.Gaussian(
+ truncated_at=[0.375, 0.625], mean=0.5, sigma=0.010
+ )
)
}
@@ -403,8 +404,10 @@ def test_weighted_fitting_cost(self, noisy_problem, parameters, dataset):
problem_4 = pybop.Problem(simulator, cost4)
weighted_4 = pybop.Problem(simulator, weighted_cost_4)
sigma = 0.01
- assert np.isfinite(cost4.parameters["Sigma for output 1"].prior.logpdf(sigma))
- assert np.isfinite(weighted_4.evaluate([0.5, sigma]).values)
+ assert np.isfinite(
+ cost4.parameters["Sigma for output 1"].distribution.logpdf(sigma)
+ )
+ assert np.isfinite(weighted_4([0.5, sigma]))
np.testing.assert_allclose(
weighted_4.evaluate([0.6, sigma]).values,
problem_1.evaluate([0.6]).values
diff --git a/tests/unit/test_priors.py b/tests/unit/test_distributions.py
similarity index 61%
rename from tests/unit/test_priors.py
rename to tests/unit/test_distributions.py
index 40de309a1..a921e5998 100644
--- a/tests/unit/test_priors.py
+++ b/tests/unit/test_distributions.py
@@ -4,9 +4,9 @@
import pybop
-class TestPriors:
+class TestDistributions:
"""
- A class to test the priors.
+ A class to test the distribution.
"""
pytestmark = pytest.mark.unit
@@ -24,18 +24,22 @@ def Exponential(self):
return pybop.Exponential(scale=1)
@pytest.fixture
- def JointPrior1(self, Gaussian, Uniform):
- return pybop.JointPrior(Gaussian, Uniform)
+ def JointDistribution1(self, Gaussian, Uniform):
+ return pybop.JointDistribution(Gaussian, Uniform)
@pytest.fixture
- def JointPrior2(self, Gaussian, Exponential):
- return pybop.JointPrior(Gaussian, Exponential)
-
- def test_base_prior(self):
- base = pybop.BasePrior()
- assert isinstance(base, pybop.BasePrior)
-
- def test_priors(self, Gaussian, Uniform, Exponential, JointPrior1, JointPrior2):
+ def JointDistribution2(self, Gaussian, Exponential):
+ return pybop.JointDistribution(Gaussian, Exponential)
+
+ def test_distribution_class(self):
+ base = pybop.Distribution()
+ assert isinstance(base, pybop.Distribution)
+ with pytest.raises(NotImplementedError):
+ base.logpdfS1(0.0)
+
+ def test_distributions(
+ self, Gaussian, Uniform, Exponential, JointDistribution1, JointDistribution2
+ ):
# Test pdf
np.testing.assert_allclose(Gaussian.pdf(0.5), 0.3989422804014327, atol=1e-4)
np.testing.assert_allclose(Uniform.pdf(0.5), 1, atol=1e-4)
@@ -57,10 +61,10 @@ def test_priors(self, Gaussian, Uniform, Exponential, JointPrior1, JointPrior2):
np.testing.assert_allclose(Exponential.cdf(1), 0.6321205588285577, atol=1e-4)
# Test logpdf
- assert JointPrior1.logpdf([0.5, 0.5]) == Gaussian.logpdf(0.5) + Uniform.logpdf(
+ assert JointDistribution1.logpdf([0.5, 0.5]) == Gaussian.logpdf(
0.5
- )
- assert JointPrior2.logpdf([0.5, 1]) == Gaussian.logpdf(
+ ) + Uniform.logpdf(0.5)
+ assert JointDistribution2.logpdf([0.5, 1]) == Gaussian.logpdf(
0.5
) + Exponential.logpdf(1)
@@ -79,46 +83,48 @@ def test_priors(self, Gaussian, Uniform, Exponential, JointPrior1, JointPrior2):
assert p == Exponential.logpdf(1)
assert dp == Exponential.logpdf(1)
- # Test JointPrior1.logpdfS1
- p, dp = JointPrior1.logpdfS1([0.5, 0.5])
+ # Test JointDistribution1.logpdfS1
+ p, dp = JointDistribution1.logpdfS1([0.5, 0.5])
assert p == Gaussian.logpdf(0.5) + Uniform.logpdf(0.5)
np.testing.assert_allclose(dp, np.array([0.0, 0.0]), atol=1e-4)
- # Test JointPrior.logpdfS1
- p, dp = JointPrior2.logpdfS1([0.5, 1])
+ # Test JointDistribution.logpdfS1
+ p, dp = JointDistribution2.logpdfS1([0.5, 1])
assert p == Gaussian.logpdf(0.5) + Exponential.logpdf(1)
np.testing.assert_allclose(
dp, np.array([0.0, Exponential.logpdf(1)]), atol=1e-4
)
- # Test JointPrior1 non-symmetric
+ # Test JointDistribution1 non-symmetric
with pytest.raises(AssertionError):
np.testing.assert_allclose(
- JointPrior1.logpdf([0.4, 0.5]),
- JointPrior1.logpdf([0.5, 0.4]),
+ JointDistribution1.logpdf([0.4, 0.5]),
+ JointDistribution1.logpdf([0.5, 0.4]),
atol=1e-4,
)
- # Test JointPrior2 non-symmetric
+ # Test JointDistribution2 non-symmetric
with pytest.raises(AssertionError):
np.testing.assert_allclose(
- JointPrior2.logpdf([0.4, 1]), JointPrior2.logpdf([1, 0.4]), atol=1e-4
+ JointDistribution2.logpdf([0.4, 1]),
+ JointDistribution2.logpdf([1, 0.4]),
+ atol=1e-4,
)
- # Test JointPrior with incorrect dimensions
+ # Test JointDistribution with incorrect dimensions
with pytest.raises(ValueError, match="Input x must have length 2, got 1"):
- JointPrior1.logpdf([0.4])
+ JointDistribution1.logpdf([0.4])
with pytest.raises(ValueError, match="Input x must have length 2, got 1"):
- JointPrior1.logpdfS1([0.4])
+ JointDistribution1.logpdfS1([0.4])
# Test properties
- assert Uniform.mean == (Uniform.upper - Uniform.lower) / 2
+ assert Uniform.mean() == (Uniform.upper - Uniform.lower) / 2
np.testing.assert_allclose(
- Uniform.sigma, (Uniform.upper - Uniform.lower) / (2 * np.sqrt(3)), atol=1e-8
+ Uniform.std(), (Uniform.upper - Uniform.lower) / (2 * np.sqrt(3)), atol=1e-8
)
- assert Exponential.mean == Exponential.scale
- assert Exponential.sigma == Exponential.scale
+ assert Exponential.mean() == Exponential.scale
+ assert Exponential.std() == Exponential.scale
def test_gaussian_rvs(self, Gaussian):
samples = Gaussian.rvs(size=500)
@@ -143,13 +149,13 @@ def test_exponential_rvs(self, Exponential):
mean = np.mean(samples)
assert abs(mean - 1) < 0.2
- def test_repr(self, Gaussian, Uniform, Exponential, JointPrior1):
- assert repr(Gaussian) == "Gaussian, loc: 0.5, scale: 1"
- assert repr(Uniform) == "Uniform, loc: 0, scale: 1"
+ def test_repr(self, Gaussian, Uniform, Exponential, JointDistribution1):
+ assert repr(Gaussian) == "Gaussian, mean: 0.5, standard deviation: 1.0"
+ assert repr(Uniform) == "Uniform, lower: 0, upper: 1"
assert repr(Exponential) == "Exponential, loc: 0, scale: 1"
assert (
- repr(JointPrior1)
- == "JointPrior(priors: [Gaussian, loc: 0.5, scale: 1, Uniform, loc: 0, scale: 1])"
+ repr(JointDistribution1)
+ == "JointDistribution(distributions: [Gaussian, mean: 0.5, standard deviation: 1.0; Uniform, lower: 0, upper: 1])"
)
def test_invalid_size(self, Gaussian, Uniform, Exponential):
@@ -160,12 +166,12 @@ def test_invalid_size(self, Gaussian, Uniform, Exponential):
with pytest.raises(ValueError):
Exponential.rvs(-1)
- def test_incorrect_composed_priors(self, Gaussian, Uniform):
+ def test_incorrect_composed_distributions(self, Gaussian, Uniform):
with pytest.raises(
- ValueError, match="All priors must be instances of BasePrior"
+ ValueError, match="All distributions must be instances of Distribution"
):
- pybop.JointPrior(Gaussian, Uniform, "string")
+ pybop.JointDistribution(Gaussian, Uniform, "string")
with pytest.raises(
- ValueError, match="All priors must be instances of BasePrior"
+ ValueError, match="All distributions must be instances of Distribution"
):
- pybop.JointPrior(Gaussian, Uniform, 0.5)
+ pybop.JointDistribution(Gaussian, Uniform, 0.5)
diff --git a/tests/unit/test_evaluation.py b/tests/unit/test_evaluation.py
index b9457cc9f..5e1ab5b45 100644
--- a/tests/unit/test_evaluation.py
+++ b/tests/unit/test_evaluation.py
@@ -1,6 +1,7 @@
import numpy as np
import pybamm
import pytest
+from scipy import stats
import pybop
@@ -29,14 +30,17 @@ def model(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.5, 0.01),
- bounds=[0.375, 0.625],
+ distribution=pybop.Gaussian(
+ 0.5,
+ 0.01,
+ truncated_at=[0.375, 0.625],
+ ),
transformation=pybop.ScaledTransformation(
coefficient=1 / 0.25, intercept=-0.375
),
),
"Positive electrode Bruggeman coefficient (electrode)": pybop.Parameter(
- prior=pybop.Gaussian(1.5, 0.1),
+ distribution=stats.norm(loc=1.5, scale=0.1),
transformation=pybop.LogTransformation(),
),
}
diff --git a/tests/unit/test_likelihoods.py b/tests/unit/test_likelihoods.py
index 9b5993c15..272121362 100644
--- a/tests/unit/test_likelihoods.py
+++ b/tests/unit/test_likelihoods.py
@@ -1,6 +1,7 @@
import numpy as np
import pybamm
import pytest
+from scipy import stats
import pybop
@@ -29,8 +30,11 @@ def ground_truth(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.5, 0.01),
- bounds=[0.375, 0.625],
+ distribution=pybop.Gaussian(
+ 0.5,
+ 0.01,
+ truncated_at=[0.375, 0.625],
+ )
)
}
@@ -112,7 +116,7 @@ def test_gaussian_log_likelihood(self, simulator, dataset):
assert grad_likelihood[0][1] <= 0
# Test construction with sigma as a Parameter
- sigma = pybop.Parameter(prior=pybop.Uniform(0.4, 0.6))
+ sigma = pybop.Parameter(stats.uniform(loc=0.4, scale=0.6 - 0.4))
likelihood = pybop.GaussianLogLikelihood(dataset, sigma0=sigma)
# Test invalid sigma
diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py
index 67c265076..98f201590 100644
--- a/tests/unit/test_optimisation.py
+++ b/tests/unit/test_optimisation.py
@@ -32,21 +32,26 @@ def dataset(self):
def one_parameter(self):
return {
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.5, 0.02),
- bounds=[0.48, 0.52],
- )
+ pybop.Gaussian(0.5, 0.02, truncated_at=(0.48, 0.52))
+ ),
}
@pytest.fixture
def two_parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.02),
- bounds=[0.58, 0.62],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.02,
+ truncated_at=[0.58, 0.62],
+ )
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.5, 0.05),
- bounds=[0.48, 0.52],
+ distribution=pybop.Gaussian(
+ 0.5,
+ 0.05,
+ truncated_at=[0.48, 0.52],
+ )
),
}
@@ -74,6 +79,41 @@ def two_param_problem(self, model, two_parameters, dataset):
cost = pybop.SumSquaredError(dataset)
return pybop.Problem(simulator, cost)
+ @pytest.fixture
+ def problem_no_bounds(self, model, one_parameter, dataset):
+ parameter_values = model.default_parameter_values
+ parameter_values.update(
+ {
+ "Positive electrode active material volume fraction": pybop.Parameter(
+ pybop.Gaussian(0.5, 0.02)
+ ),
+ }
+ )
+ simulator = pybop.pybamm.Simulator(
+ model, parameter_values=parameter_values, protocol=dataset
+ )
+ cost = pybop.SumSquaredError(dataset)
+ return pybop.Problem(simulator, cost)
+
+ @pytest.fixture
+ def two_param_problem_no_bounds(self, model, two_parameters, dataset):
+ parameter_values = model.default_parameter_values
+ parameter_values.update(
+ {
+ "Negative electrode active material volume fraction": pybop.Parameter(
+ distribution=pybop.Gaussian(0.6, 0.02)
+ ),
+ "Positive electrode active material volume fraction": pybop.Parameter(
+ distribution=pybop.Gaussian(0.5, 0.05)
+ ),
+ }
+ )
+ simulator = pybop.pybamm.Simulator(
+ model, parameter_values=parameter_values, protocol=dataset
+ )
+ cost = pybop.SumSquaredError(dataset)
+ return pybop.Problem(simulator, cost)
+
@pytest.mark.parametrize(
"optimiser, expected_name, sensitivities",
[
@@ -98,7 +138,12 @@ def two_param_problem(self, model, two_parameters, dataset):
],
)
def test_optimiser_classes(
- self, two_param_problem, optimiser, expected_name, sensitivities
+ self,
+ two_param_problem,
+ two_param_problem_no_bounds,
+ optimiser,
+ expected_name,
+ sensitivities,
):
# Test class construction
problem = two_param_problem
@@ -112,8 +157,7 @@ def test_optimiser_classes(
pybop.PSO
]:
# Test construction without bounds
- problem.parameters.remove_bounds()
- optim = optimiser(problem)
+ optim = optimiser(two_param_problem_no_bounds)
assert all(np.isinf(optim.problem.parameters.get_bounds()["lower"]))
assert all(np.isinf(optim.problem.parameters.get_bounds()["upper"]))
@@ -165,7 +209,7 @@ def check_multistart(optim, n_iters, multistarts):
multistart_optim = optimiser(problem, options=options)
check_multistart(multistart_optim, 6, 2)
- bounds = {"upper": [0.53], "lower": [0.47]}
+ bounds = {"upper": 0.53, "lower": 0.47}
if optimiser in [pybop.GradientDescent, pybop.AdamW, pybop.NelderMead]:
optim = optimiser(problem)
assert optim._optimiser._boundaries is None
@@ -173,11 +217,27 @@ def check_multistart(optim, n_iters, multistarts):
with pytest.raises(
ValueError, match="Either all bounds or no bounds must be set"
):
- problem.parameters.update(bounds={"upper": [np.inf], "lower": [0.57]})
+ problem.parameters[
+ "Positive electrode active material volume fraction"
+ ] = pybop.Parameter(
+ pybop.Gaussian(0.5, 0.02, truncated_at=(0.57, np.inf))
+ )
optimiser(problem)
- problem.parameters.update(bounds=bounds)
+ problem.parameters["Positive electrode active material volume fraction"] = (
+ pybop.Parameter(
+ pybop.Gaussian(
+ 0.5, 0.02, truncated_at=(bounds["lower"], bounds["upper"])
+ )
+ )
+ )
elif issubclass(optimiser, pybop.BasePintsOptimiser):
- problem.parameters.update(bounds=bounds)
+ problem.parameters["Positive electrode active material volume fraction"] = (
+ pybop.Parameter(
+ pybop.Gaussian(
+ 0.5, 0.02, truncated_at=(bounds["lower"], bounds["upper"])
+ )
+ )
+ )
optim = optimiser(problem)
assert optim._optimiser._boundaries is not None
@@ -224,7 +284,12 @@ def check_multistart(optim, n_iters, multistarts):
optim.optimiser.tell([0.1])
if optimiser is pybop.GradientDescent:
- assert optim.optimiser.learning_rate() == 0.02
+ assert (
+ optim.optimiser.learning_rate()
+ == problem.parameters[
+ "Positive electrode active material volume fraction"
+ ].distribution.std()
+ )
optim.optimiser.set_learning_rate(0.1)
assert optim.optimiser.learning_rate() == 0.1
assert optim.optimiser.n_hyper_parameters() == 1
@@ -308,18 +373,30 @@ def check_multistart(optim, n_iters, multistarts):
assert optim._logger.x_model[0] == x0_new
assert optim._logger.x_model[-1] != x0
- def test_cuckoo_no_bounds(self, problem):
- problem.parameters.remove_bounds()
+ def test_cuckoo_no_bounds(self, problem_no_bounds):
options = pybop.PintsOptions(max_iterations=1)
- optim = pybop.CuckooSearch(problem, options=options)
+ optim = pybop.CuckooSearch(problem_no_bounds, options=options)
optim.run()
assert all(np.isinf(optim.problem.parameters.get_bounds()["lower"]))
assert all(np.isinf(optim.problem.parameters.get_bounds()["upper"]))
- def test_randomsearch_bounds(self, two_param_problem):
+ def test_randomsearch_bounds(self, two_param_problem, two_param_problem_no_bounds):
# Test clip_candidates with bound
bounds = {"upper": [0.62, 0.54], "lower": [0.58, 0.46]}
- two_param_problem.parameters.update(bounds=bounds)
+ two_param_problem.parameters[
+ "Negative electrode active material volume fraction"
+ ] = pybop.Parameter(
+ distribution=pybop.Gaussian(
+ 0.6, 0.02, truncated_at=(bounds["lower"][0], bounds["upper"][0])
+ )
+ )
+ two_param_problem.parameters[
+ "Positive electrode active material volume fraction"
+ ] = pybop.Parameter(
+ distribution=pybop.Gaussian(
+ 0.5, 0.05, truncated_at=(bounds["lower"][1], bounds["upper"][1])
+ )
+ )
options = pybop.PintsOptions(max_iterations=1)
optim = pybop.RandomSearch(two_param_problem, options=options)
candidates = np.array([[0.57, 0.55], [0.63, 0.44]])
@@ -328,18 +405,16 @@ def test_randomsearch_bounds(self, two_param_problem):
assert np.allclose(clipped_candidates, expected_clipped)
# Test clip_candidates without bound
- two_param_problem.parameters.remove_bounds()
- optim = pybop.RandomSearch(two_param_problem, options=options)
+ optim = pybop.RandomSearch(two_param_problem_no_bounds, options=options)
candidates = np.array([[0.57, 0.52], [0.63, 0.58]])
clipped_candidates = optim.optimiser.clip_candidates(candidates)
assert np.allclose(clipped_candidates, candidates)
- def test_randomsearch_ask_without_bounds(self, two_param_problem):
+ def test_randomsearch_ask_without_bounds(self, two_param_problem_no_bounds):
# Initialize optimiser without boundaries
- two_param_problem.parameters.remove_bounds()
- two_param_problem.parameters.update(initial_values=[0.6, 0.55])
+ two_param_problem_no_bounds.parameters.update(initial_values=[0.6, 0.55])
options = pybop.PintsOptions(max_iterations=1)
- optim = pybop.RandomSearch(two_param_problem, options=options)
+ optim = pybop.RandomSearch(two_param_problem_no_bounds, options=options)
# Set population size, generate candidates
optim.set_population_size(2)
@@ -520,3 +595,25 @@ def test_optimisation_result(self, problem):
assert result.n_iterations in result._n_iterations
assert result.n_evaluations in result._n_evaluations
assert result.x0 in result._x0
+
+ def test_multistart_fails_without_distribution(self, model, dataset):
+ # parameter with inifinite bound (no distribution)
+ parameter_values = model.default_parameter_values
+ param = pybop.Parameter(bounds=(0.5, np.inf), initial_value=0.8)
+ parameter_values.update(
+ {"Positive electrode active material volume fraction": param}
+ )
+ simulator = pybop.pybamm.Simulator(
+ model, parameter_values=parameter_values, protocol=dataset
+ )
+ cost = pybop.SumSquaredError(dataset)
+ problem = pybop.Problem(simulator, cost)
+
+ # Setup optimiser
+ options = pybop.PintsOptions(max_iterations=1, multistart=3)
+ optim = pybop.XNES(problem, options=options)
+
+ with pytest.raises(
+ RuntimeError, match="Distributions must be provided for multi-start"
+ ):
+ optim.run()
diff --git a/tests/unit/test_parameters.py b/tests/unit/test_parameters.py
index d17ed2ee8..095f3d73b 100644
--- a/tests/unit/test_parameters.py
+++ b/tests/unit/test_parameters.py
@@ -1,5 +1,6 @@
import numpy as np
import pytest
+from scipy import stats
import pybop
from pybop.parameters.parameter import (
@@ -19,8 +20,11 @@ class TestParameter:
@pytest.fixture
def parameter(self):
return pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.02),
- bounds=[0.375, 0.7],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.02,
+ truncated_at=[0.375, 0.7],
+ ),
initial_value=0.6,
)
@@ -29,31 +33,35 @@ def name(self):
return "Negative electrode active material volume fraction"
def test_parameter_construction(self, parameter):
- assert parameter.prior.mean == 0.6
- assert parameter.prior.sigma == 0.02
assert parameter.bounds == [0.375, 0.7]
assert parameter.initial_value == 0.6
+ assert parameter() == 0.6
+
+ # test error if bounds and distribution
+ with pytest.raises(
+ ParameterError,
+ match="Bounds can only be set if no distribution is provided. If a bounded distribution is needed, please ensure the distribution itself is bounded.",
+ ):
+ pybop.Parameter(distribution=stats.norm(0.3, 0.1), bounds=(0.4, 0.8))
def test_parameter_repr(self, parameter):
assert (
repr(parameter)
- == "Parameter: Prior: Gaussian, loc: 0.6, scale: 0.02 \n Bounds: [0.375, 0.7]"
+ == f"Parameter - Distribution: Gaussian, mean: {parameter.distribution.mean()}, standard deviation: {parameter.distribution.std()}, Bounds: (0.375, 0.7), Initial value: 0.6"
)
def test_parameter_sampling(self, parameter):
- samples = parameter.sample_from_prior(n_samples=500)
+ samples = parameter.sample_from_distribution(n_samples=500)
assert (samples >= 0.375).all() and (samples <= 0.7).all()
+ parameter = pybop.Parameter(bounds=(0, np.inf))
+ assert parameter.sample_from_distribution() is None
+
def test_parameter_update(self, parameter):
# Test initial value update
parameter.update_initial_value(value=0.654)
assert parameter.initial_value == 0.654
- def test_parameter_margin(self, parameter):
- assert parameter._margin == 1e-4
- parameter._set_margin(margin=1e-3)
- assert parameter._margin == 1e-3
-
def test_no_bounds(self, name):
parameter = pybop.Parameter()
assert parameter.bounds is None
@@ -64,12 +72,6 @@ def test_no_bounds(self, name):
assert not np.isfinite(list(bounds.values())).all()
def test_invalid_inputs(self, parameter):
- # Test error with invalid value
- with pytest.raises(
- ParameterValidationError, match="Margin must be between 0 and 1"
- ):
- parameter._set_margin(margin=-1)
-
# Test error with opposite bounds
with pytest.raises(
ParameterValidationError, match="must be less than upper bound"
@@ -78,8 +80,11 @@ def test_invalid_inputs(self, parameter):
def test_sample_initial_values(self):
parameter = pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.02),
- bounds=[0.375, 0.7],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.02,
+ truncated_at=[0.375, 0.7],
+ )
)
sample = parameter._initial_value
assert (sample >= 0.375) and (sample <= 0.7)
@@ -95,8 +100,11 @@ class TestParameters:
@pytest.fixture
def parameter(self):
return pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.02),
- bounds=[0.375, 0.7],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.02,
+ truncated_at=[0.375, 0.7],
+ ),
initial_value=0.6,
)
@@ -120,8 +128,11 @@ def test_parameters_construction(self, name, parameter):
{
name: parameter,
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.02),
- bounds=[0.375, 0.7],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.02,
+ truncated_at=[0.375, 0.7],
+ ),
initial_value=0.6,
),
}
@@ -131,6 +142,14 @@ def test_parameters_construction(self, name, parameter):
with pytest.raises(ParameterError, match="already exists"):
params.add(name, parameter)
+ # setting parameters with wrong input
+ with pytest.raises(ParameterNotFoundError, match="not found"):
+ params["not a parameter"] = pybop.Parameter(initial_value=0.8)
+ with pytest.raises(
+ TypeError, match="Paremeter must be of type pybop.ParemterInfo"
+ ):
+ params[name] = pybop.Gaussian(0.5, 0.02)
+
params.remove(name=name)
with pytest.raises(ParameterNotFoundError, match="not found"):
params.remove(name="Negative electrode active material volume fraction")
@@ -180,9 +199,12 @@ def test_parameters_transformation(self, name):
params = pybop.Parameters(
{
name: pybop.Parameter(
- prior=pybop.Gaussian(0.01, 0.2),
+ distribution=pybop.Gaussian(
+ 0.01,
+ 0.2,
+ truncated_at=[-1, 1],
+ ),
transformation=pybop.LogTransformation(),
- bounds=[-1, 1],
)
}
)
@@ -192,28 +214,23 @@ def test_parameters_transformation(self, name):
):
params.get_bounds(transformed=True)
- def test_parameters_update(self, name, parameter):
- params = pybop.Parameters({name: parameter})
- params.update(bounds=[[0.38, 0.68]])
- assert parameter.bounds == [0.38, 0.68]
- params.update(bounds=dict(lower=[0.37], upper=[0.7]))
- assert parameter.bounds == [0.37, 0.7]
-
def test_parameters_sampling(self, name, parameter):
parameter._transformation = pybop.ScaledTransformation(
coefficient=0.2, intercept=-1
)
params = pybop.Parameters({name: parameter})
params.construct_transformation()
- samples = params.sample_from_priors(n_samples=500, transformed=True)
+ samples = params.sample_from_distributions(n_samples=500, transformed=True)
assert (samples >= -0.125).all() and (samples <= -0.06).all()
parameter._transformation = None
- def test_get_sigma(self, name, parameter):
+ def test_get_sigma(self, name):
+ parameter = pybop.Parameter(stats.norm(loc=0.6, scale=0.02))
params = pybop.Parameters({name: parameter})
- assert params.get_sigma0() == [0.02]
+ assert params.get_sigma0() == pytest.approx([0.02])
- parameter._prior = None
+ parameter = pybop.Parameter(bounds=(0.375, 0.7))
+ parameter._distribution = None
params = pybop.Parameters({name: parameter})
assert params.get_sigma0() == [
0.05 * (parameter.bounds[1] - parameter.bounds[0])
@@ -227,6 +244,11 @@ def test_initial_values_without_attributes(self):
with pytest.raises(ParameterError, match="has no initial value"):
parameter.get_initial_values()
+ def test_get_initial_values_if_none(self, name, parameter):
+ params = pybop.Parameters({name: parameter})
+ params[name]._initial_value = None
+ assert params.get_initial_values() is not None
+
def test_parameters_init(self, name, parameter):
# Error if parameters not dictionary or pybop.Parameters
with pytest.raises(
@@ -248,5 +270,5 @@ def test_parameters_repr(self, name, parameter):
params = pybop.Parameters({name: parameter})
assert (
repr(params)
- == "Parameters(1):\n Negative electrode active material volume fraction: prior= Gaussian, loc: 0.6, scale: 0.02, bounds=[0.375, 0.7]"
+ == f"Parameters(1):\n Negative electrode active material volume fraction: Parameter - Distribution: Gaussian, mean: {parameter.distribution.mean()}, standard deviation: {parameter.distribution.std()}, Bounds: (0.375, 0.7), Initial value: 0.6"
)
diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py
index 6cb9e2d35..0f1ad654e 100644
--- a/tests/unit/test_plots.py
+++ b/tests/unit/test_plots.py
@@ -33,15 +33,38 @@ def model(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.05),
- bounds=[0.5, 0.8],
+ distribution=pybop.Gaussian(
+ 0.68,
+ 0.05,
+ truncated_at=[0.5, 0.8],
+ ),
+ transformation=pybop.ScaledTransformation(
+ coefficient=1 / 0.3, intercept=-0.5
+ ),
+ ),
+ "Positive electrode active material volume fraction": pybop.Parameter(
+ distribution=pybop.Gaussian(
+ 0.58,
+ 0.05,
+ truncated_at=[0.4, 0.7],
+ ),
+ transformation=pybop.ScaledTransformation(
+ coefficient=1 / 0.3, intercept=-0.4
+ ),
+ ),
+ }
+
+ @pytest.fixture
+ def parameters_no_bounds(self):
+ return {
+ "Negative electrode active material volume fraction": pybop.Parameter(
+ distribution=pybop.Gaussian(0.68, 0.05),
transformation=pybop.ScaledTransformation(
coefficient=1 / 0.3, intercept=-0.5
),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.58, 0.05),
- bounds=[0.4, 0.7],
+ distribution=pybop.Gaussian(0.58, 0.05),
transformation=pybop.ScaledTransformation(
coefficient=1 / 0.3, intercept=-0.4
),
@@ -79,6 +102,16 @@ def fitting_problem(self, model, parameters, dataset):
cost = pybop.SumSquaredError(dataset)
return pybop.Problem(simulator, cost)
+ @pytest.fixture
+ def fitting_problem_no_bounds(self, model, parameters_no_bounds, dataset):
+ parameter_values = model.default_parameter_values
+ parameter_values.update(parameters_no_bounds)
+ simulator = pybop.pybamm.Simulator(
+ model, parameter_values=parameter_values, protocol=dataset
+ )
+ cost = pybop.SumSquaredError(dataset)
+ return pybop.Problem(simulator, cost)
+
@pytest.fixture
def experiment(self):
return pybamm.Experiment(["Discharge at 1C for 10 minutes (20 second period)"])
@@ -103,16 +136,15 @@ def test_problem_plots(self, fitting_problem, design_problem):
fitting_problem, inputs=fitting_problem.parameters.to_dict([0.6, 0.6])
)
- def test_cost_plots(self, fitting_problem):
+ def test_cost_plots(self, fitting_problem, fitting_problem_no_bounds):
# Test plot of Cost objects
pybop.plot.contour(fitting_problem, gradient=True, steps=5)
pybop.plot.contour(fitting_problem, gradient=True, steps=5, transformed=True)
# Test without bounds
- fitting_problem.parameters.remove_bounds()
with pytest.raises(ValueError, match="All parameters require bounds for plot."):
- pybop.plot.contour(fitting_problem, steps=5)
+ pybop.plot.contour(fitting_problem_no_bounds, steps=5)
# Test with bounds
pybop.plot.contour(
@@ -199,9 +231,12 @@ def test_contour_incorrect_number_of_parameters(self, model, dataset):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.68, 0.05),
- bounds=[0.5, 0.8],
- ),
+ distribution=pybop.Gaussian(
+ 0.68,
+ 0.05,
+ truncated_at=[0.5, 0.8],
+ ),
+ )
}
)
simulator = pybop.pybamm.Simulator(
@@ -218,12 +253,18 @@ def test_contour_incorrect_number_of_parameters(self, model, dataset):
parameter_values.update(
{
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.58, 0.05),
- bounds=[0.4, 0.7],
+ pybop.Gaussian(
+ 0.58,
+ 0.05,
+ truncated_at=[0.4, 0.7],
+ ),
),
"Positive particle radius [m]": pybop.Parameter(
- prior=pybop.Gaussian(4.8e-06, 0.05e-06),
- bounds=[4e-06, 6e-06],
+ distribution=pybop.Gaussian(
+ 4.8e-06,
+ 0.05e-06,
+ truncated_at=[4e-06, 6e-06],
+ ),
),
}
)
@@ -243,9 +284,12 @@ def test_nyquist(self):
parameter_values.update(
{
"Positive electrode thickness [m]": pybop.Parameter(
- prior=pybop.Gaussian(60e-6, 1e-6),
- bounds=[10e-6, 80e-6],
- )
+ distribution=pybop.Gaussian(
+ 60e-6,
+ 1e-6,
+ truncated_at=[10e-6, 80e-6],
+ ),
+ ),
}
)
diff --git a/tests/unit/test_posterior.py b/tests/unit/test_posterior.py
index 1b6ddd9a0..d5ca85c42 100644
--- a/tests/unit/test_posterior.py
+++ b/tests/unit/test_posterior.py
@@ -33,8 +33,11 @@ def parameter_values(self, model, ground_truth):
def parameter(self, ground_truth):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.5, 0.01),
- bounds=[0.375, 0.625],
+ distribution=pybop.Gaussian(
+ 0.5,
+ 0.01,
+ truncated_at=[0.375, 0.625],
+ ),
initial_value=ground_truth,
)
}
@@ -83,6 +86,22 @@ def test_log_posterior_construction(self, simulator, parameter, likelihood, prio
assert problem.parameters[key] is parameter[key]
assert problem._cost.parameters is problem.parameters
+ # Test construction with Parameter
+ posterior = pybop.LogPosterior(
+ likelihood, prior=pybop.Parameter(distribution=prior)
+ )
+ problem = pybop.Problem(
+ simulator, posterior
+ ) # uses posterior.set_joint_prior()
+ assert posterior.joint_prior == prior
+
+ with pytest.raises(
+ TypeError,
+ match="All priors must either be of type pybop.Parameter, pybop.Distribution or scipy.stats.distributions.rv_frozen",
+ ):
+ posterior = pybop.LogPosterior(likelihood, prior=st.norm)
+ posterior.set_joint_prior()
+
def test_log_posterior_construction_no_prior(self, simulator, likelihood):
# Test log posterior construction without prior
posterior = pybop.LogPosterior(likelihood, prior=None)
@@ -90,10 +109,10 @@ def test_log_posterior_construction_no_prior(self, simulator, likelihood):
problem._cost.set_joint_prior()
assert problem._cost.joint_prior is not None
- assert isinstance(problem._cost.joint_prior, pybop.JointPrior)
+ assert isinstance(problem._cost.joint_prior, pybop.JointDistribution)
- for i, p in enumerate(problem._cost.joint_prior._priors):
- assert p == problem.parameters.priors()[i]
+ for i, p in enumerate(problem.parameters.distributions()):
+ assert p == problem._cost.joint_prior._distributions[i]
@pytest.fixture
def problem(self, simulator, likelihood, prior):
diff --git a/tests/unit/test_problem.py b/tests/unit/test_problem.py
index 88ad5400c..81adefee7 100644
--- a/tests/unit/test_problem.py
+++ b/tests/unit/test_problem.py
@@ -22,12 +22,18 @@ def model(self):
def parameters(self):
return {
"Negative particle radius [m]": pybop.Parameter(
- prior=pybop.Gaussian(2e-05, 0.1e-5),
- bounds=[1e-6, 5e-5],
+ distribution=pybop.Gaussian(
+ 2e-05,
+ 0.1e-5,
+ truncated_at=[1e-6, 5e-5],
+ )
),
"Positive particle radius [m]": pybop.Parameter(
- prior=pybop.Gaussian(0.5e-05, 0.1e-5),
- bounds=[1e-6, 5e-5],
+ distribution=pybop.Gaussian(
+ 0.5e-05,
+ 0.1e-5,
+ truncated_at=[1e-6, 5e-5],
+ )
),
}
diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py
index eb75f736a..4d832e655 100644
--- a/tests/unit/test_sampling.py
+++ b/tests/unit/test_sampling.py
@@ -49,12 +49,18 @@ def dataset(self):
def parameters(self):
return {
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.2),
- bounds=[0.58, 0.62],
+ distribution=pybop.Gaussian(
+ 0.6,
+ 0.2,
+ truncated_at=[0.58, 0.62],
+ ),
),
"Positive electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.55, 0.05),
- bounds=[0.53, 0.57],
+ distribution=pybop.Gaussian(
+ 0.55,
+ 0.05,
+ truncated_at=[0.53, 0.57],
+ ),
),
}
@@ -72,7 +78,7 @@ def posterior_problem(self, model, parameters, dataset):
likelihood = pybop.GaussianLogLikelihoodKnownSigma(dataset, sigma0=0.01)
prior1 = pybop.Gaussian(0.7, 0.02)
prior2 = pybop.Gaussian(0.6, 0.02)
- composed_prior = pybop.JointPrior(prior1, prior2)
+ composed_prior = pybop.JointDistribution(prior1, prior2)
posterior = pybop.LogPosterior(likelihood, prior=composed_prior)
return pybop.Problem(simulator, posterior)
@@ -165,8 +171,7 @@ def test_single_parameter_sampling(self, model, dataset, MCMC, n_chains):
parameter_values.update(
{
"Negative electrode active material volume fraction": pybop.Parameter(
- prior=pybop.Gaussian(0.6, 0.2),
- bounds=[0.58, 0.62],
+ distribution=pybop.Gaussian(0.6, 0.2, truncated_at=[0.58, 0.62])
)
}
)
diff --git a/tests/unit/test_simulator.py b/tests/unit/test_simulator.py
new file mode 100644
index 000000000..d6aeae421
--- /dev/null
+++ b/tests/unit/test_simulator.py
@@ -0,0 +1,55 @@
+import pytest
+
+import pybop
+from pybop.simulators.base_simulator import BaseSimulator
+
+
+class TestSimulator:
+ """
+ A class to test the BaseSimulator class.
+ """
+
+ pytestmark = pytest.mark.unit
+
+ def test_parameter_errors_constructor(self):
+ params = {
+ "Negative particle radius [m]": pybop.Gaussian(
+ 2e-05,
+ 0.1e-5,
+ truncated_at=[1e-6, 5e-5],
+ ),
+ "Positive particle radius [m]": pybop.Gaussian(
+ 0.5e-05,
+ 0.1e-5,
+ truncated_at=[1e-6, 5e-5],
+ ),
+ }
+
+ with pytest.raises(
+ TypeError,
+ match="All elements in the list must be pybop.Parameter objects.",
+ ):
+ BaseSimulator(params)
+
+ params = [
+ pybop.Parameter(
+ pybop.Gaussian(
+ 2e-05,
+ 0.1e-5,
+ truncated_at=[1e-6, 5e-5],
+ )
+ ),
+ pybop.Parameter(
+ pybop.Gaussian(
+ 2e-05,
+ 0.1e-5,
+ truncated_at=[1e-6, 5e-5],
+ )
+ ),
+ ]
+
+ with pytest.raises(
+ TypeError,
+ match="The input parameters must be a a dictionary of Parameter objects or a pybop.Parameters object.",
+ ):
+ BaseSimulator(params)
From 6b9b280fec9ed37bf8f81ce388935e93108f8289 Mon Sep 17 00:00:00 2001
From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com>
Date: Mon, 8 Dec 2025 14:27:45 +0000
Subject: [PATCH 06/37] Update for new PyProBE release (#852)
---
pybop/_dataset.py | 6 +++---
pyproject.toml | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/pybop/_dataset.py b/pybop/_dataset.py
index 4cf0a6359..7cdf41953 100644
--- a/pybop/_dataset.py
+++ b/pybop/_dataset.py
@@ -15,7 +15,7 @@ def get(
"""Get result data as numpy ndarray"""
@property
- def column_list(self) -> list[str]:
+ def columns(self) -> list[str]:
"""List of column data"""
@@ -219,8 +219,8 @@ def import_pyprobe_result(
for i, col in enumerate(pybop_columns):
if (
pyprobe_columns[i] == "Cycle"
- and "Cycle" not in result.column_list
- and "Step" in result.column_list
+ and "Cycle" not in result.columns
+ and "Step" in result.columns
):
warnings.warn(
"No cycle information present. Cycles will be inferred from the step numbers.",
diff --git a/pyproject.toml b/pyproject.toml
index c58206c05..262da1bab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -63,7 +63,7 @@ bpx = [
]
pyprobe = [
- "PyProBE-Data;python_version>='3.11'"
+ "PyProBE-Data>=2.5.0;python_version>='3.11'"
]
all = ["pybop[plot,scifem,bpx,pyprobe]"]
From b843f6769992f591d18bf32467b268c3f518c52a Mon Sep 17 00:00:00 2001
From: NicolaCourtier <45851982+NicolaCourtier@users.noreply.github.com>
Date: Mon, 8 Dec 2025 15:01:39 +0000
Subject: [PATCH 07/37] Add JOSS paper citation, code and figures (#851)
* Add JOSS template and compilation action
* Increment the upload action
* Add workflow dispatch trigger
* Fix typo
* Update ref format
* Add sketch
* Add architecture figure
* Add refs
* Add table
* Update tables
* Add review refs
* Add some equations
* Add ref
* Update Omega and table refs
* Minor formatting
* updt summary, tables, statement of need
* fix: feedback from initial internal review
* add pdf asset for JOSS inclusion
* small additions, test JOSS CI on pdf figure rendering
* updates to statement of need, architecture
* update tables, monte carlo additions
* Minor updates and formatting
* Pre-commit
* Add joss plotting scripts
* Add example plotting scripts and plots
* Add example design plots
* Add example plots to paper
* Update figures and text.
* feat: adds multi-dimensional learning rate gradient descent implementation
* Update plots, article, adds shell script for plot combination
* adds contour figure, shell updates, clean-up
* feat: adds kwargs to plots, effective sample size has option combined samples arg
* feat: improve contour and surface plots, add x0 entry to optimiser log
* plots: update final position legend entry
* Updates contour plots, adds MCMC sampling to param_plots, fixes typos
* Updates and typo to article, adds posterior plot, updates design plot, w/ corresponding code
* proofreading, update parameterisation example for RMSE cost, align figure styles
* update heuristic parameter convergence plot, add tex preprint format.
* adds numerical impedance predictions, figs, citation. updts nyquist grids,
* Adds Agriya w/ correct affiliation, updts tex pre-print
* Fix typos, update table format
* Update paper.md
Dave's edits - up to the end of the first paragraph in the 'architecture' section
* fixes: internal review feedback, minus figures
* Edits to paper (#578)
* Edits to paper
* Add citation
* feat: updates figures, contour plot into plotly subplots.
* feat: align object name method, adds plotly express to PlotlyManager,
* Update paper.md
minor tweaks up to the end of 'statement of need'
* Update paper.md
Dave changes up to 'background'
* Update paper.md
Dave changes up to "next, we demonstrate.."
* Update paper.md
text changes until 'design optimisation'
* Update paper.md
more of dave's changes; minor things plus removed fig 1
* adds percentile to posterior figure, rebuilds pdf.
* Update paper.md
Dave's edits until the end of fig 7
* Update paper.md
end of editing for now
* Updates from review, adds bib entries, improves design plot
* update and vectorise figures.
* Minor clarifications
* Updates figures, /joss directory -> /papers
* Restores workflow, MCMC table
* Update Agriya's affiliation (#748)
* feat: remove redudant sections post restructure
* feat: updates for restructured arch
* feat: grammar, typos, etc.
* refactor: updates to architecture section, update architecture figure
* fix: paper build workflow
* feat: rewrites for conciseness, update architecture figure
* Clean up bib
* Update text
* Update diagram
* Update contributors link
* Remove table 1
* Update figures
* Update table 2
* Align figures
* Update paper.pdf
* Refine text in paper.md for clarity and consistency
edits up to table 1
* Fix formatting and improve clarity in paper.md
changes to end of table 2
* Refine terminology and descriptions in paper.md
Updated terminology for clarity and precision in the paper.
* Larger figure labels
* Negative sign
* Update paper.pdf
* Add equal-contrib field for authors
* Rename Quansight PBC to Quansight (#841)
* Re-run for consistency with release
* Update UK to United Kingdom in affiliations
* Update CITATION.cff
* Add JOSS badge
* Remove old versions
* Delete PyBOP-high-level.pdf
* Remove Plotly express
* Update for new PyProBE release
---------
Co-authored-by: Brady Planden
Co-authored-by: David Howey
Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com>
Co-authored-by: bradyplanden
---
.github/workflows/draft-pdf.yml | 31 +
.gitignore | 3 +
CITATION.cff | 83 +-
README.md | 1 +
papers/joss/design_plots.py | 164 +++
.../joss/figures/PyBOP_components.drawio.pdf | Bin 0 -> 73630 bytes
.../joss/figures/combined/contour_subplot.pdf | Bin 0 -> 164478 bytes
papers/joss/figures/combined/converge.pdf | Bin 0 -> 27681 bytes
papers/joss/figures/combined/design.pdf | Bin 0 -> 33369 bytes
papers/joss/figures/combined/impedance.pdf | Bin 0 -> 27072 bytes
.../combined/optimisers_parameters.pdf | Bin 0 -> 106468 bytes
papers/joss/figures/combined/posteriors.pdf | Bin 0 -> 252802 bytes
.../joss/figures/combined/sim-landscape.pdf | Bin 0 -> 33094 bytes
.../individual/convergence_maximising.pdf | Bin 0 -> 15783 bytes
.../individual/convergence_minimising.pdf | Bin 0 -> 14567 bytes
.../figures/individual/design_gravimetric.pdf | Bin 0 -> 18574 bytes
.../figures/individual/design_prediction.pdf | Bin 0 -> 17983 bytes
.../individual/evolution_parameters.pdf | Bin 0 -> 46660 bytes
.../individual/gradient_parameters.pdf | Bin 0 -> 24990 bytes
.../individual/heuristic_parameters.pdf | Bin 0 -> 40448 bytes
.../figures/individual/impedance_contour.pdf | Bin 0 -> 17945 bytes
.../figures/individual/impedance_spectrum.pdf | Bin 0 -> 12489 bytes
papers/joss/figures/individual/landscape.pdf | Bin 0 -> 19062 bytes
papers/joss/figures/individual/simulation.pdf | Bin 0 -> 17406 bytes
papers/joss/image_combine.sh | 9 +
papers/joss/paper.bib | 380 ++++++
papers/joss/paper.md | 188 +++
papers/joss/param_plots.py | 1028 +++++++++++++++++
pybop/plot/nyquist.py | 26 +-
29 files changed, 1863 insertions(+), 50 deletions(-)
create mode 100644 .github/workflows/draft-pdf.yml
create mode 100644 papers/joss/design_plots.py
create mode 100644 papers/joss/figures/PyBOP_components.drawio.pdf
create mode 100644 papers/joss/figures/combined/contour_subplot.pdf
create mode 100644 papers/joss/figures/combined/converge.pdf
create mode 100644 papers/joss/figures/combined/design.pdf
create mode 100644 papers/joss/figures/combined/impedance.pdf
create mode 100644 papers/joss/figures/combined/optimisers_parameters.pdf
create mode 100644 papers/joss/figures/combined/posteriors.pdf
create mode 100644 papers/joss/figures/combined/sim-landscape.pdf
create mode 100644 papers/joss/figures/individual/convergence_maximising.pdf
create mode 100644 papers/joss/figures/individual/convergence_minimising.pdf
create mode 100644 papers/joss/figures/individual/design_gravimetric.pdf
create mode 100644 papers/joss/figures/individual/design_prediction.pdf
create mode 100644 papers/joss/figures/individual/evolution_parameters.pdf
create mode 100644 papers/joss/figures/individual/gradient_parameters.pdf
create mode 100644 papers/joss/figures/individual/heuristic_parameters.pdf
create mode 100644 papers/joss/figures/individual/impedance_contour.pdf
create mode 100644 papers/joss/figures/individual/impedance_spectrum.pdf
create mode 100644 papers/joss/figures/individual/landscape.pdf
create mode 100644 papers/joss/figures/individual/simulation.pdf
create mode 100755 papers/joss/image_combine.sh
create mode 100644 papers/joss/paper.bib
create mode 100644 papers/joss/paper.md
create mode 100644 papers/joss/param_plots.py
diff --git a/.github/workflows/draft-pdf.yml b/.github/workflows/draft-pdf.yml
new file mode 100644
index 000000000..7ef4b7759
--- /dev/null
+++ b/.github/workflows/draft-pdf.yml
@@ -0,0 +1,31 @@
+name: JossPaperCompilation
+
+on:
+ push:
+ paths:
+ - papers/joss/**
+ pull_request:
+ paths:
+ - 'papers/joss/**'
+ workflow_dispatch:
+jobs:
+ paper:
+ runs-on: ubuntu-latest
+ name: Paper Draft
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Build draft PDF
+ uses: openjournals/openjournals-draft-action@master
+ with:
+ journal: joss
+ # This should be the path to the paper within your repo.
+ paper-path: papers/joss/paper.md
+ - name: Upload
+ uses: actions/upload-artifact@v4
+ with:
+ name: paper
+ # This is the output path where Pandoc will write the compiled
+ # PDF. Note, this should be the same directory as the input
+ # paper.md
+ path: papers/joss/paper.pdf
diff --git a/.gitignore b/.gitignore
index b2123edf4..a2b444c1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -323,3 +323,6 @@ results/
# Pycharm
*.idea/
+
+# JOSS
+jats/
diff --git a/CITATION.cff b/CITATION.cff
index 1f7be5c7a..fcadd20ac 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -1,35 +1,62 @@
-cff-version: 1.2.0
-title: 'PyBOP: A Python package for battery model optimisation and parameterisation'
-message: >-
- If you use this software, please cite the article below.
+cff-version: "1.2.0"
authors:
- - given-names: Brady
- family-names: Planden
+- family-names: Planden
+ given-names: Brady
+ orcid: "https://orcid.org/0000-0002-1082-9125"
+- family-names: Courtier
+ given-names: Nicola E.
+ orcid: "https://orcid.org/0000-0002-5714-1096"
+- family-names: Robinson
+ given-names: Martin
+ orcid: "https://orcid.org/0000-0002-1572-6782"
+- family-names: Khetarpal
+ given-names: Agriya
+ orcid: "https://orcid.org/0000-0002-1112-1786"
+- family-names: Planella
+ given-names: Ferran Brosa
+ orcid: "https://orcid.org/0000-0001-6363-2812"
+- family-names: Howey
+ given-names: David A.
+ orcid: "https://orcid.org/0000-0002-0620-3955"
+contact:
+- family-names: Howey
+ given-names: David A.
+ orcid: "https://orcid.org/0000-0002-0620-3955"
+doi: 10.5281/zenodo.17711656
+message: If you use this software, please cite our article in the
+ Journal of Open Source Software.
+preferred-citation:
+ authors:
+ - family-names: Planden
+ given-names: Brady
orcid: "https://orcid.org/0000-0002-1082-9125"
- - given-names: Nicola E.
- family-names: Courtier
+ - family-names: Courtier
+ given-names: Nicola E.
orcid: "https://orcid.org/0000-0002-5714-1096"
- - given-names: Martin
- family-names: Robinson
+ - family-names: Robinson
+ given-names: Martin
orcid: "https://orcid.org/0000-0002-1572-6782"
- - given-names: Agriya
- family-names: Khetarpal
+ - family-names: Khetarpal
+ given-names: Agriya
orcid: "https://orcid.org/0000-0002-1112-1786"
- - given-names: Ferran
- family-names: Brosa Planella
+ - family-names: Planella
+ given-names: Ferran Brosa
orcid: "https://orcid.org/0000-0001-6363-2812"
- - given-names: David A.
- family-names: Howey
+ - family-names: Howey
+ given-names: David A.
orcid: "https://orcid.org/0000-0002-0620-3955"
-
-keywords:
- - "python"
- - "battery models"
- - "parameter inference"
- - "optimization"
-
-journal: "arXiv"
-date-released: 2024-12-20
-doi: 10.48550/arXiv.2412.15859
-version: "25.11" # Update this alongside new releases
-repository-code: 'https://www.github.com/pybop-team/pybop'
+ date-published: 2025-12-03
+ doi: 10.21105/joss.07874
+ issn: 2475-9066
+ issue: 116
+ journal: Journal of Open Source Software
+ publisher:
+ name: Open Journals
+ start: 7874
+ title: "PyBOP: A Python package for battery model optimisation and
+ parameterisation"
+ type: article
+ url: "https://joss.theoj.org/papers/10.21105/joss.07874"
+ volume: 10
+title: "PyBOP: A Python package for battery model optimisation and
+ parameterisation"
diff --git a/README.md b/README.md
index 42a46bb7d..58c94e269 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
[](https://nbviewer.org/github/pybop-team/PyBOP/tree/develop/examples/notebooks/)
[](https://pybop-team.github.io/pybop-bench/)
[](https://github.com/pybop-team/PyBOP/releases)
+ [](https://doi.org/10.21105/joss.07874)
[Main Branch Examples ](https://github.com/pybop-team/PyBOP/tree/main/examples) [Develop Branch Examples ](https://github.com/pybop-team/PyBOP/tree/develop/examples)
diff --git a/papers/joss/design_plots.py b/papers/joss/design_plots.py
new file mode 100644
index 000000000..c7aec3231
--- /dev/null
+++ b/papers/joss/design_plots.py
@@ -0,0 +1,164 @@
+# A script to generate design optimisation plots for the JOSS paper.
+
+
+import numpy as np
+import pybamm
+from pybamm import Parameter
+
+import pybop
+from pybop.plot import PlotlyManager
+
+go = PlotlyManager().go
+np.random.seed(8)
+axis_font_size = 24
+tick_font_size = 20
+
+# Choose which plots to show and save
+create_plot = {}
+create_plot["gravimetric"] = True # takes longer
+create_plot["prediction"] = True
+
+# Define model and parameter values
+model = pybamm.lithium_ion.SPMe()
+pybop.pybamm.add_variable_to_model(model, "Gravimetric energy density [W.h.kg-1]")
+parameter_values = pybamm.ParameterValues("Chen2020")
+pybop.pybamm.set_formation_concentrations(parameter_values)
+parameter_values.update(
+ {
+ "Electrolyte density [kg.m-3]": Parameter("Separator density [kg.m-3]"),
+ "Negative electrode active material density [kg.m-3]": Parameter(
+ "Negative electrode density [kg.m-3]"
+ ),
+ "Negative electrode carbon-binder density [kg.m-3]": Parameter(
+ "Negative electrode density [kg.m-3]"
+ ),
+ "Positive electrode active material density [kg.m-3]": Parameter(
+ "Positive electrode density [kg.m-3]"
+ ),
+ "Positive electrode carbon-binder density [kg.m-3]": Parameter(
+ "Positive electrode density [kg.m-3]"
+ ),
+ "Positive electrode porosity": 1.0
+ - Parameter("Positive electrode active material volume fraction"),
+ "Cell mass [kg]": pybop.pybamm.cell_mass(),
+ },
+ check_already_exists=False,
+)
+
+# Fitting parameters
+parameter_values.update(
+ {
+ "Positive electrode thickness [m]": pybop.Parameter(
+ initial_value=8.88e-05,
+ prior=pybop.Gaussian(7.56e-05, 3e-05),
+ bounds=[50e-06, 120e-06],
+ transformation=pybop.UnitHyperCube(lower=50e-6, upper=120e-6),
+ ),
+ "Positive electrode active material volume fraction": pybop.Parameter(
+ initial_value=0.42,
+ prior=pybop.Gaussian(0.58, 0.1),
+ bounds=[0.3, 0.825],
+ transformation=pybop.UnitHyperCube(lower=0.3, upper=0.825),
+ ),
+ }
+)
+
+# Define test protocol
+experiment = pybamm.Experiment(["Discharge at 1C until 2.5 V (10 second period)"])
+Q = parameter_values["Nominal cell capacity [A.h]"]
+print(f"The 1C rate is {Q} A.")
+
+# Generate problem
+simulator = pybop.pybamm.Simulator(
+ model,
+ parameter_values=parameter_values,
+ protocol=experiment,
+ solver=pybamm.IDAKLUSolver(atol=1e-6, rtol=1e-6),
+)
+cost = pybop.DesignCost(target="Gravimetric energy density [W.h.kg-1]")
+problem = pybop.Problem(simulator, cost)
+
+# Set up the optimiser
+options = pybop.PintsOptions(max_iterations=250, max_unchanged_iterations=45)
+optim = pybop.NelderMead(problem, options=options)
+
+# Run optimisation
+result = optim.run()
+print(result)
+print("Estimated parameters:", result.x)
+print(f"Initial gravimetric energy density: {problem(result.x0):.1f} W.h.kg-1")
+print(f"Optimised gravimetric energy density: {problem(result.x):.1f} W.h.kg-1")
+
+if create_plot["gravimetric"]:
+ # Plot the cost landscape with optimisation path
+ gravimetric_fig = pybop.plot.contour(
+ result,
+ steps=25,
+ show=False,
+ xaxis=dict(
+ title=dict(
+ text="Positive electrode thickness / m", font_size=axis_font_size
+ ),
+ tickfont_size=tick_font_size,
+ exponentformat="power",
+ ),
+ yaxis=dict(title_font_size=axis_font_size, tickfont_size=tick_font_size),
+ legend=dict(
+ orientation="h",
+ yanchor="bottom",
+ y=1.02,
+ xanchor="right",
+ x=1,
+ font_size=tick_font_size,
+ ),
+ coloraxis_colorbar=dict(tickfont_size=tick_font_size),
+ margin=dict(t=50),
+ title=None,
+ )
+ gravimetric_fig.write_image("figures/individual/design_gravimetric.pdf")
+
+if create_plot["prediction"]:
+ # Plot the timeseries output
+ problem.target = "Voltage [V]"
+ figs = pybop.plot.problem(
+ problem,
+ inputs=result.best_inputs,
+ title=None,
+ legend=dict(
+ orientation="h",
+ yanchor="bottom",
+ y=1.02,
+ xanchor="right",
+ x=1,
+ font_size=tick_font_size - 2,
+ ),
+ xaxis=dict(
+ showline=True,
+ linewidth=1,
+ linecolor="black",
+ mirror=True,
+ title_font_size=axis_font_size,
+ tickfont_size=tick_font_size,
+ ),
+ yaxis=dict(
+ showline=True,
+ linewidth=1,
+ linecolor="black",
+ mirror=True,
+ title_font_size=axis_font_size,
+ tickfont_size=tick_font_size,
+ ),
+ margin=dict(t=60, b=84, r=50, l=15),
+ show=False,
+ )
+
+ prediction_fig = figs[0]
+ prediction_fig.data[1].update(line=dict(color="#00CC97"))
+ prediction_fig.data[
+ 0
+ ].name = f"Initial: {problem(result.x0):.1f} W h kg-1 "
+ prediction_fig.data[
+ 1
+ ].name = f"Optimised: {problem(result.x):.1f} W h kg-1 "
+ prediction_fig.show()
+ prediction_fig.write_image("figures/individual/design_prediction.pdf")
diff --git a/papers/joss/figures/PyBOP_components.drawio.pdf b/papers/joss/figures/PyBOP_components.drawio.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..efcb9074b757044ebec1c082d745a7e1fb5f7bc2
GIT binary patch
literal 73630
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