From 89ae3e6b11df9e0eb1395b4159a25fe7670878a8 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 24 May 2024 16:13:47 +0100 Subject: [PATCH 01/31] feat: initial Monte Carlo classes --- CHANGELOG.md | 1 + examples/scripts/mcmc_example.py | 91 ++++ pybop/parameters/priors.py | 161 ++++++ pybop/samplers/__init__.py | 72 +++ pybop/samplers/base_mcmc.py | 372 ++++++++++++++ pybop/samplers/mcmc_sampler.py | 110 ++++ pybop/samplers/pints_samplers.py | 838 +++++++++++++++++++++++++++++++ 7 files changed, 1645 insertions(+) create mode 100644 examples/scripts/mcmc_example.py create mode 100644 pybop/samplers/__init__.py create mode 100644 pybop/samplers/base_mcmc.py create mode 100644 pybop/samplers/mcmc_sampler.py create mode 100644 pybop/samplers/pints_samplers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c50224035..d0d5f7cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features +- [#6](https://github.com/pybop-team/PyBOP/issues/6) - Adds Monte Carlo functionality, with methods based on Pints' algorithms. A base class is added `BaseSampler`, in addition to `PintsBaseSampler`. - [#321](https://github.com/pybop-team/PyBOP/pull/321) - Updates Prior classes with BaseClass, adds a `problem.sample_initial_conditions` method to improve stability of SciPy.Minimize optimiser. - [#249](https://github.com/pybop-team/PyBOP/pull/249) - Add WeppnerHuggins model and GITT example. - [#304](https://github.com/pybop-team/PyBOP/pull/304) - Decreases the testing suite completion time. diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py new file mode 100644 index 000000000..a0409d502 --- /dev/null +++ b/examples/scripts/mcmc_example.py @@ -0,0 +1,91 @@ +import numpy as np +import plotly.graph_objects as go + +import pybop + +# Parameter set and model definition +parameter_set = pybop.ParameterSet.pybamm("Chen2020") +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) + +# Fitting parameters +parameters = [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.GaussianLogPrior(0.68, 0.05), + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.GaussianLogPrior(0.58, 0.05), + ), +] + +# Generate data +init_soc = 0.5 +sigma = 0.001 +experiment = pybop.Experiment( + [ + ( + "Discharge at 0.5C for 3 minutes (2 second period)", + "Charge at 0.5C for 3 minutes (2 second period)", + ), + ] + * 2 +) +values = model.predict(init_soc=init_soc, experiment=experiment) + + +def noise(sigma): + return np.random.normal(0, sigma, len(values["Voltage [V]"].data)) + + +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": values["Time [s]"].data, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": values["Voltage [V]"].data + noise(sigma), + "Bulk open-circuit voltage [V]": values["Bulk open-circuit voltage [V]"].data + + noise(sigma), + } +) + +signal = ["Voltage [V]", "Bulk open-circuit voltage [V]"] + +# Generate problem, cost function, and optimisation class +problem = pybop.FittingProblem( + model, parameters, dataset, signal=signal, init_soc=init_soc +) +likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.02, 0.02]) +prior1 = pybop.GaussianLogPrior(0.7, 0.02) +prior2 = pybop.GaussianLogPrior(0.6, 0.02) +composed_prior = pybop.ComposedLogPrior(prior1, prior2) +posterior = pybop.LogPosterior(likelihood, composed_prior) +x0 = [[0.68, 0.58], [0.68, 0.58], [0.68, 0.58]] + +optim = pybop.DREAM( + posterior, + chains=3, + x0=x0, + max_iterations=400, + initial_phase_iterations=250, + parallel=True, +) +result = optim.run() + + +# Create a histogram +fig = go.Figure() +for i, data in enumerate(result): + fig.add_trace(go.Histogram(x=data[:, 0], name="Neg", opacity=0.75)) + fig.add_trace(go.Histogram(x=data[:, 1], name="Pos", opacity=0.75)) + +# Update layout for better visualization +fig.update_layout( + title="Posterior distribution of volume fractions", + xaxis_title="Value", + yaxis_title="Count", + barmode="overlay", +) + +# Show the plot +fig.show() diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index 10472e17f..c92e4cf68 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -120,6 +120,10 @@ def sigma(self): """ return self.scale + @property + def n_parameters(self): + return self._n_parameters + class Gaussian(BasePrior): """ @@ -199,3 +203,160 @@ def __init__(self, scale, loc=0, random_state=None): self.loc = loc self.scale = scale self.prior = stats.expon + + +class GaussianLogPrior(BasePrior): + """ + Represents a log-normal distribution with a given mean and standard deviation. + + This class provides methods to calculate the probability density function (pdf), + the logarithm of the pdf, and to generate random variates (rvs) from the distribution. + + Parameters + ---------- + mean : float + The mean of the log-normal distribution. + sigma : float + The standard deviation of the log-normal distribution. + """ + + def __init__(self, mean, sigma, random_state=None): + self.name = "Gaussian Log Prior" + self.loc = mean + self.scale = sigma + self.prior = stats.norm + self._offset = -0.5 * np.log(2 * np.pi * self.scale**2) + self.sigma2 = self.scale**2 + self._multip = -1 / (2.0 * self.sigma2) + self._n_parameters = 1 + + def __call__(self, x): + """ + Evaluates the gaussian (log) distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the distribution. + + Returns + ------- + float + The value(s) of the distribution at x. + """ + x = np.asarray(x) + return self._offset + self._multip * (x[0] - self.loc) ** 2 + + def evaluateS1(self, x): + """ + Evaluates the first derivative of the gaussian (log) distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the first derivative. + + Returns + ------- + float + The value(s) of the first derivative at x. + """ + if not isinstance(x, np.ndarray): + x = np.asarray(x) + return self(x), -(x - self.loc) * self._multip + + def icdf(self, q): + """ + Calculates the inverse cumulative distribution function (CDF) of the distribution at q. + + Parameters + ---------- + q : float + The point(s) at which to evaluate the inverse CDF. + + Returns + ------- + float + The inverse cumulative distribution function value at q. + """ + return self.prior.ppf(q, s=self.scale, loc=self.loc) + + def cdf(self, x): + """ + Calculates the cumulative distribution function (CDF) of the distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the CDF. + + Returns + ------- + float + The cumulative distribution function value at x. + """ + return self.prior.cdf(x, s=self.scale, loc=self.loc) + + +class ComposedLogPrior(BasePrior): + """ + Represents a composition of multiple prior distributions. + """ + + def __init__(self, *priors): + self._priors = priors + for prior in priors: + if not isinstance(prior, BasePrior): + raise ValueError("All priors must be instances of BasePrior") + + self._n_parameters = len(priors) # Needs to be updated + + def __call__(self, x): + """ + Evaluates the composed prior distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the distribution. + + Returns + ------- + float + The value(s) of the distribution at x. + """ + if not isinstance(x, np.ndarray): + x = np.asarray(x) + return sum(prior(x) for prior in self._priors) + + def evaluateS1(self, x): + """ + Evaluates the first derivative of the composed prior distribution at x. + Inspired by PINTS implementation. + + *This method only works if the underlying :class:`LogPrior` classes all + implement the optional method :class:`LogPDF.evaluateS1().`.* + + Parameters + ---------- + x : float + The point(s) at which to evaluate the first derivative. + + Returns + ------- + float + The value(s) of the first derivative at x. + """ + output = 0 + doutput = np.zeros(self.n_parameters) + index = 0 + + for prior in self._priors: + num_params = prior.n_parameters + x_subset = x[index : index + num_params] + p, dp = prior.evaluateS1(x_subset) + output += p + doutput[index : index + num_params] = dp + index += num_params + + return output, doutput diff --git a/pybop/samplers/__init__.py b/pybop/samplers/__init__.py new file mode 100644 index 000000000..26a488c74 --- /dev/null +++ b/pybop/samplers/__init__.py @@ -0,0 +1,72 @@ +import numpy as np +from pints import ParallelEvaluator +import warnings + +class BaseSampler: + """ + Base class for Monte Carlo samplers. + """ + def __init__(self, x0, sigma0): + """ + Initialise the base sampler. + + Args: + cost (pybop.cost): The cost to be sampled. + """ + self._x0 = x0 + self._sigma0 = sigma0 + + def run(self) -> np.ndarray: + """ + Sample from the posterior distribution. + + Args: + n_samples (int): Number of samples to draw. + + Returns: + np.ndarray: Samples from the posterior distribution. + """ + raise NotImplementedError + + def set_initial_phase_iterations(self, iterations=250): + """ + Set the number of iterations for the initial phase of the sampler. + + Args: + iterations (int): Number of iterations for the initial phase. + """ + self._initial_phase_iterations = iterations + + def set_max_iterations(self, iterations=500): + """ + Set the maximum number of iterations for the sampler. + + Args: + iterations (int): Maximum number of iterations. + """ + iterations = int(iterations) + if iterations < 1: + raise ValueError("Number of iterations must be greater than 0") + + self._max_iterations = iterations + + def set_parallel(self, parallel=False): + """ + Enable or disable parallel evaluation. + Credit: PINTS + + Parameters + ---------- + parallel : bool or int, optional + If True, use as many worker processes as there are CPU cores. If an integer, use that many workers. + If False or 0, disable parallelism (default: False). + """ + if parallel is True: + self._parallel = True + self._n_workers = ParallelEvaluator.cpu_count() + elif parallel >= 1: + self._parallel = True + self._n_workers = int(parallel) + else: + self._parallel = False + self._n_workers = 1 diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py new file mode 100644 index 000000000..319e343ca --- /dev/null +++ b/pybop/samplers/base_mcmc.py @@ -0,0 +1,372 @@ +import logging +from typing import List, Optional, Union + +import numpy as np +from pints import ( + MultiSequentialEvaluator, + ParallelEvaluator, + SequentialEvaluator, + SingleChainMCMC, +) + +from pybop import BaseCost, BaseSampler, LogPosterior + + +class BasePintsSampler(BaseSampler): + """ + Base class for PINTS samplers. + + This class extends the BaseSampler class to provide a common interface for + PINTS samplers. The class provides a sample() method that can be used to + sample from the posterior distribution using a PINTS sampler. + """ + + def __init__( + self, + log_pdf: Union[BaseCost, List[BaseCost]], + chains: int, + sampler, + x0=None, + sigma0=None, + transformation=None, + **kwargs, + ): + """ + Initialise the base PINTS sampler. + + Args: + log_pdf (pybop.BaseCost or List[pybop.BaseCost]): The cost distribution(s) to be sampled. + chains (int): Number of chains to be used. + sampler: The sampler class to be used. + x0 (list): Initial states for the chains. + sigma0: Initial standard deviation for the chains. + transformation: Transformation to be applied to the samples. + kwargs: Additional keyword arguments. + """ + super().__init__(x0, sigma0) + + # Set kwargs + self._max_iterations = kwargs.get("max_iterations", 500) + self._log_to_screen = kwargs.get("log_to_screen", True) + self._log_filename = kwargs.get("log_filename", None) + self._num_warmup = kwargs.get("num_warmup", 250) + self._initial_phase_iterations = kwargs.get("initial_phase_iterations", 250) + self._chains_in_memory = kwargs.get("chains_in_memory", True) + self._chain_files = kwargs.get("chain_files", None) + self._evaluation_files = kwargs.get("evaluation_files", None) + self._parallel = kwargs.get("parallel", False) + self._verbose = kwargs.get("verbose", False) + self._n_parameters = ( + log_pdf[0]._n_parameters + if isinstance(log_pdf, list) + else log_pdf._n_parameters + ) + self._transformation = transformation + + # Check log_pdf + if isinstance(log_pdf, BaseCost): + self._multi_log_pdf = False + else: + if len(log_pdf) != chains: + raise ValueError("Number of log pdf's must match number of chains") + + first_pdf_parameters = log_pdf[0].n_parameters() + for pdf in log_pdf: + if not isinstance(pdf, BaseCost): + raise ValueError("All log pdf's must be instances of BaseCost") + if pdf.n_parameters() != first_pdf_parameters: + raise ValueError( + "All log pdf's must have the same number of parameters" + ) + + self._multi_log_pdf = True + + # Transformations + if transformation is not None: + self._apply_transformation(transformation) + + self._log_pdf = log_pdf + + # Number of chains + self._n_chains = chains + if self._n_chains < 1: + raise ValueError("Number of chains must be greater than 0") + + # Check initial conditions + # len of x0 matching number of chains, number of parameters, etc. + + # Single chain vs multiple chain samplers + self._single_chain = issubclass(sampler, SingleChainMCMC) + + # Construct the samplers object + try: + if self._single_chain: + self._n_samplers = self._n_chains + self._samplers = [sampler(x0, sigma0=self._sigma0) for x0 in self._x0] + else: + self._n_samplers = 1 + self._samplers = [sampler(self._n_chains, self._x0, self._sigma0)] + except Exception as e: + raise ValueError(f"Error constructing samplers: {e}") + + # Check for sensitivities from sampler and set evaluation + self._needs_sensitivities = self._samplers[0].needs_sensitivities() + + # Check initial phase + self._initial_phase = self._samplers[0].needs_initial_phase() + if self._initial_phase: + self.set_initial_phase_iterations() + + # Parallelisation (Might be able to move into parent class) + self._n_workers = 1 + self.set_parallel(self._parallel) + + def _apply_transformation(self, transformation): + # TODO: Implement transformation logic + pass + + def run(self) -> Optional[np.ndarray]: + """ + Executes the Monte Carlo sampling process and generates samples + from the posterior distribution. + + This method orchestrates the entire sampling process, managing + iterations, evaluations, logging, and stopping criteria. It + initialises the necessary structures, handles both single and + multi-chain scenarios, and manages parallel or sequential + evaluation based on the configuration. + + Returns: + np.ndarray: A numpy array containing the samples from the + posterior distribution if chains are stored in memory, + otherwise returns None. + + Raises: + ValueError: If no stopping criterion is set (i.e., + _max_iterations is None). + + Details: + - Checks and ensures at least one stopping criterion is set. + - Initialises iterations, evaluations, and other required + structures. + - Sets up the evaluator (parallel or sequential) based on the + configuration. + - Handles the initial phase, if applicable, and manages + intermediate steps in the sampling process. + - Logs progress and relevant information based on the logging + configuration. + - Iterates through the sampling process, evaluating the log + PDF, updating chains, and managing the stopping criteria. + - Finalises and returns the collected samples, or None if + chains are not stored in memory. + """ + + self._initialise_logging() + self._check_stopping_criteria() + + # Initialise iterations and evaluations + self._iteration = 0 + self._evaluations = 0 + + evaluator = self._create_evaluator() + self._check_initial_phase() + self._initialise_storage() + + running = True + while running: + if ( + self._initial_phase + and self._iteration == self._initial_phase_iterations + ): + self._end_initial_phase() + + xs = self._ask_for_samples() + self.fxs = evaluator.evaluate(xs) + self._evaluations += len(self.fxs) + + if self._single_chain: + self._process_single_chain() + self._intermediate_step = min(self._n_samples) <= self._iteration + else: + self._process_multi_chain() + + if self._intermediate_step: + continue + + self._iteration += 1 + if self._log_to_screen and self._verbose: + logging.info(f"Iteration: {self._iteration}") # TODO: Add more info + + if self._max_iterations and self._iteration >= self._max_iterations: + running = False + + self._finalise_logging() + + return self._samples if self._chains_in_memory else None + + def _process_single_chain(self): + self.fxs_iterator = iter(self.fxs) + for i in list(self._active): + reply = self._samplers[i].tell(next(self.fxs_iterator)) + if reply: + y, fy, accepted = reply + y_store = self._inverse_transform(y) + if self._chains_in_memory: + self._samples[i][self._n_samples[i]] = y_store + else: + self._samples[i] = y_store + + if accepted: + self._sampled_logpdf[i] = ( + fy[0] if self._needs_sensitivities else fy + ) # Not storing sensitivities + if self._prior: + self._sampled_prior[i] = self._prior(y) + + e = self._sampled_logpdf[i] + if self._prior: + e = [ + e, + self._sampled_logpdf[i] - self._sampled_prior[i], + self._sampled_prior[i], + ] + + self._evaluations[i][self._n_samples[i]] = e + self._n_samples[i] += 1 + if self._n_samples[i] == self._max_iterations: + self._active.remove(i) + + def _process_multi_chain(self): + reply = self._samplers[0].tell(self.fxs) + self._intermediate_step = reply is None + if reply: + ys, fys, accepted = reply + ys_store = np.array([self._inverse_transform(y) for y in ys]) + if self._chains_in_memory: + self._samples[:, self._iteration] = ys_store + else: + self._samples = ys_store + + es = [] + for i, y in enumerate(ys): + if accepted[i]: + self._sampled_logpdf[i] = ( + fys[0][i] if self._needs_sensitivities else fys[i] + ) + if self._prior: + self._sampled_prior[i] = self._prior(ys[i]) + e = self._sampled_logpdf[i] + if self._prior: + e = [ + e, + self._sampled_logpdf[i] - self._sampled_prior[i], + self._sampled_prior[i], + ] + es.append(e) + + for i, e in enumerate(es): + self._evaluations[i, self._iteration] = e + + def _initialise_logging(self): + logging.basicConfig(format="%(message)s", level=logging.INFO) + + if self._log_to_screen: + logging.info("Using " + str(self._samplers[0].name())) + logging.info("Generating " + str(self._n_chains) + " chains.") + if self._parallel: + logging.info( + f"Running in parallel with {self._n_workers} worker processes." + ) + else: + logging.info("Running in sequential mode.") + if self._chain_files: + logging.info("Writing chains to " + self._chain_files[0] + " etc.") + if self._evaluation_files: + logging.info( + "Writing evaluations to " + self._evaluation_files[0] + " etc." + ) + + def _check_stopping_criteria(self): + has_stopping_criterion = False + has_stopping_criterion |= self._max_iterations is not None + if not has_stopping_criterion: + raise ValueError("At least one stopping criterion must be set.") + + def _create_evaluator(self): + f = self._log_pdf + # Check for sensitivities from sampler and set evaluator + if self._needs_sensitivities: + if not self._multi_log_pdf: + f = f.evaluateS1 + else: + f = [pdf.evaluateS1 for pdf in f] + + if self._parallel: + if not self._multi_log_pdf: + self._n_workers = min(self._n_workers, self._n_chains) + return ParallelEvaluator(f, n_workers=self._n_workers) + else: + return ( + SequentialEvaluator(f) + if not self._multi_log_pdf + else MultiSequentialEvaluator(f) + ) + + def _check_initial_phase(self): + # Set initial phase if needed + if self._initial_phase: + for sampler in self._samplers: + sampler.set_initial_phase(True) + + def _inverse_transform(self, y): + return self._transformation.to_model(y) if self._transformation else y + + def _initialise_storage(self): + self._prior = None + if isinstance(self._log_pdf, LogPosterior): + self._prior = self._log_pdf.prior() + + # Storage of the received samples + self._sampled_logpdf = np.zeros(self._n_chains) + self._sampled_prior = np.zeros(self._n_chains) + + # Pre-allocate arrays for chain storage + self._samples = np.zeros( + (self._n_chains, self._max_iterations, self._n_parameters) + ) + + # Pre-allocate arrays for evaluation storage + if self._prior: + # Store posterior, likelihood, prior + self._evaluations = np.zeros((self._n_chains, self._max_iterations, 3)) + else: + # Store pdf + self._evaluations = np.zeros((self._n_chains, self._max_iterations)) + + # From PINTS: + # Some samplers need intermediate steps, where `None` is returned instead + # of a sample. But samplers can run asynchronously, so that one can return + # `None` while another returns a sample. To deal with this, we maintain a + # list of 'active' samplers that have not reached `max_iterations`, + # and store the number of samples so far in each chain. + if self._single_chain: + self._active = list(range(self._n_chains)) + self._n_samples = [0] * self._n_chains + + def _end_initial_phase(self): + for sampler in self._samplers: + sampler.set_initial_phase(False) + if self._log_to_screen: + logging.info("Initial phase completed.") + + def _ask_for_samples(self): + if self._single_chain: + return [self._samplers[i].ask() for i in self._active] + else: + return self._samplers[0].ask() + + def _finalise_logging(self): + if self._log_to_screen: + logging.info( + f"Halting: Maximum number of iterations ({self._iteration}) reached." + ) diff --git a/pybop/samplers/mcmc_sampler.py b/pybop/samplers/mcmc_sampler.py new file mode 100644 index 000000000..f2c3447d9 --- /dev/null +++ b/pybop/samplers/mcmc_sampler.py @@ -0,0 +1,110 @@ +from pybop import AdaptiveCovarianceMCMC + + +class MCMCSampler: + """ + A high-level class for MCMC sampling. + + This class provides an alternative API to the `PyBOP.Sampler()` API, + specifically allowing for single user-friendly interface for the + optimisation process. + """ + + def __init__( + self, + log_pdf, + chains, + sampler=AdaptiveCovarianceMCMC, + x0=None, + sigma0=None, + **kwargs, + ): + """ + Initialize the MCMCSampler. + + Parameters + ---------- + log_pdf : pybop.BaseCost + The log-probability density function to be sampled. + chains : int + The number of MCMC chains to be run. + sampler : pybop.MCMCSampler, optional + The MCMC sampler class to be used. Defaults to `pybop.MCMC`. + x0 : np.ndarray, optional + Initial positions for the MCMC chains. Defaults to None. + sigma0 : np.ndarray, optional + Initial step sizes for the MCMC chains. Defaults to None. + **kwargs : dict + Additional keyword arguments to pass to the sampler. + + Raises + ------ + ValueError + If the sampler could not be constructed due to an exception. + """ + + try: + self.sampler = sampler(log_pdf, chains, x0=x0, sigma0=sigma0, **kwargs) + except Exception as e: + raise ValueError( + f"Sampler could not be constructed, raised an exception: {e}" + ) + + def run(self): + """ + Run the MCMC sampling process. + + Returns + ------- + list + The result of the sampling process. + """ + return self.sampler.run() + + def __getattr__(self, attr): + """ + Delegate attribute access to the underlying sampler if the attribute + is not found in the MCMCSampler instance. + + Parameters + ---------- + attr : str + The attribute name to be accessed. + + Returns + ------- + Any + The attribute value from the underlying sampler. + + Raises + ------ + AttributeError + If the attribute is not found in both the MCMCSampler instance + and the underlying sampler. + """ + if "sampler" in self.__dict__ and hasattr(self.sampler, attr): + return getattr(self.sampler, attr) + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{attr}'" + ) + + def __setattr__(self, name: str, value) -> None: + """ + Delegate attribute setting to the underlying sampler if the attribute + exists in the sampler and not in the MCMCSampler instance. + + Parameters + ---------- + name : str + The attribute name to be set. + value : Any + The value to be set to the attribute. + """ + if ( + name in self.__dict__ + or "sampler" not in self.__dict__ + or not hasattr(self.sampler, name) + ): + object.__setattr__(self, name, value) + else: + setattr(self.sampler, name, value) diff --git a/pybop/samplers/pints_samplers.py b/pybop/samplers/pints_samplers.py new file mode 100644 index 000000000..b3a1d0cb0 --- /dev/null +++ b/pybop/samplers/pints_samplers.py @@ -0,0 +1,838 @@ +from pints import MALAMCMC as PintsMALAMCMC +from pints import AdaptiveCovarianceMCMC as PintsAdaptiveCovarianceMCMC +from pints import DifferentialEvolutionMCMC as PintsDifferentialEvolutionMCMC +from pints import DramACMC as PintsDramACMC +from pints import DreamMCMC as PintsDREAM +from pints import EmceeHammerMCMC as PintsEmceeHammerMCMC +from pints import HaarioACMC as PintsHaarioACMC +from pints import HaarioBardenetACMC as PintsHaarioBardenetACMC +from pints import HamiltonianMCMC as PintsHamiltonianMCMC +from pints import MetropolisRandomWalkMCMC as PintsMetropolisRandomWalkMCMC +from pints import MonomialGammaHamiltonianMCMC as PintsMonomialGammaHamiltonianMCMC +from pints import NoUTurnMCMC +from pints import PopulationMCMC as PintsPopulationMCMC +from pints import RaoBlackwellACMC as PintsRaoBlackwellACMC +from pints import RelativisticMCMC as PintsRelativisticMCMC +from pints import SliceDoublingMCMC as PintsSliceDoublingMCMC +from pints import SliceRankShrinkingMCMC as PintsSliceRankShrinkingMCMC +from pints import SliceStepoutMCMC as PintsSliceStepoutMCMC + +from pybop import BasePintsSampler + + +class NUTS(BasePintsSampler): + """ + Implements the No-U-Turn Sampler (NUTS) algorithm. + + This class extends the NUTS sampler from the PINTS library. + NUTS is a Markov chain Monte Carlo (MCMC) method for sampling + from a probability distribution. It is an extension of the + Hamiltonian Monte Carlo (HMC) method, which uses a dynamic + integration time to explore the parameter space more efficiently. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the NUTS sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The NUTS sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__(log_pdf, chains, NoUTurnMCMC, x0=x0, sigma0=sigma0, **kwargs) + + +class DREAM(BasePintsSampler): + """ + Implements the DiffeRential Evolution Adaptive Metropolis (DREAM) algorithm. + + This class extends the DREAM sampler from the PINTS library. + DREAM is a Markov chain Monte Carlo (MCMC) method for sampling + from a probability distribution. It combines the Differential + Evolution (DE) algorithm with the Adaptive Metropolis (AM) algorithm + to explore the parameter space more efficiently. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the DREAM sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The DREAM sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__(log_pdf, chains, PintsDREAM, x0=x0, sigma0=sigma0, **kwargs) + + +class AdaptiveCovarianceMCMC(BasePintsSampler): + """ + Implements the Adaptive Covariance Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Adaptive Covariance MCMC sampler from the PINTS library. + This MCMC method adapts the proposal distribution covariance matrix + during the sampling process to improve efficiency and convergence. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Adaptive Covariance MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Adaptive Covariance MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsAdaptiveCovarianceMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class DifferentialEvolutionMCMC(BasePintsSampler): + """ + Implements the Differential Evolution Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Differential Evolution MCMC sampler from the PINTS library. + This MCMC method uses the Differential Evolution algorithm to explore the + parameter space more efficiently by evolving a population of chains. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Differential Evolution MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Differential Evolution MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsDifferentialEvolutionMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class DramACMC(BasePintsSampler): + """ + Implements the Delayed Rejection Adaptive Metropolis (DRAM) Adaptive Covariance Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the DRAM Adaptive Covariance MCMC sampler from the PINTS library. + This MCMC method combines Delayed Rejection with Adaptive Metropolis to enhance + the efficiency and robustness of the sampling process. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the DRAM Adaptive Covariance MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The DRAM Adaptive Covariance MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsDramACMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class EmceeHammerMCMC(BasePintsSampler): + """ + Implements the Emcee Hammer Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Emcee Hammer MCMC sampler from the PINTS library. + The Emcee Hammer is an affine-invariant ensemble sampler for MCMC, which is + particularly effective for high-dimensional parameter spaces. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Emcee Hammer MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Emcee Hammer MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsEmceeHammerMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class HaarioACMC(BasePintsSampler): + """ + Implements the Haario Adaptive Covariance Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Haario Adaptive Covariance MCMC sampler from the PINTS library. + This MCMC method adapts the proposal distribution's covariance matrix based on the + history of the chain, improving sampling efficiency and convergence. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Haario Adaptive Covariance MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Haario Adaptive Covariance MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsHaarioACMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class HaarioBardenetACMC(BasePintsSampler): + """ + Implements the Haario-Bardenet Adaptive Covariance Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Haario-Bardenet Adaptive Covariance MCMC sampler from the PINTS library. + This MCMC method combines the adaptive covariance approach with an additional + mechanism to improve performance in high-dimensional parameter spaces. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Haario-Bardenet Adaptive Covariance MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Haario-Bardenet Adaptive Covariance MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsHaarioBardenetACMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class HamiltonianMCMC(BasePintsSampler): + """ + Implements the Hamiltonian Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Hamiltonian MCMC sampler from the PINTS library. + This MCMC method uses Hamiltonian dynamics to propose new states, + allowing for efficient exploration of high-dimensional parameter spaces. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Hamiltonian MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Hamiltonian MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsHamiltonianMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class MALAMCMC(BasePintsSampler): + """ + Implements the Metropolis Adjusted Langevin Algorithm (MALA) Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the MALA MCMC sampler from the PINTS library. + This MCMC method combines the Metropolis-Hastings algorithm with + Langevin dynamics to improve sampling efficiency and convergence. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the MALA MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The MALA MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsMALAMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class MetropolisRandomWalkMCMC(BasePintsSampler): + """ + Implements the Metropolis Random Walk Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Metropolis Random Walk MCMC sampler from the PINTS library. + This classic MCMC method uses a simple random walk proposal distribution + and the Metropolis-Hastings acceptance criterion. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Metropolis Random Walk MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Metropolis Random Walk MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsMetropolisRandomWalkMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class MonomialGammaHamiltonianMCMC(BasePintsSampler): + """ + Implements the Monomial Gamma Hamiltonian Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Monomial Gamma Hamiltonian MCMC sampler from the PINTS library. + This MCMC method uses Hamiltonian dynamics with a monomial gamma distribution + for efficient exploration of the parameter space. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Monomial Gamma Hamiltonian MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Monomial Gamma Hamiltonian MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsMonomialGammaHamiltonianMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class PopulationMCMC(BasePintsSampler): + """ + Implements the Population Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Population MCMC sampler from the PINTS library. + This MCMC method uses a population of chains at different temperatures + to explore the parameter space more efficiently and avoid local minima. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Population MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Population MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsPopulationMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class RaoBlackwellACMC(BasePintsSampler): + """ + Implements the Rao-Blackwell Adaptive Covariance Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Rao-Blackwell Adaptive Covariance MCMC sampler from the PINTS library. + This MCMC method improves sampling efficiency by combining Rao-Blackwellisation + with adaptive covariance strategies. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Rao-Blackwell Adaptive Covariance MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Rao-Blackwell Adaptive Covariance MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsRaoBlackwellACMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class RelativisticMCMC(BasePintsSampler): + """ + Implements the Relativistic Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Relativistic MCMC sampler from the PINTS library. + This MCMC method uses concepts from relativistic mechanics to propose new states, + allowing for efficient exploration of the parameter space. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Relativistic MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Relativistic MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsRelativisticMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class SliceDoublingMCMC(BasePintsSampler): + """ + Implements the Slice Doubling Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Slice Doubling MCMC sampler from the PINTS library. + This MCMC method uses slice sampling with a doubling procedure to propose new states, + allowing for efficient exploration of the parameter space. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Slice Doubling MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Slice Doubling MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsSliceDoublingMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class SliceRankShrinkingMCMC(BasePintsSampler): + """ + Implements the Slice Rank Shrinking Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Slice Rank Shrinking MCMC sampler from the PINTS library. + This MCMC method uses slice sampling with a rank shrinking procedure to propose new states, + allowing for efficient exploration of the parameter space. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Slice Rank Shrinking MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Slice Rank Shrinking MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsSliceRankShrinkingMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) + + +class SliceStepoutMCMC(BasePintsSampler): + """ + Implements the Slice Stepout Markov Chain Monte Carlo (MCMC) algorithm. + + This class extends the Slice Stepout MCMC sampler from the PINTS library. + This MCMC method uses slice sampling with a stepout procedure to propose new states, + allowing for efficient exploration of the parameter space. + + Parameters + ---------- + log_pdf : function + A function that calculates the log-probability density. + chains : int + The number of chains to run. + x0 : ndarray, optional + Initial positions for the chains. + sigma0 : ndarray, optional + Initial covariance matrix. + **kwargs + Additional arguments to pass to the Slice Stepout MCMC sampler. + + Attributes + ---------- + log_pdf : function + The log-probability density function. + chains : int + The number of chains being run. + sampler_class : class + The Slice Stepout MCMC sampler class from PINTS. + x0 : ndarray + The initial positions of the chains. + sigma0 : ndarray + The initial covariance matrix. + """ + + def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + super().__init__( + log_pdf, + chains, + PintsSliceStepoutMCMC, + x0=x0, + sigma0=sigma0, + **kwargs, + ) From 3c48f8846a46e722e4d09e6658c4ed107bc2213c Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 24 May 2024 16:14:48 +0100 Subject: [PATCH 02/31] feat: updt __init__.py, add LogPosterior --- pybop/__init__.py | 22 ++++++- pybop/costs/_likelihoods.py | 113 ++++++++++++++++++++++++++++++++- tests/unit/test_likelihoods.py | 5 -- 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/pybop/__init__.py b/pybop/__init__.py index fdef8c81e..6e6de6d15 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -71,6 +71,7 @@ BaseLikelihood, GaussianLogLikelihood, GaussianLogLikelihoodKnownSigma, + LogPosterior, ) # @@ -113,13 +114,30 @@ XNES, ) +# +# Monte Carlo classes +# +from .samplers import BaseSampler +from .samplers.base_mcmc import BasePintsSampler +from .samplers.pints_samplers import ( + NUTS, DREAM, AdaptiveCovarianceMCMC, + DifferentialEvolutionMCMC, DramACMC, + EmceeHammerMCMC, + HaarioACMC, HaarioBardenetACMC, + HamiltonianMCMC, MALAMCMC, + MetropolisRandomWalkMCMC, MonomialGammaHamiltonianMCMC, + PopulationMCMC, RaoBlackwellACMC, + RelativisticMCMC, SliceDoublingMCMC, + SliceRankShrinkingMCMC, SliceStepoutMCMC, +) +from .samplers.mcmc_sampler import MCMCSampler + # # Parameter classes # from .parameters.parameter import Parameter from .parameters.parameter_set import ParameterSet -from .parameters.priors import BasePrior, Gaussian, Uniform, Exponential - +from .parameters.priors import BasePrior, Gaussian, Uniform, Exponential, GaussianLogPrior, ComposedLogPrior # # Observer classes diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index 91374cc07..5aec27a1b 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -34,7 +34,8 @@ def get_sigma(self): """ return self.sigma0 - def get_n_parameters(self): + @property + def n_parameters(self): """ Returns the number of parameters """ @@ -182,3 +183,113 @@ def _evaluateS1(self, x, grad=None): dsigma = -self.n_time_data / sigma + sigma**-(3.0) * np.sum(r**2, axis=1) dl = np.concatenate((dl.flatten(), dsigma)) return likelihood, dl + + +class LogPosterior(BaseCost): + """ + The Log Posterior for a given problem. + + Computes the log posterior which is the sum of the log + likelihood and the log prior. + + Inherits all parameters and attributes from ``BaseCost``. + """ + + def __init__(self, log_likelihood, log_prior=None, sigma=None): + super(LogPosterior, self).__init__(problem=log_likelihood.problem, sigma=sigma) + if self.sigma0 is None: + self.sigma0 = [] + for param in self.problem.parameters: # Update for parameters class + self.sigma0.append(param.prior.sigma) + + # Store the likelihood and prior + self._log_likelihood = log_likelihood + self._prior = log_prior + if self._prior is None: + try: + self._prior = [ + param.prior + for i, param in enumerate( + log_likelihood.problem.parameters + ) # Update for parameters class + ] + except Exception as e: + raise ValueError( + f"An error occurred when constructing the Prior class: {e}" + ) + + try: # This is a patch, the n_parameters val needs to be updated across the codebase + self._n_parameters = self._prior.n_parameters + except AttributeError: + self._n_parameters = len(self._prior) + + def _evaluate(self, x, grad=None): + """ + Calculate the posterior cost for a given set of parameters. + + Parameters + ---------- + x : array-like + The parameters for which to evaluate the cost. + grad : array-like, optional + An array to store the gradient of the cost function with respect + to the parameters. + + Returns + ------- + float + The posterior cost. + """ + prior = self._prior(x) + if prior == np.inf: + return prior + return prior + self._log_likelihood.evaluate(x) + + def _evaluateS1(self, x): + """ + Compute the posterior with respect to the parameters. + The method passes the likelihood gradient to the optimiser without modification. + + Parameters + ---------- + x : array-like + The parameters for which to compute the cost and gradient. + + Returns + ------- + tuple + A tuple containing the cost and the gradient. The cost is a float, + and the gradient is an array-like of the same length as `x`. + + Raises + ------ + ValueError + If an error occurs during the calculation of the cost or gradient. + """ + prior, dp = self._prior.evaluateS1(x) + if prior == np.inf: + return prior + likelihood, dl = self._log_likelihood.evaluateS1(x) + return prior + likelihood, dp + dl + + def prior(self): + """ + Return the prior object. + + Returns + ------- + object + The prior object. + """ + return self._prior + + def likelihood(self): + """ + Returns the likelihood. + + Returns + ------- + object + The likelihood object. + """ + return self._log_likelihood diff --git a/tests/unit/test_likelihoods.py b/tests/unit/test_likelihoods.py index a590808c0..4172f4215 100644 --- a/tests/unit/test_likelihoods.py +++ b/tests/unit/test_likelihoods.py @@ -107,11 +107,6 @@ def test_base_likelihood_set_sigma_raises_value_error_for_negative_sigma( with pytest.raises(ValueError): likelihood.set_sigma(np.array([-0.2])) - @pytest.mark.unit - def test_base_likelihood_get_n_parameters(self, one_signal_problem): - likelihood = pybop.BaseLikelihood(one_signal_problem) - assert likelihood.get_n_parameters() == 1 - @pytest.mark.unit def test_base_likelihood_n_parameters_property(self, one_signal_problem): likelihood = pybop.BaseLikelihood(one_signal_problem) From a62a1269f12eacf7b54d8c4b2e205592fd0092c1 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 3 Jun 2024 13:16:11 +0100 Subject: [PATCH 03/31] tests: add unit tests for MCMC samplers --- tests/unit/test_sampling.py | 180 ++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 tests/unit/test_sampling.py diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py new file mode 100644 index 000000000..466e36c3a --- /dev/null +++ b/tests/unit/test_sampling.py @@ -0,0 +1,180 @@ +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +import pybop +from pybop import ( + DREAM, + MALAMCMC, + NUTS, + AdaptiveCovarianceMCMC, + DifferentialEvolutionMCMC, + DramACMC, + EmceeHammerMCMC, + HaarioACMC, + HaarioBardenetACMC, + HamiltonianMCMC, + MetropolisRandomWalkMCMC, + MonomialGammaHamiltonianMCMC, + PopulationMCMC, + RaoBlackwellACMC, + RelativisticMCMC, + SliceDoublingMCMC, + SliceRankShrinkingMCMC, + SliceStepoutMCMC, +) + + +class TestPintsSamplers: + """ + Class for testing the Pints-based MCMC Samplers + """ + + @pytest.fixture + def dataset(self): + return pybop.Dataset( + { + "Time [s]": np.linspace(0, 360, 10), + "Current function [A]": np.zeros(10), + "Voltage [V]": np.ones(10), + } + ) + + @pytest.fixture + def two_parameters(self): + return [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.6, 0.2), + bounds=[0.58, 0.62], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.55, 0.05), + bounds=[0.53, 0.57], + ), + ] + + @pytest.fixture + def model(self): + return pybop.lithium_ion.SPM() + + @pytest.fixture + def cost(self, model, one_parameter, dataset): + problem = pybop.FittingProblem( + model, + one_parameter, + dataset, + ) + return pybop.SumSquaredError(problem) + + @pytest.fixture + def log_posterior(self, model, two_parameters, dataset): + problem = pybop.FittingProblem( + model, + two_parameters, + dataset, + ) + likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.02, 0.02]) + prior1 = pybop.GaussianLogPrior(0.7, 0.02) + prior2 = pybop.GaussianLogPrior(0.6, 0.02) + composed_prior = pybop.ComposedLogPrior(prior1, prior2) + log_posterior = pybop.LogPosterior(likelihood, composed_prior) + + return log_posterior + + @pytest.fixture + def x0(self): + return [[0.68, 0.58], [0.68, 0.58], [0.68, 0.58]] + + @pytest.fixture + def chains(self): + return 3 + + @pytest.mark.parametrize( + "MCMC", + [ + NUTS, + DREAM, + AdaptiveCovarianceMCMC, + DifferentialEvolutionMCMC, + DramACMC, + EmceeHammerMCMC, + HaarioACMC, + HaarioBardenetACMC, + HamiltonianMCMC, + MALAMCMC, + MetropolisRandomWalkMCMC, + MonomialGammaHamiltonianMCMC, + PopulationMCMC, + RaoBlackwellACMC, + RelativisticMCMC, + SliceDoublingMCMC, + SliceRankShrinkingMCMC, + SliceStepoutMCMC, + ], + ) + @pytest.mark.unit + def test_initialization_and_run(self, log_posterior, x0, chains, MCMC): + sampler = MCMC(log_pdf=log_posterior, chains=chains, x0=x0, max_iterations=1) + assert sampler._n_chains == chains + assert sampler._log_pdf == log_posterior + assert (sampler._samplers[0]._x0 == x0[0]).all() + + # Run the sampler + samples = sampler.run() + assert samples is not None + assert samples.shape == (chains, 1, 2) + + @pytest.mark.unit + def test_invalid_initialization(self, log_posterior, x0): + with pytest.raises(ValueError, match="Number of chains must be greater than 0"): + AdaptiveCovarianceMCMC( + log_pdf=log_posterior, + chains=0, + x0=x0, + ) + + with pytest.raises( + ValueError, match="Number of log pdf's must match number of chains" + ): + AdaptiveCovarianceMCMC( + log_pdf=[log_posterior, log_posterior, log_posterior], + chains=2, + x0=x0, + ) + + @pytest.mark.unit + def test_apply_transformation(self, log_posterior, x0, chains): + sampler = AdaptiveCovarianceMCMC( + log_pdf=log_posterior, chains=chains, x0=x0, transformation=MagicMock() + ) + + with patch.object(sampler, "_apply_transformation") as mock_method: + sampler._apply_transformation(sampler._transformation) + mock_method.assert_called_once_with(sampler._transformation) + + @pytest.mark.unit + def test_logging_initialisation(self, log_posterior, x0, chains): + sampler = AdaptiveCovarianceMCMC( + log_pdf=log_posterior, + chains=chains, + x0=x0, + ) + + with patch("logging.basicConfig"), patch("logging.info") as mock_info: + sampler._initialise_logging() + assert mock_info.call_count > 0 + + @pytest.mark.unit + def test_check_stopping_criteria(self, log_posterior, x0, chains): + sampler = AdaptiveCovarianceMCMC( + log_pdf=log_posterior, chains=chains, x0=x0, max_iterations=10 + ) + + sampler._max_iterations = None + with pytest.raises( + ValueError, match="At least one stopping criterion must be set." + ): + sampler._check_stopping_criteria() From fbe56a4d2844b0c765f380f07147276ccf61ff53 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 3 Jun 2024 14:05:23 +0100 Subject: [PATCH 04/31] fix parallel for windows --- examples/scripts/mcmc_example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index a0409d502..9f2e8e6fc 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -68,7 +68,7 @@ def noise(sigma): x0=x0, max_iterations=400, initial_phase_iterations=250, - parallel=True, + # parallel=True, # uncomment to enable parallelisation (MacOS/Linux only) ) result = optim.run() From 8f74b6d4b7b53aaea9634a84025728f13b9da735 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 3 Jun 2024 18:01:07 +0100 Subject: [PATCH 05/31] tests: additional unit tests, refactors priors class --- examples/scripts/mcmc_example.py | 8 +- pybop/__init__.py | 2 +- pybop/costs/_likelihoods.py | 2 +- pybop/parameters/priors.py | 186 ++++++++++++++++++++----------- tests/unit/test_likelihoods.py | 2 +- tests/unit/test_posterior.py | 115 +++++++++++++++++++ tests/unit/test_priors.py | 30 +++++ tests/unit/test_sampling.py | 38 ++++++- 8 files changed, 307 insertions(+), 76 deletions(-) create mode 100644 tests/unit/test_posterior.py diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index 9f2e8e6fc..6f36bb6d8 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -11,11 +11,11 @@ parameters = [ pybop.Parameter( "Negative electrode active material volume fraction", - prior=pybop.GaussianLogPrior(0.68, 0.05), + prior=pybop.Gaussian(0.68, 0.05), ), pybop.Parameter( "Positive electrode active material volume fraction", - prior=pybop.GaussianLogPrior(0.58, 0.05), + prior=pybop.Gaussian(0.58, 0.05), ), ] @@ -56,8 +56,8 @@ def noise(sigma): model, parameters, dataset, signal=signal, init_soc=init_soc ) likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.02, 0.02]) -prior1 = pybop.GaussianLogPrior(0.7, 0.02) -prior2 = pybop.GaussianLogPrior(0.6, 0.02) +prior1 = pybop.Gaussian(0.7, 0.02) +prior2 = pybop.Gaussian(0.6, 0.02) composed_prior = pybop.ComposedLogPrior(prior1, prior2) posterior = pybop.LogPosterior(likelihood, composed_prior) x0 = [[0.68, 0.58], [0.68, 0.58], [0.68, 0.58]] diff --git a/pybop/__init__.py b/pybop/__init__.py index 621e5aade..df194fa1c 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -138,7 +138,7 @@ # from .parameters.parameter import Parameter from .parameters.parameter_set import ParameterSet -from .parameters.priors import BasePrior, Gaussian, Uniform, Exponential, GaussianLogPrior, ComposedLogPrior +from .parameters.priors import BasePrior, Gaussian, Uniform, Exponential, ComposedLogPrior # # Observer classes diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index 5aec27a1b..5b6ed4fec 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -268,7 +268,7 @@ def _evaluateS1(self, x): """ prior, dp = self._prior.evaluateS1(x) if prior == np.inf: - return prior + return prior, dp likelihood, dl = self._log_likelihood.evaluateS1(x) return prior + likelihood, dp + dl diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index c92e4cf68..43a99e0eb 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -56,6 +56,38 @@ def logpdf(self, x): """ return self.prior.logpdf(x, loc=self.loc, scale=self.scale) + def icdf(self, q): + """ + Calculates the inverse cumulative distribution function (CDF) of the distribution at q. + + Parameters + ---------- + q : float + The point(s) at which to evaluate the inverse CDF. + + Returns + ------- + float + The inverse cumulative distribution function value at q. + """ + return self.prior.ppf(q, scale=self.scale, loc=self.loc) + + def cdf(self, x): + """ + Calculates the cumulative distribution function (CDF) of the distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the CDF. + + Returns + ------- + float + The cumulative distribution function value at x. + """ + return self.prior.cdf(x, scale=self.scale, loc=self.loc) + def rvs(self, size=1, random_state=None): """ Generates random variates from the distribution. @@ -145,6 +177,55 @@ def __init__(self, mean, sigma, random_state=None): self.loc = mean self.scale = sigma self.prior = stats.norm + self._offset = -0.5 * np.log(2 * np.pi * self.scale**2) + self.sigma2 = self.scale**2 + self._multip = -1 / (2.0 * self.sigma2) + self._n_parameters = 1 + + def __call__(self, x): + """ + Evaluates the gaussian (log) distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the distribution. + + Returns + ------- + float + The value(s) of the distribution at x. + """ + # Convert scalar to array + x = np.asarray(x) + if x.ndim == 0: # Scalar case + x = np.array([x]) + + # Compute the distribution value(s) + values = self._offset + self._multip * (x - self.loc) ** 2 + + # Return a scalar if the input was a scalar + if values.size == 1: + return values.item() + return values + + def evaluateS1(self, x): + """ + Evaluates the first derivative of the gaussian (log) distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the first derivative. + + Returns + ------- + float + The value(s) of the first derivative at x. + """ + if not isinstance(x, np.ndarray): + x = np.asarray(x) + return self(x), -(x - self.loc) * self._multip class Uniform(BasePrior): @@ -170,6 +251,40 @@ def __init__(self, lower, upper, random_state=None): self.scale = upper - lower self.prior = stats.uniform + def __call__(self, x): + """ + Evaluates the gaussian (log) distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the distribution. + + Returns + ------- + float + The value(s) of the distribution at x. + """ + return self.logpdf(x) + + def evaluateS1(self, x): + """ + Evaluates the first derivative of the log uniform distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the first derivative. + + Returns + ------- + float + The value(s) of the first derivative at x. + """ + log_pdf = self.__call__(x) + dlog_pdf = np.zeros_like(x) + return log_pdf, dlog_pdf + @property def mean(self): """ @@ -204,32 +319,6 @@ def __init__(self, scale, loc=0, random_state=None): self.scale = scale self.prior = stats.expon - -class GaussianLogPrior(BasePrior): - """ - Represents a log-normal distribution with a given mean and standard deviation. - - This class provides methods to calculate the probability density function (pdf), - the logarithm of the pdf, and to generate random variates (rvs) from the distribution. - - Parameters - ---------- - mean : float - The mean of the log-normal distribution. - sigma : float - The standard deviation of the log-normal distribution. - """ - - def __init__(self, mean, sigma, random_state=None): - self.name = "Gaussian Log Prior" - self.loc = mean - self.scale = sigma - self.prior = stats.norm - self._offset = -0.5 * np.log(2 * np.pi * self.scale**2) - self.sigma2 = self.scale**2 - self._multip = -1 / (2.0 * self.sigma2) - self._n_parameters = 1 - def __call__(self, x): """ Evaluates the gaussian (log) distribution at x. @@ -244,12 +333,11 @@ def __call__(self, x): float The value(s) of the distribution at x. """ - x = np.asarray(x) - return self._offset + self._multip * (x[0] - self.loc) ** 2 + return self.logpdf(x) def evaluateS1(self, x): """ - Evaluates the first derivative of the gaussian (log) distribution at x. + Evaluates the first derivative of the log exponential distribution at x. Parameters ---------- @@ -261,41 +349,9 @@ def evaluateS1(self, x): float The value(s) of the first derivative at x. """ - if not isinstance(x, np.ndarray): - x = np.asarray(x) - return self(x), -(x - self.loc) * self._multip - - def icdf(self, q): - """ - Calculates the inverse cumulative distribution function (CDF) of the distribution at q. - - Parameters - ---------- - q : float - The point(s) at which to evaluate the inverse CDF. - - Returns - ------- - float - The inverse cumulative distribution function value at q. - """ - return self.prior.ppf(q, s=self.scale, loc=self.loc) - - def cdf(self, x): - """ - Calculates the cumulative distribution function (CDF) of the distribution at x. - - Parameters - ---------- - x : float - The point(s) at which to evaluate the CDF. - - Returns - ------- - float - The cumulative distribution function value at x. - """ - return self.prior.cdf(x, s=self.scale, loc=self.loc) + log_pdf = self.__call__(x) + dlog_pdf = -1 / self.scale * np.ones_like(x) + return log_pdf, dlog_pdf class ComposedLogPrior(BasePrior): @@ -327,7 +383,7 @@ def __call__(self, x): """ if not isinstance(x, np.ndarray): x = np.asarray(x) - return sum(prior(x) for prior in self._priors) + return sum(prior(x) for prior, x in zip(self._priors, x)) def evaluateS1(self, x): """ diff --git a/tests/unit/test_likelihoods.py b/tests/unit/test_likelihoods.py index 4172f4215..d0cb3027d 100644 --- a/tests/unit/test_likelihoods.py +++ b/tests/unit/test_likelihoods.py @@ -27,7 +27,7 @@ def parameters(self): def experiment(self): return pybop.Experiment( [ - ("Discharge at 1C for 10 minutes (20 second period)"), + ("Discharge at 1C for 1 minutes (5 second period)"), ] ) diff --git a/tests/unit/test_posterior.py b/tests/unit/test_posterior.py new file mode 100644 index 000000000..63e992b70 --- /dev/null +++ b/tests/unit/test_posterior.py @@ -0,0 +1,115 @@ +import numpy as np +import pytest + +import pybop + + +class TestLogPosterior: + """ + Class for log posterior unit tests + """ + + @pytest.fixture + def model(self): + return pybop.lithium_ion.SPM() + + @pytest.fixture + def parameters(self): + return [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.5, 0.01), + bounds=[0.375, 0.625], + ), + ] + + @pytest.fixture + def experiment(self): + return pybop.Experiment( + [ + ("Discharge at 1C for 1 minutes (5 second period)"), + ] + ) + + @pytest.fixture + def x0(self): + return np.array([0.52]) + + @pytest.fixture + def dataset(self, model, experiment, x0): + model.parameter_set = model.pybamm_model.default_parameter_values + model.parameter_set.update( + { + "Negative electrode active material volume fraction": x0[0], + } + ) + solution = model.predict(experiment=experiment) + return pybop.Dataset( + { + "Time [s]": solution["Time [s]"].data, + "Current function [A]": solution["Current [A]"].data, + "Voltage [V]": solution["Terminal voltage [V]"].data, + } + ) + + @pytest.fixture + def one_signal_problem(self, model, parameters, dataset, x0): + signal = ["Voltage [V]"] + return pybop.FittingProblem( + model, parameters, dataset, signal=signal, x0=x0, init_soc=1.0 + ) + + @pytest.fixture + def likelihood(self, one_signal_problem): + return pybop.GaussianLogLikelihoodKnownSigma(one_signal_problem, sigma=0.01) + + @pytest.fixture + def prior(self): + return pybop.Gaussian(0.5, 0.01) + + @pytest.mark.unit + def test_log_posterior_construction(self, likelihood, prior): + # Test log posterior construction + posterior = pybop.LogPosterior(likelihood, prior) + + assert posterior.sigma0 is not None + assert posterior._log_likelihood == likelihood + assert posterior._prior == prior + + @pytest.mark.unit + def test_log_posterior_construction_no_prior(self, likelihood): + # Test log posterior construction without prior + posterior = pybop.LogPosterior(likelihood, None) + + assert posterior._prior is not None + assert ( + posterior._prior[0] == posterior._log_likelihood.problem.parameters[0].prior + ) + + @pytest.fixture + def posterior(self, likelihood, prior): + return pybop.LogPosterior(likelihood, prior) + + @pytest.mark.unit + def test_log_posterior(self, posterior): + # Test log posterior + x = np.array([0.50]) + assert np.allclose(posterior(x), -3408.15, atol=2e-2) + + # Test log posterior evaluateS1 + p, dp = posterior.evaluateS1(x) + assert np.allclose(p, -3408.15, atol=2e-2) + assert np.allclose(dp, -1736.05, atol=2e-2) + + # Get log likelihood and log prior + likelihood = posterior.likelihood + prior = posterior.prior + + assert likelihood is not None + assert prior is not None + + # Test prior np.inf + p1 = posterior(np.array([-np.inf])) + p2, _ = posterior.evaluateS1(np.array([-np.inf])) + assert p1 == -np.inf + assert p2 == -np.inf diff --git a/tests/unit/test_priors.py b/tests/unit/test_priors.py index b773049f8..2b3491f28 100644 --- a/tests/unit/test_priors.py +++ b/tests/unit/test_priors.py @@ -38,6 +38,36 @@ def test_priors(self, Gaussian, Uniform, Exponential): np.testing.assert_allclose(Uniform.logpdf(0.5), 0, atol=1e-4) np.testing.assert_allclose(Exponential.logpdf(1), -1, atol=1e-4) + # Test icdf + np.testing.assert_allclose(Gaussian.icdf(0.5), 0.5, atol=1e-4) + np.testing.assert_allclose(Uniform.icdf(0.5), 0.5, atol=1e-4) + np.testing.assert_allclose(Exponential.icdf(0.5), 0.6931471805599453, atol=1e-4) + + # Test cdf + np.testing.assert_allclose(Gaussian.cdf(0.5), 0.5, atol=1e-4) + np.testing.assert_allclose(Uniform.cdf(0.5), 0.5, atol=1e-4) + np.testing.assert_allclose(Exponential.cdf(1), 0.6321205588285577, atol=1e-4) + + # Test evaluate + assert Gaussian(0.5) == Gaussian.logpdf(0.5) + assert Uniform(0.5) == Uniform.logpdf(0.5) + assert Exponential(1) == Exponential.logpdf(1) + + # Test Gaussian.evaluateS1 + p, dp = Gaussian.evaluateS1(0.5) + assert p == Gaussian.logpdf(0.5) + assert dp == 0.0 + + # Test Uniform.evaluateS1 + p, dp = Uniform.evaluateS1(0.5) + assert p == Uniform.logpdf(0.5) + assert dp == 0.0 + + # Test Exponential.evaluateS1 + p, dp = Exponential.evaluateS1(1) + assert p == Exponential.logpdf(1) + assert dp == Exponential.logpdf(1) + # Test properties assert Uniform.mean == (Uniform.upper - Uniform.lower) / 2 np.testing.assert_allclose( diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 466e36c3a..a18048d22 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -77,8 +77,8 @@ def log_posterior(self, model, two_parameters, dataset): dataset, ) likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.02, 0.02]) - prior1 = pybop.GaussianLogPrior(0.7, 0.02) - prior2 = pybop.GaussianLogPrior(0.6, 0.02) + prior1 = pybop.Gaussian(0.7, 0.02) + prior2 = pybop.Gaussian(0.6, 0.02) composed_prior = pybop.ComposedLogPrior(prior1, prior2) log_posterior = pybop.LogPosterior(likelihood, composed_prior) @@ -117,7 +117,9 @@ def chains(self): ) @pytest.mark.unit def test_initialization_and_run(self, log_posterior, x0, chains, MCMC): - sampler = MCMC(log_pdf=log_posterior, chains=chains, x0=x0, max_iterations=1) + sampler = pybop.MCMCSampler( + log_pdf=log_posterior, chains=chains, sampler=MCMC, x0=x0, max_iterations=1 + ) assert sampler._n_chains == chains assert sampler._log_pdf == log_posterior assert (sampler._samplers[0]._x0 == x0[0]).all() @@ -170,11 +172,39 @@ def test_logging_initialisation(self, log_posterior, x0, chains): @pytest.mark.unit def test_check_stopping_criteria(self, log_posterior, x0, chains): sampler = AdaptiveCovarianceMCMC( - log_pdf=log_posterior, chains=chains, x0=x0, max_iterations=10 + log_pdf=log_posterior, + chains=chains, + x0=x0, ) + # Set stopping criteria + sampler.set_max_iterations(10) + assert sampler._max_iterations == 10 + # Remove stopping criteria sampler._max_iterations = None with pytest.raises( ValueError, match="At least one stopping criterion must be set." ): sampler._check_stopping_criteria() + + @pytest.mark.unit + def test_set_parallel(self, log_posterior, x0, chains): + sampler = AdaptiveCovarianceMCMC( + log_pdf=log_posterior, + chains=chains, + x0=x0, + ) + + # Disable parallelism + sampler.set_parallel(False) + assert sampler._parallel is False + assert sampler._n_workers == 1 + + # Enable parallelism + sampler.set_parallel(True) + assert sampler._parallel is True + + # Enable parallelism with number of workers + sampler.set_parallel(2) + assert sampler._parallel is True + assert sampler._n_workers == 2 From 2d833156d3921414bf44fbde189d527f793aa353 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 5 Jun 2024 08:52:59 +0100 Subject: [PATCH 06/31] tests: increase coverage, adds monte carlo integration test --- pybop/parameters/priors.py | 18 +-- tests/integration/test_monte_carlo.py | 158 ++++++++++++++++++++++++++ tests/unit/test_posterior.py | 8 +- tests/unit/test_priors.py | 44 ++++++- 4 files changed, 210 insertions(+), 18 deletions(-) create mode 100644 tests/integration/test_monte_carlo.py diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index 43a99e0eb..de233ee56 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -196,18 +196,7 @@ def __call__(self, x): float The value(s) of the distribution at x. """ - # Convert scalar to array - x = np.asarray(x) - if x.ndim == 0: # Scalar case - x = np.array([x]) - - # Compute the distribution value(s) - values = self._offset + self._multip * (x - self.loc) ** 2 - - # Return a scalar if the input was a scalar - if values.size == 1: - return values.item() - return values + return self.logpdf(x) def evaluateS1(self, x): """ @@ -250,6 +239,7 @@ def __init__(self, lower, upper, random_state=None): self.loc = lower self.scale = upper - lower self.prior = stats.uniform + self._n_parameters = 1 def __call__(self, x): """ @@ -318,6 +308,7 @@ def __init__(self, scale, loc=0, random_state=None): self.loc = loc self.scale = scale self.prior = stats.expon + self._n_parameters = 1 def __call__(self, x): """ @@ -416,3 +407,6 @@ def evaluateS1(self, x): index += num_params return output, doutput + + def __repr__(self): + return f"{self.__class__.__name__}, priors: {self._priors}" diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py new file mode 100644 index 000000000..c5349b264 --- /dev/null +++ b/tests/integration/test_monte_carlo.py @@ -0,0 +1,158 @@ +import numpy as np +import pytest + +import pybop +from pybop import ( + DREAM, + DifferentialEvolutionMCMC, + DramACMC, + HaarioACMC, + HaarioBardenetACMC, + MetropolisRandomWalkMCMC, + PopulationMCMC, + RaoBlackwellACMC, + SliceDoublingMCMC, + SliceStepoutMCMC, +) + + +class Test_Sampling_SPM: + """ + A class to test the model parameterisation methods. + """ + + @pytest.fixture(autouse=True) + def setup(self): + self.ground_truth = np.array([0.55, 0.55]) + np.random.normal( + loc=0.0, scale=0.05, size=2 + ) + + @pytest.fixture + def model(self): + parameter_set = pybop.ParameterSet.pybamm("Chen2020") + return pybop.lithium_ion.SPM(parameter_set=parameter_set) + + @pytest.fixture + def parameters(self): + return [ + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Uniform(0.4, 0.7), + bounds=[0.375, 0.725], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Uniform(0.4, 0.7), + # no bounds + ), + ] + + @pytest.fixture(params=[0.5]) + def init_soc(self, request): + return request.param + + @pytest.fixture( + params=[ + pybop.GaussianLogLikelihoodKnownSigma, + ] + ) + def cost_class(self, request): + return request.param + + def noise(self, sigma, values): + return np.random.normal(0, sigma, values) + + @pytest.fixture + def spm_likelihood(self, model, parameters, cost_class, init_soc): + # Form dataset + solution = self.get_data(model, self.ground_truth, init_soc) + dataset = pybop.Dataset( + { + "Time [s]": solution["Time [s]"].data, + "Current function [A]": solution["Current [A]"].data, + "Voltage [V]": solution["Voltage [V]"].data + + self.noise(0.002, len(solution["Time [s]"].data)), + } + ) + + # Define the cost to optimise + problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc) + return cost_class(problem, sigma=[0.002]) + + @pytest.mark.parametrize( + "quick_sampler", + [ + DREAM, + DifferentialEvolutionMCMC, + DramACMC, + HaarioACMC, + HaarioBardenetACMC, + MetropolisRandomWalkMCMC, + PopulationMCMC, + RaoBlackwellACMC, + SliceDoublingMCMC, + SliceStepoutMCMC, + ], + ) + # Samplers that either have along runtime, or converge slowly + # @pytest.mark.parametrize( + # "long_sampler", + # [ + # NUTS, + # HamiltonianMCMC, + # MonomialGammaHamiltonianMCMC, + # RelativisticMCMC, + # SliceRankShrinkingMCMC, + # EmceeHammerMCMC, + # MALAMCMC, + # ], + # ) + + @pytest.mark.integration + def test_sampling_spm(self, quick_sampler, spm_likelihood): + x0 = spm_likelihood.x0 + prior1 = pybop.Gaussian(0.55, 0.05) + prior2 = pybop.Gaussian(0.55, 0.05) + composed_prior = pybop.ComposedLogPrior(prior1, prior2) + posterior = pybop.LogPosterior(spm_likelihood, composed_prior) + x0 = [[0.55, 0.55], [0.55, 0.55], [0.55, 0.55]] + if quick_sampler in [DramACMC]: + sampler = quick_sampler( + posterior, + chains=3, + x0=x0, + max_iterations=800, + initial_phase_iterations=150, + ) + else: + sampler = quick_sampler( + posterior, + chains=3, + x0=x0, + max_iterations=400, + initial_phase_iterations=150, + ) + results = sampler.run() + x = np.mean(results, axis=1) + + # Compute mean of posteriors and udate assert below + for i in range(len(x)): + np.testing.assert_allclose(x[i], self.ground_truth, atol=2.5e-2) + + def get_data(self, model, x, init_soc): + model.parameter_set.update( + { + "Negative electrode active material volume fraction": x[0], + "Positive electrode active material volume fraction": x[1], + } + ) + experiment = pybop.Experiment( + [ + ( + "Discharge at 0.5C for 6 minutes (12 second period)", + "Charge at 0.5C for 6 minutes (12 second period)", + ), + ] + ) + sim = model.predict(init_soc=init_soc, experiment=experiment) + return sim diff --git a/tests/unit/test_posterior.py b/tests/unit/test_posterior.py index 63e992b70..0ce461551 100644 --- a/tests/unit/test_posterior.py +++ b/tests/unit/test_posterior.py @@ -102,11 +102,11 @@ def test_log_posterior(self, posterior): assert np.allclose(dp, -1736.05, atol=2e-2) # Get log likelihood and log prior - likelihood = posterior.likelihood - prior = posterior.prior + likelihood = posterior.likelihood() + prior = posterior.prior() - assert likelihood is not None - assert prior is not None + assert likelihood == posterior._log_likelihood + assert prior == posterior._prior # Test prior np.inf p1 = posterior(np.array([-np.inf])) diff --git a/tests/unit/test_priors.py b/tests/unit/test_priors.py index 2b3491f28..f522ab2bc 100644 --- a/tests/unit/test_priors.py +++ b/tests/unit/test_priors.py @@ -21,13 +21,23 @@ def Uniform(self): def Exponential(self): return pybop.Exponential(scale=1) + @pytest.fixture + def ComposedPrior1(self, Gaussian, Uniform): + return pybop.ComposedLogPrior(Gaussian, Uniform) + + @pytest.fixture + def ComposedPrior2(self, Gaussian, Exponential): + return pybop.ComposedLogPrior(Gaussian, Exponential) + @pytest.mark.unit def test_base_prior(self): base = pybop.BasePrior() assert isinstance(base, pybop.BasePrior) @pytest.mark.unit - def test_priors(self, Gaussian, Uniform, Exponential): + def test_priors( + self, Gaussian, Uniform, Exponential, ComposedPrior1, ComposedPrior2 + ): # 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) @@ -52,6 +62,8 @@ def test_priors(self, Gaussian, Uniform, Exponential): assert Gaussian(0.5) == Gaussian.logpdf(0.5) assert Uniform(0.5) == Uniform.logpdf(0.5) assert Exponential(1) == Exponential.logpdf(1) + assert ComposedPrior1([0.5, 0.5]) == Gaussian.logpdf(0.5) + Uniform.logpdf(0.5) + assert ComposedPrior2([0.5, 1]) == Gaussian.logpdf(0.5) + Exponential.logpdf(1) # Test Gaussian.evaluateS1 p, dp = Gaussian.evaluateS1(0.5) @@ -68,6 +80,30 @@ def test_priors(self, Gaussian, Uniform, Exponential): assert p == Exponential.logpdf(1) assert dp == Exponential.logpdf(1) + # Test ComposedPrior1.evaluateS1 + p, dp = ComposedPrior1.evaluateS1([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 ComposedPrior.evaluateS1 + p, dp = ComposedPrior2.evaluateS1([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 ComposedPrior1 non-symmetric + with pytest.raises(AssertionError): + np.testing.assert_allclose( + ComposedPrior1([0.4, 0.5]), ComposedPrior1([0.5, 0.4]), atol=1e-4 + ) + + # Test ComposedPrior2 non-symmetric + with pytest.raises(AssertionError): + np.testing.assert_allclose( + ComposedPrior2([0.4, 1]), ComposedPrior2([1, 0.4]), atol=1e-4 + ) + # Test properties assert Uniform.mean == (Uniform.upper - Uniform.lower) / 2 np.testing.assert_allclose( @@ -104,10 +140,14 @@ def test_exponential_rvs(self, Exponential): assert abs(mean - 1) < 0.2 @pytest.mark.unit - def test_repr(self, Gaussian, Uniform, Exponential): + def test_repr(self, Gaussian, Uniform, Exponential, ComposedPrior1): assert repr(Gaussian) == "Gaussian, loc: 0.5, scale: 1" assert repr(Uniform) == "Uniform, loc: 0, scale: 1" assert repr(Exponential) == "Exponential, loc: 0, scale: 1" + assert ( + repr(ComposedPrior1) + == "ComposedLogPrior, priors: (Gaussian, loc: 0.5, scale: 1, Uniform, loc: 0, scale: 1)" + ) @pytest.mark.unit def test_invalid_size(self, Gaussian, Uniform, Exponential): From 5ed3d236d9dc525b58d9e0ab264fcca575464298 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 5 Jun 2024 10:04:47 +0100 Subject: [PATCH 07/31] tests: increase coverage, bugfix multi_log_pdf logic --- pybop/samplers/base_mcmc.py | 4 +-- tests/unit/test_posterior.py | 13 +++++++++ tests/unit/test_priors.py | 11 +++++++ tests/unit/test_sampling.py | 56 +++++++++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index 319e343ca..02065ff32 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -70,11 +70,11 @@ def __init__( if len(log_pdf) != chains: raise ValueError("Number of log pdf's must match number of chains") - first_pdf_parameters = log_pdf[0].n_parameters() + first_pdf_parameters = log_pdf[0]._n_parameters for pdf in log_pdf: if not isinstance(pdf, BaseCost): raise ValueError("All log pdf's must be instances of BaseCost") - if pdf.n_parameters() != first_pdf_parameters: + if pdf._n_parameters != first_pdf_parameters: raise ValueError( "All log pdf's must have the same number of parameters" ) diff --git a/tests/unit/test_posterior.py b/tests/unit/test_posterior.py index 0ce461551..8e97d0f73 100644 --- a/tests/unit/test_posterior.py +++ b/tests/unit/test_posterior.py @@ -76,6 +76,19 @@ def test_log_posterior_construction(self, likelihood, prior): assert posterior._log_likelihood == likelihood assert posterior._prior == prior + # Test log posterior construction without sigma + likelihood.sigma0 = None + likelihood.problem.sigma0 = None + posterior = pybop.LogPosterior(likelihood, prior, sigma=None) + assert posterior.sigma0 is not None + + # Test log posterior construction without parameters + likelihood.problem.parameters = None + with pytest.raises( + ValueError, match="An error occurred when constructing the Prior class:" + ): + pybop.LogPosterior(likelihood, log_prior=None) + @pytest.mark.unit def test_log_posterior_construction_no_prior(self, likelihood): # Test log posterior construction without prior diff --git a/tests/unit/test_priors.py b/tests/unit/test_priors.py index f522ab2bc..80cd84062 100644 --- a/tests/unit/test_priors.py +++ b/tests/unit/test_priors.py @@ -157,3 +157,14 @@ def test_invalid_size(self, Gaussian, Uniform, Exponential): Uniform.rvs(-1) with pytest.raises(ValueError): Exponential.rvs(-1) + + @pytest.mark.unit + def test_incorrect_composed_priors(self, Gaussian, Uniform): + with pytest.raises( + ValueError, match="All priors must be instances of BasePrior" + ): + pybop.ComposedLogPrior(Gaussian, Uniform, "string") + with pytest.raises( + ValueError, match="All priors must be instances of BasePrior" + ): + pybop.ComposedLogPrior(Gaussian, Uniform, 0.5) diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index a18048d22..3d247b6b9 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -118,12 +118,45 @@ def chains(self): @pytest.mark.unit def test_initialization_and_run(self, log_posterior, x0, chains, MCMC): sampler = pybop.MCMCSampler( - log_pdf=log_posterior, chains=chains, sampler=MCMC, x0=x0, max_iterations=1 + log_pdf=log_posterior, + chains=chains, + sampler=MCMC, + x0=x0, + max_iterations=1, + verbose=True, ) assert sampler._n_chains == chains assert sampler._log_pdf == log_posterior assert (sampler._samplers[0]._x0 == x0[0]).all() + # Test incorrect __getattr__ + with pytest.raises( + AttributeError, match="'MCMCSampler' object has no attribute 'test'" + ): + sampler.__getattr__("test") + + # Test __setattr__ + sampler.__setattr__("test", 1) + assert sampler.test == 1 + + # Run the sampler + samples = sampler.run() + assert samples is not None + assert samples.shape == (chains, 1, 2) + + @pytest.mark.unit + def test_multi_log_pdf(self, log_posterior, x0, chains): + multi_log_posterior = [log_posterior, log_posterior, log_posterior] + sampler = pybop.MCMCSampler( + log_pdf=multi_log_posterior, + chains=chains, + sampler=HaarioBardenetACMC, + x0=x0, + max_iterations=1, + ) + assert sampler._n_chains == chains + assert sampler._log_pdf == multi_log_posterior + # Run the sampler samples = sampler.run() assert samples is not None @@ -187,6 +220,12 @@ def test_check_stopping_criteria(self, log_posterior, x0, chains): ): sampler._check_stopping_criteria() + # Incorrect stopping criteria + with pytest.raises( + ValueError, match="Number of iterations must be greater than 0" + ): + sampler.set_max_iterations(-1) + @pytest.mark.unit def test_set_parallel(self, log_posterior, x0, chains): sampler = AdaptiveCovarianceMCMC( @@ -208,3 +247,18 @@ def test_set_parallel(self, log_posterior, x0, chains): sampler.set_parallel(2) assert sampler._parallel is True assert sampler._n_workers == 2 + + @pytest.mark.unit + def test_base_sampler(self, x0): + sampler = pybop.BaseSampler(x0=x0, sigma0=0.1) + with pytest.raises(NotImplementedError): + sampler.run() + + @pytest.mark.unit + def test_MCMC_sampler(self, log_posterior, x0, chains): + with pytest.raises(ValueError): + pybop.MCMCSampler( + log_pdf=log_posterior, + chains=chains, + sampler=log_posterior, # Incorrect sampler + ) From ca961a2fc4e1f1b612046c5fb19b0cba5db0231a Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 5 Jun 2024 11:26:41 +0100 Subject: [PATCH 08/31] tests: increase coverage, update priors on intesampling integration tests --- tests/integration/test_monte_carlo.py | 4 +-- tests/unit/test_posterior.py | 6 ---- tests/unit/test_sampling.py | 47 ++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index c5349b264..b6855c330 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -111,8 +111,8 @@ def spm_likelihood(self, model, parameters, cost_class, init_soc): @pytest.mark.integration def test_sampling_spm(self, quick_sampler, spm_likelihood): x0 = spm_likelihood.x0 - prior1 = pybop.Gaussian(0.55, 0.05) - prior2 = pybop.Gaussian(0.55, 0.05) + prior1 = pybop.Uniform(0.4, 0.7) + prior2 = pybop.Uniform(0.4, 0.7) composed_prior = pybop.ComposedLogPrior(prior1, prior2) posterior = pybop.LogPosterior(spm_likelihood, composed_prior) x0 = [[0.55, 0.55], [0.55, 0.55], [0.55, 0.55]] diff --git a/tests/unit/test_posterior.py b/tests/unit/test_posterior.py index 8e97d0f73..4c0ef2151 100644 --- a/tests/unit/test_posterior.py +++ b/tests/unit/test_posterior.py @@ -120,9 +120,3 @@ def test_log_posterior(self, posterior): assert likelihood == posterior._log_likelihood assert prior == posterior._prior - - # Test prior np.inf - p1 = posterior(np.array([-np.inf])) - p2, _ = posterior.evaluateS1(np.array([-np.inf])) - assert p1 == -np.inf - assert p2 == -np.inf diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 3d247b6b9..a563ac34d 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -1,3 +1,4 @@ +import copy from unittest.mock import MagicMock, patch import numpy as np @@ -138,6 +139,8 @@ def test_initialization_and_run(self, log_posterior, x0, chains, MCMC): # Test __setattr__ sampler.__setattr__("test", 1) assert sampler.test == 1 + sampler.verbose = True + assert sampler.verbose is True # Run the sampler samples = sampler.run() @@ -150,7 +153,7 @@ def test_multi_log_pdf(self, log_posterior, x0, chains): sampler = pybop.MCMCSampler( log_pdf=multi_log_posterior, chains=chains, - sampler=HaarioBardenetACMC, + sampler=HamiltonianMCMC, x0=x0, max_iterations=1, ) @@ -162,6 +165,33 @@ def test_multi_log_pdf(self, log_posterior, x0, chains): assert samples is not None assert samples.shape == (chains, 1, 2) + # Test incorrect multi log pdf + incorrect_multi_log_posterior = [log_posterior, log_posterior, chains] + with pytest.raises( + ValueError, match="All log pdf's must be instances of BaseCost" + ): + sampler = pybop.MCMCSampler( + log_pdf=incorrect_multi_log_posterior, + chains=chains, + sampler=HaarioBardenetACMC, + x0=x0, + max_iterations=1, + ) + + # Test incorrect number of parameters + new_multi_log_posterior = copy.copy(log_posterior) + new_multi_log_posterior._n_parameters = 10 + with pytest.raises( + ValueError, match="All log pdf's must have the same number of parameters" + ): + sampler = pybop.MCMCSampler( + log_pdf=[log_posterior, log_posterior, new_multi_log_posterior], + chains=chains, + sampler=HaarioBardenetACMC, + x0=x0, + max_iterations=1, + ) + @pytest.mark.unit def test_invalid_initialization(self, log_posterior, x0): with pytest.raises(ValueError, match="Number of chains must be greater than 0"): @@ -180,6 +210,21 @@ def test_invalid_initialization(self, log_posterior, x0): x0=x0, ) + @pytest.mark.unit + def test_no_chains_in_memory(self, log_posterior, x0, chains): + sampler = AdaptiveCovarianceMCMC( + log_pdf=log_posterior, + chains=chains, + x0=x0, + max_iterations=1, + chains_in_memory=False, + ) + assert sampler._chains_in_memory is False + + # Run the sampler + samples = sampler.run() + assert samples is None + @pytest.mark.unit def test_apply_transformation(self, log_posterior, x0, chains): sampler = AdaptiveCovarianceMCMC( From da2150624e9a71626394b20269f26330573e0d1a Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 5 Jun 2024 14:10:03 +0100 Subject: [PATCH 09/31] tests: increment coverage, refactor prior np.inf catch --- pybop/costs/_likelihoods.py | 4 ++-- tests/unit/test_posterior.py | 12 ++++++++++++ tests/unit/test_sampling.py | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index 5b6ed4fec..42176be3a 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -241,7 +241,7 @@ def _evaluate(self, x, grad=None): The posterior cost. """ prior = self._prior(x) - if prior == np.inf: + if not np.isfinite(prior): return prior return prior + self._log_likelihood.evaluate(x) @@ -267,7 +267,7 @@ def _evaluateS1(self, x): If an error occurs during the calculation of the cost or gradient. """ prior, dp = self._prior.evaluateS1(x) - if prior == np.inf: + if not np.isfinite(prior): return prior, dp likelihood, dl = self._log_likelihood.evaluateS1(x) return prior + likelihood, dp + dl diff --git a/tests/unit/test_posterior.py b/tests/unit/test_posterior.py index 4c0ef2151..7ec5352e4 100644 --- a/tests/unit/test_posterior.py +++ b/tests/unit/test_posterior.py @@ -120,3 +120,15 @@ def test_log_posterior(self, posterior): assert likelihood == posterior._log_likelihood assert prior == posterior._prior + + @pytest.fixture + def posterior_uniform_prior(self, likelihood): + return pybop.LogPosterior(likelihood, pybop.Uniform(0.45, 0.55)) + + @pytest.mark.unit + def test_log_posterior_inf(self, posterior_uniform_prior): + # Test prior np.inf + p1 = posterior_uniform_prior([1]) + p2, _ = posterior_uniform_prior.evaluateS1([1]) + assert p1 == -np.inf + assert p2 == -np.inf diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index a563ac34d..0d4707131 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -137,8 +137,8 @@ def test_initialization_and_run(self, log_posterior, x0, chains, MCMC): sampler.__getattr__("test") # Test __setattr__ - sampler.__setattr__("test", 1) - assert sampler.test == 1 + sampler.some_attribute = 1 + assert sampler.some_attribute == 1 sampler.verbose = True assert sampler.verbose is True From ce1cb540ee6073ff59ba6d0dd2f6b11e343fcbc2 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 5 Jun 2024 14:44:34 +0100 Subject: [PATCH 10/31] refactor: removes redundant code --- pybop/costs/_likelihoods.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index 42176be3a..7bdc07fa0 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -197,10 +197,6 @@ class LogPosterior(BaseCost): def __init__(self, log_likelihood, log_prior=None, sigma=None): super(LogPosterior, self).__init__(problem=log_likelihood.problem, sigma=sigma) - if self.sigma0 is None: - self.sigma0 = [] - for param in self.problem.parameters: # Update for parameters class - self.sigma0.append(param.prior.sigma) # Store the likelihood and prior self._log_likelihood = log_likelihood From 3e4c01e9d8f0dc4e9bb770fde1ee38ea0647ad36 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 7 Jun 2024 16:05:23 +0100 Subject: [PATCH 11/31] refactor: adds improvements from parameters class --- pybop/costs/_likelihoods.py | 4 ---- pybop/samplers/base_mcmc.py | 12 ++++++------ tests/unit/test_sampling.py | 6 +++++- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index ae79a917f..e72788124 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -212,10 +212,6 @@ def __init__(self, log_likelihood, log_prior=None): raise ValueError( f"An error occurred when constructing the Prior class: {e}" ) - try: # This is a patch, the n_parameters val needs to be updated across the codebase - self._n_parameters = self._prior.n_parameters - except AttributeError: - self._n_parameters = len(self._prior) def _evaluate(self, x, grad=None): """ diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index 02065ff32..6bb669d4b 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -56,10 +56,10 @@ def __init__( self._evaluation_files = kwargs.get("evaluation_files", None) self._parallel = kwargs.get("parallel", False) self._verbose = kwargs.get("verbose", False) - self._n_parameters = ( - log_pdf[0]._n_parameters + self.n_parameters = ( + log_pdf[0].n_parameters if isinstance(log_pdf, list) - else log_pdf._n_parameters + else log_pdf.n_parameters ) self._transformation = transformation @@ -70,11 +70,11 @@ def __init__( if len(log_pdf) != chains: raise ValueError("Number of log pdf's must match number of chains") - first_pdf_parameters = log_pdf[0]._n_parameters + first_pdf_parameters = log_pdf[0].n_parameters for pdf in log_pdf: if not isinstance(pdf, BaseCost): raise ValueError("All log pdf's must be instances of BaseCost") - if pdf._n_parameters != first_pdf_parameters: + if pdf.n_parameters != first_pdf_parameters: raise ValueError( "All log pdf's must have the same number of parameters" ) @@ -332,7 +332,7 @@ def _initialise_storage(self): # Pre-allocate arrays for chain storage self._samples = np.zeros( - (self._n_chains, self._max_iterations, self._n_parameters) + (self._n_chains, self._max_iterations, self.n_parameters) ) # Pre-allocate arrays for evaluation storage diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 0d4707131..4f23827a0 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -180,7 +180,11 @@ def test_multi_log_pdf(self, log_posterior, x0, chains): # Test incorrect number of parameters new_multi_log_posterior = copy.copy(log_posterior) - new_multi_log_posterior._n_parameters = 10 + new_multi_log_posterior.parameters = [ + new_multi_log_posterior.parameters[ + "Positive electrode active material volume fraction" + ] + ] with pytest.raises( ValueError, match="All log pdf's must have the same number of parameters" ): From b5ec8fecd21401d20c1fcae9b352f9b3d61d3893 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sat, 15 Jun 2024 16:56:47 +0100 Subject: [PATCH 12/31] feat: Adds burn-in functionality for sampling class --- examples/scripts/mcmc_example.py | 2 +- pybop/samplers/base_mcmc.py | 5 +++++ tests/integration/test_monte_carlo.py | 23 +++++++---------------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index 6f36bb6d8..0dbc71d74 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -67,7 +67,7 @@ def noise(sigma): chains=3, x0=x0, max_iterations=400, - initial_phase_iterations=250, + burn_in=50, # parallel=True, # uncomment to enable parallelisation (MacOS/Linux only) ) result = optim.run() diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index 6bb669d4b..bb4d5153d 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -26,6 +26,7 @@ def __init__( log_pdf: Union[BaseCost, List[BaseCost]], chains: int, sampler, + burn_in=None, x0=None, sigma0=None, transformation=None, @@ -56,6 +57,7 @@ def __init__( self._evaluation_files = kwargs.get("evaluation_files", None) self._parallel = kwargs.get("parallel", False) self._verbose = kwargs.get("verbose", False) + self.burn_in = burn_in self.n_parameters = ( log_pdf[0].n_parameters if isinstance(log_pdf, list) @@ -202,6 +204,9 @@ def run(self) -> Optional[np.ndarray]: self._finalise_logging() + if self.burn_in: + self._samples = self._samples[:, self.burn_in :, :] + return self._samples if self._chains_in_memory else None def _process_single_chain(self): diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index b6855c330..c447e7632 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -116,22 +116,13 @@ def test_sampling_spm(self, quick_sampler, spm_likelihood): composed_prior = pybop.ComposedLogPrior(prior1, prior2) posterior = pybop.LogPosterior(spm_likelihood, composed_prior) x0 = [[0.55, 0.55], [0.55, 0.55], [0.55, 0.55]] - if quick_sampler in [DramACMC]: - sampler = quick_sampler( - posterior, - chains=3, - x0=x0, - max_iterations=800, - initial_phase_iterations=150, - ) - else: - sampler = quick_sampler( - posterior, - chains=3, - x0=x0, - max_iterations=400, - initial_phase_iterations=150, - ) + sampler = quick_sampler( + posterior, + chains=3, + x0=x0, + burn_in=50, + max_iterations=400, + ) results = sampler.run() x = np.mean(results, axis=1) From c8db9f53fe501ebd97f6f3d30b1a6a92ee6c82fb Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 18 Jun 2024 13:49:45 +0100 Subject: [PATCH 13/31] fix: correct sigma0 to cov0 --- examples/scripts/mcmc_example.py | 36 ++++---- pybop/samplers/__init__.py | 4 +- pybop/samplers/base_mcmc.py | 11 ++- pybop/samplers/mcmc_sampler.py | 6 +- pybop/samplers/pints_samplers.py | 144 +++++++++++++++---------------- tests/unit/test_sampling.py | 2 +- 6 files changed, 103 insertions(+), 100 deletions(-) diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index 0dbc71d74..66da8e2db 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -5,7 +5,7 @@ # Parameter set and model definition parameter_set = pybop.ParameterSet.pybamm("Chen2020") -model = pybop.lithium_ion.SPM(parameter_set=parameter_set) +synth_model = pybop.lithium_ion.DFN(parameter_set=parameter_set) # Fitting parameters parameters = [ @@ -20,18 +20,18 @@ ] # Generate data -init_soc = 0.5 +init_soc = 1.0 sigma = 0.001 experiment = pybop.Experiment( [ ( - "Discharge at 0.5C for 3 minutes (2 second period)", - "Charge at 0.5C for 3 minutes (2 second period)", + "Discharge at 0.5C until 2.5V (10 second period)", + "Charge at 0.5C until 4.2V (10 second period)", ), ] - * 2 + # * 2 ) -values = model.predict(init_soc=init_soc, experiment=experiment) +values = synth_model.predict(init_soc=init_soc, experiment=experiment) def noise(sigma): @@ -49,30 +49,34 @@ def noise(sigma): } ) +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) signal = ["Voltage [V]", "Bulk open-circuit voltage [V]"] -# Generate problem, cost function, and optimisation class +# Generate problem, likelihood, and sampler problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) -likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.02, 0.02]) -prior1 = pybop.Gaussian(0.7, 0.02) -prior2 = pybop.Gaussian(0.6, 0.02) +likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.002, 0.002]) +prior1 = pybop.Gaussian(0.7, 0.1) +prior2 = pybop.Gaussian(0.6, 0.1) composed_prior = pybop.ComposedLogPrior(prior1, prior2) posterior = pybop.LogPosterior(likelihood, composed_prior) -x0 = [[0.68, 0.58], [0.68, 0.58], [0.68, 0.58]] + +x0 = [] +n_chains = 10 +for i in range(n_chains): + x0.append(np.array([0.68, 0.58])) optim = pybop.DREAM( posterior, - chains=3, + chains=n_chains, x0=x0, - max_iterations=400, - burn_in=50, - # parallel=True, # uncomment to enable parallelisation (MacOS/Linux only) + max_iterations=1000, + burn_in=250, + parallel=True, # uncomment to enable parallelisation (MacOS/Linux only) ) result = optim.run() - # Create a histogram fig = go.Figure() for i, data in enumerate(result): diff --git a/pybop/samplers/__init__.py b/pybop/samplers/__init__.py index 26a488c74..65a441864 100644 --- a/pybop/samplers/__init__.py +++ b/pybop/samplers/__init__.py @@ -6,7 +6,7 @@ class BaseSampler: """ Base class for Monte Carlo samplers. """ - def __init__(self, x0, sigma0): + def __init__(self, x0, cov0): """ Initialise the base sampler. @@ -14,7 +14,7 @@ def __init__(self, x0, sigma0): cost (pybop.cost): The cost to be sampled. """ self._x0 = x0 - self._sigma0 = sigma0 + self._cov0 = cov0 def run(self) -> np.ndarray: """ diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index bb4d5153d..888cb790b 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -28,7 +28,7 @@ def __init__( sampler, burn_in=None, x0=None, - sigma0=None, + cov0=None, transformation=None, **kwargs, ): @@ -40,17 +40,16 @@ def __init__( chains (int): Number of chains to be used. sampler: The sampler class to be used. x0 (list): Initial states for the chains. - sigma0: Initial standard deviation for the chains. + cov0: Initial standard deviation for the chains. transformation: Transformation to be applied to the samples. kwargs: Additional keyword arguments. """ - super().__init__(x0, sigma0) + super().__init__(x0, cov0) # Set kwargs self._max_iterations = kwargs.get("max_iterations", 500) self._log_to_screen = kwargs.get("log_to_screen", True) self._log_filename = kwargs.get("log_filename", None) - self._num_warmup = kwargs.get("num_warmup", 250) self._initial_phase_iterations = kwargs.get("initial_phase_iterations", 250) self._chains_in_memory = kwargs.get("chains_in_memory", True) self._chain_files = kwargs.get("chain_files", None) @@ -104,10 +103,10 @@ def __init__( try: if self._single_chain: self._n_samplers = self._n_chains - self._samplers = [sampler(x0, sigma0=self._sigma0) for x0 in self._x0] + self._samplers = [sampler(x0, sigma0=self._cov0) for x0 in self._x0] else: self._n_samplers = 1 - self._samplers = [sampler(self._n_chains, self._x0, self._sigma0)] + self._samplers = [sampler(self._n_chains, self._x0, self._cov0)] except Exception as e: raise ValueError(f"Error constructing samplers: {e}") diff --git a/pybop/samplers/mcmc_sampler.py b/pybop/samplers/mcmc_sampler.py index f2c3447d9..b08a10123 100644 --- a/pybop/samplers/mcmc_sampler.py +++ b/pybop/samplers/mcmc_sampler.py @@ -16,7 +16,7 @@ def __init__( chains, sampler=AdaptiveCovarianceMCMC, x0=None, - sigma0=None, + cov0=None, **kwargs, ): """ @@ -32,7 +32,7 @@ def __init__( The MCMC sampler class to be used. Defaults to `pybop.MCMC`. x0 : np.ndarray, optional Initial positions for the MCMC chains. Defaults to None. - sigma0 : np.ndarray, optional + cov0 : np.ndarray, optional Initial step sizes for the MCMC chains. Defaults to None. **kwargs : dict Additional keyword arguments to pass to the sampler. @@ -44,7 +44,7 @@ def __init__( """ try: - self.sampler = sampler(log_pdf, chains, x0=x0, sigma0=sigma0, **kwargs) + self.sampler = sampler(log_pdf, chains, x0=x0, sigma0=cov0, **kwargs) except Exception as e: raise ValueError( f"Sampler could not be constructed, raised an exception: {e}" diff --git a/pybop/samplers/pints_samplers.py b/pybop/samplers/pints_samplers.py index b3a1d0cb0..06b61e84c 100644 --- a/pybop/samplers/pints_samplers.py +++ b/pybop/samplers/pints_samplers.py @@ -38,7 +38,7 @@ class NUTS(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the NUTS sampler. @@ -53,12 +53,12 @@ class NUTS(BasePintsSampler): The NUTS sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): - super().__init__(log_pdf, chains, NoUTurnMCMC, x0=x0, sigma0=sigma0, **kwargs) + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): + super().__init__(log_pdf, chains, NoUTurnMCMC, x0=x0, cov0=cov0, **kwargs) class DREAM(BasePintsSampler): @@ -79,7 +79,7 @@ class DREAM(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the DREAM sampler. @@ -94,12 +94,12 @@ class DREAM(BasePintsSampler): The DREAM sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): - super().__init__(log_pdf, chains, PintsDREAM, x0=x0, sigma0=sigma0, **kwargs) + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): + super().__init__(log_pdf, chains, PintsDREAM, x0=x0, cov0=cov0, **kwargs) class AdaptiveCovarianceMCMC(BasePintsSampler): @@ -118,7 +118,7 @@ class AdaptiveCovarianceMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Adaptive Covariance MCMC sampler. @@ -133,17 +133,17 @@ class AdaptiveCovarianceMCMC(BasePintsSampler): The Adaptive Covariance MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsAdaptiveCovarianceMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -164,7 +164,7 @@ class DifferentialEvolutionMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Differential Evolution MCMC sampler. @@ -179,17 +179,17 @@ class DifferentialEvolutionMCMC(BasePintsSampler): The Differential Evolution MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsDifferentialEvolutionMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -210,7 +210,7 @@ class DramACMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the DRAM Adaptive Covariance MCMC sampler. @@ -225,17 +225,17 @@ class DramACMC(BasePintsSampler): The DRAM Adaptive Covariance MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsDramACMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -256,7 +256,7 @@ class EmceeHammerMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Emcee Hammer MCMC sampler. @@ -271,17 +271,17 @@ class EmceeHammerMCMC(BasePintsSampler): The Emcee Hammer MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsEmceeHammerMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -302,7 +302,7 @@ class HaarioACMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Haario Adaptive Covariance MCMC sampler. @@ -317,17 +317,17 @@ class HaarioACMC(BasePintsSampler): The Haario Adaptive Covariance MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsHaarioACMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -348,7 +348,7 @@ class HaarioBardenetACMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Haario-Bardenet Adaptive Covariance MCMC sampler. @@ -363,17 +363,17 @@ class HaarioBardenetACMC(BasePintsSampler): The Haario-Bardenet Adaptive Covariance MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsHaarioBardenetACMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -394,7 +394,7 @@ class HamiltonianMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Hamiltonian MCMC sampler. @@ -409,17 +409,17 @@ class HamiltonianMCMC(BasePintsSampler): The Hamiltonian MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsHamiltonianMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -440,7 +440,7 @@ class MALAMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the MALA MCMC sampler. @@ -455,17 +455,17 @@ class MALAMCMC(BasePintsSampler): The MALA MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsMALAMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -486,7 +486,7 @@ class MetropolisRandomWalkMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Metropolis Random Walk MCMC sampler. @@ -501,17 +501,17 @@ class MetropolisRandomWalkMCMC(BasePintsSampler): The Metropolis Random Walk MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsMetropolisRandomWalkMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -532,7 +532,7 @@ class MonomialGammaHamiltonianMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Monomial Gamma Hamiltonian MCMC sampler. @@ -547,17 +547,17 @@ class MonomialGammaHamiltonianMCMC(BasePintsSampler): The Monomial Gamma Hamiltonian MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsMonomialGammaHamiltonianMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -578,7 +578,7 @@ class PopulationMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Population MCMC sampler. @@ -593,17 +593,17 @@ class PopulationMCMC(BasePintsSampler): The Population MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsPopulationMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -624,7 +624,7 @@ class RaoBlackwellACMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Rao-Blackwell Adaptive Covariance MCMC sampler. @@ -639,17 +639,17 @@ class RaoBlackwellACMC(BasePintsSampler): The Rao-Blackwell Adaptive Covariance MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsRaoBlackwellACMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -670,7 +670,7 @@ class RelativisticMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Relativistic MCMC sampler. @@ -685,17 +685,17 @@ class RelativisticMCMC(BasePintsSampler): The Relativistic MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsRelativisticMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -716,7 +716,7 @@ class SliceDoublingMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Slice Doubling MCMC sampler. @@ -731,17 +731,17 @@ class SliceDoublingMCMC(BasePintsSampler): The Slice Doubling MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsSliceDoublingMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -762,7 +762,7 @@ class SliceRankShrinkingMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Slice Rank Shrinking MCMC sampler. @@ -777,17 +777,17 @@ class SliceRankShrinkingMCMC(BasePintsSampler): The Slice Rank Shrinking MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsSliceRankShrinkingMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) @@ -808,7 +808,7 @@ class SliceStepoutMCMC(BasePintsSampler): The number of chains to run. x0 : ndarray, optional Initial positions for the chains. - sigma0 : ndarray, optional + cov0 : ndarray, optional Initial covariance matrix. **kwargs Additional arguments to pass to the Slice Stepout MCMC sampler. @@ -823,16 +823,16 @@ class SliceStepoutMCMC(BasePintsSampler): The Slice Stepout MCMC sampler class from PINTS. x0 : ndarray The initial positions of the chains. - sigma0 : ndarray + cov0 : ndarray The initial covariance matrix. """ - def __init__(self, log_pdf, chains, x0=None, sigma0=None, **kwargs): + def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, chains, PintsSliceStepoutMCMC, x0=x0, - sigma0=sigma0, + cov0=cov0, **kwargs, ) diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 4f23827a0..bd91e1c9a 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -299,7 +299,7 @@ def test_set_parallel(self, log_posterior, x0, chains): @pytest.mark.unit def test_base_sampler(self, x0): - sampler = pybop.BaseSampler(x0=x0, sigma0=0.1) + sampler = pybop.BaseSampler(x0=x0, cov0=0.1) with pytest.raises(NotImplementedError): sampler.run() From eaaebb265c704b3e74fdd806faac5f5472266b22 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 3 Jul 2024 17:06:13 +0100 Subject: [PATCH 14/31] Merge branch 'develop' into monte-carlo-methods --- .github/workflows/periodic_benchmarks.yaml | 5 + .github/workflows/scheduled_tests.yaml | 5 + .pre-commit-config.yaml | 2 +- CHANGELOG.md | 5 +- README.md | 6 +- assets/PyBOP-high-level.svg | 7027 ++++++++++------- assets/Temp_Logo.png | Bin 9532 -> 0 bytes assets/logo/PyBOP_logo_flat.png | Bin 0 -> 87006 bytes assets/logo/PyBOP_logo_flat.svg | 1 + assets/logo/PyBOP_logo_flat_inverse.png | Bin 0 -> 86103 bytes assets/logo/PyBOP_logo_flat_inverse.svg | 1 + assets/logo/PyBOP_logo_inverse.png | Bin 0 -> 90102 bytes assets/logo/PyBOP_logo_inverse.svg | 1 + assets/logo/PyBOP_logo_mark.png | Bin 0 -> 25153 bytes assets/logo/PyBOP_logo_mark.svg | 1 + assets/logo/PyBOP_logo_mark_circle.png | Bin 0 -> 128356 bytes assets/logo/PyBOP_logo_mark_circle.svg | 1 + assets/logo/PyBOP_logo_mark_mono.png | Bin 0 -> 10278 bytes assets/logo/PyBOP_logo_mark_mono.svg | 1 + assets/logo/PyBOP_logo_mark_mono_inverse.png | Bin 0 -> 10252 bytes assets/logo/PyBOP_logo_mark_mono_inverse.svg | 1 + assets/logo/PyBOP_logo_mono.png | Bin 0 -> 78723 bytes assets/logo/PyBOP_logo_mono.svg | 1 + assets/logo/PyBOP_logo_mono_inverse.png | Bin 0 -> 78971 bytes assets/logo/PyBOP_logo_mono_inverse.svg | 1 + benchmarks/benchmark_model.py | 10 + .../multi_model_identification.ipynb | 2 +- .../multi_optimiser_identification.ipynb | 2 +- .../notebooks/optimiser_calibration.ipynb | 2 +- examples/notebooks/spm_AdamW.ipynb | 2 +- examples/scripts/ecm_CMAES.py | 2 +- examples/scripts/spm_AdamW.py | 2 +- examples/scripts/spm_IRPropMin.py | 2 +- examples/scripts/spm_MAP.py | 2 +- examples/scripts/spm_MLE.py | 2 +- examples/scripts/spm_NelderMead.py | 2 +- examples/scripts/spm_descent.py | 2 +- pybop/costs/_likelihoods.py | 10 +- pybop/costs/fitting_costs.py | 8 +- pybop/models/base_model.py | 4 +- pybop/observers/observer.py | 2 +- pybop/observers/unscented_kalman.py | 8 +- pybop/optimisers/base_pints_optimiser.py | 24 +- pybop/plotting/plot2d.py | 12 +- .../integration/test_optimisation_options.py | 2 +- .../integration/test_spm_parameterisations.py | 6 +- .../test_thevenin_parameterisation.py | 2 +- tests/plotting/test_plotly_manager.py | 2 +- 48 files changed, 4223 insertions(+), 2948 deletions(-) delete mode 100644 assets/Temp_Logo.png create mode 100644 assets/logo/PyBOP_logo_flat.png create mode 100644 assets/logo/PyBOP_logo_flat.svg create mode 100644 assets/logo/PyBOP_logo_flat_inverse.png create mode 100644 assets/logo/PyBOP_logo_flat_inverse.svg create mode 100644 assets/logo/PyBOP_logo_inverse.png create mode 100644 assets/logo/PyBOP_logo_inverse.svg create mode 100644 assets/logo/PyBOP_logo_mark.png create mode 100644 assets/logo/PyBOP_logo_mark.svg create mode 100644 assets/logo/PyBOP_logo_mark_circle.png create mode 100644 assets/logo/PyBOP_logo_mark_circle.svg create mode 100644 assets/logo/PyBOP_logo_mark_mono.png create mode 100644 assets/logo/PyBOP_logo_mark_mono.svg create mode 100644 assets/logo/PyBOP_logo_mark_mono_inverse.png create mode 100644 assets/logo/PyBOP_logo_mark_mono_inverse.svg create mode 100644 assets/logo/PyBOP_logo_mono.png create mode 100644 assets/logo/PyBOP_logo_mono.svg create mode 100644 assets/logo/PyBOP_logo_mono_inverse.png create mode 100644 assets/logo/PyBOP_logo_mono_inverse.svg diff --git a/.github/workflows/periodic_benchmarks.yaml b/.github/workflows/periodic_benchmarks.yaml index 637711507..704c7975a 100644 --- a/.github/workflows/periodic_benchmarks.yaml +++ b/.github/workflows/periodic_benchmarks.yaml @@ -22,6 +22,11 @@ jobs: runs-on: [self-hosted, macOS, ARM64] if: github.repository == 'pybop-team/PyBOP' steps: + - name: Cleanup build folder + run: | + rm -rf ./* || true + rm -rf ./.??* || true + - uses: actions/checkout@v4 - name: Install python & create virtualenv diff --git a/.github/workflows/scheduled_tests.yaml b/.github/workflows/scheduled_tests.yaml index ade881885..159152f05 100644 --- a/.github/workflows/scheduled_tests.yaml +++ b/.github/workflows/scheduled_tests.yaml @@ -113,6 +113,11 @@ jobs: matrix: ${{fromJson(needs.filter_pybamm_matrix.outputs.filtered_pybop_matrix)}} steps: + - name: Cleanup build folder + run: | + rm -rf ./* || true + rm -rf ./.??* || true + - uses: actions/checkout@v4 - name: Install python & create virtualenv shell: bash diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 401b5338f..47ef467c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.4.8" + rev: "v0.5.0" hooks: - id: ruff args: [--fix, --show-fixes] diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e29c585f..c7fa71655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - [#6](https://github.com/pybop-team/PyBOP/issues/6) - Adds Monte Carlo functionality, with methods based on Pints' algorithms. A base class is added `BaseSampler`, in addition to `PintsBaseSampler`. +- [#379](https://github.com/pybop-team/PyBOP/pull/379) - Adds model.simulateS1 to weekly benchmarks. +- [#174](https://github.com/pybop-team/PyBOP/issues/174) - Adds new logo and updates Readme for accessibility. - [#316](https://github.com/pybop-team/PyBOP/pull/316) - Adds Adam with weight decay (AdamW) optimiser, adds depreciation warning for pints.Adam implementation. - [#271](https://github.com/pybop-team/PyBOP/issues/271) - Aligns the output of the optimisers via a generalisation of Result class. - [#315](https://github.com/pybop-team/PyBOP/pull/315) - Updates __init__ structure to remove circular import issues and minimises dependancy imports across codebase for faster PyBOP module import. Adds type-hints to BaseModel and refactors rebuild parameter variables. @@ -25,7 +27,8 @@ ## Bug Fixes - +- [#380](https://github.com/pybop-team/PyBOP/pull/380) - Restore self._boundaries construction for `pybop.PSO` +- [#372](https://github.com/pybop-team/PyBOP/pull/372) - Converts `np.array` to `np.asarray` for Numpy v2.0 support. - [#165](https://github.com/pybop-team/PyBOP/issues/165) - Stores the attempted and best parameter values and the best cost for each iteration in the log attribute of the optimiser and updates the associated plots. - [#354](https://github.com/pybop-team/PyBOP/issues/354) - Fixes the calculation of the gradient in the `RootMeanSquaredError` cost. - [#347](https://github.com/pybop-team/PyBOP/issues/347) - Resets options between MSMR tests to cope with a bug in PyBaMM v23.9 which is fixed in PyBaMM v24.1. diff --git a/README.md b/README.md index 8fd09c0a4..99f7a031e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@
- ![logo](https://raw.githubusercontent.com/pybop-team/PyBOP/develop/assets/Temp_Logo.png) + logo.svg + # Python Battery Optimisation and Parameterisation @@ -25,8 +26,7 @@ PyBOP provides a complete set of tools for parameterisation and optimisation of The diagram below shows the conceptual framework of PyBOP. This package is currently under development, so users can expect the API to evolve with future releases.

- - pybop_arch.svg + pybop_arch.svg

## Installation diff --git a/assets/PyBOP-high-level.svg b/assets/PyBOP-high-level.svg index 00f3428f3..25de26fdd 100644 --- a/assets/PyBOP-high-level.svg +++ b/assets/PyBOP-high-level.svg @@ -1,2906 +1,4149 @@ image/svg+xml + + + + + + + +80μ + +100μ + +120μ + +140μ + +160μ + +80μ + +100μ + +120μ + +140μ + +160μ + +100k + +200k + +300k + +400k + +500k + +600k + +700k + +800k + +900k + +Negative electrode thickness [m] + +Positive electrode thickness [m] + +Volumetric energy density [Wh.m-3]vs. electrode thicknesses Transport limitedGoldilocksregion + +ParameterIdentification + + + + +DesignOptimisation +Design OptimisationParameter -Identification + d="m -11.033,50303 c 0,-27781 22523,-50304 50304,-50304 H 1271428 c 27781,0 50304,22523 50304,50304 v 525611 c 0,27781 -22523,50304 -50304,50304 H 50292.967 c -27781,0 -50304,-22523 -50304,-50304 z" /> + + + +ExperimentalData +inverse modelling +forward models +Physics and Empirical Models +Funding / Friend Projects +parameters +simulations + diff --git a/assets/Temp_Logo.png b/assets/Temp_Logo.png deleted file mode 100644 index 4ef2853b345d37284b8e3272c312b5a7d091b6f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9532 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 diff --git a/assets/logo/PyBOP_logo_flat.png b/assets/logo/PyBOP_logo_flat.png new file mode 100644 index 0000000000000000000000000000000000000000..8e0fb1391280d8e5f4ce06f52e1eec43af36b821 GIT binary patch literal 87006 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 z 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 diff --git a/assets/logo/PyBOP_logo_flat_inverse.png b/assets/logo/PyBOP_logo_flat_inverse.png new file mode 100644 index 0000000000000000000000000000000000000000..f8aa5817f956e5ebb51101006f248b5671a359db GIT binary patch literal 86103 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 akr4a)Bk#)%J(M)`w$;`>-=}S+{qld1t>a_> literal 0 HcmV?d00001 diff --git a/assets/logo/PyBOP_logo_flat_inverse.svg b/assets/logo/PyBOP_logo_flat_inverse.svg new file mode 100644 index 000000000..d8c3e651f --- /dev/null +++ b/assets/logo/PyBOP_logo_flat_inverse.svg @@ -0,0 +1 @@ + diff --git a/assets/logo/PyBOP_logo_inverse.png b/assets/logo/PyBOP_logo_inverse.png new file mode 100644 index 0000000000000000000000000000000000000000..62a4cf7d32c7c6a313e7bf11ccd582bb8e7d356d GIT binary patch literal 90102 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 z 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 diff --git a/assets/logo/PyBOP_logo_mark.png b/assets/logo/PyBOP_logo_mark.png new file mode 100644 index 0000000000000000000000000000000000000000..6fce2b94087eca9fa5299118d3f4c920e5f270c3 GIT binary patch literal 25153 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 Q>|a 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

*2ZH1M4t5yakJNVR{iZ;*psI**? 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

35i> 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 literal 0 HcmV?d00001 diff --git a/assets/logo/PyBOP_logo_mark.svg b/assets/logo/PyBOP_logo_mark.svg new file mode 100644 index 000000000..559ef4067 --- /dev/null +++ b/assets/logo/PyBOP_logo_mark.svg @@ -0,0 +1 @@ + diff --git a/assets/logo/PyBOP_logo_mark_circle.png b/assets/logo/PyBOP_logo_mark_circle.png new file mode 100644 index 0000000000000000000000000000000000000000..adc77e6b42507c501ef2a4b4069be6e3e9aecf58 GIT binary patch literal 128356 zcmb@uc|6o>8$UjlLfI+%mea8%Nyswxr9;`bvZp9yNesr=w}jD&N_MjE$x>sip@dP{ zmq7|yW{5Eu%lH21d7j_z`~Ua6UcFAI?)&~+_jO;(`+8rO^Vq~lpXn6uDHsgK^v^XN zGZ^f|H1v!9B={5l@~tEAF9x4$H~nBRulLX|su6rG7Yrs0`$tF9A~17xx;5GEp9Uh? z(%d@Mhv|(f)u^yPRXTN$Ny?4hl*SP(W zD`o6qw8wb{J1Xjn?5VYN%5`PGark$lw+}`Vh;BoOz)hh3VLitPqerX1$NO5~dx)8fI2kqE5(C(EZE>{+Erf?dC+JFBeO~j~ z_YH56G70;ZUv0GS#S%sFR-oOc93HhgWIK9>LF-s~kHvlspYT6?cz_=7T;G3}j&+~l zcHi-0q1naQACZ6Vc5S!PfwixXZ`;;2;+>Y3HP~a-On*i&?zbcH$hlY*{7nNJr1wY5rZtBT zS8})<;8YQ)T9K62x=N>P4M7+zS3Q~lmOTbKc%eZ1t6Jzlv3-xx4bODdY53A-F1~=S zqgw65-}~POYK6c$3mr(Dl(yi}J;TZ9WtpVSVWA~iaqADu^AQwL4?^b-&UJeiEYEN= z2g_f^4GI90OsULT@qWNjgFp-@Qw4`9+t#yo1F2~Bpu>dk)!tHT6tQSMS}Z(2^Uf$G znf>2tFL08IZQRC-MXnq;AGm0n`LUb>BbZTQC#muIdY?bFf5|@$r}l1V*l!@Zs{+C2 ztdG|)RpK0G4#m0%{#yQbo;js$VICxxyi?C!DEH6@i?W0tfhQ{Y%e>8~gjtpI(V2Rb zDw8JAmB#tw?I$YG@j)IDGy#$Rxrt6v6~qRHIbveLeX!M46x#ao-hoK5_XEF^6ku=* zV_)ylu7p<$msiiM&3WypjCz5Eks{E-tG7|t`)ntpSAPpVG-zV}!0u$ZodKU>IvTF$ z1B;xGKW7`Tdi#ai-?2UdA8!QK2OMcuB3uIyL7^MtjX-rU0ccUKhpk&}YrV+5u%BZC z4$&(x0z>s|Xq`X3cP0}sCFNs1zGt5+;ie9N6|>rnqiS!^hw0OUzYErcbZjk4ZJ4Jerb}U>3djX|HU2L02bVK_1M~fcxHV* z9O%ObZfi~1vN$W>63(ptys;eGNV*s(NBLlS1-uml^l_i!M|T)yeUze?<0G9~_kF`Ns&XV#Xs&5wV+EDP08v0V?w_4ub_(*uDpi;Zx z8@-|-K>NE#$xdV-5?Vw>j2}><32K(FK?Fxm&>JxochxE-q_1Vh3HlFdj5=SK^jfWTT#NH zgFBM@UKyhI)anIzVK5zi=ymnRm2(by`yA@|G33DXaDldq*tiWQH=NQs2oUVhSv8{| z$~?$$ad~xO4X)ouy9!%aQi}J!y4gCpz#m8rF1Jn#DKyYY;H~4$P8O{-dDp+4=mxdW znyOU5HP(;ao_>N)*a=r$tkRnxl7o-j(W^Tj4({vYlxdtEA1gsgcE~G0rx>;L(Cx$kPgtxv^ZQZ$r5C_rvG^8b}2Pom?v~+m6 zyir4VS_+E4irt6T%Btt?={YIf!jtsri5i;h%^4~feKaJKaKpg;BPUlG_oQbMKIiYu zprvTub|!eVUKa`ZnZ7?AyE)^%!VK)t018YRVVYcJY$I^TUp7)%Q?N1p~8X(AfETROu1MSCJU zYCF`8*(ovG+V6|@j=?l=)41w~&|;(G#Wnch!}i!3Z10Bb)6NM&?d?NX#@y$E)`7I^ zDyZ824Y`?RMj&MX6bf_my4-{Ao>AwNf9Nx5JBT0ktTleJ7>=M^S|KXM^LAKI9Sp=( zQ^N*XA^oNeAyWmUOnJn8Rt;cHOvX z$RcO0{^AX8Vxe6Rb0UKi&OmNx!it&5=@optn4NXDN}f;~d0&iDpUmB1z4UUL2r}!j z*-`=P%VKgyZrHD}MRiwbA_s2n&cDw;Yo+}2ZcEV@>YW74OtlP7rscT|3rs^Dy7|>F z!Pb=Fy&SH>75&rabx(*4Bvi90A^uHkSvbRloy9OYGLVaypfG_Vtl3e-%+|NUM z@UZ34+BK^4A}R#m>69~4g}*L4)Lo+ri7^UOth42#-l_iinFf}^0qH=rQk|O=m5!CP zS24JkE`1LtpYguQs!h8hzxz`_mNLt%I5&c+n(XgDXJgz`Nn5Zgk`FbIYMY-{};KL-sdM_8bUxBxhgB06V?5zH$J^z zcE0x`7w)xKyy80mGy1lYaPfDu%Bfjk=#@K>fRN2zdWX7#@oCkE|R<_VSF!2@IvbuL@WJp*M{drU3;3;X+c-nzhwVr)k=Jef*qbb8_*Db26Lme&}N( zxXG%i4C1fvs%fo`rvkPyOmmb6@BRfL3Uct*X7gZA!Mpf&XRzm!nz}AqE6b(ihwCpb zx1|JqL(699Vmc9OkIukSPC?&^A;lFq+@y0xeh8O!=$XHh^&%l2ryNoe`!CO(mN#D( ziuN+O4hM(%_y$m7A#|{4$g1fd2sAimjAxk1Te`Gdqq>u6RQyR0kFw092cum1ysO`HSbMZdL?o*DOXKrYw7%`Wj2f7`e`P?~lPchRYc z@3vmbTCeUa^s=9AO*vX2lX6&Z2lg#Ps3`i4jdZo=*!W?-4t&_=l7SOX!RL)P{l!J% zIB}B%_ohXq2&=>&D=H+UwkU5@x*%QtBa2avc=y^~o>)bn<;9TzL;7>m1&-H0;q>BP|-GS7V zQp-H4UU3v;m0xu01GmW55OKX`>EH$HoD$7?%5Cvwkv+@!!Pbd7y&Y!Q$9NFZXD=eh z1Vv!OM4#4=Sg>xF+DExQ{B7ZqoHfyLZTuW`uogJj$6VRd`ibChr^y2l)Ft zki`~(?3R{pZyUjK%`)M9nzq^W0P~XxvP5c*(2(^eVbiG9+kndb0h?RdRNdD)Z6Izu zB<>)sGZOheYmKXRu{1S@$J6IUk+UvaoY~7;h97t&$rZo*SX*dd=b%7sp*x0u?rI&U zyBc(A*sYMu4uz`F3n`^7tJlfQa>p6q3?hs|Hz2aP zs`rDh^-7#94;ENETZpUW%sFw_PWn#!sr$TXZS|?*b*8K4KX1Mq??Uk5C)t5oj35iS z`bpWdRiTdT=OIDspD(ra#+oiu5Iq7HA?Xn%G zo-dt%4RV3UZTkx(nRn@^OLdM5BWCnOPHih}a(2StJa+=wk;?kS%Vf*pP)Vv)epA=n zk>Xx#&UhD5A(I?xo6iLTAS8YPh_4=e)MPimWpUbIcjs~|MU3v!26sJ$kW^0fwmrc^ z`+RhkXa^%Fa;t@WLhqhCwAex?2c?-Gm%(`}n1HgiEcV&xabcmm7&ubeDXE z3XyAh#BrJpQnT_Vv&*6x(VGcWrT%_?P4TK!is20~qWf6gBsq4@628 zUAIHMq=}bIB%+6Z{gC*HXEuG`D!nCd3v=0ONZ4d{T{I!sl`0aEcETY?;1p^Y0TQ3k zxy|x?R@=<7!zSrsfH`4}H4Qnbr7m~E#LQSqg&4lg*()GLT0nWbB_Cp#7O^vF;Ne)g zfMGwn_&M%%8SNWh`_-bUNi|7webK&LpUfuwP9du!3wcoGH9Y2Fk0dr|W;DHE)43cr zs06ONG_LA$&u4L^3q-pHLu`-g{wjm=#=_RGEDkM!G~J{Oy9jI{F#|1QzgOdeBNAt zWMx@k!YFU7)WA5K{B#pf1#n_tnKodC_q7EG^@RPIBw3aiC=y4I^NhDT90B=^k>FAR~c}0BQGw)dC-) z!6&(A!A*J}6Vtw4O|LSKhB?$~$7Dyw5@-5XJAqX^D0P6rc!668r+2yUw#T+*`Ar%V zG@sm32On)@f7qALT*b7`T3&MS#twk0Uz~dkye&r{mmI#ck^4guqLm+V{cYoh0Fq9HTs;YKns2P?$oUM{*`r_zv{lCX(r8 z$QX?Ts%!dHnk-NA4DSW?Q9{#L!%lAcGKFv3_%r1@(oyf+=L}}xZV77?32c=ZVT9#E z=0Shqx9Mg4T;;8$k`bH>p6rp_l=b2ZWuM9)TityQ=LiqCUN*{m5k5!ZlEZxe$ODUq zDjVvAkkmv(U^%CTB0zMRJ zQ}NtdftHY${O<;{Z(7Gcg$@ltGX>^d$KdEpTOhph$GL&Dbre6&ftnBuMH=Xn8vMX= zLhdi;7Y-z~{ zu4*nw3ElT9o1u{eBA~Ysd>!QK!eA;`A5>0jjbJ9d`&Wz2Jy;tkCcj*4U+2mi6@LT3 zEAkpnvDbu2T<&A=UD1BSja{o>@$0BmfT2(T?wh~mfwg@C=Ai+MYv0LrG~hd3A$vn( z@nEOR)5wokvyxx<8VR0*_YCH9+W`zFsh9rjyIjTBr`AFdkqEgu8P|X=x=QMBb?|mR zXE&nE7$!+N3=%Gnl#Ghrz2*FQQ`aaV!j*Qs>Q?G*3A|cznzaSp0d0_jHUQXGEj>n8 z7<|I@lx&t3%Vd7^3)%PlV&ClC6MlPAqf28l%P!j&()*c)Ts)(EE<}A9!508R-RcS0 zC1&t+S6@;zOQY8wNNm+eYvq>C%w4^?V5%Lx$SVGi?2g6LpS?OS<0kx;rf(Q8VsYGS z1x!M3d#ezzdl0_hy%lD%T%Udr;-2J?{I6;JCTH+ik>EpjgrZTbQfkrR6cv6*%j5)4 zRs0_RP__!ui+%>Zcl>U;8B~8bpCp~hX``_!P5Al{fzSlN*XzRysxKum`40JxG4uyg za1VUe*25otiC>N%{P?w2q0F3CiD;a8HEvuyvLxbT9Z%s5ZYt&vUk*?(+B#e`3vCD& z6hHA=@EpzoizsyajN#}YN3~>(lRy}`@x>JeV*yA+L-)7_jG)w+ABM0vQ!Z5r74DeA z8HWj8ttF@zxrf)OxW!`%rFFCG9|Fww3?UXxq&sl!d@AQ?xJ?b?hK@MlhiK$zSQMnl z{&ccvtn19*MoIp3qi?&P8u`sCf57IQciQ4d%xUyS6fJQzm@eD^*h%iCfJQXWH?-N6 zQr=Ye0DrromSQuM7i_4QJ-30?x2P9huBw8!XKf*N0Zof10!^QCMo?bL9*B3PmgSYY zT2pja$QEU|(=aAUXZIUyZY2|Sy_?^sb+^~E?1`{hG_)iBEhejGI?mnaKk&?eq}1-~ z#^5dRIr^oA*6^$Rev?|qw8al~PgdJ0m+=3Lk(gm8z?IqZnc_= zs|jI0aX*)GF3r!QiQ!{sxA4l?^kAPWq*NUfrD0oTNM< zV^-}Vp~k_(mfzF!`?Xq3N`36LILXt`uw`$6U z`<KPmhMgDq*w$B2A`T42SO@=wADtt>ZN`qTZnkUNY-w6 z@Hnb&3^(@5lzv_!Ax(4IvgGY~V$pFIEz_JF)|sUsLcflRhHN6Zta zxP7SwLC136zs@mL4Q+}!TBaY#i@iG-cxui=g3UF5q@d$~yc{m-mc0Y=>|tr394rOO z)cm!eSemH89CQt-XDE3Jb#KjOO_#=0-kM%Lo7V&26_z*B|4uvmuS+qk4bk*WKa@%KB%l!1!bK2%kdeb z-MX7Jh>n0T=hwiS!_cdg!8vS22$avFuKR>jYXQ;{kC-SBP=w`r9E0!am*W)R-ra53o`O3T)HjnZW}B_8 z03T zvidss{(DFZd?7Z=^R*M_JVy)0zNwue*Ic3A-fsG#Y!mS|&Hs}=OdYLd>_ASjCl+?6 zuPXjmfhC|OOu_GUngRA@u{7*?4V>_@v+0m6;}1G~(s3a5I0egm(Id3Ns`qmfZ6bS{ zR6yw<#~d=r_f8aapQ`i zSW$~muGn3C`ys*Y)pf%U^i<8a;_wGP_&+kpD69q_*){XPd_cMo&La69i*-)m$*tBf%UhRfwTuBnOv@y@IU3slGT+ z_D+7q!otOXMU+;cpY7gpl_@&B8PM3n>&^X5@vHf1&i^1mjlo0C7qscMW)sq#?Ek_- zRf!JtGlpnU%*sIl)0|3AKkQn>pnwK1FpH@4g9pnwE(m1BhQj=wF3Lcpz)S|C!!p4z zuGqxcc2cS&?wpJS!%WX6A!Z%Bb@ksrF2}D(b4(q~l?sVH^GB#SV`K^)1fTG~<#^+v zZX2#_WktI#U;1cqI<5zGE-*)B;7%Cev701j7+`M!R1&_D{Op}jaU~vP>{B9X!9f?? z$MAlG_>G~6qF@JKU3)J{x^>B{AhPC;!$ba0_QbHpIc+N?uag?rK>X}5Q!@hG?~j+! zYm%r{twBL;w{Iu3mVD2g_5W4M+gASQ=d}LzI(BpR;0aSZbX$7R3DmgCFXpFiq!PWR zX0aD|;r1Zj{3HI0MZ}}vTj{!XvFt5E3*-9Rv)G@3k#iITpPCb?K~LtGa}PHe>G?GE zYP`HOtRa9kP1cjlgRZeJMEB&!tYK-_vaWM1S;>^w-}`Jvy>mc6Gmk#c-JFq*dMgsL z*u(=90qKn1rS_IX`gl@0JmdG`*mRZpi0k#79h38KEq=N1X=0xL_(pTdZ2OGe>+_Ox zl+bA6Mo$?^0J^O!xb2Th^s{%eb!77+&*yDfHQ13RUW>i4vl5;--nU{|R_;$*ypv6t z*lTSUFlX++E89U844N9xskBo{U9SJ0j-5R)PjiLkvI3*|3QX8gj&g@z?3q`Eb8~2C zK#ZW^6Habd#t9B1iPUSjFwO+3J2Gz^cCQ&)*DpHe0chIf!f+Dgi=z<0Xyk>6!Awqq zKz;KVvn)0uf-)JMk(ITmAatu?YK^SzV7907!+gvBF3bPU0;rCTnVgoW`xM|`aKU9b z+at!SIrwA9kL%CviDH6^o6r65RN^4o3f~7B${3e1KjOzs%J&3ab{|fpEIS zJ>x59tWC4+hYHT#)=T>(sdw{h861P3-eQNMWvcAoyC5+RC=ri4-zjg@S`VJ3W7GHC zd#;_B@>=9liiA zmhn3a)^;C+jE^8Gg2Hmj@%pYGzgU6NfU9apfHzxYAEPERQsN5RT#$~Jdqi>m1?y|5 z((>&K!H9X;(2-f}uuWt;MPH86<|_x|1dpEr5@dDXYWIf$ZyaYsqtKI@>5Ske9{4>L ze5mCzPOt`ji@xk!r=_Xmwj>;h&th%q;niPnN|$lnCV*bHN0I}~=>m8?0(iZo^aE8A zj;(1Wsg9=_HM=0N0W^O@Y5lKzIPF~}8UyoFI%jlRd3z0=3f=lK&sL#0Ry>mP<2aH- z2Qh+!yo}(jSvB@S7dirDdZ{2v+h?Uq9C)o_Vfc{Y52@T{POIPsRh3_J&4>W3ajE*a zBVeDao+whoM8SQ0@`6K=MZgl_jHUc`y>7V}j1ztFKgVi9E)8StPC#{|n_txvmGXK624t_q${zNhzc zti`bnyaLuVNW0kBBjh4?)*0zhmUsVc4crDJW{z@zB^I!kjGTS&JmIrznp{JHG(AP+@+to7QYX%3e?+I z+ZuA|vztS1&7w-wKlK3PDVI!wwE-SDyzCF7A)mK7IX)fQYjd#bWgUO%Ow`adXJoFG z7^s6xa1w|N1nIzp-p1qc6}hg$w;gp~40p1n1HEOFZPpLF`tZN0%3xXl|D521}2a4w0693syH`nT-eWm1QU$|Yk<7%)F{!`T% z#Ai>)vGQ177PFGLD-m5?sPppi#N|oKAxHBlx6~o`?VkR95;*C-fhg=fr16&|x+45d z{l!K%O3jcUHG=44Bz^C^r?>tZUccS4Q>5aK(V{@db9U#m28LcliqVckaORFn{0XAa zgE121{Lp-a38;RX-^^X(p(Z=IFwV0!j0(+QqL?DL6;cWBE?2R;-!7EPMsV9k?lGo5 z$K%PEIH*`LU-`_h*hyDGEe@=-%@ZE#BpCss=k7WqDK*lXRAb+aEl4HQ^Hwo()}ETc zsia72qE1SF_8}tIWR)>^{~p#;_}y?zSRKHhFTqFx{?#B|5qpC%Pu%pHf6vHcp0v(@ zxe-0n_qbC{ydNXaeAULcuiEuamtW|?5`96vG>bAorO&(WKxY6CyZR4W=%vAI=(5ck zTLZ`2(fo&DSl=^)QdUMZfH1V^wJ*Q$8B3I&Lzen}M~l2&IgkMeSkYgZDg^>+pGJgU z27AyFoR_`3MLeq&8}u0e8FR?)#gYbzeG&JA9ZZAb-WHp!-MSk@2y$e=8zymtG3L!4 z%!9JjGEEQm8oaC1zku8t0W%cQEZ5I_l7(Ej#k?Vt<7;@*;Gfb9e^3diQ_@yb@40Xp zL+4WP7=9J}b4dMm6``6_PXDS0DZ+%4p{}dzkvlD+Yq^nugnJbeU%9Xqc%4PMdZk@$ zV2he8w;6PH;p*g`aey20kjlggJ>%wvKIDj5J}}+kAqL4{@zxDftlz#*=sA8HYS!bB zYQ0>CAL6}Jh7ve|6r}qN5nZo>?&jz|5;NnT#8LMFkC$9Mja5>DV0LVo=Q{RfTt__SI;W+nzuqV@C*Z=ZD>l%FT|!Bn3Ib?L#vlw%5;M-M~+LefLY z_pmYm)Oq+2;&X~QU|)baje)$_@^+5KoMDP)NT}Nhf!y^)x!kC)v9HL&HWO{7mE6*d zV<{h&g<})R5(cp9u|o2jY{*^t75$nUuCE_%04FO0PNoIiWBtQkr!%19bQIVC^OuWgo>-rk);lM1Fxc$c7k9y}@pbU`#Kt_(RQRL9P>)%!fpq{YLb_^;1 z${3ykvZE@-rG_OIYSqdLg{&VOK;5j{!NUi;EARx^L#SMLgo2J=R3 zeSIMIg6jJ6Pn?b_NOY*)s}I;3DC~a@)7s~G+gaZsVKaRF*<5fb-MYbakPM|Rjz~-7 zCFw?js$n%yERaqaJYpL-=Z{6uPfdbLe&lKblmy#-7a1buiuEdCWqvPL-U=S4+p5+} z)okXL{rPCM9r<}9KaMDnxp_DpD?rRCyFx8YtA)sQf$Bh}#m1v6KSBCN4&k^OD2e)G zUPS@{!7~L)-vb<2Mrqk0%WG zsfW#XjMhV!xlKI`X^BD*9spO0Rpc3kSF=sMhT9vy*tm=AaxFOzej^; zikJN@&-mgKsG1G*qlvyf=K|F&Y`@c3slOVBDjIL#f5I_?0!#D}n1Ho@?p3cGML}l_ zFG%z7{xkO~8`G@b?n=fIU3o{pNd5gE zU?JhmP7vIfdL|NoaR_B+?a~nhM=39@aE~P1ih=|Tl_xh?!h?e;*-sR3b(KfrrcJR9 z#0RPb{RVP(eYN9ItEt`Q_$w1qTKaUQx+9au!_`N0$~DYWVObzDoFJIa>_1pu1O+5o zK5>z&0m*T1j;en4d&aK}$h2dd+dlj6&8cv~yY_{;uU_1ORS(`^nNxAY8dnO{ zHz3!6K^3^t<0D?vPe*+CpJ}>BEAWfeRflHRU=M7f*G|z-pzdaI3G!~Diq10TCnqA5 zdNYSwdN|*2#Iuc&)Z7xA+L8Txv_zOYz)AnU;%(BOoK_pEifgm?&g>6dE70rMjyo5< zwkKc&2;(n0f!nsGr$vydYs@<7=&yCe>3>)-__&SVi5yBH#)8d`P)H~PS5gF5(xN52 zuaDcKB@`#=ju9lZ#!6=90@D}r#XB}LpPJdEn--PxIbSru)gij zSEhI0MY1jAZ8Zl6bUc;w?G_1~vBre>1G8`h60!f!RcHwh+*wF|v;_Y1=0TpVc{wI5 zGW8ffu}=P5W?_GSd2aTRM>kfgNR6&=@!xgqf=y6&Y0xx1UT9PxX;$DGfCbmW+w2>+ zO~W-2Lca}%D*ph}+x(Ga zdw&KzRUfFBX~B4uXyw zb;jpAF#K$>%$BLXI7Sk@%b1)IWb)0YkK%R?7&M}hg)~n`Nbd}g zYi+YXA8Wy+BaczOa2t2NRLxN1A4%uZ7bSJpYqmdwmd}Ua&Ner4$ITx2+D9T_#Q>X9 zpYySWSbA!h9;<3n2hAyeGfLb%CWkRO+Z=KcPFUpfpl^i zP>|aYj6yA#e!ve$8 zCN`GAB>h(duq*wy3!KlSD5Qph+fsL z+O3u)VIxYd@uaJEz zt9;AJB>$CBzPtresdZz>Plhv&>EqLY(R65Lfa%tL6JTuwkw}0-`e()_et6+(`o{cQ zrb&39%-6I?-Yg^LCtnvW3~RbRD=`T z>}vz2?FH-aGEVmIO^;V;6+b&Tw{p_qCQzEkzjXl2IgNe=c^-uv>E(IPU7MNp} zGmA2ZEXXUG5H3!Q$a=5+0JIQ<#K?pI;-woBo}g8%NIEPsc~2boEnIZI`z09xvo(uO z7bh*DAS{u@fdpz>dk!iZ zEH8bMc1;Pd=_#=c*lXNowi^q+f5Of&lB`|7Qfit3gmD4FaQ_h|`5BL*5x#9&uEPA3 zgTHc+!Mn!VRD%CscJ*pCx0G(gvN{0Je!$jZ%`f$-3Xz;~|M)fvS_lo0dW@#?6e1`a zvcF^3Ttg$W&0yu~;R+s-xy>popFvBvc%tCLJ^A*4Wz!;X;@>FJ{+w2PnCK+u9>mYl}s8oen z{F-Ukc0b@R@L{oC_GoCkf~|a=e_}mhT5?Ng$03kE04r1~u+qc>LP#NSKW6$V%VqWk zA;G8`>}pfXm%`gVWq>BeldIFzTp5OjKQAZ1LIq<&RS6;xfc!op7T5|YVGFb_?g3LS zycj{5ll=`P3*MnUr4JN*eM(qtc&wn6ovBt}J(w2|36Z(VJV$vt!LO(d_C7yX0CAJRG|#B( zHoDf)c-EoZtFJ9c|!e-9^n8g4flVBd+(PlAm_C zfr)1CO4Nb7W8Eu{hFdEm;vS=4H*-wZ00iR{;BONG#h!)6EWy^V zsNa0vvBWbNN~c;qyLWx73Ztyhd<@fS5tkyg2*( zlWVe=s<@Si5gU@H4)$y`F^;W;OE7MBx>BuOrClY3Do@1(gdlJG0q+@Wbz=|?@yS{& z7#+sRd4D^>QP{E#Q&;mz0HC$>u_0riTUI=Rw$6awFFqNF5DwlC=)PqyO}#?m7a;(Q zve2&ig6WJO!z+>!-pDT%W5cm#-8GPd4tkL!uE0{lyZIn8{qf1Bi}$p!MF zeJJxrf{fsD?5^8=YVySB@~^-(m(CGCi#6v-@QmlKBDahK(2J9wo4@2ZnR>#@Ks?V4-)ZXHePcE1~ zZhK5Wg>q*Gaam|&fUT*KdA}LVr2K#%Dntf^r7xZo?`>XAxU5(R!ynVE?Ub>-4M?yG z#a@IQ5=>xkyaV^91NY_t@q)#5yYV<)yz#04C717I5Gdt~gzB9D?N&82_lReaR_BHP znX}Fg(ZrT{x6?BPVIL*iDX9M60Pwc}9)P0~Cr|NT9pz|xQNcHVq4g{P+8dxpx}E8w zt~ZExXxN2eKQ>%8&taLv%#4kLqA>v5SZ!PU@K+fK6|F!F2Kp(id-ue&L#sm1r|UxH z2XU{1O;6blcLuBxiauR=abYOus3)~U-}BCUsbxRYT|uZtZWRTrhHn&DjkR~G3}~p$ zBrr4o1qh-qnqAO=;m75jgNZa*#JFVy)ESvrm*nmD;o=?0U+x+Q{L9@T{Y-d zXXX~C<)ADD1law+!^PF(u4eTn&x_|fO8-1Oliiga(Bd{h8b~HL)Qg6+x#tV~DS{K2 z8UF(4oFA|uIg_kSt-PBgT0)^adlRtICbUp7x@H==iYyZ;cD0_@Az&4T7nxc(BTeo; ztxk3)p2B5X)7^|BJ6D`^VQo>YFkE*i!i&=q+o*v33DOcAlvnc(BJfDt5{Ve zV{?K9k~r##w>Wd8KNPYiOFj;kx1d-Bwm_)#n-jnku^=ulj`FTm14eMP>*Psx0MWNW zBLgHF*S=BqrWa*5TO@^EzApgxPTU9y)PGv$Tyi{e`|ir{A_HKfuVZzasT1H*s)ol! z8pkq6v-j}%z#K=9|AY-bZm1p_bB1`%af+;?;jBZMYi1kVeauU5HgwNMt-0WzE+32P zg3f%B?Z4kQY54P5bA#irdemp1$reB}gDTV|F(Hbz*&EmubnKB2@RriCHVtZUZBbf+ zAh>qiL@fV)TkolgLcz!uaYn|B>=yIfpmf(7q<}5Y&U-=bbpBeL3 zuiZ*t`pK1K;&i(<7`@;s#F>}Exxz0K!|P+j8VBa*yJ z(%ZcaMmZ`$gwOy0HXRJuj=Bc8x#GG*fIB?~?gXR^$ZU?lAYPYpmMuSeXp^dDoVTS4 z1Nj zCS6pk4lLZx7T_cX0`}=NZl<^PoNK;VMq1kC55(f`ztb$}s;`7=^^=K~qk<93dNy%? zD|K%lXJT&p_$zF5!ql*e2Qq!*q|!cz7f!jt5)niu8#9*Xd+nO7uaS;P)w@r zy6E%dA}FW_)Z|VVpVaGFwer-~Nf)=xiQcEukjzf6u8TD9UANd14XKttJ4b1sfWVOM za?qE`8v$msZ<{vr2w1MjOthJUXteI?mBc!b)6=|n*q z8vNL=$DW*1Wt+7i*^fS6S$NuzV7A=F=nEb^nqrUCyVV zWwq4YR=W6*zIx)FmgE1zfOW+`>uS{_;*OqU8;6x+ZQ+*&9_cHBtnML)0VOFd!Ec$Z zNuO!THnTom)`zUjWi{95m?r1jSehf+Y+O{AX*d_+J2l_DJAbYn)=cJ5nbJ@tc_a?hiHT3WWp^IgwKk3>Xr|p#kJDkqES&oh{XV;M=U(f+ zd9ynHATX9V-kreToWa&STE^%6AVV0lGYOholFFBaW9RRt05@!wt;^{Nc@QadD;b=h z3Z0+C*EE{yk3f3hoiXh7q0En2@1za(T=;^?wlXwL&NE&4iV;Fno&zMCoS|f*(&j5w zzpVEX`6-yWr!S;&F3M7!i;tUCYbp8dNUQvTf8VHN+#q1}08$1dBPJ9uG?id3)ojHr zXH^qP7^nKSRTFtGJZAv&q7OvOI#3ccdW>kUt2I6c*7qACcotop7XCX604$L$ns5zj zX(-f%CGOt($@&VgYM}UVn9UXe6KwaP!Cf<%xI4@fn*)p`qMK!sTA*&pL&#JTxggwI zZKGndt~NAWT`Jftg+tsa1OqmJuGg2${FVF&SfU}H+SPgx@ZQOP7WJ7av#;KIAKFqk zY5QB%c9h~d@c>j+9KSC7s11fs@`_7rl1-yW;e{)NY5f&S>e|80H8<=632@UZLl%w{ zN$7lQg^;sC(9c4d-K=tB5aXtPaH*Gfbo`j;tS zO-H_k)|ocCV!I57+OS=b7bgj&ar3*;XWgBnwV>Kd|9a%FQ&rL?(g=s!p1g`8~Ga?6I_ffM0-H;HVxpv6S;zocHX1E=9U zyy?t?t1}0FUm~dJz&ko0Ob&t&B@KF|B>Jf*qvw6pODYxROE!T#m5$@-?VZ`70!X(xx6p0a z!0Frw70l#f;-X(-B6XS?7%yt*S(_3X9S|x>Tm)L=O3T=y89fT`|8+ zRwBo)vc$>tQvxK`7B>cboa|Mv1x@=%vKFcLlwfmMGnikJR1hEJ7J8kqL`ZDI@=}ve z`In<|O;_8n!*O2qY6e9;EdP)M!?;K+dy`_{;-sH%?L>*cGuq{U-!rwC^l?;oL;mKR zaxf`nBg$$r(bPM&jJQ%>slR8ZB6-BL^OeN^FohgaBYDoHSfvB}*h?dr^6wixd89dS zCex8objw>$^dzAkj9LzD?5P-ZBlqoxsIy#d``dDjQiEbuE720g# z321&%)ssyA2EyJ9)IkCv^&a3%MDFg)T99Dxi)Y5vD{SXpFah5#4&?*0%piFi1*4C+ zHIe*qoWL5X<@@6+VI7F1jk*|+WRc74&0JN*e8BVkbqa|JAvMdRx1*?;Sl^3glIV&L zT_AvY{BkxosL62Bil`?Je(3VFWBY2>^KbK+Y}#n4ylZ||r)S|9kwCPp(FZ>eFbqdm zL<7k?PL_YFCXiP&?gDQ%IrioOU-g8xgQ4Jq6NLJKGoWllkwCA$37Mm`0zYiH=2ou{ zM+8X@%<5f%t5rW;DBI>G22HDWdj%cTWs(FpGRbvy$}7+5d(uWqN}IEpv!6C!uFLq3 z%!$V>TD9?yEJo70zmg7J;8@KPQ}2>byK5N}{#;hlXU(O#UJGi%oGBX8(c@xfHU0^L zod#QZ#cnFS{KKK244|&}j*AW>zMmd&0^jVd=~(bHGN9V)I(b4;^pXBwiVFJyo8n20 z^TwrzHT=uJT4bRQ{4?89+E3WtnBR$BgG&<-rl;)!KJkAgacWUxw$|aScfAbMPX|+W zGw+Xr0RwlnVtJ%kpv+BU1WG_P@CYP<5L-`^+;hOo=|g3+{PBEai!&mIE8^%MF8QtOt`R4SIpkDGQ{T&u#89iUQt+yqjmX-yDKqBnbTX-@O#mJVM^$(yWBZ_ z5dQHpN?j7C{d3VkMt!o5mRR3Yf*L@ky&oP?p78j>D{tu+`){V24XJD_SHqcKh6b8$ z`x8409VXJkSjz}MgqG8EXB~_`*eg`_aChJ5Ye1R=)>W~_ujY>rU*TC`jaZhmi|VZv z7}ss>?LClE?GzNa1AiDh@47u)SzzU9v8U`alxR@7;J054P$~}C$0?CVaeskiWTAS1(VxYd%5I1RiJo@0dHys;%Tsl=zy>FLB!x^7C1Gz!+#~U^3YZ7x0ain-Xsf^2X@ekUX_zN z`9YcHpsXpSD#=7qfiM&JG;F+Rtd1KYe0 zv^Bn(B>|39H_o0zVrn1K64?+~j%KORZ&}q}J+`n<;RnrNR$+LJmK!ZMEdoX{Lod~} zChbm`snZ72PsIhl+N=Ox$s_86-swh_*jpFXpt#H7#2Wj@R*F5kD~(s`7wLQ@GAGw0 zzOD`jlR^XDxc2Pm@`lXhKR2a04Ni z7As%6tAJw1=KURk{6B1c`6JX_^uJQJtRdNzN4BKMzD81%eF+(rY$N+_ED0$>iy?bx ztT7d1n;A-|8 z`A=Nsjaqx~4blMu42gUieg`&kbp6Nsu#XCeXP(7ZOGc}rIaQwT+B)Dvl~4#zw)Z$EhFD*J5ARQ{quZN;b%_9;*MKNtQF_Layt}4!xD>-@i_h!oleXGZNIh z23z|Uh~R@mYQ*`t(bxLY@O@oM?8pYOteBxDTh0TX@im03O4OPZ$KI~cx%GB1=-}d< zuVBvZ5wZgCkITcgX#~=RPxz-b@4IY0DrWST=r_J~al5_FdOE4Nq%&pDNO>7rBWO?5 z-IpzI-7L~#P#$+(*up^k7mFd*R_)S>KUbn)W?)qhRs>l*k??OnL7N6iMZI4OZH9;nE)5Hz`Zbf4;Zn=m@z3D2$}Up zx`OCk;U?9w>9etJu!^`XC-$9GP|t)?83iYNc9|!L)YDp&f;HlxFr|Q^j~KBopk$PT z4BAr{oB~%F`-Ek!=@g_5q_}GroXJZ|Ara`0+}*BrG!f~(ug-#As{7U&UjBS@73cOW zB5_`qN|Xl+8M#s1#P!0~up;Gf%ref_m7?gWE)5O=$UjZZ2L^+bP1aI=Seb9I1{MO` zH@iGyYtSE1|7@T+Fjn)-9hy7Gr2+X;)(8oVHxShzRnh zu($MFdzbf*Yfg$g1z|YzZWhn%_q{^e)6(S=&_=-Xt%ogF(XV8@!~XlKEuaZUT&dYo zeMHr>>%l#FV*lk1UuLQG%1^XxChaHrYsY4U|8Y<`aAHp>WG$wXNn{C#)s98ntOeeSvZ=ro=_Xb+Xo!DrDqbvM-o{G=8-* z*YK1IRCdyEm@4|EnL83Mh?b$ab6^gj2kN*Pp6w^m{g>s%>>v8+^6;3zVy)1QMEX60 z;PU;GVj3F_=YXrIsc<$|UY2vJ+1e$i;a+SItU-SS6s=Y-)YEo#k!we9plB~r^};Zy zdn0#CuBtIp^FEOLsepfuWEH_@MQi=D9`!YZ_s!ipVsXh;q$rfyQ!j+vAiAXgnAW>H zuxz>-=T`sGS=n$Ea=eq4rR|}R{&sx>z#hkYhPPS4M$|xnX7nupY#f^qUJ0kMu9QRC zfuZvWapvy@MO=u=0OA}`MuszUa@ORo54~TYH`~Ft8Y-y5P#e)35mo4;kyR0FJc6IF$JH&8|7{jz?@-B>=R2#W3vvnMkQ#$%h zE&u>%R0RL$lo=1JiM=j4MlJ{~fz3^>as#MVSRtGM8|c86SE92ppT_=5nQRw+@pgAx zv1@r970U^leG_*i-7EqU8~D4wYHK;&s-AkaXP{D2%{Kb0N2Y~*%`fY-1HNiScmsw8 z=$wiapj+kc(@zN;sLc7+vyf#N=XEo)L(h&-5?(|gDS-hRy(b#x^SG=W8sS;NOvvv} zK;FbCoO}a`#UK(`6(t53tq2PROjPmx8eu9grkr>eY$HT81FmJJlyxSMS_53ghck>y zx=ON9w`hXGQ@m*%~$?X|gPM z?q*D_yo`X*Hwyg4N6?2*m2c*ea6{*%wx#U-Ve+d+wW6gbq9fS&y z+E3}SPjn57L-`vmMf<$G(6W>@s`ahUPxtV3u~%B zuQ$&6Huw|l32+rVw{bIJ8G9r^*Z4i}98wBzElYA))(ljB(&5US0Mk1{w(VK+^+Hp3 z(3c6gqB~x-IX&_GQ$G##rt*gQcgN-1^rPKO!?DYm)*I0{x=X1VbO~uW9D&Js^E21e za-cODOi4t#a6KvjUf&6PPte5>v$CwhN`{7rLg8f+%{lSX4v-{`1{4s_%+OK95%>F_ zmV6`7&!jiC~9-*%fM?ND_50NG@jBggNy|)1A-ms3{|-ApewxZz~1^V z&qV+J$T3^xJ(|Qs47i_BkZY3cZQPU_(~W6&+q`uX+(=!~`qbN5uvh_&tz8ZHle z4yM@1`y%XXPt4!UM(DPYe~*Hu5@)H2(&O6OSe;PS|@>A$&%ppJLihsH#myAJbwX3#V>DWZRdv4v$B~Fw3 z4ouOkkMC_a1%gkPOB8)$3$fipRL)Pb_=>lUN=k3j8oeoi{W&&#oSSHF9a$KA zT}eaaSH;#Bo(?(SkG=u>WmTo&8AtUZM=Q`V-(}bN2zkSI#2SP@w!TQ$?9AEqO6Lpe2#%c?>c58l)cZJsUp1W!L{OpU#-6{Y zvX&={I|w^vjEOPo>-_mnk&@*nSr)?@F8x;pSPKdU0yQ0Zc(}R#i91b;0(vXFEM$+E z5!`&)sxz@~QlRhMGB%E`jT>8oyenGBpstAi9#+>-FBnywc_sYx@r&DoyTI@o|+$868z0=-;nJNpK)3pa|gfHCh(% z=&E1yDvsdP(9>#t1x;dWKlc7%o00-HWbK{_w}zGU|6w-VO3_H5P2|nKXYz52SG7RD zBP3Eq<0(>}k^^<+U(U03*gnF+m&7ocMQbOqv#)?s{cU(bnJ0AE-T+!2+5o3l3U}|q zD}U^~${hu_*)5IpaTahLiREI7wnifz*nbXF`Iq2Iqp?$v5L3HH9oQ}M_6R28vcgJC z%=M^>g_F7Aq{AF4F<|%!nXW2Dn^f=(iuD~#;ga22s>U8fzx^X(6Y}Gh{1SD;PE3^< zOy1O-8xfIEd{=ID={A18?bdYCe=g z9DFxA_$%DKTi?HYj(!TLD&9xNiyxt+E=QtT)CxvLGjcmm0dv7>I+WXq$5~lcJH9K} z%kMAojQVy$7=UM$WK!2nzbIBEXyYb+&xm}rOn_F zBu3vF(<^hQ>4Ik~BRS1?od+`R`P}k~lL7xBmPG#P6Z^K`%eMgp17NGQ+6=RgQtL}Q z(F`i`K>HrmlV*0P!a{b&WMwSvdwn)MLQ3RXgCb%CIbWZ>i=i%ncyNrE(z>SxP=Bp` zhK(k+3SsSD`C}!Ay&c4MjFQSpn_rQ_DO|Q$n9m8;qcfVou2-uRlx16URuat{d@kkQE((e zxH*6EYlv+x0-C7nav_`VFHx5R;+o{aLQsz-@hPSH}i;8s}*kb}Ls-BGSqi{EHJbFY)Z8o*Pe+0|@ zXc6(JWZvCFz}N@nc7_wR$3B6Z)Euc1_If{53UvNIy>bPsh`_}QD!>=#ZBsKtR{rt@~rh)4PiKpt!&D^K$SLIyO zI$|xb>mZ38l~|geAdoMey1hl2I={6)XVrWFx;<$*2)d+O_I3|#Qqo|5r#{bHmss^mu`A;o8)G!`}*28yd5p+Ws+!N1;b_<@oI5$5qifWabMV26{4xLMY;hkmRv1PW|>!Sa*GAOwb|9Ymu#;G{%^7sb`-i4LY1A)fbDR6Z@BJ<-58l_bJ}eFbUv1Fa{(kMKcwPXwosn;y)80EDdytm^ zyOA5-L=|hSvF2_DFjn)h%)Q+rtJqkZ1^JEVs!DYMX)~UsxHFNXWg?gyBVthHm3l2Y zV*sC^Fy|@0)`UH zSNklYX#Se5;l(f5l{K|Kv}`yw=VY1xs)S#eX%=;|ce!>NTZLWCCHlU1`Z`heYni6h z)4!QEgtc7`*uy)_$SWECMT4r+fsWN0!*A;!JO%eE+h>0K2SZaSzkTt_qYhrHWJANaRgrW8 zte`c2??QwtNu**n;_MyTBWHu2%Utva_e=M`o0HU#K>h1VarYhNpT$NcO<~_iJfzxu zn3IyZ*k9xC#gi1sMxcf2%%we~&#hLzHSo6U^2WJ)uuYhc)Rt|Nw>cAlK$xf7wHTC) zx1UK`&KQ4dv&2DIv0c>0nc0&mA)x-cErd}-b_*m%z(+yB(*PN&EsX0+13DO2EZ@~r zp_Kl;{6F3Ak;k^DahwLarX!~Vb1H!lD{ykSPO`BHh(y4chNanOl{((OnmVgIe}dw} z)3)g~p@W#{FPLSlKfSqUAPWhwaXT={Cyc*YhM8?ZceMBW!0`~E=Xaz-DWCW9X*muv z9=Z-!(#OfMs)r-s(Z--EbOUI{EhPtKcdXfiN|PRZGirUo<{vWM#(kQ8{Kn$`ra)WM zN$I^hs$5tZkln~52Ludqt}6*y(~^zYxi)I@$CyuX8d2Mx?#*#;=YK5wuz%Y+KPT6J z3gV0eJaM<O{Ezv+Hby;TY=_CFjTVj=Wi-<+}Y)DoGYNu*UfQCc<1!%k1On^5L zNSzF@4qB-_{r3=A&o5#eM*-xHs_C3c8NxCz2h1)1H6?#D?1XI$^H85?i^$d^kfj)t z#1deN*hi$LL#|1;Np{%eH{Qq`uj}+MquKHLW$c6|=oUCeT*gr95~pG<0Ax>$Kowl} zgUDR$tuM+QBdsJzWe#C^n$%-#B?VSA5>2bKuVc9*Kt zfMk{FN=ZuWFmr?L=J7Zqw{0)$-7Vu^(e=xEpH6MbU)ddD+!Z~T)aTQomcR$n zrhkqerQMja)-Lo`*zQZRI7}@fzVe4=JBHlJ2L#%QXx+c1*evFE?^TYz=s4+A6ong= zq%1E9wlxXMZqrVJQ4(#QZp-;E1YmuwEtWozR4V;}8^|NfDQB#D#xf-hFfhZT%K_sD zzWt-sNn|tSIx~awy(6ariye?}i_jyJ%;-LM{&p*pIyj(&EWJ=GRo4;GStnE+e z)Y`51`z)|cuZaGwZ<>||e-d-=Lsxbt)qk`f4^sxMO{jxG z%LjYN141(0@?a*Ox_OMc3Mp)qIM}f)vYGi;qHH9r8W)n@ia=gklrSGr)EX^~6+G(z z%|JLSI08tF&{E4A3;;<(fEUjZt%FbKLv|s+ovIuqBY2yeK>l9=nM{B^nz`q^Ab;P$ zW94AOU_YMcp!@4ktlJbyXP7o~K-g%nO&N2E-!Z6o6b(Dwpm&P&bz5R!{)@Mz^Y~V` z>0EP2B_V`B?}_N&xP2AB7e~fCsHe1zg*%*$(b2V_N8!Z&udNpr{`u~3qiPowN7A`mle0TG{dkw*z$lEzCI&>igp1l}N8{UBxD6{|xWUAypwjv2=e<0fL`SMh>BC~@)yLp(&P`n69sqOI(Td~}v zYN=)BJz0k|SEOGlph&#@e&*9{#~r{z=~Mj=d+@c&*6*Leu94k25JErR8tj-Y{<b>K=6&5~DAc0)FP1ZNM)W{Q@*)CMW{i z1K623XC*zSkjKZfqUG=W8xscs^E#Fz9y+iQ_?_Y-my~bivLJcr|k2bzBH=%SE2he>Jfn5)HBdv7$w9*^r z5y?*rGjhUAOx%m7VglC8w?F6eoyNNT6IpC)9>P_50#idL(ZH5{em~-|A4nL|OL#0< z?XgftTes+lIb4iLDFBq3%1r#kGw(NX; zSSW~loJ9EC8?sw)R9U6FLMGlwUv%J`V?Gpe<`lBobh|tzjNxgd)8H@QT8h<$0Id^D0VaVktas8D9Q^#D`=mtt-d9ZZ>NI@|cSQg5XFGqLS-ET!NSST{9`?P;z zDxqMD?+MWwywATMi)By~xDM>aEsY)&XdQ58A**>z63E>;tB06&+D>@wxAX+5(g4|= zQ!so@jav!cB>TQ>xBI^{w6- z+j?TD&U9&@Z83o~+jHhVE0Y>ffUQJRYh4v19U*RJWC_{L8niihafyUy z-uTJQv=n`868{uvlpFk?rb`^5RE&VWZHOtW){lFGV3rM<{krLb#E*2Y$xwpT?olIo zs7|LKb40g>#jNjQ8+)CL>Zj5Qds0ap8hF}MV5!%CiMKV0DS~mCCw3zH}-u^FaqT z4*c#{&=$akKP07(99@5FpjbwZMur)bVuS$9LOHa#{*sZW*Txd-i<1cCPGCP>8e{Bs zaS2u*GDJ`W8lX0#qi0;oh*qkfPFJ`BNSY&>102i;#*J%XK-`-sUp<#4N)LQgGLQ%Q{?mZM9;wGnj5P{FQ@Ztlp>WcRlFpBsL0=%*FDW-M7^1}5pct#;^kTVrM6dlczmn)7DMQ{P?DuR+|5*}y`PikvOlX(o zw-R2_ z+eVuv4%*$R+;VRMCU2~e_DO;gGPs)rq6WCz-kCkv_D@1T-)`)CqmNL0{ASCV`>$J= z^|dO*rn#-%ipu-tU2wj>DK+AV6Zlvc#4_vh#(Ep#dq_}axDZ>*uvl}`>N}ahL7XRs z-E}m4_ucDHfww8@?D#{k!<3RJvrT*YDg^nKavtZ5Zzdf+rH z)<(78g4B_MNsZbW_7bP$zkWGsD{*~sq4Kw;LhdkU-S&IFvl&CKk_JWE5u)0eT!lpo zN6A|9+~n3qwuy}fCl(w@8$X7=QH?*ul(xn(>HE{-i@Rg6?zg4LMue6*w^`r&SBv)O zntNJ5eIL0;M`=p9$`kp{J{@xXx$S6j&XN`}F|lqNy)Z1IFVDr>+knuE^b31v(@Ll# zMOWi#m%)|)jG%a#-B9i-bHL6A*v8+g(T(_G5+v!%MGUc$FSYv;QW?%#f^Iw*>$%cg zHA^u)cUJL-BvyiCCGnclP-Sr8@23BBU@1$W1&zs2-VUoguHOk8E#Kd9xR6C2?==m| z0^w_3YFIG$mg%oo{X;46Uy-4xNYi&OV-hE7r$vbyGwrQ@K`hmociSS#>3;}`&pNg-1uWbb5 z3r!<~^st7JzM2w+*NMCzq(g-o=y!5kv(aW&u0L+rFP@3+Rfq#7Vftn2gAO&}NF@WS zFV&$LO9>Vy`}(h*4jtXh4wDp#QqZ^n7Y#lK?d$zrs^0NsGJB2*bse@Vdk1kQ;|O^l z|8wjFBkC2d7FJVzNZEWck%LwL-ZXG?i5{Lqm~DiY)Y+9)V3y)Jd&+RXxlV!S3NGO7 z$pJTL5ek>owr#XaQRhG7wp83dW1dBvR!}gk(4@ z8t^~zC*{6QW_`*tqrG|IglkY%ebsuG1Toyq7dN^k=c80noX~|7&v$>sAB{L z&sm>>RSc-~qFmzW+u!%<{3)Vfx&KawP1e;eVK>!F)8pBQ%XZ89ANu=e#)m$`1p41e zB6)hsP}Aa_{cMEf`|*Om2Ye3dN<=?#`VEIMJ472QRJRzF9yv@sPWl9~DBu2TA&+a} zt9t9|rL_PQ7L8|n20eEU0_QkvDbt?UiPKkqIL6!RyEt1Tg*uAk$nx(%IkYH3LVB6~ z|Ne{{xc)`vU%&$pQ^IZ3`of(-phF}?8Irfzh+clOnPMAJh*+C1W_H^$IL_GS>nJqn zbmqfLrjAz7(NdD@MdMSSXrOi%hwr@>CCNT96I;0VZJWk9LAw&B!!+DCBjIL?gALDu zi5pwvw%`s}7c`aI>|Y7*i`G^?sLxElt!)$`?nwK|rUAKOHgBR7I9QdOP(idlD6!$M zvUypx0KUvkGZrSR7mho`!SK78tffaOOWpEEDEg$zD!*Le)N>noB|M54TMiOr$Gt=HTs_N^;YHF}w z50KC{)aXMT3R!1M9WFY|sHkdH-jn4peN#bi&cpEiBON-*>#0QD)c@^mJac6^{$j+t zx7Q6wp?-@j)s^pz>obz1uUPgLk3l4vfcc=CA(;Q)?+GxUvohfMo_W5uoXIN_7mjqozHZy$t2H7W&z>>2w>|gfgEB`? zA~eD;iEPoRya%@L`iv*Y(oaq-y>+|$EivI5iMe;eFO0Nk!Q0#Dbj~W{7NW79i{TQ* z?68BA;r~8l*;0r1;Ho#?+!~&1((;Z)yCS$s5?{QofKnMJOXq8)MfE+jYf+rKF~>8z zo96_IA@z-LOLP9j4}KpC`e&LtZB&>Zgi27U&NW2oM$6TrdOciY!`wsYP0uTiO2Xeb zV|OIG*1UmlU1d%pb2rvZRMgc7{O-b=i>iHDKV0%LvAWs(2sy1WM=}9s;77S%w!akK z4dZ4S?%jY)j)v1Boy5aHh>p0Cs%NYCv3f1p=*H1>1=@@13fjMnyNP8Z#9}-Mj=HHf zksYI<()MFHw}x3xx5$x&63}<^XV1j*$#%h-z72Y{CNr@xy>mRK>|ExE&EX`Pi{-Pq zTjHX?Kyg~7x^n*uSvFTD1>v;A$@EtxJOMT&qJa^$2<0$a_qs9VP+xCWfUNA{h;H>6 zI~S5ax_tH?fPJt1B&fedYKZQQ08J4c^rP_KMSZFUU?I12bFp@*7j3Oc5LqMYzV)oW zqv$^Exw&6oF90Q@p#(kd1Qv-6{Fp-D$6z3xHXYqkR$-F6Cn{c=e-S5fWxGbT%M{i~yYUG1Cn z+h8`R-q=1z&%rB3)Z`Zd;~?8BxRPIfNbiWvZd~{Ac;?nPpu*q!*lnIXjzj|>!s{|H z$iuiP>3o>^H@at#QT~WfSMM!E(Ta?{6751_4N=_tXbCJHz>((?#MG_tR}|oMt7v@n zkC)o47W}D+#5yHHXKae6fe(Ic z1VedzNLdDMCkmFfkpXh6F=?hkg1^LH=?ctpQ&?S-s&7aak)yA+9X(hus z&XpL$t0pLzI2+;Ea!s%6DNQ@}>i0R{`RqmIsI# zsNTEjf5rxXgi-}ivF4o3(#!9CN2&>ueq9Gg4*F&rUkacT$h?OUM>%Mh zP_URJ@=@zq3CkM^uyY)vj_hV|c8~Gz`T@QCa4f(Lv0AzMUEGYoKeI{KRcKFt)i=P4 znTJ}cMh=?z?IS{;X6=<4%H&+57_&KIb*xm4s2{-cZlB=LI83aZtJr*PBYLumlzVn~ne9;b1Z+jB_U z`fMgZsg>Z{zV5}kDQIluRZrkpjG#~gl8_~P+7)($?25N*VrJUpind6AM#sTpS|CfB z;A(iMO~p2buUmT3+@y7KQ-bM`;j%6n8>{DD89muPVAD77+fNzk@QJD=_jS-l z{Ha zzxBW9zhMe%2XeEtMg%TH1koakBS(u=g||-=62^I3=c(Dbg{$O$FLFpmCI>ps)>fIp z(RVdn%f?x!XUC-jksexqIh;xAoO4mo5z0$-R3f!NU~VAOhNBW4zCNV$-N)|XU&zoE zZ(y@!McSX+4j%iQG^ED}p-ts(Z-3ojV5CA4*H0}pKo zNe9=qI07*mmHE9cc(qRZgu{escdWSy@x5cw(1e>bj?A!0ej_R-uQJ%N*}8I~K@jrb z6%M8B$vNeDX{u=4GY%m;Iy+uhwjMCp$Vm1Ds)<5=#Zy?sl%7y`98ObcprqWxQvg^` z#34l-nSMHS@C`zNYNxi6=;X>k-|H%Gd`*=D9q~=a0rl4)PVxPn>0d_a0Iu+*=?<|+ z{d5LD8&MGdU9r_#G)$h9vtBTeXyfqAsDwwad5MtV435$m4zq-|I4S;G4w$Wyki=ax z8HL~zV2hI1YfCd2=EiZ>@fQ3N_%h0f%)pYtzxcS)5gg&{ zcn0b#ENMzl7Po77PVMVVR>lXj)jGpF8UlMm41ZmDs079WOefWPmUt{#|M%HK| z3fZVCdrAawr25yE#{0q5IoNKWg5`hy!^zPwv}#+iG<9N8(Nb^d2Pc;APq zqcN`<2;2J_s>Cpg=Tv{75F;61e_A3I(xFsB>}thO%E!uZ%gYNzw>2edh3(1Mp#^4p zatlBY0}1Gl4?GQ)Xg8;8IuH!W{3MJY2=QxA!{^9>Gi zQ?@%Rk(4gZ_*fYq=5*;$%aE{fgVlW|d8?nS2NJ`A;4A&$uWh?7=TP@MK=H~H#Avev z%OEr!_*6z0BM2&!)zXoU(3-EO|3T32+O_8N9y=1Cw$de_i0^~Cg8>o8#_NLHR|uIt zmBE9?@|6y+FM3<+Xr$8YX}@m=V2|=HeFul`WK74bC5C4T*SR^nto;CLws{l z$Zo9oESngsso3m}_( zCCN?x=K1?gpz)5EVGZc5C?vUNNzWN?{psux%0{E@yj*1?z1kyQV7sfGbwBzuKk|&< z>kCQbji|1Ei^vG0LaKS9n1IAdEk@3SB3b&UCerJG$Sj7GZTlk_yz<1vYk9+-B2#RP z7(cf~Q(c--e@J!0e=+uHhbo*c8`qPh=$ijJd3|jz{E~{N;eviK_*#eT$@>D^mk_f0Oms~vgV$X$StTn$Rf2O5Jz%+X&wZ3lw?Ozn8zEP<>0bJQn3Q5&)9g*Z#5C zTT1i~n$u~UY%^efchvZ|JV%^*hHW10%+@Jmq0f*0q}|=UWY3jEt_SXvDLK{(L{at$ zcqJDnjs*k2NWr@-Z=|nnywSKL-jjZW%-B4vGMWn27}1o4uWPEac1K-Rm}`f5upb=d zW)sfA05+~+j=&z4M79MvKIq5&ftFli*y=M3j}wt?v@BbL&dS|r`4w&59?<_$XP(1q0;cA}%q10C zOg3A5G2v17KUx5;%Lh`5t8KznR#ApEoa(LNp>hx~mqFg|6CnLd!EG3-&@W<-J*&ZA zMH-1ing-pf5wMCN{qEec;Kz|)b#@CA_o%=H~gVrMcfQkSEgY4&>dM&>cu27 zE9|fNPfHOcZyzOUl!!s2&)2M&pF?rqw`ZnKoW8D!R_~J&Z_Z&5Mz=+(i8Q|^#FQ(l z^3A8+C*LEHjr41mP&%HgpTav;wl6Kjz5DB<5&}{e0_ee3k-W23_$ecw_$-!&7I{m| z>FXE4&sClZWM#VTpk{|P^>K{)lY`d7O<}K~1OpH@^3~?{U0P3yV1oLrviBbe8abOR z?4QK9e?(6cc(M?yOt%ByJ1Qj}^Qi7g>Ckh_?>p|*+F8lV7wTb zL6_S`Vs)le=s}6bTiO=kZmHuFQwrmt#S$#otVJf|xo&PW8c(%F!aTA#|1=uX(L_;q#YfK{?-bf4D69SuE}u@dex$QgIh8q45a1IkU70_;2p(4GHU3 zawY{=sRcS-nJQ`_a2y72DKR~+cvMaOzKrSdwv7-&fVxKMuyk$P@P)~@9tJj1LP1xQ zjg(?2+IQZHePAu2odfLAKtE)Q+;gck-VjSGHr~S%IAfn_9$(Db2EZ*3uskLJJhpC! z%BG_6Pfs>eoxZR!J+8|3I@*aQN16r2yj2ee=ZkWGS(+{O07`9q|8IW`u!dF@l4NAc z$W_~}HOHu~+TefS+lWWYz6O8UD7P=~^u|Z0srV~}#fD7YmRy5efYlAMALxXoZ-~I( zIr|WE0XVP1q-m82m7Um+K>|;uVdgJ&+=%LjU*^Ux5;7AnAKZ?n8n7lSL>yJ)iV42c zq4&2k<8{C3#q2#Mrm(6K5dBu`^xJx`z4wA{JP-q@{2)5I24Rs9wd7bd6GBKUtVM9JxASPv-rqyk4(sns4R0 zE$h1ZWR$6R*^cGdal~GobsfHDy_5g})uS3vh(b^@w09an9PG5ij_l&QP%{?Tl4euCKhG*8MJf)a@ zXHmGc;O~iF=Bmw_U?~X`p@Ox_zRpg0VK!A{t~5Vu%?K?=$jRg9M%FE8TW*Em0q)E3 zRpzTjH4F;G%cr7l*QMGywOwHESufhTpnzid=XEt-tL3fSZ61Y#^J0%huW2W{TlGyz z2_ej$h_H_6Fh~#iaUbP&?*y2e{bQyFjVAF#2I-(E*}axL{zr-xzU!@|%qNigrZSv5 zOaFd5m7l?If8n}36tmEV{W=6-pyp#LUMGO4alCp$_AkfF)blr9TQ!{*hN=ZBqsre3 zGZOrLFx%vZ|8s{h`3yC|IX{`A)z%v;(PUp5hiU{W%LFeRVtQP=X+HLXWXev2*AUPEs!rqIv7(-rNotJ`{RE#V z#HO0?!*2ikRPeGvrP;!{{W+OE*7Gq3N67~qJSBN7>I$^)s4|nFcD4!ChHF#YOqI8W z=dG&(O877|TTw?v@v`x`P#?xV0zAt)bRwW*oIQ1mC_+9rp=ne|+d5d3LnC;{T9YOc zTPYg|m>H&8^$VXTv-f|Wvzd};`M}FGgzVftfKOUXY_k)?pLWO;r0JcBY*Y5aZ1Ck!(a;e6OkeYx4M)M80R` zB45)YP_UiS!GB4xyb_9IYJ`zpeF=JNmAU$P)a3bzfXKtLlQgm4P-a*a*DKta!whWl zSXz~tO7{!7b2B?2l+-zfmfLH;-E>P%tDjc+SF)s14UZfoR@OBiF{ zH+=2C27ac9`FV5R29=$hJXxkAP+r@J=teV3 zcCt}8YP}sOG;kV1abes3?U7{mi2Vc2m%gVI1_!oxr z6;YIQ{cx#}tPHKm`(OLoSr@?#hn?8Hg!_|s4A>A~H1@YTm>*8nU;!k9A=ukgrn*E9mmKv zBqt8ry|L?S+o4LiF5}40*AlCv_*&C^XkCSSR8Xf_d1wYkv#F8Nay_T&LSw>hiyr57 z1Hq5O3G#j8E$CsUvk#}}&(+z9{NpyhrUBQB%shy10n zsxI;^>7QTjX9sXyNf^m9D z4E(`P6gD9t&ev^HE@@X2=#Qy%;AQuQ?H2r}&HEZ+Yz?)OH<0HItxGSmIf(q62i%Bx zfRV#ntkFx-)r2nMcFR1IA?dU-&<_=rDPg^^<2$;n@KPDXt}O@PI_(za*VmL|0-S|G zvXM3M;Dx6Vu%XcJ{lv2^T!@r83?_h$Xg|FeaL4w5crd=kzzgOc9j}fh(BAsZ3d#Up z=RSjLH1q|8B}(}I^=D>&XKSv21S!|_FS&SC5Ie4?vi9(<5tZYHUe$AhXU}OFp&avW zrLs{0Tq#&E49;lSuYNz5+lYvwZMzC5JV#N^r@(Xa9j^ZbtmkcnI14o zC4o9E3Z{p~7D=6i9oRmmy0)Iz&H?Xt=Ks2vij1&RqO!8tlfOWZRSN=weqsEJao9(M zcGbln#h0ny{72Oz*qA!@Z~A+p_0}#VPHS5Eg_bn9DTMGb60afF9mdsbB$c|vhXE0U zLd&6}wMk@?_C{VU-p4V_hT;K!_AIuXobFPAmfotcG!Hy!e2)JR)2oarZ2!+w6?usf z*||}-L`61g>^SDT(La@=XkWTB?9j?)n2sH@H4Due{ZJ6g|4TW|e0IAm;plde?asGF zhkhO5f9^EiEBo<{ReaZrim8*%-sa?x~5zC+iQFj~r@s-imv&%{D<}pE$%M0xptm zHJDNZ=xH?zyS?lRvpdgv+uqo`@{M9`|5ihg-WWKS=1(6WJzP}4Nif{|gX;1C2fG%a zG)~E5dB6y{FH^oJm4Rwd0V&(Hy%1z>LxO6Nl<|=Oka7?i?0m$qy5qIwM1y4ghPRKV zyG@=5i8;EEAbty=U*2J0CHRY9!`{|2yQC2xXrz_*W=TB== zu}lwIzbl)TF(e3N^D4{QL6{`sJk>^1mPj*1(WXyRvF{X#;&O=bA;L}Pxd(nU;yvk& zxQ_owJa21FS$+oG(AN4VP!=R^E03MGBz}$IjaKU}N~r%BS5AJ{>TB%oX7jw_-xIq# zreFCgnlY}Lu7!EV)Fv}^3pnjA#(4R#up^Se;dyjas6>*+zm+z-L3=dz>j|oLlb%lk#%7;I^p|U7Tu0cP z&oWk`m7Z7jck1hDpN3LPlYc<|A5C8#$b|p@zmg*=m8){B^iIxHa+EViD&(f-EXlEu zYmOO8a)nMu#U%-{>)zV3s?p`!m;{%MSeTG#c6 zu*}`a-uYf|RS%H$j;!z@ew&kWF5UP5WzVA^{_V;9Klx7#8|K@&+fTD=qL(lpnhKBU zU`uX(sp~_jh&9|dPf9x0)WPkECG@?Mru}{QYza1a?X}6L-BfS{-%S`|f{kXi$mrF% zK!>D-LE7)Bhv#>kH27od9lJE_UwXl>n%mgyFbin>FM$tUc27TXtY+Rd8T1TZaW*4W zSwU>ZAa^EbFqxm5KBaC;S1MxG*Yhf z9g$5h*x?cZG1s`*aoQLw(C8^nuV=5RLej_B9dfQAltxHCpgg_{6VqNNmhAnHEv>!l{(?6;0ni^}i4c zPQvivZazi+Ja+&BVMqnZpAYOENt*nt=OL0gm;syPaAPh*pc|S&t0r@^RIf35_NT(Vr4U3?Y7O&~)o%E+gTKA!#fQlRV zqvh5DvY*1Q8218doze_D*`k*%JOfP&*qT&FBA>zi3gdNrO%4{lpF1d(qSG!BIF_3F zB+xE!O!kt3kw6wUMqci>G>*5)_dRyN(es#M{j8M=r{dwyzThOt*W$-=@7+q_3Xebq zvTC_Ad%hW&N4*>mw@BV^mLCj~1D82Hu)mVo3+mY%DB#g}rU3fTHMp==QG;Pul|u_L@+G)l+U|H} z0{u8-FnZEX6)0w;(cZSO-}PtS4+PzNM+>>Ocg)td=;YnD+Ot{MaQEaSw=JEIw^f)e zEJ_@WcAiwrvM_yDkv2Zq^vq8BiM-yH8XE-hE_4l+2UP2k_&4zUlC`S+ku%p_(|*u% zpXSGYUzQq65(te+mC&Z~V-5tq*43Ds;FY9&!(*9OB`sgbm=x46;(z8)V_eBq2>gE_ zO26YH34@T5Vv?#kxPJVMkJu}NF8$N%q!%rt+e3}5h6{aZxz0!Mh>u=B>%+7J8u>X~ z6%VH{66cQmGhBYvy@ctl{g9Lk_zG~Nou$VSg9>v@$JjkbOO*|76gnR7a&7E@(Um9iXUrz;ePY1D3r((x4YQC7!zb#C z`0LRt2GW?T2X=;*KbrWzVd_n0~m;XkVbKJ<#+!}{<=p_7?`M>`t){0Jfp#?h+tmU z+P&BEg8XCPvVf;5d8`Ai`~xN_62=r`rNeXRVi#D6`u+e&_=}q?t;yA~p99=o-7CZw zOs=1xyU=!@zgn(jfQB4+&b$2~fK$-Ye<59a>I^2jquGC;$|=1>_5|1G-wSLJyU?n) zQPbs@uNDAGB#BG1b`t{8;%UY@+PvqWTt&b9X)d**KWLjLIC1YfzDoGl50UD$2e+Gy zK@5I1L_Wisqg2k2@Pon*-t1G7wyJ;^JHbEyS!tiS?e{AIHg?Fm>(`;A{SZIB;58tm@f2Xj%{FUU&Yt$7~UXM58{@BMM=ft*fj z(a=Sq$@*>%EK0~&`Se1L9VEQ*4Kclwm`C=t^nUq{4NM&Of_Zg4y+YjR&kWHWgXEqH z<*TDb=Q>mSmkzhlNarod=_-3^c#bX1r(}JpJ4pff--kJNT!kP<4T_qv;z_9m4u{(Z zv3-TJLGV9~`T|=ixSG~OKrZm!emt_8FxR!;OHT8zuPJvSJWFZH?BmTj&mwpttHW&^ z*Nj~X)b_4_2b#O}0B8*BY3#{f;^Q&sUJ4!^^%8%{Do!P;1;SA zHt1TBac??1lTuxrR8h)mLZ7Hv7D;OmKR}MqCLuhE(oaIry^eHdXQgPa(XX$O_>o)1 zTseZ=Gh*7yS0>~St6^ZI^z4C97Ncjt*!(XsI*Se#h*%U+ICXPFvG)ki9dtTX1N?QbPRR*s)I+!S{b`GsSlZ zLULCR?7j6h?DmXg+sw?Z`-lG6h0Oj^)nApX*&J7DOW!z74nX@8bRx-Cf8%cq3ac>e zAC7qk@mkbFHzydqXO>*7PO@r1jBrh|EjavmF(WA=V93S0*tp|EtRa8TYi;Oc6x1R< z_kR-~vcLA`dS#y-HU37fkOKE)N_*auJqr$!G~X**cjlDE(5=u#AOqd%k%Ls040iQB zeb3eDTb;jcTUZ-?#!_y^J`ye;e9UTd59yAJWqA!9ue>{|wwB1asx+fE88ez{A z%@hveJ!V5$Ow5;-=U}=f0maeT4Siv{LArp8St>a#MZ>%J`UnX$}<`YYhPFEy|tlXMO4sx8)z)v#6yvdgfetF6s>9$3BP~_!jt_OW3DT0hdIc9$~R@LTf z{=`zx8clXp7p%H*LO!1k?CnpwtEw$^&uC`Kf_sBc_QLvFB0gY8&KYcvis<8Yr9lCc z-qH<$ynjyFR#bLti_`1?P+}9n7+wTbJ?QANL5`TYrquIU-{yZqet|-Dw2}%m^ zPW@*t441{)_Qkko2Hl@dBtdBQ&5)hlrfuumo5=(*RAC>qJ`CO$g3T{z1s?EKy20$oy5LqQ+J=rBOP#KNf) z7o>gei_e)at|iI$CGirB{+_l9?iT|gPOXY?p`|jor)eNm(h@fN_`LG0)`NVbp&U*~ zN6e&puH09|<(J??vq_DSJxz!fmJ4W@&L`rNQnT=O`iD14^@4g) zpn|*m`NZZs9_x60KX7&s1D2>(wSNTf(-lR^2@~l z`$JONfO@GQ-pR-0Bs_0mL1%nYnUj(&*V?k3ZakF@?wk{rH&pivBVgIwVPAS&+)@Ks z_=3}rdnz-GcPaIN(V&WH+G!Rhx?HAW5IP2WW@)=^{T5XaMgk1RH~54(=5TITRc24eXjjdyxh9&n`dp?uMhX+Pj-s3B=iF< z@j|)_Vq6AYoAn-IffvGv*CJ<1SEsdN@U8E@sbfHD&X)x0+}H;5CD1bt0yMb6&3AaZ3aEa|Ebzm8b0^w3v#+GG2VvM+sq zz}=L!T6n^1?d-9w8-4EYk%b*7>q*9dR^0E-T>q=e|v}=}v5% ziu5f*TUA7(U0%L4`7z7l9I>EjdYEvIXc8E^+jsY!$)n+y9Z{d2+Fg4u9U$ON2T!y0 z5Ujx49jcJZ&zMPdFPq?*XBHM%iphdbmpGKk!f?8z>f}nH!+LRN;%)TjHf?KDpQ5~uNU}uW zE1%V1)1#Jy)$fWmQq1+lKo?y{AiDF<4d?8kRYYjX}wMe>PE(@1IvqUqCXmsjBQU zP7lvvf@1+^9Z9?ySUlgO@2Fu)ymRA7$?vN84t}u#!|u?VQ}t@GkOPpu^IP+GF1RQ$qm~ee|v7Y(8%DaC>7iE(j{h}&Ov*fi$JK}`@_iV zY&e23Y?a_=qt$yH0~=!w2UL%rhZ!*b_On$TSq}#r^+^G-RO;QN?mF^i5JehdI%0&_ z88LOe#f{t4BgHoPAShjn;$B9PE8p^h&7h?RiU$}5+ZkAzB+f%wQ`XbJR85jv3e6wC z3=6q;wEta&Uu}jxIM<5!pbg)v0?`dEZzavMll@U~MK;E~Pp+dG+@4MiLz#7OAJHZpcUSkKa+ zcU~Ya$C5s<%pC#?JR%8zmZ(=GYE3LkUtVLKf7@o-9;}5(>+pE!-3{yxa_R{Yk^jH49|Gi*Ojex+`NB?yr!4VcPTO5VK5YV>~NWm*#X){+xs5TOA`yvaE~(+$KoMwjI(Z{Vbpf0n(aI?!W@u5ZNBa_z@V3X56_RZpT{G zHBsa*^NmE}`V`qXv5LU}?iaWsM)G$~g1@!NH+_GP{67N)eCq0-hMYu`WzdGN4r-kSfk zt$TyZabVa-S)qxgTVt-W3OQ}a5V38p8)RfG+BX`_NQiEkQ6I-hzV`S zD-D2dYU(XmK5n>8{F30F_i0dKm9<#(B#9gEGd!fw2%On>6#@_Y{SEk6qQJPs zt4F_lXG*fL>%n9x-6kv3Rj)B@oE~8YSZio0wCHB2+vze-mqw*4)N|nB0@#Q?B)a51 z(0}G8&q_NLSxmHDny8OMs_Btb860!vbG6=-+cd>_()W`FUETC*kI=&xmTHwYpdIGw zZ=-HcmfXY~FYC!U4DokA6F8q6bW+g*Tm5CILdd@yKJ%&kkMQb)cP*;F{0J561V++)n4>fm#gg%0ttaY3yI-o$h}K< zMz4Q;Q7Sk;0-hz`6VIlO@yNJsZdGp;FOyH!m0tA}zI!u{H{S~PbAuI$E_xSwh=bF7 z|A%BeOjX1t^v=umYKr&L2ddK6t1tD|*qjjAcyi|LRNx=8zeYfQ#7$h_-?bsbHYV@V zie}HwsXJP$P(dom2KYX2R0g6YJv3=|%R_I!6o0IeIE&z{^3asC_m?9pwAgHnpbc1U zkUJkni#Qp=`#bA$8NpZP`rU`Z^G|8Rjk)F{2c{abFGt%DAFL=Q8kTC>; z=g$zm-Hh$rFuCu*AFC`5-&)Ws9U8Xf$F_}Kl=|cBB7<#KVLM>F-zA5AnjNhD%blOaiFUm%cZwi_rbcz_gjt2V{f@f zB3z_afZIvPoabb$#b1g1NutDNDjn{XEw+SaD8&(IrzAy~c!@8p9fL=!>ksbC0C_iy zUzqm9HZ12gsNa|w-kHd>UI^XJWWH8@6!hveuMCm50~6!Uo_(7NJ$d&~u|T&KnwHyxWR*_GmTPmQ3|fyTU{fTd=bm}ju#bE~r&|8ab}Y8|o;YK= z*f>{abo#Sk(Z=@esR#9(a}#_yH7=g=pfi7He4{GYuW}!&Zmp;^O*{p}QmDHYMN|H9 zGdiV?0+RPT6}Ok3s9hU1Hohj7h4g<FXSngLI*waqw$pJ`lJ-M;wM$w zaFpj4TptSiGAy^(W4|fp?{O)gCuvn4fzy8}xmL4xKz{HyZ4i=S2ghrs1$rzurxTKj z2s!R+DSR9=-qNz#cFVp_DC@e?t$KBc@9~C9cl6HvcrbqcxJ9j3^*`y(zp5p_uKVS{ zTW#mZuWo1Y%H^O7L|^)wzI~>Lwv?Cu9!y5=RTY=!>e}|EwF7at#sUaXvIwGpq(}oy zVuj8BOV=a}QoWgfG`QC1M(t z{l=Akk`GCLK2J6>GYt zQZG4@Q*C)SnCE{jJ=K03^_4?wu5(`ox>*o@_=3bv>-i`&E<`H!I_EqW6dMvS`bF$vpl&0d|^y>jloimbA zS_~A}ENpLGOsp$(OFikuj{@{XwQqwiNjbcLR^P?geAbr+5w>(&3r=9+TF$46hJpME zO=gqiuq#wE6-@|4)4drf2M$m01u=7|>h4Vqy0U5y?E+*U0O9}E*EDdpSppYciq5#+ zn76n=n;4)u13V=!fwC+_k1x(F^-9qJ$W@?eZ_QWgF%6db^5h|`6p?6K-?Hl* z`jA`eT7R#$dKS{bnJza8m)r?71?HoRV2F?QhV&-*($IV_^oPo!iG}`6kW5AQu8g5g zzk>S?_3b(7Fh-syfEyepSI!ol_fzg?+0P$3__=NQs(X_;rWSY2qHx^Tgy%AhVaTRc z?0#sX0FkkadirH8(SDHP1}*! zWyL6s)psLtGgC!FGsNk@Vi296Fxd~XS&5eS{?iV)kP?ikN>|1Cgk_5rTj9J9v@^jw zVGxt1=C@tcwe^NJru~YT!&n!Ee`1K-A9sDAO0=4*ooB=Ev|VJz^J(^JR3y3K9SN6k z@kHWx7;_^g*hrsLoyu$lr!Z~_`x|cVgVYTh%a1ZDx~hU6I@$#MK){{O7N{qsN`aFA z9yv!{LQUy}Q|0GZ^f&7y#plS<}Uod~pANqzFdaqawmsG($7RF{%dI+(_T8*gj>nyfZ0`QyK1GUT~WLIQYeyreXm zY-#85Ykpo|JC%+P`)160&1_vQL|?c2gAq1QPKvp6pYk5~HhSkOM|j7Nb`AuCcw}s6 z@f;|3@)!#Q&Z-bfmflAypT)|1v2k-!<7(9}3hd?1)o8qz)-5>knMwypP)LvM!LeLC z<*c8B`a;-4ylwgd{uWnBTeAHoHq4^7T_Y^9(9Hpo$u4|8d1a?bw4C01VRXvI1`%9y zzjyb(FB#!uH1Hmf|G9Wkw~&a9lp!1|tV|zQ2q}WlkD=`CE>~0=C{E}vgw{rRSR`id zU(jX9>XHJa3&&B+Mz~H+r=7>W)T&ZLNMJp(SAMiOIf=B>1g**;df*o|ACHVO6}K;0 zFeP=RUYQ=80FRymc<)Zk0Y&Ig^UhsdgFSnqcmKKmI2Iuiy){nTl`{|dWK_y_QM zLngE7w?Qj45@5N`ngGwAjoZLRTMi=SIk@VTR4}~pUMaRJL~}kd%RTarJL8t=@5aFB z-H1Xd5SYG1#D92Vlr+EXRG2G6IHwaJF@}fP2sdCf^acDA?ZWC6zjmrGUCLk`Xvi}A z$_p9sW(Y;C?8vibAX+|)v_Z@z@~+G?=han1P`_{ML1Yy+YLs@1{>uSk$pbrZyLf!qhhse_W)HW=h zz-Q3iE$`m6@7%6R88$Ern_4XR7J=;)9stWMXJHCBx zu|Cf>?u-r%^UTAElU9O3$I8AfXp@m4|(iJY$$(-3;X(5**ckefTUj< zU`cY>``dTKfoPwv-k}DQdS|*|U~nc{yqCr8lnR$#Jh4-wRB4HL$s#<4a7o6fci5>n zS;()->~icaq!l-$_=Tw711`Ow^W}JqJHwPIwqmlx1Cf2e`19>Xsq9$s&^h6T^7{EO zo5m*R{E5Xve8!&ZJg2G5hXcKO=lm@Gnp#ray(xQO(SyoKc*M9fv7k^bYA|L(0f?7! z#^dYc;ML5mJ5e~UxZ^lHH!ld0Ag2qD{2Dq(pBpGAs8?@B zfXfFHl~{(u7c_avL(i)CrU`X*pU$^RPRY7_B08=&6RW6zR1L^Er$e*msUoQ6F=xGB zX63cPFZ;Yl#?8Z(BQrlZJ3|~V1!M@1;F5v2&uc!f8>4l#@47wKFEP%W^*8E0C_-H? z5%j9Xr-8mzgyO*bwvBz~U}5ft`RdpKJuA4>an`cJ94BQ%Z&}WK770%^O@kh`B%eZc zK@v2HI)l(-QfJ(+4l$H@yTosS{oak8u5a+A z2yo~?aWATp64u_qy)msy29|ZDg6_(<4BoCLQ5CbJyQXwkeB)qtu)MA1R={O6Dh=4U zF<_8Pc*x)i);ehgn<4MPJi4^UBd0ZEB&H$A$#KZA+TN)YY4=tmsaZru-S zsUyo3tfZSh#3Vh*LK+BUl2!_!Et7RY4U*B)>jRh5-O)bAxnM(tf7s$<1MYpOY|TL> z$Y|PTpr#kL5qB= zzHr&Abkp1k{QWr2ZAUkK)((xJ>`hev+i;sQ)4?;FwHc%bN^+u3{U|%dilbTdrR&VI zZ>O*2KJ>KZ)2g%5)PGG=^&y@I=moBl1evbeuoWr~qir-dvizK=fvCk`tY2(ahT!`*b*on_$h2Sg616`k*1&`u9SzVF9tqHdjx5X@P**Q4R~U;lG&=Y01^w>Xb^D!QPh825Xf`D^ zVtE0H@B*4~;F`sJa=ZErGTBEtMV+Nsgx8LBXXQF(<)XHE8D9FBnY`hGjE;1OM8%Tu}L zROtUo#L&Nd+eJ`k#=9p}SiWaqeHEbr;q1i>QO6J zP!KM9>nq&4mb*vbnMc!u|CeY&k2%sJW^7B761@MobZOZCA~>S^8gjX(5Y) zl)JV?K<&q1ilmFI$n3YR!)nFR)0Ea}9E;^m)0Y#9J2TDu3+cdUy+e=#+6Q*1wV} z%{{7$SG<(lRCKi(Xs{R$=cpeB%~JXX8iUDibT!gbM;Rx>xcsYg zl)KaEy&%r?+KhFzALT)tae1H-4k8BKn?)GKfo-en4Y1(*_yG*MrWACtwlK5i&{GPy(*CLF}k6Cna7lwMa_61tjkT^TEc7)YG{(`F}U8o`lr+_$mA0wtBRbR8(w-m~Db?s`Q`EqZYT{*+vt%{`- zEuM3iIWI9%r=nbO=G%>fw{QXmU9x4Q!KTEZ?(^9+s8$pC6QO)rsJn4@Hi@8t{riW~ zhpPBZNljzKjm8+K+>p+~tK*2(wHkFTwGDD!QsqCH{zZjJPWE#Ahn)<)XQ+Gwx^#-N_?Gr z@w%7P(NSZt>OY7($DE9Wi_MmmAcIUP!f}%yo}4>bCqzFsWAIRngrWG6g9?4xw~i2*|C?=wz3c}_oy)5 zgzH>AOC(N04u-PPr-IWDx8F*hti2`69C@wa*IPArcqjL0^k#0F`qp8sj9Y+T`%`0OMgF(u>`=jMstx$+(Tmgy8w8^z&yG&}^_gY+k@%8q zMs3rfm4#nF!<;#6*$N3xG)?!_Bz}(bUzU%c?B%&($F~~4M!Vv4EB6G!DelmZ_?ta_ zjG>nv&dm?)tPSS#a+41n1C=^5&f2Lcqj|xSyc;New2S0|g=#bA@;@S&`au%#sNr9C znl#INWufK`N&8RenI?w)&+HtZzs$M*_m8i&@~!+`=Q^d;mzyRu&{tA-|EdT|YPnWl zUFS6rON#0PW#h+;$9SB)7hYLD@h#4UNcwZ04M}d|Kt+TmAy0NhOS)gXQ zZaEurw}hSUwzymA*BgznmszH`CwTlR^mF(%cm(2NF91qn-G-77HYBBqR_ep~ipX;8 z?!(6pO~A48j~^YzClVH(QRnwAmpLitGm(Sm@el0|19aaDcwKZm+!PqUIM-<$@4|cQ z!mR6m)(h#m3iKGOMg1^;Ohvv2N#@BbG_3~BuxjpSTd!s2$kBJse^d(oTIAZh*jG22 zN=sg;Wu6*|30sqKzNwEvcC&ayuXpoXY-Ms&DvPlQ&jWO%xc=E%?SN2Sfd*HpXm=N5 zMp-&tBy4OT*lY|xHgm+14Dr+)i(GNorH^=Of`_IUZ^4kYOxRB7_KB~z1Vs+ssndyp zeLaOCehaNhU%ry5lzA0IM-qsRUip=;0V%Dz5cX2(Xt@4I z%%^&UP{i-XzilIpp+TQWQ6>SZ2q(w+y}H)*OOh9YF-QwJay9O_f-KIgG)Y6>g9QK5 zE6VtF-g@Ck_e-tMcRpF8V|KJ90ObZUUiS9R`56im7<&7>3{f7sfjg(VT}m?kJ4|Cj z%Sw};r7(cPa`*0y&(M0wzTp8(x8l?EFR7zrQiuO=6Xb!u?V~Fn&6VMC%V1u@XN2<) z|JK_{23z3-2wRe*seWMKcDoLZVDE~XeV(9}bud7XOAQcnjB~1$J(VXI@LIgO`w0OI zv2WPyXxg*hj1pJelghn2uj}6h`>;<0u#pJ|PA5&J$b=7bEu;5%UzLrE5-BbW>FB`xn?;aW)j?y^c5!dinZN-X z&E=wXnyHT!W^dT{M~Eb)q&nPg%5ji4Eej0=h&SJrehq||zX`weYG&X*43r{fpETjA zHpokTJ?TIGU@8yI@zr(`kZvbS%qo|bp_-MYp_i*lLwCzYc2@Y>J&&wxH(j9EX>c4p zuktkG9;2&-4v>=+XquVK52@l&tH zlzz2u?IV_50E;JX&wK55KsK-Lk1d;gbXQbnJsS6dQdQu?c^f!154AM|96FW7`n%!r z#$di(-`)ym7s6G2*t%d}>)cj(CdQv|>0?sDr86?(GJy;Ufe+@37f!<*k9uHY90^7P z!;Mvy)o44gV<3M507||g|B_VUpjKw%@}uE#g^kACD|Ke|ENRv$B~V9ahd28N)_ZL;x{N7Y$8ujj zQ$53VlQQ+X_=0QL;!_l@<-2_Z)k&EXvF8;9)`KYCUV{VDdCR8glRx3LwL!ZIzVXJg z*OmSRFR&fY+AQ`wtkL*nYA1)9z>k_&o?hog?=P;Q0cT-E%fq$?BJ8VCt|&(r&AU~; zVX6=FHEgQt@-9V7fOy26lyY6lUS1j2G7oXZ&%V>VbC`fT&a}T>`S&y|EGGsLlC!1E ze%~SJ7|s`n!nSoMTnfjt$it~$Q2~2dC~9XNVP%mO=C&5g)0kstkkpK~vrdrD4`NO_ za^fDQWj91;H96}omx_mU3$qoLf>p>gvYS)I_! zn?*+K&VilQb4Ha}t%Fz@4!?5X%|GNpMqUwtd&s3hwGB=;g6l`9`1OKnq3yB3KE^lp z*;ET-A<~I+{7rRS!-OY{w#Tr;R;E?}ft$=8Ip_v~oR>Ra(NJBcox6 z<;sAr_7n`CGh_oUwbIq*5$~5@_Qs!p!SFxO$4V@C-Rs|3zP`5i>Qr4u_W%#%42Ith zQ(203_yaXzS5sFjcXnv3yw4gvU>;g?fb{4Q%d{I5k*W&j-KyEc8kW)Ld@#ttbC38V zxc=yzP!>+$>&xiW@B*a{NpJ6>qMw7&E`22hF?b$k`Ji&I>bZisJ$$*gI?zb3v0aa> zx;u^x{@rN1s7;l3z&}}TU;Ai)*h~8kepse4qd}{q`@@4UI}wH7LIcm3=eyEAxH9y7 z1uv)=A0}8IXNn!+5-k%3vF**XKd#F++1q%VdP;{FH<+I3p{~TVFvpC&5CZspVL0*U z`lWehXzLwR-JfCJ)72fJNn*Mo0L0dv?M-)M zh}=%u2-AGf8`7x0^622n)51w9PsKwGF?ag+8}INu#F$K7?dpPPoq3L`h^bKuhNGzb z7~(=Z=Dk1N$C*%4JQ3iKfvN`X&!LV-MR^2$$0~_)PQX>e?)Ob5T;Ic6XwjzHO-5M$aY4zV@>>kU`r(@{wSpcZPImo`C;5t-w0J>!^O|Rzl_u~V(=}{A zru|S3JQAdt&8>FCpzHKdL;eqHTPKQ6-9C)1@*!eXk$zq6B433j)ZM|PR|ujI^^O0T zR36rGs<=rqZu$N-a^&y#DmSFE=rNBQAV;|`C0*z-3OWYXRbd{y2UHHWX#d#lq{L4M3^!m`mW%Q@HZSzs z78^)xtO*3ycaJB-08zPm1FY9LiWtU4L$iK4?AS*Rh(PoyZC%^5>JKt(TD-Xp1`tHrD@x;Y<3iVR4^*Cr;6 ztp5n#WJ8Ob$!|o<+&%%5(f>d@1}i*PT@rIXhuFlFZWg zEmhI>j$hhM@Y@UI=PKYv?zfBCqGxt^*zNZr;3`%g*tKppI(57bwZ1NjJ@Z!w%@?K2 zK^@oEr`^-w2JwzUDb6huuBuJ$eV@{Yw(L@VYsuDM4k!D1~SYIgt$fZVEOkr>iTIsCSTrw(=h zjjnbAWCM%yB?axhxJ`R;)a*=@tMxI)(9l3*wx-(h&Q@C_lEB0U8Y4h~K2x1UYC*QI zw9yjqYc^evZnz#8d~xwEbRK=DzXx8q2ZR)R_lhL^Tw;r3HZF^{bG`Ov^yFc-#%nJu zZGnA*@}5dZg|3yLHpc5h`t}&>mnfpuza2!Xs-hTLhb*k8y4OAIRf;k z{_9z1*mXtGzC_myj4Md6Dn*P*mE~IcA07I1i#mO09L4&ZLhk;vDWt#Bk`7Qnrfd?t z|HmbczH8)&pTi*M1?^>ixEv=-v4@7SM0C@uSm{f4b#07 zMY?dRuJqvw+nm-XNT&aRBCV6kcRWVgHEW$R9BO2k|8psVoa02Drnj$vZ&{CJJI&X^ zg?jF;btuGYpQfyQM8PTPXLcvfO#1aF9R0Ut8Y)?Bbva-9A`dT6;W zS-&H;qb4+e#vUC554aR(QRmP(!J}{3uXrpSM6wST@Rzci>OhVdoF~8K#lB*!sMB{_ z?0`3BSAV=oqyI&Np%1E}NM!*5kj?0Wdn;T8OZ=qz8_V*LcR$uILmu5&jRqgk|Ays< zDod00==#poClgb03y|w3Hm=sa_jF8~ePBtx1xrXu6{69Qxd4FU4!lIwU zalgj(7QhAKT%lLI?O*9sdbu6?+y6qE#dTEn+DWlop)XG;@4Ybo6>I$C@0meDdQHP) zp^31T3yopp2?Tp8`3S@@HtAW0D;-2vEsKbKMWRZ7TI<8gfUdRb8}Zx8r_UvNI{j?1 zBgMJPS!wEevAquUP8m&s?Hb6nJLs*^^L4XM38QUcYWMR?MMv*&p$qo+t-;O`;C8g6 zqufr)B~gniH@>Y{q_>UA5Dp%MWIFVdvHd$Avq2@l&>r#qBXtzfv9~pfJQIOXs8GNq z;3H`nWxg3AnD4>{6NG(`gU{U|Wh(D81-8IVg6ADh4Gkgg-9hJ17?%IrZ1R4je zWn*2{MlskKA6<>psSx{bA-7jNcyJvG9Em!Ff!&YUskwPk;Ij zaP&ehps5Dy0s;gPflfcunSWC3{P$`QWGb??|oocu*>=l&-_anH%V zOc*>KhW^6lGxhtjU8R#QpkOLXF}0yS1dZUuJQQ_W6ua?}L4NCm z2#hE??wW=>wfMjN=bhc;dJOkk!v4fi;;i%j`<;a`LKi=%$V9(ft`^N`cmmuqRfVFq zkjen$R`aI60t4|O)*{Y>{4{IF0y9+KA?L#N`xcguC-aE zkSze;ZE*E~(u$4A)jU-29vExdgTAz4hkT-f=$im(fBeGU%q`W8+1C`NDxW9huAxDe zb0sQ%*uix=_mhMMF{OV=jb%Pm$>Gygj;SJ(v9AP*r#m%A($0&GxX6!}m-b9l^xD^u z2x+oCwyN$znt=o9Oq{oBePboVA+-<^eqQ@5aOp+S_M3e3?CC!CVZeGasa1zp;wH`b zT-dMbViQ1mW~WWmmt@#hm!>zsp^bjuOEO;40coHP=3E@I`uU#$cYD@1H6h`4;w8&R z>@0ZLv>4f%-<^|^@0asVnvM(Q&2ZgUF5z^*8JCPj6)CU$-l+Z#8j-D2@#m0x4(%SZ zP~*{5`NU8r@!&N`<>{S=VPelmoDXxbjp3*Niha)QwfS#c_+m50@zIyXBi)Ia+-RW; z%C&+o^LwM)W7I{S>Xiy1&#@m<-anF1Y1`{5tna1H{OPT_>lkzIBBVR!$$l-*`V`1` zanDBLJU2^B_OrH5JmEA)dd(i#(Rni`-VY6|#n+S)c(W#!hF!y`o|e~<03MiEtvA0# z?Djm2?f^{Tj-cG#oTja*++&&X^t*>Ptm+)32jX7n9y$Sm1bz}shXzVVG)_{FWsBjRgh=Zy*&oj4lt3jD@10n#I z5{auS?cd=ss(<(W0B--gqjfo4)53;Nx6=DheB~i?oUm3ky6!V==>)DR4n?mL{#;DkUCd^b6jzqqJlIMdgAh#o3 zR+LLiM!f9pMmLBD|GeUnKbSYO@#0_!d*zL(li&^K{~Sdhf=QkRIKs4YZOt3l&d&pcwyEBpkGWRL0VwKCX*4rxZzxsu{#8VgoIWG4)*- z5ipYl($tkdKWFs8w_3_(>{!cyVeK|ShJf5(D~<{Tw|C+}B{PveOMJmgP_N`SfjshO zx4iisb5XQ6x*Vp_NF2wP{Tq->0COD^CWJkFc_fo_K0U}Co6X2(RA;1vrB?036Kf@K z0+d@SH^5R`ZQY6*pF2!27F=Y{b60+#RRlXS3~`(${}_1zC6p1H=%QWikcFWxc{Qh| z?IAs2w+nbGV*a6{@_~q+-0yU6Vk0$yUX0PqF2Ex1f;-}u=T6t0c*35Ec7=2yX zq62GuQRZt;0r-A!;`(y`?|WcxTj7* z*vDhaY#eakm8+FjXq5Ps01C>5cqb`wUwBI>ql<^S?0~qAUFXfphSvYrrwch~JT4U8 z23C2{b2w{G=xjS|A)^6%sY)}%?{_G3B7jO33h3@m8H7kKv8=4zRPXq2LcioeBf?|0 zQRfMG`Z!@SW|{Yi^2WgOb26kaWglGs*7TRkS&GB>S7MWLxn@Ynce2OJRP}w5OSLZP zOV6kd_LThMYI?eiEtZUW%661c=#AU`fC+dYJFjMfU9B%Bv7}f5n+2djC7+`RFF3cnb&hf zuNEgN1`ZGUvh14!f_dy2EsvEHA#f9Mkv$f>-9^P>$h~U1k9ae5oiE05ckW%Jjo?G* znC&7owBx32og>oIc6$#gPY-^43J9__WM9|a3Wh)o$7&o~0|J6%d9qaA93L6BlQekW zb$0HMCinXs>4`^tce){n13pat5>ows_g^McWo)V#7~;?HF<N)>R#f z%_dFnmB;^84}&R3NPEhLK@0~aP1f>#fa=EY0K#k?^>Ek{?7NPlx_>xOuPE1f$k`dZ^F1#n-yH|d8U<1~ly!bqZ^A1Rg@*39 zXW^;S+5O?Y)6&zN+I=4~`}-@i!+|uli2 zK%F;54#A72Bx{6Nc`Hc3vp<@-P&5Y**_nd?w@n6QCS$rThYm!j{kf59O_FGYC_K6S|MDN6zK4BfBPXO5jm&{~H`nReTE-bK@wLWLz&pxkO@wd6$>Fi*^R&fu1 zU9$csb3RLXL)}{X??`s^C~Jt*+h;eyKIA}%pZFFH)-}$VhNGVCq#7nx@;*Q4hf&V* zwX?ruE+#Pkj86w~V)nY8O9~XC@zNY&$gP44VHA3p*zJd{su~R4F7t5nF^FbZqUF|v zKximh2tppscMw2whj(5id~?`N9S=g*{baLetmDE~>CFv5Xmi0lLJ0G~t%qMKVv89e zAxGKmlkADEY**0NDZ8^9FZD}t!;T;IdB6g1AN3}ahY&y($-obow0h4W5Vau*wi~lZ z^=|Jrbk~v2Y_O#wsfnrbCBZjmhk98&2orIR;2HG|5E^`C-nb4a*Msx&zo@Xu5!>pqM&V4F7zU}Ol*(=jsHjBg@}?8wr=m5lBf&Bkg^Ked();{<|fJ?jBM*ea-+$?#p; z(BekcjGdB#Oc^2m;Xk!mRUC`^PMOT{vh783C2xxLXrd4WMMr%j|+6HosoTCVH&{n-?QB#vf$ZrFj-2|aCnFzHWgi^Yt|6IBuoQ=bV&f7 z0DlXr*_GE&jL43tYpYwANyhwa-hyGa+)oK!HCGEq{uw-c6x2pTyp#7eLSH z{IkII2xa*U0RJJs{+ro{k^&J9pe=;b7?`+Rv;iabWjKy&IWzVQfrn;faU_IvHLqtW z4@$fH#Zu=9m-y6ifoGJl_bJ}N!87jxV}oZFd%c1yoJZ2R+Mm3ff1fo$xq0cQ^G;;i zYF*F~eE;h2zCl8g)iO;J-%ePkiGGjK%+S%lBL;?(GE1>%1d7Jv>nQE%e^eN&p3Gvo zTQtNUByLY1wnph%`R!|lqsNKE?BBipfQKVPuAw5wIJj}Gs~k9F1NnVtSwjX#$NP#9(k|HMCCp*WbWO4NAG$UedtV0S=9j?RVAxN% zVckNg9z)90MRnBQ9BY9+0i)_j6mfd2>^i<|(@};GRDU8dPXkX=abyP%;TA|3b<+(p zGr#r2VJ>y+YOIACWeKKBuMvoZ=e?kN1c8iwE{nZX1dEg{B<{lN>MRJPd>{kkSOyfC z)ELX2`f5Ze=HsX)UZopOOdY$J0Os-})di>A31k&GlHS$BC_)KjZ?BQ+Ve4`Xlf+EJ z)8Rkt6IieG6T=#(tk&P}&O(kT4*yTQedMl*Lb_lj9bh_%WwnDbnp%!#&r9L)=lL%I zE2QHaE!_m-4V-9*9E22@t#hk!VVj*54qmy}D~lEH=-NyYDtCRDIdbXAdj8WzkdRmV zvLwugh!+VpT4nq}7CQav6=&J%|9bz3{&Shna@|$OYW+4dL&*1i5XLw{gn|qXpQ)?2-_@Sw}hywY>L@It0WDL3Wtbz&!lfSVoWK5g` zfhP|r3n8AbM%)Ki{oItY&xiRq_)+l{*g>=uN_4Z>3@cX$xDfxxE#{djkyt0UV$zi zocTuS>xZL3^s2U3LkWlDX#GWkju%MYW!GpYrmw*L1guAg#&pEAXbHd5h$Ym^Vx zb?6``Zj4txNLX-%uZ(5A+sQnZq7{6TtMcf~hhTPDoS+-D6c&}c38~zZC4r<1K%4mY zf<=H^iJ}@_zndWDzu`|uK9eAB+=l4) zL+?9?Qg2ztet^~79X|MTQu{tjKLO~j^fVIzmUd*rx1~AfT2I|@BVUO99tnQ<+bOBB z&$DBJPZf?^vk8Im!VLlGwSKL{W>4!GpqQPl56nX@EahYAaS(i<>tB4PU)mg*w`8N6 z>T42!A4oxQ`Y`4#B;=NMefJ}Vu3rN}H_NCn72hV7!Rvc#CAUrXrLw>w*9rEh-FjsV z*`(|Hn>B2vznzNXDdkAk@yXdMoi9E+^Aa%mgNWemA(^aUU6gL3!}0PkzrW8UV1Y)* zr(bsGoIN(wGj`ICq-X&fln==7u93G8B;?{#cUA4TL)y@@316&75|@GXNe}xHI3m2s z=#m&$)L~2D6qlMOJw+^(c?)7(a~>!MRN#%~Mz4GwZoRPkg)4;S&*DFS|<6YSUt zdofZZXw;?>Gs$g3dZU;$AI04wkDQe+JNToooK463ZHF0ej> z=NNeXySVF72X)AlAZ?3=E&r`z?ULxRP<%dSyUDjv?ned^(2DQW4XO+V$&Ut}0Q{tc`Tpl4awFYeNd~W-D3c`EQBu_XYxi4TJF23#KNMCpS zad3(8*0`A?BY|K@*}ZLd>5X0j)|1}ZD01?a#*lx>Wy^ga`ixgktzwQMq6dd?glo{g zVC0oMTH1@B`*JwoJnmVc+0M6ie1ECd+uJCESF@-276?zqauc3jeLe-J_=pD?rXP@3 zrph^W3_BUk8zm2b8L!L$0vZ5beT+(XP7EE6NI_Xbx*m56L1^}9*^X~hy(4KfEh>_D zbvuvZKM{e<*^pHZCg!vG1~@6(kW~O>@CmNs>~S*S%kcqqUNi4RHoD!!eqHn{64| z^^DD?ivpJ}TVhmwmVpJTcOkf(I2e+fw}2PF)HTn8NO_7(1H<=kPvm%(;i zv7--SEX$aKl?@yRRq_#Tm1uBA3{>p|XjJd9A!RD|Gr!_qoc zH{w)#?_Iuf%)BM8!$E29xID-t9q{P1t{TUeJVVR*D*QjS4y3X+UN3MzTSvU1YhF+v zBXSBYBLI5~iCy-45|ZvWZC{s%wMz_Uys1e+yz!J+pAl&}YY~``lorN0nH%`RhV+0% z8|nSM;oDEnIb3A3MiHO-hS3r0;M_6xHfjI_Zz1ez5$Ldp-y3ee9A(W4p>8D76ywPC+21LQZXGv06 z-v2?@-8URrqC7`6Pu(Is|DlV&PF3~<%fc(C^BEAf1>G>5K#SUbgNMGkL~Z-ZQoqQl zrUMc}WeqoFe>}fKAbV0CIBk?s#cLmw5hA4SYCQlS&JBF^6XT@`^Y_lU{k~ph8sQBe zfMoY+r!kIZ_p=rrPTz^U2^g#Gf&Ch9BfT8AWFtF@~(|uj9g%5tA6|; zo`KA&#Hg8PP0i%0o#oqX|>)qp5?)^H{57650Nldb1RU$dfTGUoaF-v zqxliB2D{S|A3gY}@)7)a%scSxQrD8AQnTnL$-|0|v5(Pp~6jt&tO7#Z6>j zDz+eQ2>DJyA=q!2YT*o)w`OovxL)MtRPvI-o;3`QM$Yc7e%~KA7NWZdl>@t1FBnL; z_E@y7y+>UV^I7K#wu`)Hg&Nhq$n6kHjAxc*ZpNA?RWq#12bL$O(@~#jub<}^Nsx=< z^E7FeXn*-WrX}P^CRF}?Iz{$$NAYw5ajnyhYbcPj{7fqnl%~mECS14G*}oSQ;#XX8vr^h zT^xd?J?U=+lX%){+xV@fOybKSSeaCcHq6s8j{L-dJJF@pE*$pSq9dAOJwaVEYZRYu8q~))x8WSn6;iONcM)NvL&Z zr|fc*;Y(WQHQwtW2(+WiGYljg0=u5ax9L}h-P~p<-LEWgP{g)zc>!8iv;@I<5E%IC z;CK3tB77zY-?e|j%Yo!9;oDL;$ca-E-Rk>ka~Z^$hfP*nzVYbqlzQMW8zA%K#H_sW zXP`gE)f>s3oOQ+Gcc|<+_JKh0OQa=fK)S;Z|X z6{U6$=(?j>bOU5wadOU0ue`Ww14E<$T6GkKao4B7zhD=<#Q=J2R^&zTLW!pqv0%4_ z3{)y$VMOZo<(1p z86%QR++BOe4qOw?bx$d3Sj%9f@0QEK>vshX6tlJ7fdXdj7I3{2kp@^MYb+Nv3}Vl_NYux`L>OZhx0pVj7Hf^3y82+$3 zTl!Fxa<{JE^!SZ68JB9&@*ZVdztgB7E6H{2xh_%4$5)XE3xo-q{Tkp?%=K{QJ;Z_q zMw7ps9H9Bi{{z|IJH;y1fB4(n_2u`=YFG8-#zPiul?L8MWz2>;*i0T@OOK2A{&{|U zY0ZnM@4a3wy#Pl<2oxA!piKQc$|19VQPvi1U#|yLdsp!NWsPuKoZ{KY@~3F%$PjvH}%oFp*Kl5|UtvS?ktrUpyFJ5C&;?j}Bu zNP1&J)j568&&H7QRgMo(E}uJyR42R@XW)AS>a3kRI;z!AiLJAWFudQw7S<1Pq3VUC zINM>U>B#8$J4))H--`J(e*PgIN8kN}u~C!8)TtTx(Ley6lfndyO4a{Cc+_)UCYir( zYtzD3%?zuF{|tZJaXGg%}wlW^S(& zy&{-)0IFXk2X>Gr$orDy>ElTsgHmmU!Q}=Up-eep3vtF1Qbno*=_&@gG=GYEURU1r zChMkp+e)TKm8b+j&02pKS`l^#O*b?mD4l~^sIKAph=>tzC$mm2olR8!hL2Ll*&b5V z5V2aSo51y>k$=S~j2eJmo_yx|lWbK}^^k?7eVnKLreI+1Cg^0qay;%+gu zBim+K8O=o5aUZFuW19{sxx03ECseeTXSD?102NtAmUj5XEy8f?A1M3J?LRkm;R_Nfsdi>D9E}OYOjc(ax z`40(py1afjo*p1vXZm?(Tjej%Nx(K2NZ?y>TUmFEg)Gwub1%6r1n9N4E;^7(n-*Wj zwDjPZSKcwv*9<1df{^ZRs2Il*Bd z@hFw|l_|j0Drp4>-=%SJfyD@k;sq@FIliy*=BD~-!7gnGA8D$el#U%uUNNhb{1^;2 zV1M@I4*`Pego(iGtK+Bn2m2BE9xYdkj=|Z&>H81R5UJE4-g z#G&iBf%>L}h z^G42iFkLNvX}voxjF8}u)Op9le|h9IpF7t4^k#!3Mz?QjRI1-{{-!gtPI5WwpTqCT zu+#30vWlR8i?h*Peui>!PYp6$RYiqk-&UB!REZf$F;@IAy5-zyKc>1TZDyF{8l3*| z%6qkDK1ZLt#4VBFWIV81o+nF!gVF5%2aG!A*Hwr`klDge<9)BC_kGRcnq zG0yv`E0fyLKJq?#M?;K=G;TJaF$_r%hXhyURDV3zHlZk`;r3Ny$M1j<$ORa(1wGBCFcuW zk0ggd=($_F5c`)9@~S$&0Qt?>1rF{>G>;AkOU$tK=~~f0r_%`z*wn4y>mRD(e~=6o z9A0>Ap3AO*$kt?4-7ziT=O{hxTl-i`xL$bFVPi-6R5W$T(`R!e{!)LnU`FIo7Z0z! zX`w?+RcoIZ*6Q^F3upoa3m|Jw9cYwM`__mlT&WUapNhT7#7p1WFkEm*;IV1^^5;VF zOqF(Ls8p`Xd$I(JSE%Cjj4Id-)`~np9FQ%zW1WOHbg>D&-|N-hS6@Y(GkQ3C2K!=u zS;-JkU(37f$#%M{mjl$pPidmPyw;f)ByjAiNKtG12=f{TG{*wr`fH&1=l#D5)QDx` zIg~`C5N=?UAA8p5VB-?lHwEnaaMIT?&Z*s)Kim%IX7TCp5kdZxh?fQ(?ILVfen%ze z#*U0V@#C^C>PJ+oWw~isMRh~bUxLtn z{3_>OJUQC3u}KP1ixK+QDY!qUb61^kJ6yQEc1w@=Gp+pg-3yDI9M{(ihE4VqiMxEMsYc1cSab;> zQVU#~NOns7h~Y#0kK;$Yawz9>#p230N+crH9rD^powLn`SR3+JQNtJvajiM3xX`DA zDC1nGr8N~2qStrt4dBdB3Zg0CJtx^eSx3<A!VxBnW6r(F^6P-xroW`jtX~D2nqxnnz6W}eZImmRcf5k)mc(PF-*E@RL ze!%ieH~e0*A(DFg(3O&MdFJ*&BVSDrRR0xf@L#j4rxO7)Ivb?2c-?jV)#@{<@40Qp z4=y08^03yz#lV2dhgZv3_XiAF32$dVhEd=-6fioev~TmL-sZdW9Dh3MfJ!jRb9xnoKn$6jhZP&b`ZCGDU2u$$qt(S1tJ{>C7@cUMIXWvW86R=J8y^>gIoYczId6yQu8O=)h$ z=l58$DhlvozX2Bk^w}Pc=fG6=wqq*ZUS!sUT<4#SvCk?;IlfbWX&2JHO0Om%yEPVA zt(nQdqftQ5PHgBhLkm1*zMtf>2C}VGPLZDZzEc>Zp7Xl&o^B5Vp7}d@2OGdex1-9 z^BhW%fD?=S8pyg}IG^uZd$Yi|709M4DgnOiVXD#i^m@-}w>B6otyxl>Gi7&lkh-xj zoG}9rgR{{?eRb^zKhruQWvKlY)stF-b&ZB0%u9yEcLyl9C&d^GC*pQFyfmaJrUmEQ zM0(}}I9a%D+}89Zgzfc7&n;v_oC&yX&Tp7sJz7g%MFyvS7zRldNamJwb&=us1x_Fz zPG^UP1W>(tLiE&s+c7yk7Fk!yXB_NjAi+PiW)zkF9K%d;+WS+V*qAtv*mS@>LRi;m zr1xA-hXlzgU5cPi9`>;I(!IQhx^Zgl@s^gct0qzD_z@+_)^GJ!0>P0?^^NbL{?Cv! z%qSJkVh>k_!2YAyw0DhKsRVy)VQ44Omv*yP zPuJX6FUyS?vXXdrRr_8S5w0wDzg2JcDb$k|pWsCFNtF!5FuP5^T+I$5wU=>i9HKvc zSCJ#J?tQGk{IC5U`tNylxN-Z2_}rdSLz7D)~_=S{}=$;$pp z#p(Qg-5Y$w4MnZIQ*!2xhl-Yh;kqHOYZ_8-gl43qwKxjYRU0RBVr*Asf>J-3WpgXP z>lTGw`vQK-045~beV5*1ZKWJOrO@-JCU)LTVw$^MqWbWGX z5V?cPWvJe<_N@LxE#Drwh*wFjAQI_F3fFg*0aAB+676E#Ec1vJyk}Tq;`4!$a#!w( zVzKgI(9+40YGZ+v+cfBBzqDFXQ)jq})+*f!aJ-SJRNwPct%qQu4FtJvUCQpw!`Wzz z;!0}I{%1>Wt72^h!yRGsIGE*f4A359qg(q0a0HLpBpQbyK~|{^4aNXw8W*3fGvn!) z(+Z^6x->5RPCSpQ>ENAON0z4Icha0S_Xx#lq>5IW#x~vWwce2C7?OjQF>Bnm5Emx2 zofVj6Mf}dx`tw@pvejG2L${wYy>67qVA&hw1?;fJ=1V- z5$oZYR={7F6aW`r??*?DQ4Bt`{ z{td$s+57~or^o8L*y)x!s*$YXqWH$ffw_=Xr_b7;Bd;8crs2`@*6n#%eUdw{cISEj zZN==N#$90LEbd)1$7u0ym1F=$?a1uj7-%OTe$zj+|KeSvwq&9ACvBw+PUq3 z`L@n^z&M=^05f&`VoJag#e%(%;_W_;U6~Y~m0LuAEHtoBjoIaZk~l7gO>O6A3tr3z z!jfyEy+vF4>igTepH$txqJJ@MYV%Cta>Gq>QK*VTgsoJsrWo5Onl#=d(4ZkmkZu$T4q3fbA zg0%!kIww7CSJu}(j&Ot=co5`w?WO4KVWe>YCOY86B|dX&wsRHvy)~Vyp$n1p;SCKk z54M_A|Nl4Y{**Fr#6Z7tQfqV%P7%{R;L&KN;W4agiGsoZcH3|?lG^oDkTlVB`tS(@4j@TBOLFMIcW@wf-I$<$k_EC8N0s@k9F8F#2iGzJ}XI z(@Zrl?ilijuwMEnZ~t}vATtM=-CH_BnY;$tt!5Xe&A7F8bVwLo+?on9COqGondD*Y z$kMJ0K2g`u$|2!)(&&HW*7)#C;QrUGErTqP+#AyrpjUId>g0#$fZsl}B4B@d*ob!7tJ2*10ZTE9lcw?DGVmu2&m39T%3 z9kxWy$7V(eVLfbOO~1hMWiHHkFC}!)mC~-5Yo)r3K40v)2>`>3j~J<@w8K1EdQRg2 zOK9psK~ z+qgF8o7}3e@iXm%Td))ITslAWxqiTh$kDEg&*2#!r=9OM_lzibM^{ROYGq7mY+EWC zLQJlsbepmFmV=CTVj1Q#L2M$bdNY12FeR*+6p-`WQK8f=uPG6|6Xe3EoAt+z6^_YUC8=EsoJcbe|bpIE=X0EGE6 zp!4hg)VH!1DPdwHxTtn>>EIWLHKAfIhT(+f?>3*PIuTbp=l0z;%qxM@M+mqZaVeZyZ?z6daStCJt(LX^4-0^4_pYcFcYVd0IT~AY7E(hQ zirV}z{I%AuT7(4;Y(I@$5AZ8xySHGk{@Mh|6G(s|9K&yyv-QYMDG;P_DaE{e6}@Fl zoj2FgIiOIk-LTPSBNHLR>{kMrw6igr)W#O#kG6I;i-(;lTBgl~J~&)seufhcRwq8) z4J^rzW~z!hzXbwmNtrfX61bjq9ChC3C)X>hhVq7oYp?oC5xwJl!#clt^mBsCES$J4 zYef8Bz)j8xR94+jk?${t98--&}p${DI}L*4`&ZC)2(_^c1U_{n`}cM$U_J4`OwTaeqK4no|`Iv$v|pmnt+I( zE)_xf{b1w{1w3vqqv4ol^HZ7-esbpgCbhR-{U+w9V;c{Y8Tpt;?0~;6780v3mBZbb zTGO&7M6;#=llbrhRPx_vaSZDVIbBb8U+gcXYiVP-?&5~lmrZIdat6U<Nejs&F#MC>5BXu3Bk8} zzO)LF#$>6A1+B!qT0$!F(RamTF8b3vPb}tbDLM`X=`b_#cw^DcJ=PCoYB($(>EXuJ z1qi~WVH^VP-Hvaq4g0e(el?QAJ(JZ+a}q=A*J#~!61(o#zfw~f-cQX=yqKP2^@ENfkq?32EIXvyzmj`N5-P;2bFG$dQZKnCk(1xsLWc^;WT$jUI{$chiIdY~@u`7D zEQ287jZ52Cv=!FrN=HKVdGzYQAuPB(`u4>%femF#JjjvrA?`a8D{w{EuwCz_Tt0OI z!NsHyl`JMV4<5k6CyKmW#9KoDR={jmTA{1NKH6J@$8&{jIGHl<{ z7Rr?H%~w;A)?at4=?@~IdM95wFj6}%b(b%KHT?SQ;p7?NX>OmY=XuAkUuZuqRAmvI zzS`!sl47=Q+3Wxg??d{YgG*;+FR2y`pI#r&7c}SMe_VCb-Yg*c^##avFcMcUr5^={ z9e#F8Lfi7$vHyQilZBn&phpNLf!~+)!`b_NEv7Z-Mot$wPx`MC>-hCX^NsA5k5a66 zdP^m@jgF+~@9Vau5uyMxuUsj~A9ZNom~(tcqgx|XpN#k0`p?0r$*bW{%KJXLNobaD zT5l{L#32P;dTHfh%!?_Lj$^hlY4zikAm`Vzw<}ejCSjahbC(m~Y2J;{vok4fA4?jj zl){yUUh3bhnht*EY>j`5bp$v$9e^LK;-j9;)}6c68@xXLW&GDk!w5Eox~it9l+^Z* zvG?B$G#u&W{NezW4f~5OviLuA(NaYIpauXPW`T-5CedxQz{dqkd)Gh7znoVyc3*st zvfMDBxv0-ulrMY2*D^NiU+NaJw}X!|fcyhQn%waQg1+ItnQD6E^rGZ%>e5I}ROKr6 z-!FkrjQiEg2LaLsaF3!c;LMjC3#xC9e}6xSk=W84<jWamkg;hqHkxCQgk`fuEnjV)|*vzC_Cc$y=g{D?U zs%FPuoVW|eJw9K!vRJME;d<*vyU_FjU+F&`)~Tq?Pw-Z-bK}la}Q`x z7+@AvO(s;I_9Q!-!A$)U`uQQJL{n@iEdaM==?#yI)v~wd=#C4$Cy6f983Tb`a-`VF zJ?y2C8--R~YS^#M7y_=;;eo4i9Dw^Xy(9idZ(vBWS{@2pNiCgoFD}$M!n#Of6 zs+L9~eoX2vv(CPK9}Vj*Nd*V>a*(R2y_D0THqMkte*^7G>T9?5^>8xCX5jtykXJ6k zl3s&j+*jzv+$|LraaZ1paVp%+&=iKlxqLN8Pa8#Tt$3^RJ!OW!3jB2om`w|5iUfJJ zV8GX)Hp3Gpvr3nbNu!eCJ+4$vu|Qs#<%9c=M;Hu~if}k3AAmIiFv_MRfH{0~ETuuv zO8HuKZp#j|oFY`k#qA`Of!Pw_r6DH9VZ2tlTs$8G|IfwcjY6%w$$0G)4w)k=BeMo! z1IZ+MNvHL0TmFMYCEb;8`yVC6bU*e50tm>H5Yhzz8YLT-W`~AftFjOp-#1uACr-9E z;9L$s`0T2b;&&@|TjHO|=7I7LfsE|k%4wYhZEtInemu+-b}Y}oP*T) zvhARFENN19h1bU3+hr2-;kfU!zR~a(^VbU<`T?4++H=9DxUb*(ir?~Z8`uU%i*y%H9V(K zAo6}MgaZR#ch+k@#Mdzb5&Ky9bz0teoK>%zmo-IM|6(pC+^O{=={H27u@R*mWeAgM zzaOsQi}V$#QcWxxaopdQE!onf>VE?Tq(7hq2A_h(hti~%&cg`Np%IL0y)Bozm<;Mp zmWP=M>Q>o$whXugz(mh#36{l!YunjC_8H`1d1N%sOD)Hu-bEfwyhM=-I@H>V+0%PE z0W2QyoUDohOK9qY^%A5s0!N8xf8J|~>V+ox8zz^{PaL!#17~tq(C0;AP{&!bXF%Qh zAzLZSsdh`uLkYQ!>^qaEgL3bsuF{|&HrLP?#)vxGd?RX&8o7Hoy8nPJ$a z9qXo>cy%#ip!7XfbD1vZ@CF(kUB06aItQuz2S{VW2iYnF@&?LsOprS-E|N-YiZgja zw|U?UU#Cn7vDJpBhJRiW`-%e*rLZ)tEkwv9I|8Z!ZJMyYQ7u%q;WJ*H*Aj78mSO$e zJKV#|`2gnz5PyF@__6o{mN7v-GWQ=yEp9*T*{;`VZp~#bKb9f5;?-2V6wJg{20LT#YzsN}jezT9K3{Bn9-#xl z{-Dyh^wX6wdI}c-)K99W3Ej1VnJV^SvfJehVr7TwVZz*})ia|~4VIr0M#|MVQ4c1;=V;B$D`)`DH2DPJV7?R_ z9uxc8U$EGRPOj_J|BQ@NZEdBiiQ}}J-S=DCMu-MA-F0UQvYWYYcX7+0ZHXt&Yc=@u zdUU;xuNL?tK(2Abg0$bYlF}`Ya z>h=Ys6(!zKxe0OdwkX@r{>ltwTmT}bXfV^d?p=N^zS()wNBQ>J-1y$?>D}fblODckwfBzdzyo3Kih3sR z>--p8`3W?nSWKw>^qUX8KGH^6jn*Az#WZp-|7T>R*9eEqWdifkzG4L2v7??if}Dd;TVoO063g&uD_ zEsJxtXnkzHk(6oPSoLK(D9A=k5bjpc?izknBKV6fq!I@{?K!}0|2eN#YYCoV_aA`w zy3par+Hoe*lQ3uRa$xGYKthS5Ux=*QR)p?GHNAQzy*@aR$6BknfiAmAJ9C@cCcWo% zYG=J8==kTy+^#)I*=R2I)jyx%Xmq&e@|y22nb8={CBzbAqw**jfWi-egEshzf(=Q) z3bcMUXQx^Fpi7J$<1ic+9NlUQ%**jN%~>5WwtYMrds^ysl^=z2`KDLxQ1iD|A^G83 zeBol{gZk7bWuie<<5M>M7y3)*86Vn+k4|H9>Gzy>^)vn$&#_BQ-yH9F*j>ReHF3Ym z@$Pe@2h``IUA)5;fp**zl5#pso)}zKL{cQxALr$glB5(JEO3$2mobCNh2cdT(g9On z>|GU`R|Ge)+(Um{%}9)h)A5~=!2uVo+Z+L&HUyN+-{9WmKOkhfKPKzA$Y7DwN<^dE zb9PHR@EvSOs()LwL+a_E;E^ZwKDe&$V;lGJU5z;+4@%33j@3Q*Rzu(|b0mUrLA=ty zc|d+SMyDc8y4u*8UVwL^4*;PdEFbIR#XKrB!PyyS31Owhg$+@|{=Hx+Y)+_ix!I+K zv8UPODPJW_=y|Xw>vRyB)*dl<>o(7~#U4hXLyanJQ*VM3i`VX1|-sl^#{K15kQXL-4_MlEbF;R+jh^ojbbVU@@9-eSJa zf8t}LAcF~LDEpLRh7edMS!;cG9kDqD45KU(-1A|+BAQ{%*_$ROP`=PTG02$P$ zeRJh!B!Z-OzmO78e)Q>LuMa~&&^p)PyGBup zPm=b4>t23|7vn*_NmtDMVE-^GfgowutVjpUhd8{itwA{Sr#bSF+FT-6c^%Q+a?B?* z|G9?@KD5F|qytDH?^w1}g)IG~Z;&g?V8|J0Hd4dl9H6np3yMJMO$kw!CbvMsu+3uEXT0cQ@W=ss|E+*(2oE88 z|CQ9a#l_)3y2vA)ncWhYs?(>2o(B%Ti}wO_aQ@4A)c4D=tyn>t<+8r_tN+4uj8#r8 zzfUL|q0m;Oav5pU13ynM>{tCVq%}WA1&*#hL3;j0Ddws8mw+A7D1~F-!wRa)9G9^ttPVg9?SA<-GAUIy4llJ%=1O%*aLp77xlP|2i@4WRL|U#z zgnkw^#lIizo?|`-&PT0Swsy!vyY88hi3WV`Q!UGB^lgKI@;yp&J}zItXZk-lW3H>Z zZUY#5qs}!T13>Ogof#92Bhv8|a@qfMWtE|IXr@??IWYQR+xm=DCvrzZk|!h6k?sFO z&^v(RJlJ%CozHTeV#z(*UULn!TOfs-9VymyXS}1FS}quqgmOH{b*-dn81+rX(=;2G zE;RA0qPE)Y6-FE8Wlw2o8!a-t1l}d+aM9N@cV4sheSZ@8I8*%3*LPNdYZ=6eQ2gkC zLAoACnRS z1oUd32K36gIeFDhBL2rSFgen1DyrTL_&Od*O))5Jb}&ti?ncoPr}j0;2L~|oNf4vd z96gJ#LA~YWt9*^q_y;E3>afm zJ)y$fDV^V*^i(qwnV;<{nES{sIG!_B298ys|T5^VK4 zQAb%{#V9i3Ln*@Kr<-x@)Rwd~K#oSWD{rT+qO7MU@H4ks=6IA`>Q&Y?wu8T{uaO^< zH?7O4KQ8uY3{=<8ZUw64n?^|cPl0nM8&3uY-&fZI^jc!9W-Qo+spfw7K8~zbp`Dr+ zrk+F_{=kEe{GuI3xjR_3C84ZO3!v%@bjloXJk)WlYvKa!Tmh!v+n(2*;5OERcpjEF zbWtz^B8=LA*j~fD-I!I+YlS4I^G#=CQVk57gfpl6uY*pknn7DLN0pIu@db~AvXV~$sj0a^>wzrSWd-|=f(4aT4U^Gn(@_T+WUC!O6*;iS9x>$ z6HzO_uMc?6WlwR+X(jnT`%0~Yc9#*%g{evcsLB)a$N(2(l?~E*1uf!aYT&UC{PlH# z9|`OiTg+GN)$ClNgYg?E^&Cbj%&7@p7Cg_8a&?4YJsrq$N7I=1D^bS{;XVbJxS<7v zM8kP&`lthZ$SIBkDw5Oq149tzH5XSmlr_Z%JwyA0pt?2|Hti>GV+2Vy>429n9rBJO>+d6%i$F=2 z60{s$X0x=RrCER7VSfXeC}!8(U8^^@eOjP%I*f_8-e^_g7w>PqmTQLj3g8G4c+Fl* z#cdcH&rYFFihn-yxlObGx^U1L355|QPmgZRr-`r#uK44#%V7is3DpLZ3n5(Ki@Ae& zp|R>9!%_q&HU7Nc)@sGu7oPQjT;=XTa67$c|uD z?EPZsk{1-Fgbn#aH=p4EIzQ(uZ`g4zzlrHWKwrgrl^-i;bUJ6)0)H076KX0UZ5U7L z7k64FrV-qRBZ5&>0u@ekXo9f&qwXn!Q*pxBfq0=V0Xo^b($rp|Pqw(OQpW%1 z!t%k{WzBo(Kpq9*v7O(i0Ny5d(CNo3=X@`;RVH2TK8e6jzNn_Z#%2b@BxQ#UgTKS@ zQ5W-ypTf~P@*iKAL1U!GVLCDnMSN}!;b216r{{&hq17M7eh;6wiCmPT^^ICe?}kzs z;hl>>*;jaxG9O*hswdeut4rb#5-a%t3b@}L65tiob$S9PfK~{iSe>n>zJJ9KvaDl6 zpR_p7S$QwiC%;8}*u4?GaT6hv^!}`+hX}AxIT6S@a9jAJnSpzohf5}j2xMn1nmnA- z6y0@vVRXXtSs;Xw-ruysAsBc5IF|O$rt}OmqYft=DS=3w^>qM{4gJ;1k_y#lg5{CB z9PXe<9BKJKS9BuUHGY!jKnjn-Bo7N-&O~7uA${m#J=gLk#pa}vstAsFu}H7KSE=Uo zwN;eiaCOF9ryUEoVGSVKROXK+7~)d!GhCXl7Xr-@N%$ zCm&MU6tjt`F<|{j;~0i1NCcm;Ie8MAs&QsUldUaax6G@qA?mM=?GI>dy+ z+&K>F7sF&fUi}{)HNEpw73aMtZ$nkvxh@O=9dggn4Tj^x%5D_0@QOnw+>rQ3{)YOOqSzJfuCv#DAphq3xd2sw57In)tr)N^Jv)QP+H|b zK0OO~69pwsAo^gnW5VuHTrp26Sakb&T*&cwu4slor~Js&tzi}yX=LTK0XEgBC*!?;xdUa4m)Q)(` zDeJd;_V!oW=F(4hV&a47il#A(ie)Sfh5SY`=SYjYd#8yPkI>G0+3f+iBgHE@R_7Dl zLo#RMX`KW8%WNdG@rghM3Se2yCGpzC9$U+SFlE>0KHlup?2pOMUVy6jSi?DszV&&O zDTN@t7ieIs>ifSE*`_Mf@|R)vvmp1Is>N)5r^E=Bb>|P^Vif+RCfBKXqKd7-;f9-x zpBlyBwS6s)cE1KUt7lgy?BQ#q)1*q58hmGc|JtDUA?W9Egod>=UgrygEiAxUDQJ`@ znA{RT;rd;yzJklR2FWImv+AT)u99V8hSz>yV#5A4993)X}DQWLTO zmx(7tUHkSQAnMYHvr2wEgD*sL>tm!(y2!Ezgpi)8yd2a5iq3f&>Yuq{|VTL!kD|GL5F3#caT?cT|LH|uxU>xmwdY6)1?GX+oGBeI#uKA zqi%ff|6g)xPH_aqTe6n;5-_yuV-j9>A2fvht91QywR<%_rLKQw`j_~AVodmMh1K}Uq_?v}sp`ste#oCcmb<`@GoGj5pstU`f4zh) z(7=PN8WYn+$xm`VSK8b&Zhe#DdD&p{_-r{q>4d$tzdZ^H%L!43EV%=^ zhZS^KiP%H0^LxQ{kTbYD*37fbjbz=U{rjNvD+E|(v*Y=#xv8d^TAkUZ5xCpEiD{G0 z|Ma{+0p^g&`zBs7c(2ObA-CFgrWhQ}$yZCmhei}KfmOo(Ep5s2{mX&sio+UX!7L5S z>LxOLtz9y7YWC7wei3wRDlWrfXY3?{ggx##&&+>0)lxj`zEK{ zY3&yk<}Y|5psf7WG=`krOaq{sCPa4O{lr%P{dGy(I*e{*)?r>}>o?E?+DGdETWqfi@~R}=B%p`H&Ix#kdJMrXaLEk_#~JQWm*&>QN`O8z zo%Ai_plGx?pQ4@fU&;=2tZxJQv~mi!F>c%GB`|%pgsdBrW}@!Cn5h* z0$zsv(K2K`J62~L20GGkRNC=&(zR^Qec$bPrw{1;EV|sG$?C+&U32Iog@~LdI78RMw3)*@H}RicVOh) zj$E4qrOV%90ck!1bu1P9HsNvPNMR{0*2`)JbgwV4Hn=zOs^b*!AwQsv+c{m!iN!nT z7b*>}Ce~bn@@K9jGY|2-i*W#Ly`3!2o;4<)qlTth5=siLLL~r{r+X?d9zD^-n!h9k zI6t3+=s<%fveTL*U=iIf%Nc@lz7C>gKsv_sRMyqLGA#z}NrxRv$Z%bke~QVuT0 z+PK*iquntYMdBeyX`qVhlIjNi(E*w#J}ddTmeZ^O5u{UX9~;$Y1n8T`oD0BF z-1t|!aoPh{3C1~-)`xM8@4!i@dh0Nbd{w$0v`a`jSnGsa9yzU!-wKWO<()KQno8*2 zO9V4I#d92Iz&)zYzb8+U49Rea69r`3UFS9LIv5s+!gWu3pzku*k2t}M92zMU zYs{l)fP61*0%qr}VG*FyV>o3uHZZn#K^2Fy3I`?+7`{#Vs z6QsIvZo1vLvV^|2O=s(|lRv+|I|SX4vW;Gw-AATJy6sFJm!HB?7TDp@w~Q>f0rWO3 zC$EOqZ2Z+KoMFd;mD6_6x^cDD`~Ko-5FIK zC2aP_GbgC3VF`G$K8R`FQ~qi&2=S?OAQpK7=i1^d=+D(tkgi-kYxUh`oV=J*@N?@t zyA+tIzp*=u5qfsK-p{%+S#wyUj`FIyU=Do6b-J0c*v~GRq0_zak56snR1{yOEpAg9 zlVkPK8A|wVC$%cato`A})uD@ z<-CIWAC8K++V>slW`r$CZuf=pwMDL`W0eYl9GjfC&6p2wI~FTFU}0Z{r8HC@GNBV( zcA?c-k-4V{X7T(MvxJAQ{@N!JG?gI#r3jN6kTev4Pd8OfG)NTml<;on$#dE253du5 z!%7h~%s>S6^#id>M}(<3r>Y^C>-iVfII~D11GsojL81`gIJc58O_|McY;h` zlh2k?n8$w>@giz`F9-B8jY%+Pv$OvNQ%aWE2v9(LF5MJ$Q{3HzI;2m${Kfuo8sO9A zBFA|ms-^I(4TZd8ZtK~o>()5?tvT`#JLBfwfc9R)gqldFGTqOmnGH>C^am&`9}*6> z`8ldG(W5PRb6Qj}k9#{kDOX(+QaYnm95Wa5xF|lNI^{*&{#7XJC6Kffg1Q8s^NFhe zN|+hNO=AJ@BM7b^&faAF3+PMypz55hNOZ*(Jytk+t)U51+D@WOd%l@d)0{0$|3x$QYz0c4!! zh8jCUc?Uq*5t7h#jhM8|I$kFbu>wKFI$K%_?jLaicj&S;L{Hn_m3>J%(D0O)BQL_n zHaiBi>o*hkaoC8bFhAE+X6%Y~Ky@>^Y6WK$xIohAehQmF3wl7MQ`EqnoARn!-!3GT zarFj&ln)jg2y#O&w4Zdh*b!~tZc+jO#$Fz-WB+&7>KR~$SdH<(ArRm+Q(CgFLQQi>NCuETK|I#jA=%|h zFqNb}p$pg85vELF+VncP{apK($L$c1^?lp`ol45p6CQ)kiXj@se$X57*H014IzO*A zvH~P&gAFio<)?;2KP@xuS=D?xyghXO{1LCtayBvJ$w^Yc!YbbwX|IagH4HguT&ahz zPc8gl+OpXs77*`nH(l7MVJ2i#HSDe#BOnVTG{7LBi}XU`5GZp$;_hOdbv?plD)a>|f*UkZmB- ze_vkYb;PPp{AEl}GvVX`kg=k#8HgoQ;??_QsLr(Op-jbPn;FXI8~Mpg%tqoG$0V9z zh4+F<`Xn(h9S}UGJxTC7_+9%1a2Ei-B@a*cI_A#Jg+*=uX=LV^XyLN{xN3*q$nwms zPGObiM3}mot(6HH_E~(Llb3oFDaF&|FcYx^#&HeN{Qy$)$Z{*}u?_|x(@bh>46pX3 z)@ApkD&+J?B~~YdS-|{i`e=zh&2(sRk<4Ai+_|XKmd25a?SvwEkHXUarRqoZh5wV*DhV;X}l)+ z3DP_fbK{>=vZ|fLCW9fc#nLO>+*w7zH#ZW7uYLADCOCIvB^(l(16iFfJA8=pxG(he zo9a|lmY$ z2IAlo9(QE>TJsj`3m3_`VKh8Df`0-1zg+6xdUnvdSO>lW(P*tFb=OtoA zf$%MY$@-)MAbkBkCO2%AfX7hq%rG_7RRdFQ;D zqXX;Sp?%0b&2uRV=KYhJ9$9C{2{qDf^jI)xMc>#A127skaqPFWZABT+DR>=sG>lUC9~qcdZ=&NFOPcHzf%PX4cI3ygwbqz!Ah|8LUf=9;ktxp@F`z z)#o5>iX1#dmi)J7<+U?;3pdu2RQu8DJC3Phdv)hopqO10KRo&>CBbYuJ{UUTP}9W56qzG)Fs1Y$@S$$svQz9woP&zaEaf$aPK!jYwq_O!f*g#T6&F10Avl z`TVoM5MyGqu4IpCUE$9X^jIa_vNovh6mjr^STETf+T)H}W!Sm%<>J1(;UTtRxrX;7 z>6z|HQbDJu%$u^1t3_Tm>a^cu7jo1~AbhqEZq|Qm6#owaxb8sZB3_h!Q*!SMq(y1n zYv1?AG~MshJe9ft&-c{Fn=xR8^C}WIw)WhLUUkoVnKd=3O4wksQ*1=IWN{*GJ4Y7U z-A6}gtI>+S-w9-2C%U9nK^LfWQ-k`3d`1`N@A$71JB}U-*r4m=3_1-_hqB)uE64P?H_yJ7#q*~Gt(sxFBp*q%b zDk`%dCR6rz$ILr9%==vW1Aghpq%nO5V<8fN#Ei$w=X+}zWpIPI-xYA9fQ*0RCwq}u zB=u*B^4O+tY0#PBm%z+^9@Z%>9@Zg#{;v20p4T}&P)MALdWgw(X&;0GVjQ=;co(!~ zES^g@>G(?-bQ}y{ngFGidtV0|%FSIRF4?O16n5yoLhtZr*7gR}z_<61AHg?+AX5>j z=N>t$y7s{@9Qtme7|WH~x4E6gy|=nkt@6RrOI7~%{9}~b-Wob<^T;C0t3kP+E7!cC z2X)P^7=0MWYc$K}%ug~juYi97k)|VrF?ay5w|z;z-`>2O7d}oT(&s-_@+sd>1QkeQ z(7hy}%0yM3!r(dG|4ccat`)W~?Yi^Apmt<8z_L+Y)CQOKjz}1?SG*MQyMM%X^!ie1 z`)IxTSMW$?W4V4c;tdw~ae(egC|)jzIr8BmA%un!X11uQL5oPjgiHpbAo$LH8b|3d z9{l*c(_VHn@em~wJsbhRRn;V*!n^c4aSf}7tyRK%`#Z6LK@R4esVJGu(;C|b?tY+I z*@tHP;-eCU#O8vhQW}itMAu24jF!fZiln%Omr=dz&tk7#-O=2wYlAwvzBoM=j2SlX zOrbIFi(Qg=7yIG(N;apgpqnz5tGM9#wk1@%JzR#uh<6=(nhi|_Un%t)vTb;#`;vsJ zd-qklG^ig1wf_lT1*yK*7Ne?+PJDYf&`uF^Q(>YxU06@K6IjQx^$`)2=UJ1N`V*`wGtfR{|QdNnXHz|dOUt3e{AE$N(WZu<;; zj+(`E03DNv1oc#~^5q>7OpX2L@(mv!e|)pyvIjM3&@9yyns0 zb_9QiYblZ+uzLZ+hH}}7i2r#Jvi?;lq*?Y7_-T^Q$t^xcS-&_29}A@3yFqAdDl{_v zs!%??qx2m_@%Y9HJI0xsMn&WEfOxQ^du|g*J5iz2cofBa0!(9=I$%RU4|L&Hnzr9d zI*gMvJGOT0J{pXqG(l#`tznGjSHC;SR!u3MBC2)iboMXhzMUv0bu7y7k%3|FcbUD| z{hAbOzb$I{a}^Ut4Fn#&n1fXj&mu!#cXtv`cI$k-6`;yvD*W9KawYQ&umd5>X&fCyp zsl}hiff3AV+iW#LJspJsGT!EWd@1WVr%?eNzdAkY8$$fZQ9E+`^v5 z^i`vEAafq8M?R=r{{>T7A_t4OhvvQ*|RLebID8g2aO3gG%J_5Fm{_%#cC6^=2@dw+^ufX?M@_u z))uv~{Oo=>b>iGnJGBIUSdP2Q6)e=(VS58ruAX*V>I^@9uvZwpyJf&EO{#uU#K^{` zibu^3msoiGGL{|D_sKK<3SW(BYc!UOgrUq@v^y5)v*g z@Re|&4_OtJ$aa0@lELq#EqaVQ#wF}Z#TXa)euh@7QYEb2?v){IOw3oUhKf4dgHtW#&+Yvx_WS9%cCk-?|~b zb~3wh7=A%;jb$*)6egba;#Jtn@k=lka9rqrJre1Wt+7(32Zxxard)xdDoqQbcNJ47 zDr|`5K2R*|D++K~^4{-E9*8U>^3_T#}9YS)tHjQ=Yevv=zZ{0@7?u(mkc1~8T zbx@U#tSm;j?9LBgS#DVM|Ys>E~ri_o35Bmd0UTRldQM^2Jy7K$`pTpI@ue0@i zKF&?UiJ|cC%XnfXGzN#1ozms#pOCp3I;xuSI)0g9&+f!^bT{dR?ToM*Nq;_XZXfc3 zrHFGwQnddH7TtS4*I%JNn6RZk+B&m!dPY*O8pm($gE=c`T`5fBXyoFm*t1=Pbz?aY zs)7DbisN)k+f;wJJ{Ouzf}Q47g=evmvf%13{LbV_z8#5rw`dlr zDgTnt*76+Nt&Tg-mfmR1?_{-QwKZ;CuxiUf_kJDpkkY|GS@>~vDI44!^V43gCF55x zs<}lz2|DJ57^@hYc?F)b<`jBQi7~=M#|*#J+m|Tm!9EfG! zy^0ODM3RpAVns=5^znB!%-pZ7&V#m&l_*a~E8_*b?#gxe=RHO31$uml~t(P{bIhLCDq zq|U94Y>$*pC#Xf52#PRtOAlu%WY!;U827BX(XN$ZQ3IV8L)UhrPXnvcu*VLp-H1eH z={n7oJ-|A$6LPhjuK?G{!i8#6TFc)%pjF8YnX!k9X`~Rko<2Yn`o^Te4K2IE#|Qxh zn3`8mCe&?N4&sK)Q{lSDy^DUbLVVssT(OS0icm5-2LY5>Zk8Gnh1JoS+~2l!B7aza zhU_Wy;OaB_@ZTqoRE@qQB#p*n+QON`NM^&*zC9Na9)p6!XY?i%3B#8p>}MwQ%k#Gm z!-{!44h=5P$S`IAC%5nCefQU=zOvQS8lr%yf0rS$M_5#x*B-GT=?ps{dPMq z)aa7m!vD+!M764`+#!BGDYMn1%2C5jdFq(JjPS^h#9C%H%e=*QYE*tuFJ_s@xPyHw zK=P(-OC^Xtd4PcYr?K{S2^*B-hZ1-v*AOC?Nqa@kRW@&}Aqr9EoZq4R#2v}~2Kt_( z&L-Ukt7Pw)b@qzZtD&zs!BNfDPr& zwZD}~AJpOg7nZbtZ@F#0HsM66wy&iwd6o#F=>=u}IGGV|q0xJr24YB+_y)v4j z%RsEWfyve_`=ds9@Az96orFAPF4C@EI`;bf%Txl8 zbC%dogA=>oMx@nZn&n8q^N~>5V}!~et=u`pWazx=U6IRJ2;^-(yv{YZ+PD7#f5iP) z*AACcSgfvoBzY~ubQXpb^y@@ZGg4>^y)4q~x$}9LmNhk*zw`2_Rj+XNHS*VSmxaxK zhP_^JyogztubSPghqCcAeNxOXHPbL>{OCT7dG??InnkZX&&%nh=U0(bx~TlprwwS( zqeN(WPvDWpvz7Y8GZx42h+ke#;1ha^VVR7UmHl zD11VAAJ*bWcEA0n#Mg53Zy#xqTtt-8#D0oW{dV+;MY~%6(W=#dt-dzk1l3341(JM| z=zTM|2l9;@(EajLPlMc}dgl=iaCxO@3#qi@OA+0b!H8GC9#UBxS4a4cEutUi$cJ9w zAbi$kfI2v?YW3C1utijIGO5DvZp53edWsF)SWJuQPX1pn0ADJ21GX-zKwGhyW!mD0 zj=OPqW;LN<8NssW8QeEUcnkbdN^5Sy3Gkdw zzIk?mDVAy^J}#6+zoWn!e_#^5)3nvqd+2JU5C>6Ob}q+XVecr!aOFIyR&sXYm$+N} z;M1KwVbmH6n-IP*=9MJZw{3&dUvqZ$uA|k|7@&~)H<3uWnXX3Tvqg9NqG>l^ugC!; z$a%_%k`5*&FcH;ZxsCiX;k>9VOk9)80 z9Ub@o&^N2Z@nkKA8`4*FxA;?yEIPa@Q=xvNhRa4rqkF#g8cWX9xQShgEQpuWBR~K= zzDb9lO+q$bwkGRdXXk}tZa>VwPw__^mVzN?T9)p1!8x9%3t5fAQzkO~P?t)2?pM2Z zX>1S_hc0cnsb`=Ev?}2{+~7OsC%#N6MX}f>WTWr6NV)U3cL`RXjwBabGLR&zsuzEa z*b359ygg2H28FcIOxNC`rI83wf2Yf(q?HUj(uEdx*T}7~;uYbDt@62WetjgmZzO8M z_p>p@(~G$FyP02oyN4_p#W~+X+YFJj|<#bz)v(;w;TUHt6$&u zUI{>ybD=migTa=bdLagrr8Mo5gS9Iexr(j%8)Ov9a*081>{|jq zMB&mhF&UQ(EbBD2oX;s9pRp+Wa5zo1t$-}vc%jT~vFF4=;3=HZ{EskWV(%M8&Y2}B z1H79KK62D{F~?qZ#78FUmtZmlVHbEu9?+lQzpf;5%5z$xqOhYCAVEi#26K_`H6WO> zu3nejG~*y%tp6s4eF`#iI@oHE-x!DJEo9CI z77on#gBilBnspUn`Dre*z-*UooB!Me;P#Ur-3Oj;_%;H0H(X8_vjB2udX2h z>(%SDT!EU+K*2w1)Vv+`(Zc+XTH3&EQFf=>+(pTRz>m%)DDUa4F@n;t%$j6mfwj;G zqD<}$^n|s3qo^R3O1%(UNVDu=_fy&}UZs_t0*Y>PHyD0T>AT~y*LrYfV=wA4dsClZ z#vTX3v+Cz;t;~Q-+n&>x#rAM`gpZ!q<~miepH``lRPKIQvAHm(6Elkcg4W09(^kN0 zJoI`2l2$ zN-p;WgoGn6&y>h?us_fBa96lTqerBb0H^_Yco5mF76N*$_NJ5p1T@XJLX-r5bhfnA zvC&5&@5OvQOaB+c|4MvoUY92Qf`jNsdTQXdYQ-J{$(ifywwpN@{PO&z1S?=dtB=*G z|5#nIB`b6Q$hjY^zpGEzhH0Q!5^rNO(pYH>qbG+CQ<9bBm0d>LSZ_PlE+6a z0?EvV5{b?$gS5qLXpkK`zFjh($fZC|W)YB%Yg@;12-u(iqmO5qeUSfhT_ zmv4=TKDs#$-)-uMrIGu3OOu(+(L6eZne{}_7(pav-paT@c;l?cY9ns}jlE~1+{n?h zu}^qEI&I-A;JczEyD_{ucgs?7 zk)Y74eogED9+)~yZA={n@;=pHA6S;J%hr1Pyn$+8#x81WG_-p$IrXADE%}2ZI%+7! zQZ^aT+P%}nU*mL*jZ41{)w3-i>jToS0H{;Tp4Ye6ASP)1=9bZrRhd%7@1S+ zjCgaje*PX+@$<`=ii4Zu3nzjhB~cL1EI-9H$#F_{vO>Pq?pqO7%gm8qrE49~p?pG$ z^g;?Xyva+-ckq0Xen@EfN|CAj;+kF5kV^En>>b{_5w8}u{A5lwgur)02Qvw#9m)Z?;Dps(Q$>u zz*EKuQohbjTAF1*(Wsb&-Tdey^$y^ldW|EFP!dQ<%q6#d@Tl#=AYXizC=Onr(F3TNjay2Xv*k)e@JjDrmr8-DJV*~5N z*MVeuKke3eHx93v;9=MOYNrsw*tQb!R)6Xm^`FDt3sDB@(c8>@Hpany+O`Wwk2+yA z6$8}D3@Ff(=3SH#;k&$qHLY*4~#xk9>n83Va-c9z{@uE`*VjBN(lHT6e_X$oI8 zQacda11=3J9yvPvnE^;Al_&pc={MwsD;WfEdItRxEOQTuP^LC;skuY9@^+c17Ck2m zp4zqi%|7?Qm?=F}D2zC^b|O+aQkkml-H(Tkd+P#z%mfPo1SNykQ*qc*f3fw>QV_=| zfcyOcJ}OWKe`BLgz#kHD&%Rq~_KN^9modMN^_Z~rdiMV3{=43HMg^mr#xg^l(Tvxh zRq2Jfdav?o5yIc+tG^xDY)y)nRO&)Cy*>jiTwWjOK=0;+9B>8Dbu| ztZ#3k-qg@eG*x16i5+>4$Hd4dpFgr%)L|kt^ORQXgsdJ}=_S+*B=!9`4A@KPps}$@ zw%*=GSj{ym++8=NwQhrf-`06l?tg;1bpX-;X!XpgZ$$b($>hA}syq`HM>YFdP>}UQ zCE!clH&%x#(|##bFr1@nJS7QB@}sP{m;Hn|#3Jj^&#H%2IkhL|){a;RpY^ z^o$lwagD411^I19JJl>713HL6)N3^JhWfF`e>lsgxT(F*#e%CMB;ib-@`3W>J3=>B z)XurK*)-mGLpmEfKVVun?t^1sAYHF)?D+xSMZZ4!>WqA;7{^MPJ#VHLDS#;jmIKnp zipQr=b`wOQE3LHQnw*3ZCY=gPS z&zEB_bdr%UuvFQANPJwK+QMh zY3n02!a;{CuTa3-9*+>!-tO0KZ%8|pm$CB8C+VD#+(M5a&#hE9c|D+f`9;u$IdZzj z&!0eNTZ_tyL3{CgFE^F%KFm?Do-&_SvOi@}r@}8LmbBs*+J<@d5RSY};SBpJbUp(WIRovvMBWf_ZqctWIjTS};#&kHK*nc@d#FEZg)^oO@T@W#AKiLn3Zbk#eDmm7I^(rkys?0x;tDo%;e?te z=Jus|eH!Nvm40ziE6&!mFXXa}T*nCS&3KwZcv@dWl_RSE??vVJ&VD_(V{Tw7BZttY zbImGCOV4gsNF~nZ9La?ht=!nUGq~g5Q4j~&AOv5U#^GndE756FvW;Y23~rW#(DTX3 zisw|hWfWPg^r=3o=9`Gb9QC8~E!rdz;9spz?3r|R)H^NY`wKv3tC1d2@iicLr28AR zha0c`oEq`T3~)DW4>+w*;|#%%HwP$J>6oK1Orl-xvl**vcEwlp0=!3qwp0J;BADG) zT4A4G?@$!lTrR1-FPYe;T5oy&qM^zAzE7@fmNGhH({iQSeF2sRrqr2U3gyd5Fm^Rc z`QiIjmTQ?l?izp*0_?j1RiI-@6p>kITxDfil4n9d+O_B@??bJxcihq~P>g^ao+Ca;LWK5e>J4}95DcdoX6#syW7jCgSqhQ3B|TcO(0 zt0RmEdb9a3Mz6?&bD<>z@t&$qyx;TTTL9i$)iS?6qnbFEtB>q+@SIl-F5`9SYaK4Y zTKy;~B_hI?ei8IVrw7dnp(lVV1}`BAUWWZcdYT36BbTIZZBn{znFxl|EE7t?o@+(A z$J*jk^tSPgEh{^co2tE^JrWXuS*3cQgo@;~pz{2D9=({IB>$7;b<@WneN{SFa&s2A zu+tQ#5-?~~{>>xOWrZC#*7*1V1_61;Saf`i7VBy?GSCDef~9&Py}IySnFad;852;m zy{+F&y^h6r)vMqDhbAT8G~M+AO$vlN-kn3-U%q$Z)f!!_gHRGXv9Nt z%sl4VCRIt_9{p>m0I$X1qkv%v92LVZOB(!;?s#*VKC>y1tBO(Qs#}9L0Hk2HmUzICWOG6RD%BN^{zd};xU(L=fMElfu>&byE9_ znqyr}R$euF%#<*nbL_)JvS0eRkdjqxdu=)bN;=NB4d9Agbw{Wq^_+~4dnaVyvqsoryUi;QH2!X`V8CfPd9fi9c(F}JPI0ubWbRBdl_x_6ZS!Gu8ca%s^i`mEj^%$0B_NdUHy~7<09SKXtS#h8SiQV zr;>L<&B;%9=6p9?VQ>TA6%=$};fR7<IU=JKL>A`Et*E8 z$_`dH)#Z07FeSjo=ICLCzDvc~O}19-3I_-fOiG&5%A(;G}0h z!ERx~D1N&`$+j80mV~?=wJ84^nAz*cryrjD45R&@8?EO=m3^+k9|_Cce&*Xx2g>7# z`4d84By|!o6t~$Jdn`8G%C+o1|0nrM=H63=zB+LC!-vtv z+Q^@uVMF`!{)N@9{YY{&o-a|TjKHRu=0kmD&M_kX#{CZd8Omd%LCFki; zOpS*EcW`;D*Ky8s*%5c!|Fl?VtP7|s(;SsdyF(aeHBpTbx~v5EYI&DKK~Fw8aJf-f z6#n7eu@_e^O(nW;)}_De+lC>z1-SRDkM2mX5FeQO;^)S&#JIG~?(L?k z+q9>N8GT-}du2kCmpyu$d~K&$WfsAEVy+KF?M6?~PS7BJL+-Ux;-&Bc-wj-x82v4{ z4oe%4czffFa%duf_r2K9t`nB7ez9+^K$gpwtCd?^?SDV}mCk_r3$~MudKSJTEz49j zrrl^f#GGriZEe8)18o)$NV)_43;oGg4*C=l;R>=i|C51>T=zqM=EGbWcSJByH(V@q zhj|Wj*gkpV2NJ>UU|7pIzAl&3rs|e*q0($l8?59Rt>j~()IW0P0^ru+i$?}PRwSW-=MGl=fK#WLx7M_;Z+d^oxa{!Z7F)tu}4tO^(LJCgIxs0mobXMH3?uqIM z6yP-?mO)ULB34M_KakRNcF)(gIwCC;gPt?g^B3H=nY$TxcZbN+qD+q77(7}#7dr#y z*I6ZT(xCsK%2oAdQk#`#3f~Qhv`bWgAUJcH>)tKK!AK6v=KlXD zEGOuBZjpOk0YcuoDP`KhE8@UVi*dmPs!ZKQq_cQob3KDK`-qFP7ObOJMTsh%&4F8t zUcis_w2c-wmu8tS=aY-o*wTGM9EdE~DfI~G4%3Yw7UxTs(UEw6aJnVm3DZWxly4{r z)jWz%*#?E0OyF8XbOC+$`0L3l0E}9_1q>J9@2;F6DkuToV1xeobmQYBRwB>av`5eo ztG>VW<4VyuyqKMX5+>(0G;xinM!Z>RL<~ifs%k0mS-%C9Hx(DbT#}uhc8}1@akThH z%AS1gW|t$6S%E{lc|(ub}0`wl}WzsC)7I1#LHd7{Zi*IckNj6=Z`sd0vg$SN?bwfM5& zq!x*Ml-(pAXLOMMEI(G9nU;y!Ah{)ocH@ECqFUm>?Jm3jw5}u(tlk6h0KEx?^2Wt` zf`CKkP?nn{cx|oh&LapQvM*!NJ)ithNlspe=w;Q{fh_|+lyB}~UsW^g22{h&U-C^6 z0ctx|I%M=xpD6)tG@b*KU_H;LAQ#6fykGjKTel72kiG(s)Sj_4;mGTAWzVl#z8Rii zr)2^)0+>#Gy<3avWz2#nM+?zP;VHY`%NmR7Zs*GPELe-{x&B0vgn89d8)t=-x6i^; zapTQjAcyr@R5(6QHw1xlzL-ht<>Tls7Wv=karfHzDTRnI$2`2B`KMba3~TXyb%Hal ze{|-st|_|apsvW^CRTM-v$cj&a|fM6;iI4_Lty*^I7akxX@0ab>iuKe)W&)={KX9G zxy7|!)vAGVe`rhYFKaWZ*zlD(dmRS0IJ52b`Cd@r&?*LR)aIYW@J9*1G4a5f=en_L zSNS8%nVFz|k^vt*+(k3mtV9eTDUx%N$)8=V%qQ}1Axg~d{YD`{lR#5c2=Lbniq^b&9qNN(uLfNID*q{n$P)h>y@bfB9TAwO>h&9tAH6#Bc|;^% z%xr%!vmpNR4E~uT;-)m+Q|{hKWxzx{_83(asn=-YYB?2Ja9#=~4-qThh-sbwz2Cf+iVTDwWr{2nH|)qUvCd>pV(1GFd+qB_B$D2lN& zKrsK=nav79dpslh{YHN#-}+KA;>ERGoM0G%bg#tx+<$*1^{RT9RC1ns-1J1jQ?{vR zm(7_0-$McBF{UBu1Go7*uD?X}?NNHC|M(nE_m1@cGU`a@(U!f;zhpDF1?u z0q!QDi~ParvE^QB5<$L81Y9z?_Pc$q(f(7=wN)p}dsos4lb6xksE;10ddbN@-XigcZq~{}L6zF5(ef860y*G&c8`m3I766}hgH z5(ykdkn@{Ig!d?LYvdGBhkwVvE&~s|p!WEQtD7Ai!NgE<2p5%D$b*StgTjbf{)Q>F zCAFiqex{j>G*CLDN)aZ%Yeau@3g>6muwr@+EAqS9h6x5P$R&G1Tu+_%OnL;8BS49s zmTCD>JFLIP!=0M2D0xB@6AAQWccQFTX>dD9z7QiUtGFSFiGCDc>jWS}=@`LpQ*pyB z=B)@e74Nwj#2u&V8!p9m)JG)Dx&YVLQoMqn?(xt^pwoPC@TU^-zcEplV6}7I)**Zm z2p(6cL(~y;BQsW+QU3 z0}-68A!`Ko1B99ZDek~z$Zr=0n+N>-% zBVqUp+;ZJkMQTEH&m}Uw++*@;8S_u;BN*G9Ncu90YfYJ;-V}2KWJ6=$nvq#5FM8}X z(R)BJn`?lcy%p=`|F~D0pe~GOK^Hnuk!?#mZLUe)Hlfeg2Bfh+(M3;6h@b$tqN4ZE zTuBE#4djvC96b5IUVsX_2330b9isO#)VR>&tUFoM$~j;`UVbZC(Z_1Bn1?&4wT~VC^;(z{t}<6X%YVKvjY( ziNHBq4MUbgn`!FLyn(K$J@ft(_~D6Y%)F{~feHsPU{xIDEsmu+d}#p-sjXM23iVc3 zZE0R~n?Gtl{nfB1iQwC8eUz8vLo~}V%eGf<=)%$Fri=uqE+W*qNV%DA?#XKg0HW4` zBB5>ydBtw;0xtw4uzFFq`9ULRFfZR%KxwjD+7f7f(YND1K zk`hm^V1v4A%I{slN$kub!mh99-n-It6wKfdf=KXIS9`pzkVM$l>AOz~!^SCAJBXbn zVpq4r4f}RI92OB>hRg?CcbPY@yYrS*e?7iean7be=j$DYBI}=C&#;j6K~L@#;-xsq z*Uru%CJRVyDkS)M<=&QT5$V<_v99vB=M`LE^ZM%lN7b7LLj8Szz)EE)B%%n(mW0Yq zD3YC|vJRDf-wno4ku{Vxdt?cr?2MsElI+XGn6WQonX!$*Se`rje1FgL{KKF3zVAKf z+;d*%^*VE)RPv#|$F<&iESj;8J2uBg?_SP3&=%_=o(vxQsIM+T%CzTKr!gOM z+TViBR{#GutN3?MNmm2}Lr~(vI4@N~F2}g{W6u2pPfKGr;G>W$s;!TFGzRc5vWV|5 zfsa!kmj>?x=mpAGUQ5mJlNrefh)tWHE}zn%3Ake+hgB~=~hn- zEN6J9fbgSGb21#t*{9Tt__}2rw~E7SYM1k#Ch;#ms7sL$9z%4-^F5j@bAV)c*1iKN zDqA^)X9ECJ4TvnxH4@O`dd{yGQZ*79Qf%$t+s{_P9*2DceSD5G?TZwkO(TfO(cJPP zr?L}B=DTZxF{+sXG~PN*)ImDU)YZ*joCxeU(Cryy+_CO`0VYNITU?h$6(d>^wNt%W zca>gsfRygbDCdXA^p7?@r{6qy}W#6L6FG&lfGe1p|mUTqjF`djqsQs73`(!L> zdN3sX;wZq(-tyLIbTWzVcy|~512L@W9>cO@np^2 zd1@gnLlbCE%f+lXuA8C3MIDUytxN^bW1V>t+JiF?F=Qro4qAsRy1|5>@zy*)uwO;X z(2=jp3GBLX1jj)zeZe_4*(n<0!Z;F=}X6wkItiT$rZ*0lei-*fC016&AQ&C)0l3q zu-(*yD0+82BhWIkDYl_-#1yY={tc*E*Lddf69=uY`ycovj9ba!EXvXHYMlBFc9t^e zpO*sQXB9d3(uU}8jOw5ph*b@f#<(v2EwT1ukgjmaIIRHJvVAQ&rps11#%V{p$y;@q zVX5y1msnqTm=Cxc#i0GIW#N1W5&&Npj+|zA2%v`vykFQ}f&=MGRC1F)*%h|HwhnxP!x4@*TbZF)=oJ zSSnwwM(A-5pyZeVN=`s;-F)#=!PVB09GQ^n9&y9U?Km&!jRP-kuMN<= zUDSEu)vvgf2Roc&_?660#&Q|BFxZ3!&FA{S6IY}`ASwTo=N^|`C{9sA#NG+J6TvoF z_DtS9mx*D?ehnxybvEyQKAtCf6~n@B^ovJ58n9IQA%I42ZyauZsy4MKn~%B;CIpx& zp7mR_cN^2?JElx;IVFMY&TBx7#3S}}Pkdmz$E((Z&^Pegaxh_ZR@tBdDuA)%2>h;7 z*{t+o_wFZ$dhNajJrpTYtk|#)LBV^$hP0<^tw=uoIiZ9!;%j}_rrT)X~QiD>RZ*ve3(>pr!f(8QciWsGv2V;odcUf1CYhQ zRZNw%<1&)F`gnbh>D8lb>nx)w)&gNl#M34Gs6uqCS~fbp#RCX7PvL|11E&NEO#!)a z+r(M(E~u|}o<%{8q>NOLjlZXavFvH+&4ZNNb6ITHg74xi46ny}_cTdAt%>!Af7=Op z;NPBec*(v_9PA(?@UTL7OrTeXoK)l-49n2N(DZl$`#6=3-gWtQ#Mdr^}2vlChBDxaV^$LQg0J>oqvaTqsa&DExqtv!j0(@Z&QFplNFf=}>Wv4~z z&L`vhx_&hc$1N{WFN`-n^HJr07KjMx_v~SBaF(gwW5fGa3Xj>WSJ~p22P))#Q>YM+ zFXB8 zNyBekMGGu#rvYp%p-@UY<$dy>{>wfoURTGAY(l>#pBP?qkY<6iB4}|Jw4mYt1Ha## z>fnt3%;+c@1>!m&n87H{scrMHQKnpVCreU^c6*8RXY1yCaMt%Rm7L9?fGYvBsLtR# z@ABYN(q(2L%TDml+|0Vxc?Eu55>!lqPPYzS_EcnixIV`nl?ZsZ0F=9^5>2o6DE$n{ zsqagyv;|TkJyP)bd0q%GkhhY6FKHcG5vv8(p1yRks&>=wu^eW!oLmh5bkJl~sY_uq=nS20{bqNXR&!b|{^Nt4)1qr)r#j$RJiOy@^pB&Qovn^PPa3x>zsAy%5`_kQugx@*ikP$M59Qq`rCXjAQ9H&3hXVr&+4m3Ja0~b$I|h-d`GNDu9}ooNzpzoLCQEodhEN^$kuZ}LIvlXy1029e7E zi$5_z>Wb~VWzrR1{M+{ZC0B_7S@lz;2@@PbyCVJ>ezhkHv53-nHWYD*S9H7+-5wY; z$&FVIFx_?R((uE@0Y6iHSJGu05pH$@9Btb4lU#~HCbb0qvvad578d3I&)>nqY_ zmR9^Q++hS5a_U2@Vd{9c) zJ#|oZex3Lg!wr5QTse*FK`1#L9?1=YIeqE|ZA11W=sw~ru`go(NL(KK*}2~s1D+j#{|voBIPLOC1wr>Wrass?_NHkjo29vCuyfc zYFQ*5{8Jv*EEV%axfeRurX1mzU}K&fZ__Yb6ToO>^MFqpre>dKGSSwvmKt+ak5{;Lh{V3%UP zi)@WVEuLMOayRpoD&0|nO4WF{!$S9CCctj91r9dXk!zrleqBy>7R#^Nmv)I5#Ls9SOJ~qs1Z>0-Zw7#XQ^^vA+4)~6=$T^^;D~h%^-2jvVobM%AqsY zXVRR5YtuE0!-}lRRMKiPitxvJc_-Y)!1ObA{3JS39W<0QWEzQ4@0Q(=4X>G0R*p0D zKVcaafL#=YgaP_Am@271S$2`0r02Q$%i)geZ^&`LHnR@p9TTws7#_C*y*v6E%Cx&p z`)$aUhF?I>ne+jm?XuZ6lA7jn9i?`r| zxHi#uMNH@Rj}AA&^!I5s2-GF(iwE4+q@tPlJNbNYA&zvCVHGpP-4x+_r$;K^8D$yL z0%Tc)Us4jO1BtBBEf4RpxGpyW7XOe{8U5$`+Eq7UGU? z=Z)Mnoe^t81cr4m8|!}ncoTTEu`Ym5*fBQq>+nIBJ09vZ3Q zEF5xo-7Go%J^XW#1U0~D{L$JDyXwMKc(*1!-~fQQKwz9A5H9%6$yq0CnWf;AV z)F}yS+IjZVnMa}?%xaree$ar*H~#T5CB|BLTk8i+PQ3AUU{7AD7Xbej^WcZR37V5w zc=DxDfEl0r^?-j&H#YeUJ&AF5yWLrEiu%!cAj*9Yz#Zln<1z~OqsJIDA32??XC;(+ z&U^qhu$}s|QH^{*lwsk+IIy;lvJAdwdJxLw{%oCjjy)?Ur=Y;wa;-XMNkZ0`ZAD!D za-LaWvwHtuWM>vo$~672&liENMRFlA!uYX$Jf9USZ5fcv1rY z3oh^uU4aT?XC-t%mauV2?kaYAW`_^!GMDE3n}lC9Xj1m8iXaH`OwK@=-U7J@^I||y;ZN0?r|919l=|2(UUv@b*%9=L{1QtRUTnyvUI|P z-amtjQexT1uhC~kKr9pbUo2b8DIPi^YOxf1%YZ^EbC!aC0+o>KQvJ|)S#kD($Ek6> z+bz(9xMPDmvl;!!xxepTa`TN^#@>+crB;y_$b(%j70GcXRWiPY$w@$3Ff*?eXF)W* zrmlVJHH-#+)Vf^0@`Gb+?4jIr;9XAZ`MvZdmsmY-G6p(+sC`u(;sk!iVL?#q)E8I`Kl@{{1qVP6du7%DkC$l!e_;%ouHEGy9tnR#TQMH&={sCNj+L9P<78x zKhdp$!#Nkh!*g+Xh8)r!z3sYwQxBd}KB`pU8=sMYPuiqL1f-~ennp(#75Z6l2FBSX zGSF-GX<$WjaNVN;^F;bJZ0x$6btNU3q7o5fG@R$aB}{ji>K!%2qtBK+Xn+BJoigJ@ zt8KeIsPEt!)(ghb&avOpPKd@A_&l!go_z##TsF5>o|>(MRZzHU*Q79qBqQlcld=*g#DsUDh@6tux)@sZnT%I7uvcU#=tA|J_rKPWYR-$Bqjz11la* z59*A7z*nP@Br15LPilO&*`ya1AF<3ulw}(<4t=?ac)99pc!RKhhx}PMis(Xo5cq9 zQlh-ysvq;XzS7U4=z(9#rsl-jMEp?jCH%NOfL^OF^09$b_aZ^ap-Fu0*{o?mo^#5l zXSMWzJdB9F4tE;l#|V$P*ce-Oo_P^M31Wz|l+&=I^jfJD(zrgVJ#k_lG9A8>uDXVm z*Jg}@M(RY7l6Ddaw|p?sL-P+tmF7mR0xhvhwmXN9RdEN4s~fUkO4w<9w{i(>KGjG8 z+WbZZKZ3#c6p5UCeIq87Ja>ZRI0ebu_TR0zI)?7(d-n`<0u>gfp}>tKeVOBFoy=wK z$j@0(9ex}+rdG#txnJXRegqij#^Hi=RU9t>Et9g8;Y5yfBqC~(bV6dB ztjK9-S)OXCc-Z)8BU{(fsB&CP-OkfvmO;ufgfh@k;3fKb&D&lYtG-C%OMLz@mnNs? zTyx?&MzsigDZ4v1*+Ll9WJk~zcxL=3kNCYSs*_j)o&>7cHZhfRAqjYH@WALF_iG-x z$*UVdL0c*NZ^<}f@_w%_>znDA?gS219Zo=Zy_fG`ULS-VtX*kBY@M*Q$hd#7_Po_$ zOH#i9w7 zC;$?uERDr`Ug!M$7RP}@tr@Fn9J)`d`B*$0tTmpF0Xl z9uZXjykcuUSTdMY9ykZ)cN~ne1O~5kXkFCBF;e|q-;=-emvSPmKTBGBP{aIOyu52W zWTaW(ZE{+GuK-$iW!2H34n;B9?4wfkR7)b>GuJHK8+CaMy)gX<6sl(tw3@71Q2mbc z>+Mu~HQ;a}9d+Z=rjTl}Iy%DIJ;6CoMdGF)@+fZJ2&kcuNnjE|hUfZ3XSBrjK?+dn zNa5u${qRr0)QroLx$wMTD;F-6hhcHhN239H(a!qC7U$*%vRG?r0DJ*jmGzQ(_2{k} za;$i+t+gX3Mx6cM@<=K{D#?AfT4Ah_-NL)N94+Xb@ll=ROKCix9oJ`a{A#lyMc<2{ zBcm%uJ<1j>P*m1Li8k;Yi)-*|V?yxT`?!POy}*Vy)EeuZBz7&_$v{MEKE1(}Sc4Tt zy9@$Fm`hovH9b_+gcV=vW!-xP{AN$`nWg^y0cXM8tH6F`6Y<;$A~VXzSIHo&PU6am zTPA6YrunQY|xni04N2fy?hj(~4d?2%1 zEHXj{>RJX?N1+nm=B@6>YFZsS#II-$=ed`sr0Y$(-XeC`<8H?|B@Q~93bxpId4KL; znN*^FdL1a?tk|zas^ai+fTnoq+e;8^nTK*T9YIxiMP0BU@u;T_I$s^u>3cKYwx+Y> zL-LT%*scw8bhOPHgn*j!)+7PO+3EW~rJq_m^MQYZO-(1u%0+n}hI)q21aNJ5MH~MX zpK6Ya6$|dlca|Zxex3(P7HNR1h2~@8wHuu=(T7e|d?z0Dm zDzdAcLO;60yyo^Q`sz=%JGbBq^*JevYGOhMQpf*@)1I4xtea|-V`*4e%A0arq|yEc@?pJ}@uOK-OPF`C1$O*rhiYJ(UuJHrBAz*gnfYCYjCV3>Y<=ET z^lZLyaQJ4PW>aBm>Vhyu6-!ZXUJ2U1LmSycnH@5lb>+8WTLaEcIQr8te^J6Y zla;fJRSF-L(u`Es%ESj|c7?DB9Zb+g%;PS}TT-%d=^ypoy{y?Pd&H}Qj`4<8U6!!? zF-KtUOMykrm#1Zmn9k@>@IU}`WB~LjrBF)59B^($(O=%bd9-FC&64SjIT1;d52LaM z;!5yZpPERpZGX)V8n+z4fbO$-mv)_ECB)ZnfsO{jSEArlAy(6F>{2R(Z6$gsi!wOm z9Bt2GV`x)l^xwVxE4MZpf@#ZP(DsG zg9XFe)HV84ALsW1X8AhD%q?qys7j}o;#BdsFFp|zxhw_WN}7k*Ef6xexS6eGn02(#P@^@n(#hjm;hmOvW zyC#ABckhJEeeMmQq&(}944VG65$t7bR7lkos(xOyt|C2Y7&LV%{3}Am#sugd*8vpc z3{RF;1Nh84Km%lq%PFQYC-pOq+5U8m<>%n}e2t+Ev1JbW7CtT60Z<59kRlS@1tQT) zL2o!IV#6Qj#LY!N5SFh5h$q#=88F%)yt&3V03N(ytr_AzuY@o4+}wk;eRQ$eULB;$ z@n%Yio9v(%Nfo!t6vUguT%*wm2zUG&)UslT#{<6=chrbupVfyICG3GOOS#Sai!`FE zU3YWy0{E_J{AEzb(td5-#n~+=ASgZoPWAv-r2b~G)dtFn-(7df;-iPc{c}!}9`tSA zeWjif#iY-`sztgZtoVosL`|b*Z7#MPv!No`e2r+o+O>lk55<*DfVk4g60s0P>RFgm z0gT<<>Tkpr&Vq7{&%^%&GWQ8c3>~zDjuc{Op4}U9{TMqg6`1B)o1{I7=f+QGooNWr zwEn&S@vmBh)>TX^NVb|^&W0B|Ua`I_dW-~x(g4k5O<}zpk-M+}5nOAZOfaPOs#Ds# zAQQ$j-BF+h#u}&`pL<<$5=?2g)e z7Mbi~Gdf|YbX&^&Du!C{pHjP>DrkIJRuo-fpMECg@(-yb%@Ds7B?3_QuKo{k8u7fZ zx_?VXD}H}>mG62PWwrtDma#JA-l5#agF&AGwakJd#xiuCGYS&Yq6lwM+yuky>7c@r zrP>4a4t=MAiGs)YC51+*&GZ;P?jyePK~u~tJyjZri8AL?b%K8=P++9P+VroGj}Ju@ zfo=e$UmV_6-Qy15W-ep8IHE_(D-Bft1HrCz;yUk{wj^~qq*YZ@a$E(h9CrX>li8wZ zaa5J_r^)vZXp`!@YF>?)c&)Ky|tbj*54caJBD!`S`076mS2jNi#Oq#!c zb-F~6wo}2%-^StCPN}zdxC}*hvZ>Q%V3!JH#3z7+6X>Yl1QHtWT;UCsECh7@L%;9N zJktrLFJfJVk2q}Nc0i7lEkMJQF{&4v<>O##$VmsJ$0r{&6oy;f(7%VBtc$DZ<^Q8f z5w<;3Hh#+fEMf-LiprG7xZbxBbJXjUHY0!pHY&_chsbj>x_ij`>S<%}YzVP_Xjq3H~HYSa#Vq`QA^ z=2?~a=lkBwV8zWiAo*|DGwkDw`4A!b%h#~y5a>_JyD9ac(0c*a0*@%mba(y9np-?; zlSMcX=HP)Ro*oC|IB4wCa)$@Ce}QO9X+vh7b{g3ds*@v%nQ-QhcC6*u^+jy0#JSr| zXue9K<8=^{U~;ZEzjL@P(I{*1;dvaL4Z;$zgi5@K^!A*oX{b(`!kUDS>UG-hdVALN z!DD58?sWv?RgB?%hxF9AV|z^_9%(hH?{I1qVUz=u!h`gu$eO`)jM2TkP(MJtzEigj zA2_fxR^B@k!u*9r{J7s&+}hsRzrlVC#1FXp=#vsg*-sapxk=(m_ns+@m6i-U|C&g8 zr?li_-joesB_%f@s|tAMkjMuYTdf=r+piV~^ljaJbwV^C^%zf9ntYasi+_jgw&oKBDzmM_{9!xKlU7C=q_mQhd=EUY$VTJ zJy=o@x0=^@R7vk}6}|s?mNM7InREVCi{gFIJ4=@f2t_>7Q*n|996X)~)+-iwaaqs* zK?+s!dI@>SIJHx{PVwC!E0ES_i-I1u0cOaVM1pJIus))$Db`w`-z=FK8hE(Ds+ZLi z=GN5wa%87%wmh64(;mjRSIdX^ayV2P8L=e~5zsn9pb7y_<^vGAz3V316NA24M5T>>41)>`zWK_y+L0;h#m^7XT{(0e+9}zm$?PgbanLa3{d@qTzMUl@}PmmRf0u9 zXlx&&%hm>i`d9o#H>oM50*pLn=0hMVWVlJbjYt%@cY6Hr_Ui=jOG*0}(LdeFkb?l) ze)#my>dyH6Oqu(OB*%*==qKLJtvp8$h18+{7NkFF>$7CJ%g9CQ)Hj2>p9XoZTaLF7 z)eA5r@7Pmy(00;vbn!GD2&d~eG0j)FvnH0Zgc?+6LtsrsmL4|AsCc<$PVLIG#Gjbu z+$h?+t~XaU`3RpjqV#i5;0f(YlDPT)`RadGxbc%V$j4g9mrMbaZsaE|(8-!%#qDxlgL28rn6#QSKbeIu46wW{?c29 z+4QE`1JM~;7xU#mq#~t**#otGhGpyPQ0rjLcfT>5#>7l8&zh9qhb4=)uHv#;22MAcds*?l7 zaP~3K?QvH9wY~r9N>n4An+o%-a?45l{r#TB=^=Py-NbJ8H6BcyQkfvn|KO%@X@S%SXG>!BdD0p9qIXiufhv%D}mI> zCGKoZ`c0pBft7WxRdrW#e<6PwR_GoN27AwPZ%0WlbN{5B820D#-hftCVL$xx;`etl zVQVF=3OA+4%#_EErQs#zFk&XHLFaj1BT6?^z`PdqN$lNZZXo7Jm`FQfE~`boyZpgh zNa_d|p0@wmrSs{P{GgAft>)>oQP5Hlx?`N&7a=t@+(pHO3B%iiC8Cl@Xat1c^?l)b zrL4~ud&ckmSFiyuk^Yp!4nCZcbk#+kOWdsoDKw8GeP4hAoS~BYHS8lWm-^c#D(s-I z#Y+!ONcdHA>+n5@+U;WZUcKti92DqqvDMMr%X^RyI6FyJAaR|x`K`K^rQ@3q3h!B` zCFV~c1!NlE)jh6D5bEca&Kp|ZyV7mm7-MJ`{JpJ&)cM-+n)qRYCoahT@az`yb_6ip z3}sp$sZsp3fh*{Q&o~Nj0}3F}RL=o4%kUPYzn)=T>6xO_`3erS6kEvgb5qd%8v~Np z`Q6$c*sYxoO~v0(=)z)wR1n7mF!wC?_mC65NA35XQtApGJpXBNzOYLj?*@8I+}T3l@NJSo)C z{}Hr1=F&Nyf$F%}q^MLoYH_gu9=Kw5dK4SZ;UOyDsWUsj+E#DrzPK!2my&HptfjC(z7t_L9s$!c0330kbn0ULbIw)#kuaoVDiJpQCK=2p z=|8Il?PqnB;7bCw=F8TG7}ZJWT(RXq(H@~IS}yQ@Lre9QNvCn8+%-6w9yL|L3FtOZ zu>Mlgiq(V9BEwSJq?ey)!buF1A*~8Gr1PADw;ZdGs^-mh^+`H__B99aTswMO12Ef9 zZ4!ICPlUj!=X(2*xED z$&HjJ;YypvTM7!d_Ua>5PzW1PAw9TfaIct^m+u@PeuJ!PhCk9dd4eOlVhZA!DX8yS znxx~uW5YKv^Sit`-o@znXt#{ia%Ogt&lycELv|^n;qtx363uT2C2nA6PjZgPCgkmKtR$cltK}5!Wwn_W!JI-gYp?`Grq)5iG(hvP*E&oxb%KWAdPnhk1!bc*w>X`!bGu%Uyvp%FjRIJ5i=}| zfq_sw2}f(^!eP3o(cVBA4Io<(Jp>5h8(a&xqx zMHmaAkTH6ysDU=Cr+hFcQ2%(>I#;hpY#yD5?hgi_|KDC8^9l68-v@(TuO(`e4k#Ar z(oP=Xy1+SVK2RhrIeyIX?8=>OvyS(s6xCxHg&D#vu1_K42mr+PoH>dB(3DRaH_zQp z#OLt=LwxjqhG;kXatdP;V@>3rMYwK4|8nJ&MgM)~Pkz6~S?5g*nx;OlMdCusOz$si z|3#E!=R&A{Z}B?;_eKT=haxg{=t+K{)g!65=456Tok*d-zSQ82rw0b#&JTVn3(!s6 zG6iVbU>Ucgpy=ELLjq>dGn{Ql>lh)c7o{#g-~WR;&>w%w6AY*5*o)tJUP(SD(65Mq za&F-l+rgPWR;I0f!vqaTT+u{*1neyr@yr&;4Sm@MAKwB}U7eDPlheCf)JJbh1S?5^qVT&K-zDFLs@IsBZ^t#oO$mMfg(svfsalXIMPB~xWY4AM^EpvAS@coC>`3krwkcpjodg;!mb^o#bL)0%;8(E zz`P8W|CR*@`+no;V|-A9DCUX+%7s&)wFs&JIZ5!C<%{D7(-%pAP2brqPxhDsIL7geBHMU9JJDFCGiY$V7< z$Vvpv)dNy(z(1Pfmqs4ef^t9wY=(={WNe)0qhdLF+F5zMK3d*0(Am}?@`DpOfig*C zC#0bew~Q=qBn6Lr;VpB{4A*LcPbS}EFl;xE%scU=YYDHq?^qn{LAv{`yyEsRzGvzKL3fss^r=X%6& z)02Yp+=V43iwR zj9~)5R}kd8!ktyyU3dFkd&<1q6(~;3mOc3aY54K?e=l%lB@ei(wj3n7xAG;EO7%OBrKl-gA7-pGzX2GWz=GG_dm3s2>>$C2Iy0w!WP%Vj&gR{e_=IXcFR zb%KmZxlj4_^2C*0r|qj~7YYL#v{t8TZk2kOt|I`3b`5NST{>>{*3EY`){-9c`Wxy1U<~1A4?Y!(anP%Le)!u;F`vKo@E3 z)Md1HQxlhksXPz+S#uc#$x{I`(n?rfUJS&bM&{>ZrX1Z`Ap?-(0VRoIP}cBgcThBH z-D~LiwmhxG``;GyKzKYhfdWP-SO_9?`VsFv#epdbstBfL0U_?Ws24Qe*>+b#MwD#n zEg^NUMOb!JllT@hQoehjHcNJ>Q-uNlYpXE3R54_TYJ3qTpTPi6A^Cje%hk zcM9Jm_t3*)==ACjT|nFEtnhu>7gA-Mm+!7KzX0TGOikZKwO_wZ7F|ks%$gum0q=;q z(BX4@E=9^=O$=$9AdGNLaQMRe+c_gdt7fm>IMwZo=-Gr~!y-FU<50PSyQo$5ra{~y zC-wNrKx8^gv(dfZ=2?HHmo{oWwtlnPva>;ID_?<3@ht{Igz?am&T7`XA11bBYP4!XpT7nat79*E%(&PolW*9Bu!!Hh|A>9B0w+7 z_i85HGTpTgBOGsHepYr(hoXN6OKPFp|A+^}cfxAE--De?!0#hD-(eIZnuDA!DAeFa zvK40(1fsUK+(&3?<8d`%!2>IlUaCNaH68pnB;8%cL^m~bP(`=fDS5dAhL!K26z{1neI~EO2tbNk!l4XX|yQ3QZ zA^?I_i(-CwGGE~e=u`znq&|G)b$-9mQU}pGA28&Zp7$7sE@zc4K({XsN@9-f0CBq_DzpOA zW7^~@WF-L##cM!ruLK&1Ngvz~$-r9$AHI3}7$7PoAWq$+0wr2A;{MGpnvw>JvvyU} z6k>@(MS~wfVn*zbs-oR??5}+e14`(26uax>(&<`{;D!M5`^SfgAAl;EHw0s4_7>d8 zGQMY9Ce(wA>49WqktAY$@>6f7$@OM5IpyVAK#@haGaiAe$w9@hk=y}N(RQn$fqTnS|EO-=5cMrFDm zvyAmk0UMf=n$0Bt>8bt}zVEUJ{Plohbna^pqDAEOgVRnKbx_nG4NOnpQXPT6jNU*6Q4n$R* z-gfj8JOdW~=Lg}i_hwk7Q|5K zR-M#dhHGsq=ozOLN_5WP5-2Qid%jsC@T5M>yTHq!anMZQcbK)JqUn1cOuAz2S`d9T zik7}8Xr|J7*sj9*WLRLN(^*x1DHfF-lFfpD&$>wOiq5w@tm&wQ>Rpq1Q2Z>L zAMmS2mBVA4K0Y#|8ETGj!WCUCal6xhebNkb?{%Op#iN+mU8k4a2onc?Za?rYfvDFu zy=Ydt5sI;T_sJeAswy>|4lEg>kJ=Bt4uo@RlNRDArb$<;IdFUE>wrTja&z(k)EMz6 zGQ5Q%!GJ5KL{3s++L2e&}|?*6#)1wI|Hg;C{V$y zazk5|G!5UQB*E7fwV;RuyjwFi4p`9Y))F;DnKlW^voihBn#SA03Z)2y4MREZ0+8Y$)pDTr`E$F@UPyf4@c9TT)-GSsn_JXgnIxOjGSIN0O&QS1J1Ga-E;O0H{IFpm*s7jCS{Z`;`)iq&ZrWdER+E8~D#S_B+pSbo+REdrM|6z^z6ZZX&A2yDEQ9-SeA7B z0?n6?LtjUK{F5wpf7TLgLu5L`LN8-=GiTiMHKk}xNnC^KPL`P;CU@hLfH3^oUO0KX zwHCVCT7B_zlZ}p61IY#ZqSli0+o8u(#1Ku+xkCYk532SuDynWd04iM+@Zc66E0LJZ z0r@c~zJv6%4=4ll*nkVCgE%7O9!xrHM#3-&8=uw#4-aMU)~wyt8tMJ{LLkujhVg7i zY8FmW{4@OIaMSUW@^6bGU+P|omc3X%{|5YTcCcFb@o<`Y`$c}3t33zVK=YJ#3vN{W ziec*Gr1>{~&r2i5Yn8b#K!;41VOs?JRcW?V^v2c*tjIcZ;56Te#L|O$b6KF>bg?X)0 zoI^({jZH@uCSVUS_?)MTJ_|tvjAfsMP%Li8uBmK3 zJIHS?{K40zeih#iz4waINjtP?E7GKF>PR)+*z|C8^f&mBe5u9wA!f;2{Js~bA~4f!exqsF|hsV28Y(x zO=2rg^C<0UL(9(Z7LH%X53nnkfgLaKbC9|T!K=0M)5VNpZ#+vo7O>rAeyQO3 zyilQHBd`hf$)=>cE(__T^H_Y)x*c}yl1!ymx$oBvjQ*Dun{FQye;!Y=H7ObEM^+~G zfHP7;dIkP@sq7>)iaY4-gKh2&Ff9culOmcnprmpb-TMcg-;|~%=|#KVZ)84&k_8p_ zhK1aP+%21*+}7*y2u7lCS+V_Rj9GFNbnMB2qz5I-OeJ9fCkULV8S0YOQHLbfruhhN zx^ZfSgg^5(-MS_LG7oo`O2xf*LiCA0(XG=~dDQbog{Lvi8$|MET#sUIv$DsV4{%kf z6U%%SWrO`0p({lJ%ym8Gx)a>B7Un1LOM{N0?&C?`6)2oVbpKh((tjobN!!&bis`7- zT#HYdt6B8l$;HUgBn500>zC0~>zn$OKcC|uBR|N^=pjzG!xS<2YNS%LRPW0?_|*7M z225>MfN@WTY%#D6VN)2&g_Bk+&xd7i_kKYI7G5Y4%E+zMdz01dN1S|0(auq8fh+1O}f=!!6m z^6e{jP{bp<32sEe>{86*lG$(j*#VjO9eakC4F7f^Cya|{%b@&rX!(=q2}Lbk_3yAx zU1g*l#or8AFXGs^GU)_)B+XWi+fI*{gxK5xH-HPd@7A~>1YbogwKgj-rwP$w2uQNX zXuvM7;^AN`_@Bd2{vl8*d?}$3b255kE+|D-OYlBVGm&m=V2rvGG3G=&rdm22o3!Il zKA^~wfAQ=wa2GhHG>Q0wp4{w84lR~O^e1-5bB!#G;sfEUiY|v^ESGHEa@%fqkA)=? zP~>JrSvGP*2(r6s2oLgdp9tnu_6}b7j;JOMw(d3uZ8LP+WnY0_a7=1PJolIzBd{aT zR+}0icI1k^goT=(z?Te}EL7wTWK1(^(_)@I4DQWH*j)VoEWJyM707Pys;cBe#&Fxv z5ah_QRy$`&T3lDLO3Epnf!jaB#GXr}F8BqU{^YY-5eYe6ir|BH)zG zo8JlL`g?9$KM(DIWeg@T;ZobV2Zs%r9&hkIIz3rnmqo~G8xd`cs~5cNUi)|)O=Bo< zoa9HO1?yM$b|UqlLSX%KO+QX_c3;gP1U<(xt#nvSJoPAer?{fVfP8N06a`}^r{%BG zbhCbk6+`Vd!?PZ}v|EHWl?E3)Y!s=4opd?0Q!8tlKA2XPHlv>qI=ta#S3G;kJi;$s zIfW?*3Q>m51HPic#~wHMyX!f1Bz*Y-u07Uqsc3pMV38NT;(8doS98`h`41UWE#Lf_ zl>9}}mSlaua`J56{exLvTJ~-x zX{Z-w0T0sWLKPLp_GAhZ$!@&%KLQ1A^LC=1PG-O4XxZv5fAbBqFeJi)6BaK!q?$W2S(67eO} z-M?7d=36aW(gE&4^+c8uN_U_Ah-W%M)p_ z9|!sUvL7ldwvcY^a$CyAtzSe7gY@<&n|sETczz!V19|{;>P+?9)6Fdw$fi;Z^h@$@3>$4%;z1> zxqsJ7v%g49S$(xhPw^GW-iL0SX;81c>)YE^B>cqc%0{3raFG0LBsr4uy}RC|;lz{Z zxg4^WzAteT$~{;l6t|S~5vrcfHG9>=$7Vl`7EOm1C0%yU{-iy@os%_WT6zi}D0^OP z`!Jrx(bKg5yr}FrTwj_x2nmuCR>Yfivn^>V}8g72@`O0bnq(HHGAu$dJF01aX+)sx^De)An7C= zn7B2OBbDksU_tU6Z`JDMC)u;VN+OAiMqiTt4GsUoR;9f|c!qlhTbOTFUxeMC(3@~AwFqM>bX9kv zt%~U7Dq~Ro9AMDjr>3k8dDQ-omJH&s%0!I!O(Mn} z{{DO=Ct+i<-}!wl4mzDvjz`L55|lm$>R@C_TcwJ`k-0O%C0oiyMkOgEJ16_)HDs(D~lP zuP$v{?R@%SVBSlFQ{>H}TZMf9IoI)BNTwVUg?W;zNICUD z&%kDaQ3_5Fg6!MO-~7n4#|FcmG|hSM#~QQTZMfc-+@0TIMbzQ@D3>!1ZAR}eHvi1C z*$dHj@ZMM4G>%Z=TGV#Rzy2QayiS5+#RARF!*~yUOkyS2+S<=NW1OquYv9Z9G?v=h z3#%RWnXf`a^{UIT*oEJPlrHv!OUdK$mpbc%Z+97L>lf2va=5N{rUuaMB_Bgn7~BeP zv2sS+Xz%Ev>CD7a*59uWdDn)r^xxU#TJrHExc9rQLbIkF0gKaAHw~S)eAM8n+$pbh z@0DNDB40m*ODE4BuGDB73-|OGDe#^prFhJjU2l5TS6zW~yGZ{xjzZ&wYlZ8xA6JL> z*4}-+`=L&~RijhOnVw=|09g*_7~AMq@;rHotH?l3`@8$lhJI$-!0S$qA7FhofC|M_ z?8WADnW$5p$J{i&S}&8dFR3iY|6906d+dGEC&cK5PlY^S@;(9MF3^s90_(LFVQgpS zB3TDQoerwc*cG4nN?%wVo%XON==@P$wtMv2U6pkxX^~0~ z-rR`to}Id0u}WTqTzc=F)v!c*A%D3tc*GHpIRkc%ypK1MGV)mZA|su-T?_4d{VUgR zL*DuW@>ZUq!ox*W)ypbFuUlbKU68d=s|ZXwX-B&)ey`-!yOn1k0-S`&_|T&=)7 zD(M^I{jf9KYfIEq*X1`Czs;V*Zk#N>|7XLW%D=}VPu}}NPw;KO`>IBIdhESI2DF5% z0sBlTihv*ep`kcHsV+c#8C7gFwV%<9*J0Mju+?J|Nm~J21p@f-Cw$D*MCM`o7-r|7 zkU?&vrW3NclI}-ZSuR0$3KBLx&Ziq92Ebq1sVnZydT^+KEo46mJXX5bIP)?ylCv-i zqG^D-E!s~wgx(iFQ|m`KUe-V5g8f%=`))xO<)2{m??1f^yCt@&_mrj%&PMo4%%y6x zF2aD(NW8oMed}{M`R?X*>-`T_{>X3qYYZPbMi02jz~5_=a4nHOU`*bvw8XqvU3$y3&=HTH)kFK#5uG$bk2<_NG25ZFDn z#ryLG-!{6!U=Q!a%tcL?ly z|Ej;anrW@fS6nNkx?kMbNJ^nOu4UtY4tQ{t3p{j8c6XAmwnP(58J&!&0~O`TgCuD= z^M~GlHvUx8{`kl`-`DV*Dlfi5C`;S%R9{Dti5!!CmK~CtMWAL3f2}ROdt$w-U#Uzx zG`tw3UHGpgQ^-*lWl4~B$fwQRMj}_K{arw&`(MZC6=>j`wm&cSH+adAS`)=xx6UiS z8d~wk*xOm%-p5nvqP0gf77`^z)dp5=zQv2&m=0X2)8ZbBWpjTEtd!mQu>mM(*n!HJhOPnt? z4c)msH2hWPI{88#?b3%upw2j+$*gwQFj5`q$Fh!hYU}T z7e%$CVWg`Ss%SmXh7$55!5R>s4)uO`l*9Ep>Z&QtcvH`ZYUuKLH!g%zuaG9=Gz6eYY9cVURa< z1y#XX8cp;>OpRd?6_}=z+_-{$l4eHKBJ=38LWTp&w4qz%vOGxMaOX1wHrzU|-;Q0? zXIr}aa(#(rM~!cezfyh3ChGof-5A?{{9pd-PAn{*y|~^$?^+PA=mCpZs_G4ua|zv` zOVloW`$#Kh?-dryU7la!ce4=uChA9fW05F zuMcOp@pVkHN1KIZ+_+rw$HICw%j!k+w3|gO{TKfuiU;wheSg>WPmj%B^x%nwAt0c3 z867#z&5Ksm40UA^tR@DL?unl@!p2{Lil#lvxHCRQ{UXe`ap|rpKS*&zl=hyky-yL-NF3sH9CTug%}h}Ko~~+C z5*Wa(*0ldjxW+`aoLb#O z5^^oWk)?WjIQ$bz0`sfJnl`82M{lh5U+ep*??vL0O7G`$(v_tzAp` zvwRxE$!pIA2@gfS#n-mR77uQJ9rpaEup^Krzd`rS+6^A-mI!gX1?uCa?sT;>+^EE&d{IW|=FCcB%}-}(iUcCF~#BK#1@V?Xc$FSVy>qrmHsDu#vXHF zX8VKM(HYgt`F||ZgIoBY(r&*+(_bcP!{RAi7p@stm}22>B2b9w=cnv9XNtX7zCJ0# zElLcOE{?`kRHR@AoPVG3+WdyDVSRPuIemov@bi4^_8Wp&w_YiG9cx=sKUED)O?0jg zQ5G*n-R{!>%yKY4LUD+VSK$xa%_}qUA5Wlcp`%2@qG=b7M|4N((urv5P-&BePn-B) zMdAi?*AsHCxQoT1Twy-KrpU0;XAR6}dpQ?u-^c5ZW(OD|{*gHgzxGSZAoe1S@HR~b zl9K7+3l196QhvBbk91~A=Hlf6@?&*BMUIkf5(~^tDv*fr87=@A%m#KaxVPLN`+Q~q zNAwrn@fqeOkCDX}DILUxPUedXvjXyig)JLnv&UBLHRM0n3KVyFwUqCEN#&Y^&vWHh z_VHz1wx?jf1OZk#=3tyEMd%ZTOriEmA%~yL*=>n3XO(?gY)(zP3A@>+CnpSHylf$A zW@xN@1@XSc??{$P36tBX9$y^|zPLc@sQ9y`&YQH*fdkEtJ%7-xXr@rgURWU1%C>L_#9VgtL~?$FSGDAG=m<@6tzRZ!)H^}RHWb&k})p7fm6 z@~>>H44<`(s39H2>7~|8lx%uli+^QV4Ma-ENdUU76OfIFS($w}5XBdBTrM6%>$mjc2J2;Rbn_E5WLWz9@P9YT1arY2g*Z#cfo_+} zAj`W&b}t&~Q&l4TiTB=mt<@42JDC$E%6#ZA?MNGUYbn$}xX^(VV7L_qW%;R|t^{l+ zdjr*ij1P!13U{E18vBRS-a^31)1x1yB`nC@T~H5Yyl9QevIpm1ykkreyEsrqpHUnW+3);O9K}d#X1W>HP9P zCne52ptI4<*Vh-TRB!A3dzXSAoyuC+=;SI0j-R$O{sM%M-(Rs_9b&`#w(#lR@oAa9 z;b9q-AmkRR)uuBOUtX{YZ~zMlZB%IZj}{NN;(8YVSYK!x8!^=wxqZ)F}s_;;sqGRomVB zUc>G*|4YKmb13XBO{sR2P+PK0Y05esL6mx^2|6%$f?vS{!Sq_CslB}3z3nXmLPuU<=uX)T=@yTr{a5sc7)Rnea5L(?%_sa7BG`h5n2I@I6GoJxDY z>&)ddM1+^j%{dtcklk^GUYly&17{%59ENk6ofHeKEZIEc7d2a)Hi#4!d(q*1WV4Ra~n)_G5@~3db*))QEykDvql(o1P>Ze zT;4W7K4rbPs1{%NPJniKAQyCRzn}}V)H?|`4Nh&&Qu-c}l9y{)W>@G8rjVC7)N5gk zUlhD&O*<2=pRxB@V#NONl&QRZd1_J=!wEv4+D?>sn|Ly!v*d)om0Y==Zk)#lU3m);RW7+1rmwCcs4#d4!eSxz&Gi3E1jQj`yqyW6seC>#%p}+VswYaxHLr1Bbe>J~7xDumAjwaf( zOXf0B{yM?$3N@5^401aR+>=VHr3&Gtcjv0KeWJN1u5L1!{_#_-{HNDbDaJy1i<{WK zCC2kzigF$D$4KUv9^3!&Deab-(Rsy2@{gu&w;77#o%dMR-W=Sw($$RA6l_I_xbfP7z3&3(c_pHn;wDxnJ11 zydv>uejQCN@zj_3zFyzz@8wV1uwx7+!Cunk{BRLXE|EfUlqV%!|6XK1{rwj9h~MyQ zT4dx(k3P0EB{jw=QNatq4I|~aNvJ8|NoNtb}Gp{|{6#3y#*Jjf`Idy8dTyHld6E`EAU*9F#M)vm0 z{*rOf&;gI>-4w{u?-u}$`5Oz#xofm2YGSeBvfcHgp2V7Z-dv7Kv%?fiIlSzz)1%({ zY`sv!C`J=}n`|8hLZ1?QWevU2C}tlki&WDqXc?oQd3U%toX85mjfPfaCErON81v}gF`uF(&fdi{oaN)@mJxbR@|pMQOHY6B6v}9n zWg>^A;}US(RZBO#Vwow^b{Yog+XC?RdGzhte6JvC5)<|A3W20#4CJp`F6=8R=^iDV zp**~V>)2ys4T?J?#z{_$*h~KGbw$_=+w@G6?6C>X*~hp>>u z+J)Q_20xY^%m@PN+#$y2X|-tch0wt9?g)>JeY(cBX{R)<5FXC2{EmN~g>e!XH^oh^ zo}#k*(G_8k>#!YjCMx%2@~dztTmR9#G6TeE==ph#vvD28=Gil%&Y$N~{nkt^&j3x4 zdv>oW?x_(Ih1H6_tG_L98Af;odCE2x(tOvR{GLU*3T6Un1Tu5Ou>_OLY0gb%!|r4E zXdNFfErjrgStRU|Doc+Lo$8;oVzmq63^rzQXTOpTU~M8B!E4S&Y>){x=^ zp9xdAVLa4KSz5B%TeR(cU<;pv@6dq0?cgx(`Ie6RrCTP?pB=f3FJnc1{ou<<9vXci z+7v~{{OE`qAWPw(j>AefNd+`k1c2hwKQeCY5K2$DC1|1L&}W!Fd-o5J8ZPBZHhIRI z8wTcM87rNfg44dZ6`!_t9`hEkVnO3?$K>9k&?!){_o1+aPwmh_r0ze2@d^NFsfyl?a<+da*BmnWU(`7?G7?`}5AZQwqY>ug!vb5L~)781FA zPkvjlu23LVa@qqrs|q_AwGG-t$81SYbaBeQVR|fg`i3S>i#1yfV@$EQX&Aw+e;_`` z^?T)d&L@eoTQt=L2ITk^*wxh;yA(>19D$@W1t6XK<=51*=VS9y)DxoR;=UKTpSDT4 z6ZBus>$_Q?T}qbBemFwD1T$reBlxooxe)|=vI-wE{IxBO+`c4GTG*HJddM(`vsZt& zCRsi2eQlIk*763zt;?d;H#Yksz1e1XUC>73XWBbZSvo2f-93piL%M(+P_*{vyu8K? z={lMsl>rcp!3U*CrRXsnX*3fdS;+%Ut3R{ja;U4t@+9TV}z;DzNV%r!>r4N;k47^`=;$^ zE&c;!`c(3lo8S?LGy1n7-HkI1qbd8#AC}*KAusi5t@>GQ97m$}D&NthwPCr19&4|2 zC54d|h2ia_`55_XqX>HWZdRHFAC7q=wSyG0$siiF7~i>;;juwH+W-sAbRs42`ixmQ zV{xZ^P6#{5>t3`T+K6W9XP|XYuX-K78M>_W3jPX(wcP$?`aSzTJsEZi! z+GrQ8z|Ax$wpd2d2xea1B2;5_nZBes*TTY|wa`cazf6#=ANU&AFa{*=MGN1_8_pLTLTJv>( z4P!F=l6-S62o315cR_ztGiE2DZ+*cOOUL)>ZKI(;?iOt8q0%3=eO3ocsW&_vqY`M` zkgrd0l=n^T^%&jR$TC=48W16ueBa2jYvg$(R!&&$tC_7TVV*yuwwE4{i*Bukri)lP zCQ7bVZDzURH3*=$A>s@}s;boD$jc>tB}2E`5> z?M%eoOxDkOb!ivGyUfMVf;8bj1+w_+rFV;9LWHp9h06mkRe}%`#zdLh&jF7MPjo!; zAsH;k%kgR$CKytuXlW8|kv2QhnPyzyd3z~Xsv3v8m&jS05&|t*`n;Crc#Ci{w@$Ph z)@OWb1FOCpCWyQTtG)+e9c>}$NdVG;0LFcB3TjQyQ-GgzXE#e5KFlC)rRV2fa?2Xx z^2kO9nu3llSdMIP775@9@6vY3^g}e-1v(IZ3ggC}-)4C%@3dtXuCGk7!lN2nXP%{s z*z2fw?fQMU&#Iwsk*h!>QsM;k=Ots-D0Ui#^3FrvdcdsZA6G)o27{1p|G(xP{}B=c zv?i23%U!M}MK$BGDzh`ul+3`(8s$QvH^)&N3ca+W4heuN`4k8!koR5MvpHpxvcLJQ z!H>RLYejNiYnT0a1y}Ew&#MBmKmjA`@Vr)iO^)5Eiv@xGs zrAE0On-_mnl{K!6NAePr@+!+-;oh%aih5_=+~`NQKI*`n`t26>!vnxpcU!lsq(rQ^ zyOtIj%P*X??!v`;uPl*s=Dp5^$_xqh(el*$ncuzc2&3Xm&OL(Uf*a7vySpZd(!@0 zmmOqJgHstVAA8N9O6nnL%Too^)C~s}hBiJzGIOSm1G#_1h576*5>XB1287*+n(_-r zJVV>wqV)3%uFA+)bpQLhD~)68B;75TeUUboFhI^;<$zYcILzNH{P-b8U!DL8LIDbp zPwY^s8t=uoAHDwo_=0*0AO>WSzfx_9nl02XV)LS-X->6BCxF~gS2it<`CGdXO-+!5 zKFB<@OBEeV1O{6IkC7X;F$hX*Hp4%ea&6hJqH6<;A}lm3Hvlo3bIeTtMe^%tSXC{? z0$^__>ld~O?Fk+r&s(RYl|%Ph33OGeO0gh15a@VY1upc3dFM=@%-d#Sk!Xo7?pZmVPn2V<z*YED%jVCzdJ)fCC&G-$|NpXUFuujAR)!_+u|Bc0sE7CZK1VM~1I# z`=|1BE%Vay=;|%yCH)O_uwW^AI7dMW#q_v?JnTGEh=<8n4R&r0$&^^&(E2wte#H|| z47$`LP{Uc7SzDF+GQCvfwDKNeP{&h4Avqp)I9@7g+^BPJLCT*|c?a>eHCGjn^sWgz zVUQyZMjP9zZ=k$vBM}EV|9YP4E|OBFD`$D1sZDILsBFbP$TE=mccoU4BqRim%0j1! z9N7i@0S_<~D)Nv(TD*nLVqmkZd;Dp9D&lA8GM*-q8taabRxf+a*T!~MVe533t%naK z-&^WnSUlwwJ|+a>lVfFVaI0F{SGGpA(-^3}F|^?Xb#xUe7T=@b4|Mhaa#B4Bw34Z~ z5%bQ8j*Qshe*=diX1tuaUo^WKr{oO{HTo6=}A=^$oz06FZxJZ8^X)_YCQF+-RkTm64YF?p`4Rv9X@3EIF%;9G}EQ**|RuW`8^m@`0?= zAb4vYZunptN0nC%HTLw_wb)kK)!Zah?B4#fZ$>M1*sIvwP?Cq@`F}4+h zISUGZqGBHbYnSRdg1ms-!sJ->(0b|V|isq zCW8%Rj|vw+PU(>A(50z~a>MjFG*_SjGJiihV_3h5e~`6MDmHQ^Q4H_KJB}7<7c5-4H$Y z!K5{!2)7z>U@zrxM9m9xOtDgzF5WmWUkFPXztzp3t08lGXTQ&B%NX`##%F-_6gU&|hSX!BSg5-oc!e<7Ts7pE)&5-SH>qayc>} z`AEf!rHPfYgA#azw^o01JnwmSzrtN`2VqlaPqXX2I%aL9&;=m6Zw;z&S8JnwdgM^D z$!uArhp4Q|#)9z<)wLuV(qMk_5l`_H>J`XB7~Nz<%LS0uY>wpQ-xHIfaCD^t(!wTK!n#}#Bkiwl zzc~!M>fB9quQ)Oh_{gW0o`D6rA2XlDWvRbg2NOsQi?fJ|uEB1voD^mH{KcK1nATv< zO5J6OOEnr@IJd88Qxi&?33gn_K`24BlmPA#p9qJLft%s?$n&2m!+N+58i7VG!6a+Wi9D&1vVKCkOT^xcPN`k?Jr&eIuLsZGl7 z*L=rc5bO{WJ zjuR`x77N#PRD2sC+b*8pSLEz1gGhkye1CTe>{mm^ie=DQ*}Ir7@DRf!Q6$tL_ITf1q zjuId$r*HQHs0D62Mbub_gkq>ldrC{^g|fIWcYbJ>p*&i#nvE+w%V|m&Gzx-V=hjeg zamB~|5tQCW`T=mwhZ*UOV!IdZpb0Eed?AOUQ1AWl5sfPkpEIo8_!_CbTVoS%Q~$vC z*}AH?VtfR?b3-rtyt)`vQT4RN%?N#1AzLU9u+{}J_}+j6EZPD}%?&NEHfCtIT4xy) zo7;Cfg11u5b&wNre^>fqzTj#;w28Ws*sELz%R;tOBKN<3TO0<~^9-&y`O1Lx2w5ug zUevso=}0UAh~LcnRyGi*Nm!6jEQ8^X$`x^3yoE!b|c5H!kcBip)otldNmDzKANi#+ZsZ}d&mY{*9V>ae6AM_UsicRs zqWy^;M3%mW2~=8_ZtYMoMJKZW?DQ$8igp-owM7E{ZxwC(?X7)|qe|er*cCC|b9`f7 zhg4e1qx5kyceRm6r@T~@Tlj+m zi&^?Km(Cw3P#ZS;(zli3njnu99{(sW(a(9eO|AE$eyD65imFeyIVe|E{HvZ{oNt-r= zOHn~2S1vV~x4h==1+e|W0?zN-3ffC{H}HMWXjpC69&1?l+}UTgq_KrAXgxQiCN1y*?7U*K)$?^RG~6PV^@?oE>)y!w#s1yuy7B$3HpL0&lsHrGAhmiHCp_5$p4h?(OE|v%87?5clw(2mLA@T~2G^b&)(i{Ui|Mlv+?Kbm z{y(gFb?bb=)3f|6x{7=eGM)qP7AMky@peZOKy*bG;Kk1G{yRtnG#VJ}B9JuF=iv4L z0JG!PrucMAHdsy$9)ne58M$*bLS=rG&CqPdVSA@}<9a__%T&_HCG`2=9lT{w5lN#W z+WxnbDs6**7$@x|tjB6>G)>nJpuO-2O=$`bXLt*oPaN*jwst*L$o0_JYK8g2G)3dWyvFXnm) zY&Sb7>Z1N3LLY<4HT2CB|9P{&`N1D6`+q0k&0R04{>lM!?}L+W$`)65bjupP%GXOT zW#tumo7$!7Dimjml|k^(8-PfG5K{KLmL=$p(t4ZUSV=pbz*0G1nx>UKH=n;Sby~Fi zfRJFUID5mpJUmm{IroyIFLU+Xg}0W_qXD69Ya~B(Bs3x&k9)gNpIXt7K|7Dr$T^tT z8}nF7BR8Y|7vauc#nq+k=^qWM9}dS9rsZ7949EI^e0X=spe{qP+gq^iEOL7%SlhDz zL+ZT8n3<4H?%}N7SgXtMdiA8t(+R8HWId^6Q=zNsHGLg=ab5MeyJ%?3A@CtqfXxuj z>?yB1gP!=`X4?K*F`7-|j$>MSUdBlG10h-Uy1xDT@m=+Fv=B9@+C9LA`~$TU1x9Gr zX}>5S!kIjJwuAC8*FDzVFk+Aonb6yLvu|it6-e}nG9b|h>>o|rG5a#XX&*`aOCM_a zWCqP(ur}`ro5#}xQ=7hU&tImM;LGI^DT`AlZT+EVTedI>`x%X7l6+_m-6x*>emi0X zEEVz`9704@22C?<6N1D*tvVFQNsgOE~%QI zb}ihd09Pg%K3uCdq>jTq#YrkxdDqX)BU6H3$P?W2az3J+gA$b@YJ!h%k*H4>SH#LJ zxK98zd=uR0MGaQfA|C#X zudO%>Q?9C|s52tdb`|@)4kDE|K}Fv{!$R!6OH3qxRFXvbQC=pfd;GB5ZgIP@e**ndLczK0#|AMFI z7*@nY5*A~E(*F?eydQK73VGVYtrxaAF00k|PLe`Knti3E6KK72S@at za#2%LtaFx)45FT8c_pUpQm(rnf-b@f9pGIGdwLLb-=AeY7Yp}p>eg+vhb>w=!aZ%Z z-92d57Uvv94ZmRf`=01|($a5U?Jj>OFq?P}$pR#bgv>7_cC?^WrbI2k8DgrZIw3kN zvq{2!GepGKd`5Am zxI4*7yoO>9U@`?iw0m06)5kP!P}sAux>q(=*UdF%&S$%&gRyUqu8O)&y_eu)F?TOTB9|XnjGDq-*kRqgdPvZh8M%`!xo~eipe?;ni-L-mtY;I;a0bWP~6+xd6JSn z&z;!6cu!P2-ooaQQ8=c5JSY3ErE(pu2faw*#jQOCyQs+EC@>C@W<@IHz}I~4P985) z18pR?5a(>#blsD`yz_b@H?w>0!KkUy1kvvUhE2^`dUs#u=>W0}D!gUjClCiO(iY=* zPUh{j#ghy`Xi&kPkV6+VWXbHxgZ1MI828 zTzyhaFg;F%EmyWr6u_a?;{8*;`>gZ^M%f;AJ#x2#o_p^C9v2_=)*hfe#d^;oNFf|L z5{$CMipvy=Sz6+M+OXpmSIn~)LNwFHRT=Q91>yH8XI%{=QVK2@P{s*YXmITF|J*~i z68FlRCu_N+TY4v}uT1S>e&8Sl(9NqB(dXY@KTXHVtG*%itJR;j!2|=sq{?95?vZJ8!31ro;9wX>5OVAmX~WGo|oC zLZC&H@?8ex3+M_GaenJX@pOY~#UqbTy~&rTv*9<7t!Ck)&`|a56-MiD$szn3R&$$)t zdMTmrw!w3ACc5g*d7TGMjL2tbQbtUUZE?Bt>)%QwMFTv!x9Q^cs>2sEu8Ed!i*UgD zp3}ZPug9WiHtQtBJ)SqiZ*%sm#(udl51~#`#J2?9^1lbs1@IZd0&Wu@%mC)=k!>n@ zR)hU|nzB#z+i6QlZpKdC()-VAq|-~8d2Y^@R5dTX%r<90+EHcUQ1@x!ee2@747oI; zR7!Mn8@?fH^_E*@oWB-)_Rm7-{;Qg{4&B&}db&b48*aqY8~dOPoG&bU6(9?ze9%~7xo!9&u997i);aH)|}U(AGDa(Od) z<7_V5&u7%n5$>L+lxW~p=d!szB>y5$;+w4}FoHJBh%O4N9`6~(;ihj%#neXWJX(pn z!bdeR&^7ITbn23N%?JR%o>rOMug3|yOzA6+!S|m9A{_bfBngOA^8X9}zL~D<%;h?n zkW_g=C{xGM-A;|#9Arv&-|{f|y7LH<1 z%bx`__kc7$7_f+CRp9Mp{3+#L;WRaGfGcF_V?dHnOL-3$B|_JCjstSr%}7~I?=+!vnr(ZQS^ z-ju?CRI;H{Utr2eo z)IkQP!_t>&Ys7{iI8@2BAR&IcYnWajwe~0Vq0G(2!=kIP9#8V`TRbn_$lV_^dY0p5 z$J|1xQOc-K`O%xMTEi?+aj2Y3pv4mT0C)bN|8&OdV`zrz(zbhjE|LV zMKGCN*3yq7|MlWs1Uz1r<$enJ3%l;61%+n3c7rM#ohB7{IP*}Yxz+s+_wun_PhC%W z{(X913NM0qqh2fZ;@Y1{Jr(iqpDi$F7lAQdqS~uU8q4@*N9#DmXAXF@oVkX{^a>9B z$cUg_en{nQ_3`phj!Jqh2C3JkTcrm~74jD-M^jQiIftE@alD5h=+-YibfeG<6p@PA zVyJ$bwBr!*l_H|;un|(8b(%+H*hrVXH|a5LC1scLkNY8-Y>4l9bUEl{5j2}Hes+hb z#-XnWZxs*P4tpaj*{hfF?{L2aVat4ia7R%3^&5~A^gJO*#93Jnxce^g!eJrgw=g0G zO!->>Ja5rOh5Ps%bCC(sJ!lE%72JkYwxUbZy^^j>4B@Ek3Z^qPepj`UP8tvvze4!N zc^-5II6XC`g%TBf>PJ#fOo&s}JkO?cjD~AKotVZ__)E&C^gVop_=mgMJ{!2z>|YEQrF@ zEl5|XyfTyeTf9j0oUgzsTK(LR_rH7auD?W9;yrftCVeLKil3aes@Ruu96_Ey#R!3x zXbLTN7lY@!0cu#qo1EX!SU{64{Uv9{t$m(+azzfu+LQE|IFj>e{#W&91i6aZfx?4n zZ+2DoA)y3f(goRVo4RqowW`-MWglDU$RLOq>cTz%792)H&wFxPh|6XY8bL>Md# z_KAC@)uq;bQO#Dc^!H{ILsrsvE&eNXzD%`VPC_pO@&mf(5abw0JLHo@n9&QpF8JD^ z86tm|8oo{UT9e4-r%N&!R;6j{s{O49Vxoomn}evAx6MDb!nol!f*AdG1H9N(EwusEL)OkcwRY|lIy8D8Aq)V_J56@ z8}t81rMDWs3t$7H76mPBI`E&Lx zuFA~k9CBimGY?Dy-)esmB)pp*ION5#D0>BV^khq86m6Mi?yd-)7WCxrU%Z@cRrm3? zuh((M7VkuqV{c8kRuB#B z!6ivlKFIz_IjdY(4g1W@44$#xdS)u9wk5XnQh-J|s}Y?4c0bvy!&Qr;`7S9LR-O+V z16}n&{Aln*ju3bWLqdwnPdI;kydE$)Wj))R9RsUIi>C;3vo0SUAT;nczY!e z8NTu1(Ds>>j$d~!9J^XW7JOfOo~5lXeAq7Zbx3hHv`XaHKOW7Tin3`gFqKcJK0c%@M9g}gZD$iMa{vs20K`4JKiBi)6C=vB9n zcYQp^_{c*L?G^?@YI3-$)R<=TXSsjxecdHIvbSP>Rx@y5x@+zg$l?vO-5|*4pd=2X zqtBRIUN?%!y?zNZbf5-}bR3S~>7##0zR`mNv`77+(E#~0({$w3A29oAH1Hv36XhS@ zvgWO_SGgSR$g2VchGdDXFhc6=opX^;;Y`A}f%zOngTYo9vpE$&O$pwN_PV&nVk&!^ zSxKbe=bdvOW}i>cfdq}&>!xZHL=hsT@CRCefXc6iUDKnJgVS-CIagiKUW zK_NVcy+1s0y12$FJsuT!mOtz3(5~s!6O68bA0Kqrz+hP?&@kxBbmDc=q4h`SpGC5a z`0d-Bnd2(SnIXb7t>eVTgdn#MZ#@*@w(FNN%WuCPUi_yF)smx(aL~Tms0|?xFYNxM zVYNAXA-VZkLhx<=BD|5%{arxn(KtM~ zRY-)hk#IC$Q+egTY!+tp<6^iW6Rj=##d|9k(&~P7rfIZdRQ?Z4*dm4Q9_c?P^hRJ^ ztAowmb;5+PoKb#(VMt1b&@)-o&?!==jffMcRqWM4jIShqCbALwb&!k&u?%1 zp2@s(MBLGH&m2!&+L=JU$+w&JE0Zw7vn`i_q(`%i32$4etH7Radpu+95>ax26yXfj|mv@(KxNsn4t<1}Bg+qV`Vxqz#>OBzO-O1C98h*Kr=)WVjjvq!q5RN*-Vz7q4 xe?IKnTJP5XTt$)5-#=S*{Qvy>|KrD-@+Ny*d~LciFQQ|qT)TBOL($~v{{bX0Y%2f& literal 0 HcmV?d00001 diff --git a/assets/logo/PyBOP_logo_mark_circle.svg b/assets/logo/PyBOP_logo_mark_circle.svg new file mode 100644 index 000000000..1f4ba833d --- /dev/null +++ b/assets/logo/PyBOP_logo_mark_circle.svg @@ -0,0 +1 @@ + diff --git a/assets/logo/PyBOP_logo_mark_mono.png b/assets/logo/PyBOP_logo_mark_mono.png new file mode 100644 index 0000000000000000000000000000000000000000..bec070d015660d1cf24a97aa1172077a09fe6b53 GIT binary patch literal 10278 zcmeHNSzHs@(htiBD8>+6P#T5qpyGloE@@YmXh%@wA}%w78b(yyLr2A37$Qj&TN_b9 z!DSQ|R79>i#$`mJ(r8p%M$kcrNE1*L6!-nB({Uc}ef!?-Lw@`@T~((}^*R5lI*B1V zifY@sOKXux)ONH|5hD`W8AT#Tm2)c?Su~|*BK&btD}U68L<`QKkKMgT$2*Bc-lEZp z(AcH9&mUe`FynsCBg>x-Yg@&pxDVN$?r@-2D~5{7JJ=jT$BH)J@-_QrY_`vSWNAoiLP-t<~Thp=+NxK$y?bfZ0Nbx#( z)&-F$aU?Htj{Gm5#z7#~Ge7I>CtbHlzi;Zdjk^oJeXM(Rpxe#WZoD-Q6U7_;A3m#I zy?=kYp-ppBx8Yl3z8arj^EtjTKkM(EU+{xZ>tn7?gUPk8?`*rd>+3nb&6kn4-xI-+;I#Mtn$) zyl`;{DO&z^M5Nz{Z82Y``PKamf9|~cHuVx3f3+|_>)Vuw5B=cJxq2rc(_!zo#a#X7 zm;bpe=IWZmZ^B24Q-VJq&1wl>wjEzHDdIz6R?A-xzUetTb=J2DJ4@dl1I_4&4;sI^ zjeWm`vJSiRcU>D_1%$Y=&$#BQuP+a-iQ`2FpZBY)ELznQMppM}YvoAXDWvVUn5pT{ z&$hHbbz#KI0WkB9I5D;QeanYC?eeqeYDcX7lV?Mt?)F0_r z*Ywp=`|ykDUH+}b{8ZejWW^s{nUxwjY6DK`Tg;> zYim~SBrf#!x-JaIbk`BnhOhb9SU2L)!x4hNXL5;pJr)wRP)zwZHFf)=@3?<8u4Zxx zTD0sOiRE-j67cc!2(J?G%eY~fQQx1s;YOx-6n16Cj1)|iWG_H?7cy5Oe*`j|P3DjD zE9602Y<9jZd#6t5^$8;>Ui2|wg`AFO;-YxbjpGgKgsYfwCufJvx@F}6aFp06?&{_ z)EzU5wpQDxYJ9oB}iZ&_z(yX z7O`&-?v#Yt;4Iv!cJODk%JQBH@fG^LFvTrB_PY6n-~-`KHr(+;aZ>&!Asfm7jtA&Q z%5TmVAns1e?;R2ZiX~)X^Ibvhz8*V~^i(irR*0XHU(ggT;}qgkjgQgWcIjupj@-G7 z1f&bx$?VGO=w(W`x{;>~cL)}a5cG?~3xuE+<#Ftf$-)!|!+CE1SwY~gFP%{FP_Xd8 zjr{Fti!eo*gv6h;2vd|v$x-*73j#L?I%9vM5Xsk}yhmuI;CjP)?$+T#VLMdxWB2xW z*bVKMcvrkECtZ%-ap`0Qv$t&$3Ys>_fKeV+VU9pOcGOiPjDo=SbYVY)#o=XLO~QWg zabl+*wFs_nTF)K7q!wo5BKgxZIKhYLb==F_n}ktS1W$Xu75uHn=?|`}(93e%DluZ| z!;uU2bMgO+Bjc-)d<``J360w)mh^62ZXk?yToHy%3R#b=(FYvk*x6uiTs7 z03p2cOzw^EcetL*>s>7fG^BD~{Q!bMgMsrRzQZltNq1WyGeY>Mt(T!iI*!8WPugLE zH+!YVH(^4UQk@kGaw-HDyrt~fzOMya`Urk%>ovj}g1D~6_O|DnGuTKWijKusrN;ik zn@p)BPrea}@c2O@OJ-my&@P0>GS=o|)zTO&HiDls5D{{Y- z)r7+wSiytyI_{y0ag@hyUeS(SaT#+_XItB~b~FA0V-M0cifH8-E{Cw& z-e?W=@xy0ftofKt;I~gE_WT*afMJiAdi&HL2|rCAhTT8B9tjt;*2s%1XS2bSeQXD$XCh$U zgWW${xT!kD4a_BaYyp_qK2m@W02WXHi{J!C6rkq_wHo-(*p-O_T5n<<`ZCq8WFMvn zQuasc)cZ1_V`+}?`+GGcrCr-re%YQ(cUxCt*TP2fBj?VdC$VGq-cRg z9&-zlnXaYrVOjI3uNW}-4U2NdO%0z6>F?hAWx(f*iM?8AX8(j_GE_%w`E$FFSAG^# zT@DQsQcn+nvq=QKgLT9PY=8hgbwpb)LBQJqV4h6?2Z1+1f;nXl6C9)C@W3b)z zB+US~C&}rQe+g+iqr}36j@p5ys~|wQ%Q7S!(hr1tUPXy3C78s>$tMIoN+YL>I1LX= zU~1I7>PNxE34pTu0=)YeU`{>C|IaJ{t=tTSc=9z1XE3vk!0lQS>z+gW?oQTp(Gfj@ zse3+j$yyUD&L$eABzN7SbTYHg^%!5P-sfQDrUR-`?{f!az%KFXeT0>JrN=HokO2+X zV;P_^%gnBFBRw{n*c*`feCdEzIwCKSVnX@dcJ`TtM)2AjsXoa?-faVPdM4iZPGmRo!%iLjYyj?1R ziGfQRXhU1LBv>zubx!4yfO|p#8Ms^POl*jm&4}O+mRpqGRwXVW-_mkv2AQc;Fnuw` zaXKUG#ZHH1fP3X3{OQXUB{&u2M(&4)GZNs?!#KoWB|z9g~HC78=!`s!?qk*OH%7sUchOl1a8VZ-psemcUCP3&pQ zHo{R-nAwV*T;5HKGRUfwGh7}_g^-T(Dzl+o+{s$C3`&F7;rvrto(YuwQAU4)DIkx2 zaW~F~DZr;BeKjqnq~KGwq6q?0o&EQ{8PepdeUhPTu@XsQ#g1BNBECm+G$ zw#*-n-H+sZ=EGD^3oz!HFKqyeR|~o!=mK!vnXlT;Y$Q}Jv2+7)Cf+F<*2SLyqEz@p zE{G28q$5@s;v}%LUz>n|rN&fHYkfvA+$w=_T;lOmozRc<)RNMw_$s7VINhpL=^%H5 zp3cmcjDkV|^rFCt4Uk!3<{l7Q&wYcDZ&8q0CPu7~7jNUdE@3W5;MCi*b2%FdV&}ph zI|7{G!ybmGeRwq=g8!aIN+)v`ZnR>7gGN@A0(_#c4ud)EfC=1;$uQ3kaIVMjg%E;f zwLD0|u7J2bvcN$+gwg{bSxOel8H41QOZTyml}g&eW^dyKiA)XrqzD@Yso~sXwd_y; z5<4cx;-5jfNu7i-FG@F|A$Qmev$N6IEHO3tNaJ@@weK22kJZ}NXAF?{dWxxo&+dL7 zN{>QAF|a-2#p}N#zwKzML$G-kjfI@3J%KS5TjszFVLyqfhI@se>f@^;T&Ir*FE=qJ zxAYTo`eyTkuZLi-x6u)coI+=xa1ed~`Kqj?@#NQq?>_fo(=c%2Cm&LHNmHB&>D?;Y05P;UQwm1NQw( z&;v4vpS<@b>htb_i3(hpdTSEE--M(J0Iv86qHu(l!DnwVm>kr zpQKPrT>};Y|LOz0M_BXSE2J9G2)wEHfLYj~fb7)mM`kQxXiwP@QB$l$-IC4b74mKLXN{nZbrQ zFEOPm#YXp2EP$Hi#X2&xs{xU7O>2#8n7i>EiKX_2gcQatF|&&H!$4P+PG)jAqGNLal{qpCiFJk~)nx#B=%bP4Y(SJ=w274N zeBJdsdh|39J^eu=tG*YB%(b>ECtHso-7RLe=n)IdYwrm?&)Iw5(H^GbKj@F21e8PU z$wLfnKO~#5c+AD$RlhF3>Vbs?Qehp= zHV;#Xdj(RZ3;slC3G`F`W4pD->7rCpx`ccd>VrGAfh4tUd{-C@Fkvn`^1cFFr$KCr zMkhlNO)iDJHdY{E0$#w8f#x1&J>~~GCw)f5kK&b}b2x7`qDQx#q_ksW6Czterjhlz z2e$)Lap>5Tljmh4-eoMH#`?4avL(|K812aP5r|lS z?ojxPG%^<;2RGIeE+4cQQ?#9HdkOT#RMkixYC4&lj)?W8^FUQ^a@+1^WU`6Tmn_aY ziBSxw6p*`dvke`P8Xa8#{gbQrOhf?DxGwWEd*81q0@0`yh4va* zDomS*|IU(`yWy0thKo(rrMMvpD#j*r!j3zQuo@1)AH)E{D?B^fP&hxtX20;F1#Ta`~N601==_JZA_k)^>l-QbEk zE`*#sdO<_fs$T6V9;g60SF^=A_uyZUG&)0=8kCd3N)WhIamXK%OF{@(79D_Is5}Ku zib@QPRi+)BlvFP%7<7OGn%W-{Qzr6dcL5}(@(_#(v7dl+!7J~a>&Tw7j@VRHfaXXs zxz!xL%&u5n|2E$;H}xnIPJY*}+toXsM>nFIxL29YKSEwI?Vt3+e6T4)S8%<@C4H)W zwdUis+s)4(AD>#Z$>gSd{GoFCwzC_rU+C&iE{D}*W}DeH{`sE^uiR`;F0X|FV-Piw zpkKqAk&T~=Qbz7&fOmdD5Gx0GSI9aitZGN;;5lgYF2Kye(*+3OlMc}!IIj+1;|v6k zSP&F$?#!_C@;5_Kc9;rbbqz)cHmH48Aj&@5wwc^^X9c}Q{Sl2uV;-9!6ZsF<5g~rQ zRy6z=hR;caW-3sQ*-hn8KW0oK5tJhcxzp|uho&%og;6Na{0YRs_E25MjpILpi>J#m zV;+}RW^sn0esaOCFNC}R5rRu?A zDrF>eND|{R8~WmA{2gONhlO5goDG$2B7rkgCYagUa#Y`z%z-Py8K@#w4bUXGx)JaO zsxD!j#~})~8yHMo89)QIigx4>8V^)Kr(B17zzhje^|x~8mS-Tje}<6;C+&9mSY-{)7H{AfRCC_SHcfH*TDD zp_MnVfA9Hc-Zj*1yqM}}W{;fs^eCK9u>y*IgHU%s70(cw14zxKg(<{U*RgW@14DZ% z_>{3`wvYNF8k}r~!70Pg;Ez`2%tfDojvb)X51OtYm+eN@039`Zw7Abyhyy74+7nd7 zVzLe)f<|*W3I~(ONT=DHMR3sM6qd*FUDwv zDpv)~R`*gtEkL4?xkEw+V;*23^R*xvl|#VT61a-glbMgz`+BiyKu&iw^4ogu;%GDb z1e)>`?qaH$Jp?F~yJ%--X9LRNLZLN=qH&Re-;HY$plBTIOGl@YK~OY0NXRM<%YnNF zJGhHfF=NUsT#Z89F@V|+r7ff(PcR1+a0CyjJO_G6M}>GD8RViPTnyOk9Adm$o|((B zF!Y0tSm{geQ!%bkvrRH!@i~MEXs{1UwAUmgLIFIMcfN>qhW)z4jZB}xxL&h3!`()B zAe9az-Ce=>WE0^)gBqOr-OO6#m_?0Ka4&^WEKYSYvwNVl^u)_%+fXE4w#SB6;AK8$ z)&~l3IZg#zISG`6{n@>}Gzrd7PlfXbOED5|P_#07-b@CAV^Zo)P7I{P*DcOZ^w|1r zLI)1qRWLiutO-hyn+gUdLuis7%WtpYfw@au*+?@+Vo+LSa=z0U465v^P##)YF>p*Q zPD96|1D@_8F{;dLI2_pxs3GFOsnj$>bqS2A<@P1R}La$q$~% zJOi&9a=FDE_HTe*yuGw?eBCo}pjfIl!%fy6k=%H2W7F%r5S{s~Bq%?IvpiG^buo)3*mOMgIj zw9I$puSHMMSd$h#Z4aohEz-4sZg^+7nd3}gFMYrmNJKh_u(o3HKj_Ysf6zVDZRsFh z`{+6Upv6nSqiAtPE?Eo|B0AO|a+83X4|XLr3FF0-XyU<+fPw)Hd;l3!K)oh|2R8IG zW~@)1|1H+c4gfbMqrRy2_LjNVhZq;GFL@5MuS$bUyp460(>8vIjri~-v$@fFd@AH{ zyQ-RHq31p?pLge>jynlSLA4hIM{GH8`b&KCO{sA?tZ&}yIn3wzM|bt6KSA`W4qic= ze-E}o(fI4Ay|@lpqiFnL1g8Ldst&-ZB=L7p0*N|+=#6qzuZlY4!Zn^ixuPwv0{|il z6?J$6-y|8j*x+D*sLB;}*Z=~ZQC%$RDrmbYl{yFcu z;!f%S4exoq2`b_mk(C=HUKR~sFZTb1Sy}tSX)SykH^a(xiduyRV^z$`Jv-1~$tWv# z?2IsYB!TIZ@7m%L4ELldWN}td4XTMt;A_U1t2Mg<;JZ7x$A*3oh*uQxku3fT6|rJv z?++Mx36;c7`!+9SFriR(h*1uu#H$c7+r|6tBuv5`NQm5}o+}clT@qGG20={Q0x;7EqLoF0n|_ zQy_s7pI>hx9VWJs!(_n6 zDI@J~fT~nN*6dF~3JD7FQlbip1+oqieikd3%ZgEmW7_lw4GW|T?XPIsY{*NBO<>sh zupV>ln7}NOm(IkU?0(1w-=o)Zp^`M@Q(_peQrS=xuL^NMWAZHSqIavEAgAuarZH`Y zg5f=Fa3`-WYOoTvO(7l}SOXqtn+#ZPVhNff9KIyGr~?1o(&1`!(?#T!)|;-V2ti|E zQZi8`_+l|&u|XWtjfF1B19=tp6Yk{c8US;oLc27%9x0p-<7>R~kT0rq?qUZo0sX+H z&9WhaB~CIZj?t+c8>2%Si6OjluL-%*zdNnHXMrgk#WGrZ_dY^VIIX=Upuyg>>(RYP z4k+0prU%jmN)F73LM{hDJ2N*Ag-;cYQ`-ikScQmiYETr4Mo0)=cF2a*c$r^RE*it} zvUnSc!Kv{*(906BdpEcvb0wb86ZJsmQj!hW*<7?pyN>SUKyoQ8+5%O*KpN6IFB9Bo76^>Hz9Qx0Qy3~~4d6oZgth5zX*g6>?r zQ>+k!09UrnJ|PAknOxgK8`{aWEx3qgbEf!Kw^hgkZFf3vuHe;ei7{#dTBOL&5J&Yw z0Z)0YXiDgSrVt&|SC8(6ToFC;p`8lQPP3b=dYBb;L0g0X-9K$cM zp+qG=@IfFNlk2&oMbpu|YI}CaaT^L^&&HdOSG$ApLC53K(~9G-*}nvP9*PItQ50s8 zYtIS%L?O7XIt{0LqX3C6YByAuqE{KB+Jpa&_NM)&OE1`;(b2I}CRTBM(fbq02F3C- z1%-1JtG=V^yYu-+Ip}E-v_>mtA>EQW2dJhzWKVQEANtNPr-5x(vztN-??7JgMW z{Et_4BK7GB&HT=K4t}mNJnKMbG~dIYFe79d_<2M-h_T1z*}$TrqhB80?Op>f(NTM)g=63P1c4jUE}LC=65m`hRO>C1C&n literal 0 HcmV?d00001 diff --git a/assets/logo/PyBOP_logo_mark_mono.svg b/assets/logo/PyBOP_logo_mark_mono.svg new file mode 100644 index 000000000..204f6e2f0 --- /dev/null +++ b/assets/logo/PyBOP_logo_mark_mono.svg @@ -0,0 +1 @@ + diff --git a/assets/logo/PyBOP_logo_mark_mono_inverse.png b/assets/logo/PyBOP_logo_mark_mono_inverse.png new file mode 100644 index 0000000000000000000000000000000000000000..3d4cc15ff86647ea58712b569a9884ebc0f146df GIT binary patch literal 10252 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 diff --git a/assets/logo/PyBOP_logo_mono.png b/assets/logo/PyBOP_logo_mono.png new file mode 100644 index 0000000000000000000000000000000000000000..af9be83016db9ee32f6339962ee34873fc1ad37c GIT binary patch literal 78723 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 Z->^QjF7%F9=9EBM?q;7&MPKed`G0VY;RpZ# literal 0 HcmV?d00001 diff --git a/assets/logo/PyBOP_logo_mono.svg b/assets/logo/PyBOP_logo_mono.svg new file mode 100644 index 000000000..b135967ec --- /dev/null +++ b/assets/logo/PyBOP_logo_mono.svg @@ -0,0 +1 @@ + diff --git a/assets/logo/PyBOP_logo_mono_inverse.png b/assets/logo/PyBOP_logo_mono_inverse.png new file mode 100644 index 0000000000000000000000000000000000000000..cc9030c6cb7d292cf4531dadaa039e7fa216371d GIT binary patch literal 78971 zcmeFZc{tT;7eD;nBn{dc4P_{qDj~`oN-ASXM3F6_jv@1qX?GNrq&68cg;0o%GRH1O zJJ~xbwwVmulsWV8uJ7K?^PKZM|Gn4yyWaPDuV-J^bB@FJd*AC`>$BGSthMg_+&im( zYSV_D8xRE9q;>klIRxQ+h9GNj>(;_=mL9#-g@3JgI&JETAdV&I|1b^hx!ee{8__y( z?7Uapa2wUYOsk}-OJ2-IuM6Xa2;8;1_4>*Il}9QF=WX_%j?}d48_(ydi@-lRwX1KH zgoKNHO?12^{>e2XK`c4hwq)S$jU!j`CZa4%Um0Uyx4(VxE3XCq_MhmFc<0T}SpWIE zMe$tKc7LK{MTE;EN=htOvXxq0HWPtF06Rt zDEqG8YMF1pc@ORR=R-w;yT1O%*-i`nr;N|G{(tOb`~OQHT5J0Mc`)Ssf9Zp;?mv(E z;=kS!-twPEJ-hxt-}*nOpo}cz%+e)XN3Edeb`P2*A+SM%XdoEwd;RE|mg{dkZ`R*L z(f{uc&3NT)HC5H;^*+3V>-U@g{sZHukx{i*{{c4y$M^rWGygvY_y6C5;(rSIJFfo^ z2F(8<=DP?f;*2Jt zC24+fT5Ve{kbjcRlOKP$7YYuSmM-l^RFZ$RU%!6z-$j8+LA__8d}!kL3W0_*GI@`D zg&Vr&qd4krx@^mly5N&u!=(YclfDB_jo$t@ireyRs?E~z&j~>__JcOfTP$3+rBhwE zWd@l3ikDm-uwVg+%|T+^xxZ06-H)$6Eh>=xgY^0qv>ra)^l{WzKd?P{V%ZPxp+cP`q}YF_2YS&LuslwB0`0Aa`RSkm*q5$J2FQ*Hi^ z$0yzGB@ffBf_D4mE`tK24zi=t1VbtGkL45IzoIP)%fFjDRudB)k;8js%KO9sEUWtf z;m2$JjdPFjXm`Z-EOeULQ__}rQmLR7i>LZ);L(cR|8YW<^8=-otsnZtv>xt!Sn0SB zHySvl+2Rb3s)MW_{w8Zt&d#{R)_IyE#iXsBpo*Rl{T@Y4&elU*|qlJ*TABt8MX>)!u&MTQsN=jTa??O%K`ApMm> zZ(TFc2(_1#UOHG8cVN}JB)2E!v0~m0H?jD~UMnFLg;|MjyqAW!r5Zpom4mjXq|<(s z8Tu)^8o}jdA+nN!Y4)^!!0%B|+BrBlNA8@GQsNOl_NqZE!Vt%ylF_G_SuMX0bn_ua z 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

BM<(R98t6Z?3H>P%={JQ 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%0AxJ%H3QIo42&vl7{}SYs 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 literal 0 HcmV?d00001 diff --git a/assets/logo/PyBOP_logo_mono_inverse.svg b/assets/logo/PyBOP_logo_mono_inverse.svg new file mode 100644 index 000000000..830bea1a3 --- /dev/null +++ b/assets/logo/PyBOP_logo_mono_inverse.svg @@ -0,0 +1 @@ + diff --git a/benchmarks/benchmark_model.py b/benchmarks/benchmark_model.py index df0335c24..843b03bcc 100644 --- a/benchmarks/benchmark_model.py +++ b/benchmarks/benchmark_model.py @@ -81,3 +81,13 @@ def time_model_simulate(self, model, parameter_set): parameter_set (str): The name of the parameter set being used. """ self.problem._model.simulate(inputs=self.inputs, t_eval=self.t_eval) + + def time_model_simulateS1(self, model, parameter_set): + """ + Benchmark the simulateS1 method of the model. + + Args: + model (pybop.Model): The model class being benchmarked. + parameter_set (str): The name of the parameter set being used. + """ + self.problem._model.simulateS1(inputs=self.inputs, t_eval=self.t_eval) diff --git a/examples/notebooks/multi_model_identification.ipynb b/examples/notebooks/multi_model_identification.ipynb index 699b2edae..e7c6b158c 100644 --- a/examples/notebooks/multi_model_identification.ipynb +++ b/examples/notebooks/multi_model_identification.ipynb @@ -3958,7 +3958,7 @@ } ], "source": [ - "bounds = np.array([[5.5e-05, 8e-05], [7.5e-05, 9e-05]])\n", + "bounds = np.asarray([[5.5e-05, 8e-05], [7.5e-05, 9e-05]])\n", "for optim in optims:\n", " pybop.plot2d(optim, bounds=bounds, steps=10, title=optim.cost.problem.model.name)" ] diff --git a/examples/notebooks/multi_optimiser_identification.ipynb b/examples/notebooks/multi_optimiser_identification.ipynb index f85b2609b..8b2a83500 100644 --- a/examples/notebooks/multi_optimiser_identification.ipynb +++ b/examples/notebooks/multi_optimiser_identification.ipynb @@ -925,7 +925,7 @@ ], "source": [ "# Plot the cost landscape with optimisation path and updated bounds\n", - "bounds = np.array([[0.5, 0.8], [0.55, 0.8]])\n", + "bounds = np.asarray([[0.5, 0.8], [0.55, 0.8]])\n", "for optim in optims:\n", " pybop.plot2d(optim, bounds=bounds, steps=10, title=optim.name())" ] diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index 3199fadb0..accfbf259 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -677,7 +677,7 @@ ], "source": [ "# Plot the cost landscape with optimisation path and updated bounds\n", - "bounds = np.array([[0.6, 0.9], [0.5, 0.8]])\n", + "bounds = np.asarray([[0.6, 0.9], [0.5, 0.8]])\n", "for optim, sigma in zip(optims, sigmas):\n", " pybop.plot2d(optim, bounds=bounds, steps=10, title=f\"Sigma: {sigma}\")" ] diff --git a/examples/notebooks/spm_AdamW.ipynb b/examples/notebooks/spm_AdamW.ipynb index 20b733307..6b2330907 100644 --- a/examples/notebooks/spm_AdamW.ipynb +++ b/examples/notebooks/spm_AdamW.ipynb @@ -530,7 +530,7 @@ "# Plot the cost landscape\n", "pybop.plot2d(cost, steps=15)\n", "# Plot the cost landscape with optimisation path and updated bounds\n", - "bounds = np.array([[0.6, 0.9], [0.5, 0.8]])\n", + "bounds = np.asarray([[0.6, 0.9], [0.5, 0.8]])\n", "pybop.plot2d(optim, bounds=bounds, steps=15);" ] }, diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index fc711cab6..96a36ec44 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -101,5 +101,5 @@ pybop.plot2d(cost, steps=15) # Plot the cost landscape with optimisation path and updated bounds -bounds = np.array([[1e-4, 1e-2], [1e-5, 1e-2]]) +bounds = np.asarray([[1e-4, 1e-2], [1e-5, 1e-2]]) pybop.plot2d(optim, bounds=bounds, steps=15) diff --git a/examples/scripts/spm_AdamW.py b/examples/scripts/spm_AdamW.py index 103515121..44bbf8b11 100644 --- a/examples/scripts/spm_AdamW.py +++ b/examples/scripts/spm_AdamW.py @@ -77,5 +77,5 @@ def noise(sigma): pybop.plot_parameters(optim) # Plot the cost landscape with optimisation path -bounds = np.array([[0.5, 0.8], [0.4, 0.7]]) +bounds = np.asarray([[0.5, 0.8], [0.4, 0.7]]) pybop.plot2d(optim, bounds=bounds, steps=15) diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index 3b38668cf..727536ff8 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -51,5 +51,5 @@ pybop.plot_parameters(optim) # Plot the cost landscape with optimisation path -bounds = np.array([[0.5, 0.8], [0.4, 0.7]]) +bounds = np.asarray([[0.5, 0.8], [0.4, 0.7]]) pybop.plot2d(optim, bounds=bounds, steps=15) diff --git a/examples/scripts/spm_MAP.py b/examples/scripts/spm_MAP.py index 191f93d84..d8460915c 100644 --- a/examples/scripts/spm_MAP.py +++ b/examples/scripts/spm_MAP.py @@ -69,5 +69,5 @@ pybop.plot2d(cost, steps=15) # Plot the cost landscape with optimisation path -bounds = np.array([[0.55, 0.77], [0.48, 0.68]]) +bounds = np.asarray([[0.55, 0.77], [0.48, 0.68]]) pybop.plot2d(optim, bounds=bounds, steps=15) diff --git a/examples/scripts/spm_MLE.py b/examples/scripts/spm_MLE.py index 7e1b3c93c..6fc0238ca 100644 --- a/examples/scripts/spm_MLE.py +++ b/examples/scripts/spm_MLE.py @@ -69,5 +69,5 @@ pybop.plot2d(likelihood, steps=15) # Plot the cost landscape with optimisation path -bounds = np.array([[0.55, 0.77], [0.48, 0.68]]) +bounds = np.asarray([[0.55, 0.77], [0.48, 0.68]]) pybop.plot2d(optim, bounds=bounds, steps=15) diff --git a/examples/scripts/spm_NelderMead.py b/examples/scripts/spm_NelderMead.py index 826396321..569dbadf2 100644 --- a/examples/scripts/spm_NelderMead.py +++ b/examples/scripts/spm_NelderMead.py @@ -77,5 +77,5 @@ def noise(sigma): pybop.plot_parameters(optim) # Plot the cost landscape with optimisation path -bounds = np.array([[0.5, 0.8], [0.4, 0.7]]) +bounds = np.asarray([[0.5, 0.8], [0.4, 0.7]]) pybop.plot2d(optim, bounds=bounds, steps=15) diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index df57a7ca1..7c7629b04 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -57,5 +57,5 @@ pybop.plot_parameters(optim) # Plot the cost landscape with optimisation path -bounds = np.array([[0.5, 0.8], [0.4, 0.7]]) +bounds = np.asarray([[0.5, 0.8], [0.4, 0.7]]) pybop.plot2d(optim, bounds=bounds, steps=15) diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index e72788124..eace827a5 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -47,7 +47,7 @@ def set_sigma(self, sigma): ) if not isinstance(sigma, np.ndarray): - sigma = np.array(sigma) + sigma = np.asarray(sigma) if not np.issubdtype(sigma.dtype, np.number): raise ValueError("Sigma must contain only numeric values") @@ -74,7 +74,7 @@ def _evaluate(self, x, grad=None): if len(y.get(key, [])) != len(self._target.get(key, [])): return -np.float64(np.inf) # prediction doesn't match target - e = np.array( + e = np.asarray( [ np.sum( self._offset @@ -102,7 +102,7 @@ def _evaluateS1(self, x, grad=None): dl = self._dl * np.ones(self.n_parameters) return -likelihood, -dl - r = np.array([self._target[signal] - y[signal] for signal in self.signal]) + r = np.asarray([self._target[signal] - y[signal] for signal in self.signal]) likelihood = self._evaluate(x) dl = np.sum((self.sigma2 * np.sum((r * dy.T), axis=2)), axis=1) return likelihood, dl @@ -148,7 +148,7 @@ def _evaluate(self, x, grad=None): if len(y.get(key, [])) != len(self._target.get(key, [])): return -np.float64(np.inf) # prediction doesn't match target - e = np.array( + e = np.asarray( [ np.sum( self._logpi @@ -181,7 +181,7 @@ def _evaluateS1(self, x, grad=None): dl = self._dl * np.ones(self.n_parameters) return -likelihood, -dl - r = np.array([self._target[signal] - y[signal] for signal in self.signal]) + r = np.asarray([self._target[signal] - y[signal] for signal in self.signal]) likelihood = self._evaluate(x) dl = sigma ** (-2.0) * np.sum((r * dy.T), axis=2) dsigma = -self.n_time_data / sigma + sigma**-(3.0) * np.sum(r**2, axis=1) diff --git a/pybop/costs/fitting_costs.py b/pybop/costs/fitting_costs.py index 569e590e7..eff56059c 100644 --- a/pybop/costs/fitting_costs.py +++ b/pybop/costs/fitting_costs.py @@ -47,7 +47,7 @@ def _evaluate(self, x, grad=None): if len(prediction.get(key, [])) != len(self._target.get(key, [])): return np.float64(np.inf) # prediction doesn't match target - e = np.array( + e = np.asarray( [ np.sqrt(np.mean((prediction[signal] - self._target[signal]) ** 2)) for signal in self.signal @@ -87,7 +87,7 @@ def _evaluateS1(self, x): de = self._de * np.ones(self.n_parameters) return e, de - r = np.array([y[signal] - self._target[signal] for signal in self.signal]) + r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) e = np.sqrt(np.mean(r**2, axis=1)) de = np.mean((r * dy.T), axis=2) / (e + np.finfo(float).eps) @@ -159,7 +159,7 @@ def _evaluate(self, x, grad=None): if len(prediction.get(key, [])) != len(self._target.get(key, [])): return np.float64(np.inf) # prediction doesn't match target - e = np.array( + e = np.asarray( [ np.sum(((prediction[signal] - self._target[signal]) ** 2)) for signal in self.signal @@ -197,7 +197,7 @@ def _evaluateS1(self, x): de = self._de * np.ones(self.n_parameters) return e, de - r = np.array([y[signal] - self._target[signal] for signal in self.signal]) + r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) e = np.sum(np.sum(r**2, axis=0), axis=0) de = 2 * np.sum(np.sum((r * dy.T), axis=2), axis=1) diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index e9809bc42..a05062672 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -292,7 +292,7 @@ def reinit( if x is None: x = self._built_model.y0 - sol = pybamm.Solution([np.array([t])], [x], self._built_model, inputs) + sol = pybamm.Solution([np.asarray([t])], [x], self._built_model, inputs) return TimeSeriesState(sol=sol, inputs=inputs, t=t) @@ -303,7 +303,7 @@ def get_state(self, inputs: Inputs, t: float, x: np.ndarray) -> TimeSeriesState: if self._built_model is None: raise ValueError("Model must be built before calling get_state") - sol = pybamm.Solution([np.array([t])], [x], self._built_model, inputs) + sol = pybamm.Solution([np.asarray([t])], [x], self._built_model, inputs) return TimeSeriesState(sol=sol, inputs=inputs, t=t) diff --git a/pybop/observers/observer.py b/pybop/observers/observer.py index 162d03de2..1b81c5ac7 100644 --- a/pybop/observers/observer.py +++ b/pybop/observers/observer.py @@ -134,7 +134,7 @@ def get_current_covariance(self) -> Covariance: def get_measure(self, x: TimeSeriesState) -> np.ndarray: measures = [x.sol[s].data[-1] for s in self._signal] - return np.array([[m] for m in measures]) + return np.asarray([[m] for m in measures]) def get_current_time(self) -> float: """ diff --git a/pybop/observers/unscented_kalman.py b/pybop/observers/unscented_kalman.py index b7ea0f359..0b6425db9 100644 --- a/pybop/observers/unscented_kalman.py +++ b/pybop/observers/unscented_kalman.py @@ -118,7 +118,7 @@ def observe(self, time: float, value: np.ndarray) -> float: if value is None: raise ValueError("Measurement must be provided.") elif isinstance(value, np.floating): - value = np.array([value]) + value = np.asarray([value]) dt = time - self.get_current_time() if dt < 0: @@ -201,7 +201,7 @@ def __init__( zero_cols = np.logical_and(np.all(P0 == 0, axis=1), np.all(Rp == 0, axis=1)) zeros = np.logical_and(zero_rows, zero_cols) ones = np.logical_not(zeros) - states = np.array(range(len(x0)))[ones] + states = np.asarray(range(len(x0)))[ones] bool_mask = np.ix_(ones, ones) S_filtered = linalg.cholesky(P0[ones, :][:, ones]) @@ -276,11 +276,11 @@ def gen_sigma_points( # Define the weights of the sigma points w_m0 = sigma / (L + sigma) - w_m = np.array([w_m0] + [1 / (2 * (L + sigma))] * (2 * L)) + w_m = np.asarray([w_m0] + [1 / (2 * (L + sigma))] * (2 * L)) # Define the weights of the covariance of the sigma points w_c0 = w_m0 + (1 - alpha**2 + beta) - w_c = np.array([w_c0] + [1 / (2 * (L + sigma))] * (2 * L)) + w_c = np.asarray([w_c0] + [1 / (2 * (L + sigma))] * (2 * L)) return (points, w_m, w_c) diff --git a/pybop/optimisers/base_pints_optimiser.py b/pybop/optimisers/base_pints_optimiser.py index a5140df0b..f5698c8ed 100644 --- a/pybop/optimisers/base_pints_optimiser.py +++ b/pybop/optimisers/base_pints_optimiser.py @@ -134,22 +134,20 @@ def _sanitise_inputs(self): # Convert bounds to PINTS boundaries if self.bounds is not None: - if issubclass( - self.pints_optimiser, - (PintsGradientDescent, PintsAdam, PintsNelderMead), - ): + ignored_optimisers = (PintsGradientDescent, PintsAdam, PintsNelderMead) + if issubclass(self.pints_optimiser, ignored_optimisers): print(f"NOTE: Boundaries ignored by {self.pints_optimiser}") self.bounds = None - elif issubclass(self.pints_optimiser, PintsPSO): - if not all( - np.isfinite(value) - for sublist in self.bounds.values() - for value in sublist - ): - raise ValueError( - "Either all bounds or no bounds must be set for Pints PSO." - ) else: + if issubclass(self.pints_optimiser, PintsPSO): + if not all( + np.isfinite(value) + for sublist in self.bounds.values() + for value in sublist + ): + raise ValueError( + f"Either all bounds or no bounds must be set for {self.pints_optimiser.__name__}." + ) self._boundaries = PintsRectangularBoundaries( self.bounds["lower"], self.bounds["upper"] ) diff --git a/pybop/plotting/plot2d.py b/pybop/plotting/plot2d.py index 2e7d4f267..961bc7c49 100644 --- a/pybop/plotting/plot2d.py +++ b/pybop/plotting/plot2d.py @@ -77,19 +77,19 @@ def plot2d( # Populate cost matrix for i, xi in enumerate(x): for j, yj in enumerate(y): - costs[j, i] = cost(np.array([xi, yj])) + costs[j, i] = cost(np.asarray([xi, yj])) if gradient: grad_parameter_costs = [] # Determine the number of gradient outputs from cost.evaluateS1 - num_gradients = len(cost.evaluateS1(np.array([x[0], y[0]]))[1]) + num_gradients = len(cost.evaluateS1(np.asarray([x[0], y[0]]))[1]) # Create an array to hold each gradient output & populate grads = [np.zeros((len(y), len(x))) for _ in range(num_gradients)] for i, xi in enumerate(x): for j, yj in enumerate(y): - (*current_grads,) = cost.evaluateS1(np.array([xi, yj]))[1] + (*current_grads,) = cost.evaluateS1(np.asarray([xi, yj]))[1] for k, grad_output in enumerate(current_grads): grads[k][j, i] = grad_output @@ -103,7 +103,7 @@ def plot2d( flat_costs = costs.flatten() # Append the optimisation trace to the data - parameter_log = np.array(optim.log["x_best"]) + parameter_log = np.asarray(optim.log["x_best"]) flat_x = np.concatenate((flat_x, parameter_log[:, 0])) flat_y = np.concatenate((flat_y, parameter_log[:, 1])) flat_costs = np.concatenate((flat_costs, optim.log["cost"])) @@ -140,7 +140,9 @@ def plot2d( if plot_optim: # Plot the optimisation trace - optim_trace = np.array([item for sublist in optim.log["x"] for item in sublist]) + optim_trace = np.asarray( + [item for sublist in optim.log["x"] for item in sublist] + ) optim_trace = optim_trace.reshape(-1, 2) fig.add_trace( go.Scatter( diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index dcd942764..01702ba24 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -13,7 +13,7 @@ class TestOptimisation: @pytest.fixture(autouse=True) def setup(self): - self.ground_truth = np.array([0.55, 0.55]) + np.random.normal( + self.ground_truth = np.asarray([0.55, 0.55]) + np.random.normal( loc=0.0, scale=0.05, size=2 ) diff --git a/tests/integration/test_spm_parameterisations.py b/tests/integration/test_spm_parameterisations.py index 9ae2b4215..7eeb0b7c0 100644 --- a/tests/integration/test_spm_parameterisations.py +++ b/tests/integration/test_spm_parameterisations.py @@ -11,7 +11,7 @@ class Test_SPM_Parameterisation: @pytest.fixture(autouse=True) def setup(self): - self.ground_truth = np.array([0.55, 0.55]) + np.random.normal( + self.ground_truth = np.asarray([0.55, 0.55]) + np.random.normal( loc=0.0, scale=0.05, size=2 ) @@ -160,7 +160,7 @@ def spm_two_signal_cost(self, parameters, model, cost_class): [ pybop.SciPyDifferentialEvolution, pybop.IRPropMin, - pybop.CMAES, + pybop.XNES, ], ) @pytest.mark.integration @@ -218,7 +218,7 @@ def test_model_misparameterisation(self, parameters, model, init_soc): cost = pybop.RootMeanSquaredError(problem) # Select optimiser - optimiser = pybop.CMAES + optimiser = pybop.XNES # Build the optimisation problem optim = optimiser(cost=cost) diff --git a/tests/integration/test_thevenin_parameterisation.py b/tests/integration/test_thevenin_parameterisation.py index 57bb06898..1ef1bc3eb 100644 --- a/tests/integration/test_thevenin_parameterisation.py +++ b/tests/integration/test_thevenin_parameterisation.py @@ -11,7 +11,7 @@ class TestTheveninParameterisation: @pytest.fixture(autouse=True) def setup(self): - self.ground_truth = np.array([0.05, 0.05]) + np.random.normal( + self.ground_truth = np.asarray([0.05, 0.05]) + np.random.normal( loc=0.0, scale=0.01, size=2 ) diff --git a/tests/plotting/test_plotly_manager.py b/tests/plotting/test_plotly_manager.py index 80bc3bb58..ba0adbd8b 100644 --- a/tests/plotting/test_plotly_manager.py +++ b/tests/plotting/test_plotly_manager.py @@ -95,7 +95,7 @@ def test_cancel_installation(mocker, uninstall_plotly_if_installed): with pytest.raises(SystemExit) as pytest_wrapped_e: PlotlyManager().prompt_for_plotly_installation() - assert pytest_wrapped_e.type == SystemExit + assert pytest_wrapped_e.type is SystemExit assert pytest_wrapped_e.value.code == 1 assert not is_package_installed("plotly") From fb97b5d059acfeb72cebc70c66ec22bafb6d4d66 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 3 Jul 2024 17:32:12 +0100 Subject: [PATCH 15/31] refactor: move general methods into parent class, replace burn_in with warm_up --- pybop/samplers/__init__.py | 81 ++++++++++++++++++++++++ pybop/samplers/base_mcmc.py | 88 ++------------------------- tests/integration/test_monte_carlo.py | 3 +- 3 files changed, 88 insertions(+), 84 deletions(-) diff --git a/pybop/samplers/__init__.py b/pybop/samplers/__init__.py index 65a441864..924cdb096 100644 --- a/pybop/samplers/__init__.py +++ b/pybop/samplers/__init__.py @@ -1,7 +1,10 @@ +import logging import numpy as np from pints import ParallelEvaluator import warnings +from pybop import LogPosterior + class BaseSampler: """ Base class for Monte Carlo samplers. @@ -70,3 +73,81 @@ def set_parallel(self, parallel=False): else: self._parallel = False self._n_workers = 1 + + def _ask_for_samples(self): + if self._single_chain: + return [self._samplers[i].ask() for i in self._active] + else: + return self._samplers[0].ask() + + def _inverse_transform(self, y): + return self._transformation.to_model(y) if self._transformation else y + + def _check_initial_phase(self): + # Set initial phase if needed + if self._initial_phase: + for sampler in self._samplers: + sampler.set_initial_phase(True) + + def _end_initial_phase(self): + for sampler in self._samplers: + sampler.set_initial_phase(False) + if self._log_to_screen: + logging.info("Initial phase completed.") + + def _initialise_storage(self): + self._prior = None + if isinstance(self._log_pdf, LogPosterior): + self._prior = self._log_pdf.prior() + + # Storage of the received samples + self._sampled_logpdf = np.zeros(self._n_chains) + self._sampled_prior = np.zeros(self._n_chains) + + # Pre-allocate arrays for chain storage + self._samples = np.zeros( + (self._n_chains, self._max_iterations, self.n_parameters) + ) + + # Pre-allocate arrays for evaluation storage + if self._prior: + # Store posterior, likelihood, prior + self._evaluations = np.zeros((self._n_chains, self._max_iterations, 3)) + else: + # Store pdf + self._evaluations = np.zeros((self._n_chains, self._max_iterations)) + + # From PINTS: + # Some samplers need intermediate steps, where `None` is returned instead + # of a sample. But samplers can run asynchronously, so that one can return + # `None` while another returns a sample. To deal with this, we maintain a + # list of 'active' samplers that have not reached `max_iterations`, + # and store the number of samples so far in each chain. + if self._single_chain: + self._active = list(range(self._n_chains)) + self._n_samples = [0] * self._n_chains + + def _initialise_logging(self): + logging.basicConfig(format="%(message)s", level=logging.INFO) + + if self._log_to_screen: + logging.info("Using " + str(self._samplers[0].name())) + logging.info("Generating " + str(self._n_chains) + " chains.") + if self._parallel: + logging.info( + f"Running in parallel with {self._n_workers} worker processes." + ) + else: + logging.info("Running in sequential mode.") + if self._chain_files: + logging.info("Writing chains to " + self._chain_files[0] + " etc.") + if self._evaluation_files: + logging.info( + "Writing evaluations to " + self._evaluation_files[0] + " etc." + ) + + def _finalise_logging(self): + if self._log_to_screen: + logging.info( + f"Halting: Maximum number of iterations ({self._iteration}) reached." + ) diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index 888cb790b..ede1f4a27 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -9,7 +9,7 @@ SingleChainMCMC, ) -from pybop import BaseCost, BaseSampler, LogPosterior +from pybop import BaseCost, BaseSampler class BasePintsSampler(BaseSampler): @@ -26,7 +26,7 @@ def __init__( log_pdf: Union[BaseCost, List[BaseCost]], chains: int, sampler, - burn_in=None, + warm_up=None, x0=None, cov0=None, transformation=None, @@ -56,7 +56,7 @@ def __init__( self._evaluation_files = kwargs.get("evaluation_files", None) self._parallel = kwargs.get("parallel", False) self._verbose = kwargs.get("verbose", False) - self.burn_in = burn_in + self.warm_up = warm_up self.n_parameters = ( log_pdf[0].n_parameters if isinstance(log_pdf, list) @@ -203,8 +203,8 @@ def run(self) -> Optional[np.ndarray]: self._finalise_logging() - if self.burn_in: - self._samples = self._samples[:, self.burn_in :, :] + if self.warm_up: + self._samples = self._samples[:, self.warm_up :, :] return self._samples if self._chains_in_memory else None @@ -271,25 +271,6 @@ def _process_multi_chain(self): for i, e in enumerate(es): self._evaluations[i, self._iteration] = e - def _initialise_logging(self): - logging.basicConfig(format="%(message)s", level=logging.INFO) - - if self._log_to_screen: - logging.info("Using " + str(self._samplers[0].name())) - logging.info("Generating " + str(self._n_chains) + " chains.") - if self._parallel: - logging.info( - f"Running in parallel with {self._n_workers} worker processes." - ) - else: - logging.info("Running in sequential mode.") - if self._chain_files: - logging.info("Writing chains to " + self._chain_files[0] + " etc.") - if self._evaluation_files: - logging.info( - "Writing evaluations to " + self._evaluation_files[0] + " etc." - ) - def _check_stopping_criteria(self): has_stopping_criterion = False has_stopping_criterion |= self._max_iterations is not None @@ -315,62 +296,3 @@ def _create_evaluator(self): if not self._multi_log_pdf else MultiSequentialEvaluator(f) ) - - def _check_initial_phase(self): - # Set initial phase if needed - if self._initial_phase: - for sampler in self._samplers: - sampler.set_initial_phase(True) - - def _inverse_transform(self, y): - return self._transformation.to_model(y) if self._transformation else y - - def _initialise_storage(self): - self._prior = None - if isinstance(self._log_pdf, LogPosterior): - self._prior = self._log_pdf.prior() - - # Storage of the received samples - self._sampled_logpdf = np.zeros(self._n_chains) - self._sampled_prior = np.zeros(self._n_chains) - - # Pre-allocate arrays for chain storage - self._samples = np.zeros( - (self._n_chains, self._max_iterations, self.n_parameters) - ) - - # Pre-allocate arrays for evaluation storage - if self._prior: - # Store posterior, likelihood, prior - self._evaluations = np.zeros((self._n_chains, self._max_iterations, 3)) - else: - # Store pdf - self._evaluations = np.zeros((self._n_chains, self._max_iterations)) - - # From PINTS: - # Some samplers need intermediate steps, where `None` is returned instead - # of a sample. But samplers can run asynchronously, so that one can return - # `None` while another returns a sample. To deal with this, we maintain a - # list of 'active' samplers that have not reached `max_iterations`, - # and store the number of samples so far in each chain. - if self._single_chain: - self._active = list(range(self._n_chains)) - self._n_samples = [0] * self._n_chains - - def _end_initial_phase(self): - for sampler in self._samplers: - sampler.set_initial_phase(False) - if self._log_to_screen: - logging.info("Initial phase completed.") - - def _ask_for_samples(self): - if self._single_chain: - return [self._samplers[i].ask() for i in self._active] - else: - return self._samplers[0].ask() - - def _finalise_logging(self): - if self._log_to_screen: - logging.info( - f"Halting: Maximum number of iterations ({self._iteration}) reached." - ) diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index c447e7632..1f7e8e627 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -95,6 +95,7 @@ def spm_likelihood(self, model, parameters, cost_class, init_soc): ], ) # Samplers that either have along runtime, or converge slowly + # Need to assess how to perform integration tests with these samplers # @pytest.mark.parametrize( # "long_sampler", # [ @@ -120,7 +121,7 @@ def test_sampling_spm(self, quick_sampler, spm_likelihood): posterior, chains=3, x0=x0, - burn_in=50, + warm_up=50, max_iterations=400, ) results = sampler.run() From 990c590d77860cbd7795278e2e232db87f5a0f60 Mon Sep 17 00:00:00 2001 From: Brady Planden <55357039+BradyPlanden@users.noreply.github.com> Date: Thu, 4 Jul 2024 09:44:49 +0100 Subject: [PATCH 16/31] Apply suggestions from code review --- pybop/parameters/priors.py | 4 ++-- pybop/samplers/base_mcmc.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index de233ee56..0557e3f69 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -243,7 +243,7 @@ def __init__(self, lower, upper, random_state=None): def __call__(self, x): """ - Evaluates the gaussian (log) distribution at x. + Evaluates the uniform distribution at x. Parameters ---------- @@ -312,7 +312,7 @@ def __init__(self, scale, loc=0, random_state=None): def __call__(self, x): """ - Evaluates the gaussian (log) distribution at x. + Evaluates the exponential (log) distribution at x. Parameters ---------- diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index ede1f4a27..e5e1be3ac 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -123,7 +123,7 @@ def __init__( self.set_parallel(self._parallel) def _apply_transformation(self, transformation): - # TODO: Implement transformation logic + # TODO: Implement transformation logic (alongside #357) pass def run(self) -> Optional[np.ndarray]: From 5f89231bbc48ffc523c52a21a26412b3df64f4d2 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 16 Jul 2024 09:48:32 +0100 Subject: [PATCH 17/31] Merge branch 'develop' into monte-carlo-methods --- .github/release_workflow.md | 1 + .github/workflows/lychee_links.yaml | 59 ++ .github/workflows/release_action.yaml | 2 +- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 26 + CITATION.cff | 2 +- CONTRIBUTING.md | 10 +- README.md | 4 +- benchmarks/benchmark_model.py | 3 +- benchmarks/benchmark_optim_construction.py | 3 +- benchmarks/benchmark_parameterisation.py | 3 +- .../benchmark_track_parameterisation.py | 3 +- docs/_extension/gallery_directive.py | 6 +- docs/_static/switcher.json | 16 +- docs/_templates/autoapi/index.rst | 2 + docs/conf.py | 6 +- docs/index.md | 2 +- docs/installation.rst | 6 +- docs/quick_start.rst | 8 +- .../1-single-pulse-circuit-model.ipynb | 44 +- .../notebooks/comparing_cost_functions.ipynb | 609 ++++++++++++++++++ .../equivalent_circuit_identification.ipynb | 4 +- .../multi_model_identification.ipynb | 2 +- .../multi_optimiser_identification.ipynb | 342 +++++++--- .../notebooks/optimiser_calibration.ipynb | 222 +++++-- .../notebooks/pouch_cell_identification.ipynb | 287 +++------ examples/notebooks/spm_AdamW.ipynb | 2 +- examples/notebooks/spm_electrode_design.ipynb | 8 +- examples/scripts/BPX_spm.py | 2 +- examples/scripts/cuckoo.py | 75 +++ examples/scripts/ecm_CMAES.py | 2 +- examples/scripts/exp_UKF.py | 12 +- examples/scripts/gitt.py | 2 +- examples/scripts/mcmc_example.py | 12 +- examples/scripts/spm_AdamW.py | 4 +- examples/scripts/spm_CMAES.py | 2 +- examples/scripts/spm_IRPropMin.py | 2 +- examples/scripts/spm_MAP.py | 63 +- examples/scripts/spm_MLE.py | 7 +- examples/scripts/spm_NelderMead.py | 2 +- examples/scripts/spm_SNES.py | 4 +- examples/scripts/spm_UKF.py | 2 +- examples/scripts/spm_XNES.py | 2 +- examples/scripts/spm_descent.py | 2 +- examples/scripts/spm_pso.py | 2 +- examples/scripts/spm_scipymin.py | 2 +- examples/scripts/spme_max_energy.py | 8 +- examples/standalone/cost.py | 9 +- examples/standalone/model.py | 8 +- examples/standalone/problem.py | 23 +- pybop/__init__.py | 6 +- pybop/_dataset.py | 28 +- pybop/costs/_likelihoods.py | 363 ++++++++--- pybop/costs/base_cost.py | 87 ++- pybop/costs/design_costs.py | 51 +- pybop/costs/fitting_costs.py | 356 +++++----- pybop/models/base_model.py | 111 ++-- pybop/models/empirical/base_ecm.py | 12 +- pybop/models/empirical/ecm.py | 5 +- pybop/models/lithium_ion/base_echem.py | 39 +- pybop/models/lithium_ion/echem.py | 3 +- pybop/models/lithium_ion/weppner_huggins.py | 2 +- pybop/observers/observer.py | 32 +- pybop/observers/unscented_kalman.py | 26 +- pybop/optimisers/_adamw.py | 2 +- pybop/optimisers/_cuckoo.py | 197 ++++++ pybop/optimisers/base_optimiser.py | 62 +- pybop/optimisers/pints_optimisers.py | 36 +- pybop/optimisers/scipy_optimisers.py | 44 +- pybop/parameters/parameter.py | 161 ++++- pybop/parameters/parameter_set.py | 58 +- pybop/parameters/priors.py | 152 +++-- pybop/plotting/plot2d.py | 35 +- pybop/plotting/plot_dataset.py | 6 +- pybop/plotting/plot_parameters.py | 6 +- pybop/plotting/plot_problem.py | 13 +- pybop/plotting/plotly_manager.py | 4 +- pybop/plotting/quick_plot.py | 16 +- pybop/problems/base_problem.py | 24 +- pybop/problems/design_problem.py | 25 +- pybop/problems/fitting_problem.py | 49 +- pybop/samplers/base_mcmc.py | 8 +- pybop/samplers/mcmc_sampler.py | 2 +- pyproject.toml | 23 +- tests/examples/test_examples.py | 6 +- .../test_model_experiment_changes.py | 4 +- tests/integration/test_monte_carlo.py | 3 +- .../integration/test_optimisation_options.py | 6 +- .../integration/test_spm_parameterisations.py | 143 ++-- .../test_thevenin_parameterisation.py | 6 +- tests/unit/test_cost.py | 91 ++- tests/unit/test_dataset.py | 4 +- tests/unit/test_likelihoods.py | 50 +- tests/unit/test_models.py | 22 +- tests/unit/test_observer_unscented_kalman.py | 37 +- tests/unit/test_observers.py | 24 +- tests/unit/test_optimisation.py | 59 +- tests/unit/test_parameter_sets.py | 13 + tests/unit/test_parameters.py | 57 +- tests/unit/test_plots.py | 78 ++- tests/unit/test_posterior.py | 9 +- tests/unit/test_sampling.py | 2 +- tests/unit/test_standalone.py | 3 +- 103 files changed, 3318 insertions(+), 1274 deletions(-) create mode 100644 .github/workflows/lychee_links.yaml create mode 100644 examples/notebooks/comparing_cost_functions.ipynb create mode 100644 examples/scripts/cuckoo.py create mode 100644 pybop/optimisers/_cuckoo.py diff --git a/.github/release_workflow.md b/.github/release_workflow.md index a655f9fee..97fc65d67 100644 --- a/.github/release_workflow.md +++ b/.github/release_workflow.md @@ -11,6 +11,7 @@ To create a new release, follow these steps: - Increment the following; - The version number in the `pyproject.toml` and `CITATION.cff` files following CalVer versioning. - The`CHANGELOG.md` version with the changes for the new version. + - Add a new entry for the documentation site version switcher located at `docs/_static/switcher.json` - Open a PR to the `main` branch. Once the PR is merged, proceed to the next step. 2. **Tag the Release:** diff --git a/.github/workflows/lychee_links.yaml b/.github/workflows/lychee_links.yaml new file mode 100644 index 000000000..fa82a4c78 --- /dev/null +++ b/.github/workflows/lychee_links.yaml @@ -0,0 +1,59 @@ +# Lychee Link Checking + +name: Links +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + schedule: + - cron: '0 6 * * 0' # Run weekly on Sundays at 06:00 UTC + +jobs: + Lychee: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Restore lychee cache + uses: actions/cache@v4 + with: + path: .lycheecache + key: cache-lychee-${{ github.sha }} + restore-keys: cache-lychee- + + - name: Set up Lychee + uses: lycheeverse/lychee-action@v1.10.0 + with: + args: >- + --cache + --no-progress + --max-cache-age 2d + --timeout 10 + --max-retries 5 + --skip-missing + --exclude-loopback + --accept 200,429 + --exclude "https://tiles.stadiamaps.com/*|https://b.tile.openstreetmap.org/*" + --exclude "https://cartodb-basemaps-c.global.ssl.fastly.net/*" + --exclude "https://events.mapbox.com/*|https://events.mapbox.cn/*|https://api.mapbox.cn/*" + --exclude "https://github.com/mikolalysenko/glsl-read-float/*" + --exclude "https://fonts.openmaptiles.org/*" + --exclude "https://a.tile.openstreetmap.org/*" + --exclude "https://openstreetmap.org/*|https://www.openstreetmap.org/*" + --exclude "https://cdn.plot.ly/*" + --exclude "https://doi.org/*" + --exclude-path ./CHANGELOG.md + --exclude-path asv.conf.json + --exclude-path docs/conf.py + './**/*.rst' + './**/*.md' + './**/*.py' + './**/*.ipynb' + './**/*.json' + './**/*.toml' + fail: true + jobSummary: true + format: markdown diff --git a/.github/workflows/release_action.yaml b/.github/workflows/release_action.yaml index 0e4ee04d4..499b40022 100644 --- a/.github/workflows/release_action.yaml +++ b/.github/workflows/release_action.yaml @@ -72,7 +72,7 @@ jobs: ./dist/*.tar.gz ./dist/*.whl - name: Publish artifacts and signatures to GitHub Releases - uses: softprops/action-gh-release@9d7c94cfd0a1f3ed45544c887983e9fa900f0564 # v2.0.4 + uses: softprops/action-gh-release@v2 with: # `dist/` contains the built packages, and the # sigstore-produced signatures and certificates. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 47ef467c5..82299a924 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.5.0" + rev: "v0.5.1" hooks: - id: ruff args: [--fix, --show-fixes] diff --git a/CHANGELOG.md b/CHANGELOG.md index c7fa71655..47fec644c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,27 @@ - [#6](https://github.com/pybop-team/PyBOP/issues/6) - Adds Monte Carlo functionality, with methods based on Pints' algorithms. A base class is added `BaseSampler`, in addition to `PintsBaseSampler`. +- [#393](https://github.com/pybop-team/PyBOP/pull/383) - Adds Minkowski and SumofPower cost classes, with an example and corresponding tests. +- [#403](https://github.com/pybop-team/PyBOP/pull/403/) - Adds lychee link checking action. + +## Bug Fixes + + +## Breaking Changes + +# [v24.6](https://github.com/pybop-team/PyBOP/tree/v24.6) - 2024-07-08 + +## Features + +- [#319](https://github.com/pybop-team/PyBOP/pull/319/) - Adds `CuckooSearch` optimiser with corresponding tests. +- [#359](https://github.com/pybop-team/PyBOP/pull/359/) - Aligning Inputs between problem, observer and model. - [#379](https://github.com/pybop-team/PyBOP/pull/379) - Adds model.simulateS1 to weekly benchmarks. - [#174](https://github.com/pybop-team/PyBOP/issues/174) - Adds new logo and updates Readme for accessibility. - [#316](https://github.com/pybop-team/PyBOP/pull/316) - Adds Adam with weight decay (AdamW) optimiser, adds depreciation warning for pints.Adam implementation. - [#271](https://github.com/pybop-team/PyBOP/issues/271) - Aligns the output of the optimisers via a generalisation of Result class. - [#315](https://github.com/pybop-team/PyBOP/pull/315) - Updates __init__ structure to remove circular import issues and minimises dependancy imports across codebase for faster PyBOP module import. Adds type-hints to BaseModel and refactors rebuild parameter variables. - [#236](https://github.com/pybop-team/PyBOP/issues/236) - Restructures the optimiser classes, adds a new optimisation API through direct construction and keyword arguments, and fixes the setting of `max_iterations`, and `_minimising`. Introduces `pybop.BaseOptimiser`, `pybop.BasePintsOptimiser`, and `pybop.BaseSciPyOptimiser` classes. +- [#322](https://github.com/pybop-team/PyBOP/pull/322) - Add `Parameters` class to store and access multiple parameters in one object. - [#321](https://github.com/pybop-team/PyBOP/pull/321) - Updates Prior classes with BaseClass, adds a `problem.sample_initial_conditions` method to improve stability of SciPy.Minimize optimiser. - [#249](https://github.com/pybop-team/PyBOP/pull/249) - Add WeppnerHuggins model and GITT example. - [#304](https://github.com/pybop-team/PyBOP/pull/304) - Decreases the testing suite completion time. @@ -27,6 +42,10 @@ ## Bug Fixes +- [#393](https://github.com/pybop-team/PyBOP/pull/393) - General integration test fixes. Adds UserWarning when using Plot2d with prior generated bounds. +- [#338](https://github.com/pybop-team/PyBOP/pull/338) - Fixes GaussianLogLikelihood class, adds integration tests, updates non-bounded parameter implementation by applying bounds from priors and `boundary_multiplier` argument. Bugfixes to CMAES construction. +- [#339](https://github.com/pybop-team/PyBOP/issues/339) - Updates the calculation of the cyclable lithium capacity in the spme_max_energy example. +- [#387](https://github.com/pybop-team/PyBOP/issues/387) - Adds keys to ParameterSet and updates ECM OCV check. - [#380](https://github.com/pybop-team/PyBOP/pull/380) - Restore self._boundaries construction for `pybop.PSO` - [#372](https://github.com/pybop-team/PyBOP/pull/372) - Converts `np.array` to `np.asarray` for Numpy v2.0 support. - [#165](https://github.com/pybop-team/PyBOP/issues/165) - Stores the attempted and best parameter values and the best cost for each iteration in the log attribute of the optimiser and updates the associated plots. @@ -42,6 +61,13 @@ - [#270](https://github.com/pybop-team/PyBOP/pull/270) - Updates PR template. - [#91](https://github.com/pybop-team/PyBOP/issues/91) - Adds a check on the number of parameters for CMAES and makes XNES the default optimiser. +## Breaking Changes + +- [#322](https://github.com/pybop-team/PyBOP/pull/322) - Add `Parameters` class to store and access multiple parameters in one object (API change). +- [#285](https://github.com/pybop-team/PyBOP/pull/285) - Drop support for Python 3.8. +- [#251](https://github.com/pybop-team/PyBOP/pull/251) - Drop support for PyBaMM v23.5 +- [#236](https://github.com/pybop-team/PyBOP/issues/236) - Restructures the optimiser classes (API change). + # [v24.3.1](https://github.com/pybop-team/PyBOP/tree/v24.3.1) - 2024-06-17 ## Features diff --git a/CITATION.cff b/CITATION.cff index a14af062e..b5c838165 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,5 +11,5 @@ authors: family-names: Courtier - given-names: David family-names: Howey -version: "24.3.1" # Update this when you release a new version +version: "24.6" # Update this when you release a new version repository-code: 'https://www.github.com/pybop-team/pybop' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f3a7f07d..abebe3b0b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,15 +62,15 @@ You now have everything you need to start making changes! ### B. Writing your code -6. PyBOP is developed in [Python](https://en.wikipedia.org/wiki/Python_(programming_language)), and makes heavy use of [NumPy](https://en.wikipedia.org/wiki/NumPy) (see also [NumPy for MatLab users](https://numpy.org/doc/stable/user/numpy-for-matlab-users.html) and [Python for R users](http://blog.hackerearth.com/how-can-r-users-learn-python-for-data-science)). +6. PyBOP is developed in [Python](https://en.wikipedia.org/wiki/Python_(programming_language)), and makes heavy use of [NumPy](https://en.wikipedia.org/wiki/NumPy) (see also [NumPy for MatLab users](https://numpy.org/doc/stable/user/numpy-for-matlab-users.html) and [Python for R users](https://rebeccabarter.com/blog/2023-09-11-from_r_to_python)). 7. Make sure to follow our [coding style guidelines](#coding-style-guidelines). -8. Commit your changes to your branch with [useful, descriptive commit messages](https://chris.beams.io/posts/git-commit/): Remember these are publicly visible and should still make sense a few months ahead in time. While developing, you can keep using the GitHub issue you're working on as a place for discussion. [Refer to your commits](https://stackoverflow.com/questions/8910271/how-can-i-reference-a-commit-in-an-issue-comment-on-github) when discussing specific lines of code. +8. Commit your changes to your branch with [useful, descriptive commit messages](https://chris.beams.io/posts/git-commit/): Remember these are publicly visible and should still make sense a few months ahead in time. While developing, you can keep using the GitHub issue you're working on as a place for discussion. Refer to your commits when discussing specific lines of code. This is achieved by referencing the SHA-hash in the comment. An example of this looks like: `the commit 3e5c1e6 solved the issue...` 9. If you want to add a dependency on another library, or re-use code you found somewhere else, have a look at [these guidelines](#dependencies-and-reusing-code). ### C. Merging your changes with PyBOP 10. [Test your code!](#testing) -12. If you added a major new feature, perhaps it should be showcased in an [example notebook](#example-notebooks). +12. If you added a major new feature, perhaps it should be showcased in an [example notebook](https://github.com/pybop-team/PyBOP/tree/develop/examples/notebooks). 13. If you've added new functionality, please add additional tests to ensure ample code coverage in PyBOP. 13. When you feel your code is finished, or at least warrants serious discussion, create a [pull request](https://help.github.com/articles/about-pull-requests/) (PR) on [PyBOP's GitHub page](https://github.com/pybop-team/PyBOP). 14. Once a PR has been created, it will be reviewed by any member of the community. Changes might be suggested which you can make by simply adding new commits to the branch. When everything's finished, someone with the right GitHub permissions will merge your changes into PyBOP main repository. @@ -105,7 +105,7 @@ Class names are CamelCase, and start with an upper case letter, for example `MyO While it's a bad idea for developers to "reinvent the wheel", it's important for users to get a _reasonably sized download and an easy install_. In addition, external libraries can sometimes cease to be supported, and when they contain bugs it might take a while before fixes become available as automatic downloads to PyBOP users. For these reasons, all dependencies in PyBOP should be thought about carefully and discussed on GitHub. -Direct inclusion of code from other packages is possible, as long as their license permits it and is compatible with ours, but again should be considered carefully and discussed in the group. Snippets from blogs and [stackoverflow](https://stackoverflow.com/) can often be included but must include attribution to the original by commenting with a link in the source code. +Direct inclusion of code from other packages is possible, as long as their license permits it and is compatible with ours, but again should be considered carefully and discussed in the group. Snippets from blogs and stackoverflow can often be included but must include attribution to the original by commenting with a link in the source code. ### Separating dependencies @@ -314,8 +314,6 @@ Configuration files: pyproject.toml ``` -Note that this file must be kept in sync with the version number in [pybop/**init**.py](https://github.com/pybop-team/PyBOP/blob/develop/pybop/__init__.py). - ### Continuous Integration using GitHub actions Each change pushed to the PyBOP GitHub repository will trigger the test and benchmark suites to be run, using [GitHub actions](https://github.com/features/actions). diff --git a/README.md b/README.md index 99f7a031e..9080dc9b6 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Contributors](https://img.shields.io/github/contributors/pybop-team/PyBOP)](https://github.com/pybop-team/PyBOP/graphs/contributors) [![Last Commit](https://img.shields.io/github/last-commit/pybop-team/PyBOP/develop?color=purple)](https://github.com/pybop-team/PyBOP/commits/develop) [![Python Versions from PEP 621 TOML](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2Fpybop-team%2FPyBOP%2Fdevelop%2Fpyproject.toml&label=Python)](https://pypi.org/project/pybop/) - [![Forks](https://img.shields.io/github/forks/pybop-team/PyBOP?style=flat)](https://github.com/pybop-team/PyBOPe/network/members) + [![Forks](https://img.shields.io/github/forks/pybop-team/PyBOP?style=flat)](https://github.com/pybop-team/PyBOP/network/members) [![Stars](https://img.shields.io/github/stars/pybop-team/PyBOP?style=flat&color=gold)](https://github.com/pybop-team/PyBOP/stargazers) [![Codecov](https://codecov.io/gh/pybop-team/PyBOP/branch/develop/graph/badge.svg)](https://codecov.io/gh/pybop-team/PyBOP) [![Open Issues](https://img.shields.io/github/issues/pybop-team/PyBOP)](https://github.com/pybop-team/PyBOP/issues/) @@ -74,7 +74,7 @@ Additional script-based examples can be found in the [examples directory](https: - [Unscented Kalman filter parameter identification of a SPM](https://github.com/pybop-team/PyBOP/blob/develop/examples/scripts/spm_UKF.py) - [Import and export parameters using Faraday's BPX format](https://github.com/pybop-team/PyBOP/blob/develop/examples/scripts/BPX_spm.py) - [Maximum a posteriori parameter identification of a SPM](https://github.com/pybop-team/PyBOP/blob/develop/examples/scripts/BPX_spm.py) -- [Gradient based parameter identification of a SPM](https://github.com/pybop-team/PyBOP/blob/develop/examples/scripts/spm_adam.py) +- [Gradient based parameter identification of a SPM](https://github.com/pybop-team/PyBOP/blob/develop/examples/scripts/spm_AdamW.py) ### Supported Methods The table below lists the currently supported [models](https://github.com/pybop-team/PyBOP/tree/develop/pybop/models), [optimisers](https://github.com/pybop-team/PyBOP/tree/develop/pybop/optimisers), and [cost functions](https://github.com/pybop-team/PyBOP/tree/develop/pybop/costs) in PyBOP. diff --git a/benchmarks/benchmark_model.py b/benchmarks/benchmark_model.py index 843b03bcc..5d496215b 100644 --- a/benchmarks/benchmark_model.py +++ b/benchmarks/benchmark_model.py @@ -1,8 +1,7 @@ import numpy as np import pybop - -from .benchmark_utils import set_random_seed +from benchmarks.benchmark_utils import set_random_seed class BenchmarkModel: diff --git a/benchmarks/benchmark_optim_construction.py b/benchmarks/benchmark_optim_construction.py index fee5f0789..75bb28b3c 100644 --- a/benchmarks/benchmark_optim_construction.py +++ b/benchmarks/benchmark_optim_construction.py @@ -1,8 +1,7 @@ import numpy as np import pybop - -from .benchmark_utils import set_random_seed +from benchmarks.benchmark_utils import set_random_seed class BenchmarkOptimisationConstruction: diff --git a/benchmarks/benchmark_parameterisation.py b/benchmarks/benchmark_parameterisation.py index a64116a48..681502387 100644 --- a/benchmarks/benchmark_parameterisation.py +++ b/benchmarks/benchmark_parameterisation.py @@ -1,8 +1,7 @@ import numpy as np import pybop - -from .benchmark_utils import set_random_seed +from benchmarks.benchmark_utils import set_random_seed class BenchmarkParameterisation: diff --git a/benchmarks/benchmark_track_parameterisation.py b/benchmarks/benchmark_track_parameterisation.py index 9180ffecb..a420dd3b9 100644 --- a/benchmarks/benchmark_track_parameterisation.py +++ b/benchmarks/benchmark_track_parameterisation.py @@ -1,8 +1,7 @@ import numpy as np import pybop - -from .benchmark_utils import set_random_seed +from benchmarks.benchmark_utils import set_random_seed class BenchmarkTrackParameterisation: diff --git a/docs/_extension/gallery_directive.py b/docs/_extension/gallery_directive.py index 3579ffcd8..4ab88d996 100644 --- a/docs/_extension/gallery_directive.py +++ b/docs/_extension/gallery_directive.py @@ -12,7 +12,7 @@ """ from pathlib import Path -from typing import Any, Dict, List +from typing import Any from docutils import nodes from docutils.parsers.rst import directives @@ -68,7 +68,7 @@ class GalleryGridDirective(SphinxDirective): "class-card": directives.unchanged, } - def run(self) -> List[nodes.Node]: + def run(self) -> list[nodes.Node]: """Create the gallery grid.""" if self.arguments: # If an argument is given, assume it's a path to a YAML file @@ -129,7 +129,7 @@ def run(self) -> List[nodes.Node]: return [container.children[0]] -def setup(app: Sphinx) -> Dict[str, Any]: +def setup(app: Sphinx) -> dict[str, Any]: """Add custom configuration to sphinx app. Args: diff --git a/docs/_static/switcher.json b/docs/_static/switcher.json index 5db09bc13..2847bc265 100644 --- a/docs/_static/switcher.json +++ b/docs/_static/switcher.json @@ -4,9 +4,19 @@ "url": "https://pybop-docs.readthedocs.io/en/latest/" }, { - "name": "v23.12 (stable)", - "version": "v23.12", - "url": "https://pybop-docs.readthedocs.io/en/v23.12/", + "name": "v24.6 (stable)", + "version": "v24.6", + "url": "https://pybop-docs.readthedocs.io/en/v24.6/", "preferred": true + }, + { + "name": "v24.3", + "version": "v24.3", + "url": "https://pybop-docs.readthedocs.io/en/v24.3/" + }, + { + "name": "v23.12", + "version": "v23.12", + "url": "https://pybop-docs.readthedocs.io/en/v23.12/" } ] diff --git a/docs/_templates/autoapi/index.rst b/docs/_templates/autoapi/index.rst index d60759952..7cc11171a 100644 --- a/docs/_templates/autoapi/index.rst +++ b/docs/_templates/autoapi/index.rst @@ -15,4 +15,6 @@ This page contains auto-generated API reference documentation [#f1]_. {% endif %} {% endfor %} + pybop/index + .. [#f1] Created with `sphinx-autoapi `_ diff --git a/docs/conf.py b/docs/conf.py index cfc37a90f..d7c54b116 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,11 +7,11 @@ from pathlib import Path sys.path.append(str(Path(".").resolve())) -from pybop._version import __version__ # noqa: E402 +from pybop._version import __version__ # -- Project information ----------------------------------------------------- project = "PyBOP" -copyright = "2023, The PyBOP Team" +copyright = "2023, The PyBOP Team" # noqa A001 author = "The PyBOP Team" release = f"v{__version__}" @@ -39,7 +39,7 @@ # -- Options for autoapi ------------------------------------------------------- autoapi_type = "python" autoapi_dirs = ["../pybop"] -autoapi_keep_files = True +autoapi_keep_files = False autoapi_root = "api" autoapi_member_order = "groupwise" diff --git a/docs/index.md b/docs/index.md index d45182ae1..4d5621654 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ html_theme.sidebar_secondary.remove: true # PyBOP: Optimise and Parameterise Battery Models -Welcome to PyBOP, a Python package dedicated to the optimization and parameterization of battery models. PyBOP is designed to streamline your workflow, whether you are conducting academic research, working in industry, or simply interested in battery technology and modelling. +Welcome to PyBOP, a Python package dedicated to the optimisation and parameterisation of battery models. PyBOP is designed to streamline your workflow, whether you are conducting academic research, working in industry, or simply interested in battery technology and modelling. ```{gallery-grid} :grid-columns: 1 2 2 2 diff --git a/docs/installation.rst b/docs/installation.rst index 3c0080c1d..6bc3169c2 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -3,7 +3,7 @@ Installation ***************************** -PyBOP is a versatile Python package designed for optimization and parameterization of battery models. Follow the instructions below to install PyBOP and set up your environment to begin utilizing its capabilities. +PyBOP is a versatile Python package designed for optimisation and parameterisation of battery models. Follow the instructions below to install PyBOP and set up your environment to begin utilising its capabilities. Installing PyBOP with pip ------------------------- @@ -55,7 +55,7 @@ To verify that PyBOP has been installed successfully, try running one of the pro For Developers -------------- -If you are installing PyBOP for development purposes, such as contributing to the project, please ensure that you follow the guidelines outlined in the `Contributing Guide <../Contributing.html>`_. It includes additional steps that might be necessary for setting up a development environment, including the installation of dependencies and setup of pre-commit hooks. +If you are installing PyBOP for development purposes, such as contributing to the project, please ensure that you follow the guidelines outlined in the `Contributing Guide `_. It includes additional steps that might be necessary for setting up a development environment, including the installation of dependencies and setup of pre-commit hooks. Further Assistance ------------------ @@ -68,4 +68,4 @@ Next Steps After installing PyBOP, you might want to: * Explore the `Quick Start Guide `_ to begin using PyBOP. -* Check out the `API Reference <../api/index.html>`_ for detailed information on PyBOP's programming interface. +* Check out the `API Reference `_ for detailed information on PyBOP's programming interface. diff --git a/docs/quick_start.rst b/docs/quick_start.rst index 683c82b40..1a1617bd2 100644 --- a/docs/quick_start.rst +++ b/docs/quick_start.rst @@ -6,7 +6,7 @@ Welcome to the Quick Start Guide for PyBOP. This guide will help you get up and Getting Started with PyBOP -------------------------- -PyBOP is equipped with a series of robust tools that can help you optimize various parameters within your battery models to better match empirical data or to explore the effects of different parameters on battery behavior. +PyBOP is equipped with a series of robust tools that can help you optimise various parameters within your battery models to better match empirical data or to explore the effects of different parameters on battery behavior. To begin using PyBOP: @@ -24,14 +24,14 @@ To begin using PyBOP: import pybop - Now you're ready to utilize PyBOP's functionality in your projects! + Now you're ready to utilise PyBOP's functionality in your projects! Exploring Examples ------------------ To help you get acquainted with PyBOP's capabilities, we provide a collection of examples that demonstrate common use cases and features of the package: -- **Jupyter Notebooks**: Interactive notebooks that include detailed explanations alongside the live code, visualizations, and results. These are an excellent resource for learning and can be easily modified and executed to suit your needs. +- **Jupyter Notebooks**: Interactive notebooks that include detailed explanations alongside the live code, visualisations, and results. These are an excellent resource for learning and can be easily modified and executed to suit your needs. - **Python Scripts**: For those who prefer working in a text editor, IDE, or for integrating into larger projects, we provide equivalent examples in plain Python script format. @@ -55,4 +55,4 @@ If you encounter any issues or have questions as you start using PyBOP, don't he - **GitHub Issues**: Report bugs or request new features by opening an `Issue `_ - **GitHub Discussions**: Post your questions or feedback on our `GitHub Discussions `_ -- **Contributions**: Interested in contributing to PyBOP? Check out our `Contributing Guide <../Contributing.html>`_ for guidelines. +- **Contributions**: Interested in contributing to PyBOP? Check out our `Contributing Guide `_ for guidelines. diff --git a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb index 365eb6e1d..9030596d8 100644 --- a/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb +++ b/examples/notebooks/LG_M50_ECM/1-single-pulse-circuit-model.ipynb @@ -7,7 +7,7 @@ "source": [ "## LG M50 Single Pulse Parameter Identification\n", "\n", - "This example presents an experimental parameter identification method for a two-RC circuit model. The data for this notebook is located within the same directory and was obtained from [[1]](https://github.com/WDWidanage/Simscape-Battery-Library/tree/main/Examples/parameterEstimation_TECMD/Data).\n", + "This example presents an experimental parameter identification method for a two-RC circuit model. The data for this notebook is located within the same directory and was obtained from WDWidanage/Simscape-Battery-Library [[1]](https://github.com/WDWidanage/Simscape-Battery-Library/tree/a3842b91b3ccda006bc9be5d59c8bcbd167ceef7/Examples/parameterEstimation_TECMD/Data).\n", "\n", "\n", "### Setting up the Environment\n", @@ -261,7 +261,7 @@ { "type": "scatter", "x": [ - 0.0, + 0, 0.01800000004004687, 0.12100000004284084, 0.25100000004749745, @@ -571,7 +571,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -607,7 +607,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -631,7 +631,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -667,7 +667,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -682,7 +682,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -718,7 +718,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -745,7 +745,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -781,7 +781,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -796,7 +796,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -832,7 +832,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -977,7 +977,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1013,7 +1013,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -1104,7 +1104,7 @@ ], "sequential": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1140,13 +1140,13 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], "sequentialminus": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1182,7 +1182,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ] @@ -1641,7 +1641,7 @@ "source": [ "optim = pybop.PSO(cost, max_unchanged_iterations=55, threshold=1e-6)\n", "x, final_cost = optim.run()\n", - "print(\"Initial parameters:\", cost.x0)\n", + "print(\"Initial parameters:\", optim.x0)\n", "print(\"Estimated parameters:\", x)" ] }, @@ -1679,7 +1679,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, problem_inputs=x, title=\"Optimised Comparison\");" ] }, { @@ -1850,7 +1850,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Parameter Extrapolation\");" + "pybop.quick_plot(problem, problem_inputs=x, title=\"Parameter Extrapolation\");" ] }, { @@ -1888,7 +1888,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.2" + "version": "3.11.7" } }, "nbformat": 4, diff --git a/examples/notebooks/comparing_cost_functions.ipynb b/examples/notebooks/comparing_cost_functions.ipynb new file mode 100644 index 000000000..54faa8011 --- /dev/null +++ b/examples/notebooks/comparing_cost_functions.ipynb @@ -0,0 +1,609 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Investigating different cost functions\n", + "\n", + "In this notebook, we take a look at the different fitting cost functions offered in PyBOP. Cost functions for fitting problems conventionally describe the distance between two points (the target and the prediction) which is to be minimised via PyBOP's optimisation algorithms. \n", + "\n", + "First, we install and import the required packages below." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Requirement already satisfied: pip in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (24.1.2)\n", + "Requirement already satisfied: ipywidgets in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (8.1.3)\n", + "Requirement already satisfied: comm>=0.1.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (0.2.2)\n", + "Requirement already satisfied: ipython>=6.1.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (8.23.0)\n", + "Requirement already satisfied: traitlets>=4.3.1 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (5.14.2)\n", + "Requirement already satisfied: widgetsnbextension~=4.0.11 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (4.0.11)\n", + "Requirement already satisfied: jupyterlab-widgets~=3.0.11 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (3.0.11)\n", + "Requirement already satisfied: decorator in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (5.1.1)\n", + "Requirement already satisfied: jedi>=0.16 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.19.1)\n", + "Requirement already satisfied: matplotlib-inline in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.1.6)\n", + "Requirement already satisfied: prompt-toolkit<3.1.0,>=3.0.41 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (3.0.43)\n", + "Requirement already satisfied: pygments>=2.4.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (2.17.2)\n", + "Requirement already satisfied: stack-data in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.6.3)\n", + "Requirement already satisfied: pexpect>4.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (4.9.0)\n", + "Requirement already satisfied: parso<0.9.0,>=0.8.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from jedi>=0.16->ipython>=6.1.0->ipywidgets) (0.8.4)\n", + "Requirement already satisfied: ptyprocess>=0.5 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets) (0.7.0)\n", + "Requirement already satisfied: wcwidth in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from prompt-toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets) (0.2.13)\n", + "Requirement already satisfied: executing>=1.2.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.0.1)\n", + "Requirement already satisfied: asttokens>=2.1.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.4.1)\n", + "Requirement already satisfied: pure-eval in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (0.2.2)\n", + "Requirement already satisfied: six>=1.12.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from asttokens>=2.1.0->stack-data->ipython>=6.1.0->ipywidgets) (1.16.0)\n", + "Note: you may need to restart the kernel to use updated packages.\n", + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install --upgrade pip ipywidgets\n", + "%pip install pybop -q\n", + "\n", + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "import plotly.io as pio\n", + "\n", + "pio.renderers.default = \"svg\"\n", + "import pybop" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For this notebook, we need to construct parameters, a model and a problem class before we can compare differing cost functions. We start with two parameters, but this is an arbitrary selection and can be expanded given the model and data in question." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "parameters = pybop.Parameters(\n", + " pybop.Parameter(\n", + " \"Positive electrode thickness [m]\",\n", + " prior=pybop.Gaussian(7.56e-05, 0.5e-05),\n", + " bounds=[65e-06, 10e-05],\n", + " ),\n", + " pybop.Parameter(\n", + " \"Positive particle radius [m]\",\n", + " prior=pybop.Gaussian(5.22e-06, 0.5e-06),\n", + " bounds=[2e-06, 9e-06],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, we will construct the Single Particle Model (SPM) with the Chen2020 parameter set, but like the above, this is an arbitrary selection and can be replaced with any PyBOP model." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "parameter_set = pybop.ParameterSet.pybamm(\"Chen2020\")\n", + "model = pybop.lithium_ion.SPM(parameter_set=parameter_set)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Next, as we will need reference data to compare our model predictions to (via the cost function), we will create synthetic data from the model constructed above. " + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "t_eval = np.arange(0, 900, 2)\n", + "values = model.predict(t_eval=t_eval)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can then construct the PyBOP dataset class with the synthetic data as," + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "dataset = pybop.Dataset(\n", + " {\n", + " \"Time [s]\": t_eval,\n", + " \"Current function [A]\": values[\"Current [A]\"].data,\n", + " \"Voltage [V]\": values[\"Voltage [V]\"].data,\n", + " }\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, we can put this all together and construct the problem class. In this situation, we are going to compare differing fitting cost functions, so we construct the `FittingProblem`." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "problem = pybop.FittingProblem(model, parameters, dataset)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Sum of Squared Errors and Root Mean Squared Error\n", + "\n", + "First, let's start with two commonly-used cost functions: the sum of squared errors (SSE) and the root mean squared error (RMSE). Constructing these classes is very concise in PyBOP, and only requires the problem class." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "cost_SSE = pybop.SumSquaredError(problem)\n", + "cost_RMSE = pybop.RootMeanSquaredError(problem)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, we can investigate how these functions differ when fitting the parameters. To acquire the cost value for each of these, we can simply use the call method of the constructed class, such as:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "1.2690291451182834e-09" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cost_SSE([7.56e-05, 5.22e-06])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Alternatively, we can use the `Parameters` class for this," + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[7.56e-05 5.22e-06]\n" + ] + }, + { + "data": { + "text/plain": [ + "1.2690291451182834e-09" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "print(parameters.current_value())\n", + "cost_SSE(parameters.current_value())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If we want to generate a random sample of candidate solutions from the parameter class prior, we can also do that as:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[7.60957550e-05 5.48691392e-06]\n" + ] + }, + { + "data": { + "text/plain": [ + "0.014466013735507651" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "sample = parameters.rvs()\n", + "print(sample)\n", + "cost_SSE(sample)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### Comparing RMSE and SSE\n", + "\n", + "Now, let's vary one of the parameters, and keep a fixed value for the other, to create a scatter plot comparing the cost values for the RMSE and SSE functions." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "4.8μ5.2μ5.4μ5.6μ00.010.020.030.040.050.060.070.08SSERMSE" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "x_range = np.linspace(4.72e-06, 5.72e-06, 75)\n", + "y_SSE = []\n", + "y_RMSE = []\n", + "for i in x_range:\n", + " y_SSE.append(cost_SSE([7.56e-05, i]))\n", + " y_RMSE.append(cost_RMSE([7.56e-05, i]))\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(go.Scatter(x=x_range, y=y_SSE, mode=\"lines\", name=\"SSE\"))\n", + "fig.add_trace(go.Scatter(x=x_range, y=y_RMSE, mode=\"lines\", name=\"RMSE\"))\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In this situation, it's clear that the curvature of the SSE cost is greater than that of the RMSE. This can improve the rate of convergence for certain optimisation algorithms. However, with incorrect hyperparameter values, larger gradients can also result in the algorithm not converging due to sampling locations outside of the \"cost valley\", e.g. infeasible parameter values." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Minkowski distance\n", + "\n", + "Next, let's investigate the Minkowski distance. The Minkowski cost takes a general form, which allows for hyperparameter calibration on the cost function itself, given by\n", + "\n", + "$\\mathcal{L_p} = \\displaystyle \\Big(\\sum_i |\\hat{y_i}-y_i|^p\\Big)^{1/p}$\n", + "\n", + "where $p ≥ 0$ is the order of the Minkowski distance.\n", + "\n", + "For $p = 1$, it is the Manhattan distance. \n", + "For $p = 2$, it is the Euclidean distance. \n", + "For $p ≥ 1$, the Minkowski distance is a metric, but for $04.8μ5.2μ5.4μ5.6μ00.050.10.150.20.25RMSE*Nsqrt(SSE)Minkowski" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "y_minkowski = []\n", + "for i in x_range:\n", + " y_minkowski.append(cost_minkowski([7.56e-05, i]))\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_range,\n", + " y=np.asarray(y_RMSE) * np.sqrt(len(t_eval)),\n", + " mode=\"lines\",\n", + " name=\"RMSE*N\",\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_range,\n", + " y=np.sqrt(y_SSE),\n", + " mode=\"lines\",\n", + " line=dict(dash=\"dash\"),\n", + " name=\"sqrt(SSE)\",\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_range, y=y_minkowski, mode=\"lines\", line=dict(dash=\"dot\"), name=\"Minkowski\"\n", + " )\n", + ")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As expected, these lines lie on top of one another. Now, let's take a look at how the Minkowski cost changes for different orders, `p`." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "p_orders = np.append(0.75, np.linspace(1, 3, 5))\n", + "y_minkowski = tuple(\n", + " [pybop.Minkowski(problem, p=j)([7.56e-05, i]) for i in x_range] for j in p_orders\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "4.8μ5.2μ5.4μ5.6μ00.10.20.30.40.50.60.7Minkowski 0.75Minkowski 1.0Minkowski 1.5Minkowski 2.0Minkowski 2.5Minkowski 3.0" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig = go.Figure()\n", + "for k, _ in enumerate(p_orders):\n", + " fig.add_trace(\n", + " go.Scatter(x=x_range, y=y_minkowski[k], mode=\"lines\", name=f\"Minkowski {_}\")\n", + " )\n", + "fig.update_yaxes(range=[0, np.max(y_minkowski[2])])\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As seen above, the Minkowski cost allows for a range of different cost functions to be created. This provides users with another hyperparameter to calibrate for optimisation algorithm convergence. This addition does expand the global search space, and should be carefully considered before deciding upon.\n", + "\n", + "### Sum of Power\n", + "Next, we introduce a similar cost function, the `SumofPower` implementation. This cost function is the $p$-th power of the Minkowski distance of order $p$. It provides a generalised formulation for the Sum of Squared Errors (SSE) cost function, and is given by,\n", + "\n", + "$\\mathcal{L_p} = \\displaystyle \\sum_i |\\hat{y_i}-y_i|^p$\n", + "\n", + "where $p ≥ 0$ is the power order. A few special cases include,\n", + "\n", + "$p = 1$: Sum of Absolute Differences\n", + "$p = 2$: Sum of Squared Differences\n", + "\n", + "Next we repeat the above examples with the addition of the `SumofPower` class." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "cost_sumofpower = pybop.SumofPower(problem, p=2)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "4.8μ5.2μ5.4μ5.6μ00.050.10.150.20.25RMSE*NSSESum of Power" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "y_sumofpower = []\n", + "for i in x_range:\n", + " y_sumofpower.append(cost_sumofpower([7.56e-05, i]))\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_range,\n", + " y=np.asarray(y_RMSE) * np.sqrt(len(t_eval)),\n", + " mode=\"lines\",\n", + " name=\"RMSE*N\",\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_range,\n", + " y=y_SSE,\n", + " mode=\"lines\",\n", + " line=dict(dash=\"dash\"),\n", + " name=\"SSE\",\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_range,\n", + " y=y_sumofpower,\n", + " mode=\"lines\",\n", + " line=dict(dash=\"dot\"),\n", + " name=\"Sum of Power\",\n", + " )\n", + ")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As expected, the `SumofPower` with order `p=2` equates to the `SSE` implementation. Next, we compare the `Minkowski` to the `SumofPower`," + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "p_orders = np.append(0.75, np.linspace(1, 2, 2))\n", + "\n", + "y_minkowski = tuple(\n", + " [pybop.Minkowski(problem, p=j)([7.56e-05, i]) for i in x_range] for j in p_orders\n", + ")\n", + "\n", + "y_sumofpower = tuple(\n", + " [pybop.SumofPower(problem, p=j)([7.56e-05, i]) for i in x_range] for j in p_orders\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "4.8μ5.2μ5.4μ5.6μ00.511.522.5Minkowski 0.75Sum of Power 0.75Minkowski 1.0Sum of Power 1.0Minkowski 2.0Sum of Power 2.0" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig = go.Figure()\n", + "for k, _ in enumerate(p_orders):\n", + " fig.add_trace(\n", + " go.Scatter(x=x_range, y=y_minkowski[k], mode=\"lines\", name=f\"Minkowski {_}\")\n", + " )\n", + " fig.add_trace(\n", + " go.Scatter(\n", + " x=x_range,\n", + " y=y_sumofpower[k],\n", + " mode=\"lines\",\n", + " line=dict(dash=\"dash\"),\n", + " name=f\"Sum of Power {_}\",\n", + " )\n", + " )\n", + "fig.update_yaxes(range=[0, 2.5])\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The figure demonstrates the distinct behaviour of the `Minkowski` distance and the `SumofPower` function. One notable difference is the effect of the `1/p` exponent in the `Minkowski` distance, which has a linearising impact on the response. This linearisation can enhance the robustness of certain optimisation algorithms, potentially making them less sensitive to outliers or extreme values. However, this increased robustness may come at the cost of a slower convergence rate, as the linearised response might require more iterations to reach the optimal solution. In contrast, the `SumofPower` function does not exhibit this linearising effect, which can lead to faster convergence in some cases but may be more susceptible to the influence of outliers or extreme values.\n", + "\n", + "In this notebook, we've shown the different fitting cost functions offered in PyBOP. Selection between these functions can affect the optimisation result in the case that the optimiser hyperparameter values are not properly calibrated. " + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pybop-3.12", + "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.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/examples/notebooks/equivalent_circuit_identification.ipynb b/examples/notebooks/equivalent_circuit_identification.ipynb index 8a13a199e..6184c191a 100644 --- a/examples/notebooks/equivalent_circuit_identification.ipynb +++ b/examples/notebooks/equivalent_circuit_identification.ipynb @@ -419,7 +419,7 @@ "source": [ "optim = pybop.CMAES(cost, max_iterations=300)\n", "x, final_cost = optim.run()\n", - "print(\"Initial parameters:\", cost.x0)\n", + "print(\"Initial parameters:\", optim.x0)\n", "print(\"Estimated parameters:\", x)" ] }, @@ -457,7 +457,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, problem_inputs=x, title=\"Optimised Comparison\");" ] }, { diff --git a/examples/notebooks/multi_model_identification.ipynb b/examples/notebooks/multi_model_identification.ipynb index e7c6b158c..a66a78f2b 100644 --- a/examples/notebooks/multi_model_identification.ipynb +++ b/examples/notebooks/multi_model_identification.ipynb @@ -3905,7 +3905,7 @@ "source": [ "for optim, x in zip(optims, xs):\n", " pybop.quick_plot(\n", - " optim.cost.problem, parameter_values=x, title=optim.cost.problem.model.name\n", + " optim.cost.problem, problem_inputs=x, title=optim.cost.problem.model.name\n", " )" ] }, diff --git a/examples/notebooks/multi_optimiser_identification.ipynb b/examples/notebooks/multi_optimiser_identification.ipynb index 8b2a83500..c4fc3b929 100644 --- a/examples/notebooks/multi_optimiser_identification.ipynb +++ b/examples/notebooks/multi_optimiser_identification.ipynb @@ -36,27 +36,42 @@ "name": "stdout", "output_type": "stream", "text": [ - "Requirement already satisfied: pip in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (24.0)\n", - "Requirement already satisfied: ipywidgets in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (8.1.2)\n", - "Requirement already satisfied: comm>=0.1.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (0.2.2)\n", - "Requirement already satisfied: ipython>=6.1.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (8.23.0)\n", - "Requirement already satisfied: traitlets>=4.3.1 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (5.14.2)\n", - "Requirement already satisfied: widgetsnbextension~=4.0.10 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (4.0.10)\n", - "Requirement already satisfied: jupyterlab-widgets~=3.0.10 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (3.0.10)\n", - "Requirement already satisfied: decorator in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (5.1.1)\n", - "Requirement already satisfied: jedi>=0.16 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.19.1)\n", - "Requirement already satisfied: matplotlib-inline in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.1.6)\n", - "Requirement already satisfied: prompt-toolkit<3.1.0,>=3.0.41 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (3.0.43)\n", - "Requirement already satisfied: pygments>=2.4.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (2.17.2)\n", - "Requirement already satisfied: stack-data in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.6.3)\n", - "Requirement already satisfied: pexpect>4.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (4.9.0)\n", - "Requirement already satisfied: parso<0.9.0,>=0.8.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from jedi>=0.16->ipython>=6.1.0->ipywidgets) (0.8.4)\n", - "Requirement already satisfied: ptyprocess>=0.5 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets) (0.7.0)\n", - "Requirement already satisfied: wcwidth in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from prompt-toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets) (0.2.13)\n", - "Requirement already satisfied: executing>=1.2.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.0.1)\n", - "Requirement already satisfied: asttokens>=2.1.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.4.1)\n", - "Requirement already satisfied: pure-eval in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (0.2.2)\n", - "Requirement already satisfied: six>=1.12.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from asttokens>=2.1.0->stack-data->ipython>=6.1.0->ipywidgets) (1.16.0)\n", + "Requirement already satisfied: pip in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (24.0)\n", + "Collecting pip\n", + " Using cached pip-24.1.1-py3-none-any.whl.metadata (3.6 kB)\n", + "Collecting ipywidgets\n", + " Using cached ipywidgets-8.1.3-py3-none-any.whl.metadata (2.4 kB)\n", + "Requirement already satisfied: comm>=0.1.3 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipywidgets) (0.2.2)\n", + "Requirement already satisfied: ipython>=6.1.0 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipywidgets) (8.26.0)\n", + "Requirement already satisfied: traitlets>=4.3.1 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipywidgets) (5.14.3)\n", + "Collecting widgetsnbextension~=4.0.11 (from ipywidgets)\n", + " Using cached widgetsnbextension-4.0.11-py3-none-any.whl.metadata (1.6 kB)\n", + "Collecting jupyterlab-widgets~=3.0.11 (from ipywidgets)\n", + " Using cached jupyterlab_widgets-3.0.11-py3-none-any.whl.metadata (4.1 kB)\n", + "Requirement already satisfied: decorator in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (5.1.1)\n", + "Requirement already satisfied: jedi>=0.16 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.19.1)\n", + "Requirement already satisfied: matplotlib-inline in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.1.7)\n", + "Requirement already satisfied: prompt-toolkit<3.1.0,>=3.0.41 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (3.0.47)\n", + "Requirement already satisfied: pygments>=2.4.0 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (2.18.0)\n", + "Requirement already satisfied: stack-data in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.6.3)\n", + "Requirement already satisfied: pexpect>4.3 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (4.9.0)\n", + "Requirement already satisfied: parso<0.9.0,>=0.8.3 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from jedi>=0.16->ipython>=6.1.0->ipywidgets) (0.8.4)\n", + "Requirement already satisfied: ptyprocess>=0.5 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets) (0.7.0)\n", + "Requirement already satisfied: wcwidth in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from prompt-toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets) (0.2.13)\n", + "Requirement already satisfied: executing>=1.2.0 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.0.1)\n", + "Requirement already satisfied: asttokens>=2.1.0 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.4.1)\n", + "Requirement already satisfied: pure-eval in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (0.2.2)\n", + "Requirement already satisfied: six>=1.12.0 in /home/engs2510/.pyenv/versions/3.12.2/envs/pybop/lib/python3.12/site-packages (from asttokens>=2.1.0->stack-data->ipython>=6.1.0->ipywidgets) (1.16.0)\n", + "Using cached pip-24.1.1-py3-none-any.whl (1.8 MB)\n", + "Using cached ipywidgets-8.1.3-py3-none-any.whl (139 kB)\n", + "Using cached jupyterlab_widgets-3.0.11-py3-none-any.whl (214 kB)\n", + "Using cached widgetsnbextension-4.0.11-py3-none-any.whl (2.3 MB)\n", + "Installing collected packages: widgetsnbextension, pip, jupyterlab-widgets, ipywidgets\n", + " Attempting uninstall: pip\n", + " Found existing installation: pip 24.0\n", + " Uninstalling pip-24.0:\n", + " Successfully uninstalled pip-24.0\n", + "Successfully installed ipywidgets-8.1.3 jupyterlab-widgets-3.0.11 pip-24.1.1 widgetsnbextension-4.0.11\n", "Note: you may need to restart the kernel to use updated packages.\n", "Note: you may need to restart the kernel to use updated packages.\n" ] @@ -307,6 +322,7 @@ " pybop.PSO,\n", " pybop.XNES,\n", " pybop.NelderMead,\n", + " pybop.CuckooSearch,\n", "]\n", "\n", "scipy_optimisers = [\n", @@ -346,7 +362,147 @@ "Running AdamW\n", "NOTE: Boundaries ignored by AdamW\n", "Running GradientDescent\n", - "NOTE: Boundaries ignored by Gradient Descent\n", + "NOTE: Boundaries ignored by \n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", + "Error: Error in Function::call for 'event_0' [MXFunction] at .../casadi/core/function.cpp:361:\n", + ".../casadi/core/function_internal.hpp:1649: Input 1 (i1) has mismatching shape. Got 100-by-1. Allowed dimensions, in general, are:\n", + " - The input dimension N-by-M (here 300-by-1)\n", + " - A scalar, i.e. 1-by-1\n", + " - M-by-N if N=1 or M=1 (i.e. a transposed vector)\n", + " - N-by-M1 if K*M1=M for some K (argument repeated horizontally)\n", + " - N-by-P*M, indicating evaluation with multiple arguments (P must be a multiple of 1 for consistency with previous inputs)\n", "Running IRPropMin\n" ] } @@ -378,7 +534,8 @@ "Running PSO\n", "Running XNES\n", "Running NelderMead\n", - "NOTE: Boundaries ignored by NelderMead\n" + "NOTE: Boundaries ignored by \n", + "Running CuckooSearch\n" ] } ], @@ -441,16 +598,17 @@ "name": "stdout", "output_type": "stream", "text": [ - "| Optimiser: AdamW | Results: [0.80186169 0.66943058] |\n", - "| Optimiser: Gradient descent | Results: [0.44491146 1.59642543] |\n", - "| Optimiser: iRprop- | Results: [0.8 0.66516386] |\n", - "| Optimiser: Covariance Matrix Adaptation Evolution Strategy (CMA-ES) | Results: [0.7999994 0.66516056] |\n", - "| Optimiser: Seperable Natural Evolution Strategy (SNES) | Results: [0.79672265 0.66566242] |\n", - "| Optimiser: Particle Swarm Optimisation (PSO) | Results: [0.79978922 0.66557426] |\n", - "| Optimiser: Exponential Natural Evolution Strategy (xNES) | Results: [0.79992605 0.66513294] |\n", - "| Optimiser: Nelder-Mead | Results: [0.81389091 0.66318217] |\n", - "| Optimiser: SciPyMinimize | Results: [0.63594266 0.7 ] |\n", - "| Optimiser: SciPyDifferentialEvolution | Results: [0.79999973 0.6651644 ] |\n" + "| Optimiser: AdamW | Results: [0.79283046 0.66146761] |\n", + "| Optimiser: Gradient descent | Results: [0.54971799 0.92691691] |\n", + "| Optimiser: iRprop- | Results: [0.72245096 0.67281911] |\n", + "| Optimiser: Covariance Matrix Adaptation Evolution Strategy (CMA-ES) | Results: [0.72099365 0.67312846] |\n", + "| Optimiser: Seperable Natural Evolution Strategy (SNES) | Results: [0.72092695 0.67313321] |\n", + "| Optimiser: Particle Swarm Optimisation (PSO) | Results: [0.71681934 0.67366943] |\n", + "| Optimiser: Exponential Natural Evolution Strategy (xNES) | Results: [0.71352763 0.67470134] |\n", + "| Optimiser: Nelder-Mead | Results: [0.72127038 0.67308243] |\n", + "| Optimiser: Cuckoo Search | Results: [0.70772893 0.67571981] |\n", + "| Optimiser: SciPyMinimize | Results: [0.62747952 0.7 ] |\n", + "| Optimiser: SciPyDifferentialEvolution | Results: [0.72100138 0.67312735] |\n" ] } ], @@ -509,7 +667,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelAdamWTime / sVoltage / V" + "05001000150020003.53.63.73.83.94ReferenceModelAdamWTime / sVoltage / V" ] }, "metadata": {}, @@ -518,7 +676,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelGradient descentTime / sVoltage / V" + "05001000150020003.53.63.73.83.944.1ReferenceModelGradient descentTime / sVoltage / V" ] }, "metadata": {}, @@ -527,7 +685,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModeliRprop-Time / sVoltage / V" + "05001000150020003.53.63.73.83.94ReferenceModeliRprop-Time / sVoltage / V" ] }, "metadata": {}, @@ -536,7 +694,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelCovariance Matrix Adaptation Evolution Strategy (CMA-ES)Time / sVoltage / V" + "05001000150020003.53.63.73.83.94ReferenceModelCovariance Matrix Adaptation Evolution Strategy (CMA-ES)Time / sVoltage / V" ] }, "metadata": {}, @@ -545,7 +703,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelSeperable Natural Evolution Strategy (SNES)Time / sVoltage / V" + "05001000150020003.53.63.73.83.94ReferenceModelSeperable Natural Evolution Strategy (SNES)Time / sVoltage / V" ] }, "metadata": {}, @@ -554,7 +712,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelParticle Swarm Optimisation (PSO)Time / sVoltage / V" + "05001000150020003.53.63.73.83.94ReferenceModelParticle Swarm Optimisation (PSO)Time / sVoltage / V" ] }, "metadata": {}, @@ -563,7 +721,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelExponential Natural Evolution Strategy (xNES)Time / sVoltage / V" + "05001000150020003.53.63.73.83.94ReferenceModelExponential Natural Evolution Strategy (xNES)Time / sVoltage / V" ] }, "metadata": {}, @@ -572,7 +730,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelNelder-MeadTime / sVoltage / V" + "05001000150020003.53.63.73.83.94ReferenceModelNelder-MeadTime / sVoltage / V" ] }, "metadata": {}, @@ -581,7 +739,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelSciPyMinimizeTime / sVoltage / V" + "05001000150020003.53.63.73.83.94ReferenceModelCuckoo SearchTime / sVoltage / V" ] }, "metadata": {}, @@ -590,7 +748,16 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelSciPyDifferentialEvolutionTime / sVoltage / V" + "05001000150020003.53.63.73.83.94ReferenceModelSciPyMinimizeTime / sVoltage / V" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "05001000150020003.53.63.73.83.94ReferenceModelSciPyDifferentialEvolutionTime / sVoltage / V" ] }, "metadata": {}, @@ -599,7 +766,7 @@ ], "source": [ "for optim, x in zip(optims, xs):\n", - " pybop.quick_plot(optim.cost.problem, parameter_values=x, title=optim.name())" + " pybop.quick_plot(optim.cost.problem, problem_inputs=x, title=optim.name())" ] }, { @@ -627,7 +794,16 @@ { "data": { "image/svg+xml": [ - "51015202530012345AdamWIterationCost" + "5101520253000.511.522.533.5AdamWIterationCost" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "1020300.60.650.70.750.80.851020300.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -636,7 +812,7 @@ { "data": { "image/svg+xml": [ - "05101520250.60.650.70.750.80.8505101520250.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "510152011.522.533.5Gradient descentIterationCost" ] }, "metadata": {}, @@ -645,7 +821,7 @@ { "data": { "image/svg+xml": [ - "510152024681012141618Gradient descentIterationCost" + "510152000.10.20.30.40.50.60.70.851015200.40.50.60.70.80.911.11.21.3Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -654,7 +830,7 @@ { "data": { "image/svg+xml": [ - "0510150510152025300510150.511.522.5Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "10203040506000.511.522.533.5iRprop-IterationCost" ] }, "metadata": {}, @@ -663,7 +839,7 @@ { "data": { "image/svg+xml": [ - "10203040012345iRprop-IterationCost" + "2040600.650.70.750.82040600.50.550.60.65Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -672,7 +848,7 @@ { "data": { "image/svg+xml": [ - "0102030400.60.650.70.750.80102030400.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "1020304000.511.522.53Covariance Matrix Adaptation Evolution Strategy (CMA-ES)IterationCost" ] }, "metadata": {}, @@ -681,7 +857,7 @@ { "data": { "image/svg+xml": [ - "1020304000.511.522.53Covariance Matrix Adaptation Evolution Strategy (CMA-ES)IterationCost" + "501001502002500.50.550.60.650.70.750.8501001502002500.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -690,7 +866,7 @@ { "data": { "image/svg+xml": [ - "0501001502002500.550.60.650.70.750.80501001502002500.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "10203040506000.511.522.5Seperable Natural Evolution Strategy (SNES)IterationCost" ] }, "metadata": {}, @@ -699,7 +875,7 @@ { "data": { "image/svg+xml": [ - "10203040506000.511.522.533.5Seperable Natural Evolution Strategy (SNES)IterationCost" + "1002003000.550.60.650.70.750.81002003000.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -708,7 +884,7 @@ { "data": { "image/svg+xml": [ - "01002003000.550.60.650.70.750.801002003000.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "10203040500.0030.00350.0040.00450.005Particle Swarm Optimisation (PSO)IterationCost" ] }, "metadata": {}, @@ -717,7 +893,7 @@ { "data": { "image/svg+xml": [ - "1020304000.511.522.5Particle Swarm Optimisation (PSO)IterationCost" + "501001502002500.50.550.60.650.70.750.8501001502002500.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -726,7 +902,7 @@ { "data": { "image/svg+xml": [ - "0501001502000.50.550.60.650.70.750.80501001502000.40.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "10203040506000.511.52Exponential Natural Evolution Strategy (xNES)IterationCost" ] }, "metadata": {}, @@ -735,7 +911,7 @@ { "data": { "image/svg+xml": [ - "10203040506000.511.522.5Exponential Natural Evolution Strategy (xNES)IterationCost" + "1002003000.540.560.580.60.620.640.660.680.70.721002003000.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -744,7 +920,7 @@ { "data": { "image/svg+xml": [ - "01002003000.60.650.70.750.801002003000.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "10203040506000.511.522.533.5Nelder-MeadIterationCost" ] }, "metadata": {}, @@ -753,7 +929,7 @@ { "data": { "image/svg+xml": [ - "10203040506000.511.522.533.5Nelder-MeadIterationCost" + "2040600.620.640.660.680.70.722040600.50.550.60.650.70.75Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -762,7 +938,7 @@ { "data": { "image/svg+xml": [ - "02040600.60.650.70.750.80.8502040600.450.50.550.60.650.70.750.80.850.9Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "1020304050600.0030.00350.0040.00450.005Cuckoo SearchIterationCost" ] }, "metadata": {}, @@ -771,7 +947,7 @@ { "data": { "image/svg+xml": [ - "510152025012345SciPyMinimizeIterationCost" + "1002003000.50.550.60.650.70.750.81002003000.40.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -780,7 +956,7 @@ { "data": { "image/svg+xml": [ - "05101520250.60.610.620.630.640.650.660.6705101520250.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "510152000.10.20.30.40.50.6SciPyMinimizeIterationCost" ] }, "metadata": {}, @@ -789,7 +965,7 @@ { "data": { "image/svg+xml": [ - "510152025300.00360.00380.0040.00420.0044SciPyDifferentialEvolutionIterationCost" + "102030400.560.580.60.620.640.660.680.70.72102030400.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -798,7 +974,16 @@ { "data": { "image/svg+xml": [ - "05101520250.7550.760.7650.770.7750.780.7850.790.7950.805101520250.6650.6660.6670.6680.6690.67Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "51015200.00290.0030.00310.00320.00330.00340.0035SciPyDifferentialEvolutionIterationCost" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "2004006000.50.550.60.650.70.750.82004006000.40.450.50.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -835,7 +1020,16 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5AdamWNegative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4AdamWNegative electrode active material volume fractionPositive electrode active material volume fraction" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4Gradient descentNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -844,7 +1038,7 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5Gradient descentNegative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4iRprop-Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -853,7 +1047,7 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5iRprop-Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4Covariance Matrix Adaptation Evolution Strategy (CMA-ES)Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -862,7 +1056,7 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5Covariance Matrix Adaptation Evolution Strategy (CMA-ES)Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4Seperable Natural Evolution Strategy (SNES)Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -871,7 +1065,7 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5Seperable Natural Evolution Strategy (SNES)Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4Particle Swarm Optimisation (PSO)Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -880,7 +1074,7 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5Particle Swarm Optimisation (PSO)Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4Exponential Natural Evolution Strategy (xNES)Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -889,7 +1083,7 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5Exponential Natural Evolution Strategy (xNES)Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4Nelder-MeadNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -898,7 +1092,7 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5Nelder-MeadNegative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4Cuckoo SearchNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -907,7 +1101,7 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5SciPyMinimizeNegative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4SciPyMinimizeNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -916,7 +1110,7 @@ { "data": { "image/svg+xml": [ - "0.50.550.60.650.70.750.80.50.550.60.650.70.750.80.511.522.533.5SciPyDifferentialEvolutionNegative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.550.60.650.70.750.80.550.60.650.70.750.80.40.81.21.622.4SciPyDifferentialEvolutionNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, diff --git a/examples/notebooks/optimiser_calibration.ipynb b/examples/notebooks/optimiser_calibration.ipynb index accfbf259..3b09cd377 100644 --- a/examples/notebooks/optimiser_calibration.ipynb +++ b/examples/notebooks/optimiser_calibration.ipynb @@ -126,9 +126,24 @@ "outputs": [], "source": [ "parameter_set = pybop.ParameterSet.pybamm(\"Chen2020\")\n", + "parameter_set.update(\n", + " {\n", + " \"Negative electrode active material volume fraction\": 0.65,\n", + " \"Positive electrode active material volume fraction\": 0.51,\n", + " }\n", + ")\n", "model = pybop.lithium_ion.SPM(parameter_set=parameter_set)\n", - "t_eval = np.arange(0, 900, 3)\n", - "values = model.predict(t_eval=t_eval)" + "init_soc = 0.4\n", + "experiment = pybop.Experiment(\n", + " [\n", + " (\n", + " \"Discharge at 0.5C for 6 minutes (4 second period)\",\n", + " \"Charge at 0.5C for 6 minutes (4 second period)\",\n", + " ),\n", + " ]\n", + " * 2\n", + ")\n", + "values = model.predict(init_soc=init_soc, experiment=experiment)" ] }, { @@ -153,8 +168,10 @@ }, "outputs": [], "source": [ - "sigma = 0.001\n", - "corrupt_values = values[\"Voltage [V]\"].data + np.random.normal(0, sigma, len(t_eval))" + "sigma = 0.002\n", + "corrupt_values = values[\"Voltage [V]\"].data + np.random.normal(\n", + " 0, sigma, len(values[\"Voltage [V]\"].data)\n", + ")" ] }, { @@ -200,7 +217,7 @@ "source": [ "dataset = pybop.Dataset(\n", " {\n", - " \"Time [s]\": t_eval,\n", + " \"Time [s]\": values[\"Time [s]\"].data,\n", " \"Current function [A]\": values[\"Current [A]\"].data,\n", " \"Voltage [V]\": corrupt_values,\n", " }\n", @@ -235,13 +252,15 @@ "parameters = pybop.Parameters(\n", " pybop.Parameter(\n", " \"Negative electrode active material volume fraction\",\n", - " prior=pybop.Gaussian(0.7, 0.025),\n", - " bounds=[0.6, 0.9],\n", + " prior=pybop.Uniform(0.45, 0.7),\n", + " bounds=[0.4, 0.8],\n", + " true_value=0.65,\n", " ),\n", " pybop.Parameter(\n", " \"Positive electrode active material volume fraction\",\n", - " prior=pybop.Gaussian(0.6, 0.025),\n", - " bounds=[0.5, 0.8],\n", + " prior=pybop.Uniform(0.45, 0.7),\n", + " bounds=[0.4, 0.8],\n", + " true_value=0.51,\n", " ),\n", ")" ] @@ -279,7 +298,7 @@ } ], "source": [ - "problem = pybop.FittingProblem(model, parameters, dataset)\n", + "problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc)\n", "cost = pybop.SumSquaredError(problem)\n", "optim = pybop.GradientDescent(cost, sigma0=0.2, max_iterations=100)" ] @@ -307,26 +326,7 @@ }, "id": "-9OVt0EQ04qB" }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n", - "Error: Events ['Maximum voltage [V]'] are non-positive at initial conditions\n" - ] - } - ], + "outputs": [], "source": [ "x, final_cost = optim.run()" ] @@ -362,7 +362,7 @@ { "data": { "text/plain": [ - "array([0.70742414, 0.58383355])" + "array([0.64609807, 0.51472958])" ] }, "execution_count": 9, @@ -396,7 +396,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.753.83.853.93.9544.05ReferenceModelOptimised ComparisonTime / sVoltage / V" + "0500100015003.53.553.63.653.7ReferenceModelOptimised ComparisonTime / sVoltage / V" ] }, "metadata": {}, @@ -404,7 +404,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, problem_inputs=x, title=\"Optimised Comparison\");" ] }, { @@ -434,26 +434,30 @@ "text": [ "0.001\n", "NOTE: Boundaries ignored by Gradient Descent\n", - "0.0045\n", + "0.012285714285714285\n", + "NOTE: Boundaries ignored by Gradient Descent\n", + "0.023571428571428573\n", + "NOTE: Boundaries ignored by Gradient Descent\n", + "0.03485714285714286\n", "NOTE: Boundaries ignored by Gradient Descent\n", - "0.008\n", + "0.046142857142857145\n", "NOTE: Boundaries ignored by Gradient Descent\n", - "0.0115\n", + "0.05742857142857143\n", "NOTE: Boundaries ignored by Gradient Descent\n", - "0.015\n", + "0.06871428571428571\n", + "NOTE: Boundaries ignored by Gradient Descent\n", + "0.08\n", "NOTE: Boundaries ignored by Gradient Descent\n" ] } ], "source": [ - "sigmas = np.linspace(\n", - " 0.001, 0.015, 5\n", - ") # Change this to a smaller range for a quicker run\n", + "sigmas = np.linspace(0.001, 0.08, 8) # Change this to a smaller range for a quicker run\n", "xs = []\n", "optims = []\n", "for sigma in sigmas:\n", " print(sigma)\n", - " problem = pybop.FittingProblem(model, parameters, dataset)\n", + " problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc)\n", " cost = pybop.SumSquaredError(problem)\n", " optim = pybop.GradientDescent(cost, sigma0=sigma, max_iterations=100)\n", " x, final_cost = optim.run()\n", @@ -477,11 +481,14 @@ "name": "stdout", "output_type": "stream", "text": [ - "| Sigma: 0.001 | Num Iterations: 100 | Best Cost: 0.0013289907848209911 | Results: [0.69535773 0.67509662] |\n", - "| Sigma: 0.0045 | Num Iterations: 100 | Best Cost: 0.0007218197918308683 | Results: [0.71892626 0.67060898] |\n", - "| Sigma: 0.008 | Num Iterations: 100 | Best Cost: 0.0006371022763628136 | Results: [0.72396797 0.6696914 ] |\n", - "| Sigma: 0.0115 | Num Iterations: 18 | Best Cost: 0.0004608694532019237 | Results: [0.74070995 0.6667419 ] |\n", - "| Sigma: 0.015 | Num Iterations: 100 | Best Cost: 0.0007468897676990436 | Results: [0.71758655 0.67085529] |\n" + "| Sigma: 0.001 | Num Iterations: 100 | Best Cost: 0.008590687346571011 | Results: [0.58273999 0.64430015] |\n", + "| Sigma: 0.012285714285714285 | Num Iterations: 100 | Best Cost: 0.0017482878947612424 | Results: [0.62229759 0.5406604 ] |\n", + "| Sigma: 0.023571428571428573 | Num Iterations: 100 | Best Cost: 0.0013871420979637958 | Results: [0.63941964 0.52140605] |\n", + "| Sigma: 0.03485714285714286 | Num Iterations: 100 | Best Cost: 0.001571369568098984 | Results: [0.62907481 0.53267599] |\n", + "| Sigma: 0.046142857142857145 | Num Iterations: 28 | Best Cost: 0.0013533853388748253 | Results: [0.64673791 0.51409832] |\n", + "| Sigma: 0.05742857142857143 | Num Iterations: 25 | Best Cost: 0.0013584031053821507 | Results: [0.64390064 0.51673076] |\n", + "| Sigma: 0.06871428571428571 | Num Iterations: 74 | Best Cost: 0.0013568172573032275 | Results: [0.64444354 0.51631924] |\n", + "| Sigma: 0.08 | Num Iterations: 73 | Best Cost: 0.0013551215844470215 | Results: [0.64505654 0.51551585] |\n" ] } ], @@ -514,7 +521,34 @@ { "data": { "image/svg+xml": [ - "2040608010000.050.10.150.2Sigma: 0.001IterationCost" + "204060801000.00850.0090.00950.010.01050.0110.01150.012Sigma: 0.001IterationCost" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "0204060800.5840.5860.5880.590.5920.5940204060800.6440.6460.6480.650.6520.6540.6560.658Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "2040608010000.0050.010.0150.020.0250.030.035Sigma: 0.012285714285714285IterationCost" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "0204060800.540.560.580.60.620204060800.520.5250.530.5350.540.5450.550.555Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -523,7 +557,7 @@ { "data": { "image/svg+xml": [ - "0204060800.680.6820.6840.6860.6880.690.6920.6940.6960204060800.610.620.630.640.650.660.67Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "2040608010000.020.040.060.080.1Sigma: 0.023571428571428573IterationCost" ] }, "metadata": {}, @@ -532,7 +566,7 @@ { "data": { "image/svg+xml": [ - "2040608010000.050.10.150.20.25Sigma: 0.0045IterationCost" + "0204060800.50.520.540.560.580.60.620.640204060800.450.460.470.480.490.50.510.520.530.54Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -541,7 +575,7 @@ { "data": { "image/svg+xml": [ - "0204060800.70.7050.710.7150.720204060800.60.610.620.630.640.650.660.67Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "204060801000.0050.010.0150.02Sigma: 0.03485714285714286IterationCost" ] }, "metadata": {}, @@ -550,7 +584,7 @@ { "data": { "image/svg+xml": [ - "2040608010000.050.10.150.20.25Sigma: 0.008IterationCost" + "0204060800.570.580.590.60.610.620.630204060800.540.560.580.60.620.640.660.680.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -559,7 +593,7 @@ { "data": { "image/svg+xml": [ - "0204060800.70.7050.710.7150.720.7250204060800.60.610.620.630.640.650.660.67Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "5101520250.0020.0040.0060.0080.010.0120.014Sigma: 0.046142857142857145IterationCost" ] }, "metadata": {}, @@ -568,7 +602,7 @@ { "data": { "image/svg+xml": [ - "5101500.010.020.030.04Sigma: 0.0115IterationCost" + "05101520250.650.660.670.680.6905101520250.520.530.540.550.56Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -577,7 +611,7 @@ { "data": { "image/svg+xml": [ - "0510150.7350.7360.7370.7380.7390.740.7410510150.6350.640.6450.650.6550.660.665Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "5101520250.001350.00140.001450.00150.001550.00160.00165Sigma: 0.05742857142857143IterationCost" ] }, "metadata": {}, @@ -586,7 +620,7 @@ { "data": { "image/svg+xml": [ - "2040608010000.10.20.30.40.5Sigma: 0.015IterationCost" + "051015200.6340.6360.6380.640.6420.644051015200.5150.51550.5160.51650.5170.51750.5180.51850.5190.5195Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -595,7 +629,34 @@ { "data": { "image/svg+xml": [ - "0204060800.660.670.680.690.70.710.720204060800.580.60.620.640.660.680.70.720.740.76Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "1020304050607000.0050.010.0150.020.0250.030.0350.04Sigma: 0.06871428571428571IterationCost" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "02040600.610.620.630.640.650.660.670.680.6902040600.520.540.560.580.60.620.64Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "102030405060700.0020.0040.0060.0080.01Sigma: 0.08IterationCost" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "02040600.560.570.580.590.60.610.620.630.640.6502040600.520.530.540.550.560.57Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -632,7 +693,34 @@ { "data": { "image/svg+xml": [ - "0.60.650.70.750.80.850.90.50.550.60.650.70.750.80.40.81.21.622.4Sigma: 0.001Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.40.50.60.70.80.40.450.50.550.60.650.70.750.80.10.20.30.4Sigma: 0.001Negative electrode active material volume fractionPositive electrode active material volume fraction" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "0.40.50.60.70.80.40.450.50.550.60.650.70.750.80.10.20.30.4Sigma: 0.012285714285714285Negative electrode active material volume fractionPositive electrode active material volume fraction" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "0.40.50.60.70.80.40.450.50.550.60.650.70.750.80.10.20.30.4Sigma: 0.023571428571428573Negative electrode active material volume fractionPositive electrode active material volume fraction" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/svg+xml": [ + "0.40.50.60.70.80.40.450.50.550.60.650.70.750.80.10.20.30.4Sigma: 0.03485714285714286Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -641,7 +729,7 @@ { "data": { "image/svg+xml": [ - "0.60.650.70.750.80.850.90.50.550.60.650.70.750.80.40.81.21.622.4Sigma: 0.0045Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.40.50.60.70.80.40.450.50.550.60.650.70.750.80.10.20.30.4Sigma: 0.046142857142857145Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -650,7 +738,7 @@ { "data": { "image/svg+xml": [ - "0.60.650.70.750.80.850.90.50.550.60.650.70.750.80.40.81.21.622.4Sigma: 0.008Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.40.50.60.70.80.40.450.50.550.60.650.70.750.80.10.20.30.4Sigma: 0.05742857142857143Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -659,7 +747,7 @@ { "data": { "image/svg+xml": [ - "0.60.650.70.750.80.850.90.50.550.60.650.70.750.80.40.81.21.622.4Sigma: 0.0115Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.40.50.60.70.80.40.450.50.550.60.650.70.750.80.10.20.30.4Sigma: 0.06871428571428571Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -668,7 +756,7 @@ { "data": { "image/svg+xml": [ - "0.60.650.70.750.80.850.90.50.550.60.650.70.750.80.40.81.21.622.4Sigma: 0.015Negative electrode active material volume fractionPositive electrode active material volume fraction" + "0.40.50.60.70.80.40.450.50.550.60.650.70.750.80.10.20.30.4Sigma: 0.08Negative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -677,7 +765,7 @@ ], "source": [ "# Plot the cost landscape with optimisation path and updated bounds\n", - "bounds = np.asarray([[0.6, 0.9], [0.5, 0.8]])\n", + "bounds = np.array([[0.4, 0.8], [0.4, 0.8]])\n", "for optim, sigma in zip(optims, sigmas):\n", " pybop.plot2d(optim, bounds=bounds, steps=10, title=f\"Sigma: {sigma}\")" ] @@ -688,12 +776,12 @@ "source": [ "### Updating the Learning Rate\n", "\n", - "Let's take `sigma0 = 0.0115` as the best learning rate for this problem and look at the time-series trajectories." + "Let's take `sigma0 = 0.08` as the best learning rate for this problem and look at the time-series trajectories." ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 16, "metadata": { "execution": { "iopub.execute_input": "2024-04-14T18:59:54.698068Z", @@ -713,7 +801,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.83.853.93.9544.05ReferenceModelOptimised ComparisonTime / sVoltage / V" + "0500100015003.53.553.63.653.7ReferenceModelOptimised ComparisonTime / sVoltage / V" ] }, "metadata": {}, @@ -721,9 +809,9 @@ } ], "source": [ - "optim = pybop.GradientDescent(cost, sigma0=0.0115)\n", + "optim = pybop.Optimisation(cost, optimiser=pybop.GradientDescent, sigma0=0.08)\n", "x, final_cost = optim.run()\n", - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, problem_inputs=x, title=\"Optimised Comparison\");" ] }, { diff --git a/examples/notebooks/pouch_cell_identification.ipynb b/examples/notebooks/pouch_cell_identification.ipynb index c24300ead..11c87993c 100644 --- a/examples/notebooks/pouch_cell_identification.ipynb +++ b/examples/notebooks/pouch_cell_identification.ipynb @@ -8,7 +8,7 @@ "source": [ "## Pouch Cell Model Parameter Identification\n", "\n", - "In this notebook, we present the single particle model with a two dimensional current collector. This is achieved via the potential-pair models introduced in [[1]](10.1149/1945-7111/abbce4) as implemented in PyBaMM. At a high-level this is accomplished as a potential-pair model which is resolved across the discretised spatial locations.\n", + "In this notebook, we present the single particle model with a two dimensional current collector. This is achieved via the potential-pair models introduced in Marquis et al. [[1]](https://doi.org/10.1149/1945-7111/abbce4) as implemented in PyBaMM. At a high-level this is accomplished as a potential-pair model which is resolved across the discretised spatial locations.\n", "\n", "### Setting up the Environment\n", "\n", @@ -36,58 +36,38 @@ "name": "stdout", "output_type": "stream", "text": [ - "Requirement already satisfied: pip in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (24.0)\r\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Requirement already satisfied: ipywidgets in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (8.1.2)\r\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Requirement already satisfied: comm>=0.1.3 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (0.2.2)\r\n", - "Requirement already satisfied: ipython>=6.1.0 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (8.23.0)\r\n", - "Requirement already satisfied: traitlets>=4.3.1 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (5.14.2)\r\n", - "Requirement already satisfied: widgetsnbextension~=4.0.10 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (4.0.10)\r\n", - "Requirement already satisfied: jupyterlab-widgets~=3.0.10 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (3.0.10)\r\n", - "Requirement already satisfied: decorator in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (5.1.1)\r\n", - "Requirement already satisfied: jedi>=0.16 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.19.1)\r\n", - "Requirement already satisfied: matplotlib-inline in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.1.6)\r\n", - "Requirement already satisfied: prompt-toolkit<3.1.0,>=3.0.41 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (3.0.43)\r\n", - "Requirement already satisfied: pygments>=2.4.0 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (2.17.2)\r\n", - "Requirement already satisfied: stack-data in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.6.3)\r\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Requirement already satisfied: pexpect>4.3 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (4.9.0)\r\n", - "Requirement already satisfied: parso<0.9.0,>=0.8.3 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from jedi>=0.16->ipython>=6.1.0->ipywidgets) (0.8.4)\r\n", - "Requirement already satisfied: ptyprocess>=0.5 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets) (0.7.0)\r\n", - "Requirement already satisfied: wcwidth in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from prompt-toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets) (0.2.13)\r\n", - "Requirement already satisfied: executing>=1.2.0 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.0.1)\r\n", - "Requirement already satisfied: asttokens>=2.1.0 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.4.1)\r\n", - "Requirement already satisfied: pure-eval in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (0.2.2)\r\n", - "Requirement already satisfied: six>=1.12.0 in /Users/engs2510/.pyenv/versions/pybop-3.12/lib/python3.12/site-packages (from asttokens>=2.1.0->stack-data->ipython>=6.1.0->ipywidgets) (1.16.0)\r\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Note: you may need to restart the kernel to use updated packages.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ + "Requirement already satisfied: pip in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (24.1.1)\n", + "Collecting pip\n", + " Downloading pip-24.1.2-py3-none-any.whl.metadata (3.6 kB)\n", + "Requirement already satisfied: ipywidgets in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (8.1.3)\n", + "Requirement already satisfied: comm>=0.1.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (0.2.2)\n", + "Requirement already satisfied: ipython>=6.1.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (8.23.0)\n", + "Requirement already satisfied: traitlets>=4.3.1 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (5.14.2)\n", + "Requirement already satisfied: widgetsnbextension~=4.0.11 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (4.0.11)\n", + "Requirement already satisfied: jupyterlab-widgets~=3.0.11 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipywidgets) (3.0.11)\n", + "Requirement already satisfied: decorator in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (5.1.1)\n", + "Requirement already satisfied: jedi>=0.16 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.19.1)\n", + "Requirement already satisfied: matplotlib-inline in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.1.6)\n", + "Requirement already satisfied: prompt-toolkit<3.1.0,>=3.0.41 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (3.0.43)\n", + "Requirement already satisfied: pygments>=2.4.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (2.17.2)\n", + "Requirement already satisfied: stack-data in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (0.6.3)\n", + "Requirement already satisfied: pexpect>4.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from ipython>=6.1.0->ipywidgets) (4.9.0)\n", + "Requirement already satisfied: parso<0.9.0,>=0.8.3 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from jedi>=0.16->ipython>=6.1.0->ipywidgets) (0.8.4)\n", + "Requirement already satisfied: ptyprocess>=0.5 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from pexpect>4.3->ipython>=6.1.0->ipywidgets) (0.7.0)\n", + "Requirement already satisfied: wcwidth in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from prompt-toolkit<3.1.0,>=3.0.41->ipython>=6.1.0->ipywidgets) (0.2.13)\n", + "Requirement already satisfied: executing>=1.2.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.0.1)\n", + "Requirement already satisfied: asttokens>=2.1.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (2.4.1)\n", + "Requirement already satisfied: pure-eval in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from stack-data->ipython>=6.1.0->ipywidgets) (0.2.2)\n", + "Requirement already satisfied: six>=1.12.0 in /Users/engs2510/.pyenv/versions/3.12.2/envs/pybop-3.12/lib/python3.12/site-packages (from asttokens>=2.1.0->stack-data->ipython>=6.1.0->ipywidgets) (1.16.0)\n", + "Downloading pip-24.1.2-py3-none-any.whl (1.8 MB)\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.8/1.8 MB\u001b[0m \u001b[31m14.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m00:01\u001b[0m\n", + "\u001b[?25hInstalling collected packages: pip\n", + " Attempting uninstall: pip\n", + " Found existing installation: pip 24.1.1\n", + " Uninstalling pip-24.1.1:\n", + " Successfully uninstalled pip-24.1.1\n", + "Successfully installed pip-24.1.2\n", + "Note: you may need to restart the kernel to use updated packages.\n", "Note: you may need to restart the kernel to use updated packages.\n" ] } @@ -454,7 +434,7 @@ { "data": { "text/plain": [ - "array([0.47496537, 0.61140011])" + "array([0.50112972, 0.60822849])" ] }, "execution_count": 12, @@ -509,7 +489,7 @@ { "data": { "image/svg+xml": [ - "02004006008003.73.723.743.763.78ReferenceModelOptimised ComparisonTime / sVoltage / V" + "02004006008003.723.743.763.78ReferenceModelOptimised ComparisonTime / sVoltage / V" ] }, "metadata": {}, @@ -517,7 +497,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, problem_inputs=x, title=\"Optimised Comparison\");" ] }, { @@ -541,35 +521,6 @@ } }, "outputs": [ - { - "data": { - "text/html": [ - " \n", - " " - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "data": { "application/vnd.plotly.v1+json": { @@ -635,39 +586,39 @@ ], "z": [ [ - -0.00022055019400039357, - -0.000208179915115477, - -0.00017733967875981405, - -0.00013382475248593996, - -0.00009744280975100401 + -0.00022054368296133442, + -0.00020817417477179742, + -0.00017733555998179898, + -0.0001338224058942881, + -0.00009744128489582522 ], [ - -0.00022006829217595638, - -0.00020771646089835422, - -0.00017025065441761346, - -0.00010411185465723509, - -1.4831730728913642e-30 + -0.0002200624215295616, + -0.00020771124661048325, + -0.00017024715510881273, + -0.00010411043723798278, + 2.0121777293998183e-28 ], [ - -0.00023289339271513726, - -0.00022148992831206213, - -0.0001854997008341146, - -0.00011792954324326614, - 1.7126139220369943e-29 + -0.00023288833495346782, + -0.0002214855296927363, + -0.00018549704922968932, + -0.0001179289043801153, + 1.0313790127184039e-29 ], [ - -0.00025468769038466233, - -0.0002482874721993978, - -0.00022970881918716953, - -0.00020352723357102716, - -0.0001826834881022567 + -0.0002546830904738895, + -0.0002482834858238037, + -0.00022970648308007864, + -0.00020352686864885834, + -0.00018268420575956896 ], [ - -0.0002626049278499214, - -0.00025977998017220894, - -0.00024814221133557976, - -0.00023376411978352077, - -0.00022697336267452442 + -0.00026260052021642185, + -0.00025977603234850937, + -0.0002481397380647309, + -0.0002337634294784395, + -0.00022697371294696725 ] ] } @@ -1505,41 +1456,14 @@ } } } - }, - "text/html": [ - "

" - ] + } }, "metadata": {}, "output_type": "display_data" } ], "source": [ - "sol = problem.evaluate(x)\n", + "sol = problem.evaluate(parameters.as_dict(x))\n", "\n", "go.Figure(\n", " [\n", @@ -1644,39 +1568,39 @@ ], "z": [ [ - 3.7078876177014553, - 3.7078763478472188, - 3.707853323934633, - 3.7078260164047094, - 3.707808564119712 + 3.7080242488013124, + 3.708012980167902, + 3.7079899587671075, + 3.707962653872268, + 3.7079452026255506 ], [ - 3.7078651244026894, - 3.7078535908947208, - 3.7078220221902427, - 3.7077785523089566, - 3.7077445721396733 + 3.7080017566721923, + 3.7079902242011094, + 3.7079586581556887, + 3.707915191282287, + 3.7078812123884246 ], [ - 3.7078229486184475, - 3.707804218479802, - 3.7077454950451716, - 3.7076369622920593, - 3.7074529354653967 + 3.7079595825355014, + 3.7079408535029024, + 3.7078821330256533, + 3.7077736038127984, + 3.7075895787025472 ], [ - 3.7077939368814126, - 3.707773778768286, - 3.7077102612800763, - 3.7075952142136557, - 3.707407186787348 + 3.707930571743512, + 3.707910414663849, + 3.7078468999564493, + 3.707731856210595, + 3.7075438305495547 ], [ - 3.7077846062570954, - 3.70776995420107, - 3.707720253036007, - 3.707647593021552, - 3.707589085925729 + 3.707921241493712, + 3.7079065902163797, + 3.7078568915338876, + 3.707784234516676, + 3.707725729161621 ] ] } @@ -2514,34 +2438,7 @@ } } } - }, - "text/html": [ - "
" - ] + } }, "metadata": {}, "output_type": "display_data" @@ -2592,7 +2489,7 @@ { "data": { "image/svg+xml": [ - "510152025300.00050.00060.00070.00080.00090.001ConvergenceIterationCost" + "510152025300.00050.0010.00150.0020.0025ConvergenceIterationCost" ] }, "metadata": {}, @@ -2601,7 +2498,7 @@ { "data": { "image/svg+xml": [ - "0501001500.450.50.550.60.650.70.750.80.850.90501001500.50.520.540.560.580.60.620.640.660.68Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" + "501001500.50.550.60.650.70.75501001500.550.60.650.7Negative electrode active material volume fractionPositive electrode active material volume fractionParameter ConvergenceFunction CallFunction CallNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, @@ -2637,7 +2534,7 @@ { "data": { "image/svg+xml": [ - "0.50.60.70.80.90.50.550.60.650.70.750.80.040.080.120.160.20.24Cost LandscapeNegative electrode active material volume fractionPositive electrode active material volume fraction" + "0.50.60.70.80.90.50.550.60.650.70.750.80.020.040.060.080.10.120.140.16Cost LandscapeNegative electrode active material volume fractionPositive electrode active material volume fraction" ] }, "metadata": {}, diff --git a/examples/notebooks/spm_AdamW.ipynb b/examples/notebooks/spm_AdamW.ipynb index 6b2330907..ec9a961a5 100644 --- a/examples/notebooks/spm_AdamW.ipynb +++ b/examples/notebooks/spm_AdamW.ipynb @@ -437,7 +437,7 @@ } ], "source": [ - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, problem_inputs=x, title=\"Optimised Comparison\");" ] }, { diff --git a/examples/notebooks/spm_electrode_design.ipynb b/examples/notebooks/spm_electrode_design.ipynb index 950cee323..90571c993 100644 --- a/examples/notebooks/spm_electrode_design.ipynb +++ b/examples/notebooks/spm_electrode_design.ipynb @@ -10,7 +10,7 @@ "\n", "NOTE: This is a brittle example, the classes and methods below will be integrated into PyBOP in a future release.\n", "\n", - "A design optimisation example loosely based on work by L.D. Couto available at https://doi.org/10.1016/j.energy.2022.125966.\n", + "A design optimisation example loosely based on work by L.D. Couto available at [[1]](https://doi.org/10.1016/j.energy.2022.125966).\n", "\n", "The target is to maximise the gravimetric energy density over a range of possible design parameter values, including for example:\n", "\n", @@ -277,7 +277,7 @@ "source": [ "x, final_cost = optim.run()\n", "print(\"Estimated parameters:\", x)\n", - "print(f\"Initial gravimetric energy density: {-cost(cost.x0):.2f} Wh.kg-1\")\n", + "print(f\"Initial gravimetric energy density: {-cost(optim.x0):.2f} Wh.kg-1\")\n", "print(f\"Optimised gravimetric energy density: {-final_cost:.2f} Wh.kg-1\")" ] }, @@ -329,7 +329,7 @@ "source": [ "if cost.update_capacity:\n", " problem._model.approximate_capacity(x)\n", - "pybop.quick_plot(problem, parameter_values=x, title=\"Optimised Comparison\");" + "pybop.quick_plot(problem, problem_inputs=x, title=\"Optimised Comparison\");" ] }, { @@ -396,7 +396,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.2" + "version": "3.11.7" }, "widgets": { "application/vnd.jupyter.widget-state+json": { diff --git a/examples/scripts/BPX_spm.py b/examples/scripts/BPX_spm.py index 6fdb76490..eea658846 100644 --- a/examples/scripts/BPX_spm.py +++ b/examples/scripts/BPX_spm.py @@ -51,7 +51,7 @@ print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/cuckoo.py b/examples/scripts/cuckoo.py new file mode 100644 index 000000000..fcdfadc17 --- /dev/null +++ b/examples/scripts/cuckoo.py @@ -0,0 +1,75 @@ +import numpy as np + +import pybop + +# Define model +parameter_set = pybop.ParameterSet.pybamm("Chen2020") +model = pybop.lithium_ion.SPM(parameter_set=parameter_set) + +# Fitting parameters +parameters = pybop.Parameters( + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.6, 0.05), + bounds=[0.4, 0.75], + initial_value=0.41, + true_value=0.7, + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.48, 0.05), + bounds=[0.4, 0.75], + initial_value=0.41, + true_value=0.67, + ), +) +init_soc = 0.7 +experiment = pybop.Experiment( + [ + ( + "Discharge at 0.5C for 3 minutes (4 second period)", + "Charge at 0.5C for 3 minutes (4 second period)", + ), + ] +) +values = model.predict( + init_soc=init_soc, experiment=experiment, inputs=parameters.as_dict("true") +) + +sigma = 0.002 +corrupt_values = values["Voltage [V]"].data + np.random.normal( + 0, sigma, len(values["Voltage [V]"].data) +) + +# Form dataset +dataset = pybop.Dataset( + { + "Time [s]": values["Time [s]"].data, + "Current function [A]": values["Current [A]"].data, + "Voltage [V]": corrupt_values, + } +) + +# Generate problem, cost function, and optimisation class +problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc) +cost = pybop.GaussianLogLikelihood(problem, sigma0=sigma * 4) +optim = pybop.Optimisation( + cost, + optimiser=pybop.CuckooSearch, + max_iterations=100, +) + +x, final_cost = optim.run() +print("Estimated parameters:", x) + +# 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) + +# Plot the cost landscape with optimisation path +pybop.plot2d(optim, steps=15) diff --git a/examples/scripts/ecm_CMAES.py b/examples/scripts/ecm_CMAES.py index 96a36ec44..953d7e6aa 100644 --- a/examples/scripts/ecm_CMAES.py +++ b/examples/scripts/ecm_CMAES.py @@ -89,7 +89,7 @@ pybop.plot_dataset(dataset) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/exp_UKF.py b/examples/scripts/exp_UKF.py index d469c781e..622a68e50 100644 --- a/examples/scripts/exp_UKF.py +++ b/examples/scripts/exp_UKF.py @@ -1,11 +1,10 @@ import numpy as np -import pybamm import pybop from examples.standalone.model import ExponentialDecay # Parameter set and model definition -parameter_set = pybamm.ParameterValues({"k": "[input]", "y0": "[input]"}) +parameter_set = {"k": "[input]", "y0": "[input]"} model = ExponentialDecay(parameter_set=parameter_set, n_states=1) # Fitting parameters @@ -28,7 +27,8 @@ sigma = 1e-2 t_eval = np.linspace(0, 20, 10) model.parameters = parameters -values = model.predict(t_eval=t_eval, inputs=parameters.true_value()) +true_inputs = parameters.as_dict("true") +values = model.predict(t_eval=t_eval, inputs=true_inputs) values = values["2y"].data corrupt_values = values + np.random.normal(0, sigma, len(t_eval)) @@ -41,7 +41,7 @@ model.build(parameters=parameters) simulator = pybop.Observer(parameters, model, signal=["2y"]) simulator._time_data = t_eval -measurements = simulator.evaluate(parameters.true_value()) +measurements = simulator.evaluate(true_inputs) # Verification step: Compare by plotting go = pybop.PlotlyManager().go @@ -84,7 +84,7 @@ ) # Verification step: Find the maximum likelihood estimate given the true parameters -estimation = observer.evaluate(parameters.true_value()) +estimation = observer.evaluate(true_inputs) # Verification step: Add the estimate to the plot line4 = go.Scatter( @@ -102,7 +102,7 @@ print("Estimated parameters:", x) # Plot the timeseries output (requires model that returns Voltage) -pybop.quick_plot(observer, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(observer, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/gitt.py b/examples/scripts/gitt.py index 52517fdb3..6d3b4a94b 100644 --- a/examples/scripts/gitt.py +++ b/examples/scripts/gitt.py @@ -59,7 +59,7 @@ print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index 66da8e2db..c872386a7 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -56,7 +56,7 @@ def noise(sigma): problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) -likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.002, 0.002]) +likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=0.002) prior1 = pybop.Gaussian(0.7, 0.1) prior2 = pybop.Gaussian(0.6, 0.1) composed_prior = pybop.ComposedLogPrior(prior1, prior2) @@ -64,22 +64,22 @@ def noise(sigma): x0 = [] n_chains = 10 -for i in range(n_chains): +for _i in range(n_chains): x0.append(np.array([0.68, 0.58])) optim = pybop.DREAM( posterior, chains=n_chains, x0=x0, - max_iterations=1000, - burn_in=250, - parallel=True, # uncomment to enable parallelisation (MacOS/Linux only) + max_iterations=300, + burn_in=100, + # parallel=True, # uncomment to enable parallelisation (MacOS/Linux only) ) result = optim.run() # Create a histogram fig = go.Figure() -for i, data in enumerate(result): +for _i, data in enumerate(result): fig.add_trace(go.Histogram(x=data[:, 0], name="Neg", opacity=0.75)) fig.add_trace(go.Histogram(x=data[:, 1], name="Pos", opacity=0.75)) diff --git a/examples/scripts/spm_AdamW.py b/examples/scripts/spm_AdamW.py index 44bbf8b11..46c963509 100644 --- a/examples/scripts/spm_AdamW.py +++ b/examples/scripts/spm_AdamW.py @@ -53,7 +53,7 @@ def noise(sigma): problem = pybop.FittingProblem( model, parameters, dataset, signal=signal, init_soc=init_soc ) -cost = pybop.RootMeanSquaredError(problem) +cost = pybop.Minkowski(problem, p=2) optim = pybop.AdamW( cost, verbose=True, @@ -68,7 +68,7 @@ def noise(sigma): print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_CMAES.py b/examples/scripts/spm_CMAES.py index 1fc051cc2..ed38144a9 100644 --- a/examples/scripts/spm_CMAES.py +++ b/examples/scripts/spm_CMAES.py @@ -53,7 +53,7 @@ pybop.plot_dataset(dataset) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_IRPropMin.py b/examples/scripts/spm_IRPropMin.py index 727536ff8..1969f6f9d 100644 --- a/examples/scripts/spm_IRPropMin.py +++ b/examples/scripts/spm_IRPropMin.py @@ -42,7 +42,7 @@ print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_MAP.py b/examples/scripts/spm_MAP.py index d8460915c..40f7a279c 100644 --- a/examples/scripts/spm_MAP.py +++ b/examples/scripts/spm_MAP.py @@ -2,51 +2,71 @@ import pybop -# Define model +# Set variables +sigma = 0.002 +init_soc = 0.7 + +# Construct and update initial parameter values parameter_set = pybop.ParameterSet.pybamm("Chen2020") +parameter_set.update( + { + "Negative electrode active material volume fraction": 0.43, + "Positive electrode active material volume fraction": 0.56, + } +) + +# Define model model = pybop.lithium_ion.SPM(parameter_set=parameter_set) # Fitting parameters parameters = pybop.Parameters( pybop.Parameter( "Negative electrode active material volume fraction", - prior=pybop.Gaussian(0.6, 0.05), - bounds=[0.5, 0.8], + prior=pybop.Uniform(0.3, 0.8), + bounds=[0.3, 0.8], + initial_value=0.653, + true_value=parameter_set["Negative electrode active material volume fraction"], ), pybop.Parameter( "Positive electrode active material volume fraction", - prior=pybop.Gaussian(0.48, 0.05), + prior=pybop.Uniform(0.3, 0.8), bounds=[0.4, 0.7], + initial_value=0.657, + true_value=parameter_set["Positive electrode active material volume fraction"], ), ) -# Set initial parameter values -parameter_set.update( - { - "Negative electrode active material volume fraction": 0.63, - "Positive electrode active material volume fraction": 0.51, - } +# Generate data and corrupt it with noise +experiment = pybop.Experiment( + [ + ( + "Discharge at 0.5C for 3 minutes (4 second period)", + "Charge at 0.5C for 3 minutes (4 second period)", + ), + ] +) +values = model.predict( + init_soc=init_soc, experiment=experiment, inputs=parameters.true_value() +) +corrupt_values = values["Voltage [V]"].data + np.random.normal( + 0, sigma, len(values["Voltage [V]"].data) ) -# Generate data -sigma = 0.005 -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, + "Time [s]": values["Time [s]"].data, "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.MAP(problem, pybop.GaussianLogLikelihoodKnownSigma) -optim = pybop.CMAES( +problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc) +cost = pybop.MAP(problem, pybop.GaussianLogLikelihoodKnownSigma, sigma0=sigma) +optim = pybop.AdamW( cost, + sigma0=0.05, max_unchanged_iterations=20, min_iterations=20, max_iterations=100, @@ -54,10 +74,11 @@ # Run the optimisation x, final_cost = optim.run() +print("True parameters:", parameters.true_value()) print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x[0:2], title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) @@ -69,5 +90,5 @@ pybop.plot2d(cost, steps=15) # Plot the cost landscape with optimisation path -bounds = np.asarray([[0.55, 0.77], [0.48, 0.68]]) +bounds = np.asarray([[0.35, 0.7], [0.45, 0.625]]) pybop.plot2d(optim, bounds=bounds, steps=15) diff --git a/examples/scripts/spm_MLE.py b/examples/scripts/spm_MLE.py index 6fc0238ca..9c9b3f368 100644 --- a/examples/scripts/spm_MLE.py +++ b/examples/scripts/spm_MLE.py @@ -16,7 +16,6 @@ pybop.Parameter( "Positive electrode active material volume fraction", prior=pybop.Gaussian(0.48, 0.05), - bounds=[0.4, 0.7], ), ) @@ -44,8 +43,8 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) -likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.03, 0.03]) -optim = pybop.CMAES( +likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=sigma) +optim = pybop.IRPropMin( likelihood, max_unchanged_iterations=20, min_iterations=20, @@ -57,7 +56,7 @@ print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x[0:2], title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_NelderMead.py b/examples/scripts/spm_NelderMead.py index 569dbadf2..e07801e04 100644 --- a/examples/scripts/spm_NelderMead.py +++ b/examples/scripts/spm_NelderMead.py @@ -68,7 +68,7 @@ def noise(sigma): print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_SNES.py b/examples/scripts/spm_SNES.py index d2afcc85a..7cddfad91 100644 --- a/examples/scripts/spm_SNES.py +++ b/examples/scripts/spm_SNES.py @@ -35,14 +35,14 @@ # Generate problem, cost function, and optimisation class problem = pybop.FittingProblem(model, parameters, dataset) -cost = pybop.SumSquaredError(problem) +cost = pybop.SumofPower(problem, p=2) optim = pybop.SNES(cost, max_iterations=100) x, final_cost = optim.run() print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_UKF.py b/examples/scripts/spm_UKF.py index e9972bd0f..e528c715e 100644 --- a/examples/scripts/spm_UKF.py +++ b/examples/scripts/spm_UKF.py @@ -68,7 +68,7 @@ print("Estimated parameters:", x) # Plot the timeseries output (requires model that returns Voltage) -pybop.quick_plot(observer, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(observer, problem_inputs=x, title="Optimised Comparison") # # Plot convergence # pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_XNES.py b/examples/scripts/spm_XNES.py index 59b6eca87..40900640f 100644 --- a/examples/scripts/spm_XNES.py +++ b/examples/scripts/spm_XNES.py @@ -43,7 +43,7 @@ print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_descent.py b/examples/scripts/spm_descent.py index 7c7629b04..94573f0c0 100644 --- a/examples/scripts/spm_descent.py +++ b/examples/scripts/spm_descent.py @@ -48,7 +48,7 @@ print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_pso.py b/examples/scripts/spm_pso.py index acb3e1c6e..efc97ad2a 100644 --- a/examples/scripts/spm_pso.py +++ b/examples/scripts/spm_pso.py @@ -43,7 +43,7 @@ print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spm_scipymin.py b/examples/scripts/spm_scipymin.py index 8c7b80c5a..b6cec3f08 100644 --- a/examples/scripts/spm_scipymin.py +++ b/examples/scripts/spm_scipymin.py @@ -45,7 +45,7 @@ print("Estimated parameters:", x) # Plot the timeseries output -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot convergence pybop.plot_convergence(optim) diff --git a/examples/scripts/spme_max_energy.py b/examples/scripts/spme_max_energy.py index 800a535cc..f5b7c827c 100644 --- a/examples/scripts/spme_max_energy.py +++ b/examples/scripts/spme_max_energy.py @@ -12,11 +12,11 @@ # NOTE: This script can be easily adjusted to consider the volumetric # (instead of gravimetric) energy density by changing the line which # defines the cost and changing the output to: -# print(f"Initial volumetric energy density: {cost(cost.x0):.2f} Wh.m-3") +# print(f"Initial volumetric energy density: {cost(optim.x0):.2f} Wh.m-3") # print(f"Optimised volumetric energy density: {final_cost:.2f} Wh.m-3") # Define parameter set and model -parameter_set = pybop.ParameterSet.pybamm("Chen2020") +parameter_set = pybop.ParameterSet.pybamm("Chen2020", formation_concentrations=True) model = pybop.lithium_ion.SPMe(parameter_set=parameter_set) # Fitting parameters @@ -54,13 +54,13 @@ # Run optimisation x, final_cost = optim.run() print("Estimated parameters:", x) -print(f"Initial gravimetric energy density: {cost(cost.x0):.2f} Wh.kg-1") +print(f"Initial gravimetric energy density: {cost(optim.x0):.2f} Wh.kg-1") print(f"Optimised gravimetric energy density: {final_cost:.2f} Wh.kg-1") # Plot the timeseries output if cost.update_capacity: problem._model.approximate_capacity(x) -pybop.quick_plot(problem, parameter_values=x, title="Optimised Comparison") +pybop.quick_plot(problem, problem_inputs=x, title="Optimised Comparison") # Plot the cost landscape with optimisation path if len(x) == 2: diff --git a/examples/standalone/cost.py b/examples/standalone/cost.py index 806bc0eab..99917f3fd 100644 --- a/examples/standalone/cost.py +++ b/examples/standalone/cost.py @@ -43,7 +43,7 @@ def __init__(self, problem=None): ) self.x0 = self.parameters.initial_value() - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs, grad=None): """ Calculate the cost for a given parameter value. @@ -52,9 +52,8 @@ def _evaluate(self, x, grad=None): Parameters ---------- - x : array-like - A one-element array containing the parameter value for which to - evaluate the cost. + inputs : Dict + The parameters for which to evaluate the cost. grad : array-like, optional Unused parameter, present for compatibility with gradient-based optimizers. @@ -65,4 +64,4 @@ def _evaluate(self, x, grad=None): The calculated cost value for the given parameter. """ - return x[0] ** 2 + 42 + return inputs["x"] ** 2 + 42 diff --git a/examples/standalone/model.py b/examples/standalone/model.py index e67477466..f5d5a7ab2 100644 --- a/examples/standalone/model.py +++ b/examples/standalone/model.py @@ -18,7 +18,8 @@ def __init__( parameter_set: pybamm.ParameterValues = None, n_states: int = 1, ): - super().__init__() + super().__init__(name=name, parameter_set=parameter_set) + self.n_states = n_states if n_states < 1: raise ValueError("The number of states (n_states) must be greater than 0") @@ -38,10 +39,11 @@ def __init__( ) self._unprocessed_model = self.pybamm_model - self.name = name self.default_parameter_values = ( - default_parameter_values if parameter_set is None else parameter_set + default_parameter_values + if self._parameter_set is None + else self._parameter_set ) self._parameter_set = self.default_parameter_values self._unprocessed_parameter_set = self._parameter_set diff --git a/examples/standalone/problem.py b/examples/standalone/problem.py index d6d1f4b01..18bf1f7d4 100644 --- a/examples/standalone/problem.py +++ b/examples/standalone/problem.py @@ -24,7 +24,7 @@ def __init__( self._dataset = dataset.data # Check that the dataset contains time and current - for name in ["Time [s]"] + self.signal: + for name in ["Time [s]", *self.signal]: if name not in self._dataset: raise ValueError(f"expected {name} in list of dataset") @@ -42,31 +42,34 @@ def __init__( ) self._target = {signal: self._dataset[signal] for signal in self.signal} - def evaluate(self, x): + def evaluate(self, inputs): """ Evaluate the model with the given parameters and return the signal. Parameters ---------- - x : np.ndarray - Parameter values to evaluate the model at. + inputs : Dict + Parameters for evaluation of the model. Returns ------- y : np.ndarray - The model output y(t) simulated with inputs x. + The model output y(t) simulated with given inputs. """ - return {signal: x[0] * self._time_data + x[1] for signal in self.signal} + return { + signal: inputs["Gradient"] * self._time_data + inputs["Intercept"] + for signal in self.signal + } - def evaluateS1(self, x): + def evaluateS1(self, inputs): """ 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. + inputs : Dict + Parameters for evaluation of the model. Returns ------- @@ -75,7 +78,7 @@ def evaluateS1(self, x): with given inputs x. """ - y = {signal: x[0] * self._time_data + x[1] for signal in self.signal} + y = self.evaluate(inputs) dy = np.zeros((self.n_time_data, self.n_outputs, self.n_parameters)) dy[:, 0, 0] = self._time_data diff --git a/pybop/__init__.py b/pybop/__init__.py index b99a0042f..255810938 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -85,8 +85,9 @@ from .costs.fitting_costs import ( RootMeanSquaredError, SumSquaredError, + Minkowski, + SumofPower, ObserverCost, - MAP, ) from .costs.design_costs import ( DesignCost, @@ -98,12 +99,14 @@ GaussianLogLikelihood, GaussianLogLikelihoodKnownSigma, LogPosterior, + MAP, ) # # Optimiser classes # +from .optimisers._cuckoo import CuckooSearchImpl from .optimisers._adamw import AdamWImpl from .optimisers.base_optimiser import BaseOptimiser, Result from .optimisers.base_pints_optimiser import BasePintsOptimiser @@ -121,6 +124,7 @@ PSO, SNES, XNES, + CuckooSearch, AdamW, ) from .optimisers.optimisation import Optimisation diff --git a/pybop/_dataset.py b/pybop/_dataset.py index 0da8be4be..66bcb1f10 100644 --- a/pybop/_dataset.py +++ b/pybop/_dataset.py @@ -1,6 +1,5 @@ import numpy as np -from pybamm import Interpolant, solvers -from pybamm import t as pybamm_t +from pybamm import solvers class Dataset: @@ -77,26 +76,7 @@ def __getitem__(self, key): return self.data[key] - def Interpolant(self): - """ - Create an interpolation function of the dataset based on the independent variable. - - Currently, only time-based interpolation is supported. This method modifies - the instance's Interpolant attribute to be an interpolation function that - can be evaluated at different points in time. - - Raises - ------ - NotImplementedError - If the independent variable for interpolation is not supported. - """ - - if self.variable == "time": - self.Interpolant = Interpolant(self.x, self.y, pybamm_t) - else: - NotImplementedError("Only time interpolation is supported") - - def check(self, signal=["Voltage [V]"]): + def check(self, signal=None): """ Check the consistency of a PyBOP Dataset against the expected format. @@ -110,11 +90,13 @@ def check(self, signal=["Voltage [V]"]): ValueError If the time series and the data series are not consistent. """ + if signal is None: + signal = ["Voltage [V]"] if isinstance(signal, str): signal = [signal] # Check that the dataset contains time and chosen signal - for name in ["Time [s]"] + signal: + for name in ["Time [s]", *signal]: if name not in self.names: raise ValueError(f"expected {name} in list of dataset") diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index eace827a5..003fab358 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -1,6 +1,11 @@ +from typing import Union + import numpy as np from pybop.costs.base_cost import BaseCost +from pybop.parameters.parameter import Inputs, Parameter, Parameters +from pybop.parameters.priors import Uniform +from pybop.problems.base_problem import BaseProblem class BaseLikelihood(BaseCost): @@ -8,8 +13,8 @@ class BaseLikelihood(BaseCost): Base class for likelihoods """ - def __init__(self, problem): - super(BaseLikelihood, self).__init__(problem) + def __init__(self, problem: BaseProblem): + super().__init__(problem) self.n_time_data = problem.n_time_data @@ -21,92 +26,70 @@ class GaussianLogLikelihoodKnownSigma(BaseLikelihood): Parameters ---------- - sigma : scalar or array + sigma0 : scalar or array Initial standard deviation around ``x0``. Either a scalar value (one standard deviation for all coordinates) or an array with one entry - per dimension. Not all methods will use this information. + per dimension. """ - def __init__(self, problem, sigma): - super(GaussianLogLikelihoodKnownSigma, self).__init__(problem) - self.sigma = None - self.set_sigma(sigma) - self._offset = -0.5 * self.n_time_data * np.log(2 * np.pi / self.sigma) - self._multip = -1 / (2.0 * self.sigma**2) - self.sigma2 = self.sigma**-2 - self._dl = np.ones(self.n_parameters) - - def set_sigma(self, sigma): - """ - Setter for sigma parameter - """ - if sigma is None: - raise ValueError( - "The GaussianLogLikelihoodKnownSigma cost requires sigma to be " - + "either a scalar value or an array with one entry per dimension." - ) - - if not isinstance(sigma, np.ndarray): - sigma = np.asarray(sigma) - - if not np.issubdtype(sigma.dtype, np.number): - raise ValueError("Sigma must contain only numeric values") - - if np.any(sigma <= 0): - raise ValueError("Sigma must be positive") - else: - self.sigma = sigma - - def get_sigma(self): - """ - Getter for sigma parameter - """ - return self.sigma + def __init__(self, problem: BaseProblem, sigma0: Union[list[float], float]): + super().__init__(problem) + sigma0 = self.check_sigma0(sigma0) + self.sigma2 = sigma0**2.0 + self._offset = -0.5 * self.n_time_data * np.log(2 * np.pi * self.sigma2) + self._multip = -1 / (2.0 * self.sigma2) - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs, grad: Union[None, np.ndarray] = None) -> float: """ - Calls the problem.evaluate method and calculates - the log-likelihood + Evaluates the Gaussian log-likelihood for the given parameters with known sigma. """ - y = self.problem.evaluate(x) + y = self.problem.evaluate(inputs) - for key in self.signal: - if len(y.get(key, [])) != len(self._target.get(key, [])): - return -np.float64(np.inf) # prediction doesn't match target + if not self.verify_prediction(y): + return -np.inf e = np.asarray( [ np.sum( self._offset - + self._multip * np.sum((self._target[signal] - y[signal]) ** 2) + + self._multip * np.sum((self._target[signal] - y[signal]) ** 2.0) ) for signal in self.signal ] ) - if self.n_outputs == 1: - return e.item() - else: - return np.sum(e) + return e.item() if self.n_outputs == 1 else np.sum(e) - def _evaluateS1(self, x, grad=None): + def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: """ - Calls the problem.evaluateS1 method and calculates - the log-likelihood + Calls the problem.evaluateS1 method and calculates the log-likelihood and gradient. """ - y, dy = self.problem.evaluateS1(x) + y, dy = self.problem.evaluateS1(inputs) + + if not self.verify_prediction(y): + return -np.inf, -self._de * np.ones(self.n_parameters) - for key in self.signal: - if len(y.get(key, [])) != len(self._target.get(key, [])): - likelihood = np.float64(np.inf) - dl = self._dl * np.ones(self.n_parameters) - return -likelihood, -dl + likelihood = self._evaluate(inputs) r = np.asarray([self._target[signal] - y[signal] for signal in self.signal]) - likelihood = self._evaluate(x) - dl = np.sum((self.sigma2 * np.sum((r * dy.T), axis=2)), axis=1) + dl = np.sum((np.sum((r * dy.T), axis=2) / self.sigma2), axis=1) + return likelihood, dl + def check_sigma0(self, sigma0: Union[np.ndarray, float]): + """ + Check the validity of sigma0. + """ + sigma0 = np.asarray(sigma0, dtype=float) + if not np.all(sigma0 > 0): + raise ValueError("Sigma0 must be positive") + if np.shape(sigma0) not in [(), (1,), (self.n_outputs,)]: + raise ValueError( + "sigma0 must be either a scalar value (one standard deviation for " + "all coordinates) or an array with one entry per dimension." + ) + return sigma0 + class GaussianLogLikelihood(BaseLikelihood): """ @@ -114,81 +97,263 @@ class GaussianLogLikelihood(BaseLikelihood): data follows a Gaussian distribution and computes the log-likelihood of observed data under this assumption. + This class estimates the standard deviation of the Gaussian distribution + alongside the parameters of the model. + Attributes ---------- _logpi : float Precomputed offset value for the log-likelihood function. + _dsigma_scale : float + Scale factor for derivative of standard deviation. """ - def __init__(self, problem): - super(GaussianLogLikelihood, self).__init__(problem) + def __init__( + self, + problem: BaseProblem, + sigma0: Union[float, list[float], list[Parameter]] = 0.002, + dsigma_scale: float = 1.0, + ): + super().__init__(problem) + self._dsigma_scale = dsigma_scale self._logpi = -0.5 * self.n_time_data * np.log(2 * np.pi) - self._dl = np.ones(self.n_parameters + self.n_outputs) - def _evaluate(self, x, grad=None): + self.sigma = Parameters() + self._add_sigma_parameters(sigma0) + self.parameters.join(self.sigma) + + def _add_sigma_parameters(self, sigma0): + sigma0 = [sigma0] if not isinstance(sigma0, list) else sigma0 + sigma0 = self._pad_sigma0(sigma0) + + for i, value in enumerate(sigma0): + self._add_single_sigma(i, value) + + def _pad_sigma0(self, sigma0): + if len(sigma0) < self.n_outputs: + return np.pad( + sigma0, + (0, self.n_outputs - len(sigma0)), + constant_values=sigma0[-1], + ) + return sigma0 + + def _add_single_sigma(self, index, value): + if isinstance(value, Parameter): + self.sigma.add(value) + elif isinstance(value, (int, float)): + self.sigma.add( + Parameter( + f"Sigma for output {index+1}", + initial_value=value, + prior=Uniform(0.5 * value, 1.5 * value), + ) + ) + else: + raise TypeError( + f"Expected sigma0 to contain Parameter objects or numeric values. " + f"Received {type(value)}" + ) + + @property + def dsigma_scale(self): + """ + Scaling factor for the dsigma term in the gradient calculation. + """ + return self._dsigma_scale + + @dsigma_scale.setter + def dsigma_scale(self, new_value): + if new_value < 0: + raise ValueError("dsigma_scale must be non-negative") + self._dsigma_scale = new_value + + def _evaluate(self, inputs: Inputs, grad: Union[None, np.ndarray] = None) -> float: """ Evaluates the Gaussian log-likelihood for the given parameters. - Args: - x (array_like): The parameters for which to evaluate the log-likelihood. - The last `self.n_outputs` elements are assumed to be the - standard deviations of the Gaussian distributions. + Parameters + ---------- + inputs : Inputs + The parameters for which to evaluate the log-likelihood, including the `n_outputs` + standard deviations of the Gaussian distributions. - Returns: - float: The log-likelihood value, or -inf if the standard deviations are received as non-positive. + Returns + ------- + float + The log-likelihood value, or -inf if the standard deviations are non-positive. """ - sigma = np.asarray(x[-self.n_outputs :]) + self.parameters.update(values=list(inputs.values())) + sigma = self.sigma.current_value() if np.any(sigma <= 0): return -np.inf - y = self.problem.evaluate(x[: -self.n_outputs]) - - for key in self.signal: - if len(y.get(key, [])) != len(self._target.get(key, [])): - return -np.float64(np.inf) # prediction doesn't match target + y = self.problem.evaluate(self.problem.parameters.as_dict()) + if not self.verify_prediction(y): + return -np.inf e = np.asarray( [ np.sum( self._logpi - self.n_time_data * np.log(sigma) - - np.sum((self._target[signal] - y[signal]) ** 2) / (2.0 * sigma**2) + - np.sum((self._target[signal] - y[signal]) ** 2.0) + / (2.0 * sigma**2.0) ) for signal in self.signal ] ) - if self.n_outputs == 1: - return e.item() - else: - return np.sum(e) + return e.item() if self.n_outputs == 1 else np.sum(e) - def _evaluateS1(self, x, grad=None): + def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: """ - Calls the problem.evaluateS1 method and calculates - the log-likelihood + Calls the problem.evaluateS1 method and calculates the log-likelihood. + + Parameters + ---------- + inputs : Inputs + The parameters for which to evaluate the log-likelihood. + + Returns + ------- + Tuple[float, np.ndarray] + The log-likelihood and its gradient. """ - sigma = np.asarray(x[-self.n_outputs :]) + self.parameters.update(values=list(inputs.values())) + sigma = self.sigma.current_value() if np.any(sigma <= 0): - return -np.float64(np.inf), -self._dl * np.ones(self.n_parameters) + return -np.inf, -self._de * np.ones(self.n_parameters) - y, dy = self.problem.evaluateS1(x[: -self.n_outputs]) - for key in self.signal: - if len(y.get(key, [])) != len(self._target.get(key, [])): - likelihood = np.float64(np.inf) - dl = self._dl * np.ones(self.n_parameters) - return -likelihood, -dl + y, dy = self.problem.evaluateS1(self.problem.parameters.as_dict()) + if not self.verify_prediction(y): + return -np.inf, -self._de * np.ones(self.n_parameters) + + likelihood = self._evaluate(inputs) r = np.asarray([self._target[signal] - y[signal] for signal in self.signal]) - likelihood = self._evaluate(x) - dl = sigma ** (-2.0) * np.sum((r * dy.T), axis=2) - dsigma = -self.n_time_data / sigma + sigma**-(3.0) * np.sum(r**2, axis=1) + dl = np.sum((np.sum((r * dy.T), axis=2) / (sigma**2.0)), axis=1) + dsigma = ( + -self.n_time_data / sigma + np.sum(r**2.0, axis=1) / (sigma**3.0) + ) / self._dsigma_scale dl = np.concatenate((dl.flatten(), dsigma)) + return likelihood, dl +class MAP(BaseLikelihood): + """ + Maximum a posteriori cost function. + + Computes the maximum a posteriori cost function, which is the sum of the + log likelihood and the log prior. The goal of maximising is achieved by + setting minimising = False in the optimiser settings. + + Inherits all parameters and attributes from ``BaseLikelihood``. + + """ + + def __init__(self, problem, likelihood, sigma0=None, gradient_step=1e-3): + super().__init__(problem) + self.sigma0 = sigma0 + self.gradient_step = gradient_step + if self.sigma0 is None: + self.sigma0 = [] + for param in self.problem.parameters: + self.sigma0.append(param.prior.sigma) + + try: + self.likelihood = likelihood(problem=self.problem, sigma0=self.sigma0) + except Exception as e: + raise ValueError( + f"An error occurred when constructing the Likelihood class: {e}" + ) from e + + if hasattr(self, "likelihood") and not isinstance( + self.likelihood, BaseLikelihood + ): + raise ValueError(f"{self.likelihood} must be a subclass of BaseLikelihood") + + def _evaluate(self, inputs: Inputs, grad=None) -> float: + """ + Calculate the maximum a posteriori cost for a given set of parameters. + + Parameters + ---------- + inputs : Inputs + The parameters for which to evaluate the cost. + grad : array-like, optional + An array to store the gradient of the cost function with respect + to the parameters. + + Returns + ------- + float + The maximum a posteriori cost. + """ + log_prior = sum( + self.parameters[key].prior.logpdf(value) for key, value in inputs.items() + ) + + if not np.isfinite(log_prior).any(): + return -np.inf + + log_likelihood = self.likelihood._evaluate(inputs) + posterior = log_likelihood + log_prior + return posterior + + def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: + """ + Compute the maximum a posteriori with respect to the parameters. + The method passes the likelihood gradient to the optimiser without modification. + + Parameters + ---------- + inputs : Inputs + The parameters for which to compute the cost and gradient. + + Returns + ------- + tuple + A tuple containing the cost and the gradient. The cost is a float, + and the gradient is an array-like of the same length as `x`. + + Raises + ------ + ValueError + If an error occurs during the calculation of the cost or gradient. + """ + log_prior = sum( + self.parameters[key].prior.logpdf(value) for key, value in inputs.items() + ) + if not np.isfinite(log_prior).any(): + return -np.inf, -self._de * np.ones(self.n_parameters) + + log_likelihood, dl = self.likelihood._evaluateS1(inputs) + + # Compute a finite difference approximation of the gradient of the log prior + delta = self.parameters.initial_value() * self.gradient_step + prior_gradient = [] + + for parameter, step_size in zip(self.problem.parameters, delta): + param_value = inputs[parameter.name] + + log_prior_upper = parameter.prior.logpdf(param_value * (1 + step_size)) + log_prior_lower = parameter.prior.logpdf(param_value * (1 - step_size)) + + gradient = (log_prior_upper - log_prior_lower) / ( + 2 * step_size * param_value + np.finfo(float).eps + ) + prior_gradient.append(gradient) + + posterior = log_likelihood + log_prior + total_gradient = dl + prior_gradient + + return posterior, total_gradient + + class LogPosterior(BaseCost): """ The Log Posterior for a given problem. @@ -200,7 +365,7 @@ class LogPosterior(BaseCost): """ def __init__(self, log_likelihood, log_prior=None): - super(LogPosterior, self).__init__(problem=log_likelihood.problem) + super().__init__(problem=log_likelihood.problem) # Store the likelihood and prior self._log_likelihood = log_likelihood @@ -211,7 +376,7 @@ def __init__(self, log_likelihood, log_prior=None): except Exception as e: raise ValueError( f"An error occurred when constructing the Prior class: {e}" - ) + ) from e def _evaluate(self, x, grad=None): """ diff --git a/pybop/costs/base_cost.py b/pybop/costs/base_cost.py index 04d0a3934..d40bbb996 100644 --- a/pybop/costs/base_cost.py +++ b/pybop/costs/base_cost.py @@ -1,4 +1,7 @@ +from typing import Union + from pybop import BaseProblem +from pybop.parameters.parameter import Inputs, Parameters class BaseCost: @@ -17,20 +20,17 @@ class BaseCost: evaluating the cost function. _target : array-like An array containing the target data to fit. - x0 : array-like - The initial guess for the model parameters. n_outputs : int The number of outputs in the model. """ def __init__(self, problem=None): - self.parameters = None + self.parameters = Parameters() self.problem = problem - self.x0 = None + self.set_fail_gradient() if isinstance(self.problem, BaseProblem): self._target = self.problem._target - self.parameters = self.problem.parameters - self.x0 = self.problem.x0 + self.parameters.join(self.problem.parameters) self.n_outputs = self.problem.n_outputs self.signal = self.problem.signal @@ -38,20 +38,20 @@ def __init__(self, problem=None): def n_parameters(self): return len(self.parameters) - def __call__(self, x, grad=None): + def __call__(self, inputs: Union[Inputs, list], grad=None): """ Call the evaluate function for a given set of parameters. """ - return self.evaluate(x, grad) + return self.evaluate(inputs, grad) - def evaluate(self, x, grad=None): + def evaluate(self, inputs: Union[Inputs, list], grad=None): """ Call the evaluate function for a given set of parameters. Parameters ---------- - x : array-like - The parameters for which to evaluate the cost. + inputs : Inputs or array-like + The parameters for which to compute the cost and gradient. grad : array-like, optional An array to store the gradient of the cost function with respect to the parameters. @@ -66,16 +66,18 @@ def evaluate(self, x, grad=None): ValueError If an error occurs during the calculation of the cost. """ + inputs = self.parameters.verify(inputs) + try: - return self._evaluate(x, grad) + return self._evaluate(inputs, grad) except NotImplementedError as e: raise e except Exception as e: - raise ValueError(f"Error in cost calculation: {e}") + raise ValueError(f"Error in cost calculation: {e}") from e - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs, grad=None): """ Calculate the cost function value for a given set of parameters. @@ -83,7 +85,7 @@ def _evaluate(self, x, grad=None): Parameters ---------- - x : array-like + inputs : Inputs The parameters for which to evaluate the cost. grad : array-like, optional An array to store the gradient of the cost function with respect @@ -101,49 +103,51 @@ def _evaluate(self, x, grad=None): """ raise NotImplementedError - def evaluateS1(self, x): + def evaluateS1(self, inputs: Union[Inputs, list]): """ Call _evaluateS1 for a given set of parameters. Parameters ---------- - x : array-like + inputs : Inputs or array-like The parameters for which to compute the cost and gradient. Returns ------- tuple A tuple containing the cost and the gradient. The cost is a float, - and the gradient is an array-like of the same length as `x`. + and the gradient is an array-like of the same length as `inputs`. Raises ------ ValueError If an error occurs during the calculation of the cost or gradient. """ + inputs = self.parameters.verify(inputs) + try: - return self._evaluateS1(x) + return self._evaluateS1(inputs) except NotImplementedError as e: raise e except Exception as e: - raise ValueError(f"Error in cost calculation: {e}") + raise ValueError(f"Error in cost calculation: {e}") from e - def _evaluateS1(self, x): + def _evaluateS1(self, inputs: Inputs): """ Compute the cost and its gradient with respect to the parameters. Parameters ---------- - x : array-like + inputs : Inputs The parameters for which to compute the cost and gradient. Returns ------- tuple A tuple containing the cost and the gradient. The cost is a float, - and the gradient is an array-like of the same length as `x`. + and the gradient is an array-like of the same length as `inputs`. Raises ------ @@ -151,3 +155,40 @@ def _evaluateS1(self, x): If the method has not been implemented by the subclass. """ raise NotImplementedError + + def set_fail_gradient(self, de: float = 1.0): + """ + Set the fail gradient to a specified value. + + The fail gradient is used if an error occurs during the calculation + of the gradient. This method allows updating the default gradient value. + + Parameters + ---------- + de : float + The new fail gradient value to be used. + """ + if not isinstance(de, float): + de = float(de) + self._de = de + + def verify_prediction(self, y): + """ + Verify that the prediction matches the target data. + + Parameters + ---------- + y : dict + The model predictions. + + Returns + ------- + bool + True if the prediction matches the target data, otherwise False. + """ + if any( + len(y.get(key, [])) != len(self._target.get(key, [])) for key in self.signal + ): + return False + + return True diff --git a/pybop/costs/design_costs.py b/pybop/costs/design_costs.py index 60064c65c..ac8ecacac 100644 --- a/pybop/costs/design_costs.py +++ b/pybop/costs/design_costs.py @@ -2,8 +2,8 @@ import numpy as np -from pybop import is_numeric from pybop.costs.base_cost import BaseCost +from pybop.parameters.parameter import Inputs class DesignCost(BaseCost): @@ -31,7 +31,7 @@ def __init__(self, problem, update_capacity=False): problem : object The problem instance containing the model and data. """ - super(DesignCost, self).__init__(problem) + super().__init__(problem) self.problem = problem if update_capacity is True: nominal_capacity_warning = ( @@ -41,23 +41,23 @@ def __init__(self, problem, update_capacity=False): nominal_capacity_warning = ( "The nominal capacity is fixed at the initial model value." ) - warnings.warn(nominal_capacity_warning, UserWarning) + warnings.warn(nominal_capacity_warning, UserWarning, stacklevel=2) self.update_capacity = update_capacity self.parameter_set = problem.model.parameter_set - self.update_simulation_data(self.x0) + self.update_simulation_data(self.parameters.as_dict("initial")) - def update_simulation_data(self, x0): + def update_simulation_data(self, inputs: Inputs): """ Updates the simulation data based on the initial parameter values. Parameters ---------- - x0 : array + inputs : Inputs The initial parameter values for the simulation. """ if self.update_capacity: - self.problem.model.approximate_capacity(x0) - solution = self.problem.evaluate(x0) + self.problem.model.approximate_capacity(inputs) + solution = self.problem.evaluate(inputs) if "Time [s]" not in solution: raise ValueError("The solution does not contain time data.") @@ -65,7 +65,7 @@ def update_simulation_data(self, x0): self.problem._target = {key: solution[key] for key in self.problem.signal} self.dt = solution["Time [s]"][1] - solution["Time [s]"][0] - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs, grad=None): """ Computes the value of the cost function. @@ -73,8 +73,8 @@ def _evaluate(self, x, grad=None): Parameters ---------- - x : array - The parameter set for which to compute the cost. + inputs : Inputs + The parameters for which to compute the cost. grad : array, optional Gradient information, not used in this method. @@ -97,16 +97,16 @@ class GravimetricEnergyDensity(DesignCost): """ def __init__(self, problem, update_capacity=False): - super(GravimetricEnergyDensity, self).__init__(problem, update_capacity) + super().__init__(problem, update_capacity) - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs, grad=None): """ Computes the cost function for the energy density. Parameters ---------- - x : array - The parameter set for which to compute the cost. + inputs : Inputs + The parameters for which to compute the cost. grad : array, optional Gradient information, not used in this method. @@ -115,17 +115,14 @@ def _evaluate(self, x, grad=None): float The gravimetric energy density or -infinity in case of infeasible parameters. """ - if not all(is_numeric(i) for i in x): - raise ValueError("Input must be a numeric array.") - try: with warnings.catch_warnings(): # Convert UserWarning to an exception warnings.filterwarnings("error", category=UserWarning) if self.update_capacity: - self.problem.model.approximate_capacity(x) - solution = self.problem.evaluate(x) + self.problem.model.approximate_capacity(inputs) + solution = self.problem.evaluate(inputs) voltage, current = solution["Voltage [V]"], solution["Current [A]"] energy_density = np.trapz(voltage * current, dx=self.dt) / ( @@ -156,16 +153,16 @@ class VolumetricEnergyDensity(DesignCost): """ def __init__(self, problem, update_capacity=False): - super(VolumetricEnergyDensity, self).__init__(problem, update_capacity) + super().__init__(problem, update_capacity) - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs, grad=None): """ Computes the cost function for the energy density. Parameters ---------- - x : array - The parameter set for which to compute the cost. + inputs : Inputs + The parameters for which to compute the cost. grad : array, optional Gradient information, not used in this method. @@ -174,16 +171,14 @@ def _evaluate(self, x, grad=None): float The volumetric energy density or -infinity in case of infeasible parameters. """ - if not all(is_numeric(i) for i in x): - raise ValueError("Input must be a numeric array.") try: with warnings.catch_warnings(): # Convert UserWarning to an exception warnings.filterwarnings("error", category=UserWarning) if self.update_capacity: - self.problem.model.approximate_capacity(x) - solution = self.problem.evaluate(x) + self.problem.model.approximate_capacity(inputs) + solution = self.problem.evaluate(inputs) voltage, current = solution["Voltage [V]"], solution["Current [A]"] energy_density = np.trapz(voltage * current, dx=self.dt) / ( diff --git a/pybop/costs/fitting_costs.py b/pybop/costs/fitting_costs.py index eff56059c..ac17f3eac 100644 --- a/pybop/costs/fitting_costs.py +++ b/pybop/costs/fitting_costs.py @@ -1,8 +1,8 @@ import numpy as np -from pybop.costs._likelihoods import BaseLikelihood from pybop.costs.base_cost import BaseCost from pybop.observers.observer import Observer +from pybop.parameters.parameter import Inputs class RootMeanSquaredError(BaseCost): @@ -18,18 +18,15 @@ class RootMeanSquaredError(BaseCost): """ def __init__(self, problem): - super(RootMeanSquaredError, self).__init__(problem) + super().__init__(problem) - # Default fail gradient - self._de = 1.0 - - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs, grad=None): """ Calculate the root mean square error for a given set of parameters. Parameters ---------- - x : array-like + inputs : Inputs The parameters for which to evaluate the cost. grad : array-like, optional An array to store the gradient of the cost function with respect @@ -41,11 +38,10 @@ def _evaluate(self, x, grad=None): The root mean square error. """ - prediction = self.problem.evaluate(x) + prediction = self.problem.evaluate(inputs) - for key in self.signal: - if len(prediction.get(key, [])) != len(self._target.get(key, [])): - return np.float64(np.inf) # prediction doesn't match target + if not self.verify_prediction(prediction): + return np.inf e = np.asarray( [ @@ -54,38 +50,31 @@ def _evaluate(self, x, grad=None): ] ) - if self.n_outputs == 1: - return e.item() - else: - return np.sum(e) + return e.item() if self.n_outputs == 1 else np.sum(e) - def _evaluateS1(self, x): + def _evaluateS1(self, inputs: Inputs): """ Compute the cost and its gradient with respect to the parameters. Parameters ---------- - x : array-like + inputs : Inputs The parameters for which to compute the cost and gradient. Returns ------- tuple A tuple containing the cost and the gradient. The cost is a float, - and the gradient is an array-like of the same length as `x`. + and the gradient is an array-like of the same length as `inputs`. Raises ------ ValueError If an error occurs during the calculation of the cost or gradient. """ - y, dy = self.problem.evaluateS1(x) - - for key in self.signal: - if len(y.get(key, [])) != len(self._target.get(key, [])): - e = np.float64(np.inf) - de = self._de * np.ones(self.n_parameters) - return e, de + y, dy = self.problem.evaluateS1(inputs) + if not self.verify_prediction(y): + return np.inf, self._de * np.ones(self.n_parameters) r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) e = np.sqrt(np.mean(r**2, axis=1)) @@ -96,21 +85,6 @@ def _evaluateS1(self, x): else: return np.sum(e), np.sum(de, axis=1) - def set_fail_gradient(self, de): - """ - Set the fail gradient to a specified value. - - The fail gradient is used if an error occurs during the calculation - of the gradient. This method allows updating the default gradient value. - - Parameters - ---------- - de : float - The new fail gradient value to be used. - """ - de = float(de) - self._de = de - class SumSquaredError(BaseCost): """ @@ -131,18 +105,15 @@ class SumSquaredError(BaseCost): """ def __init__(self, problem): - super(SumSquaredError, self).__init__(problem) - - # Default fail gradient - self._de = 1.0 + super().__init__(problem) - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs, grad=None): """ Calculate the sum of squared errors for a given set of parameters. Parameters ---------- - x : array-like + inputs : Inputs The parameters for which to evaluate the cost. grad : array-like, optional An array to store the gradient of the cost function with respect @@ -151,51 +122,45 @@ def _evaluate(self, x, grad=None): Returns ------- float - The sum of squared errors. + The Sum of Squared Error. """ - prediction = self.problem.evaluate(x) + prediction = self.problem.evaluate(inputs) - for key in self.signal: - if len(prediction.get(key, [])) != len(self._target.get(key, [])): - return np.float64(np.inf) # prediction doesn't match target + if not self.verify_prediction(prediction): + return np.inf e = np.asarray( [ - np.sum(((prediction[signal] - self._target[signal]) ** 2)) + np.sum((prediction[signal] - self._target[signal]) ** 2) for signal in self.signal ] ) - if self.n_outputs == 1: - return e.item() - else: - return np.sum(e) - def _evaluateS1(self, x): + return e.item() if self.n_outputs == 1 else np.sum(e) + + def _evaluateS1(self, inputs: Inputs): """ Compute the cost and its gradient with respect to the parameters. Parameters ---------- - x : array-like + inputs : Inputs The parameters for which to compute the cost and gradient. Returns ------- tuple A tuple containing the cost and the gradient. The cost is a float, - and the gradient is an array-like of the same length as `x`. + and the gradient is an array-like of the same length as `inputs`. Raises ------ ValueError If an error occurs during the calculation of the cost or gradient. """ - y, dy = self.problem.evaluateS1(x) - for key in self.signal: - if len(y.get(key, [])) != len(self._target.get(key, [])): - e = np.float64(np.inf) - de = self._de * np.ones(self.n_parameters) - return e, de + y, dy = self.problem.evaluateS1(inputs) + if not self.verify_prediction(y): + return np.inf, self._de * np.ones(self.n_parameters) r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) e = np.sum(np.sum(r**2, axis=0), axis=0) @@ -203,122 +168,235 @@ def _evaluateS1(self, x): return e, de - def set_fail_gradient(self, de): + +class Minkowski(BaseCost): + """ + The Minkowski distance is a generalisation of several distance metrics, + including the Euclidean and Manhattan distances. It is defined as: + + .. math:: + L_p(x, y) = ( \\sum_i |x_i - y_i|^p )^(1/p) + + where p > 0 is the order of the Minkowski distance. For p ≥ 1, the + Minkowski distance is a metric. For 0 < p < 1, it is not a metric, as it + does not satisfy the triangle inequality, although a metric can be + obtained by removing the (1/p) exponent. + + Special cases: + + * p = 1: Manhattan distance + * p = 2: Euclidean distance + * p → ∞: Chebyshev distance (not implemented as yet) + + This class implements the Minkowski distance as a cost function for + optimisation problems, allowing for flexible distance-based optimisation + across various problem domains. + + Attributes + ---------- + p : float, optional + The order of the Minkowski distance. + """ + + def __init__(self, problem, p: float = 2.0): + super().__init__(problem) + if p < 0: + raise ValueError( + "The order of the Minkowski distance must be greater than 0." + ) + elif not np.isfinite(p): + raise ValueError( + "For p = infinity, an implementation of the Chebyshev distance is required." + ) + self.p = float(p) + + def _evaluate(self, inputs: Inputs, grad=None): + """ + Calculate the Minkowski cost for a given set of parameters. + + Parameters + ---------- + inputs : Inputs + The parameters for which to compute the cost and gradient. + + Returns + ------- + float + The Minkowski cost. """ - Set the fail gradient to a specified value. + prediction = self.problem.evaluate(inputs) + if not self.verify_prediction(prediction): + return np.inf - The fail gradient is used if an error occurs during the calculation - of the gradient. This method allows updating the default gradient value. + e = np.asarray( + [ + np.sum(np.abs(prediction[signal] - self._target[signal]) ** self.p) + ** (1 / self.p) + for signal in self.signal + ] + ) + + return e.item() if self.n_outputs == 1 else np.sum(e) + + def _evaluateS1(self, inputs): + """ + Compute the cost and its gradient with respect to the parameters. Parameters ---------- - de : float - The new fail gradient value to be used. + inputs : Inputs + The parameters for which to compute the cost and gradient. + + Returns + ------- + tuple + A tuple containing the cost and the gradient. The cost is a float, + and the gradient is an array-like of the same length as `inputs`. + + Raises + ------ + ValueError + If an error occurs during the calculation of the cost or gradient. """ - de = float(de) - self._de = de + y, dy = self.problem.evaluateS1(inputs) + if not self.verify_prediction(y): + return np.inf, self._de * np.ones(self.n_parameters) + r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) + e = np.asarray( + [ + np.sum(np.abs(y[signal] - self._target[signal]) ** self.p) + ** (1 / self.p) + for signal in self.signal + ] + ) + de = np.sum( + np.sum(r ** (self.p - 1) * dy.T, axis=2) + / (e ** (self.p - 1) + np.finfo(float).eps), + axis=1, + ) -class ObserverCost(BaseCost): + return np.sum(e), de + + +class SumofPower(BaseCost): """ - Observer cost function. + The Sum of Power [1] is a generalised cost function based on the p-th power + of absolute differences between two vectors. It is defined as: - Computes the cost function for an observer model, which is log likelihood - of the data points given the model parameters. + .. math:: + C_p(x, y) = \\sum_i |x_i - y_i|^p - Inherits all parameters and attributes from ``BaseCost``. + where p ≥ 0 is the power order. + + This class implements the Sum of Power as a cost function for + optimisation problems, allowing for flexible power-based optimisation + across various problem domains. + + Special cases: + + * p = 1: Sum of Absolute Differences + * p = 2: Sum of Squared Differences + * p → ∞: Maximum Absolute Difference + + Note that this is not normalised, unlike distance metrics. To get a + distance metric, you would need to take the p-th root of the result. + + [1]: https://mathworld.wolfram.com/PowerSum.html + Attributes: + p : float, optional + The power order for Sum of Power. """ - def __init__(self, observer: Observer): - super().__init__(problem=observer) - self._observer = observer + def __init__(self, problem, p: float = 2.0): + super().__init__(problem) + if p < 0: + raise ValueError("The order of 'p' must be greater than 0.") + elif not np.isfinite(p): + raise ValueError("p = np.inf is not yet supported.") + self.p = float(p) - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs, grad=None): """ - Calculate the observer cost for a given set of parameters. + Calculate the Sum of Power cost for a given set of parameters. Parameters ---------- - x : array-like - The parameters for which to evaluate the cost. - grad : array-like, optional - An array to store the gradient of the cost function with respect - to the parameters. + inputs : Inputs + The parameters for which to compute the cost and gradient. Returns ------- float - The observer cost (negative of the log likelihood). + The Sum of Power cost. """ - inputs = self._observer.parameters.as_dict(x) - log_likelihood = self._observer.log_likelihood( - self._target, self._observer.time_data(), inputs + prediction = self.problem.evaluate(inputs) + if not self.verify_prediction(prediction): + return np.inf + + e = np.asarray( + [ + np.sum(np.abs(prediction[signal] - self._target[signal]) ** self.p) + for signal in self.signal + ] ) - return -log_likelihood - def evaluateS1(self, x): + return e.item() if self.n_outputs == 1 else np.sum(e) + + def _evaluateS1(self, inputs): """ Compute the cost and its gradient with respect to the parameters. Parameters ---------- - x : array-like + inputs : Inputs The parameters for which to compute the cost and gradient. Returns ------- tuple A tuple containing the cost and the gradient. The cost is a float, - and the gradient is an array-like of the same length as `x`. + and the gradient is an array-like of the same length as `inputs`. Raises ------ ValueError If an error occurs during the calculation of the cost or gradient. """ - raise NotImplementedError + y, dy = self.problem.evaluateS1(inputs) + if not self.verify_prediction(y): + return np.inf, self._de * np.ones(self.n_parameters) + + r = np.asarray([y[signal] - self._target[signal] for signal in self.signal]) + e = np.sum(np.sum(np.abs(r) ** self.p)) + de = self.p * np.sum(np.sum(r ** (self.p - 1) * dy.T, axis=2), axis=1) + return e, de -class MAP(BaseLikelihood): + +class ObserverCost(BaseCost): """ - Maximum a posteriori cost function. + Observer cost function. - Computes the maximum a posteriori cost function, which is the sum of the - log likelihood and the log prior. The goal of maximising is achieved by - setting minimising = False in the optimiser settings. + Computes the cost function for an observer model, which is log likelihood + of the data points given the model parameters. - Inherits all parameters and attributes from ``BaseLikelihood``. + Inherits all parameters and attributes from ``BaseCost``. """ - def __init__(self, problem, likelihood, sigma=None): - super(MAP, self).__init__(problem) - self.sigma0 = sigma - if self.sigma0 is None: - self.sigma0 = [] - for param in self.problem.parameters: - self.sigma0.append(param.prior.sigma) - - try: - self.likelihood = likelihood(problem=self.problem, sigma=self.sigma0) - except Exception as e: - raise ValueError( - f"An error occurred when constructing the Likelihood class: {e}" - ) - - if hasattr(self, "likelihood") and not isinstance( - self.likelihood, BaseLikelihood - ): - raise ValueError(f"{self.likelihood} must be a subclass of BaseLikelihood") + def __init__(self, observer: Observer): + super().__init__(problem=observer) + self._observer = observer - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs, grad=None): """ - Calculate the maximum a posteriori cost for a given set of parameters. + Calculate the observer cost for a given set of parameters. Parameters ---------- - x : array-like + inputs : Inputs The parameters for which to evaluate the cost. grad : array-like, optional An array to store the gradient of the cost function with respect @@ -327,41 +405,31 @@ def _evaluate(self, x, grad=None): Returns ------- float - The maximum a posteriori cost. + The observer cost (negative of the log likelihood). """ - log_likelihood = self.likelihood.evaluate(x) - log_prior = sum( - param.prior.logpdf(x_i) for x_i, param in zip(x, self.problem.parameters) + log_likelihood = self._observer.log_likelihood( + self._target, self._observer.time_data(), inputs ) + return -log_likelihood - posterior = log_likelihood + log_prior - return posterior - - def _evaluateS1(self, x): + def evaluateS1(self, inputs: Inputs): """ - Compute the maximum a posteriori with respect to the parameters. - The method passes the likelihood gradient to the optimiser without modification. + Compute the cost and its gradient with respect to the parameters. Parameters ---------- - x : array-like + inputs : Inputs The parameters for which to compute the cost and gradient. Returns ------- tuple A tuple containing the cost and the gradient. The cost is a float, - and the gradient is an array-like of the same length as `x`. + and the gradient is an array-like of the same length as `inputs`. Raises ------ ValueError If an error occurs during the calculation of the cost or gradient. """ - log_likelihood, dl = self.likelihood.evaluateS1(x) - log_prior = sum( - param.prior.logpdf(x_i) for x_i, param in zip(x, self.problem.parameters) - ) - - posterior = log_likelihood + log_prior - return posterior, dl + raise NotImplementedError diff --git a/pybop/models/base_model.py b/pybop/models/base_model.py index a05062672..461989086 100644 --- a/pybop/models/base_model.py +++ b/pybop/models/base_model.py @@ -1,18 +1,17 @@ import copy from dataclasses import dataclass -from typing import Any, Dict, Optional, Union +from typing import Any, Optional, Union import casadi import numpy as np import pybamm from pybop import Dataset, Experiment, Parameters, ParameterSet - -Inputs = Dict[str, float] +from pybop.parameters.parameter import Inputs @dataclass -class TimeSeriesState(object): +class TimeSeriesState: """ The current state of a time series model that is a pybamm model. """ @@ -65,7 +64,7 @@ def __init__(self, name="Base Model", parameter_set=None): else: # a pybop parameter set self._parameter_set = pybamm.ParameterValues(parameter_set.params) - self.parameters = None + self.parameters = Parameters() self.dataset = None self.signal = None self.additional_variables = [] @@ -74,16 +73,12 @@ def __init__(self, name="Base Model", parameter_set=None): self.param_check_counter = 0 self.allow_infeasible_solutions = True - @property - def n_parameters(self): - return len(self.parameters) - def build( self, dataset: Dataset = None, - parameters: Union[Parameters, Dict] = None, + parameters: Union[Parameters, dict] = None, check_model: bool = True, - init_soc: float = None, + init_soc: Optional[float] = None, ) -> None: """ Construct the PyBaMM model if not already built, and set parameters. @@ -104,8 +99,8 @@ def build( The initial state of charge to be used in simulations. """ self.dataset = dataset - self.parameters = parameters - if self.parameters is not None: + if parameters is not None: + self.parameters = parameters self.classify_and_update_parameters(self.parameters) if init_soc is not None: @@ -196,10 +191,10 @@ def set_params(self, rebuild=False): def rebuild( self, dataset: Dataset = None, - parameters: Union[Parameters, Dict] = None, + parameters: Union[Parameters, dict] = None, parameter_set: ParameterSet = None, check_model: bool = True, - init_soc: float = None, + init_soc: Optional[float] = None, ) -> None: """ Rebuild the PyBaMM model for a given parameter set. @@ -223,8 +218,8 @@ def rebuild( The initial state of charge to be used in simulations. """ self.dataset = dataset + if parameters is not None: - self.parameters = parameters self.classify_and_update_parameters(parameters) if init_soc is not None: @@ -243,7 +238,7 @@ def rebuild( # Clear solver and setup model self._solver._model_set_up = {} - def classify_and_update_parameters(self, parameters: Union[Parameters, Dict]): + def classify_and_update_parameters(self, parameters: Parameters): """ Update the parameter values according to their classification as either 'rebuild_parameters' which require a model rebuild and @@ -251,10 +246,16 @@ def classify_and_update_parameters(self, parameters: Union[Parameters, Dict]): Parameters ---------- - parameters : pybop.ParameterSet + parameters : pybop.Parameters """ - parameter_dictionary = parameters.as_dict() + if parameters is None: + self.parameters = Parameters() + else: + self.parameters = parameters + + parameter_dictionary = self.parameters.as_dict() + rebuild_parameters = { param: parameter_dictionary[param] for param in parameter_dictionary @@ -275,6 +276,9 @@ def classify_and_update_parameters(self, parameters: Union[Parameters, Dict]): self._unprocessed_parameter_set = self._parameter_set self.geometry = self.pybamm_model.default_geometry + # Update the list of parameter names and number of parameters + self._n_parameters = len(self.parameters) + def reinit( self, inputs: Inputs, t: float = 0.0, x: Optional[np.ndarray] = None ) -> TimeSeriesState: @@ -284,8 +288,7 @@ def reinit( if self._built_model is None: raise ValueError("Model must be built before calling reinit") - if not isinstance(inputs, dict): - inputs = self.parameters.as_dict(inputs) + inputs = self.parameters.verify(inputs) self._solver.set_up(self._built_model, inputs=inputs) @@ -326,15 +329,14 @@ def step(self, state: TimeSeriesState, time: np.ndarray) -> TimeSeriesState: def simulate( self, inputs: Inputs, t_eval: np.array - ) -> Dict[str, np.ndarray[np.float64]]: + ) -> dict[str, np.ndarray[np.float64]]: """ Execute the forward model simulation and return the result. Parameters ---------- - inputs : dict or array-like - The input parameters for the simulation. If array-like, it will be - converted to a dictionary using the model's fit keys. + inputs : Inputs + The input parameters for the simulation. t_eval : array-like An array of time points at which to evaluate the solution. @@ -348,6 +350,8 @@ def simulate( ValueError If the model has not been built before simulation. """ + inputs = self.parameters.verify(inputs) + if self._built_model is None: raise ValueError("Model must be built before calling simulate") else: @@ -355,9 +359,6 @@ def simulate( sol = self.solver.solve(self.built_model, t_eval=t_eval) else: - if not isinstance(inputs, dict): - inputs = self.parameters.as_dict(inputs) - if self.check_params( inputs=inputs, allow_infeasible_solutions=self.allow_infeasible_solutions, @@ -385,9 +386,8 @@ def simulateS1(self, inputs: Inputs, t_eval: np.array): Parameters ---------- - inputs : dict or array-like - The input parameters for the simulation. If array-like, it will be - converted to a dictionary using the model's fit keys. + inputs : Inputs + The input parameters for the simulation. t_eval : array-like An array of time points at which to evaluate the solution and its sensitivities. @@ -402,6 +402,7 @@ def simulateS1(self, inputs: Inputs, t_eval: np.array): ValueError If the model has not been built before simulation. """ + inputs = self.parameters.verify(inputs) if self._built_model is None: raise ValueError("Model must be built before calling simulate") @@ -411,9 +412,6 @@ def simulateS1(self, inputs: Inputs, t_eval: np.array): "Cannot use sensitivies for parameters which require a model rebuild" ) - if not isinstance(inputs, dict): - inputs = self.parameters.as_dict(inputs) - if self.check_params( inputs=inputs, allow_infeasible_solutions=self.allow_infeasible_solutions, @@ -432,7 +430,7 @@ def simulateS1(self, inputs: Inputs, t_eval: np.array): ( sol[self.signal[0]].data.shape[0], self.n_outputs, - self.n_parameters, + self._n_parameters, ) ) @@ -459,8 +457,8 @@ def predict( t_eval: np.array = None, parameter_set: ParameterSet = None, experiment: Experiment = None, - init_soc: float = None, - ) -> Dict[str, np.ndarray[np.float64]]: + init_soc: Optional[float] = None, + ) -> dict[str, np.ndarray[np.float64]]: """ Solve the model using PyBaMM's simulation framework and return the solution. @@ -470,10 +468,9 @@ def predict( Parameters ---------- - inputs : dict or array-like, optional - Input parameters for the simulation. If the input is array-like, it is converted - to a dictionary using the model's fitting keys. Defaults to None, indicating - that the default parameters should be used. + inputs : Inputs, optional + Input parameters for the simulation. Defaults to None, indicating that the + default parameters should be used. t_eval : array-like, optional An array of time points at which to evaluate the solution. Defaults to None, which means the time points need to be specified within experiment or elsewhere. @@ -499,13 +496,13 @@ def predict( if PyBaMM models are not supported by the current simulation method. """ + inputs = self.parameters.verify(inputs) + if not self.pybamm_model._built: self.pybamm_model.build_model() parameter_set = parameter_set or self._unprocessed_parameter_set if inputs is not None: - if not isinstance(inputs, dict): - inputs = self.parameters.as_dict(inputs) parameter_set.update(inputs) if self.check_params( @@ -544,7 +541,7 @@ def check_params( Parameters ---------- - inputs : dict + inputs : Inputs The input parameters for the simulation. allow_infeasible_solutions : bool, optional If True, infeasible parameter values will be allowed in the optimisation (default: True). @@ -555,17 +552,7 @@ def check_params( A boolean which signifies whether the parameters are compatible. """ - if inputs is not None: - if not isinstance(inputs, dict): - if isinstance(inputs, list): - for entry in inputs: - if not isinstance(entry, (int, float)): - raise ValueError( - "Expecting inputs in the form of a dictionary, numeric list" - + f" or None, but received a list with type: {type(inputs)}" - ) - else: - inputs = self.parameters.as_dict(inputs) + inputs = self.parameters.verify(inputs) return self._check_params( inputs=inputs, allow_infeasible_solutions=allow_infeasible_solutions @@ -580,7 +567,7 @@ def _check_params( Parameters ---------- - inputs : dict + inputs : Inputs The input parameters for the simulation. allow_infeasible_solutions : bool, optional If True, infeasible parameter values will be allowed in the optimisation (default: True). @@ -641,7 +628,7 @@ def cell_volume(self, parameter_set: ParameterSet = None): """ raise NotImplementedError - def approximate_capacity(self, x): + def approximate_capacity(self, inputs: Inputs): """ Calculate a new estimate for the nominal capacity based on the theoretical energy density and an average voltage. @@ -650,8 +637,8 @@ def approximate_capacity(self, x): Parameters ---------- - x : array-like - An array of values representing the model inputs. + inputs : Inputs + The parameters that are the inputs of the model. Raises ------ @@ -689,7 +676,7 @@ def submesh_types(self): return self._submesh_types @submesh_types.setter - def submesh_types(self, submesh_types: Optional[Dict[str, Any]]): + def submesh_types(self, submesh_types: Optional[dict[str, Any]]): self._submesh_types = ( submesh_types.copy() if submesh_types is not None else None ) @@ -703,7 +690,7 @@ def var_pts(self): return self._var_pts @var_pts.setter - def var_pts(self, var_pts: Optional[Dict[str, int]]): + def var_pts(self, var_pts: Optional[dict[str, int]]): self._var_pts = var_pts.copy() if var_pts is not None else None @property @@ -711,7 +698,7 @@ def spatial_methods(self): return self._spatial_methods @spatial_methods.setter - def spatial_methods(self, spatial_methods: Optional[Dict[str, Any]]): + def spatial_methods(self, spatial_methods: Optional[dict[str, Any]]): self._spatial_methods = ( spatial_methods.copy() if spatial_methods is not None else None ) diff --git a/pybop/models/empirical/base_ecm.py b/pybop/models/empirical/base_ecm.py index 8d15442d1..38d94d147 100644 --- a/pybop/models/empirical/base_ecm.py +++ b/pybop/models/empirical/base_ecm.py @@ -1,4 +1,4 @@ -from pybop.models.base_model import BaseModel +from pybop.models.base_model import BaseModel, Inputs class ECircuitModel(BaseModel): @@ -52,14 +52,14 @@ def __init__( # Correct OCP if set to default if ( parameter_set is not None - and "Open-circuit voltage [V]" in parameter_set.params + and "Open-circuit voltage [V]" in parameter_set.keys() ): default_ocp = self.pybamm_model.default_parameter_values[ "Open-circuit voltage [V]" ] - if parameter_set.params["Open-circuit voltage [V]"] == "default": + if parameter_set["Open-circuit voltage [V]"] == "default": print("Setting open-circuit voltage to default function") - parameter_set.params["Open-circuit voltage [V]"] = default_ocp + parameter_set["Open-circuit voltage [V]"] = default_ocp super().__init__(name=name, parameter_set=parameter_set) @@ -85,13 +85,13 @@ def __init__( self._disc = None self.geometric_parameters = {} - def _check_params(self, inputs=None, allow_infeasible_solutions=True): + def _check_params(self, inputs: Inputs = None, allow_infeasible_solutions=True): """ Check the compatibility of the model parameters. Parameters ---------- - inputs : dict + inputs : Inputs The input parameters for the simulation. allow_infeasible_solutions : bool, optional If True, infeasible parameter values will be allowed in the optimisation (default: True). diff --git a/pybop/models/empirical/ecm.py b/pybop/models/empirical/ecm.py index 031da3fde..893b30bc6 100644 --- a/pybop/models/empirical/ecm.py +++ b/pybop/models/empirical/ecm.py @@ -1,6 +1,7 @@ from pybamm import equivalent_circuit as pybamm_equivalent_circuit from pybop.models.empirical.base_ecm import ECircuitModel +from pybop.parameters.parameter import Inputs class Thevenin(ECircuitModel): @@ -44,13 +45,13 @@ def __init__( pybamm_model=pybamm_equivalent_circuit.Thevenin, name=name, **model_kwargs ) - def _check_params(self, inputs=None, allow_infeasible_solutions=True): + def _check_params(self, inputs: Inputs = None, allow_infeasible_solutions=True): """ Check the compatibility of the model parameters. Parameters ---------- - inputs : dict + inputs : Inputs The input parameters for the simulation. allow_infeasible_solutions : bool, optional If True, infeasible parameter values will be allowed in the optimisation (default: True). diff --git a/pybop/models/lithium_ion/base_echem.py b/pybop/models/lithium_ion/base_echem.py index 6947774bf..f60f500d3 100644 --- a/pybop/models/lithium_ion/base_echem.py +++ b/pybop/models/lithium_ion/base_echem.py @@ -2,7 +2,7 @@ from pybamm import lithium_ion as pybamm_lithium_ion -from pybop.models.base_model import BaseModel +from pybop.models.base_model import BaseModel, Inputs class EChemBaseModel(BaseModel): @@ -47,10 +47,7 @@ def __init__( solver=None, **model_kwargs, ): - super().__init__( - name=name, - parameter_set=parameter_set, - ) + super().__init__(name=name, parameter_set=parameter_set) model_options = dict(build=False) for key, value in model_kwargs.items(): @@ -88,14 +85,14 @@ def __init__( self.geometric_parameters = self.set_geometric_parameters() def _check_params( - self, inputs=None, parameter_set=None, allow_infeasible_solutions=True + self, inputs: Inputs = None, parameter_set=None, allow_infeasible_solutions=True ): """ Check compatibility of the model parameters. Parameters ---------- - inputs : dict + inputs : Inputs The input parameters for the simulation. allow_infeasible_solutions : bool, optional If True, infeasible parameter values will be allowed in the optimisation (default: True). @@ -139,7 +136,7 @@ def _check_params( ): if self.param_check_counter <= len(electrode_params): infeasibility_warning = "Non-physical point encountered - [{material_vol_fraction} + {porosity}] > 1.0!" - warnings.warn(infeasibility_warning, UserWarning) + warnings.warn(infeasibility_warning, UserWarning, stacklevel=2) self.param_check_counter += 1 return allow_infeasible_solutions @@ -267,7 +264,7 @@ def area_density(thickness, mass_density): ) return cross_sectional_area * total_area_density - def approximate_capacity(self, x): + def approximate_capacity(self, inputs: Inputs): """ Calculate and update an estimate for the nominal cell capacity based on the theoretical energy density and an average voltage. @@ -277,14 +274,22 @@ def approximate_capacity(self, x): Parameters ---------- - x : array-like - An array of values representing the model inputs. + inputs : Inputs + The parameters that are the inputs of the model. Returns ------- None The nominal cell capacity is updated directly in the model's parameter set. """ + inputs = self.parameters.verify(inputs) + self._parameter_set.update(inputs) + + # Calculate theoretical energy density + theoretical_energy = self._electrode_soh.calculate_theoretical_energy( + self._parameter_set + ) + # Extract stoichiometries and compute mean values ( min_sto_neg, @@ -295,16 +300,6 @@ def approximate_capacity(self, x): mean_sto_neg = (min_sto_neg + max_sto_neg) / 2 mean_sto_pos = (min_sto_pos + max_sto_pos) / 2 - inputs = { - key: x[i] for i, key in enumerate([param.name for param in self.parameters]) - } - self._parameter_set.update(inputs) - - # Calculate theoretical energy density - theoretical_energy = self._electrode_soh.calculate_theoretical_energy( - self._parameter_set - ) - # Calculate average voltage positive_electrode_ocp = self._parameter_set["Positive electrode OCP [V]"] negative_electrode_ocp = self._parameter_set["Negative electrode OCP [V]"] @@ -313,7 +308,7 @@ def approximate_capacity(self, x): mean_sto_pos ) - negative_electrode_ocp(mean_sto_neg) except Exception as e: - raise ValueError(f"Error in average voltage calculation: {e}") + raise ValueError(f"Error in average voltage calculation: {e}") from e # Calculate and update nominal capacity theoretical_capacity = theoretical_energy / average_voltage diff --git a/pybop/models/lithium_ion/echem.py b/pybop/models/lithium_ion/echem.py index 8bd8ab636..9fdd308e3 100644 --- a/pybop/models/lithium_ion/echem.py +++ b/pybop/models/lithium_ion/echem.py @@ -1,8 +1,7 @@ from pybamm import lithium_ion as pybamm_lithium_ion from pybop.models.lithium_ion.base_echem import EChemBaseModel - -from .weppner_huggins import BaseWeppnerHuggins +from pybop.models.lithium_ion.weppner_huggins import BaseWeppnerHuggins class SPM(EChemBaseModel): diff --git a/pybop/models/lithium_ion/weppner_huggins.py b/pybop/models/lithium_ion/weppner_huggins.py index 5d8d626a4..74c42c70e 100644 --- a/pybop/models/lithium_ion/weppner_huggins.py +++ b/pybop/models/lithium_ion/weppner_huggins.py @@ -36,7 +36,7 @@ def __init__(self, name="Weppner & Huggins model", **model_kwargs): # Model kwargs (build, options) are not implemented, keeping here for consistent interface if model_kwargs is not dict(build=True): unused_kwargs_warning = "The input model_kwargs are not currently used by the Weppner & Huggins model." - warnings.warn(unused_kwargs_warning, UserWarning) + warnings.warn(unused_kwargs_warning, UserWarning, stacklevel=2) super().__init__({}, name) diff --git a/pybop/observers/observer.py b/pybop/observers/observer.py index 1b81c5ac7..f7d6f25f3 100644 --- a/pybop/observers/observer.py +++ b/pybop/observers/observer.py @@ -38,10 +38,14 @@ def __init__( parameters: Parameters, model: BaseModel, check_model=True, - signal=["Voltage [V]"], - additional_variables=[], + signal=None, + additional_variables=None, init_soc=None, ) -> None: + if additional_variables is None: + additional_variables = [] + if signal is None: + signal = ["Voltage [V]"] super().__init__( parameters, model, check_model, signal, additional_variables, init_soc ) @@ -50,16 +54,15 @@ def __init__( if model.signal is None: model.signal = self.signal - inputs = dict() - for param in self.parameters: - inputs[param.name] = param.value - + inputs = self.parameters.as_dict("initial") self._state = model.reinit(inputs) self._model = model self._signal = self.signal self._n_outputs = len(self._signal) def reset(self, inputs: Inputs) -> None: + inputs = self.parameters.verify(inputs) + self._state = self._model.reinit(inputs) def observe(self, time: float, value: Optional[np.ndarray] = None) -> float: @@ -96,6 +99,8 @@ def log_likelihood(self, values: dict, times: np.ndarray, inputs: Inputs) -> flo inputs : Inputs The inputs to the model. """ + inputs = self.parameters.verify(inputs) + if self._n_outputs == 1: signal = self._signal[0] if len(values[signal]) != len(times): @@ -142,27 +147,20 @@ def get_current_time(self) -> float: """ return self._state.t - def evaluate(self, x): + def evaluate(self, inputs: Inputs): """ Evaluate the model with the given parameters and return the signal. Parameters ---------- - x : np.ndarray - Parameter values to evaluate the model at. + inputs : Inputs + Parameters for evaluation of the model. Returns ------- y : np.ndarray - The model output y(t) simulated with inputs x. + The model output y(t) simulated with given inputs. """ - inputs = dict() - if isinstance(x, Parameters): - for param in x: - inputs[param.name] = param.value - else: # x is an array of parameter values - for i, param in enumerate(self.parameters): - inputs[param.name] = x[i] self.reset(inputs) output = {} diff --git a/pybop/observers/unscented_kalman.py b/pybop/observers/unscented_kalman.py index 0b6425db9..60f4f0949 100644 --- a/pybop/observers/unscented_kalman.py +++ b/pybop/observers/unscented_kalman.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Tuple, Union +from typing import Union import numpy as np import scipy.linalg as linalg @@ -15,8 +15,8 @@ class UnscentedKalmanFilterObserver(Observer): Parameters ---------- - parameters: List[Parameters] - The inputs to the model. + parameters: Parameters + The parameters for the model. model : BaseModel The model to observe. sigma0 : np.ndarray | float @@ -41,17 +41,21 @@ class UnscentedKalmanFilterObserver(Observer): def __init__( self, - parameters: List[Parameter], + parameters: list[Parameter], model: BaseModel, sigma0: Union[Covariance, float], process: Union[Covariance, float], measure: Union[Covariance, float], dataset=None, check_model=True, - signal=["Voltage [V]"], - additional_variables=[], + signal=None, + additional_variables=None, init_soc=None, ) -> None: + if additional_variables is None: + additional_variables = [] + if signal is None: + signal = ["Voltage [V]"] super().__init__( parameters, model, check_model, signal, additional_variables, init_soc ) @@ -59,7 +63,7 @@ def __init__( self._dataset = dataset.data # Check that the dataset contains time and current - dataset.check(self.signal + ["Current function [A]"]) + dataset.check([*self.signal, "Current function [A]"]) self._time_data = self._dataset["Time [s]"] self.n_time_data = len(self._time_data) @@ -152,7 +156,7 @@ def get_current_covariance(self) -> Covariance: @dataclass -class SigmaPoint(object): +class SigmaPoint: """ A sigma point is a point in the state space that is used to estimate the mean and covariance of a random variable. """ @@ -162,7 +166,7 @@ class SigmaPoint(object): w_c: float -class SquareRootUKF(object): +class SquareRootUKF: """ van der Menve, R., & Wan, E. A. (2001). THE SQUARE-ROOT UNSCENTED KALMAN FILTER FOR STATE AND PARAMETER-ESTIMATION. https://doi.org/10.1109/ICASSP.2001.940586 @@ -235,7 +239,7 @@ def reset(self, x: np.ndarray, S: np.ndarray) -> None: @staticmethod def gen_sigma_points( x: np.ndarray, S: np.ndarray, alpha: float, beta: float, states: np.ndarray - ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Generates 2L+1 sigma points for the unscented transform, where L is the number of states. @@ -291,7 +295,7 @@ def unscented_transform( w_c: np.ndarray, sqrtR: np.ndarray, states: Union[np.ndarray, None] = None, - ) -> Tuple[np.ndarray, np.ndarray]: + ) -> tuple[np.ndarray, np.ndarray]: """ Performs the unscented transform diff --git a/pybop/optimisers/_adamw.py b/pybop/optimisers/_adamw.py index 24e5ec982..817d8f290 100644 --- a/pybop/optimisers/_adamw.py +++ b/pybop/optimisers/_adamw.py @@ -157,7 +157,7 @@ def tell(self, reply): # Check ask-tell pattern if not self._ready_for_tell: - raise Exception("ask() not called before tell()") + raise RuntimeError("ask() not called before tell()") self._ready_for_tell = False # Unpack reply diff --git a/pybop/optimisers/_cuckoo.py b/pybop/optimisers/_cuckoo.py new file mode 100644 index 000000000..346052728 --- /dev/null +++ b/pybop/optimisers/_cuckoo.py @@ -0,0 +1,197 @@ +import numpy as np +from pints import PopulationBasedOptimiser +from scipy.special import gamma + + +class CuckooSearchImpl(PopulationBasedOptimiser): + """ + Cuckoo Search (CS) optimisation algorithm, inspired by the brood parasitism + of some cuckoo species. This algorithm was introduced by Yang and Deb in 2009. + + The algorithm uses a population of host nests (solutions), where each cuckoo + (new solution) tries to replace a worse nest in the population. The quality + or fitness of the nests is determined by the cost function. A fraction + of the worst nests is abandoned at each generation, and new ones are built + randomly. + + The pseudo-code for the Cuckoo Search is as follows: + + 1. Initialise population of n host nests + 2. While (t < max_generations): + a. Get a cuckoo randomly by Lévy flights + b. Evaluate its quality/fitness F + c. Choose a nest among n (say, j) randomly + d. If (F > fitness of j): + i. Replace j with the new solution + e. Abandon a fraction (pa) of the worst nests and build new ones + f. Keep the best solutions/nests + g. Rank the solutions and find the current best + 3. End While + + This implementation also uses a decreasing step size for the Lévy flights, calculated + as sigma = sigma0 / sqrt(iterations), where sigma0 is the initial step size and + iterations is the current iteration number. + + Parameters: + - pa: Probability of discovering alien eggs/solutions (abandoning rate) + + References: + - X. -S. Yang and Suash Deb, "Cuckoo Search via Lévy flights," + 2009 World Congress on Nature & Biologically Inspired Computing (NaBIC), + Coimbatore, India, 2009, pp. 210-214, https://doi.org/10.1109/NABIC.2009.5393690. + + - S. Walton, O. Hassan, K. Morgan, M.R. Brown, + Modified cuckoo search: A new gradient free optimisation algorithm, + Chaos, Solitons & Fractals, Volume 44, Issue 9, 2011, + Pages 710-718, ISSN 0960-0779, + https://doi.org/10.1016/j.chaos.2011.06.004. + """ + + def __init__(self, x0, sigma0=0.05, boundaries=None, pa=0.25): + super().__init__(x0, sigma0, boundaries=boundaries) + + # Problem dimensionality + self._dim = len(x0) + + # Population size and abandon rate + self._n = self._population_size + self._pa = pa + self.step_size = self._sigma0 + self.beta = 1.5 + + # Set states + self._running = False + self._ready_for_tell = False + + # Initialise nests + if self._boundaries: + self._nests = np.random.uniform( + low=self._boundaries.lower(), + high=self._boundaries.upper(), + size=(self._n, self._dim), + ) + else: + self._nests = np.random.normal( + self._x0, self._sigma0, size=(self._n, self._dim) + ) + + self._fitness = np.full(self._n, np.inf) + + # Initialise best solutions + self._x_best = np.copy(x0) + self._f_best = np.inf + + # Set iteration count + self._iterations = 0 + + def ask(self): + """ + Returns a list of next points in the parameter-space + to evaluate from the optimiser. + """ + # Set flag to indicate that the optimiser is ready to receive replies + self._ready_for_tell = True + self._running = True + + # Generate new solutions (cuckoos) by Lévy flights + self.step_size = self._sigma0 / max(1, np.sqrt(self._iterations)) + step = self.levy_flight(self.beta, self._dim) * self.step_size + self.cuckoos = self._nests + step + return self.clip_nests(self.cuckoos) + + def tell(self, replies): + """ + Receives a list of function values from the cost function from points + previously specified by `self.ask()`, and updates the optimiser state + accordingly. + """ + # Update iteration count + self._iterations += 1 + + # Compare cuckoos with current nests + for i in range(self._n): + f_new = replies[i] + if f_new < self._fitness[i]: + self._nests[i] = self.cuckoos[i] + self._fitness[i] = f_new + if f_new < self._f_best: + self._f_best = f_new + self._x_best = self.cuckoos[i] + + # Abandon some worse nests + n_abandon = int(self._pa * self._n) + worst_nests = np.argsort(self._fitness)[-n_abandon:] + for idx in worst_nests: + self.abandon_nests(idx) + self._fitness[idx] = np.inf # reset fitness + + def levy_flight(self, alpha, size): + """ + Generate step sizes via the Mantegna's algorithm for Levy flights + """ + from numpy import pi, power, random, sin + + sigma_u = power( + (gamma(1 + alpha) * sin(pi * alpha / 2)) + / (gamma((1 + alpha) / 2) * alpha * power(2, (alpha - 1) / 2)), + 1 / alpha, + ) + sigma_v = 1 + + u = random.normal(0, sigma_u, size=size) + v = random.normal(0, sigma_v, size=size) + step = u / power(abs(v), 1 / alpha) + + return step + + def abandon_nests(self, idx): + """ + Updates the nests to abandon the worst performers and reinitialise. + """ + if self._boundaries: + self._nests[idx] = np.random.uniform( + low=self._boundaries.lower(), + high=self._boundaries.upper(), + ) + else: + self._nests[idx] = np.random.normal(self._x0, self._sigma0) + + def clip_nests(self, x): + """ + Clip the input array to the boundaries if available. + """ + if self._boundaries: + x = np.clip(x, self._boundaries.lower(), self._boundaries.upper()) + return x + + def _suggested_population_size(self): + """ + Inherited from Pints:PopulationBasedOptimiser. + Returns a suggested population size, based on the + dimension of the parameter space. + """ + return 4 + int(3 * np.log(self._n_parameters)) + + def running(self): + """ + Returns ``True`` if the optimisation is in progress. + """ + return self._running + + def x_best(self): + """ + Returns the best parameter values found so far. + """ + return self._x_best + + def f_best(self): + """ + Returns the best score found so far. + """ + return self._f_best + + def name(self): + """ + Returns the name of the optimiser. + """ + return "Cuckoo Search" diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index b283b9d8e..2b00b2345 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -1,8 +1,9 @@ import warnings +from typing import Optional import numpy as np -from pybop import BaseCost, BaseLikelihood, DesignCost +from pybop import BaseCost, BaseLikelihood, DesignCost, Parameter, Parameters class BaseOptimiser: @@ -50,6 +51,7 @@ def __init__( **optimiser_kwargs, ): # First set attributes to default values + self.parameters = Parameters() self.x0 = None self.bounds = None self.sigma0 = 0.1 @@ -63,36 +65,41 @@ def __init__( if isinstance(cost, BaseCost): self.cost = cost - self.x0 = cost.x0 + self.parameters.join(cost.parameters) self.set_allow_infeasible_solutions() if isinstance(cost, (BaseLikelihood, DesignCost)): self.minimising = False - # Set default bounds (for all or no parameters) - self.bounds = cost.parameters.get_bounds() - - # Set default initial standard deviation (for all or no parameters) - self.sigma0 = cost.parameters.get_sigma0() or self.sigma0 - else: try: - cost_test = cost(optimiser_kwargs.get("x0", [])) + self.x0 = optimiser_kwargs.get("x0", []) + cost_test = cost(self.x0) warnings.warn( "The cost is not an instance of pybop.BaseCost, but let's continue " - + "assuming that it is a callable function to be minimised.", + "assuming that it is a callable function to be minimised.", UserWarning, + stacklevel=2, ) self.cost = cost + for i, value in enumerate(self.x0): + self.parameters.add( + Parameter(name=f"Parameter {i}", initial_value=value) + ) self.minimising = True - except Exception: - raise Exception("The cost is not a recognised cost object or function.") + except Exception as e: + raise Exception( + "The cost is not a recognised cost object or function." + ) from e if not np.isscalar(cost_test) or not np.isreal(cost_test): raise TypeError( f"Cost returned {type(cost_test)}, not a scalar numeric value." ) + if len(self.parameters) == 0: + raise ValueError("There are no parameters to optimise.") + self.unset_options = optimiser_kwargs self.set_base_options() self._set_up_optimiser() @@ -109,9 +116,19 @@ def set_base_options(self): """ Update the base optimiser options and remove them from the options dictionary. """ - self.x0 = self.unset_options.pop("x0", self.x0) - self.bounds = self.unset_options.pop("bounds", self.bounds) - self.sigma0 = self.unset_options.pop("sigma0", self.sigma0) + # Set initial values, if x0 is None, initial values are unmodified. + self.parameters.update(initial_values=self.unset_options.pop("x0", None)) + self.x0 = self.parameters.initial_value() + + # Set default bounds (for all or no parameters) + self.bounds = self.unset_options.pop("bounds", self.parameters.get_bounds()) + + # Set default initial standard deviation (for all or no parameters) + self.sigma0 = self.unset_options.pop( + "sigma0", self.parameters.get_sigma0() or self.sigma0 + ) + + # Set other options self.verbose = self.unset_options.pop("verbose", self.verbose) self.minimising = self.unset_options.pop("minimising", self.minimising) if "allow_infeasible_solutions" in self.unset_options.keys(): @@ -147,8 +164,7 @@ def run(self): # Store the optimised parameters x = self.result.x - if hasattr(self.cost, "parameters"): - self.store_optimised_parameters(x) + self.parameters.update(values=x) # Check if parameters are viable if self.physical_viability: @@ -186,8 +202,12 @@ def store_optimised_parameters(self, x): def check_optimal_parameters(self, x): """ Check if the optimised parameters are physically viable. - """ + Parameters + ---------- + x : array-like + Optimised parameter values. + """ if self.cost.problem._model.check_params( inputs=x, allow_infeasible_solutions=False ): @@ -195,7 +215,7 @@ def check_optimal_parameters(self, x): else: warnings.warn( "Optimised parameters are not physically viable! \nConsider retrying the optimisation" - + " with a non-gradient-based optimiser and the option allow_infeasible_solutions=False", + " with a non-gradient-based optimiser and the option allow_infeasible_solutions=False", UserWarning, stacklevel=2, ) @@ -253,8 +273,8 @@ class Result: def __init__( self, x: np.ndarray = None, - final_cost: float = None, - n_iterations: int = None, + final_cost: Optional[float] = None, + n_iterations: Optional[int] = None, scipy_result=None, ): self.x = x diff --git a/pybop/optimisers/pints_optimisers.py b/pybop/optimisers/pints_optimisers.py index 4872973a8..83f13aa00 100644 --- a/pybop/optimisers/pints_optimisers.py +++ b/pybop/optimisers/pints_optimisers.py @@ -9,7 +9,7 @@ from pints import IRPropMin as PintsIRPropMin from pints import NelderMead as PintsNelderMead -from pybop import AdamWImpl, BasePintsOptimiser +from pybop import AdamWImpl, BasePintsOptimiser, CuckooSearchImpl class GradientDescent(BasePintsOptimiser): @@ -268,10 +268,38 @@ class CMAES(BasePintsOptimiser): """ def __init__(self, cost, **optimiser_kwargs): - x0 = optimiser_kwargs.pop("x0", cost.x0) - if x0 is not None and len(x0) == 1: + x0 = optimiser_kwargs.get("x0", cost.parameters.initial_value()) + if len(x0) == 1 or len(cost.parameters) == 1: raise ValueError( "CMAES requires optimisation of >= 2 parameters at once. " - + "Please choose another optimiser." + "Please choose another optimiser." ) super().__init__(cost, PintsCMAES, **optimiser_kwargs) + + +class CuckooSearch(BasePintsOptimiser): + """ + Adapter for the Cuckoo Search optimiser in PyBOP. + + Cuckoo Search is a population-based optimisation algorithm inspired by the brood parasitism of some cuckoo species. + It is designed to be simple, efficient, and robust, and is suitable for global optimisation problems. + + Parameters + ---------- + **optimiser_kwargs : optional + Valid PyBOP option keys and their values, for example: + x0 : array_like + Initial parameter values. + sigma0 : float + Initial step size. + bounds : dict + A dictionary with 'lower' and 'upper' keys containing arrays for lower and + upper bounds on the parameters. + + See Also + -------- + pybop.CuckooSearch : PyBOP implementation of Cuckoo Search algorithm. + """ + + def __init__(self, cost, **optimiser_kwargs): + super().__init__(cost, CuckooSearchImpl, **optimiser_kwargs) diff --git a/pybop/optimisers/scipy_optimisers.py b/pybop/optimisers/scipy_optimisers.py index 843423042..7528fbe00 100644 --- a/pybop/optimisers/scipy_optimisers.py +++ b/pybop/optimisers/scipy_optimisers.py @@ -141,6 +141,22 @@ def _set_up_optimiser(self): # Nest this option within an options dictionary for SciPy minimize self._options["options"]["maxiter"] = self.unset_options.pop(key) + def cost_wrapper(self, x): + """ + Scale the cost function, preserving the sign convention, and eliminate nan values + """ + self.log["x"].append([x]) + + if not self._options["jac"]: + cost = self.cost(x) / self._cost0 + if np.isinf(cost): + self.inf_count += 1 + cost = 1 + 0.9**self.inf_count # for fake finite gradient + return cost if self.minimising else -cost + + L, dl = self.cost.evaluateS1(x) + return (L, dl) if self.minimising else (-L, -dl) + def _run_optimiser(self): """ Executes the optimisation process using SciPy's minimize function. @@ -150,6 +166,7 @@ def _run_optimiser(self): result : scipy.optimize.OptimizeResult The result of the optimisation including the optimised parameter values and cost. """ + self.inf_count = 0 # Add callback storing history of parameter values def callback(intermediate_result: OptimizeResult): @@ -161,9 +178,9 @@ def callback(intermediate_result: OptimizeResult): # 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): - x0 = self.cost.parameters.rvs(1) - self._cost0 = np.abs(self.cost(x0)) + for _i in range(1, self.num_resamples): + self.x0 = self.parameters.rvs() + self._cost0 = np.abs(self.cost(self.x0)) if not np.isinf(self._cost0): break if np.isinf(self._cost0): @@ -171,27 +188,8 @@ def callback(intermediate_result: OptimizeResult): "The initial parameter values return an infinite cost." ) - # Scale the cost function, preserving the sign convention, and eliminate nan values - self.inf_count = 0 - - if not self._options["jac"]: - - def cost_wrapper(x): - self.log["x"].append([x]) - cost = self.cost(x) / self._cost0 - if np.isinf(cost): - self.inf_count += 1 - cost = 1 + 0.9**self.inf_count # for fake finite gradient - return cost if self.minimising else -cost - elif self._options["jac"] is True: - - def cost_wrapper(x): - self.log["x"].append([x]) - L, dl = self.cost.evaluateS1(x) - return L, dl if self.minimising else -L, -dl - return minimize( - cost_wrapper, + self.cost_wrapper, self.x0, bounds=self._scipy_bounds, callback=callback, diff --git a/pybop/parameters/parameter.py b/pybop/parameters/parameter.py index 0a5032987..a2d39c5aa 100644 --- a/pybop/parameters/parameter.py +++ b/pybop/parameters/parameter.py @@ -1,8 +1,13 @@ +import warnings from collections import OrderedDict -from typing import Dict, List +from typing import Union import numpy as np +from pybop._utils import is_numeric + +Inputs = dict[str, float] + class Parameter: """ @@ -42,10 +47,11 @@ def __init__( self.true_value = true_value self.initial_value = initial_value self.value = initial_value + self.applied_prior_bounds = False self.set_bounds(bounds) self.margin = 1e-4 - def rvs(self, n_samples, random_state=None): + def rvs(self, n_samples: int = 1, random_state=None): """ Draw random samples from the parameter's prior distribution. @@ -55,7 +61,7 @@ def rvs(self, n_samples, random_state=None): Parameters ---------- n_samples : int - The number of samples to draw. + The number of samples to draw (default: 1). Returns ------- @@ -73,7 +79,7 @@ def rvs(self, n_samples, random_state=None): return samples - def update(self, value=None, initial_value=None): + def update(self, initial_value=None, value=None): """ Update the parameter's current value. @@ -82,12 +88,12 @@ def update(self, value=None, initial_value=None): value : float The new value to be assigned to the parameter. """ - if value is not None: - self.value = value - elif initial_value is not None: + if initial_value is not None: self.initial_value = initial_value self.value = initial_value - else: + if value is not None: + self.value = value + if initial_value is None and value is None: raise ValueError("No value provided to update parameter") def __repr__(self): @@ -123,7 +129,7 @@ def set_margin(self, margin): self.margin = margin - def set_bounds(self, bounds=None): + def set_bounds(self, bounds=None, boundary_multiplier=6): """ Set the upper and lower bounds. @@ -132,6 +138,9 @@ def set_bounds(self, bounds=None): bounds : tuple, optional A tuple defining the lower and upper bounds for the parameter. Defaults to None. + boundary_multiplier : float, optional + Used to define the bounds when no bounds are passed but the parameter has + a prior distribution (default: 6). Raises ------ @@ -145,9 +154,25 @@ def set_bounds(self, bounds=None): else: self.lower_bound = bounds[0] self.upper_bound = bounds[1] + elif self.prior is not None: + self.applied_prior_bounds = True + self.lower_bound = self.prior.mean - boundary_multiplier * self.prior.sigma + self.upper_bound = self.prior.mean + boundary_multiplier * self.prior.sigma + bounds = [self.lower_bound, self.upper_bound] + print("Default bounds applied based on prior distribution.") self.bounds = bounds + def get_initial_value(self) -> float: + """ + Return the initial value of each parameter. + """ + if self.initial_value is None: + sample = self.rvs(1) + self.update(initial_value=sample[0]) + + return self.initial_value + class Parameters: """ @@ -180,13 +205,21 @@ def __getitem__(self, key: str) -> Parameter: ------- pybop.Parameter The Parameter object. + + Raises + ------ + ValueError + The key must be the name of one of the parameters. """ + if key not in self.param.keys(): + raise ValueError(f"The key {key} is not the name of a parameter.") + return self.param[key] def __len__(self) -> int: return len(self.param) - def keys(self) -> List: + def keys(self) -> list: """ A list of parameter names """ @@ -212,7 +245,7 @@ def add(self, parameter): if parameter.name in self.param.keys(): raise ValueError( f"There is already a parameter with the name {parameter.name} " - + "in the Parameters object. Please remove the duplicate entry." + "in the Parameters object. Please remove the duplicate entry." ) self.param[parameter.name] = parameter elif isinstance(parameter, dict): @@ -222,7 +255,7 @@ def add(self, parameter): if name in self.param.keys(): raise ValueError( f"There is already a parameter with the name {name} " - + "in the Parameters object. Please remove the duplicate entry." + "in the Parameters object. Please remove the duplicate entry." ) self.param[name] = Parameter(**parameter) else: @@ -240,7 +273,21 @@ def remove(self, parameter_name): # Remove the parameter self.param.pop(parameter_name) - def get_bounds(self) -> Dict: + def join(self, parameters=None): + """ + Join two Parameters objects into the first by copying across each Parameter. + + Parameters + ---------- + parameters : pybop.Parameters + """ + for param in parameters: + if param not in self.param.values(): + self.add(param) + else: + print(f"Discarding duplicate {param.name}.") + + def get_bounds(self) -> dict: """ Get bounds, for either all or no parameters. """ @@ -260,14 +307,22 @@ def get_bounds(self) -> Dict: return bounds - def update(self, values): + def update(self, initial_values=None, values=None, bounds=None): """ Set value of each parameter. """ for i, param in enumerate(self.param.values()): - param.update(value=values[i]) - - def rvs(self, n_samples: int) -> List: + if initial_values is not None: + param.update(initial_value=initial_values[i]) + if values is not None: + param.update(value=values[i]) + if bounds is not None: + if isinstance(bounds, dict): + param.set_bounds(bounds=[bounds["lower"][i], bounds["upper"][i]]) + else: + param.set_bounds(bounds=bounds[i]) + + def rvs(self, n_samples: int = 1) -> np.ndarray: """ Draw random samples from each parameter's prior distribution. @@ -277,7 +332,7 @@ def rvs(self, n_samples: int) -> List: Parameters ---------- n_samples : int - The number of samples to draw. + The number of samples to draw (default: 1). Returns ------- @@ -298,9 +353,9 @@ def rvs(self, n_samples: int) -> List: all_samples.append(samples) - return all_samples + return np.concatenate(all_samples) - def get_sigma0(self) -> List: + def get_sigma0(self) -> list: """ Get the standard deviation, for either all or no parameters. """ @@ -317,13 +372,13 @@ def get_sigma0(self) -> List: return sigma0 - def priors(self) -> List: + def priors(self) -> list: """ Return the prior distribution of each parameter. """ return [param.prior for param in self.param.values()] - def initial_value(self) -> List: + def initial_value(self) -> np.ndarray: """ Return the initial value of each parameter. """ @@ -331,13 +386,13 @@ def initial_value(self) -> List: for param in self.param.values(): if param.initial_value is None: - initial_value = param.rvs(1) - param.update(initial_value=initial_value[0]) + initial_value = param.rvs(1)[0] + param.update(initial_value=initial_value) initial_values.append(param.initial_value) - return initial_values + return np.asarray(initial_values) - def current_value(self) -> List: + def current_value(self) -> np.ndarray: """ Return the current value of each parameter. """ @@ -346,9 +401,9 @@ def current_value(self) -> List: for param in self.param.values(): current_values.append(param.value) - return current_values + return np.asarray(current_values) - def true_value(self) -> List: + def true_value(self) -> np.ndarray: """ Return the true value of each parameter. """ @@ -357,7 +412,7 @@ def true_value(self) -> List: for param in self.param.values(): true_values.append(param.true_value) - return true_values + return np.asarray(true_values) def get_bounds_for_plotly(self): """ @@ -368,9 +423,16 @@ def get_bounds_for_plotly(self): bounds : numpy.ndarray An array of shape (n_parameters, 2) containing the bounds for each parameter. """ - bounds = np.empty((len(self), 2)) + bounds = np.zeros((len(self), 2)) for i, param in enumerate(self.param.values()): + if param.applied_prior_bounds: + warnings.warn( + "Bounds were created from prior distributions. " + "Please provide bounds for better plotting results.", + UserWarning, + stacklevel=2, + ) if param.bounds is not None: bounds[i] = param.bounds else: @@ -378,7 +440,44 @@ def get_bounds_for_plotly(self): return bounds - def as_dict(self, values=None) -> Dict: + def as_dict(self, values=None) -> dict: + """ + Parameters + ---------- + values : list or str, optional + A list of parameter values or one of the strings "initial" or "true" which can be used + to obtain a dictionary of parameters. + + Returns + ------- + Inputs + A parameters dictionary. + """ if values is None: values = self.current_value() + elif isinstance(values, str): + if values == "initial": + values = self.initial_value() + elif values == "true": + values = self.true_value() return {key: values[i] for i, key in enumerate(self.param.keys())} + + def verify(self, inputs: Union[Inputs, None] = None): + """ + Verify that the inputs are an Inputs dictionary or numeric values + which can be used to construct an Inputs dictionary + + Parameters + ---------- + inputs : Inputs or numeric + """ + if inputs is None or isinstance(inputs, dict): + return inputs + elif (isinstance(inputs, list) and all(is_numeric(x) for x in inputs)) or all( + is_numeric(x) for x in list(inputs) + ): + return self.as_dict(inputs) + else: + raise TypeError( + f"Inputs must be a dictionary or numeric. Received {type(inputs)}" + ) diff --git a/pybop/parameters/parameter_set.py b/pybop/parameters/parameter_set.py index a1ab63cd3..c1b9505b5 100644 --- a/pybop/parameters/parameter_set.py +++ b/pybop/parameters/parameter_set.py @@ -1,7 +1,7 @@ import json import types -from pybamm import ParameterValues, parameter_sets +from pybamm import LithiumIonParameters, ParameterValues, parameter_sets class ParameterSet: @@ -34,6 +34,12 @@ def __setitem__(self, key, value): def __getitem__(self, key): return self.params[key] + def keys(self) -> list: + """ + A list of parameter names + """ + return list(self.params.keys()) + def import_parameters(self, json_path=None): """ Imports parameters from a JSON file specified by the `json_path` attribute. @@ -60,7 +66,7 @@ def import_parameters(self, json_path=None): # Read JSON file if not self.params and self.json_path: - with open(self.json_path, "r") as file: + with open(self.json_path) as file: self.params = json.load(file) else: raise ValueError( @@ -132,7 +138,7 @@ def export_parameters(self, output_json_path, fit_params=None): # Update parameter set if fit_params is not None: - for i, param in enumerate(fit_params): + for _i, param in enumerate(fit_params): exportable_params.update({param.name: param.value}) # Replace non-serializable values @@ -167,7 +173,7 @@ def is_json_serializable(self, value): return False @classmethod - def pybamm(cls, name): + def pybamm(cls, name, formation_concentrations=False): """ Retrieves a PyBaMM parameter set by name. @@ -175,6 +181,8 @@ def pybamm(cls, name): ---------- name : str The name of the PyBaMM parameter set to retrieve. + set_formation_concentrations : bool, optional + If True, re-calculates the initial concentrations of lithium in the active material (default: False). Returns ------- @@ -187,4 +195,44 @@ def pybamm(cls, name): if name not in list(parameter_sets): raise ValueError(msg) - return ParameterValues(name).copy() + parameter_set = ParameterValues(name).copy() + + if formation_concentrations: + set_formation_concentrations(parameter_set) + + return parameter_set + + +def set_formation_concentrations(parameter_set): + """ + Compute the concentration of lithium in the positive electrode assuming that + all lithium in the active material originated from the positive electrode. + + Parameters + ---------- + parameter_set : pybamm.ParameterValues + A PyBaMM parameter set containing standard lithium ion parameters. + """ + # Obtain the total amount of lithium in the active material + Q_Li_particles_init = parameter_set.evaluate( + LithiumIonParameters().Q_Li_particles_init + ) + + # Convert this total amount to a concentration in the positive electrode + c_init = ( + Q_Li_particles_init + * 3600 + / ( + parameter_set["Positive electrode active material volume fraction"] + * parameter_set["Positive electrode thickness [m]"] + * parameter_set["Electrode height [m]"] + * parameter_set["Electrode width [m]"] + * parameter_set["Faraday constant [C.mol-1]"] + ) + ) + + # Update the initial lithium concentrations + parameter_set.update({"Initial concentration in negative electrode [mol.m-3]": 0}) + parameter_set.update( + {"Initial concentration in positive electrode [mol.m-3]": c_init} + ) diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index 0557e3f69..634299607 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -122,6 +122,98 @@ def rvs(self, size=1, random_state=None): loc=self.loc, scale=self.scale, size=size, random_state=random_state ) + def __call__(self, x): + """ + Evaluates the distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the distribution. + + Returns + ------- + float + The value(s) of the distribution at x. + """ + return self.evaluate(x) + + def evaluate(self, x): + """ + Evaluates the distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the distribution. + + Returns + ------- + float + The value(s) of the distribution at x. + """ + inputs = self.verify(x) + return self._evaluate(inputs) + + def _evaluate(self, x): + """ + Evaluates the distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the distribution. + + Returns + ------- + float + The value(s) of the distribution at x. + """ + return self.logpdf(x) + + def evaluateS1(self, x): + """ + Evaluates the first derivative of the distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the first derivative. + + Returns + ------- + float + The value(s) of the first derivative at x. + """ + inputs = self.verify(x) + return self._evaluateS1(inputs) + + def _evaluateS1(self, x): + """ + Evaluates the first derivative of the distribution at x. + + Parameters + ---------- + x : float + The point(s) at which to evaluate the first derivative. + + Returns + ------- + float + The value(s) of the first derivative at x. + """ + raise NotImplementedError + + def verify(self, x): + """ + Verifies that the input is a numpy array and converts it if necessary. + """ + if isinstance(x, dict): + x = np.asarray(list(x.values())) + elif not isinstance(x, np.ndarray): + x = np.asarray(x) + return x + def __repr__(self): """ Returns a string representation of the object. @@ -182,23 +274,7 @@ def __init__(self, mean, sigma, random_state=None): self._multip = -1 / (2.0 * self.sigma2) self._n_parameters = 1 - def __call__(self, x): - """ - Evaluates the gaussian (log) distribution at x. - - Parameters - ---------- - x : float - The point(s) at which to evaluate the distribution. - - Returns - ------- - float - The value(s) of the distribution at x. - """ - return self.logpdf(x) - - def evaluateS1(self, x): + def _evaluateS1(self, x): """ Evaluates the first derivative of the gaussian (log) distribution at x. @@ -241,23 +317,7 @@ def __init__(self, lower, upper, random_state=None): self.prior = stats.uniform self._n_parameters = 1 - def __call__(self, x): - """ - Evaluates the uniform distribution at x. - - Parameters - ---------- - x : float - The point(s) at which to evaluate the distribution. - - Returns - ------- - float - The value(s) of the distribution at x. - """ - return self.logpdf(x) - - def evaluateS1(self, x): + def _evaluateS1(self, x): """ Evaluates the first derivative of the log uniform distribution at x. @@ -310,23 +370,7 @@ def __init__(self, scale, loc=0, random_state=None): self.prior = stats.expon self._n_parameters = 1 - def __call__(self, x): - """ - Evaluates the exponential (log) distribution at x. - - Parameters - ---------- - x : float - The point(s) at which to evaluate the distribution. - - Returns - ------- - float - The value(s) of the distribution at x. - """ - return self.logpdf(x) - - def evaluateS1(self, x): + def _evaluateS1(self, x): """ Evaluates the first derivative of the log exponential distribution at x. @@ -358,7 +402,7 @@ def __init__(self, *priors): self._n_parameters = len(priors) # Needs to be updated - def __call__(self, x): + def _evaluate(self, x): """ Evaluates the composed prior distribution at x. @@ -372,11 +416,9 @@ def __call__(self, x): float The value(s) of the distribution at x. """ - if not isinstance(x, np.ndarray): - x = np.asarray(x) return sum(prior(x) for prior, x in zip(self._priors, x)) - def evaluateS1(self, x): + def _evaluateS1(self, x): """ Evaluates the first derivative of the composed prior distribution at x. Inspired by PINTS implementation. diff --git a/pybop/plotting/plot2d.py b/pybop/plotting/plot2d.py index 961bc7c49..d5c85574c 100644 --- a/pybop/plotting/plot2d.py +++ b/pybop/plotting/plot2d.py @@ -1,4 +1,6 @@ import sys +import warnings +from typing import Union import numpy as np from scipy.interpolate import griddata @@ -9,7 +11,7 @@ def plot2d( cost_or_optim, gradient: bool = False, - bounds: np.ndarray = None, + bounds: Union[np.ndarray, None] = None, steps: int = 10, show: bool = True, use_optim_log: bool = False, @@ -63,6 +65,25 @@ def plot2d( cost = cost_or_optim plot_optim = False + if len(cost.parameters) < 2: + raise ValueError("This cost function takes fewer than 2 parameters.") + + additional_values = [] + if len(cost.parameters) > 2: + warnings.warn( + "This cost function requires more than 2 parameters. " + "Plotting in 2d with fixed values for the additional parameters.", + UserWarning, + stacklevel=2, + ) + for ( + i, + param, + ) in enumerate(cost.parameters): + if i > 1: + additional_values.append(param.value) + print(f"Fixed {param.name}:", param.value) + # Set up parameter bounds if bounds is None: bounds = cost.parameters.get_bounds_for_plotly() @@ -77,19 +98,23 @@ def plot2d( # Populate cost matrix for i, xi in enumerate(x): for j, yj in enumerate(y): - costs[j, i] = cost(np.asarray([xi, yj])) + costs[j, i] = cost(np.asarray([xi, yj] + additional_values)) if gradient: grad_parameter_costs = [] # Determine the number of gradient outputs from cost.evaluateS1 - num_gradients = len(cost.evaluateS1(np.asarray([x[0], y[0]]))[1]) + num_gradients = len( + cost.evaluateS1(np.asarray([x[0], y[0]] + additional_values))[1] + ) # Create an array to hold each gradient output & populate grads = [np.zeros((len(y), len(x))) for _ in range(num_gradients)] for i, xi in enumerate(x): for j, yj in enumerate(y): - (*current_grads,) = cost.evaluateS1(np.asarray([xi, yj]))[1] + (*current_grads,) = cost.evaluateS1( + np.asarray([xi, yj] + additional_values) + )[1] for k, grad_output in enumerate(current_grads): grads[k][j, i] = grad_output @@ -141,7 +166,7 @@ def plot2d( if plot_optim: # Plot the optimisation trace optim_trace = np.asarray( - [item for sublist in optim.log["x"] for item in sublist] + [item[:2] for sublist in optim.log["x"] for item in sublist] ) optim_trace = optim_trace.reshape(-1, 2) fig.add_trace( diff --git a/pybop/plotting/plot_dataset.py b/pybop/plotting/plot_dataset.py index 70573e476..ecc84aa6e 100644 --- a/pybop/plotting/plot_dataset.py +++ b/pybop/plotting/plot_dataset.py @@ -3,9 +3,7 @@ from pybop import StandardPlot, plot_trajectories -def plot_dataset( - dataset, signal=["Voltage [V]"], trace_names=None, show=True, **layout_kwargs -): +def plot_dataset(dataset, signal=None, trace_names=None, show=True, **layout_kwargs): """ Quickly plot a PyBOP Dataset using Plotly. @@ -31,6 +29,8 @@ def plot_dataset( """ # Get data dictionary + if signal is None: + signal = ["Voltage [V]"] dataset.check(signal) # Compile ydata and labels or legend diff --git a/pybop/plotting/plot_parameters.py b/pybop/plotting/plot_parameters.py index bc1f9a7ac..94cb71cb4 100644 --- a/pybop/plotting/plot_parameters.py +++ b/pybop/plotting/plot_parameters.py @@ -1,6 +1,6 @@ import sys -from pybop import StandardSubplot +from pybop import GaussianLogLikelihood, StandardSubplot def plot_parameters(optim, show=True, **layout_kwargs): @@ -51,6 +51,10 @@ def plot_parameters(optim, show=True, **layout_kwargs): for name in trace_names: axis_titles.append(("Function Call", name)) + if isinstance(optim.cost, GaussianLogLikelihood): + axis_titles.append(("Function Call", "Sigma")) + trace_names.append("Sigma") + # Set subplot layout options layout_options = dict( title="Parameter Convergence", diff --git a/pybop/plotting/plot_problem.py b/pybop/plotting/plot_problem.py index 968da94d6..fb8759c98 100644 --- a/pybop/plotting/plot_problem.py +++ b/pybop/plotting/plot_problem.py @@ -3,9 +3,10 @@ import numpy as np from pybop import DesignProblem, FittingProblem, StandardPlot +from pybop.parameters.parameter import Inputs -def quick_plot(problem, parameter_values=None, show=True, **layout_kwargs): +def quick_plot(problem, problem_inputs: Inputs = None, show=True, **layout_kwargs): """ Quickly plot the target dataset against optimised model output. @@ -16,7 +17,7 @@ def quick_plot(problem, parameter_values=None, show=True, **layout_kwargs): ---------- problem : object Problem object with dataset and signal attributes. - parameter_values : array-like + problem_inputs : Inputs Optimised (or example) parameter values. show : bool, optional If True, the figure is shown upon creation (default: True). @@ -30,12 +31,14 @@ def quick_plot(problem, parameter_values=None, show=True, **layout_kwargs): plotly.graph_objs.Figure The Plotly figure object for the scatter plot. """ - if parameter_values is None: - parameter_values = problem.x0 + if problem_inputs is None: + problem_inputs = problem.parameters.as_dict() + else: + problem_inputs = problem.parameters.verify(problem_inputs) # Extract the time data and evaluate the model's output and target values xaxis_data = problem.time_data() - model_output = problem.evaluate(parameter_values) + model_output = problem.evaluate(problem_inputs) target_output = problem.get_target() # Create a plot for each output diff --git a/pybop/plotting/plotly_manager.py b/pybop/plotting/plotly_manager.py index 7b4b079a4..5554a2af5 100644 --- a/pybop/plotting/plotly_manager.py +++ b/pybop/plotting/plotly_manager.py @@ -119,7 +119,7 @@ def check_browser_availability(self): if self.pio and self.pio.renderers.default == "browser": try: webbrowser.get() - except webbrowser.Error: + except webbrowser.Error as e: raise Exception( "\n **Browser Not Found** \nFor Windows users, in order to view figures in the browser using Plotly, " "you need to set the environment variable BROWSER equal to the " @@ -129,4 +129,4 @@ def check_browser_availability(self): "\n\nThen reactivate your virtual environment. Alternatively, you can use a " "different Plotly renderer. For more information see: " "https://plotly.com/python/renderers/#setting-the-default-renderer" - ) + ) from e diff --git a/pybop/plotting/quick_plot.py b/pybop/plotting/quick_plot.py index 5be353a62..1ef4e3ffe 100644 --- a/pybop/plotting/quick_plot.py +++ b/pybop/plotting/quick_plot.py @@ -57,16 +57,16 @@ def __init__( x, y, layout=None, - layout_options=DEFAULT_LAYOUT_OPTIONS.copy(), - trace_options=DEFAULT_TRACE_OPTIONS.copy(), + layout_options=DEFAULT_LAYOUT_OPTIONS, + trace_options=DEFAULT_TRACE_OPTIONS, trace_names=None, trace_name_width=40, ): self.x = x self.y = y self.layout = layout - self.layout_options = layout_options - self.trace_options = DEFAULT_TRACE_OPTIONS.copy() + self.layout_options = layout_options.copy() + self.trace_options = trace_options.copy() if trace_options is not None: for arg, value in trace_options.items(): self.trace_options[arg] = value @@ -246,9 +246,9 @@ def __init__( num_cols=None, axis_titles=None, layout=None, - layout_options=DEFAULT_LAYOUT_OPTIONS.copy(), - subplot_options=DEFAULT_SUBPLOT_OPTIONS.copy(), - trace_options=DEFAULT_SUBPLOT_TRACE_OPTIONS.copy(), + layout_options=DEFAULT_LAYOUT_OPTIONS, + subplot_options=DEFAULT_SUBPLOT_OPTIONS, + trace_options=DEFAULT_SUBPLOT_TRACE_OPTIONS, trace_names=None, trace_name_width=40, ): @@ -267,7 +267,7 @@ def __init__( elif self.num_cols is None: self.num_cols = int(math.ceil(self.num_traces / self.num_rows)) self.axis_titles = axis_titles - self.subplot_options = DEFAULT_SUBPLOT_OPTIONS.copy() + self.subplot_options = subplot_options.copy() if subplot_options is not None: for arg, value in subplot_options.items(): self.subplot_options[arg] = value diff --git a/pybop/problems/base_problem.py b/pybop/problems/base_problem.py index 48f53dab1..ee36a6bb4 100644 --- a/pybop/problems/base_problem.py +++ b/pybop/problems/base_problem.py @@ -1,4 +1,5 @@ from pybop import BaseModel, Dataset, Parameter, Parameters +from pybop.parameters.parameter import Inputs class BaseProblem: @@ -26,11 +27,15 @@ def __init__( parameters, model=None, check_model=True, - signal=["Voltage [V]"], - additional_variables=[], + signal=None, + additional_variables=None, init_soc=None, ): # Check if parameters is a list of pybop.Parameter objects + if additional_variables is None: + additional_variables = [] + if signal is None: + signal = ["Voltage [V]"] if isinstance(parameters, list): if all(isinstance(param, Parameter) for param in parameters): parameters = Parameters(*parameters) @@ -65,21 +70,18 @@ def __init__( else: self.additional_variables = [] - # Set initial values - self.x0 = self.parameters.initial_value() - @property def n_parameters(self): return len(self.parameters) - def evaluate(self, x): + def evaluate(self, inputs: Inputs): """ Evaluate the model with the given parameters and return the signal. Parameters ---------- - x : np.ndarray - Parameter values to evaluate the model at. + inputs : Inputs + Parameters for evaluation of the model. Raises ------ @@ -88,15 +90,15 @@ def evaluate(self, x): """ raise NotImplementedError - def evaluateS1(self, x): + def evaluateS1(self, inputs: Inputs): """ 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. + inputs : Inputs + Parameters for evaluation of the model. Raises ------ diff --git a/pybop/problems/design_problem.py b/pybop/problems/design_problem.py index 3217ca95d..30e2d9c3a 100644 --- a/pybop/problems/design_problem.py +++ b/pybop/problems/design_problem.py @@ -1,6 +1,7 @@ import numpy as np from pybop import BaseProblem +from pybop.parameters.parameter import Inputs class DesignProblem(BaseProblem): @@ -33,11 +34,15 @@ def __init__( parameters, experiment, check_model=True, - signal=["Voltage [V]"], - additional_variables=[], + signal=None, + additional_variables=None, init_soc=None, ): # Add time and current and remove duplicates + if additional_variables is None: + additional_variables = [] + if signal is None: + signal = ["Voltage [V]"] additional_variables.extend(["Time [s]", "Current [A]"]) additional_variables = list(set(additional_variables)) @@ -54,7 +59,7 @@ def __init__( # Build the model if required if experiment is not None: # Leave the build until later to apply the experiment - self._model.parameters = self.parameters + self._model.classify_and_update_parameters(self.parameters) elif self._model._built_model is None: self._model.build( @@ -65,27 +70,29 @@ def __init__( ) # Add an example dataset for plotting comparison - sol = self.evaluate(self.x0) + sol = self.evaluate(self.parameters.as_dict("initial")) self._time_data = sol["Time [s]"] self._target = {key: sol[key] for key in self.signal} self._dataset = None - def evaluate(self, x): + def evaluate(self, inputs: Inputs): """ Evaluate the model with the given parameters and return the signal. Parameters ---------- - x : np.ndarray - Parameter values to evaluate the model at. + inputs : Inputs + Parameters for evaluation of the model. Returns ------- y : np.ndarray - The model output y(t) simulated with inputs x. + The model output y(t) simulated with inputs. """ + inputs = self.parameters.verify(inputs) + sol = self._model.predict( - inputs=x, + inputs=inputs, experiment=self.experiment, init_soc=self.init_soc, ) diff --git a/pybop/problems/fitting_problem.py b/pybop/problems/fitting_problem.py index 15d1ed7e2..58d59e9ee 100644 --- a/pybop/problems/fitting_problem.py +++ b/pybop/problems/fitting_problem.py @@ -1,6 +1,7 @@ import numpy as np from pybop import BaseProblem +from pybop.parameters.parameter import Inputs class FittingProblem(BaseProblem): @@ -31,11 +32,15 @@ def __init__( parameters, dataset, check_model=True, - signal=["Voltage [V]"], - additional_variables=[], + signal=None, + additional_variables=None, init_soc=None, ): # Add time and remove duplicates + if additional_variables is None: + additional_variables = [] + if signal is None: + signal = ["Voltage [V]"] additional_variables.extend(["Time [s]"]) additional_variables = list(set(additional_variables)) @@ -43,10 +48,10 @@ def __init__( parameters, model, check_model, signal, additional_variables, init_soc ) self._dataset = dataset.data - self.x = self.x0 + self.parameters.initial_value() # Check that the dataset contains time and current - dataset.check(self.signal + ["Current function [A]"]) + dataset.check([*self.signal, "Current function [A]"]) # Unpack time and target data self._time_data = self._dataset["Time [s]"] @@ -74,51 +79,61 @@ def __init__( init_soc=self.init_soc, ) - def evaluate(self, x): + def evaluate(self, inputs: Inputs): """ Evaluate the model with the given parameters and return the signal. Parameters ---------- - x : np.ndarray - Parameter values to evaluate the model at. + inputs : Inputs + Parameters for evaluation of the model. Returns ------- y : np.ndarray - The model output y(t) simulated with inputs x. + The model output y(t) simulated with given inputs. """ - if np.any(x != self.x) and self._model.rebuild_parameters: - self.parameters.update(values=x) + inputs = self.parameters.verify(inputs) + + requires_rebuild = False + for key, value in inputs.items(): + if key in self._model.rebuild_parameters: + current_value = self.parameters[key].value + if value != current_value: + self.parameters[key].update(value=value) + requires_rebuild = True + + if requires_rebuild: self._model.rebuild(parameters=self.parameters) - self.x = x - y = self._model.simulate(inputs=x, t_eval=self._time_data) + y = self._model.simulate(inputs=inputs, t_eval=self._time_data) return y - def evaluateS1(self, x): + def evaluateS1(self, inputs: Inputs): """ 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. + inputs : Inputs + Parameters for evaluation of the model. Returns ------- tuple A tuple containing the simulation result y(t) and the sensitivities dy/dx(t) evaluated - with given inputs x. + with given inputs. """ + inputs = self.parameters.verify(inputs) + if self._model.rebuild_parameters: raise RuntimeError( "Gradient not available when using geometric parameters." ) y, dy = self._model.simulateS1( - inputs=x, + inputs=inputs, t_eval=self._time_data, ) diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index e5e1be3ac..c7c2263a7 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, Union +from typing import Optional, Union import numpy as np from pints import ( @@ -23,7 +23,7 @@ class BasePintsSampler(BaseSampler): def __init__( self, - log_pdf: Union[BaseCost, List[BaseCost]], + log_pdf: Union[BaseCost, list[BaseCost]], chains: int, sampler, warm_up=None, @@ -108,7 +108,7 @@ def __init__( self._n_samplers = 1 self._samplers = [sampler(self._n_chains, self._x0, self._cov0)] except Exception as e: - raise ValueError(f"Error constructing samplers: {e}") + raise ValueError(f"Error constructing samplers: {e}") from e # Check for sensitivities from sampler and set evaluation self._needs_sensitivities = self._samplers[0].needs_sensitivities() @@ -252,7 +252,7 @@ def _process_multi_chain(self): self._samples = ys_store es = [] - for i, y in enumerate(ys): + for i, _y in enumerate(ys): if accepted[i]: self._sampled_logpdf[i] = ( fys[0][i] if self._needs_sensitivities else fys[i] diff --git a/pybop/samplers/mcmc_sampler.py b/pybop/samplers/mcmc_sampler.py index b08a10123..9e991761b 100644 --- a/pybop/samplers/mcmc_sampler.py +++ b/pybop/samplers/mcmc_sampler.py @@ -48,7 +48,7 @@ def __init__( except Exception as e: raise ValueError( f"Sampler could not be constructed, raised an exception: {e}" - ) + ) from e def run(self): """ diff --git a/pyproject.toml b/pyproject.toml index 6d2e1b61c..0ec781e58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pybop" -version = "24.3.1" +version = "24.6" authors = [ {name = "The PyBOP Team"}, ] @@ -73,7 +73,7 @@ Homepage = "https://github.com/pybop-team/PyBOP" Documentation = "https://pybop-docs.readthedocs.io" Repository = "https://github.com/pybop-team/PyBOP" Releases = "https://github.com/pybop-team/PyBOP/releases" -Changelog = "https://github.com/pybop-team/PyBOP/CHANGELOG.md" +Changelog = "https://github.com/pybop-team/PyBOP/blob/develop/CHANGELOG.md" [tool.pytest.ini_options] addopts = "--showlocals -v -n auto" @@ -81,10 +81,23 @@ addopts = "--showlocals -v -n auto" [tool.ruff] extend-include = ["*.ipynb"] extend-exclude = ["__init__.py"] +fix = true [tool.ruff.lint] -extend-select = ["I"] +select = [ + "A", # flake8-builtins: Check for Python builtins being used as variables or parameters + "B", # flake8-bugbear: Find likely bugs and design problems + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes: Detect various errors by parsing the source file + "I", # isort: Check and enforce import ordering + "ISC", # flake8-implicit-str-concat: Check for implicit string concatenation + "TID", # flake8-tidy-imports: Validate import hygiene + "UP", # pyupgrade: Automatically upgrade syntax for newer versions of Python +] + ignore = ["E501","E741"] +per-file-ignores = {"**.ipynb" = ["E402", "E703"]} -[tool.ruff.lint.per-file-ignores] -"**.ipynb" = ["E402", "E703"] +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" diff --git a/tests/examples/test_examples.py b/tests/examples/test_examples.py index 1c45be840..2bebc6fc6 100644 --- a/tests/examples/test_examples.py +++ b/tests/examples/test_examples.py @@ -12,14 +12,14 @@ class TestExamples: """ def list_of_examples(): - list = [] + examples_list = [] path_to_example_scripts = os.path.join( pybop.script_path, "..", "examples", "scripts" ) for example in os.listdir(path_to_example_scripts): if example.endswith(".py"): - list.append(os.path.join(path_to_example_scripts, example)) - return list + examples_list.append(os.path.join(path_to_example_scripts, example)) + return examples_list @pytest.mark.parametrize("example", list_of_examples()) @pytest.mark.examples diff --git a/tests/integration/test_model_experiment_changes.py b/tests/integration/test_model_experiment_changes.py index 6902f873e..64d27132a 100644 --- a/tests/integration/test_model_experiment_changes.py +++ b/tests/integration/test_model_experiment_changes.py @@ -48,7 +48,9 @@ def test_changing_experiment(self, parameters): experiment = pybop.Experiment(["Charge at 1C until 4.1 V (2 seconds period)"]) solution_2 = model.predict( - init_soc=init_soc, experiment=experiment, inputs=parameters.true_value() + init_soc=init_soc, + experiment=experiment, + inputs=parameters.as_dict("true"), ) cost_2 = self.final_cost(solution_2, model, parameters, init_soc) diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index 1f7e8e627..6e8fc93a4 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -77,7 +77,7 @@ def spm_likelihood(self, model, parameters, cost_class, init_soc): # Define the cost to optimise problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc) - return cost_class(problem, sigma=[0.002]) + return cost_class(problem, sigma0=0.002) @pytest.mark.parametrize( "quick_sampler", @@ -111,7 +111,6 @@ def spm_likelihood(self, model, parameters, cost_class, init_soc): @pytest.mark.integration def test_sampling_spm(self, quick_sampler, spm_likelihood): - x0 = spm_likelihood.x0 prior1 = pybop.Uniform(0.4, 0.7) prior2 = pybop.Uniform(0.4, 0.7) composed_prior = pybop.ComposedLogPrior(prior1, prior2) diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index 01702ba24..47782a03a 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -67,7 +67,7 @@ def spm_costs(self, model, parameters, cost_class): # Define the cost to optimise problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc) if cost_class in [pybop.GaussianLogLikelihoodKnownSigma]: - return cost_class(problem, sigma=[0.03, 0.03]) + return cost_class(problem, sigma0=0.002) else: return cost_class(problem) @@ -80,7 +80,7 @@ def spm_costs(self, model, parameters, cost_class): ) @pytest.mark.integration def test_optimisation_f_guessed(self, f_guessed, spm_costs): - x0 = spm_costs.x0 + x0 = spm_costs.parameters.initial_value() # Test each optimiser optim = pybop.XNES( cost=spm_costs, @@ -107,7 +107,7 @@ def test_optimisation_f_guessed(self, f_guessed, spm_costs): np.testing.assert_allclose(x, self.ground_truth, atol=1.5e-2) def get_data(self, model, parameters, x, init_soc): - model.parameters = parameters + model.classify_and_update_parameters(parameters) experiment = pybop.Experiment( [ ( diff --git a/tests/integration/test_spm_parameterisations.py b/tests/integration/test_spm_parameterisations.py index 7eeb0b7c0..5e9d6b005 100644 --- a/tests/integration/test_spm_parameterisations.py +++ b/tests/integration/test_spm_parameterisations.py @@ -11,6 +11,7 @@ class Test_SPM_Parameterisation: @pytest.fixture(autouse=True) def setup(self): + self.sigma0 = 0.002 self.ground_truth = np.asarray([0.55, 0.55]) + np.random.normal( loc=0.0, scale=0.05, size=2 ) @@ -25,12 +26,12 @@ def parameters(self): return pybop.Parameters( pybop.Parameter( "Negative electrode active material volume fraction", - prior=pybop.Uniform(0.4, 0.7), - bounds=[0.375, 0.725], + prior=pybop.Uniform(0.4, 0.75), + bounds=[0.375, 0.75], ), pybop.Parameter( "Positive electrode active material volume fraction", - prior=pybop.Uniform(0.4, 0.7), + prior=pybop.Uniform(0.4, 0.75), # no bounds ), ) @@ -42,8 +43,11 @@ def init_soc(self, request): @pytest.fixture( params=[ pybop.GaussianLogLikelihoodKnownSigma, + pybop.GaussianLogLikelihood, pybop.RootMeanSquaredError, pybop.SumSquaredError, + pybop.SumofPower, + pybop.Minkowski, pybop.MAP, ] ) @@ -62,18 +66,22 @@ def spm_costs(self, model, parameters, cost_class, init_soc): "Time [s]": solution["Time [s]"].data, "Current function [A]": solution["Current [A]"].data, "Voltage [V]": solution["Voltage [V]"].data - + self.noise(0.002, len(solution["Time [s]"].data)), + + self.noise(self.sigma0, len(solution["Time [s]"].data)), } ) # Define the cost to optimise problem = pybop.FittingProblem(model, parameters, dataset, init_soc=init_soc) if cost_class in [pybop.GaussianLogLikelihoodKnownSigma]: - return cost_class(problem, sigma=[0.03, 0.03]) + return cost_class(problem, sigma0=self.sigma0) + elif cost_class in [pybop.GaussianLogLikelihood]: + return cost_class(problem, sigma0=self.sigma0 * 4) # Initial sigma0 guess elif cost_class in [pybop.MAP]: return cost_class( - problem, pybop.GaussianLogLikelihoodKnownSigma, sigma=[0.03, 0.03] + problem, pybop.GaussianLogLikelihoodKnownSigma, sigma0=self.sigma0 ) + elif cost_class in [pybop.SumofPower, pybop.Minkowski]: + return cost_class(problem, p=2) else: return cost_class(problem) @@ -83,6 +91,7 @@ def spm_costs(self, model, parameters, cost_class, init_soc): pybop.SciPyDifferentialEvolution, pybop.AdamW, pybop.CMAES, + pybop.CuckooSearch, pybop.IRPropMin, pybop.NelderMead, pybop.SNES, @@ -91,38 +100,52 @@ def spm_costs(self, model, parameters, cost_class, init_soc): ) @pytest.mark.integration def test_spm_optimisers(self, optimiser, spm_costs): - x0 = spm_costs.x0 - # Some optimisers require a complete set of bounds - if optimiser in [ - pybop.SciPyDifferentialEvolution, - ]: - spm_costs.problem.parameters[ - "Positive electrode active material volume fraction" - ].set_bounds([0.375, 0.725]) # Large range to ensure IC within bounds - bounds = spm_costs.problem.parameters.get_bounds() - spm_costs.problem.bounds = bounds - spm_costs.bounds = bounds + x0 = spm_costs.parameters.initial_value() + common_args = { + "cost": spm_costs, + "max_iterations": 250, + "absolute_tolerance": 1e-6, + "max_unchanged_iterations": 55, + } - # Test each optimiser - if optimiser in [pybop.PSO]: - optim = pybop.Optimisation( - cost=spm_costs, optimiser=optimiser, sigma0=0.05, max_iterations=250 + # Add sigma0 to ground truth for GaussianLogLikelihood + if isinstance(spm_costs, pybop.GaussianLogLikelihood): + self.ground_truth = np.concatenate( + (self.ground_truth, np.asarray([self.sigma0])) ) - else: - optim = optimiser(cost=spm_costs, sigma0=0.05, max_iterations=250) - if issubclass(optimiser, pybop.BasePintsOptimiser): - optim.set_max_unchanged_iterations(iterations=35, absolute_tolerance=1e-5) + if isinstance(spm_costs, pybop.MAP): + for i in spm_costs.parameters.keys(): + spm_costs.parameters[i].prior = pybop.Uniform( + 0.2, 2.0 + ) # Increase range to avoid prior == np.inf + # Set sigma0 and create optimiser + sigma0 = 0.05 if isinstance(spm_costs, pybop.MAP) else None + optim = optimiser(sigma0=sigma0, **common_args) + + # AdamW will use lowest sigma0 for learning rate, so allow more iterations + if issubclass(optimiser, (pybop.AdamW, pybop.IRPropMin)) and isinstance( + spm_costs, pybop.GaussianLogLikelihood + ): + common_args["max_unchanged_iterations"] = 75 + optim = optimiser(**common_args) initial_cost = optim.cost(x0) x, final_cost = optim.run() # Assertions - if not np.allclose(x0, self.ground_truth, atol=1e-5): - if optim.minimising: - assert initial_cost > final_cost - else: - assert initial_cost < final_cost - np.testing.assert_allclose(x, self.ground_truth, atol=1.5e-2) + if np.allclose(x0, self.ground_truth, atol=1e-5): + raise AssertionError("Initial guess is too close to ground truth") + + if isinstance(spm_costs, pybop.GaussianLogLikelihood): + np.testing.assert_allclose(x, self.ground_truth, atol=1.5e-2) + np.testing.assert_allclose(x[-1], self.sigma0, atol=5e-4) + else: + assert ( + (initial_cost > final_cost) + if optim.minimising + else (initial_cost < final_cost) + ) + np.testing.assert_allclose(x, self.ground_truth, atol=1.5e-2) @pytest.fixture def spm_two_signal_cost(self, parameters, model, cost_class): @@ -134,11 +157,11 @@ def spm_two_signal_cost(self, parameters, model, cost_class): "Time [s]": solution["Time [s]"].data, "Current function [A]": solution["Current [A]"].data, "Voltage [V]": solution["Voltage [V]"].data - + self.noise(0.002, len(solution["Time [s]"].data)), + + self.noise(self.sigma0, len(solution["Time [s]"].data)), "Bulk open-circuit voltage [V]": solution[ "Bulk open-circuit voltage [V]" ].data - + self.noise(0.002, len(solution["Time [s]"].data)), + + self.noise(self.sigma0, len(solution["Time [s]"].data)), } ) @@ -149,9 +172,11 @@ def spm_two_signal_cost(self, parameters, model, cost_class): ) if cost_class in [pybop.GaussianLogLikelihoodKnownSigma]: - return cost_class(problem, sigma=[0.05, 0.05]) + return cost_class(problem, sigma0=self.sigma0) elif cost_class in [pybop.MAP]: - return cost_class(problem, pybop.GaussianLogLikelihoodKnownSigma) + return cost_class( + problem, pybop.GaussianLogLikelihoodKnownSigma, sigma0=self.sigma0 + ) else: return cost_class(problem) @@ -160,20 +185,13 @@ def spm_two_signal_cost(self, parameters, model, cost_class): [ pybop.SciPyDifferentialEvolution, pybop.IRPropMin, - pybop.XNES, + pybop.CMAES, ], ) @pytest.mark.integration def test_multiple_signals(self, multi_optimiser, spm_two_signal_cost): - x0 = spm_two_signal_cost.x0 - # Some optimisers require a complete set of bounds - if multi_optimiser in [pybop.SciPyDifferentialEvolution]: - spm_two_signal_cost.problem.parameters[ - "Positive electrode active material volume fraction" - ].set_bounds([0.375, 0.725]) # Large range to ensure IC within bounds - bounds = spm_two_signal_cost.problem.parameters.get_bounds() - spm_two_signal_cost.problem.bounds = bounds - spm_two_signal_cost.bounds = bounds + x0 = spm_two_signal_cost.parameters.initial_value() + combined_sigma0 = np.asarray([self.sigma0, self.sigma0]) # Test each optimiser optim = multi_optimiser( @@ -181,19 +199,31 @@ def test_multiple_signals(self, multi_optimiser, spm_two_signal_cost): sigma0=0.03, max_iterations=250, ) + + # Add sigma0 to ground truth for GaussianLogLikelihood + if isinstance(spm_two_signal_cost, pybop.GaussianLogLikelihood): + self.ground_truth = np.concatenate((self.ground_truth, combined_sigma0)) + if issubclass(multi_optimiser, pybop.BasePintsOptimiser): optim.set_max_unchanged_iterations(iterations=35, absolute_tolerance=1e-5) - initial_cost = optim.cost(spm_two_signal_cost.x0) + initial_cost = optim.cost(optim.parameters.initial_value()) x, final_cost = optim.run() # Assertions - if not np.allclose(x0, self.ground_truth, atol=1e-5): - if optim.minimising: - assert initial_cost > final_cost - else: - assert initial_cost < final_cost - np.testing.assert_allclose(x, self.ground_truth, atol=1.5e-2) + if np.allclose(x0, self.ground_truth, atol=1e-5): + raise AssertionError("Initial guess is too close to ground truth") + + if isinstance(spm_two_signal_cost, pybop.GaussianLogLikelihood): + np.testing.assert_allclose(x, self.ground_truth, atol=1.5e-2) + np.testing.assert_allclose(x[-2:], combined_sigma0, atol=5e-4) + else: + assert ( + (initial_cost > final_cost) + if optim.minimising + else (initial_cost < final_cost) + ) + np.testing.assert_allclose(x, self.ground_truth, atol=1.5e-2) @pytest.mark.parametrize("init_soc", [0.4, 0.6]) @pytest.mark.integration @@ -222,7 +252,7 @@ def test_model_misparameterisation(self, parameters, model, init_soc): # Build the optimisation problem optim = optimiser(cost=cost) - initial_cost = optim.cost(cost.x0) + initial_cost = optim.cost(optim.x0) # Run the optimisation problem x, final_cost = optim.run() @@ -235,15 +265,14 @@ def test_model_misparameterisation(self, parameters, model, init_soc): np.testing.assert_allclose(x, self.ground_truth, atol=2e-2) def get_data(self, model, parameters, x, init_soc): - model.parameters = parameters + model.classify_and_update_parameters(parameters) experiment = pybop.Experiment( [ ( - "Discharge at 0.5C for 6 minutes (4 second period)", - "Charge at 0.5C for 6 minutes (4 second period)", + "Discharge at 0.5C for 3 minutes (4 second period)", + "Charge at 0.5C for 3 minutes (4 second period)", ), ] - * 2 ) sim = model.predict(init_soc=init_soc, experiment=experiment, inputs=x) return sim diff --git a/tests/integration/test_thevenin_parameterisation.py b/tests/integration/test_thevenin_parameterisation.py index 1ef1bc3eb..98dde5cbc 100644 --- a/tests/integration/test_thevenin_parameterisation.py +++ b/tests/integration/test_thevenin_parameterisation.py @@ -65,7 +65,7 @@ def cost(self, model, parameters, cost_class): ) @pytest.mark.integration def test_optimisers_on_simple_model(self, optimiser, cost): - x0 = cost.x0 + x0 = cost.parameters.initial_value() if optimiser in [pybop.GradientDescent]: optim = optimiser( cost=cost, @@ -81,7 +81,7 @@ def test_optimisers_on_simple_model(self, optimiser, cost): if isinstance(optimiser, pybop.BasePintsOptimiser): optim.set_max_unchanged_iterations(iterations=35, absolute_tolerance=1e-5) - initial_cost = optim.cost(x0) + initial_cost = optim.cost(optim.parameters.initial_value()) x, final_cost = optim.run() # Assertions @@ -93,7 +93,7 @@ def test_optimisers_on_simple_model(self, optimiser, cost): np.testing.assert_allclose(x, self.ground_truth, atol=1.5e-2) def get_data(self, model, parameters, x): - model.parameters = parameters + model.classify_and_update_parameters(parameters) experiment = pybop.Experiment( [ ( diff --git a/tests/unit/test_cost.py b/tests/unit/test_cost.py index 3c0d81514..6a7d1a900 100644 --- a/tests/unit/test_cost.py +++ b/tests/unit/test_cost.py @@ -9,6 +9,11 @@ class TestCosts: Class for tests cost functions """ + # Define an invalid likelihood class for MAP tests + class InvalidLikelihood: + def __init__(self, problem, sigma0): + pass + @pytest.fixture def model(self): return pybop.lithium_ion.SPM() @@ -69,6 +74,8 @@ def problem(self, model, parameters, dataset, signal, request): params=[ pybop.RootMeanSquaredError, pybop.SumSquaredError, + pybop.Minkowski, + pybop.SumofPower, pybop.ObserverCost, pybop.MAP, ] @@ -79,6 +86,8 @@ def cost(self, problem, request): return cls(problem) elif cls in [pybop.MAP]: return cls(problem, pybop.GaussianLogLikelihoodKnownSigma) + elif cls in [pybop.Minkowski, pybop.SumofPower]: + return cls(problem, p=2) elif cls in [pybop.ObserverCost]: inputs = problem.parameters.initial_value() state = problem._model.reinit(inputs) @@ -113,15 +122,54 @@ def test_base(self, problem): with pytest.raises(NotImplementedError): base_cost.evaluateS1([0.5]) + @pytest.mark.unit + def test_error_in_cost_calculation(self, problem): + class RaiseErrorCost(pybop.BaseCost): + def _evaluate(self, inputs, grad=None): + raise ValueError("Error test.") + + def _evaluateS1(self, inputs): + raise ValueError("Error test.") + + cost = RaiseErrorCost(problem) + with pytest.raises(ValueError, match="Error in cost calculation: Error test."): + cost([0.5]) + with pytest.raises(ValueError, match="Error in cost calculation: Error test."): + cost.evaluateS1([0.5]) + @pytest.mark.unit def test_MAP(self, problem): # Incorrect likelihood - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match="An error occurred when constructing the Likelihood class:", + ): pybop.MAP(problem, pybop.SumSquaredError) # Incorrect construction of likelihood - with pytest.raises(ValueError): - pybop.MAP(problem, pybop.GaussianLogLikelihoodKnownSigma, sigma="string") + with pytest.raises( + ValueError, + match="An error occurred when constructing the Likelihood class: could not convert string to float: 'string'", + ): + pybop.MAP(problem, pybop.GaussianLogLikelihoodKnownSigma, sigma0="string") + + # Incorrect likelihood + with pytest.raises(ValueError, match="must be a subclass of BaseLikelihood"): + pybop.MAP(problem, self.InvalidLikelihood, sigma0=0.1) + + # Non finite prior + parameter = pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Uniform(0.55, 0.6), + ) + problem_non_finite = pybop.FittingProblem( + problem.model, parameter, problem.dataset, signal=problem.signal + ) + likelihood = pybop.MAP( + problem_non_finite, pybop.GaussianLogLikelihoodKnownSigma, sigma0=0.01 + ) + assert not np.isfinite(likelihood([0.7])) + assert not np.isfinite(likelihood.evaluateS1([0.7])[0]) @pytest.mark.unit def test_costs(self, cost): @@ -151,14 +199,16 @@ def test_costs(self, cost): # Test option setting cost.set_fail_gradient(1) - if isinstance(cost, pybop.SumSquaredError): + if not isinstance(cost, (pybop.ObserverCost, pybop.MAP)): e, de = cost.evaluateS1([0.5]) assert np.isscalar(e) - assert type(de) == np.ndarray + assert isinstance(de, np.ndarray) # Test exception for non-numeric inputs - with pytest.raises(ValueError): + with pytest.raises( + TypeError, match="Inputs must be a dictionary or numeric." + ): cost.evaluateS1(["StringInputShouldNotWork"]) with pytest.warns(UserWarning) as record: @@ -175,11 +225,30 @@ def test_costs(self, cost): assert cost.evaluateS1([0.01]) == (np.inf, cost._de) # Test exception for non-numeric inputs - with pytest.raises(ValueError): + with pytest.raises(TypeError, match="Inputs must be a dictionary or numeric."): cost(["StringInputShouldNotWork"]) - # Test treatment of simulations that terminated early - # by variation of the cut-off voltage. + @pytest.mark.unit + def test_minkowski(self, problem): + # Incorrect order + with pytest.raises(ValueError, match="The order of the Minkowski distance"): + pybop.Minkowski(problem, p=-1) + with pytest.raises( + ValueError, + match="For p = infinity, an implementation of the Chebyshev distance is required.", + ): + pybop.Minkowski(problem, p=np.inf) + + @pytest.mark.unit + def test_SumofPower(self, problem): + # Incorrect order + with pytest.raises( + ValueError, match="The order of 'p' must be greater than 0." + ): + pybop.SumofPower(problem, p=-1) + + with pytest.raises(ValueError, match="p = np.inf is not yet supported."): + pybop.SumofPower(problem, p=np.inf) @pytest.mark.parametrize( "cost_class", @@ -224,7 +293,9 @@ def test_design_costs( assert cost([1.1]) == -np.inf # Test exception for non-numeric inputs - with pytest.raises(ValueError): + with pytest.raises( + TypeError, match="Inputs must be a dictionary or numeric." + ): cost(["StringInputShouldNotWork"]) # Compute after updating nominal capacity diff --git a/tests/unit/test_dataset.py b/tests/unit/test_dataset.py index 9c4eac13d..618b8ad53 100644 --- a/tests/unit/test_dataset.py +++ b/tests/unit/test_dataset.py @@ -20,7 +20,7 @@ def test_dataset(self): data_dictionary = { "Time [s]": solution["Time [s]"].data, "Current [A]": solution["Current [A]"].data, - "Terminal voltage [V]": solution["Terminal voltage [V]"].data, + "Voltage [V]": solution["Voltage [V]"].data, } dataset = pybop.Dataset(data_dictionary) @@ -55,4 +55,4 @@ def test_dataset(self): dataset["Time"] # Test conversion of single signal to list - assert dataset.check(signal="Terminal voltage [V]") + assert dataset.check() diff --git a/tests/unit/test_likelihoods.py b/tests/unit/test_likelihoods.py index ea140918d..f4e0cd15e 100644 --- a/tests/unit/test_likelihoods.py +++ b/tests/unit/test_likelihoods.py @@ -76,7 +76,6 @@ def test_base_likelihood_init(self, problem_name, n_outputs, request): assert likelihood.problem == problem assert likelihood.n_outputs == n_outputs assert likelihood.n_time_data == problem.n_time_data - assert likelihood.x0 == problem.x0 assert likelihood.n_parameters == 1 assert np.array_equal(likelihood._target, problem._target) @@ -89,21 +88,22 @@ def test_base_likelihood_call_raises_not_implemented_error( likelihood(np.array([0.5, 0.5])) @pytest.mark.unit - def test_set_get_sigma(self, one_signal_problem): - likelihood = pybop.GaussianLogLikelihoodKnownSigma(one_signal_problem, 0.1) - likelihood.set_sigma(np.array([0.3])) - assert np.array_equal(likelihood.get_sigma(), np.array([0.3])) - + def test_likelihood_check_sigma0(self, one_signal_problem): with pytest.raises( ValueError, - match="The GaussianLogLikelihoodKnownSigma cost requires sigma to be " - + "either a scalar value or an array with one entry per dimension.", + match="Sigma0 must be positive", ): - pybop.GaussianLogLikelihoodKnownSigma(one_signal_problem, sigma=None) + pybop.GaussianLogLikelihoodKnownSigma(one_signal_problem, sigma0=None) likelihood = pybop.GaussianLogLikelihoodKnownSigma(one_signal_problem, 0.1) - with pytest.raises(ValueError): - likelihood.set_sigma(np.array([-0.2])) + sigma = likelihood.check_sigma0(0.2) + assert sigma == np.array(0.2) + + with pytest.raises( + ValueError, + match=r"sigma0 must be either a scalar value", + ): + pybop.GaussianLogLikelihoodKnownSigma(one_signal_problem, sigma0=[0.2, 0.3]) @pytest.mark.unit def test_base_likelihood_n_parameters_property(self, one_signal_problem): @@ -117,7 +117,7 @@ def test_base_likelihood_n_parameters_property(self, one_signal_problem): def test_gaussian_log_likelihood_known_sigma(self, problem_name, request): problem = request.getfixturevalue(problem_name) likelihood = pybop.GaussianLogLikelihoodKnownSigma( - problem, sigma=np.array([1.0]) + problem, sigma0=np.array([1.0]) ) result = likelihood(np.array([0.5])) grad_result, grad_likelihood = likelihood.evaluateS1(np.array([0.5])) @@ -134,6 +134,30 @@ def test_gaussian_log_likelihood(self, one_signal_problem): np.testing.assert_allclose(result, grad_result, atol=1e-5) assert np.all(grad_likelihood <= 0) + # Test construction with sigma as a Parameter + sigma = pybop.Parameter("sigma", prior=pybop.Uniform(0.4, 0.6)) + likelihood = pybop.GaussianLogLikelihood(one_signal_problem, sigma0=sigma) + + # Test invalid sigma + with pytest.raises( + TypeError, + match=r"Expected sigma0 to contain Parameter objects or numeric values.", + ): + likelihood = pybop.GaussianLogLikelihood( + one_signal_problem, sigma0="Invalid string" + ) + + @pytest.mark.unit + def test_gaussian_log_likelihood_dsigma_scale(self, one_signal_problem): + likelihood = pybop.GaussianLogLikelihood(one_signal_problem, dsigma_scale=0.05) + assert likelihood.dsigma_scale == 0.05 + likelihood.dsigma_scale = 1e3 + assert likelihood.dsigma_scale == 1e3 + + # Test incorrect sigma scale + with pytest.raises(ValueError): + likelihood.dsigma_scale = -1e3 + @pytest.mark.unit def test_gaussian_log_likelihood_returns_negative_inf(self, one_signal_problem): likelihood = pybop.GaussianLogLikelihood(one_signal_problem) @@ -151,7 +175,7 @@ def test_gaussian_log_likelihood_known_sigma_returns_negative_inf( self, one_signal_problem ): likelihood = pybop.GaussianLogLikelihoodKnownSigma( - one_signal_problem, sigma=np.array([0.2]) + one_signal_problem, sigma0=np.array([0.2]) ) assert likelihood(np.array([0.01])) == -np.inf # parameter value too small assert ( diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 9c11b4c6b..b50a14bfc 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -137,10 +137,19 @@ def test_build(self, model): @pytest.mark.unit def test_rebuild(self, model): + # Test rebuild before build + with pytest.raises( + ValueError, match="Model must be built before calling rebuild" + ): + model.rebuild() + model.build() initial_built_model = model._built_model assert model._built_model is not None + model.set_params() + assert model.model_with_set_params is not None + # Test that the model can be built again model.rebuild() rebuilt_model = model._built_model @@ -252,11 +261,17 @@ def test_reinit(self): k = 0.1 y0 = 1 model = ExponentialDecay(pybamm.ParameterValues({"k": k, "y0": y0})) + + with pytest.raises( + ValueError, match="Model must be built before calling get_state" + ): + model.get_state({"k": k, "y0": y0}, 0, np.array([0])) + model.build() state = model.reinit(inputs={}) np.testing.assert_array_almost_equal(state.as_ndarray(), np.array([[y0]])) - model.parameters = pybop.Parameters(pybop.Parameter("y0")) + model.classify_and_update_parameters(pybop.Parameters(pybop.Parameter("y0"))) state = model.reinit(inputs=[1]) np.testing.assert_array_almost_equal(state.as_ndarray(), np.array([[y0]])) @@ -296,6 +311,9 @@ def test_basemodel(self): with pytest.raises(NotImplementedError): base.approximate_capacity(x) + base.classify_and_update_parameters(parameters=None) + assert base._n_parameters == 0 + @pytest.mark.unit def test_thevenin_model(self): parameter_set = pybop.ParameterSet( @@ -317,7 +335,7 @@ def test_check_params(self): assert base.check_params() assert base.check_params(inputs={"a": 1}) assert base.check_params(inputs=[1]) - with pytest.raises(ValueError, match="Expecting inputs in the form of"): + with pytest.raises(TypeError, match="Inputs must be a dictionary or numeric."): base.check_params(inputs=["unexpected_string"]) @pytest.mark.unit diff --git a/tests/unit/test_observer_unscented_kalman.py b/tests/unit/test_observer_unscented_kalman.py index 2a947e716..0b5d3067b 100644 --- a/tests/unit/test_observer_unscented_kalman.py +++ b/tests/unit/test_observer_unscented_kalman.py @@ -14,15 +14,6 @@ class TestUKF: measure_noise = 1e-4 - @pytest.fixture(params=[1, 2, 3]) - def model(self, request): - model = ExponentialDecay( - parameter_set=pybamm.ParameterValues({"k": "[input]", "y0": "[input]"}), - n_states=request.param, - ) - model.build() - return model - @pytest.fixture def parameters(self): return pybop.Parameters( @@ -40,6 +31,15 @@ def parameters(self): ), ) + @pytest.fixture(params=[1, 2, 3]) + def model(self, parameters, request): + model = ExponentialDecay( + parameter_set=pybamm.ParameterValues({"k": "[input]", "y0": "[input]"}), + n_states=request.param, + ) + model.build(parameters=parameters) + return model + @pytest.fixture def dataset(self, model: pybop.BaseModel, parameters): observer = pybop.Observer(parameters, model, signal=["2y"]) @@ -156,3 +156,22 @@ def test_wrong_input_shapes(self, model, parameters): pybop.UnscentedKalmanFilterObserver( parameters, model, sigma0, process, measure, signal=signal ) + + @pytest.mark.unit + def test_without_signal(self): + model = pybop.lithium_ion.SPM() + parameters = pybop.Parameters( + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.5, 0.05), + ) + ) + model.build(parameters=parameters) + n = model.n_states + sigma0 = np.diag([1e-4] * n) + process = np.diag([1e-4] * n) + measure = np.diag([1e-4]) + observer = pybop.UnscentedKalmanFilterObserver( + parameters, model, sigma0, process, measure + ) + assert observer.signal == ["Voltage [V]"] diff --git a/tests/unit/test_observers.py b/tests/unit/test_observers.py index 46987bae9..2d2e3bc6e 100644 --- a/tests/unit/test_observers.py +++ b/tests/unit/test_observers.py @@ -11,15 +11,6 @@ class TestObserver: A class to test the observer class. """ - @pytest.fixture(params=[1, 2]) - def model(self, request): - model = ExponentialDecay( - parameter_set=pybamm.ParameterValues({"k": "[input]", "y0": "[input]"}), - n_states=request.param, - ) - model.build() - return model - @pytest.fixture def parameters(self): return pybop.Parameters( @@ -37,6 +28,15 @@ def parameters(self): ), ) + @pytest.fixture(params=[1, 2]) + def model(self, parameters, request): + model = ExponentialDecay( + parameter_set=pybamm.ParameterValues({"k": "[input]", "y0": "[input]"}), + n_states=request.param, + ) + model.build(parameters=parameters) + return model + @pytest.mark.unit def test_observer(self, model, parameters): n = model.n_states @@ -72,8 +72,8 @@ def test_observer(self, model, parameters): # Test evaluate with different inputs observer._time_data = t_eval - observer.evaluate(parameters.initial_value()) - observer.evaluate(parameters) + observer.evaluate(parameters.as_dict()) + observer.evaluate(parameters.current_value()) # Test evaluate with dataset observer._dataset = pybop.Dataset( @@ -83,7 +83,7 @@ def test_observer(self, model, parameters): } ) observer._target = {"2y": expected} - observer.evaluate(parameters.initial_value()) + observer.evaluate(parameters.as_dict()) @pytest.mark.unit def test_unbuilt_model(self, parameters): diff --git a/tests/unit/test_optimisation.py b/tests/unit/test_optimisation.py index 97fe12fc5..bb6d08fa2 100644 --- a/tests/unit/test_optimisation.py +++ b/tests/unit/test_optimisation.py @@ -75,6 +75,7 @@ def two_param_cost(self, model, two_parameters, dataset): (pybop.Adam, "Adam"), (pybop.AdamW, "AdamW"), (pybop.CMAES, "Covariance Matrix Adaptation Evolution Strategy (CMA-ES)"), + (pybop.CuckooSearch, "Cuckoo Search"), (pybop.SNES, "Seperable Natural Evolution Strategy (SNES)"), (pybop.XNES, "Exponential Natural Evolution Strategy (xNES)"), (pybop.PSO, "Particle Swarm Optimisation (PSO)"), @@ -104,6 +105,15 @@ def test_optimiser_classes(self, two_param_cost, optimiser, expected_name): if issubclass(optimiser, pybop.BasePintsOptimiser): assert optim._boundaries is None + @pytest.mark.unit + def test_no_optimisation_parameters(self, model, dataset): + problem = pybop.FittingProblem( + model=model, parameters=pybop.Parameters(), dataset=dataset + ) + cost = pybop.RootMeanSquaredError(problem) + with pytest.raises(ValueError, match="There are no parameters to optimise."): + pybop.Optimisation(cost=cost) + @pytest.mark.parametrize( "optimiser", [ @@ -117,6 +127,7 @@ def test_optimiser_classes(self, two_param_cost, optimiser, expected_name): pybop.PSO, pybop.IRPropMin, pybop.NelderMead, + pybop.CuckooSearch, ], ) @pytest.mark.unit @@ -166,6 +177,7 @@ def test_optimiser_kwargs(self, cost, optimiser): ): warnings.simplefilter("always") optim = optimiser(cost=cost, unrecognised=10) + assert not optim.pints_optimiser.running() else: # Check bounds in list format and update tol bounds = [ @@ -224,7 +236,7 @@ def test_optimiser_kwargs(self, cost, optimiser): assert optim.pints_optimiser._lambda == 0.1 # Incorrect values - for i, match in (("Value", -1),): + for i, _match in (("Value", -1),): with pytest.raises( Exception, match="must be a numeric value between 0 and 1." ): @@ -240,18 +252,24 @@ def test_optimiser_kwargs(self, cost, optimiser): # Check defaults assert optim.pints_optimiser.n_hyper_parameters() == 5 - assert not optim.pints_optimiser.running() assert optim.pints_optimiser.x_guessed() == optim.pints_optimiser._x0 - with pytest.raises(Exception): + with pytest.raises(RuntimeError): optim.pints_optimiser.tell([0.1]) else: # Check and update initial values - assert optim.x0 == cost.x0 + x0 = cost.parameters.initial_value() + assert optim.x0 == x0 x0_new = np.array([0.6]) optim = optimiser(cost=cost, x0=x0_new) assert optim.x0 == x0_new - assert optim.x0 != cost.x0 + assert optim.x0 != x0 + + @pytest.mark.unit + def test_cuckoo_no_bounds(self, dataset, cost, model): + optim = pybop.CuckooSearch(cost=cost, bounds=None, max_iterations=1) + optim.run() + assert optim.pints_optimiser._boundaries is None @pytest.mark.unit def test_scipy_minimize_with_jac(self, cost): @@ -266,6 +284,16 @@ def test_scipy_minimize_with_jac(self, cost): ): optim = pybop.SciPyMinimize(cost=cost, jac="Invalid string") + @pytest.mark.unit + def test_scipy_minimize_invalid_x0(self, cost): + # Check a starting point that returns an infinite cost + invalid_x0 = np.array([1.1]) + optim = pybop.SciPyMinimize( + cost=cost, x0=invalid_x0, maxiter=10, allow_infeasible_solutions=False + ) + optim.run() + assert abs(optim._cost0) != np.inf + @pytest.mark.unit def test_single_parameter(self, cost): # Test catch for optimisers that can only run with multiple parameters @@ -298,12 +326,8 @@ def test_default_optimiser(self, cost): optim = pybop.Optimisation(cost=cost) assert optim.name() == "Exponential Natural Evolution Strategy (xNES)" - # Test incorrect setting attribute - with pytest.raises( - AttributeError, - match="'Optimisation' object has no attribute 'not_a_valid_attribute'", - ): - optim.not_a_valid_attribute + # Test getting incorrect attribute + assert not hasattr(optim, "not_a_valid_attribute") @pytest.mark.unit def test_incorrect_optimiser_class(self, cost): @@ -322,19 +346,13 @@ class RandomClass: with pytest.raises(ValueError): pybop.Optimisation(cost=cost, optimiser=RandomClass) - @pytest.mark.unit - def test_prior_sampling(self, cost): - # Tests prior sampling - for i in range(50): - optim = pybop.Optimisation(cost=cost) - assert optim.x0[0] < 0.62 and optim.x0[0] > 0.58 - @pytest.mark.unit @pytest.mark.parametrize( "mean, sigma, expect_exception", [ (0.85, 0.2, False), (0.85, 0.001, True), + (1.0, 0.5, False), ], ) def test_scipy_prior_resampling( @@ -368,6 +386,10 @@ def test_scipy_prior_resampling( else: opt.run() + # Test cost_wrapper inf return + cost = opt.cost_wrapper(np.array([0.9])) + assert cost in [1.729, 1.81, 1.9] + @pytest.mark.unit def test_halting(self, cost): # Test max evalutions @@ -403,6 +425,7 @@ def test_halting(self, cost): optim = pybop.Optimisation(cost=cost) # Trigger threshold + optim.set_threshold(None) optim.set_threshold(np.inf) optim.run() optim.set_max_unchanged_iterations() diff --git a/tests/unit/test_parameter_sets.py b/tests/unit/test_parameter_sets.py index 12b2c18a6..347dfde92 100644 --- a/tests/unit/test_parameter_sets.py +++ b/tests/unit/test_parameter_sets.py @@ -123,3 +123,16 @@ def test_bpx_parameter_sets(self): match="Parameter set already constructed, or path to bpx file not provided.", ): bpx_parameters.import_from_bpx() + + @pytest.mark.unit + def test_set_formation_concentrations(self): + parameter_set = pybop.ParameterSet.pybamm( + "Chen2020", formation_concentrations=True + ) + + assert ( + parameter_set["Initial concentration in negative electrode [mol.m-3]"] == 0 + ) + assert ( + parameter_set["Initial concentration in positive electrode [mol.m-3]"] > 0 + ) diff --git a/tests/unit/test_parameters.py b/tests/unit/test_parameters.py index 736684fef..90c43622c 100644 --- a/tests/unit/test_parameters.py +++ b/tests/unit/test_parameters.py @@ -78,6 +78,16 @@ def test_invalid_inputs(self, parameter): ): pybop.Parameter("Name", bounds=[0.7, 0.3]) + @pytest.mark.unit + def test_sample_initial_values(self): + parameter = pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.6, 0.02), + bounds=[0.375, 0.7], + ) + sample = parameter.get_initial_value() + assert (sample >= 0.375) and (sample <= 0.7) + class TestParameters: """ @@ -105,11 +115,23 @@ def test_parameters_construction(self, parameter): assert parameter.name in params.param.keys() assert parameter in params.param.values() + params.join( + pybop.Parameters( + parameter, + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.6, 0.02), + bounds=[0.375, 0.7], + initial_value=0.6, + ), + ) + ) + with pytest.raises( ValueError, match="There is already a parameter with the name " - + "Negative electrode active material volume fraction" - + " in the Parameters object. Please remove the duplicate entry.", + "Negative electrode active material volume fraction" + " in the Parameters object. Please remove the duplicate entry.", ): params.add(parameter) @@ -128,11 +150,16 @@ def test_parameters_construction(self, parameter): initial_value=0.6, ) ) + with pytest.raises( + Exception, + match="Parameter requires a name.", + ): + params.add(dict(value=1)) with pytest.raises( ValueError, match="There is already a parameter with the name " - + "Negative electrode active material volume fraction" - + " in the Parameters object. Please remove the duplicate entry.", + "Negative electrode active material volume fraction" + " in the Parameters object. Please remove the duplicate entry.", ): params.add( dict( @@ -156,6 +183,28 @@ def test_parameters_construction(self, parameter): ): params.remove(parameter_name=parameter) + @pytest.mark.unit + def test_parameters_naming(self, parameter): + params = pybop.Parameters(parameter) + param = params["Negative electrode active material volume fraction"] + assert param == parameter + + with pytest.raises( + ValueError, + match="is not the name of a parameter.", + ): + params["Positive electrode active material volume fraction"] + + @pytest.mark.unit + def test_parameters_update(self, parameter): + params = pybop.Parameters(parameter) + params.update(values=[0.5]) + assert parameter.value == 0.5 + 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] + @pytest.mark.unit def test_get_sigma(self, parameter): params = pybop.Parameters(parameter) diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index b810e3f0e..79015c006 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np import pytest from packaging import version @@ -64,7 +66,7 @@ def test_dataset_plots(self, dataset): dataset["Voltage [V]"], trace_names=["Time [s]", "Voltage [V]"], ) - pybop.plot_dataset(dataset, signal=["Voltage [V]"]) + pybop.plot_dataset(dataset) @pytest.fixture def fitting_problem(self, model, parameters, dataset): @@ -88,6 +90,9 @@ def test_problem_plots(self, fitting_problem, design_problem): pybop.quick_plot(fitting_problem, title="Optimised Comparison") pybop.quick_plot(design_problem) + # Test conversion of values into inputs + pybop.quick_plot(fitting_problem, problem_inputs=[0.6, 0.6]) + @pytest.fixture def cost(self, fitting_problem): # Define an example cost @@ -141,3 +146,74 @@ def test_with_ipykernel(self, dataset, cost, optim): pybop.plot_convergence(optim) pybop.plot_parameters(optim) pybop.plot2d(optim, steps=5) + + @pytest.mark.unit + def test_gaussianlogliklihood_plots(self, fitting_problem): + # Test plotting of GaussianLogLikelihood + likelihood = pybop.GaussianLogLikelihood(fitting_problem) + optim = pybop.CMAES(likelihood, max_iterations=5) + optim.run() + + # Plot parameters + pybop.plot_parameters(optim) + + @pytest.mark.unit + def test_plot2d_incorrect_number_of_parameters(self, model, dataset): + # Test with less than two paramters + parameters = pybop.Parameters( + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.68, 0.05), + bounds=[0.5, 0.8], + ), + ) + fitting_problem = pybop.FittingProblem(model, parameters, dataset) + cost = pybop.SumSquaredError(fitting_problem) + with pytest.raises( + ValueError, match="This cost function takes fewer than 2 parameters." + ): + pybop.plot2d(cost) + + # Test with more than two paramters + parameters = pybop.Parameters( + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.68, 0.05), + bounds=[0.5, 0.8], + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.58, 0.05), + bounds=[0.4, 0.7], + ), + pybop.Parameter( + "Positive particle radius [m]", + prior=pybop.Gaussian(4.8e-06, 0.05e-06), + bounds=[4e-06, 6e-06], + ), + ) + fitting_problem = pybop.FittingProblem(model, parameters, dataset) + cost = pybop.SumSquaredError(fitting_problem) + pybop.plot2d(cost) + + @pytest.mark.unit + def test_plot2d_prior_bounds(self, model, dataset): + # Test with prior bounds + parameters = pybop.Parameters( + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.68, 0.01), + ), + pybop.Parameter( + "Positive electrode active material volume fraction", + prior=pybop.Gaussian(0.58, 0.01), + ), + ) + fitting_problem = pybop.FittingProblem(model, parameters, dataset) + cost = pybop.SumSquaredError(fitting_problem) + with pytest.warns( + UserWarning, + match="Bounds were created from prior distributions.", + ): + warnings.simplefilter("always") + pybop.plot2d(cost) diff --git a/tests/unit/test_posterior.py b/tests/unit/test_posterior.py index 511b6b00f..82ebdbff4 100644 --- a/tests/unit/test_posterior.py +++ b/tests/unit/test_posterior.py @@ -57,7 +57,7 @@ def one_signal_problem(self, model, parameters, dataset): @pytest.fixture def likelihood(self, one_signal_problem): - return pybop.GaussianLogLikelihoodKnownSigma(one_signal_problem, sigma=0.01) + return pybop.GaussianLogLikelihoodKnownSigma(one_signal_problem, sigma0=0.01) @pytest.fixture def prior(self): @@ -72,7 +72,8 @@ def test_log_posterior_construction(self, likelihood, prior): assert posterior._prior == prior # Test log posterior construction without parameters - likelihood.problem.parameters = None + likelihood.problem.parameters.priors = None + with pytest.raises( ValueError, match="An error occurred when constructing the Prior class:" ): @@ -95,11 +96,11 @@ def posterior(self, likelihood, prior): def test_log_posterior(self, posterior): # Test log posterior x = np.array([0.50]) - assert np.allclose(posterior(x), -3408.15, atol=2e-2) + assert np.allclose(posterior(x), -3318.34, atol=2e-2) # Test log posterior evaluateS1 p, dp = posterior.evaluateS1(x) - assert np.allclose(p, -3408.15, atol=2e-2) + assert np.allclose(p, -3318.34, atol=2e-2) assert np.allclose(dp, -1736.05, atol=2e-2) # Get log likelihood and log prior diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index bd91e1c9a..4261e5bf8 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -77,7 +77,7 @@ def log_posterior(self, model, two_parameters, dataset): two_parameters, dataset, ) - likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma=[0.02, 0.02]) + likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=0.01) prior1 = pybop.Gaussian(0.7, 0.02) prior2 = pybop.Gaussian(0.6, 0.02) composed_prior = pybop.ComposedLogPrior(prior1, prior2) diff --git a/tests/unit/test_standalone.py b/tests/unit/test_standalone.py index 026692011..2d5727b60 100644 --- a/tests/unit/test_standalone.py +++ b/tests/unit/test_standalone.py @@ -35,7 +35,8 @@ def test_optimisation_on_standalone_cost(self): optim = pybop.SciPyDifferentialEvolution(cost=cost) x, final_cost = optim.run() - initial_cost = optim.cost(cost.x0) + optim.x0 = optim.log["x"][0][0] + initial_cost = optim.cost(optim.x0) assert initial_cost > final_cost np.testing.assert_allclose(final_cost, 42, atol=1e-1) From 13aa83faf644f5ee379cf978379a8bcd9ed0eeb8 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sun, 21 Jul 2024 10:05:32 +0100 Subject: [PATCH 18/31] refactor: log_pdf to base class, update docstrings. --- pybop/optimisers/base_optimiser.py | 2 +- pybop/samplers/__init__.py | 17 +- pybop/samplers/base_mcmc.py | 24 ++- pybop/samplers/pints_samplers.py | 273 +++-------------------------- tests/unit/test_sampling.py | 4 +- 5 files changed, 44 insertions(+), 276 deletions(-) diff --git a/pybop/optimisers/base_optimiser.py b/pybop/optimisers/base_optimiser.py index 2b00b2345..f5a25790d 100644 --- a/pybop/optimisers/base_optimiser.py +++ b/pybop/optimisers/base_optimiser.py @@ -104,7 +104,7 @@ def __init__( self.set_base_options() self._set_up_optimiser() - # Throw an warning if any options remain + # Throw a warning if any options remain if self.unset_options: warnings.warn( f"Unrecognised keyword arguments: {self.unset_options} will not be used.", diff --git a/pybop/samplers/__init__.py b/pybop/samplers/__init__.py index 924cdb096..4e90cc635 100644 --- a/pybop/samplers/__init__.py +++ b/pybop/samplers/__init__.py @@ -1,21 +1,27 @@ import logging import numpy as np from pints import ParallelEvaluator -import warnings +from typing import Union from pybop import LogPosterior + class BaseSampler: """ Base class for Monte Carlo samplers. """ - def __init__(self, x0, cov0): + + def __init__(self, log_pdf, x0, cov0: Union[np.ndarray, float]): """ Initialise the base sampler. - Args: - cost (pybop.cost): The cost to be sampled. + Parameters + ---------------- + log_pdf (pybop.BaseCost): The distribution to be sampled. + x0: List-like initial condition for Monte Carlo sampling. + cov0: The covariance matrix to be sampled. """ + self._log_pdf = log_pdf self._x0 = x0 self._cov0 = cov0 @@ -23,9 +29,6 @@ def run(self) -> np.ndarray: """ Sample from the posterior distribution. - Args: - n_samples (int): Number of samples to draw. - Returns: np.ndarray: Samples from the posterior distribution. """ diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index c7c2263a7..7f0890ad7 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -36,7 +36,7 @@ def __init__( Initialise the base PINTS sampler. Args: - log_pdf (pybop.BaseCost or List[pybop.BaseCost]): The cost distribution(s) to be sampled. + log_pdf (pybop.BaseCost or List[pybop.BaseCost]): The distribution(s) to be sampled. chains (int): Number of chains to be used. sampler: The sampler class to be used. x0 (list): Initial states for the chains. @@ -44,7 +44,7 @@ def __init__( transformation: Transformation to be applied to the samples. kwargs: Additional keyword arguments. """ - super().__init__(x0, cov0) + super().__init__(log_pdf, x0, cov0) # Set kwargs self._max_iterations = kwargs.get("max_iterations", 500) @@ -56,23 +56,24 @@ def __init__( self._evaluation_files = kwargs.get("evaluation_files", None) self._parallel = kwargs.get("parallel", False) self._verbose = kwargs.get("verbose", False) + self._iteration = 0 self.warm_up = warm_up self.n_parameters = ( - log_pdf[0].n_parameters - if isinstance(log_pdf, list) - else log_pdf.n_parameters + self._log_pdf[0].n_parameters + if isinstance(self._log_pdf, list) + else self._log_pdf.n_parameters ) self._transformation = transformation # Check log_pdf - if isinstance(log_pdf, BaseCost): + if isinstance(self._log_pdf, BaseCost): self._multi_log_pdf = False else: - if len(log_pdf) != chains: + if len(self._log_pdf) != chains: raise ValueError("Number of log pdf's must match number of chains") - first_pdf_parameters = log_pdf[0].n_parameters - for pdf in log_pdf: + first_pdf_parameters = self._log_pdf[0].n_parameters + for pdf in self._log_pdf: if not isinstance(pdf, BaseCost): raise ValueError("All log pdf's must be instances of BaseCost") if pdf.n_parameters != first_pdf_parameters: @@ -86,15 +87,13 @@ def __init__( if transformation is not None: self._apply_transformation(transformation) - self._log_pdf = log_pdf - # Number of chains self._n_chains = chains if self._n_chains < 1: raise ValueError("Number of chains must be greater than 0") # Check initial conditions - # len of x0 matching number of chains, number of parameters, etc. + # TODO: len of x0 matching number of chains, number of parameters, etc. # Single chain vs multiple chain samplers self._single_chain = issubclass(sampler, SingleChainMCMC) @@ -167,7 +166,6 @@ def run(self) -> Optional[np.ndarray]: # Initialise iterations and evaluations self._iteration = 0 - self._evaluations = 0 evaluator = self._create_evaluator() self._check_initial_phase() diff --git a/pybop/samplers/pints_samplers.py b/pybop/samplers/pints_samplers.py index 06b61e84c..901d7a2a2 100644 --- a/pybop/samplers/pints_samplers.py +++ b/pybop/samplers/pints_samplers.py @@ -32,7 +32,7 @@ class NUTS(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -42,19 +42,6 @@ class NUTS(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the NUTS sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The NUTS sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -73,7 +60,7 @@ class DREAM(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -83,19 +70,6 @@ class DREAM(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the DREAM sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The DREAM sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -112,7 +86,7 @@ class AdaptiveCovarianceMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -122,19 +96,6 @@ class AdaptiveCovarianceMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Adaptive Covariance MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Adaptive Covariance MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -158,7 +119,7 @@ class DifferentialEvolutionMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -168,19 +129,6 @@ class DifferentialEvolutionMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Differential Evolution MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Differential Evolution MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -196,7 +144,8 @@ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): class DramACMC(BasePintsSampler): """ - Implements the Delayed Rejection Adaptive Metropolis (DRAM) Adaptive Covariance Markov Chain Monte Carlo (MCMC) algorithm. + Implements the Delayed Rejection Adaptive Metropolis (DRAM) Adaptive Covariance Markov Chain + Monte Carlo (MCMC) algorithm. This class extends the DRAM Adaptive Covariance MCMC sampler from the PINTS library. This MCMC method combines Delayed Rejection with Adaptive Metropolis to enhance @@ -204,7 +153,7 @@ class DramACMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -214,19 +163,6 @@ class DramACMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the DRAM Adaptive Covariance MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The DRAM Adaptive Covariance MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -250,7 +186,7 @@ class EmceeHammerMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -260,19 +196,6 @@ class EmceeHammerMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Emcee Hammer MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Emcee Hammer MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -296,7 +219,7 @@ class HaarioACMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -306,19 +229,6 @@ class HaarioACMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Haario Adaptive Covariance MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Haario Adaptive Covariance MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -342,7 +252,7 @@ class HaarioBardenetACMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -352,19 +262,6 @@ class HaarioBardenetACMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Haario-Bardenet Adaptive Covariance MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Haario-Bardenet Adaptive Covariance MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -388,7 +285,7 @@ class HamiltonianMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -398,19 +295,6 @@ class HamiltonianMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Hamiltonian MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Hamiltonian MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -434,7 +318,7 @@ class MALAMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -444,19 +328,6 @@ class MALAMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the MALA MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The MALA MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -480,7 +351,7 @@ class MetropolisRandomWalkMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -490,19 +361,6 @@ class MetropolisRandomWalkMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Metropolis Random Walk MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Metropolis Random Walk MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -526,7 +384,7 @@ class MonomialGammaHamiltonianMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -536,19 +394,6 @@ class MonomialGammaHamiltonianMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Monomial Gamma Hamiltonian MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Monomial Gamma Hamiltonian MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -572,7 +417,7 @@ class PopulationMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -582,19 +427,6 @@ class PopulationMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Population MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Population MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -618,7 +450,7 @@ class RaoBlackwellACMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -628,19 +460,6 @@ class RaoBlackwellACMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Rao-Blackwell Adaptive Covariance MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Rao-Blackwell Adaptive Covariance MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -664,7 +483,7 @@ class RelativisticMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -674,19 +493,6 @@ class RelativisticMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Relativistic MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Relativistic MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -710,7 +516,7 @@ class SliceDoublingMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -720,19 +526,6 @@ class SliceDoublingMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Slice Doubling MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Slice Doubling MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -756,7 +549,7 @@ class SliceRankShrinkingMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -766,19 +559,6 @@ class SliceRankShrinkingMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Slice Rank Shrinking MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Slice Rank Shrinking MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): @@ -802,7 +582,7 @@ class SliceStepoutMCMC(BasePintsSampler): Parameters ---------- - log_pdf : function + log_pdf : pybop.BaseCost A function that calculates the log-probability density. chains : int The number of chains to run. @@ -812,19 +592,6 @@ class SliceStepoutMCMC(BasePintsSampler): Initial covariance matrix. **kwargs Additional arguments to pass to the Slice Stepout MCMC sampler. - - Attributes - ---------- - log_pdf : function - The log-probability density function. - chains : int - The number of chains being run. - sampler_class : class - The Slice Stepout MCMC sampler class from PINTS. - x0 : ndarray - The initial positions of the chains. - cov0 : ndarray - The initial covariance matrix. """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 4261e5bf8..e09aa6d74 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -298,8 +298,8 @@ def test_set_parallel(self, log_posterior, x0, chains): assert sampler._n_workers == 2 @pytest.mark.unit - def test_base_sampler(self, x0): - sampler = pybop.BaseSampler(x0=x0, cov0=0.1) + def test_base_sampler(self, log_posterior, x0): + sampler = pybop.BaseSampler(log_posterior, x0, cov0=0.1) with pytest.raises(NotImplementedError): sampler.run() From 0b328891f8be71d7a02129550c2fa88f57e95a62 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Sun, 21 Jul 2024 10:58:21 +0100 Subject: [PATCH 19/31] Adds catches and initialisation for x0, update tests --- examples/scripts/mcmc_example.py | 11 +++-------- pybop/samplers/__init__.py | 7 ++++++- pybop/samplers/base_mcmc.py | 5 ++++- tests/integration/test_monte_carlo.py | 2 +- tests/unit/test_sampling.py | 15 ++++++++++++--- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index c872386a7..8c386431b 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -62,18 +62,13 @@ def noise(sigma): composed_prior = pybop.ComposedLogPrior(prior1, prior2) posterior = pybop.LogPosterior(likelihood, composed_prior) -x0 = [] -n_chains = 10 -for _i in range(n_chains): - x0.append(np.array([0.68, 0.58])) - optim = pybop.DREAM( posterior, - chains=n_chains, - x0=x0, + chains=4, + x0=[0.68, 0.58], max_iterations=300, burn_in=100, - # parallel=True, # uncomment to enable parallelisation (MacOS/Linux only) + # parallel=True, # uncomment to enable parallelisation (MacOS/WSL/Linux only) ) result = optim.run() diff --git a/pybop/samplers/__init__.py b/pybop/samplers/__init__.py index 4e90cc635..9ec3374e2 100644 --- a/pybop/samplers/__init__.py +++ b/pybop/samplers/__init__.py @@ -22,9 +22,14 @@ def __init__(self, log_pdf, x0, cov0: Union[np.ndarray, float]): cov0: The covariance matrix to be sampled. """ self._log_pdf = log_pdf - self._x0 = x0 self._cov0 = cov0 + if not isinstance(x0, np.ndarray): + try: + self._x0 = np.asarray(x0) + except ValueError as e: + raise ValueError(f"Error initialising x0: {e}") + def run(self) -> np.ndarray: """ Sample from the posterior distribution. diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index 7f0890ad7..2d4d015c8 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -93,7 +93,10 @@ def __init__( raise ValueError("Number of chains must be greater than 0") # Check initial conditions - # TODO: len of x0 matching number of chains, number of parameters, etc. + if self._x0.size != self.n_parameters: + raise ValueError("x0 must have the same number of parameters as log_pdf") + if len(self._x0) != self._n_chains: + self._x0 = np.tile(self._x0, (self._n_chains, 1)) # Single chain vs multiple chain samplers self._single_chain = issubclass(sampler, SingleChainMCMC) diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index 6e8fc93a4..526796b8f 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -115,7 +115,7 @@ def test_sampling_spm(self, quick_sampler, spm_likelihood): prior2 = pybop.Uniform(0.4, 0.7) composed_prior = pybop.ComposedLogPrior(prior1, prior2) posterior = pybop.LogPosterior(spm_likelihood, composed_prior) - x0 = [[0.55, 0.55], [0.55, 0.55], [0.55, 0.55]] + x0 = [0.55, 0.55] sampler = quick_sampler( posterior, chains=3, diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index e09aa6d74..7117d162e 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -87,12 +87,16 @@ def log_posterior(self, model, two_parameters, dataset): @pytest.fixture def x0(self): - return [[0.68, 0.58], [0.68, 0.58], [0.68, 0.58]] + return [0.68, 0.58] @pytest.fixture def chains(self): return 3 + @pytest.fixture + def multi_samplers(self): + return (pybop.DREAM, pybop.EmceeHammerMCMC, pybop.DifferentialEvolutionMCMC) + @pytest.mark.parametrize( "MCMC", [ @@ -117,7 +121,9 @@ def chains(self): ], ) @pytest.mark.unit - def test_initialization_and_run(self, log_posterior, x0, chains, MCMC): + def test_initialization_and_run( + self, log_posterior, x0, chains, MCMC, multi_samplers + ): sampler = pybop.MCMCSampler( log_pdf=log_posterior, chains=chains, @@ -128,7 +134,10 @@ def test_initialization_and_run(self, log_posterior, x0, chains, MCMC): ) assert sampler._n_chains == chains assert sampler._log_pdf == log_posterior - assert (sampler._samplers[0]._x0 == x0[0]).all() + if isinstance(sampler.sampler, multi_samplers): + np.testing.assert_allclose(sampler._samplers[0]._x0[0], x0) + else: + np.testing.assert_allclose(sampler._samplers[0]._x0, x0) # Test incorrect __getattr__ with pytest.raises( From 251f86fc964bd69295ec86cef3abf9433bf37ff4 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 7 Aug 2024 14:23:58 +0100 Subject: [PATCH 20/31] feat: updates for transformation class, cleanup --- examples/scripts/mcmc_example.py | 4 +++- pybop/samplers/__init__.py | 17 ++++++++++------- pybop/samplers/base_mcmc.py | 24 ++++++++++-------------- tests/unit/test_likelihoods.py | 3 ++- tests/unit/test_sampling.py | 31 ++++++------------------------- 5 files changed, 31 insertions(+), 48 deletions(-) diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index 8c386431b..28b66cb73 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -12,10 +12,12 @@ pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Gaussian(0.68, 0.05), + transformation=pybop.LogTransformation(), ), pybop.Parameter( "Positive electrode active material volume fraction", prior=pybop.Gaussian(0.58, 0.05), + transformation=pybop.LogTransformation(), ), ] @@ -65,7 +67,7 @@ def noise(sigma): optim = pybop.DREAM( posterior, chains=4, - x0=[0.68, 0.58], + # x0=[0.68, 0.58], max_iterations=300, burn_in=100, # parallel=True, # uncomment to enable parallelisation (MacOS/WSL/Linux only) diff --git a/pybop/samplers/__init__.py b/pybop/samplers/__init__.py index 9ec3374e2..18430c926 100644 --- a/pybop/samplers/__init__.py +++ b/pybop/samplers/__init__.py @@ -3,7 +3,7 @@ from pints import ParallelEvaluator from typing import Union -from pybop import LogPosterior +from pybop import LogPosterior, Parameters class BaseSampler: @@ -11,20 +11,26 @@ class BaseSampler: Base class for Monte Carlo samplers. """ - def __init__(self, log_pdf, x0, cov0: Union[np.ndarray, float]): + def __init__(self, log_pdf: LogPosterior, x0, cov0: Union[np.ndarray, float]): """ Initialise the base sampler. Parameters ---------------- - log_pdf (pybop.BaseCost): The distribution to be sampled. + log_pdf (pybop.LogPosterior): The posterior or PDF to be sampled. x0: List-like initial condition for Monte Carlo sampling. cov0: The covariance matrix to be sampled. """ self._log_pdf = log_pdf + self._x0 = x0 self._cov0 = cov0 + self.parameters = Parameters() + if isinstance(log_pdf, LogPosterior): + self.parameters = log_pdf.parameters - if not isinstance(x0, np.ndarray): + if x0 is None: + self._x0 = self.parameters.initial_value() + elif not isinstance(x0, np.ndarray): try: self._x0 = np.asarray(x0) except ValueError as e: @@ -88,9 +94,6 @@ def _ask_for_samples(self): else: return self._samplers[0].ask() - def _inverse_transform(self, y): - return self._transformation.to_model(y) if self._transformation else y - def _check_initial_phase(self): # Set initial phase if needed if self._initial_phase: diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index 2d4d015c8..982b68a02 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, Union +from typing import Optional import numpy as np from pints import ( @@ -9,7 +9,7 @@ SingleChainMCMC, ) -from pybop import BaseCost, BaseSampler +from pybop import BaseCost, BaseSampler, LogPosterior class BasePintsSampler(BaseSampler): @@ -23,7 +23,7 @@ class BasePintsSampler(BaseSampler): def __init__( self, - log_pdf: Union[BaseCost, list[BaseCost]], + log_pdf: LogPosterior, chains: int, sampler, warm_up=None, @@ -63,7 +63,6 @@ def __init__( if isinstance(self._log_pdf, list) else self._log_pdf.n_parameters ) - self._transformation = transformation # Check log_pdf if isinstance(self._log_pdf, BaseCost): @@ -83,10 +82,6 @@ def __init__( self._multi_log_pdf = True - # Transformations - if transformation is not None: - self._apply_transformation(transformation) - # Number of chains self._n_chains = chains if self._n_chains < 1: @@ -124,10 +119,6 @@ def __init__( self._n_workers = 1 self.set_parallel(self._parallel) - def _apply_transformation(self, transformation): - # TODO: Implement transformation logic (alongside #357) - pass - def run(self) -> Optional[np.ndarray]: """ Executes the Monte Carlo sampling process and generates samples @@ -215,7 +206,9 @@ def _process_single_chain(self): reply = self._samplers[i].tell(next(self.fxs_iterator)) if reply: y, fy, accepted = reply - y_store = self._inverse_transform(y) + y_store = self._inverse_transform( + y, self._log_pdf[i] if self._multi_log_pdf else self._log_pdf + ) if self._chains_in_memory: self._samples[i][self._n_samples[i]] = y_store else: @@ -246,7 +239,7 @@ def _process_multi_chain(self): self._intermediate_step = reply is None if reply: ys, fys, accepted = reply - ys_store = np.array([self._inverse_transform(y) for y in ys]) + ys_store = np.array([self._inverse_transform(y, self._log_pdf) for y in ys]) if self._chains_in_memory: self._samples[:, self._iteration] = ys_store else: @@ -297,3 +290,6 @@ def _create_evaluator(self): if not self._multi_log_pdf else MultiSequentialEvaluator(f) ) + + def _inverse_transform(self, y, log_pdf): + return log_pdf.transformation.to_model(y) if log_pdf.transformation else y diff --git a/tests/unit/test_likelihoods.py b/tests/unit/test_likelihoods.py index c424db408..226ef306d 100644 --- a/tests/unit/test_likelihoods.py +++ b/tests/unit/test_likelihoods.py @@ -132,7 +132,8 @@ def test_gaussian_log_likelihood(self, one_signal_problem): grad_result, grad_likelihood = likelihood.evaluateS1(np.array([0.8, 0.2])) assert isinstance(result, float) np.testing.assert_allclose(result, grad_result, atol=1e-5) - assert np.all(grad_likelihood <= 0) + assert grad_likelihood[0] <= 0 + assert grad_likelihood[1] >= 0 # Test construction with sigma as a Parameter sigma = pybop.Parameter("sigma", prior=pybop.Uniform(0.4, 0.6)) diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 7117d162e..355b370d1 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -1,5 +1,5 @@ import copy -from unittest.mock import MagicMock, patch +from unittest.mock import patch import numpy as np import pytest @@ -43,8 +43,8 @@ def dataset(self): ) @pytest.fixture - def two_parameters(self): - return [ + def parameters(self): + return pybop.Parameters( pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Gaussian(0.6, 0.2), @@ -55,26 +55,17 @@ def two_parameters(self): prior=pybop.Gaussian(0.55, 0.05), bounds=[0.53, 0.57], ), - ] + ) @pytest.fixture def model(self): return pybop.lithium_ion.SPM() @pytest.fixture - def cost(self, model, one_parameter, dataset): + def log_posterior(self, model, parameters, dataset): problem = pybop.FittingProblem( model, - one_parameter, - dataset, - ) - return pybop.SumSquaredError(problem) - - @pytest.fixture - def log_posterior(self, model, two_parameters, dataset): - problem = pybop.FittingProblem( - model, - two_parameters, + parameters, dataset, ) likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=0.01) @@ -238,16 +229,6 @@ def test_no_chains_in_memory(self, log_posterior, x0, chains): samples = sampler.run() assert samples is None - @pytest.mark.unit - def test_apply_transformation(self, log_posterior, x0, chains): - sampler = AdaptiveCovarianceMCMC( - log_pdf=log_posterior, chains=chains, x0=x0, transformation=MagicMock() - ) - - with patch.object(sampler, "_apply_transformation") as mock_method: - sampler._apply_transformation(sampler._transformation) - mock_method.assert_called_once_with(sampler._transformation) - @pytest.mark.unit def test_logging_initialisation(self, log_posterior, x0, chains): sampler = AdaptiveCovarianceMCMC( From 5bb4d9489bb81d49ce6bb156d85bfc23d9277f11 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 7 Aug 2024 16:31:46 +0100 Subject: [PATCH 21/31] fix: uniformly apply bound transformations, update LogPosterior --- examples/scripts/mcmc_example.py | 5 ++--- pybop/costs/_likelihoods.py | 24 ++++++++++++++++-------- pybop/parameters/parameter.py | 18 ++++++++++-------- pybop/samplers/base_mcmc.py | 2 +- tests/unit/test_posterior.py | 6 ++---- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index 28b66cb73..909bd7d46 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -8,7 +8,7 @@ synth_model = pybop.lithium_ion.DFN(parameter_set=parameter_set) # Fitting parameters -parameters = [ +parameters = pybop.Parameters( pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Gaussian(0.68, 0.05), @@ -19,7 +19,7 @@ prior=pybop.Gaussian(0.58, 0.05), transformation=pybop.LogTransformation(), ), -] +) # Generate data init_soc = 1.0 @@ -67,7 +67,6 @@ def noise(sigma): optim = pybop.DREAM( posterior, chains=4, - # x0=[0.68, 0.58], max_iterations=300, burn_in=100, # parallel=True, # uncomment to enable parallelisation (MacOS/WSL/Linux only) diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index ca5d5088f..0bd03ad89 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -385,7 +385,7 @@ def __init__(self, log_likelihood, log_prior=None): f"An error occurred when constructing the Prior class: {e}" ) from e - def _evaluate(self, x, grad=None): + def _evaluate(self, inputs: Inputs) -> float: """ Calculate the posterior cost for a given set of parameters. @@ -402,12 +402,18 @@ def _evaluate(self, x, grad=None): float The posterior cost. """ - prior = self._prior(x) - if not np.isfinite(prior): - return prior - return prior + self._log_likelihood.evaluate(x) + log_prior = self._prior(inputs) - def _evaluateS1(self, x): + if not np.isfinite(log_prior).any(): + return log_prior + + if self._has_separable_problem: + self._log_likelihood.y = self.y + log_likelihood = self._log_likelihood._evaluate(inputs) + + return log_likelihood + log_prior + + def _evaluateS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: """ Compute the posterior with respect to the parameters. The method passes the likelihood gradient to the optimiser without modification. @@ -428,10 +434,12 @@ def _evaluateS1(self, x): ValueError If an error occurs during the calculation of the cost or gradient. """ - prior, dp = self._prior.evaluateS1(x) + prior, dp = self._prior.evaluateS1(inputs) if not np.isfinite(prior): return prior, dp - likelihood, dl = self._log_likelihood.evaluateS1(x) + if self._has_separable_problem: + self._log_likelihood.y, self._log_likelihood.dy = (self.y, self.dy) + likelihood, dl = self._log_likelihood._evaluateS1(inputs) return prior + likelihood, dp + dl def prior(self): diff --git a/pybop/parameters/parameter.py b/pybop/parameters/parameter.py index 5401b6c38..c8fda2851 100644 --- a/pybop/parameters/parameter.py +++ b/pybop/parameters/parameter.py @@ -56,6 +56,9 @@ def __init__( self.value = initial_value self.transformation = transformation self.applied_prior_bounds = False + self.bounds = None + self.lower_bounds = None + self.upper_bounds = None self.set_bounds(bounds) self.margin = 1e-4 @@ -162,17 +165,9 @@ def set_bounds(self, bounds=None, boundary_multiplier=6): if bounds is not None: if bounds[0] >= bounds[1]: raise ValueError("Lower bound must be less than upper bound") - elif self.transformation is not None: - self.lower_bound = np.ndarray.item( - self.transformation.to_search(bounds[0]) - ) - self.upper_bound = np.ndarray.item( - self.transformation.to_search(bounds[1]) - ) else: self.lower_bound = bounds[0] self.upper_bound = bounds[1] - elif self.prior is not None: self.applied_prior_bounds = True self.lower_bound = self.prior.mean - boundary_multiplier * self.prior.sigma @@ -181,6 +176,13 @@ def set_bounds(self, bounds=None, boundary_multiplier=6): else: self.bounds = None return + if self.transformation is not None: + self.lower_bound = np.ndarray.item( + self.transformation.to_search(self.lower_bound) + ) + self.upper_bound = np.ndarray.item( + self.transformation.to_search(self.upper_bound) + ) self.bounds = [self.lower_bound, self.upper_bound] diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_mcmc.py index 982b68a02..5c51b00bd 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_mcmc.py @@ -28,7 +28,7 @@ def __init__( sampler, warm_up=None, x0=None, - cov0=None, + cov0=0.1, transformation=None, **kwargs, ): diff --git a/tests/unit/test_posterior.py b/tests/unit/test_posterior.py index 82ebdbff4..72c073aae 100644 --- a/tests/unit/test_posterior.py +++ b/tests/unit/test_posterior.py @@ -117,7 +117,5 @@ def posterior_uniform_prior(self, likelihood): @pytest.mark.unit def test_log_posterior_inf(self, posterior_uniform_prior): # Test prior np.inf - p1 = posterior_uniform_prior([1]) - p2, _ = posterior_uniform_prior.evaluateS1([1]) - assert p1 == -np.inf - assert p2 == -np.inf + assert not np.isfinite(posterior_uniform_prior([1])) + assert not np.isfinite(posterior_uniform_prior.evaluateS1([1])[0]) From cd07072c91fb75359c2bab185125d706e579375a Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 13 Aug 2024 14:57:58 +0100 Subject: [PATCH 22/31] refactor: ComposedLogPrior -> JointLogPrior, prior.evaluateS1 -> logpdfS1, suggestions from review --- examples/scripts/mcmc_example.py | 35 ++-- pybop/__init__.py | 6 +- pybop/costs/_likelihoods.py | 2 +- pybop/parameters/priors.py | 66 ++----- pybop/samplers/__init__.py | 164 ----------------- .../{base_mcmc.py => base_pints_sampler.py} | 7 +- pybop/samplers/base_sampler.py | 165 ++++++++++++++++++ pyproject.toml | 2 +- tests/integration/test_monte_carlo.py | 29 ++- tests/unit/test_priors.py | 56 +++--- tests/unit/test_sampling.py | 2 +- 11 files changed, 260 insertions(+), 274 deletions(-) rename pybop/samplers/{base_mcmc.py => base_pints_sampler.py} (98%) create mode 100644 pybop/samplers/base_sampler.py diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index 375f8fdd8..e73cc0d4a 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -1,22 +1,30 @@ import numpy as np import plotly.graph_objects as go +import pybamm import pybop # Parameter set and model definition +solver = pybamm.IDAKLUSolver() parameter_set = pybop.ParameterSet.pybamm("Chen2020") -synth_model = pybop.lithium_ion.DFN(parameter_set=parameter_set) +parameter_set.update( + { + "Negative electrode active material volume fraction": 0.63, + "Positive electrode active material volume fraction": 0.71, + } +) +synth_model = pybop.lithium_ion.DFN(parameter_set=parameter_set, solver=solver) # Fitting parameters parameters = pybop.Parameters( pybop.Parameter( "Negative electrode active material volume fraction", - prior=pybop.Gaussian(0.68, 0.05), + prior=pybop.Gaussian(0.68, 0.02), transformation=pybop.LogTransformation(), ), pybop.Parameter( "Positive electrode active material volume fraction", - prior=pybop.Gaussian(0.58, 0.05), + prior=pybop.Gaussian(0.68, 0.02), transformation=pybop.LogTransformation(), ), ) @@ -27,8 +35,8 @@ experiment = pybop.Experiment( [ ( - "Discharge at 0.5C until 2.5V (10 second period)", - "Charge at 0.5C until 4.2V (10 second period)", + "Discharge at 0.5C until 3.5V (10 second period)", + "Charge at 0.5C until 4.0V (10 second period)", ), ] # * 2 @@ -51,24 +59,25 @@ def noise(sigma): } ) -model = pybop.lithium_ion.SPM(parameter_set=parameter_set) +model = pybop.lithium_ion.SPM(parameter_set=parameter_set, solver=pybamm.IDAKLUSolver()) model.build(initial_state={"Initial SoC": 1.0}) signal = ["Voltage [V]", "Bulk open-circuit voltage [V]"] # Generate problem, likelihood, and sampler problem = pybop.FittingProblem(model, parameters, dataset, signal=signal) likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=0.002) -prior1 = pybop.Gaussian(0.7, 0.1) -prior2 = pybop.Gaussian(0.6, 0.1) -composed_prior = pybop.ComposedLogPrior(prior1, prior2) +prior1 = pybop.Gaussian(0.59, 0.05) +prior2 = pybop.Gaussian(0.65, 0.05) +composed_prior = pybop.JointLogPrior(prior1, prior2) posterior = pybop.LogPosterior(likelihood, composed_prior) optim = pybop.DREAM( posterior, - chains=4, - max_iterations=300, - burn_in=100, - # parallel=True, # uncomment to enable parallelisation (MacOS/WSL/Linux only) + chains=6, + max_iterations=4000, + burn_in=2000, + verbose=True, + parallel=True, # uncomment to enable parallelisation (MacOS/WSL/Linux only) ) result = optim.run() diff --git a/pybop/__init__.py b/pybop/__init__.py index 6077c27da..260b6f607 100644 --- a/pybop/__init__.py +++ b/pybop/__init__.py @@ -71,7 +71,7 @@ # from .parameters.parameter import Parameter, Parameters from .parameters.parameter_set import ParameterSet -from .parameters.priors import BasePrior, Gaussian, Uniform, Exponential, ComposedLogPrior +from .parameters.priors import BasePrior, Gaussian, Uniform, Exponential, JointLogPrior # # Model classes @@ -145,8 +145,8 @@ # # Monte Carlo classes # -from .samplers import BaseSampler -from .samplers.base_mcmc import BasePintsSampler +from .samplers.base_sampler import BaseSampler +from .samplers.base_pints_sampler import BasePintsSampler from .samplers.pints_samplers import ( NUTS, DREAM, AdaptiveCovarianceMCMC, DifferentialEvolutionMCMC, DramACMC, diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index f26a9d23a..cf5084ef3 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -450,7 +450,7 @@ def computeS1(self, inputs: Inputs) -> tuple[float, np.ndarray]: ValueError If an error occurs during the calculation of the cost or gradient. """ - prior, dp = self._prior.evaluateS1(inputs) + prior, dp = self._prior.logpdfS1(inputs) if not np.isfinite(prior): return prior, dp if self._has_separable_problem: diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index 634299607..1f3edd68b 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -126,22 +126,6 @@ def __call__(self, x): """ Evaluates the distribution at x. - Parameters - ---------- - x : float - The point(s) at which to evaluate the distribution. - - Returns - ------- - float - The value(s) of the distribution at x. - """ - return self.evaluate(x) - - def evaluate(self, x): - """ - Evaluates the distribution at x. - Parameters ---------- x : float @@ -153,25 +137,9 @@ def evaluate(self, x): The value(s) of the distribution at x. """ inputs = self.verify(x) - return self._evaluate(inputs) + return self.logpdf(inputs) - def _evaluate(self, x): - """ - Evaluates the distribution at x. - - Parameters - ---------- - x : float - The point(s) at which to evaluate the distribution. - - Returns - ------- - float - The value(s) of the distribution at x. - """ - return self.logpdf(x) - - def evaluateS1(self, x): + def logpdfS1(self, x): """ Evaluates the first derivative of the distribution at x. @@ -186,9 +154,9 @@ def evaluateS1(self, x): The value(s) of the first derivative at x. """ inputs = self.verify(x) - return self._evaluateS1(inputs) + return self._logpdfS1(inputs) - def _evaluateS1(self, x): + def _logpdfS1(self, x): """ Evaluates the first derivative of the distribution at x. @@ -244,10 +212,6 @@ def sigma(self): """ return self.scale - @property - def n_parameters(self): - return self._n_parameters - class Gaussian(BasePrior): """ @@ -274,7 +238,7 @@ def __init__(self, mean, sigma, random_state=None): self._multip = -1 / (2.0 * self.sigma2) self._n_parameters = 1 - def _evaluateS1(self, x): + def _logpdfS1(self, x): """ Evaluates the first derivative of the gaussian (log) distribution at x. @@ -317,7 +281,7 @@ def __init__(self, lower, upper, random_state=None): self.prior = stats.uniform self._n_parameters = 1 - def _evaluateS1(self, x): + def _logpdfS1(self, x): """ Evaluates the first derivative of the log uniform distribution at x. @@ -370,7 +334,7 @@ def __init__(self, scale, loc=0, random_state=None): self.prior = stats.expon self._n_parameters = 1 - def _evaluateS1(self, x): + def _logpdfS1(self, x): """ Evaluates the first derivative of the log exponential distribution at x. @@ -389,9 +353,9 @@ def _evaluateS1(self, x): return log_pdf, dlog_pdf -class ComposedLogPrior(BasePrior): +class JointLogPrior(BasePrior): """ - Represents a composition of multiple prior distributions. + Represents a joint prior distributions. """ def __init__(self, *priors): @@ -402,7 +366,7 @@ def __init__(self, *priors): self._n_parameters = len(priors) # Needs to be updated - def _evaluate(self, x): + def logpdf(self, x): """ Evaluates the composed prior distribution at x. @@ -418,13 +382,13 @@ def _evaluate(self, x): """ return sum(prior(x) for prior, x in zip(self._priors, x)) - def _evaluateS1(self, x): + def _logpdfS1(self, x): """ Evaluates the first derivative of the composed prior distribution at x. Inspired by PINTS implementation. *This method only works if the underlying :class:`LogPrior` classes all - implement the optional method :class:`LogPDF.evaluateS1().`.* + implement the optional method :class:`LogPDF.logpdfS1().`.* Parameters ---------- @@ -437,13 +401,13 @@ def _evaluateS1(self, x): The value(s) of the first derivative at x. """ output = 0 - doutput = np.zeros(self.n_parameters) + doutput = np.zeros(self._n_parameters) index = 0 for prior in self._priors: - num_params = prior.n_parameters + num_params = 1 x_subset = x[index : index + num_params] - p, dp = prior.evaluateS1(x_subset) + p, dp = prior.logpdfS1(x_subset) output += p doutput[index : index + num_params] = dp index += num_params diff --git a/pybop/samplers/__init__.py b/pybop/samplers/__init__.py index 18430c926..e69de29bb 100644 --- a/pybop/samplers/__init__.py +++ b/pybop/samplers/__init__.py @@ -1,164 +0,0 @@ -import logging -import numpy as np -from pints import ParallelEvaluator -from typing import Union - -from pybop import LogPosterior, Parameters - - -class BaseSampler: - """ - Base class for Monte Carlo samplers. - """ - - def __init__(self, log_pdf: LogPosterior, x0, cov0: Union[np.ndarray, float]): - """ - Initialise the base sampler. - - Parameters - ---------------- - log_pdf (pybop.LogPosterior): The posterior or PDF to be sampled. - x0: List-like initial condition for Monte Carlo sampling. - cov0: The covariance matrix to be sampled. - """ - self._log_pdf = log_pdf - self._x0 = x0 - self._cov0 = cov0 - self.parameters = Parameters() - if isinstance(log_pdf, LogPosterior): - self.parameters = log_pdf.parameters - - if x0 is None: - self._x0 = self.parameters.initial_value() - elif not isinstance(x0, np.ndarray): - try: - self._x0 = np.asarray(x0) - except ValueError as e: - raise ValueError(f"Error initialising x0: {e}") - - def run(self) -> np.ndarray: - """ - Sample from the posterior distribution. - - Returns: - np.ndarray: Samples from the posterior distribution. - """ - raise NotImplementedError - - def set_initial_phase_iterations(self, iterations=250): - """ - Set the number of iterations for the initial phase of the sampler. - - Args: - iterations (int): Number of iterations for the initial phase. - """ - self._initial_phase_iterations = iterations - - def set_max_iterations(self, iterations=500): - """ - Set the maximum number of iterations for the sampler. - - Args: - iterations (int): Maximum number of iterations. - """ - iterations = int(iterations) - if iterations < 1: - raise ValueError("Number of iterations must be greater than 0") - - self._max_iterations = iterations - - def set_parallel(self, parallel=False): - """ - Enable or disable parallel evaluation. - Credit: PINTS - - Parameters - ---------- - parallel : bool or int, optional - If True, use as many worker processes as there are CPU cores. If an integer, use that many workers. - If False or 0, disable parallelism (default: False). - """ - if parallel is True: - self._parallel = True - self._n_workers = ParallelEvaluator.cpu_count() - elif parallel >= 1: - self._parallel = True - self._n_workers = int(parallel) - else: - self._parallel = False - self._n_workers = 1 - - def _ask_for_samples(self): - if self._single_chain: - return [self._samplers[i].ask() for i in self._active] - else: - return self._samplers[0].ask() - - def _check_initial_phase(self): - # Set initial phase if needed - if self._initial_phase: - for sampler in self._samplers: - sampler.set_initial_phase(True) - - def _end_initial_phase(self): - for sampler in self._samplers: - sampler.set_initial_phase(False) - if self._log_to_screen: - logging.info("Initial phase completed.") - - def _initialise_storage(self): - self._prior = None - if isinstance(self._log_pdf, LogPosterior): - self._prior = self._log_pdf.prior() - - # Storage of the received samples - self._sampled_logpdf = np.zeros(self._n_chains) - self._sampled_prior = np.zeros(self._n_chains) - - # Pre-allocate arrays for chain storage - self._samples = np.zeros( - (self._n_chains, self._max_iterations, self.n_parameters) - ) - - # Pre-allocate arrays for evaluation storage - if self._prior: - # Store posterior, likelihood, prior - self._evaluations = np.zeros((self._n_chains, self._max_iterations, 3)) - else: - # Store pdf - self._evaluations = np.zeros((self._n_chains, self._max_iterations)) - - # From PINTS: - # Some samplers need intermediate steps, where `None` is returned instead - # of a sample. But samplers can run asynchronously, so that one can return - # `None` while another returns a sample. To deal with this, we maintain a - # list of 'active' samplers that have not reached `max_iterations`, - # and store the number of samples so far in each chain. - if self._single_chain: - self._active = list(range(self._n_chains)) - self._n_samples = [0] * self._n_chains - - def _initialise_logging(self): - logging.basicConfig(format="%(message)s", level=logging.INFO) - - if self._log_to_screen: - logging.info("Using " + str(self._samplers[0].name())) - logging.info("Generating " + str(self._n_chains) + " chains.") - if self._parallel: - logging.info( - f"Running in parallel with {self._n_workers} worker processes." - ) - else: - logging.info("Running in sequential mode.") - if self._chain_files: - logging.info("Writing chains to " + self._chain_files[0] + " etc.") - if self._evaluation_files: - logging.info( - "Writing evaluations to " + self._evaluation_files[0] + " etc." - ) - - def _finalise_logging(self): - if self._log_to_screen: - logging.info( - f"Halting: Maximum number of iterations ({self._iteration}) reached." - ) diff --git a/pybop/samplers/base_mcmc.py b/pybop/samplers/base_pints_sampler.py similarity index 98% rename from pybop/samplers/base_mcmc.py rename to pybop/samplers/base_pints_sampler.py index 5c51b00bd..f4104117c 100644 --- a/pybop/samplers/base_mcmc.py +++ b/pybop/samplers/base_pints_sampler.py @@ -29,7 +29,6 @@ def __init__( warm_up=None, x0=None, cov0=0.1, - transformation=None, **kwargs, ): """ @@ -57,7 +56,7 @@ def __init__( self._parallel = kwargs.get("parallel", False) self._verbose = kwargs.get("verbose", False) self._iteration = 0 - self.warm_up = warm_up + self._warm_up = warm_up self.n_parameters = ( self._log_pdf[0].n_parameters if isinstance(self._log_pdf, list) @@ -195,8 +194,8 @@ def run(self) -> Optional[np.ndarray]: self._finalise_logging() - if self.warm_up: - self._samples = self._samples[:, self.warm_up :, :] + if self._warm_up: + self._samples = self._samples[:, self._warm_up :, :] return self._samples if self._chains_in_memory else None diff --git a/pybop/samplers/base_sampler.py b/pybop/samplers/base_sampler.py new file mode 100644 index 000000000..52bdc2082 --- /dev/null +++ b/pybop/samplers/base_sampler.py @@ -0,0 +1,165 @@ +import logging +from typing import Union + +import numpy as np +from pints import ParallelEvaluator + +from pybop import LogPosterior, Parameters + + +class BaseSampler: + """ + Base class for Monte Carlo samplers. + """ + + def __init__(self, log_pdf: LogPosterior, x0, cov0: Union[np.ndarray, float]): + """ + Initialise the base sampler. + + Parameters + ---------------- + log_pdf (pybop.LogPosterior): The posterior or PDF to be sampled. + x0: List-like initial condition for Monte Carlo sampling. + cov0: The covariance matrix to be sampled. + """ + self._log_pdf = log_pdf + self._x0 = x0 + self._cov0 = cov0 + self.parameters = Parameters() + if isinstance(log_pdf, LogPosterior): + self.parameters = log_pdf.parameters + + if x0 is None: + self._x0 = self.parameters.initial_value() + elif not isinstance(x0, np.ndarray): + try: + self._x0 = np.asarray(x0) + except ValueError as e: + raise ValueError(f"Error initialising x0: {e}") from e + + def run(self) -> np.ndarray: + """ + Sample from the posterior distribution. + + Returns: + np.ndarray: Samples from the posterior distribution. + """ + raise NotImplementedError + + def set_initial_phase_iterations(self, iterations=250): + """ + Set the number of iterations for the initial phase of the sampler. + + Args: + iterations (int): Number of iterations for the initial phase. + """ + self._initial_phase_iterations = iterations + + def set_max_iterations(self, iterations=500): + """ + Set the maximum number of iterations for the sampler. + + Args: + iterations (int): Maximum number of iterations. + """ + iterations = int(iterations) + if iterations < 1: + raise ValueError("Number of iterations must be greater than 0") + + self._max_iterations = iterations + + def set_parallel(self, parallel=False): + """ + Enable or disable parallel evaluation. + Credit: PINTS + + Parameters + ---------- + parallel : bool or int, optional + If True, use as many worker processes as there are CPU cores. If an integer, use that many workers. + If False or 0, disable parallelism (default: False). + """ + if parallel is True: + self._parallel = True + self._n_workers = ParallelEvaluator.cpu_count() + elif parallel >= 1: + self._parallel = True + self._n_workers = int(parallel) + else: + self._parallel = False + self._n_workers = 1 + + def _ask_for_samples(self): + if self._single_chain: + return [self._samplers[i].ask() for i in self._active] + else: + return self._samplers[0].ask() + + def _check_initial_phase(self): + # Set initial phase if needed + if self._initial_phase: + for sampler in self._samplers: + sampler.set_initial_phase(True) + + def _end_initial_phase(self): + for sampler in self._samplers: + sampler.set_initial_phase(False) + if self._log_to_screen: + logging.info("Initial phase completed.") + + def _initialise_storage(self): + self._prior = None + if isinstance(self._log_pdf, LogPosterior): + self._prior = self._log_pdf.prior() + + # Storage of the received samples + self._sampled_logpdf = np.zeros(self._n_chains) + self._sampled_prior = np.zeros(self._n_chains) + + # Pre-allocate arrays for chain storage + self._samples = np.zeros( + (self._n_chains, self._max_iterations, self.n_parameters) + ) + + # Pre-allocate arrays for evaluation storage + if self._prior: + # Store posterior, likelihood, prior + self._evaluations = np.zeros((self._n_chains, self._max_iterations, 3)) + else: + # Store pdf + self._evaluations = np.zeros((self._n_chains, self._max_iterations)) + + # From PINTS: + # Some samplers need intermediate steps, where `None` is returned instead + # of a sample. But samplers can run asynchronously, so that one can return + # `None` while another returns a sample. To deal with this, we maintain a + # list of 'active' samplers that have not reached `max_iterations`, + # and store the number of samples so far in each chain. + if self._single_chain: + self._active = list(range(self._n_chains)) + self._n_samples = [0] * self._n_chains + + def _initialise_logging(self): + logging.basicConfig(format="%(message)s", level=logging.INFO) + + if self._log_to_screen: + logging.info("Using " + str(self._samplers[0].name())) + logging.info("Generating " + str(self._n_chains) + " chains.") + if self._parallel: + logging.info( + f"Running in parallel with {self._n_workers} worker processes." + ) + else: + logging.info("Running in sequential mode.") + if self._chain_files: + logging.info("Writing chains to " + self._chain_files[0] + " etc.") + if self._evaluation_files: + logging.info( + "Writing evaluations to " + self._evaluation_files[0] + " etc." + ) + + def _finalise_logging(self): + if self._log_to_screen: + logging.info( + f"Halting: Maximum number of iterations ({self._iteration}) reached." + ) diff --git a/pyproject.toml b/pyproject.toml index a1b892fd0..e94e9742d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] requires-python = ">=3.9, <3.13" dependencies = [ - "pybamm>=24.5", + "pybamm[cite]>=24.5", "numpy>=1.16, <2.0", "scipy>=1.3", "pints>=0.5", diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index 6f2a20003..b3117b717 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -1,4 +1,5 @@ import numpy as np +import pybamm import pytest import pybop @@ -13,6 +14,14 @@ RaoBlackwellACMC, SliceDoublingMCMC, SliceStepoutMCMC, + # Grad samplers + # NUTS, + # HamiltonianMCMC, + # MonomialGammaHamiltonianMCMC, + # RelativisticMCMC, + # SliceRankShrinkingMCMC, + # EmceeHammerMCMC, + # MALAMCMC, ) @@ -30,11 +39,19 @@ def setup(self): @pytest.fixture def model(self): parameter_set = pybop.ParameterSet.pybamm("Chen2020") - return pybop.lithium_ion.SPM(parameter_set=parameter_set) + x = self.ground_truth + parameter_set.update( + { + "Negative electrode active material volume fraction": x[0], + "Positive electrode active material volume fraction": x[1], + } + ) + solver = pybamm.IDAKLUSolver() + return pybop.lithium_ion.SPM(parameter_set=parameter_set, solver=solver) @pytest.fixture def parameters(self): - return [ + return pybop.Parameters( pybop.Parameter( "Negative electrode active material volume fraction", prior=pybop.Uniform(0.4, 0.7), @@ -45,7 +62,7 @@ def parameters(self): prior=pybop.Uniform(0.4, 0.7), # no bounds ), - ] + ) @pytest.fixture(params=[0.5]) def init_soc(self, request): @@ -97,7 +114,7 @@ def spm_likelihood(self, model, parameters, cost_class, init_soc): # Samplers that either have along runtime, or converge slowly # Need to assess how to perform integration tests with these samplers # @pytest.mark.parametrize( - # "long_sampler", + # "gradient_sampler", # [ # NUTS, # HamiltonianMCMC, @@ -113,13 +130,11 @@ def spm_likelihood(self, model, parameters, cost_class, init_soc): def test_sampling_spm(self, quick_sampler, spm_likelihood): prior1 = pybop.Uniform(0.4, 0.7) prior2 = pybop.Uniform(0.4, 0.7) - composed_prior = pybop.ComposedLogPrior(prior1, prior2) + composed_prior = pybop.JointLogPrior(prior1, prior2) posterior = pybop.LogPosterior(spm_likelihood, composed_prior) - x0 = [0.55, 0.55] sampler = quick_sampler( posterior, chains=3, - x0=x0, warm_up=50, max_iterations=400, ) diff --git a/tests/unit/test_priors.py b/tests/unit/test_priors.py index 80cd84062..22a317e8c 100644 --- a/tests/unit/test_priors.py +++ b/tests/unit/test_priors.py @@ -22,12 +22,12 @@ def Exponential(self): return pybop.Exponential(scale=1) @pytest.fixture - def ComposedPrior1(self, Gaussian, Uniform): - return pybop.ComposedLogPrior(Gaussian, Uniform) + def JointPrior1(self, Gaussian, Uniform): + return pybop.JointLogPrior(Gaussian, Uniform) @pytest.fixture - def ComposedPrior2(self, Gaussian, Exponential): - return pybop.ComposedLogPrior(Gaussian, Exponential) + def JointPrior2(self, Gaussian, Exponential): + return pybop.JointLogPrior(Gaussian, Exponential) @pytest.mark.unit def test_base_prior(self): @@ -35,9 +35,7 @@ def test_base_prior(self): assert isinstance(base, pybop.BasePrior) @pytest.mark.unit - def test_priors( - self, Gaussian, Uniform, Exponential, ComposedPrior1, ComposedPrior2 - ): + def test_priors(self, Gaussian, Uniform, Exponential, JointPrior1, JointPrior2): # 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) @@ -58,50 +56,50 @@ def test_priors( np.testing.assert_allclose(Uniform.cdf(0.5), 0.5, atol=1e-4) np.testing.assert_allclose(Exponential.cdf(1), 0.6321205588285577, atol=1e-4) - # Test evaluate + # Test __call__ assert Gaussian(0.5) == Gaussian.logpdf(0.5) assert Uniform(0.5) == Uniform.logpdf(0.5) assert Exponential(1) == Exponential.logpdf(1) - assert ComposedPrior1([0.5, 0.5]) == Gaussian.logpdf(0.5) + Uniform.logpdf(0.5) - assert ComposedPrior2([0.5, 1]) == Gaussian.logpdf(0.5) + Exponential.logpdf(1) + assert JointPrior1([0.5, 0.5]) == Gaussian.logpdf(0.5) + Uniform.logpdf(0.5) + assert JointPrior2([0.5, 1]) == Gaussian.logpdf(0.5) + Exponential.logpdf(1) - # Test Gaussian.evaluateS1 - p, dp = Gaussian.evaluateS1(0.5) + # Test Gaussian.logpdfS1 + p, dp = Gaussian.logpdfS1(0.5) assert p == Gaussian.logpdf(0.5) assert dp == 0.0 - # Test Uniform.evaluateS1 - p, dp = Uniform.evaluateS1(0.5) + # Test Uniform.logpdfS1 + p, dp = Uniform.logpdfS1(0.5) assert p == Uniform.logpdf(0.5) assert dp == 0.0 - # Test Exponential.evaluateS1 - p, dp = Exponential.evaluateS1(1) + # Test Exponential.logpdfS1 + p, dp = Exponential.logpdfS1(1) assert p == Exponential.logpdf(1) assert dp == Exponential.logpdf(1) - # Test ComposedPrior1.evaluateS1 - p, dp = ComposedPrior1.evaluateS1([0.5, 0.5]) + # Test JointPrior1.logpdfS1 + p, dp = JointPrior1.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 ComposedPrior.evaluateS1 - p, dp = ComposedPrior2.evaluateS1([0.5, 1]) + # Test JointPrior.logpdfS1 + p, dp = JointPrior2.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 ComposedPrior1 non-symmetric + # Test JointPrior1 non-symmetric with pytest.raises(AssertionError): np.testing.assert_allclose( - ComposedPrior1([0.4, 0.5]), ComposedPrior1([0.5, 0.4]), atol=1e-4 + JointPrior1([0.4, 0.5]), JointPrior1([0.5, 0.4]), atol=1e-4 ) - # Test ComposedPrior2 non-symmetric + # Test JointPrior2 non-symmetric with pytest.raises(AssertionError): np.testing.assert_allclose( - ComposedPrior2([0.4, 1]), ComposedPrior2([1, 0.4]), atol=1e-4 + JointPrior2([0.4, 1]), JointPrior2([1, 0.4]), atol=1e-4 ) # Test properties @@ -140,13 +138,13 @@ def test_exponential_rvs(self, Exponential): assert abs(mean - 1) < 0.2 @pytest.mark.unit - def test_repr(self, Gaussian, Uniform, Exponential, ComposedPrior1): + 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" assert repr(Exponential) == "Exponential, loc: 0, scale: 1" assert ( - repr(ComposedPrior1) - == "ComposedLogPrior, priors: (Gaussian, loc: 0.5, scale: 1, Uniform, loc: 0, scale: 1)" + repr(JointPrior1) + == "JointLogPrior, priors: (Gaussian, loc: 0.5, scale: 1, Uniform, loc: 0, scale: 1)" ) @pytest.mark.unit @@ -163,8 +161,8 @@ def test_incorrect_composed_priors(self, Gaussian, Uniform): with pytest.raises( ValueError, match="All priors must be instances of BasePrior" ): - pybop.ComposedLogPrior(Gaussian, Uniform, "string") + pybop.JointLogPrior(Gaussian, Uniform, "string") with pytest.raises( ValueError, match="All priors must be instances of BasePrior" ): - pybop.ComposedLogPrior(Gaussian, Uniform, 0.5) + pybop.JointLogPrior(Gaussian, Uniform, 0.5) diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 355b370d1..9865f472d 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -71,7 +71,7 @@ def log_posterior(self, model, parameters, dataset): likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=0.01) prior1 = pybop.Gaussian(0.7, 0.02) prior2 = pybop.Gaussian(0.6, 0.02) - composed_prior = pybop.ComposedLogPrior(prior1, prior2) + composed_prior = pybop.JointLogPrior(prior1, prior2) log_posterior = pybop.LogPosterior(likelihood, composed_prior) return log_posterior From 08dc40778cd19be7a00c24cdfdf2f87766c89c9d Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Tue, 13 Aug 2024 15:21:46 +0100 Subject: [PATCH 23/31] fix: update tests for low convergence sampler --- examples/scripts/mcmc_example.py | 8 +++--- tests/integration/test_monte_carlo.py | 35 ++++++++++++++------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index e73cc0d4a..3c7b4825d 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -73,11 +73,11 @@ def noise(sigma): optim = pybop.DREAM( posterior, - chains=6, - max_iterations=4000, - burn_in=2000, + chains=3, + max_iterations=300, + burn_in=100, verbose=True, - parallel=True, # uncomment to enable parallelisation (MacOS/WSL/Linux only) + # parallel=True, # uncomment to enable parallelisation (MacOS/WSL/Linux only) ) result = optim.run() diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index b3117b717..12262de15 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -82,7 +82,7 @@ def noise(self, sigma, values): @pytest.fixture def spm_likelihood(self, model, parameters, cost_class, init_soc): # Form dataset - solution = self.get_data(model, self.ground_truth, init_soc) + solution = self.get_data(model, init_soc) dataset = pybop.Dataset( { "Time [s]": solution["Time [s]"].data, @@ -132,12 +132,20 @@ def test_sampling_spm(self, quick_sampler, spm_likelihood): prior2 = pybop.Uniform(0.4, 0.7) composed_prior = pybop.JointLogPrior(prior1, prior2) posterior = pybop.LogPosterior(spm_likelihood, composed_prior) - sampler = quick_sampler( - posterior, - chains=3, - warm_up=50, - max_iterations=400, - ) + + # set common args + common_args = { + "log_pdf": posterior, + "chains": 3, + "warm_up": 50, + "max_iterations": 400, + } + if issubclass(quick_sampler, pybop.DramACMC): + common_args["warm_up"] = 200 + common_args["max_iterations"] = 650 + + # construct and run + sampler = quick_sampler(**common_args) results = sampler.run() # compute mean of posterior and assert @@ -145,13 +153,8 @@ def test_sampling_spm(self, quick_sampler, spm_likelihood): for i in range(len(x)): np.testing.assert_allclose(x[i], self.ground_truth, atol=2.5e-2) - def get_data(self, model, x, init_soc): - model.parameter_set.update( - { - "Negative electrode active material volume fraction": x[0], - "Positive electrode active material volume fraction": x[1], - } - ) + def get_data(self, model, init_soc): + initial_state = {"Initial SoC": init_soc} experiment = pybop.Experiment( [ ( @@ -160,7 +163,5 @@ def get_data(self, model, x, init_soc): ), ] ) - sim = model.predict( - initial_state={"Initial SoC": init_soc}, experiment=experiment - ) + sim = model.predict(initial_state=initial_state, experiment=experiment) return sim From c225065c2c46db32c07a7424a2c50500db3438ec Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 14 Aug 2024 08:50:28 +0100 Subject: [PATCH 24/31] refactor: update priors, refactor JointLogPrior --- pybop/parameters/priors.py | 68 ++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index 1f3edd68b..d3973f4b6 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -1,3 +1,5 @@ +from typing import Union + import numpy as np import scipy.stats as stats @@ -229,6 +231,7 @@ class Gaussian(BasePrior): """ def __init__(self, mean, sigma, random_state=None): + super().__init__() self.name = "Gaussian" self.loc = mean self.scale = sigma @@ -273,6 +276,7 @@ class Uniform(BasePrior): """ def __init__(self, lower, upper, random_state=None): + super().__init__() self.name = "Uniform" self.lower = lower self.upper = upper @@ -328,6 +332,7 @@ class Exponential(BasePrior): """ def __init__(self, scale, loc=0, random_state=None): + super().__init__() self.name = "Exponential" self.loc = loc self.scale = scale @@ -355,51 +360,65 @@ def _logpdfS1(self, x): class JointLogPrior(BasePrior): """ - Represents a joint prior distributions. + Represents a joint prior distribution composed of multiple prior distributions. + + Parameters + ---------- + priors : BasePrior + One or more prior distributions to combine into a joint distribution. """ - def __init__(self, *priors): - self._priors = priors - for prior in priors: - if not isinstance(prior, BasePrior): - raise ValueError("All priors must be instances of BasePrior") + def __init__(self, *priors: BasePrior): + super().__init__() - self._n_parameters = len(priors) # Needs to be updated + if not all(isinstance(prior, BasePrior) for prior in priors): + raise ValueError("All priors must be instances of BasePrior") - def logpdf(self, x): + self._n_parameters = len(priors) + self._priors: list[BasePrior] = list(priors) + + def logpdf(self, x: Union[float, np.ndarray]) -> float: """ - Evaluates the composed prior distribution at x. + Evaluates the joint log-prior distribution at a given point. Parameters ---------- - x : float - The point(s) at which to evaluate the distribution. + x : Union[float, np.ndarray] + The point(s) at which to evaluate the distribution. The length of `x` + should match the total number of parameters in the joint distribution. Returns ------- float - The value(s) of the distribution at x. + The joint log-probability density of the distribution at `x`. """ + if len(x) != self._n_parameters: + raise ValueError( + f"Input x must have length {self._n_parameters}, got {len(x)}" + ) + return sum(prior(x) for prior, x in zip(self._priors, x)) - def _logpdfS1(self, x): + def _logpdfS1(self, x: Union[float, np.ndarray]) -> tuple[float, np.ndarray]: """ - Evaluates the first derivative of the composed prior distribution at x. - Inspired by PINTS implementation. - - *This method only works if the underlying :class:`LogPrior` classes all - implement the optional method :class:`LogPDF.logpdfS1().`.* + Evaluates the first derivative of the joint log-prior distribution at a given point. Parameters ---------- - x : float - The point(s) at which to evaluate the first derivative. + x : Union[float, np.ndarray] + The point(s) at which to evaluate the first derivative. The length of `x` + should match the total number of parameters in the joint distribution. Returns ------- - float - The value(s) of the first derivative at x. + Tuple[float, np.ndarray] + A tuple containing the log-probability density and its first derivative at `x`. """ + if len(x) != self._n_parameters: + raise ValueError( + f"Input x must have length {self._n_parameters}, got {len(x)}" + ) + output = 0 doutput = np.zeros(self._n_parameters) index = 0 @@ -414,5 +433,6 @@ def _logpdfS1(self, x): return output, doutput - def __repr__(self): - return f"{self.__class__.__name__}, priors: {self._priors}" + def __repr__(self) -> str: + priors_repr = ", ".join([repr(prior) for prior in self._priors]) + return f"{self.__class__.__name__}(priors=[{priors_repr}])" From 4df088596496d786640ea757e530938ec72b8494 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 14 Aug 2024 09:53:15 +0100 Subject: [PATCH 25/31] tests: update unit tests and increase coverage. --- pybop/parameters/priors.py | 4 +--- tests/unit/test_priors.py | 4 +++- tests/unit/test_sampling.py | 38 ++++++++++++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/pybop/parameters/priors.py b/pybop/parameters/priors.py index d3973f4b6..9e68cf008 100644 --- a/pybop/parameters/priors.py +++ b/pybop/parameters/priors.py @@ -255,8 +255,6 @@ def _logpdfS1(self, x): float The value(s) of the first derivative at x. """ - if not isinstance(x, np.ndarray): - x = np.asarray(x) return self(x), -(x - self.loc) * self._multip @@ -435,4 +433,4 @@ def _logpdfS1(self, x: Union[float, np.ndarray]) -> tuple[float, np.ndarray]: def __repr__(self) -> str: priors_repr = ", ".join([repr(prior) for prior in self._priors]) - return f"{self.__class__.__name__}(priors=[{priors_repr}])" + return f"{self.__class__.__name__}(priors: [{priors_repr}])" diff --git a/tests/unit/test_priors.py b/tests/unit/test_priors.py index 22a317e8c..3e8b8a3bb 100644 --- a/tests/unit/test_priors.py +++ b/tests/unit/test_priors.py @@ -33,6 +33,8 @@ def JointPrior2(self, Gaussian, Exponential): def test_base_prior(self): base = pybop.BasePrior() assert isinstance(base, pybop.BasePrior) + with pytest.raises(NotImplementedError): + base._logpdfS1(0.0) @pytest.mark.unit def test_priors(self, Gaussian, Uniform, Exponential, JointPrior1, JointPrior2): @@ -144,7 +146,7 @@ def test_repr(self, Gaussian, Uniform, Exponential, JointPrior1): assert repr(Exponential) == "Exponential, loc: 0, scale: 1" assert ( repr(JointPrior1) - == "JointLogPrior, priors: (Gaussian, loc: 0.5, scale: 1, Uniform, loc: 0, scale: 1)" + == "JointLogPrior(priors: [Gaussian, loc: 0.5, scale: 1, Uniform, loc: 0, scale: 1])" ) @pytest.mark.unit diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 9865f472d..86a1744b9 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -1,5 +1,6 @@ import copy -from unittest.mock import patch +import logging +from unittest.mock import call, patch import numpy as np import pytest @@ -229,17 +230,44 @@ def test_no_chains_in_memory(self, log_posterior, x0, chains): samples = sampler.run() assert samples is None + @patch("logging.basicConfig") + @patch("logging.info") @pytest.mark.unit - def test_logging_initialisation(self, log_posterior, x0, chains): + def test_initialise_logging( + self, mock_info, mock_basicConfig, log_posterior, x0, chains + ): sampler = AdaptiveCovarianceMCMC( log_pdf=log_posterior, chains=chains, x0=x0, + parallel=True, + evaluation_files=["eval1.txt", "eval2.txt"], + chain_files=["chain1.txt", "chain2.txt"], + ) + + # Set parallel workers + sampler.set_parallel(parallel=2) + sampler._initialise_logging() + + # Check if basicConfig was called with correct arguments + mock_basicConfig.assert_called_once_with( + format="%(message)s", level=logging.INFO ) - with patch("logging.basicConfig"), patch("logging.info") as mock_info: - sampler._initialise_logging() - assert mock_info.call_count > 0 + # Check if correct messages were called + expected_calls = [ + call("Using Haario-Bardenet adaptive covariance MCMC"), + call("Generating 3 chains."), + call("Running in parallel with 2 worker processes."), + call("Writing chains to chain1.txt etc."), + call("Writing evaluations to eval1.txt etc."), + ] + mock_info.assert_has_calls(expected_calls, any_order=False) + + # Test when _log_to_screen is False + sampler._log_to_screen = False + sampler._initialise_logging() + assert mock_info.call_count == len(expected_calls) # No additional calls @pytest.mark.unit def test_check_stopping_criteria(self, log_posterior, x0, chains): From e50812a98e4697d06e8ef5c5cddd4ec6f4f014e2 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 14 Aug 2024 10:42:38 +0100 Subject: [PATCH 26/31] refactor: base_sampler init, update docstrings, update tests, remove potential thread deadlock in optimisation_options test --- pybop/samplers/base_pints_sampler.py | 3 +- pybop/samplers/base_sampler.py | 32 ++++++++++------- pybop/samplers/pints_samplers.py | 36 +++++++++---------- .../integration/test_optimisation_options.py | 6 ---- tests/unit/test_priors.py | 7 ++++ tests/unit/test_sampling.py | 15 ++++++++ 6 files changed, 60 insertions(+), 39 deletions(-) diff --git a/pybop/samplers/base_pints_sampler.py b/pybop/samplers/base_pints_sampler.py index f4104117c..058bc6d91 100644 --- a/pybop/samplers/base_pints_sampler.py +++ b/pybop/samplers/base_pints_sampler.py @@ -35,12 +35,11 @@ def __init__( Initialise the base PINTS sampler. Args: - log_pdf (pybop.BaseCost or List[pybop.BaseCost]): The distribution(s) to be sampled. + log_pdf (pybop.LogPosterior or List[pybop.LogPosterior]): The distribution(s) to be sampled. chains (int): Number of chains to be used. sampler: The sampler class to be used. x0 (list): Initial states for the chains. cov0: Initial standard deviation for the chains. - transformation: Transformation to be applied to the samples. kwargs: Additional keyword arguments. """ super().__init__(log_pdf, x0, cov0) diff --git a/pybop/samplers/base_sampler.py b/pybop/samplers/base_sampler.py index 52bdc2082..6249dceab 100644 --- a/pybop/samplers/base_sampler.py +++ b/pybop/samplers/base_sampler.py @@ -18,24 +18,30 @@ def __init__(self, log_pdf: LogPosterior, x0, cov0: Union[np.ndarray, float]): Parameters ---------------- - log_pdf (pybop.LogPosterior): The posterior or PDF to be sampled. + log_pdf (pybop.LogPosterior or List[pybop.LogPosterior]): The posterior or PDF to be sampled. x0: List-like initial condition for Monte Carlo sampling. cov0: The covariance matrix to be sampled. """ self._log_pdf = log_pdf - self._x0 = x0 self._cov0 = cov0 - self.parameters = Parameters() - if isinstance(log_pdf, LogPosterior): - self.parameters = log_pdf.parameters - - if x0 is None: - self._x0 = self.parameters.initial_value() - elif not isinstance(x0, np.ndarray): - try: - self._x0 = np.asarray(x0) - except ValueError as e: - raise ValueError(f"Error initialising x0: {e}") from e + + # Set up parameters based on log_pdf + self.parameters = ( + log_pdf.parameters if isinstance(log_pdf, LogPosterior) else Parameters() + ) + + # Initialize x0 + self._x0 = ( + self.parameters.initial_value() + if x0 is None + else np.asarray(x0, dtype=float) + ) + + # Validate x0 shape + if self._x0.ndim == 0: + raise ValueError( + f"x0 must be an array-like structure, but got a scalar: {x0}" + ) def run(self) -> np.ndarray: """ diff --git a/pybop/samplers/pints_samplers.py b/pybop/samplers/pints_samplers.py index 901d7a2a2..8658243af 100644 --- a/pybop/samplers/pints_samplers.py +++ b/pybop/samplers/pints_samplers.py @@ -32,7 +32,7 @@ class NUTS(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -60,7 +60,7 @@ class DREAM(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -86,7 +86,7 @@ class AdaptiveCovarianceMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -119,7 +119,7 @@ class DifferentialEvolutionMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -153,7 +153,7 @@ class DramACMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -186,7 +186,7 @@ class EmceeHammerMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -219,7 +219,7 @@ class HaarioACMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -252,7 +252,7 @@ class HaarioBardenetACMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -285,7 +285,7 @@ class HamiltonianMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -318,7 +318,7 @@ class MALAMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -351,7 +351,7 @@ class MetropolisRandomWalkMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -384,7 +384,7 @@ class MonomialGammaHamiltonianMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -417,7 +417,7 @@ class PopulationMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -450,7 +450,7 @@ class RaoBlackwellACMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -483,7 +483,7 @@ class RelativisticMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -516,7 +516,7 @@ class SliceDoublingMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -549,7 +549,7 @@ class SliceRankShrinkingMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. @@ -582,7 +582,7 @@ class SliceStepoutMCMC(BasePintsSampler): Parameters ---------- - log_pdf : pybop.BaseCost + log_pdf : (pybop.LogPosterior or List[pybop.LogPosterior]) A function that calculates the log-probability density. chains : int The number of chains to run. diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index b2feb7629..17fd86e24 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -1,5 +1,3 @@ -import sys - import numpy as np import pytest @@ -98,10 +96,6 @@ def test_optimisation_f_guessed(self, f_guessed, spm_costs): use_f_guessed=f_guessed, ) - # Set parallelisation if not on Windows - if sys.platform != "win32": - optim.set_parallel(True) - initial_cost = optim.cost(x0) x, final_cost = optim.run() diff --git a/tests/unit/test_priors.py b/tests/unit/test_priors.py index 3e8b8a3bb..3e9184374 100644 --- a/tests/unit/test_priors.py +++ b/tests/unit/test_priors.py @@ -104,6 +104,13 @@ def test_priors(self, Gaussian, Uniform, Exponential, JointPrior1, JointPrior2): JointPrior2([0.4, 1]), JointPrior2([1, 0.4]), atol=1e-4 ) + # Test JointPrior with incorrect dimensions + with pytest.raises(ValueError, match="Input x must have length 2, got 1"): + JointPrior1([0.4]) + + with pytest.raises(ValueError, match="Input x must have length 2, got 1"): + JointPrior1.logpdfS1([0.4]) + # Test properties assert Uniform.mean == (Uniform.upper - Uniform.lower) / 2 np.testing.assert_allclose( diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 86a1744b9..74f2ce467 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -4,6 +4,7 @@ import numpy as np import pytest +from pints import ParallelEvaluator import pybop from pybop import ( @@ -215,6 +216,15 @@ def test_invalid_initialization(self, log_posterior, x0): x0=x0, ) + with pytest.raises( + ValueError, match="x0 must have the same number of parameters as log_pdf" + ): + AdaptiveCovarianceMCMC( + log_pdf=[log_posterior, log_posterior, log_posterior], + chains=3, + x0=[0.4, 0.4, 0.4, 0.4], + ) + @pytest.mark.unit def test_no_chains_in_memory(self, log_posterior, x0, chains): sampler = AdaptiveCovarianceMCMC( @@ -315,6 +325,11 @@ def test_set_parallel(self, log_posterior, x0, chains): assert sampler._parallel is True assert sampler._n_workers == 2 + # Test evaluator construction + sampler.set_parallel(2) + evaluator = sampler._create_evaluator() + assert isinstance(evaluator, ParallelEvaluator) + @pytest.mark.unit def test_base_sampler(self, log_posterior, x0): sampler = pybop.BaseSampler(log_posterior, x0, cov0=0.1) From 7a000cf4d3ad6ac8dd2d7f5d9f259f7c32fb5d9e Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 14 Aug 2024 14:57:40 +0100 Subject: [PATCH 27/31] tests: increase coverage, remove redundant ValueError, sampler.chains now optional arg with default=1 --- pybop/samplers/base_pints_sampler.py | 17 +++++-------- pybop/samplers/base_sampler.py | 6 ----- pybop/samplers/pints_samplers.py | 38 +++++++++++++++------------- tests/unit/test_sampling.py | 4 +-- 4 files changed, 29 insertions(+), 36 deletions(-) diff --git a/pybop/samplers/base_pints_sampler.py b/pybop/samplers/base_pints_sampler.py index 058bc6d91..b1d3e96e4 100644 --- a/pybop/samplers/base_pints_sampler.py +++ b/pybop/samplers/base_pints_sampler.py @@ -24,8 +24,8 @@ class BasePintsSampler(BaseSampler): def __init__( self, log_pdf: LogPosterior, - chains: int, sampler, + chains: int = 1, warm_up=None, x0=None, cov0=0.1, @@ -95,15 +95,12 @@ def __init__( self._single_chain = issubclass(sampler, SingleChainMCMC) # Construct the samplers object - try: - if self._single_chain: - self._n_samplers = self._n_chains - self._samplers = [sampler(x0, sigma0=self._cov0) for x0 in self._x0] - else: - self._n_samplers = 1 - self._samplers = [sampler(self._n_chains, self._x0, self._cov0)] - except Exception as e: - raise ValueError(f"Error constructing samplers: {e}") from e + if self._single_chain: + self._n_samplers = self._n_chains + self._samplers = [sampler(x0, sigma0=self._cov0) for x0 in self._x0] + else: + self._n_samplers = 1 + self._samplers = [sampler(self._n_chains, self._x0, self._cov0)] # Check for sensitivities from sampler and set evaluation self._needs_sensitivities = self._samplers[0].needs_sensitivities() diff --git a/pybop/samplers/base_sampler.py b/pybop/samplers/base_sampler.py index 6249dceab..c0bee9430 100644 --- a/pybop/samplers/base_sampler.py +++ b/pybop/samplers/base_sampler.py @@ -37,12 +37,6 @@ def __init__(self, log_pdf: LogPosterior, x0, cov0: Union[np.ndarray, float]): else np.asarray(x0, dtype=float) ) - # Validate x0 shape - if self._x0.ndim == 0: - raise ValueError( - f"x0 must be an array-like structure, but got a scalar: {x0}" - ) - def run(self) -> np.ndarray: """ Sample from the posterior distribution. diff --git a/pybop/samplers/pints_samplers.py b/pybop/samplers/pints_samplers.py index 8658243af..481a5eaeb 100644 --- a/pybop/samplers/pints_samplers.py +++ b/pybop/samplers/pints_samplers.py @@ -45,7 +45,9 @@ class NUTS(BasePintsSampler): """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): - super().__init__(log_pdf, chains, NoUTurnMCMC, x0=x0, cov0=cov0, **kwargs) + super().__init__( + log_pdf, NoUTurnMCMC, chains=chains, x0=x0, cov0=cov0, **kwargs + ) class DREAM(BasePintsSampler): @@ -73,7 +75,7 @@ class DREAM(BasePintsSampler): """ def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): - super().__init__(log_pdf, chains, PintsDREAM, x0=x0, cov0=cov0, **kwargs) + super().__init__(log_pdf, PintsDREAM, chains=chains, x0=x0, cov0=cov0, **kwargs) class AdaptiveCovarianceMCMC(BasePintsSampler): @@ -101,8 +103,8 @@ class AdaptiveCovarianceMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsAdaptiveCovarianceMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -134,8 +136,8 @@ class DifferentialEvolutionMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsDifferentialEvolutionMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -168,8 +170,8 @@ class DramACMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsDramACMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -201,8 +203,8 @@ class EmceeHammerMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsEmceeHammerMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -234,8 +236,8 @@ class HaarioACMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsHaarioACMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -267,8 +269,8 @@ class HaarioBardenetACMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsHaarioBardenetACMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -300,8 +302,8 @@ class HamiltonianMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsHamiltonianMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -333,8 +335,8 @@ class MALAMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsMALAMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -366,8 +368,8 @@ class MetropolisRandomWalkMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsMetropolisRandomWalkMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -399,8 +401,8 @@ class MonomialGammaHamiltonianMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsMonomialGammaHamiltonianMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -432,8 +434,8 @@ class PopulationMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsPopulationMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -465,8 +467,8 @@ class RaoBlackwellACMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsRaoBlackwellACMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -498,8 +500,8 @@ class RelativisticMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsRelativisticMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -531,8 +533,8 @@ class SliceDoublingMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsSliceDoublingMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -564,8 +566,8 @@ class SliceRankShrinkingMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsSliceRankShrinkingMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, @@ -597,8 +599,8 @@ class SliceStepoutMCMC(BasePintsSampler): def __init__(self, log_pdf, chains, x0=None, cov0=None, **kwargs): super().__init__( log_pdf, - chains, PintsSliceStepoutMCMC, + chains=chains, x0=x0, cov0=cov0, **kwargs, diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 74f2ce467..b73cf7fbc 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -114,7 +114,7 @@ def multi_samplers(self): ], ) @pytest.mark.unit - def test_initialization_and_run( + def test_initialisation_and_run( self, log_posterior, x0, chains, MCMC, multi_samplers ): sampler = pybop.MCMCSampler( @@ -199,7 +199,7 @@ def test_multi_log_pdf(self, log_posterior, x0, chains): ) @pytest.mark.unit - def test_invalid_initialization(self, log_posterior, x0): + def test_invalid_initialisation(self, log_posterior, x0): with pytest.raises(ValueError, match="Number of chains must be greater than 0"): AdaptiveCovarianceMCMC( log_pdf=log_posterior, From 711dcc89fb5db80b6fb893c8cd0211032a1bf4fa Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Wed, 14 Aug 2024 15:38:57 +0100 Subject: [PATCH 28/31] tests: restore parallel optimisation with thread limit to 1 --- tests/integration/test_optimisation_options.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integration/test_optimisation_options.py b/tests/integration/test_optimisation_options.py index 17fd86e24..258bdc17b 100644 --- a/tests/integration/test_optimisation_options.py +++ b/tests/integration/test_optimisation_options.py @@ -1,3 +1,5 @@ +import sys + import numpy as np import pytest @@ -96,6 +98,10 @@ def test_optimisation_f_guessed(self, f_guessed, spm_costs): use_f_guessed=f_guessed, ) + # Set parallelisation if not on Windows + if sys.platform != "win32": + optim.set_parallel(1) + initial_cost = optim.cost(x0) x, final_cost = optim.run() From 503af199ab8de8da8ec977769feadd3cb9bd5a4d Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Thu, 29 Aug 2024 22:23:48 +0100 Subject: [PATCH 29/31] Refactor and bugfixes. Adds gradient-based integration sampling tests via thevenin. --- examples/scripts/mcmc_example.py | 27 ++-- pybop/costs/_likelihoods.py | 14 +- pybop/parameters/parameter.py | 4 +- pybop/samplers/base_pints_sampler.py | 2 + pybop/samplers/mcmc_sampler.py | 7 +- tests/integration/test_monte_carlo.py | 61 ++------ .../integration/test_monte_carlo_thevenin.py | 146 ++++++++++++++++++ tests/unit/test_posterior.py | 8 +- tests/unit/test_sampling.py | 29 +++- 9 files changed, 215 insertions(+), 83 deletions(-) create mode 100644 tests/integration/test_monte_carlo_thevenin.py diff --git a/examples/scripts/mcmc_example.py b/examples/scripts/mcmc_example.py index 3c7b4825d..436886d00 100644 --- a/examples/scripts/mcmc_example.py +++ b/examples/scripts/mcmc_example.py @@ -24,24 +24,22 @@ ), pybop.Parameter( "Positive electrode active material volume fraction", - prior=pybop.Gaussian(0.68, 0.02), + prior=pybop.Gaussian(0.65, 0.02), transformation=pybop.LogTransformation(), ), ) # Generate data -init_soc = 1.0 -sigma = 0.001 +init_soc = 0.5 +sigma = 0.002 experiment = pybop.Experiment( [ - ( - "Discharge at 0.5C until 3.5V (10 second period)", - "Charge at 0.5C until 4.0V (10 second period)", - ), + ("Discharge at 0.5C for 6 minutes (5 second period)",), ] - # * 2 ) -values = synth_model.predict(initial_state={"Initial SoC": 1.0}, experiment=experiment) +values = synth_model.predict( + initial_state={"Initial SoC": init_soc}, experiment=experiment +) def noise(sigma): @@ -60,22 +58,19 @@ def noise(sigma): ) model = pybop.lithium_ion.SPM(parameter_set=parameter_set, solver=pybamm.IDAKLUSolver()) -model.build(initial_state={"Initial SoC": 1.0}) +model.build(initial_state={"Initial SoC": init_soc}) signal = ["Voltage [V]", "Bulk open-circuit voltage [V]"] # Generate problem, likelihood, and sampler problem = pybop.FittingProblem(model, parameters, dataset, signal=signal) likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=0.002) -prior1 = pybop.Gaussian(0.59, 0.05) -prior2 = pybop.Gaussian(0.65, 0.05) -composed_prior = pybop.JointLogPrior(prior1, prior2) -posterior = pybop.LogPosterior(likelihood, composed_prior) +posterior = pybop.LogPosterior(likelihood) -optim = pybop.DREAM( +optim = pybop.DifferentialEvolutionMCMC( posterior, chains=3, max_iterations=300, - burn_in=100, + warm_up=100, verbose=True, # parallel=True, # uncomment to enable parallelisation (MacOS/WSL/Linux only) ) diff --git a/pybop/costs/_likelihoods.py b/pybop/costs/_likelihoods.py index 071d847bd..6da737e5f 100644 --- a/pybop/costs/_likelihoods.py +++ b/pybop/costs/_likelihoods.py @@ -4,7 +4,7 @@ from pybop.costs.base_cost import BaseCost from pybop.parameters.parameter import Parameter, Parameters -from pybop.parameters.priors import Uniform +from pybop.parameters.priors import JointLogPrior, Uniform from pybop.problems.base_problem import BaseProblem @@ -307,14 +307,10 @@ def __init__(self, log_likelihood, log_prior=None): # Store the likelihood and prior self._log_likelihood = log_likelihood - self._prior = log_prior - if self._prior is None: - try: - self._prior = log_likelihood.problem.parameters.priors() - except Exception as e: - raise ValueError( - f"An error occurred when constructing the Prior class: {e}" - ) from e + if log_prior is None: + self._prior = JointLogPrior(*log_likelihood.problem.parameters.priors()) + else: + self._prior = log_prior def compute( self, diff --git a/pybop/parameters/parameter.py b/pybop/parameters/parameter.py index e53eafa42..370abc276 100644 --- a/pybop/parameters/parameter.py +++ b/pybop/parameters/parameter.py @@ -549,7 +549,9 @@ def verify(self, inputs: Optional[Inputs] = None): """ if inputs is None or isinstance(inputs, dict): return inputs - elif (isinstance(inputs, list) and all(is_numeric(x) for x in inputs)) or all( + if isinstance(inputs, np.ndarray) and inputs.ndim == 0: + inputs = inputs[np.newaxis] + if (isinstance(inputs, list) and all(is_numeric(x) for x in inputs)) or all( is_numeric(x) for x in list(inputs) ): return self.as_dict(inputs) diff --git a/pybop/samplers/base_pints_sampler.py b/pybop/samplers/base_pints_sampler.py index b0591edfb..844f38078 100644 --- a/pybop/samplers/base_pints_sampler.py +++ b/pybop/samplers/base_pints_sampler.py @@ -202,6 +202,8 @@ def _process_single_chain(self): reply = self._samplers[i].tell(next(self.fxs_iterator)) if reply: y, fy, accepted = reply + if y.ndim == 0: + y = y[np.newaxis] y_store = self._inverse_transform( y, self._log_pdf[i] if self._multi_log_pdf else self._log_pdf ) diff --git a/pybop/samplers/mcmc_sampler.py b/pybop/samplers/mcmc_sampler.py index 9e991761b..933c36957 100644 --- a/pybop/samplers/mcmc_sampler.py +++ b/pybop/samplers/mcmc_sampler.py @@ -43,12 +43,7 @@ def __init__( If the sampler could not be constructed due to an exception. """ - try: - self.sampler = sampler(log_pdf, chains, x0=x0, sigma0=cov0, **kwargs) - except Exception as e: - raise ValueError( - f"Sampler could not be constructed, raised an exception: {e}" - ) from e + self.sampler = sampler(log_pdf, chains, x0=x0, sigma0=cov0, **kwargs) def run(self): """ diff --git a/tests/integration/test_monte_carlo.py b/tests/integration/test_monte_carlo.py index 12262de15..6d2a51219 100644 --- a/tests/integration/test_monte_carlo.py +++ b/tests/integration/test_monte_carlo.py @@ -6,34 +6,24 @@ from pybop import ( DREAM, DifferentialEvolutionMCMC, - DramACMC, HaarioACMC, HaarioBardenetACMC, MetropolisRandomWalkMCMC, PopulationMCMC, - RaoBlackwellACMC, - SliceDoublingMCMC, - SliceStepoutMCMC, - # Grad samplers - # NUTS, - # HamiltonianMCMC, - # MonomialGammaHamiltonianMCMC, - # RelativisticMCMC, - # SliceRankShrinkingMCMC, - # EmceeHammerMCMC, - # MALAMCMC, ) class Test_Sampling_SPM: """ - A class to test the model parameterisation methods. + A class to test the MCMC samplers on a physics-based model. """ @pytest.fixture(autouse=True) def setup(self): - self.ground_truth = np.array([0.55, 0.55]) + np.random.normal( - loc=0.0, scale=0.05, size=2 + self.ground_truth = np.clip( + np.asarray([0.55, 0.55]) + np.random.normal(loc=0.0, scale=0.05, size=2), + a_min=0.4, + a_max=0.75, ) @pytest.fixture @@ -101,65 +91,44 @@ def spm_likelihood(self, model, parameters, cost_class, init_soc): [ DREAM, DifferentialEvolutionMCMC, - DramACMC, HaarioACMC, HaarioBardenetACMC, MetropolisRandomWalkMCMC, PopulationMCMC, - RaoBlackwellACMC, - SliceDoublingMCMC, - SliceStepoutMCMC, ], ) - # Samplers that either have along runtime, or converge slowly - # Need to assess how to perform integration tests with these samplers - # @pytest.mark.parametrize( - # "gradient_sampler", - # [ - # NUTS, - # HamiltonianMCMC, - # MonomialGammaHamiltonianMCMC, - # RelativisticMCMC, - # SliceRankShrinkingMCMC, - # EmceeHammerMCMC, - # MALAMCMC, - # ], - # ) - @pytest.mark.integration def test_sampling_spm(self, quick_sampler, spm_likelihood): - prior1 = pybop.Uniform(0.4, 0.7) - prior2 = pybop.Uniform(0.4, 0.7) - composed_prior = pybop.JointLogPrior(prior1, prior2) - posterior = pybop.LogPosterior(spm_likelihood, composed_prior) + posterior = pybop.LogPosterior(spm_likelihood) # set common args common_args = { "log_pdf": posterior, "chains": 3, - "warm_up": 50, - "max_iterations": 400, + "warm_up": 250, + "max_iterations": 550, } - if issubclass(quick_sampler, pybop.DramACMC): - common_args["warm_up"] = 200 - common_args["max_iterations"] = 650 + if issubclass(quick_sampler, DifferentialEvolutionMCMC): + common_args["warm_up"] = 750 + common_args["max_iterations"] = 900 # construct and run sampler = quick_sampler(**common_args) results = sampler.run() - # compute mean of posterior and assert + # Assert both final sample and posterior mean x = np.mean(results, axis=1) for i in range(len(x)): np.testing.assert_allclose(x[i], self.ground_truth, atol=2.5e-2) + np.testing.assert_allclose(results[i][-1], self.ground_truth, atol=2.0e-2) def get_data(self, model, init_soc): initial_state = {"Initial SoC": init_soc} experiment = pybop.Experiment( [ ( - "Discharge at 0.5C for 6 minutes (12 second period)", - "Charge at 0.5C for 6 minutes (12 second period)", + "Discharge at 0.5C for 4 minutes (12 second period)", + "Charge at 0.5C for 4 minutes (12 second period)", ), ] ) diff --git a/tests/integration/test_monte_carlo_thevenin.py b/tests/integration/test_monte_carlo_thevenin.py new file mode 100644 index 000000000..a76d9933f --- /dev/null +++ b/tests/integration/test_monte_carlo_thevenin.py @@ -0,0 +1,146 @@ +import numpy as np +import pytest + +import pybop +from pybop import ( + MALAMCMC, + NUTS, + DramACMC, + HamiltonianMCMC, + MonomialGammaHamiltonianMCMC, + RaoBlackwellACMC, + RelativisticMCMC, + SliceDoublingMCMC, + SliceRankShrinkingMCMC, + SliceStepoutMCMC, +) + + +class TestSamplingThevenin: + """ + A class to test a subset of samplers on the simple Thevenin Model. + """ + + @pytest.fixture(autouse=True) + def setup(self): + self.sigma0 = 1e-3 + self.ground_truth = np.clip( + np.asarray([0.05, 0.05]) + np.random.normal(loc=0.0, scale=0.01, size=2), + a_min=0.0, + a_max=0.1, + ) + self.fast_samplers = [ + MALAMCMC, + RaoBlackwellACMC, + SliceDoublingMCMC, + SliceStepoutMCMC, + DramACMC, + ] + + @pytest.fixture + def model(self): + parameter_set = pybop.ParameterSet( + json_path="examples/scripts/parameters/initial_ecm_parameters.json" + ) + parameter_set.import_parameters() + parameter_set.params.update( + { + "C1 [F]": 1000, + "R0 [Ohm]": self.ground_truth[0], + "R1 [Ohm]": self.ground_truth[1], + } + ) + + return pybop.empirical.Thevenin(parameter_set=parameter_set) + + @pytest.fixture + def parameters(self): + return pybop.Parameters( + pybop.Parameter( + "R0 [Ohm]", prior=pybop.Uniform(1e-2, 8e-2), bounds=[1e-2, 8e-2] + ), + pybop.Parameter( + "R1 [Ohm]", prior=pybop.Uniform(1e-2, 8e-2), bounds=[1e-2, 8e-2] + ), + ) + + @pytest.fixture(params=[0.5]) + def init_soc(self, request): + return request.param + + def noise(self, sigma, values): + return np.random.normal(0, sigma, values) + + @pytest.fixture + def likelihood(self, model, parameters, init_soc): + # Form dataset + solution = self.get_data(model, init_soc) + dataset = pybop.Dataset( + { + "Time [s]": solution["Time [s]"].data, + "Current function [A]": solution["Current [A]"].data, + "Voltage [V]": solution["Voltage [V]"].data + + self.noise(self.sigma0, len(solution["Time [s]"].data)), + } + ) + + # Define the cost to optimise + problem = pybop.FittingProblem(model, parameters, dataset) + return pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=0.0075) + + # Parameterize the samplers + @pytest.mark.parametrize( + "sampler", + [ + NUTS, + HamiltonianMCMC, + MonomialGammaHamiltonianMCMC, + RelativisticMCMC, + SliceRankShrinkingMCMC, + MALAMCMC, + RaoBlackwellACMC, + SliceDoublingMCMC, + SliceStepoutMCMC, + DramACMC, + ], + ) + @pytest.mark.integration + def test_sampling_thevenin(self, sampler, likelihood): + posterior = pybop.LogPosterior(likelihood) + + # set common args + common_args = { + "log_pdf": posterior, + "chains": 1, + "warm_up": 250, + "max_iterations": 500, + "cov0": [3e-4, 3e-4], + } + if sampler in self.fast_samplers: + common_args["warm_up"] = 500 + common_args["max_iterations"] = 800 + + # construct and run + sampler = sampler(**common_args) + if isinstance(sampler, SliceRankShrinkingMCMC): + sampler._samplers[0].set_hyper_parameters([1e-3]) + results = sampler.run() + + # Assert both final sample and posterior mean + x = np.mean(results, axis=1) + for i in range(len(x)): + np.testing.assert_allclose(x[i], self.ground_truth, atol=1.5e-2) + np.testing.assert_allclose(results[i][-1], self.ground_truth, atol=5e-3) + + def get_data(self, model, init_soc): + initial_state = {"Initial SoC": init_soc} + experiment = pybop.Experiment( + [ + ( + "Discharge at 0.5C for 2 minutes (4 second period)", + "Rest for 1 minute (4 second period)", + ), + ] + ) + sim = model.predict(initial_state=initial_state, experiment=experiment) + return sim diff --git a/tests/unit/test_posterior.py b/tests/unit/test_posterior.py index e1d0c9281..7073b94c3 100644 --- a/tests/unit/test_posterior.py +++ b/tests/unit/test_posterior.py @@ -74,9 +74,7 @@ def test_log_posterior_construction(self, likelihood, prior): # Test log posterior construction without parameters likelihood.problem.parameters.priors = None - with pytest.raises( - ValueError, match="An error occurred when constructing the Prior class:" - ): + with pytest.raises(TypeError, match="'NoneType' object is not callable"): pybop.LogPosterior(likelihood, log_prior=None) @pytest.mark.unit @@ -85,7 +83,9 @@ def test_log_posterior_construction_no_prior(self, likelihood): posterior = pybop.LogPosterior(likelihood, None) assert posterior._prior is not None - for i, p in enumerate(posterior._prior): + assert isinstance(posterior._prior, pybop.JointLogPrior) + + for i, p in enumerate(posterior._prior._priors): assert p == posterior._log_likelihood.problem.parameters.priors()[i] @pytest.fixture diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index b73cf7fbc..10d103457 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -149,6 +149,33 @@ def test_initialisation_and_run( assert samples is not None assert samples.shape == (chains, 1, 2) + @pytest.mark.unit + def test_single_parameter_sampling(self, model, dataset): + parameters = pybop.Parameters( + pybop.Parameter( + "Negative electrode active material volume fraction", + prior=pybop.Gaussian(0.6, 0.2), + bounds=[0.58, 0.62], + ) + ) + problem = pybop.FittingProblem( + model, + parameters, + dataset, + ) + likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=0.01) + log_posterior = pybop.LogPosterior(likelihood) + + # Construct and run + sampler = pybop.MCMCSampler( + log_pdf=log_posterior, + chains=1, + sampler=MALAMCMC, + max_iterations=1, + verbose=True, + ) + sampler.run() + @pytest.mark.unit def test_multi_log_pdf(self, log_posterior, x0, chains): multi_log_posterior = [log_posterior, log_posterior, log_posterior] @@ -338,7 +365,7 @@ def test_base_sampler(self, log_posterior, x0): @pytest.mark.unit def test_MCMC_sampler(self, log_posterior, x0, chains): - with pytest.raises(ValueError): + with pytest.raises(TypeError): pybop.MCMCSampler( log_pdf=log_posterior, chains=chains, From 85e1ce1f0ba68132e84f04b12cfb47f6f611fdad Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Fri, 30 Aug 2024 09:21:33 +0100 Subject: [PATCH 30/31] Remainder review suggestions, update assert tolerances, small array dimensions bugfix for single parameter inference --- pybop/samplers/base_pints_sampler.py | 8 ++--- pybop/samplers/base_sampler.py | 2 +- .../integration/test_monte_carlo_thevenin.py | 4 +-- tests/unit/test_sampling.py | 33 ++++++++++++++----- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/pybop/samplers/base_pints_sampler.py b/pybop/samplers/base_pints_sampler.py index 844f38078..76446e198 100644 --- a/pybop/samplers/base_pints_sampler.py +++ b/pybop/samplers/base_pints_sampler.py @@ -89,7 +89,7 @@ def __init__( # Check initial conditions if self._x0.size != self.n_parameters: raise ValueError("x0 must have the same number of parameters as log_pdf") - if len(self._x0) != self._n_chains: + if len(self._x0) != self._n_chains or len(self._x0) == 1: self._x0 = np.tile(self._x0, (self._n_chains, 1)) # Single chain vs multiple chain samplers @@ -202,8 +202,6 @@ def _process_single_chain(self): reply = self._samplers[i].tell(next(self.fxs_iterator)) if reply: y, fy, accepted = reply - if y.ndim == 0: - y = y[np.newaxis] y_store = self._inverse_transform( y, self._log_pdf[i] if self._multi_log_pdf else self._log_pdf ) @@ -237,7 +235,9 @@ def _process_multi_chain(self): self._intermediate_step = reply is None if reply: ys, fys, accepted = reply - ys_store = np.array([self._inverse_transform(y, self._log_pdf) for y in ys]) + ys_store = np.asarray( + [self._inverse_transform(y, self._log_pdf) for y in ys] + ) if self._chains_in_memory: self._samples[:, self._iteration] = ys_store else: diff --git a/pybop/samplers/base_sampler.py b/pybop/samplers/base_sampler.py index c0bee9430..1766759bf 100644 --- a/pybop/samplers/base_sampler.py +++ b/pybop/samplers/base_sampler.py @@ -34,7 +34,7 @@ def __init__(self, log_pdf: LogPosterior, x0, cov0: Union[np.ndarray, float]): self._x0 = ( self.parameters.initial_value() if x0 is None - else np.asarray(x0, dtype=float) + else np.asarray([x0], dtype=float) ) def run(self) -> np.ndarray: diff --git a/tests/integration/test_monte_carlo_thevenin.py b/tests/integration/test_monte_carlo_thevenin.py index a76d9933f..bec7d6cac 100644 --- a/tests/integration/test_monte_carlo_thevenin.py +++ b/tests/integration/test_monte_carlo_thevenin.py @@ -118,7 +118,7 @@ def test_sampling_thevenin(self, sampler, likelihood): } if sampler in self.fast_samplers: common_args["warm_up"] = 500 - common_args["max_iterations"] = 800 + common_args["max_iterations"] = 1000 # construct and run sampler = sampler(**common_args) @@ -130,7 +130,7 @@ def test_sampling_thevenin(self, sampler, likelihood): x = np.mean(results, axis=1) for i in range(len(x)): np.testing.assert_allclose(x[i], self.ground_truth, atol=1.5e-2) - np.testing.assert_allclose(results[i][-1], self.ground_truth, atol=5e-3) + np.testing.assert_allclose(results[i][-1], self.ground_truth, atol=1e-2) def get_data(self, model, init_soc): initial_state = {"Initial SoC": init_soc} diff --git a/tests/unit/test_sampling.py b/tests/unit/test_sampling.py index 10d103457..e854d24ab 100644 --- a/tests/unit/test_sampling.py +++ b/tests/unit/test_sampling.py @@ -90,9 +90,8 @@ def chains(self): def multi_samplers(self): return (pybop.DREAM, pybop.EmceeHammerMCMC, pybop.DifferentialEvolutionMCMC) - @pytest.mark.parametrize( - "MCMC", - [ + @pytest.fixture( + params=[ NUTS, DREAM, AdaptiveCovarianceMCMC, @@ -111,8 +110,11 @@ def multi_samplers(self): SliceDoublingMCMC, SliceRankShrinkingMCMC, SliceStepoutMCMC, - ], + ] ) + def MCMC(self, request): + return request.param + @pytest.mark.unit def test_initialisation_and_run( self, log_posterior, x0, chains, MCMC, multi_samplers @@ -150,7 +152,7 @@ def test_initialisation_and_run( assert samples.shape == (chains, 1, 2) @pytest.mark.unit - def test_single_parameter_sampling(self, model, dataset): + def test_single_parameter_sampling(self, model, dataset, MCMC, chains): parameters = pybop.Parameters( pybop.Parameter( "Negative electrode active material volume fraction", @@ -166,11 +168,15 @@ def test_single_parameter_sampling(self, model, dataset): likelihood = pybop.GaussianLogLikelihoodKnownSigma(problem, sigma0=0.01) log_posterior = pybop.LogPosterior(likelihood) + # Skip RelativisticMCMC as it requires > 1 parameter + if issubclass(MCMC, RelativisticMCMC): + return + # Construct and run sampler = pybop.MCMCSampler( log_pdf=log_posterior, - chains=1, - sampler=MALAMCMC, + chains=chains, + sampler=MCMC, max_iterations=1, verbose=True, ) @@ -252,9 +258,17 @@ def test_invalid_initialisation(self, log_posterior, x0): x0=[0.4, 0.4, 0.4, 0.4], ) + # SingleChain & MultiChain Sampler + @pytest.mark.parametrize( + "sampler", + [ + AdaptiveCovarianceMCMC, + DifferentialEvolutionMCMC, + ], + ) @pytest.mark.unit - def test_no_chains_in_memory(self, log_posterior, x0, chains): - sampler = AdaptiveCovarianceMCMC( + def test_no_chains_in_memory(self, log_posterior, x0, chains, sampler): + sampler = sampler( log_pdf=log_posterior, chains=chains, x0=x0, @@ -265,6 +279,7 @@ def test_no_chains_in_memory(self, log_posterior, x0, chains): # Run the sampler samples = sampler.run() + assert sampler._samples is not None assert samples is None @patch("logging.basicConfig") From 8a928af85b6d5cde2fd72ae68305120fd0bc8b65 Mon Sep 17 00:00:00 2001 From: Brady Planden Date: Mon, 2 Sep 2024 08:54:23 +0100 Subject: [PATCH 31/31] tests: increment iterations from scheduled test run --- tests/integration/test_monte_carlo_thevenin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_monte_carlo_thevenin.py b/tests/integration/test_monte_carlo_thevenin.py index bec7d6cac..6a9046f62 100644 --- a/tests/integration/test_monte_carlo_thevenin.py +++ b/tests/integration/test_monte_carlo_thevenin.py @@ -117,8 +117,8 @@ def test_sampling_thevenin(self, sampler, likelihood): "cov0": [3e-4, 3e-4], } if sampler in self.fast_samplers: - common_args["warm_up"] = 500 - common_args["max_iterations"] = 1000 + common_args["warm_up"] = 600 + common_args["max_iterations"] = 1200 # construct and run sampler = sampler(**common_args)