import numpy as np
import pybop
[docs]
class BaseProblem:
"""
Base class for defining a problem within the PyBOP framework, compatible with PINTS.
Parameters
----------
parameters : list
List of parameters for the problem.
model : object, optional
The model to be used for the problem (default: None).
check_model : bool, optional
Flag to indicate if the model should be checked (default: True).
signal: List[str]
The signal to observe.
additional_variables : List[str], optional
Additional variables to observe and store in the solution (default: []).
init_soc : float, optional
Initial state of charge (default: None).
x0 : np.ndarray, optional
Initial parameter values (default: None).
"""
def __init__(
self,
parameters,
model=None,
check_model=True,
signal=["Voltage [V]"],
additional_variables=[],
init_soc=None,
x0=None,
):
self.parameters = parameters
self._model = model
self.check_model = check_model
if isinstance(signal, str):
signal = [signal]
elif not all(isinstance(item, str) for item in signal):
raise ValueError("Signal should be either a string or list of strings.")
self.signal = signal
self.init_soc = init_soc
self.x0 = x0
self.n_parameters = len(self.parameters)
self.n_outputs = len(self.signal)
self._time_data = None
self._target = None
if isinstance(model, pybop.BaseModel):
self.additional_variables = additional_variables
else:
self.additional_variables = []
# Set bounds (for all or no parameters)
all_unbounded = True # assumption
self.bounds = {"lower": [], "upper": []}
for param in self.parameters:
if param.bounds is not None:
self.bounds["lower"].append(param.bounds[0])
self.bounds["upper"].append(param.bounds[1])
all_unbounded = False
else:
self.bounds["lower"].append(-np.inf)
self.bounds["upper"].append(np.inf)
if all_unbounded:
self.bounds = None
# Set initial standard deviation (for all or no parameters)
all_have_sigma = True # assumption
self.sigma0 = []
for param in self.parameters:
if hasattr(param.prior, "sigma"):
self.sigma0.append(param.prior.sigma)
else:
all_have_sigma = False
if not all_have_sigma:
self.sigma0 = None
# Sample from prior for x0
if x0 is None:
self.x0 = np.zeros(self.n_parameters)
for i, param in enumerate(self.parameters):
self.x0[i] = param.rvs(1)
elif len(x0) != self.n_parameters:
raise ValueError("x0 dimensions do not match number of parameters")
# Add the initial values to the parameter definitions
for i, param in enumerate(self.parameters):
param.update(initial_value=self.x0[i])
[docs]
def evaluate(self, x):
"""
Evaluate the model with the given parameters and return the signal.
Parameters
----------
x : np.ndarray
Parameter values to evaluate the model at.
Raises
------
NotImplementedError
This method must be implemented by subclasses.
"""
raise NotImplementedError
[docs]
def evaluateS1(self, x):
"""
Evaluate the model with the given parameters and return the signal and
its derivatives.
Parameters
----------
x : np.ndarray
Parameter values to evaluate the model at.
Raises
------
NotImplementedError
This method must be implemented by subclasses.
"""
raise NotImplementedError
[docs]
def time_data(self):
"""
Returns the time data.
Returns
-------
np.ndarray
The time array.
"""
return self._time_data
[docs]
def target(self):
"""
Return the target dataset.
Returns
-------
np.ndarray
The target dataset array.
"""
return self._target
@property
[docs]
def model(self):
return self._model
[docs]
class FittingProblem(BaseProblem):
"""
Problem class for fitting (parameter estimation) problems.
Extends `BaseProblem` with specifics for fitting a model to a dataset.
Parameters
----------
model : object
The model to fit.
parameters : list
List of parameters for the problem.
dataset : Dataset
Dataset object containing the data to fit the model to.
signal : str, optional
The variable used for fitting (default: "Voltage [V]").
additional_variables : List[str], optional
Additional variables to observe and store in the solution (default additions are: ["Time [s]"]).
init_soc : float, optional
Initial state of charge (default: None).
x0 : np.ndarray, optional
Initial parameter values (default: None).
"""
def __init__(
self,
model,
parameters,
dataset,
check_model=True,
signal=["Voltage [V]"],
additional_variables=[],
init_soc=None,
x0=None,
):
# Add time and remove duplicates
additional_variables.extend(["Time [s]"])
additional_variables = list(set(additional_variables))
super().__init__(
parameters, model, check_model, signal, additional_variables, init_soc, x0
)
self._dataset = dataset.data
self.x = self.x0
# Check that the dataset contains time and current
dataset.check(self.signal + ["Current function [A]"])
# Unpack time and target data
self._time_data = self._dataset["Time [s]"]
self.n_time_data = len(self._time_data)
self._target = {signal: self._dataset[signal] for signal in self.signal}
# Add useful parameters to model
if model is not None:
self._model.signal = self.signal
self._model.additional_variables = self.additional_variables
self._model.n_parameters = self.n_parameters
self._model.n_outputs = self.n_outputs
self._model.n_time_data = self.n_time_data
# Build the model from scratch
if self._model._built_model is not None:
self._model._model_with_set_params = None
self._model._built_model = None
self._model._built_initial_soc = None
self._model._mesh = None
self._model._disc = None
self._model.build(
dataset=self._dataset,
parameters=self.parameters,
check_model=self.check_model,
init_soc=self.init_soc,
)
[docs]
def evaluate(self, x):
"""
Evaluate the model with the given parameters and return the signal.
Parameters
----------
x : np.ndarray
Parameter values to evaluate the model at.
Returns
-------
y : np.ndarray
The model output y(t) simulated with inputs x.
"""
if np.any(x != self.x) and self._model.matched_parameters:
for i, param in enumerate(self.parameters):
param.update(value=x[i])
self._model.rebuild(parameters=self.parameters)
self.x = x
y = self._model.simulate(inputs=x, t_eval=self._time_data)
return y
[docs]
def evaluateS1(self, x):
"""
Evaluate the model with the given parameters and return the signal and its derivatives.
Parameters
----------
x : np.ndarray
Parameter values to evaluate the model at.
Returns
-------
tuple
A tuple containing the simulation result y(t) and the sensitivities dy/dx(t) evaluated
with given inputs x.
"""
if self._model.matched_parameters:
raise RuntimeError(
"Gradient not available when using geometric parameters."
)
y, dy = self._model.simulateS1(
inputs=x,
t_eval=self._time_data,
)
return (y, np.asarray(dy))
[docs]
class DesignProblem(BaseProblem):
"""
Problem class for design optimization problems.
Extends `BaseProblem` with specifics for applying a model to an experimental design.
Parameters
----------
model : object
The model to apply the design to.
parameters : list
List of parameters for the problem.
experiment : object
The experimental setup to apply the model to.
check_model : bool, optional
Flag to indicate if the model parameters should be checked for feasibility each iteration (default: True).
signal : str, optional
The signal to fit (default: "Voltage [V]").
additional_variables : List[str], optional
Additional variables to observe and store in the solution (default additions are: ["Time [s]", "Current [A]"]).
init_soc : float, optional
Initial state of charge (default: None).
x0 : np.ndarray, optional
Initial parameter values (default: None).
"""
def __init__(
self,
model,
parameters,
experiment,
check_model=True,
signal=["Voltage [V]"],
additional_variables=[],
init_soc=None,
x0=None,
):
# Add time and current and remove duplicates
additional_variables.extend(["Time [s]", "Current [A]"])
additional_variables = list(set(additional_variables))
super().__init__(
parameters, model, check_model, signal, additional_variables, init_soc, x0
)
self.experiment = experiment
# Build the model if required
if experiment is not None:
# Leave the build until later to apply the experiment
self._model.parameters = self.parameters
if self.parameters is not None:
self._model.fit_keys = [param.name for param in self.parameters]
elif self._model._built_model is None:
self._model.build(
experiment=self.experiment,
parameters=self.parameters,
check_model=self.check_model,
init_soc=self.init_soc,
)
# Add an example dataset for plotting comparison
sol = self.evaluate(self.x0)
self._time_data = sol["Time [s]"]
self._target = {key: sol[key] for key in self.signal}
self._dataset = None
[docs]
def evaluate(self, x):
"""
Evaluate the model with the given parameters and return the signal.
Parameters
----------
x : np.ndarray
Parameter values to evaluate the model at.
Returns
-------
y : np.ndarray
The model output y(t) simulated with inputs x.
"""
sol = self._model.predict(
inputs=x,
experiment=self.experiment,
init_soc=self.init_soc,
)
if sol == [np.inf]:
return sol
else:
predictions = {}
for signal in self.signal + self.additional_variables:
predictions[signal] = sol[signal].data
return predictions