diff --git a/examples/standalone/problem.py b/examples/standalone/problem.py index 28c2d7562..6fdcf97f5 100644 --- a/examples/standalone/problem.py +++ b/examples/standalone/problem.py @@ -1,6 +1,6 @@ import numpy as np -from pybop._problem import BaseProblem +from pybop import BaseProblem class StandaloneProblem(BaseProblem): diff --git a/pybop/__init__.py b/pybop/__init__.py index 5a7c3ac23..c5d1abf00 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -31,7 +31,9 @@ # Cost class # Problem class # -from ._problem import BaseProblem, FittingProblem, DesignProblem +from .problems.base_problem import BaseProblem +from .problems.fitting_problem import FittingProblem +from .problems.design_problem import DesignProblem # # Cost function class diff --git a/pybop/_problem.py b/pybop/_problem.py deleted file mode 100644 index 9b51f9b10..000000000 --- a/pybop/_problem.py +++ /dev/null @@ -1,395 +0,0 @@ -import numpy as np - -import pybop - - -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]) - - 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 - - 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 - - def time_data(self): - """ - Returns the time data. - - Returns - ------- - np.ndarray - The time array. - """ - return self._time_data - - def get_target(self): - """ - Return the target dataset. - - Returns - ------- - np.ndarray - The target dataset array. - """ - return self._target - - def set_target(self, dataset): - """ - Set the target dataset. - - Parameters - ---------- - target : np.ndarray - The target dataset array. - """ - if self.signal is None: - raise ValueError("Signal must be defined to set target.") - if not isinstance(dataset, pybop.Dataset): - raise ValueError("Dataset must be a pybop Dataset object.") - - self._target = {signal: dataset[signal] for signal in self.signal} - - @property - def model(self): - return self._model - - -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.set_target(dataset) - - # 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, - ) - - 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 - - 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)) - - -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 - - 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 diff --git a/pybop/observers/observer.py b/pybop/observers/observer.py index 496bd10cf..38c7ef863 100644 --- a/pybop/observers/observer.py +++ b/pybop/observers/observer.py @@ -2,7 +2,7 @@ import numpy as np -from pybop._problem import BaseProblem +from pybop import BaseProblem from pybop.models.base_model import BaseModel, Inputs, TimeSeriesState from pybop.parameters.parameter import Parameter diff --git a/pybop/problems/__init__.py b/pybop/problems/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pybop/problems/base_problem.py b/pybop/problems/base_problem.py new file mode 100644 index 000000000..37a6af253 --- /dev/null +++ b/pybop/problems/base_problem.py @@ -0,0 +1,168 @@ +import numpy as np + +import pybop + + +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]) + + 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 + + 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 + + def time_data(self): + """ + Returns the time data. + + Returns + ------- + np.ndarray + The time array. + """ + return self._time_data + + def get_target(self): + """ + Return the target dataset. + + Returns + ------- + np.ndarray + The target dataset array. + """ + return self._target + + def set_target(self, dataset): + """ + Set the target dataset. + + Parameters + ---------- + target : np.ndarray + The target dataset array. + """ + if self.signal is None: + raise ValueError("Signal must be defined to set target.") + if not isinstance(dataset, pybop.Dataset): + raise ValueError("Dataset must be a pybop Dataset object.") + + self._target = {signal: dataset[signal] for signal in self.signal} + + @property + def model(self): + return self._model diff --git a/pybop/problems/design_problem.py b/pybop/problems/design_problem.py new file mode 100644 index 000000000..29f58ba95 --- /dev/null +++ b/pybop/problems/design_problem.py @@ -0,0 +1,102 @@ +import numpy as np + +from pybop import BaseProblem + + +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 + + 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 diff --git a/pybop/problems/fitting_problem.py b/pybop/problems/fitting_problem.py new file mode 100644 index 000000000..6bfa38149 --- /dev/null +++ b/pybop/problems/fitting_problem.py @@ -0,0 +1,131 @@ +import numpy as np + +from pybop import BaseProblem + + +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.set_target(dataset) + + # 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, + ) + + 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 + + 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)) diff --git a/tests/unit/test_problem.py b/tests/unit/test_problem.py index 6ebdd946b..4fe2e7693 100644 --- a/tests/unit/test_problem.py +++ b/tests/unit/test_problem.py @@ -70,10 +70,10 @@ def signal(self): def test_base_problem(self, parameters, model, dataset): # Test incorrect number of initial parameter values with pytest.raises(ValueError): - pybop._problem.BaseProblem(parameters, model=model, x0=np.array([])) + pybop.BaseProblem(parameters, model=model, x0=np.array([])) # Construct Problem - problem = pybop._problem.BaseProblem(parameters, model=model) + problem = pybop.BaseProblem(parameters, model=model) assert problem._model == model @@ -83,12 +83,12 @@ def test_base_problem(self, parameters, model, dataset): problem.evaluateS1([1e-5, 1e-5]) with pytest.raises(ValueError): - pybop._problem.BaseProblem(parameters, model=model, signal=[1e-5, 1e-5]) + pybop.BaseProblem(parameters, model=model, signal=[1e-5, 1e-5]) # Test without bounds for param in parameters: param.bounds = None - problem = pybop._problem.BaseProblem(parameters, model=model) + problem = pybop.BaseProblem(parameters, model=model) assert problem.bounds is None # Incorrect set target