From 17d97bcb42ad0517e8aac450ef632788f1e87034 Mon Sep 17 00:00:00 2001 From: Ferran Brosa Planella Date: Mon, 8 Apr 2024 16:30:37 +0100 Subject: [PATCH 1/5] #279 refactor problem into multiple files --- pybop/__init__.py | 4 +- pybop/_problem.py | 347 ------------------------------ pybop/problems/__init__.py | 0 pybop/problems/base_problem.py | 142 ++++++++++++ pybop/problems/design_problem.py | 82 +++++++ pybop/problems/fitting_problem.py | 127 +++++++++++ 6 files changed, 354 insertions(+), 348 deletions(-) delete mode 100644 pybop/_problem.py create mode 100644 pybop/problems/__init__.py create mode 100644 pybop/problems/base_problem.py create mode 100644 pybop/problems/design_problem.py create mode 100644 pybop/problems/fitting_problem.py diff --git a/pybop/__init__.py b/pybop/__init__.py index 236c15012..be0ada532 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -26,7 +26,9 @@ # # 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 5665d2365..000000000 --- a/pybop/_problem.py +++ /dev/null @@ -1,347 +0,0 @@ -import numpy as np - - -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. - 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]"], - 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 - - # 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 target(self): - """ - Return the target dataset. - - Returns - ------- - np.ndarray - The target dataset array. - """ - return self._target - - @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 signal to fit (default: "Voltage [V]"). - """ - - def __init__( - self, - model, - parameters, - dataset, - check_model=True, - signal=["Voltage [V]"], - init_soc=None, - x0=None, - ): - super().__init__(parameters, model, check_model, signal, init_soc, x0) - self._dataset = dataset.data - self.x = self.x0 - - # Check that the dataset contains time and current - for name in ["Time [s]", "Current function [A]"] + self.signal: - if name not in self._dataset: - raise ValueError(f"Expected {name} in list of dataset") - - self._time_data = self._dataset["Time [s]"] - self.n_time_data = len(self._time_data) - if np.any(self._time_data < 0): - raise ValueError("Times can not be negative.") - if np.any(self._time_data[:-1] >= self._time_data[1:]): - raise ValueError("Times must be increasing.") - - for signal in self.signal: - if len(self._dataset[signal]) != self.n_time_data: - raise ValueError( - f"Time data and {signal} data must be the same length." - ) - target = [self._dataset[signal] for signal in self.signal] - self._target = np.vstack(target).T - - # Add useful parameters to model - if model is not None: - self._model.signal = self.signal - 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 (x != self.x).any() 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 = np.asarray(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 (np.asarray(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. - """ - - def __init__( - self, - model, - parameters, - experiment, - check_model=True, - signal=["Voltage [V]"], - init_soc=None, - x0=None, - ): - super().__init__(parameters, model, check_model, signal, 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[:, -1] - self._target = sol[:, 0:-1] - 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 = [sol[signal].data for signal in self.signal + ["Time [s]"]] - - return np.vstack(predictions).T 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..7450ff878 --- /dev/null +++ b/pybop/problems/base_problem.py @@ -0,0 +1,142 @@ +import numpy as np + + +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. + 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]"], + 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 + + # 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 target(self): + """ + Return the target dataset. + + Returns + ------- + np.ndarray + The target dataset array. + """ + return self._target + + @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..2ad294305 --- /dev/null +++ b/pybop/problems/design_problem.py @@ -0,0 +1,82 @@ +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. + """ + + def __init__( + self, + model, + parameters, + experiment, + check_model=True, + signal=["Voltage [V]"], + init_soc=None, + x0=None, + ): + super().__init__(parameters, model, check_model, signal, 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[:, -1] + self._target = sol[:, 0:-1] + 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 = [sol[signal].data for signal in self.signal + ["Time [s]"]] + + return np.vstack(predictions).T diff --git a/pybop/problems/fitting_problem.py b/pybop/problems/fitting_problem.py new file mode 100644 index 000000000..3f0da42f7 --- /dev/null +++ b/pybop/problems/fitting_problem.py @@ -0,0 +1,127 @@ +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 signal to fit (default: "Voltage [V]"). + """ + + def __init__( + self, + model, + parameters, + dataset, + check_model=True, + signal=["Voltage [V]"], + init_soc=None, + x0=None, + ): + super().__init__(parameters, model, check_model, signal, init_soc, x0) + self._dataset = dataset.data + self.x = self.x0 + + # Check that the dataset contains time and current + for name in ["Time [s]", "Current function [A]"] + self.signal: + if name not in self._dataset: + raise ValueError(f"Expected {name} in list of dataset") + + self._time_data = self._dataset["Time [s]"] + self.n_time_data = len(self._time_data) + if np.any(self._time_data < 0): + raise ValueError("Times can not be negative.") + if np.any(self._time_data[:-1] >= self._time_data[1:]): + raise ValueError("Times must be increasing.") + + for signal in self.signal: + if len(self._dataset[signal]) != self.n_time_data: + raise ValueError( + f"Time data and {signal} data must be the same length." + ) + target = [self._dataset[signal] for signal in self.signal] + self._target = np.vstack(target).T + + # Add useful parameters to model + if model is not None: + self._model.signal = self.signal + 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 (x != self.x).any() 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 = np.asarray(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 (np.asarray(y), np.asarray(dy)) \ No newline at end of file From dd5cbd48bff6cf25ec5f9c0d6a7a395adc163504 Mon Sep 17 00:00:00 2001 From: Ferran Brosa Planella Date: Mon, 8 Apr 2024 16:35:45 +0100 Subject: [PATCH 2/5] #279 fix tests --- examples/standalone/problem.py | 2 +- pybop/observers/observer.py | 2 +- tests/unit/test_problem.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/standalone/problem.py b/examples/standalone/problem.py index 5a29138e8..55b8a6c72 100644 --- a/examples/standalone/problem.py +++ b/examples/standalone/problem.py @@ -1,5 +1,5 @@ import numpy as np -from pybop._problem import BaseProblem +from pybop import BaseProblem class StandaloneProblem(BaseProblem): diff --git a/pybop/observers/observer.py b/pybop/observers/observer.py index 0a93fe8a4..0b8bdc3f3 100644 --- a/pybop/observers/observer.py +++ b/pybop/observers/observer.py @@ -1,6 +1,6 @@ from typing import List, Optional 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/tests/unit/test_problem.py b/tests/unit/test_problem.py index 94f1ff1a0..ac2117ee9 100644 --- a/tests/unit/test_problem.py +++ b/tests/unit/test_problem.py @@ -68,10 +68,10 @@ def signal(self): def test_base_problem(self, parameters, model): # Test incorrect number of initial parameter values with pytest.raises(ValueError): - pybop._problem.BaseProblem(parameters, model=model, x0=np.array([])) + 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 @@ -81,12 +81,12 @@ def test_base_problem(self, parameters, model): 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 @pytest.mark.unit From b108747edc86337f13e33472b94f9f8e6066fcd0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 8 Apr 2024 15:39:57 +0000 Subject: [PATCH 3/5] style: pre-commit fixes --- examples/standalone/problem.py | 1 + pybop/observers/observer.py | 1 + pybop/problems/base_problem.py | 2 +- pybop/problems/design_problem.py | 1 + pybop/problems/fitting_problem.py | 3 ++- 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/standalone/problem.py b/examples/standalone/problem.py index 7fb916e84..6fdcf97f5 100644 --- a/examples/standalone/problem.py +++ b/examples/standalone/problem.py @@ -1,4 +1,5 @@ import numpy as np + from pybop import BaseProblem diff --git a/pybop/observers/observer.py b/pybop/observers/observer.py index 35cb5578a..38c7ef863 100644 --- a/pybop/observers/observer.py +++ b/pybop/observers/observer.py @@ -1,6 +1,7 @@ from typing import List, Optional import numpy as np + from pybop import BaseProblem from pybop.models.base_model import BaseModel, Inputs, TimeSeriesState from pybop.parameters.parameter import Parameter diff --git a/pybop/problems/base_problem.py b/pybop/problems/base_problem.py index 7aced2686..ab4a070eb 100644 --- a/pybop/problems/base_problem.py +++ b/pybop/problems/base_problem.py @@ -163,4 +163,4 @@ def set_target(self, dataset): @property def model(self): - return self._model \ No newline at end of file + return self._model diff --git a/pybop/problems/design_problem.py b/pybop/problems/design_problem.py index 0c9efd012..29f58ba95 100644 --- a/pybop/problems/design_problem.py +++ b/pybop/problems/design_problem.py @@ -2,6 +2,7 @@ from pybop import BaseProblem + class DesignProblem(BaseProblem): """ Problem class for design optimization problems. diff --git a/pybop/problems/fitting_problem.py b/pybop/problems/fitting_problem.py index ad4918ed6..6bfa38149 100644 --- a/pybop/problems/fitting_problem.py +++ b/pybop/problems/fitting_problem.py @@ -2,6 +2,7 @@ from pybop import BaseProblem + class FittingProblem(BaseProblem): """ Problem class for fitting (parameter estimation) problems. @@ -127,4 +128,4 @@ def evaluateS1(self, x): t_eval=self._time_data, ) - return (y, np.asarray(dy)) \ No newline at end of file + return (y, np.asarray(dy)) From 3654cd2811058b3be696b6fd9eadce4dd634b192 Mon Sep 17 00:00:00 2001 From: Ferran Brosa Planella Date: Mon, 8 Apr 2024 16:41:02 +0100 Subject: [PATCH 4/5] ruff --- pybop/problems/base_problem.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pybop/problems/base_problem.py b/pybop/problems/base_problem.py index 7aced2686..b3deed64e 100644 --- a/pybop/problems/base_problem.py +++ b/pybop/problems/base_problem.py @@ -1,3 +1,4 @@ +import pybop import numpy as np From c2147bafb0bf79db0121d7f00124eca8855573be Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 8 Apr 2024 15:41:37 +0000 Subject: [PATCH 5/5] style: pre-commit fixes --- pybop/problems/base_problem.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pybop/problems/base_problem.py b/pybop/problems/base_problem.py index f3621b4e6..37a6af253 100644 --- a/pybop/problems/base_problem.py +++ b/pybop/problems/base_problem.py @@ -1,6 +1,7 @@ -import pybop import numpy as np +import pybop + class BaseProblem: """