Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow nonlinear constraints #353 #371

Merged
merged 72 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
54ef586
Add different callback signature for trust-constr optimiser
Jun 17, 2024
93365c6
Add nonlinear constraints example
Jun 17, 2024
316c971
Add trust-constr optimisation
Jun 17, 2024
b0eff21
Update change-log for #353
Jun 18, 2024
260337f
Style: pre-commit fixes
Jun 18, 2024
863bc70
Merge branch 'develop' into master
MarkBlyth Jun 18, 2024
e6f4dca
style: pre-commit fixes
pre-commit-ci[bot] Jun 18, 2024
167dd99
Clarify multiple constraints
Jun 20, 2024
ddc6302
Initial commit
Jun 20, 2024
65215c8
Rename trust-constr example with correct optimiser
Jun 20, 2024
0e1fd32
Merge branch 'master' of https://github.com/MarkBlyth/PyBOP_develop
Jun 20, 2024
ccf4879
style: pre-commit fixes
pre-commit-ci[bot] Jun 20, 2024
aca5582
Add parameter checking capabilities to base model
Jul 30, 2024
bfb6dd5
Allow user-defined check_parameters function
Jul 30, 2024
6983374
Use user-defined check_parameters for constraints
Jul 30, 2024
722672d
Merge branch 'develop' of https://github.com/MarkBlyth/PyBOP_develop
Jul 30, 2024
5839922
style: pre-commit fixes
pre-commit-ci[bot] Jul 30, 2024
ac0d58b
Add tests for user-defined check_params
Jul 30, 2024
a54e173
Merge branch 'master' of https://github.com/MarkBlyth/PyBOP_develop
Jul 30, 2024
7e07e51
style: pre-commit fixes
pre-commit-ci[bot] Jul 30, 2024
74c0a4a
Move change from bottom to top of list
Jul 31, 2024
cce4507
Remove unused variable
Jul 31, 2024
b10dae2
Update plotting kwargs to new API
Jul 31, 2024
da8851e
Merge branch 'master' of https://github.com/MarkBlyth/PyBOP_develop
Jul 31, 2024
50e5e93
Fix code coverage for parameter checkers
Jul 31, 2024
3f62704
style: pre-commit fixes
pre-commit-ci[bot] Jul 31, 2024
d7e2f12
Merge branch 'pybop-team:develop' into master
MarkBlyth Jul 31, 2024
0f72e16
Merge branch 'develop' into master
MarkBlyth Jul 31, 2024
b6d6a1a
Handle unconventional scipy solver APIs
Jul 31, 2024
05bca23
Merge branch 'master' of https://github.com/MarkBlyth/PyBOP_develop
Jul 31, 2024
d4f8dc7
Update changelog
Jul 31, 2024
be6667f
style: pre-commit fixes
pre-commit-ci[bot] Jul 31, 2024
4bfa384
Update CHANGELOG.md
MarkBlyth Aug 2, 2024
613f2f9
Tidy up arg checks
MarkBlyth Aug 2, 2024
cec6ee5
Add docstring to parameter checker
MarkBlyth Aug 2, 2024
c764ad0
More concise callback-function selection
MarkBlyth Aug 2, 2024
97630bc
Update tests/unit/test_optimisation.py
MarkBlyth Aug 2, 2024
ff0429d
Formatting
MarkBlyth Aug 5, 2024
9b64ef6
Add extra comments for clarity
MarkBlyth Aug 6, 2024
53298f6
Merge branch 'develop' into master
MarkBlyth Aug 6, 2024
a0fd21e
style: pre-commit fixes
pre-commit-ci[bot] Aug 6, 2024
48e19c6
Add docstring to explain callback handling
MarkBlyth Aug 6, 2024
d623df4
Merge branch 'master' of https://github.com/MarkBlyth/PyBOP_develop
MarkBlyth Aug 6, 2024
90caf73
Update test for new check_params API
MarkBlyth Aug 6, 2024
759b826
Change type hints to Union for py3.9 support
MarkBlyth Aug 6, 2024
2539538
Catch attribute error when trying to sample from constant parameter
MarkBlyth Aug 13, 2024
1762182
Merge branch 'develop' into master
MarkBlyth Aug 22, 2024
86aa8fd
style: pre-commit fixes
pre-commit-ci[bot] Aug 22, 2024
d1e84b9
Produce warning when solver terminates prematurely
MarkBlyth Aug 22, 2024
ec0e12f
Merge branch 'master' of https://github.com/MarkBlyth/PyBOP_develop
MarkBlyth Aug 22, 2024
0388795
Add missing string formatter
MarkBlyth Aug 22, 2024
9e45768
Move ecm_trust-constr example from script to notebook
MarkBlyth Aug 22, 2024
1f7c3b7
Remove solver termination warning
MarkBlyth Aug 22, 2024
ce19560
Update check_params to new API
MarkBlyth Aug 22, 2024
76d3b9a
style: pre-commit fixes
pre-commit-ci[bot] Aug 22, 2024
373d1f0
Add code coverage for no-prior handling
MarkBlyth Aug 22, 2024
42a5f93
Merge branch 'master' of https://github.com/MarkBlyth/PyBOP_develop
MarkBlyth Aug 22, 2024
211b417
Rescale cost value before logging
MarkBlyth Aug 22, 2024
2c401ed
Merge branch 'develop' into master
MarkBlyth Aug 22, 2024
19b46cf
style: pre-commit fixes
pre-commit-ci[bot] Aug 22, 2024
302024d
Update pybop/optimisers/scipy_optimisers.py
MarkBlyth Aug 23, 2024
267c9e0
style: pre-commit fixes
pre-commit-ci[bot] Aug 23, 2024
2b3d45b
Import warnings
MarkBlyth Aug 23, 2024
7621916
style: pre-commit fixes
pre-commit-ci[bot] Aug 23, 2024
18337c9
Update pybop/optimisers/scipy_optimisers.py
MarkBlyth Aug 23, 2024
b4bc92d
style: pre-commit fixes
pre-commit-ci[bot] Aug 23, 2024
c0466df
Lower sample rate for speed
MarkBlyth Aug 23, 2024
e143e33
Merge branch 'master' of https://github.com/MarkBlyth/PyBOP_develop
MarkBlyth Aug 23, 2024
1eebc18
style: pre-commit fixes
pre-commit-ci[bot] Aug 23, 2024
30b6101
fix: updates scipy bounds with keep_feasible, clipping groundtruth fo…
BradyPlanden Aug 27, 2024
553d478
tests: increase coverage
BradyPlanden Aug 27, 2024
5b53ba7
Merge branch 'develop' into master
BradyPlanden Aug 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Features

