diff --git a/docs/docs/tutorials/tutorial0_basics.ipynb b/docs/docs/tutorials/tutorial0_basics.ipynb index 2d34368f..4f1ad48e 100644 --- a/docs/docs/tutorials/tutorial0_basics.ipynb +++ b/docs/docs/tutorials/tutorial0_basics.ipynb @@ -24,7 +24,9 @@ "\n", "import easydynamics as edyn\n", "import easydynamics.sample_model as sm\n", - "from easydynamics.analysis.analysis import Analysis\n", + "from easydynamics.analysis import Analysis\n", + "from easydynamics.analysis import ParameterAnalysis\n", + "from easydynamics.analysis.parameter_analysis import FitBinding\n", "\n", "# Make the plots interactive\n", "%matplotlib widget" @@ -388,7 +390,79 @@ "id": "842c1f01", "metadata": {}, "source": [ - "It will soon be possible to use **EasyDynamics** to fit these parameters to e.g. a polynomial." + "The final step in this tutorial is to fit the are of the `Gaussian` to a straight line. For this, we use the `ParameterAnalysis` class. We create a `Polynomial` with two coefficients for the fit function. We create a `FitBinding`, telling the class we want to fit the parameter named `Gaussian area` with the fit function that we define." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75db3d4c", + "metadata": {}, + "outputs": [], + "source": [ + "fit_func = sm.Polynomial(coefficients=[3.7, -0.5], display_name='Straight line')\n", + "\n", + "binding = FitBinding(parameter_name='Gaussian area', model=fit_func)\n", + "\n", + "parameter_analysis = ParameterAnalysis(\n", + " parameters=analysis,\n", + " bindings=[binding],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "01f45034", + "metadata": {}, + "source": [ + "Let us plot the start guess using the `plot()` method:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd3cf4a6", + "metadata": {}, + "outputs": [], + "source": [ + "parameter_analysis.plot()" + ] + }, + { + "cell_type": "markdown", + "id": "634bebdc", + "metadata": {}, + "source": [ + "It looks decent, so we can fit and plot again:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d5ba8985", + "metadata": {}, + "outputs": [], + "source": [ + "parameter_analysis.fit()\n", + "parameter_analysis.plot()" + ] + }, + { + "cell_type": "markdown", + "id": "dc33728c", + "metadata": {}, + "source": [ + "To see the parameters we can use the `get_all_parameters()` method:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f18e2944", + "metadata": {}, + "outputs": [], + "source": [ + "parameter_analysis.get_all_parameters()" ] } ], diff --git a/docs/docs/tutorials/tutorial0_more_advanced.ipynb b/docs/docs/tutorials/tutorial0_more_advanced.ipynb index 49cb8963..ee1e4e57 100644 --- a/docs/docs/tutorials/tutorial0_more_advanced.ipynb +++ b/docs/docs/tutorials/tutorial0_more_advanced.ipynb @@ -292,7 +292,65 @@ "metadata": {}, "outputs": [], "source": [ - "analysis.plot_parameters(names=['Gaussian area', 'Lorentzian area', 'DHO area'])" + "analysis.plot_parameters(names=['Gaussian area', 'DHO area', 'DHO center'])" + ] + }, + { + "cell_type": "markdown", + "id": "0eadbd91", + "metadata": {}, + "source": [ + "With apologies for the lack of creativity, these all appear like straight lines. We can fit them individually or all together using `ParameterAnalysis`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb2f0e06", + "metadata": {}, + "outputs": [], + "source": [ + "gauss_fit_func = sm.Polynomial(\n", + " coefficients=[3.7, -0.5], unit='1/angstrom', display_name='Gauss area fit'\n", + ")\n", + "dho_area_fit_func = sm.Polynomial(\n", + " coefficients=[2.0, 0.12], unit='1/angstrom', display_name='DHO area fit'\n", + ")\n", + "dho_center_fit_func = sm.Polynomial(\n", + " coefficients=[1.1, 0.2], unit='1/angstrom', display_name='DHO center fit'\n", + ")\n", + "\n", + "binding1 = edyn.FitBinding(parameter_name='Gaussian area', model=gauss_fit_func)\n", + "\n", + "binding2 = edyn.FitBinding(parameter_name='DHO area', model=dho_area_fit_func)\n", + "\n", + "binding3 = edyn.FitBinding(parameter_name='DHO center', model=dho_center_fit_func)\n", + "\n", + "parameter_analysis = edyn.ParameterAnalysis(\n", + " parameters=analysis,\n", + " bindings=[binding1, binding2, binding3],\n", + ")\n", + "\n", + "parameter_analysis.plot()" + ] + }, + { + "cell_type": "markdown", + "id": "32bc1efc", + "metadata": {}, + "source": [ + "The start guesses look reasonable, so we fit:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a5548093", + "metadata": {}, + "outputs": [], + "source": [ + "parameter_analysis.fit()\n", + "parameter_analysis.plot()" ] } ], diff --git a/docs/docs/tutorials/tutorial1_brownian.ipynb b/docs/docs/tutorials/tutorial1_brownian.ipynb index 413ce036..ea0b90df 100644 --- a/docs/docs/tutorials/tutorial1_brownian.ipynb +++ b/docs/docs/tutorials/tutorial1_brownian.ipynb @@ -24,6 +24,8 @@ "import pooch\n", "\n", "from easydynamics.analysis.analysis import Analysis\n", + "from easydynamics.analysis.parameter_analysis import FitBinding\n", + "from easydynamics.analysis.parameter_analysis import ParameterAnalysis\n", "from easydynamics.experiment import Experiment\n", "from easydynamics.sample_model import BrownianTranslationalDiffusion\n", "from easydynamics.sample_model import ComponentCollection\n", @@ -460,7 +462,7 @@ "id": "45daa848", "metadata": {}, "source": [ - "The fit looks good, so now we want to look at the most interesting fit parameters: the width and area of the Lorentzian. In later versions of EasyDynamics it will be possible to fit them to e.g. a DiffusionModel." + "The fit looks good, so now we want to look at the most interesting fit parameters: the width and area of the Lorentzian. " ] }, { @@ -470,7 +472,6 @@ "metadata": {}, "outputs": [], "source": [ - "# Let us look at the most interesting fit parameters\n", "diffusion_analysis.plot_parameters(names=['Lorentzian width', 'Lorentzian area'])" ] }, @@ -485,7 +486,105 @@ "$$\n", "where $\\Gamma(Q) = D Q^2$ and $D$ is the diffusion coefficient. $S$ is an overall scale.\n", "\n", - "In addition to this diffusion model, there is still the elastic incoherent scattering.\n", + "To fit the Brownian translational diffusion model to the data, we use the `ParameterAnalysis`. This time, we wish to fit the `Lorentzian`, and we wish to fit both its area (scale, $S$) and width to the diffusion model. We do this by creating a `FitBinding`, saying we want to fit both the area an width of the component called `Lorentzian`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9c1ab6b0", + "metadata": {}, + "outputs": [], + "source": [ + "brownian_diffusion_model = BrownianTranslationalDiffusion(\n", + " display_name='Brownian Translational Diffusion', diffusion_coefficient=2.4e-9, scale=0.5\n", + ")\n", + "\n", + "binding = FitBinding(\n", + " parameter_name='Lorentzian',\n", + " model=brownian_diffusion_model,\n", + " modes=['area', 'width'],\n", + ")\n", + "\n", + "parameter_analysis = ParameterAnalysis(\n", + " parameters=diffusion_analysis,\n", + " bindings=[binding],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "53dc12dc", + "metadata": {}, + "source": [ + "We first plot the start guess to see if it's reasonable." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c7074c64", + "metadata": {}, + "outputs": [], + "source": [ + "parameter_analysis.plot()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57b76d06", + "metadata": {}, + "outputs": [], + "source": [ + "parameter_analysis.plot(names=['Polynomial_c0'])" + ] + }, + { + "cell_type": "markdown", + "id": "64babb01", + "metadata": {}, + "source": [ + "This looks pretty good. Let's fit and plot again:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26c418ef", + "metadata": {}, + "outputs": [], + "source": [ + "parameter_analysis.fit()\n", + "parameter_analysis.plot()" + ] + }, + { + "cell_type": "markdown", + "id": "81d30f55", + "metadata": {}, + "source": [ + "And we can get the parameters of the diffusion model:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7c47bcd4", + "metadata": {}, + "outputs": [], + "source": [ + "parameter_analysis.get_all_parameters()" + ] + }, + { + "cell_type": "markdown", + "id": "fc2f8434", + "metadata": {}, + "source": [ + "This is a good result, but we can do better. Now that we know that the quasielastic scattering is well described by a model of diffusion, we can fit this model directly to the data, fitting all $Q$ simultaneously.\n", + "\n", + "In addition to this diffusion model, we will still fit the elastic incoherent scattering.\n", "\n", "We create a new `SampleModel` which as a `DeltaFunction` component for the elastic incoherent scattering and a `BrownianTranslationalDiffusion` diffusion model to describe the rest. We also create a new `BackgroundModel` and `InstrumentModel`." ] @@ -593,7 +692,7 @@ "id": "bbeec088", "metadata": {}, "source": [ - "It does not make sense to plot the diffusion parameters, but we can display them (with uncertainties) like this." + "It does not make sense to plot the diffusion parameters, as they are just two numbers with uncertainties, but we can display them (with uncertainties) like this." ] }, { @@ -605,6 +704,24 @@ "source": [ "diffusion_model.get_all_parameters()" ] + }, + { + "cell_type": "markdown", + "id": "fc9bf6b1", + "metadata": {}, + "source": [ + "For reference, here are the diffusion parameters found from fitting the width and scale of the fitted Lorentzians. Notice that the error bars when fitting everything simultaneously are a lot lower than the two-step fit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0af81fa8", + "metadata": {}, + "outputs": [], + "source": [ + "parameter_analysis.get_all_parameters()" + ] } ], "metadata": { diff --git a/src/easydynamics/__init__.py b/src/easydynamics/__init__.py index 54557144..f4c956e5 100644 --- a/src/easydynamics/__init__.py +++ b/src/easydynamics/__init__.py @@ -3,11 +3,17 @@ """EasyDynamics library.""" from easydynamics.analysis import Analysis +from easydynamics.analysis.fit_binding import FitBinding +from easydynamics.analysis.parameter_analysis import ParameterAnalysis from easydynamics.experiment import Experiment from easydynamics.settings.convolution_settings import ConvolutionSettings +from easydynamics.settings.detailed_balance_settings import DetailedBalanceSettings __all__ = [ 'Analysis', 'ConvolutionSettings', + 'DetailedBalanceSettings', 'Experiment', + 'FitBinding', + 'ParameterAnalysis', ] diff --git a/src/easydynamics/analysis/__init__.py b/src/easydynamics/analysis/__init__.py index a52854bc..289ec02f 100644 --- a/src/easydynamics/analysis/__init__.py +++ b/src/easydynamics/analysis/__init__.py @@ -2,7 +2,9 @@ # SPDX-License-Identifier: BSD-3-Clause from easydynamics.analysis.analysis import Analysis +from easydynamics.analysis.parameter_analysis import ParameterAnalysis __all__ = [ 'Analysis', + 'ParameterAnalysis', ] diff --git a/src/easydynamics/analysis/fit_binding.py b/src/easydynamics/analysis/fit_binding.py new file mode 100644 index 00000000..ad11ff60 --- /dev/null +++ b/src/easydynamics/analysis/fit_binding.py @@ -0,0 +1,327 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from easydynamics.base_classes.easydynamics_base import EasyDynamicsBase +from easydynamics.sample_model.component_collection import ComponentCollection +from easydynamics.sample_model.components.model_component import ModelComponent +from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase + +if TYPE_CHECKING: + from collections.abc import Callable + + +class FitBinding(EasyDynamicsBase): + """ + Contract between dataset, model, and fit function for ParameterAnalysis. This class + encapsulates the necessary information to bind a dataset key to a model and convert it into a + fit function callable. + """ + + def __init__( + self, + parameter_name: str, + model: ModelComponent | ComponentCollection | DiffusionModelBase, + modes: str | list[str] | None = None, + display_name: str | None = None, + unique_name: str | None = None, + ) -> None: + """ + Initialize a FitBinding. + + Parameters + ---------- + parameter_name : str + The name of the parameter to fit. This should correspond to a key in the dataset. + model : ModelComponent | ComponentCollection | DiffusionModelBase + The model to fit. This can be a single ModelComponent, a ComponentCollection, or a + DiffusionModelBase. + modes : str | list[str] | None, default=None + The modes to fit for diffusion models. This can be a single string, a list of strings, + or None (which defaults to ["area", "width"]). Only applicable if the model is a + DiffusionModelBase. Default is None. + display_name : str | None, default=None + An optional display name for the FitBinding. If None, the unique_name will be used. + Default is None. + unique_name : str | None, default=None + An optional unique name for the FitBinding. If None, a unique name will be generated. + Default is None. + + Raises + ------ + TypeError + If parameter_name is not a string, if model is not a ModelComponent, + ComponentCollection or DiffusionModelBase, or if modes is not a string, list of + strings, or None. + + Examples + -------- + 1. Basic usage with a ModelComponent: + >>> import easydynamics.sample_model as sm + >>> import easydynamics as edyn + >>> fit_func = sm.Polynomial(coefficients=[3.7, -0.5], display_name='Straight line') + >>> binding = edyn.FitBinding(parameter_name='Gaussian area', model=fit_func) + >>> print(binding) + FitBinding(parameter_name='Gaussian area', model=Polynomial(unique_name = Polynomial_1, + unit = meV, coefficients = [Straight line_c0=3.7, Straight line_c1=-0.5]), modes=None) + + 2. Usage with a DiffusionModelBase and specific modes: + >>> brownian_diffusion_model = sm.BrownianTranslationalDiffusion( + ... display_name='Brownian Translational Diffusion', + ... diffusion_coefficient=2.4e-9, + ... scale=0.5, + ... ) + >>> binding = edyn.FitBinding( + ... parameter_name='Lorentzian', + ... model=brownian_diffusion_model, + ... modes=['area', 'width'], + ... ) + FitBinding(parameter_name=Lorentzian, model=Brownian Translational Diffusion, + modes=['area', 'width'], display_name=FitBinding_1, unique_name=FitBinding_1) + """ + + super().__init__(display_name=display_name, unique_name=unique_name) + + if not isinstance(parameter_name, str): + raise TypeError('parameter_name must be a string') + + if not isinstance(model, (ModelComponent, ComponentCollection, DiffusionModelBase)): + raise TypeError( + 'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase' + ) + + if modes is not None and not isinstance(modes, (str, list)): + raise TypeError('modes must be a string, list of strings, or None') + + if isinstance(modes, list) and not all(isinstance(mode, str) for mode in modes): + raise TypeError('All modes in the list must be strings') + + self._parameter_name = parameter_name + self._model = model + self._modes = modes + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def parameter_name(self) -> str: + """ + The name of the parameter to fit. This should correspond to a key in the dataset. + + Returns + ------- + str + The name of the parameter to fit. + """ + return self._parameter_name + + @parameter_name.setter + def parameter_name(self, value: str) -> None: + """ + Set the name of the parameter to fit. + + Parameters + ---------- + value : str + The new name of the parameter to fit. + + Raises + ------ + TypeError + If the value is not a string. + """ + if not isinstance(value, str): + raise TypeError('parameter_name must be a string') + self._parameter_name = value + + @property + def model(self) -> ModelComponent | ComponentCollection | DiffusionModelBase: + """ + The model to fit. This can be a single ModelComponent, a ComponentCollection, or a + DiffusionModelBase. + + Returns + ------- + ModelComponent | ComponentCollection | DiffusionModelBase + The model to fit. + """ + return self._model + + @model.setter + def model(self, value: ModelComponent | ComponentCollection | DiffusionModelBase) -> None: + """ + Set the model to fit. + + Parameters + ---------- + value : ModelComponent | ComponentCollection | DiffusionModelBase + The new model to fit. + + Raises + ------ + TypeError + If the value is not a ModelComponent, ComponentCollection, or DiffusionModelBase. + """ + if not isinstance(value, (ModelComponent, ComponentCollection, DiffusionModelBase)): + raise TypeError( + 'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase.' + ) + self._model = value + + @property + def modes(self) -> str | list[str] | None: + """ + The modes to fit for diffusion models. This can be a single string, a list of strings, or + None (which defaults to ["area", "width"]). + + Returns + ------- + str | list[str] | None + The modes to fit for diffusion models. + """ + return self._modes + + @modes.setter + def modes(self, value: str | list[str] | None) -> None: + """ + Set the modes to fit for diffusion models. + + Parameters + ---------- + value : str | list[str] | None + The new modes to fit for diffusion models. + + Raises + ------ + TypeError + If the value is not a string, list of strings, or None. + """ + if value is not None and not isinstance(value, (str, list)): + raise TypeError('modes must be a string, list of strings, or None') + + if isinstance(value, str): + value = [value] + if isinstance(value, list) and not all(isinstance(mode, str) for mode in value): + raise TypeError('All modes in the list must be strings') + self._modes = value + + # ------------------------------------------------------------------ + # Other methods + # ------------------------------------------------------------------ + + def build_callables(self) -> list[Callable]: + """ + Build the callables for fitting based on the model and modes. + + Returns + ------- + list[Callable] + A list of callables for fitting. + """ + modes = self._get_modes() + + if isinstance(self.model, DiffusionModelBase): + return [self._build_diffusion_callable(mode) for mode in modes] + + return [lambda x, **_: self.model.evaluate(x)] + + def get_model_names(self) -> list[str]: + """ + Get the names of the models based on the current modes. + + Returns + ------- + list[str] + A list of model names. + """ + modes = self._get_modes() + + if isinstance(self.model, DiffusionModelBase): + return [f'{self.model.display_name} {mode}' for mode in modes] + + return [self.model.display_name] + + def get_parameter_names(self) -> list[str]: + """ + Get the names of the parameters based on the current modes. + + Returns + ------- + list[str] + A list of parameter names. + """ + modes = self._get_modes() + + if isinstance(self.model, DiffusionModelBase): + return [f'{self.parameter_name} {mode}' for mode in modes] + + return [self.parameter_name] + + # ------------------------------------------------------------------ + # Private methods + # ------------------------------------------------------------------ + + def _build_diffusion_callable(self, mode: str) -> Callable: + """ + Build a callable for a specific diffusion mode. + + Parameters + ---------- + mode : str + The diffusion mode ("area" or "width"). + + Returns + ------- + Callable + A callable for the specified diffusion mode. + + Raises + ------ + ValueError + If the mode is unknown. + """ + model = self.model + + if mode == 'area': + return lambda x, **_: model.calculate_QISF(x) * model.scale.value + + if mode == 'width': + return lambda x, **_: model.calculate_width(x) + + raise ValueError(f'Unknown diffusion mode: {mode}') + + def _get_modes(self) -> list[str]: + """ + Get the modes to fit for diffusion models, defaulting to ["area", "width"] if not set. + + Returns + ------- + list[str] + The modes to fit for diffusion models. + """ + return ['area', 'width'] if self.modes is None else self.modes + + # ------------------------------------------------------------------ + # dunder methods + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + """ + Return a string representation of the FitBinding. + + Returns + ------- + str + A string representation of the FitBinding. + """ + return ( + f'FitBinding(parameter_name={self.parameter_name},\n ' + f'model={self.model.display_name},\n ' + f'modes={self.modes},\n ' + f'display_name={self.display_name},\n ' + f'unique_name={self.unique_name})' + ) diff --git a/src/easydynamics/analysis/parameter_analysis.py b/src/easydynamics/analysis/parameter_analysis.py new file mode 100644 index 00000000..eb3e3b4f --- /dev/null +++ b/src/easydynamics/analysis/parameter_analysis.py @@ -0,0 +1,557 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import itertools +from typing import Any + +import numpy as np +import plopp as pp +import scipp as sc +from easyscience.fitting.minimizers.utils import FitResults +from easyscience.fitting.multi_fitter import MultiFitter +from matplotlib import rcParams +from plopp.backends.matplotlib.figure import InteractiveFigure + +from easydynamics.analysis.analysis import Analysis +from easydynamics.analysis.fit_binding import FitBinding +from easydynamics.base_classes.easydynamics_modelbase import EasyDynamicsModelBase +from easydynamics.utils.utils import _in_notebook + + +class ParameterAnalysis(EasyDynamicsModelBase): + """ + For analysing fitted parameters. + + Can be used to fit parameters to ModelComponents, ComponentCollections, or DiffusionModelBase + objects, and to plot the parameters and fit results. The parameters to be analyzed can be + provided as a sc.Dataset or directly as an Analysis object. Multiple parameters can be fitted + simultaneously, and the fit functions can be customized for each parameter. For diffusion + models, the area and width can be fitted separately (or not at all) by specifying fit settings. + """ + + def __init__( + self, + parameters: sc.Dataset | Analysis | None = None, + bindings: FitBinding | list[FitBinding] | None = None, + display_name: str | None = 'ParameterAnalysis', + unique_name: str | None = None, + ) -> None: + """ + Initialize the ParameterAnalysis. + + Parameters + ---------- + parameters : sc.Dataset | Analysis | None, default=None + The parameters to analyze. Can be provided as a sc.Dataset or as an Analysis (in which + case the parameters will be extracted from the Analysis). + bindings : FitBinding | list[FitBinding] | None, default=None + The fit bindings to use for fitting the parameters. Can be a single FitBinding or a + list of FitBindings. If None, no fit bindings are provided. + display_name : str | None, default='ParameterAnalysis' + Display name of the analysis. + unique_name : str | None, default=None + Unique name of the analysis. If None, a unique name is automatically generated. By + default, None. + """ + + super().__init__(display_name=display_name, unique_name=unique_name) + + self._parameters = self._verify_parameters(parameters) + + self._bindings = self._verify_bindings(bindings) + + ############# + # Properties + ############# + @property + def parameters(self) -> sc.Dataset | None: + """ + Get the parameters for the parameter analysis. + + Returns + ------- + sc.Dataset | None + The parameters for the parameter analysis. + """ + return self._parameters + + @parameters.setter + def parameters(self, value: sc.Dataset | Analysis | None) -> None: + """ + Set the parameter dataset for the parameter analysis. + + Parameters + ---------- + value : sc.Dataset | Analysis | None + The new parameter dataset for the parameter analysis. + """ + self._parameters = self._verify_parameters(value) + + @property + def bindings(self) -> list[FitBinding]: + """ + Get the fit bindings for the parameter analysis. + + Returns + ------- + list[FitBinding] + The fit bindings for the parameter analysis. + """ + return self._bindings + + @bindings.setter + def bindings(self, value: FitBinding | list[FitBinding] | None) -> None: + """ + Set the fit bindings for the parameter analysis. + + Parameters + ---------- + value : FitBinding | list[FitBinding] | None + The new fit bindings for the parameter analysis. + """ + self._bindings = self._verify_bindings(value) + + ############# + # Other methods + ############# + + def fit(self) -> FitResults: + """ + Fit the parameters using the specified fit functions and settings. + + Returns + ------- + FitResults + The results of the fit + + Raises + ------ + ValueError + If no parameters Dataset is provided. If no fit functions are provided. If no parameter + names are found for the fit functions. + """ + + if self.parameters is None: + raise ValueError('No parameters Dataset provided.') + + if not self.bindings: + raise ValueError('No fit bindings provided.') + + xs = [] + ys = [] + ws = [] + funcs, models = [], [] + + for binding in self.bindings: + param_names = binding.get_parameter_names() + callables = binding.build_callables() + + for pname, func in zip(param_names, callables, strict=True): + if pname not in self.parameters: + raise ValueError( + f"Parameter '{pname}' from binding '{binding.unique_name}' " + f'not found in parameters Dataset.' + ) + + x, y, weight = self._get_xyweight_from_dataset(pname) + + xs.append(x) + ys.append(y) + ws.append(weight) + + funcs.append(func) + models.append(binding.model) + + mf = MultiFitter( + fit_objects=models, + fit_functions=funcs, + ) + + return mf.fit( + x=xs, + y=ys, + weights=ws, + ) + + def plot( + self, names: str | list[str] | None = None, **kwargs: dict[str, Any] + ) -> InteractiveFigure: + """ + Plot the parameters and fit results. + + Parameters + ---------- + names : str | list[str] | None, default=None + The names of the parameters to plot. If None, all parameters with bindings are plotted. + **kwargs : dict[str, Any] + Additional keyword arguments to pass to the plotting function. + + Returns + ------- + InteractiveFigure + An interactive figure containing the plots of the parameters and fit results. + + Raises + ------ + ValueError + If the units of the specified parameters are not consistent. + RuntimeError + If plot() is called outside of a Jupyter notebook environment. + """ + + if not _in_notebook(): + raise RuntimeError('plot() can only be used in a Jupyter notebook environment.') + + if self.parameters is None: + raise ValueError('No parameters available to plot.') + + full_model_dataset = None + if self.bindings: + full_model_dataset = self.calculate_model_dataset(self.bindings) + + # If no names are provided, default to plot all parameters that have bindings. + # If no bindings are provided, plot all parameters. + if names is None: + names = [] + + if not self.bindings: + names = list(self.parameters.keys()) + else: + for b in self.bindings: + names.extend(b.get_parameter_names()) + + names = self._normalize_names(names) + + # Check that the units of the specified parameters are consistent. + units = [self.parameters[name].unit for name in names] + first_unit = units[0] + if any(unit != first_unit for unit in units): + raise ValueError(f'Units are not compatible, and cannot be plotted together: {units}') + + color_cycle = itertools.cycle(rcParams['axes.prop_cycle'].by_key()['color']) + markers = itertools.cycle(['o', 's', 'D', '^', 'v', '<', '>']) + + plot_kwargs = { + 'title': self.display_name, + 'linestyle': {}, + 'marker': {}, + 'color': {}, + 'markerfacecolor': {}, + } + + data_arrays = {} + model_arrays = {} + + # map parameter names to model names + param_to_model = {} + if self.bindings is not None: + for b in self.bindings: + param_names = b.get_parameter_names() + model_names = b.get_model_names() + + param_to_model.update(dict(zip(param_names, model_names, strict=True))) + + for pname in names: + data_arrays[pname] = self.parameters[pname] + color = next(color_cycle) + marker = next(markers) + + # Data styling + plot_kwargs['linestyle'][pname] = 'none' + plot_kwargs['marker'][pname] = marker + plot_kwargs['color'][pname] = color + plot_kwargs['markerfacecolor'][pname] = 'none' + + if full_model_dataset is not None and pname in param_to_model: + mname = param_to_model[pname] + model_arrays[mname] = full_model_dataset[mname] + + # Model styling + plot_kwargs['linestyle'][mname] = '--' + plot_kwargs['marker'][mname] = None + plot_kwargs['color'][mname] = color + + # Update kwargs with user provided kwargs. + plot_kwargs.update(kwargs) + + data_and_model = sc.Dataset(data_arrays) + data_and_model.update(model_arrays) + + return pp.plot(data_and_model, **plot_kwargs) + + def calculate_model_dataset(self, bindings: list[FitBinding]) -> sc.Dataset: + """ + Evaluate all bindings into a sc.Dataset of model predictions. + + Parameters + ---------- + bindings : list[FitBinding] + The bindings to evaluate. + + Returns + ------- + sc.Dataset + A sc.Dataset containing the model predictions for all bindings. + + Raises + ------ + ValueError + If any parameter name from the bindings is not found in the parameters Dataset. + + TypeError + If bindings is not a list of FitBinding objects. + """ + + if self.parameters is None: + raise ValueError('No parameters Dataset provided.') + + if not bindings: + raise ValueError('No fit bindings provided.') + + if not isinstance(bindings, list) or not all(isinstance(b, FitBinding) for b in bindings): + raise TypeError('bindings must be a list of FitBinding objects.') + + arrays = {} + + for b in bindings: + param_names = b.get_parameter_names() + model_names = b.get_model_names() + callables = b.build_callables() + + for pname, mname, func in zip(param_names, model_names, callables, strict=True): + if pname not in self.parameters: + raise ValueError( + f"Parameter '{pname}' from binding '{b.unique_name}' " + f'not found in parameters Dataset.' + ) + da = self.parameters[pname] + x = da.coords['Q'] + + y_model = func(x.values) + + arrays[mname] = sc.DataArray( + data=sc.array(dims=['Q'], values=y_model, unit=da.unit), + coords={'Q': x}, + ) + return sc.Dataset(arrays) + + def append_binding(self, binding: FitBinding) -> None: + """ + Append a FitBinding to the list of bindings for the parameter analysis. + + Parameters + ---------- + binding : FitBinding + The FitBinding to append. + + Raises + ------ + TypeError + If binding is not a FitBinding object. + """ + if not isinstance(binding, FitBinding): + raise TypeError('binding must be a FitBinding object.') + self._bindings.append(binding) + + def clear_bindings(self) -> None: + """ + Clear all FitBindings from the list of bindings for the parameter analysis. + """ + self._bindings.clear() + + def get_all_variables(self) -> list: + """ + Get all variables from the fit functions. + + Returns + ------- + list + A list of all variables from the fit functions. + """ + variables = set() + for b in self._bindings: + variables.update(b.model.get_all_variables()) + return list(variables) + + ############# + # Private methods: verification and preparation + ############# + + def _verify_bindings(self, bindings: FitBinding | list[FitBinding] | None) -> list[FitBinding]: + """ + Verify the bindings input. + + Parameters + ---------- + bindings : FitBinding | list[FitBinding] | None + The bindings to verify. + + Returns + ------- + list[FitBinding] + A list of verified FitBindings. + + Raises + ------ + TypeError + If bindings is not a FitBinding, a list of FitBindings, or None. + """ + if bindings is None: + return [] + if isinstance(bindings, FitBinding): + return [bindings] + if isinstance(bindings, list) and all(isinstance(b, FitBinding) for b in bindings): + return bindings + raise TypeError('bindings must be a FitBinding, a list of FitBindings, or None.') + + def _verify_parameters(self, parameters: sc.Dataset | Analysis | None) -> sc.Dataset | None: + """ + Verify the parameters input and convert it to a sc.Dataset if it's an Analysis. + + Parameters + ---------- + parameters : sc.Dataset | Analysis | None + The parameters to verify. + + Returns + ------- + sc.Dataset | None + The verified parameters as a sc.Dataset, or None if no parameters were provided. + + Raises + ------ + TypeError + If parameters is not a sc.Dataset, an Analysis, or None. + ValueError + If parameters is a sc.Dataset but does not have a 'Q' coordinate. + """ + if parameters is None: + return None + + if not isinstance(parameters, (sc.Dataset, Analysis)): + raise TypeError(r'parameters must be a sc.Dataset, an Analysis, or None.') + + if isinstance(parameters, Analysis): + verified_parameters = parameters.parameters_to_dataset() + else: + verified_parameters = parameters + + if 'Q' not in verified_parameters.coords: + raise ValueError(r"parameters must have a 'Q' coordinate.") + return verified_parameters + + def _normalize_names(self, names: str | list[str] | None) -> list[str] | None: + """ + Normalize the names input to a list of strings and verify that they exist in the parameters + Dataset. + + Parameters + ---------- + names : str | list[str] | None + The names to normalize and verify. + + Returns + ------- + list[str] | None + The normalized list of names, or None if names was None. + + Raises + ------ + ValueError + If any of the specified names are not found in the parameters Dataset, or if names is a + list that contains non-string elements. + """ + if names is None: + return None + if not isinstance(names, (str, list)): + raise ValueError('names must be a string, a list of strings, or None.') + if isinstance(names, list): + if not all(isinstance(name, str) for name in names): + raise ValueError('All names in the list must be strings.') + for name in names: + if name not in self.parameters: + raise ValueError(f"Parameter name '{name}' not found in parameters Dataset.") + if isinstance(names, str): + if names not in self.parameters: + raise ValueError(f"Parameter name '{names}' not found in parameters Dataset.") + names = [names] + return names + + ############# + # Private methods + ############# + + def _get_xyweight_from_dataset( + self, parameter_name: str + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Get the x, y, and weight values for a given parameter name from the parameters Dataset. + + Parameters + ---------- + parameter_name : str + The name of the parameter to get x, y, and weight values for. + + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray] + A tuple containing the x, y, and weight values for the given parameter name. + + Raises + ------ + ValueError + If the parameter name is not found in the parameters Dataset. If non-finite weights are + found for the parameter. + """ + if self._parameters is None: + raise ValueError('No parameters Dataset provided.') + if parameter_name not in self._parameters: + raise ValueError(f"Parameter name '{parameter_name}' not found in parameters Dataset.") + + variances = self._parameters[parameter_name].variances + if variances is None: + weight = np.ones_like(self._parameters[parameter_name].values) + elif np.any(~np.isfinite(variances)) or np.any(variances <= 0): + raise ValueError( + f"Non-finite variances found for parameter '{parameter_name}', " + f'cannot compute weights.' + ) + else: + weight = 1 / np.sqrt(variances) + + return ( + self._parameters[parameter_name].coords['Q'].values, + self._parameters[parameter_name].values, + weight, + ) + + ############# + # Dunder methods + ############# + def __repr__(self) -> str: + cls = self.__class__.__name__ + + n_params = len(self._parameters) if isinstance(self._parameters, sc.Dataset) else 0 + + param_names = ( + list(self._parameters.keys()) if isinstance(self._parameters, sc.Dataset) else None + ) + + binding_info = [ + { + 'parameter': b.parameter_name, + 'model': b.model.display_name, + 'modes': b.modes, + } + for b in self._bindings + ] + + return ( + f'{cls}(\n' + f'display_name={self.display_name},\n' + f'unique_name={self.unique_name},\n' + f'n_parameters={n_params},\n' + f'parameter_names={param_names},\n' + f'bindings={binding_info}\n' + f')' + ) diff --git a/tests/performance_tests/convolution/convolution_width_thresholds.ipynb b/tests/performance_tests/convolution/convolution_width_thresholds.ipynb index 8b72d888..fbb5df29 100644 --- a/tests/performance_tests/convolution/convolution_width_thresholds.ipynb +++ b/tests/performance_tests/convolution/convolution_width_thresholds.ipynb @@ -45,9 +45,8 @@ " sample_components=sample_components,\n", " resolution_components=resolution_components,\n", " energy=x,\n", - " upsample_factor=None,\n", ")\n", - "\n", + "numerical_convolver.upsample_factor = None\n", "for gwidth in gaussian_widths:\n", " sample_components.components[0].width = gwidth\n", " y_analytical = analytical_convolver.convolution()\n", @@ -74,14 +73,6 @@ "plt.legend()" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "d6124d02", - "metadata": {}, - "outputs": [], - "source": [] - }, { "cell_type": "code", "execution_count": null, @@ -112,9 +103,8 @@ " sample_components=sample_components,\n", " resolution_components=resolution_components,\n", " energy=x,\n", - " upsample_factor=None,\n", ")\n", - "\n", + "numerical_convolver.upsample_factor = None\n", "for gwidth, gcenter in zip(gaussian_widths, gaussian_centers, strict=True):\n", " sample_components.components[0].width = gwidth\n", " sample_components.components[0].center = gcenter\n", @@ -145,7 +135,7 @@ ], "metadata": { "kernelspec": { - "display_name": "easydynamics_newbase", + "display_name": "Python 3", "language": "python", "name": "python3" }, @@ -159,7 +149,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.12" + "version": "3.14.4" } }, "nbformat": 4, diff --git a/tests/unit/easydynamics/analysis/test_fit_binding.py b/tests/unit/easydynamics/analysis/test_fit_binding.py new file mode 100644 index 00000000..a59e0f70 --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_fit_binding.py @@ -0,0 +1,289 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + + +from unittest.mock import Mock + +import pytest + +from easydynamics.analysis.fit_binding import FitBinding +from easydynamics.sample_model.components.gaussian import Gaussian +from easydynamics.sample_model.diffusion_model.brownian_translational_diffusion import ( + BrownianTranslationalDiffusion, +) + + +class TestFitBinding: + @pytest.fixture + def fit_binding(self): + model = Gaussian() + return FitBinding(parameter_name='parameter1', model=model) + + @pytest.fixture + def diffusion_fit_binding(self): + model = BrownianTranslationalDiffusion() + return FitBinding(parameter_name='parameter3', model=model) + + def test_initialization(self, fit_binding): + # WHEN THEN EXPECT + assert isinstance(fit_binding, FitBinding) + assert fit_binding.parameter_name == 'parameter1' + assert isinstance(fit_binding.model, Gaussian) + assert fit_binding.modes is None + + @pytest.mark.parametrize( + 'parameter_name, model, modes, error_msg', + [ + # parameter_name errors + (123, Gaussian(), None, 'parameter_name must be a string'), + (None, Gaussian(), None, 'parameter_name must be a string'), + # model errors + ( + 'param', + 123, + None, + 'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase', + ), + ( + 'param', + 'not_a_model', + None, + 'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase', + ), + # modes type errors + ( + 'param', + Gaussian(), + 123, + 'modes must be a string, list of strings, or None', + ), + ( + 'param', + Gaussian(), + {'mode': 'area'}, + 'modes must be a string, list of strings, or None', + ), + # modes list content errors + ( + 'param', + Gaussian(), + ['area', 123], + 'All modes in the list must be strings', + ), + ('param', Gaussian(), [None], 'All modes in the list must be strings'), + ], + ) + def test_fitbinding_init_errors(self, parameter_name, model, modes, error_msg): + with pytest.raises(TypeError, match=error_msg): + FitBinding( + parameter_name=parameter_name, + model=model, + modes=modes, + ) + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + def test_parameter_name_setter(self, fit_binding): + # WHEN + fit_binding.parameter_name = 'new_parameter' + + # THEN EXPECT + assert fit_binding.parameter_name == 'new_parameter' + + def test_parameter_name_setter_errors(self, fit_binding): + with pytest.raises(TypeError, match='parameter_name must be a string'): + fit_binding.parameter_name = 123 + + def test_model_setter(self, fit_binding): + # WHEN + model = BrownianTranslationalDiffusion() + + # THEN + fit_binding.model = model + + # EXPECT + assert fit_binding.model is model + + def test_model_setter_errors(self, fit_binding): + with pytest.raises( + TypeError, + match='model must be a ModelComponent, ComponentCollection, or DiffusionModelBase', + ): + fit_binding.model = 'not_a_model' + + def test_modes_setter(self, fit_binding): + # WHEN + fit_binding.modes = 'area' + + # THEN EXPECT + assert fit_binding.modes == ['area'] + + # WHEN + fit_binding.modes = ['area', 'width'] + + # THEN EXPECT + assert fit_binding.modes == ['area', 'width'] + + def test_modes_setter_errors(self, fit_binding): + with pytest.raises(TypeError, match='modes must be a string, list of strings, or None'): + fit_binding.modes = 123 + + with pytest.raises(TypeError, match='modes must be a string, list of strings, or None'): + fit_binding.modes = {'mode': 'area'} + + with pytest.raises(TypeError, match='All modes in the list must be strings'): + fit_binding.modes = ['area', 123] + + with pytest.raises(TypeError, match='All modes in the list must be strings'): + fit_binding.modes = [None] + + # ------------------------------------------------------------------ + # Other methods + # ------------------------------------------------------------------ + + def test_build_callables_component(self, fit_binding): + # WHEN + mock_model = Mock() + mock_model.evaluate.return_value = 1.0 + fit_binding._model = mock_model + + # THEN + callables = fit_binding.build_callables() + + # EXPECT + assert len(callables) == 1 + assert callable(callables[0]) + assert callables[0](0) == pytest.approx(1.0) + mock_model.evaluate.assert_called_once_with(0) + + def test_build_callables_diffusion(self, diffusion_fit_binding): + # WHEN + mock_model = Mock(spec=BrownianTranslationalDiffusion) + mock_model.calculate_QISF.return_value = 2.0 + mock_model.scale.value = 3.0 + mock_model.calculate_width.return_value = 0.5 + diffusion_fit_binding._model = mock_model + + # THEN + callables = diffusion_fit_binding.build_callables() + + # EXPECT + assert len(callables) == 2 + assert callable(callables[0]) + assert callable(callables[1]) + assert callables[0](0) == pytest.approx(6.0) # 2.0 * 3.0 + assert callables[1](0) == pytest.approx(0.5) + mock_model.calculate_QISF.assert_called_once_with(0) + mock_model.calculate_width.assert_called_once_with(0) + + def test_build_callables_diffusion_with_modes(self, diffusion_fit_binding): + # WHEN + diffusion_fit_binding.modes = 'area' + mock_model = Mock(spec=BrownianTranslationalDiffusion) + mock_model.calculate_QISF.return_value = 2.0 + mock_model.scale.value = 3.0 + diffusion_fit_binding._model = mock_model + + # THEN + callables = diffusion_fit_binding.build_callables() + + # EXPECT + assert len(callables) == 1 + assert callable(callables[0]) + assert callables[0](0) == pytest.approx(6.0) # 2.0 * 3.0 + mock_model.calculate_QISF.assert_called_once_with(0) + + def test_get_model_names(self, fit_binding): + # WHEN THEN + model_names = fit_binding.get_model_names() + + # EXPECT + assert model_names == ['Gaussian'] + + def test_get_model_names_diffusion(self, diffusion_fit_binding): + # WHEN THEN + model_names = diffusion_fit_binding.get_model_names() + + # EXPECT + assert model_names == [ + 'BrownianTranslationalDiffusion area', + 'BrownianTranslationalDiffusion width', + ] + + def test_get_parameter_names(self, fit_binding): + # WHEN THEN + parameter_names = fit_binding.get_parameter_names() + + # EXPECT + assert parameter_names == ['parameter1'] + + def test_get_parameter_names_diffusion(self, diffusion_fit_binding): + # WHEN THEN + parameter_names = diffusion_fit_binding.get_parameter_names() + + # EXPECT + assert parameter_names == ['parameter3 area', 'parameter3 width'] + + # ------------------------------------------------------------------ + # Private methods + # ------------------------------------------------------------------ + + def test_build_diffusion_callable(self, diffusion_fit_binding): + + # WHEN + mock_model = Mock() + mock_model.calculate_QISF.return_value = 2.0 + mock_model.scale.value = 3.0 + mock_model.calculate_width.return_value = 0.5 + diffusion_fit_binding._model = mock_model + + # THEN + area_callable = diffusion_fit_binding._build_diffusion_callable(mode='area') + width_callable = diffusion_fit_binding._build_diffusion_callable(mode='width') + + # EXPECT + assert area_callable(0) == pytest.approx(6.0) # 2.0 * 3.0 + mock_model.calculate_QISF.assert_called_once_with(0) + + assert width_callable(0) == pytest.approx(0.5) + mock_model.calculate_width.assert_called_once_with(0) + + # THEN + result_area = area_callable(0, unused_arg=123) + result_width = width_callable(0, unused_arg=123) + + # EXPECT + assert result_area == pytest.approx(6.0) # Should ignore unused_arg + assert result_width == pytest.approx(0.5) # Should ignore unused_arg + + def test_build_diffusion_callable_errors(self, diffusion_fit_binding): + with pytest.raises(ValueError, match='Unknown diffusion mode: invalid_mode'): + diffusion_fit_binding._build_diffusion_callable(mode='invalid_mode') + + def test_get_modes(self, diffusion_fit_binding): + # WHEN + modes = diffusion_fit_binding._get_modes() + + # EXPECT + assert modes == ['area', 'width'] + + # THEN + diffusion_fit_binding.modes = 'area' + modes = diffusion_fit_binding._get_modes() + # EXPECT + assert modes == ['area'] + + # ------------------------------------------------------------------ + # dunder methods + # ------------------------------------------------------------------ + def test_repr(self, fit_binding): + # WHEN + repr_str = repr(fit_binding) + + # THEN EXPECT + assert 'FitBinding' in repr_str + assert 'parameter_name=parameter1' in repr_str + assert 'model=Gaussian' in repr_str + assert 'modes=None' in repr_str diff --git a/tests/unit/easydynamics/analysis/test_parameter_analysis.py b/tests/unit/easydynamics/analysis/test_parameter_analysis.py new file mode 100644 index 00000000..94fe9ddb --- /dev/null +++ b/tests/unit/easydynamics/analysis/test_parameter_analysis.py @@ -0,0 +1,885 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + + +from unittest.mock import MagicMock +from unittest.mock import patch + +import numpy as np +import pytest +import scipp as sc + +from easydynamics.analysis.analysis import Analysis +from easydynamics.analysis.fit_binding import FitBinding +from easydynamics.analysis.parameter_analysis import ParameterAnalysis +from easydynamics.sample_model.components.gaussian import Gaussian +from easydynamics.sample_model.components.polynomial import Polynomial +from easydynamics.sample_model.diffusion_model.brownian_translational_diffusion import ( + BrownianTranslationalDiffusion, +) + + +class TestParameterAnalysis: + @pytest.fixture + def dataset(self): + Q = sc.array(dims=['Q'], values=[0.1, 0.2]) + return sc.Dataset( + data={ + 'parameter1': sc.DataArray( + data=sc.array(dims=['Q'], values=[1.0, 2.0], variances=[0.1, 0.2], unit='meV'), + coords={'Q': Q}, + ), + 'parameter2': sc.DataArray( + data=sc.array( + dims=['Q'], + values=[1.5, 2.5], + variances=[0.15, 0.25], + unit='1/meV', + ), + coords={'Q': Q}, + ), + 'parameter3 area': sc.DataArray( + data=sc.array(dims=['Q'], values=[4.0, 5.0], variances=[0.3, 0.5], unit='meV'), + coords={'Q': Q}, + ), + 'parameter3 width': sc.DataArray( + data=sc.array(dims=['Q'], values=[6.0, 7.0], variances=[0.6, 0.7], unit='meV'), + coords={'Q': Q}, + ), + } + ) + + @pytest.fixture + def mock_model_dataset(self): + return sc.Dataset({ + 'Polynomial': sc.DataArray(data=sc.array(dims=['Q'], values=[1.1, 2.1], unit='meV')), + 'BrownianTranslationalDiffusion area': sc.DataArray( + data=sc.array(dims=['Q'], values=[6.1, 7.1], unit='meV') + ), + 'BrownianTranslationalDiffusion width': sc.DataArray( + data=sc.array(dims=['Q'], values=[8.1, 9.1], unit='meV') + ), + }) + + @pytest.fixture + def parameter_analysis(self, dataset): + model = Polynomial(coefficients=[1.0, 0.5]) + diffusion_model = BrownianTranslationalDiffusion() + + fit_binding1 = FitBinding(parameter_name='parameter1', model=model) + fit_binding2 = FitBinding(parameter_name='parameter3', model=diffusion_model) + + return ParameterAnalysis(parameters=dataset, bindings=[fit_binding1, fit_binding2]) + + def test_initialization(self, parameter_analysis): + # WHEN THEN EXPECT + assert isinstance(parameter_analysis, ParameterAnalysis) + assert len(parameter_analysis.bindings) == 2 + assert parameter_analysis.bindings[0].parameter_name == 'parameter1' + assert parameter_analysis.bindings[1].parameter_name == 'parameter3' + + def test_parameter_property(self, parameter_analysis): + # WHEN + parameters = parameter_analysis.parameters + + # THEN EXPECT + assert isinstance(parameters, sc.Dataset) + assert set(parameters.keys()) == { + 'parameter1', + 'parameter2', + 'parameter3 area', + 'parameter3 width', + } + + # WHEN + Q = sc.array(dims=['Q'], values=[0.1, 0.2]) + new_data = sc.Dataset( + data={ + 'parameter4': sc.DataArray( + data=sc.array( + dims=['Q'], + values=[71.0, 12.0], + variances=[1.1, 2.2], + unit='meV', + ), + coords={'Q': Q}, + ), + 'parameter5': sc.DataArray( + data=sc.array( + dims=['Q'], + values=[8.5, 0.5], + variances=[2.15, 1.25], + unit='1/meV', + ), + coords={'Q': Q}, + ), + } + ) + + # THEN + parameter_analysis.parameters = new_data + + # EXPECT + assert parameter_analysis.parameters is new_data + + def test_bindings_property(self, parameter_analysis): + # WHEN + bindings = parameter_analysis.bindings + + # THEN EXPECT + assert isinstance(bindings, list) + assert len(bindings) == 2 + assert all(isinstance(b, FitBinding) for b in bindings) + + # WHEN + model = Polynomial(coefficients=[2.0, 1.0]) + new_binding = FitBinding(parameter_name='parameter2', model=model) + parameter_analysis.bindings = new_binding + + # THEN EXPECT + assert parameter_analysis.bindings == [new_binding] + + def test_fit_no_bindings_raises(self, parameter_analysis): + # WHEN + + # THEN + parameter_analysis.bindings = None + + # EXPECT + with pytest.raises(ValueError, match='No fit bindings provided'): + parameter_analysis.fit() + + def test_fit_no_parameters_raises(self, parameter_analysis): + # WHEN + + # THEN + parameter_analysis.parameters = None + + # EXPECT + with pytest.raises(ValueError, match='No parameters Dataset provided'): + parameter_analysis.fit() + + def test_fit_wrong_parameter_name_raises(self, parameter_analysis): + # WHEN + model = Polynomial(coefficients=[2.0, 1.0]) + incorrect_binding = FitBinding(parameter_name='nonexistent_parameter', model=model) + parameter_analysis.bindings = incorrect_binding + + # THEN EXPECT + with pytest.raises( + ValueError, + match="Parameter 'nonexistent_parameter' from binding", + ): + parameter_analysis.fit() + + def test_fit_success(self, parameter_analysis): + # WHEN + mock_result = MagicMock() + + with patch('easydynamics.analysis.parameter_analysis.MultiFitter') as MockMultiFitter: + instance = MockMultiFitter.return_value + instance.fit.return_value = mock_result + + # THEN + result = parameter_analysis.fit() + + # EXPECT + assert MockMultiFitter.called + + kwargs = MockMultiFitter.call_args.kwargs + assert 'fit_objects' in kwargs + assert 'fit_functions' in kwargs + + # Expect 3 fits: + # - parameter1 → 1 callable + # - parameter3 → 2 callables (area + width) + assert len(kwargs['fit_objects']) == 3 + assert len(kwargs['fit_functions']) == 3 + + # --- Fit called correctly --- + instance.fit.assert_called_once() + + call_kwargs = instance.fit.call_args.kwargs + + x = call_kwargs['x'] + y = call_kwargs['y'] + w = call_kwargs['weights'] + + assert len(x) == 3 + assert len(y) == 3 + assert len(w) == 3 + + # Check one concrete value + np.testing.assert_allclose(x[0], [0.1, 0.2]) + np.testing.assert_allclose(y[0], [1.0, 2.0]) + + expected_w = 1 / np.sqrt([0.1, 0.2]) + np.testing.assert_allclose(w[0], expected_w) + + assert result is mock_result + + def test_plot_not_in_notebook_raises(self, parameter_analysis): + # WHEN / THEN / EXPECT + with ( + patch( + 'easydynamics.analysis.parameter_analysis._in_notebook', + return_value=False, + ), + pytest.raises( + RuntimeError, + match=r'can only be used in a Jupyter notebook environment', + ), + ): + parameter_analysis.plot() + + def test_plot_no_parameters_raises(self, parameter_analysis): + # WHEN + parameter_analysis.parameters = None + + # THEN EXPECT + with ( + patch( + 'easydynamics.analysis.parameter_analysis._in_notebook', + return_value=True, + ), + pytest.raises(ValueError, match=r'No parameters available to plot.'), + ): + parameter_analysis.plot() + + def test_plot_incompatible_units_raises(self, parameter_analysis): + # WHEN THEN EXPECT + with ( + patch( + 'easydynamics.analysis.parameter_analysis._in_notebook', + return_value=True, + ), + pytest.raises(ValueError, match=r'Units are not compatible'), + ): + # parameter1 in meV, parameter2 in 1/meV + parameter_analysis.plot(names=['parameter1', 'parameter2']) + + @patch('easydynamics.analysis.parameter_analysis._in_notebook', return_value=True) + @patch('easydynamics.analysis.parameter_analysis.pp.plot') + def test_plot_calls_dependencies_correctly_names_none( + self, mock_plot, mock_notebook, parameter_analysis, mock_model_dataset, dataset + ): + # WHEN + # ensure compatible units for all parameters + dataset.pop('parameter2') + parameter_analysis.parameters = dataset + + # Mock calculate_model_dataset + parameter_analysis.calculate_model_dataset = MagicMock(return_value=mock_model_dataset) + + # THEN + result = parameter_analysis.plot() + + # EXPECT + + # 1. Notebook check + mock_notebook.assert_called_once() + + # 2. Model dataset calculation + parameter_analysis.calculate_model_dataset.assert_called_once_with( + parameter_analysis.bindings + ) + + # 3. Plot called + mock_plot.assert_called_once() + + # Extract call args + args, kwargs = mock_plot.call_args + + # 4. Dataset passed correctly + dataset = args[0] + assert isinstance(dataset, sc.Dataset) + + # Data keys + assert 'parameter1' in dataset + assert 'parameter3 area' in dataset + assert 'parameter3 width' in dataset + + # Model keys (from bindings) + assert 'Polynomial' in dataset + assert 'BrownianTranslationalDiffusion area' in dataset + assert 'BrownianTranslationalDiffusion width' in dataset + + # 5. Check some kwargs + assert kwargs['title'] == parameter_analysis.display_name + + # Ensure styling dictionaries exist + for key in ['linestyle', 'marker', 'color', 'markerfacecolor']: + assert key in kwargs + + # 6. Return value propagated + assert result == mock_plot.return_value + + @patch('easydynamics.analysis.parameter_analysis._in_notebook', return_value=True) + @patch('easydynamics.analysis.parameter_analysis.pp.plot') + def test_plot_calls_dependencies_correctly( + self, + mock_plot, + mock_notebook, + parameter_analysis, + mock_model_dataset, + ): + # WHEN + # Mock calculate_model_dataset + parameter_analysis.calculate_model_dataset = MagicMock(return_value=mock_model_dataset) + + # THEN + result = parameter_analysis.plot(names=['parameter1', 'parameter3 area']) + + # EXPECT + + # 1. Notebook check + mock_notebook.assert_called_once() + + # 2. Model dataset calculation + parameter_analysis.calculate_model_dataset.assert_called_once_with( + parameter_analysis.bindings + ) + + # 3. Plot called + mock_plot.assert_called_once() + + # Extract call args + args, kwargs = mock_plot.call_args + + # 4. Dataset passed correctly + dataset = args[0] + assert isinstance(dataset, sc.Dataset) + + # Data keys + assert 'parameter1' in dataset + assert 'parameter3 area' in dataset + + # Model keys (from bindings) + assert 'Polynomial' in dataset + assert 'BrownianTranslationalDiffusion area' in dataset + assert 'BrownianTranslationalDiffusion width' not in dataset # not requested + + # 5. Check some kwargs + assert kwargs['title'] == parameter_analysis.display_name + + # Ensure styling dictionaries exist + for key in ['linestyle', 'marker', 'color', 'markerfacecolor']: + assert key in kwargs + + # 6. Return value propagated + assert result == mock_plot.return_value + + @patch('easydynamics.analysis.parameter_analysis._in_notebook', return_value=True) + @patch('easydynamics.analysis.parameter_analysis.pp.plot') + def test_plot_no_bindings( + self, mock_plot, mock_notebook, parameter_analysis, mock_model_dataset, dataset + ): + # WHEN + # ensure compatible units for all parameters + dataset.pop('parameter2') + parameter_analysis.parameters = dataset + parameter_analysis.bindings = None + + # Mock calculate_model_dataset + parameter_analysis.calculate_model_dataset = MagicMock(return_value=mock_model_dataset) + + # THEN + result = parameter_analysis.plot() + + # EXPECT + + # 1. Notebook check + mock_notebook.assert_called_once() + + # 2. Model dataset calculation + parameter_analysis.calculate_model_dataset.assert_not_called() + + # 3. Plot called + mock_plot.assert_called_once() + + # Extract call args + args, kwargs = mock_plot.call_args + + # 4. Dataset passed correctly + dataset = args[0] + assert isinstance(dataset, sc.Dataset) + + # Data keys + assert 'parameter1' in dataset + assert 'parameter3 area' in dataset + assert 'parameter3 width' in dataset + + # Model keys should be absent + assert 'Polynomial' not in dataset + assert 'BrownianTranslationalDiffusion area' not in dataset + assert 'BrownianTranslationalDiffusion width' not in dataset + + # 5. Check some kwargs + assert kwargs['title'] == parameter_analysis.display_name + + # Ensure styling dictionaries exist + for key in ['linestyle', 'marker', 'color', 'markerfacecolor']: + assert key in kwargs + + # 6. Return value propagated + assert result == mock_plot.return_value + + @pytest.mark.parametrize( + 'set_pars_none, bindings, expected_exception, match', + [ + # No parameters + ( + True, + [MagicMock(spec=FitBinding)], + ValueError, + 'No parameters Dataset provided', + ), + # No bindings + ( + False, + [], + ValueError, + 'No fit bindings provided', + ), + # Wrong bindings type + ( + False, + 'not_a_list', + TypeError, + 'bindings must be a list of FitBinding objects', + ), + ], + ids=['no_parameters', 'no_bindings', 'wrong_bindings_type'], + ) + def test_calculate_model_dataset_invalid_inputs( + self, parameter_analysis, set_pars_none, bindings, expected_exception, match + ): + # WHEN + if set_pars_none: + parameter_analysis.parameters = None + else: + # keep existing valid dataset + pass + + # THEN EXPECT + with pytest.raises(expected_exception, match=match): + parameter_analysis.calculate_model_dataset(bindings) + + def test_calculate_model_dataset_missing_parameter_raises(self, parameter_analysis): + binding = parameter_analysis.bindings[0] + + with ( + patch.object(binding, 'get_parameter_names', return_value=['missing_name']), + patch.object(binding, 'get_model_names', return_value=['model']), + patch.object(binding, 'build_callables', return_value=[lambda x: x]), + pytest.raises(ValueError, match='not found in parameters Dataset'), + ): + parameter_analysis.calculate_model_dataset([binding]) + + def test_calculate_model_dataset_single_binding(self, parameter_analysis): + # WHEN + binding = parameter_analysis.bindings[0] + + # Mock callable to return predictable output + mock_callable = MagicMock(return_value=np.array([10.0, 20.0])) + + with ( + patch.object(binding, 'build_callables', return_value=[mock_callable]), + patch.object(binding, 'get_model_names', return_value=['model1']), + patch.object(binding, 'get_parameter_names', return_value=['parameter1']), + ): + # THEN + result = parameter_analysis.calculate_model_dataset([binding]) + + # EXPECT + assert isinstance(result, sc.Dataset) + assert 'model1' in result + + da = result['model1'] + + np.testing.assert_allclose(da.values, [10.0, 20.0]) + np.testing.assert_allclose(da.coords['Q'].values, [0.1, 0.2]) + assert da.unit == parameter_analysis.parameters['parameter1'].unit + mock_callable.assert_called_once() + + args, kwargs = mock_callable.call_args + np.testing.assert_allclose(args[0], [0.1, 0.2]) + assert kwargs == {} + + def test_calculate_model_dataset_multiple_bindings(self, parameter_analysis): + # WHEN + binding1 = parameter_analysis.bindings[0] + binding2 = parameter_analysis.bindings[1] + + # Mock callable to return predictable output + mock_callable1 = MagicMock(return_value=np.array([10.0, 20.0])) + mock_callable2 = MagicMock(return_value=np.array([30.0, 40.0])) + + with ( + patch.object(binding1, 'build_callables', return_value=[mock_callable1]), + patch.object(binding1, 'get_model_names', return_value=['model1']), + patch.object(binding1, 'get_parameter_names', return_value=['parameter1']), + patch.object(binding2, 'build_callables', return_value=[mock_callable2]), + patch.object(binding2, 'get_model_names', return_value=['model2']), + patch.object(binding2, 'get_parameter_names', return_value=['parameter2']), + ): + # THEN + result = parameter_analysis.calculate_model_dataset([binding1, binding2]) + + # EXPECT + assert isinstance(result, sc.Dataset) + assert 'model1' in result + assert 'model2' in result + + da1 = result['model1'] + da2 = result['model2'] + + np.testing.assert_allclose(da1.values, [10.0, 20.0]) + np.testing.assert_allclose(da1.coords['Q'].values, [0.1, 0.2]) + assert da1.unit == parameter_analysis.parameters['parameter1'].unit + + np.testing.assert_allclose(da2.values, [30.0, 40.0]) + np.testing.assert_allclose(da2.coords['Q'].values, [0.1, 0.2]) + assert da2.unit == parameter_analysis.parameters['parameter2'].unit + mock_callable1.assert_called_once() + args, kwargs = mock_callable1.call_args + np.testing.assert_allclose(args[0], [0.1, 0.2]) + assert kwargs == {} + + mock_callable2.assert_called_once() + args, kwargs = mock_callable2.call_args + np.testing.assert_allclose(args[0], [0.1, 0.2]) + assert kwargs == {} + + def test_calculate_model_dataset_multiple_bindings_diffusion(self, parameter_analysis): + # WHEN + binding1 = parameter_analysis.bindings[0] + binding2 = parameter_analysis.bindings[1] + + # Mock callable to return predictable output + mock_callable1 = MagicMock(return_value=np.array([10.0, 20.0])) + mock_callable2a = MagicMock(return_value=np.array([30.0, 40.0])) + mock_callable2w = MagicMock(return_value=np.array([50.0, 60.0])) + + with ( + patch.object(binding1, 'build_callables', return_value=[mock_callable1]), + patch.object(binding1, 'get_model_names', return_value=['model1']), + patch.object(binding1, 'get_parameter_names', return_value=['parameter1']), + patch.object( + binding2, + 'build_callables', + return_value=[mock_callable2a, mock_callable2w], + ), + patch.object(binding2, 'get_model_names', return_value=['model2a', 'model2w']), + patch.object( + binding2, + 'get_parameter_names', + return_value=['parameter3 area', 'parameter3 width'], + ), + ): + # THEN + result = parameter_analysis.calculate_model_dataset([binding1, binding2]) + + # EXPECT + assert isinstance(result, sc.Dataset) + assert 'model1' in result + assert 'model2a' in result + assert 'model2w' in result + + da1 = result['model1'] + da2a = result['model2a'] + da2w = result['model2w'] + + np.testing.assert_allclose(da1.values, [10.0, 20.0]) + np.testing.assert_allclose(da1.coords['Q'].values, [0.1, 0.2]) + assert da1.unit == parameter_analysis.parameters['parameter1'].unit + + np.testing.assert_allclose(da2a.values, [30.0, 40.0]) + np.testing.assert_allclose(da2a.coords['Q'].values, [0.1, 0.2]) + assert da2a.unit == parameter_analysis.parameters['parameter3 area'].unit + + np.testing.assert_allclose(da2w.values, [50.0, 60.0]) + np.testing.assert_allclose(da2w.coords['Q'].values, [0.1, 0.2]) + assert da2w.unit == parameter_analysis.parameters['parameter3 width'].unit + + args, kwargs = mock_callable1.call_args + np.testing.assert_allclose(args[0], [0.1, 0.2]) + assert kwargs == {} + + args, kwargs = mock_callable2a.call_args + np.testing.assert_allclose(args[0], [0.1, 0.2]) + assert kwargs == {} + + args, kwargs = mock_callable2w.call_args + np.testing.assert_allclose(args[0], [0.1, 0.2]) + assert kwargs == {} + + def test_append_binding(self, parameter_analysis): + # WHEN + model = Polynomial(coefficients=[2.0, 1.0]) + new_binding = FitBinding(parameter_name='parameter2', model=model) + + # THEN + parameter_analysis.append_binding(new_binding) + + # EXPECT + assert len(parameter_analysis.bindings) == 3 + assert parameter_analysis.bindings[-1] == new_binding + + def test_append_binding_wrong_type_raises(self, parameter_analysis): + # WHEN + new_binding = 'not_a_fit_binding' + + # THEN EXPECT + with pytest.raises(TypeError, match='binding must be a FitBinding object'): + parameter_analysis.append_binding(new_binding) + + def test_clear_bindings(self, parameter_analysis): + # WHEN + parameter_analysis.clear_bindings() + + # THEN EXPECT + assert len(parameter_analysis.bindings) == 0 + + def test_get_all_variables(self, parameter_analysis): + # WHEN + + # THEN + variables = parameter_analysis.get_all_variables() + + # EXPECT + expected_variables = [] + expected_variables.extend(parameter_analysis.bindings[0].model.get_all_variables()) + expected_variables.extend(parameter_analysis.bindings[1].model.get_all_variables()) + assert set(variables) == set(expected_variables) + + def test_get_all_variables_no_bindings(self, parameter_analysis): + # WHEN + parameter_analysis.clear_bindings() + + # THEN EXPECT + variables = parameter_analysis.get_all_variables() + assert variables == [] + + def test_get_all_variables_overlapping_variables(self, parameter_analysis): + # WHEN + # Create two bindings with overlapping variables + model1 = Gaussian(display_name='model1') + + binding1 = FitBinding(parameter_name='parameter1', model=model1) + binding2 = FitBinding(parameter_name='parameter2', model=model1) + + parameter_analysis.bindings = [binding1, binding2] + + # THEN + variables = parameter_analysis.get_all_variables() + + # EXPECT + assert isinstance(variables, list) + assert set(variables) == set(model1.get_all_variables()) + assert len(variables) == len(model1.get_all_variables()) + + @pytest.mark.parametrize( + 'input_bindings, expected_length', + [ + (None, 0), + ('single', 1), + ('list', 2), + ], + ) + def test_verify_bindings_valid(self, parameter_analysis, input_bindings, expected_length): + # WHEN + b1 = parameter_analysis.bindings[0] + b2 = parameter_analysis.bindings[1] + + if input_bindings == 'single': + input_bindings = b1 + elif input_bindings == 'list': + input_bindings = [b1, b2] + + # THEN + result = parameter_analysis._verify_bindings(input_bindings) + + # EXPECT + assert isinstance(result, list) + assert len(result) == expected_length + + if expected_length == 1: + assert result[0] is b1 + if expected_length == 2: + assert result == [b1, b2] + + @pytest.mark.parametrize( + 'invalid_bindings', + [ + 'not_a_list_or_fit_binding', + [MagicMock(spec=FitBinding), 'not_a_fit_binding'], + ], + ids=['wrong_type', 'list_with_wrong_type'], + ) + def test_verify_bindings_invalid(self, parameter_analysis, invalid_bindings): + # WHEN THEN EXPECT + with pytest.raises((ValueError, TypeError)): + parameter_analysis._verify_bindings(invalid_bindings) + + def test_verify_parameters_none(self, parameter_analysis): + # WHEN THEN EXPECT + assert parameter_analysis._verify_parameters(None) is None + + def test_verify_parameters_wrong_type(self, parameter_analysis): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, match=r'parameters must be a sc.Dataset, an Analysis, or None' + ): + parameter_analysis._verify_parameters('not_a_dataset_or_analysis') + + def test_verify_parameters_wrong_coordinate(self, parameter_analysis): + # WHEN + T = sc.array(dims=['T'], values=[0.1, 0.2]) + wrong_dataset = sc.Dataset( + data={ + 'parameter1': sc.DataArray( + data=sc.array(dims=['T'], values=[1.0, 2.0], variances=[0.1, 0.2], unit='meV'), + coords={'T': T}, + ), + } + ) + + # THEN EXPECT + with pytest.raises(ValueError, match="parameters must have a 'Q' coordinate"): + parameter_analysis._verify_parameters(wrong_dataset) + + @pytest.mark.parametrize( + 'input_type', + ['dataset', 'analysis'], + ) + def test_verify_parameters_valid_inputs(self, parameter_analysis, dataset, input_type): + # WHEN + if input_type == 'dataset': + input_value = dataset + else: + mock_analysis = MagicMock(spec=Analysis) + mock_analysis.parameters_to_dataset.return_value = dataset + input_value = mock_analysis + + # THEN + result = parameter_analysis._verify_parameters(input_value) + + # EXPECT + assert result is dataset + + if input_type == 'analysis': + input_value.parameters_to_dataset.assert_called_once() + + def test_normalize_names_none(self, parameter_analysis): + # WHEN THEN EXPECT + assert parameter_analysis._normalize_names(None) is None + + def test_normalize_names_wrong_type(self, parameter_analysis): + # WHEN THEN EXPECT + with pytest.raises( + ValueError, match=r'names must be a string, a list of strings, or None.' + ): + parameter_analysis._normalize_names(123) + + def test_normalize_names_list_with_non_string(self, parameter_analysis): + # WHEN THEN EXPECT + with pytest.raises(ValueError, match=r'All names in the list must be strings.'): + parameter_analysis._normalize_names(['parameter1', 123]) + + def test_normalize_names_nonexistent_parameter(self, parameter_analysis): + # WHEN THEN EXPECT + with pytest.raises( + ValueError, + match=r"Parameter name 'nonexistent_parameter' not found in parameters Dataset.", + ): + parameter_analysis._normalize_names('nonexistent_parameter') + + with pytest.raises( + ValueError, + match=r"Parameter name 'nonexistent_parameter' not found in parameters Dataset.", + ): + parameter_analysis._normalize_names(['parameter1', 'nonexistent_parameter']) + + def test_normalize_names_valid_string(self, parameter_analysis): + # WHEN + result = parameter_analysis._normalize_names('parameter1') + + # THEN EXPECT + assert result == ['parameter1'] + + def test_normalize_names_valid_list(self, parameter_analysis): + # WHEN + result = parameter_analysis._normalize_names(['parameter1', 'parameter2']) + + # THEN EXPECT + assert result == ['parameter1', 'parameter2'] + + def test_get_xyweight_from_dataset_no_parameters_raises(self, parameter_analysis): + # WHEN + parameter_analysis.parameters = None + + # THEN EXPECT + with pytest.raises(ValueError, match='No parameters Dataset provided'): + parameter_analysis._get_xyweight_from_dataset('parameter1') + + def test_get_xyweight_from_dataset_wrong_parameter_name_raises(self, parameter_analysis): + # WHEN THEN EXPECT + with pytest.raises( + ValueError, + match="Parameter name 'nonexistent_parameter' not found in parameters Dataset", + ): + parameter_analysis._get_xyweight_from_dataset('nonexistent_parameter') + + @pytest.mark.parametrize( + 'non_finite_variance', + [np.inf, -np.inf, np.nan, -1.0, 0.0], + ids=['inf', '-inf', 'nan', 'negative', 'zero'], + ) + def test_get_xyweight_from_dataset_non_finite_weights_raises( + self, parameter_analysis, non_finite_variance + ): + # WHEN + Q = sc.array(dims=['Q'], values=[0.1, 0.2]) + parameter_analysis.parameters = sc.Dataset( + data={ + 'parameter1': sc.DataArray( + data=sc.array( + dims=['Q'], + values=[1.0, 2.0], + variances=[1.0, non_finite_variance], + unit='meV', + ), + coords={'Q': Q}, + ), + } + ) + + # THEN EXPECT + with pytest.raises( + ValueError, match="Non-finite variances found for parameter 'parameter1'" + ): + parameter_analysis._get_xyweight_from_dataset('parameter1') + + def test_get_xyweight_from_dataset_valid(self, parameter_analysis): + # WHEN THEN + x, y, w = parameter_analysis._get_xyweight_from_dataset('parameter1') + + # EXPECT + np.testing.assert_allclose(x, [0.1, 0.2]) + np.testing.assert_allclose(y, [1.0, 2.0]) + expected_w = 1 / np.sqrt([0.1, 0.2]) + np.testing.assert_allclose(w, expected_w) + + def test_repr(self, parameter_analysis): + # WHEN + repr_str = repr(parameter_analysis) + + # THEN EXPECT + assert isinstance(repr_str, str) + assert 'ParameterAnalysis' in repr_str + assert f'display_name={parameter_analysis.display_name}' in repr_str + assert f'unique_name={parameter_analysis.unique_name}' in repr_str + assert f'n_parameters={len(parameter_analysis.parameters)}' in repr_str + assert 'parameter_names=' in repr_str + assert 'bindings=' in repr_str