- [#353](https://github.com/pybop-team/PyBOP/issues/353) - Allow user-defined check_params functions to enforce nonlinear constraints, and enable SciPy constrained optimisation methods
- [#222](https://github.com/pybop-team/PyBOP/issues/222) - Adds an example for performing and electrode balancing.
- [#441](https://github.com/pybop-team/PyBOP/issues/441) - Adds an example for estimating constants within a `pybamm.FunctionalParameter`.
- [#405](https://github.com/pybop-team/PyBOP/pull/405) - Adds frequency-domain based EIS prediction methods via `model.simulateEIS` and updates to `problem.evaluate` with examples and tests.
Expand Down
316 changes: 316 additions & 0 deletions examples/notebooks/ecm_trust-constr.ipynb

Large diffs are not rendered by default.

192 changes: 192 additions & 0 deletions examples/scripts/ecm_tau_constraints.py
MarkBlyth marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import numpy as np

import pybop

"""
When fitting empirical models, the parameters we are able to identify
will be constrained from the data that's available. For example, it's
no good trying to fit an RC timescale of 0.1 s from data sampled at
1 Hz! Likewise, an RC timescale of 100 s cannot be meaningfully fitted
to just 10 s of data. To ensure the optimiser doesn't propose
excessively long or short timescales - beyond what can reasonably be
inferred from the data - it is common to apply nonlinear constraints
on the parameter space. This script fits an RC pair with the
constraint 0.5 <= R1 * C1 <= 1, to highlight a possible method for
applying constraints on the timescales.

An alternative approach is given i the ecm_trust-constr notebook,
which can lead to better results and higher optimisation efficiency
when good timescale guesses are available.
"""

# Import the ECM parameter set from JSON
# parameter_set = pybop.ParameterSet(
# json_path="examples/scripts/parameters/initial_ecm_parameters.json"
# )
# parameter_set.import_parameters()


# Alternatively, define the initial parameter set with a dictionary
# Add definitions for R's, C's, and initial overpotentials for any additional RC elements
parameter_set = {
"chemistry": "ecm",
"Initial SoC": 0.5,
"Initial temperature [K]": 25 + 273.15,
"Cell capacity [A.h]": 5,
"Nominal cell capacity [A.h]": 5,
"Ambient temperature [K]": 25 + 273.15,
"Current function [A]": 5,
"Upper voltage cut-off [V]": 4.2,
"Lower voltage cut-off [V]": 3.0,
"Cell thermal mass [J/K]": 1000,
"Cell-jig heat transfer coefficient [W/K]": 10,
"Jig thermal mass [J/K]": 500,
"Jig-air heat transfer coefficient [W/K]": 10,
"Open-circuit voltage [V]": pybop.empirical.Thevenin().default_parameter_values[
"Open-circuit voltage [V]"
],
"R0 [Ohm]": 0.001,
"Element-1 initial overpotential [V]": 0,
"Element-2 initial overpotential [V]": 0,
"R1 [Ohm]": 0.0002,
"R2 [Ohm]": 0.0003,
"C1 [F]": 10000,
"C2 [F]": 5000,
"Entropic change [V/K]": 0.0004,
}


def get_parameter_checker(
tau_mins: float | list[float],
tau_maxs: float | list[float],
fitted_rc_pair_indices: int | list[int],
):
"""Returns a function to check parameters against given tau bounds.
The resulting check_params function will be sent off to PyBOP; the
rest of the code does some light checking of the constraints.

Parameters
----------
tau_mins: float | list[float]
Lower bounds on timescale tau_i = Ri * Ci
tau_maxs: float | list[float]
Upper bounds on timescale tau_i = Ri * Ci
fitted_rc_pair_indices: int | list[float]
The index of each RC pair whose parameters are to be fitted.
Eg. [1, 2] means fitting R1, R2, C1, C2. The timescale of RC
pair fitted_rc_pair_indices[j] is constrained to be in the
range tau_mins[j] <= R * C <= tau_maxs[j]

Returns
-------
check_params
Function to check the proposed parameter values match the
requested constraints

"""

# Ensure inputs are lists
tau_mins = [tau_mins] if not isinstance(tau_mins, list) else tau_mins
tau_maxs = [tau_maxs] if not isinstance(tau_maxs, list) else tau_maxs
fitted_rc_pair_indices = (
[fitted_rc_pair_indices]
if not isinstance(fitted_rc_pair_indices, list)
else fitted_rc_pair_indices
)

# Validate input lengths
if len(tau_mins) != len(fitted_rc_pair_indices) or len(tau_maxs) != len(
fitted_rc_pair_indices
):
raise ValueError(
"tau_mins and tau_maxs must have the same length as fitted_rc_pair_indices"
)

def check_params(
inputs: dict[str, float] = None,
parameter_set=None,
allow_infeasible_solutions: bool = False,
) -> bool:
"""Checks if the given inputs are within the tau bounds."""
# Allow simulation to run if inputs are None
if inputs is None or inputs == {}:
return True

# Check every respective R*C against tau bounds
print(inputs)
for i, tau_min, tau_max in zip(fitted_rc_pair_indices, tau_mins, tau_maxs):
tau = inputs[f"R{i} [Ohm]"] * inputs[f"C{i} [F]"]
if not tau_min <= tau <= tau_max:
return False
return True

return check_params


# Define the model
params = pybop.ParameterSet(params_dict=parameter_set)
model = pybop.empirical.Thevenin(
parameter_set=params,
check_params=get_parameter_checker(
0, 0.5, 1
), # Set the model up to automatically check parameters
options={"number of rc elements": 2},
)

# Fitting parameters
parameters = pybop.Parameters(
pybop.Parameter(
"R0 [Ohm]",
prior=pybop.Gaussian(0.0002, 0.0001),
bounds=[1e-4, 1e-2],
),
pybop.Parameter(
"R1 [Ohm]",
prior=pybop.Gaussian(0.0001, 0.0001),
bounds=[1e-5, 1e-2],
),
pybop.Parameter(
"C1 [F]",
prior=pybop.Gaussian(10000, 2500),
bounds=[2500, 5e4],
),
)

sigma = 0.001
t_eval = np.arange(0, 900, 3)
values = model.predict(t_eval=t_eval)
corrupt_values = values["Voltage [V]"].data + np.random.normal(0, sigma, len(t_eval))

# Form dataset
dataset = pybop.Dataset(
{
"Time [s]": t_eval,
"Current function [A]": values["Current [A]"].data,
"Voltage [V]": corrupt_values,
}
)

# Generate problem, cost function, and optimisation class
problem = pybop.FittingProblem(model, parameters, dataset)
cost = pybop.SumSquaredError(problem)
optim = pybop.XNES(
cost,
allow_infeasible_solutions=False,
max_iterations=100,
)

x, final_cost = optim.run()
print("Estimated parameters:", x)


# Plot the time series
pybop.plot_dataset(dataset)

# Plot the timeseries output
pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison")

# Plot convergence
pybop.plot_convergence(optim)

# Plot the parameter traces
pybop.plot_parameters(optim)
15 changes: 14 additions & 1 deletion pybop/models/base_model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import copy
from dataclasses import dataclass
from typing import Optional, Union
from typing import Callable, Optional, Union

import casadi
import numpy as np
Expand Down Expand Up @@ -70,6 +70,7 @@ def __init__(
self,
name: str = "Base Model",
parameter_set: Optional[ParameterSet] = None,
check_params: Callable = None,
eis=False,
):
"""
Expand All @@ -82,6 +83,15 @@ def __init__(
parameter_set : pybop.ParameterSet, optional
A PyBOP ParameterSet, PyBaMM ParameterValues object or a dictionary containing the
parameter values.
check_params : Callable, optional
A compatibility check for the model parameters. Function, with
signature
check_params(
inputs: dict,
allow_infeasible_solutions: bool, optional
)
Returns true if parameters are valid, False otherwise. Can be
used to impose constraints on valid parameters.

Additional Attributes
---------------------
Expand All @@ -105,6 +115,7 @@ def __init__(
self._parameter_set = parameter_set.copy()
else: # a pybop parameter set
self._parameter_set = pybamm.ParameterValues(parameter_set.params).copy()
self.param_checker = check_params

self.pybamm_model = None
self.parameters = Parameters()
Expand Down Expand Up @@ -798,6 +809,8 @@ def _check_params(
bool
A boolean which signifies whether the parameters are compatible.
"""
if self.param_checker:
return self.param_checker(inputs, allow_infeasible_solutions)
return True

def copy(self):
Expand Down
7 changes: 6 additions & 1 deletion pybop/models/empirical/base_ecm.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def __init__(
var_pts=None,
spatial_methods=None,
solver=None,
check_params=None,
eis=False,
**model_kwargs,
):
Expand All @@ -65,7 +66,9 @@ def __init__(
print("Setting open-circuit voltage to default function")
parameter_set["Open-circuit voltage [V]"] = default_ocp

super().__init__(name=name, parameter_set=parameter_set, eis=eis)
super().__init__(
name=name, parameter_set=parameter_set, check_params=check_params, eis=eis
)
self.pybamm_model = pybamm_model
self._unprocessed_model = self.pybamm_model

Expand Down Expand Up @@ -114,6 +117,8 @@ def _check_params(
bool
A boolean which signifies whether the parameters are compatible.
"""
if self.param_checker:
return self.param_checker(inputs, allow_infeasible_solutions)
return True

def get_initial_state(
Expand Down
70 changes: 56 additions & 14 deletions pybop/optimisers/scipy_optimisers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import warnings
from typing import Union

MarkBlyth marked this conversation as resolved.
Show resolved Hide resolved
import numpy as np
from scipy.optimize import OptimizeResult, differential_evolution, minimize
from scipy.optimize import Bounds, OptimizeResult, differential_evolution, minimize

from pybop import BaseOptimiser, Result

Expand Down Expand Up @@ -52,12 +55,18 @@ def _sanitise_inputs(self):

# Convert bounds to SciPy format
if isinstance(self.bounds, dict):
self._scipy_bounds = [
(lower, upper)
for lower, upper in zip(self.bounds["lower"], self.bounds["upper"])
]
else:
self._scipy_bounds = Bounds(
self.bounds["lower"], self.bounds["upper"], True
)
elif isinstance(self.bounds, list):
lb, ub = zip(*self.bounds)
self._scipy_bounds = Bounds(lb, ub, True)
elif isinstance(self.bounds, Bounds) or self.bounds is None:
self._scipy_bounds = self.bounds
else:
raise TypeError(
"Bounds provided must be either type dict, list or SciPy.optimize.bounds object."
)

def _run(self):
"""
Expand All @@ -70,10 +79,15 @@ def _run(self):
"""
result = self._run_optimiser()

try:
MarkBlyth marked this conversation as resolved.
Show resolved Hide resolved
nit = result.nit
except AttributeError:
nit = -1

return Result(
x=result.x,
final_cost=self.cost(result.x),
n_iterations=result.nit,
n_iterations=nit,
scipy_result=result,
)

Expand Down Expand Up @@ -107,6 +121,7 @@ def __init__(self, cost, **optimiser_kwargs):
optimiser_options = dict(method="Nelder-Mead", jac=False)
optimiser_options.update(**optimiser_kwargs)
super().__init__(cost, **optimiser_options)
self._cost0 = 1.0

def _set_up_optimiser(self):
"""
Expand Down Expand Up @@ -169,17 +184,45 @@ def _run_optimiser(self):
self.inf_count = 0

# Add callback storing history of parameter values
def callback(intermediate_result: OptimizeResult):
self.log["x_best"].append(intermediate_result.x)

def base_callback(intermediate_result: Union[OptimizeResult, np.ndarray]):
"""
Log intermediate optimisation solutions. Depending on the
optimisation algorithm, intermediate_result may be either
a OptimizeResult or an array of parameter values, with a
try/except ensuring both cases are handled correctly.
"""
if isinstance(intermediate_result, OptimizeResult):
x_best = intermediate_result.x
cost = intermediate_result.fun
else:
x_best = intermediate_result
cost = self.cost(x_best)

self.log["x_best"].append(x_best)
self.log["cost"].append(
intermediate_result.fun if self.minimising else -intermediate_result.fun
(-1 if not self.minimising else 1) * cost * self._cost0
)

callback = (
base_callback
if self._options["method"] != "trust-constr"
else lambda x, intermediate_result: base_callback(intermediate_result)
)

# Compute the absolute initial cost and resample if required
self._cost0 = np.abs(self.cost(self.x0))
if np.isinf(self._cost0):
for _i in range(1, self.num_resamples):
self.x0 = self.parameters.rvs()
try:
self.x0 = self.parameters.rvs()
except AttributeError:
warnings.warn(
"Parameter does not have a prior distribution. Stopping resampling.",
UserWarning,
stacklevel=2,
)
break
self._cost0 = np.abs(self.cost(self.x0))
if not np.isinf(self._cost0):
break
Expand Down Expand Up @@ -249,9 +292,8 @@ def _set_up_optimiser(self):
if self._scipy_bounds is None:
raise ValueError("Bounds must be specified for differential_evolution.")
else:
if not all(
np.isfinite(value) for pair in self._scipy_bounds for value in pair
):
bnds = self._scipy_bounds
if not (np.isfinite(bnds.lb).all() and np.isfinite(bnds.ub).all()):
raise ValueError("Bounds must be specified for differential_evolution.")

# Apply default maxiter and tolerance
Expand Down
Loading
Loading